Files
bot-pontos/docs/ARQUITETURA.md
Bruno Santos 61fde19133 docs: revisa a documentação com base no que a aplicação faz hoje
- README: o que a aplicação faz, migrations, BP_WEB_PORT e testes com
  go.sum/package-lock.json (sem go mod tidy)
- ARQUITETURA: esquema real (00001 a 00003), rotas que faltavam, Compose
  e imagens atuais, cadeia de proxies, retenção, situação dos testes,
  decisões 30 a 33 e estado da implementação
- DEPLOY: cópia por tar, BP_WEB_PORT em todas as etapas e atualização
  para a versão com exclusão de contas
- MANUAL: papéis do owner e navegação por ano nos feriados
- openapi.yaml: DELETE /accounts/{id} e regras da conta restrita
- Dockerfiles: comentários sobre go.sum e package-lock.json

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 22:20:25 -03:00

1151 lines
81 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Bot de Pontos v2 — 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**
---
## 1. Objetivo
Reconstruir o Bot de Pontos com uma arquitetura que:
- 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 domínio continua o mesmo: consultar os pontos do dia na PontoMais, calcular `entrada2` e `saida2` e lembrar o usuário pelo Telegram.
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 |
## 3. Visão de containers
```mermaid
flowchart LR
subgraph Internet
TGAPI[Telegram API]
PM[PontoMais API]
HOL[feriadosapi.com]
end
subgraph Docker["Docker Compose (rede interna)"]
WEB[web-next<br/>Next.js standalone<br/>:3000]
API[api<br/>Go HTTP<br/>:8080]
WRK[worker<br/>Go: bot + scheduler]
DB[(postgres<br/>:5432)]
BKP[backup<br/>pg_dump agendado]
end
ADM[Administrador<br/>navegador] -->|HTTPS| NPM[Nginx Proxy Manager<br/>já existente na rede]
NPM -->|HTTP interno| WEB
WEB -->|/api/* proxy interno| API
API --> DB
WRK --> DB
WRK --> TGAPI
WRK --> PM
WRK --> HOL
API --> PM
API --> TGAPI
API --> HOL
BKP --> DB
```
**Regras de exposição**
- Só o `web-next` recebe conexões de fora, e **sempre pelo Nginx Proxy Manager (NPM) que já existe na rede**, que cuida do HTTPS. O projeto **não** tem proxy próprio (ver 11.4).
- `api` e `postgres` ficam apenas na rede interna do Compose, sem `ports:`.
- O `worker` não recebe conexões de fora; o bot usa *long polling* no Telegram. Ele só expõe `/health/live` e `/health/ready` na porta 8081, dentro do container, para o healthcheck.
- A `api` também sai para a internet: valida credenciais na PontoMais, faz o reenvio manual de lembretes pelo Telegram e a sincronização de feriados acionada pelo painel.
## 4. Backend Go
### 4.1 Estilo
É um **monólito modular em arquitetura hexagonal** (ports and adapters). O domínio e as regras ficam no centro e não conhecem HTTP, Telegram nem Postgres. Cada integração é um *adapter* que implementa uma interface (*port*) declarada pelo núcleo.
```text
┌──────────── adapters de entrada ────────────┐
│ HTTP (api) Telegram (worker) CLI │
└───────────────────┬─────────────────────────┘
▼
application (casos de uso)
▼
domain + rules (puro, testável)
▲
┌───────────────────┴─────────────────────────┐
│ Postgres PontoMais Telegram Feriados │
└──────────── adapters de saída ──────────────┘
```
### 4.2 Estrutura de pastas
```text
backend/
├── cmd/
│ └── botpontos/ # binário único: api | worker | admin | migrate | healthcheck | version
├── internal/
│ ├── domain/ # entidades, value objects, erros de domínio
│ ├── rules/ # cálculos puros: workday, entrada2, saida2, dia útil
│ ├── app/ # casos de uso; os ports (PointsProvider, Messenger, HolidayProvider, Cipher) ficam em app/service.go
│ ├── bootstrap/ # composition root: monta o app com os adapters reais
│ ├── 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)
│ │ ├── feriados/ # cliente feriadosapi.com (endpoint por cidade)
│ │ └── crypto/ # AES-GCM, Argon2id, tokens
│ ├── httpapi/ # API REST: router, handlers JSON, DTOs
│ ├── httpx/ # utilitários HTTP compartilhados (JSON, problem+json, log, recover)
│ ├── worker/ # liderança (advisory lock), bot e ciclos (reconciliação, despacho, manutenção)
│ ├── bot/ # roteador de comandos do Telegram → casos de uso
│ ├── metrics/ # contadores internos (sem endpoint /metrics por enquanto)
│ └── config/
├── db/
│ └── migrations/ # goose: 00001 (esquema), 00002 (feriadosapi), 00003 (excluir conta web)
├── api/
│ └── openapi.yaml # contrato da API (referência para os tipos do Next)
├── go.mod / go.sum # versões fixadas das dependências
└── Dockerfile
```
### 4.3 Processos
O binário é um só e o subcomando define o papel:
| Subcomando | Container | Função |
| --- | --- | --- |
| `botpontos api` | `api` | API REST para o Next.js. Pode ter várias réplicas. |
| `botpontos worker` | `worker` | Bot do Telegram (*long polling*), reconciliação e despacho. **Só uma réplica ativa.** |
| `botpontos migrate [up\|status\|down]` | `migrate` (*one-shot*) | Aplica (`up`, padrão) ou consulta as migrations antes de `api` e `worker` subirem. |
| `botpontos admin ...` | `docker compose run` | `create-owner`, `reset-password`, `bot-admin`, `set-credentials`, `rotate-key` (senhas lidas do terminal, sem eco) |
| `botpontos healthcheck <url>` | healthcheck do Compose | Faz GET num endpoint de saúde (a imagem distroless não tem `curl`) |
Mesmo que alguém suba duas réplicas do `worker` por engano, o processo pega um **advisory lock do Postgres** (`pg_try_advisory_lock`) antes de iniciar o bot e os jobs. Quem não conseguir o lock fica em espera.
### 4.4 Bibliotecas
| Necessidade | Escolha |
| --- | --- |
| 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 |
| 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) |
| 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) |
## 5. Frontend Next.js
### 5.1 Papel
O Next.js é a **interface do painel** (administradores e contas de leitura, inclusive a conta restrita a um usuário, ver 9.6) e atua como **BFF** (*backend for frontend*). O navegador fala apenas com o Next; o Next fala com a API Go pela rede interna.
### 5.2 Estrutura
```text
web/
├── app/
│ ├── login/page.tsx
│ ├── healthz/route.ts # healthcheck do container (não passa pela API)
│ ├── (painel)/
│ │ ├── layout.tsx # verifica a sessão (server component) e monta o menu por papel
│ │ ├── page.tsx # Início (dashboard)
│ │ ├── usuarios/
│ │ │ ├── page.tsx
│ │ │ └── [id]/page.tsx # + forms.tsx: credencial, jornada, janela, ações
│ │ ├── notificacoes/page.tsx # tela "Lembretes"
│ │ ├── auditoria/page.tsx
│ │ ├── feriados/page.tsx # + form.tsx
│ │ ├── configuracoes/page.tsx # janela de consulta (9.13), + form.tsx
│ │ └── contas/page.tsx # contas web (somente owner), + forms.tsx
├── components/ # ActionButton, WindowFields, LogoutButton
├── lib/
│ ├── types.ts # tipos da API, escritos à mão a partir do openapi.yaml
│ ├── server-api.ts # GET dos Server Components, repassando o cookie
│ ├── client-api.ts # mutações do navegador, com o token CSRF
│ ├── access.ts # isRestricted: esconde menu e botões da conta restrita
│ └── format.ts # datas, horários e rótulos em pt-BR
├── middleware.ts # redireciona para /login sem cookie de sessão
├── next.config.ts # output: 'standalone' + rewrites /api/v1 → api:8080
└── Dockerfile
```
### 5.3 Comunicação
- **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).
- **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:
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.
## 6. Banco de dados (PostgreSQL)
### 6.1 Convenções
- IDs `bigint generated always as identity`.
- 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.
### 6.2 Esquema atual
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.
```sql
CREATE TYPE user_status AS ENUM ('pending', 'active', 'disabled');
CREATE TYPE notification_type AS ENUM ('entrada2', 'saida2');
CREATE TYPE notification_status AS ENUM ('scheduled', 'sending', 'sent', 'failed', 'cancelled', 'expired');
CREATE TYPE web_role AS ENUM ('owner', 'admin', 'viewer');
CREATE TYPE audit_source AS ENUM ('telegram', 'web', 'worker', 'cli');
-- Pessoa que usa o bot
CREATE TABLE users (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
telegram_chat_id bigint NOT NULL UNIQUE,
display_name text NOT NULL,
status user_status NOT NULL DEFAULT 'pending',
is_bot_admin boolean NOT NULL DEFAULT false,
notifications_enabled boolean NOT NULL DEFAULT false,
-- motivo da pausa, exibido no /status: 'manual' | 'api_failures' | 'admin' | 'user_disabled'
notifications_paused_reason text,
timezone text NOT NULL DEFAULT 'America/Sao_Paulo',
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
-- Regras de jornada separadas do cadastro (permite histórico/versão no futuro)
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),
daily_work_minutes int NOT NULL DEFAULT 480 CHECK (daily_work_minutes BETWEEN 60 AND 720),
entrada2_lead_minutes int NOT NULL DEFAULT 5 CHECK (entrada2_lead_minutes BETWEEN 0 AND 60),
saida2_lead_minutes int NOT NULL DEFAULT 5 CHECK (saida2_lead_minutes BETWEEN 0 AND 60),
-- Exceção opcional à janela global (ver 9.14). NULL = usa o valor de app_settings.
-- Ou as quatro colunas estão preenchidas, ou nenhuma.
query_window_start time,
query_window_end time,
day_check_deadline time,
no_points_recheck_until time,
CHECK (num_nulls(query_window_start, query_window_end, day_check_deadline, no_points_recheck_until) IN (0, 4)),
updated_at timestamptz NOT NULL DEFAULT now()
);
-- Credencial PontoMais separada do usuário
CREATE TABLE point_credentials (
user_id bigint PRIMARY KEY REFERENCES users(id),
api_email text NOT NULL,
encrypted_password bytea NOT NULL,
key_version smallint NOT NULL, -- permite rotação de chave
last_validated_at timestamptz,
last_error text,
consecutive_failures int NOT NULL DEFAULT 0, -- zera a cada consulta bem-sucedida
last_failure_at timestamptz,
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE notifications (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id bigint NOT NULL REFERENCES users(id),
reference_date date NOT NULL,
type notification_type NOT NULL,
scheduled_at timestamptz NOT NULL, -- quando o aviso deve sair
predicted_at timestamptz NOT NULL, -- horário previsto do ponto (vai na mensagem)
status notification_status NOT NULL DEFAULT 'scheduled',
attempts int NOT NULL DEFAULT 0,
locked_at timestamptz,
sent_at timestamptz,
telegram_message_id bigint,
error_message text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (user_id, reference_date, type)
);
CREATE INDEX notifications_due_idx ON notifications (scheduled_at) WHERE status = 'scheduled';
CREATE INDEX notifications_sending_idx ON notifications (locked_at) WHERE status = 'sending';
CREATE TABLE web_accounts (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
username citext NOT NULL UNIQUE,
password_hash text NOT NULL,
role web_role NOT NULL,
user_id bigint REFERENCES users(id), -- vínculo opcional com o Telegram
failed_login_count int NOT NULL DEFAULT 0,
locked_until timestamptz,
disabled_at timestamptz,
last_login_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
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
token_hash bytea NOT NULL UNIQUE,
csrf_token_hash bytea NOT NULL,
expires_at timestamptz NOT NULL,
last_seen_at timestamptz NOT NULL,
revoked_at timestamptz,
user_agent text,
ip inet,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE holidays ( -- cache persistente da API de feriados
day date PRIMARY KEY,
name text NOT NULL,
source text NOT NULL CHECK (source IN ('feriadosapi', 'feriados.dev', 'manual')), -- 00002; feriados.dev = legado
created_at timestamptz NOT NULL DEFAULT now()
);
-- Situação de cada usuário em cada dia (janela de consulta, ver 9.12)
CREATE TYPE day_status AS ENUM ('awaiting', 'no_points', 'working', 'not_working', 'finished');
CREATE TABLE user_days (
user_id bigint NOT NULL REFERENCES users(id),
day date NOT NULL,
status day_status NOT NULL DEFAULT 'awaiting',
zero_point_checks smallint NOT NULL DEFAULT 0, -- consultas bem-sucedidas com 0 pontos
last_point_count smallint NOT NULL DEFAULT 0,
next_check_at timestamptz, -- próxima consulta à PontoMais (NULL = nenhuma hoje)
resumed_by text, -- 'ativarponto' | 'entrada2' | 'saida2' | 'web'
updated_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (user_id, day)
);
CREATE INDEX user_days_next_check_idx ON user_days (next_check_at) WHERE next_check_at IS NOT NULL;
-- Configurações globais editáveis pelo painel (ver 9.13)
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_at timestamptz NOT NULL DEFAULT now()
);
-- Estado operacional do worker (ex.: queda da PontoMais em andamento)
CREATE TABLE service_state (
key text PRIMARY KEY, -- ex.: 'pontomais_outage'
value jsonb NOT NULL, -- ex.: {"since": "...", "last_error": "..."}
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE audit_logs (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
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
target_user_id bigint REFERENCES users(id),
event_type text NOT NULL,
success boolean NOT NULL,
details jsonb NOT NULL DEFAULT '{}'
);
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:
- 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`).
- 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.
- `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`.
### 6.3 Despacho sem duplicidade
```sql
-- 1) expira o que passou da tolerância (BP_LATE_TOLERANCE = 10 min)
UPDATE notifications
SET status = 'expired', error_message = 'atraso acima da tolerância'
WHERE status = 'scheduled' AND scheduled_at < now() - interval '10 minutes'
RETURNING id, user_id, type, scheduled_at; -- cada linha gera um audit_log
-- 2) reserva o que está dentro da tolerância (transação curta)
WITH due AS (
SELECT id FROM notifications
WHERE status = 'scheduled' AND scheduled_at <= now()
ORDER BY scheduled_at
LIMIT 50
FOR UPDATE SKIP LOCKED
)
UPDATE notifications n
SET status = 'sending', locked_at = now(), attempts = attempts + 1
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".
### 6.4 Pool e conexões
- `api`: pool de 10 conexões.
- `worker`: pool de 5 conexões.
- Postgres com `max_connections` padrão (100), o que sobra muito para 10 a 20 usuários.
## 7. Contrato da API (v1)
Todas as rotas ficam sob `/api/v1`, usam JSON e exigem sessão, exceto `auth/login` e `health`.
| Método | Rota | Papel mínimo | Descrição |
| --- | --- | --- | --- |
| GET | `/health/live` · `/health/ready` | — | Liveness e readiness (o ready faz ping no Postgres) |
| POST | `/auth/login` · `/auth/logout` | — / qualquer | Sessão |
| GET | `/auth/me` | viewer | Conta logada e papel |
| GET | `/dashboard` | viewer | Contadores: pendentes, notificações do dia, falhas recentes |
| GET | `/users` | viewer | Lista com filtros (`status`, `q`, `custom_window`); indica quem tem janela personalizada |
| GET | `/users/{id}` | viewer | Detalhe: cadastro, jornada, credencial (sem a senha) |
| POST | `/users/{id}/activate` · `/disable` | admin | Mesmo caso de uso usado pelo Telegram |
| PUT | `/users/{id}/schedule` | admin | Altera a jornada (almoço, jornada, antecedências, fuso) e a exceção de janela (`window: null` = usa o global); valida conforme 9.8 e 9.14 |
| PUT | `/users/{id}/credentials` | admin | Valida na PontoMais antes de salvar |
| POST | `/users/{id}/credentials/test` | admin | Testa a credencial atual |
| POST | `/users/{id}/notifications/enable` · `/disable` | admin | Liga ou pausa os lembretes |
| PUT | `/users/{id}/bot-admin` | admin | Liga ou desliga o admin do bot no Telegram (`{"enabled": true}`) |
| GET | `/users/{id}/today` | viewer | Pontos do dia, previsões calculadas (consulta ao vivo) e situação do dia (`user_days`) |
| POST | `/users/{id}/today/resume` | admin | Retoma um dia `no_points`/`not_working` (ver 9.12) |
| GET | `/settings` | viewer | Configurações globais (janela de consulta) e parâmetros do servidor (só leitura) |
| PUT | `/settings` | admin | Altera as configurações globais; valida conforme 9.13 |
| POST | `/settings/preview` | admin | Pré-visualiza o efeito de uma janela antes de salvar (9.13) |
| GET | `/notifications` | viewer | Filtros: data, status, usuário, tipo |
| POST | `/notifications/{id}/cancel` | admin | Só quando o status é `scheduled` |
| POST | `/notifications/{id}/resend` | admin | Reenvio manual; só `failed` do dia atual (ver 9.5) |
| GET | `/audit` | viewer | Filtros + paginação por cursor |
| GET | `/holidays?year=AAAA` | viewer | Feriados do ano |
| POST | `/holidays` | admin | Cadastra feriado manual |
| POST | `/holidays/sync` | admin | Sincroniza o ano com a feriadosapi.com (9.9) |
| DELETE | `/holidays/{day}` | admin | Remove um feriado manual |
| 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" |
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):
```json
{ "type": "about:blank", "title": "Credencial ausente", "status": 409, "code": "CREDENTIALS_REQUIRED" }
```
## 8. Fluxos principais
### 8.1 Reconciliação (worker, a cada 15 min; despacho a cada 2 min)
```mermaid
flowchart TD
A[Tick] --> B{Dia útil?<br/>holidays + feriadosapi.com}
B -- não --> Z[Fim]
B -- sim --> W{Dentro da janela<br/>11:30–19:00?}
W -- não --> Z
W -- sim --> C[Usuários ativos com lembretes ligados<br/>e next_check_at <= agora]
C --> D[Para cada usuário: busca pontos na PontoMais]
D --> E[rules.Workday no FUSO DO USUÁRIO]
E -->|2 pontos| F[Upsert entrada2]
E -->|3 pontos| G[Cancela entrada2 · upsert saida2 · próxima consulta em 60 min ou na confirmação]
E -->|0 pontos| K[11:30 aguarda; 11:45 no_points com reverificação de hora em hora até 15:00]
E -->|4 pontos| H[Cancela pendências do dia · marca finished]
E -->|>4| I[Hora extra: sem ação]
E -->|inválido| J[Audita]
```
Os usuários são processados com concorrência limitada (*worker pool* de 4, `BP_RECONCILIATION_WORKERS`) e timeout por chamada externa.
### 8.2 Ciclos e latência dos lembretes
| Ciclo | Intervalo | Variável | Função |
| --- | --- | --- | --- |
| Reconciliação | tick de 15 min, **só das 11:30 às 19:00** | `BP_RECONCILIATION_INTERVAL` | Consulta na PontoMais só os usuários com `next_check_at` vencido e cria, atualiza ou cancela lembretes (ver 9.12) |
| Despacho | **2 min** | `BP_DISPATCH_INTERVAL` | Envia os lembretes `scheduled` cujo `scheduled_at <= now()` |
| Manutenção | 5 min | `BP_MAINTENANCE_INTERVAL` | Marca como `failed` o que está em `sending` há mais de 5 min (`BP_SENDING_TIMEOUT`); 1×/dia, às 03h, aplica a retenção de dados (ver 9.10) |
| Tolerância de atraso | 10 min | `BP_LATE_TOLERANCE` | Limite para ainda enviar um lembrete atrasado (ver 9.3) |
**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.
- 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.
**Atraso esperado.** Em operação normal, o lembrete sai entre 0 e 2 min depois do `scheduled_at`. Com 5 min de antecedência, o usuário recebe o aviso de 3 a 5 min antes do horário previsto.
**Situações que podem gerar atraso maior** (até 10 min o lembrete ainda é enviado; acima disso só é auditado, ver 9.3):
| Situação | Atraso possível | Mitigação |
| --- | --- | --- |
| Ponto detectado tarde. A reconciliação roda a cada 15 min e, com almoço curto (ex.: 15 min), o horário do lembrete pode já ter passado quando o ponto é visto. | até ~15 min | Validação `almoço − antecedência ≥ intervalo de reconciliação` ao salvar a jornada (ver 9.8) |
| PontoMais lenta ou fora do ar | até a API voltar | Timeout curto + nova tentativa no próximo ciclo; pausa só após 3 falhas seguidas (ver 9.4) |
| Endpoint `last_cached` da PontoMais com cache defasado | desconhecido (a medir no piloto) | Registrar no log a diferença entre o horário do ponto e a primeira detecção |
| Worker reiniciando ou fora do ar | duração da queda | `restart: unless-stopped` + healthcheck; na volta, lembretes com até 10 min de atraso são enviados e os demais expiram |
| Falha no Telegram | lembrete não é enviado (`failed`) | Reenvio manual pelo painel (ver 9.5) |
### 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.
### 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 |
| --- | --- | --- |
| `/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 |
| `/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 |
| --- | --- |
| `/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 |
| `/desativarusuario <id>` | Desativa o usuário e cancela os `scheduled` dele |
- A credencial da PontoMais é configurada **só pelo painel** (ou pela CLI). O bot nunca pede senha no chat.
- `/comando@nome_do_bot` é normalizado para `/comando`, e um comando desconhecido recebe orientação para usar `/ajuda`.
## 9. Regras de negócio
### 9.1 Mantidas do Modelo
- Quatro pontos normais: `entrada1`, `saida1`, `entrada2`, `saida2`.
- `entrada2 = saida1 + almoço` e `saida2 = entrada2 + (jornada − (saida1 − entrada1))`.
- No máximo um lembrete por `(usuário, dia, tipo)`.
- Mais de 4 pontos conta como hora extra e fica fora do escopo.
- Notificação `failed` não é reenviada **automaticamente** (o reenvio manual está na seção 9.5).
- Nada é registrado na PontoMais.
### 9.2 Corrigidas
| Problema no Modelo | Correção na v2 |
| --- | --- |
| 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) |
**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)
**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.
Os 10 min ficam na variável `BP_LATE_TOLERANCE`.
A regra vale nos dois pontos em que o atraso pode surgir:
| Momento | Situação | Ação |
| --- | --- | --- |
| Reconciliação | Lembrete ainda não existe e o `scheduled_at` calculado já passou, com atraso ≤ 10 min | Cria como `scheduled`; o próximo despacho (≤ 2 min) envia |
| Reconciliação | Lembrete ainda não existe e o atraso é > 10 min | Cria direto como `expired` (assim nada é recriado nos ciclos seguintes) e audita |
| Reconciliação | Lembrete já existe (qualquer status) | Não altera; só cancela, se o próximo ponto já tiver sido batido |
| Despacho | `scheduled` com atraso > 10 min | Muda para `expired` e audita (etapa 1 da seção 6.3) |
| Despacho | `scheduled` com atraso ≤ 10 min | Envia normalmente |
**Detalhes:**
- 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.
- 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)
**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.
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.
- 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.
**Espaçamento:** as falhas acompanham a reconciliação, de **15 em 15 min**. A pausa acontece cerca de **30 min depois da primeira falha**:
```text
12:00 falha 1 (contador = 1) → audita, sem aviso ao usuário
12:15 falha 2 (contador = 2) → audita
12:30 falha 3 (contador = 3) → pausa lembretes, avisa usuário e admin
```
**Comportamento:**
| Evento | Açã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). |
| `/ativarponto` ou reativação pelo painel | Zera o contador. |
**Queda geral da PontoMais:**
Se **todos** os usuários consultados num ciclo falharem, o problema é tratado como queda da API, não como erro de credencial de um usuário:
| Situação no ciclo | Ação |
| --- | --- |
| Todos falharam, com 2 ou mais usuários consultados | **Queda.** Ninguém tem o contador incrementado nem zerado e ninguém é pausado. O admin é avisado pelo Telegram **uma vez**, no início da queda. Grava `service_state['pontomais_outage']` e audita `point_api_outage_started`. |
| Ciclos seguintes, ainda todos falhando | Continua em queda. Não avisa de novo; só registra no log. |
| Pelo menos 1 usuário teve sucesso | Fim da queda (se havia uma). Avisa o admin com a duração, apaga o estado e audita `point_api_outage_ended`. As falhas desse ciclo voltam a contar individualmente. |
| Só 1 usuário consultado | Não dá para distinguir queda de erro de credencial, então conta como falha individual (regra normal). |
- O estado fica no banco (`service_state`), não em memória. Assim um restart do worker no meio da queda não gera um segundo aviso.
- A regra vale para qualquer tipo de erro, inclusive 401/403: como cada usuário tem a própria credencial, todas serem recusadas juntas indica problema na API.
- O usuário comum não recebe aviso durante a queda. Os lembretes já agendados continuam sendo enviados.
**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)
Um lembrete `failed` pode ser reenviado **manualmente** pelo painel, num botão com confirmação. O reenvio automático continua não existindo.
| Regra | Detalhe |
| --- | --- |
| Quem pode | Papel `admin` ou `owner` |
| O que pode ser reenviado | Só status `failed` e `reference_date` = dia atual (no fuso do usuário). `expired`, `cancelled` e `sent` não podem. |
| Pré-condições | Usuário `active` e com lembretes ligados. Se o ponto seguinte já tiver sido batido (ex.: `entrada2` já registrada), o painel avisa e bloqueia o reenvio. |
| Confirmação | O diálogo avisa que, se a falha foi um *crash* durante o envio (`sending` → `failed`), o usuário **pode já ter recebido** a mensagem original. |
| Execução | `UPDATE notifications SET status='sending', attempts=attempts+1 WHERE id=$1 AND status='failed' RETURNING *`. A troca de status é atômica, então um duplo clique ou dois admins ao mesmo tempo resultam em um único envio. O envio acontece na hora, sem esperar o próximo despacho. |
| Tolerância de 10 min | **Não se aplica**: o reenvio é uma decisão consciente do admin. A mensagem informa o horário previsto ("estava previsto para 13:02" se já tiver passado). |
| 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)
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).
| Ação | `viewer` | `admin` | `owner` |
| --- | :-: | :-: | :-: |
| Ver dashboard, usuários, notificações, auditoria, feriados e configurações | ✓ | ✓ | ✓ |
| Ativar/desativar usuário, ligar/pausar lembretes | | ✓ | ✓ |
| Alterar jornada e credenciais, testar API | | ✓ | ✓ |
| Cancelar e reenviar notificação | | ✓ | ✓ |
| Gerenciar feriados manuais | | ✓ | ✓ |
| Alterar configurações globais (janela de consulta) | | ✓ | ✓ |
| 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.
- 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.
- 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)
Conta restrita = `role = viewer` **e** `user_id` preenchido (`isRestricted`, em `httpapi/middleware.go`). Os middlewares são `auth` (papel mínimo), `authUnrestricted` (recusa a conta restrita) e `authUser` (rotas `/users/{id}`: admin e owner passam; a conta restrita só passa no próprio id). Violações respondem `403 FORBIDDEN`.
| Rota | Conta restrita |
| --- | --- |
| `GET /dashboard` | Só `outage_since`, `holiday_fallback_day`, `today` e `today_by_status` calculado com os próprios lembretes; contadores globais vêm zerados |
| `GET /users` | Devolve só o próprio usuário |
| `GET /users/{id}`, `GET /users/{id}/today` | Só o próprio id |
| `PUT /users/{id}/schedule`, `PUT /users/{id}/credentials`, `POST /users/{id}/credentials/test`, `POST /users/{id}/notifications/enable\|disable` | Permitido só no próprio id (`authUser(RoleAdmin, true, …)`) |
| `activate`, `disable`, `bot-admin`, `today/resume` | Negado (exigem `admin`) |
| `GET /notifications` | O filtro `user_id` é forçado ao próprio, ignorando o enviado. `cancel` e `resend` seguem exigindo `admin` |
| `GET /holidays` | Permitido; escrita segue exigindo `admin` |
| `GET /audit`, `GET /settings` | Negado (`authUnrestricted`) |
| `/accounts*` | Negado (`owner`) |
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).
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)
- **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.**
- **Registro de ponto na PontoMais.**
### 9.8 Validação da jornada
Validações ao salvar a jornada (`PUT /users/{id}/schedule`). Quando uma falha, a API responde `422` com o campo e o motivo.
| Campo | Regra |
| --- | --- |
| `lunch_minutes` | 15 a 180 |
| `daily_work_minutes` | 60 a 720 |
| `entrada2_lead_minutes`, `saida2_lead_minutes` | 0 a 60 |
| **Combinação** | `lunch_minutes − entrada2_lead_minutes ≥ BP_RECONCILIATION_INTERVAL` (15 min por padrão) |
| `timezone` | Nome IANA válido (ex.: `America/Sao_Paulo`) |
A combinação garante que a saída para o almoço seja detectada antes do horário do lembrete de `entrada2`. Exemplo: com almoço de 20 min e antecedência de 5 min, sobram 15 min, o que passa. Com almoço de 15 min e antecedência de 5 min, sobram 10 min, e a jornada é recusada.
Se o intervalo de reconciliação for aumentado depois, as jornadas existentes não são alteradas. Ao iniciar, o worker avisa os admins sobre os usuários que ficaram fora da regra.
### 9.9 Dia útil e feriados
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`).
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.
- 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`).
### 9.10 Retenção de dados
Aplicada pelo job de manutenção, uma vez por dia, no primeiro ciclo depois das 03h (`BP_RETENTION_HOUR`, no fuso padrão). O último dia executado fica em `service_state`, então um restart não repete a limpeza.
| Dado | Mantido por |
| --- | --- |
| `audit_logs` | 1 ano |
| `notifications` | 90 dias (por `reference_date`) |
| `user_days` | 90 dias (mesmo corte das notificações) |
| `web_sessions` expiradas ou revogadas | 7 dias após expirar ou ser revogada |
| `holidays` | Sem limpeza (volume desprezível) |
Os prazos ficam nas variáveis `BP_RETENTION_AUDIT`, `BP_RETENTION_NOTIFICATIONS` e `BP_RETENTION_SESSIONS`. Cada execução registra na auditoria (`retention_applied`) quantas linhas removeu.
### 9.11 Avisos ao administrador
Todo aviso "ao admin" citado neste documento é enviado **pelo Telegram para todos os usuários com `is_bot_admin = true` e status `active`**. Não há e-mail nem push; os eventos também aparecem no dashboard do painel.
| Evento | Quando avisa |
| --- | --- |
| Novo cadastro (`/cadastrar`) | A cada pedido |
| Usuário pausado após 3 falhas na PontoMais (9.4) | Na pausa |
| Queda geral da PontoMais (9.4) | No início e no fim |
| Fallback de feriados em uso (9.9) | 1× por dia enquanto durar |
| Usuários fora da regra de jornada após mudança de intervalo (9.8) | Na inicialização do worker |
Se não houver nenhum admin ativo no Telegram, o evento fica só na auditoria e no log, com nível `warn`.
### 9.12 Janela de consulta e frequência adaptativa
**Janela global:** a reconciliação só consulta a PontoMais **das 11:30 às 19:00**, no fuso de cada usuário. Os horários são **editáveis no painel** (ver 9.13) e valem para todos, **exceto** usuários com janela personalizada (ver 9.14).
| Configuração (painel) | Padrão | Uso |
| --- | --- | --- |
| Início da janela (`query_window_start`) | `11:30` | Primeira consulta do dia |
| Fim da janela (`query_window_end`) | `19:00` | Última consulta do dia |
| Confirmação sem pontos (`day_check_deadline`) | `11:45` | 2ª verificação sem pontos (ver abaixo) |
| Fim das reverificações (`no_points_recheck_until`) | `15:00` | Depois disso, um dia sem pontos é encerrado |
| Configuração (ambiente) | Padrão | Uso |
| --- | --- | --- |
| `BP_SLOW_CHECK_INTERVAL` | `60m` | Intervalo das consultas "lentas" (dia sem pontos e tarde com 3 pontos) |
| `BP_EXIT_CONFIRM_WINDOW` | `20m..5m` | Janela antes do lembrete de `saida2` onde cai a consulta de confirmação |
Os ticks de 15 min são alinhados ao relógio (:00, :15, :30, :45). **Fora da janela não há consulta, mas o resto continua:** o despacho roda o dia todo, porque não consulta a API, e os comandos manuais (`/entrada2`, `/saida2`) funcionam a qualquer hora.
#### Próxima consulta por usuário
Em vez de consultar todos os usuários em todo ciclo, cada usuário tem **sua próxima consulta** em `user_days.next_check_at`. A reconciliação, a cada tick, só consulta quem tem `next_check_at <= agora` e, depois de processar, calcula o próximo horário conforme a situação:
| 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 |
| `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 |
| `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
```text
11:30 0 pontos → awaiting (1ª verificação, não decide nada)
11:45 0 pontos → no_points (2ª verificação; passa a consultar de hora em hora)
12:45 0 pontos → no_points
13:45 0 pontos → no_points
14:45 0 pontos → no_points
15:00+ (sem nova consulta) → not_working (dia encerrado)
```
- **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.
- 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
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:
- **Consultas lentas:** de hora em hora a partir da detecção do 3º ponto.
- **Consulta de confirmação:** exatamente uma, no tick que cair entre **T − 20 min e T − 5 min**. Com ticks de 15 min, sempre existe exatamente um tick nesse intervalo. Se ela encontrar o 4º ponto, o lembrete é cancelado antes de sair.
- Se uma consulta lenta coincidir com a de confirmação, as duas viram uma só.
- **Depois da confirmação, não há mais consultas no dia** (`next_check_at = NULL`): o lembrete já vai sair, e consultar até 19:00 não mudaria nada.
- Uma saída antecipada que aconteça **depois** da consulta de confirmação não é vista a tempo. O lembrete sai mesmo assim (no máximo ~20 min de diferença), o que é aceito.
**Exemplo de dia típico** (entrada 08:00, almoço 12:00–13:00, saída prevista 17:00, lembrete 16:55):
```text
11:30 11:45 12:00 12:15 12:30 12:45 13:00 → 7 consultas (15 em 15, até detectar o 3º ponto)
14:00 15:00 16:00 → 3 consultas lentas
16:45 → confirmação (entre 16:35 e 16:50); fim das consultas
total = 11 consultas
```
#### Retomar o dia
| Ação | Efeito |
| --- | --- |
| `/ativarponto` | Dia `no_points`/`not_working` volta para `working`; consultas de 15 em 15 min até 19:00 |
| `/entrada2` ou `/saida2` que encontre pontos | Dia volta para `working` automaticamente |
| Admin no painel ("retomar dia") | Dia volta para `working` |
`resumed_by` registra quem retomou, e o evento é auditado (`day_resumed`).
#### Casos que a janela não cobre (aceitos)
| Caso | Consequência |
| --- | --- |
| Almoço muito cedo (ex.: saída 10:30 com 60 min de almoço → lembrete 11:25) | O ponto só é visto às 11:30; o lembrete sai atrasado (dentro de 10 min) ou vira `expired` (9.3) |
| Retorno do almoço depois das 19:00 | `entrada2` não é detectada e o `saida2` não é agendado |
| Saída antecipada depois da consulta de confirmação ou depois das 19:00 | O lembrete de `saida2` sai mesmo assim |
| Primeiro ponto do dia depois das 15:00 sem retomar | Nenhum lembrete no dia |
| Entrada esquecida sem ajuste | Ordem dos pontos errada; lembretes incorretos ou ausentes |
#### Impacto estimado (20 usuários, dia útil)
| Cenário | Consultas/usuário | Total/dia |
| --- | --- | --- |
| Dia inteiro (Modelo) | 96 | ~1.920 |
| Só a janela 11:30–19:00 | até 31 | ~620 |
| Janela + frequência adaptativa (A + C), 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
Os horários da janela (9.12) ficam na tabela `app_settings` e são editados na tela **Configurações** do painel (`PUT /settings`, papel `admin` ou `owner`).
Os quatro horários de 9.12 (início, confirmação, fim das reverificações e fim) são os editáveis.
**Origem dos valores**
- Na **primeira inicialização**, se a tabela estiver vazia, os padrões são gravados (`11:30`, `11:45`, `15:00`, `19:00`). As variáveis `BP_QUERY_WINDOW_START`, `BP_DAY_CHECK_DEADLINE`, `BP_NO_POINTS_RECHECK_UNTIL` e `BP_QUERY_WINDOW_END` servem só para trocar esses padrões iniciais.
- Depois disso, **vale o que está no banco**. As variáveis de ambiente não sobrescrevem o que foi salvo pelo painel.
**Validação ao salvar** (resposta `422` com o motivo):
| Regra | Motivo |
| --- | --- |
| Formato `HH:MM`, em múltiplos do intervalo de reconciliação (15 min: :00, :15, :30, :45) | Os ticks são alinhados ao relógio; um horário fora do alinhamento nunca coincidiria com um ciclo |
| `início < confirmação < fim das reverificações < fim` | A ordem precisa fazer sentido |
| `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.
**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.
**Auditoria:** cada alteração grava `settings_changed` com os valores antigo e novo e a conta web que alterou.
A tabela é genérica (chave/valor), então outras configurações globais podem migrar para o painel no futuro sem nova estrutura.
### 9.14 Exceção de janela por usuário
A janela global (9.12/9.13) vale para todos. Um usuário com horário diferente pode ter uma **janela personalizada**, que substitui a global só para ele.
**Regra de resolução**
```text
janela efetiva do usuário = janela personalizada (work_schedules), se existir
senão janela global (app_settings)
```
- A exceção é **completa**: os quatro horários (início, confirmação, fim das reverificações e fim) são preenchidos juntos, ou nenhum. Isso evita misturar metade global e metade personalizada, o que seria difícil de entender.
- Os horários são interpretados no **fuso do usuário**, como a janela global.
- A frequência adaptativa (15 min / 60 min / confirmação) é a mesma para todos; só os horários mudam.
- Uma alteração na janela global **não afeta** quem tem exceção.
**Painel**
- Na tela do usuário, o bloco **Janela de consulta** tem a opção **"Usar padrão global"** (marcada por padrão). Desmarcando, os quatro campos aparecem já preenchidos com os valores globais atuais, para servir de ponto de partida.
- A validação é a mesma de 9.13 (alinhamento de 15 min, ordem, mínimo de 15 min entre início e confirmação, janela de pelo menos 4 h) e mais a regra de 9.8, conferida contra a janela do usuário.
- A lista de usuários mostra o selo **"janela personalizada"** e pode ser filtrada por ele.
- Marcar de novo "Usar padrão global" apaga a exceção (as quatro colunas voltam a `NULL`).
**Quando passa a valer:** no próximo tick, como a configuração global. O `next_check_at` do dia é recalculado com a nova janela.
**Visibilidade e auditoria**
- `/status` informa ao usuário a janela que vale para ele e se é a global ou personalizada.
- Criar, alterar ou remover a exceção grava `user_window_changed` na auditoria, com os valores antigo e novo e a conta web que alterou.
- O dashboard mostra quantos usuários estão com janela personalizada.
**Permissão:** papel `admin` ou `owner`, como as demais alterações de jornada.
## 10. Segurança
| Camada | Medida |
| --- | --- |
| Rede | Só o Next é exposto, e apenas para o Nginx Proxy Manager. A API e o Postgres ficam sem porta publicada. |
| Transporte | TLS terminado no Nginx Proxy Manager (Force SSL + HSTS), com cookies `Secure`. |
| Autenticação web | Argon2id; bloqueio após 5 falhas por 15 min; sessão de 8h / 30 min ociosa. |
| Autorização | Papel conferido no Go em toda rota; o Next nunca decide permissão. |
| CSRF | Double submit + checagem de `Origin` + `SameSite=Strict`. |
| Segredos | Senhas PontoMais com AES-256-GCM e `key_version` para rotação. As chaves vêm de *Docker secrets* (`/run/secrets/...`), não de variáveis de ambiente em texto. |
| Banco | Usuário de aplicação (`botpontos_app`) sem `SUPERUSER` e só com DML; usuário dono (`botpontos`) para migrations e backup. |
| Containers | Imagens `distroless`/`alpine`, usuário não-root, `read_only: true`, `cap_drop: [ALL]`, `no-new-privileges`. |
| HTTP | API responde com `X-Content-Type-Options: nosniff`, `Cache-Control: no-store` e `Referrer-Policy: same-origin`. |
| Auditoria | Toda mutação web/Telegram/CLI grava em `audit_logs`, nunca com segredos. |
## 11. Docker
### 11.1 Imagens
- **Go:** build *multi-stage* (`golang:1.25-alpine` → `gcr.io/distroless/static-debian12:nonroot`). Um binário estático com migrations embutidas, imagem de ~15 MB. As dependências vêm do `go.sum` versionado.
- **Next:** `node:22-alpine` com `output: 'standalone'` e `npm ci` a partir do `package-lock.json`, imagem de ~150 MB. O endereço da API (`API_INTERNAL_URL`) entra no build, porque os *rewrites* do Next são resolvidos nessa hora.
### 11.2 `docker-compose.yml`
O arquivo real fica na raiz do projeto. Resumo:
| Serviço | Imagem | Redes | Segredos | Observações |
| --- | --- | --- | --- | --- |
| `postgres` | `postgres:17-alpine` | `internal` | `pg_password`, `pg_app_password` | Volume `pgdata`; `postgres/init/` cria o usuário `botpontos_app` na 1ª subida |
| `migrate` | `botpontos-backend` | `internal` | `pg_password` | `migrate up` com o usuário dono; termina com `exited (0)` |
| `api` | `botpontos-backend` | `internal`, `egress` | `pg_app_password`, `encryption_key`, `telegram_token`, `holiday_api_key` | Pool de 10 conexões; healthcheck em `:8080/api/v1/health/live` |
| `worker` | `botpontos-backend` | `internal`, `egress` | os mesmos da `api` | Pool de 5 conexões; healthcheck em `:8081/health/live` |
| `web` | `botpontos-web` | `internal`, `ingress` | — | Publica `${BP_HOST_LAN_IP}:${BP_WEB_PORT:-3000}` → `3000`; healthcheck em `/healthz` |
| `backup` | `postgres:17-alpine` | `internal` | `pg_password` | `scripts/backup-loop.sh`: `pg_dump -Fc` diário em `./backups`, 14 dias |
- Todos os serviços usam `restart: unless-stopped` (o `migrate` usa `"no"`). Os do backend e o `web` rodam com `read_only: true`, `cap_drop: [ALL]` e `no-new-privileges`.
- A rede `internal` é `internal: true` (sem internet); `egress` dá saída para PontoMais, Telegram e feriadosapi.com; `ingress` é a rede da porta publicada.
- Variáveis vindas do `.env`: `BP_HOST_LAN_IP` (obrigatória), `BP_WEB_PORT`, `BP_LOG_LEVEL`, `BP_PUBLIC_URL`, `BP_NPM_IP`, `BP_COOKIE_SECURE`, `BP_DEFAULT_TIMEZONE`, `BP_HOLIDAY_LOCATION` e `BP_ENCRYPTION_KEY_VERSION`. As demais variáveis de `internal/config/config.go` têm padrão e só precisam ser definidas para fugir dele.
A `api` tem saída para a internet porque a validação de credenciais na PontoMais (`PUT /users/{id}/credentials`, `POST /users/{id}/credentials/test`), o reenvio manual de lembretes (`POST /notifications/{id}/resend`) e o botão **Sincronizar** de feriados são feitos por ela, na hora, sem passar pelo `worker`.
### 11.3 Backup e restauração
- `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.
### 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.
**Topologia (decidida):** o NPM roda em **outra máquina da mesma rede**.
```text
Navegador ──HTTPS──► NPM (outra máquina) ──HTTP :3000──► servidor Docker: web ──► api ──► postgres
```
- O `web` publica a porta `3000` **só no IP de rede local do servidor** (`BP_HOST_LAN_IP`), nunca em `0.0.0.0`. Se a 3000 já estiver ocupada no servidor, `BP_WEB_PORT` troca a porta do lado do host (dentro do container continua 3000); a mesma porta vai no firewall e no Forward Port do NPM. Nos exemplos abaixo, troque 3000 por ela.
- A porta é liberada **apenas para o IP do NPM**. Qualquer outra máquina da rede é recusada, então ninguém consegue pular o NPM (e o HTTPS) acessando o Next direto.
- A `api` e o `postgres` não publicam porta; o NPM só enxerga o Next.
**Cuidado com o firewall e o Docker:** portas publicadas pelo Docker **ignoram as regras comuns do `ufw`/`iptables INPUT`** do host, porque o Docker cria as próprias regras de NAT. Como o servidor é uma **máquina física** (fora do Proxmox), a restrição é feita na cadeia **`DOCKER-USER`**, que o Docker consulta antes das próprias regras:
```bash
# bloqueia a porta 3000 para qualquer origem que não seja o NPM
iptables -I DOCKER-USER -p tcp -m conntrack --ctorigdstport 3000 ! -s IP_DO_NPM -j DROP
```
No Debian 12 o comando `iptables` já é o `iptables-nft`, o mesmo backend que o Docker usa, então a regra funciona sem ajustes.
**Persistência:** não use `iptables-save`/`netfilter-persistent` para isso. O arquivo salvo levaria junto as regras internas do Docker e poderia conflitar com elas no boot. Em vez disso, a unit systemd `deploy/botpontos-firewall.service` aplica só essa regra **depois** que o Docker sobe, sem duplicar. Ela tem duas variáveis para ajustar antes de instalar: `NPM_IP` (IP do NPM) e `WEB_PORT` (a mesma porta de `BP_WEB_PORT`, padrão 3000):
```ini
[Service]
Type=oneshot
RemainAfterExit=yes
Environment=NPM_IP=IP_DO_NPM
Environment=WEB_PORT=3000
ExecStart=/bin/sh -c 'iptables -C DOCKER-USER -p tcp -m conntrack --ctorigdstport $WEB_PORT ! -s $NPM_IP -j DROP 2>/dev/null || iptables -I DOCKER-USER -p tcp -m conntrack --ctorigdstport $WEB_PORT ! -s $NPM_IP -j DROP'
```
Ativação: `systemctl daemon-reload && systemctl enable --now botpontos-firewall`. Se um dia o servidor for movido para um LXC ou VM no Proxmox, a regra pode ser trocada pelo firewall do Proxmox.
**Teste de aceite:** de uma máquina da rede que não seja o NPM, `curl http://IP_DO_SERVIDOR:3000` precisa **falhar**; pelo domínio via NPM, o painel precisa abrir em HTTPS.
**Proxy Host no NPM**
| Campo | Valor |
| --- | --- |
| Domain Names | ex.: `botpontos.lan` ou `painel.seudominio.com.br` |
| Scheme / Forward Hostname / Port | `http` → `IP_DO_SERVIDOR_DOCKER` : `3000` (ou `BP_WEB_PORT`) |
| Block Common Exploits | ligado |
| 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 |
**Certificado interno (decidido)**
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.
- **Nome:** um nome interno fixo, por exemplo `botpontos.lan`. O certificado precisa ter esse nome no **SAN** (*Subject Alternative Name*); navegadores modernos ignoram o campo CN.
- **DNS interno:** um registro `botpontos.lan → IP_DO_NPM` no DNS da rede (roteador, Pi-hole, AdGuard etc.). O nome aponta para o **NPM**, não para o servidor Docker.
- **Confiança:** o certificado raiz da CA interna precisa estar instalado em todos os computadores que acessam o painel. Sem isso, o navegador mostra alerta de segurança.
- **Validade:** a renovação é **manual**, sem automação como no Let's Encrypt. Registrar a data de expiração e renovar com antecedência. Com HSTS ligado, um certificado vencido **bloqueia o acesso** em vez de só mostrar um aviso.
- **`BP_PUBLIC_URL`:** `https://botpontos.lan`, o mesmo nome do certificado.
- **Sem impacto interno:** a comunicação NPM → Next → Go continua em HTTP na rede, então a aplicação não precisa confiar na CA interna.
**Ajustes na aplicação por causa do proxy**
- O NPM envia `X-Forwarded-For`, e o Next o repassa ao Go junto com o próprio endereço. O **Go** percorre o `X-Forwarded-For` da direita para a esquerda e usa o primeiro endereço que **não** é um proxy confiável (`BP_TRUSTED_PROXIES`: `127.0.0.0/8`, `172.16.0.0/12` das redes Docker e o `BP_NPM_IP`, montados no Compose). O valor mais à esquerda, que o cliente poderia forjar, nunca é usado direto, e o firewall só deixa o NPM falar com a porta do painel. Esse IP real é o usado no bloqueio de login, na sessão e na auditoria. O Next não lê `BP_NPM_IP`.
- A checagem de `Origin` do CSRF (5.4) compara com a URL pública configurada em `BP_PUBLIC_URL` (ex.: `https://botpontos.lan`), não com o endereço interno.
- Os cookies continuam `Secure`, porque o navegador só fala HTTPS com o NPM.
### 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:
| Tema | Requisito |
| --- | --- |
| Sistema | **Debian 12 (bookworm)** com Docker Engine e plugin Compose instalados do repositório oficial da Docker (`download.docker.com`), não o pacote `docker.io` da distribuição |
| Rede | IP fixo (ou reserva DHCP) — é o `BP_HOST_LAN_IP` usado no Compose e no Proxy Host do NPM |
| Inicialização | `systemctl enable docker` e `restart: unless-stopped` em todos os serviços, para tudo voltar sozinho após queda de energia ou reboot |
| Relógio | **NTP obrigatório** (`systemd-timesyncd` ou `chrony`). Os lembretes dependem da hora certa; um relógio adiantado ou atrasado gera envios fora de hora ou `expired` |
| Fuso do host | Indiferente: a aplicação grava em UTC e calcula no fuso de cada usuário |
| Backup | Os dumps do `pg_dump` **não podem ficar só no mesmo disco**. Copiar diariamente para outra máquina ou NAS (ex.: `rsync`/`rclone` no fim do `backup-loop.sh`) |
| Chave de criptografia | Cópia guardada fora da máquina (cofre de senhas), independente do backup do banco |
| Energia | Recomendado nobreak; o Postgres tolera desligamento abrupto, mas ele aumenta o risco para o disco |
| Atualizações | Atualizar o SO e as imagens base (`postgres:17-alpine`, `node`, `golang`) periodicamente |
| Firewall | Além da `DOCKER-USER` (11.4), liberar SSH só para a rede de administração |
## 12. Observabilidade
- 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.
## 13. Testes
| Nível | Ferramenta | Foco | Situação |
| --- | --- | --- | --- |
| Unidade | `go test` | `rules` (jornada, janela, frequência adaptativa), `config`, `crypto`, clientes PontoMais e feriadosapi.com (servidor HTTP falso), comandos do bot, rotas de saúde e segurança da API | **Existe** e passa |
| Tipos do painel | `tsc --noEmit` | Componentes e chamadas à API | **Existe** e passa |
| Integração | `testcontainers-go` | Repositórios, `SKIP LOCKED`, migrations | Pendente |
| Contrato | `openapi.yaml` + validação | Handlers respeitam o contrato | Pendente (o `openapi.yaml` é mantido à mão) |
| Web | Vitest + Playwright | Login, ativação de usuário, cancelamento de notificação | Pendente |
Os comandos para rodar os testes em containers estão no `README.md` ("Desenvolvimento e testes").
## 14. Plano de sprints sugerido
| Sprint | Entrega |
| --- | --- |
| 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 |
## 15. Decisões registradas (ADR resumido)
| # | Decisão | Motivo |
| --- | --- | --- |
| 1 | PostgreSQL no lugar do SQLite | Vários containers escrevendo; `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) |
| 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) |
| 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 |
| 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 |
| 16 | Avisos operacionais vão pelo Telegram para os admins do bot | Um único canal, que já existe |
| 17 | Usar o Nginx Proxy Manager existente (em outra máquina), sem proxy próprio | Ele já resolve HTTPS, certificados e lista de acesso; a porta 3000 só aceita o IP do NPM |
| 18 | Produção em máquina física Debian 12 com Docker, fora do Proxmox | Ambiente disponível hoje; exige NTP, firewall via `DOCKER-USER` e backup fora da máquina |
| 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) |
| 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` |
| 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) |
| 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 |
## 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.
**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`.
**Ainda não verificado automaticamente:** acesso ao Postgres (repositórios, migrations, `SKIP LOCKED`), worker e fluxo do painel no navegador. Isso depende dos testes de integração e web da seção 13 ou da subida com Docker Compose (DEPLOY.md, etapas 6 e 7).
**Pendências externas** (dependem do operador):
- token do bot no @BotFather e chave da feriadosapi.com;
- credenciais PontoMais de cada usuário;
- chave de criptografia com cópia fora do servidor;
- certificado interno e Proxy Host no NPM; DNS `botpontos.lan`;
- cópia dos backups para fora da máquina (11.5);
- medir no piloto o atraso do `last_cached` da PontoMais (8.2).
**Pendências de código:** testes de integração com Postgres real, testes de contrato e testes web (13); endpoint `/metrics`, se um dia houver Prometheus (12).