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

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 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:

@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 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:

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:

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] |