feat: add full project - backend, frontend, docker, specs and configs
This commit is contained in:
parent
b77c7d5a01
commit
e6cb06255b
24489 changed files with 61341 additions and 36 deletions
424
.specify/features/012-register-rich-profile/plan.md
Normal file
424
.specify/features/012-register-rich-profile/plan.md
Normal file
|
|
@ -0,0 +1,424 @@
|
|||
# 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] |
|
||||
Loading…
Add table
Add a link
Reference in a new issue