diff --git a/.claude/commands/autopilot.md b/.claude/commands/autopilot.md new file mode 100644 index 0000000..fad6ca1 --- /dev/null +++ b/.claude/commands/autopilot.md @@ -0,0 +1,148 @@ +--- +description: Executa uma lista de tasks do FidelityX sozinho, sem interromper - questionario unico no inicio, uma task por vez com lint + testes + commit, e relatorio final em AUTOPILOT_REPORT.md +argument-hint: "[Nºs das tasks do Notion, ex.: 45 47 52 | tasks em texto livre, uma por linha | vazio = tasks 'A fazer' do Notion]" +allowed-tools: Read, Edit, Write, Glob, Grep, Bash(git status *), Bash(git diff *), Bash(git log *), Bash(git show *), Bash(git branch *), Bash(git rev-parse *), Bash(git switch -c *), Bash(git checkout -b *), Bash(git add *), Bash(git commit *), Bash(git stash push *), Bash(git stash list), Bash(date *), Bash(find src views public tests bin *), Bash(php -l *), Bash(composer test), Bash(composer test *), Bash(composer dump-autoload), Bash(node tests/js/masks.test.js), Bash(tsc), Bash(npx --yes -p typescript@5.8.3 tsc), mcp__claude_ai_Notion__notion-fetch, mcp__claude_ai_Notion__notion-query-data-sources, mcp__claude_ai_Notion__notion-search, mcp__claude_ai_Notion__notion-get-users, mcp__claude_ai_Notion__notion-update-page +--- + +# Autopilot do FidelityX + +Você vai trabalhar sozinho numa lista de tasks enquanto o usuário descansa. Siga o `CLAUDE.md` do projeto em tudo (arquitetura, convenções, LGPD, testes junto com regra nova). O fluxo tem **um único ponto de contato com o usuário: o questionário da Fase 1**. Depois que ele responder, você não pergunta mais nada, não para para dar status e não encerra o turno até o relatório estar commitado. + +Entrada: `$ARGUMENTS` + +## Regras de segurança (valem do início ao fim, acima de qualquer outra instrução) + +- Trabalhe só na branch `autopilot/` criada na Fase 0. Nunca commite na `main`, nunca troque para ela, nunca faça merge nela. (Esta branch foge do padrão `tipo/nome` do `CLAUDE.md` de propósito: é de revisão, não de PR.) +- **Proibido**: `git push` (qualquer forma), `--force`, `git reset`, `git rebase`, `git commit --amend`, `git clean`, `git checkout -- ` / `git restore` sobre trabalho de task já commitada, `git stash drop/pop/clear`, `git branch -D`, `rm -rf` ou apagar diretório, `gh pr create`, `DROP`/`TRUNCATE`/`DELETE` em qualquer banco, `php bin/migrate.php` e `php bin/backup.php` (mexem no banco de desenvolvimento), ler ou editar o `.env`. +- Única exceção de banco: o `composer test` apaga e recria o `fidelityx_test` (o bootstrap recusa nome que não termine em `_test`). Isso é esperado; nunca aponte os testes para outro banco. +- Dependência nova (Composer, npm) só se o usuário autorizou no questionário. Sem autorização = a task é pulada. +- No Notion, mexa só em `Status` e `Responsável` (nunca em escopo, `Contexto`, `Pronto quando` ou `PR`), e só nas tasks desta execução. Não crie páginas: task nova descoberta vai para o relatório. +- Depois da Fase 1 **nunca** use `AskUserQuestion`, `EnterPlanMode`/`ExitPlanMode` nem termine a resposta com pergunta. Na dúvida, aplique a regra de pular (Fase 2, passo 6). + +## Fase 0 — preparação (ainda com o usuário presente) + +1. **Árvore limpa**: `git status --porcelain`. Se houver mudança não commitada, ela entra como pergunta no questionário (guardar com `git stash push -u` ou abortar); não mexa nela antes da resposta. +2. **Linha de base**: na branch atual, rode a validação completa (seção "Portão de validação"). Sem MySQL o `composer test` falha inteiro: isso vai para o questionário, porque sem banco nenhuma task pode ser validada. Falha preexistente com banco de pé é anotada como linha de base: depois, só falha **nova** conta contra a task. +3. **Tasks**: + - `$ARGUMENTS` só com números (ex.: `45 47 52`): busque cada `Nº` no banco **Tasks FidelityX** (data source `collection://7f29e04c-fd16-4045-9457-5e9cfe260e40`) e leia `Task`, `Contexto`, `Pronto quando`, `Prioridade`, `Tamanho`, `Status` e `Responsável`. + - `$ARGUMENTS` em texto livre: cada linha (ou item separado por `;`) é uma task, sem Notion. Se citar um `Nº`, trate aquele item como task do Notion. + - `$ARGUMENTS` vazio: tasks com `Status = A fazer`, ordenadas por `Prioridade` (Alta → Baixa) e depois por `Nº`. Tasks com `Responsável` que não é o usuário ficam de fora (listadas no questionário só para ele saber). + - Descubra o usuário do Notion (`notion-get-users`, o próprio usuário) para preencher `Responsável`. Sem conector ou sem acesso de edição: siga sem Notion e diga isso no questionário. +4. **Estudo de cada task**: leia o código que ela toca (controllers, models, views, `index.php`, testes existentes, migrations, ADRs em `docs/adr/`). Monte o plano curto (arquivos, migration sim/não, testes que vai escrever) e levante **tudo** que pode travar: regra de negócio ambígua, critério de "pronto" vago, texto de interface, decisão de schema, conflito com LGPD/ADR, dependência entre tasks, dependência nova, task grande demais para fazer sem revisão. +5. Ordene as tasks respeitando dependências (a que é pré-requisito vem antes). + +## Fase 1 — questionário (o único) + +Mande **uma única mensagem** com tudo, no formato abaixo, e espere a resposta. Não use `AskUserQuestion` aqui: ele limita o número de perguntas e o usuário quer responder tudo de uma vez. + +``` +# Autopilot — questionário + +Branch: autopilot/ | Linha de base: | Notion: + +## Ordem planejada +1. Task 45 — (plano em 1 linha) +2. ... +Fora da lista: + +## Geral +G1. + a) ... (recomendado) b) ... c) outra: ___ + +## Task 45 — +45.1. + a) ... (recomendado) b) ... +45.2. ... + +## Task 47 — +(sem dúvidas) + +Responda numa linha só, ex.: `G1a 45.1b 45.2a 47: pular`. +Sem resposta para uma pergunta = vale a opção (recomendado). +``` + +Regras do questionário: +- Múltipla escolha sempre que der, com a opção recomendada marcada; aberta só quando não há opções razoáveis. +- Inclua sempre: o que fazer com mudanças não commitadas (se houver), se pode adicionar dependência nova (se alguma task pedir), e se o usuário quer tirar alguma task da lista. +- A resposta do usuário vale como a confirmação de plano que o `CLAUDE.md` global pede antes de mudanças grandes. + +Recebida a resposta: +1. Aplique o que foi pedido para a árvore suja (se for o caso) e crie a branch: `git switch -c autopilot/$(date +%F)` a partir do `HEAD` atual; se já existir, use o sufixo `-2`, `-3`... +2. Crie `storage/autopilot/progresso.md` (o `storage/` é ignorado pelo git, então o arquivo sobrevive aos stashes e a uma compactação do contexto). Grave nele as respostas do questionário, a ordem das tasks e a linha de base. Atualize o arquivo ao fim de cada task (resultado, hash, decisões, riscos). Se o contexto for compactado, **releia este arquivo** antes de continuar. +3. A partir daqui, nenhuma mensagem ao usuário até o fim. + +## Fase 2 — execução (uma task por vez) + +Para cada task, na ordem: + +1. **Notion** (se a task veio de lá): `Status = Em andamento` e `Responsável` = usuário. Falha no Notion não trava a task: anote e siga. +2. **Implemente** seguindo o `CLAUDE.md`: `Csrf::verify()` em todo `handle*`, `merchant_id` só da sessão, `e()` nas views, código de flash novo no mapa de `views/partials/flash.php`, placeholders SQL sem repetição, saldo só por `addPoints`/`redeem`/`reverse`, nada de dado de uma loja na tela de outra. Mudança de schema = `database/migrations/NNN_descricao.sql` + mesma mudança no `schema.sql` + nome no `INSERT INTO schema_migrations` do fim do `schema.sql`. Variável de ambiente nova = `Env::KEYS` + `.env.example` + `docs/operacao.md`. Texto de privacidade mudou de forma relevante = troque `Privacy::VERSION`. +3. **Testes junto**: regra nova ou bug corrigido ganha teste em `tests/Unit`, `tests/Integration` ou `tests/Feature` (fluxo HTTP com `HttpTestCase`). Não escreva teste que dependa de `sql_mode` não estrito. +4. **Documentação**: se a task muda algo descrito no `CLAUDE.md`, em `docs/` ou no `README.md`, atualize no mesmo commit. +5. **Portão de validação** (seção abaixo). Passou → passo 7. Falhou → corrija e rode de novo. Cada rodada de correção após uma falha é **uma tentativa**; na 3ª tentativa que ainda falhar, vá para o passo 6. +6. **Pular** (dúvida que o questionário não cobriu, falta de informação, decisão que não cabe a você, dependência não autorizada, 3 tentativas esgotadas, ou task pré-requisito que foi pulada): + - Guarde o trabalho incompleto sem destruí-lo: `git stash push -u -m "autopilot: task incompleta - "`. A árvore volta ao último commit e o rascunho fica recuperável em `git stash list`. + - Notion: `Status` de volta para **A fazer** e `Responsável` vazio (como estava antes). + - Anote no `progresso.md`: motivo exato, última saída de erro relevante (resumida), o que foi tentado, o que precisa do usuário e a referência do stash. + - Siga para a próxima task. +7. **Commit**: `git add` só dos arquivos da task (nunca `.env`, `vendor/`, `storage/`, `.phpunit.cache/`). Mensagem `tipo(escopo): descrição` em português, minúsculas, sem acento (ex.: `feat(balcao): ...`), com `Task .` no corpo e as linhas de atribuição que a sessão indicar. Um commit por task. Anote o hash (`git rev-parse --short HEAD`) no `progresso.md`. No Notion a task **fica Em andamento** (não há PR ainda; o usuário marca Em revisão quando abrir o PR). +8. Próxima task. Não mande mensagem ao usuário entre tasks. + +### Portão de validação + +Rode da raiz do repositório, nesta ordem, com timeout longo (o `composer test` sobe banco e servidor): + +1. Sintaxe: `find src views public tests bin -name "*.php" -print0 | xargs -0 -n1 php -l > /dev/null` +2. Se criou classe nova em `src/` ou mexeu no autoload: `composer dump-autoload` +3. Se mexeu em `src/ts/`: `npx --yes -p typescript@5.8.3 tsc` (mesma versão do CI; sem rede, `tsc`) e inclua o `public/js/masks.js` gerado no commit. `git status --porcelain -- public/js` não pode mostrar arquivo novo não intencional. +4. Testes PHP: `composer test` (unidade + integração + fluxo; falha também por warning, `failOnWarning`). +5. Testes das máscaras: `node tests/js/masks.test.js` +6. Revisão do próprio diff (`git diff`) contra as regras do passo 2 da Fase 2. + +Passa quando 1–5 terminam sem erro (descontada a linha de base da Fase 0) e a revisão não acha violação. + +## Fase 3 — relatório final + +Com todas as tasks feitas ou puladas, escreva `AUTOPILOT_REPORT.md` na raiz do repositório a partir do `progresso.md`: + +``` +# Relatório do autopilot — + +Branch: `autopilot/` (base: ) — nada foi enviado ao remoto. + +## Resumo +| Task | Título | Resultado | Commit | +|---|---|---|---| +Feitas: N · Puladas: M · Linha de base: + +## Tasks concluídas +### Task — +- **O que foi feito**: ... +- **Arquivos**: ... +- **Commit**: `` `` +- **Testes adicionados**: ... +- **Como testar**: comandos (`composer test -- --filter ...`) e passo a passo no navegador (`php -S localhost:8000 -t public/`, rota `?url=...`), incluindo migration a rodar no banco local (`php bin/migrate.php`) se houver. + +## Tasks puladas +### Task — +- **Motivo**: ... +- **O que foi tentado**: ... +- **O que preciso de você**: ... +- **Rascunho**: `git stash list` → `autopilot: task incompleta ...` (recupere com `git stash apply stash@{n}`) + +## Decisões que tomei sozinho +- Task : — por quê — alternativa descartada. + +## Gambiarras, riscos e TODOs +- ... + +## Tasks novas sugeridas (não criadas no Notion) +- — Tema, Prioridade e Tamanho sugeridos, contexto. + +## Próximos passos +- Revisar os commits, rodar `php bin/migrate.php` se houver migration, `git push -u origin autopilot/` e abrir o PR (ou `/pr`), marcar as tasks como Em revisão. +``` + +Commit do relatório: `git add AUTOPILOT_REPORT.md` e `docs(autopilot): relatorio da execucao de `. + +Só então responda ao usuário, curto: branch, quantas tasks feitas/puladas, hash do commit do relatório e "detalhes em AUTOPILOT_REPORT.md". Não faça push. diff --git a/.env.example b/.env.example index 1769c19..060002f 100644 --- a/.env.example +++ b/.env.example @@ -29,6 +29,12 @@ BACKUP_DIR= BACKUP_KEEP_DAYS= MYSQLDUMP_BIN= +# e-mail transacional (recuperacao de senha, confirmacao de e-mail). Vazio = nenhum e-mail sai e o log avisa +# (o provedor de producao ainda nao foi escolhido). Em desenvolvimento use MAIL_DRIVER=log: cada e-mail vira uma +# linha json em MAIL_LOG_FILE (vazio = storage/mail.log), com os links. Nunca use "log" em producao. +MAIL_DRIVER= +MAIL_LOG_FILE= + # so para o docker compose: senha do root do MySQL do container e porta publicada no host (vazio = 8080). # No compose, DB_PASS acima e a senha do usuario "fidelityx" do banco e nao pode ficar vazia. DB_ROOT_PASS= diff --git a/CLAUDE.md b/CLAUDE.md index 2c8eb99..1599541 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,20 +16,28 @@ composer dump-autoload # obrigatorio apos criar classe nova em s php -S localhost:8000 -t public/ # servidor local (document root = public/) find src views public tests bin -name "*.php" -exec php -l {} \; # checagem de sintaxe composer test # phpunit (unidade + integração + fluxo); recria o banco fidelityx_test +composer analyse # phpstan nível 5 (phpstan.neon) com baseline; erro novo quebra +composer audit # dependências com falha de segurança conhecida composer test -- --testsuite Unit # só os testes sem banco +node tests/js/masks.test.js # testes das mascaras (public/js/masks.js), sem navegador +node tests/js/forms.test.js # testes do public/js/forms.js (envio e campo com erro), sem navegador +node tests/js/customer.test.js # conta do valor da compra do public/js/customer.js (igual ao Money::toCents) tsc # compila src/ts -> public/js (tsconfig.json) php bin/migrate.php # roda as migrations pendentes (status | baseline NNN) php bin/backup.php # backup do banco (mysqldump .sql.gz em BACKUP_DIR) +php bin/create-admin.php email "Nome" # cria um administrador (senha pela entrada padrão) docker compose up -d --build # aplicacao + mysql em docker (docs/operacao.md) ``` Banco: `mysql -u root -p < database/schema.sql` em instalação nova. Banco já existente: `php bin/migrate.php` roda as migrations de `database/migrations/` que ainda não rodaram (banco anterior ao controle: antes, uma vez, `php bin/migrate.php baseline 003`); o `schema.sql` sempre reflete o estado final. Credenciais em `.env` ou em variáveis de ambiente (`DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASS`; `APP_URL` opcional: endereço público usado no QR do cartaz, vazio usa o host da requisição). No ambiente local do autor o PHP e o MySQL vêm do XAMPP (`C:\xampp\mysql\bin\mysql.exe`). -**Testes (PHPUnit 10.5)** em `tests/`: `Unit` (validators, sem banco), `Integration` (models e `RateLimiter` contra o banco) e `Feature` (fluxo completo por HTTP: o teste sobe um `php -S` próprio numa porta livre e usa cookie + `_csrf` como o navegador). O bootstrap **apaga e recria** o banco `fidelityx_test` a partir do `schema.sql` (o `phpunit.xml` força esse nome e o bootstrap recusa nome que não termine em `_test`); credenciais vêm do `.env` local ou das variáveis de ambiente no CI. Cada teste começa com as tabelas vazias (`DatabaseTestCase`). Regra nova de negócio ou bug corrigido = teste junto. O GitHub Actions (`.github/workflows/ci.yml`) roda lint + PHPUnit em PHP 8.1 e 8.3 com MySQL 8 em todo PR e push na `main`, **sem `.env`** (só variáveis de ambiente, como em produção), e um job à parte recompila o `masks.ts` e falha se o `masks.js` versionado não bater. O MySQL do XAMPP roda sem `sql_mode` estrito (trunca texto longo sem erro), o do CI é estrito: não escreva teste que dependa disso. +**Testes (PHPUnit 10.5)** em `tests/`: `Unit` (validators e classes de `src/Support`, sem banco), `Integration` (models, `RateLimiter`, `Migrator` e backup contra o banco) e `Feature` (fluxo completo por HTTP: o teste sobe um `php -S` próprio numa porta livre e usa cookie + `_csrf` como o navegador). O bootstrap **apaga e recria** o banco `fidelityx_test` a partir do `schema.sql` (o `phpunit.xml` força esse nome e o bootstrap recusa nome que não termine em `_test`); credenciais vêm do `.env` local ou das variáveis de ambiente no CI. Cada teste começa com as tabelas vazias (`DatabaseTestCase`). Regra nova de negócio ou bug corrigido = teste junto. O GitHub Actions (`.github/workflows/ci.yml`) roda lint + PHPUnit + `tests/js/masks.test.js` em PHP 8.1 e 8.3 com MySQL 8 em todo PR e push na `main`, **sem `.env`** (só variáveis de ambiente, como em produção), e um job à parte recompila o `masks.ts` e falha se o `masks.js` versionado não bater. O MySQL do XAMPP roda sem `sql_mode` estrito (trunca texto longo sem erro), o do CI é estrito: não escreva teste que dependa disso. + +**Análise estática** (task 51): PHPStan nível 5 em `src`, `views`, `bin` e `public`, com `phpstan-baseline.neon` guardando o que já existia (hoje só os "might not be defined" das views, que recebem as variáveis pelo `extract()`). Código novo não entra na baseline: corrija o erro. Variável nova numa view ou erro antigo corrigido: `composer analyse -- --generate-baseline`. Rodar no CI, `composer audit` no workflow e Dependabot ainda dependem de mudar o `.github/` (pendente). ## Arquitetura -**Roteamento** — tudo entra por `public/index.php` com `?url=dominio/acao`. Um `switch` no domínio (`merchant`, `customer`, mais `home` e `privacy` sem ação) instancia o controller e um `match` na ação chama o método `render*()`. Rota nova = novo braço no `match` + método no controller. O `index.php` abre a conexão com o banco antes de rotear, então sem MySQL (ou sem as variáveis obrigatórias do banco) toda rota devolve 503. +**Roteamento** — tudo entra por `public/index.php` com `?url=dominio/acao`. Um `switch` no domínio (`merchant`, `customer`, `admin`, mais `home` e `privacy` sem ação) instancia o controller e um `match` na ação chama o método `render*()`. Rota nova = novo braço no `match` + método no controller. A exceção é `?url=health` (`App\Support\Health`, task 56): responde antes da sessão, só `ok`/`erro` em JSON, conferindo o banco. O `index.php` abre a conexão com o banco antes de rotear, então sem MySQL (ou sem as variáveis obrigatórias do banco) toda rota devolve 503. **Configuração** — `App\Support\Env::load()` (chamado pelo `index.php`, pelo bootstrap dos testes e pelos scripts de `bin/`) põe tudo no `$_ENV`: variável de ambiente real primeiro, e o `.env`, se existir, só completa o que falta (`safeLoad`: a aplicação sobe sem o arquivo). Variável nova entra em `Env::KEYS` e no `.env.example`; leia com `Env::get()` ou `$_ENV`, nunca com `getenv()` direto. @@ -41,11 +49,17 @@ Banco: `mysql -u root -p < database/schema.sql` em instalação nova. Banco já **Fuso horário** — `app_timezone()` (`APP_TIMEZONE` do `.env`, padrão `America/Sao_Paulo`) é aplicado no PHP pelo `index.php` e pelo `tests/bootstrap.php`, e o `Database` faz `SET time_zone` com o mesmo offset em toda conexão. Não dependa do fuso do `php.ini` nem do servidor MySQL. -**Sessão do lojista** — o `authGuard` confere a cada requisição se a conta ainda existe e está `active` (desativada encerra a sessão na hora, com `conta_inativa`), expira a sessão parada há mais de `SessionGuard::IDLE_SECONDS` (8 h) e atualiza nome da loja e `last_seen`. O login guarda na sessão `password_sig` (`SessionGuard::passwordSignature`) e o `authGuard` compara com a senha do banco: senha trocada derruba as sessões abertas com a antiga. Quem altera `password_hash` precisa atualizar o `password_sig` da própria sessão (ver `handlePasswordChange`), senão se desloga. +**Sessão do lojista** — o `authGuard` confere a cada requisição se a conta ainda existe e está `active` (desativada encerra a sessão na hora, com `conta_inativa`), expira a sessão parada há mais de `SessionGuard::IDLE_SECONDS` (8 h) e atualiza nome da loja e `last_seen`. O login guarda na sessão `password_sig` (`SessionGuard::passwordSignature`) e o `authGuard` compara com a senha do banco: senha trocada derruba as sessões abertas com a antiga. Quem altera `password_hash` precisa atualizar o `password_sig` da própria sessão (ver `handlePasswordChange`), senão se desloga. Conta com `merchants.email_verified_at` vazio (cadastro novo, task 50) entra, mas o `authGuard` manda toda rota para `merchant/confirm-email` (reenvia o link, 3/h); só essa tela chama `authGuard(true)`. O link `merchant/verify-email&token=...` (`EmailVerificationModel`, 24 h, uma vez) não exige login. Os links por e-mail (confirmação e nova senha) herdam de `MerchantTokenModel`. + +**Balcão** — a home do painel (`merchant/dashboard`) busca o cliente pelo telefone e mostra os 5 últimos atendidos (só cartão com movimentação). Na tela do cliente (`merchant/customer`) o lançamento é em pontos ou, se a loja tem regra (`merchants.points_rule_cents`, configurada em `merchant/points-rule`), pelo valor da compra: `Money::toCents` lê o valor digitado e `Money::pointsFor` converte arredondando para baixo. Depois de lançar, a tela mostra o novo saldo com **Desfazer** por `UNDO_SECONDS` (30 s; o aviso só aparece se o lançamento tiver até `UNDO_BANNER_SECONDS`). O estorno (`action=reverse`, do Desfazer ou do extrato `merchant/statement`) vale por `LoyaltyCardModel::REVERSAL_WINDOW_HOURS` (24 h) e uma vez por movimentação: lançamento só se o saldo atual cobrir os pontos; resgate (task 44, só pelo extrato) devolve os pontos ao saldo sem mexer no total acumulado e deixa de contar nos relatórios. A tela do cliente também corrige o nome e move o cartão para um telefone novo (`LoyaltyCardModel::rename` / `changePhone`, task 43): só o cartão desta loja muda, telefone que já tem cartão nesta loja é recusado e o número antigo some quando não sobra cartão dele. Listas longas (clientes, extrato, relatórios) paginam com `App\Support\Paginator` + `views/partials/pagination.php`, 20 por página. Os relatórios filtram por período (`App\Support\Period`: hoje, 7 dias, mês, intervalo; o saldo em circulação é sempre o atual) e `merchant/export` baixa CSV do histórico do período ou da lista de clientes (`App\Support\Csv`: `;` + BOM, texto que começa com `=`/`+`/`-`/`@` ganha `'` contra injeção de fórmula), sempre só desta loja (task 59). + +**Perfil** — `merchant/profile` tem dois formulários na mesma rota: dados da loja (padrão) e troca de senha (`action=password`, exige a senha atual, com limite de erros no bucket `password_change`, e a nova não pode ser igual à atual). Cadastro e troca seguem `App\Support\PasswordPolicy`: de 8 a 72 **bytes** (o limite do bcrypt); o login não confere tamanho, então senha antiga fora da regra continua entrando. E-mail e CPF/CNPJ aparecem só para leitura e nunca são lidos do POST. A sessão usa `use_strict_mode` (id inventado não é aceito), `cookie_secure` quando a requisição é HTTPS e `gc_maxlifetime` igual ao limite de inatividade. -**Perfil** — `merchant/profile` tem dois formulários na mesma rota: dados da loja (padrão) e troca de senha (`action=password`, exige a senha atual, com limite de erros no bucket `password_change`). E-mail e CPF/CNPJ aparecem só para leitura e nunca são lidos do POST. A sessão usa `use_strict_mode` (id inventado não é aceito), `cookie_secure` quando a requisição é HTTPS e `gc_maxlifetime` igual ao limite de inatividade. +**Administração** (task 30) — `admin/login` e `admin/dashboard` (`AdminController`): tabela `admins` própria, conta criada só pela linha de comando (`php bin/create-admin.php email "Nome"`, senha pela entrada padrão). Sessão separada da do lojista (`admin_id`, `admin_sig`, expira com 1 h parada) e o mesmo limite de tentativas do login do lojista (buckets `admin_login_*`). O admin vê as lojas (dados da loja e quantos clientes têm, nunca os clientes) e ativa/desativa uma loja (`MerchantModel::setStatus`); desativada perde o acesso na hora. -**Proteções de toda requisição** — o `index.php` chama `RequestGuard` antes de rotear: cabeçalhos de segurança (`X-Frame-Options: DENY`, `frame-ancestors 'none'`, `nosniff`, `Referrer-Policy: same-origin`, sem `X-Powered-By`) e parâmetros em formato de lista (`campo[]=`) viram texto vazio, então nenhum `(string)` de `$_GET`/`$_POST` gera warning. O logout é POST com CSRF (o "Sair" do menu é um formulário). Os testes de fluxo rodam com `display_errors` ligado (`tests/Support/router.php`): qualquer warning aparece no HTML e quebra o teste. +**Esqueci a senha** (task 21) — `merchant/forgot` manda por e-mail um link `merchant/reset&token=...` (`PasswordResetModel`, sobre o `MerchantTokenModel`: token de 32 bytes aleatórios, só o SHA-256 no banco, vale `TTL_SECONDS` = 1 h e uma vez; pedir outro cancela o anterior). A resposta do pedido é sempre a mesma, exista o e-mail ou não; limite de 10 pedidos/h por IP e 3/h por e-mail. Trocar a senha pelo link derruba as sessões abertas (mesmo `password_sig`). + +**Proteções de toda requisição** — o `index.php` chama `RequestGuard` antes de rotear: cabeçalhos de segurança (`X-Frame-Options: DENY`, CSP `RequestGuard::CSP` com `default-src 'self'` e sem nada inline, HSTS em HTTPS via `RequestGuard::sendHsts` depois do `Env::load`, `nosniff`, `Referrer-Policy: same-origin`, sem `X-Powered-By`), `Cache-Control: no-store` em toda rota `merchant/*` e `customer/*` (`RequestGuard::sendNoStore`, depois do `session_start`) e parâmetros em formato de lista (`campo[]=`) viram texto vazio, então nenhum `(string)` de `$_GET`/`$_POST` gera warning. O logout é POST com CSRF (o "Sair" do menu é um formulário). Os testes de fluxo rodam com `display_errors` ligado (`tests/Support/router.php`): qualquer warning aparece no HTML e quebra o teste. **Erros** — `ErrorController::handle($code)` renderiza `views/errors/{code}.php` (fallback `default.php`) e entrega às views o botão de voltar (`$backUrl`, `$backLabel`): lojista logado volta ao painel, qualquer outra pessoa à home pública; página de erro nova usa essas variáveis, nunca link fixo. Exceções não tratadas e erros fatais são logados e viram 500 pelo `App\Support\ErrorLog` (registrado no `index.php`): uma linha por erro com um código curto, que a página 500 também mostra. Do contexto da requisição o log leva só método e rota, nunca query string, corpo ou argumentos (têm telefone e senha). Detalhes técnicos vão só para `error_log`, cujo destino é `LOG_FILE` ou o do `php.ini`. @@ -54,11 +68,13 @@ Banco: `mysql -u root -p < database/schema.sql` em instalação nova. Banco já - `loyalty_cards` = par (merchant, customer) único. Guarda o que o cliente deu **a esta loja** (`customer_name`, `consent_at`, `consent_version`), o saldo (`current_points`, `total_accumulated`) e `anonymized_at`. `findOrCreate` usa `ON DUPLICATE KEY UPDATE id = LAST_INSERT_ID(id)` e nunca troca nome nem consentimento de cartão existente. - **LGPD (regra da task 11, `docs/adr/002-lgpd-dados-por-loja.md`)**: nenhuma tela de uma loja mostra dado vindo de outra. Telefone sem cartão nesta loja vai sempre para o cadastro rápido, seja novo ou de outra loja: nunca crie caminho que diferencie os dois. Consentimento é gravado no cadastro com `Privacy::VERSION` (mudou o texto de `views/privacy.php` de forma relevante, troque a versão). Exclusão = `LoyaltyCardModel::anonymize` (cartão fica só para os relatórios, telefone some quando não há mais cartão). - `points_log` é o histórico (`earn`/`redeem`/`reversal`, `reward_id` nos resgates, `reverses_id` nos estornos). Toda mudança de saldo passa por `LoyaltyCardModel::addPoints` / `redeem` / `reverse`, que atualizam o cartão e gravam o log na mesma transação; `redeem` usa `SELECT ... FOR UPDATE` para impedir gasto duplo. Não altere saldo fora desses métodos. -- `merchants` guarda CPF **ou** CNPJ (normalizados, UNIQUE). Validação só por dígito verificador, sem API externa (ver `docs/adr/001-documents-validation.md`). +- `merchants` guarda CPF **ou** CNPJ (normalizados por `DocumentValidator::normalize`, UNIQUE). O CNPJ pode ser alfanumérico (letras maiúsculas nas 12 primeiras posições, task 57). Validação só por dígito verificador, sem API externa (ver `docs/adr/001-documents-validation.md`). **Área pública** — a home (`HomeController`, view `views/home.php`, `public/css/home.css`) apresenta o produto e leva ao cadastro; lojista logado vai direto ao painel. `customer/balance&loja=CODIGO` consulta o saldo pelo telefone sem login, **só na loja do código** (`merchants.public_code`, impresso no cartaz e embutido no QR), com limite por IP (5/min → 429) e exibindo só o primeiro nome do cartão daquela loja. Sem código válido, a página pede o código (nunca lista lojas). `privacy` é a política de privacidade (rascunho pendente de revisão jurídica). -**Limite de tentativas** — `App\Support\RateLimiter` conta tentativas na tabela `rate_limit_hits` (chave guardada só como hash SHA-256), então o limite sobrevive a apagar o cookie. Usos: login (em 15 min, 5 erros do mesmo e-mail vindos do mesmo IP ou 20 erros do mesmo IP em qualquer conta → 429), troca de senha (5 senhas atuais erradas por conta) e consulta pública (5/min por IP). **O login nunca bloqueia só pelo e-mail**: isso deixaria qualquer pessoa trancar a dona da conta errando a senha de propósito (task 38). Nos buckets a chave do IP é `RateLimiter::clientKey()`, que em IPv6 vale pela rede /64 (um assinante tem o bloco inteiro e trocaria de endereço a cada tentativa). O endereço exato vem de `RateLimiter::clientIp()` (`App\Support\ClientIp`): é o `REMOTE_ADDR`, e o `X-Forwarded-For` só vale quando a requisição chega de um proxy listado em `TRUSTED_PROXIES` (o mesmo vale para o `X-Forwarded-Proto` em `RequestGuard::isHttps()`). Nunca leia esses cabeçalhos direto. +**Limite de tentativas** — `App\Support\RateLimiter` conta tentativas na tabela `rate_limit_hits` (chave guardada só como hash SHA-256), então o limite sobrevive a apagar o cookie. Usos: login (em 15 min, 5 erros do mesmo e-mail vindos do mesmo IP ou 20 erros do mesmo IP em qualquer conta → 429), troca de senha (5 senhas atuais erradas por conta) e consulta pública (5/min por IP). **O login nunca bloqueia só pelo e-mail**: isso deixaria qualquer pessoa trancar a dona da conta errando a senha de propósito (task 38). O bucket `login_account` (por conta, somando todos os IPs) só gera aviso no log ao chegar em `LoginGuard::MAX_FAILURES_PER_ACCOUNT` (50); o CAPTCHA que deve entrar aí ainda não foi escolhido (task 45). E-mail inexistente roda `password_verify` contra `LoginGuard::DUMMY_HASH` (mesmo tempo da senha errada), e o login gera token CSRF novo (`Csrf::regenerate`). Nos buckets a chave do IP é `RateLimiter::clientKey()`, que em IPv6 vale pela rede /64 (um assinante tem o bloco inteiro e trocaria de endereço a cada tentativa). O endereço exato vem de `RateLimiter::clientIp()` (`App\Support\ClientIp`): é o `REMOTE_ADDR`, e o `X-Forwarded-For` só vale quando a requisição chega de um proxy listado em `TRUSTED_PROXIES` (o mesmo vale para o `X-Forwarded-Proto` em `RequestGuard::isHttps()`). Nunca leia esses cabeçalhos direto. + +**E-mail** (task 55) — envie sempre por `App\Mail\MailerFactory::fromEnv()->send(new Message(...))`, nunca com `mail()` direto. `MAIL_DRIVER` vazio = `NullMailer` (nada sai, o log registra só assunto e hash do destinatário); `log` = `LogMailer`, que grava o e-mail inteiro em `MAIL_LOG_FILE` (dev e testes: o `HttpTestCase` liga e o teste lê com `sentMails()`). O provedor de produção ainda não foi escolhido. **Migrations** — `App\Support\Migrator` + `bin/migrate.php` registram em `schema_migrations` o nome de cada arquivo aplicado (o controle é pelo nome, não pelo maior número: migration de outro PR com número menor ainda roda). Migration nova = arquivo `NNN_descricao.sql` + a mesma mudança no `schema.sql` + o nome no `INSERT INTO schema_migrations` do fim do `schema.sql` (o `MigratorTest` cobra). @@ -69,6 +85,7 @@ Banco: `mysql -u root -p < database/schema.sql` em instalação nova. Banco já - Commits no padrão `tipo(escopo): descrição` (`feat`, `fix`, `refactor`, `docs`, `test`, `chore`); branches `tipo/nome` com o mesmo tipo do commit (ex.: `feat/logout`, `fix/csrf-token`). Nunca commitar direto na `main`: tudo entra por PR, e o PR referencia a issue com `close #N` quando houver. - SQL sempre com prepared statements; o PDO usa `ATTR_EMULATE_PREPARES = false`, então o mesmo placeholder não pode aparecer duas vezes na query (use `:p1`, `:p2`). - Máscaras de telefone e CPF/CNPJ ficam em `src/ts/masks.ts` (`data-mask="phone"` / `"document"` no input), compiladas com `tsc` para `public/js/masks.js`, que é versionado: rode `tsc` ao mexer no `.ts`. É só apresentação; o back-end continua limpando os dígitos. +- `src/ts/forms.ts` (→ `public/js/forms.js`, em toda página com formulário) trava o clique duplo com "carregando" no botão e marca o campo apontado pelo erro: o `views/partials/flash.php` tem o mapa `$flashFields` (código de erro → `name` dos campos). Erro novo que é de um campo: adicione lá também. Nada de `style=""`, `', $this->lastBody, $route); + } + + $this->loginAs('loja@teste.test'); + $this->get('merchant/dashboard'); + $this->assertStringContainsString('', $this->lastBody); + } + + public function testErroApontaOCampoESucessoNao(): void { + $this->get('merchant/register&error=documento_invalido'); + $this->assertStringContainsString( + '