189 lines
5.1 KiB
Markdown
189 lines
5.1 KiB
Markdown
# REST API Contracts: Property Detail Page (004)
|
||
|
||
**Feature**: `004-property-detail-page`
|
||
**Date**: 2026-04-13
|
||
**Base URL**: `/api/v1`
|
||
|
||
---
|
||
|
||
## GET /properties/{slug}
|
||
|
||
Retorna o detalhe completo de um imóvel ativo pelo slug.
|
||
|
||
### Request
|
||
|
||
```
|
||
GET /api/v1/properties/{slug}
|
||
Content-Type: application/json
|
||
Auth: não requerida
|
||
```
|
||
|
||
**Path parameters**:
|
||
| Param | Tipo | Descrição |
|
||
|-------|------|-----------|
|
||
| `slug` | string `[a-z0-9-]+` | Slug único do imóvel |
|
||
|
||
### Response 200 OK
|
||
|
||
```json
|
||
{
|
||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||
"title": "Apartamento 3 quartos no Centro",
|
||
"slug": "apartamento-3-quartos-centro-123",
|
||
"code": "AP-00042",
|
||
"description": "Excelente apartamento com vista para o jardim...",
|
||
"address": "Rua das Flores, 100, Centro",
|
||
"price": "850000.00",
|
||
"condo_fee": "650.00",
|
||
"type": "venda",
|
||
"bedrooms": 3,
|
||
"bathrooms": 2,
|
||
"parking_spots": 2,
|
||
"area_m2": 95,
|
||
"is_featured": true,
|
||
"subtype": { "id": 10, "name": "Apartamento", "slug": "apartamento" },
|
||
"city": { "id": 5, "name": "Franca", "slug": "franca", "state": "SP" },
|
||
"neighborhood": { "id": 12, "name": "Jardim São Luiz", "slug": "jardim-sao-luiz" },
|
||
"photos": [
|
||
{ "url": "https://...", "alt_text": "Sala de estar", "display_order": 0 },
|
||
{ "url": "https://...", "alt_text": "Quarto principal", "display_order": 1 }
|
||
],
|
||
"amenities": [
|
||
{ "id": 3, "name": "Aceita animais", "slug": "aceita-animais", "group": "caracteristica" },
|
||
{ "id": 7, "name": "Piscina", "slug": "piscina", "group": "lazer" }
|
||
]
|
||
}
|
||
```
|
||
|
||
**Notas do schema**:
|
||
- `price` e `condo_fee` são strings de decimal (ex: `"850000.00"`) — use `parseFloat()` no frontend
|
||
- `type` é o campo canônico (equivale a `listing_type` na documentação da spec)
|
||
- `photos` ordenadas por `display_order` ASC (índice 0 = foto principal do carrossel)
|
||
- `code` e `description` são `null` se não preenchidos no cadastro
|
||
- `address` é `null` se não preenchido
|
||
|
||
### Response 404 Not Found
|
||
|
||
Imóvel inexistente **ou** `is_active = false`.
|
||
|
||
```json
|
||
{ "error": "Imóvel não encontrado" }
|
||
```
|
||
|
||
> Retornar 404 (não 403) para imóveis inativos evita vazar informação sobre existência.
|
||
|
||
---
|
||
|
||
## POST /properties/{slug}/contact
|
||
|
||
Registra um lead de contato vinculado ao imóvel identificado pelo slug.
|
||
|
||
### Request
|
||
|
||
```
|
||
POST /api/v1/properties/{slug}/contact
|
||
Content-Type: application/json
|
||
Auth: não requerida
|
||
```
|
||
|
||
**Path parameters**:
|
||
| Param | Tipo | Descrição |
|
||
|-------|------|-----------|
|
||
| `slug` | string | Slug do imóvel para o qual o contato é enviado |
|
||
|
||
**Request body**:
|
||
```json
|
||
{
|
||
"name": "João Silva",
|
||
"email": "joao@email.com",
|
||
"phone": "(16) 99999-0000",
|
||
"message": "Tenho interesse no imóvel, gostaria de agendar uma visita."
|
||
}
|
||
```
|
||
|
||
**Campo** | **Tipo** | **Obrigatório** | **Restrições**
|
||
----------|----------|-----------------|---------------
|
||
`name` | string | sim | 2–150 caracteres
|
||
`email` | string | sim | formato e-mail válido (RFC 5322)
|
||
`phone` | string | não | máximo 20 caracteres
|
||
`message` | string | sim | 10–2000 caracteres
|
||
|
||
### Response 201 Created
|
||
|
||
```json
|
||
{
|
||
"id": 88,
|
||
"message": "Mensagem enviada com sucesso!"
|
||
}
|
||
```
|
||
|
||
### Response 404 Not Found
|
||
|
||
Slug não encontrado ou imóvel inativo.
|
||
|
||
```json
|
||
{ "error": "Imóvel não encontrado" }
|
||
```
|
||
|
||
### Response 422 Unprocessable Entity
|
||
|
||
Falha de validação Pydantic.
|
||
|
||
```json
|
||
{
|
||
"error": "Dados inválidos",
|
||
"details": {
|
||
"email": ["E-mail inválido"],
|
||
"message": ["Campo obrigatório"]
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Comportamentos Implícitos
|
||
|
||
| Situação | Comportamento |
|
||
|----------|--------------|
|
||
| `slug` com caracteres inválidos (maiúsculas, /, etc.) | 404 (SQLAlchemy não encontra correspondência) |
|
||
| `property_id` na tabela `contact_leads` quando imóvel é deletado | SET NULL (lead preservado) |
|
||
| Duplo envio de formulário | Dois registros criados (UI bloqueia durante `submitting`) |
|
||
| `photos` vazia | Array `[]` retornado; frontend exibe placeholder |
|
||
| `amenities` vazia | Array `[]` retornado; frontend omite seção de amenidades |
|
||
|
||
---
|
||
|
||
## Integração Frontend
|
||
|
||
### `getProperty(slug: string): Promise<PropertyDetail>`
|
||
|
||
```typescript
|
||
// services/properties.ts
|
||
export async function getProperty(slug: string): Promise<PropertyDetail> {
|
||
const response = await api.get<PropertyDetail>(`/properties/${slug}`)
|
||
return response.data
|
||
}
|
||
```
|
||
|
||
- `404` → Axios lança `AxiosError` com `status 404` → frontend exibe estado "não encontrado"
|
||
- Outros erros → propagar para tratamento genérico
|
||
|
||
### `submitContactForm(slug: string, data: ContactFormData): Promise<{ id: number; message: string }>`
|
||
|
||
```typescript
|
||
// services/properties.ts
|
||
export async function submitContactForm(
|
||
slug: string,
|
||
data: ContactFormData,
|
||
): Promise<{ id: number; message: string }> {
|
||
const response = await api.post<{ id: number; message: string }>(
|
||
`/properties/${slug}/contact`,
|
||
data,
|
||
)
|
||
return response.data
|
||
}
|
||
```
|
||
|
||
- `201` → retorna `{ id, message }`
|
||
- `422` → `AxiosError` com `response.data.details` disponível para mapear erros por campo
|
||
- `5xx` → exibir mensagem genérica sem apagar dados do formulário
|