diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..3acaf0e --- /dev/null +++ b/.editorconfig @@ -0,0 +1,18 @@ +root = true + +# Scripts executados dentro de containers Linux precisam de quebra de linha LF. +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.go] +indent_style = tab + +[*.{ts,tsx,json,yml,yaml,css}] +indent_style = space +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..832f5f8 --- /dev/null +++ b/.env.example @@ -0,0 +1,27 @@ +# Copie para .env (na mesma pasta do docker-compose.yml) e ajuste. +# Este arquivo NÃO contém segredos; senhas e chaves ficam em ./secrets. + +# IP da máquina na rede local onde a porta 3000 do painel será publicada. +# Produção: IP fixo do servidor Debian (o Nginx Proxy Manager aponta para ele). +# Desenvolvimento local: 127.0.0.1 +BP_HOST_LAN_IP=127.0.0.1 + +# debug | info | warn | error +BP_LOG_LEVEL=info + +# Endereço público do painel, igual ao nome do certificado no Nginx Proxy Manager. +# É usado na checagem de origem das requisições (CSRF). Vazio só em desenvolvimento. +BP_PUBLIC_URL=https://botpontos.lan + +# IP do Nginx Proxy Manager (para registrar o IP real do usuário na auditoria). +BP_NPM_IP= + +# Em desenvolvimento por http://127.0.0.1:3000, use false (cookies Secure exigem HTTPS). +BP_COOKIE_SECURE=true + +# Fuso padrão e cidade dos feriados (código IBGE; 3106200 = Belo Horizonte) +BP_DEFAULT_TIMEZONE=America/Sao_Paulo +BP_HOLIDAY_LOCATION=3106200 + +# Versão da chave de criptografia (aumente ao trocar a chave; ver README) +BP_ENCRYPTION_KEY_VERSION=1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..81ffac5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +# Segredos e configuração local +.env +secrets/* +!secrets/README.md + +# Dados gerados +backups/ + +# Web +web/node_modules/ +web/.next/ +web/next-env.d.ts diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..7096a97 --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,3 @@ +*.md +api/ +**/*_test.go diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..04e9005 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,22 @@ +# syntax=docker/dockerfile:1 + +# ---- build ---- +FROM golang:1.25-alpine AS build +WORKDIR /src + +COPY go.mod go.sum* ./ +RUN if [ -f go.sum ]; then go mod download; fi + +COPY . . +ARG VERSION=dev +# Enquanto o go.sum não existir no repositório, ele é gerado aqui (ver README). +RUN if [ ! -f go.sum ]; then go mod tidy; fi \ + && CGO_ENABLED=0 go build -trimpath -ldflags="-s -w -X main.version=${VERSION}" \ + -o /out/botpontos ./cmd/botpontos + +# ---- runtime ---- +# distroless/static: sem shell, sem gerenciador de pacotes, usuário não-root. +FROM gcr.io/distroless/static-debian12:nonroot +COPY --from=build /out/botpontos /botpontos +USER nonroot:nonroot +ENTRYPOINT ["/botpontos"] diff --git a/backend/api/openapi.yaml b/backend/api/openapi.yaml new file mode 100644 index 0000000..3b288f3 --- /dev/null +++ b/backend/api/openapi.yaml @@ -0,0 +1,576 @@ +openapi: 3.1.0 +info: + title: Bot de Pontos API + version: 1.0.0 + description: | + API REST consumida pelo painel Next.js (ARQUITETURA.md, seção 7). + + Autenticação: cookie de sessão `bp_session` (HttpOnly). Toda requisição que + altera dados exige também o header `X-CSRF-Token` com o valor do cookie + `bp_csrf` e um `Origin` igual a BP_PUBLIC_URL. + + `x-min-role` indica o papel mínimo: viewer < admin < owner. + Erros seguem application/problem+json (RFC 9457). +servers: + - url: /api/v1 + +components: + securitySchemes: + session: + type: apiKey + in: cookie + name: bp_session + responses: + Problem: + description: Erro + content: + application/problem+json: + schema: { $ref: "#/components/schemas/Problem" } + UserDetail: + description: Usuário atualizado + content: + application/json: + schema: { $ref: "#/components/schemas/UserDetail" } + parameters: + id: + name: id + in: path + required: true + schema: { type: integer, format: int64 } + schemas: + Problem: + type: object + required: [type, title, status] + properties: + type: { type: string, example: about:blank } + title: { type: string } + status: { type: integer } + code: + type: string + description: | + Código estável: VALIDATION, NOT_FOUND, UNAUTHENTICATED, FORBIDDEN, BAD_ORIGIN, BAD_CSRF, + INVALID_LOGIN, ACCOUNT_LOCKED, CREDENTIALS_REQUIRED, CREDENTIALS_REJECTED, POINTS_UNAVAILABLE, + USER_NOT_ACTIVE, INVALID_STATE, LAST_OWNER, NOT_CONFIGURED, DB_UNAVAILABLE, INTERNAL + errors: + type: array + items: + type: object + properties: + field: { type: string } + message: { type: string } + Account: + type: object + properties: + id: { type: integer } + username: { type: string } + role: { type: string, enum: [owner, admin, viewer] } + user_id: { type: [integer, "null"] } + disabled: { type: boolean } + locked_until: { type: [string, "null"], format: date-time } + last_login_at: { type: [string, "null"], format: date-time } + created_at: { type: string, format: date-time } + User: + type: object + properties: + id: { type: integer } + telegram_chat_id: { type: integer } + display_name: { type: string } + status: { type: string, enum: [pending, active, disabled] } + is_bot_admin: { type: boolean } + notifications_enabled: { type: boolean } + paused_reason: { type: [string, "null"], enum: [manual, api_failures, admin, user_disabled, null] } + timezone: { type: string } + created_at: { type: string, format: date-time } + UserSummary: + allOf: + - $ref: "#/components/schemas/User" + - type: object + properties: + has_credential: { type: boolean } + credential_validated: { type: boolean } + consecutive_failures: { type: integer } + custom_window: { type: boolean } + QueryWindow: + type: object + required: [query_window_start, query_window_end, day_check_deadline, no_points_recheck_until] + properties: + query_window_start: { type: string, example: "11:30" } + query_window_end: { type: string, example: "19:00" } + day_check_deadline: { type: string, example: "11:45" } + no_points_recheck_until: { type: string, example: "15:00" } + Schedule: + type: object + properties: + lunch_minutes: { type: integer, minimum: 15, maximum: 180 } + daily_work_minutes: { type: integer, minimum: 60, maximum: 720 } + entrada2_lead_minutes: { type: integer, minimum: 0, maximum: 60 } + saida2_lead_minutes: { type: integer, minimum: 0, maximum: 60 } + window: + description: null = usa a janela global + oneOf: + - $ref: "#/components/schemas/QueryWindow" + - type: "null" + Day: + type: object + properties: + day: { type: string, format: date } + status: { type: string, enum: [awaiting, no_points, working, not_working, finished] } + zero_point_checks: { type: integer } + last_point_count: { type: integer } + next_check_at: { type: [string, "null"], format: date-time } + resumed_by: { type: [string, "null"] } + UserDetail: + type: object + properties: + user: { $ref: "#/components/schemas/User" } + schedule: { $ref: "#/components/schemas/Schedule" } + credential: + type: [object, "null"] + description: A senha nunca é devolvida. + properties: + api_email: { type: string } + validated: { type: boolean } + last_validated_at: { type: [string, "null"], format: date-time } + last_error: { type: [string, "null"] } + consecutive_failures: { type: integer } + last_failure_at: { type: [string, "null"], format: date-time } + updated_at: { type: string, format: date-time } + today: { $ref: "#/components/schemas/Day" } + effective_window: { $ref: "#/components/schemas/QueryWindow" } + custom_window: { type: boolean } + Today: + type: object + properties: + day: { type: string, format: date } + timezone: { type: string } + points: { type: array, items: { type: string, format: date-time } } + invalid: { type: boolean } + overtime: { type: boolean } + entrada2_at: { type: [string, "null"], format: date-time } + saida2_at: { type: [string, "null"], format: date-time } + saida2_error: { type: string } + day_state: { $ref: "#/components/schemas/Day" } + window: { $ref: "#/components/schemas/QueryWindow" } + custom_window: { type: boolean } + Notification: + type: object + properties: + id: { type: integer } + user_id: { type: integer } + user_name: { type: string } + reference_date: { type: string, format: date } + type: { type: string, enum: [entrada2, saida2] } + scheduled_at: { type: string, format: date-time } + predicted_at: { type: string, format: date-time } + status: { type: string, enum: [scheduled, sending, sent, failed, cancelled, expired] } + attempts: { type: integer } + sent_at: { type: [string, "null"], format: date-time } + error_message: { type: [string, "null"] } + AuditLog: + type: object + properties: + id: { type: integer } + occurred_at: { type: string, format: date-time } + source: { type: string, enum: [telegram, web, worker, cli] } + actor_user_id: { type: [integer, "null"] } + actor_user_name: { type: [string, "null"] } + actor_account: { type: [integer, "null"] } + actor_username: { type: [string, "null"] } + target_user_id: { type: [integer, "null"] } + target_user_name: { type: [string, "null"] } + event_type: { type: string } + success: { type: boolean } + details: { type: object } + Holiday: + type: object + properties: + day: { type: string, format: date } + name: { type: string } + source: { type: string, enum: [feriadosapi, feriados.dev, manual] } + +security: + - session: [] + +paths: + /health/live: + get: + summary: Processo da API está de pé + security: [] + responses: + "200": { description: OK } + /health/ready: + get: + summary: API pronta (Postgres respondendo) + security: [] + responses: + "200": { description: OK } + "503": { $ref: "#/components/responses/Problem" } + + /auth/login: + post: + summary: Login (Argon2id; 5 falhas bloqueiam por 15 min) + security: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [username, password] + properties: + username: { type: string } + password: { type: string } + responses: + "200": { description: Sessão criada; define os cookies bp_session e bp_csrf } + "401": { $ref: "#/components/responses/Problem" } + "423": { $ref: "#/components/responses/Problem" } + /auth/logout: + post: + summary: Revoga a sessão e limpa os cookies + x-min-role: viewer + responses: + "204": { description: OK } + /auth/me: + get: + summary: Conta logada + x-min-role: viewer + responses: + "200": { description: OK } + + /dashboard: + get: + summary: Contadores, queda da PontoMais e fallback de feriados + x-min-role: viewer + responses: + "200": { description: OK } + + /users: + get: + summary: Lista de usuários + x-min-role: viewer + parameters: + - { name: status, in: query, schema: { type: string, enum: [pending, active, disabled] } } + - { name: q, in: query, schema: { type: string }, description: Nome ou chat ID } + - { name: custom_window, in: query, schema: { type: boolean } } + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + users: { type: array, items: { $ref: "#/components/schemas/UserSummary" } } + /users/{id}: + parameters: [{ $ref: "#/components/parameters/id" }] + get: + summary: Detalhe do usuário + x-min-role: viewer + responses: + "200": { $ref: "#/components/responses/UserDetail" } + "404": { $ref: "#/components/responses/Problem" } + /users/{id}/activate: + parameters: [{ $ref: "#/components/parameters/id" }] + post: + summary: Ativa (exige credencial validada — mesma regra do /ativarusuario) + x-min-role: admin + responses: + "200": { $ref: "#/components/responses/UserDetail" } + "409": { $ref: "#/components/responses/Problem" } + /users/{id}/disable: + parameters: [{ $ref: "#/components/parameters/id" }] + post: + summary: Desativa e cancela os lembretes agendados + x-min-role: admin + responses: + "200": { $ref: "#/components/responses/UserDetail" } + /users/{id}/schedule: + parameters: [{ $ref: "#/components/parameters/id" }] + put: + summary: Jornada, fuso e exceção de janela (9.8, 9.14) + x-min-role: admin + requestBody: + required: true + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/Schedule" + - type: object + properties: + timezone: { type: string } + responses: + "200": { $ref: "#/components/responses/UserDetail" } + "422": { $ref: "#/components/responses/Problem" } + /users/{id}/credentials: + parameters: [{ $ref: "#/components/parameters/id" }] + put: + summary: Valida na PontoMais e grava cifrada; em falha a anterior é mantida + x-min-role: admin + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [email, password] + properties: + email: { type: string } + password: { type: string } + responses: + "200": { $ref: "#/components/responses/UserDetail" } + "422": { $ref: "#/components/responses/Problem" } + "502": { $ref: "#/components/responses/Problem" } + /users/{id}/credentials/test: + parameters: [{ $ref: "#/components/parameters/id" }] + post: + summary: Consulta a PontoMais com a credencial atual + x-min-role: admin + responses: + "200": + description: OK + content: + application/json: + schema: { $ref: "#/components/schemas/Today" } + /users/{id}/notifications/enable: + parameters: [{ $ref: "#/components/parameters/id" }] + post: + summary: Liga os lembretes, zera falhas e retoma o dia se estava sem pontos + x-min-role: admin + responses: + "200": { $ref: "#/components/responses/UserDetail" } + /users/{id}/notifications/disable: + parameters: [{ $ref: "#/components/parameters/id" }] + post: + summary: Pausa os lembretes e cancela os agendados + x-min-role: admin + responses: + "200": { $ref: "#/components/responses/UserDetail" } + /users/{id}/bot-admin: + parameters: [{ $ref: "#/components/parameters/id" }] + put: + summary: Liga ou desliga o admin do bot no Telegram + x-min-role: admin + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + enabled: { type: boolean } + responses: + "200": { $ref: "#/components/responses/UserDetail" } + /users/{id}/today: + parameters: [{ $ref: "#/components/parameters/id" }] + get: + summary: Consulta ao vivo dos pontos e previsões do dia + x-min-role: viewer + responses: + "200": + description: OK + content: + application/json: + schema: { $ref: "#/components/schemas/Today" } + "502": { $ref: "#/components/responses/Problem" } + /users/{id}/today/resume: + parameters: [{ $ref: "#/components/parameters/id" }] + post: + summary: Retoma um dia sem pontos (9.12) + x-min-role: admin + responses: + "200": { $ref: "#/components/responses/UserDetail" } + "409": { $ref: "#/components/responses/Problem" } + + /notifications: + get: + summary: Lembretes (até 200) + x-min-role: viewer + parameters: + - { name: day, in: query, schema: { type: string, format: date } } + - { name: status, in: query, schema: { type: string } } + - { name: type, in: query, schema: { type: string, enum: [entrada2, saida2] } } + - { name: user_id, in: query, schema: { type: integer } } + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + notifications: { type: array, items: { $ref: "#/components/schemas/Notification" } } + /notifications/{id}/cancel: + parameters: [{ $ref: "#/components/parameters/id" }] + post: + summary: Cancela (só scheduled) + x-min-role: admin + responses: + "200": { description: OK } + "409": { $ref: "#/components/responses/Problem" } + /notifications/{id}/resend: + parameters: [{ $ref: "#/components/parameters/id" }] + post: + summary: Reenvio manual (só failed do dia atual, 9.5) + x-min-role: admin + responses: + "200": { description: OK } + "409": { $ref: "#/components/responses/Problem" } + + /audit: + get: + summary: Auditoria, paginada por cursor (next_before) + x-min-role: viewer + parameters: + - { name: from, in: query, schema: { type: string, format: date } } + - { name: to, in: query, schema: { type: string, format: date } } + - { name: source, in: query, schema: { type: string } } + - { name: event_type, in: query, schema: { type: string } } + - { name: success, in: query, schema: { type: boolean } } + - { name: target_user_id, in: query, schema: { type: integer } } + - { name: actor_account, in: query, schema: { type: integer } } + - { name: before, in: query, schema: { type: integer } } + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + logs: { type: array, items: { $ref: "#/components/schemas/AuditLog" } } + next_before: { type: [integer, "null"] } + + /holidays: + get: + summary: Feriados do ano + x-min-role: viewer + parameters: + - { name: year, in: query, schema: { type: integer } } + responses: + "200": { description: OK } + post: + summary: Cadastra feriado manual + x-min-role: admin + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/Holiday" } + responses: + "201": { description: Criado } + "422": { $ref: "#/components/responses/Problem" } + /holidays/sync: + post: + summary: Sincroniza o ano com a feriadosapi.com + x-min-role: admin + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + year: { type: integer } + responses: + "200": { description: OK } + "503": { $ref: "#/components/responses/Problem" } + /holidays/{day}: + delete: + summary: Remove feriado manual + x-min-role: admin + parameters: + - { name: day, in: path, required: true, schema: { type: string, format: date } } + responses: + "204": { description: OK } + + /settings: + get: + summary: Janela global e parâmetros do servidor + x-min-role: viewer + responses: + "200": { description: OK } + put: + summary: Altera a janela global (9.13) + x-min-role: admin + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/QueryWindow" } + responses: + "200": { description: OK } + "422": { $ref: "#/components/responses/Problem" } + /settings/preview: + post: + summary: Efeito estimado de uma nova janela global + x-min-role: admin + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/QueryWindow" } + responses: + "200": { description: OK } + + /accounts: + get: + summary: Contas do painel + x-min-role: owner + responses: + "200": { description: OK } + post: + summary: Cria conta + x-min-role: owner + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [username, password, role] + properties: + username: { type: string } + password: { type: string, minLength: 12 } + role: { type: string, enum: [owner, admin, viewer] } + user_id: { type: [integer, "null"] } + responses: + "201": + description: Criada + content: + application/json: + schema: { $ref: "#/components/schemas/Account" } + /accounts/{id}: + parameters: [{ $ref: "#/components/parameters/id" }] + patch: + summary: Altera papel, desativa e/ou troca o usuário ligado (mantém pelo menos um owner ativo) + x-min-role: owner + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + role: { type: string, enum: [owner, admin, viewer] } + disabled: { type: boolean } + user_id: { type: [integer, "null"], description: "Usuário do Telegram ligado à conta; null remove o vínculo" } + responses: + "200": { description: OK } + "409": { $ref: "#/components/responses/Problem" } + /accounts/{id}/reset-password: + parameters: [{ $ref: "#/components/parameters/id" }] + post: + summary: Redefine a senha e encerra as sessões da conta + x-min-role: owner + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [password] + properties: + password: { type: string, minLength: 12 } + responses: + "204": { description: OK } diff --git a/backend/cmd/botpontos/admin.go b/backend/cmd/botpontos/admin.go new file mode 100644 index 0000000..269b0c3 --- /dev/null +++ b/backend/cmd/botpontos/admin.go @@ -0,0 +1,152 @@ +package main + +import ( + "bufio" + "context" + "errors" + "flag" + "fmt" + "log/slog" + "os" + "strings" + + "golang.org/x/term" + + "botpontos/internal/app" + "botpontos/internal/bootstrap" + "botpontos/internal/config" + "botpontos/internal/domain" +) + +const adminUsage = `uso: botpontos admin + +comandos: + create-owner --username NOME cria a primeira conta owner do painel + reset-password --username NOME redefine a senha de uma conta do painel + bot-admin --user-id ID --enable|--disable + liga ou desliga o admin do bot no Telegram + set-credentials --user-id ID --email E grava e valida a credencial da PontoMais + rotate-key recifra as credenciais com a chave atual + +As senhas são lidas do terminal, sem eco. Execute com: + docker compose run --rm api admin ... +` + +func runAdmin(ctx context.Context, cfg config.Config, logger *slog.Logger, args []string) error { + if len(args) == 0 { + fmt.Fprint(os.Stderr, adminUsage) + return errors.New("comando administrativo não informado") + } + built, err := bootstrap.Build(ctx, cfg, logger) + if err != nil { + return err + } + defer built.Pool.Close() + svc := built.Service + + fs := flag.NewFlagSet("admin "+args[0], flag.ContinueOnError) + username := fs.String("username", "", "nome da conta do painel") + userID := fs.Int64("user-id", 0, "ID do usuário do Telegram (veja /usuarios)") + email := fs.String("email", "", "e-mail de login na PontoMais") + enable := fs.Bool("enable", false, "liga") + disable := fs.Bool("disable", false, "desliga") + if err := fs.Parse(args[1:]); err != nil { + return err + } + + switch args[0] { + case "create-owner": + if *username == "" { + return errors.New("informe --username") + } + password, err := readNewPassword() + if err != nil { + return err + } + account, err := svc.CreateAccount(ctx, app.CLIActor, app.AccountInput{ + Username: *username, Password: password, Role: domain.RoleOwner, + }) + if err != nil { + return err + } + fmt.Printf("Conta owner %q criada (ID %d).\n", account.Username, account.ID) + + case "reset-password": + if *username == "" { + return errors.New("informe --username") + } + password, err := readNewPassword() + if err != nil { + return err + } + if err := svc.ResetPasswordByUsername(ctx, app.CLIActor, *username, password); err != nil { + return err + } + fmt.Println("Senha redefinida. As sessões abertas dessa conta foram encerradas.") + + case "bot-admin": + if *userID <= 0 || *enable == *disable { + return errors.New("informe --user-id e exatamente um entre --enable e --disable") + } + if err := svc.SetBotAdmin(ctx, app.CLIActor, *userID, *enable); err != nil { + return err + } + fmt.Println("Admin do bot atualizado.") + + case "set-credentials": + if *userID <= 0 || *email == "" { + return errors.New("informe --user-id e --email") + } + password, err := readPassword("Senha da PontoMais: ") + if err != nil { + return err + } + if err := svc.SetCredentials(ctx, app.CLIActor, *userID, *email, password); err != nil { + return err + } + fmt.Println("Credencial validada na PontoMais e gravada cifrada.") + + case "rotate-key": + count, err := svc.RotateKey(ctx) + if err != nil { + return err + } + fmt.Printf("%d credencial(is) recifrada(s) com a chave versão %d.\n", count, cfg.Crypto.KeyVersion) + + default: + fmt.Fprint(os.Stderr, adminUsage) + return fmt.Errorf("comando administrativo desconhecido: %s", args[0]) + } + return nil +} + +func readNewPassword() (string, error) { + first, err := readPassword("Nova senha (mínimo 12 caracteres): ") + if err != nil { + return "", err + } + second, err := readPassword("Repita a senha: ") + if err != nil { + return "", err + } + if first != second { + return "", errors.New("as senhas não conferem") + } + return first, nil +} + +// readPassword lê sem eco quando há terminal; senão lê uma linha da entrada padrão. +func readPassword(prompt string) (string, error) { + fmt.Fprint(os.Stderr, prompt) + fd := int(os.Stdin.Fd()) + if term.IsTerminal(fd) { + raw, err := term.ReadPassword(fd) + fmt.Fprintln(os.Stderr) + return string(raw), err + } + line, err := bufio.NewReader(os.Stdin).ReadString('\n') + if err != nil && line == "" { + return "", err + } + return strings.TrimRight(line, "\r\n"), nil +} diff --git a/backend/cmd/botpontos/main.go b/backend/cmd/botpontos/main.go new file mode 100644 index 0000000..46c9ce2 --- /dev/null +++ b/backend/cmd/botpontos/main.go @@ -0,0 +1,116 @@ +// Comando botpontos: um único binário com os papéis api, worker, migrate e admin. +package main + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net/http" + "os" + "os/signal" + "syscall" + "time" + _ "time/tzdata" // a imagem distroless não tem /usr/share/zoneinfo + + "botpontos/internal/adapters/postgres" + "botpontos/internal/config" + "botpontos/internal/httpapi" + "botpontos/internal/worker" +) + +// version é preenchida no build com -ldflags "-X main.version=...". +var version = "dev" + +const usage = `uso: botpontos + +comandos: + api inicia a API REST + worker inicia o worker (bot do Telegram e jobs) + migrate [up|status|down] aplica ou consulta as migrations (padrão: up) + admin tarefas administrativas (botpontos admin para ver a lista) + healthcheck verifica um endpoint de saúde (usado pelo Docker) + version mostra a versão +` + +func main() { + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + if err := run(ctx, os.Args[1:]); err != nil { + fmt.Fprintln(os.Stderr, "erro:", err) + os.Exit(1) + } +} + +func run(ctx context.Context, args []string) error { + if len(args) == 0 { + fmt.Fprint(os.Stderr, usage) + return errors.New("comando não informado") + } + + switch args[0] { + case "version": + fmt.Println(version) + return nil + case "healthcheck": + if len(args) != 2 { + return errors.New("uso: botpontos healthcheck ") + } + return healthcheck(ctx, args[1]) + case "api", "worker", "migrate", "admin": + // precisam de configuração; seguem abaixo + default: + fmt.Fprint(os.Stderr, usage) + return fmt.Errorf("comando desconhecido: %s", args[0]) + } + + cfg, err := config.Load() + if err != nil { + return fmt.Errorf("configuração inválida:\n%w", err) + } + logger := newLogger(cfg.LogLevel).With("component", args[0], "version", version) + slog.SetDefault(logger) + + switch args[0] { + case "api": + return httpapi.Run(ctx, cfg, logger) + case "worker": + return worker.Run(ctx, cfg, logger) + case "admin": + return runAdmin(ctx, cfg, logger, args[1:]) + default: // migrate + command := "up" + if len(args) > 1 { + command = args[1] + } + return postgres.Migrate(ctx, cfg.DB, command, logger) + } +} + +func newLogger(level string) *slog.Logger { + var lvl slog.Level + _ = lvl.UnmarshalText([]byte(level)) // já validado em config.Load + return slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: lvl})) +} + +// healthcheck existe porque a imagem distroless não tem curl nem wget. +func healthcheck(ctx context.Context, url string) error { + ctx, cancel := context.WithTimeout(ctx, 3*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return err + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode > 299 { + return fmt.Errorf("status %d", resp.StatusCode) + } + return nil +} diff --git a/backend/db/migrations/00001_initial_schema.sql b/backend/db/migrations/00001_initial_schema.sql new file mode 100644 index 0000000..0b59ae7 --- /dev/null +++ b/backend/db/migrations/00001_initial_schema.sql @@ -0,0 +1,218 @@ +-- Esquema inicial do Bot de Pontos v2 (ARQUITETURA.md, seção 6.2). +-- Executado pelo usuário dono do banco; o usuário da aplicação (botpontos_app) +-- recebe apenas SELECT/INSERT/UPDATE/DELETE no final. + +-- +goose Up +CREATE EXTENSION IF NOT EXISTS citext; + +-- +goose StatementBegin +CREATE FUNCTION set_updated_at() RETURNS trigger +LANGUAGE plpgsql AS $$ +BEGIN + NEW.updated_at = now(); + RETURN NEW; +END; +$$; +-- +goose StatementEnd + +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'); +CREATE TYPE day_status AS ENUM ('awaiting', 'no_points', 'working', 'not_working', 'finished'); + +-- 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 dos lembretes, exibido no /status + notifications_paused_reason text CHECK (notifications_paused_reason IN ('manual', 'api_failures', 'admin', 'user_disabled')), + 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 e exceção opcional da janela de consulta (9.8 e 9.14) +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), + query_window_start time, + query_window_end time, + day_check_deadline time, + no_points_recheck_until time, + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT work_schedules_window_all_or_none CHECK ( + num_nulls(query_window_start, query_window_end, day_check_deadline, no_points_recheck_until) IN (0, 4) + ) +); + +-- Credencial PontoMais (senha cifrada com AES-GCM na aplicação) +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, + last_validated_at timestamptz, + last_error text, + consecutive_failures int NOT NULL DEFAULT 0, + 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 INDEX notifications_reference_date_idx ON notifications (reference_date); + +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), + 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 INDEX web_sessions_expires_idx ON web_sessions (expires_at); + +-- Feriados vindos da API e cadastrados manualmente (9.9) +CREATE TABLE holidays ( + day date PRIMARY KEY, + name text NOT NULL, + source text NOT NULL CHECK (source IN ('feriados.dev', 'manual')), + created_at timestamptz NOT NULL DEFAULT now() +); + +-- Situação de cada usuário em cada dia e próxima consulta (9.12) +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, + last_point_count smallint NOT NULL DEFAULT 0, + next_check_at timestamptz, + resumed_by text CHECK (resumed_by IN ('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 (9.13) +CREATE TABLE app_settings ( + key text PRIMARY KEY, + value jsonb NOT NULL, + 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, + value jsonb NOT NULL, + 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); + +CREATE TRIGGER users_set_updated_at BEFORE UPDATE ON users FOR EACH ROW EXECUTE FUNCTION set_updated_at(); +CREATE TRIGGER work_schedules_set_updated_at BEFORE UPDATE ON work_schedules FOR EACH ROW EXECUTE FUNCTION set_updated_at(); +-- point_credentials não usa o trigger: updated_at marca só a troca da credencial (e invalida o +-- cache de sessão da PontoMais); contadores de falha não devem forçar novo login. +CREATE TRIGGER notifications_set_updated_at BEFORE UPDATE ON notifications FOR EACH ROW EXECUTE FUNCTION set_updated_at(); +CREATE TRIGGER web_accounts_set_updated_at BEFORE UPDATE ON web_accounts FOR EACH ROW EXECUTE FUNCTION set_updated_at(); +CREATE TRIGGER user_days_set_updated_at BEFORE UPDATE ON user_days FOR EACH ROW EXECUTE FUNCTION set_updated_at(); +CREATE TRIGGER app_settings_set_updated_at BEFORE UPDATE ON app_settings FOR EACH ROW EXECUTE FUNCTION set_updated_at(); +CREATE TRIGGER service_state_set_updated_at BEFORE UPDATE ON service_state FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +-- Permissões do usuário da aplicação (criado por postgres/init/01-create-app-role.sh). +-- As tabelas de migrations futuras herdam as permissões pelo DEFAULT PRIVILEGES. +-- +goose StatementBegin +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'botpontos_app') THEN + GRANT USAGE ON SCHEMA public TO botpontos_app; + GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO botpontos_app; + GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO botpontos_app; + ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO botpontos_app; + ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT USAGE, SELECT ON SEQUENCES TO botpontos_app; + -- A aplicação não altera o controle de versão das migrations. + REVOKE INSERT, UPDATE, DELETE ON goose_db_version FROM botpontos_app; + ELSE + RAISE NOTICE 'role botpontos_app não existe; permissões da aplicação não aplicadas'; + END IF; +END; +$$; +-- +goose StatementEnd + +-- +goose Down +DROP TABLE IF EXISTS audit_logs; +DROP TABLE IF EXISTS service_state; +DROP TABLE IF EXISTS app_settings; +DROP TABLE IF EXISTS user_days; +DROP TABLE IF EXISTS holidays; +DROP TABLE IF EXISTS web_sessions; +DROP TABLE IF EXISTS web_accounts; +DROP TABLE IF EXISTS notifications; +DROP TABLE IF EXISTS point_credentials; +DROP TABLE IF EXISTS work_schedules; +DROP TABLE IF EXISTS users; +DROP TYPE IF EXISTS day_status; +DROP TYPE IF EXISTS audit_source; +DROP TYPE IF EXISTS web_role; +DROP TYPE IF EXISTS notification_status; +DROP TYPE IF EXISTS notification_type; +DROP TYPE IF EXISTS user_status; +DROP FUNCTION IF EXISTS set_updated_at(); diff --git a/backend/db/migrations/00002_holiday_source_feriadosapi.sql b/backend/db/migrations/00002_holiday_source_feriadosapi.sql new file mode 100644 index 0000000..b213312 --- /dev/null +++ b/backend/db/migrations/00002_holiday_source_feriadosapi.sql @@ -0,0 +1,13 @@ +-- A origem dos feriados passa a ser a feriadosapi.com (endpoint por cidade). +-- Linhas antigas da feriados.dev continuam válidas. + +-- +goose Up +ALTER TABLE holidays DROP CONSTRAINT holidays_source_check; +ALTER TABLE holidays ADD CONSTRAINT holidays_source_check + CHECK (source IN ('feriadosapi', 'feriados.dev', 'manual')); + +-- +goose Down +UPDATE holidays SET source = 'feriados.dev' WHERE source = 'feriadosapi'; +ALTER TABLE holidays DROP CONSTRAINT holidays_source_check; +ALTER TABLE holidays ADD CONSTRAINT holidays_source_check + CHECK (source IN ('feriados.dev', 'manual')); diff --git a/backend/db/migrations/embed.go b/backend/db/migrations/embed.go new file mode 100644 index 0000000..c91786b --- /dev/null +++ b/backend/db/migrations/embed.go @@ -0,0 +1,7 @@ +// Package migrations embute os arquivos SQL aplicados pelo goose. +package migrations + +import "embed" + +//go:embed *.sql +var FS embed.FS diff --git a/backend/go.mod b/backend/go.mod new file mode 100644 index 0000000..95e1e20 --- /dev/null +++ b/backend/go.mod @@ -0,0 +1,3 @@ +module botpontos + +go 1.25 diff --git a/backend/internal/adapters/crypto/crypto_test.go b/backend/internal/adapters/crypto/crypto_test.go new file mode 100644 index 0000000..3636530 --- /dev/null +++ b/backend/internal/adapters/crypto/crypto_test.go @@ -0,0 +1,83 @@ +package crypto + +import ( + "strings" + "testing" +) + +const ( + keyA = "chave-de-teste-numero-um-com-32-caracteres!" + keyB = "chave-de-teste-numero-dois-com-32-caracteres" +) + +func TestKeyringRoundTrip(t *testing.T) { + k, err := NewKeyring(keyA, 1, "", 0) + if err != nil { + t.Fatal(err) + } + data, version, err := k.Encrypt("senha-pontomais") + if err != nil || version != 1 { + t.Fatalf("Encrypt: version=%d err=%v", version, err) + } + if strings.Contains(string(data), "senha-pontomais") { + t.Fatal("texto cifrado contém a senha em claro") + } + plain, err := k.Decrypt(data, version) + if err != nil || plain != "senha-pontomais" { + t.Fatalf("Decrypt = %q, %v", plain, err) + } +} + +func TestKeyringRotation(t *testing.T) { + old, _ := NewKeyring(keyA, 1, "", 0) + data, version, _ := old.Encrypt("segredo") + + rotated, err := NewKeyring(keyB, 2, keyA, 1) + if err != nil { + t.Fatal(err) + } + if plain, err := rotated.Decrypt(data, version); err != nil || plain != "segredo" { + t.Fatalf("chave anterior deveria decifrar: %q, %v", plain, err) + } + if _, v, _ := rotated.Encrypt("novo"); v != 2 { + t.Fatalf("novas cifras deveriam usar a versão 2, usaram %d", v) + } + if _, err := rotated.Decrypt(data, 9); err == nil { + t.Fatal("versão desconhecida deveria falhar") + } +} + +func TestKeyringRejectsShortKey(t *testing.T) { + if _, err := NewKeyring("curta", 1, "", 0); err == nil { + t.Fatal("chave curta deveria ser recusada") + } +} + +func TestPasswordHash(t *testing.T) { + hash, err := HashPassword("senha-forte-do-painel") + if err != nil { + t.Fatal(err) + } + if !VerifyPassword(hash, "senha-forte-do-painel") { + t.Fatal("senha correta deveria conferir") + } + if VerifyPassword(hash, "senha-errada-do-painel") { + t.Fatal("senha errada não deveria conferir") + } + if _, err := HashPassword("curta"); err == nil { + t.Fatal("senha curta deveria ser recusada") + } +} + +func TestToken(t *testing.T) { + token, hash, err := NewToken() + if err != nil { + t.Fatal(err) + } + if !ValidTokenFormat(token) || !TokenMatches(token, hash) { + t.Fatal("token gerado deveria ser válido e conferir com o hash") + } + if TokenMatches(token+"x", hash) { + t.Fatal("token alterado não deveria conferir") + } +} diff --git a/backend/internal/adapters/crypto/keyring.go b/backend/internal/adapters/crypto/keyring.go new file mode 100644 index 0000000..da31f96 --- /dev/null +++ b/backend/internal/adapters/crypto/keyring.go @@ -0,0 +1,90 @@ +// Package crypto cifra as senhas da PontoMais (AES-256-GCM), faz hash das +// senhas do painel (Argon2id) e gera tokens de sessão. +package crypto + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha256" + "errors" + "fmt" + "io" +) + +const MinKeyLength = 32 + +var ErrUnknownKeyVersion = errors.New("versão de chave de criptografia desconhecida") + +// Keyring guarda a chave atual e, opcionalmente, a anterior. Cada senha cifrada +// registra a versão usada (point_credentials.key_version), o que permite trocar +// a chave sem perder as credenciais já gravadas (comando admin rotate-key). +type Keyring struct { + current int16 + keys map[int16][]byte +} + +// NewKeyring recebe o segredo atual e sua versão; previous é opcional (vazio = sem chave anterior). +func NewKeyring(current string, currentVersion int16, previous string, previousVersion int16) (*Keyring, error) { + if len(current) < MinKeyLength { + return nil, fmt.Errorf("a chave de criptografia precisa ter pelo menos %d caracteres", MinKeyLength) + } + k := &Keyring{current: currentVersion, keys: map[int16][]byte{currentVersion: derive(current)}} + if previous != "" { + if previousVersion == currentVersion { + return nil, errors.New("a chave anterior precisa ter uma versão diferente da atual") + } + if len(previous) < MinKeyLength { + return nil, fmt.Errorf("a chave anterior precisa ter pelo menos %d caracteres", MinKeyLength) + } + k.keys[previousVersion] = derive(previous) + } + return k, nil +} + +func derive(secret string) []byte { + sum := sha256.Sum256([]byte(secret)) + return sum[:] +} + +func (k *Keyring) CurrentVersion() int16 { return k.current } + +// Encrypt cifra com a chave atual e devolve nonce||ciphertext e a versão usada. +func (k *Keyring) Encrypt(plain string) ([]byte, int16, error) { + gcm, err := newGCM(k.keys[k.current]) + if err != nil { + return nil, 0, err + } + nonce := make([]byte, gcm.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return nil, 0, err + } + return gcm.Seal(nonce, nonce, []byte(plain), nil), k.current, nil +} + +func (k *Keyring) Decrypt(data []byte, version int16) (string, error) { + key, ok := k.keys[version] + if !ok { + return "", fmt.Errorf("%w: %d", ErrUnknownKeyVersion, version) + } + gcm, err := newGCM(key) + if err != nil { + return "", err + } + if len(data) < gcm.NonceSize() { + return "", errors.New("credencial cifrada inválida") + } + plain, err := gcm.Open(nil, data[:gcm.NonceSize()], data[gcm.NonceSize():], nil) + if err != nil { + return "", errors.New("não foi possível decifrar a credencial (chave incorreta?)") + } + return string(plain), nil +} + +func newGCM(key []byte) (cipher.AEAD, error) { + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + return cipher.NewGCM(block) +} diff --git a/backend/internal/adapters/crypto/password.go b/backend/internal/adapters/crypto/password.go new file mode 100644 index 0000000..1108b20 --- /dev/null +++ b/backend/internal/adapters/crypto/password.go @@ -0,0 +1,107 @@ +package crypto + +import ( + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "fmt" + "strings" + "unicode/utf8" + + "golang.org/x/crypto/argon2" +) + +const ( + MinPasswordLength = 12 + + argonMemory = 64 * 1024 + argonIterations = 3 + argonParallelism = 2 + argonSaltLength = 16 + argonKeyLength = 32 +) + +// ValidatePassword aplica a política mínima das senhas do painel. +func ValidatePassword(password string) error { + if utf8.RuneCountInString(password) < MinPasswordLength { + return fmt.Errorf("a senha deve ter no mínimo %d caracteres", MinPasswordLength) + } + return nil +} + +// HashPassword gera um hash Argon2id no formato PHC ($argon2id$v=19$m=...,t=...,p=...$salt$hash). +func HashPassword(password string) (string, error) { + if err := ValidatePassword(password); err != nil { + return "", err + } + salt := make([]byte, argonSaltLength) + if _, err := rand.Read(salt); err != nil { + return "", err + } + hash := argon2.IDKey([]byte(password), salt, argonIterations, argonMemory, argonParallelism, argonKeyLength) + return fmt.Sprintf("$argon2id$v=19$m=%d,t=%d,p=%d$%s$%s", + argonMemory, argonIterations, argonParallelism, + base64.RawStdEncoding.EncodeToString(salt), + base64.RawStdEncoding.EncodeToString(hash)), nil +} + +// VerifyPassword compara em tempo constante. Parâmetros fora de limites +// razoáveis são recusados, para um hash adulterado não travar o servidor. +func VerifyPassword(encoded, password string) bool { + parts := strings.Split(encoded, "$") + if len(parts) != 6 || parts[1] != "argon2id" || parts[2] != "v=19" { + return false + } + var memory, iterations uint32 + var parallelism uint8 + if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &iterations, ¶llelism); err != nil { + return false + } + if memory < 8*1024 || memory > 256*1024 || iterations < 1 || iterations > 10 || parallelism < 1 || parallelism > 8 { + return false + } + salt, err := base64.RawStdEncoding.DecodeString(parts[4]) + if err != nil || len(salt) < 8 { + return false + } + want, err := base64.RawStdEncoding.DecodeString(parts[5]) + if err != nil || len(want) < 16 || len(want) > 64 { + return false + } + got := argon2.IDKey([]byte(password), salt, iterations, memory, parallelism, uint32(len(want))) + return subtle.ConstantTimeCompare(got, want) == 1 +} + +// DummyVerify gasta o mesmo tempo de uma verificação real; usado quando o +// usuário não existe, para o tempo de resposta não revelar isso. +func DummyVerify(password string) { + salt := sha256.Sum256([]byte("botpontos-dummy-salt")) + _ = argon2.IDKey([]byte(password), salt[:argonSaltLength], argonIterations, argonMemory, argonParallelism, argonKeyLength) +} + +// NewToken gera um token aleatório (enviado ao navegador) e seu hash SHA-256 (gravado no banco). +func NewToken() (token string, hash []byte, err error) { + raw := make([]byte, 32) + if _, err := rand.Read(raw); err != nil { + return "", nil, err + } + token = base64.RawURLEncoding.EncodeToString(raw) + return token, HashToken(token), nil +} + +func HashToken(token string) []byte { + sum := sha256.Sum256([]byte(token)) + return sum[:] +} + +// ValidTokenFormat evita consultar o banco com valores claramente inválidos. +func ValidTokenFormat(token string) bool { + raw, err := base64.RawURLEncoding.DecodeString(token) + return err == nil && len(raw) == 32 +} + +// TokenMatches compara um token recebido com o hash gravado, em tempo constante. +func TokenMatches(token string, hash []byte) bool { + return subtle.ConstantTimeCompare(HashToken(token), hash) == 1 +} diff --git a/backend/internal/adapters/feriados/client.go b/backend/internal/adapters/feriados/client.go new file mode 100644 index 0000000..7e4167e --- /dev/null +++ b/backend/internal/adapters/feriados/client.go @@ -0,0 +1,112 @@ +// Package feriados consulta a API feriadosapi.com, endpoint por cidade +// (GET /feriados/cidade/{ibge}?ano=AAAA), que devolve os feriados nacionais e +// os municipais do ano. +package feriados + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "botpontos/internal/domain" +) + +// apiDateLayout é o formato das datas devolvidas pela API (dd/mm/aaaa). +const apiDateLayout = "02/01/2006" + +type Client struct { + http *http.Client + baseURL string + apiKey string + location string // código IBGE da cidade +} + +func NewClient(baseURL, apiKey, location string) *Client { + return &Client{ + http: &http.Client{Timeout: 10 * time.Second}, + baseURL: strings.TrimRight(strings.TrimSpace(baseURL), "/"), + apiKey: strings.TrimSpace(apiKey), + location: strings.TrimSpace(location), + } +} + +func (c *Client) Configured() bool { + return c.baseURL != "" && c.apiKey != "" && c.location != "" +} + +type response struct { + Feriados []struct { + Data string `json:"data"` + Nome string `json:"nome"` + } `json:"feriados"` +} + +// FetchYear devolve os feriados do ano para a cidade configurada. +func (c *Client) FetchYear(ctx context.Context, year int) ([]domain.Holiday, error) { + if !c.Configured() { + return nil, domain.ErrNotConfigured + } + if _, err := strconv.Atoi(c.location); err != nil { + return nil, fmt.Errorf("BP_HOLIDAY_LOCATION deve ser o código IBGE da cidade (ex.: 3106200), não %q", c.location) + } + endpoint, err := url.Parse(c.baseURL + "/feriados/cidade/" + url.PathEscape(c.location)) + if err != nil { + return nil, fmt.Errorf("URL da API de feriados: %w", err) + } + query := endpoint.Query() + query.Set("ano", strconv.Itoa(year)) + endpoint.RawQuery = query.Encode() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", "Bearer "+c.apiKey) + + resp, err := c.http.Do(req) + if err != nil { + var urlErr *url.Error + if errors.As(err, &urlErr) { + err = urlErr.Err + } + return nil, fmt.Errorf("consultar API de feriados: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode > 299 { + return nil, fmt.Errorf("API de feriados retornou HTTP %d", resp.StatusCode) + } + + var payload response + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + return nil, fmt.Errorf("resposta da API de feriados ilegível: %w", err) + } + + found := map[string]string{} + for _, item := range payload.Feriados { + parsed, err := time.Parse(apiDateLayout, strings.TrimSpace(item.Data)) + if err != nil || parsed.Year() != year { + continue + } + name := strings.TrimSpace(item.Nome) + if name == "" { + name = "Feriado" + } + found[parsed.Format(domain.DateLayout)] = name + } + if len(found) == 0 { + return nil, fmt.Errorf("a API de feriados não retornou datas para %d", year) + } + + holidays := make([]domain.Holiday, 0, len(found)) + for day, name := range found { + holidays = append(holidays, domain.Holiday{Day: day, Name: name, Source: domain.HolidaySourceAPI}) + } + return holidays, nil +} diff --git a/backend/internal/adapters/feriados/client_test.go b/backend/internal/adapters/feriados/client_test.go new file mode 100644 index 0000000..5e14ed9 --- /dev/null +++ b/backend/internal/adapters/feriados/client_test.go @@ -0,0 +1,52 @@ +package feriados + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestFetchYear(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/feriados/cidade/3106200" || r.URL.Query().Get("ano") != "2026" { + t.Errorf("requisição inesperada: %s", r.URL) + } + if r.Header.Get("Authorization") != "Bearer chave" { + t.Errorf("Authorization = %q", r.Header.Get("Authorization")) + } + _, _ = w.Write([]byte(`{"feriados":[ + {"data":"01/01/2026","nome":"Ano Novo","tipo":"NACIONAL"}, + {"data":"15/08/2026","nome":"Assunção de Nossa Senhora","tipo":"MUNICIPAL"}, + {"data":"31/12/2025","nome":"Outro ano"}, + {"data":"lixo","nome":"Inválido"}]}`)) + })) + defer srv.Close() + + got, err := NewClient(srv.URL, "chave", "3106200").FetchYear(context.Background(), 2026) + if err != nil { + t.Fatal(err) + } + days := map[string]string{} + for _, h := range got { + days[h.Day] = h.Name + } + if len(days) != 2 || days["2026-01-01"] != "Ano Novo" || days["2026-08-15"] != "Assunção de Nossa Senhora" { + t.Fatalf("feriados = %v", days) + } +} + +func TestFetchYearErrors(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + })) + defer srv.Close() + + if _, err := NewClient(srv.URL, "chave", "3106200").FetchYear(context.Background(), 2026); err == nil || !strings.Contains(err.Error(), "401") { + t.Fatalf("esperava HTTP 401, veio %v", err) + } + if _, err := NewClient(srv.URL, "chave", "MG-belo-horizonte").FetchYear(context.Background(), 2026); err == nil || !strings.Contains(err.Error(), "IBGE") { + t.Fatalf("esperava erro de código IBGE, veio %v", err) + } +} diff --git a/backend/internal/adapters/pontomais/client.go b/backend/internal/adapters/pontomais/client.go new file mode 100644 index 0000000..a25422c --- /dev/null +++ b/backend/internal/adapters/pontomais/client.go @@ -0,0 +1,290 @@ +// Package pontomais é o adapter de saída para a API da PontoMais. +// +// Contratos confirmados no Modelo: POST /api/auth/sign_in devolve token e +// client_id; GET /api/time_cards/current/last_cached devolve os registros +// recentes. A API não informa a validade do token: a sessão fica só em +// memória e é renovada quando o histórico responde 401/403. +package pontomais + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "strings" + "sync" + "time" + + "botpontos/internal/domain" +) + +var ( + // ErrAuthRejected: a PontoMais recusou o login (credencial inválida ou bloqueada). + ErrAuthRejected = errors.New("a PontoMais recusou a credencial") +) + +// Decrypter decifra a senha guardada; satisfeito por *crypto.Keyring. +type Decrypter interface { + Decrypt(data []byte, version int16) (string, error) +} + +type Client struct { + http *http.Client + authURL string + historyURL string + keys Decrypter + retryDelay time.Duration + + mu sync.Mutex + sessions map[int64]cachedSession +} + +type session struct { + client string + token string + uid string +} + +type cachedSession struct { + session session + credentialsUpdated time.Time +} + +func NewClient(authURL, historyURL string, timeout time.Duration, keys Decrypter) *Client { + return &Client{ + http: &http.Client{Timeout: timeout}, + authURL: authURL, + historyURL: historyURL, + keys: keys, + retryDelay: 3 * time.Second, + sessions: make(map[int64]cachedSession), + } +} + +// History devolve os pontos recentes do usuário. Faz uma nova tentativa rápida +// em falhas transitórias (rede, timeout, 5xx) e refaz o login uma vez após 401/403. +func (c *Client) History(ctx context.Context, cred domain.Credential, loc *time.Location) ([]domain.Point, error) { + points, err := c.historyOnce(ctx, cred, loc) + if err == nil || !isTransient(err) { + return points, err + } + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(c.retryDelay): + } + return c.historyOnce(ctx, cred, loc) +} + +func (c *Client) historyOnce(ctx context.Context, cred domain.Credential, loc *time.Location) ([]domain.Point, error) { + s, ok := c.cached(cred) + if !ok { + password, err := c.keys.Decrypt(cred.EncryptedPassword, cred.KeyVersion) + if err != nil { + return nil, err + } + if s, err = c.signIn(ctx, cred.APIEmail, password); err != nil { + return nil, err + } + c.store(cred, s) + } + + points, err := c.fetchHistory(ctx, s, loc) + if !errors.Is(err, errUnauthorized) { + return points, err + } + + // Token recusado: descarta a sessão e tenta uma vez com login novo. + c.Invalidate(cred.UserID) + password, err := c.keys.Decrypt(cred.EncryptedPassword, cred.KeyVersion) + if err != nil { + return nil, err + } + if s, err = c.signIn(ctx, cred.APIEmail, password); err != nil { + return nil, err + } + c.store(cred, s) + points, err = c.fetchHistory(ctx, s, loc) + if errors.Is(err, errUnauthorized) { + return nil, fmt.Errorf("%w: token recusado mesmo após novo login", ErrAuthRejected) + } + return points, err +} + +// Validate testa uma credencial ainda não gravada: login + uma leitura do histórico. +func (c *Client) Validate(ctx context.Context, email, password string, loc *time.Location) error { + s, err := c.signIn(ctx, email, password) + if err != nil { + return err + } + _, err = c.fetchHistory(ctx, s, loc) + if errors.Is(err, errUnauthorized) { + return fmt.Errorf("%w: histórico recusou o token", ErrAuthRejected) + } + return err +} + +// Invalidate descarta a sessão em memória (ex.: credencial alterada). +func (c *Client) Invalidate(userID int64) { + c.mu.Lock() + defer c.mu.Unlock() + delete(c.sessions, userID) +} + +func (c *Client) cached(cred domain.Credential) (session, bool) { + c.mu.Lock() + defer c.mu.Unlock() + item, ok := c.sessions[cred.UserID] + if !ok || !item.credentialsUpdated.Equal(cred.UpdatedAt) { + return session{}, false + } + return item.session, true +} + +func (c *Client) store(cred domain.Credential, s session) { + c.mu.Lock() + defer c.mu.Unlock() + c.sessions[cred.UserID] = cachedSession{session: s, credentialsUpdated: cred.UpdatedAt} +} + +type authRequest struct { + Login string `json:"login"` + Password string `json:"password"` +} + +type authResponse struct { + Token string `json:"token"` + ClientID string `json:"client_id"` + Data struct { + Login string `json:"login"` + } `json:"data"` +} + +func (c *Client) signIn(ctx context.Context, email, password string) (session, error) { + body, err := json.Marshal(authRequest{Login: email, Password: password}) + if err != nil { + return session{}, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.authURL, bytes.NewReader(body)) + if err != nil { + return session{}, err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + + resp, err := c.http.Do(req) + if err != nil { + return session{}, networkError("login", err) + } + defer resp.Body.Close() + + switch { + case resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden || + resp.StatusCode == http.StatusUnprocessableEntity: + return session{}, fmt.Errorf("%w (login HTTP %d)", ErrAuthRejected, resp.StatusCode) + case resp.StatusCode < 200 || resp.StatusCode > 299: + return session{}, &statusError{op: "login", code: resp.StatusCode} + } + + var payload authResponse + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + return session{}, fmt.Errorf("resposta de login ilegível: %w", err) + } + if payload.Token == "" || payload.ClientID == "" { + return session{}, errors.New("resposta de login sem token ou client_id") + } + uid := payload.Data.Login + if uid == "" { + uid = email + } + return session{client: payload.ClientID, token: payload.Token, uid: uid}, nil +} + +type historyResponse struct { + TimeCards []struct { + Date string `json:"date"` + Time string `json:"time"` + Disabled bool `json:"disabled"` + } `json:"time_cards"` +} + +var errUnauthorized = errors.New("token não autorizado") + +func (c *Client) fetchHistory(ctx context.Context, s session, loc *time.Location) ([]domain.Point, error) { + if _, err := url.Parse(c.historyURL); err != nil { + return nil, fmt.Errorf("URL do histórico inválida: %w", err) + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.historyURL, nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/json") + req.Header.Set("access-token", s.token) + req.Header.Set("client", s.client) + req.Header.Set("uid", s.uid) + + resp, err := c.http.Do(req) + if err != nil { + return nil, networkError("histórico", err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { + return nil, errUnauthorized + } + if resp.StatusCode < 200 || resp.StatusCode > 299 { + return nil, &statusError{op: "histórico", code: resp.StatusCode} + } + + var payload historyResponse + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + return nil, fmt.Errorf("resposta de histórico ilegível: %w", err) + } + + points := make([]domain.Point, 0, len(payload.TimeCards)) + for _, raw := range payload.TimeCards { + if raw.Disabled { + continue + } + value := strings.TrimSpace(raw.Date) + " " + strings.TrimSpace(raw.Time) + occurredAt, err := time.ParseInLocation("2006-01-02 15:04:05", value, loc) + if err != nil { + return nil, fmt.Errorf("%w: horário %q", domain.ErrInvalidPointHistory, value) + } + points = append(points, domain.Point{OccurredAt: occurredAt}) + } + return points, nil +} + +type statusError struct { + op string + code int +} + +func (e *statusError) Error() string { return fmt.Sprintf("PontoMais: %s retornou HTTP %d", e.op, e.code) } + +type transientError struct{ err error } + +func (e *transientError) Error() string { return e.err.Error() } +func (e *transientError) Unwrap() error { return e.err } + +// networkError remove a URL do erro (por segurança e legibilidade) e marca como transitório. +func networkError(op string, err error) error { + var urlErr *url.Error + if errors.As(err, &urlErr) { + err = urlErr.Err + } + return &transientError{err: fmt.Errorf("PontoMais: falha de rede no %s: %w", op, err)} +} + +func isTransient(err error) bool { + var t *transientError + if errors.As(err, &t) { + return true + } + var s *statusError + return errors.As(err, &s) && (s.code >= 500 || s.code == http.StatusTooManyRequests) +} diff --git a/backend/internal/adapters/pontomais/client_test.go b/backend/internal/adapters/pontomais/client_test.go new file mode 100644 index 0000000..d48f112 --- /dev/null +++ b/backend/internal/adapters/pontomais/client_test.go @@ -0,0 +1,141 @@ +package pontomais + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + "time" + + "botpontos/internal/domain" +) + +type plainKeys struct{} + +func (plainKeys) Decrypt(data []byte, _ int16) (string, error) { return string(data), nil } + +var saoPaulo = domain.LoadLocation("America/Sao_Paulo") + +func newServer(t *testing.T, historyStatus func(call int32) int) (*httptest.Server, *int32, *int32) { + t.Helper() + var logins, histories int32 + mux := http.NewServeMux() + mux.HandleFunc("POST /api/auth/sign_in", func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&logins, 1) + var body authRequest + _ = json.NewDecoder(r.Body).Decode(&body) + if body.Password != "certa" { + w.WriteHeader(http.StatusUnauthorized) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{"token": "tok", "client_id": "cli", "data": map[string]string{"login": body.Login}}) + }) + mux.HandleFunc("GET /api/time_cards/current/last_cached", func(w http.ResponseWriter, r *http.Request) { + call := atomic.AddInt32(&histories, 1) + if status := historyStatus(call); status != http.StatusOK { + w.WriteHeader(status) + return + } + if r.Header.Get("access-token") != "tok" { + w.WriteHeader(http.StatusUnauthorized) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{"time_cards": []map[string]any{ + {"date": "2026-10-14", "time": "08:00:00"}, + {"date": "2026-10-14", "time": "12:00:00"}, + {"date": "2026-10-14", "time": "12:30:00", "disabled": true}, + }}) + }) + server := httptest.NewServer(mux) + t.Cleanup(server.Close) + return server, &logins, &histories +} + +func newClient(server *httptest.Server) *Client { + c := NewClient(server.URL+"/api/auth/sign_in", server.URL+"/api/time_cards/current/last_cached", 2*time.Second, plainKeys{}) + c.retryDelay = time.Millisecond + return c +} + +func cred(password string) domain.Credential { + return domain.Credential{UserID: 1, APIEmail: "a@b.c", EncryptedPassword: []byte(password), UpdatedAt: time.Unix(100, 0)} +} + +func TestHistoryParsesAndCachesSession(t *testing.T) { + server, logins, _ := newServer(t, func(int32) int { return http.StatusOK }) + c := newClient(server) + + points, err := c.History(context.Background(), cred("certa"), saoPaulo) + if err != nil { + t.Fatal(err) + } + if len(points) != 2 { + t.Fatalf("esperados 2 pontos (o desabilitado é ignorado), vieram %d", len(points)) + } + if got := points[0].OccurredAt.In(saoPaulo).Format("15:04"); got != "08:00" { + t.Errorf("primeiro ponto = %s", got) + } + + if _, err := c.History(context.Background(), cred("certa"), saoPaulo); err != nil { + t.Fatal(err) + } + if *logins != 1 { + t.Errorf("a sessão deveria ser reaproveitada; logins = %d", *logins) + } +} + +func TestHistoryRetriesTransientError(t *testing.T) { + server, _, histories := newServer(t, func(call int32) int { + if call == 1 { + return http.StatusBadGateway + } + return http.StatusOK + }) + if _, err := newClient(server).History(context.Background(), cred("certa"), saoPaulo); err != nil { + t.Fatalf("a segunda tentativa deveria funcionar: %v", err) + } + if *histories != 2 { + t.Errorf("esperadas 2 chamadas ao histórico, vieram %d", *histories) + } +} + +func TestHistoryRejectedCredential(t *testing.T) { + server, _, _ := newServer(t, func(int32) int { return http.StatusOK }) + _, err := newClient(server).History(context.Background(), cred("errada"), saoPaulo) + if !errors.Is(err, ErrAuthRejected) { + t.Fatalf("esperado ErrAuthRejected, veio %v", err) + } +} + +func TestHistoryRelogsAfterUnauthorized(t *testing.T) { + server, logins, _ := newServer(t, func(call int32) int { + if call == 2 { + return http.StatusUnauthorized + } + return http.StatusOK + }) + c := newClient(server) + if _, err := c.History(context.Background(), cred("certa"), saoPaulo); err != nil { + t.Fatal(err) + } + if _, err := c.History(context.Background(), cred("certa"), saoPaulo); err != nil { + t.Fatalf("deveria refazer o login após 401: %v", err) + } + if *logins != 2 { + t.Errorf("esperados 2 logins, vieram %d", *logins) + } +} + +func TestValidate(t *testing.T) { + server, _, _ := newServer(t, func(int32) int { return http.StatusOK }) + c := newClient(server) + if err := c.Validate(context.Background(), "a@b.c", "certa", saoPaulo); err != nil { + t.Fatalf("credencial certa: %v", err) + } + if err := c.Validate(context.Background(), "a@b.c", "errada", saoPaulo); !errors.Is(err, ErrAuthRejected) { + t.Fatalf("credencial errada: %v", err) + } +} diff --git a/backend/internal/adapters/postgres/audit.go b/backend/internal/adapters/postgres/audit.go new file mode 100644 index 0000000..79feda8 --- /dev/null +++ b/backend/internal/adapters/postgres/audit.go @@ -0,0 +1,78 @@ +package postgres + +import ( + "context" + "encoding/json" + "time" + + "botpontos/internal/domain" +) + +func (s *Store) InsertAudit(ctx context.Context, e domain.AuditEntry) error { + details := e.Details + if details == nil { + details = map[string]any{} + } + raw, err := json.Marshal(details) + if err != nil { + return err + } + _, err = s.pool.Exec(ctx, ` + INSERT INTO audit_logs (source, actor_user_id, actor_account, target_user_id, event_type, success, details) + VALUES ($1::text::audit_source, $2, $3, $4, $5, $6, $7::text::jsonb)`, + string(e.Source), e.ActorUserID, e.ActorAccount, e.TargetUserID, e.EventType, e.Success, string(raw)) + return err +} + +type AuditFilter struct { + From *time.Time + To *time.Time + Source *string + EventType *string + Success *bool + TargetUserID *int64 + ActorAccount *int64 + Before *int64 // cursor: id do último item da página anterior + Limit int +} + +func (s *Store) ListAudit(ctx context.Context, f AuditFilter) ([]domain.AuditLog, error) { + if f.Limit <= 0 || f.Limit > 200 { + f.Limit = 50 + } + rows, err := s.pool.Query(ctx, ` + SELECT a.id, a.occurred_at, a.source, a.actor_user_id, au.display_name, a.actor_account, wa.username::text, + a.target_user_id, tu.display_name, a.event_type, a.success, a.details::text + FROM audit_logs a + LEFT JOIN users au ON au.id = a.actor_user_id + LEFT JOIN web_accounts wa ON wa.id = a.actor_account + LEFT JOIN users tu ON tu.id = a.target_user_id + WHERE ($1::timestamptz IS NULL OR a.occurred_at >= $1) + AND ($2::timestamptz IS NULL OR a.occurred_at < $2) + AND ($3::text IS NULL OR a.source::text = $3) + AND ($4::text IS NULL OR a.event_type = $4) + AND ($5::boolean IS NULL OR a.success = $5) + AND ($6::bigint IS NULL OR a.target_user_id = $6) + AND ($7::bigint IS NULL OR a.actor_account = $7) + AND ($8::bigint IS NULL OR a.id < $8) + ORDER BY a.id DESC + LIMIT $9`, + f.From, f.To, f.Source, f.EventType, f.Success, f.TargetUserID, f.ActorAccount, f.Before, f.Limit) + return collect(rows, err, func(row scanner) (domain.AuditLog, error) { + var l domain.AuditLog + var source, details string + if err := row.Scan(&l.ID, &l.OccurredAt, &source, &l.ActorUserID, &l.ActorUserName, &l.ActorAccount, + &l.ActorUsername, &l.TargetUserID, &l.TargetUserName, &l.EventType, &l.Success, &details); err != nil { + return l, err + } + l.Source = domain.AuditSource(source) + l.Details = map[string]any{} + _ = json.Unmarshal([]byte(details), &l.Details) + return l, nil + }) +} + +func (s *Store) DeleteAuditBefore(ctx context.Context, before time.Time) (int64, error) { + tag, err := s.pool.Exec(ctx, `DELETE FROM audit_logs WHERE occurred_at < $1`, before) + return tag.RowsAffected(), err +} diff --git a/backend/internal/adapters/postgres/credentials.go b/backend/internal/adapters/postgres/credentials.go new file mode 100644 index 0000000..ba9968e --- /dev/null +++ b/backend/internal/adapters/postgres/credentials.go @@ -0,0 +1,91 @@ +package postgres + +import ( + "context" + "time" + + "botpontos/internal/domain" +) + +func credentialCols(p string) string { + return cols(p, "user_id", "api_email", "encrypted_password", "key_version", "last_validated_at", + "last_error", "consecutive_failures", "last_failure_at", "updated_at") +} + +type credentialScan struct { + c domain.Credential +} + +func (s *credentialScan) dest() []any { + return []any{&s.c.UserID, &s.c.APIEmail, &s.c.EncryptedPassword, &s.c.KeyVersion, &s.c.LastValidatedAt, + &s.c.LastError, &s.c.ConsecutiveFailures, &s.c.LastFailureAt, &s.c.UpdatedAt} +} + +func (s *credentialScan) result() domain.Credential { return s.c } + +func readCredential(row scanner) (domain.Credential, error) { + var cs credentialScan + if err := row.Scan(cs.dest()...); err != nil { + return domain.Credential{}, mapErr(err) + } + return cs.result(), nil +} + +func (s *Store) GetCredential(ctx context.Context, userID int64) (domain.Credential, error) { + return readCredential(s.pool.QueryRow(ctx, `SELECT `+credentialCols("")+` FROM point_credentials WHERE user_id = $1`, userID)) +} + +// SaveCredential grava uma credencial já validada na PontoMais e zera o histórico de falhas. +func (s *Store) SaveCredential(ctx context.Context, userID int64, email string, encrypted []byte, keyVersion int16, validatedAt time.Time) error { + _, err := s.pool.Exec(ctx, ` + INSERT INTO point_credentials (user_id, api_email, encrypted_password, key_version, last_validated_at) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (user_id) DO UPDATE SET + api_email = EXCLUDED.api_email, + encrypted_password = EXCLUDED.encrypted_password, + key_version = EXCLUDED.key_version, + last_validated_at = EXCLUDED.last_validated_at, + last_error = NULL, + consecutive_failures = 0, + last_failure_at = NULL, + updated_at = now()`, + userID, email, encrypted, keyVersion, validatedAt) + return mapErr(err) +} + +// MarkCredentialValidated registra um teste bem-sucedido da credencial atual. +func (s *Store) MarkCredentialValidated(ctx context.Context, userID int64, at time.Time) error { + return expectOne(s.pool.Exec(ctx, ` + UPDATE point_credentials SET last_validated_at = $2, last_error = NULL WHERE user_id = $1`, userID, at)) +} + +// RecordCredentialFailure soma uma falha consecutiva e devolve o total (9.4). +func (s *Store) RecordCredentialFailure(ctx context.Context, userID int64, message string, at time.Time) (int, error) { + var failures int + err := s.pool.QueryRow(ctx, ` + UPDATE point_credentials + SET consecutive_failures = consecutive_failures + 1, last_error = $2, last_failure_at = $3 + WHERE user_id = $1 + RETURNING consecutive_failures`, userID, message, at).Scan(&failures) + return failures, mapErr(err) +} + +// ResetCredentialFailures zera o contador após uma consulta bem-sucedida. +func (s *Store) ResetCredentialFailures(ctx context.Context, userID int64) error { + _, err := s.pool.Exec(ctx, ` + UPDATE point_credentials SET consecutive_failures = 0, last_error = NULL + WHERE user_id = $1 AND (consecutive_failures > 0 OR last_error IS NOT NULL)`, userID) + return err +} + +func (s *Store) ListCredentials(ctx context.Context) ([]domain.Credential, error) { + rows, err := s.pool.Query(ctx, `SELECT `+credentialCols("")+` FROM point_credentials ORDER BY user_id`) + return collect(rows, err, readCredential) +} + +// UpdateCredentialCipher regrava a senha cifrada com outra chave (rotação). +func (s *Store) UpdateCredentialCipher(ctx context.Context, userID int64, encrypted []byte, keyVersion int16) error { + return expectOne(s.pool.Exec(ctx, ` + UPDATE point_credentials SET encrypted_password = $2, key_version = $3, updated_at = now() WHERE user_id = $1`, + userID, encrypted, keyVersion)) +} diff --git a/backend/internal/adapters/postgres/days.go b/backend/internal/adapters/postgres/days.go new file mode 100644 index 0000000..8943b7b --- /dev/null +++ b/backend/internal/adapters/postgres/days.go @@ -0,0 +1,64 @@ +package postgres + +import ( + "context" + + "botpontos/internal/domain" +) + +const dayCols = `user_id, to_char(day, 'YYYY-MM-DD'), status, zero_point_checks, last_point_count, + next_check_at, resumed_by, updated_at` + +func readDay(row scanner) (domain.UserDay, error) { + var d domain.UserDay + var status string + var resumed *string + if err := row.Scan(&d.UserID, &d.Day, &status, &d.ZeroPointChecks, &d.LastPointCount, + &d.NextCheckAt, &resumed, &d.UpdatedAt); err != nil { + return d, mapErr(err) + } + d.Status = domain.DayStatus(status) + if resumed != nil { + r := domain.ResumeSource(*resumed) + d.ResumedBy = &r + } + return d, nil +} + +// GetUserDay devolve a situação do dia; found=false se o dia ainda não foi consultado. +func (s *Store) GetUserDay(ctx context.Context, userID int64, day string) (domain.UserDay, bool, error) { + d, err := readDay(s.pool.QueryRow(ctx, `SELECT `+dayCols+` FROM user_days WHERE user_id = $1 AND day = $2::text::date`, userID, day)) + if err == domain.ErrNotFound { + return domain.NewUserDay(userID, day), false, nil + } + return d, err == nil, err +} + +func (s *Store) SaveUserDay(ctx context.Context, d domain.UserDay) error { + var resumed *string + if d.ResumedBy != nil { + v := string(*d.ResumedBy) + resumed = &v + } + _, err := s.pool.Exec(ctx, ` + INSERT INTO user_days (user_id, day, status, zero_point_checks, last_point_count, next_check_at, resumed_by) + VALUES ($1, $2::text::date, $3::text::day_status, $4, $5, $6, $7) + ON CONFLICT (user_id, day) DO UPDATE SET + status = EXCLUDED.status, + zero_point_checks = EXCLUDED.zero_point_checks, + last_point_count = EXCLUDED.last_point_count, + next_check_at = EXCLUDED.next_check_at, + resumed_by = EXCLUDED.resumed_by`, + d.UserID, d.Day, string(d.Status), d.ZeroPointChecks, d.LastPointCount, d.NextCheckAt, resumed) + return mapErr(err) +} + +// ResetNextChecks antecipa as próximas consultas do dia para agora; usado quando a +// janela muda (9.13), para a reconciliação recalcular tudo no próximo tick. Inclui +// o dia anterior para cobrir usuários em fusos diferentes perto da meia-noite. +func (s *Store) ResetNextChecks(ctx context.Context, day string) error { + _, err := s.pool.Exec(ctx, ` + UPDATE user_days SET next_check_at = now() + WHERE day >= $1::text::date - 1 AND next_check_at IS NOT NULL`, day) + return err +} diff --git a/backend/internal/adapters/postgres/holidays.go b/backend/internal/adapters/postgres/holidays.go new file mode 100644 index 0000000..692c163 --- /dev/null +++ b/backend/internal/adapters/postgres/holidays.go @@ -0,0 +1,66 @@ +package postgres + +import ( + "context" + + "botpontos/internal/domain" +) + +func readHoliday(row scanner) (domain.Holiday, error) { + var h domain.Holiday + err := row.Scan(&h.Day, &h.Name, &h.Source) + return h, mapErr(err) +} + +func (s *Store) IsHoliday(ctx context.Context, day string) (bool, error) { + var exists bool + err := s.pool.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM holidays WHERE day = $1::text::date)`, day).Scan(&exists) + return exists, err +} + +func (s *Store) HasSyncedHolidays(ctx context.Context, year int) (bool, error) { + var exists bool + err := s.pool.QueryRow(ctx, ` + SELECT EXISTS ( + SELECT 1 FROM holidays + WHERE source <> 'manual' AND day >= make_date($1::int, 1, 1) AND day < make_date($1::int + 1, 1, 1) + )`, year).Scan(&exists) + return exists, err +} + +func (s *Store) ListHolidays(ctx context.Context, year int) ([]domain.Holiday, error) { + rows, err := s.pool.Query(ctx, ` + SELECT to_char(day, 'YYYY-MM-DD'), name, source FROM holidays + WHERE day >= make_date($1::int, 1, 1) AND day < make_date($1::int + 1, 1, 1) + ORDER BY day`, year) + return collect(rows, err, readHoliday) +} + +// InsertHolidays grava os feriados da API sem sobrescrever datas existentes +// (feriados manuais nunca são substituídos). Devolve quantos foram inseridos. +func (s *Store) InsertHolidays(ctx context.Context, holidays []domain.Holiday) (int64, error) { + var inserted int64 + err := s.inTx(ctx, func(q querier) error { + for _, h := range holidays { + tag, err := q.Exec(ctx, ` + INSERT INTO holidays (day, name, source) VALUES ($1::text::date, $2, $3) + ON CONFLICT (day) DO NOTHING`, h.Day, h.Name, h.Source) + if err != nil { + return err + } + inserted += tag.RowsAffected() + } + return nil + }) + return inserted, err +} + +func (s *Store) AddHoliday(ctx context.Context, h domain.Holiday) error { + _, err := s.pool.Exec(ctx, `INSERT INTO holidays (day, name, source) VALUES ($1::text::date, $2, $3)`, h.Day, h.Name, h.Source) + return mapErr(err) +} + +// DeleteManualHoliday remove só feriados cadastrados manualmente. +func (s *Store) DeleteManualHoliday(ctx context.Context, day string) error { + return expectOne(s.pool.Exec(ctx, `DELETE FROM holidays WHERE day = $1::text::date AND source = 'manual'`, day)) +} diff --git a/backend/internal/adapters/postgres/migrate.go b/backend/internal/adapters/postgres/migrate.go new file mode 100644 index 0000000..4a0d9b2 --- /dev/null +++ b/backend/internal/adapters/postgres/migrate.go @@ -0,0 +1,50 @@ +package postgres + +import ( + "context" + "database/sql" + "fmt" + "log/slog" + + _ "github.com/jackc/pgx/v5/stdlib" // registra o driver "pgx" no database/sql + "github.com/pressly/goose/v3" + + "botpontos/db/migrations" + "botpontos/internal/config" +) + +// Migrate executa o goose com as migrations embutidas no binário. +// Deve rodar com o usuário dono do banco, não com o usuário da aplicação. +func Migrate(ctx context.Context, cfg config.DB, command string, logger *slog.Logger) error { + db, err := sql.Open("pgx", cfg.DSN()) + if err != nil { + return fmt.Errorf("abrir conexão para migrations: %w", err) + } + defer db.Close() + + if err := waitFor(ctx, logger, db.PingContext); err != nil { + return err + } + + goose.SetBaseFS(migrations.FS) + if err := goose.SetDialect("postgres"); err != nil { + return err + } + + switch command { + case "up": + err = goose.UpContext(ctx, db, ".") + case "status": + err = goose.StatusContext(ctx, db, ".") + case "down": + err = goose.DownContext(ctx, db, ".") + default: + return fmt.Errorf("subcomando de migrate desconhecido: %s (use up, status ou down)", command) + } + if err != nil { + return fmt.Errorf("migrate %s: %w", command, err) + } + + logger.Info("migrations concluídas", "command", command) + return nil +} diff --git a/backend/internal/adapters/postgres/notifications.go b/backend/internal/adapters/postgres/notifications.go new file mode 100644 index 0000000..55d9bd5 --- /dev/null +++ b/backend/internal/adapters/postgres/notifications.go @@ -0,0 +1,179 @@ +package postgres + +import ( + "context" + "time" + + "botpontos/internal/domain" +) + +func notificationCols(p string) string { + return cols(p, "id", "user_id") + ", to_char(" + p + "reference_date, 'YYYY-MM-DD'), " + + cols(p, "type", "scheduled_at", "predicted_at", "status", "attempts", "locked_at", "sent_at", + "telegram_message_id", "error_message", "created_at", "updated_at") +} + +func readNotification(row scanner) (domain.Notification, error) { + var n domain.Notification + var typ, status string + if err := row.Scan(&n.ID, &n.UserID, &n.ReferenceDate, &typ, &n.ScheduledAt, &n.PredictedAt, &status, + &n.Attempts, &n.LockedAt, &n.SentAt, &n.TelegramMessageID, &n.ErrorMessage, &n.CreatedAt, &n.UpdatedAt); err != nil { + return n, mapErr(err) + } + n.Type = domain.NotificationType(typ) + n.Status = domain.NotificationStatus(status) + return n, nil +} + +func (s *Store) GetNotification(ctx context.Context, id int64) (domain.Notification, error) { + return readNotification(s.pool.QueryRow(ctx, `SELECT `+notificationCols("")+` FROM notifications WHERE id = $1`, id)) +} + +// FindNotification devolve o lembrete do usuário para o dia e tipo; found=false se não existir. +func (s *Store) FindNotification(ctx context.Context, userID int64, day string, t domain.NotificationType) (domain.Notification, bool, error) { + n, err := readNotification(s.pool.QueryRow(ctx, ` + SELECT `+notificationCols("")+` FROM notifications + WHERE user_id = $1 AND reference_date = $2::text::date AND type::text = $3`, userID, day, string(t))) + if err == domain.ErrNotFound { + return n, false, nil + } + return n, err == nil, err +} + +// InsertNotification cria o lembrete se ainda não existir um para (usuário, dia, tipo). +// inserted=false devolve o lembrete que já existia, sem alterá-lo. +func (s *Store) InsertNotification(ctx context.Context, n domain.Notification) (domain.Notification, bool, error) { + created, err := readNotification(s.pool.QueryRow(ctx, ` + INSERT INTO notifications (user_id, reference_date, type, scheduled_at, predicted_at, status, error_message) + VALUES ($1, $2::text::date, $3::text::notification_type, $4, $5, $6::text::notification_status, $7) + ON CONFLICT (user_id, reference_date, type) DO NOTHING + RETURNING `+notificationCols(""), + n.UserID, n.ReferenceDate, string(n.Type), n.ScheduledAt, n.PredictedAt, string(n.Status), n.ErrorMessage)) + if err == domain.ErrNotFound { + existing, _, err := s.FindNotification(ctx, n.UserID, n.ReferenceDate, n.Type) + return existing, false, err + } + return created, err == nil, err +} + +// CancelScheduled cancela o lembrete agendado de um tipo no dia. +func (s *Store) CancelScheduled(ctx context.Context, userID int64, day string, t domain.NotificationType) (bool, error) { + tag, err := s.pool.Exec(ctx, ` + UPDATE notifications SET status = 'cancelled' + WHERE user_id = $1 AND reference_date = $2::text::date AND type::text = $3 AND status = 'scheduled'`, + userID, day, string(t)) + return tag.RowsAffected() > 0, err +} + +// CancelScheduledFrom cancela todos os lembretes agendados do usuário a partir do dia. +func (s *Store) CancelScheduledFrom(ctx context.Context, userID int64, day string) (int64, error) { + tag, err := s.pool.Exec(ctx, ` + UPDATE notifications SET status = 'cancelled' + WHERE user_id = $1 AND reference_date >= $2::text::date AND status = 'scheduled'`, userID, day) + return tag.RowsAffected(), err +} + +// CancelNotification cancela um lembrete específico, só se ainda estiver agendado. +func (s *Store) CancelNotification(ctx context.Context, id int64) (domain.Notification, error) { + return readNotification(s.pool.QueryRow(ctx, ` + UPDATE notifications SET status = 'cancelled', error_message = 'cancelado pelo painel' + WHERE id = $1 AND status = 'scheduled' + RETURNING `+notificationCols(""), id)) +} + +// ExpireOverdue marca como expired o que passou da tolerância de atraso (9.3, etapa 1). +func (s *Store) ExpireOverdue(ctx context.Context, before time.Time) ([]domain.Notification, error) { + rows, err := s.pool.Query(ctx, ` + UPDATE notifications SET status = 'expired', error_message = 'atraso acima da tolerância' + WHERE status = 'scheduled' AND scheduled_at < $1 + RETURNING `+notificationCols(""), before) + return collect(rows, err, readNotification) +} + +// ClaimDue reserva os lembretes vencidos (scheduled → sending) sem duplicidade (6.3, etapa 2). +func (s *Store) ClaimDue(ctx context.Context, now time.Time, limit int) ([]domain.Notification, error) { + rows, err := s.pool.Query(ctx, ` + WITH due AS ( + SELECT id FROM notifications + WHERE status = 'scheduled' AND scheduled_at <= $1 + ORDER BY scheduled_at + LIMIT $2 + FOR UPDATE SKIP LOCKED + ) + UPDATE notifications n + SET status = 'sending', locked_at = now(), attempts = n.attempts + 1 + FROM due WHERE n.id = due.id + RETURNING `+notificationCols("n."), now, limit) + return collect(rows, err, readNotification) +} + +// ClaimFailed reserva um lembrete failed para o reenvio manual (9.5). A troca +// de status é atômica: duplo clique ou dois admins resultam num envio só. +func (s *Store) ClaimFailed(ctx context.Context, id int64) (domain.Notification, error) { + return readNotification(s.pool.QueryRow(ctx, ` + UPDATE notifications + SET status = 'sending', locked_at = now(), attempts = attempts + 1, error_message = NULL + WHERE id = $1 AND status = 'failed' + RETURNING `+notificationCols(""), id)) +} + +func (s *Store) MarkSent(ctx context.Context, id, messageID int64, at time.Time) error { + return expectOne(s.pool.Exec(ctx, ` + UPDATE notifications SET status = 'sent', sent_at = $3, telegram_message_id = $2, error_message = NULL + WHERE id = $1 AND status = 'sending'`, id, messageID, at)) +} + +// MarkFinished encerra um lembrete em envio com failed, expired ou cancelled. +func (s *Store) MarkFinished(ctx context.Context, id int64, status domain.NotificationStatus, message string) error { + return expectOne(s.pool.Exec(ctx, ` + UPDATE notifications SET status = $2::text::notification_status, error_message = $3 + WHERE id = $1 AND status = 'sending'`, id, string(status), message)) +} + +// FailStuckSending marca como failed o que está em sending há tempo demais (crash no envio). +func (s *Store) FailStuckSending(ctx context.Context, before time.Time) ([]domain.Notification, error) { + rows, err := s.pool.Query(ctx, ` + UPDATE notifications SET status = 'failed', error_message = 'envio interrompido (reinício ou travamento)' + WHERE status = 'sending' AND locked_at < $1 + RETURNING `+notificationCols(""), before) + return collect(rows, err, readNotification) +} + +type NotificationView struct { + Notification domain.Notification + UserName string +} + +type NotificationFilter struct { + Day *string + Status *string + Type *string + UserID *int64 + Limit int +} + +func (s *Store) ListNotifications(ctx context.Context, f NotificationFilter) ([]NotificationView, error) { + if f.Limit <= 0 || f.Limit > 500 { + f.Limit = 200 + } + rows, err := s.pool.Query(ctx, ` + SELECT `+notificationCols("n.")+`, u.display_name + FROM notifications n JOIN users u ON u.id = n.user_id + WHERE ($1::text IS NULL OR n.reference_date = $1::text::date) + AND ($2::text IS NULL OR n.status::text = $2) + AND ($3::text IS NULL OR n.type::text = $3) + AND ($4::bigint IS NULL OR n.user_id = $4) + ORDER BY n.reference_date DESC, n.scheduled_at DESC + LIMIT $5`, f.Day, f.Status, f.Type, f.UserID, f.Limit) + return collect(rows, err, func(row scanner) (NotificationView, error) { + var v NotificationView + var typ, status string + n := &v.Notification + err := row.Scan(&n.ID, &n.UserID, &n.ReferenceDate, &typ, &n.ScheduledAt, &n.PredictedAt, &status, + &n.Attempts, &n.LockedAt, &n.SentAt, &n.TelegramMessageID, &n.ErrorMessage, &n.CreatedAt, &n.UpdatedAt, + &v.UserName) + n.Type = domain.NotificationType(typ) + n.Status = domain.NotificationStatus(status) + return v, err + }) +} diff --git a/backend/internal/adapters/postgres/pool.go b/backend/internal/adapters/postgres/pool.go new file mode 100644 index 0000000..55852a4 --- /dev/null +++ b/backend/internal/adapters/postgres/pool.go @@ -0,0 +1,58 @@ +// Package postgres é o adapter de saída para o PostgreSQL. +package postgres + +import ( + "context" + "fmt" + "log/slog" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + + "botpontos/internal/config" +) + +const ( + connectAttempts = 10 + connectDelay = 3 * time.Second +) + +// OpenPool abre o pool e espera o Postgres responder, com algumas tentativas +// para tolerar a ordem de subida dos containers. +func OpenPool(ctx context.Context, cfg config.DB, logger *slog.Logger) (*pgxpool.Pool, error) { + poolCfg, err := pgxpool.ParseConfig(cfg.DSN()) + if err != nil { + return nil, fmt.Errorf("configuração do Postgres: %w", err) + } + poolCfg.MaxConns = cfg.MaxConns + + pool, err := pgxpool.NewWithConfig(ctx, poolCfg) + if err != nil { + return nil, fmt.Errorf("criar pool do Postgres: %w", err) + } + if err := waitFor(ctx, logger, pool.Ping); err != nil { + pool.Close() + return nil, err + } + return pool, nil +} + +func waitFor(ctx context.Context, logger *slog.Logger, ping func(context.Context) error) error { + var err error + for attempt := 1; attempt <= connectAttempts; attempt++ { + pingCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + err = ping(pingCtx) + cancel() + if err == nil { + return nil + } + + logger.Warn("Postgres indisponível; nova tentativa", "attempt", attempt, "error", err) + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(connectDelay): + } + } + return fmt.Errorf("conectar ao Postgres após %d tentativas: %w", connectAttempts, err) +} diff --git a/backend/internal/adapters/postgres/reports.go b/backend/internal/adapters/postgres/reports.go new file mode 100644 index 0000000..c52b8e9 --- /dev/null +++ b/backend/internal/adapters/postgres/reports.go @@ -0,0 +1,92 @@ +package postgres + +import ( + "context" + "time" +) + +// Dashboard são os contadores da página inicial do painel. +type Dashboard struct { + UsersByStatus map[string]int + NotificationsEnabled int + PausedByAPIFailures int + TodayByStatus map[string]int + FailedLast7Days int + ExpiredLast7Days int + CustomWindows int +} + +func (s *Store) Dashboard(ctx context.Context, today string) (Dashboard, error) { + d := Dashboard{UsersByStatus: map[string]int{}, TodayByStatus: map[string]int{}} + + if err := s.countBy(ctx, `SELECT status::text, count(*) FROM users GROUP BY 1`, d.UsersByStatus); err != nil { + return d, err + } + if err := s.countBy(ctx, `SELECT status::text, count(*) FROM notifications WHERE reference_date = $1::text::date GROUP BY 1`, + d.TodayByStatus, today); err != nil { + return d, err + } + err := s.pool.QueryRow(ctx, ` + SELECT + (SELECT count(*) FROM users WHERE status = 'active' AND notifications_enabled), + (SELECT count(*) FROM users WHERE status = 'active' AND notifications_paused_reason = 'api_failures'), + (SELECT count(*) FROM notifications WHERE status = 'failed' AND reference_date >= $1::text::date - 7), + (SELECT count(*) FROM notifications WHERE status = 'expired' AND reference_date >= $1::text::date - 7), + (SELECT count(*) FROM work_schedules ws JOIN users u ON u.id = ws.user_id + WHERE ws.query_window_start IS NOT NULL AND u.status = 'active')`, today). + Scan(&d.NotificationsEnabled, &d.PausedByAPIFailures, &d.FailedLast7Days, &d.ExpiredLast7Days, &d.CustomWindows) + return d, err +} + +func (s *Store) countBy(ctx context.Context, sql string, out map[string]int, args ...any) error { + rows, err := s.pool.Query(ctx, sql, args...) + if err != nil { + return err + } + defer rows.Close() + for rows.Next() { + var key string + var n int + if err := rows.Scan(&key, &n); err != nil { + return err + } + out[key] = n + } + return rows.Err() +} + +// ReminderSample é um lembrete de entrada2 recente, usado na pré-visualização +// da janela (9.13): se o horário do aviso cai antes do início da nova janela, +// ele teria saído atrasado ou expirado. +type ReminderSample struct { + UserID int64 + Timezone string + Day string + ScheduledAt time.Time + CustomWindow bool +} + +func (s *Store) Entrada2Samples(ctx context.Context, sinceDay string) ([]ReminderSample, error) { + rows, err := s.pool.Query(ctx, ` + SELECT n.user_id, u.timezone, to_char(n.reference_date, 'YYYY-MM-DD'), n.scheduled_at, + ws.query_window_start IS NOT NULL + FROM notifications n + JOIN users u ON u.id = n.user_id + JOIN work_schedules ws ON ws.user_id = n.user_id + WHERE n.type = 'entrada2' AND n.reference_date >= $1::text::date`, sinceDay) + return collect(rows, err, func(row scanner) (ReminderSample, error) { + var x ReminderSample + err := row.Scan(&x.UserID, &x.Timezone, &x.Day, &x.ScheduledAt, &x.CustomWindow) + return x, err + }) +} + +func (s *Store) DeleteNotificationsBefore(ctx context.Context, day string) (int64, error) { + tag, err := s.pool.Exec(ctx, `DELETE FROM notifications WHERE reference_date < $1::text::date`, day) + return tag.RowsAffected(), err +} + +func (s *Store) DeleteUserDaysBefore(ctx context.Context, day string) (int64, error) { + tag, err := s.pool.Exec(ctx, `DELETE FROM user_days WHERE day < $1::text::date`, day) + return tag.RowsAffected(), err +} diff --git a/backend/internal/adapters/postgres/schedules.go b/backend/internal/adapters/postgres/schedules.go new file mode 100644 index 0000000..bc79b5b --- /dev/null +++ b/backend/internal/adapters/postgres/schedules.go @@ -0,0 +1,93 @@ +package postgres + +import ( + "context" + "fmt" + + "botpontos/internal/domain" +) + +func scheduleCols(p string) string { + return fmt.Sprintf(`%[1]suser_id, %[1]slunch_minutes, %[1]sdaily_work_minutes, %[1]sentrada2_lead_minutes, + %[1]ssaida2_lead_minutes, + to_char(%[1]squery_window_start, 'HH24:MI'), to_char(%[1]squery_window_end, 'HH24:MI'), + to_char(%[1]sday_check_deadline, 'HH24:MI'), to_char(%[1]sno_points_recheck_until, 'HH24:MI')`, p) +} + +type scheduleScan struct { + s domain.WorkSchedule + start, end, deadline, recheckUntil *string +} + +func (s *scheduleScan) dest() []any { + return []any{&s.s.UserID, &s.s.LunchMinutes, &s.s.DailyWorkMinutes, &s.s.Entrada2LeadMinutes, + &s.s.Saida2LeadMinutes, &s.start, &s.end, &s.deadline, &s.recheckUntil} +} + +func (s *scheduleScan) result() (domain.WorkSchedule, error) { + out := s.s + if s.start == nil || s.end == nil || s.deadline == nil || s.recheckUntil == nil { + return out, nil + } + var w domain.QueryWindow + var err error + if w.Start, err = domain.ParseClock(*s.start); err != nil { + return out, err + } + if w.End, err = domain.ParseClock(*s.end); err != nil { + return out, err + } + if w.DayCheckDeadline, err = domain.ParseClock(*s.deadline); err != nil { + return out, err + } + if w.NoPointsRecheckUntil, err = domain.ParseClock(*s.recheckUntil); err != nil { + return out, err + } + out.Window = &w + return out, nil +} + +// GetSchedule devolve a jornada do usuário ou a padrão, se ainda não houver linha. +func (s *Store) GetSchedule(ctx context.Context, userID int64) (domain.WorkSchedule, error) { + var ss scheduleScan + err := s.pool.QueryRow(ctx, `SELECT `+scheduleCols("")+` FROM work_schedules WHERE user_id = $1`, userID).Scan(ss.dest()...) + if err != nil { + if mapErr(err) == domain.ErrNotFound { + return domain.DefaultSchedule(userID), nil + } + return domain.WorkSchedule{}, err + } + return ss.result() +} + +func (s *Store) SaveSchedule(ctx context.Context, sch domain.WorkSchedule) error { + var start, end, deadline, recheck *string + if w := sch.Window; w != nil { + start, end = strPtr(w.Start.String()), strPtr(w.End.String()) + deadline, recheck = strPtr(w.DayCheckDeadline.String()), strPtr(w.NoPointsRecheckUntil.String()) + } + _, err := s.pool.Exec(ctx, ` + INSERT INTO work_schedules (user_id, lunch_minutes, daily_work_minutes, entrada2_lead_minutes, saida2_lead_minutes, + query_window_start, query_window_end, day_check_deadline, no_points_recheck_until) + VALUES ($1, $2, $3, $4, $5, $6::text::time, $7::text::time, $8::text::time, $9::text::time) + ON CONFLICT (user_id) DO UPDATE SET + lunch_minutes = EXCLUDED.lunch_minutes, + daily_work_minutes = EXCLUDED.daily_work_minutes, + entrada2_lead_minutes = EXCLUDED.entrada2_lead_minutes, + saida2_lead_minutes = EXCLUDED.saida2_lead_minutes, + query_window_start = EXCLUDED.query_window_start, + query_window_end = EXCLUDED.query_window_end, + day_check_deadline = EXCLUDED.day_check_deadline, + no_points_recheck_until = EXCLUDED.no_points_recheck_until`, + sch.UserID, sch.LunchMinutes, sch.DailyWorkMinutes, sch.Entrada2LeadMinutes, sch.Saida2LeadMinutes, + start, end, deadline, recheck) + return mapErr(err) +} + +func (s *Store) CountCustomWindows(ctx context.Context) (int, error) { + var n int + err := s.pool.QueryRow(ctx, ` + SELECT count(*) FROM work_schedules ws JOIN users u ON u.id = ws.user_id + WHERE ws.query_window_start IS NOT NULL AND u.status = 'active'`).Scan(&n) + return n, err +} diff --git a/backend/internal/adapters/postgres/settings.go b/backend/internal/adapters/postgres/settings.go new file mode 100644 index 0000000..3cc6d50 --- /dev/null +++ b/backend/internal/adapters/postgres/settings.go @@ -0,0 +1,90 @@ +package postgres + +import ( + "context" + "encoding/json" + "fmt" + + "botpontos/internal/domain" +) + +// GetSettings devolve as configurações globais (app_settings) como texto. +func (s *Store) GetSettings(ctx context.Context) (map[string]string, error) { + rows, err := s.pool.Query(ctx, `SELECT key, value #>> '{}' FROM app_settings`) + if err != nil { + return nil, err + } + defer rows.Close() + out := map[string]string{} + for rows.Next() { + var key string + var value *string + if err := rows.Scan(&key, &value); err != nil { + return nil, err + } + if value != nil { + out[key] = *value + } + } + return out, rows.Err() +} + +// SeedSettings grava os valores padrão que ainda não existem (primeira subida). +func (s *Store) SeedSettings(ctx context.Context, values map[string]string) error { + return s.inTx(ctx, func(q querier) error { + for key, value := range values { + if _, err := q.Exec(ctx, ` + INSERT INTO app_settings (key, value) VALUES ($1, to_jsonb($2::text)) + ON CONFLICT (key) DO NOTHING`, key, value); err != nil { + return err + } + } + return nil + }) +} + +func (s *Store) SaveSettings(ctx context.Context, values map[string]string, accountID *int64) error { + return s.inTx(ctx, func(q querier) error { + for key, value := range values { + if _, err := q.Exec(ctx, ` + INSERT INTO app_settings (key, value, updated_by_account) VALUES ($1, to_jsonb($2::text), $3) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_by_account = EXCLUDED.updated_by_account`, + key, value, accountID); err != nil { + return err + } + } + return nil + }) +} + +// GetState lê um estado operacional (service_state) para dentro de out. +func (s *Store) GetState(ctx context.Context, key string, out any) (bool, error) { + var raw string + err := s.pool.QueryRow(ctx, `SELECT value::text FROM service_state WHERE key = $1`, key).Scan(&raw) + if err != nil { + if mapErr(err) == domain.ErrNotFound { + return false, nil + } + return false, err + } + if err := json.Unmarshal([]byte(raw), out); err != nil { + return false, fmt.Errorf("service_state %s ilegível: %w", key, err) + } + return true, nil +} + +func (s *Store) SetState(ctx context.Context, key string, value any) error { + raw, err := json.Marshal(value) + if err != nil { + return err + } + _, err = s.pool.Exec(ctx, ` + INSERT INTO service_state (key, value) VALUES ($1, $2::text::jsonb) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`, key, string(raw)) + return err +} + +func (s *Store) DeleteState(ctx context.Context, key string) error { + _, err := s.pool.Exec(ctx, `DELETE FROM service_state WHERE key = $1`, key) + return err +} diff --git a/backend/internal/adapters/postgres/store.go b/backend/internal/adapters/postgres/store.go new file mode 100644 index 0000000..5445e66 --- /dev/null +++ b/backend/internal/adapters/postgres/store.go @@ -0,0 +1,125 @@ +package postgres + +import ( + "context" + "errors" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgxpool" + + "botpontos/internal/domain" +) + +// Store reúne as consultas da aplicação. As queries são escritas à mão com pgx. +// +// Convenções: +// - parâmetros de enum usam $n::text::, para o pgx enviar texto; +// - datas (AAAA-MM-DD) trafegam como string, com $n::text::date e to_char na leitura; +// - filtros opcionais usam ($n:: IS NULL OR ...) com ponteiros nil. +type Store struct { + pool *pgxpool.Pool +} + +func NewStore(pool *pgxpool.Pool) *Store { + return &Store{pool: pool} +} + +// Ping é usado pelos healthchecks. +func (s *Store) Ping(ctx context.Context) error { + return s.pool.Ping(ctx) +} + +type querier interface { + Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error) + Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error) + QueryRow(ctx context.Context, sql string, args ...any) pgx.Row +} + +type scanner interface { + Scan(dest ...any) error +} + +// inTx executa fn numa transação; qualquer erro desfaz tudo. +func (s *Store) inTx(ctx context.Context, fn func(q querier) error) error { + tx, err := s.pool.Begin(ctx) + if err != nil { + return err + } + defer func() { _ = tx.Rollback(ctx) }() + if err := fn(tx); err != nil { + return err + } + return tx.Commit(ctx) +} + +// mapErr traduz erros do pgx para os erros do domínio. +func mapErr(err error) error { + if err == nil { + return nil + } + if errors.Is(err, pgx.ErrNoRows) { + return domain.ErrNotFound + } + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) && pgErr.Code == "23505" { + return domain.ErrConflict + } + return err +} + +func expectOne(tag pgconn.CommandTag, err error) error { + if err != nil { + return mapErr(err) + } + if tag.RowsAffected() == 0 { + return domain.ErrNotFound + } + return nil +} + +// collect lê todas as linhas com a função de leitura informada. +func collect[T any](rows pgx.Rows, err error, read func(scanner) (T, error)) ([]T, error) { + if err != nil { + return nil, err + } + defer rows.Close() + var out []T + for rows.Next() { + item, err := read(rows) + if err != nil { + return nil, err + } + out = append(out, item) + } + if err := rows.Err(); err != nil { + return nil, err + } + return out, nil +} + +func cols(prefix string, names ...string) string { + out := "" + for i, n := range names { + if i > 0 { + out += ", " + } + out += prefix + n + } + return out +} + +func strPtr(s string) *string { + if s == "" { + return nil + } + return &s +} + +func pauseReasonPtr(r *domain.PauseReason) *string { + if r == nil { + return nil + } + v := string(*r) + return &v +} diff --git a/backend/internal/adapters/postgres/users.go b/backend/internal/adapters/postgres/users.go new file mode 100644 index 0000000..7fcf24c --- /dev/null +++ b/backend/internal/adapters/postgres/users.go @@ -0,0 +1,198 @@ +package postgres + +import ( + "context" + + "botpontos/internal/domain" +) + +func userCols(p string) string { + return cols(p, "id", "telegram_chat_id", "display_name", "status", "is_bot_admin", + "notifications_enabled", "notifications_paused_reason", "timezone", "created_at", "updated_at") +} + +type userScan struct { + u domain.User + status string + reason *string +} + +func (s *userScan) dest() []any { + return []any{&s.u.ID, &s.u.TelegramChatID, &s.u.DisplayName, &s.status, &s.u.IsBotAdmin, + &s.u.NotificationsEnabled, &s.reason, &s.u.Timezone, &s.u.CreatedAt, &s.u.UpdatedAt} +} + +func (s *userScan) result() domain.User { + u := s.u + u.Status = domain.UserStatus(s.status) + if s.reason != nil { + r := domain.PauseReason(*s.reason) + u.PausedReason = &r + } + return u +} + +func readUser(row scanner) (domain.User, error) { + var s userScan + if err := row.Scan(s.dest()...); err != nil { + return domain.User{}, mapErr(err) + } + return s.result(), nil +} + +func (s *Store) GetUser(ctx context.Context, id int64) (domain.User, error) { + return readUser(s.pool.QueryRow(ctx, `SELECT `+userCols("")+` FROM users WHERE id = $1`, id)) +} + +func (s *Store) GetUserByChatID(ctx context.Context, chatID int64) (domain.User, error) { + return readUser(s.pool.QueryRow(ctx, `SELECT `+userCols("")+` FROM users WHERE telegram_chat_id = $1`, chatID)) +} + +// CreatePendingUser cria o cadastro (idempotente por chat_id) e a jornada padrão. +func (s *Store) CreatePendingUser(ctx context.Context, chatID int64, name, timezone string) (domain.User, bool, error) { + var user domain.User + created := false + err := s.inTx(ctx, func(q querier) error { + u, err := readUser(q.QueryRow(ctx, ` + INSERT INTO users (telegram_chat_id, display_name, timezone) + VALUES ($1, $2, $3) + ON CONFLICT (telegram_chat_id) DO NOTHING + RETURNING `+userCols(""), chatID, name, timezone)) + if err == domain.ErrNotFound { + user, err = readUser(q.QueryRow(ctx, `SELECT `+userCols("")+` FROM users WHERE telegram_chat_id = $1`, chatID)) + return err + } + if err != nil { + return err + } + if _, err := q.Exec(ctx, `INSERT INTO work_schedules (user_id) VALUES ($1) ON CONFLICT DO NOTHING`, u.ID); err != nil { + return err + } + user, created = u, true + return nil + }) + return user, created, err +} + +// UserSummary é a linha da lista de usuários do painel. +type UserSummary struct { + User domain.User + HasCredential bool + CredentialValidated bool + ConsecutiveFailures int + CustomWindow bool +} + +type UserFilter struct { + Status *string + Query *string + CustomWindow *bool +} + +func (s *Store) ListUsers(ctx context.Context, f UserFilter) ([]UserSummary, error) { + rows, err := s.pool.Query(ctx, ` + SELECT `+userCols("u.")+`, + c.user_id IS NOT NULL, + c.last_validated_at IS NOT NULL, + COALESCE(c.consecutive_failures, 0), + ws.query_window_start IS NOT NULL + FROM users u + LEFT JOIN point_credentials c ON c.user_id = u.id + LEFT JOIN work_schedules ws ON ws.user_id = u.id + WHERE ($1::text IS NULL OR u.status::text = $1) + AND ($2::text IS NULL OR u.display_name ILIKE '%' || replace(replace(replace($2, '\', '\\'), '%', '\%'), '_', '\_') || '%' + OR u.telegram_chat_id::text = $2) + AND ($3::boolean IS NULL OR (ws.query_window_start IS NOT NULL) = $3) + ORDER BY u.status, u.display_name`, f.Status, f.Query, f.CustomWindow) + return collect(rows, err, func(row scanner) (UserSummary, error) { + var us userScan + var sum UserSummary + dest := append(us.dest(), &sum.HasCredential, &sum.CredentialValidated, &sum.ConsecutiveFailures, &sum.CustomWindow) + if err := row.Scan(dest...); err != nil { + return sum, err + } + sum.User = us.result() + return sum, nil + }) +} + +func (s *Store) SetUserStatus(ctx context.Context, id int64, status domain.UserStatus) error { + return expectOne(s.pool.Exec(ctx, `UPDATE users SET status = $2::text::user_status WHERE id = $1`, id, string(status))) +} + +// SetNotifications liga ou desliga os lembretes; reason é nil ao ligar. +func (s *Store) SetNotifications(ctx context.Context, id int64, enabled bool, reason *domain.PauseReason) error { + return expectOne(s.pool.Exec(ctx, + `UPDATE users SET notifications_enabled = $2, notifications_paused_reason = $3 WHERE id = $1`, + id, enabled, pauseReasonPtr(reason))) +} + +func (s *Store) SetBotAdmin(ctx context.Context, id int64, enabled bool) error { + return expectOne(s.pool.Exec(ctx, `UPDATE users SET is_bot_admin = $2 WHERE id = $1`, id, enabled)) +} + +func (s *Store) SetTimezone(ctx context.Context, id int64, timezone string) error { + return expectOne(s.pool.Exec(ctx, `UPDATE users SET timezone = $2 WHERE id = $1`, id, timezone)) +} + +func (s *Store) ListActiveBotAdmins(ctx context.Context) ([]domain.User, error) { + rows, err := s.pool.Query(ctx, `SELECT `+userCols("")+` FROM users WHERE status = 'active' AND is_bot_admin ORDER BY id`) + return collect(rows, err, readUser) +} + +// ReconUser é um usuário apto à reconciliação: ativo, com lembretes ligados e credencial validada. +type ReconUser struct { + User domain.User + Schedule domain.WorkSchedule + Credential domain.Credential +} + +func (s *Store) ListReconciliationUsers(ctx context.Context) ([]ReconUser, error) { + rows, err := s.pool.Query(ctx, ` + SELECT `+userCols("u.")+`, `+scheduleCols("ws.")+`, `+credentialCols("c.")+` + FROM users u + JOIN work_schedules ws ON ws.user_id = u.id + JOIN point_credentials c ON c.user_id = u.id + WHERE u.status = 'active' AND u.notifications_enabled AND c.last_validated_at IS NOT NULL + ORDER BY u.id`) + return collect(rows, err, func(row scanner) (ReconUser, error) { + var us userScan + var ss scheduleScan + var cs credentialScan + dest := append(append(us.dest(), ss.dest()...), cs.dest()...) + if err := row.Scan(dest...); err != nil { + return ReconUser{}, err + } + schedule, err := ss.result() + if err != nil { + return ReconUser{}, err + } + return ReconUser{User: us.result(), Schedule: schedule, Credential: cs.result()}, nil + }) +} + +// UserWithSchedule é usado na verificação de jornadas na subida do worker. +type UserWithSchedule struct { + User domain.User + Schedule domain.WorkSchedule +} + +func (s *Store) ListActiveSchedules(ctx context.Context) ([]UserWithSchedule, error) { + rows, err := s.pool.Query(ctx, ` + SELECT `+userCols("u.")+`, `+scheduleCols("ws.")+` + FROM users u JOIN work_schedules ws ON ws.user_id = u.id + WHERE u.status = 'active' + ORDER BY u.id`) + return collect(rows, err, func(row scanner) (UserWithSchedule, error) { + var us userScan + var ss scheduleScan + if err := row.Scan(append(us.dest(), ss.dest()...)...); err != nil { + return UserWithSchedule{}, err + } + schedule, err := ss.result() + if err != nil { + return UserWithSchedule{}, err + } + return UserWithSchedule{User: us.result(), Schedule: schedule}, nil + }) +} diff --git a/backend/internal/adapters/postgres/web.go b/backend/internal/adapters/postgres/web.go new file mode 100644 index 0000000..536f4da --- /dev/null +++ b/backend/internal/adapters/postgres/web.go @@ -0,0 +1,157 @@ +package postgres + +import ( + "context" + "time" + + "botpontos/internal/domain" +) + +func accountCols(p string) string { + return p + "id, " + p + "username::text, " + cols(p, "password_hash", "role", "user_id", "failed_login_count", + "locked_until", "disabled_at", "last_login_at", "created_at") +} + +type accountScan struct { + a domain.WebAccount + role string +} + +func (s *accountScan) dest() []any { + return []any{&s.a.ID, &s.a.Username, &s.a.PasswordHash, &s.role, &s.a.UserID, &s.a.FailedLoginCount, + &s.a.LockedUntil, &s.a.DisabledAt, &s.a.LastLoginAt, &s.a.CreatedAt} +} + +func (s *accountScan) result() domain.WebAccount { + a := s.a + a.Role = domain.Role(s.role) + return a +} + +func readAccount(row scanner) (domain.WebAccount, error) { + var as accountScan + if err := row.Scan(as.dest()...); err != nil { + return domain.WebAccount{}, mapErr(err) + } + return as.result(), nil +} + +func (s *Store) CreateWebAccount(ctx context.Context, username, passwordHash string, role domain.Role, userID *int64) (domain.WebAccount, error) { + return readAccount(s.pool.QueryRow(ctx, ` + INSERT INTO web_accounts (username, password_hash, role, user_id) + VALUES ($1::text::citext, $2, $3::text::web_role, $4) + RETURNING `+accountCols(""), username, passwordHash, string(role), userID)) +} + +func (s *Store) GetWebAccount(ctx context.Context, id int64) (domain.WebAccount, error) { + return readAccount(s.pool.QueryRow(ctx, `SELECT `+accountCols("")+` FROM web_accounts WHERE id = $1`, id)) +} + +func (s *Store) GetWebAccountByUsername(ctx context.Context, username string) (domain.WebAccount, error) { + return readAccount(s.pool.QueryRow(ctx, `SELECT `+accountCols("")+` FROM web_accounts WHERE username = $1::text::citext`, username)) +} + +func (s *Store) ListWebAccounts(ctx context.Context) ([]domain.WebAccount, error) { + rows, err := s.pool.Query(ctx, `SELECT `+accountCols("")+` FROM web_accounts ORDER BY username`) + return collect(rows, err, readAccount) +} + +// UpdateWebAccount altera papel, situação e/ou vínculo com o usuário do Telegram; +// nil mantém o valor atual. Em setUser, userID nil remove o vínculo. +func (s *Store) UpdateWebAccount(ctx context.Context, id int64, role *domain.Role, disabled *bool, setUser bool, userID *int64) error { + var roleText *string + if role != nil { + v := string(*role) + roleText = &v + } + return expectOne(s.pool.Exec(ctx, ` + UPDATE web_accounts SET + role = COALESCE($2::text::web_role, role), + disabled_at = CASE + WHEN $3::boolean IS NULL THEN disabled_at + WHEN $3::boolean THEN COALESCE(disabled_at, now()) + ELSE NULL + END, + user_id = CASE WHEN $4::boolean THEN $5::bigint ELSE user_id END + WHERE id = $1`, id, roleText, disabled, setUser, userID)) +} + +func (s *Store) SetWebAccountPassword(ctx context.Context, id int64, passwordHash string) error { + return expectOne(s.pool.Exec(ctx, ` + UPDATE web_accounts SET password_hash = $2, failed_login_count = 0, locked_until = NULL + WHERE id = $1`, id, passwordHash)) +} + +func (s *Store) CountActiveOwners(ctx context.Context) (int, error) { + var n int + err := s.pool.QueryRow(ctx, `SELECT count(*) FROM web_accounts WHERE role = 'owner' AND disabled_at IS NULL`).Scan(&n) + return n, err +} + +// RecordLoginFailure soma uma falha; ao atingir o limite, bloqueia até lockUntil e zera o contador. +func (s *Store) RecordLoginFailure(ctx context.Context, id int64, maxFailures int, lockUntil time.Time) (locked bool, err error) { + err = s.pool.QueryRow(ctx, ` + UPDATE web_accounts SET + failed_login_count = CASE WHEN failed_login_count + 1 >= $2 THEN 0 ELSE failed_login_count + 1 END, + locked_until = CASE WHEN failed_login_count + 1 >= $2 THEN $3 ELSE locked_until END + WHERE id = $1 + RETURNING locked_until IS NOT NULL AND locked_until > now()`, id, maxFailures, lockUntil).Scan(&locked) + return locked, mapErr(err) +} + +func (s *Store) RecordLoginSuccess(ctx context.Context, id int64, at time.Time) error { + return expectOne(s.pool.Exec(ctx, ` + UPDATE web_accounts SET failed_login_count = 0, locked_until = NULL, last_login_at = $2 WHERE id = $1`, id, at)) +} + +type NewSession struct { + AccountID int64 + TokenHash []byte + CSRFTokenHash []byte + ExpiresAt time.Time + UserAgent *string + IP *string +} + +func (s *Store) CreateSession(ctx context.Context, n NewSession) error { + _, err := s.pool.Exec(ctx, ` + INSERT INTO web_sessions (account_id, token_hash, csrf_token_hash, expires_at, last_seen_at, user_agent, ip) + VALUES ($1, $2, $3, $4, now(), $5, $6::text::inet)`, + n.AccountID, n.TokenHash, n.CSRFTokenHash, n.ExpiresAt, n.UserAgent, n.IP) + return mapErr(err) +} + +func (s *Store) GetSessionByTokenHash(ctx context.Context, tokenHash []byte) (domain.WebSession, error) { + var ws domain.WebSession + var as accountScan + dest := append([]any{&ws.ID, &ws.AccountID, &ws.CSRFTokenHash, &ws.ExpiresAt, &ws.LastSeenAt, &ws.RevokedAt}, as.dest()...) + err := s.pool.QueryRow(ctx, ` + SELECT s.id, s.account_id, s.csrf_token_hash, s.expires_at, s.last_seen_at, s.revoked_at, `+accountCols("a.")+` + FROM web_sessions s JOIN web_accounts a ON a.id = s.account_id + WHERE s.token_hash = $1`, tokenHash).Scan(dest...) + if err != nil { + return ws, mapErr(err) + } + ws.Account = as.result() + return ws, nil +} + +func (s *Store) TouchSession(ctx context.Context, id int64, at time.Time) error { + _, err := s.pool.Exec(ctx, `UPDATE web_sessions SET last_seen_at = $2 WHERE id = $1`, id, at) + return err +} + +func (s *Store) RevokeSession(ctx context.Context, id int64) error { + _, err := s.pool.Exec(ctx, `UPDATE web_sessions SET revoked_at = now() WHERE id = $1 AND revoked_at IS NULL`, id) + return err +} + +func (s *Store) RevokeAccountSessions(ctx context.Context, accountID int64) error { + _, err := s.pool.Exec(ctx, `UPDATE web_sessions SET revoked_at = now() WHERE account_id = $1 AND revoked_at IS NULL`, accountID) + return err +} + +func (s *Store) DeleteSessionsEndedBefore(ctx context.Context, before time.Time) (int64, error) { + tag, err := s.pool.Exec(ctx, `DELETE FROM web_sessions WHERE COALESCE(revoked_at, expires_at) < $1`, before) + return tag.RowsAffected(), err +} diff --git a/backend/internal/adapters/telegram/client.go b/backend/internal/adapters/telegram/client.go new file mode 100644 index 0000000..f50af7e --- /dev/null +++ b/backend/internal/adapters/telegram/client.go @@ -0,0 +1,165 @@ +// Package telegram é um cliente mínimo da Bot API (long polling e envio de +// mensagens), portado do Modelo. Não usa bibliotecas externas. +package telegram + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "botpontos/internal/domain" +) + +const apiBase = "https://api.telegram.org" + +type Client struct { + token string + http *http.Client + base string +} + +// NewClient aceita token vazio: nesse caso Send devolve domain.ErrNotConfigured. +func NewClient(token string) *Client { + return &Client{token: strings.TrimSpace(token), http: &http.Client{Timeout: 40 * time.Second}, base: apiBase} +} + +func (c *Client) Configured() bool { return c.token != "" } + +type Update struct { + UpdateID int64 `json:"update_id"` + Message *Message `json:"message"` +} + +type Message struct { + Chat Chat `json:"chat"` + From *From `json:"from"` + Text string `json:"text"` +} + +type Chat struct { + ID int64 `json:"id"` + Type string `json:"type"` +} + +type From struct { + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + Username string `json:"username"` +} + +// Name é o nome de exibição usado no cadastro. +func (f *From) Name() string { + if f == nil { + return "Sem nome" + } + name := strings.TrimSpace(f.FirstName + " " + f.LastName) + if name == "" { + name = f.Username + } + if name == "" { + name = "Sem nome" + } + return name +} + +type response[T any] struct { + OK bool `json:"ok"` + Result T `json:"result"` + Description string `json:"description"` +} + +func (r *response[T]) status() (bool, string) { return r.OK, r.Description } + +type sendResult struct { + MessageID int64 `json:"message_id"` +} + +// apiResponse é satisfeita por *response[T] de qualquer tipo. +type apiResponse interface { + status() (ok bool, description string) +} + +// GetUpdates faz long polling por até timeout segundos. +func (c *Client) GetUpdates(ctx context.Context, offset int64, timeout int) ([]Update, error) { + if !c.Configured() { + return nil, domain.ErrNotConfigured + } + query := url.Values{} + query.Set("timeout", strconv.Itoa(timeout)) + query.Set("offset", strconv.FormatInt(offset, 10)) + query.Set("allowed_updates", `["message"]`) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.endpoint("getUpdates")+"?"+query.Encode(), nil) + if err != nil { + return nil, c.redact(err) + } + var body response[[]Update] + if err := c.do(req, &body); err != nil { + return nil, fmt.Errorf("telegram getUpdates: %w", err) + } + return body.Result, nil +} + +// Send envia uma mensagem de texto e devolve o message_id. +func (c *Client) Send(ctx context.Context, chatID int64, text string) (int64, error) { + if !c.Configured() { + return 0, domain.ErrNotConfigured + } + form := url.Values{} + form.Set("chat_id", strconv.FormatInt(chatID, 10)) + form.Set("text", text) + form.Set("disable_web_page_preview", "true") + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint("sendMessage"), strings.NewReader(form.Encode())) + if err != nil { + return 0, c.redact(err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + var body response[sendResult] + if err := c.do(req, &body); err != nil { + return 0, fmt.Errorf("telegram sendMessage: %w", err) + } + return body.Result.MessageID, nil +} + +func (c *Client) do(req *http.Request, out apiResponse) error { + resp, err := c.http.Do(req) + if err != nil { + return c.redact(err) + } + defer resp.Body.Close() + + if err := json.NewDecoder(resp.Body).Decode(out); err != nil { + return fmt.Errorf("HTTP %d com resposta ilegível", resp.StatusCode) + } + if ok, description := out.status(); !ok { + if description == "" { + description = fmt.Sprintf("HTTP %d", resp.StatusCode) + } + return errors.New(description) + } + return nil +} + +func (c *Client) endpoint(method string) string { + return c.base + "/bot" + c.token + "/" + method +} + +// redact evita que o token (que faz parte da URL) apareça em logs e auditoria. +func (c *Client) redact(err error) error { + var urlErr *url.Error + if errors.As(err, &urlErr) { + err = urlErr.Err + } + if c.token == "" { + return err + } + return errors.New(strings.ReplaceAll(err.Error(), c.token, "")) +} diff --git a/backend/internal/app/calendar.go b/backend/internal/app/calendar.go new file mode 100644 index 0000000..46523d6 --- /dev/null +++ b/backend/internal/app/calendar.go @@ -0,0 +1,141 @@ +package app + +import ( + "context" + "errors" + "strings" + "time" + + "botpontos/internal/domain" + "botpontos/internal/rules" +) + +const stateHolidayFallbackNotified = "holiday_fallback_notified" + +// IsBusinessDay aplica a ordem da seção 9.9: fim de semana → tabela holidays +// (com sincronização anual da API) → fallback de segunda a sexta com aviso. +func (s *Service) IsBusinessDay(ctx context.Context, t time.Time, loc *time.Location) (bool, error) { + if rules.IsWeekend(t, loc) { + return false, nil + } + local := t.In(loc) + s.ensureHolidays(ctx, local.Year()) + + holiday, err := s.store.IsHoliday(ctx, local.Format(domain.DateLayout)) + if err != nil { + // Sem banco não há como saber; mantém a automação funcionando. + return true, err + } + return !holiday, nil +} + +// ensureHolidays sincroniza o ano com a API na primeira vez e, enquanto não +// der certo, tenta no máximo uma vez por dia (9.9). +func (s *Service) ensureHolidays(ctx context.Context, year int) { + today := s.TodayDate() + + s.mu.Lock() + if s.syncedYears[year] || s.syncTried[year] == today { + s.mu.Unlock() + return + } + s.syncTried[year] = today + s.mu.Unlock() + + synced, err := s.store.HasSyncedHolidays(ctx, year) + if err == nil && synced { + s.markSynced(year) + return + } + if _, err := s.syncHolidays(ctx, WorkerActor, year); err != nil { + s.holidayFallback(ctx, year, err) + return + } + s.markSynced(year) +} + +func (s *Service) markSynced(year int) { + s.mu.Lock() + s.syncedYears[year] = true + s.mu.Unlock() +} + +// holidayFallback avisa os admins no máximo uma vez por dia (9.9 e 9.11). +func (s *Service) holidayFallback(ctx context.Context, year int, cause error) { + today := s.TodayDate() + var state struct { + Day string `json:"day"` + } + if found, _ := s.store.GetState(ctx, stateHolidayFallbackNotified, &state); found && state.Day == today { + return + } + state.Day = today + if err := s.store.SetState(ctx, stateHolidayFallbackNotified, state); err != nil { + s.log.Warn("falha ao registrar aviso de feriados", "error", err) + } + s.audit(ctx, WorkerActor, nil, domain.EventHolidaySyncFailed, false, map[string]any{"year": year, "error": shortError(cause)}) + s.notifyAdmins(ctx, msgHolidayFallback(year, cause)) +} + +// SyncHolidays baixa os feriados do ano (worker ou botão do painel). +func (s *Service) SyncHolidays(ctx context.Context, actor Actor, year int) (int64, error) { + inserted, err := s.syncHolidays(ctx, actor, year) + if err == nil { + s.markSynced(year) + } + return inserted, err +} + +func (s *Service) syncHolidays(ctx context.Context, actor Actor, year int) (int64, error) { + if s.holidays == nil { + return 0, domain.ErrNotConfigured + } + holidays, err := s.holidays.FetchYear(ctx, year) + if err != nil { + if actor.Source != domain.SourceWorker { + s.audit(ctx, actor, nil, domain.EventHolidaySyncFailed, false, map[string]any{"year": year, "error": shortError(err)}) + } + return 0, err + } + inserted, err := s.store.InsertHolidays(ctx, holidays) + if err != nil { + return 0, err + } + s.audit(ctx, actor, nil, domain.EventHolidaySynced, true, map[string]any{"year": year, "received": len(holidays), "inserted": inserted}) + return inserted, nil +} + +func (s *Service) ListHolidays(ctx context.Context, year int) ([]domain.Holiday, error) { + return s.store.ListHolidays(ctx, year) +} + +func (s *Service) AddHoliday(ctx context.Context, actor Actor, day, name string) error { + name = strings.TrimSpace(name) + var errs domain.ValidationErrors + if _, err := time.Parse(domain.DateLayout, day); err != nil { + errs.Add("day", "data inválida (use AAAA-MM-DD)") + } + if name == "" || len(name) > 120 { + errs.Add("name", "obrigatório, até 120 caracteres") + } + if len(errs) > 0 { + return errs + } + err := s.store.AddHoliday(ctx, domain.Holiday{Day: day, Name: name, Source: domain.HolidaySourceManual}) + if errors.Is(err, domain.ErrConflict) { + return domain.ValidationErrors{{Field: "day", Message: "já existe um feriado nessa data"}} + } + if err != nil { + return err + } + s.audit(ctx, actor, nil, domain.EventHolidayAdded, true, map[string]any{"day": day, "name": name}) + return nil +} + +func (s *Service) DeleteHoliday(ctx context.Context, actor Actor, day string) error { + if err := s.store.DeleteManualHoliday(ctx, day); err != nil { + return err + } + s.audit(ctx, actor, nil, domain.EventHolidayRemoved, true, map[string]any{"day": day}) + return nil +} diff --git a/backend/internal/app/dispatch.go b/backend/internal/app/dispatch.go new file mode 100644 index 0000000..31e78fc --- /dev/null +++ b/backend/internal/app/dispatch.go @@ -0,0 +1,151 @@ +package app + +import ( + "context" + "errors" + + "botpontos/internal/domain" + "botpontos/internal/rules" +) + +// Dispatch é o ciclo de 2 min (6.3): expira o que passou da tolerância e +// envia o que venceu, reservando cada lembrete sem duplicidade. +func (s *Service) Dispatch(ctx context.Context) error { + now := s.now() + tolerance := s.cfg.Jobs.LateTolerance + + expired, err := s.store.ExpireOverdue(ctx, now.Add(-tolerance)) + if err != nil { + return err + } + for _, n := range expired { + s.metrics.Notification(string(n.Type), string(domain.NotificationExpired)) + s.audit(ctx, WorkerActor, &n.UserID, domain.EventNotificationExpired, false, map[string]any{ + "type": string(n.Type), "delay_minutes": int(now.Sub(n.ScheduledAt).Minutes()), + }) + } + + claimed, err := s.store.ClaimDue(ctx, now, 100) + if err != nil { + return err + } + for _, n := range claimed { + // A tolerância é conferida de novo entre a reserva e o envio. + if delay := s.now().Sub(n.ScheduledAt); delay > tolerance { + _ = s.store.MarkFinished(ctx, n.ID, domain.NotificationExpired, "atraso acima da tolerância") + s.audit(ctx, WorkerActor, &n.UserID, domain.EventNotificationExpired, false, map[string]any{ + "type": string(n.Type), "delay_minutes": int(delay.Minutes()), + }) + continue + } + u, err := s.store.GetUser(ctx, n.UserID) + if err != nil { + _ = s.store.MarkFinished(ctx, n.ID, domain.NotificationFailed, shortError(err)) + continue + } + if !u.IsActive() || !u.NotificationsEnabled { + _ = s.store.MarkFinished(ctx, n.ID, domain.NotificationCancelled, "lembretes desligados") + continue + } + if err := s.deliver(ctx, n, u); err != nil { + s.log.Warn("lembrete não enviado", "notification_id", n.ID, "error", err) + } + } + return nil +} + +// deliver envia um lembrete já reservado (sending) e registra o resultado. +func (s *Service) deliver(ctx context.Context, n domain.Notification, u domain.User) error { + schedule, err := s.store.GetSchedule(ctx, u.ID) + if err != nil { + schedule = domain.DefaultSchedule(u.ID) + } + text := msgReminder(n, u.Location(), schedule, s.now()) + + messageID, err := s.messenger.Send(ctx, u.TelegramChatID, text) + if err != nil { + _ = s.store.MarkFinished(ctx, n.ID, domain.NotificationFailed, shortError(err)) + s.metrics.Notification(string(n.Type), string(domain.NotificationFailed)) + s.audit(ctx, WorkerActor, &u.ID, domain.EventNotificationFailed, false, + map[string]any{"type": string(n.Type), "error": shortError(err)}) + return err + } + if err := s.store.MarkSent(ctx, n.ID, messageID, s.now()); err != nil { + return err + } + s.metrics.Notification(string(n.Type), string(domain.NotificationSent)) + s.audit(ctx, WorkerActor, &u.ID, domain.EventNotificationSent, true, map[string]any{"type": string(n.Type)}) + return nil +} + +// CancelNotification cancela um lembrete agendado pelo painel. +func (s *Service) CancelNotification(ctx context.Context, actor Actor, id int64) (domain.Notification, error) { + n, err := s.store.CancelNotification(ctx, id) + if errors.Is(err, domain.ErrNotFound) { + if _, getErr := s.store.GetNotification(ctx, id); getErr == nil { + return n, domain.InvalidState("só é possível cancelar lembretes agendados") + } + return n, domain.ErrNotFound + } + if err != nil { + return n, err + } + s.audit(ctx, actor, &n.UserID, domain.EventNotificationCancelled, true, + map[string]any{"notification_id": id, "type": string(n.Type)}) + return n, nil +} + +// ResendNotification reenvia manualmente um lembrete failed do dia (9.5). +func (s *Service) ResendNotification(ctx context.Context, actor Actor, id int64) (domain.Notification, error) { + n, err := s.store.GetNotification(ctx, id) + if err != nil { + return n, err + } + if n.Status != domain.NotificationFailed { + return n, domain.InvalidState("só é possível reenviar lembretes com falha") + } + u, err := s.store.GetUser(ctx, n.UserID) + if err != nil { + return n, err + } + if n.ReferenceDate != domain.LocalDay(s.now(), u.Location()) { + return n, domain.InvalidState("só é possível reenviar lembretes do dia atual") + } + if !u.IsActive() || !u.NotificationsEnabled { + return n, domain.InvalidState("o usuário precisa estar ativo e com os lembretes ligados") + } + + // Bloqueia se o ponto seguinte já foi batido. + cred, err := s.requireValidatedCredential(ctx, u.ID) + if err != nil { + return n, err + } + points, err := s.points.History(ctx, cred, u.Location()) + if err != nil { + return n, domain.InvalidState("não foi possível conferir os pontos na PontoMais: " + shortError(err)) + } + wd := rules.CurrentWorkday(points, n.ReferenceDate, u.Location()) + if (n.Type == domain.NotificationEntrada2 && wd.Count() >= 3) || (n.Type == domain.NotificationSaida2 && wd.Count() >= 4) { + return n, domain.InvalidState("o ponto seguinte já foi registrado; o lembrete não é mais necessário") + } + + claimed, err := s.store.ClaimFailed(ctx, id) + if errors.Is(err, domain.ErrNotFound) { + return n, domain.InvalidState("o lembrete já foi reenviado por outra pessoa") + } + if err != nil { + return n, err + } + sendErr := s.deliver(ctx, claimed, u) + s.audit(ctx, actor, &u.ID, domain.EventNotificationResent, sendErr == nil, + map[string]any{"notification_id": id, "type": string(n.Type)}) + + updated, err := s.store.GetNotification(ctx, id) + if err != nil { + return claimed, err + } + if sendErr != nil { + return updated, domain.InvalidState("o reenvio falhou: " + shortError(sendErr)) + } + return updated, nil +} diff --git a/backend/internal/app/maintenance.go b/backend/internal/app/maintenance.go new file mode 100644 index 0000000..8001576 --- /dev/null +++ b/backend/internal/app/maintenance.go @@ -0,0 +1,124 @@ +package app + +import ( + "context" + + "botpontos/internal/adapters/postgres" + "botpontos/internal/domain" +) + +const stateRetentionLastRun = "retention_last_run" + +// Maintenance é o ciclo de 5 min: falha os envios travados e, uma vez por dia +// no horário configurado, aplica a retenção de dados (9.10). +func (s *Service) Maintenance(ctx context.Context) error { + stuck, err := s.store.FailStuckSending(ctx, s.now().Add(-s.cfg.Jobs.SendingTimeout)) + if err != nil { + return err + } + for _, n := range stuck { + s.audit(ctx, WorkerActor, &n.UserID, domain.EventInterruptedSending, false, + map[string]any{"notification_id": n.ID, "type": string(n.Type)}) + } + return s.applyRetentionIfDue(ctx) +} + +func (s *Service) applyRetentionIfDue(ctx context.Context) error { + now := s.now().In(s.location) + if now.Hour() != s.cfg.Jobs.RetentionHour { + return nil + } + today := s.TodayDate() + var last struct { + Day string `json:"day"` + } + if found, err := s.store.GetState(ctx, stateRetentionLastRun, &last); err != nil { + return err + } else if found && last.Day == today { + return nil + } + + audit, err := s.store.DeleteAuditBefore(ctx, now.Add(-s.cfg.Jobs.RetentionAudit)) + if err != nil { + return err + } + notificationsCutoff := domain.LocalDay(now.Add(-s.cfg.Jobs.RetentionNotifications), s.location) + notifications, err := s.store.DeleteNotificationsBefore(ctx, notificationsCutoff) + if err != nil { + return err + } + days, err := s.store.DeleteUserDaysBefore(ctx, notificationsCutoff) + if err != nil { + return err + } + sessions, err := s.store.DeleteSessionsEndedBefore(ctx, now.Add(-s.cfg.Jobs.RetentionSessions)) + if err != nil { + return err + } + + last.Day = today + if err := s.store.SetState(ctx, stateRetentionLastRun, last); err != nil { + return err + } + s.audit(ctx, WorkerActor, nil, domain.EventRetentionApplied, true, map[string]any{ + "audit_logs": audit, "notifications": notifications, "user_days": days, "web_sessions": sessions, + }) + return nil +} + +// RotateKey recifra com a chave atual as credenciais gravadas com outra versão. +func (s *Service) RotateKey(ctx context.Context) (int, error) { + creds, err := s.store.ListCredentials(ctx) + if err != nil { + return 0, err + } + current := s.cipher.CurrentVersion() + rotated := 0 + for _, c := range creds { + if c.KeyVersion == current { + continue + } + plain, err := s.cipher.Decrypt(c.EncryptedPassword, c.KeyVersion) + if err != nil { + return rotated, err + } + encrypted, version, err := s.cipher.Encrypt(plain) + if err != nil { + return rotated, err + } + if err := s.store.UpdateCredentialCipher(ctx, c.UserID, encrypted, version); err != nil { + return rotated, err + } + rotated++ + } + s.audit(ctx, CLIActor, nil, domain.EventEncryptionKeyRotated, true, map[string]any{"credentials": rotated, "version": current}) + return rotated, nil +} + +// Overview reúne os dados do dashboard do painel. +type Overview struct { + Counts postgres.Dashboard + OutageSince *string + HolidayFallback *string +} + +func (s *Service) Overview(ctx context.Context) (Overview, error) { + var o Overview + counts, err := s.store.Dashboard(ctx, s.TodayDate()) + if err != nil { + return o, err + } + o.Counts = counts + var outage outageState + if found, _ := s.store.GetState(ctx, stateOutage, &outage); found { + since := outage.Since.Format("2006-01-02T15:04:05Z07:00") + o.OutageSince = &since + } + var fallback struct { + Day string `json:"day"` + } + if found, _ := s.store.GetState(ctx, stateHolidayFallbackNotified, &fallback); found && fallback.Day == s.TodayDate() { + o.HolidayFallback = &fallback.Day + } + return o, nil +} diff --git a/backend/internal/app/messages.go b/backend/internal/app/messages.go new file mode 100644 index 0000000..1b52f96 --- /dev/null +++ b/backend/internal/app/messages.go @@ -0,0 +1,78 @@ +package app + +import ( + "fmt" + "time" + + "botpontos/internal/domain" + "botpontos/internal/rules" +) + +// Textos enviados pelo Telegram. Ficam juntos para facilitar a revisão. + +func msgNewRegistration(u domain.User) string { + return fmt.Sprintf("Novo pedido de cadastro: %s (ID %d).\n"+ + "Configure a credencial da PontoMais no painel e ative com /ativarusuario %d ou pelo painel.", + u.DisplayName, u.ID, u.ID) +} + +const msgActivated = "Seu cadastro foi aprovado. Envie /ativarponto para ligar os lembretes e /ajuda para ver os comandos." + +const msgDisabledByAdmin = "Seu acesso ao Bot de Pontos foi desativado por um administrador." + +func msgPausedAPIFailures(failures int) string { + return fmt.Sprintf("Não consegui consultar a PontoMais %d vezes seguidas, então pausei seus lembretes.\n"+ + "Confira com o administrador se sua credencial está correta e depois envie /ativarponto.", failures) +} + +func msgAdminUserPaused(u domain.User, failures int, err error) string { + return fmt.Sprintf("Lembretes de %s (ID %d) pausados após %d falhas seguidas na PontoMais.\nÚltimo erro: %s", + u.DisplayName, u.ID, failures, shortError(err)) +} + +func msgOutageStarted(users int, err error) string { + return fmt.Sprintf("A PontoMais parece fora do ar: as consultas de %d usuários falharam no mesmo ciclo.\n"+ + "Ninguém foi pausado. Último erro: %s", users, shortError(err)) +} + +func msgOutageEnded(duration time.Duration) string { + return fmt.Sprintf("A PontoMais voltou a responder. A queda durou cerca de %s.", formatDuration(duration)) +} + +func msgHolidayFallback(year int, err error) string { + return fmt.Sprintf("Não consegui obter os feriados de %d (%s).\n"+ + "Enquanto isso, segunda a sexta contam como dias úteis. Cadastre feriados manualmente no painel se necessário.", + year, shortError(err)) +} + +func msgScheduleViolations(names []string, interval time.Duration) string { + return fmt.Sprintf("Estes usuários têm almoço − antecedência menor que o intervalo de reconciliação (%s) e podem receber "+ + "o lembrete de retorno atrasado: %v. Ajuste a jornada no painel.", formatDuration(interval), names) +} + +// msgReminder é o lembrete automático; sempre informa o horário previsto (9.3). +func msgReminder(n domain.Notification, loc *time.Location, schedule domain.WorkSchedule, now time.Time) string { + clock := rules.FormatClock(n.PredictedAt, loc) + late := now.After(n.PredictedAt) + switch n.Type { + case domain.NotificationEntrada2: + if late { + return "⏰ Lembrete: seu retorno do almoço estava previsto para " + clock + "." + } + return "⏰ Lembrete: seu retorno do almoço está previsto para " + clock + "." + default: + jornada := rules.FormatMinutes(schedule.DailyWorkMinutes) + if late { + return "⏰ Lembrete: sua saída estava prevista para " + clock + " (jornada de " + jornada + ")." + } + return "⏰ Lembrete: sua saída está prevista para " + clock + " (jornada de " + jornada + ")." + } +} + +func formatDuration(d time.Duration) string { + minutes := int(d.Round(time.Minute) / time.Minute) + if minutes < 60 { + return fmt.Sprintf("%d min", minutes) + } + return rules.FormatMinutes(minutes) +} diff --git a/backend/internal/app/reconcile.go b/backend/internal/app/reconcile.go new file mode 100644 index 0000000..8a87769 --- /dev/null +++ b/backend/internal/app/reconcile.go @@ -0,0 +1,380 @@ +package app + +import ( + "context" + "errors" + "sync" + "time" + + "botpontos/internal/adapters/postgres" + "botpontos/internal/domain" + "botpontos/internal/rules" +) + +const stateOutage = "pontomais_outage" + +type outageState struct { + Since time.Time `json:"since"` + LastError string `json:"last_error"` +} + +// reconJob é um usuário que deve ser consultado neste tick. +type reconJob struct { + ru postgres.ReconUser + loc *time.Location + day string + window domain.QueryWindow + state domain.UserDay +} + +type reconResult struct { + job reconJob + points []domain.Point + err error + skipped bool // não chegou a ser consultado (prazo do ciclo esgotado) +} + +// Reconcile é o ciclo de 15 min (8.1, 8.2, 9.4, 9.12): seleciona quem está +// na janela e com a próxima consulta vencida, consulta a PontoMais com +// concorrência limitada e aplica as regras. +func (s *Service) Reconcile(ctx context.Context, tick time.Time) error { + global, err := s.GlobalWindow(ctx) + if err != nil { + return err + } + users, err := s.store.ListReconciliationUsers(ctx) + if err != nil { + return err + } + + var due []reconJob + for _, ru := range users { + loc := ru.User.Location() + day := domain.LocalDay(tick, loc) + window, _ := rules.EffectiveWindow(global, ru.Schedule) + if !rules.InWindow(tick, window, day, loc) { + continue + } + if business, _ := s.IsBusinessDay(ctx, tick, loc); !business { + continue + } + state, found, err := s.store.GetUserDay(ctx, ru.User.ID, day) + if err != nil { + return err + } + if found && (state.NextCheckAt == nil || state.NextCheckAt.After(tick)) { + continue + } + due = append(due, reconJob{ru: ru, loc: loc, day: day, window: window, state: state}) + } + if len(due) == 0 { + return nil + } + + started := s.now() + results := s.fetchAll(ctx, due) + + var successes, failures []reconResult + for _, r := range results { + switch { + case r.skipped: + case r.err == nil || errors.Is(r.err, domain.ErrInvalidPointHistory): + successes = append(successes, r) // a API respondeu (mesmo com dado inválido) + default: + failures = append(failures, r) + } + } + + var outage outageState + outageActive, err := s.store.GetState(ctx, stateOutage, &outage) + if err != nil { + s.log.Warn("falha ao ler estado de queda", "error", err) + } + attempted := len(successes) + len(failures) + s.metrics.ReconciliationCycle(s.now().Sub(started), attempted, len(failures)) + + switch { + case attempted >= 2 && len(successes) == 0: + // Todos falharam: queda da PontoMais. Ninguém soma falha (9.4). + if !outageActive { + s.startOutage(ctx, len(failures), failures[0].err) + } + return nil + case outageActive && len(successes) == 0: + // Queda em andamento e só um usuário consultado: não conta. + return nil + case outageActive: + s.endOutage(ctx, outage) + } + + for _, r := range successes { + if r.err != nil { + s.handleInvalidHistory(ctx, r.job, tick, r.err) + continue + } + if err := s.applyPoints(ctx, r.job, r.points, tick); err != nil { + s.log.Error("falha ao aplicar pontos", "user_id", r.job.ru.User.ID, "error", err) + } + } + for _, r := range failures { + s.recordFailure(ctx, r.job, r.err) + } + return nil +} + +func (s *Service) fetchAll(ctx context.Context, jobs []reconJob) []reconResult { + deadline, cancel := context.WithTimeout(ctx, s.cfg.Jobs.ReconciliationDeadline) + defer cancel() + + results := make([]reconResult, len(jobs)) + sem := make(chan struct{}, s.cfg.Jobs.ReconciliationWorkers) + var wg sync.WaitGroup + for i, job := range jobs { + wg.Add(1) + go func(i int, job reconJob) { + defer wg.Done() + results[i].job = job + select { + case sem <- struct{}{}: + case <-deadline.Done(): + results[i].skipped = true + return + } + defer func() { <-sem }() + requestStart := time.Now() + points, err := s.points.History(deadline, job.ru.Credential, job.loc) + s.metrics.PointsRequest(time.Since(requestStart), err) + if err != nil && deadline.Err() != nil && ctx.Err() == nil { + // Prazo do ciclo esgotado: fica para o próximo tick, sem contar falha. + results[i].skipped = true + return + } + results[i].points, results[i].err = points, err + }(i, job) + } + wg.Wait() + return results +} + +func (s *Service) startOutage(ctx context.Context, users int, cause error) { + state := outageState{Since: s.now(), LastError: shortError(cause)} + if err := s.store.SetState(ctx, stateOutage, state); err != nil { + s.log.Warn("falha ao registrar queda", "error", err) + } + s.audit(ctx, WorkerActor, nil, domain.EventPointAPIOutageStarted, false, map[string]any{"users": users, "error": state.LastError}) + s.notifyAdmins(ctx, msgOutageStarted(users, cause)) +} + +func (s *Service) endOutage(ctx context.Context, outage outageState) { + if err := s.store.DeleteState(ctx, stateOutage); err != nil { + s.log.Warn("falha ao encerrar queda", "error", err) + } + duration := s.now().Sub(outage.Since) + s.audit(ctx, WorkerActor, nil, domain.EventPointAPIOutageEnded, true, map[string]any{"minutes": int(duration.Minutes())}) + s.notifyAdmins(ctx, msgOutageEnded(duration)) +} + +// recordFailure aplica a regra das 3 falhas seguidas (9.4). O usuário volta a +// ser consultado no próximo tick, porque o estado do dia não muda. +func (s *Service) recordFailure(ctx context.Context, job reconJob, cause error) { + u := job.ru.User + failures, err := s.store.RecordCredentialFailure(ctx, u.ID, shortError(cause), s.now()) + if err != nil { + s.log.Error("falha ao registrar falha da PontoMais", "user_id", u.ID, "error", err) + return + } + s.audit(ctx, WorkerActor, &u.ID, domain.EventPointAPIFailure, false, + map[string]any{"consecutive_failures": failures, "error": shortError(cause)}) + + if failures < s.cfg.Jobs.APIFailureThreshold { + return + } + reason := domain.PauseAPIFailures + if err := s.store.SetNotifications(ctx, u.ID, false, &reason); err != nil { + s.log.Error("falha ao pausar lembretes", "user_id", u.ID, "error", err) + return + } + s.audit(ctx, WorkerActor, &u.ID, domain.EventNotificationsPausedAPI, false, map[string]any{"consecutive_failures": failures}) + s.notifyUser(ctx, u, msgPausedAPIFailures(failures)) + s.notifyAdmins(ctx, msgAdminUserPaused(u, failures, cause)) +} + +// handleInvalidHistory: a API respondeu com dado ilegível. Não conta como falha; +// consulta de novo mais devagar. +func (s *Service) handleInvalidHistory(ctx context.Context, job reconJob, tick time.Time, cause error) { + u := job.ru.User + s.audit(ctx, WorkerActor, &u.ID, domain.EventInvalidPointHistory, false, map[string]any{"error": shortError(cause)}) + next := tick.Add(s.cfg.Jobs.SlowCheckInterval) + state := job.state + if next.After(job.window.End.On(job.day, job.loc)) { + state.NextCheckAt = nil + } else { + state.NextCheckAt = &next + } + if err := s.store.SaveUserDay(ctx, state); err != nil { + s.log.Error("falha ao salvar o dia", "user_id", u.ID, "error", err) + } +} + +// applyPoints cria, mantém ou cancela os lembretes e calcula a próxima consulta. +func (s *Service) applyPoints(ctx context.Context, job reconJob, points []domain.Point, tick time.Time) error { + u, schedule := job.ru.User, job.ru.Schedule + if job.ru.Credential.ConsecutiveFailures > 0 || job.ru.Credential.LastError != nil { + if err := s.store.ResetCredentialFailures(ctx, u.ID); err != nil { + return err + } + } + + wd := rules.CurrentWorkday(points, job.day, job.loc) + previous := job.state + var saida2At *time.Time + + switch { + case wd.Invalid: + if previous.LastPointCount != wd.Count() || previous.UpdatedAt.IsZero() { + s.audit(ctx, WorkerActor, &u.ID, domain.EventInvalidPointHistory, false, + map[string]any{"reason": "pontos repetidos ou fora de ordem", "points": wd.Count()}) + } + + case wd.Overtime || wd.Count() >= 4: + cancelled, err := s.store.CancelScheduledFrom(ctx, u.ID, job.day) + if err != nil { + return err + } + if cancelled > 0 { + s.audit(ctx, WorkerActor, &u.ID, domain.EventNotificationCancelled, true, + map[string]any{"reason": "jornada encerrada", "count": cancelled}) + } + + case wd.Count() == 3: + cancelled, err := s.store.CancelScheduled(ctx, u.ID, job.day, domain.NotificationEntrada2) + if err != nil { + return err + } + if cancelled { + s.audit(ctx, WorkerActor, &u.ID, domain.EventNotificationCancelled, true, + map[string]any{"type": "entrada2", "reason": "retorno do almoço detectado"}) + } + predicted, err := rules.Saida2Time(wd, schedule.DailyWorkMinutes) + if err != nil { + if previous.LastPointCount != 3 { + s.audit(ctx, WorkerActor, &u.ID, domain.EventInvalidWorkday, false, map[string]any{"error": err.Error()}) + } + break + } + n, err := s.ensureReminder(ctx, job, domain.NotificationSaida2, predicted, schedule.Saida2LeadMinutes) + if err != nil { + return err + } + if n.Status != domain.NotificationCancelled { + saida2At = &n.ScheduledAt + } + + case wd.Count() == 2: + predicted, err := rules.Entrada2Time(wd, schedule.LunchMinutes) + if err != nil { + return err + } + if _, err := s.ensureReminder(ctx, job, domain.NotificationEntrada2, predicted, schedule.Entrada2LeadMinutes); err != nil { + return err + } + } + + plan := rules.PlanNextCheck(rules.PlanInput{ + Tick: tick, + Day: job.day, + Location: job.loc, + Window: job.window, + Workday: wd, + Resumed: previous.ResumedBy != nil, + Saida2At: saida2At, + Reconciliation: s.cfg.Jobs.ReconciliationInterval, + Slow: s.cfg.Jobs.SlowCheckInterval, + ExitConfirmFrom: s.cfg.Jobs.ExitConfirmFrom, + ExitConfirmTo: s.cfg.Jobs.ExitConfirmTo, + }) + + // Se o dia foi retomado (/ativarponto ou painel) durante a consulta, a + // retomada vence: o próximo tick recalcula com o estado novo. + if current, found, err := s.store.GetUserDay(ctx, u.ID, job.day); err == nil && found && + previous.ResumedBy == nil && current.ResumedBy != nil { + return nil + } + + next := previous + next.Status = plan.Status + next.NextCheckAt = plan.NextCheckAt + next.LastPointCount = wd.Count() + if wd.Count() == 0 { + next.ZeroPointChecks++ + } + if err := s.store.SaveUserDay(ctx, next); err != nil { + return err + } + + if plan.Status == domain.DayNoPoints && previous.Status != domain.DayNoPoints { + s.audit(ctx, WorkerActor, &u.ID, domain.EventDayNoPoints, true, nil) + } + if plan.Status == domain.DayNotWorking && previous.Status != domain.DayNotWorking { + s.audit(ctx, WorkerActor, &u.ID, domain.EventDayNotWorked, true, nil) + } + return nil +} + +// ensureReminder cria o lembrete do dia se ainda não existir (um existente não +// é alterado). Se o horário do aviso já passou da tolerância, ele nasce +// expired e é só auditado (9.3). +func (s *Service) ensureReminder(ctx context.Context, job reconJob, t domain.NotificationType, predicted time.Time, lead int) (domain.Notification, error) { + u := job.ru.User + now := s.now() + n := domain.Notification{ + UserID: u.ID, + ReferenceDate: job.day, + Type: t, + ScheduledAt: rules.ReminderAt(predicted, lead), + PredictedAt: predicted, + Status: domain.NotificationScheduled, + } + delay := now.Sub(n.ScheduledAt) + if delay > s.cfg.Jobs.LateTolerance { + n.Status = domain.NotificationExpired + n.ErrorMessage = ptr("detectado com atraso acima da tolerância") + } + + saved, inserted, err := s.store.InsertNotification(ctx, n) + if err != nil || !inserted { + return saved, err + } + details := map[string]any{ + "type": string(t), + "scheduled_at": saved.ScheduledAt, + "predicted_at": saved.PredictedAt, + } + if saved.Status == domain.NotificationExpired { + details["delay_minutes"] = int(delay.Minutes()) + s.audit(ctx, WorkerActor, &u.ID, domain.EventNotificationExpired, false, details) + } else { + s.audit(ctx, WorkerActor, &u.ID, domain.EventNotificationScheduled, true, details) + } + return saved, nil +} + +// CheckScheduleIntervals avisa, na subida do worker, sobre jornadas que ficaram +// fora da regra 9.8 após mudança do intervalo de reconciliação. +func (s *Service) CheckScheduleIntervals(ctx context.Context) { + items, err := s.store.ListActiveSchedules(ctx) + if err != nil { + s.log.Warn("falha ao verificar jornadas", "error", err) + return + } + var names []string + for _, item := range items { + if !rules.ScheduleFitsInterval(item.Schedule, s.cfg.Jobs.ReconciliationInterval) { + names = append(names, item.User.DisplayName) + s.audit(ctx, WorkerActor, &item.User.ID, domain.EventScheduleWindowViolation, false, nil) + } + } + if len(names) > 0 { + s.notifyAdmins(ctx, msgScheduleViolations(names, s.cfg.Jobs.ReconciliationInterval)) + } +} diff --git a/backend/internal/app/service.go b/backend/internal/app/service.go new file mode 100644 index 0000000..4507b8b --- /dev/null +++ b/backend/internal/app/service.go @@ -0,0 +1,184 @@ +// Package app contém os casos de uso. É chamado pelo bot do Telegram, pela +// API HTTP, pela CLI e pelos jobs do worker, de modo que a mesma regra vale +// para todas as interfaces (ex.: ativação exige credencial validada). +package app + +import ( + "context" + "log/slog" + "sync" + "time" + "unicode/utf8" + + "botpontos/internal/adapters/postgres" + "botpontos/internal/config" + "botpontos/internal/domain" + "botpontos/internal/metrics" +) + +// PointsProvider é o port da PontoMais (adapter: pontomais.Client). +type PointsProvider interface { + History(ctx context.Context, cred domain.Credential, loc *time.Location) ([]domain.Point, error) + Validate(ctx context.Context, email, password string, loc *time.Location) error + Invalidate(userID int64) +} + +// Messenger é o port do Telegram (adapter: telegram.Client). +type Messenger interface { + Send(ctx context.Context, chatID int64, text string) (int64, error) +} + +// HolidayProvider é o port da API de feriados (adapter: feriados.Client). +type HolidayProvider interface { + FetchYear(ctx context.Context, year int) ([]domain.Holiday, error) +} + +// Cipher é o port da criptografia das senhas PontoMais (adapter: crypto.Keyring). +type Cipher interface { + Encrypt(plain string) ([]byte, int16, error) + Decrypt(data []byte, version int16) (string, error) + CurrentVersion() int16 +} + +type Deps struct { + Store *postgres.Store + Points PointsProvider // obrigatório para api e worker + Messenger Messenger // pode estar sem token (envios falham com ErrNotConfigured) + Holidays HolidayProvider // opcional: sem ele vale o fallback de segunda a sexta + Cipher Cipher + Config config.Config + Logger *slog.Logger + Metrics metrics.Recorder // opcional: sem ele, metrics.Noop + Now func() time.Time // opcional (testes) +} + +type Service struct { + store *postgres.Store + points PointsProvider + messenger Messenger + holidays HolidayProvider + cipher Cipher + cfg config.Config + log *slog.Logger + metrics metrics.Recorder + now func() time.Time + location *time.Location // fuso padrão (configurações globais, retenção) + + mu sync.Mutex + syncedYears map[int]bool + syncTried map[int]string // ano → último dia em que a sincronização foi tentada +} + +func New(d Deps) *Service { + now := d.Now + if now == nil { + now = time.Now + } + var recorder metrics.Recorder = metrics.Noop{} + if d.Metrics != nil { + recorder = d.Metrics + } + return &Service{ + store: d.Store, + points: d.Points, + messenger: d.Messenger, + holidays: d.Holidays, + cipher: d.Cipher, + cfg: d.Config, + log: d.Logger, + metrics: recorder, + now: now, + location: domain.LoadLocation(d.Config.DefaultTimezone), + syncedYears: map[int]bool{}, + syncTried: map[int]string{}, + } +} + +// Store expõe o repositório para leituras simples da API (listas e detalhes). +func (s *Service) Store() *postgres.Store { return s.store } + +func (s *Service) Config() config.Config { return s.cfg } + +func (s *Service) Now() time.Time { return s.now() } + +// Location é o fuso padrão da instalação. +func (s *Service) Location() *time.Location { return s.location } + +// TodayDate devolve a data de hoje (AAAA-MM-DD) no fuso padrão. +func (s *Service) TodayDate() string { return domain.LocalDay(s.now(), s.location) } + +// Actor identifica quem executou uma ação, para a auditoria. +type Actor struct { + Source domain.AuditSource + UserID *int64 + AccountID *int64 +} + +func TelegramActor(userID int64) Actor { return Actor{Source: domain.SourceTelegram, UserID: &userID} } +func WebActor(accountID int64) Actor { return Actor{Source: domain.SourceWeb, AccountID: &accountID} } + +var ( + WorkerActor = Actor{Source: domain.SourceWorker} + CLIActor = Actor{Source: domain.SourceCLI} +) + +// audit grava um evento; uma falha na auditoria é registrada no log e não interrompe a operação. +func (s *Service) audit(ctx context.Context, actor Actor, target *int64, event string, success bool, details map[string]any) { + err := s.store.InsertAudit(ctx, domain.AuditEntry{ + Source: actor.Source, + ActorUserID: actor.UserID, + ActorAccount: actor.AccountID, + TargetUserID: target, + EventType: event, + Success: success, + Details: details, + }) + if err != nil { + s.log.Error("falha ao gravar auditoria", "event", event, "error", err) + } +} + +// notifyAdmins envia um aviso a todos os admins ativos do bot (9.11). +func (s *Service) notifyAdmins(ctx context.Context, text string) { + admins, err := s.store.ListActiveBotAdmins(ctx) + if err != nil { + s.log.Error("falha ao listar admins para aviso", "error", err) + return + } + if len(admins) == 0 { + s.log.Warn("nenhum admin ativo no Telegram para receber o aviso", "text", text) + return + } + for _, admin := range admins { + if _, err := s.messenger.Send(ctx, admin.TelegramChatID, text); err != nil { + s.log.Warn("falha ao avisar admin", "user_id", admin.ID, "error", err) + } + } +} + +// notifyUser envia uma mensagem avulsa ao usuário; falhas só vão para o log. +func (s *Service) notifyUser(ctx context.Context, u domain.User, text string) { + if _, err := s.messenger.Send(ctx, u.TelegramChatID, text); err != nil { + s.log.Warn("falha ao enviar mensagem ao usuário", "user_id", u.ID, "error", err) + } +} + +func ptr[T any](v T) *T { return &v } + +// shortError limita o tamanho de mensagens de erro gravadas no banco. +func shortError(err error) string { + return truncate(err.Error(), 300) +} + +// truncate corta em até max bytes sem quebrar um caractere UTF-8 (o Postgres +// recusa texto com bytes inválidos). +func truncate(s string, max int) string { + if len(s) <= max { + return s + } + n := max + for n > 0 && !utf8.RuneStart(s[n]) { + n-- + } + return s[:n] + "…" +} diff --git a/backend/internal/app/settings.go b/backend/internal/app/settings.go new file mode 100644 index 0000000..c8f4a6c --- /dev/null +++ b/backend/internal/app/settings.go @@ -0,0 +1,165 @@ +package app + +import ( + "context" + "time" + + "botpontos/internal/domain" + "botpontos/internal/rules" +) + +const ( + keyWindowStart = "query_window_start" + keyWindowEnd = "query_window_end" + keyCheckDeadline = "day_check_deadline" + keyRecheckUntil = "no_points_recheck_until" +) + +func windowToSettings(w domain.QueryWindow) map[string]string { + return map[string]string{ + keyWindowStart: w.Start.String(), + keyWindowEnd: w.End.String(), + keyCheckDeadline: w.DayCheckDeadline.String(), + keyRecheckUntil: w.NoPointsRecheckUntil.String(), + } +} + +// SeedSettings grava os padrões da janela na primeira subida (9.13). +func (s *Service) SeedSettings(ctx context.Context) error { + return s.store.SeedSettings(ctx, windowToSettings(s.cfg.DefaultWindow)) +} + +// GlobalWindow lê a janela global do banco; valores ausentes ou inválidos usam o padrão. +func (s *Service) GlobalWindow(ctx context.Context) (domain.QueryWindow, error) { + values, err := s.store.GetSettings(ctx) + if err != nil { + return s.cfg.DefaultWindow, err + } + w := s.cfg.DefaultWindow + read := func(key string, target *domain.ClockTime) { + if v, ok := values[key]; ok { + if c, err := domain.ParseClock(v); err == nil { + *target = c + } + } + } + read(keyWindowStart, &w.Start) + read(keyWindowEnd, &w.End) + read(keyCheckDeadline, &w.DayCheckDeadline) + read(keyRecheckUntil, &w.NoPointsRecheckUntil) + return w, nil +} + +// UpdateGlobalWindow valida e grava a janela global; vale a partir do próximo tick. +func (s *Service) UpdateGlobalWindow(ctx context.Context, actor Actor, w domain.QueryWindow) error { + if errs := rules.ValidateWindow(w, s.cfg.Jobs.ReconciliationInterval, ""); len(errs) > 0 { + return errs + } + previous, err := s.GlobalWindow(ctx) + if err != nil { + return err + } + if err := s.store.SaveSettings(ctx, windowToSettings(w), actor.AccountID); err != nil { + return err + } + if err := s.store.ResetNextChecks(ctx, s.TodayDate()); err != nil { + s.log.Warn("falha ao recalcular as consultas do dia", "error", err) + } + s.audit(ctx, actor, nil, domain.EventSettingsChanged, true, map[string]any{ + "before": windowToSettings(previous), + "after": windowToSettings(w), + }) + return nil +} + +// WindowPreview é o efeito estimado de uma nova janela global (9.13). +type WindowPreview struct { + Errors domain.ValidationErrors `json:"errors"` + MaxQueriesPerUser int `json:"max_queries_per_user"` + TypicalQueriesPerDay int `json:"typical_queries_per_user"` + DayOffQueries int `json:"day_off_queries_per_user"` + AffectedUsers int `json:"affected_users"` + AffectedDays int `json:"affected_days"` + CustomWindowUsers int `json:"custom_window_users"` +} + +func (s *Service) PreviewWindow(ctx context.Context, w domain.QueryWindow) (WindowPreview, error) { + recon := s.cfg.Jobs.ReconciliationInterval + p := WindowPreview{Errors: rules.ValidateWindow(w, recon, "")} + if len(p.Errors) > 0 { + return p, nil + } + p.MaxQueriesPerUser = int(time.Duration(w.End-w.Start)*time.Minute/recon) + 1 + p.TypicalQueriesPerDay = s.simulateDay(w, []string{"08:00", "12:00", "13:00", "17:00"}) + p.DayOffQueries = s.simulateDay(w, nil) + + since := domain.LocalDay(s.now().AddDate(0, 0, -30), s.location) + samples, err := s.store.Entrada2Samples(ctx, since) + if err != nil { + return p, err + } + users := map[int64]bool{} + for _, x := range samples { + if x.CustomWindow { + continue + } + loc := domain.LoadLocation(x.Timezone) + if x.ScheduledAt.Before(w.Start.On(x.Day, loc)) { + users[x.UserID] = true + p.AffectedDays++ + } + } + p.AffectedUsers = len(users) + if p.CustomWindowUsers, err = s.store.CountCustomWindows(ctx); err != nil { + return p, err + } + return p, nil +} + +// simulateDay conta quantas consultas a frequência adaptativa faria num dia +// em que os pontos são batidos nos horários informados (jornada padrão). +func (s *Service) simulateDay(w domain.QueryWindow, clocks []string) int { + const day = "2026-01-07" // uma quarta-feira qualquer; só os horários importam + loc := s.location + schedule := domain.DefaultSchedule(0) + recon := s.cfg.Jobs.ReconciliationInterval + + var punches []domain.Point + for _, c := range clocks { + punches = append(punches, domain.Point{OccurredAt: domain.MustParseClock(c).On(day, loc)}) + } + + queries := 0 + next := w.Start.On(day, loc) + end := w.End.On(day, loc) + for tick := next; !tick.After(end); tick = tick.Add(recon) { + if tick.Before(next) { + continue + } + queries++ + var visible []domain.Point + for _, p := range punches { + if !p.OccurredAt.After(tick) { + visible = append(visible, p) + } + } + wd := rules.CurrentWorkday(visible, day, loc) + var saida2At *time.Time + if wd.Count() == 3 { + if t, err := rules.Saida2Time(wd, schedule.DailyWorkMinutes); err == nil { + r := rules.ReminderAt(t, schedule.Saida2LeadMinutes) + saida2At = &r + } + } + plan := rules.PlanNextCheck(rules.PlanInput{ + Tick: tick, Day: day, Location: loc, Window: w, Workday: wd, Saida2At: saida2At, + Reconciliation: recon, Slow: s.cfg.Jobs.SlowCheckInterval, + ExitConfirmFrom: s.cfg.Jobs.ExitConfirmFrom, ExitConfirmTo: s.cfg.Jobs.ExitConfirmTo, + }) + if plan.NextCheckAt == nil { + break + } + next = *plan.NextCheckAt + } + return queries +} diff --git a/backend/internal/app/today.go b/backend/internal/app/today.go new file mode 100644 index 0000000..e471a2a --- /dev/null +++ b/backend/internal/app/today.go @@ -0,0 +1,214 @@ +package app + +import ( + "context" + "errors" + "time" + + "botpontos/internal/domain" + "botpontos/internal/rules" +) + +// TodayView é o resultado de uma consulta ao vivo dos pontos do dia +// (/entrada2, /saida2, botão "testar API" e tela do usuário). +type TodayView struct { + User domain.User + Day string + Points []time.Time + Invalid bool + Overtime bool + Entrada2At *time.Time + Saida2At *time.Time + Saida2Error string + LunchMinutes int + DailyWorkMinutes int + DayState domain.UserDay + Window domain.QueryWindow + CustomWindow bool + Resumed bool // a consulta encontrou pontos e retomou um dia encerrado sem pontos +} + +// Today consulta a PontoMais agora. source indica quem pediu, para retomar +// automaticamente um dia "sem pontos" quando a consulta manual encontra pontos (9.12). +func (s *Service) Today(ctx context.Context, actor Actor, userID int64, source *domain.ResumeSource) (TodayView, error) { + u, err := s.store.GetUser(ctx, userID) + if err != nil { + return TodayView{}, err + } + cred, err := s.requireValidatedCredential(ctx, userID) + if err != nil { + return TodayView{}, err + } + schedule, err := s.store.GetSchedule(ctx, userID) + if err != nil { + return TodayView{}, err + } + global, err := s.GlobalWindow(ctx) + if err != nil { + return TodayView{}, err + } + + loc := u.Location() + points, err := s.points.History(ctx, cred, loc) + if err != nil { + return TodayView{}, err + } + + now := s.now() + view := TodayView{ + User: u, + Day: domain.LocalDay(now, loc), + LunchMinutes: schedule.LunchMinutes, + DailyWorkMinutes: schedule.DailyWorkMinutes, + } + view.Window, view.CustomWindow = rules.EffectiveWindow(global, schedule) + + w := rules.CurrentWorkday(points, view.Day, loc) + view.Invalid, view.Overtime = w.Invalid, w.Overtime + for _, p := range w.Points { + view.Points = append(view.Points, p.OccurredAt) + } + if !w.Invalid && !w.Overtime { + if t, err := rules.Entrada2Time(w, schedule.LunchMinutes); err == nil { + view.Entrada2At = &t + } + if w.Count() >= 3 { + if t, err := rules.Saida2Time(w, schedule.DailyWorkMinutes); err == nil { + view.Saida2At = &t + } else { + view.Saida2Error = err.Error() + } + } + } + + if view.DayState, _, err = s.store.GetUserDay(ctx, userID, view.Day); err != nil { + return view, err + } + if source != nil && w.Count() > 0 && view.DayState.IsClosedWithoutPoints() { + if d, err := s.resumeDayIfClosed(ctx, actor, u, *source); err == nil { + view.DayState, view.Resumed = d, true + } + } + return view, nil +} + +// TestCredentials consulta a PontoMais com a credencial atual (botão do painel). +func (s *Service) TestCredentials(ctx context.Context, actor Actor, userID int64) (TodayView, error) { + view, err := s.Today(ctx, actor, userID, nil) + if err != nil { + if errors.Is(err, domain.ErrCredentialsRequired) { + // Credencial gravada mas nunca validada também pode ser testada. + if cred, cerr := s.store.GetCredential(ctx, userID); cerr == nil { + return s.testUnvalidated(ctx, actor, userID, cred) + } + } + s.audit(ctx, actor, &userID, domain.EventCredentialsTested, false, map[string]any{"error": shortError(err)}) + return view, err + } + if err := s.store.MarkCredentialValidated(ctx, userID, s.now()); err != nil { + return view, err + } + s.audit(ctx, actor, &userID, domain.EventCredentialsTested, true, map[string]any{"points": len(view.Points)}) + return view, nil +} + +func (s *Service) testUnvalidated(ctx context.Context, actor Actor, userID int64, cred domain.Credential) (TodayView, error) { + u, err := s.store.GetUser(ctx, userID) + if err != nil { + return TodayView{}, err + } + if _, err := s.points.History(ctx, cred, u.Location()); err != nil { + s.audit(ctx, actor, &userID, domain.EventCredentialsTested, false, map[string]any{"error": shortError(err)}) + return TodayView{}, err + } + if err := s.store.MarkCredentialValidated(ctx, userID, s.now()); err != nil { + return TodayView{}, err + } + return s.Today(ctx, actor, userID, nil) +} + +// ResumeDay retoma um dia encerrado sem pontos (botão do painel, 9.12). +func (s *Service) ResumeDay(ctx context.Context, actor Actor, userID int64) (domain.UserDay, error) { + u, err := s.store.GetUser(ctx, userID) + if err != nil { + return domain.UserDay{}, err + } + d, err := s.resumeDayIfClosed(ctx, actor, u, domain.ResumeWeb) + if errors.Is(err, errNothingToResume) { + return d, domain.InvalidState("o dia não está encerrado por falta de pontos") + } + return d, err +} + +var errNothingToResume = errors.New("nada a retomar") + +func (s *Service) resumeDayIfClosed(ctx context.Context, actor Actor, u domain.User, source domain.ResumeSource) (domain.UserDay, error) { + loc := u.Location() + now := s.now() + day := domain.LocalDay(now, loc) + d, found, err := s.store.GetUserDay(ctx, u.ID, day) + if err != nil { + return d, err + } + if !found || !d.IsClosedWithoutPoints() { + return d, errNothingToResume + } + window, err := s.windowFor(ctx, u.ID) + if err != nil { + return d, err + } + d.Status = domain.DayWorking + d.ResumedBy = &source + d.NextCheckAt = rules.ResumeNextCheck(now, window, day, loc, s.cfg.Jobs.ReconciliationInterval) + if err := s.store.SaveUserDay(ctx, d); err != nil { + return d, err + } + s.audit(ctx, actor, &u.ID, domain.EventDayResumed, true, map[string]any{"source": string(source)}) + return d, nil +} + +// windowFor devolve a janela efetiva de um usuário. +func (s *Service) windowFor(ctx context.Context, userID int64) (domain.QueryWindow, error) { + global, err := s.GlobalWindow(ctx) + if err != nil { + return global, err + } + schedule, err := s.store.GetSchedule(ctx, userID) + if err != nil { + return global, err + } + w, _ := rules.EffectiveWindow(global, schedule) + return w, nil +} + +// StatusInfo reúne o que o /status mostra. +type StatusInfo struct { + User domain.User + HasCredential bool + Validated bool + Failures int + Today domain.UserDay + Window domain.QueryWindow + CustomWindow bool + BusinessDay bool +} + +func (s *Service) Status(ctx context.Context, userID int64) (StatusInfo, error) { + detail, err := s.UserDetail(ctx, userID) + if err != nil { + return StatusInfo{}, err + } + info := StatusInfo{ + User: detail.User, + Today: detail.Today, + Window: detail.Window, + CustomWindow: detail.CustomWindow, + } + if detail.Credential != nil { + info.HasCredential = true + info.Validated = detail.Credential.IsValidated() + info.Failures = detail.Credential.ConsecutiveFailures + } + info.BusinessDay, _ = s.IsBusinessDay(ctx, s.now(), detail.User.Location()) + return info, nil +} diff --git a/backend/internal/app/users.go b/backend/internal/app/users.go new file mode 100644 index 0000000..55c155e --- /dev/null +++ b/backend/internal/app/users.go @@ -0,0 +1,323 @@ +package app + +import ( + "context" + "errors" + "fmt" + "strings" + + "botpontos/internal/domain" + "botpontos/internal/rules" +) + +// Register cria o pedido de cadastro pelo /cadastrar (idempotente) e avisa os admins. +func (s *Service) Register(ctx context.Context, chatID int64, name string) (domain.User, bool, error) { + name = strings.TrimSpace(name) + if name == "" { + name = "Sem nome" + } + u, created, err := s.store.CreatePendingUser(ctx, chatID, name, s.cfg.DefaultTimezone) + if err != nil { + return u, false, err + } + if created { + s.audit(ctx, TelegramActor(u.ID), &u.ID, domain.EventUserRegistered, true, map[string]any{"name": u.DisplayName}) + s.notifyAdmins(ctx, msgNewRegistration(u)) + } + return u, created, nil +} + +func (s *Service) UserByChatID(ctx context.Context, chatID int64) (domain.User, error) { + return s.store.GetUserByChatID(ctx, chatID) +} + +// requireValidatedCredential é a regra única de ativação (Telegram e painel, 9.2). +func (s *Service) requireValidatedCredential(ctx context.Context, userID int64) (domain.Credential, error) { + cred, err := s.store.GetCredential(ctx, userID) + if errors.Is(err, domain.ErrNotFound) || (err == nil && !cred.IsValidated()) { + return cred, domain.ErrCredentialsRequired + } + return cred, err +} + +// ActivateUser aprova um cadastro. Exige credencial validada na PontoMais. +func (s *Service) ActivateUser(ctx context.Context, actor Actor, userID int64) (domain.User, error) { + u, err := s.store.GetUser(ctx, userID) + if err != nil { + return u, err + } + if u.IsActive() { + return u, nil + } + if _, err := s.requireValidatedCredential(ctx, userID); err != nil { + s.audit(ctx, actor, &userID, domain.EventUserActivated, false, map[string]any{"reason": "credencial ausente ou não validada"}) + return u, err + } + if err := s.store.SetUserStatus(ctx, userID, domain.UserActive); err != nil { + return u, err + } + u.Status = domain.UserActive + s.audit(ctx, actor, &userID, domain.EventUserActivated, true, nil) + s.notifyUser(ctx, u, msgActivated) + return u, nil +} + +// DisableUser bloqueia o usuário, desliga os lembretes e cancela os agendados. +func (s *Service) DisableUser(ctx context.Context, actor Actor, userID int64) error { + u, err := s.store.GetUser(ctx, userID) + if err != nil { + return err + } + if err := s.store.SetUserStatus(ctx, userID, domain.UserDisabled); err != nil { + return err + } + if u.NotificationsEnabled { + reason := domain.PauseUserDisabled + if err := s.store.SetNotifications(ctx, userID, false, &reason); err != nil { + return err + } + } + cancelled, err := s.store.CancelScheduledFrom(ctx, userID, domain.LocalDay(s.now(), u.Location())) + if err != nil { + return err + } + s.audit(ctx, actor, &userID, domain.EventUserDisabled, true, map[string]any{"cancelled_notifications": cancelled}) + if u.IsActive() { + s.notifyUser(ctx, u, msgDisabledByAdmin) + } + return nil +} + +// EnableNotifications liga os lembretes (/ativarponto ou painel), zera o +// contador de falhas e retoma o dia se ele tinha sido encerrado sem pontos. +func (s *Service) EnableNotifications(ctx context.Context, actor Actor, userID int64) (domain.User, error) { + u, err := s.store.GetUser(ctx, userID) + if err != nil { + return u, err + } + if !u.IsActive() { + return u, domain.ErrUserNotActive + } + if _, err := s.requireValidatedCredential(ctx, userID); err != nil { + return u, err + } + if err := s.store.SetNotifications(ctx, userID, true, nil); err != nil { + return u, err + } + if err := s.store.ResetCredentialFailures(ctx, userID); err != nil { + return u, err + } + u.NotificationsEnabled, u.PausedReason = true, nil + s.audit(ctx, actor, &userID, domain.EventNotificationsEnabled, true, nil) + + source := domain.ResumeAtivarPonto + if actor.Source == domain.SourceWeb { + source = domain.ResumeWeb + } + if _, err := s.resumeDayIfClosed(ctx, actor, u, source); err != nil && !errors.Is(err, errNothingToResume) { + s.log.Warn("falha ao retomar o dia ao ligar lembretes", "user_id", userID, "error", err) + } + return u, nil +} + +// DisableNotifications pausa os lembretes e cancela os agendados do dia em diante. +func (s *Service) DisableNotifications(ctx context.Context, actor Actor, userID int64, reason domain.PauseReason) error { + u, err := s.store.GetUser(ctx, userID) + if err != nil { + return err + } + if err := s.store.SetNotifications(ctx, userID, false, &reason); err != nil { + return err + } + cancelled, err := s.store.CancelScheduledFrom(ctx, userID, domain.LocalDay(s.now(), u.Location())) + if err != nil { + return err + } + s.audit(ctx, actor, &userID, domain.EventNotificationsDisabled, true, + map[string]any{"reason": string(reason), "cancelled_notifications": cancelled}) + return nil +} + +func (s *Service) SetBotAdmin(ctx context.Context, actor Actor, userID int64, enabled bool) error { + if _, err := s.store.GetUser(ctx, userID); err != nil { + return err + } + if err := s.store.SetBotAdmin(ctx, userID, enabled); err != nil { + return err + } + s.audit(ctx, actor, &userID, domain.EventBotAdminChanged, true, map[string]any{"enabled": enabled}) + return nil +} + +// ScheduleInput são os campos editáveis da jornada no painel. +type ScheduleInput struct { + LunchMinutes int + DailyWorkMinutes int + Entrada2LeadMinutes int + Saida2LeadMinutes int + Timezone string + Window *domain.QueryWindow // nil = usa a janela global +} + +// UpdateSchedule valida (9.8, 9.13, 9.14) e grava a jornada e a exceção de janela. +func (s *Service) UpdateSchedule(ctx context.Context, actor Actor, userID int64, in ScheduleInput) (domain.WorkSchedule, error) { + u, err := s.store.GetUser(ctx, userID) + if err != nil { + return domain.WorkSchedule{}, err + } + global, err := s.GlobalWindow(ctx) + if err != nil { + return domain.WorkSchedule{}, err + } + next := domain.WorkSchedule{ + UserID: userID, + LunchMinutes: in.LunchMinutes, + DailyWorkMinutes: in.DailyWorkMinutes, + Entrada2LeadMinutes: in.Entrada2LeadMinutes, + Saida2LeadMinutes: in.Saida2LeadMinutes, + Window: in.Window, + } + errs := rules.ValidateSchedule(next, global, s.cfg.Jobs.ReconciliationInterval) + var tzErrs domain.ValidationErrors + if err := rules.ValidateTimezone(in.Timezone); errors.As(err, &tzErrs) { + errs = append(errs, tzErrs...) + } + if len(errs) > 0 { + return next, errs + } + + previous, err := s.store.GetSchedule(ctx, userID) + if err != nil { + return next, err + } + if err := s.store.SaveSchedule(ctx, next); err != nil { + return next, err + } + if in.Timezone != u.Timezone { + if err := s.store.SetTimezone(ctx, userID, in.Timezone); err != nil { + return next, err + } + } + + s.audit(ctx, actor, &userID, domain.EventScheduleChanged, true, map[string]any{ + "before": scheduleDetails(previous, u.Timezone), + "after": scheduleDetails(next, in.Timezone), + }) + if windowText(previous.Window) != windowText(next.Window) { + s.audit(ctx, actor, &userID, domain.EventUserWindowChanged, true, map[string]any{ + "before": windowText(previous.Window), + "after": windowText(next.Window), + }) + s.recheckToday(ctx, u) + } + return next, nil +} + +// recheckToday antecipa a próxima consulta do dia, para a nova janela valer no próximo tick. +func (s *Service) recheckToday(ctx context.Context, u domain.User) { + day := domain.LocalDay(s.now(), u.Location()) + d, found, err := s.store.GetUserDay(ctx, u.ID, day) + if err != nil || !found || d.NextCheckAt == nil { + return + } + now := s.now() + d.NextCheckAt = &now + if err := s.store.SaveUserDay(ctx, d); err != nil { + s.log.Warn("falha ao recalcular a próxima consulta", "user_id", u.ID, "error", err) + } +} + +func scheduleDetails(sch domain.WorkSchedule, timezone string) map[string]any { + return map[string]any{ + "lunch_minutes": sch.LunchMinutes, + "daily_work_minutes": sch.DailyWorkMinutes, + "entrada2_lead_minutes": sch.Entrada2LeadMinutes, + "saida2_lead_minutes": sch.Saida2LeadMinutes, + "timezone": timezone, + "window": windowText(sch.Window), + } +} + +func windowText(w *domain.QueryWindow) string { + if w == nil { + return "global" + } + return fmt.Sprintf("%s–%s (confirmação %s, reverificações até %s)", w.Start, w.End, w.DayCheckDeadline, w.NoPointsRecheckUntil) +} + +// CredentialError indica que a PontoMais não aceitou a credencial informada. +type CredentialError struct{ Err error } + +func (e *CredentialError) Error() string { return "a PontoMais não aceitou a credencial: " + e.Err.Error() } +func (e *CredentialError) Unwrap() error { return e.Err } + +// SetCredentials valida a credencial na PontoMais e, só se der certo, cifra e +// substitui a anterior (que é preservada em caso de falha). +func (s *Service) SetCredentials(ctx context.Context, actor Actor, userID int64, email, password string) error { + email = strings.TrimSpace(email) + var errs domain.ValidationErrors + if email == "" { + errs.Add("email", "obrigatório") + } + if password == "" { + errs.Add("password", "obrigatória") + } + if len(errs) > 0 { + return errs + } + u, err := s.store.GetUser(ctx, userID) + if err != nil { + return err + } + if err := s.points.Validate(ctx, email, password, u.Location()); err != nil { + s.audit(ctx, actor, &userID, domain.EventCredentialsChanged, false, map[string]any{"error": shortError(err)}) + return &CredentialError{Err: err} + } + encrypted, version, err := s.cipher.Encrypt(password) + if err != nil { + return err + } + if err := s.store.SaveCredential(ctx, userID, email, encrypted, version, s.now()); err != nil { + return err + } + s.points.Invalidate(userID) + s.audit(ctx, actor, &userID, domain.EventCredentialsChanged, true, map[string]any{"email": email}) + return nil +} + +// UserDetail reúne o que a tela do usuário mostra (a senha nunca é exposta). +type UserDetail struct { + User domain.User + Schedule domain.WorkSchedule + Credential *domain.Credential + Today domain.UserDay + Window domain.QueryWindow + CustomWindow bool +} + +func (s *Service) UserDetail(ctx context.Context, userID int64) (UserDetail, error) { + u, err := s.store.GetUser(ctx, userID) + if err != nil { + return UserDetail{}, err + } + schedule, err := s.store.GetSchedule(ctx, userID) + if err != nil { + return UserDetail{}, err + } + detail := UserDetail{User: u, Schedule: schedule} + if cred, err := s.store.GetCredential(ctx, userID); err == nil { + cred.EncryptedPassword = nil + detail.Credential = &cred + } else if !errors.Is(err, domain.ErrNotFound) { + return detail, err + } + if detail.Today, _, err = s.store.GetUserDay(ctx, userID, domain.LocalDay(s.now(), u.Location())); err != nil { + return detail, err + } + global, err := s.GlobalWindow(ctx) + if err != nil { + return detail, err + } + detail.Window, detail.CustomWindow = rules.EffectiveWindow(global, schedule) + return detail, nil +} diff --git a/backend/internal/app/webauth.go b/backend/internal/app/webauth.go new file mode 100644 index 0000000..dcfafa4 --- /dev/null +++ b/backend/internal/app/webauth.go @@ -0,0 +1,296 @@ +package app + +import ( + "context" + "errors" + "net/netip" + "regexp" + "strings" + "time" + + "botpontos/internal/adapters/crypto" + "botpontos/internal/adapters/postgres" + "botpontos/internal/domain" +) + +type LoginResult struct { + Token string + CSRFToken string + ExpiresAt time.Time + Account domain.WebAccount +} + +// Login valida usuário e senha (Argon2id), aplica o bloqueio por tentativas e cria a sessão. +func (s *Service) Login(ctx context.Context, username, password, ip, userAgent string) (LoginResult, error) { + now := s.now() + username = strings.TrimSpace(username) + + account, err := s.store.GetWebAccountByUsername(ctx, username) + if errors.Is(err, domain.ErrNotFound) { + crypto.DummyVerify(password) + s.audit(ctx, Actor{Source: domain.SourceWeb}, nil, domain.EventWebLoginFailed, false, + map[string]any{"username": username, "reason": "usuário inexistente", "ip": ip}) + return LoginResult{}, domain.ErrInvalidLogin + } + if err != nil { + return LoginResult{}, err + } + actor := WebActor(account.ID) + if account.IsDisabled() { + crypto.DummyVerify(password) + s.audit(ctx, actor, nil, domain.EventWebLoginFailed, false, map[string]any{"reason": "conta desativada", "ip": ip}) + return LoginResult{}, domain.ErrInvalidLogin + } + if account.IsLocked(now) { + s.audit(ctx, actor, nil, domain.EventWebLoginFailed, false, map[string]any{"reason": "conta bloqueada", "ip": ip}) + return LoginResult{}, domain.ErrAccountLocked + } + if !crypto.VerifyPassword(account.PasswordHash, password) { + locked, err := s.store.RecordLoginFailure(ctx, account.ID, s.cfg.Web.LoginMaxFailures, now.Add(s.cfg.Web.LoginLock)) + if err != nil { + return LoginResult{}, err + } + s.audit(ctx, actor, nil, domain.EventWebLoginFailed, false, map[string]any{"reason": "senha incorreta", "locked": locked, "ip": ip}) + if locked { + return LoginResult{}, domain.ErrAccountLocked + } + return LoginResult{}, domain.ErrInvalidLogin + } + + if err := s.store.RecordLoginSuccess(ctx, account.ID, now); err != nil { + return LoginResult{}, err + } + token, tokenHash, err := crypto.NewToken() + if err != nil { + return LoginResult{}, err + } + csrf, csrfHash, err := crypto.NewToken() + if err != nil { + return LoginResult{}, err + } + expires := now.Add(s.cfg.Web.SessionDuration) + if err := s.store.CreateSession(ctx, postgres.NewSession{ + AccountID: account.ID, + TokenHash: tokenHash, + CSRFTokenHash: csrfHash, + ExpiresAt: expires, + UserAgent: limitPtr(userAgent, 300), + IP: ipPtr(ip), + }); err != nil { + return LoginResult{}, err + } + s.audit(ctx, actor, nil, domain.EventWebLogin, true, map[string]any{"ip": ip}) + account.PasswordHash = "" + return LoginResult{Token: token, CSRFToken: csrf, ExpiresAt: expires, Account: account}, nil +} + +// Authenticate valida o token de sessão: revogação, expiração absoluta e inatividade. +func (s *Service) Authenticate(ctx context.Context, token string) (domain.WebSession, error) { + if !crypto.ValidTokenFormat(token) { + return domain.WebSession{}, domain.ErrUnauthenticated + } + session, err := s.store.GetSessionByTokenHash(ctx, crypto.HashToken(token)) + if errors.Is(err, domain.ErrNotFound) { + return session, domain.ErrUnauthenticated + } + if err != nil { + return session, err + } + now := s.now() + switch { + case session.RevokedAt != nil, now.After(session.ExpiresAt), session.Account.IsDisabled(): + return session, domain.ErrUnauthenticated + case now.Sub(session.LastSeenAt) > s.cfg.Web.SessionIdle: + _ = s.store.RevokeSession(ctx, session.ID) + return session, domain.ErrUnauthenticated + } + if now.Sub(session.LastSeenAt) > time.Minute { + if err := s.store.TouchSession(ctx, session.ID, now); err != nil { + s.log.Warn("falha ao atualizar sessão", "error", err) + } + } + session.Account.PasswordHash = "" + return session, nil +} + +func (s *Service) CheckCSRF(session domain.WebSession, token string) bool { + return token != "" && crypto.TokenMatches(token, session.CSRFTokenHash) +} + +func (s *Service) Logout(ctx context.Context, session domain.WebSession) error { + if err := s.store.RevokeSession(ctx, session.ID); err != nil { + return err + } + s.audit(ctx, WebActor(session.AccountID), nil, domain.EventWebLogout, true, nil) + return nil +} + +var usernamePattern = regexp.MustCompile(`^[a-zA-Z0-9._-]{3,50}$`) + +type AccountInput struct { + Username string + Password string + Role domain.Role + UserID *int64 +} + +func (s *Service) CreateAccount(ctx context.Context, actor Actor, in AccountInput) (domain.WebAccount, error) { + var errs domain.ValidationErrors + in.Username = strings.TrimSpace(in.Username) + if !usernamePattern.MatchString(in.Username) { + errs.Add("username", "use de 3 a 50 caracteres: letras, números, ponto, hífen ou sublinhado") + } + if !in.Role.Valid() { + errs.Add("role", "use owner, admin ou viewer") + } + if err := crypto.ValidatePassword(in.Password); err != nil { + errs.Add("password", err.Error()) + } + if in.UserID != nil { + if err := s.checkAccountUser(ctx, 0, *in.UserID); err != nil { + errs.Add("user_id", err.Error()) + } + } + if len(errs) > 0 { + return domain.WebAccount{}, errs + } + hash, err := crypto.HashPassword(in.Password) + if err != nil { + return domain.WebAccount{}, err + } + account, err := s.store.CreateWebAccount(ctx, in.Username, hash, in.Role, in.UserID) + if errors.Is(err, domain.ErrConflict) { + return account, domain.ValidationErrors{{Field: "username", Message: "já existe uma conta com esse nome"}} + } + if err != nil { + return account, err + } + s.audit(ctx, actor, in.UserID, domain.EventWebAccountCreated, true, + map[string]any{"account_id": account.ID, "username": account.Username, "role": string(account.Role)}) + account.PasswordHash = "" + return account, nil +} + +// checkAccountUser confere que o usuário do Telegram existe e não está ligado a +// outra conta do painel (accountID é a conta que está sendo alterada, 0 se nova). +func (s *Service) checkAccountUser(ctx context.Context, accountID, userID int64) error { + if _, err := s.store.GetUser(ctx, userID); err != nil { + return errors.New("usuário do Telegram não encontrado") + } + accounts, err := s.store.ListWebAccounts(ctx) + if err != nil { + return err + } + for _, a := range accounts { + if a.ID != accountID && a.UserID != nil && *a.UserID == userID { + return errors.New("esse usuário já está ligado à conta " + a.Username) + } + } + return nil +} + +// AccountUserLink pede para trocar o vínculo da conta; UserID nil remove o vínculo. +type AccountUserLink struct { + UserID *int64 +} + +// UpdateAccount altera papel, situação e/ou vínculo com o usuário do Telegram, +// mantendo sempre um owner ativo (9.6). +func (s *Service) UpdateAccount(ctx context.Context, actor Actor, id int64, role *domain.Role, disabled *bool, link *AccountUserLink) error { + if role != nil && !role.Valid() { + return domain.ValidationErrors{{Field: "role", Message: "use owner, admin ou viewer"}} + } + if link != nil && link.UserID != nil { + if err := s.checkAccountUser(ctx, id, *link.UserID); err != nil { + return domain.ValidationErrors{{Field: "user_id", Message: err.Error()}} + } + } + account, err := s.store.GetWebAccount(ctx, id) + if err != nil { + return err + } + losesOwner := account.Role == domain.RoleOwner && !account.IsDisabled() && + ((role != nil && *role != domain.RoleOwner) || (disabled != nil && *disabled)) + if losesOwner { + owners, err := s.store.CountActiveOwners(ctx) + if err != nil { + return err + } + if owners <= 1 { + return domain.ErrLastOwner + } + } + var userID *int64 + if link != nil { + userID = link.UserID + } + if err := s.store.UpdateWebAccount(ctx, id, role, disabled, link != nil, userID); err != nil { + return err + } + if disabled != nil && *disabled { + if err := s.store.RevokeAccountSessions(ctx, id); err != nil { + return err + } + } + details := map[string]any{"account_id": id} + if role != nil { + details["role"] = string(*role) + } + if disabled != nil { + details["disabled"] = *disabled + } + if link != nil { + details["user_id"] = link.UserID + } + s.audit(ctx, actor, userID, domain.EventWebAccountChanged, true, details) + return nil +} + +// ResetPassword troca a senha e encerra todas as sessões da conta. +func (s *Service) ResetPassword(ctx context.Context, actor Actor, id int64, password string) error { + if err := crypto.ValidatePassword(password); err != nil { + return domain.ValidationErrors{{Field: "password", Message: err.Error()}} + } + if _, err := s.store.GetWebAccount(ctx, id); err != nil { + return err + } + hash, err := crypto.HashPassword(password) + if err != nil { + return err + } + if err := s.store.SetWebAccountPassword(ctx, id, hash); err != nil { + return err + } + if err := s.store.RevokeAccountSessions(ctx, id); err != nil { + return err + } + s.audit(ctx, actor, nil, domain.EventWebPasswordReset, true, map[string]any{"account_id": id}) + return nil +} + +func (s *Service) ResetPasswordByUsername(ctx context.Context, actor Actor, username, password string) error { + account, err := s.store.GetWebAccountByUsername(ctx, username) + if err != nil { + return err + } + return s.ResetPassword(ctx, actor, account.ID, password) +} + +func limitPtr(v string, max int) *string { + v = strings.TrimSpace(v) + if v == "" { + return nil + } + v = truncate(v, max) + return &v +} + +// ipPtr só aceita endereços válidos (a coluna é inet). +func ipPtr(ip string) *string { + addr, err := netip.ParseAddr(ip) + if err != nil { + return nil + } + v := addr.String() + return &v +} diff --git a/backend/internal/bootstrap/bootstrap.go b/backend/internal/bootstrap/bootstrap.go new file mode 100644 index 0000000..ef057ca --- /dev/null +++ b/backend/internal/bootstrap/bootstrap.go @@ -0,0 +1,60 @@ +// Package bootstrap é o composition root: monta o app.Service com os adapters +// reais. Usado pela api, pelo worker e pela CLI. +package bootstrap + +import ( + "context" + "fmt" + "log/slog" + + "github.com/jackc/pgx/v5/pgxpool" + + "botpontos/internal/adapters/crypto" + "botpontos/internal/adapters/feriados" + "botpontos/internal/adapters/pontomais" + "botpontos/internal/adapters/postgres" + "botpontos/internal/adapters/telegram" + "botpontos/internal/app" + "botpontos/internal/config" +) + +// Built é o serviço montado com todos os adapters reais. +type Built struct { + Service *app.Service + Pool *pgxpool.Pool + Telegram *telegram.Client +} + +// Build abre o banco e monta o serviço com os adapters reais. +func Build(ctx context.Context, cfg config.Config, logger *slog.Logger) (*Built, error) { + if err := cfg.RequireCrypto(); err != nil { + return nil, err + } + keyring, err := crypto.NewKeyring(cfg.Crypto.Key, int16(cfg.Crypto.KeyVersion), + cfg.Crypto.PreviousKey, int16(cfg.Crypto.PreviousKeyVersion)) + if err != nil { + return nil, fmt.Errorf("chave de criptografia: %w", err) + } + + pool, err := postgres.OpenPool(ctx, cfg.DB, logger) + if err != nil { + return nil, err + } + + tg := telegram.NewClient(cfg.Telegram.Token) + var holidays app.HolidayProvider + if client := feriados.NewClient(cfg.Holidays.BaseURL, cfg.Holidays.APIKey, cfg.Holidays.Location); client.Configured() { + holidays = client + } + + svc := app.New(app.Deps{ + Store: postgres.NewStore(pool), + Points: pontomais.NewClient(cfg.PontoMais.AuthURL, cfg.PontoMais.HistoryURL, cfg.PontoMais.Timeout, keyring), + Messenger: tg, + Holidays: holidays, + Cipher: keyring, + Config: cfg, + Logger: logger, + }) + return &Built{Service: svc, Pool: pool, Telegram: tg}, nil +} diff --git a/backend/internal/bot/router.go b/backend/internal/bot/router.go new file mode 100644 index 0000000..1dba312 --- /dev/null +++ b/backend/internal/bot/router.go @@ -0,0 +1,360 @@ +// Package bot é o adapter de entrada do Telegram: traduz comandos em casos de +// uso e formata as respostas (ARQUITETURA.md, 8.4). +package bot + +import ( + "context" + "errors" + "fmt" + "log/slog" + "strconv" + "strings" + + "botpontos/internal/adapters/postgres" + "botpontos/internal/app" + "botpontos/internal/domain" + "botpontos/internal/rules" +) + +type Router struct { + svc *app.Service + log *slog.Logger +} + +func NewRouter(svc *app.Service, log *slog.Logger) *Router { + return &Router{svc: svc, log: log} +} + +// Handle processa uma mensagem e devolve o texto da resposta. +func (r *Router) Handle(ctx context.Context, chatID int64, name, text string) string { + command, args := parse(text) + if command == "" { + return "Envie /ajuda para ver os comandos disponíveis." + } + + user, err := r.svc.UserByChatID(ctx, chatID) + known := err == nil + if err != nil && !errors.Is(err, domain.ErrNotFound) { + r.log.Error("falha ao identificar usuário", "error", err) + return msgInternalError + } + + switch command { + case "/start", "/iniciar": + if !known { + return "Olá! Este é o Bot de Pontos. Envie /cadastrar para solicitar acesso." + } + return helpText(user) + case "/cadastrar": + return r.register(ctx, chatID, name, known, user) + } + + if !known { + return "Você ainda não tem cadastro. Envie /cadastrar para solicitar acesso." + } + + switch command { + case "/ajuda": + return helpText(user) + case "/status": + return r.status(ctx, user) + } + + if !user.IsActive() { + if user.Status == domain.UserPending { + return "Seu cadastro ainda aguarda aprovação de um administrador." + } + return "Seu acesso está desativado. Procure um administrador." + } + + switch command { + case "/entrada2": + return r.today(ctx, user, domain.ResumeEntrada2) + case "/saida2": + return r.today(ctx, user, domain.ResumeSaida2) + case "/ativarponto": + return r.enable(ctx, user) + case "/desativarponto": + return r.disable(ctx, user) + } + + if !user.IsActiveBotAdmin() { + return "Comando desconhecido. Envie /ajuda para ver os comandos disponíveis." + } + + switch command { + case "/usuarios": + return r.listUsers(ctx) + case "/ativarusuario": + return r.activateUser(ctx, user, args) + case "/desativarusuario": + return r.disableUser(ctx, user, args) + } + return "Comando desconhecido. Envie /ajuda para ver os comandos disponíveis." +} + +const msgInternalError = "Ocorreu um erro ao processar o comando. Tente novamente em instantes." + +// parse normaliza "/status@nome_do_bot arg" para ("/status", ["arg"]). +func parse(text string) (string, []string) { + fields := strings.Fields(strings.TrimSpace(text)) + if len(fields) == 0 || !strings.HasPrefix(fields[0], "/") { + return "", nil + } + command := strings.ToLower(fields[0]) + if at := strings.Index(command, "@"); at >= 0 { + command = command[:at] + } + return command, fields[1:] +} + +func helpText(u domain.User) string { + var b strings.Builder + b.WriteString("Comandos:\n") + b.WriteString("/status — situação do cadastro e dos lembretes\n") + if u.IsActive() { + b.WriteString("/entrada2 — retorno previsto do almoço\n") + b.WriteString("/saida2 — saída prevista\n") + b.WriteString("/ativarponto — ligar os lembretes automáticos\n") + b.WriteString("/desativarponto — pausar os lembretes\n") + } + b.WriteString("/ajuda — esta lista\n") + if u.IsActiveBotAdmin() { + b.WriteString("\nAdministração:\n") + b.WriteString("/usuarios — lista de usuários\n") + b.WriteString("/ativarusuario — aprovar cadastro (exige credencial validada)\n") + b.WriteString("/desativarusuario — desativar usuário\n") + } + b.WriteString("\nO bot não registra ponto; a fonte oficial continua sendo a PontoMais.") + return b.String() +} + +func (r *Router) register(ctx context.Context, chatID int64, name string, known bool, user domain.User) string { + if known { + switch user.Status { + case domain.UserPending: + return "Seu pedido de cadastro já foi enviado e aguarda aprovação." + case domain.UserActive: + return "Você já está cadastrado. Envie /ajuda para ver os comandos." + default: + return "Seu acesso está desativado. Procure um administrador." + } + } + if _, _, err := r.svc.Register(ctx, chatID, name); err != nil { + r.log.Error("falha no cadastro", "error", err) + return msgInternalError + } + return "Pedido de cadastro enviado. Um administrador vai configurar sua credencial da PontoMais e aprovar o acesso." +} + +func (r *Router) status(ctx context.Context, user domain.User) string { + info, err := r.svc.Status(ctx, user.ID) + if err != nil { + r.log.Error("falha no /status", "user_id", user.ID, "error", err) + return msgInternalError + } + var b strings.Builder + fmt.Fprintf(&b, "Cadastro: %s\n", statusLabel(info.User.Status)) + + switch { + case !info.HasCredential: + b.WriteString("Credencial PontoMais: não configurada\n") + case !info.Validated: + b.WriteString("Credencial PontoMais: configurada, ainda não validada\n") + case info.Failures > 0: + fmt.Fprintf(&b, "Credencial PontoMais: validada (%d falha(s) seguida(s) na última consulta)\n", info.Failures) + default: + b.WriteString("Credencial PontoMais: validada\n") + } + + if info.User.NotificationsEnabled { + b.WriteString("Lembretes: ligados\n") + } else { + fmt.Fprintf(&b, "Lembretes: pausados%s\n", pauseLabel(info.User.PausedReason)) + } + + origin := "global" + if info.CustomWindow { + origin = "personalizada" + } + fmt.Fprintf(&b, "Janela de consulta: %s às %s (%s)\n", info.Window.Start, info.Window.End, origin) + + if !info.BusinessDay { + b.WriteString("Hoje: não é dia útil (sem consultas)") + } else { + b.WriteString("Hoje: " + dayLabel(info.Today)) + } + return b.String() +} + +func statusLabel(s domain.UserStatus) string { + switch s { + case domain.UserActive: + return "ativo" + case domain.UserPending: + return "aguardando aprovação" + default: + return "desativado" + } +} + +func pauseLabel(r *domain.PauseReason) string { + if r == nil { + return "" + } + switch *r { + case domain.PauseManual: + return " (por você, com /desativarponto)" + case domain.PauseAPIFailures: + return " (3 falhas seguidas na PontoMais; envie /ativarponto depois de conferir a credencial)" + case domain.PauseAdmin: + return " (por um administrador)" + case domain.PauseUserDisabled: + return " (acesso desativado)" + } + return "" +} + +func dayLabel(d domain.UserDay) string { + switch d.Status { + case domain.DayNoPoints: + return "nenhum ponto encontrado até a confirmação; reverificando de hora em hora" + case domain.DayNotWorking: + return "nenhum ponto encontrado, consultas encerradas (envie /ativarponto se estiver trabalhando)" + case domain.DayFinished: + return "jornada encerrada" + case domain.DayWorking: + return fmt.Sprintf("acompanhando (%d ponto(s) até a última consulta)", d.LastPointCount) + default: + return "aguardando a primeira consulta" + } +} + +func (r *Router) today(ctx context.Context, user domain.User, source domain.ResumeSource) string { + view, err := r.svc.Today(ctx, app.TelegramActor(user.ID), user.ID, &source) + switch { + case errors.Is(err, domain.ErrCredentialsRequired): + return "Sua credencial da PontoMais ainda não foi configurada. Procure um administrador." + case err != nil: + r.log.Warn("falha na consulta manual", "user_id", user.ID, "error", err) + return "Não consegui consultar a PontoMais agora. Tente novamente em alguns minutos." + } + + loc := user.Location() + var b strings.Builder + if view.Invalid { + return "Os pontos de hoje estão repetidos ou fora de ordem. Confira no sistema oficial." + } + if view.Overtime { + return "Há mais de quatro pontos hoje (hora extra), situação fora do controle do bot." + } + + if source == domain.ResumeEntrada2 { + if view.Entrada2At == nil { + return fmt.Sprintf("Encontrei %d ponto(s) hoje. O retorno do almoço é calculado a partir do segundo ponto.", len(view.Points)) + } + fmt.Fprintf(&b, "Retorno previsto do almoço: %s (almoço de %s).", + rules.FormatClock(*view.Entrada2At, loc), rules.FormatMinutesShort(view.LunchMinutes)) + } else { + switch { + case view.Saida2Error != "": + return "Não foi possível calcular a saída: o tempo trabalhado antes do almoço já completa a jornada." + case view.Saida2At == nil: + return fmt.Sprintf("Encontrei %d ponto(s) hoje. A saída é calculada a partir do terceiro ponto.", len(view.Points)) + } + fmt.Fprintf(&b, "Saída prevista: %s. Para completar %s.", + rules.FormatClock(*view.Saida2At, loc), rules.FormatMinutes(view.DailyWorkMinutes)) + } + if view.Resumed { + b.WriteString("\nAs consultas automáticas de hoje foram retomadas.") + } + return b.String() +} + +func (r *Router) enable(ctx context.Context, user domain.User) string { + _, err := r.svc.EnableNotifications(ctx, app.TelegramActor(user.ID), user.ID) + switch { + case errors.Is(err, domain.ErrCredentialsRequired): + return "Sua credencial da PontoMais ainda não foi validada. Procure um administrador." + case err != nil: + r.log.Error("falha no /ativarponto", "user_id", user.ID, "error", err) + return msgInternalError + } + return "Lembretes ligados. Você receberá o aviso de retorno do almoço e de saída nos dias úteis." +} + +func (r *Router) disable(ctx context.Context, user domain.User) string { + if err := r.svc.DisableNotifications(ctx, app.TelegramActor(user.ID), user.ID, domain.PauseManual); err != nil { + r.log.Error("falha no /desativarponto", "user_id", user.ID, "error", err) + return msgInternalError + } + return "Lembretes pausados. As consultas manuais (/entrada2, /saida2) continuam disponíveis." +} + +func (r *Router) listUsers(ctx context.Context) string { + users, err := r.svc.Store().ListUsers(ctx, postgres.UserFilter{}) + if err != nil { + r.log.Error("falha no /usuarios", "error", err) + return msgInternalError + } + if len(users) == 0 { + return "Nenhum usuário cadastrado." + } + var b strings.Builder + for _, s := range users { + credential := "sem credencial" + if s.CredentialValidated { + credential = "credencial ok" + } else if s.HasCredential { + credential = "credencial não validada" + } + reminders := "lembretes off" + if s.User.NotificationsEnabled { + reminders = "lembretes on" + } + fmt.Fprintf(&b, "%d — %s — %s, %s, %s\n", s.User.ID, s.User.DisplayName, statusLabel(s.User.Status), credential, reminders) + } + return strings.TrimSpace(b.String()) +} + +func parseID(args []string) (int64, bool) { + if len(args) != 1 { + return 0, false + } + id, err := strconv.ParseInt(args[0], 10, 64) + return id, err == nil && id > 0 +} + +func (r *Router) activateUser(ctx context.Context, admin domain.User, args []string) string { + id, ok := parseID(args) + if !ok { + return "Uso: /ativarusuario (veja os IDs em /usuarios)" + } + u, err := r.svc.ActivateUser(ctx, app.TelegramActor(admin.ID), id) + switch { + case errors.Is(err, domain.ErrNotFound): + return "Usuário não encontrado." + case errors.Is(err, domain.ErrCredentialsRequired): + return "Esse usuário ainda não tem credencial da PontoMais validada. Configure-a pelo painel antes de ativar." + case err != nil: + r.log.Error("falha no /ativarusuario", "error", err) + return msgInternalError + } + return fmt.Sprintf("%s (ID %d) está ativo.", u.DisplayName, u.ID) +} + +func (r *Router) disableUser(ctx context.Context, admin domain.User, args []string) string { + id, ok := parseID(args) + if !ok { + return "Uso: /desativarusuario (veja os IDs em /usuarios)" + } + err := r.svc.DisableUser(ctx, app.TelegramActor(admin.ID), id) + switch { + case errors.Is(err, domain.ErrNotFound): + return "Usuário não encontrado." + case err != nil: + r.log.Error("falha no /desativarusuario", "error", err) + return msgInternalError + } + return fmt.Sprintf("Usuário %d desativado. Os lembretes agendados foram cancelados.", id) +} diff --git a/backend/internal/bot/router_test.go b/backend/internal/bot/router_test.go new file mode 100644 index 0000000..1fdfe0b --- /dev/null +++ b/backend/internal/bot/router_test.go @@ -0,0 +1,40 @@ +package bot + +import ( + "reflect" + "testing" +) + +func TestParse(t *testing.T) { + cases := []struct { + in string + command string + args []string + }{ + {"/status", "/status", []string{}}, + {"/Status@meu_bot", "/status", []string{}}, + {" /ativarusuario 42 ", "/ativarusuario", []string{"42"}}, + {"oi", "", nil}, + {"", "", nil}, + } + for _, c := range cases { + command, args := parse(c.in) + if command != c.command { + t.Errorf("parse(%q) comando = %q, esperado %q", c.in, command, c.command) + } + if c.args != nil && !reflect.DeepEqual(args, c.args) { + t.Errorf("parse(%q) args = %v, esperado %v", c.in, args, c.args) + } + } +} + +func TestParseID(t *testing.T) { + if id, ok := parseID([]string{"42"}); !ok || id != 42 { + t.Errorf("parseID(42) = %d, %v", id, ok) + } + for _, invalid := range [][]string{nil, {"abc"}, {"-1"}, {"1", "2"}} { + if _, ok := parseID(invalid); ok { + t.Errorf("parseID(%v) deveria falhar", invalid) + } + } +} diff --git a/backend/internal/bot/runner.go b/backend/internal/bot/runner.go new file mode 100644 index 0000000..231eb7e --- /dev/null +++ b/backend/internal/bot/runner.go @@ -0,0 +1,76 @@ +package bot + +import ( + "context" + "log/slog" + "time" + + "botpontos/internal/adapters/telegram" +) + +// Runner faz o long polling do Telegram e responde cada mensagem. +type Runner struct { + client *telegram.Client + router *Router + log *slog.Logger + pollTimeout int +} + +func NewRunner(client *telegram.Client, router *Router, log *slog.Logger, pollTimeout int) *Runner { + return &Runner{client: client, router: router, log: log, pollTimeout: pollTimeout} +} + +// Run roda até o contexto ser cancelado. Erros de rede não derrubam o +// worker: o loop espera alguns segundos e tenta de novo. +func (r *Runner) Run(ctx context.Context) { + var offset int64 + backoff := time.Second + r.log.Info("bot do Telegram iniciado") + for ctx.Err() == nil { + updates, err := r.client.GetUpdates(ctx, offset, r.pollTimeout) + if err != nil { + if ctx.Err() != nil { + return + } + r.log.Warn("falha no getUpdates do Telegram", "error", err, "retry_in", backoff.String()) + select { + case <-ctx.Done(): + return + case <-time.After(backoff): + } + if backoff < 30*time.Second { + backoff *= 2 + } + continue + } + backoff = time.Second + + for _, update := range updates { + offset = update.UpdateID + 1 + msg := update.Message + // Só conversas privadas: o bot trata dados pessoais de jornada. + if msg == nil || msg.Text == "" || (msg.Chat.Type != "" && msg.Chat.Type != "private") { + continue + } + r.handle(ctx, msg) + } + } +} + +func (r *Runner) handle(ctx context.Context, msg *telegram.Message) { + defer func() { + if v := recover(); v != nil { + r.log.Error("panic ao tratar mensagem do Telegram", "panic", v) + } + }() + handleCtx, cancel := context.WithTimeout(ctx, 45*time.Second) + defer cancel() + + reply := r.router.Handle(handleCtx, msg.Chat.ID, msg.From.Name(), msg.Text) + if reply == "" { + return + } + if _, err := r.client.Send(ctx, msg.Chat.ID, reply); err != nil { + r.log.Warn("falha ao responder no Telegram", "error", err) + } +} diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go new file mode 100644 index 0000000..0aab8c2 --- /dev/null +++ b/backend/internal/config/config.go @@ -0,0 +1,429 @@ +// Package config carrega a configuração a partir de variáveis de ambiente. +// +// Segredos aceitam a variante _FILE, que aponta para um arquivo +// (ex.: Docker secrets em /run/secrets/...). +package config + +import ( + "errors" + "fmt" + "log/slog" + "net/netip" + "net/url" + "os" + "strconv" + "strings" + "time" + + "botpontos/internal/domain" +) + +type Config struct { + LogLevel string + DefaultTimezone string + DB DB + HTTP HTTP + Worker Worker + Jobs Jobs + PontoMais PontoMais + Crypto Crypto + Telegram Telegram + Holidays Holidays + Web Web + DefaultWindow domain.QueryWindow // padrões gravados em app_settings na 1ª subida +} + +type DB struct { + Host string + Port int + Name string + User string + Password string + MaxConns int32 +} + +// DSN monta a URL de conexão. A rede entre containers é interna, por isso sslmode=disable. +func (d DB) DSN() string { + query := url.Values{} + query.Set("sslmode", "disable") + query.Set("application_name", "botpontos") + + u := url.URL{ + Scheme: "postgres", + User: url.UserPassword(d.User, d.Password), + Host: fmt.Sprintf("%s:%d", d.Host, d.Port), + Path: "/" + d.Name, + RawQuery: query.Encode(), + } + return u.String() +} + +type HTTP struct { + Addr string +} + +type Worker struct { + HealthAddr string + LeaderRetry time.Duration +} + +// Jobs reúne os parâmetros dos ciclos do worker (ARQUITETURA.md, seções 8.2 e 9). +// Os horários da janela de consulta ficam no banco (app_settings), não aqui. +type Jobs struct { + ReconciliationInterval time.Duration + ReconciliationDeadline time.Duration + ReconciliationWorkers int + DispatchInterval time.Duration + MaintenanceInterval time.Duration + LateTolerance time.Duration + SendingTimeout time.Duration + SlowCheckInterval time.Duration + ExitConfirmFrom time.Duration + ExitConfirmTo time.Duration + APIFailureThreshold int + RetentionAudit time.Duration + RetentionNotifications time.Duration + RetentionSessions time.Duration + RetentionHour int +} + +type PontoMais struct { + AuthURL string + HistoryURL string + Timeout time.Duration +} + +type Crypto struct { + Key string + KeyVersion int + PreviousKey string + PreviousKeyVersion int +} + +type Telegram struct { + Token string + PollTimeout int // segundos +} + +type Holidays struct { + BaseURL string + APIKey string + Location string +} + +type Web struct { + PublicURL string + CookieSecure bool + SessionDuration time.Duration + SessionIdle time.Duration + LoginLock time.Duration + LoginMaxFailures int + TrustedProxies []netip.Prefix +} + +func Load() (Config, error) { + return load(os.Getenv) +} + +func load(getenv func(string) string) (Config, error) { + r := &reader{getenv: getenv} + cfg := Config{ + LogLevel: r.str("BP_LOG_LEVEL", "info"), + DefaultTimezone: r.str("BP_DEFAULT_TIMEZONE", domain.DefaultTimezone), + DB: DB{ + Host: r.str("BP_DB_HOST", "postgres"), + Port: r.integer("BP_DB_PORT", 5432), + Name: r.str("BP_DB_NAME", "botpontos"), + User: r.str("BP_DB_USER", ""), + Password: r.secret("BP_DB_PASSWORD"), + MaxConns: int32(r.integer("BP_DB_MAX_CONNS", 10)), + }, + HTTP: HTTP{ + Addr: r.str("BP_HTTP_ADDR", ":8080"), + }, + Worker: Worker{ + HealthAddr: r.str("BP_WORKER_HEALTH_ADDR", ":8081"), + LeaderRetry: r.duration("BP_WORKER_LEADER_RETRY", 10*time.Second), + }, + Jobs: Jobs{ + ReconciliationInterval: r.duration("BP_RECONCILIATION_INTERVAL", 15*time.Minute), + ReconciliationDeadline: r.duration("BP_RECONCILIATION_DEADLINE", 5*time.Minute), + ReconciliationWorkers: r.integer("BP_RECONCILIATION_WORKERS", 4), + DispatchInterval: r.duration("BP_DISPATCH_INTERVAL", 2*time.Minute), + MaintenanceInterval: r.duration("BP_MAINTENANCE_INTERVAL", 5*time.Minute), + LateTolerance: r.duration("BP_LATE_TOLERANCE", 10*time.Minute), + SendingTimeout: r.duration("BP_SENDING_TIMEOUT", 5*time.Minute), + SlowCheckInterval: r.duration("BP_SLOW_CHECK_INTERVAL", 60*time.Minute), + APIFailureThreshold: r.integer("BP_API_FAILURE_THRESHOLD", 3), + RetentionAudit: r.duration("BP_RETENTION_AUDIT", 365*24*time.Hour), + RetentionNotifications: r.duration("BP_RETENTION_NOTIFICATIONS", 90*24*time.Hour), + RetentionSessions: r.duration("BP_RETENTION_SESSIONS", 7*24*time.Hour), + RetentionHour: r.integer("BP_RETENTION_HOUR", 3), + }, + PontoMais: PontoMais{ + AuthURL: r.str("BP_PONTOMAIS_AUTH_URL", "https://api.pontomais.com.br/api/auth/sign_in"), + HistoryURL: r.str("BP_PONTOMAIS_HISTORY_URL", "https://api.pontomais.com.br/api/time_cards/current/last_cached"), + Timeout: r.duration("BP_PONTOMAIS_TIMEOUT", 10*time.Second), + }, + Crypto: Crypto{ + Key: r.secret("BP_ENCRYPTION_KEY"), + KeyVersion: r.integer("BP_ENCRYPTION_KEY_VERSION", 1), + PreviousKey: r.secret("BP_ENCRYPTION_PREVIOUS_KEY"), + PreviousKeyVersion: r.integer("BP_ENCRYPTION_PREVIOUS_KEY_VERSION", 0), + }, + Telegram: Telegram{ + Token: r.secret("BP_TELEGRAM_TOKEN"), + PollTimeout: r.integer("BP_TELEGRAM_POLL_TIMEOUT", 25), + }, + Holidays: Holidays{ + BaseURL: r.str("BP_HOLIDAY_API_BASE_URL", "https://feriadosapi.com/api/v1"), + APIKey: r.secret("BP_HOLIDAY_API_KEY"), + Location: r.str("BP_HOLIDAY_LOCATION", "3106200"), + }, + Web: Web{ + PublicURL: strings.TrimRight(r.str("BP_PUBLIC_URL", ""), "/"), + CookieSecure: r.boolean("BP_COOKIE_SECURE", true), + SessionDuration: r.duration("BP_SESSION_DURATION", 8*time.Hour), + SessionIdle: r.duration("BP_SESSION_IDLE", 30*time.Minute), + LoginLock: r.duration("BP_LOGIN_LOCK", 15*time.Minute), + LoginMaxFailures: r.integer("BP_LOGIN_MAX_FAILURES", 5), + TrustedProxies: r.prefixes("BP_TRUSTED_PROXIES", "127.0.0.0/8,172.16.0.0/12"), + }, + } + from, to := r.durationRange("BP_EXIT_CONFIRM_WINDOW", 20*time.Minute, 5*time.Minute) + cfg.Jobs.ExitConfirmFrom, cfg.Jobs.ExitConfirmTo = from, to + + cfg.DefaultWindow = domain.QueryWindow{ + Start: r.clock("BP_QUERY_WINDOW_START", "11:30"), + End: r.clock("BP_QUERY_WINDOW_END", "19:00"), + DayCheckDeadline: r.clock("BP_DAY_CHECK_DEADLINE", "11:45"), + NoPointsRecheckUntil: r.clock("BP_NO_POINTS_RECHECK_UNTIL", "15:00"), + } + + errs := append(r.errs, cfg.validate()...) + return cfg, errors.Join(errs...) +} + +func (c Config) validate() []error { + var errs []error + + var level slog.Level + if err := level.UnmarshalText([]byte(c.LogLevel)); err != nil { + errs = append(errs, fmt.Errorf("BP_LOG_LEVEL: valor inválido %q (use debug, info, warn ou error)", c.LogLevel)) + } + if _, err := time.LoadLocation(c.DefaultTimezone); err != nil { + errs = append(errs, fmt.Errorf("BP_DEFAULT_TIMEZONE: fuso inválido %q", c.DefaultTimezone)) + } + + if c.DB.User == "" { + errs = append(errs, errors.New("BP_DB_USER: obrigatório")) + } + if c.DB.Password == "" { + errs = append(errs, errors.New("BP_DB_PASSWORD ou BP_DB_PASSWORD_FILE: obrigatório")) + } + if c.DB.Port <= 0 || c.DB.Port > 65535 { + errs = append(errs, fmt.Errorf("BP_DB_PORT: porta inválida %d", c.DB.Port)) + } + if c.DB.MaxConns <= 0 { + errs = append(errs, errors.New("BP_DB_MAX_CONNS: precisa ser maior que zero")) + } + + durations := []struct { + name string + value time.Duration + }{ + {"BP_WORKER_LEADER_RETRY", c.Worker.LeaderRetry}, + {"BP_RECONCILIATION_INTERVAL", c.Jobs.ReconciliationInterval}, + {"BP_RECONCILIATION_DEADLINE", c.Jobs.ReconciliationDeadline}, + {"BP_DISPATCH_INTERVAL", c.Jobs.DispatchInterval}, + {"BP_MAINTENANCE_INTERVAL", c.Jobs.MaintenanceInterval}, + {"BP_LATE_TOLERANCE", c.Jobs.LateTolerance}, + {"BP_SENDING_TIMEOUT", c.Jobs.SendingTimeout}, + {"BP_SLOW_CHECK_INTERVAL", c.Jobs.SlowCheckInterval}, + {"BP_RETENTION_AUDIT", c.Jobs.RetentionAudit}, + {"BP_RETENTION_NOTIFICATIONS", c.Jobs.RetentionNotifications}, + {"BP_RETENTION_SESSIONS", c.Jobs.RetentionSessions}, + {"BP_PONTOMAIS_TIMEOUT", c.PontoMais.Timeout}, + {"BP_SESSION_DURATION", c.Web.SessionDuration}, + {"BP_SESSION_IDLE", c.Web.SessionIdle}, + {"BP_LOGIN_LOCK", c.Web.LoginLock}, + } + for _, d := range durations { + if d.value <= 0 { + errs = append(errs, fmt.Errorf("%s: precisa ser maior que zero", d.name)) + } + } + + if c.Jobs.ReconciliationInterval > 0 && time.Hour%c.Jobs.ReconciliationInterval != 0 { + errs = append(errs, errors.New("BP_RECONCILIATION_INTERVAL: precisa dividir 1 hora (ex.: 5m, 10m, 15m, 30m)")) + } + if c.Jobs.SlowCheckInterval > 0 && c.Jobs.ReconciliationInterval > 0 && + c.Jobs.SlowCheckInterval%c.Jobs.ReconciliationInterval != 0 { + errs = append(errs, errors.New("BP_SLOW_CHECK_INTERVAL: precisa ser múltiplo de BP_RECONCILIATION_INTERVAL")) + } + if c.Jobs.ExitConfirmFrom-c.Jobs.ExitConfirmTo < c.Jobs.ReconciliationInterval { + errs = append(errs, errors.New("BP_EXIT_CONFIRM_WINDOW: a janela precisa ter pelo menos o intervalo de reconciliação")) + } + if c.Jobs.APIFailureThreshold < 1 { + errs = append(errs, errors.New("BP_API_FAILURE_THRESHOLD: precisa ser pelo menos 1")) + } + if c.Jobs.ReconciliationWorkers < 1 { + errs = append(errs, errors.New("BP_RECONCILIATION_WORKERS: precisa ser pelo menos 1")) + } + if c.Jobs.RetentionHour < 0 || c.Jobs.RetentionHour > 23 { + errs = append(errs, errors.New("BP_RETENTION_HOUR: precisa estar entre 0 e 23")) + } + if c.Crypto.KeyVersion < 1 || c.Crypto.KeyVersion > 32767 { + errs = append(errs, errors.New("BP_ENCRYPTION_KEY_VERSION: precisa estar entre 1 e 32767")) + } + if c.Web.LoginMaxFailures < 1 { + errs = append(errs, errors.New("BP_LOGIN_MAX_FAILURES: precisa ser pelo menos 1")) + } + if c.Telegram.PollTimeout < 1 || c.Telegram.PollTimeout > 30 { + errs = append(errs, errors.New("BP_TELEGRAM_POLL_TIMEOUT: precisa estar entre 1 e 30 segundos")) + } + if c.Crypto.PreviousKey != "" && (c.Crypto.PreviousKeyVersion < 1 || c.Crypto.PreviousKeyVersion > 32767) { + errs = append(errs, errors.New("BP_ENCRYPTION_PREVIOUS_KEY_VERSION: precisa estar entre 1 e 32767")) + } + if c.Web.PublicURL != "" { + if u, err := url.Parse(c.Web.PublicURL); err != nil || u.Scheme == "" || u.Host == "" { + errs = append(errs, fmt.Errorf("BP_PUBLIC_URL: URL inválida %q (ex.: https://botpontos.lan)", c.Web.PublicURL)) + } + } + return errs +} + +// RequireCrypto confere a chave de criptografia (API, worker e CLI de credenciais). +func (c Config) RequireCrypto() error { + if len(c.Crypto.Key) < 32 { + return errors.New("BP_ENCRYPTION_KEY ou BP_ENCRYPTION_KEY_FILE: obrigatório, com pelo menos 32 caracteres") + } + return nil +} + +// RequireTelegram confere o token do bot (worker). +func (c Config) RequireTelegram() error { + if c.Telegram.Token == "" { + return errors.New("BP_TELEGRAM_TOKEN ou BP_TELEGRAM_TOKEN_FILE: obrigatório para o worker") + } + return nil +} + +type reader struct { + getenv func(string) string + errs []error +} + +func (r *reader) str(key, fallback string) string { + if v := strings.TrimSpace(r.getenv(key)); v != "" { + return v + } + return fallback +} + +func (r *reader) integer(key string, fallback int) int { + v := strings.TrimSpace(r.getenv(key)) + if v == "" { + return fallback + } + n, err := strconv.Atoi(v) + if err != nil { + r.errs = append(r.errs, fmt.Errorf("%s: número inválido %q", key, v)) + return fallback + } + return n +} + +func (r *reader) boolean(key string, fallback bool) bool { + v := strings.TrimSpace(r.getenv(key)) + if v == "" { + return fallback + } + b, err := strconv.ParseBool(v) + if err != nil { + r.errs = append(r.errs, fmt.Errorf("%s: use true ou false, veio %q", key, v)) + return fallback + } + return b +} + +func (r *reader) duration(key string, fallback time.Duration) time.Duration { + v := strings.TrimSpace(r.getenv(key)) + if v == "" { + return fallback + } + d, err := time.ParseDuration(v) + if err != nil { + r.errs = append(r.errs, fmt.Errorf("%s: duração inválida %q (ex.: 15m, 2m, 10s)", key, v)) + return fallback + } + return d +} + +// durationRange lê "A..B" (ex.: "20m..5m"). +func (r *reader) durationRange(key string, fromDefault, toDefault time.Duration) (time.Duration, time.Duration) { + v := strings.TrimSpace(r.getenv(key)) + if v == "" { + return fromDefault, toDefault + } + parts := strings.Split(v, "..") + if len(parts) != 2 { + r.errs = append(r.errs, fmt.Errorf("%s: use o formato 20m..5m, veio %q", key, v)) + return fromDefault, toDefault + } + from, err1 := time.ParseDuration(strings.TrimSpace(parts[0])) + to, err2 := time.ParseDuration(strings.TrimSpace(parts[1])) + if err1 != nil || err2 != nil || to < 0 || from <= to { + r.errs = append(r.errs, fmt.Errorf("%s: intervalo inválido %q (o primeiro valor precisa ser maior)", key, v)) + return fromDefault, toDefault + } + return from, to +} + +func (r *reader) clock(key, fallback string) domain.ClockTime { + v := r.str(key, fallback) + c, err := domain.ParseClock(v) + if err != nil { + r.errs = append(r.errs, fmt.Errorf("%s: %w", key, err)) + return domain.MustParseClock(fallback) + } + return c +} + +// prefixes lê uma lista de CIDRs ou IPs separados por vírgula. +func (r *reader) prefixes(key, fallback string) []netip.Prefix { + var out []netip.Prefix + for _, item := range strings.Split(r.str(key, fallback), ",") { + item = strings.TrimSpace(item) + if item == "" { + continue + } + if p, err := netip.ParsePrefix(item); err == nil { + out = append(out, p.Masked()) + continue + } + if a, err := netip.ParseAddr(item); err == nil { + out = append(out, netip.PrefixFrom(a, a.BitLen())) + continue + } + r.errs = append(r.errs, fmt.Errorf("%s: endereço ou CIDR inválido %q", key, item)) + } + return out +} + +// secret lê ou, se vazio, o arquivo apontado por _FILE. +func (r *reader) secret(key string) string { + if v := strings.TrimSpace(r.getenv(key)); v != "" { + return v + } + path := strings.TrimSpace(r.getenv(key + "_FILE")) + if path == "" { + return "" + } + content, err := os.ReadFile(path) + if err != nil { + r.errs = append(r.errs, fmt.Errorf("%s_FILE: %w", key, err)) + return "" + } + return strings.TrimSpace(string(content)) +} diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go new file mode 100644 index 0000000..ea27d15 --- /dev/null +++ b/backend/internal/config/config_test.go @@ -0,0 +1,111 @@ +package config + +import ( + "net/url" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func env(values map[string]string) func(string) string { + return func(key string) string { return values[key] } +} + +func TestLoadDefaults(t *testing.T) { + cfg, err := load(env(map[string]string{ + "BP_DB_USER": "botpontos_app", + "BP_DB_PASSWORD": "segredo", + })) + if err != nil { + t.Fatalf("erro inesperado: %v", err) + } + + if cfg.DB.Host != "postgres" || cfg.DB.Port != 5432 || cfg.DB.Name != "botpontos" { + t.Errorf("padrões do banco inesperados: %+v", cfg.DB) + } + if cfg.HTTP.Addr != ":8080" || cfg.Worker.HealthAddr != ":8081" { + t.Errorf("endereços inesperados: api=%s worker=%s", cfg.HTTP.Addr, cfg.Worker.HealthAddr) + } + if cfg.Jobs.ReconciliationInterval != 15*time.Minute { + t.Errorf("reconciliação = %s, esperado 15m", cfg.Jobs.ReconciliationInterval) + } + if cfg.Jobs.DispatchInterval != 2*time.Minute { + t.Errorf("despacho = %s, esperado 2m", cfg.Jobs.DispatchInterval) + } + if cfg.Jobs.LateTolerance != 10*time.Minute { + t.Errorf("tolerância = %s, esperado 10m", cfg.Jobs.LateTolerance) + } + if cfg.Jobs.APIFailureThreshold != 3 { + t.Errorf("limite de falhas = %d, esperado 3", cfg.Jobs.APIFailureThreshold) + } +} + +func TestLoadSecretFromFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "pg_app_password") + if err := os.WriteFile(path, []byte("segredo-do-arquivo\n"), 0o600); err != nil { + t.Fatal(err) + } + + cfg, err := load(env(map[string]string{ + "BP_DB_USER": "botpontos_app", + "BP_DB_PASSWORD_FILE": path, + })) + if err != nil { + t.Fatalf("erro inesperado: %v", err) + } + if cfg.DB.Password != "segredo-do-arquivo" { + t.Errorf("senha = %q, esperado sem quebra de linha", cfg.DB.Password) + } +} + +func TestLoadMissingSecretFile(t *testing.T) { + _, err := load(env(map[string]string{ + "BP_DB_USER": "botpontos_app", + "BP_DB_PASSWORD_FILE": filepath.Join(t.TempDir(), "nao-existe"), + })) + if err == nil || !strings.Contains(err.Error(), "BP_DB_PASSWORD_FILE") { + t.Fatalf("esperado erro de arquivo de segredo, veio: %v", err) + } +} + +func TestLoadInvalidValues(t *testing.T) { + _, err := load(env(map[string]string{ + "BP_LOG_LEVEL": "verboso", + "BP_DISPATCH_INTERVAL": "abc", + "BP_DB_PORT": "porta", + "BP_DEFAULT_TIMEZONE": "Lugar/Inexistente", + })) + if err == nil { + t.Fatal("esperado erro de validação") + } + for _, expected := range []string{ + "BP_LOG_LEVEL", + "BP_DISPATCH_INTERVAL", + "BP_DB_PORT", + "BP_DEFAULT_TIMEZONE", + "BP_DB_USER", + "BP_DB_PASSWORD", + } { + if !strings.Contains(err.Error(), expected) { + t.Errorf("erro deveria citar %s: %v", expected, err) + } + } +} + +func TestDSNEscapesPassword(t *testing.T) { + db := DB{Host: "postgres", Port: 5432, Name: "botpontos", User: "app", Password: "p@ss/w:rd?#"} + + parsed, err := url.Parse(db.DSN()) + if err != nil { + t.Fatalf("DSN inválido: %v", err) + } + password, _ := parsed.User.Password() + if password != db.Password { + t.Errorf("senha = %q, esperado %q", password, db.Password) + } + if parsed.Query().Get("sslmode") != "disable" { + t.Errorf("sslmode inesperado: %s", parsed.RawQuery) + } +} diff --git a/backend/internal/domain/audit.go b/backend/internal/domain/audit.go new file mode 100644 index 0000000..bfbb15d --- /dev/null +++ b/backend/internal/domain/audit.go @@ -0,0 +1,84 @@ +package domain + +import "time" + +type AuditSource string + +const ( + SourceTelegram AuditSource = "telegram" + SourceWeb AuditSource = "web" + SourceWorker AuditSource = "worker" + SourceCLI AuditSource = "cli" +) + +// Tipos de evento gravados em audit_logs. +const ( + EventUserRegistered = "user_registered" + EventUserActivated = "user_activated" + EventUserDisabled = "user_disabled" + EventBotAdminChanged = "bot_admin_changed" + EventScheduleChanged = "schedule_changed" + EventUserWindowChanged = "user_window_changed" + EventCredentialsChanged = "credentials_changed" + EventCredentialsTested = "credentials_tested" + EventNotificationsEnabled = "notifications_enabled" + EventNotificationsDisabled = "notifications_disabled" + EventNotificationsPausedAPI = "notifications_paused_api_failure" + EventPointAPIFailure = "point_api_failure" + EventPointAPIOutageStarted = "point_api_outage_started" + EventPointAPIOutageEnded = "point_api_outage_ended" + EventInvalidPointHistory = "invalid_point_history" + EventInvalidWorkday = "invalid_workday_calculation" + EventNotificationScheduled = "notification_scheduled" + EventNotificationSent = "notification_sent" + EventNotificationFailed = "notification_failed" + EventNotificationExpired = "notification_expired" + EventNotificationCancelled = "notification_cancelled" + EventNotificationResent = "notification_resent" + EventInterruptedSending = "notification_interrupted" + EventDayNoPoints = "day_no_points" + EventDayNotWorked = "day_not_worked" + EventDayResumed = "day_resumed" + EventHolidaySyncFailed = "holiday_sync_failed" + EventHolidaySynced = "holiday_synced" + EventHolidayAdded = "holiday_added" + EventHolidayRemoved = "holiday_removed" + EventSettingsChanged = "settings_changed" + EventWebLogin = "web_login" + EventWebLoginFailed = "web_login_failed" + EventWebLogout = "web_logout" + EventWebAccountCreated = "web_account_created" + EventWebAccountChanged = "web_account_changed" + EventWebPasswordReset = "web_password_reset" + EventRetentionApplied = "retention_applied" + EventEncryptionKeyRotated = "encryption_key_rotated" + EventTelegramFailure = "telegram_failure" + EventScheduleWindowViolation = "schedule_window_violation" +) + +// AuditEntry é um evento a ser gravado. Details nunca deve conter segredos. +type AuditEntry struct { + Source AuditSource + ActorUserID *int64 + ActorAccount *int64 + TargetUserID *int64 + EventType string + Success bool + Details map[string]any +} + +// AuditLog é um evento lido do banco, com nomes para exibição. +type AuditLog struct { + ID int64 + OccurredAt time.Time + Source AuditSource + ActorUserID *int64 + ActorUserName *string + ActorAccount *int64 + ActorUsername *string + TargetUserID *int64 + TargetUserName *string + EventType string + Success bool + Details map[string]any +} diff --git a/backend/internal/domain/clock.go b/backend/internal/domain/clock.go new file mode 100644 index 0000000..2b28b43 --- /dev/null +++ b/backend/internal/domain/clock.go @@ -0,0 +1,54 @@ +package domain + +import ( + "fmt" + "time" +) + +// ClockTime é um horário do dia (HH:MM), guardado em minutos desde 00:00. +type ClockTime int + +func ParseClock(value string) (ClockTime, error) { + if len(value) != 5 || value[2] != ':' { + return 0, fmt.Errorf("horário inválido %q (use HH:MM)", value) + } + digits := [4]byte{value[0], value[1], value[3], value[4]} + for _, d := range digits { + if d < '0' || d > '9' { + return 0, fmt.Errorf("horário inválido %q (use HH:MM)", value) + } + } + hour := int(digits[0]-'0')*10 + int(digits[1]-'0') + minute := int(digits[2]-'0')*10 + int(digits[3]-'0') + if hour > 23 || minute > 59 { + return 0, fmt.Errorf("horário inválido %q", value) + } + return ClockTime(hour*60 + minute), nil +} + +func MustParseClock(value string) ClockTime { + c, err := ParseClock(value) + if err != nil { + panic(err) + } + return c +} + +func (c ClockTime) String() string { + return fmt.Sprintf("%02d:%02d", int(c)/60, int(c)%60) +} + +// On devolve o instante desse horário na data (AAAA-MM-DD) e fuso informados. +func (c ClockTime) On(day string, loc *time.Location) time.Time { + date, err := time.ParseInLocation(DateLayout, day, loc) + if err != nil { + return time.Time{} + } + return time.Date(date.Year(), date.Month(), date.Day(), int(c)/60, int(c)%60, 0, 0, loc) +} + +// ClockOf devolve o horário do dia de t no fuso informado. +func ClockOf(t time.Time, loc *time.Location) ClockTime { + local := t.In(loc) + return ClockTime(local.Hour()*60 + local.Minute()) +} diff --git a/backend/internal/domain/credential.go b/backend/internal/domain/credential.go new file mode 100644 index 0000000..6b64be8 --- /dev/null +++ b/backend/internal/domain/credential.go @@ -0,0 +1,19 @@ +package domain + +import "time" + +// Credential é a credencial PontoMais de um usuário. A senha fica cifrada. +type Credential struct { + UserID int64 + APIEmail string + EncryptedPassword []byte + KeyVersion int16 + LastValidatedAt *time.Time + LastError *string + ConsecutiveFailures int + LastFailureAt *time.Time + UpdatedAt time.Time +} + +// IsValidated indica que a credencial já autenticou com sucesso na PontoMais. +func (c Credential) IsValidated() bool { return c.LastValidatedAt != nil } diff --git a/backend/internal/domain/day.go b/backend/internal/domain/day.go new file mode 100644 index 0000000..ca60122 --- /dev/null +++ b/backend/internal/domain/day.go @@ -0,0 +1,44 @@ +package domain + +import "time" + +// DayStatus é a situação de um usuário num dia (ARQUITETURA.md 9.12). +type DayStatus string + +const ( + DayAwaiting DayStatus = "awaiting" + DayNoPoints DayStatus = "no_points" + DayWorking DayStatus = "working" + DayNotWorking DayStatus = "not_working" + DayFinished DayStatus = "finished" +) + +type ResumeSource string + +const ( + ResumeAtivarPonto ResumeSource = "ativarponto" + ResumeEntrada2 ResumeSource = "entrada2" + ResumeSaida2 ResumeSource = "saida2" + ResumeWeb ResumeSource = "web" +) + +type UserDay struct { + UserID int64 + Day string + Status DayStatus + ZeroPointChecks int + LastPointCount int + NextCheckAt *time.Time + ResumedBy *ResumeSource + UpdatedAt time.Time +} + +// NewUserDay é a situação inicial de um dia ainda não consultado. +func NewUserDay(userID int64, day string) UserDay { + return UserDay{UserID: userID, Day: day, Status: DayAwaiting} +} + +// IsClosedWithoutPoints indica um dia sem pontos (em reverificação ou encerrado). +func (d UserDay) IsClosedWithoutPoints() bool { + return d.Status == DayNoPoints || d.Status == DayNotWorking +} diff --git a/backend/internal/domain/errors.go b/backend/internal/domain/errors.go new file mode 100644 index 0000000..1bcc1d3 --- /dev/null +++ b/backend/internal/domain/errors.go @@ -0,0 +1,57 @@ +package domain + +import ( + "errors" + "strings" +) + +var ( + ErrNotFound = errors.New("registro não encontrado") + ErrConflict = errors.New("registro já existe") + ErrCredentialsRequired = errors.New("credencial da PontoMais não configurada ou não validada") + ErrUserNotActive = errors.New("usuário não está ativo") + ErrInvalidState = errors.New("operação não permitida no estado atual") + ErrUnauthenticated = errors.New("sessão inválida ou expirada") + ErrForbidden = errors.New("permissão insuficiente") + ErrInvalidLogin = errors.New("usuário ou senha inválidos") + ErrAccountLocked = errors.New("conta bloqueada temporariamente por tentativas de login") + ErrLastOwner = errors.New("é preciso manter pelo menos um owner ativo") + ErrNotConfigured = errors.New("integração não configurada") +) + +type ValidationError struct { + Field string `json:"field"` + Message string `json:"message"` +} + +// ValidationErrors agrupa erros de validação de campos (HTTP 422). +type ValidationErrors []ValidationError + +func (v ValidationErrors) Error() string { + parts := make([]string, 0, len(v)) + for _, e := range v { + parts = append(parts, e.Field+": "+e.Message) + } + return strings.Join(parts, "; ") +} + +func (v *ValidationErrors) Add(field, message string) { + *v = append(*v, ValidationError{Field: field, Message: message}) +} + +// OrNil devolve nil quando não há erros, para uso em `return errs.OrNil()`. +func (v ValidationErrors) OrNil() error { + if len(v) == 0 { + return nil + } + return v +} + +// stateError é um ErrInvalidState com mensagem específica para o usuário. +type stateError struct{ msg string } + +func (e stateError) Error() string { return e.msg } +func (e stateError) Is(target error) bool { return target == ErrInvalidState } + +// InvalidState cria um erro que satisfaz errors.Is(err, ErrInvalidState). +func InvalidState(msg string) error { return stateError{msg: msg} } diff --git a/backend/internal/domain/holiday.go b/backend/internal/domain/holiday.go new file mode 100644 index 0000000..95c81ea --- /dev/null +++ b/backend/internal/domain/holiday.go @@ -0,0 +1,12 @@ +package domain + +const ( + HolidaySourceAPI = "feriadosapi" + HolidaySourceManual = "manual" +) + +type Holiday struct { + Day string // AAAA-MM-DD + Name string + Source string +} diff --git a/backend/internal/domain/notification.go b/backend/internal/domain/notification.go new file mode 100644 index 0000000..85fb91b --- /dev/null +++ b/backend/internal/domain/notification.go @@ -0,0 +1,51 @@ +package domain + +import "time" + +type NotificationType string + +const ( + NotificationEntrada2 NotificationType = "entrada2" + NotificationSaida2 NotificationType = "saida2" +) + +type NotificationStatus string + +const ( + NotificationScheduled NotificationStatus = "scheduled" + NotificationSending NotificationStatus = "sending" + NotificationSent NotificationStatus = "sent" + NotificationFailed NotificationStatus = "failed" + NotificationCancelled NotificationStatus = "cancelled" + NotificationExpired NotificationStatus = "expired" +) + +type Notification struct { + ID int64 + UserID int64 + ReferenceDate string + Type NotificationType + ScheduledAt time.Time // quando o aviso deve sair + PredictedAt time.Time // horário previsto do ponto (retorno ou saída) + Status NotificationStatus + Attempts int + LockedAt *time.Time + SentAt *time.Time + TelegramMessageID *int64 + ErrorMessage *string + CreatedAt time.Time + UpdatedAt time.Time +} + +func ValidNotificationType(t string) bool { + return t == string(NotificationEntrada2) || t == string(NotificationSaida2) +} + +func ValidNotificationStatus(s string) bool { + switch NotificationStatus(s) { + case NotificationScheduled, NotificationSending, NotificationSent, + NotificationFailed, NotificationCancelled, NotificationExpired: + return true + } + return false +} diff --git a/backend/internal/domain/point.go b/backend/internal/domain/point.go new file mode 100644 index 0000000..351fd70 --- /dev/null +++ b/backend/internal/domain/point.go @@ -0,0 +1,26 @@ +package domain + +import ( + "errors" + "time" +) + +var ErrInvalidPointHistory = errors.New("histórico de pontos inválido") + +type Point struct { + OccurredAt time.Time +} + +// Workday são os pontos de um dia, em ordem. +type Workday struct { + Points []Point + Invalid bool // pontos repetidos ou fora de ordem + Overtime bool // mais de quatro pontos (hora extra, fora do escopo) +} + +func (w Workday) Count() int { return len(w.Points) } + +func (w Workday) Entry1() time.Time { return w.Points[0].OccurredAt } +func (w Workday) Exit1() time.Time { return w.Points[1].OccurredAt } +func (w Workday) Entry2() time.Time { return w.Points[2].OccurredAt } +func (w Workday) Exit2() time.Time { return w.Points[3].OccurredAt } diff --git a/backend/internal/domain/schedule.go b/backend/internal/domain/schedule.go new file mode 100644 index 0000000..135b34a --- /dev/null +++ b/backend/internal/domain/schedule.go @@ -0,0 +1,46 @@ +package domain + +// QueryWindow são os horários da janela de consulta à PontoMais (ARQUITETURA.md 9.12). +type QueryWindow struct { + Start ClockTime + End ClockTime + DayCheckDeadline ClockTime + NoPointsRecheckUntil ClockTime +} + +func DefaultQueryWindow() QueryWindow { + return QueryWindow{ + Start: MustParseClock("11:30"), + End: MustParseClock("19:00"), + DayCheckDeadline: MustParseClock("11:45"), + NoPointsRecheckUntil: MustParseClock("15:00"), + } +} + +// WorkSchedule são as regras de jornada do usuário e a exceção opcional de janela (9.14). +type WorkSchedule struct { + UserID int64 + LunchMinutes int + DailyWorkMinutes int + Entrada2LeadMinutes int + Saida2LeadMinutes int + Window *QueryWindow // nil = usa a janela global +} + +func DefaultSchedule(userID int64) WorkSchedule { + return WorkSchedule{ + UserID: userID, + LunchMinutes: 60, + DailyWorkMinutes: 480, + Entrada2LeadMinutes: 5, + Saida2LeadMinutes: 5, + } +} + +// LeadMinutes devolve a antecedência configurada para o tipo de lembrete. +func (s WorkSchedule) LeadMinutes(t NotificationType) int { + if t == NotificationSaida2 { + return s.Saida2LeadMinutes + } + return s.Entrada2LeadMinutes +} diff --git a/backend/internal/domain/user.go b/backend/internal/domain/user.go new file mode 100644 index 0000000..9f2c87f --- /dev/null +++ b/backend/internal/domain/user.go @@ -0,0 +1,72 @@ +// Package domain contém as entidades e estados centrais. Não depende de +// nenhum outro pacote do projeto. +package domain + +import "time" + +type UserStatus string + +const ( + UserPending UserStatus = "pending" + UserActive UserStatus = "active" + UserDisabled UserStatus = "disabled" +) + +// PauseReason explica por que os lembretes de um usuário estão desligados (/status). +type PauseReason string + +const ( + PauseManual PauseReason = "manual" + PauseAPIFailures PauseReason = "api_failures" + PauseAdmin PauseReason = "admin" + PauseUserDisabled PauseReason = "user_disabled" +) + +const DefaultTimezone = "America/Sao_Paulo" + +type User struct { + ID int64 + TelegramChatID int64 + DisplayName string + Status UserStatus + IsBotAdmin bool + NotificationsEnabled bool + PausedReason *PauseReason + Timezone string + CreatedAt time.Time + UpdatedAt time.Time +} + +func (u User) IsActive() bool { return u.Status == UserActive } + +// IsActiveBotAdmin indica quem pode usar os comandos administrativos do Telegram. +func (u User) IsActiveBotAdmin() bool { return u.IsActive() && u.IsBotAdmin } + +// Location devolve o fuso do usuário; um valor inválido cai no fuso padrão. +func (u User) Location() *time.Location { return LoadLocation(u.Timezone) } + +var fallbackLocation = func() *time.Location { + loc, err := time.LoadLocation(DefaultTimezone) + if err != nil { + return time.UTC + } + return loc +}() + +func LoadLocation(name string) *time.Location { + if name == "" { + return fallbackLocation + } + loc, err := time.LoadLocation(name) + if err != nil { + return fallbackLocation + } + return loc +} + +// LocalDay devolve a data (AAAA-MM-DD) de t no fuso informado. +func LocalDay(t time.Time, loc *time.Location) string { + return t.In(loc).Format(DateLayout) +} + +const DateLayout = "2006-01-02" diff --git a/backend/internal/domain/web.go b/backend/internal/domain/web.go new file mode 100644 index 0000000..c40bd1f --- /dev/null +++ b/backend/internal/domain/web.go @@ -0,0 +1,58 @@ +package domain + +import "time" + +// Role é o papel de uma conta do painel (ARQUITETURA.md 9.6). +type Role string + +const ( + RoleViewer Role = "viewer" + RoleAdmin Role = "admin" + RoleOwner Role = "owner" +) + +func (r Role) rank() int { + switch r { + case RoleOwner: + return 3 + case RoleAdmin: + return 2 + case RoleViewer: + return 1 + } + return 0 +} + +func (r Role) Valid() bool { return r.rank() > 0 } + +// AtLeast indica se o papel tem pelo menos as permissões de min. +func (r Role) AtLeast(min Role) bool { return r.rank() >= min.rank() && r.Valid() } + +type WebAccount struct { + ID int64 + Username string + PasswordHash string + Role Role + UserID *int64 + FailedLoginCount int + LockedUntil *time.Time + DisabledAt *time.Time + LastLoginAt *time.Time + CreatedAt time.Time +} + +func (a WebAccount) IsDisabled() bool { return a.DisabledAt != nil } + +func (a WebAccount) IsLocked(now time.Time) bool { + return a.LockedUntil != nil && now.Before(*a.LockedUntil) +} + +type WebSession struct { + ID int64 + AccountID int64 + CSRFTokenHash []byte + ExpiresAt time.Time + LastSeenAt time.Time + RevokedAt *time.Time + Account WebAccount +} diff --git a/backend/internal/httpapi/dto.go b/backend/internal/httpapi/dto.go new file mode 100644 index 0000000..75021e1 --- /dev/null +++ b/backend/internal/httpapi/dto.go @@ -0,0 +1,274 @@ +package httpapi + +import ( + "time" + + "botpontos/internal/adapters/postgres" + "botpontos/internal/app" + "botpontos/internal/domain" +) + +// Formatos JSON da API. Os tipos TypeScript equivalentes ficam em web/lib/types.ts. + +type userJSON struct { + ID int64 `json:"id"` + TelegramChatID int64 `json:"telegram_chat_id"` + DisplayName string `json:"display_name"` + Status string `json:"status"` + IsBotAdmin bool `json:"is_bot_admin"` + NotificationsEnabled bool `json:"notifications_enabled"` + PausedReason *string `json:"paused_reason"` + Timezone string `json:"timezone"` + CreatedAt time.Time `json:"created_at"` +} + +func toUser(u domain.User) userJSON { + var reason *string + if u.PausedReason != nil { + v := string(*u.PausedReason) + reason = &v + } + return userJSON{ + ID: u.ID, TelegramChatID: u.TelegramChatID, DisplayName: u.DisplayName, Status: string(u.Status), + IsBotAdmin: u.IsBotAdmin, NotificationsEnabled: u.NotificationsEnabled, PausedReason: reason, + Timezone: u.Timezone, CreatedAt: u.CreatedAt, + } +} + +type userSummaryJSON struct { + userJSON + HasCredential bool `json:"has_credential"` + CredentialValidated bool `json:"credential_validated"` + ConsecutiveFailures int `json:"consecutive_failures"` + CustomWindow bool `json:"custom_window"` +} + +func toUserSummary(s postgres.UserSummary) userSummaryJSON { + return userSummaryJSON{ + userJSON: toUser(s.User), HasCredential: s.HasCredential, CredentialValidated: s.CredentialValidated, + ConsecutiveFailures: s.ConsecutiveFailures, CustomWindow: s.CustomWindow, + } +} + +type windowJSON struct { + QueryWindowStart string `json:"query_window_start"` + QueryWindowEnd string `json:"query_window_end"` + DayCheckDeadline string `json:"day_check_deadline"` + NoPointsRecheckUntil string `json:"no_points_recheck_until"` +} + +func toWindow(w domain.QueryWindow) windowJSON { + return windowJSON{ + QueryWindowStart: w.Start.String(), QueryWindowEnd: w.End.String(), + DayCheckDeadline: w.DayCheckDeadline.String(), NoPointsRecheckUntil: w.NoPointsRecheckUntil.String(), + } +} + +// parse converte para o domínio, com erros por campo. +func (w windowJSON) parse(prefix string) (domain.QueryWindow, error) { + var out domain.QueryWindow + var errs domain.ValidationErrors + fields := []struct { + name string + value string + target *domain.ClockTime + }{ + {"query_window_start", w.QueryWindowStart, &out.Start}, + {"query_window_end", w.QueryWindowEnd, &out.End}, + {"day_check_deadline", w.DayCheckDeadline, &out.DayCheckDeadline}, + {"no_points_recheck_until", w.NoPointsRecheckUntil, &out.NoPointsRecheckUntil}, + } + for _, f := range fields { + c, err := domain.ParseClock(f.value) + if err != nil { + errs.Add(prefix+f.name, "horário inválido (use HH:MM)") + continue + } + *f.target = c + } + return out, errs.OrNil() +} + +type scheduleJSON struct { + LunchMinutes int `json:"lunch_minutes"` + DailyWorkMinutes int `json:"daily_work_minutes"` + Entrada2LeadMinutes int `json:"entrada2_lead_minutes"` + Saida2LeadMinutes int `json:"saida2_lead_minutes"` + Window *windowJSON `json:"window"` +} + +func toSchedule(s domain.WorkSchedule) scheduleJSON { + out := scheduleJSON{ + LunchMinutes: s.LunchMinutes, DailyWorkMinutes: s.DailyWorkMinutes, + Entrada2LeadMinutes: s.Entrada2LeadMinutes, Saida2LeadMinutes: s.Saida2LeadMinutes, + } + if s.Window != nil { + w := toWindow(*s.Window) + out.Window = &w + } + return out +} + +type credentialJSON struct { + APIEmail string `json:"api_email"` + Validated bool `json:"validated"` + LastValidatedAt *time.Time `json:"last_validated_at"` + LastError *string `json:"last_error"` + ConsecutiveFailures int `json:"consecutive_failures"` + LastFailureAt *time.Time `json:"last_failure_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type dayJSON struct { + Day string `json:"day"` + Status string `json:"status"` + ZeroPointChecks int `json:"zero_point_checks"` + LastPointCount int `json:"last_point_count"` + NextCheckAt *time.Time `json:"next_check_at"` + ResumedBy *string `json:"resumed_by"` +} + +func toDay(d domain.UserDay) dayJSON { + var resumed *string + if d.ResumedBy != nil { + v := string(*d.ResumedBy) + resumed = &v + } + return dayJSON{ + Day: d.Day, Status: string(d.Status), ZeroPointChecks: d.ZeroPointChecks, + LastPointCount: d.LastPointCount, NextCheckAt: d.NextCheckAt, ResumedBy: resumed, + } +} + +type userDetailJSON struct { + User userJSON `json:"user"` + Schedule scheduleJSON `json:"schedule"` + Credential *credentialJSON `json:"credential"` + Today dayJSON `json:"today"` + EffectiveWindow windowJSON `json:"effective_window"` + CustomWindow bool `json:"custom_window"` +} + +func toUserDetail(d app.UserDetail) userDetailJSON { + out := userDetailJSON{ + User: toUser(d.User), Schedule: toSchedule(d.Schedule), Today: toDay(d.Today), + EffectiveWindow: toWindow(d.Window), CustomWindow: d.CustomWindow, + } + if c := d.Credential; c != nil { + out.Credential = &credentialJSON{ + APIEmail: c.APIEmail, Validated: c.IsValidated(), LastValidatedAt: c.LastValidatedAt, + LastError: c.LastError, ConsecutiveFailures: c.ConsecutiveFailures, LastFailureAt: c.LastFailureAt, + UpdatedAt: c.UpdatedAt, + } + } + return out +} + +type todayJSON struct { + Day string `json:"day"` + Timezone string `json:"timezone"` + Points []time.Time `json:"points"` + Invalid bool `json:"invalid"` + Overtime bool `json:"overtime"` + Entrada2At *time.Time `json:"entrada2_at"` + Saida2At *time.Time `json:"saida2_at"` + Saida2Error string `json:"saida2_error,omitempty"` + DayState dayJSON `json:"day_state"` + Window windowJSON `json:"window"` + CustomWindow bool `json:"custom_window"` +} + +func toToday(v app.TodayView) todayJSON { + points := v.Points + if points == nil { + points = []time.Time{} + } + return todayJSON{ + Day: v.Day, Timezone: v.User.Timezone, Points: points, Invalid: v.Invalid, Overtime: v.Overtime, + Entrada2At: v.Entrada2At, Saida2At: v.Saida2At, Saida2Error: v.Saida2Error, + DayState: toDay(v.DayState), Window: toWindow(v.Window), CustomWindow: v.CustomWindow, + } +} + +type notificationJSON struct { + ID int64 `json:"id"` + UserID int64 `json:"user_id"` + UserName string `json:"user_name,omitempty"` + ReferenceDate string `json:"reference_date"` + Type string `json:"type"` + ScheduledAt time.Time `json:"scheduled_at"` + PredictedAt time.Time `json:"predicted_at"` + Status string `json:"status"` + Attempts int `json:"attempts"` + SentAt *time.Time `json:"sent_at"` + ErrorMessage *string `json:"error_message"` +} + +func toNotification(n domain.Notification, userName string) notificationJSON { + return notificationJSON{ + ID: n.ID, UserID: n.UserID, UserName: userName, ReferenceDate: n.ReferenceDate, Type: string(n.Type), + ScheduledAt: n.ScheduledAt, PredictedAt: n.PredictedAt, Status: string(n.Status), Attempts: n.Attempts, + SentAt: n.SentAt, ErrorMessage: n.ErrorMessage, + } +} + +type auditJSON struct { + ID int64 `json:"id"` + OccurredAt time.Time `json:"occurred_at"` + Source string `json:"source"` + ActorUserID *int64 `json:"actor_user_id"` + ActorUserName *string `json:"actor_user_name"` + ActorAccount *int64 `json:"actor_account"` + ActorUsername *string `json:"actor_username"` + TargetUserID *int64 `json:"target_user_id"` + TargetUserName *string `json:"target_user_name"` + EventType string `json:"event_type"` + Success bool `json:"success"` + Details map[string]any `json:"details"` +} + +func toAudit(l domain.AuditLog) auditJSON { + return auditJSON{ + ID: l.ID, OccurredAt: l.OccurredAt, Source: string(l.Source), ActorUserID: l.ActorUserID, + ActorUserName: l.ActorUserName, ActorAccount: l.ActorAccount, ActorUsername: l.ActorUsername, + TargetUserID: l.TargetUserID, TargetUserName: l.TargetUserName, EventType: l.EventType, + Success: l.Success, Details: l.Details, + } +} + +type holidayJSON struct { + Day string `json:"day"` + Name string `json:"name"` + Source string `json:"source"` +} + +type accountJSON struct { + ID int64 `json:"id"` + Username string `json:"username"` + Role string `json:"role"` + UserID *int64 `json:"user_id"` + Disabled bool `json:"disabled"` + LockedUntil *time.Time `json:"locked_until"` + LastLoginAt *time.Time `json:"last_login_at"` + CreatedAt time.Time `json:"created_at"` +} + +func toAccount(a domain.WebAccount) accountJSON { + return accountJSON{ + ID: a.ID, Username: a.Username, Role: string(a.Role), UserID: a.UserID, Disabled: a.IsDisabled(), + LockedUntil: a.LockedUntil, LastLoginAt: a.LastLoginAt, CreatedAt: a.CreatedAt, + } +} + +type dashboardJSON struct { + UsersByStatus map[string]int `json:"users_by_status"` + NotificationsEnabled int `json:"notifications_enabled"` + PausedByAPIFailures int `json:"paused_by_api_failures"` + TodayByStatus map[string]int `json:"today_by_status"` + FailedLast7Days int `json:"failed_last_7_days"` + ExpiredLast7Days int `json:"expired_last_7_days"` + CustomWindows int `json:"custom_windows"` + OutageSince *string `json:"outage_since"` + HolidayFallbackDay *string `json:"holiday_fallback_day"` + Today string `json:"today"` +} diff --git a/backend/internal/httpapi/handler.go b/backend/internal/httpapi/handler.go new file mode 100644 index 0000000..fbc8b42 --- /dev/null +++ b/backend/internal/httpapi/handler.go @@ -0,0 +1,111 @@ +// Package httpapi é o adapter de entrada HTTP: a API REST consumida pelo Next.js +// (ARQUITETURA.md, seções 5.4 e 7). +package httpapi + +import ( + "context" + "log/slog" + "net/http" + "time" + + "botpontos/internal/app" + "botpontos/internal/config" + "botpontos/internal/domain" + "botpontos/internal/httpx" +) + +// Pinger é satisfeito por *pgxpool.Pool; a interface permite testar sem banco. +type Pinger interface { + Ping(context.Context) error +} + +type API struct { + db Pinger + svc *app.Service + cfg config.Config + log *slog.Logger +} + +// NewHandler monta as rotas. svc pode ser nil nos testes das rotas de saúde. +func NewHandler(db Pinger, svc *app.Service, cfg config.Config, logger *slog.Logger) http.Handler { + a := &API{db: db, svc: svc, cfg: cfg, log: logger} + mux := http.NewServeMux() + + mux.HandleFunc("GET /api/v1/health/live", a.live) + mux.HandleFunc("GET /api/v1/health/ready", a.ready) + + mux.HandleFunc("POST /api/v1/auth/login", a.login) + mux.Handle("POST /api/v1/auth/logout", a.auth(domain.RoleViewer, a.logout)) + mux.Handle("GET /api/v1/auth/me", a.auth(domain.RoleViewer, a.me)) + + mux.Handle("GET /api/v1/dashboard", a.auth(domain.RoleViewer, a.dashboard)) + + mux.Handle("GET /api/v1/users", a.auth(domain.RoleViewer, a.listUsers)) + mux.Handle("GET /api/v1/users/{id}", a.authUser(domain.RoleViewer, false, a.getUser)) + mux.Handle("POST /api/v1/users/{id}/activate", a.auth(domain.RoleAdmin, a.activateUser)) + mux.Handle("POST /api/v1/users/{id}/disable", a.auth(domain.RoleAdmin, a.disableUser)) + mux.Handle("PUT /api/v1/users/{id}/schedule", a.authUser(domain.RoleAdmin, true, a.updateSchedule)) + mux.Handle("PUT /api/v1/users/{id}/credentials", a.authUser(domain.RoleAdmin, true, a.updateCredentials)) + mux.Handle("POST /api/v1/users/{id}/credentials/test", a.authUser(domain.RoleAdmin, true, a.testCredentials)) + mux.Handle("POST /api/v1/users/{id}/notifications/enable", a.authUser(domain.RoleAdmin, true, a.enableNotifications)) + mux.Handle("POST /api/v1/users/{id}/notifications/disable", a.authUser(domain.RoleAdmin, true, a.disableNotifications)) + mux.Handle("PUT /api/v1/users/{id}/bot-admin", a.auth(domain.RoleAdmin, a.setBotAdmin)) + mux.Handle("GET /api/v1/users/{id}/today", a.authUser(domain.RoleViewer, false, a.today)) + mux.Handle("POST /api/v1/users/{id}/today/resume", a.auth(domain.RoleAdmin, a.resumeDay)) + + mux.Handle("GET /api/v1/notifications", a.auth(domain.RoleViewer, a.listNotifications)) + mux.Handle("POST /api/v1/notifications/{id}/cancel", a.auth(domain.RoleAdmin, a.cancelNotification)) + mux.Handle("POST /api/v1/notifications/{id}/resend", a.auth(domain.RoleAdmin, a.resendNotification)) + + mux.Handle("GET /api/v1/audit", a.authUnrestricted(domain.RoleViewer, a.listAudit)) + + mux.Handle("GET /api/v1/holidays", a.auth(domain.RoleViewer, a.listHolidays)) + mux.Handle("POST /api/v1/holidays", a.auth(domain.RoleAdmin, a.addHoliday)) + mux.Handle("POST /api/v1/holidays/sync", a.auth(domain.RoleAdmin, a.syncHolidays)) + mux.Handle("DELETE /api/v1/holidays/{day}", a.auth(domain.RoleAdmin, a.deleteHoliday)) + + mux.Handle("GET /api/v1/settings", a.authUnrestricted(domain.RoleViewer, a.getSettings)) + mux.Handle("PUT /api/v1/settings", a.auth(domain.RoleAdmin, a.updateSettings)) + mux.Handle("POST /api/v1/settings/preview", a.auth(domain.RoleAdmin, a.previewSettings)) + + mux.Handle("GET /api/v1/accounts", a.auth(domain.RoleOwner, a.listAccounts)) + mux.Handle("POST /api/v1/accounts", a.auth(domain.RoleOwner, a.createAccount)) + mux.Handle("PATCH /api/v1/accounts/{id}", a.auth(domain.RoleOwner, a.updateAccount)) + mux.Handle("POST /api/v1/accounts/{id}/reset-password", a.auth(domain.RoleOwner, a.resetPassword)) + + mux.HandleFunc("/", notFound) + + return httpx.Recover(logger, httpx.RequestLog(logger, securityHeaders(mux))) +} + +// live indica só que o processo está de pé (usado pelo healthcheck do Docker). +func (a *API) live(w http.ResponseWriter, _ *http.Request) { + httpx.WriteJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} + +// ready indica que a API consegue atender, ou seja, que o Postgres responde. +func (a *API) ready(w http.ResponseWriter, req *http.Request) { + ctx, cancel := context.WithTimeout(req.Context(), 2*time.Second) + defer cancel() + + if err := a.db.Ping(ctx); err != nil { + a.log.Warn("banco de dados indisponível", "error", err) + httpx.WriteProblem(w, http.StatusServiceUnavailable, "Banco de dados indisponível", "DB_UNAVAILABLE") + return + } + httpx.WriteJSON(w, http.StatusOK, map[string]string{"status": "ok", "database": "ok"}) +} + +func notFound(w http.ResponseWriter, _ *http.Request) { + httpx.WriteProblem(w, http.StatusNotFound, "Recurso não encontrado", "NOT_FOUND") +} + +func securityHeaders(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + h := w.Header() + h.Set("X-Content-Type-Options", "nosniff") + h.Set("Cache-Control", "no-store") + h.Set("Referrer-Policy", "same-origin") + next.ServeHTTP(w, r) + }) +} diff --git a/backend/internal/httpapi/handler_test.go b/backend/internal/httpapi/handler_test.go new file mode 100644 index 0000000..5dcc782 --- /dev/null +++ b/backend/internal/httpapi/handler_test.go @@ -0,0 +1,152 @@ +package httpapi + +import ( + "context" + "encoding/json" + "errors" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "net/netip" + "testing" + + "botpontos/internal/config" + "botpontos/internal/domain" +) + +type fakePinger struct{ err error } + +func (f fakePinger) Ping(context.Context) error { return f.err } + +var discard = slog.New(slog.NewTextHandler(io.Discard, nil)) + +func serve(t *testing.T, db Pinger, method, path string) *httptest.ResponseRecorder { + t.Helper() + rec := httptest.NewRecorder() + NewHandler(db, nil, config.Config{}, discard).ServeHTTP(rec, httptest.NewRequest(method, path, nil)) + return rec +} + +func TestLive(t *testing.T) { + rec := serve(t, fakePinger{err: errors.New("banco fora")}, http.MethodGet, "/api/v1/health/live") + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, esperado 200 (live não depende do banco)", rec.Code) + } + if rec.Header().Get("X-Request-ID") == "" { + t.Error("X-Request-ID ausente") + } +} + +func TestReadyWithDatabase(t *testing.T) { + rec := serve(t, fakePinger{}, http.MethodGet, "/api/v1/health/ready") + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, esperado 200", rec.Code) + } +} + +func TestReadyWithoutDatabase(t *testing.T) { + rec := serve(t, fakePinger{err: errors.New("conexão recusada")}, http.MethodGet, "/api/v1/health/ready") + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("status = %d, esperado 503", rec.Code) + } + assertProblem(t, rec, "DB_UNAVAILABLE") +} + +func TestUnknownRoute(t *testing.T) { + rec := serve(t, fakePinger{}, http.MethodGet, "/api/v1/nao-existe") + if rec.Code != http.StatusNotFound { + t.Fatalf("status = %d, esperado 404", rec.Code) + } + assertProblem(t, rec, "NOT_FOUND") +} + +func TestProtectedRouteWithoutSession(t *testing.T) { + rec := serve(t, fakePinger{}, http.MethodGet, "/api/v1/users") + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, esperado 401", rec.Code) + } + assertProblem(t, rec, "UNAUTHENTICATED") +} + +func TestWriteErrorValidation(t *testing.T) { + a := &API{log: discard} + rec := httptest.NewRecorder() + a.writeError(rec, domain.ValidationErrors{{Field: "lunch_minutes", Message: "inválido"}}) + if rec.Code != http.StatusUnprocessableEntity { + t.Fatalf("status = %d, esperado 422", rec.Code) + } + var body struct { + Errors []domain.ValidationError `json:"errors"` + } + _ = json.NewDecoder(rec.Body).Decode(&body) + if len(body.Errors) != 1 || body.Errors[0].Field != "lunch_minutes" { + t.Errorf("erros de campo inesperados: %+v", body.Errors) + } +} + +func TestSameOrigin(t *testing.T) { + a := &API{cfg: config.Config{Web: config.Web{PublicURL: "https://botpontos.lan"}}} + cases := []struct { + origin, referer string + want bool + }{ + {"https://botpontos.lan", "", true}, + {"https://evil.example", "", false}, + {"", "https://botpontos.lan/usuarios", true}, + {"", "https://botpontos.lan.evil.example/", false}, + {"", "", false}, + } + for _, c := range cases { + r := httptest.NewRequest(http.MethodPost, "/api/v1/users/1/activate", nil) + if c.origin != "" { + r.Header.Set("Origin", c.origin) + } + if c.referer != "" { + r.Header.Set("Referer", c.referer) + } + if got := a.sameOrigin(r); got != c.want { + t.Errorf("origin=%q referer=%q: %v, esperado %v", c.origin, c.referer, got, c.want) + } + } +} + +func TestClientIP(t *testing.T) { + a := &API{cfg: config.Config{Web: config.Web{TrustedProxies: []netip.Prefix{ + netip.MustParsePrefix("172.16.0.0/12"), + netip.MustParsePrefix("192.168.18.10/32"), // NPM + }}}} + + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.RemoteAddr = "172.18.0.5:41000" // container web + // Cliente forjou um IP à esquerda; o NPM acrescentou o IP real; o Next acrescentou o NPM. + r.Header.Set("X-Forwarded-For", "1.2.3.4, 192.168.18.50, 192.168.18.10") + if got := a.clientIP(r); got != "192.168.18.50" { + t.Errorf("clientIP = %s, esperado 192.168.18.50", got) + } + + direct := httptest.NewRequest(http.MethodGet, "/", nil) + direct.RemoteAddr = "10.9.9.9:1234" + direct.Header.Set("X-Forwarded-For", "1.2.3.4") + if got := a.clientIP(direct); got != "10.9.9.9" { + t.Errorf("origem não confiável deveria ignorar X-Forwarded-For, veio %s", got) + } +} + +func assertProblem(t *testing.T, rec *httptest.ResponseRecorder, code string) { + t.Helper() + if ct := rec.Header().Get("Content-Type"); ct != "application/problem+json; charset=utf-8" { + t.Errorf("Content-Type = %q", ct) + } + var body struct { + Status int `json:"status"` + Code string `json:"code"` + } + if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { + t.Fatalf("corpo inválido: %v", err) + } + if body.Code != code || body.Status != rec.Code { + t.Errorf("problem = %+v, esperado code=%s status=%d", body, code, rec.Code) + } +} diff --git a/backend/internal/httpapi/handlers_admin.go b/backend/internal/httpapi/handlers_admin.go new file mode 100644 index 0000000..ef5eca9 --- /dev/null +++ b/backend/internal/httpapi/handlers_admin.go @@ -0,0 +1,359 @@ +package httpapi + +import ( + "encoding/json" + "net/http" + "strconv" + + "botpontos/internal/adapters/postgres" + "botpontos/internal/app" + "botpontos/internal/domain" + "botpontos/internal/httpx" +) + +// --- notificações --- + +func (a *API) listNotifications(w http.ResponseWriter, r *http.Request) { + userID, err := queryInt64(r, "user_id") + if err != nil { + a.writeError(w, err) + return + } + if account := sessionFrom(r).Account; isRestricted(account) { + userID = account.UserID // ignora qualquer filtro enviado: só os próprios lembretes + } + status, typ := queryString(r, "status"), queryString(r, "type") + if status != nil && !domain.ValidNotificationStatus(*status) { + a.writeError(w, badRequest("status", "status inválido")) + return + } + if typ != nil && !domain.ValidNotificationType(*typ) { + a.writeError(w, badRequest("type", "use entrada2 ou saida2")) + return + } + day := queryString(r, "day") + if day != nil { + if _, err := queryDate(r, "day", a.svc.Location(), false); err != nil { + a.writeError(w, err) + return + } + } + items, err := a.svc.Store().ListNotifications(r.Context(), postgres.NotificationFilter{ + Day: day, Status: status, Type: typ, UserID: userID, Limit: 200, + }) + if err != nil { + a.writeError(w, err) + return + } + out := make([]notificationJSON, 0, len(items)) + for _, v := range items { + out = append(out, toNotification(v.Notification, v.UserName)) + } + httpx.WriteJSON(w, http.StatusOK, map[string]any{"notifications": out}) +} + +func (a *API) cancelNotification(w http.ResponseWriter, r *http.Request) { + id, ok := pathID(w, r) + if !ok { + return + } + n, err := a.svc.CancelNotification(r.Context(), actorOf(r), id) + if err != nil { + a.writeError(w, err) + return + } + httpx.WriteJSON(w, http.StatusOK, toNotification(n, "")) +} + +func (a *API) resendNotification(w http.ResponseWriter, r *http.Request) { + id, ok := pathID(w, r) + if !ok { + return + } + n, err := a.svc.ResendNotification(r.Context(), actorOf(r), id) + if err != nil { + a.writeError(w, err) + return + } + httpx.WriteJSON(w, http.StatusOK, toNotification(n, "")) +} + +// --- auditoria --- + +func (a *API) listAudit(w http.ResponseWriter, r *http.Request) { + loc := a.svc.Location() + from, err := queryDate(r, "from", loc, false) + if err != nil { + a.writeError(w, err) + return + } + to, err := queryDate(r, "to", loc, true) + if err != nil { + a.writeError(w, err) + return + } + success, err := queryBool(r, "success") + if err != nil { + a.writeError(w, err) + return + } + target, err := queryInt64(r, "target_user_id") + if err != nil { + a.writeError(w, err) + return + } + account, err := queryInt64(r, "actor_account") + if err != nil { + a.writeError(w, err) + return + } + before, err := queryInt64(r, "before") + if err != nil { + a.writeError(w, err) + return + } + const limit = 50 + logs, err := a.svc.Store().ListAudit(r.Context(), postgres.AuditFilter{ + From: from, To: to, Source: queryString(r, "source"), EventType: queryString(r, "event_type"), + Success: success, TargetUserID: target, ActorAccount: account, Before: before, Limit: limit, + }) + if err != nil { + a.writeError(w, err) + return + } + out := make([]auditJSON, 0, len(logs)) + for _, l := range logs { + out = append(out, toAudit(l)) + } + var next *int64 + if len(logs) == limit { + next = &logs[len(logs)-1].ID + } + httpx.WriteJSON(w, http.StatusOK, map[string]any{"logs": out, "next_before": next}) +} + +// --- feriados --- + +func (a *API) yearParam(r *http.Request) (int, error) { + v := queryString(r, "year") + if v == nil { + return a.svc.Now().In(a.svc.Location()).Year(), nil + } + year, err := strconv.Atoi(*v) + if err != nil || year < 2000 || year > 2100 { + return 0, badRequest("year", "ano inválido") + } + return year, nil +} + +func (a *API) listHolidays(w http.ResponseWriter, r *http.Request) { + year, err := a.yearParam(r) + if err != nil { + a.writeError(w, err) + return + } + holidays, err := a.svc.ListHolidays(r.Context(), year) + if err != nil { + a.writeError(w, err) + return + } + out := make([]holidayJSON, 0, len(holidays)) + for _, h := range holidays { + out = append(out, holidayJSON{Day: h.Day, Name: h.Name, Source: h.Source}) + } + httpx.WriteJSON(w, http.StatusOK, map[string]any{"year": year, "holidays": out}) +} + +func (a *API) addHoliday(w http.ResponseWriter, r *http.Request) { + var body holidayJSON + if !decode(w, r, &body) { + return + } + if err := a.svc.AddHoliday(r.Context(), actorOf(r), body.Day, body.Name); err != nil { + a.writeError(w, err) + return + } + w.WriteHeader(http.StatusCreated) +} + +func (a *API) deleteHoliday(w http.ResponseWriter, r *http.Request) { + if err := a.svc.DeleteHoliday(r.Context(), actorOf(r), r.PathValue("day")); err != nil { + a.writeError(w, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (a *API) syncHolidays(w http.ResponseWriter, r *http.Request) { + var body struct { + Year int `json:"year"` + } + if !decode(w, r, &body) { + return + } + if body.Year < 2000 || body.Year > 2100 { + a.writeError(w, badRequest("year", "ano inválido")) + return + } + inserted, err := a.svc.SyncHolidays(r.Context(), actorOf(r), body.Year) + if err != nil { + a.writeError(w, err) + return + } + httpx.WriteJSON(w, http.StatusOK, map[string]any{"inserted": inserted}) +} + +// --- configurações globais --- + +func (a *API) getSettings(w http.ResponseWriter, r *http.Request) { + window, err := a.svc.GlobalWindow(r.Context()) + if err != nil { + a.writeError(w, err) + return + } + jobs := a.svc.Config().Jobs + httpx.WriteJSON(w, http.StatusOK, map[string]any{ + "window": toWindow(window), + "reconciliation_minutes": int(jobs.ReconciliationInterval.Minutes()), + "dispatch_minutes": int(jobs.DispatchInterval.Minutes()), + "late_tolerance_minutes": int(jobs.LateTolerance.Minutes()), + "api_failure_threshold": jobs.APIFailureThreshold, + "slow_check_minutes": int(jobs.SlowCheckInterval.Minutes()), + }) +} + +func (a *API) updateSettings(w http.ResponseWriter, r *http.Request) { + var body windowJSON + if !decode(w, r, &body) { + return + } + window, err := body.parse("") + if err == nil { + err = a.svc.UpdateGlobalWindow(r.Context(), actorOf(r), window) + } + if err != nil { + a.writeError(w, err) + return + } + a.getSettings(w, r) +} + +func (a *API) previewSettings(w http.ResponseWriter, r *http.Request) { + var body windowJSON + if !decode(w, r, &body) { + return + } + window, err := body.parse("") + if err != nil { + a.writeError(w, err) + return + } + preview, err := a.svc.PreviewWindow(r.Context(), window) + if err != nil { + a.writeError(w, err) + return + } + if preview.Errors == nil { + preview.Errors = domain.ValidationErrors{} + } + httpx.WriteJSON(w, http.StatusOK, preview) +} + +// --- contas do painel (owner) --- + +func (a *API) listAccounts(w http.ResponseWriter, r *http.Request) { + accounts, err := a.svc.Store().ListWebAccounts(r.Context()) + if err != nil { + a.writeError(w, err) + return + } + out := make([]accountJSON, 0, len(accounts)) + for _, acc := range accounts { + out = append(out, toAccount(acc)) + } + httpx.WriteJSON(w, http.StatusOK, map[string]any{"accounts": out}) +} + +func (a *API) createAccount(w http.ResponseWriter, r *http.Request) { + var body struct { + Username string `json:"username"` + Password string `json:"password"` + Role string `json:"role"` + UserID *int64 `json:"user_id"` + } + if !decode(w, r, &body) { + return + } + account, err := a.svc.CreateAccount(r.Context(), actorOf(r), app.AccountInput{ + Username: body.Username, Password: body.Password, Role: domain.Role(body.Role), UserID: body.UserID, + }) + if err != nil { + a.writeError(w, err) + return + } + httpx.WriteJSON(w, http.StatusCreated, toAccount(account)) +} + +func (a *API) updateAccount(w http.ResponseWriter, r *http.Request) { + id, ok := pathID(w, r) + if !ok { + return + } + var body struct { + Role *string `json:"role"` + Disabled *bool `json:"disabled"` + UserID json.RawMessage `json:"user_id"` // ausente: mantém; null: remove o vínculo + } + if !decode(w, r, &body) { + return + } + var link *app.AccountUserLink + if body.UserID != nil { + link = &app.AccountUserLink{} + if string(body.UserID) != "null" { + var uid int64 + if err := json.Unmarshal(body.UserID, &uid); err != nil || uid <= 0 { + a.writeError(w, badRequest("user_id", "informe o id do usuário ou null")) + return + } + link.UserID = &uid + } + } + var role *domain.Role + if body.Role != nil { + v := domain.Role(*body.Role) + role = &v + } + if id == sessionFrom(r).AccountID && body.Disabled != nil && *body.Disabled { + a.writeError(w, domain.InvalidState("você não pode desativar a própria conta")) + return + } + if err := a.svc.UpdateAccount(r.Context(), actorOf(r), id, role, body.Disabled, link); err != nil { + a.writeError(w, err) + return + } + account, err := a.svc.Store().GetWebAccount(r.Context(), id) + if err != nil { + a.writeError(w, err) + return + } + httpx.WriteJSON(w, http.StatusOK, toAccount(account)) +} + +func (a *API) resetPassword(w http.ResponseWriter, r *http.Request) { + id, ok := pathID(w, r) + if !ok { + return + } + var body struct { + Password string `json:"password"` + } + if !decode(w, r, &body) { + return + } + if err := a.svc.ResetPassword(r.Context(), actorOf(r), id, body.Password); err != nil { + a.writeError(w, err) + return + } + w.WriteHeader(http.StatusNoContent) +} diff --git a/backend/internal/httpapi/handlers_users.go b/backend/internal/httpapi/handlers_users.go new file mode 100644 index 0000000..e5f43d9 --- /dev/null +++ b/backend/internal/httpapi/handlers_users.go @@ -0,0 +1,289 @@ +package httpapi + +import ( + "net/http" + + "botpontos/internal/adapters/postgres" + "botpontos/internal/app" + "botpontos/internal/domain" + "botpontos/internal/httpx" +) + +func actorOf(r *http.Request) app.Actor { + return app.WebActor(sessionFrom(r).AccountID) +} + +// --- autenticação --- + +func (a *API) login(w http.ResponseWriter, r *http.Request) { + if !a.sameOrigin(r) { + httpx.WriteProblem(w, http.StatusForbidden, "Origem da requisição não permitida", "BAD_ORIGIN") + return + } + var body struct { + Username string `json:"username"` + Password string `json:"password"` + } + if !decode(w, r, &body) { + return + } + result, err := a.svc.Login(r.Context(), body.Username, body.Password, a.clientIP(r), r.UserAgent()) + if err != nil { + a.writeError(w, err) + return + } + setSessionCookies(w, result.Token, result.CSRFToken, result.ExpiresAt, a.cfg.Web.CookieSecure) + httpx.WriteJSON(w, http.StatusOK, map[string]any{"account": toAccount(result.Account), "expires_at": result.ExpiresAt}) +} + +func (a *API) logout(w http.ResponseWriter, r *http.Request) { + if err := a.svc.Logout(r.Context(), sessionFrom(r)); err != nil { + a.writeError(w, err) + return + } + clearCookies(w, a.cfg.Web.CookieSecure) + w.WriteHeader(http.StatusNoContent) +} + +func (a *API) me(w http.ResponseWriter, r *http.Request) { + s := sessionFrom(r) + httpx.WriteJSON(w, http.StatusOK, map[string]any{"account": toAccount(s.Account), "expires_at": s.ExpiresAt}) +} + +func (a *API) dashboard(w http.ResponseWriter, r *http.Request) { + o, err := a.svc.Overview(r.Context()) + if err != nil { + a.writeError(w, err) + return + } + if account := sessionFrom(r).Account; isRestricted(account) { + // Início da conta restrita: sem contadores globais, só os avisos do + // sistema e os lembretes de hoje do próprio usuário. + today := a.svc.TodayDate() + items, err := a.svc.Store().ListNotifications(r.Context(), postgres.NotificationFilter{Day: &today, UserID: account.UserID, Limit: 200}) + if err != nil { + a.writeError(w, err) + return + } + byStatus := map[string]int{} + for _, v := range items { + byStatus[string(v.Notification.Status)]++ + } + httpx.WriteJSON(w, http.StatusOK, dashboardJSON{ + UsersByStatus: map[string]int{}, TodayByStatus: byStatus, + OutageSince: o.OutageSince, HolidayFallbackDay: o.HolidayFallback, Today: today, + }) + return + } + httpx.WriteJSON(w, http.StatusOK, dashboardJSON{ + UsersByStatus: o.Counts.UsersByStatus, NotificationsEnabled: o.Counts.NotificationsEnabled, + PausedByAPIFailures: o.Counts.PausedByAPIFailures, TodayByStatus: o.Counts.TodayByStatus, + FailedLast7Days: o.Counts.FailedLast7Days, ExpiredLast7Days: o.Counts.ExpiredLast7Days, + CustomWindows: o.Counts.CustomWindows, OutageSince: o.OutageSince, HolidayFallbackDay: o.HolidayFallback, + Today: a.svc.TodayDate(), + }) +} + +// --- usuários --- + +func (a *API) listUsers(w http.ResponseWriter, r *http.Request) { + custom, err := queryBool(r, "custom_window") + if err != nil { + a.writeError(w, err) + return + } + users, err := a.svc.Store().ListUsers(r.Context(), postgres.UserFilter{ + Status: queryString(r, "status"), Query: queryString(r, "q"), CustomWindow: custom, + }) + if err != nil { + a.writeError(w, err) + return + } + account := sessionFrom(r).Account + out := make([]userSummaryJSON, 0, len(users)) + for _, u := range users { + if isRestricted(account) && u.User.ID != *account.UserID { + continue + } + out = append(out, toUserSummary(u)) + } + httpx.WriteJSON(w, http.StatusOK, map[string]any{"users": out}) +} + +func (a *API) getUser(w http.ResponseWriter, r *http.Request) { + id, ok := pathID(w, r) + if !ok { + return + } + detail, err := a.svc.UserDetail(r.Context(), id) + if err != nil { + a.writeError(w, err) + return + } + httpx.WriteJSON(w, http.StatusOK, toUserDetail(detail)) +} + +// respondUser devolve o detalhe atualizado após uma ação. +func (a *API) respondUser(w http.ResponseWriter, r *http.Request, id int64) { + detail, err := a.svc.UserDetail(r.Context(), id) + if err != nil { + a.writeError(w, err) + return + } + httpx.WriteJSON(w, http.StatusOK, toUserDetail(detail)) +} + +func (a *API) activateUser(w http.ResponseWriter, r *http.Request) { + id, ok := pathID(w, r) + if !ok { + return + } + if _, err := a.svc.ActivateUser(r.Context(), actorOf(r), id); err != nil { + a.writeError(w, err) + return + } + a.respondUser(w, r, id) +} + +func (a *API) disableUser(w http.ResponseWriter, r *http.Request) { + id, ok := pathID(w, r) + if !ok { + return + } + if err := a.svc.DisableUser(r.Context(), actorOf(r), id); err != nil { + a.writeError(w, err) + return + } + a.respondUser(w, r, id) +} + +func (a *API) updateSchedule(w http.ResponseWriter, r *http.Request) { + id, ok := pathID(w, r) + if !ok { + return + } + var body struct { + scheduleJSON + Timezone string `json:"timezone"` + } + if !decode(w, r, &body) { + return + } + in := app.ScheduleInput{ + LunchMinutes: body.LunchMinutes, DailyWorkMinutes: body.DailyWorkMinutes, + Entrada2LeadMinutes: body.Entrada2LeadMinutes, Saida2LeadMinutes: body.Saida2LeadMinutes, + Timezone: body.Timezone, + } + if body.Window != nil { + window, err := body.Window.parse("window.") + if err != nil { + a.writeError(w, err) + return + } + in.Window = &window + } + if _, err := a.svc.UpdateSchedule(r.Context(), actorOf(r), id, in); err != nil { + a.writeError(w, err) + return + } + a.respondUser(w, r, id) +} + +func (a *API) updateCredentials(w http.ResponseWriter, r *http.Request) { + id, ok := pathID(w, r) + if !ok { + return + } + var body struct { + Email string `json:"email"` + Password string `json:"password"` + } + if !decode(w, r, &body) { + return + } + if err := a.svc.SetCredentials(r.Context(), actorOf(r), id, body.Email, body.Password); err != nil { + a.writeError(w, err) + return + } + a.respondUser(w, r, id) +} + +func (a *API) testCredentials(w http.ResponseWriter, r *http.Request) { + id, ok := pathID(w, r) + if !ok { + return + } + view, err := a.svc.TestCredentials(r.Context(), actorOf(r), id) + if err != nil { + a.writeError(w, err) + return + } + httpx.WriteJSON(w, http.StatusOK, toToday(view)) +} + +func (a *API) enableNotifications(w http.ResponseWriter, r *http.Request) { + id, ok := pathID(w, r) + if !ok { + return + } + if _, err := a.svc.EnableNotifications(r.Context(), actorOf(r), id); err != nil { + a.writeError(w, err) + return + } + a.respondUser(w, r, id) +} + +func (a *API) disableNotifications(w http.ResponseWriter, r *http.Request) { + id, ok := pathID(w, r) + if !ok { + return + } + if err := a.svc.DisableNotifications(r.Context(), actorOf(r), id, domain.PauseAdmin); err != nil { + a.writeError(w, err) + return + } + a.respondUser(w, r, id) +} + +func (a *API) setBotAdmin(w http.ResponseWriter, r *http.Request) { + id, ok := pathID(w, r) + if !ok { + return + } + var body struct { + Enabled bool `json:"enabled"` + } + if !decode(w, r, &body) { + return + } + if err := a.svc.SetBotAdmin(r.Context(), actorOf(r), id, body.Enabled); err != nil { + a.writeError(w, err) + return + } + a.respondUser(w, r, id) +} + +func (a *API) today(w http.ResponseWriter, r *http.Request) { + id, ok := pathID(w, r) + if !ok { + return + } + view, err := a.svc.Today(r.Context(), actorOf(r), id, nil) + if err != nil { + a.writeError(w, err) + return + } + httpx.WriteJSON(w, http.StatusOK, toToday(view)) +} + +func (a *API) resumeDay(w http.ResponseWriter, r *http.Request) { + id, ok := pathID(w, r) + if !ok { + return + } + if _, err := a.svc.ResumeDay(r.Context(), actorOf(r), id); err != nil { + a.writeError(w, err) + return + } + a.respondUser(w, r, id) +} diff --git a/backend/internal/httpapi/middleware.go b/backend/internal/httpapi/middleware.go new file mode 100644 index 0000000..07301be --- /dev/null +++ b/backend/internal/httpapi/middleware.go @@ -0,0 +1,179 @@ +package httpapi + +import ( + "context" + "errors" + "net" + "net/http" + "net/netip" + "strings" + "time" + + "botpontos/internal/domain" + "botpontos/internal/httpx" +) + +const ( + sessionCookie = "bp_session" + csrfCookie = "bp_csrf" + csrfHeader = "X-CSRF-Token" +) + +type ctxKey int + +const sessionKey ctxKey = 1 + +func sessionFrom(r *http.Request) domain.WebSession { + s, _ := r.Context().Value(sessionKey).(domain.WebSession) + return s +} + +// auth exige sessão válida e o papel mínimo. Em métodos que alteram dados, +// exige também a origem do painel e o token CSRF (double submit, 5.4). +func (a *API) auth(min domain.Role, next http.HandlerFunc) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + cookie, err := r.Cookie(sessionCookie) + if err != nil { + httpx.WriteProblem(w, http.StatusUnauthorized, "Faça login para continuar", "UNAUTHENTICATED") + return + } + session, err := a.svc.Authenticate(r.Context(), cookie.Value) + if err != nil { + if errors.Is(err, domain.ErrUnauthenticated) { + clearCookies(w, a.cfg.Web.CookieSecure) + httpx.WriteProblem(w, http.StatusUnauthorized, "Sessão expirada; faça login novamente", "UNAUTHENTICATED") + return + } + a.writeError(w, err) + return + } + if !session.Account.Role.AtLeast(min) { + httpx.WriteProblem(w, http.StatusForbidden, "Seu papel não permite esta ação", "FORBIDDEN") + return + } + if unsafeMethod(r.Method) { + if !a.sameOrigin(r) { + httpx.WriteProblem(w, http.StatusForbidden, "Origem da requisição não permitida", "BAD_ORIGIN") + return + } + if !a.svc.CheckCSRF(session, r.Header.Get(csrfHeader)) { + httpx.WriteProblem(w, http.StatusForbidden, "Token CSRF inválido; recarregue a página", "BAD_CSRF") + return + } + } + next(w, r.WithContext(context.WithValue(r.Context(), sessionKey, session))) + }) +} + +// isRestricted indica a conta de leitura ligada a um usuário do Telegram: ela +// só enxerga e altera os dados desse usuário. +func isRestricted(a domain.WebAccount) bool { + return a.Role == domain.RoleViewer && a.UserID != nil +} + +// authUnrestricted é auth, mas recusa a conta restrita (telas globais). +func (a *API) authUnrestricted(min domain.Role, next http.HandlerFunc) http.Handler { + return a.auth(min, func(w http.ResponseWriter, r *http.Request) { + if isRestricted(sessionFrom(r).Account) { + httpx.WriteProblem(w, http.StatusForbidden, "Seu papel não permite esta ação", "FORBIDDEN") + return + } + next(w, r) + }) +} + +// authUser protege rotas /users/{id}. Admin e owner passam sempre. A conta +// restrita só passa no próprio usuário; com self, ela também pode alterá-lo +// (mesmo sem o papel mínimo). Leitura sem vínculo mantém o papel de sempre. +func (a *API) authUser(min domain.Role, self bool, next http.HandlerFunc) http.Handler { + return a.auth(domain.RoleViewer, func(w http.ResponseWriter, r *http.Request) { + account := sessionFrom(r).Account + if !account.Role.AtLeast(domain.RoleAdmin) { + id, ok := pathID(w, r) + if !ok { + return + } + own := account.UserID != nil && *account.UserID == id + if (isRestricted(account) && !own) || (!account.Role.AtLeast(min) && !(self && own)) { + httpx.WriteProblem(w, http.StatusForbidden, "Seu papel não permite esta ação", "FORBIDDEN") + return + } + } + next(w, r) + }) +} + +func unsafeMethod(method string) bool { + return method != http.MethodGet && method != http.MethodHead && method != http.MethodOptions +} + +// sameOrigin confere Origin (ou Referer) contra BP_PUBLIC_URL. Vazio desliga a checagem (desenvolvimento). +func (a *API) sameOrigin(r *http.Request) bool { + public := a.cfg.Web.PublicURL + if public == "" { + return true + } + if origin := r.Header.Get("Origin"); origin != "" { + return origin == public + } + if referer := r.Header.Get("Referer"); referer != "" { + return referer == public || strings.HasPrefix(referer, public+"/") + } + return false +} + +func setSessionCookies(w http.ResponseWriter, token, csrf string, expires time.Time, secure bool) { + http.SetCookie(w, &http.Cookie{ + Name: sessionCookie, Value: token, Path: "/", Expires: expires, + HttpOnly: true, Secure: secure, SameSite: http.SameSiteStrictMode, + }) + // Legível pelo JavaScript do painel, que o repete no header X-CSRF-Token. + http.SetCookie(w, &http.Cookie{ + Name: csrfCookie, Value: csrf, Path: "/", Expires: expires, + HttpOnly: false, Secure: secure, SameSite: http.SameSiteStrictMode, + }) +} + +func clearCookies(w http.ResponseWriter, secure bool) { + for _, name := range []string{sessionCookie, csrfCookie} { + http.SetCookie(w, &http.Cookie{ + Name: name, Value: "", Path: "/", MaxAge: -1, + HttpOnly: name == sessionCookie, Secure: secure, SameSite: http.SameSiteStrictMode, + }) + } +} + +// clientIP percorre o X-Forwarded-For da direita para a esquerda e devolve o +// primeiro endereço que não é um proxy confiável (NPM, Next, redes Docker). +// O valor à esquerda pode ter sido enviado pelo próprio cliente, por isso não é usado diretamente. +func (a *API) clientIP(r *http.Request) string { + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + host = r.RemoteAddr + } + remote, err := netip.ParseAddr(host) + if err != nil || !a.trusted(remote) { + return host + } + parts := strings.Split(r.Header.Get("X-Forwarded-For"), ",") + for i := len(parts) - 1; i >= 0; i-- { + addr, err := netip.ParseAddr(strings.TrimSpace(parts[i])) + if err != nil { + break + } + if !a.trusted(addr) { + return addr.String() + } + } + return host +} + +func (a *API) trusted(addr netip.Addr) bool { + addr = addr.Unmap() + for _, p := range a.cfg.Web.TrustedProxies { + if p.Contains(addr) { + return true + } + } + return false +} diff --git a/backend/internal/httpapi/middleware_test.go b/backend/internal/httpapi/middleware_test.go new file mode 100644 index 0000000..d616b38 --- /dev/null +++ b/backend/internal/httpapi/middleware_test.go @@ -0,0 +1,26 @@ +package httpapi + +import ( + "testing" + + "botpontos/internal/domain" +) + +func TestIsRestricted(t *testing.T) { + id := int64(7) + cases := []struct { + name string + account domain.WebAccount + want bool + }{ + {"leitura ligada a um usuário", domain.WebAccount{Role: domain.RoleViewer, UserID: &id}, true}, + {"leitura sem vínculo", domain.WebAccount{Role: domain.RoleViewer}, false}, + {"admin ligado a um usuário", domain.WebAccount{Role: domain.RoleAdmin, UserID: &id}, false}, + {"owner ligado a um usuário", domain.WebAccount{Role: domain.RoleOwner, UserID: &id}, false}, + } + for _, c := range cases { + if got := isRestricted(c.account); got != c.want { + t.Errorf("%s: isRestricted = %v, esperado %v", c.name, got, c.want) + } + } +} diff --git a/backend/internal/httpapi/request.go b/backend/internal/httpapi/request.go new file mode 100644 index 0000000..e425546 --- /dev/null +++ b/backend/internal/httpapi/request.go @@ -0,0 +1,152 @@ +package httpapi + +import ( + "encoding/json" + "errors" + "io" + "net/http" + "strconv" + "strings" + "time" + "unicode" + "unicode/utf8" + + "botpontos/internal/adapters/pontomais" + "botpontos/internal/app" + "botpontos/internal/domain" + "botpontos/internal/httpx" +) + +const maxBodyBytes = 64 << 10 + +// decode lê o corpo JSON recusando campos desconhecidos. +func decode(w http.ResponseWriter, r *http.Request, out any) bool { + r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes) + dec := json.NewDecoder(r.Body) + dec.DisallowUnknownFields() + if err := dec.Decode(out); err != nil { + title := "Corpo da requisição inválido" + if errors.Is(err, io.EOF) { + title = "Corpo da requisição vazio" + } + httpx.WriteProblem(w, http.StatusBadRequest, title, "BAD_REQUEST") + return false + } + return true +} + +func pathID(w http.ResponseWriter, r *http.Request) (int64, bool) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil || id <= 0 { + httpx.WriteProblem(w, http.StatusBadRequest, "Identificador inválido", "BAD_REQUEST") + return 0, false + } + return id, true +} + +func queryString(r *http.Request, key string) *string { + v := strings.TrimSpace(r.URL.Query().Get(key)) + if v == "" { + return nil + } + return &v +} + +func queryInt64(r *http.Request, key string) (*int64, error) { + v := queryString(r, key) + if v == nil { + return nil, nil + } + n, err := strconv.ParseInt(*v, 10, 64) + if err != nil { + return nil, domain.ValidationErrors{{Field: key, Message: "número inválido"}} + } + return &n, nil +} + +func queryBool(r *http.Request, key string) (*bool, error) { + v := queryString(r, key) + if v == nil { + return nil, nil + } + b, err := strconv.ParseBool(*v) + if err != nil { + return nil, domain.ValidationErrors{{Field: key, Message: "use true ou false"}} + } + return &b, nil +} + +// queryDate lê AAAA-MM-DD; end=true devolve o início do dia seguinte (limite exclusivo). +func queryDate(r *http.Request, key string, loc *time.Location, end bool) (*time.Time, error) { + v := queryString(r, key) + if v == nil { + return nil, nil + } + t, err := time.ParseInLocation(domain.DateLayout, *v, loc) + if err != nil { + return nil, domain.ValidationErrors{{Field: key, Message: "data inválida (use AAAA-MM-DD)"}} + } + if end { + t = t.AddDate(0, 0, 1) + } + return &t, nil +} + +// writeError traduz os erros do domínio em respostas problem+json. +func (a *API) writeError(w http.ResponseWriter, err error) { + var validation domain.ValidationErrors + var credErr *app.CredentialError + switch { + case errors.As(err, &validation): + httpx.WriteProblemDetails(w, httpx.Problem{ + Title: "Dados inválidos", Status: http.StatusUnprocessableEntity, Code: "VALIDATION", Errors: validation, + }) + case errors.As(err, &credErr): + if errors.Is(err, pontomais.ErrAuthRejected) { + httpx.WriteProblem(w, http.StatusUnprocessableEntity, + "A PontoMais recusou o e-mail ou a senha; a credencial anterior foi mantida", "CREDENTIALS_REJECTED") + return + } + httpx.WriteProblem(w, http.StatusBadGateway, + "Não foi possível validar na PontoMais agora; a credencial anterior foi mantida", "POINTS_UNAVAILABLE") + case errors.Is(err, domain.ErrNotFound): + httpx.WriteProblem(w, http.StatusNotFound, "Registro não encontrado", "NOT_FOUND") + case errors.Is(err, domain.ErrCredentialsRequired): + httpx.WriteProblem(w, http.StatusConflict, "Configure e valide a credencial da PontoMais antes", "CREDENTIALS_REQUIRED") + case errors.Is(err, domain.ErrUserNotActive): + httpx.WriteProblem(w, http.StatusConflict, "O usuário precisa estar ativo", "USER_NOT_ACTIVE") + case errors.Is(err, domain.ErrLastOwner): + httpx.WriteProblem(w, http.StatusConflict, "É preciso manter pelo menos um owner ativo", "LAST_OWNER") + case errors.Is(err, domain.ErrInvalidState): + httpx.WriteProblem(w, http.StatusConflict, capitalize(err.Error()), "INVALID_STATE") + case errors.Is(err, domain.ErrInvalidLogin): + httpx.WriteProblem(w, http.StatusUnauthorized, "Usuário ou senha inválidos", "INVALID_LOGIN") + case errors.Is(err, domain.ErrAccountLocked): + httpx.WriteProblem(w, http.StatusLocked, "Conta bloqueada por tentativas de login; tente mais tarde", "ACCOUNT_LOCKED") + case errors.Is(err, domain.ErrNotConfigured): + httpx.WriteProblem(w, http.StatusServiceUnavailable, "Integração não configurada no servidor", "NOT_CONFIGURED") + case errors.Is(err, pontomais.ErrAuthRejected): + httpx.WriteProblem(w, http.StatusBadGateway, "A PontoMais recusou a credencial gravada", "CREDENTIALS_REJECTED") + case isPointsError(err): + httpx.WriteProblem(w, http.StatusBadGateway, "A PontoMais não respondeu; tente novamente", "POINTS_UNAVAILABLE") + default: + a.log.Error("erro interno na API", "error", err) + httpx.WriteProblem(w, http.StatusInternalServerError, "Erro interno", "INTERNAL") + } +} + +func isPointsError(err error) bool { + return strings.Contains(err.Error(), "PontoMais") +} + +func capitalize(s string) string { + first, size := utf8.DecodeRuneInString(s) + if first == utf8.RuneError { + return s + } + return string(unicode.ToUpper(first)) + s[size:] +} + +func badRequest(field, message string) error { + return domain.ValidationErrors{{Field: field, Message: message}} +} diff --git a/backend/internal/httpapi/server.go b/backend/internal/httpapi/server.go new file mode 100644 index 0000000..5ca2b6a --- /dev/null +++ b/backend/internal/httpapi/server.go @@ -0,0 +1,56 @@ +package httpapi + +import ( + "context" + "errors" + "log/slog" + "net/http" + "time" + + "botpontos/internal/bootstrap" + "botpontos/internal/config" +) + +// Run monta o serviço e atende HTTP até o contexto ser cancelado. +func Run(ctx context.Context, cfg config.Config, logger *slog.Logger) error { + built, err := bootstrap.Build(ctx, cfg, logger) + if err != nil { + return err + } + defer built.Pool.Close() + + if cfg.Web.PublicURL == "" { + logger.Warn("BP_PUBLIC_URL vazio: a checagem de origem das requisições está desligada (use só em desenvolvimento)") + } + if err := built.Service.SeedSettings(ctx); err != nil { + return err + } + + server := &http.Server{ + Addr: cfg.HTTP.Addr, + Handler: NewHandler(built.Pool, built.Service, cfg, logger), + ReadHeaderTimeout: 5 * time.Second, + ReadTimeout: 15 * time.Second, + WriteTimeout: 120 * time.Second, // o teste de credencial pode fazer várias consultas à PontoMais + IdleTimeout: 60 * time.Second, + } + + errCh := make(chan error, 1) + go func() { + logger.Info("api ouvindo", "addr", cfg.HTTP.Addr) + errCh <- server.ListenAndServe() + }() + + select { + case <-ctx.Done(): + logger.Info("encerrando api") + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + return server.Shutdown(shutdownCtx) + case err := <-errCh: + if errors.Is(err, http.ErrServerClosed) { + return nil + } + return err + } +} diff --git a/backend/internal/httpx/httpx.go b/backend/internal/httpx/httpx.go new file mode 100644 index 0000000..5472008 --- /dev/null +++ b/backend/internal/httpx/httpx.go @@ -0,0 +1,100 @@ +// Package httpx reúne utilitários HTTP compartilhados pela API e pelo worker. +package httpx + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "log/slog" + "net/http" + "runtime/debug" + "strings" + "time" + + "botpontos/internal/domain" +) + +// Problem segue o formato application/problem+json (RFC 9457). +type Problem struct { + Type string `json:"type"` + Title string `json:"title"` + Status int `json:"status"` + Code string `json:"code,omitempty"` + Errors []domain.ValidationError `json:"errors,omitempty"` +} + +func WriteJSON(w http.ResponseWriter, status int, value any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(value) +} + +func WriteProblem(w http.ResponseWriter, status int, title, code string) { + WriteProblemDetails(w, Problem{Title: title, Status: status, Code: code}) +} + +func WriteProblemDetails(w http.ResponseWriter, p Problem) { + if p.Type == "" { + p.Type = "about:blank" + } + w.Header().Set("Content-Type", "application/problem+json; charset=utf-8") + w.WriteHeader(p.Status) + _ = json.NewEncoder(w).Encode(p) +} + +type statusRecorder struct { + http.ResponseWriter + status int +} + +func (r *statusRecorder) WriteHeader(status int) { + r.status = status + r.ResponseWriter.WriteHeader(status) +} + +// RequestLog registra cada requisição com um request_id. Rotas de saúde +// ficam em nível debug, porque o healthcheck do Docker as chama o tempo todo. +func RequestLog(logger *slog.Logger, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + start := time.Now() + requestID := newRequestID() + w.Header().Set("X-Request-ID", requestID) + + rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK} + next.ServeHTTP(rec, req) + + level := slog.LevelInfo + if strings.Contains(req.URL.Path, "/health/") { + level = slog.LevelDebug + } + logger.Log(req.Context(), level, "requisição http", + "request_id", requestID, + "method", req.Method, + "path", req.URL.Path, + "status", rec.status, + "duration_ms", time.Since(start).Milliseconds(), + ) + }) +} + +// Recover transforma um panic num 500 sem derrubar o processo. +func Recover(logger *slog.Logger, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + defer func() { + if v := recover(); v != nil { + if v == http.ErrAbortHandler { + panic(v) + } + logger.Error("panic no handler", "panic", v, "stack", string(debug.Stack())) + WriteProblem(w, http.StatusInternalServerError, "Erro interno", "INTERNAL") + } + }() + next.ServeHTTP(w, req) + }) +} + +func newRequestID() string { + b := make([]byte, 8) + _, _ = rand.Read(b) + return hex.EncodeToString(b) +} diff --git a/backend/internal/metrics/metrics.go b/backend/internal/metrics/metrics.go new file mode 100644 index 0000000..b8e8c0a --- /dev/null +++ b/backend/internal/metrics/metrics.go @@ -0,0 +1,23 @@ +// Package metrics define os pontos de medição do sistema (ARQUITETURA.md 12, +// decisão 23). Na primeira versão a implementação não faz nada e não existe +// endpoint /metrics. Para ativar o Prometheus, basta criar uma implementação +// de Recorder com prometheus/client_golang e expor /metrics na rede interna. +package metrics + +import "time" + +type Recorder interface { + // PointsRequest mede cada consulta de histórico à PontoMais. + PointsRequest(duration time.Duration, err error) + // ReconciliationCycle mede um ciclo de reconciliação. + ReconciliationCycle(duration time.Duration, queried, failed int) + // Notification conta lembretes por tipo e status final (sent, failed, expired, cancelled). + Notification(notificationType, status string) +} + +// Noop é a implementação padrão: não registra nada. +type Noop struct{} + +func (Noop) PointsRequest(time.Duration, error) {} +func (Noop) ReconciliationCycle(time.Duration, int, int) {} +func (Noop) Notification(string, string) {} diff --git a/backend/internal/rules/format.go b/backend/internal/rules/format.go new file mode 100644 index 0000000..4f8aa30 --- /dev/null +++ b/backend/internal/rules/format.go @@ -0,0 +1,24 @@ +package rules + +import ( + "fmt" + "time" +) + +// FormatMinutes formata uma duração de jornada: 480 → "8h00", 390 → "6h30". +func FormatMinutes(minutes int) string { + return fmt.Sprintf("%dh%02d", minutes/60, minutes%60) +} + +// FormatMinutesShort formata intervalos curtos: 15 → "15 min", 60 → "1h00". +func FormatMinutesShort(minutes int) string { + if minutes < 60 { + return fmt.Sprintf("%d min", minutes) + } + return FormatMinutes(minutes) +} + +// FormatClock formata o horário no fuso informado: "13:02". +func FormatClock(t time.Time, loc *time.Location) string { + return t.In(loc).Format("15:04") +} diff --git a/backend/internal/rules/plan.go b/backend/internal/rules/plan.go new file mode 100644 index 0000000..28ff58b --- /dev/null +++ b/backend/internal/rules/plan.go @@ -0,0 +1,134 @@ +package rules + +import ( + "time" + + "botpontos/internal/domain" +) + +// Tick alinha um instante ao relógio no intervalo informado (ex.: :00, :15, +// :30, :45 para 15 min). Fusos com deslocamento em horas inteiras ou em +// múltiplos de 15 min mantêm o alinhamento local. +func Tick(t time.Time, interval time.Duration) time.Time { + return t.Truncate(interval) +} + +// NextTick é o próximo instante alinhado depois de t. +func NextTick(t time.Time, interval time.Duration) time.Time { + return Tick(t, interval).Add(interval) +} + +// InWindow indica se t está dentro da janela de consulta do dia (inclusive nas pontas). +func InWindow(t time.Time, w domain.QueryWindow, day string, loc *time.Location) bool { + start := w.Start.On(day, loc) + end := w.End.On(day, loc) + return !t.Before(start) && !t.After(end) +} + +// PlanInput reúne o que a reconciliação sabe sobre um usuário depois de uma +// consulta bem-sucedida à PontoMais. +type PlanInput struct { + Tick time.Time // instante alinhado do ciclo + Day string // AAAA-MM-DD no fuso do usuário + Location *time.Location + Window domain.QueryWindow + Workday domain.Workday + Resumed bool // o dia foi retomado manualmente (9.12) + Saida2At *time.Time // scheduled_at do lembrete de saida2, se agendado + Reconciliation time.Duration + Slow time.Duration // consultas lentas (60 min) + ExitConfirmFrom time.Duration // início da janela de confirmação antes do lembrete (20 min) + ExitConfirmTo time.Duration // fim da janela de confirmação antes do lembrete (5 min) +} + +// Plan é a nova situação do dia e a próxima consulta (nil = nenhuma hoje). +type Plan struct { + Status domain.DayStatus + NextCheckAt *time.Time +} + +// PlanNextCheck implementa a frequência adaptativa da seção 9.12: +// - 0 pontos antes da confirmação: próximo tick; +// - 0 pontos depois da confirmação: de hora em hora até o fim das reverificações; +// - 1 ou 2 pontos: próximo tick; +// - 3 pontos: de hora em hora + uma consulta de confirmação antes do lembrete de saida2; +// - 4 ou mais pontos: dia encerrado. +func PlanNextCheck(in PlanInput) Plan { + end := in.Window.End.On(in.Day, in.Location) + within := func(t time.Time) *time.Time { + if t.After(end) { + return nil + } + return &t + } + quick := in.Tick.Add(in.Reconciliation) + slow := in.Tick.Add(in.Slow) + + w := in.Workday + switch { + case w.Overtime || w.Count() >= 4: + return Plan{Status: domain.DayFinished} + + case w.Invalid: + // Dado inconsistente: continua consultando, mas devagar, para não poluir a auditoria. + status := domain.DayWorking + if w.Count() == 0 { + status = domain.DayAwaiting + } + return Plan{Status: status, NextCheckAt: within(slow)} + + case w.Count() == 3: + if in.Saida2At == nil { + return Plan{Status: domain.DayWorking, NextCheckAt: within(slow)} + } + confirmStart := in.Saida2At.Add(-in.ExitConfirmFrom) + if !in.Tick.Before(confirmStart) { + // Esta já foi a consulta de confirmação (ou passou dela): nada mais a fazer hoje. + return Plan{Status: domain.DayWorking} + } + next := slow + if confirm := ConfirmTick(*in.Saida2At, in.Reconciliation, in.ExitConfirmTo); confirm.Before(next) { + next = confirm + } + return Plan{Status: domain.DayWorking, NextCheckAt: within(next)} + + case w.Count() >= 1: + return Plan{Status: domain.DayWorking, NextCheckAt: within(quick)} + } + + // 0 pontos. + if in.Resumed { + // Dia retomado manualmente: consulta no ritmo normal até o fim da janela. + return Plan{Status: domain.DayWorking, NextCheckAt: within(quick)} + } + deadline := in.Window.DayCheckDeadline.On(in.Day, in.Location) + if in.Tick.Before(deadline) { + return Plan{Status: domain.DayAwaiting, NextCheckAt: within(quick)} + } + recheckUntil := in.Window.NoPointsRecheckUntil.On(in.Day, in.Location) + if slow.After(recheckUntil) { + return Plan{Status: domain.DayNotWorking} + } + return Plan{Status: domain.DayNoPoints, NextCheckAt: within(slow)} +} + +// ConfirmTick é o último tick antes de (lembrete − to). Com to = 5 min e +// tick de 15 min, ele cai sempre entre 20 e 5 minutos antes do lembrete. +func ConfirmTick(reminderAt time.Time, reconciliation, to time.Duration) time.Time { + return Tick(reminderAt.Add(-to).Add(-time.Nanosecond), reconciliation) +} + +// ResumeNextCheck é a próxima consulta depois de um dia ser retomado: o próximo +// tick dentro da janela, ou o início da janela se ela ainda não começou. +func ResumeNextCheck(now time.Time, w domain.QueryWindow, day string, loc *time.Location, reconciliation time.Duration) *time.Time { + start := w.Start.On(day, loc) + end := w.End.On(day, loc) + next := NextTick(now, reconciliation) + if next.Before(start) { + next = start + } + if next.After(end) { + return nil + } + return &next +} diff --git a/backend/internal/rules/rules_test.go b/backend/internal/rules/rules_test.go new file mode 100644 index 0000000..6917f84 --- /dev/null +++ b/backend/internal/rules/rules_test.go @@ -0,0 +1,229 @@ +package rules + +import ( + "testing" + "time" + + "botpontos/internal/domain" +) + +var saoPaulo = domain.LoadLocation("America/Sao_Paulo") + +const testDay = "2026-10-14" // quarta-feira + +func at(clock string) time.Time { + return domain.MustParseClock(clock).On(testDay, saoPaulo) +} + +func workday(clocks ...string) domain.Workday { + points := make([]domain.Point, 0, len(clocks)) + for _, c := range clocks { + points = append(points, domain.Point{OccurredAt: at(c)}) + } + return CurrentWorkday(points, testDay, saoPaulo) +} + +func TestCurrentWorkdayFiltersAndSorts(t *testing.T) { + yesterday := domain.MustParseClock("17:00").On("2026-10-13", saoPaulo) + points := []domain.Point{{OccurredAt: at("12:00")}, {OccurredAt: yesterday}, {OccurredAt: at("08:00")}} + + w := CurrentWorkday(points, testDay, saoPaulo) + if w.Count() != 2 || !w.Entry1().Equal(at("08:00")) || !w.Exit1().Equal(at("12:00")) { + t.Fatalf("pontos inesperados: %+v", w.Points) + } + if w.Invalid || w.Overtime { + t.Fatalf("dia deveria ser válido: %+v", w) + } +} + +func TestCurrentWorkdayInvalidAndOvertime(t *testing.T) { + if w := workday("08:00", "08:00"); !w.Invalid { + t.Error("pontos repetidos deveriam invalidar o dia") + } + if w := workday("08:00", "12:00", "13:00", "17:00", "18:00"); !w.Overtime { + t.Error("mais de quatro pontos deveria ser hora extra") + } +} + +func TestEntrada2AndSaida2(t *testing.T) { + w := workday("08:00", "12:00", "13:00") + + entrada2, err := Entrada2Time(w, 60) + if err != nil || !entrada2.Equal(at("13:00")) { + t.Fatalf("entrada2 = %v, %v", entrada2, err) + } + saida2, err := Saida2Time(w, 480) + if err != nil || !saida2.Equal(at("17:00")) { + t.Fatalf("saida2 = %v, %v", saida2, err) + } + if reminder := ReminderAt(saida2, 5); !reminder.Equal(at("16:55")) { + t.Fatalf("lembrete = %v", reminder) + } + if _, err := Saida2Time(workday("08:00", "17:00", "18:00"), 480); err != ErrNoRemainingWork { + t.Fatalf("esperado ErrNoRemainingWork, veio %v", err) + } + if _, err := Entrada2Time(workday("08:00"), 60); err != ErrNotEnoughPoints { + t.Fatalf("esperado ErrNotEnoughPoints, veio %v", err) + } +} + +func TestFormat(t *testing.T) { + if got := FormatMinutes(480); got != "8h00" { + t.Errorf("FormatMinutes(480) = %s", got) + } + if got := FormatMinutes(390); got != "6h30" { + t.Errorf("FormatMinutes(390) = %s", got) + } + if got := FormatMinutesShort(15); got != "15 min" { + t.Errorf("FormatMinutesShort(15) = %s", got) + } +} + +func TestValidateSchedule(t *testing.T) { + global := domain.DefaultQueryWindow() + ok := domain.DefaultSchedule(1) + if errs := ValidateSchedule(ok, global, 15*time.Minute); len(errs) != 0 { + t.Fatalf("jornada padrão deveria ser válida: %v", errs) + } + + short := ok + short.LunchMinutes = 15 // 15 − 5 = 10 < 15 + if errs := ValidateSchedule(short, global, 15*time.Minute); len(errs) == 0 { + t.Fatal("almoço de 15 min com antecedência de 5 deveria ser recusado") + } + + twenty := ok + twenty.LunchMinutes = 20 // 20 − 5 = 15 + if errs := ValidateSchedule(twenty, global, 15*time.Minute); len(errs) != 0 { + t.Fatalf("almoço de 20 min deveria passar: %v", errs) + } +} + +func TestValidateWindow(t *testing.T) { + if errs := ValidateWindow(domain.DefaultQueryWindow(), 15*time.Minute, ""); len(errs) != 0 { + t.Fatalf("janela padrão deveria ser válida: %v", errs) + } + + misaligned := domain.DefaultQueryWindow() + misaligned.Start = domain.MustParseClock("11:20") + if errs := ValidateWindow(misaligned, 15*time.Minute, ""); len(errs) == 0 { + t.Error("horário fora do alinhamento deveria ser recusado") + } + + outOfOrder := domain.DefaultQueryWindow() + outOfOrder.DayCheckDeadline = domain.MustParseClock("16:00") + if errs := ValidateWindow(outOfOrder, 15*time.Minute, ""); len(errs) == 0 { + t.Error("confirmação depois do fim das reverificações deveria ser recusada") + } + + tooShort := domain.QueryWindow{ + Start: domain.MustParseClock("11:30"), + DayCheckDeadline: domain.MustParseClock("11:45"), + NoPointsRecheckUntil: domain.MustParseClock("13:00"), + End: domain.MustParseClock("15:00"), + } + if errs := ValidateWindow(tooShort, 15*time.Minute, ""); len(errs) == 0 { + t.Error("janela com menos de 4 horas deveria ser recusada") + } +} + +func plan(tick string, w domain.Workday, saida2At *time.Time) Plan { + return PlanNextCheck(PlanInput{ + Tick: at(tick), + Day: testDay, + Location: saoPaulo, + Window: domain.DefaultQueryWindow(), + Workday: w, + Saida2At: saida2At, + Reconciliation: 15 * time.Minute, + Slow: 60 * time.Minute, + ExitConfirmFrom: 20 * time.Minute, + ExitConfirmTo: 5 * time.Minute, + }) +} + +func assertPlan(t *testing.T, got Plan, status domain.DayStatus, next string) { + t.Helper() + if got.Status != status { + t.Errorf("status = %s, esperado %s", got.Status, status) + } + switch { + case next == "" && got.NextCheckAt != nil: + t.Errorf("próxima consulta = %s, esperado nenhuma", got.NextCheckAt.In(saoPaulo).Format("15:04")) + case next != "" && got.NextCheckAt == nil: + t.Errorf("próxima consulta = nenhuma, esperado %s", next) + case next != "" && !got.NextCheckAt.Equal(at(next)): + t.Errorf("próxima consulta = %s, esperado %s", got.NextCheckAt.In(saoPaulo).Format("15:04"), next) + } +} + +func TestPlanNoPoints(t *testing.T) { + empty := workday() + assertPlan(t, plan("11:30", empty, nil), domain.DayAwaiting, "11:45") + assertPlan(t, plan("11:45", empty, nil), domain.DayNoPoints, "12:45") + assertPlan(t, plan("13:45", empty, nil), domain.DayNoPoints, "14:45") + assertPlan(t, plan("14:45", empty, nil), domain.DayNotWorking, "") +} + +func TestPlanResumedWithoutPoints(t *testing.T) { + in := PlanInput{ + Tick: at("12:30"), Day: testDay, Location: saoPaulo, Window: domain.DefaultQueryWindow(), + Workday: workday(), Resumed: true, Reconciliation: 15 * time.Minute, Slow: time.Hour, + ExitConfirmFrom: 20 * time.Minute, ExitConfirmTo: 5 * time.Minute, + } + assertPlan(t, PlanNextCheck(in), domain.DayWorking, "12:45") +} + +func TestPlanBeforeAndDuringLunch(t *testing.T) { + assertPlan(t, plan("11:30", workday("08:00"), nil), domain.DayWorking, "11:45") + assertPlan(t, plan("12:15", workday("08:00", "12:00"), nil), domain.DayWorking, "12:30") +} + +func TestPlanAfternoonWithConfirmation(t *testing.T) { + afternoon := workday("08:00", "12:00", "13:00") + reminder := at("16:55") + + assertPlan(t, plan("13:00", afternoon, &reminder), domain.DayWorking, "14:00") + assertPlan(t, plan("16:00", afternoon, &reminder), domain.DayWorking, "16:45") + assertPlan(t, plan("16:45", afternoon, &reminder), domain.DayWorking, "") +} + +func TestPlanFinishedAndWindowEnd(t *testing.T) { + assertPlan(t, plan("17:00", workday("08:00", "12:00", "13:00", "17:00"), nil), domain.DayFinished, "") + // 3 pontos às 18:30 sem lembrete agendado: a próxima consulta lenta (19:30) passa do fim da janela. + assertPlan(t, plan("18:30", workday("10:00", "14:00", "15:00"), nil), domain.DayWorking, "") +} + +func TestConfirmTick(t *testing.T) { + cases := map[string]string{"16:55": "16:45", "17:00": "16:45", "16:50": "16:30"} + for reminder, expected := range cases { + got := ConfirmTick(at(reminder), 15*time.Minute, 5*time.Minute) + if !got.Equal(at(expected)) { + t.Errorf("ConfirmTick(%s) = %s, esperado %s", reminder, got.In(saoPaulo).Format("15:04"), expected) + } + } +} + +func TestResumeNextCheck(t *testing.T) { + w := domain.DefaultQueryWindow() + if next := ResumeNextCheck(at("09:10"), w, testDay, saoPaulo, 15*time.Minute); next == nil || !next.Equal(at("11:30")) { + t.Errorf("antes da janela deveria começar às 11:30, veio %v", next) + } + if next := ResumeNextCheck(at("12:31"), w, testDay, saoPaulo, 15*time.Minute); next == nil || !next.Equal(at("12:45")) { + t.Errorf("dentro da janela deveria ser o próximo tick, veio %v", next) + } + if next := ResumeNextCheck(at("19:10"), w, testDay, saoPaulo, 15*time.Minute); next != nil { + t.Errorf("depois da janela não deveria haver consulta, veio %v", next) + } +} + +func TestParseClock(t *testing.T) { + for _, invalid := range []string{"7:30", "24:00", "12:60", "ab:cd", "12-30", ""} { + if _, err := domain.ParseClock(invalid); err == nil { + t.Errorf("ParseClock(%q) deveria falhar", invalid) + } + } + if c, err := domain.ParseClock("11:45"); err != nil || c.String() != "11:45" { + t.Errorf("ParseClock(11:45) = %v, %v", c, err) + } +} diff --git a/backend/internal/rules/validation.go b/backend/internal/rules/validation.go new file mode 100644 index 0000000..3eb018c --- /dev/null +++ b/backend/internal/rules/validation.go @@ -0,0 +1,98 @@ +package rules + +import ( + "time" + + "botpontos/internal/domain" +) + +// EffectiveWindow devolve a janela do usuário (exceção) ou a global (9.14). +func EffectiveWindow(global domain.QueryWindow, schedule domain.WorkSchedule) (window domain.QueryWindow, custom bool) { + if schedule.Window != nil { + return *schedule.Window, true + } + return global, false +} + +// ValidateWindow aplica as regras da seção 9.13. prefix identifica o campo +// na resposta (ex.: "window." no formulário do usuário). +func ValidateWindow(w domain.QueryWindow, reconciliation time.Duration, prefix string) domain.ValidationErrors { + var errs domain.ValidationErrors + step := int(reconciliation / time.Minute) + + fields := []struct { + name string + value domain.ClockTime + }{ + {"query_window_start", w.Start}, + {"day_check_deadline", w.DayCheckDeadline}, + {"no_points_recheck_until", w.NoPointsRecheckUntil}, + {"query_window_end", w.End}, + } + if step > 0 { + for _, f := range fields { + if int(f.value)%step != 0 { + errs.Add(prefix+f.name, "precisa ser múltiplo de "+formatStep(step)+" (ex.: :00, :15, :30, :45)") + } + } + } + + if !(w.Start < w.DayCheckDeadline && w.DayCheckDeadline < w.NoPointsRecheckUntil && w.NoPointsRecheckUntil < w.End) { + errs.Add(prefix+"query_window", "a ordem precisa ser início < confirmação < fim das reverificações < fim") + } + if step > 0 && int(w.DayCheckDeadline-w.Start) < step { + errs.Add(prefix+"day_check_deadline", "precisa ser pelo menos "+formatStep(step)+" depois do início, para haver duas verificações") + } + if int(w.End-w.Start) < 4*60 { + errs.Add(prefix+"query_window_end", "a janela precisa ter pelo menos 4 horas") + } + return errs +} + +// ValidateSchedule aplica as regras da seção 9.8 e, se houver exceção de +// janela, as da 9.13. global é a janela usada quando o usuário não tem exceção. +func ValidateSchedule(s domain.WorkSchedule, global domain.QueryWindow, reconciliation time.Duration) domain.ValidationErrors { + var errs domain.ValidationErrors + if s.LunchMinutes < 15 || s.LunchMinutes > 180 { + errs.Add("lunch_minutes", "precisa estar entre 15 e 180") + } + if s.DailyWorkMinutes < 60 || s.DailyWorkMinutes > 720 { + errs.Add("daily_work_minutes", "precisa estar entre 60 e 720") + } + if s.Entrada2LeadMinutes < 0 || s.Entrada2LeadMinutes > 60 { + errs.Add("entrada2_lead_minutes", "precisa estar entre 0 e 60") + } + if s.Saida2LeadMinutes < 0 || s.Saida2LeadMinutes > 60 { + errs.Add("saida2_lead_minutes", "precisa estar entre 0 e 60") + } + step := int(reconciliation / time.Minute) + if step > 0 && s.LunchMinutes-s.Entrada2LeadMinutes < step { + errs.Add("lunch_minutes", "almoço − antecedência de entrada2 precisa ser pelo menos "+formatStep(step)+ + ", para a saída do almoço ser detectada antes do lembrete") + } + if s.Window != nil { + errs = append(errs, ValidateWindow(*s.Window, reconciliation, "window.")...) + } + return errs +} + +// ScheduleFitsInterval indica se a jornada continua válida para o intervalo de +// reconciliação atual (verificado na inicialização do worker, 9.8). +func ScheduleFitsInterval(s domain.WorkSchedule, reconciliation time.Duration) bool { + step := int(reconciliation / time.Minute) + return s.LunchMinutes-s.Entrada2LeadMinutes >= step +} + +func ValidateTimezone(name string) error { + if name == "" { + return domain.ValidationErrors{{Field: "timezone", Message: "obrigatório"}} + } + if _, err := time.LoadLocation(name); err != nil { + return domain.ValidationErrors{{Field: "timezone", Message: "fuso inválido (use um nome IANA, ex.: America/Sao_Paulo)"}} + } + return nil +} + +func formatStep(minutes int) string { + return FormatMinutesShort(minutes) +} diff --git a/backend/internal/rules/workday.go b/backend/internal/rules/workday.go new file mode 100644 index 0000000..5f4c8b0 --- /dev/null +++ b/backend/internal/rules/workday.go @@ -0,0 +1,73 @@ +// Package rules contém os cálculos determinísticos da jornada. Não conhece +// banco, HTTP nem Telegram, e por isso é testado sem nenhuma dependência. +package rules + +import ( + "errors" + "sort" + "time" + + "botpontos/internal/domain" +) + +var ( + ErrNotEnoughPoints = errors.New("pontos insuficientes para o cálculo") + ErrNoRemainingWork = errors.New("tempo restante da jornada igual ou menor que zero") +) + +// CurrentWorkday filtra os pontos da data (AAAA-MM-DD, no fuso loc), ordena e +// classifica: mais de quatro pontos é hora extra; horários repetidos ou fora +// de ordem tornam o dia inválido. +func CurrentWorkday(points []domain.Point, day string, loc *time.Location) domain.Workday { + filtered := make([]domain.Point, 0, len(points)) + for _, p := range points { + if domain.LocalDay(p.OccurredAt, loc) == day { + filtered = append(filtered, p) + } + } + sort.Slice(filtered, func(i, j int) bool { return filtered[i].OccurredAt.Before(filtered[j].OccurredAt) }) + + workday := domain.Workday{Points: filtered} + if len(filtered) > 4 { + workday.Overtime = true + return workday + } + for i := 1; i < len(filtered); i++ { + if !filtered[i].OccurredAt.After(filtered[i-1].OccurredAt) { + workday.Invalid = true + } + } + return workday +} + +// Entrada2Time: retorno previsto do almoço = saida1 + almoço. +func Entrada2Time(w domain.Workday, lunchMinutes int) (time.Time, error) { + if w.Count() < 2 { + return time.Time{}, ErrNotEnoughPoints + } + return w.Exit1().Add(time.Duration(lunchMinutes) * time.Minute), nil +} + +// Saida2Time: saída prevista = entrada2 + (jornada − (saida1 − entrada1)). +func Saida2Time(w domain.Workday, dailyWorkMinutes int) (time.Time, error) { + if w.Count() < 3 { + return time.Time{}, ErrNotEnoughPoints + } + workedBeforeLunch := w.Exit1().Sub(w.Entry1()) + remaining := time.Duration(dailyWorkMinutes)*time.Minute - workedBeforeLunch + if remaining <= 0 { + return time.Time{}, ErrNoRemainingWork + } + return w.Entry2().Add(remaining), nil +} + +// ReminderAt é o horário do aviso: o horário previsto menos a antecedência. +func ReminderAt(predicted time.Time, leadMinutes int) time.Time { + return predicted.Add(-time.Duration(leadMinutes) * time.Minute) +} + +// IsWeekend indica sábado ou domingo no fuso informado. +func IsWeekend(t time.Time, loc *time.Location) bool { + wd := t.In(loc).Weekday() + return wd == time.Saturday || wd == time.Sunday +} diff --git a/backend/internal/worker/worker.go b/backend/internal/worker/worker.go new file mode 100644 index 0000000..7d918e8 --- /dev/null +++ b/backend/internal/worker/worker.go @@ -0,0 +1,232 @@ +// Package worker monta o processo que roda o bot do Telegram e os jobs. +// +// Só uma instância pode estar ativa: o Telegram em long polling não aceita +// dois consumidores e os jobs não devem rodar em duplicidade. A exclusividade +// vem de um advisory lock do Postgres (ARQUITETURA.md, 4.3). +package worker + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net/http" + "runtime/debug" + "sync" + "sync/atomic" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + + "botpontos/internal/app" + "botpontos/internal/bootstrap" + "botpontos/internal/bot" + "botpontos/internal/config" + "botpontos/internal/httpx" + "botpontos/internal/rules" +) + +// advisoryLockKey identifica o lock de liderança do worker no Postgres. +const advisoryLockKey int64 = 7_202_610_001 + +// lockCheckInterval define de quanto em quanto tempo a conexão que segura o lock é verificada. +const lockCheckInterval = 30 * time.Second + +type worker struct { + cfg config.Config + pool *pgxpool.Pool + built *bootstrap.Built + svc *app.Service + log *slog.Logger + leader atomic.Bool +} + +func Run(ctx context.Context, cfg config.Config, logger *slog.Logger) error { + if err := cfg.RequireTelegram(); err != nil { + return err + } + built, err := bootstrap.Build(ctx, cfg, logger) + if err != nil { + return err + } + defer built.Pool.Close() + + w := &worker{cfg: cfg, pool: built.Pool, built: built, svc: built.Service, log: logger} + if err := w.svc.SeedSettings(ctx); err != nil { + return fmt.Errorf("gravar configurações padrão: %w", err) + } + + health := &http.Server{ + Addr: cfg.Worker.HealthAddr, + Handler: httpx.RequestLog(logger, w.healthHandler()), + ReadHeaderTimeout: 5 * time.Second, + } + go func() { + if err := health.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + logger.Error("servidor de saúde do worker parou", "error", err) + } + }() + defer func() { + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = health.Shutdown(shutdownCtx) + }() + + w.lead(ctx) + logger.Info("worker encerrado") + return nil +} + +// lead tenta assumir a liderança até o contexto ser cancelado. +func (w *worker) lead(ctx context.Context) { + for { + if err := w.leadOnce(ctx); err != nil && ctx.Err() == nil { + w.log.Warn("liderança indisponível", "error", err) + } + select { + case <-ctx.Done(): + return + case <-time.After(w.cfg.Worker.LeaderRetry): + } + } +} + +func (w *worker) leadOnce(ctx context.Context) error { + conn, err := w.pool.Acquire(ctx) + if err != nil { + return fmt.Errorf("obter conexão para o lock: %w", err) + } + defer conn.Release() + + var acquired bool + if err := conn.QueryRow(ctx, "SELECT pg_try_advisory_lock($1)", advisoryLockKey).Scan(&acquired); err != nil { + return fmt.Errorf("tentar advisory lock: %w", err) + } + if !acquired { + w.log.Info("outra instância do worker está ativa; aguardando") + return nil + } + + w.leader.Store(true) + defer w.leader.Store(false) + defer func() { + unlockCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _, _ = conn.Exec(unlockCtx, "SELECT pg_advisory_unlock($1)", advisoryLockKey) + }() + w.log.Info("liderança obtida; worker ativo") + + activeCtx, stop := context.WithCancel(ctx) + defer stop() + + var lockErr error + done := make(chan struct{}) + go func() { + defer close(done) + w.active(activeCtx) + }() + + ticker := time.NewTicker(lockCheckInterval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + stop() + <-done + return nil + case <-ticker.C: + // O lock vive na sessão: se esta conexão cair, a liderança se perde. + if err := conn.Ping(ctx); err != nil { + lockErr = fmt.Errorf("conexão que segura o lock caiu: %w", err) + stop() + <-done + return lockErr + } + } + } +} + +// active roda o bot e os ciclos enquanto a instância for a líder. +func (w *worker) active(ctx context.Context) { + w.svc.CheckScheduleIntervals(ctx) + + jobs := w.cfg.Jobs + runner := bot.NewRunner(w.built.Telegram, bot.NewRouter(w.svc, w.log), w.log, w.cfg.Telegram.PollTimeout) + + var wg sync.WaitGroup + start := func(fn func()) { + wg.Add(1) + go func() { + defer wg.Done() + fn() + }() + } + start(func() { runner.Run(ctx) }) + start(func() { + w.every(ctx, "reconciliação", jobs.ReconciliationInterval, func(ctx context.Context, tick time.Time) error { + return w.svc.Reconcile(ctx, tick) + }) + }) + start(func() { + w.every(ctx, "despacho", jobs.DispatchInterval, func(ctx context.Context, _ time.Time) error { + return w.svc.Dispatch(ctx) + }) + }) + start(func() { + w.every(ctx, "manutenção", jobs.MaintenanceInterval, func(ctx context.Context, _ time.Time) error { + return w.svc.Maintenance(ctx) + }) + }) + wg.Wait() +} + +// every executa fn no tick atual e depois em cada tick alinhado ao relógio +// (ex.: :00, :15, :30, :45). Cada ciclo roda na sua própria goroutine; se uma +// execução passar do próximo tick, ele é pulado em vez de acumular (8.2). +func (w *worker) every(ctx context.Context, name string, interval time.Duration, fn func(context.Context, time.Time) error) { + w.runSafely(ctx, name, rules.Tick(time.Now(), interval), fn) + for { + next := rules.NextTick(time.Now(), interval) + select { + case <-ctx.Done(): + return + case <-time.After(time.Until(next)): + } + w.runSafely(ctx, name, next, fn) + } +} + +func (w *worker) runSafely(ctx context.Context, name string, tick time.Time, fn func(context.Context, time.Time) error) { + defer func() { + if v := recover(); v != nil { + w.log.Error("panic no ciclo", "job", name, "panic", v, "stack", string(debug.Stack())) + } + }() + started := time.Now() + if err := fn(ctx, tick); err != nil && ctx.Err() == nil { + w.log.Error("ciclo falhou", "job", name, "error", err) + return + } + w.log.Debug("ciclo concluído", "job", name, "duration_ms", time.Since(started).Milliseconds()) +} + +func (w *worker) healthHandler() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("GET /health/live", func(rw http.ResponseWriter, _ *http.Request) { + httpx.WriteJSON(rw, http.StatusOK, map[string]string{"status": "ok"}) + }) + mux.HandleFunc("GET /health/ready", func(rw http.ResponseWriter, req *http.Request) { + if !w.leader.Load() { + httpx.WriteProblem(rw, http.StatusServiceUnavailable, "Worker em espera (outra instância está ativa)", "STANDBY") + return + } + ctx, cancel := context.WithTimeout(req.Context(), 2*time.Second) + defer cancel() + if err := w.pool.Ping(ctx); err != nil { + httpx.WriteProblem(rw, http.StatusServiceUnavailable, "Banco de dados indisponível", "DB_UNAVAILABLE") + return + } + httpx.WriteJSON(rw, http.StatusOK, map[string]string{"status": "ok", "role": "leader"}) + }) + return mux +} diff --git a/deploy/botpontos-firewall.service b/deploy/botpontos-firewall.service new file mode 100644 index 0000000..9beaa27 --- /dev/null +++ b/deploy/botpontos-firewall.service @@ -0,0 +1,22 @@ +# Restringe a porta 3000 (painel Next.js) ao IP do Nginx Proxy Manager. +# ARQUITETURA.md, seção 11.4. Instalação no servidor Debian: +# 1. Trocar IP_DO_NPM abaixo pelo IP real do NPM. +# 2. Copiar para /etc/systemd/system/botpontos-firewall.service +# 3. systemctl daemon-reload && systemctl enable --now botpontos-firewall +# +# Portas publicadas pelo Docker ignoram ufw/iptables INPUT; por isso a regra +# vai na cadeia DOCKER-USER, aplicada depois que o Docker sobe. + +[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 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..3759039 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,176 @@ +# Bot de Pontos v2 — Docker Compose (ARQUITETURA.md, seção 11). +# Antes de subir: criar o .env (a partir do .env.example) e os arquivos em ./secrets (ver README). + +name: botpontos + +x-backend: &backend + build: + context: ./backend + image: botpontos-backend:dev + restart: unless-stopped + read_only: true + cap_drop: [ALL] + security_opt: ["no-new-privileges:true"] + +x-backend-env: &backend-env + BP_LOG_LEVEL: ${BP_LOG_LEVEL:-info} + BP_DEFAULT_TIMEZONE: ${BP_DEFAULT_TIMEZONE:-America/Sao_Paulo} + BP_DB_HOST: postgres + BP_DB_NAME: botpontos + BP_DB_USER: botpontos_app + BP_DB_PASSWORD_FILE: /run/secrets/pg_app_password + BP_ENCRYPTION_KEY_FILE: /run/secrets/encryption_key + BP_ENCRYPTION_KEY_VERSION: ${BP_ENCRYPTION_KEY_VERSION:-1} + BP_TELEGRAM_TOKEN_FILE: /run/secrets/telegram_token + BP_HOLIDAY_API_KEY_FILE: /run/secrets/holiday_api_key + BP_HOLIDAY_LOCATION: ${BP_HOLIDAY_LOCATION:-3106200} + BP_PUBLIC_URL: ${BP_PUBLIC_URL:-} + BP_COOKIE_SECURE: ${BP_COOKIE_SECURE:-true} + BP_TRUSTED_PROXIES: 127.0.0.0/8,172.16.0.0/12,${BP_NPM_IP:-} + +x-backend-secrets: &backend-secrets + - pg_app_password + - encryption_key + - telegram_token + - holiday_api_key + +services: + postgres: + image: postgres:17-alpine + restart: unless-stopped + environment: + POSTGRES_DB: botpontos + POSTGRES_USER: botpontos + POSTGRES_PASSWORD_FILE: /run/secrets/pg_password + volumes: + - pgdata:/var/lib/postgresql/data + - ./postgres/init:/docker-entrypoint-initdb.d:ro + secrets: [pg_password, pg_app_password] + healthcheck: + test: ["CMD-SHELL", "pg_isready -U botpontos -d botpontos"] + interval: 10s + timeout: 5s + retries: 10 + networks: [internal] + + # Aplica as migrations com o usuário dono do banco e termina. + migrate: + <<: *backend + restart: "no" + command: ["migrate", "up"] + environment: + BP_LOG_LEVEL: ${BP_LOG_LEVEL:-info} + BP_DB_HOST: postgres + BP_DB_NAME: botpontos + BP_DB_USER: botpontos + BP_DB_PASSWORD_FILE: /run/secrets/pg_password + secrets: [pg_password] + depends_on: + postgres: + condition: service_healthy + networks: [internal] + + api: + <<: *backend + command: ["api"] + environment: + <<: *backend-env + BP_DB_MAX_CONNS: "10" + secrets: *backend-secrets + depends_on: + migrate: + condition: service_completed_successfully + healthcheck: + test: ["CMD", "/botpontos", "healthcheck", "http://127.0.0.1:8080/api/v1/health/live"] + interval: 15s + timeout: 5s + retries: 3 + # egress: a API valida credenciais na PontoMais, reenvia lembretes e sincroniza feriados + networks: [internal, egress] + + worker: + <<: *backend + command: ["worker"] + environment: + <<: *backend-env + BP_DB_MAX_CONNS: "5" + secrets: *backend-secrets + depends_on: + migrate: + condition: service_completed_successfully + healthcheck: + test: ["CMD", "/botpontos", "healthcheck", "http://127.0.0.1:8081/health/live"] + interval: 15s + timeout: 5s + retries: 3 + networks: [internal, egress] + + web: + build: + context: ./web + args: + API_INTERNAL_URL: http://api:8080 + image: botpontos-web:dev + restart: unless-stopped + environment: + API_INTERNAL_URL: http://api:8080 + depends_on: + api: + condition: service_healthy + # Publicado só no IP de rede local do servidor. O Nginx Proxy Manager (outra + # máquina) aponta para cá, e o firewall libera a 3000 só para o IP do NPM + # (deploy/botpontos-firewall.service). Em desenvolvimento, use 127.0.0.1. + ports: + - "${BP_HOST_LAN_IP:?defina BP_HOST_LAN_IP no arquivo .env}:3000:3000" + read_only: true + tmpfs: + - /tmp + - /app/.next/cache + cap_drop: [ALL] + security_opt: ["no-new-privileges:true"] + healthcheck: + test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"] + interval: 15s + timeout: 5s + retries: 3 + networks: [internal, ingress] + + backup: + image: postgres:17-alpine + restart: unless-stopped + entrypoint: ["sh", "/scripts/backup-loop.sh"] + environment: + PGHOST: postgres + PGUSER: botpontos + PGDATABASE: botpontos + BACKUP_INTERVAL_SECONDS: "86400" + BACKUP_RETENTION_DAYS: "14" + volumes: + - ./backups:/backups + - ./scripts:/scripts:ro + secrets: [pg_password] + depends_on: + postgres: + condition: service_healthy + networks: [internal] + +volumes: + pgdata: + +networks: + internal: + internal: true # sem acesso à internet; só comunicação entre containers + egress: {} # saída para PontoMais, Telegram e feriadosapi.com + ingress: {} # rede da porta publicada do web + +secrets: + pg_password: + file: ./secrets/pg_password + pg_app_password: + file: ./secrets/pg_app_password + encryption_key: + file: ./secrets/encryption_key + telegram_token: + file: ./secrets/telegram_token + holiday_api_key: + file: ./secrets/holiday_api_key # pode ficar vazio: vale o fallback de segunda a sexta diff --git a/docs/ARQUITETURA.md b/docs/ARQUITETURA.md new file mode 100644 index 0000000..ef57d28 --- /dev/null +++ b/docs/ARQUITETURA.md @@ -0,0 +1,1162 @@ +# 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 + +```mermaid +flowchart LR + subgraph Internet + TGAPI[Telegram API] + PM[PontoMais API] + HOL[feriadosapi.com] + end + + subgraph Docker["Docker Compose (rede interna)"] + WEB[web-next
Next.js standalone
:3000] + API[api
Go HTTP
:8080] + WRK[worker
Go: bot + scheduler] + DB[(postgres
:5432)] + BKP[backup
pg_dump agendado] + end + + ADM[Administrador
navegador] -->|HTTPS| NPM[Nginx Proxy Manager
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. + +```text + ┌──────────── adapters de entrada ────────────┐ + │ HTTP (api) Telegram (worker) CLI │ + └───────────────────┬─────────────────────────┘ + ▼ + application (casos de uso) + ▼ + domain + rules (puro, testável) + ▲ + ┌───────────────────┴─────────────────────────┐ + │ Postgres PontoMais Telegram Feriados │ + └──────────── adapters de saída ──────────────┘ +``` + +### 4.2 Estrutura de pastas + +```text +backend/ +├── cmd/ +│ └── botpontos/ # binário único: api | worker | admin | migrate +├── 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 + +```text +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 + +```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, + 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 + +```sql +-- 1) expira o que passou da tolerância (BP_LATE_TOLERANCE = 10 min) +UPDATE notifications +SET status = 'expired', error_message = 'atraso acima da tolerância' +WHERE status = 'scheduled' AND scheduled_at < now() - interval '10 minutes' +RETURNING id, user_id, type, scheduled_at; -- cada linha gera um audit_log + +-- 2) reserva o que está dentro da tolerância (transação curta) +WITH due AS ( + SELECT id FROM notifications + WHERE status = 'scheduled' AND scheduled_at <= now() + ORDER BY scheduled_at + LIMIT 50 + FOR UPDATE SKIP LOCKED +) +UPDATE notifications n +SET status = 'sending', locked_at = now(), attempts = attempts + 1 +FROM due WHERE n.id = due.id +RETURNING n.*; +``` + +As duas etapas rodam a cada **2 min** (ver 8.2), sempre nessa ordem, então nada com mais de 10 min de atraso chega a ser reservado. Na etapa 2, a tolerância é conferida de novo no Go, logo antes do envio, para cobrir o tempo entre a reserva e a chamada ao Telegram. Depois do `COMMIT`, o worker envia cada mensagem e marca o resultado (`sent` ou `failed`). Um job de limpeza marca como `failed` toda linha que esteja em `sending` há mais de **5 minutos** (`BP_SENDING_TIMEOUT`), que é o caso de *crash* no meio do envio. **Ela não volta para `scheduled`**, para manter a garantia do Modelo de "no máximo uma mensagem". + +### 6.4 Pool e conexões + +- `api`: pool de 10 conexões. +- `worker`: pool de 5 conexões. +- Postgres com `max_connections` padrão (100), o que sobra muito para 10 a 20 usuários. + +## 7. Contrato da API (v1) + +Todas as rotas ficam sob `/api/v1`, usam JSON e exigem sessão, exceto `auth/login` e `health`. + +| Método | Rota | Papel mínimo | Descrição | +| --- | --- | --- | --- | +| GET | `/health/live` · `/health/ready` | — | Liveness e readiness (o ready faz ping no Postgres) | +| POST | `/auth/login` · `/auth/logout` | — / qualquer | Sessão | +| GET | `/auth/me` | viewer | Conta logada e papel | +| GET | `/dashboard` | viewer | Contadores: pendentes, notificações do dia, falhas recentes | +| GET | `/users` | viewer | Lista com filtros (`status`, `q`, `custom_window`); indica quem tem janela personalizada | +| GET | `/users/{id}` | viewer | Detalhe: cadastro, jornada, credencial (sem a senha) | +| POST | `/users/{id}/activate` · `/disable` | admin | Mesmo caso de uso usado pelo Telegram | +| PUT | `/users/{id}/schedule` | admin | Altera a jornada (almoço, jornada, antecedências, fuso) e a exceção de janela (`window: null` = usa o global); valida conforme 9.8 e 9.14 | +| PUT | `/users/{id}/credentials` | admin | Valida na PontoMais antes de salvar | +| POST | `/users/{id}/credentials/test` | admin | Testa a credencial atual | +| POST | `/users/{id}/notifications/enable` · `/disable` | admin | Liga ou pausa os lembretes | +| 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): + +```json +{ "type": "about:blank", "title": "Credencial ausente", "status": 409, "code": "CREDENTIALS_REQUIRED" } +``` + +## 8. Fluxos principais + +### 8.1 Reconciliação (worker, a cada 15 min; despacho a cada 2 min) + +```mermaid +flowchart TD + A[Tick] --> B{Dia útil?
holidays + feriadosapi.com} + B -- não --> Z[Fim] + B -- sim --> W{Dentro da janela
11:30–19:00?} + W -- não --> Z + W -- sim --> C[Usuários ativos com lembretes ligados
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 ` | 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 ` | Desativa o usuário e cancela os `scheduled` dele | + +- A credencial da PontoMais é configurada **só pelo painel** (ou pela CLI). O bot nunca pede senha no chat. +- `/comando@nome_do_bot` é normalizado para `/comando`, e um comando desconhecido recebe orientação para usar `/ajuda`. + +## 9. Regras de negócio + +### 9.1 Mantidas do Modelo + +- Quatro pontos normais: `entrada1`, `saida1`, `entrada2`, `saida2`. +- `entrada2 = saida1 + almoço` e `saida2 = entrada2 + (jornada − (saida1 − entrada1))`. +- No máximo um lembrete por `(usuário, dia, tipo)`. +- Mais de 4 pontos conta como hora extra e fica fora do escopo. +- Notificação `failed` não é reenviada **automaticamente** (o reenvio manual está na seção 9.5). +- Nada é registrado na PontoMais. + +### 9.2 Corrigidas + +| Problema no Modelo | Correção na v2 | +| --- | --- | +| Fuso do usuário ignorado | O cálculo do dia e dos horários usa `users.timezone` | +| Texto "8h00" fixo em `/saida2` | A mensagem formata `daily_work_minutes` | +| Ativação diferente entre Telegram e web | Um caso de uso `ActivateUser`; regra única: **exige credencial validada** | +| Sem redefinição de senha web | `POST /accounts/{id}/reset-password` + revogação das sessões | +| Uma só conta web | Várias contas com papéis | +| Retorno atrasado do almoço pausa os lembretes do usuário | Ver o detalhe abaixo | +| Reconciliação lenta bloqueia o despacho (mesmo loop) | Goroutines independentes e despacho a cada 2 min (ver 8.2) | + +**Detalhe do último item.** No Modelo (`scheduler/reconciliation.go`), com 2 pontos, se o horário do lembrete já passou (`scheduledAt.Before(now)`), o usuário tem os lembretes do dia cancelados **e as notificações desativadas**. Isso acontece sempre que alguém fica mais tempo no almoço do que o previsto menos a antecedência e a reconciliação roda nesse intervalo. Na v2 vale a regra de lembrete atrasado da seção 9.3, e **o usuário nunca é pausado por atraso**. + +### 9.3 Lembrete atrasado (decidido) + +**Regra:** se o lembrete atrasar **até 10 min** em relação ao `scheduled_at`, ele é enviado. **Acima de 10 min, não é enviado**: fica com status `expired` e é registrado na auditoria (`event_type = 'notification_expired'`, com o atraso em minutos em `details`). O usuário **não** é pausado. + +Os 10 min ficam na variável `BP_LATE_TOLERANCE`. + +A regra vale nos dois pontos em que o atraso pode surgir: + +| Momento | Situação | Ação | +| --- | --- | --- | +| Reconciliação | Lembrete ainda não existe e o `scheduled_at` calculado já passou, com atraso ≤ 10 min | Cria como `scheduled`; o próximo despacho (≤ 2 min) envia | +| Reconciliação | Lembrete ainda não existe e o atraso é > 10 min | Cria direto como `expired` (assim nada é recriado nos ciclos seguintes) e audita | +| Reconciliação | Lembrete já existe (qualquer status) | Não altera; só cancela, se o próximo ponto já tiver sido batido | +| Despacho | `scheduled` com atraso > 10 min | Muda para `expired` e audita (etapa 1 da seção 6.3) | +| Despacho | `scheduled` com atraso ≤ 10 min | Envia normalmente | + +**Detalhes:** + +- O atraso é medido em relação ao `scheduled_at` (horário planejado do aviso), não ao horário previsto do ponto. Com 5 min de antecedência, o limite significa que o aviso pode chegar até 5 min **depois** do horário previsto de voltar ou sair. +- Por isso, **toda mensagem informa o horário previsto**, por exemplo "Seu retorno do almoço está previsto para 13:02". Se o aviso chegar atrasado, o usuário ainda sabe a hora exata. Quando já passou do horário previsto, o texto muda para "Seu retorno estava previsto para 13:02". +- Se o ponto seguinte já tiver sido batido (ex.: `entrada2` registrada), a reconciliação cancela o lembrete antes do despacho, como no Modelo. +- O painel mostra `expired` como um status próprio, separado de `failed`, para diferenciar "chegou tarde demais" de "erro no envio". + +### 9.4 Falha na PontoMais (decidido) + +**Regra:** os lembretes do usuário só são pausados depois de **3 falhas seguidas** na consulta à PontoMais. No Modelo, a pausa acontece na primeira falha. + +O limite fica na variável `BP_API_FAILURE_THRESHOLD`, com 3 de padrão. + +**O que conta como uma falha:** + +- Conta **uma falha por ciclo de reconciliação**, por usuário. Dentro do mesmo ciclo o cliente faz **1 nova tentativa rápida** (após ~3 s) e, no caso de 401/403, refaz o login uma vez, como no Modelo. Só se tudo isso falhar o ciclo conta como falha. +- Contam: timeout, erro de rede, HTTP 5xx, 401/403 que persiste após o novo login e resposta impossível de interpretar. +- **Não** contam: histórico com pontos inválidos ou fora de ordem. A API respondeu, então é um problema de dado, que só é auditado. + +**Espaçamento:** as falhas acompanham a reconciliação, de **15 em 15 min**. A pausa acontece cerca de **30 min depois da primeira falha**: + +```text +12:00 falha 1 (contador = 1) → audita, sem aviso ao usuário +12:15 falha 2 (contador = 2) → audita +12:30 falha 3 (contador = 3) → pausa lembretes, avisa usuário e admin +``` + +**Comportamento:** + +| Evento | Ação | +| --- | --- | +| Falha 1 e 2 | Incrementa `consecutive_failures`, grava `last_error` e `last_failure_at`, audita (`point_api_failure`). Usuário não é avisado. | +| Falha 3 | `notifications_enabled = false`, audita (`notifications_paused_api_failure`), avisa o usuário pelo Telegram para conferir a credencial e depois usar `/ativarponto`, e avisa o(s) admin(s). | +| Consulta com sucesso | Zera `consecutive_failures`. | +| Lembretes já agendados | São **mantidos** durante as falhas e após a pausa, porque foram calculados com dados válidos (mesmo comportamento do Modelo). | +| `/ativarponto` ou reativação pelo painel | Zera o contador. | + +**Queda geral da PontoMais:** + +Se **todos** os usuários consultados num ciclo falharem, o problema é tratado como queda da API, não como erro de credencial de um usuário: + +| Situação no ciclo | Ação | +| --- | --- | +| Todos falharam, com 2 ou mais usuários consultados | **Queda.** Ninguém tem o contador incrementado nem zerado e ninguém é pausado. O admin é avisado pelo Telegram **uma vez**, no início da queda. Grava `service_state['pontomais_outage']` e audita `point_api_outage_started`. | +| Ciclos seguintes, ainda todos falhando | Continua em queda. Não avisa de novo; só registra no log. | +| Pelo menos 1 usuário teve sucesso | Fim da queda (se havia uma). Avisa o admin com a duração, apaga o estado e audita `point_api_outage_ended`. As falhas desse ciclo voltam a contar individualmente. | +| Só 1 usuário consultado | Não dá para distinguir queda de erro de credencial, então conta como falha individual (regra normal). | + +- O estado fica no banco (`service_state`), não em memória. Assim um restart do worker no meio da queda não gera um segundo aviso. +- A regra vale para qualquer tipo de erro, inclusive 401/403: como cada usuário tem a própria credencial, todas serem recusadas juntas indica problema na API. +- O usuário comum não recebe aviso durante a queda. Os lembretes já agendados continuam sendo enviados. + +**Efeito colateral a considerar:** enquanto as falhas duram (até ~30 min), pontos novos não são detectados. Por exemplo, um `saida1` batido nesse intervalo só gera o lembrete de `entrada2` quando a API voltar. Pela regra 9.3, ele pode virar `expired`. + +### 9.5 Reenvio manual de lembrete com falha (decidido) + +Um lembrete `failed` pode ser reenviado **manualmente** pelo painel, num botão com confirmação. O reenvio automático continua não existindo. + +| Regra | Detalhe | +| --- | --- | +| Quem pode | Papel `admin` ou `owner` | +| O que pode ser reenviado | Só status `failed` e `reference_date` = dia atual (no fuso do usuário). `expired`, `cancelled` e `sent` não podem. | +| Pré-condições | Usuário `active` e com lembretes ligados. Se o ponto seguinte já tiver sido batido (ex.: `entrada2` já registrada), o painel avisa e bloqueia o reenvio. | +| Confirmação | O diálogo avisa que, se a falha foi um *crash* durante o envio (`sending` → `failed`), o usuário **pode já ter recebido** a mensagem original. | +| Execução | `UPDATE notifications SET status='sending', attempts=attempts+1 WHERE id=$1 AND status='failed' RETURNING *`. A troca de status é atômica, então um duplo clique ou dois admins ao mesmo tempo resultam em um único envio. O envio acontece na hora, sem esperar o próximo despacho. | +| Tolerância de 10 min | **Não se aplica**: o reenvio é uma decisão consciente do admin. A mensagem informa o horário previsto ("estava previsto para 13:02" se já tiver passado). | +| Resultado | `sent` ou `failed` de novo. Pode ser repetido enquanto as pré-condições valerem. | +| Auditoria | `notification_resent`, com a conta web que acionou e o resultado. | + +### 9.6 Papéis do painel (decidido) + +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 + +```text +11:30 0 pontos → awaiting (1ª verificação, não decide nada) +11:45 0 pontos → no_points (2ª verificação; passa a consultar de hora em hora) +12:45 0 pontos → no_points +13:45 0 pontos → no_points +14:45 0 pontos → no_points +15:00+ (sem nova consulta) → not_working (dia encerrado) +``` + +- **Só resposta bem-sucedida conta** como "0 pontos". Se a API falhar, a situação não avança. +- Se um ponto aparecer em qualquer reverificação, o dia vira `working` e volta para consultas de 15 em 15 min. +- **Ajuda quando:** o cache da PontoMais (`last_cached`) demora a mostrar o ponto; a pessoa começa a trabalhar mais tarde (consulta médica); um ajuste de ponto é aprovado durante o dia. +- **Limite honesto:** se a pessoa **esqueceu de bater a entrada** e não houve ajuste, o primeiro ponto que aparece é a saída para o almoço, mas o sistema o interpreta como `entrada1`. A ordem dos pontos fica errada e o bot não tem como calcular os lembretes corretamente. Isso continua fora do alcance da aplicação, como no Modelo. +- O usuário **não recebe mensagem** sobre `no_points`/`not_working`, para não gerar aviso diário em férias e folgas. A situação aparece no `/status`, no painel e na auditoria (`day_no_points`, `day_not_worked`). + +#### Ajuste C: tarde com 3 pontos + +Com `entrada2` detectada e o lembrete de `saida2` agendado para o horário **T**, as consultas só servem para descobrir se a pessoa saiu **antes** do previsto (4º ponto) e então cancelar o lembrete: + +- **Consultas lentas:** de hora em hora a partir da detecção do 3º ponto. +- **Consulta de confirmação:** exatamente uma, no tick que cair entre **T − 20 min e T − 5 min**. Com ticks de 15 min, sempre existe exatamente um tick nesse intervalo. Se ela encontrar o 4º ponto, o lembrete é cancelado antes de sair. +- Se uma consulta lenta coincidir com a de confirmação, as duas viram uma só. +- **Depois da confirmação, não há mais consultas no dia** (`next_check_at = NULL`): o lembrete já vai sair, e consultar até 19:00 não mudaria nada. +- Uma saída antecipada que aconteça **depois** da consulta de confirmação não é vista a tempo. O lembrete sai mesmo assim (no máximo ~20 min de diferença), o que é aceito. + +**Exemplo de dia típico** (entrada 08:00, almoço 12:00–13:00, saída prevista 17:00, lembrete 16:55): + +```text +11:30 11:45 12:00 12:15 12:30 12:45 13:00 → 7 consultas (15 em 15, até detectar o 3º ponto) +14:00 15:00 16:00 → 3 consultas lentas +16:45 → confirmação (entre 16:35 e 16:50); fim das consultas + total = 11 consultas +``` + +#### Retomar o dia + +| Ação | Efeito | +| --- | --- | +| `/ativarponto` | Dia `no_points`/`not_working` volta para `working`; consultas de 15 em 15 min até 19:00 | +| `/entrada2` ou `/saida2` que encontre pontos | Dia volta para `working` automaticamente | +| Admin no painel ("retomar dia") | Dia volta para `working` | + +`resumed_by` registra quem retomou, e o evento é auditado (`day_resumed`). + +#### Casos que a janela não cobre (aceitos) + +| Caso | Consequência | +| --- | --- | +| Almoço muito cedo (ex.: saída 10:30 com 60 min de almoço → lembrete 11:25) | O ponto só é visto às 11:30; o lembrete sai atrasado (dentro de 10 min) ou vira `expired` (9.3) | +| Retorno do almoço depois das 19:00 | `entrada2` não é detectada e o `saida2` não é agendado | +| Saída antecipada depois da consulta de confirmação ou depois das 19:00 | O lembrete de `saida2` sai mesmo assim | +| Primeiro ponto do dia depois das 15:00 sem retomar | Nenhum lembrete no dia | +| Entrada esquecida sem ajuste | Ordem dos pontos errada; lembretes incorretos ou ausentes | + +#### Impacto estimado (20 usuários, dia útil) + +| Cenário | Consultas/usuário | Total/dia | +| --- | --- | --- | +| Dia inteiro (Modelo) | 96 | ~1.920 | +| Só a janela 11:30–19:00 | até 31 | ~620 | +| Janela + frequência adaptativa (A + C), dia típico | ~11 | **~220** | +| Usuário de férias/folga | 6 (11:30, 11:45, 12:45, 13:45, 14:45) → depois 0 | — | + +### 9.13 Configurações globais pelo painel + +Os horários da janela (9.12) ficam na tabela `app_settings` e são editados na tela **Configurações** do painel (`PUT /settings`, papel `admin` ou `owner`). + +Os quatro horários de 9.12 (início, confirmação, fim das reverificações e fim) são os editáveis. + +**Origem dos valores** + +- Na **primeira inicialização**, se a tabela estiver vazia, 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** + +```text +janela efetiva do usuário = janela personalizada (work_schedules), se existir + senão janela global (app_settings) +``` + +- A exceção é **completa**: os quatro horários (início, confirmação, fim das reverificações e fim) são preenchidos juntos, ou nenhum. Isso evita misturar metade global e metade personalizada, o que seria difícil de entender. +- Os horários são interpretados no **fuso do usuário**, como a janela global. +- A frequência adaptativa (15 min / 60 min / confirmação) é a mesma para todos; só os horários mudam. +- Uma alteração na janela global **não afeta** quem tem exceção. + +**Painel** + +- Na tela do usuário, o bloco **Janela de consulta** tem a opção **"Usar padrão global"** (marcada por padrão). Desmarcando, os quatro campos aparecem já preenchidos com os valores globais atuais, para servir de ponto de partida. +- A validação é a mesma de 9.13 (alinhamento de 15 min, ordem, mínimo de 15 min entre início e confirmação, janela de pelo menos 4 h) e mais a regra de 9.8, conferida contra a janela do usuário. +- A lista de usuários mostra o selo **"janela personalizada"** e pode ser filtrada por ele. +- Marcar de novo "Usar padrão global" apaga a exceção (as quatro colunas voltam a `NULL`). + +**Quando passa a valer:** no próximo tick, como a configuração global. O `next_check_at` do dia é recalculado com a nova janela. + +**Visibilidade e auditoria** + +- `/status` informa ao usuário a janela que vale para ele e se é a global ou personalizada. +- Criar, alterar ou remover a exceção grava `user_window_changed` na auditoria, com os valores antigo e novo e a conta web que alterou. +- O dashboard mostra quantos usuários estão com janela personalizada. + +**Permissão:** papel `admin` ou `owner`, como as demais alterações de jornada. + +## 10. Segurança + +| Camada | Medida | +| --- | --- | +| Rede | Só o Next é exposto, e apenas para o Nginx Proxy Manager. A API e o Postgres ficam sem porta publicada. | +| Transporte | TLS terminado no Nginx Proxy Manager (Force SSL + HSTS), com cookies `Secure`. | +| Autenticação web | Argon2id; bloqueio após 5 falhas por 15 min; sessão de 8h / 30 min ociosa. | +| Autorização | Papel conferido no Go em toda rota; o Next nunca decide permissão. | +| CSRF | Double submit + checagem de `Origin` + `SameSite=Strict`. | +| Segredos | Senhas PontoMais com AES-256-GCM e `key_version` para rotação. As chaves vêm de *Docker secrets* (`/run/secrets/...`), não de variáveis de ambiente em texto. | +| Banco | Usuário de aplicação 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) + +```yaml +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**. + +```text +Navegador ──HTTPS──► NPM (outra máquina) ──HTTP :3000──► servidor Docker: web ──► api ──► postgres +``` + +- O `web` publica a porta `3000` **só no IP de rede local do servidor** (`BP_HOST_LAN_IP`), nunca em `0.0.0.0`. +- 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: + +```bash +# bloqueia a porta 3000 para qualquer origem que não seja o NPM +iptables -I DOCKER-USER -p tcp -m conntrack --ctorigdstport 3000 ! -s IP_DO_NPM -j DROP +``` + +No Debian 12 o comando `iptables` já é o `iptables-nft`, o mesmo backend que o Docker usa, então a regra funciona sem ajustes. + +**Persistência:** não use `iptables-save`/`netfilter-persistent` para isso. O arquivo salvo levaria junto as regras internas do Docker e poderia conflitar com elas no boot. Em vez disso, uma unit systemd aplica só essa regra **depois** que o Docker sobe, sem duplicar: + +```ini +# /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). diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md new file mode 100644 index 0000000..0a5c556 --- /dev/null +++ b/docs/DEPLOY.md @@ -0,0 +1,564 @@ +# Guia de deploy — Bot de Pontos v2 + +Passo a passo para colocar o Bot de Pontos no ar no servidor de produção: + +- **Servidor:** máquina física com **Debian 12**, fora do Proxmox, rodando Docker. +- **Acesso ao painel:** pelo **Nginx Proxy Manager (NPM)** que já existe em outra máquina da rede, com **certificado interno**. +- **Endereço do painel:** `https://botpontos.lan` (troque pelo nome que você usar). + +> O código **ainda não foi compilado nem testado**. A etapa 6 faz essa validação no próprio servidor antes de subir tudo. Se algo falhar, guarde a saída do comando para correção. + +## Visão geral + +```text +Navegador ──HTTPS──► NPM (outra máquina) ──HTTP :3000──► Servidor Debian (Docker) + ├── web (Next.js, porta 3000) + ├── api (Go, só rede interna) + ├── worker (Go: bot + lembretes) + ├── postgres (só rede interna) + └── backup (pg_dump diário) +``` + +| Etapa | O quê | Onde | +| --- | --- | --- | +| 1 | Reunir informações e credenciais | — | +| 2 | Preparar o Debian (IP fixo, NTP, atualizações) | servidor | +| 3 | Instalar o Docker | servidor | +| 4 | Copiar o projeto | Windows → servidor | +| 5 | Criar `.env` e segredos | servidor | +| 6 | Validar o código (testes) | servidor | +| 7 | Subir os containers | servidor | +| 8 | Firewall da porta 3000 | servidor | +| 9 | DNS, certificado e Proxy Host | rede / NPM | +| 10 | Primeiro acesso e primeiro admin | navegador / Telegram | +| 11 | Backup fora da máquina | servidor | +| 12 | Conferência final | — | + +Os comandos no servidor são executados como `root` (ou com `sudo`). + +--- + +## 1. Reunir informações e credenciais + +Tenha em mãos antes de começar: + +| Item | Exemplo | Como obter | +| --- | --- | --- | +| IP fixo do servidor | `192.168.18.50` | reserva DHCP no roteador ou IP estático (etapa 2) | +| IP do NPM | `192.168.18.10` | máquina onde o NPM roda | +| Nome do painel | `botpontos.lan` | escolha um nome interno | +| Token do bot | `123456:ABC...` | no Telegram, fale com **@BotFather** → `/newbot` | +| Chave da feriadosapi.com | — | conta em feriadosapi.com (opcional: sem ela vale segunda a sexta) | +| Certificado interno | `.crt` + `.key` | emitido pela CA interna da rede para `botpontos.lan` (com o nome no **SAN**) | +| Credenciais PontoMais | e-mail e senha de cada usuário | cada usuário | + +No @BotFather, depois de criar o bot, é recomendado: + +- `/setjoingroups` → **Disable** (o bot só atende conversas privadas); +- `/setcommands` com a lista abaixo, para aparecer o menu de comandos: + +```text +status - situação do cadastro e dos lembretes +entrada2 - retorno previsto do almoço +saida2 - saída prevista +ativarponto - ligar os lembretes +desativarponto - pausar os lembretes +cadastrar - solicitar acesso +ajuda - lista de comandos +``` + +--- + +## 2. Preparar o Debian + +### 2.1 Atualizar o sistema + +```bash +apt update && apt full-upgrade -y +``` + +### 2.2 IP fixo + +O jeito mais simples é uma **reserva DHCP** no roteador para o MAC do servidor. Se preferir IP estático, edite `/etc/network/interfaces` (ajuste interface, IP e gateway): + +```text +auto enp1s0 +iface enp1s0 inet static + address 192.168.18.50/24 + gateway 192.168.18.1 + dns-nameservers 192.168.18.1 +``` + +Confira: + +```bash +ip -4 addr show +``` + +### 2.3 Relógio (NTP) — obrigatório + +Os lembretes dependem da hora certa. + +```bash +apt install -y systemd-timesyncd +``` + +```bash +timedatectl set-ntp true +``` + +```bash +timedatectl +``` + +A saída precisa mostrar `System clock synchronized: yes`. O fuso do sistema não importa (a aplicação grava em UTC e calcula no fuso de cada usuário), mas `America/Sao_Paulo` facilita a leitura dos logs: + +```bash +timedatectl set-timezone America/Sao_Paulo +``` + +### 2.4 Ferramentas + +```bash +apt install -y ca-certificates curl openssl iptables +``` + +--- + +## 3. Instalar o Docker + +Use o repositório oficial da Docker (o pacote `docker.io` do Debian costuma estar defasado). + +```bash +install -m 0755 -d /etc/apt/keyrings +``` + +```bash +curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc +``` + +```bash +chmod a+r /etc/apt/keyrings/docker.asc +``` + +```bash +echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian $(. /etc/os-release && echo "$VERSION_CODENAME") stable" > /etc/apt/sources.list.d/docker.list +``` + +```bash +apt update && apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin +``` + +Ligue o Docker na inicialização e confira: + +```bash +systemctl enable --now docker +``` + +```bash +docker compose version +``` + +--- + +## 4. Copiar o projeto + +O projeto vai para `/opt/botpontos`. + +No servidor: + +```bash +mkdir -p /opt/botpontos +``` + +No **Windows**, no PowerShell, dentro de `C:\Users\BrunoSantos\Documents\Codex\projeto prl3`, envie a pasta (troque usuário e IP): + +```powershell +scp -r .\bot-pontos-v2 usuario@192.168.18.50:/tmp/ +``` + +> A pasta `secrets/` deve ter só o `README.md` neste momento: os segredos são criados direto no servidor (etapa 5). Não é preciso enviar `node_modules` ou `.next`, se existirem. + +De volta ao servidor, mova para o lugar final: + +```bash +cp -r /tmp/bot-pontos-v2/. /opt/botpontos/ && rm -rf /tmp/bot-pontos-v2 +``` + +```bash +cd /opt/botpontos && ls +``` + +Deve aparecer `backend`, `web`, `docs`, `docker-compose.yml`, `.env.example` etc. + +Os scripts precisam de quebra de linha Linux (LF). Confira e, se aparecer `CRLF`, converta: + +```bash +file postgres/init/01-create-app-role.sh scripts/backup-loop.sh +``` + +```bash +sed -i 's/\r$//' postgres/init/01-create-app-role.sh scripts/backup-loop.sh +``` + +--- + +## 5. Criar `.env` e segredos + +Todos os comandos desta etapa rodam em `/opt/botpontos`. + +### 5.1 `.env` + +```bash +cp .env.example .env && nano .env +``` + +Ajuste: + +```env +BP_HOST_LAN_IP=192.168.18.50 # IP fixo do servidor +BP_PUBLIC_URL=https://botpontos.lan # exatamente o endereço que o navegador usa +BP_NPM_IP=192.168.18.10 # IP do Nginx Proxy Manager +BP_COOKIE_SECURE=true +BP_LOG_LEVEL=info +``` + +### 5.2 Segredos + +```bash +mkdir -p secrets +``` + +```bash +openssl rand -base64 32 | tr -d '\n' > secrets/pg_password +``` + +```bash +openssl rand -base64 32 | tr -d '\n' > secrets/pg_app_password +``` + +```bash +openssl rand -base64 48 | tr -d '\n' > secrets/encryption_key +``` + +Token do bot (cole o seu entre as aspas simples): + +```bash +printf '%s' 'COLE_O_TOKEN_DO_BOTFATHER' > secrets/telegram_token +``` + +Chave da feriadosapi.com, ou arquivo vazio se não tiver: + +```bash +printf '%s' 'COLE_A_CHAVE' > secrets/holiday_api_key +``` + +```bash +: > secrets/holiday_api_key +``` + +Permissões (a pasta protege; os arquivos precisam ser legíveis pelos containers): + +```bash +chmod 700 secrets && chmod 644 secrets/* +``` + +### 5.3 Guardar a chave de criptografia fora do servidor + +**Sem a `encryption_key`, as senhas da PontoMais gravadas não podem ser recuperadas.** Copie o conteúdo para o seu cofre de senhas agora: + +```bash +cat secrets/encryption_key +``` + +--- + +## 6. Validar o código (testes) + +O código nunca foi compilado. Rode a validação no servidor antes de subir. Esses comandos usam containers temporários e não instalam Go nem Node na máquina. + +Backend — gera o `go.sum`, roda `go vet` e os testes: + +```bash +docker run --rm -v "$PWD/backend:/src" -w /src golang:1.25-alpine sh -c "go mod tidy && go vet ./... && go test ./..." +``` + +Painel — gera o `package-lock.json` e checa os tipos: + +```bash +docker run --rm -v "$PWD/web:/app" -w /app node:22-alpine sh -c "npm install && npx tsc --noEmit" +``` + +- Tudo passou: siga para a etapa 7. +- Algo falhou: **pare aqui**, salve a saída completa e traga para correção. + +> Os arquivos `go.sum` e `package-lock.json` gerados ficam no projeto e tornam os próximos builds reproduzíveis. Copie-os de volta para a pasta do Windows também. + +--- + +## 7. Subir os containers + +```bash +docker compose up -d --build +``` + +O primeiro build leva alguns minutos. Depois confira: + +```bash +docker compose ps -a +``` + +Resultado esperado: + +| Serviço | Estado | +| --- | --- | +| `postgres` | `running (healthy)` | +| `migrate` | `exited (0)` | +| `api` | `running (healthy)` | +| `worker` | `running (healthy)` | +| `web` | `running (healthy)` | +| `backup` | `running` | + +Logs, se algo não ficar saudável: + +```bash +docker compose logs --tail=100 migrate api worker +``` + +O worker deve registrar `liderança obtida; worker ativo` e `bot do Telegram iniciado`. + +Teste local da porta (no próprio servidor): + +```bash +curl -s http://192.168.18.50:3000/healthz +``` + +Deve responder `{"status":"ok"}`. + +--- + +## 8. Firewall da porta 3000 + +Portas publicadas pelo Docker **ignoram** regras comuns de `ufw`/`iptables INPUT`. A regra vai na cadeia `DOCKER-USER`, aplicada por um serviço do systemd. + +Coloque o IP do NPM no arquivo: + +```bash +sed -i 's/IP_DO_NPM/192.168.18.10/' deploy/botpontos-firewall.service +``` + +Instale e ative: + +```bash +cp deploy/botpontos-firewall.service /etc/systemd/system/ +``` + +```bash +systemctl daemon-reload && systemctl enable --now botpontos-firewall +``` + +Confira a regra: + +```bash +iptables -S DOCKER-USER +``` + +Deve aparecer uma linha com `--ctorigdstport 3000` e `! -s 192.168.18.10/32 -j DROP`. + +**Teste de aceite:** de um computador da rede que **não** seja o NPM, o acesso direto precisa falhar (timeout): + +```bash +curl -m 5 http://192.168.18.50:3000/healthz +``` + +--- + +## 9. DNS, certificado e Proxy Host + +### 9.1 DNS interno + +No DNS da rede (roteador, Pi-hole, AdGuard etc.), crie: + +```text +botpontos.lan → 192.168.18.10 (IP do NPM, não do servidor Docker) +``` + +### 9.2 Certificado no NPM + +No NPM: **SSL Certificates → Add SSL Certificate → Custom**. Envie o `.crt` (com a cadeia, se houver) e a `.key` emitidos pela CA interna para `botpontos.lan`. + +O certificado raiz da CA interna precisa estar instalado nos computadores que acessam o painel. + +### 9.3 Proxy Host + +**Hosts → Proxy Hosts → Add Proxy Host:** + +| Aba | Campo | Valor | +| --- | --- | --- | +| Details | Domain Names | `botpontos.lan` | +| Details | Scheme | `http` | +| Details | Forward Hostname / IP | `192.168.18.50` | +| Details | Forward Port | `3000` | +| Details | Block Common Exploits | ligado | +| Details | Websockets Support | desligado | +| Details | Access List | uma lista que permita só a rede local (ex.: `192.168.18.0/24`) | +| SSL | SSL Certificate | o certificado da etapa 9.2 | +| SSL | Force SSL / HTTP/2 / HSTS | ligados | + +Abra `https://botpontos.lan` num computador da rede: deve aparecer a tela de login, sem alerta de certificado. + +> Anote a data de validade do certificado interno. Com HSTS ligado, um certificado vencido **bloqueia** o acesso em vez de só mostrar aviso. + +--- + +## 10. Primeiro acesso e primeiro admin + +### 10.1 Conta owner do painel + +No servidor (a senha, de no mínimo 12 caracteres, é pedida sem eco): + +```bash +docker compose run --rm api admin create-owner --username admin +``` + +Entre em `https://botpontos.lan` com essa conta. + +### 10.2 Primeiro admin do Telegram + +1. A pessoa que será admin envia `/cadastrar` ao bot no Telegram. +2. No painel, **Usuários** → abra o cadastro. +3. Em **Credencial PontoMais**, informe e-mail e senha → **Validar e salvar**. A credencial é testada na hora; se a PontoMais recusar, nada é gravado. +4. Clique em **Ativar**. +5. Clique em **Tornar admin do bot**. +6. A pessoa envia `/ativarponto` e confere com `/status`. + +A partir daí, novos pedidos de cadastro chegam como aviso no Telegram desse admin. + +### 10.3 Demais usuários + +Para cada usuário: `/cadastrar` no Telegram → credencial no painel → **Ativar** → o usuário envia `/ativarponto`. + +Recomendação: comece com 1 a 3 usuários (piloto) por alguns dias antes de liberar para todos. + +### 10.4 Conta de leitura para um usuário (opcional) + +Para que uma pessoa acompanhe só os próprios dados no painel: + +1. O usuário já deve estar cadastrado e ativo (item 10.2 ou 10.3). +2. Entre com o owner → **Contas** → **Nova conta**: defina usuário e senha, papel **leitura** e escolha o **Usuário do Telegram**. +3. Entregue a senha à pessoa. Ela verá apenas Início, Usuários, Lembretes e Feriados, e só os próprios dados. + +Não há migration nem variável de ambiente nova para isso: o vínculo usa a coluna `web_accounts.user_id`, que já existe. O backend e o painel precisam ser atualizados juntos. + +### 10.5 Feriados + +Em **Feriados**, clique em **Sincronizar** para o ano atual (se houver chave da feriadosapi.com) e cadastre feriados manuais que faltarem. + +--- + +## 11. Backup fora da máquina + +O container `backup` faz um `pg_dump` por dia em `/opt/botpontos/backups`, guardando 14 dias. **Um backup no mesmo disco não protege contra falha do disco.** Copie para outra máquina ou NAS. + +Exemplo com um compartilhamento montado em `/mnt/nas` e cron diário às 04:00: + +```bash +mkdir -p /mnt/nas/botpontos +``` + +```bash +echo '0 4 * * * root cp -n /opt/botpontos/backups/*.dump /mnt/nas/botpontos/ 2>/dev/null' > /etc/cron.d/botpontos-backup +``` + +Guarde também, fora do servidor: + +- a `encryption_key` (etapa 5.3); +- o arquivo `.env`; +- uma cópia da pasta `secrets/` (num local seguro). + +### Restaurar um backup + +Pare os serviços que escrevem no banco: + +```bash +docker compose stop api worker web +``` + +Restaure (troque o nome do arquivo): + +```bash +docker compose exec -T postgres pg_restore -U botpontos -d botpontos --clean --if-exists --no-owner < backups/botpontos-AAAAMMDD-HHMMSS.dump +``` + +Suba de novo: + +```bash +docker compose up -d +``` + +A `encryption_key` usada no restore precisa ser a mesma da época do backup. + +--- + +## 12. Conferência final + +- [ ] `docker compose ps -a` com todos saudáveis e `migrate` em `exited (0)`. +- [ ] `timedatectl` mostra o relógio sincronizado. +- [ ] Acesso direto à porta 3000 bloqueado para quem não é o NPM. +- [ ] `https://botpontos.lan` abre sem alerta de certificado. +- [ ] Login no painel funciona; conta owner criada. +- [ ] Admin do Telegram recebe aviso ao surgir um novo `/cadastrar`. +- [ ] Um usuário piloto recebeu o lembrete de retorno do almoço. +- [ ] Feriados do ano sincronizados ou cadastrados. +- [ ] Backup do dia existe em `backups/` e foi copiado para fora. +- [ ] `encryption_key` guardada no cofre de senhas. + +--- + +## Atualizar a aplicação + +1. Gere um backup manual antes: + + ```bash + docker compose exec backup sh -c 'PGPASSWORD=$(cat /run/secrets/pg_password) pg_dump -h postgres -U botpontos -d botpontos -Fc -f /backups/antes-da-atualizacao.dump' + ``` + +2. Copie a nova versão do código para `/opt/botpontos` (como na etapa 4), **sem** sobrescrever `.env`, `secrets/` e `backups/`. +3. Reconstrua e suba (as migrations rodam sozinhas no serviço `migrate`): + + ```bash + docker compose up -d --build + ``` + +4. Confira com `docker compose ps -a` e os logs. + +Atualize também o sistema e as imagens base de tempos em tempos: + +```bash +apt update && apt full-upgrade -y +``` + +```bash +docker compose pull postgres backup && docker compose up -d --build +``` + +--- + +## Problemas comuns + +| Sintoma | Causa provável | O que fazer | +| --- | --- | --- | +| `docker compose up` reclama de `BP_HOST_LAN_IP` | `.env` ausente ou sem a variável | etapa 5.1 | +| `secret ... file not found` | algum arquivo de `secrets/` não existe | etapa 5.2 (o `holiday_api_key` pode ser vazio, mas precisa existir) | +| `migrate` termina com erro de senha | senhas do banco trocadas depois da primeira subida | as senhas só valem na 1ª inicialização do volume; veja `secrets/README.md` | +| `api`/`worker` saem com `BP_ENCRYPTION_KEY ... obrigatório` | chave ausente ou com menos de 32 caracteres | refaça `secrets/encryption_key` | +| `worker` sai com `BP_TELEGRAM_TOKEN ... obrigatório` | token ausente | etapa 5.2 | +| Worker com `falha no getUpdates` repetido | token errado ou servidor sem internet | confira o token e a saída para a internet | +| Painel abre, mas o login diz "Origem da requisição não permitida" | `BP_PUBLIC_URL` diferente do endereço usado no navegador | ajuste o `.env` (com `https://`, sem barra no fim) e rode `docker compose up -d` | +| Login funciona, mas toda ação diz "Token CSRF inválido" | cookies de outra origem ou cache | apague os cookies do site e entre de novo; use sempre o mesmo endereço | +| Após login, volta para a tela de login | `BP_COOKIE_SECURE=true` acessando por `http://` | acesse pelo NPM com `https://` | +| Navegador alerta sobre o certificado | CA interna não instalada no computador, ou nome fora do SAN | etapa 9.2 | +| Lembretes não chegam | usuário sem `/ativarponto`, dia não útil, fora da janela, ou dia sem pontos | `/status` no Telegram; tela do usuário e **Auditoria** no painel | +| Lembrete aparece como **expirado** | ponto detectado com mais de 10 min de atraso | normal em almoço muito cedo ou atraso da PontoMais; ver ARQUITETURA.md 9.3 | +| Usuário pausado "após falhas na PontoMais" | 3 falhas seguidas de consulta | teste a credencial no painel e religue os lembretes | +| Alerta de "PontoMais fora do ar" | todas as consultas do ciclo falharam | aguarde; ninguém é pausado; o admin recebe aviso quando voltar | diff --git a/docs/MANUAL_USUARIO.md b/docs/MANUAL_USUARIO.md new file mode 100644 index 0000000..3e6facb --- /dev/null +++ b/docs/MANUAL_USUARIO.md @@ -0,0 +1,427 @@ +# Manual do usuário — Bot de Pontos + +O Bot de Pontos avisa pelo Telegram a hora de **voltar do almoço** e a hora de **encerrar a jornada**, com base nos pontos que você registra na PontoMais. + +Este manual tem duas partes: + +- **[Parte 1 — Usuário](#parte-1--usuário):** para quem recebe os lembretes pelo Telegram. +- **[Parte 2 — Administrador](#parte-2--administrador):** para quem aprova usuários e cuida do painel. + +--- + +# Parte 1 — Usuário + +## O que o bot faz + +- Consulta os seus pontos do dia na PontoMais. +- Calcula a hora prevista de **voltar do almoço** e de **sair**. +- Envia um lembrete pelo Telegram **alguns minutos antes** de cada horário (5 minutos, por padrão). +- Responde na hora quando você pergunta (`/entrada2`, `/saida2`). + +## O que o bot **não** faz + +- **Não registra ponto.** Você continua batendo o ponto na PontoMais normalmente. +- Não substitui a conferência do seu espelho de ponto. +- Não controla hora extra (dias com mais de quatro pontos). +- Não funciona em grupos: só em conversa privada com o bot. + +> O bot é uma ajuda para lembrar. A fonte oficial continua sendo a PontoMais. + +## Primeiro acesso + +1. **Abra o bot no Telegram.** Peça ao administrador o nome ou o link do bot e toque em **Iniciar**. +2. **Peça acesso:** envie `/cadastrar`. O administrador recebe um aviso. +3. **Credencial da PontoMais:** o administrador vai configurar o seu e-mail e senha de login da PontoMais no painel. + - Combine com ele uma forma segura de passar a senha. + - **Nunca envie sua senha para o bot no chat.** O bot nunca pede senha. +4. **Aguarde a aprovação.** Você recebe a mensagem *"Seu cadastro foi aprovado"*. +5. **Ligue os lembretes:** envie `/ativarponto`. +6. **Confira:** envie `/status`. Deve aparecer *Cadastro: ativo* e *Lembretes: ligados*. + +Pronto. A partir do próximo dia útil você passa a receber os lembretes. + +## Os lembretes + +### Como os horários são calculados + +A jornada padrão tem **quatro pontos**: + +| Ponto | Significado | +| --- | --- | +| 1º | Entrada | +| 2º | Saída para o almoço | +| 3º | Volta do almoço | +| 4º | Saída do dia | + +- **Volta do almoço** = hora em que você saiu para o almoço + tempo de almoço (60 min, por padrão). +- **Saída** = hora em que você voltou do almoço + o que falta da jornada (8h00, por padrão). + +**Exemplo:** entrada às 08:00, saída para o almoço às 12:00 e volta às 13:00. + +- Lembrete de volta do almoço: **12:55** (volta prevista às 13:00). +- Você trabalhou 4 horas antes do almoço; faltam 4 horas. +- Lembrete de saída: **16:55** (saída prevista às 17:00). + +Tempo de almoço, jornada e antecedência podem ser diferentes para você. Quem ajusta é o administrador. + +### Como as mensagens chegam + +```text +⏰ Lembrete: seu retorno do almoço está previsto para 13:00. +⏰ Lembrete: sua saída está prevista para 17:00 (jornada de 8h00). +``` + +Se o lembrete chegar depois do horário previsto, a frase muda para *"estava previsto para 13:00"*. Assim você sabe a hora certa mesmo com atraso. + +### Quando os lembretes **não** chegam (e está tudo certo) + +- **Fim de semana e feriado:** não há consultas nem lembretes. +- **Você já bateu o ponto seguinte:** se voltou do almoço antes do lembrete, ele é cancelado. O mesmo vale para a saída. +- **Lembrete muito atrasado:** se o bot só percebe o seu ponto com mais de 10 minutos de atraso em relação ao horário do aviso, o lembrete não é enviado, porque já não ajudaria. Isso pode acontecer se você sair para o almoço muito cedo, antes do horário em que o bot começa a consultar. +- **Hora extra:** com mais de quatro pontos no dia, o bot para de acompanhar. + +### Horário em que o bot acompanha seus pontos + +O bot consulta a PontoMais apenas numa **janela do dia**: por padrão, **das 11:30 às 19:00**. + +- Antes das 11:30 o bot ainda não olha seus pontos. Isso é normal: o primeiro lembrete do dia (volta do almoço) quase sempre é depois disso. +- Se o seu horário for bem diferente (por exemplo, almoço às 10:30), peça ao administrador uma **janela personalizada**. + +### Dia sem pontos + +Se às **11:30** e às **11:45** o bot não encontrar nenhum ponto seu, ele entende que você pode não estar trabalhando hoje (férias, folga, atestado). Ele passa a conferir **de hora em hora até as 15:00** e, se continuar sem pontos, encerra as consultas do dia. + +Você **não** recebe mensagem quando isso acontece (para não receber aviso todo dia nas férias). Se começou a trabalhar mais tarde, faça uma destas coisas e as consultas voltam: + +- envie `/ativarponto`; ou +- envie `/entrada2` ou `/saida2` depois de bater o ponto. + +## Comandos + +| Comando | Para que serve | +| --- | --- | +| `/cadastrar` | Pedir acesso ao bot (só na primeira vez) | +| `/status` | Ver a situação do seu cadastro, da credencial, dos lembretes e do dia | +| `/entrada2` | Perguntar agora a hora prevista de voltar do almoço (precisa de pelo menos 2 pontos no dia) | +| `/saida2` | Perguntar agora a hora prevista de saída (precisa de pelo menos 3 pontos no dia) | +| `/ativarponto` | Ligar os lembretes automáticos (ou religar depois de uma pausa) | +| `/desativarponto` | Pausar os lembretes. Os comandos `/entrada2` e `/saida2` continuam funcionando | +| `/ajuda` | Ver a lista de comandos | + +### Entendendo o `/status` + +```text +Cadastro: ativo +Credencial PontoMais: validada +Lembretes: ligados +Janela de consulta: 11:30 às 19:00 (global) +Hoje: acompanhando (2 ponto(s) até a última consulta) +``` + +| Linha | O que significa | +| --- | --- | +| **Cadastro** | *aguardando aprovação*, *ativo* ou *desativado* | +| **Credencial PontoMais** | se o administrador já configurou e validou seu login. Se houver falhas recentes, aparece quantas | +| **Lembretes** | *ligados* ou *pausados*, com o motivo da pausa | +| **Janela de consulta** | horário em que o bot acompanha seus pontos; *personalizada* se for só sua | +| **Hoje** | o que o bot sabe do seu dia: aguardando, acompanhando, sem pontos, jornada encerrada ou dia não útil | + +## Situações comuns + +### "Pausei os lembretes por falha na PontoMais" + +O bot tentou consultar seus pontos **3 vezes seguidas** (cerca de 30 minutos) e não conseguiu, então pausou seus lembretes e avisou você e o administrador. O motivo mais comum é **senha da PontoMais alterada**. + +1. Se você trocou a senha da PontoMais, informe a nova ao administrador (de forma segura, fora do bot). +2. Depois que ele atualizar, envie `/ativarponto`. + +### "A PontoMais está fora do ar" + +Quando a PontoMais cai para todo mundo, **ninguém é pausado**. O administrador é avisado, e os lembretes voltam ao normal quando ela responder de novo. Enquanto isso, pontos novos podem não ser percebidos a tempo. + +### Esqueci de bater a entrada + +O bot calcula pela **ordem** dos pontos. Se a entrada estiver faltando, ele vai confundir os horários (vai achar que a saída para o almoço é a entrada). Faça o ajuste do ponto na PontoMais. Enquanto isso, desconsidere os lembretes do dia. + +### Vou sair antes / fazer hora extra + +- **Saída antecipada:** se você bater o ponto de saída antes do lembrete, ele é cancelado. Se bater pouco antes do aviso, o lembrete ainda pode chegar. +- **Hora extra:** a partir do quinto ponto o bot para de acompanhar o dia. + +### Férias ou afastamento longo + +Envie `/desativarponto` antes de sair e `/ativarponto` na volta. Mesmo sem isso o bot para de consultar sozinho nos dias sem pontos, mas pausar evita consultas desnecessárias. + +### Mudei de horário + +Peça ao administrador para ajustar o tempo de almoço, a jornada, a antecedência dos avisos ou a janela de consulta. + +## Privacidade e segurança + +- Sua senha da PontoMais fica **cifrada** no servidor. Ninguém, nem o administrador, consegue vê-la depois de salva. +- O bot só **lê** seus pontos; ele não altera nada na PontoMais. +- O bot só responde em conversa privada. +- **Nunca** envie senhas no chat do bot. Ele nunca vai pedir. + +## Perguntas frequentes + +**O lembrete chegou depois da hora prevista. Por quê?** +O bot confere seus pontos de tempos em tempos (a cada 15 minutos na maior parte do dia) e envia os lembretes a cada 2 minutos. Pequenos atrasos são normais. Atrasos maiores acontecem quando a PontoMais demora a mostrar o ponto. Por isso a mensagem sempre traz o horário previsto. + +**Não recebi nenhum lembrete hoje.** +Envie `/status` e confira, nesta ordem: +1. *Cadastro: ativo* e *Lembretes: ligados*. +2. Hoje é dia útil. +3. A linha *Hoje* não mostra "sem pontos". +4. Seus pontos aparecem na PontoMais na ordem certa. + +Se tudo estiver certo, envie `/entrada2` para testar. Se der erro, procure o administrador. + +**Posso usar o bot em um grupo?** +Não. Ele só responde em conversa privada. + +--- + +# Parte 2 — Administrador + +## Papéis + +O painel tem três tipos de conta. Usuários comuns **não** acessam o painel; eles usam só o Telegram. + +| Ação | Leitura | Admin | Owner | +| --- | :-: | :-: | :-: | +| Ver início, usuários, lembretes, auditoria, feriados e configurações | ✓ | ✓ | ✓ | +| Ativar/desativar usuários, ligar/pausar lembretes | | ✓ | ✓ | +| Alterar jornada, janela personalizada e credencial; testar a PontoMais | | ✓ | ✓ | +| Cancelar e reenviar lembretes | | ✓ | ✓ | +| Cadastrar feriados e alterar a janela global | | ✓ | ✓ | +| Criar contas, mudar papéis, redefinir senhas | | | ✓ | + +### Conta de leitura ligada a um usuário + +Uma conta de **leitura** pode ser ligada a um usuário do Telegram (feito pelo owner em **Contas**). Ela serve para a pessoa acompanhar e ajustar só o que é dela, sem ver nada dos outros. + +| Tela | O que a conta ligada vê e faz | +| --- | --- | +| **Início** | Os avisos do sistema (PontoMais fora do ar, feriados indisponíveis) e os próprios lembretes de hoje, só para consulta (sem link). Não há contadores gerais. | +| **Usuários** | Abre direto na própria ficha. Pode salvar e testar a credencial PontoMais, alterar a jornada e a janela de consulta, e ligar ou pausar os próprios lembretes. Não ativa nem desativa o cadastro, não define admin do bot e não retoma consultas. | +| **Lembretes** | Só os próprios, somente leitura (não cancela nem reenvia). | +| **Feriados** | Somente visualização. | +| **Auditoria, Configurações e Contas** | Não aparecem no menu e a API recusa o acesso. | + +Uma conta de leitura **sem** usuário ligado continua como antes: vê todas as telas, menos Contas, e não altera nada. Um usuário do Telegram só pode estar ligado a **uma** conta do painel. + +Existe ainda o **admin do bot**: um usuário do Telegram que recebe os avisos operacionais e pode usar os comandos administrativos no Telegram. Ser admin do bot é independente de ter conta no painel. + +## Acessar o painel + +1. Abra o endereço do painel (ex.: `https://botpontos.lan`) num computador da rede. +2. Entre com seu usuário e senha. + +Regras de acesso: + +- A sessão dura no máximo **8 horas** e expira após **30 minutos** sem uso. +- **5 senhas erradas seguidas** bloqueiam a conta por **15 minutos**. +- Use **Sair** ao terminar, principalmente em computadores compartilhados. +- Esqueceu a senha? Peça a um owner para redefinir (em **Contas**). + +## Início (dashboard) + +Mostra o resumo do sistema: + +- usuários **aguardando aprovação** e **ativos**; +- quantos estão com lembretes ligados; +- quantos foram **pausados por falha na PontoMais**; +- falhas de envio e lembretes expirados nos últimos 7 dias; +- quantos usuários têm **janela personalizada**; +- lembretes de hoje por situação. + +Faixas de alerta aparecem no topo quando: + +- **a PontoMais está fora do ar:** ninguém é pausado; aguarde; +- **os feriados do ano não foram obtidos:** segunda a sexta estão valendo como dias úteis. Cadastre os feriados manualmente se precisar. + +Os números são clicáveis e levam à lista filtrada. + +## Aprovar um novo usuário + +Quando alguém envia `/cadastrar`, os admins do bot recebem um aviso no Telegram. + +1. Em **Usuários**, filtre por *Aguardando aprovação* e abra o cadastro. +2. Em **Credencial PontoMais**, informe o e-mail e a senha de login da pessoa e clique em **Validar e salvar**. + - A credencial é testada na PontoMais **na hora**. Se for recusada, nada é gravado. + - Depois de salva, a senha nunca mais aparece. +3. Ajuste a **jornada**, se for diferente do padrão (ver adiante). +4. Clique em **Ativar**. A pessoa recebe a mensagem de aprovação. +5. Peça para ela enviar `/ativarponto`. + +> Sem credencial validada **não é possível ativar**, nem pelo painel nem pelo Telegram. + +## Tela do usuário + +### Cadastro e lembretes + +| Botão | O que faz | +| --- | --- | +| **Ativar** | Aprova o cadastro (exige credencial validada) | +| **Desativar** | Bloqueia o uso do bot e cancela os lembretes agendados. Não apaga o histórico | +| **Ligar lembretes** | Liga os lembretes, zera o contador de falhas e retoma o dia, se ele estava "sem pontos" | +| **Pausar lembretes** | Desliga os lembretes e cancela os agendados. O `/status` do usuário mostra "pausado pelo painel" | +| **Tornar admin do bot** / **Remover admin do bot** | Define quem recebe os avisos operacionais no Telegram | +| **Retomar consultas de hoje** | Aparece quando o dia está "sem pontos". Volta a consultar até o fim da janela | + +A linha **Hoje** mostra a situação do dia, quantos pontos foram vistos na última consulta e quando será a próxima. + +### Credencial PontoMais + +Mostra o e-mail, se a credencial está validada, as **falhas seguidas** e o **último erro**. Para trocar a senha (por exemplo, depois que o usuário mudou a senha na PontoMais), preencha de novo e clique em **Validar e salvar**. + +### Jornada e janela de consulta + +| Campo | Padrão | Regras | +| --- | --- | --- | +| Almoço | 60 min | entre 15 e 180 | +| Jornada | 480 min (8h00) | entre 60 e 720 | +| Antecedência do retorno | 5 min | entre 0 e 60 | +| Antecedência da saída | 5 min | entre 0 e 60 | +| Fuso horário | `America/Sao_Paulo` | nome no formato IANA | + +**Regra importante:** *almoço − antecedência do retorno* precisa ser **pelo menos 15 minutos**. Assim o bot tem tempo de perceber a saída para o almoço antes da hora do aviso. Exemplo: almoço de 20 min com antecedência de 5 min passa; almoço de 15 min com antecedência de 5 min é recusado. + +**Janela personalizada:** desmarque **Usar padrão global** para definir uma janela só para este usuário. Os quatro horários aparecem preenchidos com os valores globais, como ponto de partida: + +| Horário | Significado | +| --- | --- | +| Início da janela | primeira consulta do dia | +| Confirmação sem pontos | segunda verificação sem pontos; depois dela, o bot passa a conferir de hora em hora | +| Fim das reverificações | depois desse horário, um dia sem pontos é encerrado | +| Fim da janela | última consulta do dia | + +Os horários precisam ser múltiplos de 15 minutos (:00, :15, :30, :45), estar nessa ordem e somar pelo menos 4 horas de janela. Para voltar à janela global, marque **Usar padrão global** e salve. + +Mudanças valem a partir da próxima consulta. Lembretes que já foram agendados hoje não mudam. + +### Pontos de hoje + +- **Consultar PontoMais agora:** mostra os pontos do dia e os horários previstos. Não é automático, para não gastar consultas à toa. +- **Testar credencial:** igual, mas também marca a credencial como validada se der certo. + +## Lembretes + +Lista os lembretes com filtros por dia, situação, tipo e usuário. + +| Situação | Significado | +| --- | --- | +| **agendado** | aguardando a hora do aviso | +| **enviando** | envio em andamento | +| **enviado** | entregue ao Telegram | +| **falhou** | o Telegram recusou ou não respondeu | +| **cancelado** | não será enviado (ponto seguinte já batido, pausa ou cancelamento manual) | +| **expirado** | o atraso passou de 10 min e o lembrete não foi enviado | + +Ações: + +- **Cancelar:** só para lembretes **agendados**. +- **Reenviar:** só para lembretes que **falharam** e são **do dia de hoje**. + - O usuário precisa estar ativo e com os lembretes ligados. + - Se o ponto seguinte já foi batido, o reenvio é bloqueado. + - Atenção: se a falha foi um travamento no meio do envio, a pessoa **pode já ter recebido** a mensagem original. O painel pergunta antes de reenviar. + +Lembretes **expirados** não podem ser reenviados. + +## Auditoria + +Registra quem fez o quê e quando: cadastros, ativações, mudanças de jornada e de credencial, envios, falhas, pausas, logins, alterações de configuração etc. + +Filtre por período, origem (Telegram, painel, worker, terminal), tipo de evento e resultado. A partir da tela de um usuário, o link **Auditoria deste usuário** já abre filtrado. Use **Mais antigos →** para paginar. + +Senhas e tokens nunca aparecem na auditoria. + +## Feriados + +O bot não consulta nem envia lembretes em fins de semana e feriados. + +- **Sincronizar com a feriadosapi.com:** baixa os feriados do ano para a cidade configurada. O sistema também faz isso sozinho no primeiro uso de cada ano. +- **Cadastrar feriado manual:** para pontos facultativos, recessos ou feriados que a API não trouxe. +- **Remover:** só feriados manuais. + +Se a API de feriados estiver fora do ar e o ano ainda não tiver sido sincronizado, segunda a sexta contam como dias úteis. Os admins do bot recebem um aviso por dia enquanto isso durar. + +## Configurações + +### Janela de consulta global + +Vale para todos os usuários **sem** janela personalizada. O padrão é 11:30 às 19:00, com confirmação às 11:45 e reverificações até as 15:00. + +1. Ajuste os horários. +2. Clique em **Pré-visualizar efeito** para ver: + - quantas consultas por usuário a janela gera (máximo, dia típico e dia de folga); + - quantos usuários teriam tido lembretes atrasados nos últimos 30 dias; + - quantos usuários têm janela personalizada e não serão afetados. +3. Clique em **Salvar**. + +### Parâmetros do servidor + +Só leitura: intervalo das consultas, do envio, tolerância de atraso e limite de falhas. Alterá-los exige mudar a configuração do servidor. + +## Contas do painel (owner) + +- **Nova conta:** usuário (3 a 50 caracteres: letras, números, ponto, hífen ou sublinhado), senha (mínimo 12 caracteres) e papel. +- **Papel:** troque no seletor da linha. +- **Usuário do Telegram:** ao criar a conta, ou depois no seletor da linha, escolha o usuário ao qual ela fica ligada (ou "Sem usuário ligado" para remover o vínculo). Com papel de leitura, isso restringe a conta aos dados desse usuário (ver [Papéis](#papéis)). A troca vale na próxima requisição, sem precisar sair e entrar de novo. +- **Redefinir senha:** define uma nova senha e encerra as sessões abertas daquela conta. +- **Desativar / Reativar:** desativar encerra as sessões; ninguém pode desativar a própria conta. + +Sempre precisa existir **pelo menos um owner ativo**. O sistema recusa rebaixar ou desativar o último. + +## Comandos de admin no Telegram + +Disponíveis para quem é **admin do bot** e está ativo: + +| Comando | O que faz | +| --- | --- | +| `/usuarios` | Lista os usuários com ID, situação, credencial e lembretes | +| `/ativarusuario ` | Ativa (exige credencial validada, configurada no painel) | +| `/desativarusuario ` | Desativa e cancela os lembretes agendados | + +Exemplo: `/ativarusuario 12`. + +## Avisos que o admin do bot recebe + +| Aviso | Quando | O que fazer | +| --- | --- | --- | +| Novo pedido de cadastro | alguém enviou `/cadastrar` | configurar a credencial e ativar | +| Usuário pausado após falhas | 3 falhas seguidas na PontoMais para aquela pessoa | testar a credencial no painel; se a senha mudou, atualizar; pedir `/ativarponto` | +| PontoMais fora do ar / voltou | todas as consultas de um ciclo falharam / alguma voltou a funcionar | aguardar; ninguém é pausado | +| Feriados não obtidos | API de feriados indisponível (no máximo 1 aviso por dia) | sincronizar depois ou cadastrar manualmente | +| Jornadas fora da regra | ao reiniciar o servidor, se alguma jornada ficou com almoço − antecedência menor que o intervalo das consultas | ajustar a jornada na tela do usuário | + +Se não houver nenhum admin do bot ativo, esses avisos ficam só na auditoria. **Mantenha pelo menos um admin do bot.** + +## Rotina sugerida + +**Diariamente** (1 minuto, pela tela **Início**): + +- alguém aguardando aprovação? +- alguém pausado por falha na PontoMais? +- falhas de envio hoje? + +**Mensalmente:** + +- revisar usuários desativados e contas do painel que não são mais usadas; +- conferir se os feriados do ano estão cadastrados; +- confirmar com o operador que os backups estão sendo copiados para fora do servidor. + +## Problemas comuns (administrador) + +| Situação | Causa provável | O que fazer | +| --- | --- | --- | +| "Configure e valide a credencial da PontoMais antes" ao ativar | credencial ausente ou nunca validada | salvar a credencial na tela do usuário | +| "A PontoMais recusou o e-mail ou a senha" | dados errados ou senha alterada | conferir com o usuário; a credencial anterior foi mantida | +| "Não foi possível validar na PontoMais agora" | PontoMais lenta ou fora do ar | tentar mais tarde | +| Usuário diz que não recebe lembretes | lembretes pausados, dia sem pontos, fora da janela ou feriado | tela do usuário (linha **Hoje**), **Lembretes** filtrado pela pessoa e **Auditoria** | +| Muitos lembretes **expirados** de um usuário | sai para o almoço antes do início da janela | dar a ele uma janela personalizada que comece mais cedo | +| Lembrete **falhou** | Telegram recusou (ex.: o usuário bloqueou o bot) ou instabilidade | pedir para o usuário desbloquear/abrir o bot; reenviar se for do dia | +| "Origem da requisição não permitida" ou "Token CSRF inválido" | painel acessado por endereço diferente do configurado, ou cookies antigos | usar sempre o endereço oficial; apagar os cookies do site e entrar de novo | +| Conta de leitura vê "Seu papel não permite esta ação" | tentou abrir tela ou ação fora do que a conta ligada permite | esperado; para ver tudo, usar uma conta sem vínculo ou admin | +| Conta bloqueada | 5 senhas erradas | aguardar 15 minutos ou pedir a um owner para redefinir a senha | diff --git a/postgres/init/01-create-app-role.sh b/postgres/init/01-create-app-role.sh new file mode 100644 index 0000000..ea9a6d4 --- /dev/null +++ b/postgres/init/01-create-app-role.sh @@ -0,0 +1,19 @@ +#!/bin/sh +# Executado pelo entrypoint oficial do Postgres apenas na PRIMEIRA inicialização +# do volume (pasta de dados vazia). Cria o usuário da aplicação, sem privilégios +# de superusuário. As permissões nas tabelas são concedidas pela migration inicial. +# +# Este arquivo é "sourced" pelo entrypoint quando não é executável, por isso +# não usa `set -e` nem `exit`. + +APP_PASSWORD="$(cat /run/secrets/pg_app_password)" + +psql -v ON_ERROR_STOP=1 \ + --username "$POSTGRES_USER" \ + --dbname "$POSTGRES_DB" \ + --set app_password="$APP_PASSWORD" <<'EOSQL' +CREATE ROLE botpontos_app LOGIN PASSWORD :'app_password'; +GRANT CONNECT ON DATABASE botpontos TO botpontos_app; +EOSQL + +unset APP_PASSWORD diff --git a/scripts/backup-loop.sh b/scripts/backup-loop.sh new file mode 100644 index 0000000..6902077 --- /dev/null +++ b/scripts/backup-loop.sh @@ -0,0 +1,28 @@ +#!/bin/sh +# Backup diário do Postgres com retenção (ARQUITETURA.md, 11.3 e 11.5). +# Roda no container "backup". IMPORTANTE: copie a pasta ./backups para fora +# da máquina (NAS ou outro servidor); um backup no mesmo disco não protege +# contra falha do disco. +set -eu + +PGPASSWORD="$(cat /run/secrets/pg_password)" +export PGPASSWORD + +INTERVAL="${BACKUP_INTERVAL_SECONDS:-86400}" +RETENTION_DAYS="${BACKUP_RETENTION_DAYS:-14}" + +while true; do + timestamp="$(date +%Y%m%d-%H%M%S)" + file="/backups/botpontos-${timestamp}.dump" + + if pg_dump -h "${PGHOST:-postgres}" -U "${PGUSER:-botpontos}" -d "${PGDATABASE:-botpontos}" -Fc -f "${file}.tmp"; then + mv "${file}.tmp" "$file" + echo "backup concluído: $file" + else + rm -f "${file}.tmp" + echo "backup FALHOU em ${timestamp}" >&2 + fi + + find /backups -name 'botpontos-*.dump' -mtime +"$RETENTION_DAYS" -delete + sleep "$INTERVAL" +done diff --git a/secrets/README.md b/secrets/README.md new file mode 100644 index 0000000..36f6e08 --- /dev/null +++ b/secrets/README.md @@ -0,0 +1,70 @@ +# Segredos + +Esta pasta guarda os arquivos lidos como Docker secrets. **Nunca** coloque o conteúdo deles em Markdown, no `.env`, em logs ou no chat. + +| Arquivo | Uso | Obrigatório | +| --- | --- | --- | +| `pg_password` | Senha do usuário dono do banco (`botpontos`), usado só pelas migrations e pelo backup | sim | +| `pg_app_password` | Senha do usuário da aplicação (`botpontos_app`), usado pela API e pelo worker | sim | +| `encryption_key` | Chave AES-256 das senhas da PontoMais (mínimo 32 caracteres). **Guarde uma cópia fora do servidor** | sim | +| `telegram_token` | Token do bot criado no @BotFather | sim (o worker não sobe sem ele) | +| `holiday_api_key` | Chave da API feriadosapi.com | o arquivo precisa existir, mas pode ficar vazio | + +## Gerar os arquivos + +No Git Bash ou no Debian, a partir da pasta do projeto: + +```bash +openssl rand -base64 32 | tr -d '\n' > secrets/pg_password +``` + +```bash +openssl rand -base64 32 | tr -d '\n' > secrets/pg_app_password +``` + +```bash +openssl rand -base64 48 | tr -d '\n' > secrets/encryption_key +``` + +O token do Telegram e a chave da feriadosapi.com são colados à mão, sem quebra de linha no final: + +```bash +printf '%s' 'COLE_O_TOKEN_AQUI' > secrets/telegram_token +``` + +```bash +printf '%s' 'COLE_A_CHAVE_AQUI' > secrets/holiday_api_key +``` + +Sem chave de feriados, crie o arquivo vazio: + +```bash +: > secrets/holiday_api_key +``` + +## Permissões no servidor Debian + +Restrinja **pela pasta**, não pelos arquivos: + +```bash +chmod 700 secrets +``` + +```bash +chmod 644 secrets/* +``` + +O Docker Compose monta cada arquivo dentro do container mantendo dono e permissões do host. Os arquivos precisam ser legíveis pelos usuários dos containers (Postgres usa UID 70; a API e o worker, UID 65532). Com `chmod 600`, eles não conseguiriam ler. Quem protege os segredos no host é a pasta com `700`. + +## Atenção + +- `pg_password` e `pg_app_password` são lidas pelo Postgres **apenas na primeira inicialização** do volume `pgdata`. Trocar os arquivos depois não troca as senhas no banco. +- **Sem a `encryption_key`, as senhas da PontoMais gravadas não podem ser recuperadas.** Guarde uma cópia num cofre de senhas. + +## Trocar a chave de criptografia + +1. Copie a chave atual para `secrets/encryption_key_previous` e gere uma nova `secrets/encryption_key`. +2. No `.env`, aumente `BP_ENCRYPTION_KEY_VERSION` (ex.: de 1 para 2). +3. No `docker-compose.yml`, adicione o segredo `encryption_key_previous` e as variáveis `BP_ENCRYPTION_PREVIOUS_KEY_FILE` e `BP_ENCRYPTION_PREVIOUS_KEY_VERSION` (com a versão antiga) aos serviços `api` e `worker`. +4. Suba os containers e rode `docker compose run --rm api admin rotate-key`. +5. Depois que o comando informar que tudo foi recifrado, remova a chave anterior do Compose e da pasta. diff --git a/web/.dockerignore b/web/.dockerignore new file mode 100644 index 0000000..b364aeb --- /dev/null +++ b/web/.dockerignore @@ -0,0 +1,3 @@ +node_modules +.next +*.md diff --git a/web/Dockerfile b/web/Dockerfile new file mode 100644 index 0000000..56b352e --- /dev/null +++ b/web/Dockerfile @@ -0,0 +1,32 @@ +# syntax=docker/dockerfile:1 + +# ---- dependências ---- +FROM node:22-alpine AS deps +WORKDIR /app +COPY package.json package-lock.json* ./ +# Enquanto o package-lock.json não existir no repositório, usa npm install (ver README). +RUN if [ -f package-lock.json ]; then npm ci; else npm install; fi + +# ---- build ---- +FROM node:22-alpine AS build +WORKDIR /app +ENV NEXT_TELEMETRY_DISABLED=1 +# Os rewrites do Next são resolvidos no build, então o endereço da API entra aqui. +ARG API_INTERNAL_URL=http://api:8080 +ENV API_INTERNAL_URL=${API_INTERNAL_URL} +COPY --from=deps /app/node_modules ./node_modules +COPY . . +RUN npm run build + +# ---- runtime ---- +FROM node:22-alpine AS run +WORKDIR /app +ENV NODE_ENV=production \ + NEXT_TELEMETRY_DISABLED=1 \ + PORT=3000 \ + HOSTNAME=0.0.0.0 +COPY --from=build --chown=node:node /app/.next/standalone ./ +COPY --from=build --chown=node:node /app/.next/static ./.next/static +USER node +EXPOSE 3000 +CMD ["node", "server.js"] diff --git a/web/app/(painel)/auditoria/page.tsx b/web/app/(painel)/auditoria/page.tsx new file mode 100644 index 0000000..f0324f9 --- /dev/null +++ b/web/app/(painel)/auditoria/page.tsx @@ -0,0 +1,103 @@ +import Link from "next/link"; +import { redirect } from "next/navigation"; +import { apiGet } from "@/lib/server-api"; +import { fmtDateTime } from "@/lib/format"; +import { isRestricted } from "@/lib/access"; +import type { AuditLog, Me } from "@/lib/types"; + +type Search = Promise>; + +const FILTERS = ["from", "to", "source", "event_type", "success", "target_user_id", "actor_account"]; + +export default async function AuditPage({ searchParams }: { searchParams: Search }) { + const me = await apiGet("/auth/me"); + if (isRestricted(me.account)) redirect("/"); + const sp = await searchParams; + const params = new URLSearchParams(); + for (const key of [...FILTERS, "before"]) { + const v = sp[key]; + if (typeof v === "string" && v) params.set(key, v); + } + const { logs, next_before } = await apiGet<{ logs: AuditLog[]; next_before: number | null }>(`/audit?${params}`); + const value = (key: string) => (typeof sp[key] === "string" ? (sp[key] as string) : ""); + + const nextParams = new URLSearchParams(params); + if (next_before) nextParams.set("before", String(next_before)); + + return ( + <> +

Auditoria

+
+ + + + + + {value("target_user_id") && } + + Limpar +
+ +
+ + + + + + + + + + + + + + {logs.length === 0 && ( + + + + )} + {logs.map((l) => ( + + + + + + + + + + ))} + +
QuandoOrigemQuemEventoAlvoResultadoDetalhes
+ Nenhum evento encontrado. +
{fmtDateTime(l.occurred_at)}{l.source}{l.actor_username ?? l.actor_user_name ?? "—"} + {l.event_type} + {l.target_user_id ? {l.target_user_name} : "—"}{l.success ? sucesso : falha} + {Object.keys(l.details).length > 0 && {JSON.stringify(l.details)}} +
+
+ {next_before && ( +

+ Mais antigos → +

+ )} + + ); +} diff --git a/web/app/(painel)/configuracoes/form.tsx b/web/app/(painel)/configuracoes/form.tsx new file mode 100644 index 0000000..72f4ce6 --- /dev/null +++ b/web/app/(painel)/configuracoes/form.tsx @@ -0,0 +1,88 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useState, type FormEvent } from "react"; +import { WindowFields } from "@/components/WindowFields"; +import { apiFetch, errorMessage } from "@/lib/client-api"; +import type { QueryWindow, WindowPreview } from "@/lib/types"; + +export function SettingsForm({ initial, disabled }: { initial: QueryWindow; disabled: boolean }) { + const router = useRouter(); + const [value, setValue] = useState(initial); + const [preview, setPreview] = useState(null); + const [message, setMessage] = useState<{ ok: boolean; text: string } | null>(null); + const [busy, setBusy] = useState(false); + + async function runPreview() { + setBusy(true); + setMessage(null); + try { + setPreview(await apiFetch("/settings/preview", { method: "POST", body: value })); + } catch (err) { + setMessage({ ok: false, text: errorMessage(err) }); + } finally { + setBusy(false); + } + } + + async function save(e: FormEvent) { + e.preventDefault(); + if (!window.confirm("Salvar a nova janela global? Ela vale para todos os usuários sem janela personalizada.")) return; + setBusy(true); + setMessage(null); + try { + await apiFetch("/settings", { method: "PUT", body: value }); + setMessage({ ok: true, text: "Janela salva." }); + router.refresh(); + } catch (err) { + setMessage({ ok: false, text: errorMessage(err) }); + } finally { + setBusy(false); + } + } + + return ( +
+ { setValue(w); setPreview(null); }} disabled={disabled} /> + {!disabled && ( +
+ + +
+ )} + {message &&

{message.text}

} + + {preview && ( +
+ {preview.errors.length > 0 ? ( +
    + {preview.errors.map((e) => ( +
  • {e.message}
  • + ))} +
+ ) : ( +
+
Consultas por usuário (máximo)
+
{preview.max_queries_per_user} por dia
+
Consultas num dia típico
+
{preview.typical_queries_per_user} por usuário
+
Consultas num dia de folga
+
{preview.day_off_queries_per_user} por usuário
+
Lembretes que sairiam atrasados
+
+ {preview.affected_users} usuário(s), {preview.affected_days} dia(s) nos últimos 30 dias (aviso de retorno + antes do início da janela) +
+
Não afetados
+
{preview.custom_window_users} usuário(s) com janela personalizada
+
+ )} +
+ )} + + ); +} diff --git a/web/app/(painel)/configuracoes/page.tsx b/web/app/(painel)/configuracoes/page.tsx new file mode 100644 index 0000000..ba95590 --- /dev/null +++ b/web/app/(painel)/configuracoes/page.tsx @@ -0,0 +1,43 @@ +import { redirect } from "next/navigation"; +import { isRestricted } from "@/lib/access"; +import { apiGet } from "@/lib/server-api"; +import type { Me, Settings } from "@/lib/types"; +import { SettingsForm } from "./form"; + +export default async function SettingsPage() { + const me = await apiGet("/auth/me"); + if (isRestricted(me.account)) redirect("/"); + const settings = await apiGet("/settings"); + + return ( + <> +

Configurações

+ +
+

Janela de consulta global

+

+ A PontoMais só é consultada dentro desta janela, no fuso de cada usuário. Usuários com janela personalizada não + são afetados por mudanças aqui. A mudança vale a partir da próxima consulta. +

+ +
+ +
+

Parâmetros do servidor

+

Definidos por variáveis de ambiente; alterá-los exige reiniciar os containers.

+
+
Reconciliação
+
a cada {settings.reconciliation_minutes} min
+
Consultas lentas
+
a cada {settings.slow_check_minutes} min (dia sem pontos e tarde com 3 pontos)
+
Despacho dos lembretes
+
a cada {settings.dispatch_minutes} min
+
Tolerância de atraso
+
{settings.late_tolerance_minutes} min (acima disso o lembrete expira)
+
Pausa por falhas
+
após {settings.api_failure_threshold} falhas seguidas na PontoMais
+
+
+ + ); +} diff --git a/web/app/(painel)/contas/forms.tsx b/web/app/(painel)/contas/forms.tsx new file mode 100644 index 0000000..b67291b --- /dev/null +++ b/web/app/(painel)/contas/forms.tsx @@ -0,0 +1,164 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useState, type FormEvent } from "react"; +import { ActionButton } from "@/components/ActionButton"; +import { apiFetch, errorMessage } from "@/lib/client-api"; +import type { Account, Role } from "@/lib/types"; + +const ROLES: { value: Role; label: string }[] = [ + { value: "viewer", label: "leitura" }, + { value: "admin", label: "admin" }, + { value: "owner", label: "owner" }, +]; + +export type UserOption = { id: number; name: string }; + +function UserSelect({ + users, + value, + onChange, + label, +}: { + users: UserOption[]; + value: number | null; + onChange: (id: number | null) => void; + label: string; +}) { + return ( + + ); +} + +export function CreateAccountForm({ users }: { users: UserOption[] }) { + const router = useRouter(); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [role, setRole] = useState("admin"); + const [userId, setUserId] = useState(null); + const [message, setMessage] = useState<{ ok: boolean; text: string } | null>(null); + const [busy, setBusy] = useState(false); + + async function submit(e: FormEvent) { + e.preventDefault(); + setBusy(true); + setMessage(null); + try { + await apiFetch("/accounts", { method: "POST", body: { username, password, role, user_id: userId } }); + setUsername(""); + setUserId(null); + setPassword(""); + setMessage({ ok: true, text: "Conta criada." }); + router.refresh(); + } catch (err) { + setMessage({ ok: false, text: errorMessage(err) }); + } finally { + setBusy(false); + } + } + + return ( +
+ + + + + + {message &&

{message.text}

} +
+ ); +} + +export function AccountActions({ account, isSelf, users }: { account: Account; isSelf: boolean; users: UserOption[] }) { + const router = useRouter(); + const [error, setError] = useState(""); + + async function changeRole(role: Role) { + setError(""); + try { + await apiFetch(`/accounts/${account.id}`, { method: "PATCH", body: { role } }); + router.refresh(); + } catch (err) { + setError(errorMessage(err)); + } + } + + async function changeUser(userId: number | null) { + setError(""); + try { + await apiFetch(`/accounts/${account.id}`, { method: "PATCH", body: { user_id: userId } }); + router.refresh(); + } catch (err) { + setError(errorMessage(err)); + } + } + + async function resetPassword() { + const password = window.prompt(`Nova senha para ${account.username} (mínimo 12 caracteres):`); + if (!password) return; + setError(""); + try { + await apiFetch(`/accounts/${account.id}/reset-password`, { method: "POST", body: { password } }); + window.alert("Senha redefinida. As sessões abertas dessa conta foram encerradas."); + } catch (err) { + setError(errorMessage(err)); + } + } + + return ( +
+ + + + {!isSelf && + (account.disabled ? ( + + ) : ( + + ))} + {error && {error}} +
+ ); +} diff --git a/web/app/(painel)/contas/page.tsx b/web/app/(painel)/contas/page.tsx new file mode 100644 index 0000000..b6b857e --- /dev/null +++ b/web/app/(painel)/contas/page.tsx @@ -0,0 +1,84 @@ +import { ForbiddenError, apiGet } from "@/lib/server-api"; +import { fmtDateTime } from "@/lib/format"; +import type { Account, Me, UserSummary } from "@/lib/types"; +import { AccountActions, CreateAccountForm } from "./forms"; + +export default async function AccountsPage() { + const me = await apiGet("/auth/me"); + if (me.account.role !== "owner") { + return ( + <> +

Contas do painel

+

Somente contas owner gerenciam as contas do painel.

+ + ); + } + + let accounts: Account[] = []; + let users: UserSummary[] = []; + try { + [accounts, users] = await Promise.all([ + apiGet<{ accounts: Account[] }>("/accounts").then((r) => r.accounts), + apiGet<{ users: UserSummary[] }>("/users").then((r) => r.users), + ]); + } catch (err) { + if (!(err instanceof ForbiddenError)) throw err; + } + const userOptions = users.map((u) => ({ id: u.id, name: u.display_name })); + + return ( + <> +

Contas do painel

+

+ owner gerencia contas; admin altera usuários, lembretes, feriados e + configurações; leitura só consulta. Uma conta de leitura ligada a um usuário do Telegram enxerga e + altera somente os dados desse usuário. Sempre existe pelo menos um owner ativo. +

+ +
+ + + + + + + + + + + + + {accounts.map((a) => ( + + + + + + + + + ))} + +
UsuárioPapelUsuário do TelegramSituaçãoÚltimo login
+ {a.username} + {a.id === me.account.id && (você)} + {a.role === "viewer" ? "leitura" : a.role}{userOptions.find((u) => u.id === a.user_id)?.name ?? —} + {a.disabled ? ( + desativada + ) : a.locked_until && new Date(a.locked_until) > new Date() ? ( + bloqueada até {fmtDateTime(a.locked_until)} + ) : ( + ativa + )} + {fmtDateTime(a.last_login_at)} + +
+
+ +
+

Nova conta

+ +
+ + ); +} diff --git a/web/app/(painel)/feriados/form.tsx b/web/app/(painel)/feriados/form.tsx new file mode 100644 index 0000000..b221978 --- /dev/null +++ b/web/app/(painel)/feriados/form.tsx @@ -0,0 +1,46 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useState, type FormEvent } from "react"; +import { apiFetch, errorMessage } from "@/lib/client-api"; + +export function AddHolidayForm() { + const router = useRouter(); + const [day, setDay] = useState(""); + const [name, setName] = useState(""); + const [error, setError] = useState(""); + const [busy, setBusy] = useState(false); + + async function submit(e: FormEvent) { + e.preventDefault(); + setBusy(true); + setError(""); + try { + await apiFetch("/holidays", { method: "POST", body: { day, name, source: "manual" } }); + setDay(""); + setName(""); + router.refresh(); + } catch (err) { + setError(errorMessage(err)); + } finally { + setBusy(false); + } + } + + return ( +
+ + + + {error &&

{error}

} +
+ ); +} diff --git a/web/app/(painel)/feriados/page.tsx b/web/app/(painel)/feriados/page.tsx new file mode 100644 index 0000000..9c0fd52 --- /dev/null +++ b/web/app/(painel)/feriados/page.tsx @@ -0,0 +1,86 @@ +import Link from "next/link"; +import { ActionButton } from "@/components/ActionButton"; +import { apiGet } from "@/lib/server-api"; +import { fmtDay } from "@/lib/format"; +import type { Holiday, Me } from "@/lib/types"; +import { AddHolidayForm } from "./form"; + +type Search = Promise>; + +export default async function HolidaysPage({ searchParams }: { searchParams: Search }) { + const sp = await searchParams; + const yearParam = typeof sp.year === "string" ? sp.year : ""; + const [{ year, holidays }, me] = await Promise.all([ + apiGet<{ year: number; holidays: Holiday[] }>(`/holidays${yearParam ? `?year=${encodeURIComponent(yearParam)}` : ""}`), + apiGet("/auth/me"), + ]); + const canEdit = me.account.role !== "viewer"; + + return ( + <> +

Feriados de {year}

+

+ Dias úteis: fim de semana não conta; depois vale esta tabela (feriados da feriadosapi.com e manuais). Se a API não + responder e o ano não tiver sido sincronizado, segunda a sexta contam como úteis e os admins são avisados. +

+

+ ← {year - 1} · {year + 1} → +

+ + {canEdit && ( +
+ +
+ )} + +
+ + + + + + + {canEdit && } + + + + {holidays.length === 0 && ( + + + + )} + {holidays.map((h) => ( + + + + + {canEdit && ( + + )} + + ))} + +
DataNomeOrigem
+ Nenhum feriado registrado para {year}. +
{fmtDay(h.day)}{h.name}{h.source === "manual" ? manual : h.source} + {h.source === "manual" && ( + + )} +
+
+ + {canEdit && ( +
+

Cadastrar feriado manual

+ +
+ )} + + ); +} diff --git a/web/app/(painel)/layout.tsx b/web/app/(painel)/layout.tsx new file mode 100644 index 0000000..18b0dca --- /dev/null +++ b/web/app/(painel)/layout.tsx @@ -0,0 +1,44 @@ +import Link from "next/link"; +import type { ReactNode } from "react"; +import { LogoutButton } from "@/components/LogoutButton"; +import { apiGet } from "@/lib/server-api"; +import { isRestricted } from "@/lib/access"; +import type { Me } from "@/lib/types"; + +export const dynamic = "force-dynamic"; + +const roleLabel = { owner: "owner", admin: "admin", viewer: "leitura" } as const; + +export default async function PainelLayout({ children }: { children: ReactNode }) { + const me = await apiGet("/auth/me"); + const isOwner = me.account.role === "owner"; + const restricted = isRestricted(me.account); + + return ( + <> +
+
+ + Bot de Pontos + + +
+ + {me.account.username} ({roleLabel[me.account.role]}) + + +
+
+
+
{children}
+ + ); +} diff --git a/web/app/(painel)/notificacoes/page.tsx b/web/app/(painel)/notificacoes/page.tsx new file mode 100644 index 0000000..3eebf54 --- /dev/null +++ b/web/app/(painel)/notificacoes/page.tsx @@ -0,0 +1,112 @@ +import Link from "next/link"; +import { ActionButton } from "@/components/ActionButton"; +import { apiGet } from "@/lib/server-api"; +import { badgeClass, fmtClock, fmtDay, notificationStatusLabel } from "@/lib/format"; +import type { Me, Notification } from "@/lib/types"; + +type Search = Promise>; + +export default async function NotificationsPage({ searchParams }: { searchParams: Search }) { + const sp = await searchParams; + const params = new URLSearchParams(); + for (const key of ["day", "status", "type", "user_id"]) { + const v = sp[key]; + if (typeof v === "string" && v) params.set(key, v); + } + const [{ notifications }, me] = await Promise.all([ + apiGet<{ notifications: Notification[] }>(`/notifications?${params}`), + apiGet("/auth/me"), + ]); + const canEdit = me.account.role !== "viewer"; + const value = (key: string) => (typeof sp[key] === "string" ? (sp[key] as string) : ""); + + return ( + <> +

Lembretes

+
+ + + + {value("user_id") && } + + Limpar +
+ +

+ expirado = chegou tarde demais (mais de 10 min de atraso) e não foi enviado;{" "} + falhou = erro no envio pelo Telegram. Só lembretes com falha do dia atual podem ser reenviados. +

+ +
+ + + + + + + + + + + {canEdit && } + + + + {notifications.length === 0 && ( + + + + )} + {notifications.map((n) => ( + + + + + + + + + {canEdit && ( + + )} + + ))} + +
DiaUsuárioTipoAvisoPrevistoStatusDetalhe
+ Nenhum lembrete encontrado. +
{fmtDay(n.reference_date)} + {n.user_name} + {n.type === "entrada2" ? "retorno" : "saída"}{fmtClock(n.scheduled_at)}{fmtClock(n.predicted_at)} + {notificationStatusLabel[n.status]} + {n.error_message ?? (n.sent_at ? `enviado às ${fmtClock(n.sent_at)}` : "")} + {n.status === "scheduled" && ( + + )} + {n.status === "failed" && ( + + )} +
+
+ + ); +} diff --git a/web/app/(painel)/page.tsx b/web/app/(painel)/page.tsx new file mode 100644 index 0000000..b9d8151 --- /dev/null +++ b/web/app/(painel)/page.tsx @@ -0,0 +1,79 @@ +import Link from "next/link"; +import { apiGet } from "@/lib/server-api"; +import { fmtDateTime, fmtDay, notificationStatusLabel } from "@/lib/format"; +import { isRestricted } from "@/lib/access"; +import type { Dashboard, Me } from "@/lib/types"; + +export default async function DashboardPage() { + const [d, me] = await Promise.all([apiGet("/dashboard"), apiGet("/auth/me")]); + const users = d.users_by_status; + const restricted = isRestricted(me.account); + + return ( + <> +

Início

+ + {d.outage_since && ( +
+ A PontoMais está fora do ar desde {fmtDateTime(d.outage_since)}. Ninguém foi pausado; os admins foram avisados + pelo Telegram. +
+ )} + {d.holiday_fallback_day && ( +
+ Não foi possível obter os feriados deste ano: segunda a sexta estão valendo como dias úteis.{" "} + Ver feriados +
+ )} + + {!restricted && ( +
+ + + + + + + +
+ )} + +
+

Lembretes de hoje ({fmtDay(d.today)})

+ {Object.keys(d.today_by_status).length === 0 ? ( +

Nenhum lembrete gerado hoje.

+ ) : ( +
    + {Object.entries(d.today_by_status).map(([status, count]) => ( +
  • + {restricted ? ( + + {notificationStatusLabel[status] ?? status}: {count} + + ) : ( + + {notificationStatusLabel[status] ?? status}: {count} + + )} +
  • + ))} +
+ )} +
+ + ); +} + +function Stat({ label, value, href, tone }: { label: string; value: number; href?: string; tone?: "error" }) { + const content = ( + <> + {value} + {label} + + ); + return ( +
+ {href ? {content} : content} +
+ ); +} diff --git a/web/app/(painel)/usuarios/[id]/forms.tsx b/web/app/(painel)/usuarios/[id]/forms.tsx new file mode 100644 index 0000000..8c2737e --- /dev/null +++ b/web/app/(painel)/usuarios/[id]/forms.tsx @@ -0,0 +1,209 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useState, type ChangeEvent, type FormEvent } from "react"; +import { WindowFields } from "@/components/WindowFields"; +import { apiFetch, errorMessage } from "@/lib/client-api"; +import { dayStatusLabel, fmtClock, fmtMinutes } from "@/lib/format"; +import type { QueryWindow, Schedule, Today } from "@/lib/types"; + +/** Grava e valida a credencial na PontoMais; a anterior é mantida se a validação falhar. */ +export function CredentialsForm({ userId }: { userId: number }) { + const router = useRouter(); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [message, setMessage] = useState<{ ok: boolean; text: string } | null>(null); + const [busy, setBusy] = useState(false); + + async function submit(e: FormEvent) { + e.preventDefault(); + setBusy(true); + setMessage(null); + try { + await apiFetch(`/users/${userId}/credentials`, { method: "PUT", body: { email, password } }); + setPassword(""); + setMessage({ ok: true, text: "Credencial validada na PontoMais e gravada." }); + router.refresh(); + } catch (err) { + setMessage({ ok: false, text: errorMessage(err) }); + } finally { + setBusy(false); + } + } + + return ( +
+ + + + {message &&

{message.text}

} +
+ ); +} + +export function ScheduleForm({ + userId, + schedule, + timezone, + effectiveWindow, + disabled, +}: { + userId: number; + schedule: Schedule; + timezone: string; + effectiveWindow: QueryWindow; + disabled: boolean; +}) { + const router = useRouter(); + const [form, setForm] = useState({ + lunch_minutes: schedule.lunch_minutes, + daily_work_minutes: schedule.daily_work_minutes, + entrada2_lead_minutes: schedule.entrada2_lead_minutes, + saida2_lead_minutes: schedule.saida2_lead_minutes, + timezone, + }); + const [useGlobal, setUseGlobal] = useState(schedule.window === null); + const [windowValue, setWindowValue] = useState(schedule.window ?? effectiveWindow); + const [message, setMessage] = useState<{ ok: boolean; text: string } | null>(null); + const [busy, setBusy] = useState(false); + + function number(key: keyof typeof form) { + return (e: ChangeEvent) => setForm({ ...form, [key]: Number(e.target.value) }); + } + + async function submit(e: FormEvent) { + e.preventDefault(); + setBusy(true); + setMessage(null); + try { + await apiFetch(`/users/${userId}/schedule`, { + method: "PUT", + body: { ...form, window: useGlobal ? null : windowValue }, + }); + setMessage({ ok: true, text: "Jornada salva. Vale a partir da próxima consulta." }); + router.refresh(); + } catch (err) { + setMessage({ ok: false, text: errorMessage(err) }); + } finally { + setBusy(false); + } + } + + return ( +
+
+ + + + +
+ + +
+ Janela de consulta + + {useGlobal ? ( +

+ Janela global: {effectiveWindow.query_window_start} às {effectiveWindow.query_window_end} (confirmação{" "} + {effectiveWindow.day_check_deadline}, reverificações até {effectiveWindow.no_points_recheck_until}). +

+ ) : ( + + )} +
+ + {!disabled && ( + + )} + {message &&

{message.text}

} +
+ ); +} + +/** Consulta ao vivo na PontoMais (não é automática para não gastar consultas à toa). */ +export function TodayPanel({ userId, canTest }: { userId: number; canTest: boolean }) { + const [today, setToday] = useState(null); + const [error, setError] = useState(""); + const [busy, setBusy] = useState(false); + + async function load(test: boolean) { + setBusy(true); + setError(""); + try { + const path = test ? `/users/${userId}/credentials/test` : `/users/${userId}/today`; + setToday(await apiFetch(path, { method: test ? "POST" : "GET" })); + } catch (err) { + setError(errorMessage(err)); + } finally { + setBusy(false); + } + } + + return ( +
+
+ + {canTest && ( + + )} +
+ {error &&

{error}

} + {today && ( +
+
Pontos
+
{today.points.length ? today.points.map((p) => fmtClock(p, today.timezone)).join(" · ") : "nenhum"}
+ {today.invalid && ( + <> +
Atenção
+
pontos repetidos ou fora de ordem
+ + )} + {today.overtime && ( + <> +
Atenção
+
mais de quatro pontos (hora extra, fora do escopo)
+ + )} +
Retorno previsto
+
{fmtClock(today.entrada2_at, today.timezone)}
+
Saída prevista
+
{today.saida2_error ? {today.saida2_error} : fmtClock(today.saida2_at, today.timezone)}
+
Situação do dia
+
{dayStatusLabel[today.day_state.status]}
+
+ )} +
+ ); +} diff --git a/web/app/(painel)/usuarios/[id]/page.tsx b/web/app/(painel)/usuarios/[id]/page.tsx new file mode 100644 index 0000000..e64f93d --- /dev/null +++ b/web/app/(painel)/usuarios/[id]/page.tsx @@ -0,0 +1,137 @@ +import Link from "next/link"; +import { apiGet } from "@/lib/server-api"; +import { badgeClass, dayStatusLabel, fmtDateTime, pauseReasonLabel, userStatusLabel } from "@/lib/format"; +import { isRestricted } from "@/lib/access"; +import type { Me, UserDetail } from "@/lib/types"; +import { ActionButton } from "@/components/ActionButton"; +import { CredentialsForm, ScheduleForm, TodayPanel } from "./forms"; + +export default async function UserPage({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + const [detail, me] = await Promise.all([apiGet(`/users/${id}`), apiGet("/auth/me")]); + const { user, credential, today } = detail; + const restricted = isRestricted(me.account); + const canAdmin = me.account.role !== "viewer"; + // Conta de leitura ligada a este usuário também altera as próprias configurações. + const canEdit = canAdmin || me.account.user_id === user.id; + const base = `/users/${user.id}`; + + return ( + <> + {!restricted && ( +

+ ← Usuários +

+ )} +

+ {user.display_name} {userStatusLabel[user.status]} +

+

+ ID {user.id} · chat {user.telegram_chat_id} · cadastrado em {fmtDateTime(user.created_at)} +

+ +
+

Cadastro e lembretes

+
+
Lembretes
+
+ {user.notifications_enabled + ? "ligados" + : user.paused_reason + ? pauseReasonLabel[user.paused_reason] + : "desligados"} +
+
Admin do bot
+
{user.is_bot_admin ? "sim" : "não"}
+
Hoje ({today.day})
+
+ {dayStatusLabel[today.status]}{" "} + + {today.last_point_count} ponto(s) na última consulta · próxima consulta: {fmtDateTime(today.next_check_at)} + +
+
+ {canEdit && ( +
+ {canAdmin && user.status !== "active" && } + {canAdmin && user.status !== "disabled" && ( + + )} + {user.status === "active" && + (user.notifications_enabled ? ( + + ) : ( + + ))} + {canAdmin && ( + + )} + {canAdmin && (today.status === "no_points" || today.status === "not_working") && ( + + )} +
+ )} +
+ +
+

Credencial PontoMais

+ {credential ? ( +
+
E-mail
+
{credential.api_email}
+
Situação
+
+ {credential.validated ? ( + validada em {fmtDateTime(credential.last_validated_at)} + ) : ( + não validada + )} +
+
Falhas seguidas
+
{credential.consecutive_failures}
+ {credential.last_error && ( + <> +
Último erro
+
{credential.last_error}
+ + )} +
+ ) : ( +

Nenhuma credencial configurada. Sem ela o usuário não pode ser ativado.

+ )} + {canEdit && } +
+ +
+

Jornada e janela de consulta

+ +
+ +
+

Pontos de hoje

+ +
+ +

+ Lembretes deste usuário ·{" "} + {!restricted && Auditoria deste usuário} +

+ + ); +} diff --git a/web/app/(painel)/usuarios/page.tsx b/web/app/(painel)/usuarios/page.tsx new file mode 100644 index 0000000..8ebb067 --- /dev/null +++ b/web/app/(painel)/usuarios/page.tsx @@ -0,0 +1,97 @@ +import Link from "next/link"; +import { redirect } from "next/navigation"; +import { apiGet } from "@/lib/server-api"; +import { badgeClass, pauseReasonLabel, userStatusLabel } from "@/lib/format"; +import { isRestricted } from "@/lib/access"; +import type { Me, UserSummary } from "@/lib/types"; + +type Search = Promise>; + +export default async function UsersPage({ searchParams }: { searchParams: Search }) { + const me = await apiGet("/auth/me"); + if (isRestricted(me.account)) redirect(`/usuarios/${me.account.user_id}`); + const sp = await searchParams; + const params = new URLSearchParams(); + for (const key of ["status", "q", "custom_window"]) { + const v = sp[key]; + if (typeof v === "string" && v) params.set(key, v); + } + const { users } = await apiGet<{ users: UserSummary[] }>(`/users?${params}`); + + return ( + <> +

Usuários

+
+ + + + +
+ +
+ + + + + + + + + + + + + {users.length === 0 && ( + + + + )} + {users.map((u) => ( + + + + + + + + + ))} + +
IDNomeStatusCredencialLembretesJanela
+ Nenhum usuário encontrado. +
{u.id} + {u.display_name} + {u.is_bot_admin && admin do bot} + + {userStatusLabel[u.status]} + + {!u.has_credential ? ( + não configurada + ) : u.credential_validated ? ( + validada + ) : ( + não validada + )} + {u.consecutive_failures > 0 && {u.consecutive_failures} falha(s)} + + {u.notifications_enabled ? ( + ligados + ) : ( + {u.paused_reason ? pauseReasonLabel[u.paused_reason] : "desligados"} + )} + {u.custom_window ? personalizada : global}
+
+ + ); +} diff --git a/web/app/globals.css b/web/app/globals.css new file mode 100644 index 0000000..5d00299 --- /dev/null +++ b/web/app/globals.css @@ -0,0 +1,385 @@ +/* Estilo do painel: CSS próprio, sem framework (ARQUITETURA.md, decisão 27). */ +:root { + --bg: #f5f6f8; + --surface: #ffffff; + --text: #1c2024; + --muted: #5f6b7a; + --border: #dde1e6; + --accent: #2457c5; + --accent-text: #ffffff; + --ok-bg: #e3f5e8; + --ok-text: #1d6b34; + --warn-bg: #fdf3d8; + --warn-text: #7a5300; + --error-bg: #fde8e8; + --error-text: #a61b1b; + --radius: 8px; +} + +@media (prefers-color-scheme: dark) { + :root { + --bg: #111418; + --surface: #1a1f25; + --text: #e6e9ed; + --muted: #9aa5b1; + --border: #2b323b; + --accent: #5b8cff; + --accent-text: #0b1020; + --ok-bg: #173a24; + --ok-text: #7fd89a; + --warn-bg: #3a3014; + --warn-text: #f0c96a; + --error-bg: #3d1a1a; + --error-text: #f39b9b; + } +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + background: var(--bg); + color: var(--text); + font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + font-size: 15px; + line-height: 1.5; +} + +a { + color: var(--accent); + text-decoration: none; +} +a:hover { + text-decoration: underline; +} + +h1 { + font-size: 1.5rem; + margin: 0 0 16px; +} +h2 { + font-size: 1.1rem; + margin: 0 0 12px; +} + +code { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.85em; +} + +/* --- estrutura --- */ +.container { + max-width: 1100px; + margin: 0 auto; + padding: 24px 16px 48px; +} + +.topbar { + background: var(--surface); + border-bottom: 1px solid var(--border); +} +.topbar-inner { + max-width: 1100px; + margin: 0 auto; + padding: 10px 16px; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 24px; +} +.brand { + font-weight: 700; + color: var(--text); +} +.topbar nav { + display: flex; + flex-wrap: wrap; + gap: 4px 16px; + flex: 1; +} +.whoami { + display: flex; + align-items: center; + gap: 8px; +} + +.card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 16px 20px; + margin: 0 0 16px; +} + +.login { + min-height: 100vh; + display: grid; + place-items: center; + padding: 16px; +} +.login .card { + width: 100%; + max-width: 360px; +} +.login .btn { + width: 100%; +} + +/* --- textos e selos --- */ +.muted { + color: var(--muted); +} +.small { + font-size: 0.85rem; +} +.nowrap { + white-space: nowrap; +} +.error-text { + color: var(--error-text); +} +.ok-text { + color: var(--ok-text); +} + +.badge { + display: inline-block; + padding: 1px 8px; + border-radius: 999px; + font-size: 0.8rem; + font-weight: 600; + background: var(--border); + white-space: nowrap; +} +.badge.ok { + background: var(--ok-bg); + color: var(--ok-text); +} +.badge.warn { + background: var(--warn-bg); + color: var(--warn-text); +} +.badge.error { + background: var(--error-bg); + color: var(--error-text); +} + +.alert { + border-radius: var(--radius); + padding: 12px 16px; + margin-bottom: 16px; +} +.alert.warn { + background: var(--warn-bg); + color: var(--warn-text); +} +.alert.error { + background: var(--error-bg); + color: var(--error-text); +} + +/* --- botões e formulários --- */ +.btn { + font: inherit; + border-radius: 6px; + padding: 6px 14px; + border: 1px solid var(--border); + background: var(--surface); + color: var(--text); + cursor: pointer; +} +.btn:disabled { + opacity: 0.6; + cursor: progress; +} +.btn.primary { + background: var(--accent); + border-color: var(--accent); + color: var(--accent-text); +} +.btn.danger { + border-color: var(--error-text); + color: var(--error-text); +} +.btn.link { + border: none; + background: none; + color: var(--accent); + padding: 0; +} + +.action { + display: inline-flex; + flex-direction: column; + gap: 4px; +} +.actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: flex-start; + margin: 12px 0; +} + +input, +select { + font: inherit; + color: var(--text); + background: var(--surface); + border: 1px solid var(--border); + border-radius: 6px; + padding: 6px 10px; + max-width: 100%; +} + +.field { + display: flex; + flex-direction: column; + gap: 4px; + margin-bottom: 12px; +} +.field > span { + font-size: 0.9rem; + font-weight: 600; +} + +.checkbox { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 8px; +} + +.fieldset { + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 12px 16px; + margin: 8px 0 16px; +} + +.grid-4 { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 0 16px; +} + +.inline-form { + display: flex; + flex-wrap: wrap; + gap: 0 16px; + align-items: flex-end; +} +.inline-form .btn { + margin-bottom: 12px; +} +.inline-form p { + flex-basis: 100%; + margin: 0; +} + +.filters { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + margin-bottom: 16px; +} + +/* --- dados --- */ +.table-wrap { + overflow-x: auto; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + margin-bottom: 16px; +} +table { + width: 100%; + border-collapse: collapse; +} +th, +td { + text-align: left; + padding: 8px 12px; + border-bottom: 1px solid var(--border); + vertical-align: top; +} +th { + font-size: 0.85rem; + color: var(--muted); + font-weight: 600; +} +tr:last-child td { + border-bottom: none; +} +.details { + word-break: break-word; +} + +.kv { + display: grid; + grid-template-columns: max-content 1fr; + gap: 6px 16px; + margin: 0; +} +.kv dt { + color: var(--muted); +} +.kv dd { + margin: 0; +} + +.stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: 12px; + margin-bottom: 16px; +} +.stat { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 12px 16px; +} +.stat a { + color: inherit; + text-decoration: none; +} +.stat-value { + display: block; + font-size: 1.6rem; + font-weight: 700; +} +.stat-label { + color: var(--muted); + font-size: 0.85rem; +} +.stat.error .stat-value { + color: var(--error-text); +} + +.inline-list { + display: flex; + flex-wrap: wrap; + gap: 8px 24px; + list-style: none; + margin: 0; + padding: 0; +} + +.preview { + border-top: 1px solid var(--border); + margin-top: 12px; + padding-top: 12px; +} + +@media (max-width: 600px) { + .kv { + grid-template-columns: 1fr; + } + .kv dd { + margin-bottom: 8px; + } +} diff --git a/web/app/healthz/route.ts b/web/app/healthz/route.ts new file mode 100644 index 0000000..6f00084 --- /dev/null +++ b/web/app/healthz/route.ts @@ -0,0 +1,6 @@ +// Usado pelo healthcheck do Docker: indica só que o servidor Next está de pé. +export const dynamic = "force-dynamic"; + +export function GET() { + return Response.json({ status: "ok" }); +} diff --git a/web/app/layout.tsx b/web/app/layout.tsx new file mode 100644 index 0000000..1239050 --- /dev/null +++ b/web/app/layout.tsx @@ -0,0 +1,16 @@ +import type { Metadata } from "next"; +import type { ReactNode } from "react"; +import "./globals.css"; + +export const metadata: Metadata = { + title: "Bot de Pontos", + description: "Painel administrativo do Bot de Pontos", +}; + +export default function RootLayout({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} diff --git a/web/app/login/page.tsx b/web/app/login/page.tsx new file mode 100644 index 0000000..9a08cae --- /dev/null +++ b/web/app/login/page.tsx @@ -0,0 +1,52 @@ +"use client"; + +import { useState, type FormEvent } from "react"; +import { apiFetch, errorMessage } from "@/lib/client-api"; + +export default function LoginPage() { + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(""); + const [busy, setBusy] = useState(false); + + async function submit(e: FormEvent) { + e.preventDefault(); + setBusy(true); + setError(""); + try { + await apiFetch("/auth/login", { method: "POST", body: { username, password } }); + const next = new URLSearchParams(window.location.search).get("next"); + window.location.href = next && next.startsWith("/") && !next.startsWith("//") ? next : "/"; + } catch (err) { + setError(errorMessage(err)); + setBusy(false); + } + } + + return ( +
+
+

Bot de Pontos

+

Painel administrativo

+ + + {error &&

{error}

} + +
+
+ ); +} diff --git a/web/components/ActionButton.tsx b/web/components/ActionButton.tsx new file mode 100644 index 0000000..6f28b96 --- /dev/null +++ b/web/components/ActionButton.tsx @@ -0,0 +1,46 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { apiFetch, errorMessage } from "@/lib/client-api"; + +interface Props { + path: string; + label: string; + method?: "POST" | "PUT" | "PATCH" | "DELETE"; + body?: unknown; + confirm?: string; + variant?: "primary" | "danger" | "secondary"; + onDone?: (result: unknown) => void; +} + +/** Botão que executa uma ação na API e recarrega os dados da página. */ +export function ActionButton({ path, label, method = "POST", body, confirm, variant = "secondary", onDone }: Props) { + const router = useRouter(); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + + async function run() { + if (confirm && !window.confirm(confirm)) return; + setBusy(true); + setError(""); + try { + const result = await apiFetch(path, { method, body }); + onDone?.(result); + router.refresh(); + } catch (err) { + setError(errorMessage(err)); + } finally { + setBusy(false); + } + } + + return ( + + + {error && {error}} + + ); +} diff --git a/web/components/LogoutButton.tsx b/web/components/LogoutButton.tsx new file mode 100644 index 0000000..6b7da34 --- /dev/null +++ b/web/components/LogoutButton.tsx @@ -0,0 +1,18 @@ +"use client"; + +import { apiFetch } from "@/lib/client-api"; + +export function LogoutButton() { + async function logout() { + try { + await apiFetch("/auth/logout", { method: "POST" }); + } finally { + window.location.href = "/login"; + } + } + return ( + + ); +} diff --git a/web/components/WindowFields.tsx b/web/components/WindowFields.tsx new file mode 100644 index 0000000..6c3ad7c --- /dev/null +++ b/web/components/WindowFields.tsx @@ -0,0 +1,40 @@ +"use client"; + +import type { QueryWindow } from "@/lib/types"; + +const FIELDS: { key: keyof QueryWindow; label: string; hint: string }[] = [ + { key: "query_window_start", label: "Início da janela", hint: "Primeira consulta do dia" }, + { key: "day_check_deadline", label: "Confirmação sem pontos", hint: "2ª verificação sem pontos" }, + { key: "no_points_recheck_until", label: "Fim das reverificações", hint: "Depois disso, dia sem pontos é encerrado" }, + { key: "query_window_end", label: "Fim da janela", hint: "Última consulta do dia" }, +]; + +/** Os quatro horários da janela de consulta (ARQUITETURA.md 9.12). */ +export function WindowFields({ + value, + onChange, + disabled, +}: { + value: QueryWindow; + onChange: (w: QueryWindow) => void; + disabled?: boolean; +}) { + return ( +
+ {FIELDS.map((f) => ( + + ))} +
+ ); +} diff --git a/web/lib/access.ts b/web/lib/access.ts new file mode 100644 index 0000000..62ca0d8 --- /dev/null +++ b/web/lib/access.ts @@ -0,0 +1,7 @@ +import type { Account } from "./types"; + +// Conta de leitura ligada a um usuário do Telegram: só vê e altera esse usuário. +// A regra de verdade está na API Go; aqui só se escolhe o que mostrar. +export function isRestricted(account: Account): boolean { + return account.role === "viewer" && account.user_id !== null; +} diff --git a/web/lib/client-api.ts b/web/lib/client-api.ts new file mode 100644 index 0000000..87e4c4d --- /dev/null +++ b/web/lib/client-api.ts @@ -0,0 +1,68 @@ +"use client"; + +// Chamadas feitas pelo navegador. Vão para /api/v1 na mesma origem; o Next +// repassa para a API Go. Toda mutação envia o token CSRF (double submit). +import type { Problem } from "./types"; + +export class ApiError extends Error { + status: number; + problem: Problem | null; + + constructor(status: number, problem: Problem | null) { + super(problem?.title ?? `Erro ${status}`); + this.status = status; + this.problem = problem; + } + + /** Mensagem com os erros de campo, se houver. */ + details(): string { + const fields = this.problem?.errors ?? []; + if (fields.length === 0) return this.message; + return `${this.message}: ${fields.map((e) => `${e.field} — ${e.message}`).join("; ")}`; + } +} + +function readCookie(name: string): string { + const match = document.cookie.split("; ").find((c) => c.startsWith(`${name}=`)); + return match ? decodeURIComponent(match.slice(name.length + 1)) : ""; +} + +export async function apiFetch( + path: string, + options: { method?: string; body?: unknown } = {}, +): Promise { + const method = options.method ?? "GET"; + const headers: Record = { accept: "application/json" }; + if (method !== "GET") { + headers["X-CSRF-Token"] = readCookie("bp_csrf"); + } + if (options.body !== undefined) { + headers["content-type"] = "application/json"; + } + + const res = await fetch(`/api/v1${path}`, { + method, + headers, + body: options.body === undefined ? undefined : JSON.stringify(options.body), + credentials: "same-origin", + }); + + if (res.status === 401 && path !== "/auth/login") { + window.location.href = "/login"; + throw new ApiError(401, null); + } + if (!res.ok) { + const problem = (await res.json().catch(() => null)) as Problem | null; + throw new ApiError(res.status, problem); + } + if (res.status === 204) { + return undefined as T; + } + return (await res.json()) as T; +} + +export function errorMessage(err: unknown): string { + if (err instanceof ApiError) return err.details(); + if (err instanceof Error) return err.message; + return "Erro inesperado"; +} diff --git a/web/lib/format.ts b/web/lib/format.ts new file mode 100644 index 0000000..bcff423 --- /dev/null +++ b/web/lib/format.ts @@ -0,0 +1,86 @@ +// Formatação para exibição (pt-BR, fuso de Belo Horizonte). + +const TZ = "America/Sao_Paulo"; + +const dateTime = new Intl.DateTimeFormat("pt-BR", { + timeZone: TZ, + day: "2-digit", + month: "2-digit", + year: "numeric", + hour: "2-digit", + minute: "2-digit", +}); + +const clock = new Intl.DateTimeFormat("pt-BR", { timeZone: TZ, hour: "2-digit", minute: "2-digit" }); + +export function fmtDateTime(value: string | null | undefined): string { + return value ? dateTime.format(new Date(value)) : "—"; +} + +export function fmtClock(value: string | null | undefined, timeZone = TZ): string { + if (!value) return "—"; + return timeZone === TZ + ? clock.format(new Date(value)) + : new Intl.DateTimeFormat("pt-BR", { timeZone, hour: "2-digit", minute: "2-digit" }).format(new Date(value)); +} + +export function fmtDay(day: string): string { + const [y, m, d] = day.split("-"); + return `${d}/${m}/${y}`; +} + +export function fmtMinutes(minutes: number): string { + return `${Math.floor(minutes / 60)}h${String(minutes % 60).padStart(2, "0")}`; +} + +export const userStatusLabel: Record = { + pending: "aguardando aprovação", + active: "ativo", + disabled: "desativado", +}; + +export const notificationStatusLabel: Record = { + scheduled: "agendado", + sending: "enviando", + sent: "enviado", + failed: "falhou", + cancelled: "cancelado", + expired: "expirado", +}; + +export const dayStatusLabel: Record = { + awaiting: "aguardando primeira consulta", + no_points: "sem pontos (reverificando)", + working: "acompanhando", + not_working: "sem pontos (encerrado)", + finished: "jornada encerrada", +}; + +export const pauseReasonLabel: Record = { + manual: "pausado pelo usuário", + api_failures: "pausado após falhas na PontoMais", + admin: "pausado pelo painel", + user_disabled: "usuário desativado", +}; + +export function badgeClass(status: string): string { + switch (status) { + case "active": + case "sent": + case "finished": + case "working": + return "badge ok"; + case "failed": + case "disabled": + case "expired": + case "not_working": + return "badge error"; + case "pending": + case "scheduled": + case "sending": + case "no_points": + return "badge warn"; + default: + return "badge"; + } +} diff --git a/web/lib/server-api.ts b/web/lib/server-api.ts new file mode 100644 index 0000000..245f817 --- /dev/null +++ b/web/lib/server-api.ts @@ -0,0 +1,34 @@ +// Chamadas à API Go feitas pelos Server Components, pela rede interna do Docker. +// O cookie de sessão do navegador é repassado; quem autoriza é sempre o Go. +import { cookies } from "next/headers"; +import { redirect } from "next/navigation"; +import type { Problem } from "./types"; + +const API = process.env.API_INTERNAL_URL ?? "http://api:8080"; + +export class ForbiddenError extends Error {} + +export async function apiGet(path: string): Promise { + const store = await cookies(); + const cookie = store + .getAll() + .map((c) => `${c.name}=${c.value}`) + .join("; "); + + const res = await fetch(`${API}/api/v1${path}`, { + headers: { cookie, accept: "application/json" }, + cache: "no-store", + }); + + if (res.status === 401) { + redirect("/login"); + } + if (res.status === 403) { + throw new ForbiddenError("Seu papel não permite acessar esta página."); + } + if (!res.ok) { + const problem = (await res.json().catch(() => null)) as Problem | null; + throw new Error(problem?.title ?? `Erro ${res.status} ao consultar a API`); + } + return (await res.json()) as T; +} diff --git a/web/lib/types.ts b/web/lib/types.ts new file mode 100644 index 0000000..68ae52f --- /dev/null +++ b/web/lib/types.ts @@ -0,0 +1,174 @@ +// Tipos da API (espelham backend/internal/httpapi/dto.go e api/openapi.yaml). + +export type Role = "owner" | "admin" | "viewer"; +export type UserStatus = "pending" | "active" | "disabled"; +export type NotificationStatus = "scheduled" | "sending" | "sent" | "failed" | "cancelled" | "expired"; +export type NotificationType = "entrada2" | "saida2"; +export type DayStatus = "awaiting" | "no_points" | "working" | "not_working" | "finished"; + +export interface Problem { + type: string; + title: string; + status: number; + code?: string; + errors?: { field: string; message: string }[]; +} + +export interface Account { + id: number; + username: string; + role: Role; + user_id: number | null; + disabled: boolean; + locked_until: string | null; + last_login_at: string | null; + created_at: string; +} + +export interface Me { + account: Account; + expires_at: string; +} + +export interface User { + id: number; + telegram_chat_id: number; + display_name: string; + status: UserStatus; + is_bot_admin: boolean; + notifications_enabled: boolean; + paused_reason: string | null; + timezone: string; + created_at: string; +} + +export interface UserSummary extends User { + has_credential: boolean; + credential_validated: boolean; + consecutive_failures: number; + custom_window: boolean; +} + +export interface QueryWindow { + query_window_start: string; + query_window_end: string; + day_check_deadline: string; + no_points_recheck_until: string; +} + +export interface Schedule { + lunch_minutes: number; + daily_work_minutes: number; + entrada2_lead_minutes: number; + saida2_lead_minutes: number; + window: QueryWindow | null; +} + +export interface Credential { + api_email: string; + validated: boolean; + last_validated_at: string | null; + last_error: string | null; + consecutive_failures: number; + last_failure_at: string | null; + updated_at: string; +} + +export interface Day { + day: string; + status: DayStatus; + zero_point_checks: number; + last_point_count: number; + next_check_at: string | null; + resumed_by: string | null; +} + +export interface UserDetail { + user: User; + schedule: Schedule; + credential: Credential | null; + today: Day; + effective_window: QueryWindow; + custom_window: boolean; +} + +export interface Today { + day: string; + timezone: string; + points: string[]; + invalid: boolean; + overtime: boolean; + entrada2_at: string | null; + saida2_at: string | null; + saida2_error?: string; + day_state: Day; + window: QueryWindow; + custom_window: boolean; +} + +export interface Notification { + id: number; + user_id: number; + user_name?: string; + reference_date: string; + type: NotificationType; + scheduled_at: string; + predicted_at: string; + status: NotificationStatus; + attempts: number; + sent_at: string | null; + error_message: string | null; +} + +export interface AuditLog { + id: number; + occurred_at: string; + source: string; + actor_user_id: number | null; + actor_user_name: string | null; + actor_account: number | null; + actor_username: string | null; + target_user_id: number | null; + target_user_name: string | null; + event_type: string; + success: boolean; + details: Record; +} + +export interface Holiday { + day: string; + name: string; + source: string; +} + +export interface Dashboard { + users_by_status: Record; + notifications_enabled: number; + paused_by_api_failures: number; + today_by_status: Record; + failed_last_7_days: number; + expired_last_7_days: number; + custom_windows: number; + outage_since: string | null; + holiday_fallback_day: string | null; + today: string; +} + +export interface Settings { + window: QueryWindow; + reconciliation_minutes: number; + dispatch_minutes: number; + late_tolerance_minutes: number; + api_failure_threshold: number; + slow_check_minutes: number; +} + +export interface WindowPreview { + errors: { field: string; message: string }[]; + max_queries_per_user: number; + typical_queries_per_user: number; + day_off_queries_per_user: number; + affected_users: number; + affected_days: number; + custom_window_users: number; +} diff --git a/web/middleware.ts b/web/middleware.ts new file mode 100644 index 0000000..efe21ef --- /dev/null +++ b/web/middleware.ts @@ -0,0 +1,18 @@ +// Só redireciona para /login quando não há cookie de sessão. +// A autorização de verdade acontece sempre na API Go (ARQUITETURA.md 5.4). +import { NextResponse, type NextRequest } from "next/server"; + +export function middleware(req: NextRequest) { + if (!req.cookies.get("bp_session")) { + const url = req.nextUrl.clone(); + url.pathname = "/login"; + url.search = req.nextUrl.pathname === "/" ? "" : `?next=${encodeURIComponent(req.nextUrl.pathname)}`; + return NextResponse.redirect(url); + } + return NextResponse.next(); +} + +export const config = { + // Tudo, menos login, healthcheck, API (repassada ao Go) e arquivos do Next. + matcher: ["/((?!login|healthz|api/|_next/|favicon.ico).*)"], +}; diff --git a/web/next.config.ts b/web/next.config.ts new file mode 100644 index 0000000..996a4de --- /dev/null +++ b/web/next.config.ts @@ -0,0 +1,21 @@ +import type { NextConfig } from "next"; + +// Endereço da API Go na rede interna do Docker. O navegador nunca fala com +// ela diretamente: as chamadas a /api/v1/* chegam ao Next (mesma origem) e +// são repassadas para cá (ARQUITETURA.md, 5.3). +const apiInternalUrl = process.env.API_INTERNAL_URL ?? "http://api:8080"; + +const nextConfig: NextConfig = { + output: "standalone", + poweredByHeader: false, + async rewrites() { + return [ + { + source: "/api/v1/:path*", + destination: `${apiInternalUrl}/api/v1/:path*`, + }, + ]; + }, +}; + +export default nextConfig; diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..f195c0f --- /dev/null +++ b/web/package.json @@ -0,0 +1,22 @@ +{ + "name": "botpontos-web", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "next": "^15.3.0", + "react": "^19.1.0", + "react-dom": "^19.1.0" + }, + "devDependencies": { + "@types/node": "^22.15.0", + "@types/react": "^19.1.0", + "@types/react-dom": "^19.1.0", + "typescript": "^5.8.0" + } +} diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..25a72b4 --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": false, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [{ "name": "next" }], + "paths": { "@/*": ["./*"] } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +}