17 KiB
Implementation Plan: Cadastro Rico de Cliente (Perfil Completo no Registro)
Branch: master | Date: 2026-04-14 | Spec: 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:
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 noonChangeantes 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:
@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:
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
registerUserfazconst { confirmPassword: _c, ...payload } = datae enviapayloaddiretamente. O backend esperasnake_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:
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...restsem 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:
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):
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 já aceita o tipo expandido — se RegisterCredentials for tipado corretamente, nenhuma alteração é necessária no contexto.
Project Structure
Documentation (this feature)
.specify/features/012-register-rich-profile/
├── plan.md # Este arquivo
└── tasks.md # Fase 2 (gerado por /speckit.tasks)
Source Code (repository root)
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] |