Files
bot-pontos/docs/ARQUITETURA.md
2026-11-05 22:40:10 -03:00

85 KiB
Raw Blame History

Bot de Pontos — Documento de Arquitetura

Estado: implementado — backend compila e passa nos testes; painel passa na checagem de tipos (seção 16) Stack: Go 1.25 (backend) · Next.js 15 (painel) · PostgreSQL 17 · Docker Compose

Este documento descreve o sistema como ele é hoje. Os números das seções são citados nos comentários do código; ao editar, mantenha a numeração.


1. Visão geral

O Bot de Pontos lembra cada usuário, pelo Telegram, da hora de voltar do almoço e da hora de encerrar a jornada. Para isso, consulta os pontos do dia na PontoMais, calcula o retorno (entrada2) e a saída (saida2) previstos e envia o aviso alguns minutos antes de cada horário.

  • O usuário comum usa o Telegram: cadastro, consulta ao vivo e liga/pausa os lembretes.
  • Os administradores usam um painel web para aprovar usuários, configurar credenciais e jornadas, acompanhar lembretes e auditoria, manter feriados e a janela de consulta e gerenciar as contas do painel.
  • Nada é registrado na PontoMais: o sistema só lê os pontos.

2. Características principais

Tema Como funciona
Interfaces Bot do Telegram (long polling) e painel Next.js que consome a API REST do Go (/api/v1)
Banco PostgreSQL 17 em container próprio; instantes em timestamptz, dias em date
Processos Uma imagem Go com os subcomandos api, worker, migrate, admin, healthcheck e version
Ciclos Reconciliação a cada 15 min e despacho a cada 2 min, em goroutines independentes (8.2)
Consultas à PontoMais Só na janela 11:30–19:00, com frequência adaptativa por usuário; ~11 consultas/usuário num dia típico (9.12)
Despacho SELECT ... FOR UPDATE SKIP LOCKED e worker único por advisory lock; no máximo uma mensagem por lembrete (6.3)
Fuso Cada usuário tem o seu; todos os cálculos usam esse fuso
Jornada Por usuário (almoço, jornada, antecedências, janela opcional); mensagens formatadas a partir dela
Ativação Um único caso de uso para Telegram e painel; exige credencial validada
Contas do painel Várias contas com papéis owner, admin e viewer, inclusive leitura restrita a um usuário (9.6)
Implantação Docker Compose numa máquina Debian, atrás do Nginx Proxy Manager da rede (11)

3. Visão de containers

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.

         ┌──────────── 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 | 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
