- 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>
1151 lines
81 KiB
Markdown
1151 lines
81 KiB
Markdown
# 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).
|