sass-imobiliaria/specs/023-ux-melhorias-imoveis/contracts/properties-api.md

214 lines
7 KiB
Markdown

# API Contract — `GET /api/v1/properties`
**Feature**: 023-ux-melhorias-imoveis
**Versão**: extensão da rota existente — sem breaking changes
**Arquivo**: `backend/app/routes/properties.py`
---
## Descrição
Rota de listagem paginada de imóveis. Esta feature adiciona dois novos parâmetros query opcionais (`q` e `sort`) à rota existente. Todos os parâmetros existentes são preservados sem alteração.
---
## Request
```
GET /api/v1/properties
```
### Query Parameters
#### Parâmetros novos (adicionados nesta feature)
| Parâmetro | Tipo | Obrigatório | Valores | Default | Descrição |
|---|---|---|---|---|---|
| `q` | `string` | Não | qualquer string, máx 200 chars | — (sem filtro textual) | Busca case-insensitive em `title`, `address`, `code`, `neighborhood.name` |
| `sort` | `string` | Não | `relevance` · `price_asc` · `price_desc` · `area_desc` · `newest` | `relevance` | Critério de ordenação dos resultados |
#### Parâmetros existentes (preservados)
| Parâmetro | Tipo | Descrição |
|---|---|---|
| `listing_type` | `'venda' \| 'aluguel'` | Tipo de negócio |
| `subtype_id` | `integer` | ID do subtipo de imóvel |
| `city_id` | `integer` | ID da cidade |
| `neighborhood_id` | `integer` | ID do bairro |
| `imobiliaria_id` | `integer` | ID da imobiliária |
| `price_min` | `number` | Preço mínimo |
| `price_max` | `number` | Preço máximo |
| `include_condo` | `'true'` | Incluir condomínio no cálculo de preço |
| `bedrooms_min` | `integer` | Mínimo de quartos |
| `bedrooms_max` | `integer` | Máximo de quartos |
| `bathrooms_min` | `integer` | Mínimo de banheiros |
| `bathrooms_max` | `integer` | Máximo de banheiros |
| `parking_min` | `integer` | Mínimo de vagas |
| `parking_max` | `integer` | Máximo de vagas |
| `area_min` | `integer` | Área mínima (m²) |
| `area_max` | `integer` | Área máxima (m²) |
| `amenity_ids` | `string` (lista separada por vírgula) | IDs de comodidades (AND lógico) |
| `page` | `integer` | Página atual (default: 1) |
| `per_page` | `integer` | Resultados por página (default: 24, max: 48) |
---
## Response
### 200 OK
Sem alterações no schema de resposta existente.
```json
{
"items": [
{
"id": "uuid",
"title": "Apartamento Jardins 2 quartos",
"slug": "apartamento-jardins-2-quartos",
"address": "Rua Oscar Freire, 123",
"code": "AP-0042",
"price": "3500.00",
"type": "aluguel",
"bedrooms": 2,
"bathrooms": 1,
"parking_spots": 1,
"area_m2": 75,
"is_featured": false,
"created_at": "2026-04-11T14:30:00",
"photos": [
{ "url": "/imoveis/ap-0042/foto1.jpg", "alt_text": "Sala de estar" }
],
"subtype": { "id": 1, "name": "Apartamento" },
"city": { "id": 1, "name": "São Paulo" },
"neighborhood": { "id": 5, "name": "Jardins" }
}
],
"total": 42,
"page": 1,
"per_page": 16,
"pages": 3
}
```
### Erros
| Status | Condição |
|---|---|
| `400` | Não aplicável — parâmetros inválidos são ignorados silenciosamente (comportamento existente) |
| `500` | Erro interno do servidor |
---
## Semântica dos Novos Parâmetros
### `q` — Busca Textual
- **Campos buscados**: `title`, `address`, `code`, `neighborhood.name`
- **Operador**: `ILIKE '%termo%'` (case-insensitive, busca parcial)
- **Lógica**: OR entre os campos (`title ILIKE $q OR address ILIKE $q OR ...`)
- **Combinação com outros filtros**: AND com todos os filtros existentes
- **Sanitização**: `.strip()` + truncamento em 200 chars
- **Segurança**: bind parameter do SQLAlchemy ORM — sem risco de SQL injection
**Exemplo**: `?q=Jardins&listing_type=aluguel` retorna apenas imóveis de aluguel cujo título, endereço, código ou bairro contenha "Jardins".
**Exemplo**: `?q=AP-0042` retorna o imóvel com `code = 'AP-0042'` (ou qualquer imóvel com "AP-0042" no título/endereço).
### `sort` — Ordenação
| Valor | `ORDER BY` gerado | Comportamento |
|---|---|---|
| `relevance` | `property.created_at DESC` | Mais recentes primeiro (comportamento anterior) |
| `price_asc` | `property.price ASC` | Menor preço primeiro |
| `price_desc` | `property.price DESC` | Maior preço primeiro |
| `area_desc` | `property.area_m2 DESC` | Maior área primeiro |
| `newest` | `property.created_at DESC` | Igual a `relevance` |
| *(valor desconhecido)* | `property.created_at DESC` | Fallback para `relevance` |
---
## Exemplos de Chamada
```bash
# Busca por bairro + tipo de negócio ordenado por preço
GET /api/v1/properties?q=Jardins&sort=price_asc&listing_type=aluguel&page=1&per_page=16
# Busca por código exato
GET /api/v1/properties?q=AP-0042
# Imóveis mais recentes de São Paulo
GET /api/v1/properties?sort=newest&city_id=1
# Menor área mínima + maior preço (combinação complexa)
GET /api/v1/properties?sort=price_desc&bedrooms_min=3&city_id=1&q=Pinheiros
```
---
## Implementação Backend
```python
# Em backend/app/routes/properties.py — adicionar antes da paginação
# ── Busca textual (q) ────────────────────────────────────────────────────────
q = args.get("q", "").strip()
if len(q) > 200:
q = q[:200]
if q:
from sqlalchemy import or_
from sqlalchemy.orm import aliased
from app.models.location import Neighborhood as NeighborhoodAlias
nbh_alias = aliased(NeighborhoodAlias)
query = query.outerjoin(nbh_alias, Property.neighborhood_id == nbh_alias.id)
pattern = f"%{q}%"
query = query.filter(or_(
Property.title.ilike(pattern),
Property.address.ilike(pattern),
Property.code.ilike(pattern),
nbh_alias.name.ilike(pattern),
))
# ── Ordenação (sort) — SUBSTITUIR o order_by existente ─────────────────────
sort = args.get("sort", "relevance")
sort_map = {
"price_asc": Property.price.asc(),
"price_desc": Property.price.desc(),
"area_desc": Property.area_m2.desc(),
"newest": Property.created_at.desc(),
}
order_clause = sort_map.get(sort, Property.created_at.desc())
# ── Paginação (existente — apenas mover order_by) ───────────────────────────
props = (
query.order_by(order_clause) # ← substituiu o .order_by(Property.created_at.desc()) fixo
.offset((page - 1) * per_page)
.limit(per_page)
.all()
)
```
---
## Implementação Frontend
```ts
// services/properties.ts — adicionar ao PropertyFilters
q?: string
sort?: 'relevance' | 'price_asc' | 'price_desc' | 'area_desc' | 'newest'
// No getProperties():
if (filters.q?.trim()) params.q = filters.q.trim()
if (filters.sort && filters.sort !== 'relevance') params.sort = filters.sort
```
```ts
// PropertiesPage.tsx — filtersFromParams
q: get('q') ?? undefined,
sort: (get('sort') as SortOption) ?? undefined,
// PropertiesPage.tsx — filtersToParams
if (filters.q) p.set('q', filters.q)
if (filters.sort && filters.sort !== 'relevance') p.set('sort', filters.sort)
```