Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ node tests/js/customer.test.js # conta do valor da compra do public/js/c
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)
```

Expand All @@ -36,7 +37,7 @@ Banco: `mysql -u root -p < database/schema.sql` em instalação nova. Banco já

## 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. 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.
**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.

Expand All @@ -54,6 +55,8 @@ Banco: `mysql -u root -p < database/schema.sql` em instalação nova. Banco já

**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.

**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.

**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.
Expand Down
51 changes: 51 additions & 0 deletions bin/create-admin.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<?php
// cria um administrador do FidelityX (task 30), que entra em /index.php?url=admin/login.
//
// php bin/create-admin.php email@dominio.com "Nome"
//
// a senha e pedida no terminal (ou lida da entrada: echo "senha" | php bin/create-admin.php ...),
// nunca vai como argumento (ficaria no historico do shell).
// usa o banco do .env / variaveis de ambiente (DB_NAME).

require __DIR__ . '/../vendor/autoload.php';

use App\Database;
use App\Models\AdminModel;
use App\Support\Env;

if (PHP_SAPI !== 'cli') {
exit(1);
}

Env::load(dirname(__DIR__));
date_default_timezone_set(app_timezone());

$email = trim((string)($argv[1] ?? ''));
$name = trim((string)($argv[2] ?? ''));
if ($email === '' || $name === '') {
fwrite(STDERR, "uso: php bin/create-admin.php email@dominio.com \"Nome\"\n");
exit(1);
}

fwrite(STDOUT, 'senha: ');
$password = rtrim((string)fgets(STDIN), "\r\n");

$problem = AdminModel::problem($email, $name, $password);
if ($problem !== null) {
fwrite(STDERR, "erro: $problem\n");
exit(1);
}

try {
if (Env::missing() !== []) {
throw new RuntimeException('variaveis obrigatorias sem valor: ' . implode(', ', Env::missing()));
}
$id = (new AdminModel(Database::connect()))->create($email, $name, $password);
fwrite(STDOUT, "\nadmin criado (id $id). entre em /index.php?url=admin/login\n");
} catch (PDOException $e) {
fwrite(STDERR, (($e->errorInfo[1] ?? null) === 1062 ? 'erro: ja existe admin com esse e-mail' : 'erro no banco: ' . $e->getMessage()) . "\n");
exit(1);
} catch (Throwable $e) {
fwrite(STDERR, 'erro: ' . $e->getMessage() . "\n");
exit(1);
}
21 changes: 21 additions & 0 deletions database/migrations/010_admins.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
-- fidelityx - migration 010 (painel administrativo, task 30)
-- para bancos criados antes desta versao do schema.sql. rodar uma unica vez:
-- php bin/migrate.php
-- (ou, a mao: mysql -u root -p fidelityx < database/migrations/010_admins.sql)

USE fidelityx;

-- administradores do FidelityX (nao sao lojistas): login proprio em admin/login, conta criada
-- pela linha de comando (php bin/create-admin.php). veem as lojas, nunca os clientes delas.
CREATE TABLE IF NOT EXISTS admins (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,

email VARCHAR(255) NOT NULL,
name VARCHAR(255) NOT NULL,
password_hash VARCHAR(255) NOT NULL,

created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,

UNIQUE KEY uq_admins_email (email)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
20 changes: 19 additions & 1 deletion database/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,23 @@ CREATE TABLE IF NOT EXISTS email_verifications (
ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

-- =========================
-- admins // administradores do FidelityX (task 30)
-- =========================
-- login proprio em admin/login, conta criada por php bin/create-admin.php. veem as lojas, nunca os clientes.
CREATE TABLE IF NOT EXISTS admins (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,

email VARCHAR(255) NOT NULL,
name VARCHAR(255) NOT NULL,
password_hash VARCHAR(255) NOT NULL,

created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,

UNIQUE KEY uq_admins_email (email)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

-- =========================
-- schema_migrations // quais migrations de database/migrations ja rodaram neste banco
-- =========================
Expand All @@ -249,4 +266,5 @@ INSERT IGNORE INTO schema_migrations (version) VALUES
('006_points_log_extrato'),
('007_customers_so_telefone'),
('008_password_resets'),
('009_email_verifications');
('009_email_verifications'),
('010_admins');
81 changes: 81 additions & 0 deletions docs/adr/003-atendentes-com-login-proprio.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# ADR 003 — Atendentes com login próprio

Status: **proposta** (2026-10-06, rascunho para o time decidir) · Task 58 do backlog · Sem código ainda

## Problema encontrado

A loja tem uma senha só. Quem está no balcão usa a conta do dono, então:

1. **Não fica registrado quem fez o quê.** `points_log.responsible_user` existe desde o MVP, mas nunca foi preenchido. Um lançamento errado, um resgate indevido ou um estorno não têm autor.
2. **Todo mundo pode tudo.** Quem lança pontos também apaga prêmio, muda a regra de pontos, troca a senha da conta e exclui dados de cliente.
3. **Tirar o acesso de alguém custa caro.** Quando um funcionário sai, o dono precisa trocar a senha e repassar a nova para todos os outros.

## Decisão proposta

### 1. Quem entra vira "usuário"; a loja continua sendo a loja

Tabela nova `users`, com e-mail e senha saindo de `merchants`:

| Coluna | Observação |
|---|---|
| `id` | |
| `merchant_id` | Loja a que o usuário pertence (FK). Um usuário, uma loja. Ver "Filiais" abaixo. |
| `role` | `owner` (dono) ou `attendant` (atendente). |
| `name`, `email` | E-mail `UNIQUE` na plataforma inteira: o login continua sendo só e-mail + senha. |
| `password_hash`, `email_verified_at`, `status` | Os mesmos de hoje em `merchants`, agora por pessoa. `status` = `active`/`inactive`. |

A migration cria um `owner` para cada loja existente, copiando `owner_name`, `email`, `password_hash` e `email_verified_at`, e só depois tira essas colunas de `merchants`. `merchants.status` continua valendo para a loja inteira: o admin (task 30) desativa a loja, e o dono desativa um atendente.

**Por que uma tabela única e não uma tabela `attendants` ao lado de `merchants`:** com duas tabelas, cada login, sessão, limite de tentativas, recuperação de senha (task 21), confirmação de e-mail (task 50) e o autor de cada movimentação precisariam saber "de qual tabela" é a pessoa. Com `users`, tudo isso passa a apontar para um `user_id`, e o caso do dono deixa de ser especial.

### 2. Sessão e permissões

- A sessão guarda `user_id`, `merchant_id` e `role`. O `authGuard` passa a conferir, a cada requisição, se o usuário **e** a loja estão ativos (desativar um atendente derruba a sessão dele na hora, como hoje acontece com a loja).
- `password_sig` passa a ser do usuário: trocar a senha de um atendente não derruba o dono.
- Um método `requireOwner()` no controller, chamado no começo das rotas só do dono. Rota nova nasce restrita ao dono; liberar para atendente é decisão explícita.

| Ação | Dono | Atendente |
|---|---|---|
| Buscar cliente, cadastro rápido, lançar, resgatar, estornar (24 h) | sim | sim |
| Extrato do cliente, registrar consentimento | sim | sim |
| Corrigir nome do cliente (task 43) | sim | sim *(a confirmar)* |
| Trocar telefone do cliente (task 43) | sim | não *(a confirmar)* |
| Excluir dados do cliente (LGPD) | sim | não |
| Prêmios, regra de pontos, perfil da loja, cartaz | sim | não |
| Relatórios e exportação CSV (task 59) | sim | não *(a confirmar)* |
| Cadastrar, desativar e reativar atendentes | sim | não |

### 3. Quem fez cada movimentação

`points_log.responsible_user VARCHAR(255)` vira `user_id BIGINT UNSIGNED NULL` com FK para `users` (`ON DELETE SET NULL`). `addPoints`, `redeem` e `reverse` recebem o `user_id` da sessão e gravam na mesma transação. Movimentações antigas ficam com `NULL` e aparecem como "—". O extrato, o histórico dos relatórios e o CSV ganham a coluna "Feito por".

O nome mostrado é o do usuário **hoje** (não uma cópia na hora do lançamento): se o atendente for removido (ver LGPD), o histórico passa a mostrar "Atendente removido".

### 4. Cadastro de atendente

O dono informa nome e e-mail numa tela nova (`merchant/team`). O sistema cria o usuário sem senha e manda por e-mail um link para ele criar a própria senha, reaproveitando o `MerchantTokenModel` (tasks 21 e 50): o dono nunca sabe a senha do atendente, e o e-mail já sai confirmado pelo uso do link. **Depende do provedor de e-mail (task 55)**, que ainda não foi escolhido.

### 5. LGPD

Os atendentes são titulares de dados (nome e e-mail) e a loja é a controladora. Desativar mantém o registro (o histórico precisa do autor). Para atender um pedido de exclusão, o dono "remove" o atendente: o usuário fica `inactive`, com nome trocado por "Atendente removido" e e-mail por um valor inválido único, e o `user_id` continua nas movimentações. Nenhuma tela de uma loja mostra atendente de outra.

## Consequências

- **Migration grande e com cópia de dados** (`merchants` → `users`). Precisa de teste de migration sobre um banco no formato antigo, como a 007.
- **Login, recuperação de senha, confirmação de e-mail e limite de tentativas** passam a falar de usuário, não de loja. Os buckets do `RateLimiter` continuam por e-mail, então nada muda para quem usa.
- **Toda rota do painel ganha uma decisão de permissão.** A tabela acima vira teste de fluxo: atendente recebe 403 em cada rota só do dono.
- **Abre caminho para filiais (task 60):** "um usuário, várias lojas" vira uma tabela de vínculo `user_stores` no lugar de `users.merchant_id`, sem mexer em login nem em `points_log`.

## Alternativas descartadas

- **Tabela `attendants` separada, dono continua em `merchants`:** menos migration agora, mas dois caminhos em tudo que envolve login e autoria (ver item 1).
- **PIN de 4 dígitos por atendente, sem login:** registra o autor e é rápido no balcão, mas a sessão continua sendo a do dono (o atendente vê e faz tudo) e PIN curto é fácil de adivinhar.
- **Só um campo "atendente" de texto livre no lançamento:** não controla acesso e qualquer um digita qualquer nome.

## Perguntas em aberto (para o time)

1. Atendente pode corrigir nome, trocar telefone e ver relatórios? (marcados "a confirmar" na tabela)
2. Limite de atendentes por loja? Liga com os planos Free/Pro (task 32).
3. Dono pode redefinir a senha de um atendente, ou só reenviar o link para ele criar outra?
4. A sessão do atendente expira em menos tempo que a do dono (hoje 8 h)?
5. Sem provedor de e-mail (task 55), aceitamos um caminho provisório (dono define a senha inicial e o atendente é obrigado a trocar no primeiro acesso)?
8 changes: 8 additions & 0 deletions docs/db/schema-explanation.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,3 +97,11 @@ O `schema.sql` já insere todas as migrations existentes: banco novo nasce sem p

### `email_verifications` — links de confirmação do e-mail (task 50)
Mesma forma da `password_resets` (`merchant_id`, `token_hash` com `UNIQUE`, `expires_at`, `used_at`), com validade de 24 horas. Reenviar o link cancela o anterior.

### `admins` — administradores do FidelityX (task 30)
| Coluna | Observação |
|---|---|
| `email` | `UNIQUE`. Login do painel `admin/login`. |
| `password_hash` | bcrypt. A conta é criada por `php bin/create-admin.php`. |

Separada de `merchants` de propósito: um admin não é uma loja e um lojista nunca vira admin por um campo trocado.
11 changes: 11 additions & 0 deletions docs/operacao.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,17 @@ TRUSTED_PROXIES=127.0.0.1,172.16.0.0/12

Com o Compose, o proxy no host chega ao container pelo gateway da rede do Docker, normalmente um endereço em `172.16.0.0/12`.

## Administrador

O painel de administração (`/index.php?url=admin/login`) tem contas próprias, que não são lojistas. Para criar uma (no container, `docker compose exec app php bin/create-admin.php ...`):

```bash
php bin/create-admin.php admin@seudominio.com.br "Nome da pessoa"
# a senha (8 a 72 bytes) é pedida em seguida; não passe a senha como argumento
```

O admin vê todas as lojas e ativa ou desativa um lojista (a desativação derruba a sessão dele na hora). Não há tela para criar ou remover admins: remover é `DELETE FROM admins WHERE email = ...` no banco.

## Rota de saúde

`GET /index.php?url=health` responde `200 {"status":"ok"}` quando a aplicação consegue falar com o banco e `503 {"status":"erro"}` quando não (o motivo vai para o log, nunca para a resposta). Ela roda antes da sessão, então checar a cada 30 s não cria arquivo no volume de sessões, e sai com `Cache-Control: no-store`. É o endereço para o monitor externo e para o `HEALTHCHECK` do container (task 56; a troca do healthcheck no `Dockerfile` e o monitor com alerta ainda estão pendentes).
Expand Down
12 changes: 12 additions & 0 deletions phpstan-baseline.neon
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
parameters:
ignoreErrors:
-
message: '#^Variable \$merchants might not be defined\.$#'
identifier: variable.undefined
count: 1
path: views/admin/dashboard.php

-
message: '#^Variable \$paginator might not be defined\.$#'
identifier: variable.undefined
count: 3
path: views/admin/dashboard.php

-
message: '#^Variable \$categories might not be defined\.$#'
identifier: variable.undefined
Expand Down
13 changes: 13 additions & 0 deletions public/index.php
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,19 @@
};
break;

case 'admin':
// painel administrativo (task 30): login e sessao proprios, separados dos do lojista
RequestGuard::sendNoStore();
$controller = new \App\Controllers\AdminController($db);

match ($action ?? 'dashboard') {
'login' => $controller->renderLogin(),
'logout' => $controller->logout(),
'dashboard' => $controller->renderDashboard(),
default => (new ErrorController())->handle(404),
};
break;

case 'merchant':
RequestGuard::sendNoStore();
$controller = new \App\Controllers\MerchantController($db);
Expand Down
Loading
Loading