Projeto bot pontos
This commit is contained in:
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user