# Data Model: Property Detail Page (004) **Feature**: `004-property-detail-page` **Date**: 2026-04-13 --- ## Entidades Modificadas ### Property (existente — colunas adicionadas) | Campo | Tipo SQL | Tipo Python | Nullable | Notas | |-------|----------|-------------|----------|-------| | `id` | UUID PK | `UUID` | não | existente | | `title` | VARCHAR(200) | `str` | não | existente | | `slug` | VARCHAR(220) UNIQUE | `str` | não | existente | | **`code`** | **VARCHAR(30) UNIQUE** | **`str \| None`** | **sim** | **novo** — ex: `"AP-00042"` | | **`description`** | **TEXT** | **`str \| None`** | **sim** | **novo** — descrição narrativa | | `address` | VARCHAR(300) | `str \| None` | sim | existente | | `price` | NUMERIC(12,2) | `Decimal` | não | existente | | `condo_fee` | NUMERIC(10,2) | `Decimal \| None` | sim | existente | | `type` | ENUM(venda,aluguel) | `Literal["venda","aluguel"]` | não | existente | | `subtype_id` | INT FK → property_types | `int \| None` | sim | existente | | `bedrooms` | INT | `int` | não | existente | | `bathrooms` | INT | `int` | não | existente | | `parking_spots` | INT | `int` | não | existente | | `area_m2` | INT | `int` | não | existente | | `city_id` | INT FK → cities | `int \| None` | sim | existente | | `neighborhood_id` | INT FK → neighborhoods | `int \| None` | sim | existente | | `is_featured` | BOOLEAN | `bool` | não | existente | | `is_active` | BOOLEAN | `bool` | não | existente | | `created_at` | TIMESTAMP | `datetime` | não | existente | **Relacionamentos existentes** (sem mudança): - `photos`: 1:M → `PropertyPhoto` (cascade delete-orphan, order by `display_order`) - `subtype`: M:1 → `PropertyType` (joined) - `city`: M:1 → `City` (joined) - `neighborhood`: M:1 → `Neighborhood` (joined) - `amenities`: M:M via `property_amenity` --- ## Entidades Novas ### ContactLead (novo) Registra cada solicitação de contato feita por um visitante em relação a um imóvel. | Campo | Tipo SQL | Tipo Python | Nullable | Restrições | |-------|----------|-------------|----------|------------| | `id` | SERIAL PK | `int` | não | autoincrement | | `property_id` | UUID FK → properties | `UUID \| None` | **sim** | ON DELETE SET NULL | | `name` | VARCHAR(150) | `str` | não | | | `email` | VARCHAR(254) | `str` | não | | | `phone` | VARCHAR(20) | `str \| None` | sim | | | `message` | TEXT | `str` | não | | | `created_at` | TIMESTAMP WITH TIME ZONE | `datetime` | não | server_default=NOW() | **Índices**: - `ix_contact_leads_property_id` em `property_id` (consultas futuras de admin) - `ix_contact_leads_created_at` em `created_at` (ordenação) **Relacionamento**: - `ContactLead.property` → `Property` (lazy="select", nullable via FK SET NULL) **Sem cascade delete**: leads são preservados mesmo se o imóvel for deletado (histórico de negócio). --- ## Schemas Pydantic (backend) ### PropertyDetailOut (novo — herda de PropertyOut) ```python class PropertyDetailOut(PropertyOut): model_config = ConfigDict(from_attributes=True) address: str | None code: str | None description: str | None ``` Usado exclusivamente pelo endpoint `GET /api/v1/properties/`. ### ContactLeadIn (novo — input de validação) ```python class ContactLeadIn(BaseModel): name: Annotated[str, Field(min_length=2, max_length=150)] email: EmailStr phone: Annotated[str | None, Field(max_length=20)] = None message: Annotated[str, Field(min_length=10, max_length=2000)] ``` ### ContactLeadCreatedOut (novo — resposta 201) ```python class ContactLeadCreatedOut(BaseModel): id: int message: str ``` --- ## Types TypeScript (frontend) ### PropertyDetail (novo — herda de Property) ```typescript export interface PropertyDetail extends Property { address: string | null code: string | null description: string | null } ``` ### ContactFormData (novo) ```typescript export interface ContactFormData { name: string email: string phone: string message: string } ``` --- ## Estado do React — PropertyDetailPage ``` PropertyDetailPage ├── property: PropertyDetail | null (fetch por slug) ├── notFound: boolean (true se 404) ├── loading: boolean └── ContactSection ├── formData: ContactFormData ├── submitting: boolean ├── submitStatus: 'idle' | 'success' | 'error' └── fieldErrors: Partial> ``` --- ## Validações de Estado | Regra | Onde aplicada | |-------|---------------| | `is_active = false` → 404 | Backend (GET /properties/\) | | slug inexistente → 404 | Backend | | `name` obrigatório, 2–150 chars | Backend (Pydantic) + Frontend (HTML validation) | | `email` formato válido | Backend (EmailStr) + Frontend (type="email") | | `phone` max 20 chars, opcional | Backend (Pydantic) | | `message` obrigatório, 10–2000 chars | Backend (Pydantic) + Frontend | | `property_id` via slug (nunca do cliente) | Backend | --- ## Transições de Estado do Formulário de Contato ``` idle → [usuário clica Enviar] ↓ validação frontend falha → exibe erros de campo → idle ↓ validação ok → submitting (botão desabilitado) ↓ 201 Created → success (mensagem de confirmação, formulário limpo) ↓ 4xx/5xx → error (mensagem de erro, dados preservados) error → [usuário edita] → idle ``` --- ## Agrupamento de Amenidades | Valor `group` no BD | Label exibido | |--------------------|---------------| | `caracteristica` | Características | | `lazer` | Lazer | | `condominio` | Condomínio | | `seguranca` | Segurança | Grupos sem amenidade associada ao imóvel **não são renderizados** (FR-F07).