sass-imobiliaria/.specify/features/012-register-rich-profile/plan.md

424 lines
17 KiB
Markdown

# Implementation Plan: Cadastro Rico de Cliente (Perfil Completo no Registro)
**Branch**: `master` | **Date**: 2026-04-14 | **Spec**: [spec.md](spec.md)
**Depends On**: Feature 011 — migration `a2b3c4d5e6f7` que adicionou as colunas ricas a `client_users`
## Summary
Expansão do formulário público `/cadastro` para coletar dados opcionais de contato (telefone, WhatsApp), CPF, data de nascimento e endereço completo. Alterações em 4 arquivos: `RegisterIn` schema (backend), `register()` handler (backend), `RegisterCredentials` type (frontend) e `RegisterPage.tsx` (frontend). Sem migration — todas as colunas já existem na tabela `client_users` desde a feature 011.
## Technical Context
**Language/Version**: Python 3.12 (backend) · TypeScript 5.5 (frontend)
**Primary Dependencies**: Flask 3.x, SQLAlchemy 2.x, Pydantic v2 (backend) · React 18, Tailwind CSS 3.4, react-router-dom v6, Axios (frontend)
**Storage**: PostgreSQL 16 — tabela `client_users` já possui as colunas adicionadas por `a2b3c4d5e6f7`
**Testing**: pytest (backend) · Vite build check (frontend)
**Target Platform**: Servidor Linux (Docker) + SPA na mesma origem via proxy Vite
**Project Type**: Web service (Flask REST API) + SPA (React)
**Performance Goals**: Formulário renderizado sem re-renders desnecessários; submit < 500 ms
**Constraints**: Todos os novos campos são opcionais; máscaras apenas visuais (frontend-only); sem validação de dígito verificador de CPF no MVP; sem lookup de CEP externo
**Scale/Scope**: MVP formulário de auto-registro público expandido; nenhuma nova rota criada
## Constitution Check
| Princípio | Status | Observação |
|-----------|--------|------------|
| I. Design-First | PASS | Seções usam `border-white/[0.06]`, `bg-[#0f1011]`, `#5e6ad2` tokens idênticos ao `RegisterPage` atual e ao `DESIGN.md`. |
| II. Separation of Concerns | PASS | Backend retorna JSON puro. Máscaras são responsabilidade exclusiva do frontend. Nenhuma lógica de negócio no frontend. |
| III. Spec-Driven | PASS | spec.md aprovado plan.md (este) tasks.md implementação. |
| IV. Data Integrity | PASS | Campos opcionais validados via Pydantic antes de persistir. `cpf` armazenado sem máscara (apenas dígitos). `birth_date` validado como `date` pelo Pydantic. |
| V. Security | PASS | Endpoint `POST /auth/register` público por design. Nenhum campo novo é sensível além do que era. Sem exposição de dados internos (campos de admin como `notes` não entram no `RegisterIn`). |
| VI. Simplicity First | PASS | Sem migração nova, sem nova rota, sem nova lib de máscara. Alterações cirúrgicas em 4 arquivos existentes. |
**POST-DESIGN RE-CHECK**: O design adiciona estado local ao `RegisterPage` e expande um schema Pydantic sem introduzir infraestrutura nova.
## Architecture Overview
```
RegisterIn (schemas/auth.py)
└── novos campos Optional adicionados
register() handler (routes/auth.py)
└── ClientUser(**data) recebe os novos campos
RegisterCredentials (types/auth.ts)
└── interface expandida com campos opcionais
registerUser() (services/auth.ts)
└── SEM ALTERAÇÃO — desestrutura apenas confirmPassword,
todos os outros campos são passados automaticamente no payload
RegisterPage.tsx
└── formulário expandido com 3 seções visuais:
(1) Acesso — campos obrigatórios (nome, e-mail, senha, confirmar senha)
(2) Contato — campos opcionais (telefone, WhatsApp, CPF, data de nascimento)
(3) Endereço — campos opcionais (logradouro, número, complemento, bairro, cidade, estado, CEP)
```
## Database Changes
**Nenhuma migration necessária.** Todas as colunas utilizadas foram adicionadas pela feature 011 via migration `a2b3c4d5e6f7_enrich_client_users.py`:
| Coluna | Tipo | Status |
|--------|------|--------|
| `phone` | `String(20)` | existe |
| `whatsapp` | `String(20)` | existe |
| `cpf` | `String(14)` | existe |
| `birth_date` | `Date` | existe |
| `address_street` | `String(200)` | existe |
| `address_number` | `String(20)` | existe |
| `address_complement` | `String(100)` | existe |
| `address_neighborhood` | `String(100)` | existe |
| `address_city` | `String(100)` | existe |
| `address_state` | `String(2)` | existe |
| `address_zip` | `String(9)` | existe |
O model `ClientUser` em `backend/app/models/user.py` mapeia todas essas colunas.
## Backend Changes
### 1. Pydantic Schema — `backend/app/schemas/auth.py`
**Classe**: `RegisterIn`
Adicionar import de `Optional` e `date` do módulo `datetime`, depois expandir os campos:
```python
from typing import Optional
from datetime import datetime, date
class RegisterIn(BaseModel):
# Campos existentes (sem alteração)
name: str
email: EmailStr
password: str
# Novos campos opcionais — contato
phone: Optional[str] = None
whatsapp: Optional[str] = None
# Novos campos opcionais — dados pessoais
cpf: Optional[str] = None
birth_date: Optional[date] = None
# Novos campos opcionais — endereço
address_street: Optional[str] = None
address_number: Optional[str] = None
address_complement: Optional[str] = None
address_neighborhood: Optional[str] = None
address_city: Optional[str] = None
address_state: Optional[str] = None
address_zip: Optional[str] = None
# Validators existentes permanecem inalterados
```
> **Nota sobre `cpf`**: o frontend envia o valor sem máscara (apenas dígitos, ex.: `"12345678901"`). A máscara é removida no `onChange` antes do submit.
### 2. Route Handler — `backend/app/routes/auth.py`
**Função**: `register()`
Substituir a criação de `ClientUser` para incluir os novos campos opcionais via `model_dump`:
```python
@auth_bp.post("/register")
def register():
try:
data = RegisterIn.model_validate(request.get_json() or {})
except ValidationError as e:
return jsonify({"error": e.errors(include_url=False)}), 422
existing = ClientUser.query.filter_by(email=data.email).first()
if existing:
return jsonify({"error": "E-mail já cadastrado"}), 409
pwd_hash = bcrypt.hashpw(data.password.encode(), bcrypt.gensalt()).decode()
# Extrair campos opcionais (excluir 'password' que é tratado separadamente)
optional_fields = data.model_dump(
exclude={"name", "email", "password"},
exclude_none=True,
)
user = ClientUser(
name=data.name,
email=data.email,
password_hash=pwd_hash,
**optional_fields,
)
db.session.add(user)
db.session.commit()
token = _generate_token(user.id, current_app.config["JWT_SECRET_KEY"])
user_out = UserOut.model_validate(user)
return (
jsonify(
AuthTokenOut(access_token=token, user=user_out).model_dump(mode="json")
),
201,
)
```
> **Alternativa mais simples** (também válida): passar cada campo explicitamente no construtor do `ClientUser`, evitando o `**optional_fields`. Escolher conforme preferência de legibilidade.
## Frontend Changes
### 1. Types — `frontend/src/types/auth.ts`
**Interface**: `RegisterCredentials`
Adicionar os campos opcionais:
```typescript
export interface RegisterCredentials {
name: string;
email: string;
password: string;
confirmPassword: string;
// Contato
phone?: string;
whatsapp?: string;
// Dados pessoais
cpf?: string;
birthDate?: string; // enviado como "YYYY-MM-DD" para o backend (campo birth_date)
// Endereço
addressStreet?: string;
addressNumber?: string;
addressComplement?: string;
addressNeighborhood?: string;
addressCity?: string;
addressState?: string;
addressZip?: string;
}
```
> **Nota sobre nomes de campo**: o serviço `registerUser` faz `const { confirmPassword: _c, ...payload } = data` e envia `payload` diretamente. O backend espera `snake_case`. Portanto, os campos camelCase do frontend devem ser mapeados para snake_case no payload. Dois enfoques possíveis:
> - **Opção A (preferida)**: manter os nomes camelCase na interface e fazer o mapeamento explícito em `registerUser` — mais explícito e seguro.
> - **Opção B**: usar snake_case na interface desde o início (alinhado com o contrato do backend) — menos ergonômico em React.
>
> O plano adota a **Opção A** — mapeamento explícito em `registerUser`.
### 2. Service — `frontend/src/services/auth.ts`
**Função**: `registerUser`
Adicionar mapeamento de camelCase snake_case para os novos campos:
```typescript
export async function registerUser(data: RegisterCredentials): Promise<AuthTokenResponse> {
const {
confirmPassword: _confirmPassword,
birthDate,
addressStreet,
addressNumber,
addressComplement,
addressNeighborhood,
addressCity,
addressState,
addressZip,
...rest
} = data
const payload = {
...rest,
...(birthDate && { birth_date: birthDate }),
...(addressStreet && { address_street: addressStreet }),
...(addressNumber && { address_number: addressNumber }),
...(addressComplement && { address_complement: addressComplement }),
...(addressNeighborhood && { address_neighborhood: addressNeighborhood }),
...(addressCity && { address_city: addressCity }),
...(addressState && { address_state: addressState }),
...(addressZip && { address_zip: addressZip }),
}
const response = await api.post<AuthTokenResponse>('/auth/register', payload)
return response.data
}
```
> Campos de telefone (`phone`) e whatsapp permanecem com o mesmo nome em camelCase e snake_case, então são passados via `...rest` sem necessidade de remapeamento. CPF também (`cpf`).
### 3. Page — `frontend/src/pages/RegisterPage.tsx`
**Estratégia**: Expandir o componente existente mantendo a estrutura atual para os campos obrigatórios (Seção 1 Acesso) e adicionar duas seções visuais abaixo, separadas por divisórias e labels de seção.
**Layout das 3 seções**:
```
┌─────────────────────────────────────────┐
│ Criar conta │
│ Acesse a área do cliente │
├─────────────────────────────────────────┤
│ [erro global, se houver] │
│ │
│ ── ACESSO ──────────────────────── │
│ Nome * │
│ E-mail * │
│ Senha * │
│ Confirmar Senha * │
│ │
│ ── CONTATO (opcional) ──────────── │
│ Telefone WhatsApp │
│ CPF Data de Nascimento │
│ │
│ ── ENDEREÇO (opcional) ─────────── │
│ CEP Estado │
│ Logradouro │
│ Número Complemento │
│ Bairro Cidade │
│ │
│ [Criar conta] │
│ Já tem conta? Entrar │
└─────────────────────────────────────────┘
```
**Novo estado** (`useState`) a adicionar ao componente:
| State var | Tipo | Initial |
|-----------|------|---------|
| `phone` | `string` | `''` |
| `whatsapp` | `string` | `''` |
| `cpf` | `string` | `''` |
| `birthDate` | `string` | `''` |
| `addressStreet` | `string` | `''` |
| `addressNumber` | `string` | `''` |
| `addressComplement` | `string` | `''` |
| `addressNeighborhood` | `string` | `''` |
| `addressCity` | `string` | `''` |
| `addressState` | `string` | `''` |
| `addressZip` | `string` | `''` |
**Atualização do `handleSubmit`**:
```typescript
await register({
name, email, password, confirmPassword,
phone: phone || undefined,
whatsapp: whatsapp || undefined,
cpf: cpf.replace(/\D/g, '') || undefined, // armazena apenas dígitos
birthDate: birthDate || undefined,
addressStreet: addressStreet || undefined,
addressNumber: addressNumber || undefined,
addressComplement: addressComplement || undefined,
addressNeighborhood: addressNeighborhood || undefined,
addressCity: addressCity || undefined,
addressState: addressState || undefined,
addressZip: addressZip || undefined,
})
```
**Máscaras inline** (mesmas implementações de `ClienteForm.tsx`, copiadas no topo do arquivo antes do componente):
```typescript
function maskCpf(v: string) {
return v.replace(/\D/g, '').slice(0, 11)
.replace(/(\d{3})(\d)/, '$1.$2')
.replace(/(\d{3})(\d)/, '$1.$2')
.replace(/(\d{3})(\d{1,2})$/, '$1-$2')
}
function maskPhone(v: string) {
const d = v.replace(/\D/g, '').slice(0, 11)
if (d.length <= 10)
return d.replace(/(\d{2})(\d{4})(\d{0,4})/, '($1) $2-$3').trim().replace(/-$/, '')
return d.replace(/(\d{2})(\d{5})(\d{0,4})/, '($1) $2-$3').trim().replace(/-$/, '')
}
function maskZip(v: string) {
return v.replace(/\D/g, '').slice(0, 8)
.replace(/(\d{5})(\d)/, '$1-$2')
}
```
**CSS / Classes Tailwind usadas nas seções novas** (idênticas ao formulário existente):
```
// Divisória de seção
<div className="border-t border-white/[0.06] pt-4">
<p className="text-xs font-medium text-white/40 uppercase tracking-wider mb-3">Contato</p>
...
</div>
// Grid 2 colunas para campos curtos
<div className="grid grid-cols-2 gap-3">...</div>
// Input (mesma classe atual)
"w-full rounded-lg border border-white/[0.08] bg-white/[0.03] px-3 py-2 text-sm text-white
placeholder-white/30 focus:border-[#5e6ad2]/60 focus:outline-none focus:ring-1 focus:ring-[#5e6ad2]/30"
```
**Tamanho máximo do card**: o `max-w-sm` atual pode ser expandido para `max-w-md` ou `max-w-lg` para acomodar o grid de 2 colunas na seção de endereço sem quebrar o layout.
## Context — `AuthContext.tsx`
A função `register` em `AuthContext.tsx` recebe `RegisterCredentials` e a repassa para `registerUser`. Verificar se a assinatura aceita o tipo expandido se `RegisterCredentials` for tipado corretamente, nenhuma alteração é necessária no contexto.
## Project Structure
### Documentation (this feature)
```text
.specify/features/012-register-rich-profile/
├── plan.md # Este arquivo
└── tasks.md # Fase 2 (gerado por /speckit.tasks)
```
### Source Code (repository root)
```text
backend/
└── app/
├── schemas/
│ └── auth.py # ATUALIZAR — RegisterIn: 11 novos campos Optional
└── routes/
└── auth.py # ATUALIZAR — register(): ClientUser recebe novos campos
frontend/
└── src/
├── types/
│ └── auth.ts # ATUALIZAR — RegisterCredentials: 11 novos campos opcionais
├── services/
│ └── auth.ts # ATUALIZAR — registerUser(): mapeamento camelCase → snake_case
└── pages/
└── RegisterPage.tsx # ATUALIZAR — 3 seções + máscaras inline
```
## Complexity Tracking
| Item | Decisão | Alternativas rejeitadas |
|------|---------|------------------------|
| Nomes de campo frontendbackend | Mapeamento explícito em `registerUser` (camelCase snake_case) | Snake_case na interface React menos ergonômico |
| CPF no payload | Enviar apenas dígitos (`cpf.replace(/\D/g, '')`) | Enviar formatado inconsistente com `ClienteForm.tsx` que armazena sem máscara |
| Layout do formulário | Seções inline separadas por `border-t` sem accordion/collapse | Accordion collapsível mais complexo, benefício marginal para um formulário pequeno |
| Largura do card | `max-w-md` para acomodar grid 2 cols | Manter `max-w-sm` com campos full-width mais estreito, menos legível |
| Máscaras | Inline no arquivo (`maskCpf`, `maskPhone`, `maskZip`) copiadas de `ClienteForm.tsx` | Extrair para `utils/masks.ts` generalização prematura para MVP |
| Validação de campos opcionais | Nenhuma validação frontend backend é fonte da verdade | Validação de comprimento de CPF custo/benefício baixo no MVP |
├── components/
├── pages/
└── services/
└── tests/
# [REMOVE IF UNUSED] Option 3: Mobile + API (when "iOS/Android" detected)
api/
└── [same as backend above]
ios/ or android/
└── [platform-specific structure: feature modules, UI flows, platform tests]
```
**Structure Decision**: [Document the selected structure and reference the real
directories captured above]
## Complexity Tracking
> **Fill ONLY if Constitution Check has violations that must be justified**
| Violation | Why Needed | Simpler Alternative Rejected Because |
|-----------|------------|-------------------------------------|
| [e.g., 4th project] | [current need] | [why 3 projects insufficient] |
| [e.g., Repository pattern] | [specific problem] | [why direct DB access insufficient] |