feat: add full project - backend, frontend, docker, specs and configs
This commit is contained in:
parent
b77c7d5a01
commit
e6cb06255b
24489 changed files with 61341 additions and 36 deletions
270
specs/024-filtro-busca-avancada/data-model.md
Normal file
270
specs/024-filtro-busca-avancada/data-model.md
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
# Data Model: Filtro de Busca Avançada — FilterSidebar
|
||||
|
||||
**Feature**: `024-filtro-busca-avancada` | **Phase**: 1 | **Date**: 2026-04-20
|
||||
|
||||
---
|
||||
|
||||
## Resumo
|
||||
|
||||
Sem novas tabelas ou migrations. A única alteração de "modelo de dados" é a extensão dos schemas Pydantic de saída com o campo calculado `property_count: int = 0`. No frontend, os tipos TypeScript espelham a mudança com `property_count?: number` (opcional para backward-compatibility).
|
||||
|
||||
São adicionados dois tipos internos do TypeScript exclusivos ao `FilterSidebar.tsx`, sem impacto em outros componentes.
|
||||
|
||||
---
|
||||
|
||||
## Backend — Schemas Pydantic (catalog.py)
|
||||
|
||||
### Antes e depois
|
||||
|
||||
```python
|
||||
# ANTES
|
||||
class PropertyTypeOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: int
|
||||
name: str
|
||||
slug: str
|
||||
parent_id: int | None
|
||||
subtypes: list["PropertyTypeOut"] = []
|
||||
|
||||
class CityOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: int
|
||||
name: str
|
||||
slug: str
|
||||
state: str
|
||||
|
||||
class NeighborhoodOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: int
|
||||
name: str
|
||||
slug: str
|
||||
city_id: int
|
||||
|
||||
# DEPOIS (adição de property_count: int = 0 em cada schema)
|
||||
class PropertyTypeOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: int
|
||||
name: str
|
||||
slug: str
|
||||
parent_id: int | None
|
||||
subtypes: list["PropertyTypeOut"] = []
|
||||
property_count: int = 0 # ← NOVO
|
||||
|
||||
class CityOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: int
|
||||
name: str
|
||||
slug: str
|
||||
state: str
|
||||
property_count: int = 0 # ← NOVO
|
||||
|
||||
class NeighborhoodOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: int
|
||||
name: str
|
||||
slug: str
|
||||
city_id: int
|
||||
property_count: int = 0 # ← NOVO
|
||||
```
|
||||
|
||||
**Regras de validação**:
|
||||
- `property_count >= 0` (COUNT nunca é negativo)
|
||||
- Default `0` garante que entidades sem imóveis ativos retornem um valor válido
|
||||
- Campo somente-leitura (nenhum endpoint de escrita o aceita como input)
|
||||
|
||||
---
|
||||
|
||||
## Backend — Queries (routes)
|
||||
|
||||
### `list_cities()` — locations.py
|
||||
|
||||
```python
|
||||
from sqlalchemy import func
|
||||
from app.models.property import Property
|
||||
|
||||
rows = (
|
||||
db.session.query(City, func.count(Property.id).label("cnt"))
|
||||
.outerjoin(
|
||||
Property,
|
||||
(Property.city_id == City.id) & (Property.is_active.is_(True))
|
||||
)
|
||||
.group_by(City.id)
|
||||
.order_by(City.state, City.name)
|
||||
.all()
|
||||
)
|
||||
return jsonify([
|
||||
{**CityOut.model_validate(city).model_dump(), "property_count": cnt}
|
||||
for city, cnt in rows
|
||||
])
|
||||
```
|
||||
|
||||
### `list_neighborhoods()` — locations.py
|
||||
|
||||
```python
|
||||
q = (
|
||||
db.session.query(Neighborhood, func.count(Property.id).label("cnt"))
|
||||
.outerjoin(
|
||||
Property,
|
||||
(Property.neighborhood_id == Neighborhood.id) & (Property.is_active.is_(True))
|
||||
)
|
||||
.group_by(Neighborhood.id)
|
||||
)
|
||||
if city_id:
|
||||
q = q.filter(Neighborhood.city_id == int(city_id))
|
||||
rows = q.order_by(Neighborhood.name).all()
|
||||
return jsonify([
|
||||
{**NeighborhoodOut.model_validate(n).model_dump(), "property_count": cnt}
|
||||
for n, cnt in rows
|
||||
])
|
||||
```
|
||||
|
||||
### `list_property_types()` — catalog.py
|
||||
|
||||
A hierarquia pai → subtypes torna a injeção de `property_count` ligeiramente diferente: o COUNT é calculado por `subtype_id` (leaf nodes), não por parent. Os tipos pai não recebem `property_count` (deixam default `0`).
|
||||
|
||||
```python
|
||||
from sqlalchemy import func
|
||||
from app.models.property import Property
|
||||
|
||||
# 1. Calcular counts por subtype em uma query plana
|
||||
subtype_rows = (
|
||||
db.session.query(
|
||||
PropertyType.id,
|
||||
func.count(Property.id).label("cnt")
|
||||
)
|
||||
.filter(PropertyType.parent_id.isnot(None)) # apenas leaf nodes
|
||||
.outerjoin(
|
||||
Property,
|
||||
(Property.subtype_id == PropertyType.id) & (Property.is_active.is_(True))
|
||||
)
|
||||
.group_by(PropertyType.id)
|
||||
.all()
|
||||
)
|
||||
count_map: dict[int, int] = {row.id: row.cnt for row in subtype_rows}
|
||||
|
||||
# 2. Buscar hierarquia normalmente
|
||||
categories = (
|
||||
PropertyType.query.filter_by(parent_id=None).order_by(PropertyType.id).all()
|
||||
)
|
||||
|
||||
# 3. Serializar injetando property_count nos subtypes
|
||||
def serialize_category(cat: PropertyType) -> dict:
|
||||
data = PropertyTypeOut.model_validate(cat).model_dump(mode="json")
|
||||
data["subtypes"] = [
|
||||
{**sub, "property_count": count_map.get(sub["id"], 0)}
|
||||
for sub in data["subtypes"]
|
||||
]
|
||||
return data
|
||||
|
||||
return jsonify([serialize_category(c) for c in categories])
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Frontend — Tipos TypeScript (catalog.ts)
|
||||
|
||||
```ts
|
||||
// ANTES
|
||||
export interface PropertyType {
|
||||
id: number; name: string; slug: string
|
||||
parent_id: number | null; subtypes: PropertyType[]
|
||||
}
|
||||
export interface City {
|
||||
id: number; name: string; slug: string; state: string
|
||||
}
|
||||
export interface Neighborhood {
|
||||
id: number; name: string; slug: string; city_id: number
|
||||
}
|
||||
|
||||
// DEPOIS
|
||||
export interface PropertyType {
|
||||
id: number; name: string; slug: string
|
||||
parent_id: number | null; subtypes: PropertyType[]
|
||||
property_count?: number // ← NOVO (opcional — backward-compat)
|
||||
}
|
||||
export interface City {
|
||||
id: number; name: string; slug: string; state: string
|
||||
property_count?: number // ← NOVO
|
||||
}
|
||||
export interface Neighborhood {
|
||||
id: number; name: string; slug: string; city_id: number
|
||||
property_count?: number // ← NOVO
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Frontend — Tipos Internos (FilterSidebar.tsx)
|
||||
|
||||
Estes tipos são declarados localmente no arquivo `FilterSidebar.tsx` e não exportados.
|
||||
|
||||
### `SectionKey`
|
||||
|
||||
```ts
|
||||
type SectionKey =
|
||||
| 'imobiliaria'
|
||||
| 'localizacao'
|
||||
| 'tipo'
|
||||
| 'preco'
|
||||
| 'quartos'
|
||||
| 'area'
|
||||
| 'comodidades'
|
||||
```
|
||||
|
||||
### `FilterSuggestion`
|
||||
|
||||
```ts
|
||||
interface FilterSuggestion {
|
||||
category: string // Label do grupo exibido na UI (ex.: "Tipo de imóvel")
|
||||
sectionKey: SectionKey // Chave para expandir a seção correta ao selecionar
|
||||
label: string // Texto exibido na sugestão
|
||||
filterKey: keyof PropertyFilters
|
||||
value: number | string | undefined
|
||||
isAmenity?: boolean // true quando a ação é toggle em amenity_ids
|
||||
amenityId?: number // preenchido quando isAmenity = true
|
||||
}
|
||||
```
|
||||
|
||||
**Mapeamento de categorias**:
|
||||
|
||||
| `category` (label UI) | `filterKey` | `sectionKey` | Fonte |
|
||||
|-----------------------|-------------|--------------|-------|
|
||||
| "Tipo de imóvel" | `subtype_id` | `tipo` | `propertyTypes[*].subtypes[*]` |
|
||||
| "Cidade" | `city_id` | `localizacao` | `cities` |
|
||||
| "Bairro" | `neighborhood_id` | `localizacao` | `neighborhoods` |
|
||||
| "Comodidade" | n/a (`amenity_ids` toggle) | `comodidades` | `amenities` |
|
||||
|
||||
### `initOpenSections` — lógica de estado inicial
|
||||
|
||||
```ts
|
||||
function initOpenSections(filters: PropertyFilters): Record<SectionKey, boolean> {
|
||||
return {
|
||||
imobiliaria: filters.imobiliaria_id != null,
|
||||
localizacao: filters.city_id != null || filters.neighborhood_id != null,
|
||||
tipo: filters.subtype_id != null,
|
||||
preco: true, // sempre aberta (FR-009)
|
||||
quartos: filters.bedrooms_min != null
|
||||
|| filters.bathrooms_min != null
|
||||
|| filters.parking_min != null,
|
||||
area: filters.area_min != null || filters.area_max != null,
|
||||
comodidades: (filters.amenity_ids?.length ?? 0) > 0,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Estado do Banco de Dados
|
||||
|
||||
| Aspecto | Situação |
|
||||
|---------|----------|
|
||||
| Novas tabelas | Nenhuma |
|
||||
| Migrations Alembic | Nenhuma |
|
||||
| Colunas adicionadas | Nenhuma |
|
||||
| Índices adicionados | Nenhum (os índices em `city_id`, `neighborhood_id`, `subtype_id` em `properties` já existem) |
|
||||
| Triggers | Nenhum |
|
||||
|
||||
`property_count` é calculado em tempo de execução. O overhead da query é negligenciável:
|
||||
- `cities`: esperado < 50 linhas × JOIN com `properties` (< 5 k linhas) → < 5 ms
|
||||
- `neighborhoods`: esperado < 200 linhas → < 10 ms
|
||||
- `property_types`: contagem por subtypes separada das categorias → < 5 ms
|
||||
Loading…
Add table
Add a link
Reference in a new issue