│   │   ├── 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, sem framework
Logs log/slog em JSON
Validação validação manual nos DTOs (sem framework)
Telegram cliente próprio, sem biblioteca externa (ver decisão 27)
Criptografia e terminal golang.org/x/crypto (Argon2id), golang.org/x/term (senha sem eco na CLI)
Testes de integração não há; o plano é testcontainers-go com Postgres real (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

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 (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 fica no Go, centralizada:

  1. POST /api/v1/auth/login valida a senha com Argon2id, aplica o bloqueio por tentativas e cria a sessão no Postgres, guardando só o hash do token.
  2. O Go responde com o cookie bp_session (HttpOnly, Secure, SameSite=Strict, Path=/).
  3. CSRF: é usado double submit. O cookie bp_csrf (legível pelo JS) precisa ser repetido no header X-CSRF-Token em toda requisição mutável. Além disso, o Go confere o header Origin.
  4. O middleware.ts do Next só verifica se o cookie existe, para redirecionar. Quem autoriza de verdade é sempre o Go.
  5. Sessão absoluta de 8h e 30 min de inatividade (BP_SESSION_DURATION, BP_SESSION_IDLE). Após 5 senhas erradas seguidas, a conta fica bloqueada por 15 min.

6. Banco de dados (PostgreSQL)

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 numeradas, em SQL (goose), embutidas no binário e aplicadas pelo serviço migrate.

6.2 Esquema

Resumo do esquema resultante das migrations em backend/db/migrations/. Triggers de updated_at, índices secundários e permissões ficam nos arquivos 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 do usuário
CREATE TABLE work_schedules (
    user_id               bigint PRIMARY KEY REFERENCES users(id),
    lunch_minutes         int NOT NULL DEFAULT 60  CHECK (lunch_minutes BETWEEN 15 AND 180),
    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,
    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 (                 -- feriados da API e manuais
    day        date PRIMARY KEY,
    name       text NOT NULL,
    source     text NOT NULL CHECK (source IN ('feriadosapi', 'feriados.dev', 'manual')),  -- feriados.dev: só registros antigos
    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,
    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,
    actor_account_name text,            -- nome da conta no momento do evento (sobrevive à exclusão)
    target_user_id bigint REFERENCES users(id),
    event_type     text    NOT NULL,
    success        boolean NOT NULL,
    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);

Notas sobre o esquema:

  • Credencial e jornada ficam em tabelas próprias, separadas do cadastro. A senha da PontoMais é cifrada com AES-256-GCM, e key_version permite trocar a chave (botpontos admin rotate-key).
  • A antecedência pode ser diferente para cada lembrete (entrada2_lead_minutes / saida2_lead_minutes).
  • A tabela holidays guarda os feriados vindos da API e os feriados manuais cadastrados pelo painel. A ordem de consulta está na seção 9.9.
  • audit_logs.details é jsonb, sem segredos.
  • notifications.predicted_at guarda o horário previsto do ponto, para a mensagem sempre informar a hora certa (9.3).
  • O banco tem dois usuários: botpontos (dono, usado pelas migrations e pelo backup) e botpontos_app (API e worker, só SELECT/INSERT/UPDATE/DELETE), criado por postgres/init/01-create-app-role.sh.

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 garantir "no máximo uma mensagem" por lembrete.

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 Define nova senha e encerra as sessões da conta

As rotas marcadas como viewer têm restrições extras para a conta de leitura ligada a um usuário (tabela em 9.6). Erros seguem o formato application/problem+json (RFC 9457):

{ "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, 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, então uma reconciliação lenta não atrasa os envios.
  • A reconciliação tem prazo total (deadline) de 5 min por ciclo (BP_RECONCILIATION_DEADLINE) e timeout de 10 s por chamada à PontoMais (BP_PONTOMAIS_TIMEOUT). Usuários que não terminarem dentro do prazo ficam para o próximo ciclo.
  • Se um ciclo ainda estiver rodando quando o próximo tick chegar, o tick é ignorado e não se acumula.
  • Os ticks são alinhados ao relógio (ex.: :00, :02, :04...) para o comportamento ser previsível nos logs.

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, então Telegram e painel aplicam as mesmas regras (ex.: ativação só com credencial validada). O bot só responde em conversa privada; mensagens de grupos são ignoradas.

8.4 Comandos do Telegram

Usuário

Comando Quem pode Comportamento
/start, /iniciar qualquer chat Chat desconhecido recebe orientação para usar /cadastrar; usuário conhecido recebe a ajuda do seu papel
/cadastrar qualquer chat Cria o cadastro pending (idempotente por chat_id) e avisa os admins
/ajuda cadastrado Lista os comandos do papel
/status cadastrado Cadastro, credencial (configurada/validada, sem dados), lembretes ligados ou pausados e motivo da pausa (manual, 3 falhas na API), a situação do dia (ex.: "hoje: nenhum ponto até 11:45, consultas encerradas") e a janela de consulta que vale para o usuário (indicando se é a global ou personalizada)
/entrada2 ativo + credencial Consulta ao vivo e informa o retorno previsto (≥ 2 pontos), no fuso do usuário. Funciona a qualquer hora, fora da janela também; se encontrar pontos num dia no_points/not_working, retoma o dia (9.12)
/saida2 ativo + credencial Consulta ao vivo e informa a saída prevista (≥ 3 pontos). O texto usa a jornada configurada do usuário (ex.: "Para completar 6h00")
/ativarponto ativo + credencial validada Liga os lembretes, zera o contador de falhas e, se o dia estiver no_points/not_working, retoma as consultas de hoje (9.12)
/desativarponto ativo Pausa os lembretes e cancela os scheduled do dia

Admin do Telegram (is_bot_admin = true e status active)

Comando Comportamento
/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 Cálculo dos horários

  • Quatro pontos normais por dia: entrada1, saida1, entrada2, saida2, na ordem em que aparecem na PontoMais.
  • entrada2 = saida1 + almoço e saida2 = entrada2 + (jornada − (saida1 − entrada1)).
  • O lembrete sai antecedência minutos antes do horário previsto (entrada2_lead_minutes / saida2_lead_minutes, 5 min por padrão).
  • No máximo um lembrete por (usuário, dia, tipo).
  • Mais de 4 pontos conta como hora extra e fica fora do escopo (9.7).
  • Notificação failed não é reenviada automaticamente (o reenvio manual está na seção 9.5).

9.2 Regras gerais

Regra Como é aplicada
Fuso do usuário O dia de referência e todos os horários são calculados em users.timezone
Jornada nas mensagens O texto usa daily_work_minutes do usuário (ex.: "jornada de 6h00")
Ativação Um caso de uso ActivateUser para Telegram e painel; exige credencial validada
Atraso no almoço Um lembrete calculado para um horário que já passou segue a regra de 9.3; o usuário nunca é pausado por atraso
Ciclos Reconciliação e despacho independentes; uma reconciliação lenta não segura os envios (8.2)
Contas do painel Várias contas com papéis; redefinir a senha encerra as sessões da conta (9.6)

9.3 Lembrete atrasado

Regra: se o lembrete atrasar até 10 min em relação ao scheduled_at, ele é enviado. Acima de 10 min, não é enviado: fica com status expired e é registrado na auditoria (event_type = 'notification_expired', com o atraso em minutos em details). O usuário não é pausado.

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

Regra: os lembretes do usuário só são pausados depois de 3 falhas seguidas na consulta à PontoMais.

O limite fica na variável BP_API_FAILURE_THRESHOLD, com 3 de padrão.

O que conta como uma falha:

  • Conta uma falha por ciclo de reconciliação, por usuário. Dentro do mesmo ciclo o cliente faz 1 nova tentativa rápida (após ~3 s) e, no caso de 401/403, refaz o login uma vez. 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.
/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

Um lembrete failed pode ser reenviado manualmente pelo painel, num botão com confirmação. Não existe reenvio automático.

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. Antes de reenviar, a API consulta a PontoMais ao vivo: se o ponto seguinte já tiver sido batido (ex.: entrada2 já registrada), o reenvio é bloqueado; se a PontoMais não responder, o reenvio também é recusado (409) e pode ser tentado de novo depois.
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

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 vê tudo (menos Contas), sem editar.
  • A primeira conta owner é criada pela CLI (botpontos admin create-owner).
  • Precisa existir sempre pelo menos um owner ativo. A API recusa rebaixar, desativar ou excluir o último.
  • Ninguém pode desativar nem excluir a própria conta (a API responde 409).
  • Excluir conta (DELETE /accounts/{id}, resposta 204) apaga a linha de web_accounts. As sessões vão junto (ON DELETE CASCADE), e audit_logs.actor_account e app_settings.updated_by_account ficam NULL. Como o nome da conta é gravado em audit_logs.actor_account_name em cada evento, a auditoria continua mostrando quem fez. Desativar é a alternativa reversível.
  • O vínculo entre conta web e usuário do Telegram (web_accounts.user_id) é opcional; o owner define e troca em Contas. O admin do Telegram (is_bot_admin) e as contas do painel são independentes.

Conta de leitura restrita (como a API aplica)

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.
  • Leitura sem vínculo não é restrita: vê tudo (menos Contas) e não edita.

No painel, web/lib/access.ts repete a regra só para esconder menu e botões; a segurança está na API.

9.7 Fora do escopo

  • 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). O notification_type é um enum; novos tipos exigiriam uma migration (14).
  • Acesso do usuário comum ao painel, exceto pela conta de leitura ligada a ele, criada pelo owner (9.6).
  • Registro de ponto na PontoMais.

9.8 Validação da jornada

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 para a cidade configurada (BP_HOLIDAY_LOCATION, código IBGE; padrão 3106200, Belo Horizonte) e grava em holidays.
  4. Fallback: se o ano ainda não foi sincronizado e a API falhar, segunda a sexta contam como dias úteis. Os admins são avisados uma vez por dia enquanto isso durar, e o evento é auditado (holiday_sync_failed).

A sincronização nunca apaga feriados manuais nem sobrescreve uma data que já esteja na tabela.

API usada (feriadosapi.com):

  • Chamada: GET {BP_HOLIDAY_API_BASE_URL}/feriados/cidade/{ibge}?ano=AAAA, com Authorization: Bearer <chave>. Padrão da base: https://feriadosapi.com/api/v1; chave em secrets/holiday_api_key.
  • BP_HOLIDAY_LOCATION é o código IBGE da cidade (Belo Horizonte: 3106200). Um valor não numérico 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). Em falha, no máximo uma tentativa por dia por processo. O botão Sincronizar do painel chama a API a cada clique.
  • Origem gravada em holidays.source: feriadosapi (ou manual para os cadastrados no painel).

9.10 Retenção de dados

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) 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 À 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.

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 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 está fora do alcance da aplicação.
  • O usuário não recebe mensagem sobre no_points/not_working, para não gerar aviso diário em férias e folgas. A situação aparece no /status, no painel e na auditoria (day_no_points, day_not_worked).

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
Sem janela (a cada 15 min o dia todo) 96 ~1.920
Só a janela 11:30–19:00 até 31 ~620
Janela + frequência adaptativa, 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 o sistema não guarda os pontos. A pré-visualização também informa quantos usuários têm janela personalizada e, portanto, não serão afetados.

Quando passa a valer: o worker lê app_settings no começo de cada ciclo, então a mudança vale a partir do próximo ciclo, sem reiniciar nada. Dias já marcados como not_working hoje continuam assim (podem ser retomados como em 9.12); os next_check_at já calculados são recalculados no próximo tick.

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

  • /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.

11.4 Proxy reverso: Nginx Proxy Manager existente

A rede onde o projeto roda já tem um Nginx Proxy Manager (NPM). Ele faz o papel de proxy HTTPS, então o projeto não inclui Caddy nem Traefik.

Topologia: o NPM roda em outra máquina da mesma rede.

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:

# 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):

[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)

Certificado interno

O HTTPS do painel usa um certificado emitido pela CA interna da rede, carregado no NPM como Custom Certificate. Não há Let's Encrypt nem domínio público.

  • 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 é 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). A implementação atual é no-op (não faz nada); os contadores do dashboard vêm de consultas ao banco. Não existe endpoint /metrics.
  • Para ativar o Prometheus: trocar a implementação do Recorder por uma baseada em prometheus/client_golang e expor /metrics só na rede interna. Nenhum job precisa mudar.

