424 lines
17 KiB
Markdown
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` 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<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 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] |
|