73 KiB
Bot de Pontos v2 — Documento de Arquitetura
Status: Sprints 0 a 6 implementadas, ainda não compiladas nem testadas (ver
README.mdna raiz do projeto e a seção 16) Base: projetoModelo(versão final emModelo/Fase2/Sprint4) Stack alvo: Go (backend) · Next.js (web) · PostgreSQL · 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 |
| 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
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
BKP --> DB
Regras de exposição
- Só o
web-nextrecebe 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). apiepostgresficam apenas na rede interna do Compose, semports:.- O
workernão recebe conexões; o bot usa long polling no Telegram.
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.
┌──────────── 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
backend/
├── cmd/
│ └── botpontos/ # binário único: api | worker | admin | migrate
├── 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 + cache anual
│ │ └── 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
├── api/
│ └── openapi.yaml # contrato da API (fonte dos tipos do Next)
└── 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 |
migrate (one-shot) |
Aplica as migrations antes de api e worker subirem. |
botpontos admin ... |
docker compose run |
Bootstrap: primeira conta owner, rotação de chave etc. |
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) |
| Testes de integração | testcontainers-go com Postgres real |
5. Frontend Next.js
5.1 Papel
O Next.js é só a interface administrativa 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
web/
├── app/
│ ├── (auth)/login/page.tsx
│ ├── (painel)/
│ │ ├── layout.tsx # verifica a sessão (server component)
│ │ ├── page.tsx # dashboard
│ │ ├── usuarios/
│ │ │ ├── page.tsx
│ │ │ └── [id]/page.tsx
│ │ ├── notificacoes/page.tsx
│ │ ├── auditoria/page.tsx
│ │ ├── feriados/page.tsx
│ │ ├── configuracoes/page.tsx # janela de consulta (9.13)
│ │ └── contas/page.tsx # contas web (somente owner)
├── components/ # botões de ação, campos da janela, logout
├── 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
│ └── 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 → 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 Server Actions ou
fetch('/api/v1/...')no cliente. Orewritedo 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 doopenapi.yamle dehttpapi/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:
POST /api/v1/auth/loginvalida a senha com Argon2id, aplica o bloqueio por tentativas e cria a sessão no Postgres, guardando só o hash do token.- O Go responde com o cookie
bp_session(HttpOnly,Secure,SameSite=Strict,Path=/). - CSRF: é usado double submit. O cookie
bp_csrf(legível pelo JS) precisa ser repetido no headerX-CSRF-Tokenem toda requisição mutável. Além disso, o Go confere o headerOrigin. - O
middleware.tsdo Next só verifica se o cookie existe, para redirecionar. Quem autoriza de verdade é sempre o Go. - 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 emdate. - Estados em tipos
enumdo Postgres. created_ateupdated_at, com trigger para atualizarupdated_at.- Migrations só para a frente, numeradas e em SQL.
6.2 Esquema proposto
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,
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,
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 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),
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, -- 'feriadosapi' | 'feriados.dev' (legado) | 'manual'
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),
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),
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
userse 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
holidaysguarda feriados confirmados e também feriados manuais cadastrados pelo painel. A ordem de consulta está na seção 9.9. audit_logs.detailspassa a serjsonb, o que permite filtrar no painel.
6.3 Despacho sem duplicidade
-- 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_connectionspadrã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 |
| 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) |
| PUT | /settings |
admin | Altera as configurações globais; valida conforme 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/POST/DELETE | /holidays |
admin | Feriados manuais e sincronização |
| GET/POST/PATCH | /accounts |
owner | Contas web e papéis |
| POST | /accounts/{id}/reset-password |
owner | Resolve a pendência "sem redefinição de senha" |
Erros seguem o formato application/problem+json (RFC 9457):
{ "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)
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) 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() |
| Limpeza | 5 min | — | Marca como failed o que está em sending há mais de 5 min (BP_SENDING_TIMEOUT); 1×/dia 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 mesmoselect, então uma reconciliação lenta segura todos os envios. - A reconciliação tem prazo total (deadline) de 5 min por ciclo e timeout de 10 s por chamada à PontoMais. 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çoesaida2 = 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
failednã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.:
entrada2registrada), a reconciliação cancela o lembrete antes do despacho, como no Modelo. - O painel mostra
expiredcomo um status próprio, separado defailed, 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:
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)
Só administradores acessam o painel. O usuário comum continua usando só o Telegram.
| Ação | viewer |
admin |
owner |
|---|---|---|---|
| Ver dashboard, usuários, notificações, auditoria e feriados | ✓ | ✓ | ✓ |
| 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) | ✓ | ✓ | |
| Criar/desativar contas web, alterar papéis, redefinir senhas | ✓ |
- Conta de leitura ligada a um usuário (
viewercomweb_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
ownerativo. A API recusa rebaixar ou desativar o último. - 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 /accountsePATCH /accounts/{id}aceitamuser_id. NoPATCH, campo ausente mantém o vínculo,nullremove e um número troca.- O usuário precisa existir e não pode estar ligado a outra conta (
422no campouser_id). A troca é auditada emweb_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.:
entrada1não detectada, almoço não iniciado): não entram por enquanto. Onotification_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:
- Fim de semana (no fuso do usuário) não é dia útil.
- Tabela
holidays: se a data estiver lá (vinda da API ou cadastrada manualmente), não é dia útil. - 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ão3106200(código IBGE)) e grava emholidays. - 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.
9.10 Retenção de dados
Aplicada pelo job de limpeza, uma vez por dia, de madrugada:
| Dado | Mantido por |
|---|---|
audit_logs |
1 ano |
notifications |
90 dias (por reference_date) |
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 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
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
workinge 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):
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, o worker grava os padrões (
11:30,19:00,11:45). As variáveisBP_QUERY_WINDOW_START,BP_QUERY_WINDOW_ENDeBP_DAY_CHECK_DEADLINEservem 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
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
/statusinforma 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_changedna 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 sem SUPERUSER; usuário separado para migrations. |
| Containers | Imagens distroless/alpine, usuário não-root, read_only: true, cap_drop: [ALL]. |
| 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.23-alpine→gcr.io/distroless/static). Um binário estático com migrations embutidas, imagem de ~15 MB. - Next:
node:22-alpinecomoutput: 'standalone', imagem de ~150 MB.
11.2 docker-compose.yml (esqueleto)
services:
postgres:
image: postgres:17-alpine
environment:
POSTGRES_DB: botpontos
POSTGRES_USER: botpontos
POSTGRES_PASSWORD_FILE: /run/secrets/pg_password
volumes: [pgdata:/var/lib/postgresql/data]
secrets: [pg_password]
healthcheck:
test: ["CMD-SHELL", "pg_isready -U botpontos"]
interval: 10s
networks: [internal]
migrate:
image: botpontos-backend
command: ["migrate", "up"]
depends_on: { postgres: { condition: service_healthy } }
secrets: [pg_password]
networks: [internal]
api:
image: botpontos-backend
command: ["api"]
depends_on: { migrate: { condition: service_completed_successfully } }
secrets: [pg_password, encryption_key]
networks: [internal, egress] # egress: testar credenciais na PontoMais
worker:
image: botpontos-backend
command: ["worker"]
depends_on: { migrate: { condition: service_completed_successfully } }
secrets: [pg_password, encryption_key, telegram_token, holiday_api_key]
networks: [internal, egress]
web:
image: botpontos-web
environment:
API_INTERNAL_URL: http://api:8080
depends_on: [api]
# O NPM está em outra máquina da rede (ver 11.4).
# Publica só no IP de rede local do servidor; o acesso é liberado apenas para o IP do NPM.
ports: ["${BP_HOST_LAN_IP}:3000:3000"]
networks: [internal, ingress]
backup:
image: postgres:17-alpine
entrypoint: ["/scripts/backup-loop.sh"] # pg_dump diário + retenção
volumes: [./backups:/backups, ./scripts:/scripts:ro]
secrets: [pg_password]
networks: [internal]
volumes:
pgdata:
networks:
internal: { internal: true }
egress: {}
ingress: {} # rede com porta publicada, usada só pelo web
secrets:
pg_password: { file: ./secrets/pg_password }
encryption_key: { file: ./secrets/encryption_key }
telegram_token: { file: ./secrets/telegram_token }
holiday_api_key: { file: ./secrets/holiday_api_key }
A api também tem saída para a internet (rede egress), porque o teste e a validação de credenciais na PontoMais (PUT /users/{id}/credentials, POST /users/{id}/credentials/test) são feitos por ela, na hora, sem passar pelo worker.
11.3 Backup e restauração
pg_dump -Fcdiário, com retenção de 14 dias, num volume montado.- Restauração documentada com
pg_restorenum 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.
Navegador ──HTTPS──► NPM (outra máquina) ──HTTP :3000──► servidor Docker: web ──► api ──► postgres
- O
webpublica a porta3000só no IP de rede local do servidor (BP_HOST_LAN_IP), nunca em0.0.0.0. - A porta 3000 é 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
apie opostgresnã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:
# 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, uma unit systemd aplica só essa regra depois que o Docker sobe, sem duplicar:
# /etc/systemd/system/botpontos-firewall.service
[Unit]
Description=Restringe a porta 3000 do Bot de Pontos ao NPM
After=docker.service
Requires=docker.service
[Service]
Type=oneshot
RemainAfterExit=yes
Environment=NPM_IP=IP_DO_NPM
ExecStart=/bin/sh -c 'iptables -C DOCKER-USER -p tcp -m conntrack --ctorigdstport 3000 ! -s $NPM_IP -j DROP 2>/dev/null || iptables -I DOCKER-USER -p tcp -m conntrack --ctorigdstport 3000 ! -s $NPM_IP -j DROP'
[Install]
WantedBy=multi-user.target
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 |
| 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_NPMno 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-ForeX-Forwarded-Proto. A cadeia de confiança é: o Next só aceita esses headers vindos do IP do NPM (BP_NPM_IP), e o Go só aceita os headers repassados pelo containerweb(BP_TRUSTED_PROXIES). Como o firewall só deixa o NPM falar com a porta 3000, ninguém consegue forjar o IP de origem. Esse IP real é o usado no bloqueio de login e na auditoria. - A checagem de
Origindo CSRF (5.4) compara com a URL pública configurada emBP_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) comrequest_id,user_idejob. /health/livee/health/readynos healthchecks do Compose.- Métricas preparadas, não expostas. O pacote
internal/metricsdefine 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
Recorderpor uma baseada emprometheus/client_golange expor/metricssó na rede interna. Nenhum job precisa mudar.
13. Testes
| Nível | Ferramenta | Foco |
|---|---|---|
| Unidade | go test |
rules/ e app/ com fakes dos ports (relógio, PontoMais, Telegram) |
| Integração | testcontainers-go |
Repositórios, SKIP LOCKED, migrations |
| Contrato | openapi.yaml + validação |
Handlers respeitam o contrato; os tipos do Next são gerados dele |
| Web | Vitest + Playwright | Login, ativação de usuário, cancelamento de notificação |
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 |
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 |
Tipos do TypeScript gerados automaticamente; evita divergência entre Go e Next |
| 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 |
| 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 |
16. Estado da implementação e pendências
Implementado (Sprints 0 a 6): tudo o que as seções 4 a 14 descrevem, com os desvios da decisão 27.
Não verificado: o código nunca foi compilado nem testado, por regra do ambiente onde foi escrito. A primeira validação está no README.md ("Primeira validação"): go mod tidy, go vet, go test, npm install, tsc e subida com Docker Compose.
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_cachedda PontoMais (8.2).
Testes automatizados existentes: regras de jornada, janela e frequência adaptativa (rules), configuração, criptografia, cliente PontoMais (com servidor HTTP falso), comandos do bot e rotas de saúde/segurança da API. Ainda faltam testes de integração com Postgres real (13).