Projeto bot pontos
This commit is contained in:
18
.editorconfig
Normal file
18
.editorconfig
Normal file
@@ -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
|
||||||
27
.env.example
Normal file
27
.env.example
Normal file
@@ -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
|
||||||
12
.gitignore
vendored
Normal file
12
.gitignore
vendored
Normal file
@@ -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
|
||||||
3
backend/.dockerignore
Normal file
3
backend/.dockerignore
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
*.md
|
||||||
|
api/
|
||||||
|
**/*_test.go
|
||||||
22
backend/Dockerfile
Normal file
22
backend/Dockerfile
Normal file
@@ -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"]
|
||||||
576
backend/api/openapi.yaml
Normal file
576
backend/api/openapi.yaml
Normal file
@@ -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 }
|
||||||
152
backend/cmd/botpontos/admin.go
Normal file
152
backend/cmd/botpontos/admin.go
Normal file
@@ -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 <comando>
|
||||||
|
|
||||||
|
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 <comando> ...
|
||||||
|
`
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
116
backend/cmd/botpontos/main.go
Normal file
116
backend/cmd/botpontos/main.go
Normal file
@@ -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 <comando>
|
||||||
|
|
||||||
|
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 <comando> tarefas administrativas (botpontos admin para ver a lista)
|
||||||
|
healthcheck <url> 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 <url>")
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
218
backend/db/migrations/00001_initial_schema.sql
Normal file
218
backend/db/migrations/00001_initial_schema.sql
Normal file
@@ -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();
|
||||||
13
backend/db/migrations/00002_holiday_source_feriadosapi.sql
Normal file
13
backend/db/migrations/00002_holiday_source_feriadosapi.sql
Normal file
@@ -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'));
|
||||||
7
backend/db/migrations/embed.go
Normal file
7
backend/db/migrations/embed.go
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
// Package migrations embute os arquivos SQL aplicados pelo goose.
|
||||||
|
package migrations
|
||||||
|
|
||||||
|
import "embed"
|
||||||
|
|
||||||
|
//go:embed *.sql
|
||||||
|
var FS embed.FS
|
||||||
3
backend/go.mod
Normal file
3
backend/go.mod
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
module botpontos
|
||||||
|
|
||||||
|
go 1.25
|
||||||
83
backend/internal/adapters/crypto/crypto_test.go
Normal file
83
backend/internal/adapters/crypto/crypto_test.go
Normal file
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
90
backend/internal/adapters/crypto/keyring.go
Normal file
90
backend/internal/adapters/crypto/keyring.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
107
backend/internal/adapters/crypto/password.go
Normal file
107
backend/internal/adapters/crypto/password.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
112
backend/internal/adapters/feriados/client.go
Normal file
112
backend/internal/adapters/feriados/client.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
52
backend/internal/adapters/feriados/client_test.go
Normal file
52
backend/internal/adapters/feriados/client_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
290
backend/internal/adapters/pontomais/client.go
Normal file
290
backend/internal/adapters/pontomais/client.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
141
backend/internal/adapters/pontomais/client_test.go
Normal file
141
backend/internal/adapters/pontomais/client_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
78
backend/internal/adapters/postgres/audit.go
Normal file
78
backend/internal/adapters/postgres/audit.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
91
backend/internal/adapters/postgres/credentials.go
Normal file
91
backend/internal/adapters/postgres/credentials.go
Normal file
@@ -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))
|
||||||
|
}
|
||||||
64
backend/internal/adapters/postgres/days.go
Normal file
64
backend/internal/adapters/postgres/days.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
66
backend/internal/adapters/postgres/holidays.go
Normal file
66
backend/internal/adapters/postgres/holidays.go
Normal file
@@ -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))
|
||||||
|
}
|
||||||
50
backend/internal/adapters/postgres/migrate.go
Normal file
50
backend/internal/adapters/postgres/migrate.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
179
backend/internal/adapters/postgres/notifications.go
Normal file
179
backend/internal/adapters/postgres/notifications.go
Normal file
@@ -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
|
||||||
|
})
|
||||||
|
}
|
||||||
58
backend/internal/adapters/postgres/pool.go
Normal file
58
backend/internal/adapters/postgres/pool.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
92
backend/internal/adapters/postgres/reports.go
Normal file
92
backend/internal/adapters/postgres/reports.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
93
backend/internal/adapters/postgres/schedules.go
Normal file
93
backend/internal/adapters/postgres/schedules.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
90
backend/internal/adapters/postgres/settings.go
Normal file
90
backend/internal/adapters/postgres/settings.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
125
backend/internal/adapters/postgres/store.go
Normal file
125
backend/internal/adapters/postgres/store.go
Normal file
@@ -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::<tipo>, 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::<tipo> 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
|
||||||
|
}
|
||||||
198
backend/internal/adapters/postgres/users.go
Normal file
198
backend/internal/adapters/postgres/users.go
Normal file
@@ -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
|
||||||
|
})
|
||||||
|
}
|
||||||
157
backend/internal/adapters/postgres/web.go
Normal file
157
backend/internal/adapters/postgres/web.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
165
backend/internal/adapters/telegram/client.go
Normal file
165
backend/internal/adapters/telegram/client.go
Normal file
@@ -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, "<token>"))
|
||||||
|
}
|
||||||
141
backend/internal/app/calendar.go
Normal file
141
backend/internal/app/calendar.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
151
backend/internal/app/dispatch.go
Normal file
151
backend/internal/app/dispatch.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
124
backend/internal/app/maintenance.go
Normal file
124
backend/internal/app/maintenance.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
78
backend/internal/app/messages.go
Normal file
78
backend/internal/app/messages.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
380
backend/internal/app/reconcile.go
Normal file
380
backend/internal/app/reconcile.go
Normal file
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
184
backend/internal/app/service.go
Normal file
184
backend/internal/app/service.go
Normal file
@@ -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] + "…"
|
||||||
|
}
|
||||||
165
backend/internal/app/settings.go
Normal file
165
backend/internal/app/settings.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
214
backend/internal/app/today.go
Normal file
214
backend/internal/app/today.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
323
backend/internal/app/users.go
Normal file
323
backend/internal/app/users.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
296
backend/internal/app/webauth.go
Normal file
296
backend/internal/app/webauth.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
60
backend/internal/bootstrap/bootstrap.go
Normal file
60
backend/internal/bootstrap/bootstrap.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
360
backend/internal/bot/router.go
Normal file
360
backend/internal/bot/router.go
Normal file
@@ -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 <id> — aprovar cadastro (exige credencial validada)\n")
|
||||||
|
b.WriteString("/desativarusuario <id> — 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 <id> (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 <id> (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)
|
||||||
|
}
|
||||||
40
backend/internal/bot/router_test.go
Normal file
40
backend/internal/bot/router_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
76
backend/internal/bot/runner.go
Normal file
76
backend/internal/bot/runner.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
429
backend/internal/config/config.go
Normal file
429
backend/internal/config/config.go
Normal file
@@ -0,0 +1,429 @@
|
|||||||
|
// Package config carrega a configuração a partir de variáveis de ambiente.
|
||||||
|
//
|
||||||
|
// Segredos aceitam a variante <NOME>_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ê <key> ou, se vazio, o arquivo apontado por <key>_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))
|
||||||
|
}
|
||||||
111
backend/internal/config/config_test.go
Normal file
111
backend/internal/config/config_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
84
backend/internal/domain/audit.go
Normal file
84
backend/internal/domain/audit.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
54
backend/internal/domain/clock.go
Normal file
54
backend/internal/domain/clock.go
Normal file
@@ -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())
|
||||||
|
}
|
||||||
19
backend/internal/domain/credential.go
Normal file
19
backend/internal/domain/credential.go
Normal file
@@ -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 }
|
||||||
44
backend/internal/domain/day.go
Normal file
44
backend/internal/domain/day.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
57
backend/internal/domain/errors.go
Normal file
57
backend/internal/domain/errors.go
Normal file
@@ -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} }
|
||||||
12
backend/internal/domain/holiday.go
Normal file
12
backend/internal/domain/holiday.go
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
const (
|
||||||
|
HolidaySourceAPI = "feriadosapi"
|
||||||
|
HolidaySourceManual = "manual"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Holiday struct {
|
||||||
|
Day string // AAAA-MM-DD
|
||||||
|
Name string
|
||||||
|
Source string
|
||||||
|
}
|
||||||
51
backend/internal/domain/notification.go
Normal file
51
backend/internal/domain/notification.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
26
backend/internal/domain/point.go
Normal file
26
backend/internal/domain/point.go
Normal file
@@ -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 }
|
||||||
46
backend/internal/domain/schedule.go
Normal file
46
backend/internal/domain/schedule.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
72
backend/internal/domain/user.go
Normal file
72
backend/internal/domain/user.go
Normal file
@@ -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"
|
||||||
58
backend/internal/domain/web.go
Normal file
58
backend/internal/domain/web.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
274
backend/internal/httpapi/dto.go
Normal file
274
backend/internal/httpapi/dto.go
Normal file
@@ -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"`
|
||||||
|
}
|
||||||
111
backend/internal/httpapi/handler.go
Normal file
111
backend/internal/httpapi/handler.go
Normal file
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
152
backend/internal/httpapi/handler_test.go
Normal file
152
backend/internal/httpapi/handler_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
359
backend/internal/httpapi/handlers_admin.go
Normal file
359
backend/internal/httpapi/handlers_admin.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
289
backend/internal/httpapi/handlers_users.go
Normal file
289
backend/internal/httpapi/handlers_users.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
179
backend/internal/httpapi/middleware.go
Normal file
179
backend/internal/httpapi/middleware.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
26
backend/internal/httpapi/middleware_test.go
Normal file
26
backend/internal/httpapi/middleware_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
152
backend/internal/httpapi/request.go
Normal file
152
backend/internal/httpapi/request.go
Normal file
@@ -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}}
|
||||||
|
}
|
||||||
56
backend/internal/httpapi/server.go
Normal file
56
backend/internal/httpapi/server.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
100
backend/internal/httpx/httpx.go
Normal file
100
backend/internal/httpx/httpx.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
23
backend/internal/metrics/metrics.go
Normal file
23
backend/internal/metrics/metrics.go
Normal file
@@ -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) {}
|
||||||
24
backend/internal/rules/format.go
Normal file
24
backend/internal/rules/format.go
Normal file
@@ -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")
|
||||||
|
}
|
||||||
134
backend/internal/rules/plan.go
Normal file
134
backend/internal/rules/plan.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
229
backend/internal/rules/rules_test.go
Normal file
229
backend/internal/rules/rules_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
98
backend/internal/rules/validation.go
Normal file
98
backend/internal/rules/validation.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
73
backend/internal/rules/workday.go
Normal file
73
backend/internal/rules/workday.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
232
backend/internal/worker/worker.go
Normal file
232
backend/internal/worker/worker.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
22
deploy/botpontos-firewall.service
Normal file
22
deploy/botpontos-firewall.service
Normal file
@@ -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
|
||||||
176
docker-compose.yml
Normal file
176
docker-compose.yml
Normal file
@@ -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
|
||||||
1162
docs/ARQUITETURA.md
Normal file
1162
docs/ARQUITETURA.md
Normal file
File diff suppressed because it is too large
Load Diff
564
docs/DEPLOY.md
Normal file
564
docs/DEPLOY.md
Normal file
@@ -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 |
|
||||||
427
docs/MANUAL_USUARIO.md
Normal file
427
docs/MANUAL_USUARIO.md
Normal file
@@ -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 <id>` | Ativa (exige credencial validada, configurada no painel) |
|
||||||
|
| `/desativarusuario <id>` | 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 |
|
||||||
19
postgres/init/01-create-app-role.sh
Normal file
19
postgres/init/01-create-app-role.sh
Normal file
@@ -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
|
||||||
28
scripts/backup-loop.sh
Normal file
28
scripts/backup-loop.sh
Normal file
@@ -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
|
||||||
70
secrets/README.md
Normal file
70
secrets/README.md
Normal file
@@ -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.
|
||||||
3
web/.dockerignore
Normal file
3
web/.dockerignore
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
*.md
|
||||||
32
web/Dockerfile
Normal file
32
web/Dockerfile
Normal file
@@ -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"]
|
||||||
103
web/app/(painel)/auditoria/page.tsx
Normal file
103
web/app/(painel)/auditoria/page.tsx
Normal file
@@ -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<Record<string, string | string[] | undefined>>;
|
||||||
|
|
||||||
|
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<Me>("/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 (
|
||||||
|
<>
|
||||||
|
<h1>Auditoria</h1>
|
||||||
|
<form className="filters" method="get">
|
||||||
|
<label>
|
||||||
|
De <input type="date" name="from" defaultValue={value("from")} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Até <input type="date" name="to" defaultValue={value("to")} />
|
||||||
|
</label>
|
||||||
|
<select name="source" defaultValue={value("source")}>
|
||||||
|
<option value="">Todas as origens</option>
|
||||||
|
<option value="telegram">Telegram</option>
|
||||||
|
<option value="web">Painel</option>
|
||||||
|
<option value="worker">Worker</option>
|
||||||
|
<option value="cli">Terminal</option>
|
||||||
|
</select>
|
||||||
|
<input name="event_type" placeholder="Evento (ex.: user_activated)" defaultValue={value("event_type")} />
|
||||||
|
<select name="success" defaultValue={value("success")}>
|
||||||
|
<option value="">Qualquer resultado</option>
|
||||||
|
<option value="true">Sucesso</option>
|
||||||
|
<option value="false">Falha</option>
|
||||||
|
</select>
|
||||||
|
{value("target_user_id") && <input type="hidden" name="target_user_id" value={value("target_user_id")} />}
|
||||||
|
<button className="btn secondary" type="submit">
|
||||||
|
Filtrar
|
||||||
|
</button>
|
||||||
|
<Link href="/auditoria">Limpar</Link>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Quando</th>
|
||||||
|
<th>Origem</th>
|
||||||
|
<th>Quem</th>
|
||||||
|
<th>Evento</th>
|
||||||
|
<th>Alvo</th>
|
||||||
|
<th>Resultado</th>
|
||||||
|
<th>Detalhes</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{logs.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={7} className="muted">
|
||||||
|
Nenhum evento encontrado.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{logs.map((l) => (
|
||||||
|
<tr key={l.id}>
|
||||||
|
<td className="nowrap">{fmtDateTime(l.occurred_at)}</td>
|
||||||
|
<td>{l.source}</td>
|
||||||
|
<td>{l.actor_username ?? l.actor_user_name ?? "—"}</td>
|
||||||
|
<td>
|
||||||
|
<code>{l.event_type}</code>
|
||||||
|
</td>
|
||||||
|
<td>{l.target_user_id ? <Link href={`/usuarios/${l.target_user_id}`}>{l.target_user_name}</Link> : "—"}</td>
|
||||||
|
<td>{l.success ? <span className="badge ok">sucesso</span> : <span className="badge error">falha</span>}</td>
|
||||||
|
<td className="small">
|
||||||
|
{Object.keys(l.details).length > 0 && <code className="details">{JSON.stringify(l.details)}</code>}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{next_before && (
|
||||||
|
<p>
|
||||||
|
<Link href={`/auditoria?${nextParams}`}>Mais antigos →</Link>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
88
web/app/(painel)/configuracoes/form.tsx
Normal file
88
web/app/(painel)/configuracoes/form.tsx
Normal file
@@ -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<QueryWindow>(initial);
|
||||||
|
const [preview, setPreview] = useState<WindowPreview | null>(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<WindowPreview>("/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 (
|
||||||
|
<form onSubmit={save}>
|
||||||
|
<WindowFields value={value} onChange={(w) => { setValue(w); setPreview(null); }} disabled={disabled} />
|
||||||
|
{!disabled && (
|
||||||
|
<div className="actions">
|
||||||
|
<button className="btn secondary" type="button" onClick={runPreview} disabled={busy}>
|
||||||
|
Pré-visualizar efeito
|
||||||
|
</button>
|
||||||
|
<button className="btn primary" type="submit" disabled={busy}>
|
||||||
|
Salvar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{message && <p className={message.ok ? "ok-text" : "error-text"}>{message.text}</p>}
|
||||||
|
|
||||||
|
{preview && (
|
||||||
|
<div className="preview">
|
||||||
|
{preview.errors.length > 0 ? (
|
||||||
|
<ul className="error-text">
|
||||||
|
{preview.errors.map((e) => (
|
||||||
|
<li key={e.field + e.message}>{e.message}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<dl className="kv">
|
||||||
|
<dt>Consultas por usuário (máximo)</dt>
|
||||||
|
<dd>{preview.max_queries_per_user} por dia</dd>
|
||||||
|
<dt>Consultas num dia típico</dt>
|
||||||
|
<dd>{preview.typical_queries_per_user} por usuário</dd>
|
||||||
|
<dt>Consultas num dia de folga</dt>
|
||||||
|
<dd>{preview.day_off_queries_per_user} por usuário</dd>
|
||||||
|
<dt>Lembretes que sairiam atrasados</dt>
|
||||||
|
<dd>
|
||||||
|
{preview.affected_users} usuário(s), {preview.affected_days} dia(s) nos últimos 30 dias (aviso de retorno
|
||||||
|
antes do início da janela)
|
||||||
|
</dd>
|
||||||
|
<dt>Não afetados</dt>
|
||||||
|
<dd>{preview.custom_window_users} usuário(s) com janela personalizada</dd>
|
||||||
|
</dl>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
43
web/app/(painel)/configuracoes/page.tsx
Normal file
43
web/app/(painel)/configuracoes/page.tsx
Normal file
@@ -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<Me>("/auth/me");
|
||||||
|
if (isRestricted(me.account)) redirect("/");
|
||||||
|
const settings = await apiGet<Settings>("/settings");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h1>Configurações</h1>
|
||||||
|
|
||||||
|
<section className="card">
|
||||||
|
<h2>Janela de consulta global</h2>
|
||||||
|
<p className="muted">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
<SettingsForm initial={settings.window} disabled={me.account.role === "viewer"} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="card">
|
||||||
|
<h2>Parâmetros do servidor</h2>
|
||||||
|
<p className="muted">Definidos por variáveis de ambiente; alterá-los exige reiniciar os containers.</p>
|
||||||
|
<dl className="kv">
|
||||||
|
<dt>Reconciliação</dt>
|
||||||
|
<dd>a cada {settings.reconciliation_minutes} min</dd>
|
||||||
|
<dt>Consultas lentas</dt>
|
||||||
|
<dd>a cada {settings.slow_check_minutes} min (dia sem pontos e tarde com 3 pontos)</dd>
|
||||||
|
<dt>Despacho dos lembretes</dt>
|
||||||
|
<dd>a cada {settings.dispatch_minutes} min</dd>
|
||||||
|
<dt>Tolerância de atraso</dt>
|
||||||
|
<dd>{settings.late_tolerance_minutes} min (acima disso o lembrete expira)</dd>
|
||||||
|
<dt>Pausa por falhas</dt>
|
||||||
|
<dd>após {settings.api_failure_threshold} falhas seguidas na PontoMais</dd>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
164
web/app/(painel)/contas/forms.tsx
Normal file
164
web/app/(painel)/contas/forms.tsx
Normal file
@@ -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 (
|
||||||
|
<select value={value ?? ""} onChange={(e) => onChange(e.target.value ? Number(e.target.value) : null)} aria-label={label}>
|
||||||
|
<option value="">Sem usuário ligado</option>
|
||||||
|
{users.map((u) => (
|
||||||
|
<option key={u.id} value={u.id}>
|
||||||
|
{u.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CreateAccountForm({ users }: { users: UserOption[] }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [username, setUsername] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [role, setRole] = useState<Role>("admin");
|
||||||
|
const [userId, setUserId] = useState<number | null>(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 (
|
||||||
|
<form className="inline-form" onSubmit={submit} autoComplete="off">
|
||||||
|
<label className="field">
|
||||||
|
<span>Usuário</span>
|
||||||
|
<input value={username} onChange={(e) => setUsername(e.target.value)} pattern="[a-zA-Z0-9._-]{3,50}" required />
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>Senha (mínimo 12 caracteres)</span>
|
||||||
|
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} minLength={12} autoComplete="new-password" required />
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>Papel</span>
|
||||||
|
<select value={role} onChange={(e) => setRole(e.target.value as Role)}>
|
||||||
|
{ROLES.map((r) => (
|
||||||
|
<option key={r.value} value={r.value}>
|
||||||
|
{r.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>Usuário do Telegram (leitura só vê este)</span>
|
||||||
|
<UserSelect users={users} value={userId} onChange={setUserId} label="Usuário do Telegram" />
|
||||||
|
</label>
|
||||||
|
<button className="btn primary" type="submit" disabled={busy}>
|
||||||
|
Criar
|
||||||
|
</button>
|
||||||
|
{message && <p className={message.ok ? "ok-text" : "error-text"}>{message.text}</p>}
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="actions">
|
||||||
|
<select value={account.role} onChange={(e) => changeRole(e.target.value as Role)} aria-label="Papel">
|
||||||
|
{ROLES.map((r) => (
|
||||||
|
<option key={r.value} value={r.value}>
|
||||||
|
{r.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<UserSelect users={users} value={account.user_id} onChange={changeUser} label="Usuário do Telegram" />
|
||||||
|
<button type="button" className="btn secondary" onClick={resetPassword}>
|
||||||
|
Redefinir senha
|
||||||
|
</button>
|
||||||
|
{!isSelf &&
|
||||||
|
(account.disabled ? (
|
||||||
|
<ActionButton path={`/accounts/${account.id}`} method="PATCH" body={{ disabled: false }} label="Reativar" />
|
||||||
|
) : (
|
||||||
|
<ActionButton
|
||||||
|
path={`/accounts/${account.id}`}
|
||||||
|
method="PATCH"
|
||||||
|
body={{ disabled: true }}
|
||||||
|
label="Desativar"
|
||||||
|
variant="danger"
|
||||||
|
confirm={`Desativar a conta ${account.username}? As sessões abertas serão encerradas.`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{error && <span className="error-text">{error}</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
84
web/app/(painel)/contas/page.tsx
Normal file
84
web/app/(painel)/contas/page.tsx
Normal file
@@ -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<Me>("/auth/me");
|
||||||
|
if (me.account.role !== "owner") {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h1>Contas do painel</h1>
|
||||||
|
<p className="muted">Somente contas owner gerenciam as contas do painel.</p>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<h1>Contas do painel</h1>
|
||||||
|
<p className="muted">
|
||||||
|
<strong>owner</strong> gerencia contas; <strong>admin</strong> altera usuários, lembretes, feriados e
|
||||||
|
configurações; <strong>leitura</strong> 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.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Usuário</th>
|
||||||
|
<th>Papel</th>
|
||||||
|
<th>Usuário do Telegram</th>
|
||||||
|
<th>Situação</th>
|
||||||
|
<th>Último login</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{accounts.map((a) => (
|
||||||
|
<tr key={a.id}>
|
||||||
|
<td>
|
||||||
|
{a.username}
|
||||||
|
{a.id === me.account.id && <span className="muted"> (você)</span>}
|
||||||
|
</td>
|
||||||
|
<td>{a.role === "viewer" ? "leitura" : a.role}</td>
|
||||||
|
<td>{userOptions.find((u) => u.id === a.user_id)?.name ?? <span className="muted">—</span>}</td>
|
||||||
|
<td>
|
||||||
|
{a.disabled ? (
|
||||||
|
<span className="badge error">desativada</span>
|
||||||
|
) : a.locked_until && new Date(a.locked_until) > new Date() ? (
|
||||||
|
<span className="badge warn">bloqueada até {fmtDateTime(a.locked_until)}</span>
|
||||||
|
) : (
|
||||||
|
<span className="badge ok">ativa</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>{fmtDateTime(a.last_login_at)}</td>
|
||||||
|
<td>
|
||||||
|
<AccountActions account={a} isSelf={a.id === me.account.id} users={userOptions} />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="card">
|
||||||
|
<h2>Nova conta</h2>
|
||||||
|
<CreateAccountForm users={userOptions} />
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
46
web/app/(painel)/feriados/form.tsx
Normal file
46
web/app/(painel)/feriados/form.tsx
Normal file
@@ -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 (
|
||||||
|
<form className="inline-form" onSubmit={submit}>
|
||||||
|
<label className="field">
|
||||||
|
<span>Data</span>
|
||||||
|
<input type="date" value={day} onChange={(e) => setDay(e.target.value)} required />
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>Nome</span>
|
||||||
|
<input value={name} onChange={(e) => setName(e.target.value)} maxLength={120} required />
|
||||||
|
</label>
|
||||||
|
<button className="btn primary" type="submit" disabled={busy}>
|
||||||
|
Cadastrar
|
||||||
|
</button>
|
||||||
|
{error && <p className="error-text">{error}</p>}
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
86
web/app/(painel)/feriados/page.tsx
Normal file
86
web/app/(painel)/feriados/page.tsx
Normal file
@@ -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<Record<string, string | string[] | undefined>>;
|
||||||
|
|
||||||
|
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<Me>("/auth/me"),
|
||||||
|
]);
|
||||||
|
const canEdit = me.account.role !== "viewer";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h1>Feriados de {year}</h1>
|
||||||
|
<p className="muted">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<Link href={`/feriados?year=${year - 1}`}>← {year - 1}</Link> · <Link href={`/feriados?year=${year + 1}`}>{year + 1} →</Link>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{canEdit && (
|
||||||
|
<div className="actions">
|
||||||
|
<ActionButton path="/holidays/sync" body={{ year }} label={`Sincronizar ${year} com a feriadosapi.com`} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Data</th>
|
||||||
|
<th>Nome</th>
|
||||||
|
<th>Origem</th>
|
||||||
|
{canEdit && <th></th>}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{holidays.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={canEdit ? 4 : 3} className="muted">
|
||||||
|
Nenhum feriado registrado para {year}.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{holidays.map((h) => (
|
||||||
|
<tr key={h.day}>
|
||||||
|
<td>{fmtDay(h.day)}</td>
|
||||||
|
<td>{h.name}</td>
|
||||||
|
<td>{h.source === "manual" ? <span className="badge warn">manual</span> : h.source}</td>
|
||||||
|
{canEdit && (
|
||||||
|
<td>
|
||||||
|
{h.source === "manual" && (
|
||||||
|
<ActionButton
|
||||||
|
path={`/holidays/${h.day}`}
|
||||||
|
method="DELETE"
|
||||||
|
label="Remover"
|
||||||
|
variant="danger"
|
||||||
|
confirm={`Remover o feriado de ${fmtDay(h.day)}?`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{canEdit && (
|
||||||
|
<section className="card">
|
||||||
|
<h2>Cadastrar feriado manual</h2>
|
||||||
|
<AddHolidayForm />
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
44
web/app/(painel)/layout.tsx
Normal file
44
web/app/(painel)/layout.tsx
Normal file
@@ -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<Me>("/auth/me");
|
||||||
|
const isOwner = me.account.role === "owner";
|
||||||
|
const restricted = isRestricted(me.account);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<header className="topbar">
|
||||||
|
<div className="topbar-inner">
|
||||||
|
<Link href="/" className="brand">
|
||||||
|
Bot de Pontos
|
||||||
|
</Link>
|
||||||
|
<nav>
|
||||||
|
<Link href="/">Início</Link>
|
||||||
|
<Link href="/usuarios">Usuários</Link>
|
||||||
|
<Link href="/notificacoes">Lembretes</Link>
|
||||||
|
{!restricted && <Link href="/auditoria">Auditoria</Link>}
|
||||||
|
<Link href="/feriados">Feriados</Link>
|
||||||
|
{!restricted && <Link href="/configuracoes">Configurações</Link>}
|
||||||
|
{isOwner && <Link href="/contas">Contas</Link>}
|
||||||
|
</nav>
|
||||||
|
<div className="whoami">
|
||||||
|
<span className="muted">
|
||||||
|
{me.account.username} ({roleLabel[me.account.role]})
|
||||||
|
</span>
|
||||||
|
<LogoutButton />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main className="container">{children}</main>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
112
web/app/(painel)/notificacoes/page.tsx
Normal file
112
web/app/(painel)/notificacoes/page.tsx
Normal file
@@ -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<Record<string, string | string[] | undefined>>;
|
||||||
|
|
||||||
|
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<Me>("/auth/me"),
|
||||||
|
]);
|
||||||
|
const canEdit = me.account.role !== "viewer";
|
||||||
|
const value = (key: string) => (typeof sp[key] === "string" ? (sp[key] as string) : "");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h1>Lembretes</h1>
|
||||||
|
<form className="filters" method="get">
|
||||||
|
<input type="date" name="day" defaultValue={value("day")} />
|
||||||
|
<select name="status" defaultValue={value("status")}>
|
||||||
|
<option value="">Todos os status</option>
|
||||||
|
{Object.entries(notificationStatusLabel).map(([k, label]) => (
|
||||||
|
<option key={k} value={k}>
|
||||||
|
{label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select name="type" defaultValue={value("type")}>
|
||||||
|
<option value="">Todos os tipos</option>
|
||||||
|
<option value="entrada2">Retorno do almoço</option>
|
||||||
|
<option value="saida2">Saída</option>
|
||||||
|
</select>
|
||||||
|
{value("user_id") && <input type="hidden" name="user_id" value={value("user_id")} />}
|
||||||
|
<button className="btn secondary" type="submit">
|
||||||
|
Filtrar
|
||||||
|
</button>
|
||||||
|
<Link href="/notificacoes">Limpar</Link>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p className="muted">
|
||||||
|
<strong>expirado</strong> = chegou tarde demais (mais de 10 min de atraso) e não foi enviado;{" "}
|
||||||
|
<strong>falhou</strong> = erro no envio pelo Telegram. Só lembretes com falha do dia atual podem ser reenviados.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Dia</th>
|
||||||
|
<th>Usuário</th>
|
||||||
|
<th>Tipo</th>
|
||||||
|
<th>Aviso</th>
|
||||||
|
<th>Previsto</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Detalhe</th>
|
||||||
|
{canEdit && <th></th>}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{notifications.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={canEdit ? 8 : 7} className="muted">
|
||||||
|
Nenhum lembrete encontrado.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{notifications.map((n) => (
|
||||||
|
<tr key={n.id}>
|
||||||
|
<td>{fmtDay(n.reference_date)}</td>
|
||||||
|
<td>
|
||||||
|
<Link href={`/usuarios/${n.user_id}`}>{n.user_name}</Link>
|
||||||
|
</td>
|
||||||
|
<td>{n.type === "entrada2" ? "retorno" : "saída"}</td>
|
||||||
|
<td>{fmtClock(n.scheduled_at)}</td>
|
||||||
|
<td>{fmtClock(n.predicted_at)}</td>
|
||||||
|
<td>
|
||||||
|
<span className={badgeClass(n.status)}>{notificationStatusLabel[n.status]}</span>
|
||||||
|
</td>
|
||||||
|
<td className="muted small">{n.error_message ?? (n.sent_at ? `enviado às ${fmtClock(n.sent_at)}` : "")}</td>
|
||||||
|
{canEdit && (
|
||||||
|
<td>
|
||||||
|
{n.status === "scheduled" && (
|
||||||
|
<ActionButton path={`/notifications/${n.id}/cancel`} label="Cancelar" confirm="Cancelar este lembrete?" />
|
||||||
|
)}
|
||||||
|
{n.status === "failed" && (
|
||||||
|
<ActionButton
|
||||||
|
path={`/notifications/${n.id}/resend`}
|
||||||
|
label="Reenviar"
|
||||||
|
variant="primary"
|
||||||
|
confirm={
|
||||||
|
"Reenviar este lembrete agora?\n\nSe a falha foi um travamento durante o envio, o usuário pode já ter recebido a mensagem original."
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
79
web/app/(painel)/page.tsx
Normal file
79
web/app/(painel)/page.tsx
Normal file
@@ -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>("/dashboard"), apiGet<Me>("/auth/me")]);
|
||||||
|
const users = d.users_by_status;
|
||||||
|
const restricted = isRestricted(me.account);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h1>Início</h1>
|
||||||
|
|
||||||
|
{d.outage_since && (
|
||||||
|
<div className="alert error">
|
||||||
|
A PontoMais está fora do ar desde {fmtDateTime(d.outage_since)}. Ninguém foi pausado; os admins foram avisados
|
||||||
|
pelo Telegram.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{d.holiday_fallback_day && (
|
||||||
|
<div className="alert warn">
|
||||||
|
Não foi possível obter os feriados deste ano: segunda a sexta estão valendo como dias úteis.{" "}
|
||||||
|
<Link href="/feriados">Ver feriados</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!restricted && (
|
||||||
|
<section className="stats">
|
||||||
|
<Stat label="Aguardando aprovação" value={users.pending ?? 0} href="/usuarios?status=pending" />
|
||||||
|
<Stat label="Usuários ativos" value={users.active ?? 0} href="/usuarios?status=active" />
|
||||||
|
<Stat label="Com lembretes ligados" value={d.notifications_enabled} />
|
||||||
|
<Stat label="Pausados por falha na PontoMais" value={d.paused_by_api_failures} tone={d.paused_by_api_failures ? "error" : undefined} />
|
||||||
|
<Stat label="Falhas de envio (7 dias)" value={d.failed_last_7_days} href="/notificacoes?status=failed" tone={d.failed_last_7_days ? "error" : undefined} />
|
||||||
|
<Stat label="Expirados (7 dias)" value={d.expired_last_7_days} href="/notificacoes?status=expired" />
|
||||||
|
<Stat label="Janelas personalizadas" value={d.custom_windows} href="/usuarios?custom_window=true" />
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section className="card">
|
||||||
|
<h2>Lembretes de hoje ({fmtDay(d.today)})</h2>
|
||||||
|
{Object.keys(d.today_by_status).length === 0 ? (
|
||||||
|
<p className="muted">Nenhum lembrete gerado hoje.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="inline-list">
|
||||||
|
{Object.entries(d.today_by_status).map(([status, count]) => (
|
||||||
|
<li key={status}>
|
||||||
|
{restricted ? (
|
||||||
|
<span>
|
||||||
|
{notificationStatusLabel[status] ?? status}: <strong>{count}</strong>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<Link href={`/notificacoes?day=${d.today}&status=${status}`}>
|
||||||
|
{notificationStatusLabel[status] ?? status}: <strong>{count}</strong>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Stat({ label, value, href, tone }: { label: string; value: number; href?: string; tone?: "error" }) {
|
||||||
|
const content = (
|
||||||
|
<>
|
||||||
|
<span className="stat-value">{value}</span>
|
||||||
|
<span className="stat-label">{label}</span>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div className={`stat ${tone ?? ""}`}>
|
||||||
|
{href ? <Link href={href}>{content}</Link> : content}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
209
web/app/(painel)/usuarios/[id]/forms.tsx
Normal file
209
web/app/(painel)/usuarios/[id]/forms.tsx
Normal file
@@ -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 (
|
||||||
|
<form className="inline-form" onSubmit={submit} autoComplete="off">
|
||||||
|
<label className="field">
|
||||||
|
<span>E-mail de login na PontoMais</span>
|
||||||
|
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} required />
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>Senha da PontoMais</span>
|
||||||
|
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} autoComplete="new-password" required />
|
||||||
|
</label>
|
||||||
|
<button className="btn primary" type="submit" disabled={busy}>
|
||||||
|
{busy ? "Validando…" : "Validar e salvar"}
|
||||||
|
</button>
|
||||||
|
{message && <p className={message.ok ? "ok-text" : "error-text"}>{message.text}</p>}
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<QueryWindow>(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<HTMLInputElement>) => 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 (
|
||||||
|
<form onSubmit={submit}>
|
||||||
|
<div className="grid-4">
|
||||||
|
<label className="field">
|
||||||
|
<span>Almoço (min)</span>
|
||||||
|
<input type="number" min={15} max={180} value={form.lunch_minutes} onChange={number("lunch_minutes")} disabled={disabled} />
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>Jornada (min) — {fmtMinutes(form.daily_work_minutes)}</span>
|
||||||
|
<input type="number" min={60} max={720} value={form.daily_work_minutes} onChange={number("daily_work_minutes")} disabled={disabled} />
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>Antecedência retorno (min)</span>
|
||||||
|
<input type="number" min={0} max={60} value={form.entrada2_lead_minutes} onChange={number("entrada2_lead_minutes")} disabled={disabled} />
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>Antecedência saída (min)</span>
|
||||||
|
<input type="number" min={0} max={60} value={form.saida2_lead_minutes} onChange={number("saida2_lead_minutes")} disabled={disabled} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label className="field">
|
||||||
|
<span>Fuso horário</span>
|
||||||
|
<input value={form.timezone} onChange={(e) => setForm({ ...form, timezone: e.target.value })} disabled={disabled} />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<fieldset className="fieldset">
|
||||||
|
<legend>Janela de consulta</legend>
|
||||||
|
<label className="checkbox">
|
||||||
|
<input type="checkbox" checked={useGlobal} onChange={(e) => setUseGlobal(e.target.checked)} disabled={disabled} />
|
||||||
|
Usar padrão global
|
||||||
|
</label>
|
||||||
|
{useGlobal ? (
|
||||||
|
<p className="muted">
|
||||||
|
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}).
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<WindowFields value={windowValue} onChange={setWindowValue} disabled={disabled} />
|
||||||
|
)}
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
{!disabled && (
|
||||||
|
<button className="btn primary" type="submit" disabled={busy}>
|
||||||
|
{busy ? "Salvando…" : "Salvar jornada"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{message && <p className={message.ok ? "ok-text" : "error-text"}>{message.text}</p>}
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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<Today | null>(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<Today>(path, { method: test ? "POST" : "GET" }));
|
||||||
|
} catch (err) {
|
||||||
|
setError(errorMessage(err));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="actions">
|
||||||
|
<button className="btn secondary" type="button" onClick={() => load(false)} disabled={busy}>
|
||||||
|
{busy ? "Consultando…" : "Consultar PontoMais agora"}
|
||||||
|
</button>
|
||||||
|
{canTest && (
|
||||||
|
<button className="btn secondary" type="button" onClick={() => load(true)} disabled={busy}>
|
||||||
|
Testar credencial
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{error && <p className="error-text">{error}</p>}
|
||||||
|
{today && (
|
||||||
|
<dl className="kv">
|
||||||
|
<dt>Pontos</dt>
|
||||||
|
<dd>{today.points.length ? today.points.map((p) => fmtClock(p, today.timezone)).join(" · ") : "nenhum"}</dd>
|
||||||
|
{today.invalid && (
|
||||||
|
<>
|
||||||
|
<dt>Atenção</dt>
|
||||||
|
<dd className="error-text">pontos repetidos ou fora de ordem</dd>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{today.overtime && (
|
||||||
|
<>
|
||||||
|
<dt>Atenção</dt>
|
||||||
|
<dd>mais de quatro pontos (hora extra, fora do escopo)</dd>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<dt>Retorno previsto</dt>
|
||||||
|
<dd>{fmtClock(today.entrada2_at, today.timezone)}</dd>
|
||||||
|
<dt>Saída prevista</dt>
|
||||||
|
<dd>{today.saida2_error ? <span className="error-text">{today.saida2_error}</span> : fmtClock(today.saida2_at, today.timezone)}</dd>
|
||||||
|
<dt>Situação do dia</dt>
|
||||||
|
<dd>{dayStatusLabel[today.day_state.status]}</dd>
|
||||||
|
</dl>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
137
web/app/(painel)/usuarios/[id]/page.tsx
Normal file
137
web/app/(painel)/usuarios/[id]/page.tsx
Normal file
@@ -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<UserDetail>(`/users/${id}`), apiGet<Me>("/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 && (
|
||||||
|
<p>
|
||||||
|
<Link href="/usuarios">← Usuários</Link>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<h1>
|
||||||
|
{user.display_name} <span className={badgeClass(user.status)}>{userStatusLabel[user.status]}</span>
|
||||||
|
</h1>
|
||||||
|
<p className="muted">
|
||||||
|
ID {user.id} · chat {user.telegram_chat_id} · cadastrado em {fmtDateTime(user.created_at)}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<section className="card">
|
||||||
|
<h2>Cadastro e lembretes</h2>
|
||||||
|
<dl className="kv">
|
||||||
|
<dt>Lembretes</dt>
|
||||||
|
<dd>
|
||||||
|
{user.notifications_enabled
|
||||||
|
? "ligados"
|
||||||
|
: user.paused_reason
|
||||||
|
? pauseReasonLabel[user.paused_reason]
|
||||||
|
: "desligados"}
|
||||||
|
</dd>
|
||||||
|
<dt>Admin do bot</dt>
|
||||||
|
<dd>{user.is_bot_admin ? "sim" : "não"}</dd>
|
||||||
|
<dt>Hoje ({today.day})</dt>
|
||||||
|
<dd>
|
||||||
|
<span className={badgeClass(today.status)}>{dayStatusLabel[today.status]}</span>{" "}
|
||||||
|
<span className="muted">
|
||||||
|
{today.last_point_count} ponto(s) na última consulta · próxima consulta: {fmtDateTime(today.next_check_at)}
|
||||||
|
</span>
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
{canEdit && (
|
||||||
|
<div className="actions">
|
||||||
|
{canAdmin && user.status !== "active" && <ActionButton path={`${base}/activate`} label="Ativar" variant="primary" />}
|
||||||
|
{canAdmin && user.status !== "disabled" && (
|
||||||
|
<ActionButton
|
||||||
|
path={`${base}/disable`}
|
||||||
|
label="Desativar"
|
||||||
|
variant="danger"
|
||||||
|
confirm="Desativar este usuário? Os lembretes agendados serão cancelados."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{user.status === "active" &&
|
||||||
|
(user.notifications_enabled ? (
|
||||||
|
<ActionButton path={`${base}/notifications/disable`} label="Pausar lembretes" />
|
||||||
|
) : (
|
||||||
|
<ActionButton path={`${base}/notifications/enable`} label="Ligar lembretes" variant="primary" />
|
||||||
|
))}
|
||||||
|
{canAdmin && (
|
||||||
|
<ActionButton
|
||||||
|
path={`${base}/bot-admin`}
|
||||||
|
method="PUT"
|
||||||
|
body={{ enabled: !user.is_bot_admin }}
|
||||||
|
label={user.is_bot_admin ? "Remover admin do bot" : "Tornar admin do bot"}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{canAdmin && (today.status === "no_points" || today.status === "not_working") && (
|
||||||
|
<ActionButton path={`${base}/today/resume`} label="Retomar consultas de hoje" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="card">
|
||||||
|
<h2>Credencial PontoMais</h2>
|
||||||
|
{credential ? (
|
||||||
|
<dl className="kv">
|
||||||
|
<dt>E-mail</dt>
|
||||||
|
<dd>{credential.api_email}</dd>
|
||||||
|
<dt>Situação</dt>
|
||||||
|
<dd>
|
||||||
|
{credential.validated ? (
|
||||||
|
<span className="badge ok">validada em {fmtDateTime(credential.last_validated_at)}</span>
|
||||||
|
) : (
|
||||||
|
<span className="badge warn">não validada</span>
|
||||||
|
)}
|
||||||
|
</dd>
|
||||||
|
<dt>Falhas seguidas</dt>
|
||||||
|
<dd>{credential.consecutive_failures}</dd>
|
||||||
|
{credential.last_error && (
|
||||||
|
<>
|
||||||
|
<dt>Último erro</dt>
|
||||||
|
<dd className="error-text">{credential.last_error}</dd>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</dl>
|
||||||
|
) : (
|
||||||
|
<p className="muted">Nenhuma credencial configurada. Sem ela o usuário não pode ser ativado.</p>
|
||||||
|
)}
|
||||||
|
{canEdit && <CredentialsForm userId={user.id} />}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="card">
|
||||||
|
<h2>Jornada e janela de consulta</h2>
|
||||||
|
<ScheduleForm
|
||||||
|
userId={user.id}
|
||||||
|
schedule={detail.schedule}
|
||||||
|
timezone={user.timezone}
|
||||||
|
effectiveWindow={detail.effective_window}
|
||||||
|
disabled={!canEdit}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="card">
|
||||||
|
<h2>Pontos de hoje</h2>
|
||||||
|
<TodayPanel userId={user.id} canTest={canEdit} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<Link href={`/notificacoes?user_id=${user.id}`}>Lembretes deste usuário</Link> ·{" "}
|
||||||
|
{!restricted && <Link href={`/auditoria?target_user_id=${user.id}`}>Auditoria deste usuário</Link>}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
97
web/app/(painel)/usuarios/page.tsx
Normal file
97
web/app/(painel)/usuarios/page.tsx
Normal file
@@ -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<Record<string, string | string[] | undefined>>;
|
||||||
|
|
||||||
|
export default async function UsersPage({ searchParams }: { searchParams: Search }) {
|
||||||
|
const me = await apiGet<Me>("/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 (
|
||||||
|
<>
|
||||||
|
<h1>Usuários</h1>
|
||||||
|
<form className="filters" method="get">
|
||||||
|
<input name="q" placeholder="Nome ou chat ID" defaultValue={typeof sp.q === "string" ? sp.q : ""} />
|
||||||
|
<select name="status" defaultValue={typeof sp.status === "string" ? sp.status : ""}>
|
||||||
|
<option value="">Todos os status</option>
|
||||||
|
<option value="pending">Aguardando aprovação</option>
|
||||||
|
<option value="active">Ativos</option>
|
||||||
|
<option value="disabled">Desativados</option>
|
||||||
|
</select>
|
||||||
|
<select name="custom_window" defaultValue={typeof sp.custom_window === "string" ? sp.custom_window : ""}>
|
||||||
|
<option value="">Qualquer janela</option>
|
||||||
|
<option value="true">Janela personalizada</option>
|
||||||
|
<option value="false">Janela global</option>
|
||||||
|
</select>
|
||||||
|
<button className="btn secondary" type="submit">
|
||||||
|
Filtrar
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Nome</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Credencial</th>
|
||||||
|
<th>Lembretes</th>
|
||||||
|
<th>Janela</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{users.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={6} className="muted">
|
||||||
|
Nenhum usuário encontrado.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{users.map((u) => (
|
||||||
|
<tr key={u.id}>
|
||||||
|
<td>{u.id}</td>
|
||||||
|
<td>
|
||||||
|
<Link href={`/usuarios/${u.id}`}>{u.display_name}</Link>
|
||||||
|
{u.is_bot_admin && <span className="badge"> admin do bot</span>}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span className={badgeClass(u.status)}>{userStatusLabel[u.status]}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{!u.has_credential ? (
|
||||||
|
<span className="badge warn">não configurada</span>
|
||||||
|
) : u.credential_validated ? (
|
||||||
|
<span className="badge ok">validada</span>
|
||||||
|
) : (
|
||||||
|
<span className="badge warn">não validada</span>
|
||||||
|
)}
|
||||||
|
{u.consecutive_failures > 0 && <span className="badge error"> {u.consecutive_failures} falha(s)</span>}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{u.notifications_enabled ? (
|
||||||
|
<span className="badge ok">ligados</span>
|
||||||
|
) : (
|
||||||
|
<span className="muted">{u.paused_reason ? pauseReasonLabel[u.paused_reason] : "desligados"}</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>{u.custom_window ? <span className="badge warn">personalizada</span> : <span className="muted">global</span>}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user