Projeto bot pontos

This commit is contained in:
2026-10-23 22:31:02 -03:00
parent 29614855ff
commit b35e1a7d69
116 changed files with 14523 additions and 0 deletions

View 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"`
}

View 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)
})
}

View 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)
}
}

View 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)
}

View 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)
}

View 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
}

View 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)
}
}
}

View 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}}
}

View 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
}
}