13. Testes

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. Pontos de extensão

O que a arquitetura já prevê, sem implementação hoje:

Extensão Onde entra
Novos tipos de lembrete (ex.: entrada1 não detectada) Novo valor no enum notification_type (migration) e regra em rules/app
Outro sistema de ponto Novo adapter que implemente o port PointsProvider (app/service.go)
Métricas Prometheus Implementação do Recorder com /metrics na rede interna (12)
Outras configurações globais no painel Novas chaves em app_settings (9.13), sem mudar a estrutura
Tipos TypeScript gerados openapi-typescript a partir do openapi.yaml (decisão 27)
Testes de integração e web Seção 13

15. Decisões registradas (ADR resumido)

# Decisão Motivo
1 PostgreSQL Vários containers escrevendo com segurança; SKIP LOCKED; crescimento futuro
2 Next.js como BFF, auth no Go Uma fonte de verdade de autorização; sem CORS; API não exposta
3 Binário Go único com subcomandos Uma imagem, mesmo código, deploy simples
4 Hexagonal com ports explícitos Regras testáveis sem rede; adapters trocáveis (ex.: outro sistema de ponto)
5 openapi.yaml como contrato Evita divergência entre Go e Next; 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
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 para administradores, com papéis owner/admin/viewer O usuário comum já tem o que precisa no Telegram; quem precisar ver os próprios dados recebe uma conta restrita (decisão 31)
13 Jornada exige almoço − antecedência ≥ intervalo de reconciliação Garante que o lembrete de entrada2 seja agendado a tempo
14 Feriados: tabela local → API → fallback seg–sex com aviso A automação continua funcionando, com a falha visível aos admins
15 Retenção: auditoria 1 ano, notificações 90 dias, sessões 7 dias Mantém o banco pequeno sem perder histórico útil
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 Roteador nativo do Go, goose e api com saída para a internet Menos dependências; validação de credencial, reenvio e sincronização de feriados na hora, sem passar pelo worker
23 Métricas preparadas por interface, sem expor /metrics Não há Prometheus na rede hoje; ativar depois não exige refatoração
24 Horários da janela editáveis no painel (app_settings), com validação e pré-visualização Ajuste sem redeploy; a validação evita configurações que quebrariam os lembretes
25 Frequência adaptativa: 15 min até o 3º ponto, depois 60 min + 1 consulta de confirmação antes do lembrete de saída ~11 consultas/usuário/dia em vez de ~31, mantendo o cancelamento quando a pessoa sai antes
26 Janela global com exceção completa e opcional por usuário Atende horários diferentes sem complicar o caso comum; selo, /status e auditoria deixam a exceção visível
27 Sem geradores de código nem frameworks de UI: SQL à mão com pgx, cliente Telegram próprio, tipos TypeScript à mão e CSS próprio Menos dependências e nenhuma etapa de geração no build. sqlc, openapi-typescript ou uma biblioteca de UI podem ser adotados sem mudar a arquitetura
28 app usa o repositório Postgres concreto; os ports ficam só para integrações externas Menos código de interface; as regras puras são testadas em rules
29 Em queda da PontoMais já registrada, falhas de ciclos com um único usuário consultado também não contam Com a frequência adaptativa, muitos ciclos consultam só um usuário; sem isso, alguém poderia ser pausado durante uma queda conhecida
30 Feriados pela feriadosapi.com, por código IBGE da cidade Traz feriados nacionais e municipais numa chamada por ano
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 mais novas das bibliotecas exigem Go 1.26

