# 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` já público por design. Nenhum campo novo é sensível além do que já 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 já foram adicionadas pela feature 011 via migration `a2b3c4d5e6f7_enrich_client_users.py`: | Coluna | Tipo | Status | |--------|------|--------| | `phone` | `String(20)` | ✅ Já existe | | `whatsapp` | `String(20)` | ✅ Já existe | | `cpf` | `String(14)` | ✅ Já existe | | `birth_date` | `Date` | ✅ Já existe | | `address_street` | `String(200)` | ✅ Já existe | | `address_number` | `String(20)` | ✅ Já existe | | `address_complement` | `String(100)` | ✅ Já existe | | `address_neighborhood` | `String(100)` | ✅ Já existe | | `address_city` | `String(100)` | ✅ Já existe | | `address_state` | `String(2)` | ✅ Já existe | | `address_zip` | `String(9)` | ✅ Já existe | O model `ClientUser` em `backend/app/models/user.py` já 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 { 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('/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

Contato

...
// Grid 2 colunas para campos curtos
...
// 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 já 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 frontend→backend | 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] |