Files
bot-pontos/docs/ARQUITETURA.md
2026-10-23 22:31:02 -03:00

73 KiB
Raw Blame History

Bot de Pontos v2 — Documento de Arquitetura

Status: Sprints 0 a 6 implementadas, ainda não compiladas nem testadas (ver README.md na raiz do projeto e a seção 16) Base: projeto Modelo (versão final em Modelo/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-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; 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. O rewrite do Next encaminha essas chamadas para o Go na mesma origem, então não há CORS e o cookie funciona direto.
  • Os tipos ficam em web/lib/types.ts, escritos à mão a partir do openapi.yaml e de httpapi/dto.go. A geração automática (openapi-typescript) pode ser adotada depois (decisão 27).
  • UI: CSS próprio (app/globals.css), com tema claro/escuro e layout responsivo; filtros por query string nas tabelas (decisão 27).

5.4 Autenticação

A autenticação continua no Go, porque a lógica já existe e fica centralizada:

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

6. Banco de dados (PostgreSQL)

6.1 Convenções

  • IDs bigint generated always as identity.
  • Instantes em timestamptz (gravados em UTC); dia de referência em date.
  • Estados em tipos enum do Postgres.
  • created_at e updated_at, com trigger para atualizar updated_at.
  • Migrations só para a frente, numeradas e em SQL.

6.2 Esquema 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 users e ganham tabelas próprias. Isso deixa o cadastro mais limpo e permite rotacionar a chave de criptografia (key_version).
  • A antecedência pode ser diferente para cada lembrete (entrada2_lead_minutes / saida2_lead_minutes).
  • A tabela holidays guarda feriados confirmados e também feriados manuais cadastrados pelo painel. A ordem de consulta está na seção 9.9.
  • audit_logs.details passa a ser jsonb, o que permite filtrar no painel.

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_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
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 mesmo select, 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ço e saida2 = entrada2 + (jornada − (saida1 − entrada1)).
  • No máximo um lembrete por (usuário, dia, tipo).
  • Mais de 4 pontos conta como hora extra e fica fora do escopo.
  • Notificação failed não é reenviada automaticamente (o reenvio manual está na seção 9.5).
  • Nada é registrado na PontoMais.

9.2 Corrigidas

Problema no Modelo Correção na v2
Fuso do usuário ignorado O cálculo do dia e dos horários usa users.timezone
Texto "8h00" fixo em /saida2 A mensagem formata daily_work_minutes
Ativação diferente entre Telegram e web Um caso de uso ActivateUser; regra única: exige credencial validada
Sem redefinição de senha web POST /accounts/{id}/reset-password + revogação das sessões
Uma só conta web Várias contas com papéis
Retorno atrasado do almoço pausa os lembretes do usuário Ver o detalhe abaixo
Reconciliação lenta bloqueia o despacho (mesmo loop) Goroutines independentes e despacho a cada 2 min (ver 8.2)

Detalhe do último item. No Modelo (scheduler/reconciliation.go), com 2 pontos, se o horário do lembrete já passou (scheduledAt.Before(now)), o usuário tem os lembretes do dia cancelados e as notificações desativadas. Isso acontece sempre que alguém fica mais tempo no almoço do que o previsto menos a antecedência e a reconciliação roda nesse intervalo. Na v2 vale a regra de lembrete atrasado da seção 9.3, e o usuário nunca é pausado por atraso.

9.3 Lembrete atrasado (decidido)

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

Os 10 min ficam na variável BP_LATE_TOLERANCE.

A regra vale nos dois pontos em que o atraso pode surgir:

Momento Situação Ação
Reconciliação Lembrete ainda não existe e o scheduled_at calculado já passou, com atraso ≤ 10 min Cria como scheduled; o próximo despacho (≤ 2 min) envia
Reconciliação Lembrete ainda não existe e o atraso é > 10 min Cria direto como expired (assim nada é recriado nos ciclos seguintes) e audita
Reconciliação Lembrete já existe (qualquer status) Não altera; só cancela, se o próximo ponto já tiver sido batido
Despacho scheduled com atraso > 10 min Muda para expired e audita (etapa 1 da seção 6.3)
Despacho scheduled com atraso ≤ 10 min Envia normalmente

Detalhes:

  • O atraso é medido em relação ao scheduled_at (horário planejado do aviso), não ao horário previsto do ponto. Com 5 min de antecedência, o limite significa que o aviso pode chegar até 5 min depois do horário previsto de voltar ou sair.
  • Por isso, toda mensagem informa o horário previsto, por exemplo "Seu retorno do almoço está previsto para 13:02". Se o aviso chegar atrasado, o usuário ainda sabe a hora exata. Quando já passou do horário previsto, o texto muda para "Seu retorno estava previsto para 13:02".
  • Se o ponto seguinte já tiver sido batido (ex.: entrada2 registrada), a reconciliação cancela o lembrete antes do despacho, como no Modelo.
  • O painel mostra expired como um status próprio, separado de failed, para diferenciar "chegou tarde demais" de "erro no envio".

9.4 Falha na PontoMais (decidido)

Regra: os lembretes do usuário só são pausados depois de 3 falhas seguidas na consulta à PontoMais. No Modelo, a pausa acontece na primeira falha.

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

O que conta como uma falha:

  • Conta uma falha por ciclo de reconciliação, por usuário. Dentro do mesmo ciclo o cliente faz 1 nova tentativa rápida (após ~3 s) e, no caso de 401/403, refaz o login uma vez, como no Modelo. Só se tudo isso falhar o ciclo conta como falha.
  • Contam: timeout, erro de rede, HTTP 5xx, 401/403 que persiste após o novo login e resposta impossível de interpretar.
  • Não contam: histórico com pontos inválidos ou fora de ordem. A API respondeu, então é um problema de dado, que só é auditado.

Espaçamento: as falhas acompanham a reconciliação, de 15 em 15 min. A pausa acontece cerca de 30 min depois da primeira falha:

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 (viewer com web_accounts.user_id): fica restrita ao próprio usuário. Vê Início (avisos do sistema e os próprios lembretes de hoje, sem links), Usuários (só a própria ficha), Lembretes (só os próprios, somente leitura) e Feriados (somente leitura). Pode alterar jornada, credencial PontoMais, testar a API e ligar/pausar os próprios lembretes. Não vê Auditoria, Configurações nem Contas e não ativa, desativa nem promove usuários. A regra é aplicada na API (authUser, authUnrestricted, isRestricted), não só no menu. Um usuário só pode estar ligado a uma conta. Leitura sem vínculo continua vendo tudo, sem editar.
  • A primeira conta owner é criada pela CLI (botpontos admin create-owner).
  • Precisa existir sempre pelo menos um owner ativo. A API recusa rebaixar 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 /accounts e PATCH /accounts/{id} aceitam user_id. No PATCH, campo ausente mantém o vínculo, null remove e um número troca.
  • O usuário precisa existir e não pode estar ligado a outra conta (422 no campo user_id). A troca é auditada em web_account_changed, com o usuário no alvo.
  • O papel e o vínculo são lidos da conta a cada requisição, então valem na hora, sem novo login.
  • Não há migration: usa web_accounts.user_id, que já existia.
  • Leitura sem vínculo não é restrita: mantém o comportamento anterior (vê tudo, não edita).

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

9.7 Fora do escopo (decidido)

  • Hora extra: com mais de 4 pontos, nenhum lembrete é criado e não há cálculo de saldo ou banco de horas.
  • Lembretes adicionais (ex.: entrada1 não detectada, almoço não iniciado): não entram por enquanto. O notification_type é um enum, então novos tipos podem ser adicionados depois por migration.
  • Acesso do usuário comum ao painel.
  • Registro de ponto na PontoMais.

9.8 Validação da jornada

Validações ao salvar a jornada (PUT /users/{id}/schedule). Quando uma falha, a API responde 422 com o campo e o motivo.

Campo Regra
lunch_minutes 15 a 180
daily_work_minutes 60 a 720
entrada2_lead_minutes, saida2_lead_minutes 0 a 60
Combinação lunch_minutes − entrada2_lead_minutes ≥ BP_RECONCILIATION_INTERVAL (15 min por padrão)
timezone Nome IANA válido (ex.: America/Sao_Paulo)

A combinação garante que a saída para o almoço seja detectada antes do horário do lembrete de entrada2. Exemplo: com almoço de 20 min e antecedência de 5 min, sobram 15 min, o que passa. Com almoço de 15 min e antecedência de 5 min, sobram 10 min, e a jornada é recusada.

Se o intervalo de reconciliação for aumentado depois, as jornadas existentes não são alteradas. Ao iniciar, o worker avisa os admins sobre os usuários que ficaram fora da regra.

9.9 Dia útil e feriados

A cada ciclo, o worker decide se o dia é útil nesta ordem:

  1. Fim de semana (no fuso do usuário) não é dia útil.
  2. Tabela holidays: se a data estiver lá (vinda da API ou cadastrada manualmente), não é dia útil.
  3. Sincronização anual: no primeiro uso de cada ano (e uma vez por dia enquanto não der certo), o worker baixa os feriados da feriadosapi.com (endpoint por cidade) para a cidade configurada (BP_HOLIDAY_LOCATION, padrão 3106200 (código IBGE)) e grava em holidays.
  4. Fallback: se o ano ainda não foi sincronizado e a API falhar, segunda a sexta contam como dias úteis (como no Modelo). Os admins são avisados uma vez por dia enquanto isso durar, e o evento é auditado (holiday_sync_failed).

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

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 working e volta para consultas de 15 em 15 min.
  • Ajuda quando: o cache da PontoMais (last_cached) demora a mostrar o ponto; a pessoa começa a trabalhar mais tarde (consulta médica); um ajuste de ponto é aprovado durante o dia.
  • Limite honesto: se a pessoa esqueceu de bater a entrada e não houve ajuste, o primeiro ponto que aparece é a saída para o almoço, mas o sistema o interpreta como entrada1. A ordem dos pontos fica errada e o bot não tem como calcular os lembretes corretamente. Isso continua fora do alcance da aplicação, como no Modelo.
  • O usuário não recebe mensagem sobre no_points/not_working, para não gerar aviso diário em férias e folgas. A situação aparece no /status, no painel e na auditoria (day_no_points, day_not_worked).

Ajuste C: tarde com 3 pontos

Com entrada2 detectada e o lembrete de saida2 agendado para o horário T, as consultas só servem para descobrir se a pessoa saiu antes do previsto (4º ponto) e então cancelar o lembrete:

  • Consultas lentas: de hora em hora a partir da detecção do 3º ponto.
  • Consulta de confirmação: exatamente uma, no tick que cair entre T − 20 min e T − 5 min. Com ticks de 15 min, sempre existe exatamente um tick nesse intervalo. Se ela encontrar o 4º ponto, o lembrete é cancelado antes de sair.
  • Se uma consulta lenta coincidir com a de confirmação, as duas viram uma só.
  • Depois da confirmação, não há mais consultas no dia (next_check_at = NULL): o lembrete já vai sair, e consultar até 19:00 não mudaria nada.
  • Uma saída antecipada que aconteça depois da consulta de confirmação não é vista a tempo. O lembrete sai mesmo assim (no máximo ~20 min de diferença), o que é aceito.

Exemplo de dia típico (entrada 08:00, almoço 12:00–13:00, saída prevista 17:00, lembrete 16:55):

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áveis BP_QUERY_WINDOW_START, BP_QUERY_WINDOW_END e BP_DAY_CHECK_DEADLINE servem só para trocar esses padrões iniciais.
  • Depois disso, vale o que está no banco. As variáveis de ambiente não sobrescrevem o que foi salvo pelo painel.

Validação ao salvar (resposta 422 com o motivo):

Regra Motivo
Formato HH:MM, em múltiplos do intervalo de reconciliação (15 min: :00, :15, :30, :45) Os ticks são alinhados ao relógio; um horário fora do alinhamento nunca coincidiria com um ciclo
início < confirmação < fim das reverificações < fim A ordem precisa fazer sentido
confirmação ≥ início + 15 min Garante as duas verificações (9.12)
fim − início ≥ 4 h Uma janela curta demais deixaria de detectar retorno do almoço e saída

Pré-visualização: antes de salvar, a tela mostra o efeito estimado. Por exemplo: quantas consultas por dia a nova janela gera e quantos usuários, nos últimos 30 dias, bateram saida1 antes do novo início e teriam lembretes atrasados ou expired. O horário de saida1 é deduzido dos lembretes de entrada2 (scheduled_at − almoço + antecedência), já que a v2 não guarda os pontos. A pré-visualização também informa quantos usuários têm janela personalizada e, portanto, não serão afetados.

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

Auditoria: cada alteração grava settings_changed com os valores antigo e novo e a conta web que alterou.

A tabela é genérica (chave/valor), então outras configurações globais podem migrar para o painel no futuro sem nova estrutura.

9.14 Exceção de janela por usuário

A janela global (9.12/9.13) vale para todos. Um usuário com horário diferente pode ter uma janela personalizada, que substitui a global só para ele.

Regra de resolução

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 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-alpine com output: '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 -Fc diário, com retenção de 14 dias, num volume montado.
  • Restauração documentada com pg_restore num container temporário.
  • A chave de criptografia precisa ter backup separado. Sem ela, as senhas da PontoMais não podem ser recuperadas, como já acontece no Modelo.

11.4 Proxy reverso: Nginx Proxy Manager existente

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

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

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.
  • 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 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, 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_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 X-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 container web (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 Origin do CSRF (5.4) compara com a URL pública configurada em BP_PUBLIC_URL (ex.: https://botpontos.lan), não com o endereço interno.
  • Os cookies continuam Secure, porque o navegador só fala HTTPS com o NPM.

11.5 Host: máquina física com Docker

O ambiente de produção (até o momento) é uma máquina física dedicada, separada do Proxmox, rodando Docker. Por não ter snapshots nem alta disponibilidade de hypervisor, alguns cuidados ficam por conta do host:

Tema Requisito
Sistema Debian 12 (bookworm) com Docker Engine e plugin Compose instalados do repositório oficial da Docker (download.docker.com), não o pacote docker.io da distribuição
Rede IP fixo (ou reserva DHCP) — é o BP_HOST_LAN_IP usado no Compose e no Proxy Host do NPM
Inicialização systemctl enable docker e restart: unless-stopped em todos os serviços, para tudo voltar sozinho após queda de energia ou reboot
Relógio NTP obrigatório (systemd-timesyncd ou chrony). Os lembretes dependem da hora certa; um relógio adiantado ou atrasado gera envios fora de hora ou expired
Fuso do host Indiferente: a aplicação grava em UTC e calcula no fuso de cada usuário
Backup Os dumps do pg_dump não podem ficar só no mesmo disco. Copiar diariamente para outra máquina ou NAS (ex.: rsync/rclone no fim do backup-loop.sh)
Chave de criptografia Cópia guardada fora da máquina (cofre de senhas), independente do backup do banco
Energia Recomendado nobreak; o Postgres tolera desligamento abrupto, mas ele aumenta o risco para o disco
Atualizações Atualizar o SO e as imagens base (postgres:17-alpine, node, golang) periodicamente
Firewall Além da DOCKER-USER (11.4), liberar SSH só para a rede de administração

12. Observabilidade

  • Logs JSON (slog) com request_id, user_id e job.
  • /health/live e /health/ready nos healthchecks do Compose.
  • Métricas preparadas, não expostas. O pacote internal/metrics define uma interface (Recorder) chamada pelos jobs e pelo cliente da PontoMais: lembretes por tipo e status, falhas e latência da PontoMais, duração da reconciliação, consultas evitadas pela janela (9.12). Na primeira versão a implementação é no-op (não faz nada) ou só alimenta os contadores do dashboard do painel. Não existe endpoint /metrics.
  • Para ativar o Prometheus no futuro: trocar a implementação do Recorder por uma baseada em prometheus/client_golang e expor /metrics só na rede interna. Nenhum job precisa mudar.

13. Testes

Nível Ferramenta Foco
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_cached da 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).