214 lines
7 KiB
Markdown
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)
|
|
```
|