aquitetura

This commit is contained in:
2026-11-05 22:40:10 -03:00
parent e6603f2cb9
commit 14f1ea240f
3 changed files with 121 additions and 133 deletions

1
.gitignore vendored
View File

@@ -10,3 +10,4 @@ backups/
web/node_modules/
web/.next/
web/next-env.d.ts
web/tsconfig.tsbuildinfo

View File

@@ -1,41 +1,35 @@
# Bot de Pontos v2 — Documento de Arquitetura
# Bot de Pontos — Documento de Arquitetura
> Status: **Sprints 0 a 6 e evoluções implementadas; backend compila e passa nos testes, painel passa na checagem de tipos** (ver `README.md` na raiz do projeto e a seção 16)
> Base: projeto `Modelo` (versão final em `Modelo/Fase2/Sprint4`)
> Stack: **Go 1.25 (backend) · Next.js 15 (web) · PostgreSQL 17 · Docker Compose**
> Estado: **implementado** — backend compila e passa nos testes; painel passa na checagem de tipos (seção 16)
> Stack: **Go 1.25 (backend) · Next.js 15 (painel) · PostgreSQL 17 · Docker Compose**
Este documento descreve o sistema como ele é hoje. Os números das seções são citados nos comentários do código; ao editar, mantenha a numeração.
---
## 1. Objetivo
## 1. Visão geral
Reconstruir o Bot de Pontos com uma arquitetura que:
O Bot de Pontos lembra cada usuário, pelo Telegram, da hora de **voltar do almoço** e da hora de **encerrar a jornada**. Para isso, consulta os pontos do dia na **PontoMais**, calcula o retorno (`entrada2`) e a saída (`saida2`) previstos e envia o aviso alguns minutos antes de cada horário.
- separe **backend** (Go) e **interface web** (Next.js) por um contrato de API explícito;
- troque o SQLite por **PostgreSQL**, permitindo vários processos e containers escrevendo com segurança;
- rode inteiramente em **containers Docker**;
- corrija as inconsistências conhecidas do Modelo e deixe pontos de extensão para novas regras.
- O usuário comum usa o **Telegram**: cadastro, consulta ao vivo e liga/pausa os lembretes.
- Os administradores usam um **painel web** para aprovar usuários, configurar credenciais e jornadas, acompanhar lembretes e auditoria, manter feriados e a janela de consulta e gerenciar as contas do painel.
- Nada é registrado na PontoMais: o sistema só lê os pontos.
O domínio continua o mesmo: consultar os pontos do dia na PontoMais, calcular `entrada2` e `saida2` e lembrar o usuário pelo Telegram.
## 2. Características principais
O Modelo **nunca rodou em produção**, então não há dados a migrar: a v2 começa com o banco vazio e não precisa ser compatível com o SQLite do Modelo.
## 2. O que muda em relação ao Modelo
| Tema | Modelo | v2 |
| --- | --- | --- |
| Web | HTML renderizado pelo Go (`webassets/`) | Next.js (App Router) consumindo API REST do Go |
| API | Não existe (rotas devolvem HTML) | API REST JSON versionada (`/api/v1`) |
| Banco | SQLite WAL, arquivo compartilhado | PostgreSQL 17, container próprio |
| Concorrência do despacho | `UPDATE ... WHERE status='scheduled'` | `SELECT ... FOR UPDATE SKIP LOCKED` + lock de líder |
| Ciclos | Reconciliação 15 min e despacho 5 min, **no mesmo loop** (um bloqueia o outro) | Reconciliação 15 min e despacho **2 min**, em **goroutines independentes** |
| Consultas à PontoMais | O dia inteiro, todo dia útil, todos os usuários a cada 15 min | Só na **janela 11:30–19:00**, com **frequência adaptativa por usuário** (15 min, 60 min ou nenhuma); ~11 consultas/usuário num dia típico (ver 9.12) |
| Datas | `TEXT` | `timestamptz` / `date` |
| Executáveis | `bot`, `admin`, `web` | uma imagem Go com subcomandos `api`, `worker`, `admin`, `migrate` (e `healthcheck`, `version`) |
| Contas web | Uma conta | N contas com papéis (`owner`, `admin`, `viewer`) |
| Fuso por usuário | Campo existe, mas é ignorado | Aplicado em todos os cálculos |
| Regras de jornada | Por usuário, mas com o texto "8h00" fixo | Por usuário, com mensagens formatadas a partir da configuração |
| Ativação | Telegram e web com validações diferentes | Um único caso de uso para as duas interfaces |
| Deploy | LXC + systemd, ou Docker | Docker Compose |
| Tema | Como funciona |
| --- | --- |
| Interfaces | Bot do Telegram (*long polling*) e painel Next.js que consome a API REST do Go (`/api/v1`) |
| Banco | PostgreSQL 17 em container próprio; instantes em `timestamptz`, dias em `date` |
| Processos | Uma imagem Go com os subcomandos `api`, `worker`, `migrate`, `admin`, `healthcheck` e `version` |
| Ciclos | Reconciliação a cada 15 min e despacho a cada 2 min, em goroutines independentes (8.2) |
| Consultas à PontoMais | Só na **janela 11:30–19:00**, com **frequência adaptativa por usuário**; ~11 consultas/usuário num dia típico (9.12) |
| Despacho | `SELECT ... FOR UPDATE SKIP LOCKED` e worker único por *advisory lock*; no máximo uma mensagem por lembrete (6.3) |
| Fuso | Cada usuário tem o seu; todos os cálculos usam esse fuso |
| Jornada | Por usuário (almoço, jornada, antecedências, janela opcional); mensagens formatadas a partir dela |
| Ativação | Um único caso de uso para Telegram e painel; exige credencial validada |
| Contas do painel | Várias contas com papéis `owner`, `admin` e `viewer`, inclusive leitura restrita a um usuário (9.6) |
| Implantação | Docker Compose numa máquina Debian, atrás do Nginx Proxy Manager da rede (11) |
## 3. Visão de containers
@@ -110,7 +104,7 @@ backend/
│ ├── adapters/
│ │ ├── postgres/ # repositório (SQL escrito à mão com pgx) e migrations
│ │ ├── pontomais/ # cliente HTTP + cache de token em memória
│ │ ├── telegram/ # cliente próprio da Bot API (portado do Modelo)
│ │ ├── telegram/ # cliente próprio da Bot API
│ │ ├── feriados/ # cliente feriadosapi.com (endpoint por cidade)
│ │ └── crypto/ # AES-GCM, Argon2id, tokens
│ ├── httpapi/ # API REST: router, handlers JSON, DTOs
@@ -148,12 +142,12 @@ Mesmo que alguém suba duas réplicas do `worker` por engano, o processo pega um
| Driver e pool | `jackc/pgx/v5` (`pgxpool`) |
| Queries | SQL escrito à mão com `pgx` (ver decisão 27) |
| Migrations | `pressly/goose` (migrations em SQL embutidas no binário) |
| HTTP | `net/http` com o roteador nativo do Go 1.22+ (como no Modelo), sem framework |
| HTTP | `net/http` com o roteador nativo do Go, sem framework |
| Logs | `log/slog` em JSON |
| Validação | validação manual nos DTOs (sem framework) |
| Telegram | cliente próprio, portado do Modelo (ver decisão 27) |
| Telegram | cliente próprio, sem biblioteca externa (ver decisão 27) |
| Criptografia e terminal | `golang.org/x/crypto` (Argon2id), `golang.org/x/term` (senha sem eco na CLI) |
| Testes de integração | previstos com `testcontainers-go` e Postgres real; **ainda não existem** (ver 13) |
| Testes de integração | não há; o plano é `testcontainers-go` com Postgres real (13) |
## 5. Frontend Next.js
@@ -195,18 +189,18 @@ web/
- **Server Components** buscam dados chamando `http://api:8080/api/v1/...` e repassam o cookie de sessão recebido.
- **Mutações** usam `fetch('/api/v1/...')` no cliente (`lib/client-api.ts`), sem *Server Actions*. O `rewrite` do Next encaminha essas chamadas para o Go **na mesma origem**, então não há CORS e o cookie funciona direto.
- Os **tipos** ficam em `web/lib/types.ts`, escritos à mão a partir do `openapi.yaml` e de `httpapi/dto.go`. A geração automática (`openapi-typescript`) pode ser adotada depois (decisão 27).
- Os **tipos** ficam em `web/lib/types.ts`, escritos à mão a partir do `openapi.yaml` e de `httpapi/dto.go` (decisão 27).
- **UI:** CSS próprio (`app/globals.css`), com tema claro/escuro e layout responsivo; filtros por query string nas tabelas (decisão 27).
### 5.4 Autenticação
A autenticação continua **no Go**, porque a lógica já existe e fica centralizada:
A autenticação fica **no Go**, centralizada:
1. `POST /api/v1/auth/login` valida a senha com Argon2id, aplica o bloqueio por tentativas e cria a sessão no Postgres, guardando só o hash do token.
2. O Go responde com o cookie `bp_session` (`HttpOnly`, `Secure`, `SameSite=Strict`, `Path=/`).
3. **CSRF:** é usado *double submit*. O cookie `bp_csrf` (legível pelo JS) precisa ser repetido no header `X-CSRF-Token` em toda requisição mutável. Além disso, o Go confere o header `Origin`.
4. O `middleware.ts` do Next só verifica se o cookie existe, para redirecionar. **Quem autoriza de verdade é sempre o Go.**
5. Os timeouts continuam os do Modelo: sessão absoluta de 8h e 30 min de inatividade.
5. Sessão absoluta de 8h e 30 min de inatividade (`BP_SESSION_DURATION`, `BP_SESSION_IDLE`). Após 5 senhas erradas seguidas, a conta fica bloqueada por 15 min.
## 6. Banco de dados (PostgreSQL)
@@ -216,11 +210,11 @@ A autenticação continua **no Go**, porque a lógica já existe e fica centrali
- Instantes em `timestamptz` (gravados em UTC); dia de referência em `date`.
- Estados em tipos `enum` do Postgres.
- `created_at` e `updated_at`, com trigger para atualizar `updated_at`.
- Migrations só para a frente, numeradas e em SQL.
- Migrations numeradas, em SQL (`goose`), embutidas no binário e aplicadas pelo serviço `migrate`.
### 6.2 Esquema atual
### 6.2 Esquema
Resumo do resultado das migrations `00001` a `00003` (`backend/db/migrations/`). Triggers de `updated_at`, índices secundários e permissões ficam nos arquivos SQL.
Resumo do esquema resultante das migrations em `backend/db/migrations/`. Triggers de `updated_at`, índices secundários e permissões ficam nos arquivos SQL.
```sql
CREATE TYPE user_status AS ENUM ('pending', 'active', 'disabled');
@@ -244,7 +238,7 @@ CREATE TABLE users (
updated_at timestamptz NOT NULL DEFAULT now()
);
-- Regras de jornada separadas do cadastro (permite histórico/versão no futuro)
-- Regras de jornada do usuário
CREATE TABLE work_schedules (
user_id bigint PRIMARY KEY REFERENCES users(id),
lunch_minutes int NOT NULL DEFAULT 60 CHECK (lunch_minutes BETWEEN 15 AND 180),
@@ -310,7 +304,7 @@ CREATE TABLE web_accounts (
CREATE TABLE web_sessions (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
account_id bigint NOT NULL REFERENCES web_accounts(id) ON DELETE CASCADE, -- 00003
account_id bigint NOT NULL REFERENCES web_accounts(id) ON DELETE CASCADE,
token_hash bytea NOT NULL UNIQUE,
csrf_token_hash bytea NOT NULL,
expires_at timestamptz NOT NULL,
@@ -321,10 +315,10 @@ CREATE TABLE web_sessions (
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE holidays ( -- cache persistente da API de feriados
CREATE TABLE holidays ( -- feriados da API e manuais
day date PRIMARY KEY,
name text NOT NULL,
source text NOT NULL CHECK (source IN ('feriadosapi', 'feriados.dev', 'manual')), -- 00002; feriados.dev = legado
source text NOT NULL CHECK (source IN ('feriadosapi', 'feriados.dev', 'manual')), -- feriados.dev: só registros antigos
created_at timestamptz NOT NULL DEFAULT now()
);
@@ -348,7 +342,7 @@ CREATE INDEX user_days_next_check_idx ON user_days (next_check_at) WHERE next_ch
CREATE TABLE app_settings (
key text PRIMARY KEY, -- ex.: 'query_window_start'
value jsonb NOT NULL, -- ex.: "11:30"
updated_by_account bigint REFERENCES web_accounts(id) ON DELETE SET NULL, -- 00003
updated_by_account bigint REFERENCES web_accounts(id) ON DELETE SET NULL,
updated_at timestamptz NOT NULL DEFAULT now()
);
@@ -364,8 +358,8 @@ CREATE TABLE audit_logs (
occurred_at timestamptz NOT NULL DEFAULT now(),
source audit_source NOT NULL,
actor_user_id bigint REFERENCES users(id),
actor_account bigint REFERENCES web_accounts(id) ON DELETE SET NULL, -- 00003
actor_account_name text, -- 00003: nome da conta no momento do evento
actor_account bigint REFERENCES web_accounts(id) ON DELETE SET NULL,
actor_account_name text, -- nome da conta no momento do evento (sobrevive à exclusão)
target_user_id bigint REFERENCES users(id),
event_type text NOT NULL,
success boolean NOT NULL,
@@ -375,12 +369,12 @@ CREATE INDEX audit_logs_time_idx ON audit_logs (occurred_at DESC);
CREATE INDEX audit_logs_target_idx ON audit_logs (target_user_id, occurred_at DESC);
```
Diferenças importantes em relação ao Modelo:
Notas sobre o esquema:
- A **credencial** e a **jornada** saem de `users` e ganham tabelas próprias. Isso deixa o cadastro mais limpo e permite rotacionar a chave de criptografia (`key_version`).
- **Credencial** e **jornada** ficam em tabelas próprias, separadas do cadastro. A senha da PontoMais é cifrada com AES-256-GCM, e `key_version` permite trocar a chave (`botpontos admin rotate-key`).
- A **antecedência** pode ser diferente para cada lembrete (`entrada2_lead_minutes` / `saida2_lead_minutes`).
- A tabela `holidays` guarda feriados confirmados e também **feriados manuais** cadastrados pelo painel. A ordem de consulta está na seção 9.9.
- `audit_logs.details` passa a ser `jsonb`, o que permite filtrar no painel.
- A tabela `holidays` guarda os feriados vindos da API e os **feriados manuais** cadastrados pelo painel. A ordem de consulta está na seção 9.9.
- `audit_logs.details` é `jsonb`, sem segredos.
- `notifications.predicted_at` guarda o horário previsto do ponto, para a mensagem sempre informar a hora certa (9.3).
- O banco tem dois usuários: `botpontos` (dono, usado pelas migrations e pelo backup) e `botpontos_app` (API e worker, só `SELECT/INSERT/UPDATE/DELETE`), criado por `postgres/init/01-create-app-role.sh`.
@@ -407,7 +401,7 @@ FROM due WHERE n.id = due.id
RETURNING n.*;
```
As duas etapas rodam a cada **2 min** (ver 8.2), sempre nessa ordem, então nada com mais de 10 min de atraso chega a ser reservado. Na etapa 2, a tolerância é conferida de novo no Go, logo antes do envio, para cobrir o tempo entre a reserva e a chamada ao Telegram. Depois do `COMMIT`, o worker envia cada mensagem e marca o resultado (`sent` ou `failed`). Um job de limpeza marca como `failed` toda linha que esteja em `sending` há mais de **5 minutos** (`BP_SENDING_TIMEOUT`), que é o caso de *crash* no meio do envio. **Ela não volta para `scheduled`**, para manter a garantia do Modelo de "no máximo uma mensagem".
As duas etapas rodam a cada **2 min** (ver 8.2), sempre nessa ordem, então nada com mais de 10 min de atraso chega a ser reservado. Na etapa 2, a tolerância é conferida de novo no Go, logo antes do envio, para cobrir o tempo entre a reserva e a chamada ao Telegram. Depois do `COMMIT`, o worker envia cada mensagem e marca o resultado (`sent` ou `failed`). Um job de limpeza marca como `failed` toda linha que esteja em `sending` há mais de **5 minutos** (`BP_SENDING_TIMEOUT`), que é o caso de *crash* no meio do envio. **Ela não volta para `scheduled`**, para garantir "no máximo uma mensagem" por lembrete.
### 6.4 Pool e conexões
@@ -449,7 +443,7 @@ Todas as rotas ficam sob `/api/v1`, usam JSON e exigem sessão, exceto `auth/log
| GET/POST | `/accounts` | owner | Lista e cria contas web |
| PATCH | `/accounts/{id}` | owner | Papel, desativação e usuário ligado (`user_id`) |
| DELETE | `/accounts/{id}` | owner | Exclui a conta (não a própria nem o último owner ativo); auditado em `web_account_deleted` |
| POST | `/accounts/{id}/reset-password` | owner | Resolve a pendência "sem redefinição de senha" |
| POST | `/accounts/{id}/reset-password` | owner | Define nova senha e encerra as sessões da conta |
As rotas marcadas como `viewer` têm restrições extras para a conta de leitura ligada a um usuário (tabela em 9.6). Erros seguem o formato `application/problem+json` (RFC 9457):
@@ -491,7 +485,7 @@ Os usuários são processados com concorrência limitada (*worker pool* de 4, `B
**Regras de implementação:**
- Cada ciclo roda na **sua própria goroutine**, com ticker próprio. No Modelo (`scheduler/scheduler.go`), reconciliação e despacho dividem o mesmo `select`, então uma reconciliação lenta segura todos os envios.
- Cada ciclo roda na **sua própria goroutine**, com ticker próprio, então uma reconciliação lenta não atrasa os envios.
- A reconciliação tem **prazo total** (*deadline*) de 5 min por ciclo (`BP_RECONCILIATION_DEADLINE`) e timeout de 10 s por chamada à PontoMais (`BP_PONTOMAIS_TIMEOUT`). Usuários que não terminarem dentro do prazo ficam para o próximo ciclo.
- Se um ciclo ainda estiver rodando quando o próximo tick chegar, o tick é ignorado e não se acumula.
- Os ticks são alinhados ao relógio (ex.: :00, :02, :04...) para o comportamento ser previsível nos logs.
@@ -510,28 +504,26 @@ Os usuários são processados com concorrência limitada (*worker pool* de 4, `B
### 8.3 Fluxo de um comando do Telegram
`Update → bot.Router → autorização → app.CasoDeUso → resposta formatada`. O mesmo caso de uso atende o handler HTTP, e é isso que elimina a divergência de ativação entre Telegram e web que existe no Modelo.
`Update → bot.Router → autorização → app.CasoDeUso → resposta formatada`. O mesmo caso de uso atende o handler HTTP, então Telegram e painel aplicam as mesmas regras (ex.: ativação só com credencial validada). O bot só responde em conversa privada; mensagens de grupos são ignoradas.
### 8.4 Comandos do Telegram
São os mesmos comandos do Modelo, com as correções da seção 9.2.
**Usuário**
| Comando | Quem pode | Comportamento na v2 |
| Comando | Quem pode | Comportamento |
| --- | --- | --- |
| `/start`, `/iniciar` | qualquer chat | Chat desconhecido recebe orientação para usar `/cadastrar`; usuário conhecido recebe a ajuda do seu papel |
| `/cadastrar` | qualquer chat | Cria o cadastro `pending` (idempotente por `chat_id`) e avisa os admins |
| `/ajuda` | cadastrado | Lista os comandos do papel |
| `/status` | cadastrado | Cadastro, credencial (configurada/validada, sem dados), lembretes ligados ou pausados e motivo da pausa (manual, 3 falhas na API), a situação do dia (ex.: "hoje: nenhum ponto até 11:45, consultas encerradas") e a janela de consulta que vale para o usuário (indicando se é a global ou personalizada) |
| `/entrada2` | ativo + credencial | Consulta ao vivo e informa o retorno previsto (≥ 2 pontos), no fuso do usuário. Funciona a qualquer hora, fora da janela também; se encontrar pontos num dia `no_points`/`not_working`, retoma o dia (9.12) |
| `/saida2` | ativo + credencial | Consulta ao vivo e informa a saída prevista (≥ 3 pontos). O texto usa a jornada configurada (ex.: "Para completar 6h00"), não um "8h00" fixo |
| `/saida2` | ativo + credencial | Consulta ao vivo e informa a saída prevista (≥ 3 pontos). O texto usa a jornada configurada do usuário (ex.: "Para completar 6h00") |
| `/ativarponto` | ativo + credencial validada | Liga os lembretes, zera o contador de falhas e, se o dia estiver `no_points`/`not_working`, **retoma as consultas de hoje** (9.12) |
| `/desativarponto` | ativo | Pausa os lembretes e cancela os `scheduled` do dia |
**Admin do Telegram** (`is_bot_admin = true` e status `active`)
| Comando | Comportamento na v2 |
| Comando | Comportamento |
| --- | --- |
| `/usuarios` | Lista usuários com ID, status, credencial e lembretes |
| `/ativarusuario <id>` | Usa o mesmo caso de uso `ActivateUser` do painel: **recusa se o usuário não tiver credencial validada** e orienta a configurá-la pelo painel |
@@ -542,30 +534,27 @@ São os mesmos comandos do Modelo, com as correções da seção 9.2.
## 9. Regras de negócio
### 9.1 Mantidas do Modelo
### 9.1 Cálculo dos horários
- Quatro pontos normais: `entrada1`, `saida1`, `entrada2`, `saida2`.
- Quatro pontos normais por dia: `entrada1`, `saida1`, `entrada2`, `saida2`, na ordem em que aparecem na PontoMais.
- `entrada2 = saida1 + almoço` e `saida2 = entrada2 + (jornada − (saida1 − entrada1))`.
- O lembrete sai `antecedência` minutos antes do horário previsto (`entrada2_lead_minutes` / `saida2_lead_minutes`, 5 min por padrão).
- No máximo um lembrete por `(usuário, dia, tipo)`.
- Mais de 4 pontos conta como hora extra e fica fora do escopo.
- Mais de 4 pontos conta como hora extra e fica fora do escopo (9.7).
- Notificação `failed` não é reenviada **automaticamente** (o reenvio manual está na seção 9.5).
- Nada é registrado na PontoMais.
### 9.2 Corrigidas
### 9.2 Regras gerais
| Problema no Modelo | Correção na v2 |
| Regra | Como é aplicada |
| --- | --- |
| Fuso do usuário ignorado | O cálculo do dia e dos horários usa `users.timezone` |
| Texto "8h00" fixo em `/saida2` | A mensagem formata `daily_work_minutes` |
| Ativação diferente entre Telegram e web | Um caso de uso `ActivateUser`; regra única: **exige credencial validada** |
| Sem redefinição de senha web | `POST /accounts/{id}/reset-password` + revogação das sessões |
| Uma só conta web | Várias contas com papéis |
| Retorno atrasado do almoço pausa os lembretes do usuário | Ver o detalhe abaixo |
| Reconciliação lenta bloqueia o despacho (mesmo loop) | Goroutines independentes e despacho a cada 2 min (ver 8.2) |
| Fuso do usuário | O dia de referência e todos os horários são calculados em `users.timezone` |
| Jornada nas mensagens | O texto usa `daily_work_minutes` do usuário (ex.: "jornada de 6h00") |
| Ativação | Um caso de uso `ActivateUser` para Telegram e painel; **exige credencial validada** |
| Atraso no almoço | Um lembrete calculado para um horário que já passou segue a regra de 9.3; **o usuário nunca é pausado por atraso** |
| Ciclos | Reconciliação e despacho independentes; uma reconciliação lenta não segura os envios (8.2) |
| Contas do painel | Várias contas com papéis; redefinir a senha encerra as sessões da conta (9.6) |
**Detalhe do último item.** No Modelo (`scheduler/reconciliation.go`), com 2 pontos, se o horário do lembrete já passou (`scheduledAt.Before(now)`), o usuário tem os lembretes do dia cancelados **e as notificações desativadas**. Isso acontece sempre que alguém fica mais tempo no almoço do que o previsto menos a antecedência e a reconciliação roda nesse intervalo. Na v2 vale a regra de lembrete atrasado da seção 9.3, e **o usuário nunca é pausado por atraso**.
### 9.3 Lembrete atrasado (decidido)
### 9.3 Lembrete atrasado
**Regra:** se o lembrete atrasar **até 10 min** em relação ao `scheduled_at`, ele é enviado. **Acima de 10 min, não é enviado**: fica com status `expired` e é registrado na auditoria (`event_type = 'notification_expired'`, com o atraso em minutos em `details`). O usuário **não** é pausado.
@@ -585,18 +574,18 @@ A regra vale nos dois pontos em que o atraso pode surgir:
- O atraso é medido em relação ao `scheduled_at` (horário planejado do aviso), não ao horário previsto do ponto. Com 5 min de antecedência, o limite significa que o aviso pode chegar até 5 min **depois** do horário previsto de voltar ou sair.
- Por isso, **toda mensagem informa o horário previsto**, por exemplo "Seu retorno do almoço está previsto para 13:02". Se o aviso chegar atrasado, o usuário ainda sabe a hora exata. Quando já passou do horário previsto, o texto muda para "Seu retorno estava previsto para 13:02".
- Se o ponto seguinte já tiver sido batido (ex.: `entrada2` registrada), a reconciliação cancela o lembrete antes do despacho, como no Modelo.
- Se o ponto seguinte já tiver sido batido (ex.: `entrada2` registrada), a reconciliação cancela o lembrete antes do despacho.
- O painel mostra `expired` como um status próprio, separado de `failed`, para diferenciar "chegou tarde demais" de "erro no envio".
### 9.4 Falha na PontoMais (decidido)
### 9.4 Falha na PontoMais
**Regra:** os lembretes do usuário só são pausados depois de **3 falhas seguidas** na consulta à PontoMais. No Modelo, a pausa acontece na primeira falha.
**Regra:** os lembretes do usuário só são pausados depois de **3 falhas seguidas** na consulta à PontoMais.
O limite fica na variável `BP_API_FAILURE_THRESHOLD`, com 3 de padrão.
**O que conta como uma falha:**
- Conta **uma falha por ciclo de reconciliação**, por usuário. Dentro do mesmo ciclo o cliente faz **1 nova tentativa rápida** (após ~3 s) e, no caso de 401/403, refaz o login uma vez, como no Modelo. Só se tudo isso falhar o ciclo conta como falha.
- Conta **uma falha por ciclo de reconciliação**, por usuário. Dentro do mesmo ciclo o cliente faz **1 nova tentativa rápida** (após ~3 s) e, no caso de 401/403, refaz o login uma vez. Só se tudo isso falhar o ciclo conta como falha.
- Contam: timeout, erro de rede, HTTP 5xx, 401/403 que persiste após o novo login e resposta impossível de interpretar.
- **Não** contam: histórico com pontos inválidos ou fora de ordem. A API respondeu, então é um problema de dado, que só é auditado.
@@ -615,7 +604,7 @@ O limite fica na variável `BP_API_FAILURE_THRESHOLD`, com 3 de padrão.
| Falha 1 e 2 | Incrementa `consecutive_failures`, grava `last_error` e `last_failure_at`, audita (`point_api_failure`). Usuário não é avisado. |
| Falha 3 | `notifications_enabled = false`, audita (`notifications_paused_api_failure`), avisa o usuário pelo Telegram para conferir a credencial e depois usar `/ativarponto`, e avisa o(s) admin(s). |
| Consulta com sucesso | Zera `consecutive_failures`. |
| Lembretes já agendados | São **mantidos** durante as falhas e após a pausa, porque foram calculados com dados válidos (mesmo comportamento do Modelo). |
| Lembretes já agendados | São **mantidos** durante as falhas e após a pausa, porque foram calculados com dados válidos. |
| `/ativarponto` ou reativação pelo painel | Zera o contador. |
**Queda geral da PontoMais:**
@@ -635,9 +624,9 @@ Se **todos** os usuários consultados num ciclo falharem, o problema é tratado
**Efeito colateral a considerar:** enquanto as falhas duram (até ~30 min), pontos novos não são detectados. Por exemplo, um `saida1` batido nesse intervalo só gera o lembrete de `entrada2` quando a API voltar. Pela regra 9.3, ele pode virar `expired`.
### 9.5 Reenvio manual de lembrete com falha (decidido)
### 9.5 Reenvio manual de lembrete com falha
Um lembrete `failed` pode ser reenviado **manualmente** pelo painel, num botão com confirmação. O reenvio automático continua não existindo.
Um lembrete `failed` pode ser reenviado **manualmente** pelo painel, num botão com confirmação. Não existe reenvio automático.
| Regra | Detalhe |
| --- | --- |
@@ -650,7 +639,7 @@ Um lembrete `failed` pode ser reenviado **manualmente** pelo painel, num botão
| Resultado | `sent` ou `failed` de novo. Pode ser repetido enquanto as pré-condições valerem. |
| Auditoria | `notification_resent`, com a conta web que acionou e o resultado. |
### 9.6 Papéis do painel (decidido)
### 9.6 Papéis do painel
O painel é para administradores e contas de leitura. **O usuário comum usa o Telegram**; se precisar ver os próprios dados no painel, o owner cria para ele uma conta de leitura ligada ao seu usuário (abaixo).
@@ -665,11 +654,11 @@ O painel é para administradores e contas de leitura. **O usuário comum usa o T
| Definir o admin do bot no Telegram | | ✓ | ✓ |
| Criar/desativar/excluir contas web, alterar papéis e vínculo com usuário, redefinir senhas | | | ✓ |
- **Conta de leitura ligada a um usuário** (`viewer` com `web_accounts.user_id`): fica restrita ao próprio usuário. Vê Início (avisos do sistema e os próprios lembretes de hoje, sem links), Usuários (só a própria ficha), Lembretes (só os próprios, somente leitura) e Feriados (somente leitura). Pode alterar jornada, credencial PontoMais, testar a API e ligar/pausar os próprios lembretes. Não vê Auditoria, Configurações nem Contas e não ativa, desativa nem promove usuários. A regra é aplicada na API (`authUser`, `authUnrestricted`, `isRestricted`), não só no menu. Um usuário só pode estar ligado a uma conta. Leitura **sem** vínculo continua vendo tudo, sem editar.
- **Conta de leitura ligada a um usuário** (`viewer` com `web_accounts.user_id`): fica restrita ao próprio usuário. Vê Início (avisos do sistema e os próprios lembretes de hoje, sem links), Usuários (só a própria ficha), Lembretes (só os próprios, somente leitura) e Feriados (somente leitura). Pode alterar jornada, credencial PontoMais, testar a API e ligar/pausar os próprios lembretes. Não vê Auditoria, Configurações nem Contas e não ativa, desativa nem promove usuários. A regra é aplicada na API (`authUser`, `authUnrestricted`, `isRestricted`), não só no menu. Um usuário só pode estar ligado a uma conta. Leitura **sem** vínculo vê tudo (menos Contas), sem editar.
- A primeira conta `owner` é criada pela CLI (`botpontos admin create-owner`).
- Precisa existir sempre pelo menos um `owner` ativo. A API recusa rebaixar, desativar ou excluir o último.
- Ninguém pode desativar nem excluir a própria conta (a API responde `409`).
- **Excluir conta** (`DELETE /accounts/{id}`, resposta `204`) apaga a linha de `web_accounts`. A migration `00003` faz as sessões irem junto (`ON DELETE CASCADE`) e deixa `audit_logs.actor_account` e `app_settings.updated_by_account` como `NULL`; o nome da conta fica em `audit_logs.actor_account_name`, gravado em cada evento, então a auditoria continua mostrando quem fez.
- **Excluir conta** (`DELETE /accounts/{id}`, resposta `204`) apaga a linha de `web_accounts`. As sessões vão junto (`ON DELETE CASCADE`), e `audit_logs.actor_account` e `app_settings.updated_by_account` ficam `NULL`. Como o nome da conta é gravado em `audit_logs.actor_account_name` em cada evento, a auditoria continua mostrando quem fez. Desativar é a alternativa reversível.
- O vínculo entre conta web e usuário do Telegram (`web_accounts.user_id`) é **opcional**; o owner define e troca em Contas. O admin do Telegram (`is_bot_admin`) e as contas do painel são independentes.
#### Conta de leitura restrita (como a API aplica)
@@ -693,16 +682,15 @@ Vínculo:
- `POST /accounts` e `PATCH /accounts/{id}` aceitam `user_id`. No `PATCH`, campo ausente mantém o vínculo, `null` remove e um número troca.
- O usuário precisa existir e não pode estar ligado a outra conta (`422` no campo `user_id`). A troca é auditada em `web_account_changed`, com o usuário no alvo.
- O papel e o vínculo são lidos da conta a cada requisição, então valem na hora, sem novo login.
- Não há migration: usa `web_accounts.user_id`, que já existia.
- Leitura **sem** vínculo não é restrita: mantém o comportamento anterior (vê tudo, não edita).
- Leitura **sem** vínculo não é restrita: vê tudo (menos Contas) e não edita.
No painel, `web/lib/access.ts` repete a regra só para esconder menu e botões; a segurança está na API.
### 9.7 Fora do escopo (decidido)
### 9.7 Fora do escopo
- **Hora extra:** com mais de 4 pontos, nenhum lembrete é criado e não há cálculo de saldo ou banco de horas.
- **Lembretes adicionais** (ex.: `entrada1` não detectada, almoço não iniciado): não entram por enquanto. O `notification_type` é um enum, então novos tipos podem ser adicionados depois por migration.
- **Acesso do usuário comum ao painel.**
- **Lembretes adicionais** (ex.: `entrada1` não detectada, almoço não iniciado). O `notification_type` é um enum; novos tipos exigiriam uma migration (14).
- **Acesso do usuário comum ao painel**, exceto pela conta de leitura ligada a ele, criada pelo owner (9.6).
- **Registro de ponto na PontoMais.**
### 9.8 Validação da jornada
@@ -727,19 +715,19 @@ A cada ciclo, o worker decide se o dia é útil nesta ordem:
1. **Fim de semana** (no fuso do usuário) não é dia útil.
2. **Tabela `holidays`:** se a data estiver lá (vinda da API ou cadastrada manualmente), não é dia útil.
3. **Sincronização anual:** no primeiro uso de cada ano (e uma vez por dia enquanto não der certo), o worker baixa os feriados da feriadosapi.com (endpoint por cidade) para a cidade configurada (`BP_HOLIDAY_LOCATION`, padrão `3106200` (código IBGE)) e grava em `holidays`.
4. **Fallback:** se o ano ainda não foi sincronizado e a API falhar, segunda a sexta contam como dias úteis (como no Modelo). Os admins são **avisados uma vez por dia** enquanto isso durar, e o evento é auditado (`holiday_sync_failed`).
3. **Sincronização anual:** no primeiro uso de cada ano (e uma vez por dia enquanto não der certo), o worker baixa os feriados da feriadosapi.com para a cidade configurada (`BP_HOLIDAY_LOCATION`, código IBGE; padrão `3106200`, Belo Horizonte) e grava em `holidays`.
4. **Fallback:** se o ano ainda não foi sincronizado e a API falhar, segunda a sexta contam como dias úteis. Os admins são **avisados uma vez por dia** enquanto isso durar, e o evento é auditado (`holiday_sync_failed`).
A sincronização nunca apaga feriados manuais nem sobrescreve uma data que já esteja na tabela.
**API usada (feriadosapi.com):**
- Chamada: `GET {BP_HOLIDAY_API_BASE_URL}/feriados/cidade/{ibge}?ano=AAAA`, com `Authorization: Bearer <chave>`. Padrão da base: `https://feriadosapi.com/api/v1`; chave em `secrets/holiday_api_key`.
- `BP_HOLIDAY_LOCATION` é o **código IBGE** da cidade (Belo Horizonte: `3106200`). Um valor não numérico, como o antigo `MG-belo-horizonte`, faz a sincronização falhar com mensagem explicativa e o fallback de segunda a sexta entra em vigor.
- `BP_HOLIDAY_LOCATION` é o **código IBGE** da cidade (Belo Horizonte: `3106200`). Um valor não numérico faz a sincronização falhar com mensagem explicativa, e o fallback de segunda a sexta entra em vigor.
- A resposta traz os feriados **nacionais e municipais** da cidade. O cliente lê `feriados[].data` (formato `dd/mm/aaaa`, convertido para `AAAA-MM-DD`) e `feriados[].nome`; ignora datas de outro ano ou ilegíveis e falha se nenhuma data válida vier.
- Carnaval e outros pontos facultativos que a API não lista devem ser cadastrados como feriado manual.
- Quantas chamadas: uma por ano, na primeira verificação de dia útil sem feriados do ano no banco (`HasSyncedHolidays` considera qualquer origem diferente de `manual`, inclusive a `feriados.dev` legada). Em falha, no máximo uma tentativa por dia por processo. O botão **Sincronizar** do painel chama a API a cada clique.
- Origem gravada em `holidays.source`: `feriadosapi`. Linhas antigas `feriados.dev` continuam válidas (migration `00002`, que também libera o novo valor no `CHECK`).
- Quantas chamadas: uma por ano, na primeira verificação de dia útil sem feriados do ano no banco (`HasSyncedHolidays` considera qualquer origem diferente de `manual`). Em falha, no máximo uma tentativa por dia por processo. O botão **Sincronizar** do painel chama a API a cada clique.
- Origem gravada em `holidays.source`: `feriadosapi` (ou `manual` para os cadastrados no painel).
### 9.10 Retenção de dados
@@ -794,15 +782,15 @@ Em vez de consultar todos os usuários em todo ciclo, cada usuário tem **sua pr
| Situação do dia | Pontos | Próxima consulta | Por quê |
| --- | --- | --- | --- |
| `awaiting` | 0 (antes da confirmação) | próximo tick (15 min) | Fazer as duas verificações (11:30 e 11:45) |
| `no_points` | 0 (após a confirmação) | **a cada 60 min** até 15:00 (12:45, 13:45, 14:45) | **Ajuste A:** não abandonar o dia cedo demais |
| `no_points` | 0 (após a confirmação) | **a cada 60 min** até 15:00 (12:45, 13:45, 14:45) | Não abandonar o dia cedo demais |
| `working` | 1 ou 2 | próximo tick (15 min) | Detectar a saída e a volta do almoço sem atraso |
| `working` | 3 | **a cada 60 min**, mais **1 consulta de confirmação** antes do lembrete de `saida2` | **Ajuste C:** à tarde, só interessa saber se a pessoa saiu antes |
| `working` | 3 | **a cada 60 min**, mais **1 consulta de confirmação** antes do lembrete de `saida2` | À tarde, só interessa saber se a pessoa saiu antes |
| `finished` | 4 | nenhuma (até amanhã) | Dia fechado |
| `not_working` | 0 até 15:00 | nenhuma (até amanhã) | Dia sem trabalho confirmado |
Uma consulta que **falha** não muda a situação. O usuário volta a ser consultado no próximo tick, e a falha conta conforme 9.4.
#### Ajuste A: dia sem pontos
#### Dia sem pontos
```text
11:30 0 pontos → awaiting (1ª verificação, não decide nada)
@@ -816,10 +804,10 @@ Uma consulta que **falha** não muda a situação. O usuário volta a ser consul
- **Só resposta bem-sucedida conta** como "0 pontos". Se a API falhar, a situação não avança.
- Se um ponto aparecer em qualquer reverificação, o dia vira `working` e volta para consultas de 15 em 15 min.
- **Ajuda quando:** o cache da PontoMais (`last_cached`) demora a mostrar o ponto; a pessoa começa a trabalhar mais tarde (consulta médica); um ajuste de ponto é aprovado durante o dia.
- **Limite honesto:** se a pessoa **esqueceu de bater a entrada** e não houve ajuste, o primeiro ponto que aparece é a saída para o almoço, mas o sistema o interpreta como `entrada1`. A ordem dos pontos fica errada e o bot não tem como calcular os lembretes corretamente. Isso continua fora do alcance da aplicação, como no Modelo.
- **Limite honesto:** se a pessoa **esqueceu de bater a entrada** e não houve ajuste, o primeiro ponto que aparece é a saída para o almoço, mas o sistema o interpreta como `entrada1`. A ordem dos pontos fica errada e o bot não tem como calcular os lembretes corretamente. Isso está fora do alcance da aplicação.
- O usuário **não recebe mensagem** sobre `no_points`/`not_working`, para não gerar aviso diário em férias e folgas. A situação aparece no `/status`, no painel e na auditoria (`day_no_points`, `day_not_worked`).
#### Ajuste C: tarde com 3 pontos
#### Tarde com 3 pontos
Com `entrada2` detectada e o lembrete de `saida2` agendado para o horário **T**, as consultas só servem para descobrir se a pessoa saiu **antes** do previsto (4º ponto) e então cancelar o lembrete:
@@ -862,9 +850,9 @@ Com `entrada2` detectada e o lembrete de `saida2` agendado para o horário **T**
| Cenário | Consultas/usuário | Total/dia |
| --- | --- | --- |
| Dia inteiro (Modelo) | 96 | ~1.920 |
| Sem janela (a cada 15 min o dia todo) | 96 | ~1.920 |
| Só a janela 11:30–19:00 | até 31 | ~620 |
| Janela + frequência adaptativa (A + C), dia típico | ~11 | **~220** |
| Janela + frequência adaptativa, dia típico | ~11 | **~220** |
| Usuário de férias/folga | 6 (11:30, 11:45, 12:45, 13:45, 14:45) → depois 0 | — |
### 9.13 Configurações globais pelo painel
@@ -887,7 +875,7 @@ Os quatro horários de 9.12 (início, confirmação, fim das reverificações e
| `confirmação ≥ início + 15 min` | Garante as duas verificações (9.12) |
| `fim − início ≥ 4 h` | Uma janela curta demais deixaria de detectar retorno do almoço e saída |
**Pré-visualização:** antes de salvar, a tela mostra o efeito estimado. Por exemplo: quantas consultas por dia a nova janela gera e quantos usuários, nos últimos 30 dias, bateram `saida1` antes do novo início e teriam lembretes atrasados ou `expired`. O horário de `saida1` é deduzido dos lembretes de `entrada2` (`scheduled_at − almoço + antecedência`), já que a v2 não guarda os pontos. A pré-visualização também informa **quantos usuários têm janela personalizada** e, portanto, não serão afetados.
**Pré-visualização:** antes de salvar, a tela mostra o efeito estimado. Por exemplo: quantas consultas por dia a nova janela gera e quantos usuários, nos últimos 30 dias, bateram `saida1` antes do novo início e teriam lembretes atrasados ou `expired`. O horário de `saida1` é deduzido dos lembretes de `entrada2` (`scheduled_at − almoço + antecedência`), já que o sistema não guarda os pontos. A pré-visualização também informa **quantos usuários têm janela personalizada** e, portanto, não serão afetados.
**Quando passa a valer:** o worker lê `app_settings` no começo de cada ciclo, então a mudança vale **a partir do próximo ciclo**, sem reiniciar nada. Dias já marcados como `not_working` hoje continuam assim (podem ser retomados como em 9.12); os `next_check_at` já calculados são recalculados no próximo tick.
@@ -973,13 +961,13 @@ A `api` tem saída para a internet porque a validação de credenciais na PontoM
- `pg_dump -Fc` diário, com retenção de 14 dias, num volume montado.
- Restauração documentada com `pg_restore` num container temporário.
- **A chave de criptografia precisa ter backup separado.** Sem ela, as senhas da PontoMais não podem ser recuperadas, como já acontece no Modelo.
- **A chave de criptografia precisa ter backup separado.** Sem ela, as senhas da PontoMais não podem ser recuperadas.
### 11.4 Proxy reverso: Nginx Proxy Manager existente
A rede onde o projeto vai rodar já tem um **Nginx Proxy Manager (NPM)**. Ele faz o papel de proxy HTTPS, então o projeto **não** inclui Caddy nem Traefik.
A rede onde o projeto roda já tem um **Nginx Proxy Manager (NPM)**. Ele faz o papel de proxy HTTPS, então o projeto **não** inclui Caddy nem Traefik.
**Topologia (decidida):** o NPM roda em **outra máquina da mesma rede**.
**Topologia:** o NPM roda em **outra máquina da mesma rede**.
```text
Navegador ──HTTPS──► NPM (outra máquina) ──HTTP :3000──► servidor Docker: web ──► api ──► postgres
@@ -1023,9 +1011,9 @@ Ativação: `systemctl daemon-reload && systemctl enable --now botpontos-firewal
| Websockets Support | pode ficar desligado (o painel não usa) |
| SSL Certificate | **Custom certificate**: o certificado interno da rede, emitido pela CA interna para o nome do painel (ver abaixo) |
| Force SSL / HTTP/2 / HSTS | ligados |
| Access List | restringir à rede local (ex.: `192.168.18.0/24`), substituindo o filtro de CIDR que o Modelo fazia no Go |
| Access List | restringir à rede local (ex.: `192.168.18.0/24`) |
**Certificado interno (decidido)**
**Certificado interno**
O HTTPS do painel usa um **certificado emitido pela CA interna da rede**, carregado no NPM como *Custom Certificate*. Não há Let's Encrypt nem domínio público.
@@ -1044,7 +1032,7 @@ O HTTPS do painel usa um **certificado emitido pela CA interna da rede**, carreg
### 11.5 Host: máquina física com Docker
O ambiente de produção (até o momento) é uma **máquina física dedicada, separada do Proxmox**, rodando Docker. Por não ter snapshots nem alta disponibilidade de hypervisor, alguns cuidados ficam por conta do host:
O ambiente de produção é uma **máquina física dedicada, separada do Proxmox**, rodando Docker. Por não ter snapshots nem alta disponibilidade de hypervisor, alguns cuidados ficam por conta do host:
| Tema | Requisito |
| --- | --- |
@@ -1064,8 +1052,8 @@ O ambiente de produção (até o momento) é uma **máquina física dedicada, se
- Logs JSON (`slog`) com `request_id`, `user_id` e `job`.
- Healthchecks do Compose: `api` em `:8080/api/v1/health/live`, `worker` em `:8081/health/live` (ambos com `botpontos healthcheck`) e `web` em `/healthz`. O `/health/ready` da API e do worker também faz ping no Postgres.
- O worker registra `liderança obtida; worker ativo` e `bot do Telegram iniciado` ao assumir; uma segunda instância registra `outra instância do worker está ativa; aguardando`.
- **Métricas preparadas, não expostas.** O pacote `internal/metrics` define uma interface (`Recorder`) chamada pelos jobs e pelo cliente da PontoMais: lembretes por tipo e status, falhas e latência da PontoMais, duração da reconciliação, consultas evitadas pela janela (9.12). Na primeira versão a implementação é *no-op* (não faz nada) ou só alimenta os contadores do dashboard do painel. **Não existe endpoint `/metrics`.**
- Para ativar o Prometheus no futuro: trocar a implementação do `Recorder` por uma baseada em `prometheus/client_golang` e expor `/metrics` só na rede interna. Nenhum job precisa mudar.
- **Métricas preparadas, não expostas.** O pacote `internal/metrics` define uma interface (`Recorder`) chamada pelos jobs e pelo cliente da PontoMais: lembretes por tipo e status, falhas e latência da PontoMais, duração da reconciliação, consultas evitadas pela janela (9.12). A implementação atual é *no-op* (não faz nada); os contadores do dashboard vêm de consultas ao banco. **Não existe endpoint `/metrics`.**
- Para ativar o Prometheus: trocar a implementação do `Recorder` por uma baseada em `prometheus/client_golang` e expor `/metrics` só na rede interna. Nenhum job precisa mudar.
## 13. Testes
@@ -1079,35 +1067,35 @@ O ambiente de produção (até o momento) é uma **máquina física dedicada, se
Os comandos para rodar os testes em containers estão no `README.md` ("Desenvolvimento e testes").
## 14. Plano de sprints sugerido
## 14. Pontos de extensão
| Sprint | Entrega |
O que a arquitetura já prevê, sem implementação hoje:
| Extensão | Onde entra |
| --- | --- |
| 0 — Fundação ✓ | Monorepo (`backend/`, `web/`), Compose com Postgres, migrations, `migrate`/`api`/`worker` com health. Sem CI (projeto sem repositório remoto); testes rodam num container Go |
| 1 — Núcleo de domínio ✓ | `rules` e casos de uso portados do Modelo com as correções (fuso, mensagens); adapters PontoMais e crypto |
| 2 — Bot Telegram ✓ | Cadastro, `/status`, `/entrada2`, `/saida2`, ativação unificada |
| 3 — Lembretes ✓ | Reconciliação na janela 11:30–19:00 com frequência adaptativa (`user_days.next_check_at`), despacho a cada 2 min com `SKIP LOCKED`, advisory lock, tolerância de 10 min (`expired`) |
| 4 — API + Auth ✓ | Login, sessões, CSRF, papéis, endpoints de usuários/notificações/auditoria, `openapi.yaml` |
| 5 — Painel Next ✓ | Login, dashboard, usuários (incluindo janela personalizada), notificações, auditoria, configurações globais |
| 6 — Operação ✓ | Feriados (API + manual), contas web, backup/restore, Proxy Host no NPM, hardening |
| Evoluções ✓ | Feriados pela feriadosapi.com (migration `00002`); conta de leitura restrita a um usuário (9.6); exclusão de contas web (migration `00003`); porta do painel configurável (`BP_WEB_PORT`); `go.sum` e `package-lock.json` versionados |
| Novos tipos de lembrete (ex.: `entrada1` não detectada) | Novo valor no enum `notification_type` (migration) e regra em `rules`/`app` |
| Outro sistema de ponto | Novo adapter que implemente o port `PointsProvider` (`app/service.go`) |
| Métricas Prometheus | Implementação do `Recorder` com `/metrics` na rede interna (12) |
| Outras configurações globais no painel | Novas chaves em `app_settings` (9.13), sem mudar a estrutura |
| Tipos TypeScript gerados | `openapi-typescript` a partir do `openapi.yaml` (decisão 27) |
| Testes de integração e web | Seção 13 |
## 15. Decisões registradas (ADR resumido)
| # | Decisão | Motivo |
| --- | --- | --- |
| 1 | PostgreSQL no lugar do SQLite | Vários containers escrevendo; `SKIP LOCKED`; crescimento futuro |
| 1 | PostgreSQL | Vários containers escrevendo com segurança; `SKIP LOCKED`; crescimento futuro |
| 2 | Next.js como BFF, auth no Go | Uma fonte de verdade de autorização; sem CORS; API não exposta |
| 3 | Binário Go único com subcomandos | Uma imagem, mesmo código, deploy simples |
| 4 | Hexagonal com ports explícitos | Regras testáveis sem rede; adapters trocáveis (ex.: outro sistema de ponto) |
| 5 | `openapi.yaml` como contrato | Evita divergência entre Go e Next. Por enquanto os tipos do TypeScript são escritos à mão a partir dele (decisão 27) |
| 5 | `openapi.yaml` como contrato | Evita divergência entre Go e Next; os tipos do TypeScript são escritos à mão a partir dele (decisão 27) |
| 6 | Worker único com advisory lock | O Telegram em long polling não aceita dois consumidores |
| 7 | "No máximo uma vez" para lembretes | Preferível perder um lembrete a enviar em duplicidade (mantido do Modelo) |
| 7 | "No máximo uma vez" para lembretes | Preferível perder um lembrete a enviar em duplicidade |
| 8 | Despacho a cada 2 min, tolerância de 10 min | Aviso útil sem mensagens tardias demais; acima de 10 min vira `expired` e só é auditado |
| 9 | Pausa após 3 falhas seguidas na PontoMais (1 por ciclo de 15 min) | Evita pausar por instabilidade momentânea; pausa em ~30 min se o problema persistir |
| 10 | Todos falhando no ciclo = queda da API: só avisa o admin | Uma queda do fornecedor não deve pausar todos os usuários |
| 11 | Reenvio manual de `failed` pelo painel, só no mesmo dia | Recupera falhas do Telegram sem abrir mão do "no máximo uma vez" automático |
| 12 | Painel só para administradores, com papéis `owner`/`admin`/`viewer` | O usuário comum já tem o que precisa no Telegram |
| 12 | Painel para administradores, com papéis `owner`/`admin`/`viewer` | O usuário comum já tem o que precisa no Telegram; quem precisar ver os próprios dados recebe uma conta restrita (decisão 31) |
| 13 | Jornada exige `almoço − antecedência ≥ intervalo de reconciliação` | Garante que o lembrete de `entrada2` seja agendado a tempo |
| 14 | Feriados: tabela local → API → fallback seg–sex com aviso | A automação continua funcionando, com a falha visível aos admins |
| 15 | Retenção: auditoria 1 ano, notificações 90 dias, sessões 7 dias | Mantém o banco pequeno sem perder histórico útil |
@@ -1117,22 +1105,22 @@ Os comandos para rodar os testes em containers estão no `README.md` ("Desenvolv
| 19 | HTTPS com certificado da CA interna no NPM (`botpontos.lan`) | Painel só na rede local; sem exposição pública nem Let's Encrypt |
| 20 | Consultas só na janela global 11:30–19:00 | Reduz ~70–80% das chamadas à PontoMais sem atrasar os lembretes comuns |
| 21 | Sem ponto às 11:30 e 11:45 → reverificação de hora em hora até 15:00 → dia não trabalhado | Evita consultas inúteis em férias sem abandonar cedo quem atrasou ou teve ajuste de ponto |
| 22 | Ferramentas: roteador nativo do Go, `goose`, `go-telegram/bot`, `api` com saída para a PontoMais | Menos dependências; validação de credencial na hora. O `go-telegram/bot` foi substituído por cliente próprio (decisão 27) |
| 22 | Roteador nativo do Go, `goose` e `api` com saída para a internet | Menos dependências; validação de credencial, reenvio e sincronização de feriados na hora, sem passar pelo `worker` |
| 23 | Métricas preparadas por interface, sem expor `/metrics` | Não há Prometheus na rede hoje; ativar depois não exige refatoração |
| 24 | Horários da janela editáveis no painel (`app_settings`), com validação e pré-visualização | Ajuste sem redeploy; a validação evita configurações que quebrariam os lembretes |
| 25 | Frequência adaptativa: 15 min até o 3º ponto, depois 60 min + 1 consulta de confirmação antes do lembrete de saída | ~11 consultas/usuário/dia em vez de ~31, mantendo o cancelamento quando a pessoa sai antes |
| 26 | Janela global com exceção completa e opcional por usuário | Atende horários diferentes sem complicar o caso comum; selo, `/status` e auditoria deixam a exceção visível |
| 27 | Sem geradores de código nesta fase: SQL à mão com `pgx` (no lugar de `sqlc`), cliente Telegram próprio (no lugar de `go-telegram/bot`), tipos TypeScript à mão e CSS próprio (no lugar de Tailwind/shadcn) | O código foi escrito numa máquina onde não é permitido rodar ferramentas; tudo o que exigiria gerar código ou acertar a API de uma biblioteca sem compilar foi evitado. Podem ser adotados depois sem mudar a arquitetura |
| 28 | `app` usa o repositório Postgres concreto; os ports ficam só para integrações externas | Menos código de interface sem compilador; regras puras continuam testadas em `rules` |
| 27 | Sem geradores de código nem frameworks de UI: SQL à mão com `pgx`, cliente Telegram próprio, tipos TypeScript à mão e CSS próprio | Menos dependências e nenhuma etapa de geração no build. `sqlc`, `openapi-typescript` ou uma biblioteca de UI podem ser adotados sem mudar a arquitetura |
| 28 | `app` usa o repositório Postgres concreto; os ports ficam só para integrações externas | Menos código de interface; as regras puras são testadas em `rules` |
| 29 | Em queda da PontoMais já registrada, falhas de ciclos com um único usuário consultado também não contam | Com a frequência adaptativa, muitos ciclos consultam só um usuário; sem isso, alguém poderia ser pausado durante uma queda conhecida |
| 30 | Feriados pela feriadosapi.com, por código IBGE da cidade | Traz feriados nacionais e municipais numa chamada por ano; a feriados.dev deixou de ser usada (linhas antigas continuam válidas) |
| 30 | Feriados pela feriadosapi.com, por código IBGE da cidade | Traz feriados nacionais e municipais numa chamada por ano |
| 31 | Conta `viewer` ligada a um usuário fica restrita aos dados dele, com a regra na API | Permite ao usuário acompanhar e ajustar só o que é dele sem abrir o painel inteiro; o menu do Next só esconde o que a API já recusa |
| 32 | Excluir conta web apaga a linha e preserva o nome na auditoria (`actor_account_name`) | Remove contas sem uso sem perder o histórico de quem fez cada ação; desativar continua sendo a opção reversível |
| 33 | Versões das dependências fixadas em `go.sum` e `package-lock.json`; sem `go mod tidy` com Go 1.25 | Builds reproduzíveis; versões novas das bibliotecas exigem Go 1.26 |
| 33 | Versões das dependências fixadas em `go.sum` e `package-lock.json`; sem `go mod tidy` com Go 1.25 | Builds reproduzíveis; versões mais novas das bibliotecas exigem Go 1.26 |
## 16. Estado da implementação e pendências
**Implementado:** tudo o que as seções 4 a 14 descrevem (Sprints 0 a 6 e evoluções), com os desvios das decisões 27 e 28.
**Implementado:** tudo o que as seções 3 a 13 descrevem. A seção 14 lista o que é só previsto.
**Verificado:** o backend compila e passa em `go vet` e `go test`; o painel passa em `tsc --noEmit`. As dependências estão fixadas em `backend/go.sum` e `web/package-lock.json`.

File diff suppressed because one or more lines are too long