16. Estado da implementação e pendências

Implementado: tudo o que as seções 3 a 13 descrevem. A seção 14 lista o que é só previsto.

Verificado: o backend compila e passa em go vet e go test; o painel passa em tsc --noEmit. As dependências estão fixadas em backend/go.sum e web/package-lock.json.

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

17. Referência: variáveis de ambiente

Todas são lidas por backend/internal/config/config.go. Segredos aceitam a variante <NOME>_FILE, que aponta para um arquivo (é assim que o Compose passa os Docker secrets). Na prática só as do .env.example precisam ser ajustadas; as demais têm padrão.

Usadas só pelo Compose (.env): BP_HOST_LAN_IP (IP onde o painel é publicado, obrigatória), BP_WEB_PORT (porta do painel no host, padrão 3000) e BP_NPM_IP (somado a BP_TRUSTED_PROXIES).

Variável Padrão Uso
BP_LOG_LEVEL info debug, info, warn ou error
BP_DEFAULT_TIMEZONE America/Sao_Paulo Fuso padrão: novos usuários, hora da retenção e o "hoje" das rotinas globais
BP_DB_HOST · BP_DB_PORT · BP_DB_NAME postgres · 5432 · botpontos Conexão com o banco
BP_DB_USER · BP_DB_PASSWORD(_FILE) — (obrigatórias) botpontos_app na api/worker; botpontos no migrate
BP_DB_MAX_CONNS 10 Pool (o Compose usa 10 na api e 5 no worker)
BP_HTTP_ADDR :8080 Endereço da API
BP_WORKER_HEALTH_ADDR :8081 Health do worker
BP_WORKER_LEADER_RETRY 10s Intervalo entre tentativas de obter a liderança (4.3)
BP_RECONCILIATION_INTERVAL 15m Tick da reconciliação (8.2)
BP_RECONCILIATION_DEADLINE 5m Prazo total de um ciclo de reconciliação
BP_RECONCILIATION_WORKERS 4 Usuários consultados em paralelo
BP_DISPATCH_INTERVAL 2m Tick do despacho
BP_MAINTENANCE_INTERVAL 5m Tick da manutenção
BP_LATE_TOLERANCE 10m Atraso máximo para ainda enviar (9.3)
BP_SENDING_TIMEOUT 5m sending parado vira failed (6.3)
BP_SLOW_CHECK_INTERVAL 60m Consultas lentas (9.12)
BP_EXIT_CONFIRM_WINDOW 20m..5m Janela da consulta de confirmação antes do saida2
BP_API_FAILURE_THRESHOLD 3 Falhas seguidas até pausar (9.4)
BP_RETENTION_AUDIT · BP_RETENTION_NOTIFICATIONS · BP_RETENTION_SESSIONS 8760h · 2160h · 168h Retenção (9.10)
BP_RETENTION_HOUR 3 Hora da limpeza diária
BP_QUERY_WINDOW_START · BP_DAY_CHECK_DEADLINE · BP_NO_POINTS_RECHECK_UNTIL · BP_QUERY_WINDOW_END 11:30 · 11:45 · 15:00 · 19:00 Só os padrões gravados na 1ª subida (9.13)
BP_PONTOMAIS_AUTH_URL · BP_PONTOMAIS_HISTORY_URL URLs de api.pontomais.com.br Login e histórico (last_cached)
BP_PONTOMAIS_TIMEOUT 10s Timeout por chamada à PontoMais
BP_ENCRYPTION_KEY(_FILE) — (obrigatória, ≥ 32 caracteres) Chave AES-256 das senhas da PontoMais
BP_ENCRYPTION_KEY_VERSION 1 Versão da chave atual
BP_ENCRYPTION_PREVIOUS_KEY(_FILE) · BP_ENCRYPTION_PREVIOUS_KEY_VERSION vazio · 0 Só durante a troca de chave (secrets/README.md)
BP_TELEGRAM_TOKEN(_FILE) — (obrigatória no worker) Token do @BotFather
BP_TELEGRAM_POLL_TIMEOUT 25 Segundos do long polling
BP_HOLIDAY_API_BASE_URL https://feriadosapi.com/api/v1 Base da API de feriados
BP_HOLIDAY_API_KEY(_FILE) vazio Chave da feriadosapi.com (vazio = fallback seg–sex)
BP_HOLIDAY_LOCATION 3106200 Código IBGE da cidade (9.9)
BP_PUBLIC_URL vazio URL pública do painel, usada na checagem de Origin (vazio só em desenvolvimento)
BP_COOKIE_SECURE true Cookies Secure (use false só em http:// local)
BP_SESSION_DURATION · BP_SESSION_IDLE 8h · 30m Sessão absoluta e ociosa
BP_LOGIN_MAX_FAILURES · BP_LOGIN_LOCK 5 · 15m Bloqueio de login
BP_TRUSTED_PROXIES 127.0.0.0/8,172.16.0.0/12 Proxies cujo X-Forwarded-For é aceito (11.4)

18. Referência: eventos de auditoria

O filtro Evento da tela Auditoria compara o nome exato (ex.: user_activated). Origem: telegram, web (painel), worker ou cli.

Evento Quando é gravado
user_registered /cadastrar
user_activated · user_disabled Ativação ou desativação do usuário (painel ou Telegram)
bot_admin_changed Liga/desliga o admin do bot
schedule_changed · user_window_changed Jornada alterada; janela personalizada criada, alterada ou removida (9.14)
schedule_window_violation Na subida do worker, jornada fora da regra de 9.8
credentials_changed · credentials_tested Credencial PontoMais salva; teste da credencial
notifications_enabled · notifications_disabled Lembretes ligados ou pausados (usuário ou painel)
notifications_paused_api_failure Pausa automática após 3 falhas (9.4)
point_api_failure Falha individual na consulta à PontoMais
point_api_outage_started · point_api_outage_ended Início e fim de queda geral da PontoMais
invalid_point_history · invalid_workday_calculation Pontos fora de ordem ou cálculo impossível
day_no_points · day_not_worked · day_resumed Situação do dia (9.12)
notification_scheduled · notification_sent · notification_failed Lembrete agendado, enviado ou com erro no Telegram
notification_expired Atraso acima da tolerância (9.3)
notification_cancelled Cancelado (ponto seguinte batido, pausa ou painel)
notification_interrupted sending parado, marcado como failed pela manutenção
notification_resent Reenvio manual (9.5)
holiday_synced · holiday_sync_failed Sincronização de feriados com sucesso ou falha (fallback)
holiday_added · holiday_removed Feriado manual cadastrado ou removido
settings_changed Janela global alterada (9.13)
web_login · web_login_failed · web_logout Acesso ao painel
web_account_created · web_account_changed · web_account_deleted · web_password_reset Gestão de contas do painel
retention_applied Limpeza diária (9.10)
encryption_key_rotated botpontos admin rotate-key

A constante telegram_failure existe no código (domain/audit.go), mas hoje nenhum ponto grava esse evento: falhas ao avisar os admins pelo Telegram ficam só no log.