Projeto bot pontos
This commit is contained in:
141
backend/internal/app/calendar.go
Normal file
141
backend/internal/app/calendar.go
Normal file
@@ -0,0 +1,141 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"botpontos/internal/domain"
|
||||
"botpontos/internal/rules"
|
||||
)
|
||||
|
||||
const stateHolidayFallbackNotified = "holiday_fallback_notified"
|
||||
|
||||
// IsBusinessDay aplica a ordem da seção 9.9: fim de semana → tabela holidays
|
||||
// (com sincronização anual da API) → fallback de segunda a sexta com aviso.
|
||||
func (s *Service) IsBusinessDay(ctx context.Context, t time.Time, loc *time.Location) (bool, error) {
|
||||
if rules.IsWeekend(t, loc) {
|
||||
return false, nil
|
||||
}
|
||||
local := t.In(loc)
|
||||
s.ensureHolidays(ctx, local.Year())
|
||||
|
||||
holiday, err := s.store.IsHoliday(ctx, local.Format(domain.DateLayout))
|
||||
if err != nil {
|
||||
// Sem banco não há como saber; mantém a automação funcionando.
|
||||
return true, err
|
||||
}
|
||||
return !holiday, nil
|
||||
}
|
||||
|
||||
// ensureHolidays sincroniza o ano com a API na primeira vez e, enquanto não
|
||||
// der certo, tenta no máximo uma vez por dia (9.9).
|
||||
func (s *Service) ensureHolidays(ctx context.Context, year int) {
|
||||
today := s.TodayDate()
|
||||
|
||||
s.mu.Lock()
|
||||
if s.syncedYears[year] || s.syncTried[year] == today {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
s.syncTried[year] = today
|
||||
s.mu.Unlock()
|
||||
|
||||
synced, err := s.store.HasSyncedHolidays(ctx, year)
|
||||
if err == nil && synced {
|
||||
s.markSynced(year)
|
||||
return
|
||||
}
|
||||
if _, err := s.syncHolidays(ctx, WorkerActor, year); err != nil {
|
||||
s.holidayFallback(ctx, year, err)
|
||||
return
|
||||
}
|
||||
s.markSynced(year)
|
||||
}
|
||||
|
||||
func (s *Service) markSynced(year int) {
|
||||
s.mu.Lock()
|
||||
s.syncedYears[year] = true
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// holidayFallback avisa os admins no máximo uma vez por dia (9.9 e 9.11).
|
||||
func (s *Service) holidayFallback(ctx context.Context, year int, cause error) {
|
||||
today := s.TodayDate()
|
||||
var state struct {
|
||||
Day string `json:"day"`
|
||||
}
|
||||
if found, _ := s.store.GetState(ctx, stateHolidayFallbackNotified, &state); found && state.Day == today {
|
||||
return
|
||||
}
|
||||
state.Day = today
|
||||
if err := s.store.SetState(ctx, stateHolidayFallbackNotified, state); err != nil {
|
||||
s.log.Warn("falha ao registrar aviso de feriados", "error", err)
|
||||
}
|
||||
s.audit(ctx, WorkerActor, nil, domain.EventHolidaySyncFailed, false, map[string]any{"year": year, "error": shortError(cause)})
|
||||
s.notifyAdmins(ctx, msgHolidayFallback(year, cause))
|
||||
}
|
||||
|
||||
// SyncHolidays baixa os feriados do ano (worker ou botão do painel).
|
||||
func (s *Service) SyncHolidays(ctx context.Context, actor Actor, year int) (int64, error) {
|
||||
inserted, err := s.syncHolidays(ctx, actor, year)
|
||||
if err == nil {
|
||||
s.markSynced(year)
|
||||
}
|
||||
return inserted, err
|
||||
}
|
||||
|
||||
func (s *Service) syncHolidays(ctx context.Context, actor Actor, year int) (int64, error) {
|
||||
if s.holidays == nil {
|
||||
return 0, domain.ErrNotConfigured
|
||||
}
|
||||
holidays, err := s.holidays.FetchYear(ctx, year)
|
||||
if err != nil {
|
||||
if actor.Source != domain.SourceWorker {
|
||||
s.audit(ctx, actor, nil, domain.EventHolidaySyncFailed, false, map[string]any{"year": year, "error": shortError(err)})
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
inserted, err := s.store.InsertHolidays(ctx, holidays)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
s.audit(ctx, actor, nil, domain.EventHolidaySynced, true, map[string]any{"year": year, "received": len(holidays), "inserted": inserted})
|
||||
return inserted, nil
|
||||
}
|
||||
|
||||
func (s *Service) ListHolidays(ctx context.Context, year int) ([]domain.Holiday, error) {
|
||||
return s.store.ListHolidays(ctx, year)
|
||||
}
|
||||
|
||||
func (s *Service) AddHoliday(ctx context.Context, actor Actor, day, name string) error {
|
||||
name = strings.TrimSpace(name)
|
||||
var errs domain.ValidationErrors
|
||||
if _, err := time.Parse(domain.DateLayout, day); err != nil {
|
||||
errs.Add("day", "data inválida (use AAAA-MM-DD)")
|
||||
}
|
||||
if name == "" || len(name) > 120 {
|
||||
errs.Add("name", "obrigatório, até 120 caracteres")
|
||||
}
|
||||
if len(errs) > 0 {
|
||||
return errs
|
||||
}
|
||||
err := s.store.AddHoliday(ctx, domain.Holiday{Day: day, Name: name, Source: domain.HolidaySourceManual})
|
||||
if errors.Is(err, domain.ErrConflict) {
|
||||
return domain.ValidationErrors{{Field: "day", Message: "já existe um feriado nessa data"}}
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.audit(ctx, actor, nil, domain.EventHolidayAdded, true, map[string]any{"day": day, "name": name})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) DeleteHoliday(ctx context.Context, actor Actor, day string) error {
|
||||
if err := s.store.DeleteManualHoliday(ctx, day); err != nil {
|
||||
return err
|
||||
}
|
||||
s.audit(ctx, actor, nil, domain.EventHolidayRemoved, true, map[string]any{"day": day})
|
||||
return nil
|
||||
}
|
||||
151
backend/internal/app/dispatch.go
Normal file
151
backend/internal/app/dispatch.go
Normal file
@@ -0,0 +1,151 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"botpontos/internal/domain"
|
||||
"botpontos/internal/rules"
|
||||
)
|
||||
|
||||
// Dispatch é o ciclo de 2 min (6.3): expira o que passou da tolerância e
|
||||
// envia o que venceu, reservando cada lembrete sem duplicidade.
|
||||
func (s *Service) Dispatch(ctx context.Context) error {
|
||||
now := s.now()
|
||||
tolerance := s.cfg.Jobs.LateTolerance
|
||||
|
||||
expired, err := s.store.ExpireOverdue(ctx, now.Add(-tolerance))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, n := range expired {
|
||||
s.metrics.Notification(string(n.Type), string(domain.NotificationExpired))
|
||||
s.audit(ctx, WorkerActor, &n.UserID, domain.EventNotificationExpired, false, map[string]any{
|
||||
"type": string(n.Type), "delay_minutes": int(now.Sub(n.ScheduledAt).Minutes()),
|
||||
})
|
||||
}
|
||||
|
||||
claimed, err := s.store.ClaimDue(ctx, now, 100)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, n := range claimed {
|
||||
// A tolerância é conferida de novo entre a reserva e o envio.
|
||||
if delay := s.now().Sub(n.ScheduledAt); delay > tolerance {
|
||||
_ = s.store.MarkFinished(ctx, n.ID, domain.NotificationExpired, "atraso acima da tolerância")
|
||||
s.audit(ctx, WorkerActor, &n.UserID, domain.EventNotificationExpired, false, map[string]any{
|
||||
"type": string(n.Type), "delay_minutes": int(delay.Minutes()),
|
||||
})
|
||||
continue
|
||||
}
|
||||
u, err := s.store.GetUser(ctx, n.UserID)
|
||||
if err != nil {
|
||||
_ = s.store.MarkFinished(ctx, n.ID, domain.NotificationFailed, shortError(err))
|
||||
continue
|
||||
}
|
||||
if !u.IsActive() || !u.NotificationsEnabled {
|
||||
_ = s.store.MarkFinished(ctx, n.ID, domain.NotificationCancelled, "lembretes desligados")
|
||||
continue
|
||||
}
|
||||
if err := s.deliver(ctx, n, u); err != nil {
|
||||
s.log.Warn("lembrete não enviado", "notification_id", n.ID, "error", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// deliver envia um lembrete já reservado (sending) e registra o resultado.
|
||||
func (s *Service) deliver(ctx context.Context, n domain.Notification, u domain.User) error {
|
||||
schedule, err := s.store.GetSchedule(ctx, u.ID)
|
||||
if err != nil {
|
||||
schedule = domain.DefaultSchedule(u.ID)
|
||||
}
|
||||
text := msgReminder(n, u.Location(), schedule, s.now())
|
||||
|
||||
messageID, err := s.messenger.Send(ctx, u.TelegramChatID, text)
|
||||
if err != nil {
|
||||
_ = s.store.MarkFinished(ctx, n.ID, domain.NotificationFailed, shortError(err))
|
||||
s.metrics.Notification(string(n.Type), string(domain.NotificationFailed))
|
||||
s.audit(ctx, WorkerActor, &u.ID, domain.EventNotificationFailed, false,
|
||||
map[string]any{"type": string(n.Type), "error": shortError(err)})
|
||||
return err
|
||||
}
|
||||
if err := s.store.MarkSent(ctx, n.ID, messageID, s.now()); err != nil {
|
||||
return err
|
||||
}
|
||||
s.metrics.Notification(string(n.Type), string(domain.NotificationSent))
|
||||
s.audit(ctx, WorkerActor, &u.ID, domain.EventNotificationSent, true, map[string]any{"type": string(n.Type)})
|
||||
return nil
|
||||
}
|
||||
|
||||
// CancelNotification cancela um lembrete agendado pelo painel.
|
||||
func (s *Service) CancelNotification(ctx context.Context, actor Actor, id int64) (domain.Notification, error) {
|
||||
n, err := s.store.CancelNotification(ctx, id)
|
||||
if errors.Is(err, domain.ErrNotFound) {
|
||||
if _, getErr := s.store.GetNotification(ctx, id); getErr == nil {
|
||||
return n, domain.InvalidState("só é possível cancelar lembretes agendados")
|
||||
}
|
||||
return n, domain.ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return n, err
|
||||
}
|
||||
s.audit(ctx, actor, &n.UserID, domain.EventNotificationCancelled, true,
|
||||
map[string]any{"notification_id": id, "type": string(n.Type)})
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// ResendNotification reenvia manualmente um lembrete failed do dia (9.5).
|
||||
func (s *Service) ResendNotification(ctx context.Context, actor Actor, id int64) (domain.Notification, error) {
|
||||
n, err := s.store.GetNotification(ctx, id)
|
||||
if err != nil {
|
||||
return n, err
|
||||
}
|
||||
if n.Status != domain.NotificationFailed {
|
||||
return n, domain.InvalidState("só é possível reenviar lembretes com falha")
|
||||
}
|
||||
u, err := s.store.GetUser(ctx, n.UserID)
|
||||
if err != nil {
|
||||
return n, err
|
||||
}
|
||||
if n.ReferenceDate != domain.LocalDay(s.now(), u.Location()) {
|
||||
return n, domain.InvalidState("só é possível reenviar lembretes do dia atual")
|
||||
}
|
||||
if !u.IsActive() || !u.NotificationsEnabled {
|
||||
return n, domain.InvalidState("o usuário precisa estar ativo e com os lembretes ligados")
|
||||
}
|
||||
|
||||
// Bloqueia se o ponto seguinte já foi batido.
|
||||
cred, err := s.requireValidatedCredential(ctx, u.ID)
|
||||
if err != nil {
|
||||
return n, err
|
||||
}
|
||||
points, err := s.points.History(ctx, cred, u.Location())
|
||||
if err != nil {
|
||||
return n, domain.InvalidState("não foi possível conferir os pontos na PontoMais: " + shortError(err))
|
||||
}
|
||||
wd := rules.CurrentWorkday(points, n.ReferenceDate, u.Location())
|
||||
if (n.Type == domain.NotificationEntrada2 && wd.Count() >= 3) || (n.Type == domain.NotificationSaida2 && wd.Count() >= 4) {
|
||||
return n, domain.InvalidState("o ponto seguinte já foi registrado; o lembrete não é mais necessário")
|
||||
}
|
||||
|
||||
claimed, err := s.store.ClaimFailed(ctx, id)
|
||||
if errors.Is(err, domain.ErrNotFound) {
|
||||
return n, domain.InvalidState("o lembrete já foi reenviado por outra pessoa")
|
||||
}
|
||||
if err != nil {
|
||||
return n, err
|
||||
}
|
||||
sendErr := s.deliver(ctx, claimed, u)
|
||||
s.audit(ctx, actor, &u.ID, domain.EventNotificationResent, sendErr == nil,
|
||||
map[string]any{"notification_id": id, "type": string(n.Type)})
|
||||
|
||||
updated, err := s.store.GetNotification(ctx, id)
|
||||
if err != nil {
|
||||
return claimed, err
|
||||
}
|
||||
if sendErr != nil {
|
||||
return updated, domain.InvalidState("o reenvio falhou: " + shortError(sendErr))
|
||||
}
|
||||
return updated, nil
|
||||
}
|
||||
124
backend/internal/app/maintenance.go
Normal file
124
backend/internal/app/maintenance.go
Normal file
@@ -0,0 +1,124 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"botpontos/internal/adapters/postgres"
|
||||
"botpontos/internal/domain"
|
||||
)
|
||||
|
||||
const stateRetentionLastRun = "retention_last_run"
|
||||
|
||||
// Maintenance é o ciclo de 5 min: falha os envios travados e, uma vez por dia
|
||||
// no horário configurado, aplica a retenção de dados (9.10).
|
||||
func (s *Service) Maintenance(ctx context.Context) error {
|
||||
stuck, err := s.store.FailStuckSending(ctx, s.now().Add(-s.cfg.Jobs.SendingTimeout))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, n := range stuck {
|
||||
s.audit(ctx, WorkerActor, &n.UserID, domain.EventInterruptedSending, false,
|
||||
map[string]any{"notification_id": n.ID, "type": string(n.Type)})
|
||||
}
|
||||
return s.applyRetentionIfDue(ctx)
|
||||
}
|
||||
|
||||
func (s *Service) applyRetentionIfDue(ctx context.Context) error {
|
||||
now := s.now().In(s.location)
|
||||
if now.Hour() != s.cfg.Jobs.RetentionHour {
|
||||
return nil
|
||||
}
|
||||
today := s.TodayDate()
|
||||
var last struct {
|
||||
Day string `json:"day"`
|
||||
}
|
||||
if found, err := s.store.GetState(ctx, stateRetentionLastRun, &last); err != nil {
|
||||
return err
|
||||
} else if found && last.Day == today {
|
||||
return nil
|
||||
}
|
||||
|
||||
audit, err := s.store.DeleteAuditBefore(ctx, now.Add(-s.cfg.Jobs.RetentionAudit))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
notificationsCutoff := domain.LocalDay(now.Add(-s.cfg.Jobs.RetentionNotifications), s.location)
|
||||
notifications, err := s.store.DeleteNotificationsBefore(ctx, notificationsCutoff)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
days, err := s.store.DeleteUserDaysBefore(ctx, notificationsCutoff)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sessions, err := s.store.DeleteSessionsEndedBefore(ctx, now.Add(-s.cfg.Jobs.RetentionSessions))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
last.Day = today
|
||||
if err := s.store.SetState(ctx, stateRetentionLastRun, last); err != nil {
|
||||
return err
|
||||
}
|
||||
s.audit(ctx, WorkerActor, nil, domain.EventRetentionApplied, true, map[string]any{
|
||||
"audit_logs": audit, "notifications": notifications, "user_days": days, "web_sessions": sessions,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// RotateKey recifra com a chave atual as credenciais gravadas com outra versão.
|
||||
func (s *Service) RotateKey(ctx context.Context) (int, error) {
|
||||
creds, err := s.store.ListCredentials(ctx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
current := s.cipher.CurrentVersion()
|
||||
rotated := 0
|
||||
for _, c := range creds {
|
||||
if c.KeyVersion == current {
|
||||
continue
|
||||
}
|
||||
plain, err := s.cipher.Decrypt(c.EncryptedPassword, c.KeyVersion)
|
||||
if err != nil {
|
||||
return rotated, err
|
||||
}
|
||||
encrypted, version, err := s.cipher.Encrypt(plain)
|
||||
if err != nil {
|
||||
return rotated, err
|
||||
}
|
||||
if err := s.store.UpdateCredentialCipher(ctx, c.UserID, encrypted, version); err != nil {
|
||||
return rotated, err
|
||||
}
|
||||
rotated++
|
||||
}
|
||||
s.audit(ctx, CLIActor, nil, domain.EventEncryptionKeyRotated, true, map[string]any{"credentials": rotated, "version": current})
|
||||
return rotated, nil
|
||||
}
|
||||
|
||||
// Overview reúne os dados do dashboard do painel.
|
||||
type Overview struct {
|
||||
Counts postgres.Dashboard
|
||||
OutageSince *string
|
||||
HolidayFallback *string
|
||||
}
|
||||
|
||||
func (s *Service) Overview(ctx context.Context) (Overview, error) {
|
||||
var o Overview
|
||||
counts, err := s.store.Dashboard(ctx, s.TodayDate())
|
||||
if err != nil {
|
||||
return o, err
|
||||
}
|
||||
o.Counts = counts
|
||||
var outage outageState
|
||||
if found, _ := s.store.GetState(ctx, stateOutage, &outage); found {
|
||||
since := outage.Since.Format("2006-01-02T15:04:05Z07:00")
|
||||
o.OutageSince = &since
|
||||
}
|
||||
var fallback struct {
|
||||
Day string `json:"day"`
|
||||
}
|
||||
if found, _ := s.store.GetState(ctx, stateHolidayFallbackNotified, &fallback); found && fallback.Day == s.TodayDate() {
|
||||
o.HolidayFallback = &fallback.Day
|
||||
}
|
||||
return o, nil
|
||||
}
|
||||
78
backend/internal/app/messages.go
Normal file
78
backend/internal/app/messages.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"botpontos/internal/domain"
|
||||
"botpontos/internal/rules"
|
||||
)
|
||||
|
||||
// Textos enviados pelo Telegram. Ficam juntos para facilitar a revisão.
|
||||
|
||||
func msgNewRegistration(u domain.User) string {
|
||||
return fmt.Sprintf("Novo pedido de cadastro: %s (ID %d).\n"+
|
||||
"Configure a credencial da PontoMais no painel e ative com /ativarusuario %d ou pelo painel.",
|
||||
u.DisplayName, u.ID, u.ID)
|
||||
}
|
||||
|
||||
const msgActivated = "Seu cadastro foi aprovado. Envie /ativarponto para ligar os lembretes e /ajuda para ver os comandos."
|
||||
|
||||
const msgDisabledByAdmin = "Seu acesso ao Bot de Pontos foi desativado por um administrador."
|
||||
|
||||
func msgPausedAPIFailures(failures int) string {
|
||||
return fmt.Sprintf("Não consegui consultar a PontoMais %d vezes seguidas, então pausei seus lembretes.\n"+
|
||||
"Confira com o administrador se sua credencial está correta e depois envie /ativarponto.", failures)
|
||||
}
|
||||
|
||||
func msgAdminUserPaused(u domain.User, failures int, err error) string {
|
||||
return fmt.Sprintf("Lembretes de %s (ID %d) pausados após %d falhas seguidas na PontoMais.\nÚltimo erro: %s",
|
||||
u.DisplayName, u.ID, failures, shortError(err))
|
||||
}
|
||||
|
||||
func msgOutageStarted(users int, err error) string {
|
||||
return fmt.Sprintf("A PontoMais parece fora do ar: as consultas de %d usuários falharam no mesmo ciclo.\n"+
|
||||
"Ninguém foi pausado. Último erro: %s", users, shortError(err))
|
||||
}
|
||||
|
||||
func msgOutageEnded(duration time.Duration) string {
|
||||
return fmt.Sprintf("A PontoMais voltou a responder. A queda durou cerca de %s.", formatDuration(duration))
|
||||
}
|
||||
|
||||
func msgHolidayFallback(year int, err error) string {
|
||||
return fmt.Sprintf("Não consegui obter os feriados de %d (%s).\n"+
|
||||
"Enquanto isso, segunda a sexta contam como dias úteis. Cadastre feriados manualmente no painel se necessário.",
|
||||
year, shortError(err))
|
||||
}
|
||||
|
||||
func msgScheduleViolations(names []string, interval time.Duration) string {
|
||||
return fmt.Sprintf("Estes usuários têm almoço − antecedência menor que o intervalo de reconciliação (%s) e podem receber "+
|
||||
"o lembrete de retorno atrasado: %v. Ajuste a jornada no painel.", formatDuration(interval), names)
|
||||
}
|
||||
|
||||
// msgReminder é o lembrete automático; sempre informa o horário previsto (9.3).
|
||||
func msgReminder(n domain.Notification, loc *time.Location, schedule domain.WorkSchedule, now time.Time) string {
|
||||
clock := rules.FormatClock(n.PredictedAt, loc)
|
||||
late := now.After(n.PredictedAt)
|
||||
switch n.Type {
|
||||
case domain.NotificationEntrada2:
|
||||
if late {
|
||||
return "⏰ Lembrete: seu retorno do almoço estava previsto para " + clock + "."
|
||||
}
|
||||
return "⏰ Lembrete: seu retorno do almoço está previsto para " + clock + "."
|
||||
default:
|
||||
jornada := rules.FormatMinutes(schedule.DailyWorkMinutes)
|
||||
if late {
|
||||
return "⏰ Lembrete: sua saída estava prevista para " + clock + " (jornada de " + jornada + ")."
|
||||
}
|
||||
return "⏰ Lembrete: sua saída está prevista para " + clock + " (jornada de " + jornada + ")."
|
||||
}
|
||||
}
|
||||
|
||||
func formatDuration(d time.Duration) string {
|
||||
minutes := int(d.Round(time.Minute) / time.Minute)
|
||||
if minutes < 60 {
|
||||
return fmt.Sprintf("%d min", minutes)
|
||||
}
|
||||
return rules.FormatMinutes(minutes)
|
||||
}
|
||||
380
backend/internal/app/reconcile.go
Normal file
380
backend/internal/app/reconcile.go
Normal file
@@ -0,0 +1,380 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"botpontos/internal/adapters/postgres"
|
||||
"botpontos/internal/domain"
|
||||
"botpontos/internal/rules"
|
||||
)
|
||||
|
||||
const stateOutage = "pontomais_outage"
|
||||
|
||||
type outageState struct {
|
||||
Since time.Time `json:"since"`
|
||||
LastError string `json:"last_error"`
|
||||
}
|
||||
|
||||
// reconJob é um usuário que deve ser consultado neste tick.
|
||||
type reconJob struct {
|
||||
ru postgres.ReconUser
|
||||
loc *time.Location
|
||||
day string
|
||||
window domain.QueryWindow
|
||||
state domain.UserDay
|
||||
}
|
||||
|
||||
type reconResult struct {
|
||||
job reconJob
|
||||
points []domain.Point
|
||||
err error
|
||||
skipped bool // não chegou a ser consultado (prazo do ciclo esgotado)
|
||||
}
|
||||
|
||||
// Reconcile é o ciclo de 15 min (8.1, 8.2, 9.4, 9.12): seleciona quem está
|
||||
// na janela e com a próxima consulta vencida, consulta a PontoMais com
|
||||
// concorrência limitada e aplica as regras.
|
||||
func (s *Service) Reconcile(ctx context.Context, tick time.Time) error {
|
||||
global, err := s.GlobalWindow(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
users, err := s.store.ListReconciliationUsers(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var due []reconJob
|
||||
for _, ru := range users {
|
||||
loc := ru.User.Location()
|
||||
day := domain.LocalDay(tick, loc)
|
||||
window, _ := rules.EffectiveWindow(global, ru.Schedule)
|
||||
if !rules.InWindow(tick, window, day, loc) {
|
||||
continue
|
||||
}
|
||||
if business, _ := s.IsBusinessDay(ctx, tick, loc); !business {
|
||||
continue
|
||||
}
|
||||
state, found, err := s.store.GetUserDay(ctx, ru.User.ID, day)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if found && (state.NextCheckAt == nil || state.NextCheckAt.After(tick)) {
|
||||
continue
|
||||
}
|
||||
due = append(due, reconJob{ru: ru, loc: loc, day: day, window: window, state: state})
|
||||
}
|
||||
if len(due) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
started := s.now()
|
||||
results := s.fetchAll(ctx, due)
|
||||
|
||||
var successes, failures []reconResult
|
||||
for _, r := range results {
|
||||
switch {
|
||||
case r.skipped:
|
||||
case r.err == nil || errors.Is(r.err, domain.ErrInvalidPointHistory):
|
||||
successes = append(successes, r) // a API respondeu (mesmo com dado inválido)
|
||||
default:
|
||||
failures = append(failures, r)
|
||||
}
|
||||
}
|
||||
|
||||
var outage outageState
|
||||
outageActive, err := s.store.GetState(ctx, stateOutage, &outage)
|
||||
if err != nil {
|
||||
s.log.Warn("falha ao ler estado de queda", "error", err)
|
||||
}
|
||||
attempted := len(successes) + len(failures)
|
||||
s.metrics.ReconciliationCycle(s.now().Sub(started), attempted, len(failures))
|
||||
|
||||
switch {
|
||||
case attempted >= 2 && len(successes) == 0:
|
||||
// Todos falharam: queda da PontoMais. Ninguém soma falha (9.4).
|
||||
if !outageActive {
|
||||
s.startOutage(ctx, len(failures), failures[0].err)
|
||||
}
|
||||
return nil
|
||||
case outageActive && len(successes) == 0:
|
||||
// Queda em andamento e só um usuário consultado: não conta.
|
||||
return nil
|
||||
case outageActive:
|
||||
s.endOutage(ctx, outage)
|
||||
}
|
||||
|
||||
for _, r := range successes {
|
||||
if r.err != nil {
|
||||
s.handleInvalidHistory(ctx, r.job, tick, r.err)
|
||||
continue
|
||||
}
|
||||
if err := s.applyPoints(ctx, r.job, r.points, tick); err != nil {
|
||||
s.log.Error("falha ao aplicar pontos", "user_id", r.job.ru.User.ID, "error", err)
|
||||
}
|
||||
}
|
||||
for _, r := range failures {
|
||||
s.recordFailure(ctx, r.job, r.err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) fetchAll(ctx context.Context, jobs []reconJob) []reconResult {
|
||||
deadline, cancel := context.WithTimeout(ctx, s.cfg.Jobs.ReconciliationDeadline)
|
||||
defer cancel()
|
||||
|
||||
results := make([]reconResult, len(jobs))
|
||||
sem := make(chan struct{}, s.cfg.Jobs.ReconciliationWorkers)
|
||||
var wg sync.WaitGroup
|
||||
for i, job := range jobs {
|
||||
wg.Add(1)
|
||||
go func(i int, job reconJob) {
|
||||
defer wg.Done()
|
||||
results[i].job = job
|
||||
select {
|
||||
case sem <- struct{}{}:
|
||||
case <-deadline.Done():
|
||||
results[i].skipped = true
|
||||
return
|
||||
}
|
||||
defer func() { <-sem }()
|
||||
requestStart := time.Now()
|
||||
points, err := s.points.History(deadline, job.ru.Credential, job.loc)
|
||||
s.metrics.PointsRequest(time.Since(requestStart), err)
|
||||
if err != nil && deadline.Err() != nil && ctx.Err() == nil {
|
||||
// Prazo do ciclo esgotado: fica para o próximo tick, sem contar falha.
|
||||
results[i].skipped = true
|
||||
return
|
||||
}
|
||||
results[i].points, results[i].err = points, err
|
||||
}(i, job)
|
||||
}
|
||||
wg.Wait()
|
||||
return results
|
||||
}
|
||||
|
||||
func (s *Service) startOutage(ctx context.Context, users int, cause error) {
|
||||
state := outageState{Since: s.now(), LastError: shortError(cause)}
|
||||
if err := s.store.SetState(ctx, stateOutage, state); err != nil {
|
||||
s.log.Warn("falha ao registrar queda", "error", err)
|
||||
}
|
||||
s.audit(ctx, WorkerActor, nil, domain.EventPointAPIOutageStarted, false, map[string]any{"users": users, "error": state.LastError})
|
||||
s.notifyAdmins(ctx, msgOutageStarted(users, cause))
|
||||
}
|
||||
|
||||
func (s *Service) endOutage(ctx context.Context, outage outageState) {
|
||||
if err := s.store.DeleteState(ctx, stateOutage); err != nil {
|
||||
s.log.Warn("falha ao encerrar queda", "error", err)
|
||||
}
|
||||
duration := s.now().Sub(outage.Since)
|
||||
s.audit(ctx, WorkerActor, nil, domain.EventPointAPIOutageEnded, true, map[string]any{"minutes": int(duration.Minutes())})
|
||||
s.notifyAdmins(ctx, msgOutageEnded(duration))
|
||||
}
|
||||
|
||||
// recordFailure aplica a regra das 3 falhas seguidas (9.4). O usuário volta a
|
||||
// ser consultado no próximo tick, porque o estado do dia não muda.
|
||||
func (s *Service) recordFailure(ctx context.Context, job reconJob, cause error) {
|
||||
u := job.ru.User
|
||||
failures, err := s.store.RecordCredentialFailure(ctx, u.ID, shortError(cause), s.now())
|
||||
if err != nil {
|
||||
s.log.Error("falha ao registrar falha da PontoMais", "user_id", u.ID, "error", err)
|
||||
return
|
||||
}
|
||||
s.audit(ctx, WorkerActor, &u.ID, domain.EventPointAPIFailure, false,
|
||||
map[string]any{"consecutive_failures": failures, "error": shortError(cause)})
|
||||
|
||||
if failures < s.cfg.Jobs.APIFailureThreshold {
|
||||
return
|
||||
}
|
||||
reason := domain.PauseAPIFailures
|
||||
if err := s.store.SetNotifications(ctx, u.ID, false, &reason); err != nil {
|
||||
s.log.Error("falha ao pausar lembretes", "user_id", u.ID, "error", err)
|
||||
return
|
||||
}
|
||||
s.audit(ctx, WorkerActor, &u.ID, domain.EventNotificationsPausedAPI, false, map[string]any{"consecutive_failures": failures})
|
||||
s.notifyUser(ctx, u, msgPausedAPIFailures(failures))
|
||||
s.notifyAdmins(ctx, msgAdminUserPaused(u, failures, cause))
|
||||
}
|
||||
|
||||
// handleInvalidHistory: a API respondeu com dado ilegível. Não conta como falha;
|
||||
// consulta de novo mais devagar.
|
||||
func (s *Service) handleInvalidHistory(ctx context.Context, job reconJob, tick time.Time, cause error) {
|
||||
u := job.ru.User
|
||||
s.audit(ctx, WorkerActor, &u.ID, domain.EventInvalidPointHistory, false, map[string]any{"error": shortError(cause)})
|
||||
next := tick.Add(s.cfg.Jobs.SlowCheckInterval)
|
||||
state := job.state
|
||||
if next.After(job.window.End.On(job.day, job.loc)) {
|
||||
state.NextCheckAt = nil
|
||||
} else {
|
||||
state.NextCheckAt = &next
|
||||
}
|
||||
if err := s.store.SaveUserDay(ctx, state); err != nil {
|
||||
s.log.Error("falha ao salvar o dia", "user_id", u.ID, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// applyPoints cria, mantém ou cancela os lembretes e calcula a próxima consulta.
|
||||
func (s *Service) applyPoints(ctx context.Context, job reconJob, points []domain.Point, tick time.Time) error {
|
||||
u, schedule := job.ru.User, job.ru.Schedule
|
||||
if job.ru.Credential.ConsecutiveFailures > 0 || job.ru.Credential.LastError != nil {
|
||||
if err := s.store.ResetCredentialFailures(ctx, u.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
wd := rules.CurrentWorkday(points, job.day, job.loc)
|
||||
previous := job.state
|
||||
var saida2At *time.Time
|
||||
|
||||
switch {
|
||||
case wd.Invalid:
|
||||
if previous.LastPointCount != wd.Count() || previous.UpdatedAt.IsZero() {
|
||||
s.audit(ctx, WorkerActor, &u.ID, domain.EventInvalidPointHistory, false,
|
||||
map[string]any{"reason": "pontos repetidos ou fora de ordem", "points": wd.Count()})
|
||||
}
|
||||
|
||||
case wd.Overtime || wd.Count() >= 4:
|
||||
cancelled, err := s.store.CancelScheduledFrom(ctx, u.ID, job.day)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if cancelled > 0 {
|
||||
s.audit(ctx, WorkerActor, &u.ID, domain.EventNotificationCancelled, true,
|
||||
map[string]any{"reason": "jornada encerrada", "count": cancelled})
|
||||
}
|
||||
|
||||
case wd.Count() == 3:
|
||||
cancelled, err := s.store.CancelScheduled(ctx, u.ID, job.day, domain.NotificationEntrada2)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if cancelled {
|
||||
s.audit(ctx, WorkerActor, &u.ID, domain.EventNotificationCancelled, true,
|
||||
map[string]any{"type": "entrada2", "reason": "retorno do almoço detectado"})
|
||||
}
|
||||
predicted, err := rules.Saida2Time(wd, schedule.DailyWorkMinutes)
|
||||
if err != nil {
|
||||
if previous.LastPointCount != 3 {
|
||||
s.audit(ctx, WorkerActor, &u.ID, domain.EventInvalidWorkday, false, map[string]any{"error": err.Error()})
|
||||
}
|
||||
break
|
||||
}
|
||||
n, err := s.ensureReminder(ctx, job, domain.NotificationSaida2, predicted, schedule.Saida2LeadMinutes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n.Status != domain.NotificationCancelled {
|
||||
saida2At = &n.ScheduledAt
|
||||
}
|
||||
|
||||
case wd.Count() == 2:
|
||||
predicted, err := rules.Entrada2Time(wd, schedule.LunchMinutes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := s.ensureReminder(ctx, job, domain.NotificationEntrada2, predicted, schedule.Entrada2LeadMinutes); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
plan := rules.PlanNextCheck(rules.PlanInput{
|
||||
Tick: tick,
|
||||
Day: job.day,
|
||||
Location: job.loc,
|
||||
Window: job.window,
|
||||
Workday: wd,
|
||||
Resumed: previous.ResumedBy != nil,
|
||||
Saida2At: saida2At,
|
||||
Reconciliation: s.cfg.Jobs.ReconciliationInterval,
|
||||
Slow: s.cfg.Jobs.SlowCheckInterval,
|
||||
ExitConfirmFrom: s.cfg.Jobs.ExitConfirmFrom,
|
||||
ExitConfirmTo: s.cfg.Jobs.ExitConfirmTo,
|
||||
})
|
||||
|
||||
// Se o dia foi retomado (/ativarponto ou painel) durante a consulta, a
|
||||
// retomada vence: o próximo tick recalcula com o estado novo.
|
||||
if current, found, err := s.store.GetUserDay(ctx, u.ID, job.day); err == nil && found &&
|
||||
previous.ResumedBy == nil && current.ResumedBy != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
next := previous
|
||||
next.Status = plan.Status
|
||||
next.NextCheckAt = plan.NextCheckAt
|
||||
next.LastPointCount = wd.Count()
|
||||
if wd.Count() == 0 {
|
||||
next.ZeroPointChecks++
|
||||
}
|
||||
if err := s.store.SaveUserDay(ctx, next); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if plan.Status == domain.DayNoPoints && previous.Status != domain.DayNoPoints {
|
||||
s.audit(ctx, WorkerActor, &u.ID, domain.EventDayNoPoints, true, nil)
|
||||
}
|
||||
if plan.Status == domain.DayNotWorking && previous.Status != domain.DayNotWorking {
|
||||
s.audit(ctx, WorkerActor, &u.ID, domain.EventDayNotWorked, true, nil)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureReminder cria o lembrete do dia se ainda não existir (um existente não
|
||||
// é alterado). Se o horário do aviso já passou da tolerância, ele nasce
|
||||
// expired e é só auditado (9.3).
|
||||
func (s *Service) ensureReminder(ctx context.Context, job reconJob, t domain.NotificationType, predicted time.Time, lead int) (domain.Notification, error) {
|
||||
u := job.ru.User
|
||||
now := s.now()
|
||||
n := domain.Notification{
|
||||
UserID: u.ID,
|
||||
ReferenceDate: job.day,
|
||||
Type: t,
|
||||
ScheduledAt: rules.ReminderAt(predicted, lead),
|
||||
PredictedAt: predicted,
|
||||
Status: domain.NotificationScheduled,
|
||||
}
|
||||
delay := now.Sub(n.ScheduledAt)
|
||||
if delay > s.cfg.Jobs.LateTolerance {
|
||||
n.Status = domain.NotificationExpired
|
||||
n.ErrorMessage = ptr("detectado com atraso acima da tolerância")
|
||||
}
|
||||
|
||||
saved, inserted, err := s.store.InsertNotification(ctx, n)
|
||||
if err != nil || !inserted {
|
||||
return saved, err
|
||||
}
|
||||
details := map[string]any{
|
||||
"type": string(t),
|
||||
"scheduled_at": saved.ScheduledAt,
|
||||
"predicted_at": saved.PredictedAt,
|
||||
}
|
||||
if saved.Status == domain.NotificationExpired {
|
||||
details["delay_minutes"] = int(delay.Minutes())
|
||||
s.audit(ctx, WorkerActor, &u.ID, domain.EventNotificationExpired, false, details)
|
||||
} else {
|
||||
s.audit(ctx, WorkerActor, &u.ID, domain.EventNotificationScheduled, true, details)
|
||||
}
|
||||
return saved, nil
|
||||
}
|
||||
|
||||
// CheckScheduleIntervals avisa, na subida do worker, sobre jornadas que ficaram
|
||||
// fora da regra 9.8 após mudança do intervalo de reconciliação.
|
||||
func (s *Service) CheckScheduleIntervals(ctx context.Context) {
|
||||
items, err := s.store.ListActiveSchedules(ctx)
|
||||
if err != nil {
|
||||
s.log.Warn("falha ao verificar jornadas", "error", err)
|
||||
return
|
||||
}
|
||||
var names []string
|
||||
for _, item := range items {
|
||||
if !rules.ScheduleFitsInterval(item.Schedule, s.cfg.Jobs.ReconciliationInterval) {
|
||||
names = append(names, item.User.DisplayName)
|
||||
s.audit(ctx, WorkerActor, &item.User.ID, domain.EventScheduleWindowViolation, false, nil)
|
||||
}
|
||||
}
|
||||
if len(names) > 0 {
|
||||
s.notifyAdmins(ctx, msgScheduleViolations(names, s.cfg.Jobs.ReconciliationInterval))
|
||||
}
|
||||
}
|
||||
184
backend/internal/app/service.go
Normal file
184
backend/internal/app/service.go
Normal file
@@ -0,0 +1,184 @@
|
||||
// Package app contém os casos de uso. É chamado pelo bot do Telegram, pela
|
||||
// API HTTP, pela CLI e pelos jobs do worker, de modo que a mesma regra vale
|
||||
// para todas as interfaces (ex.: ativação exige credencial validada).
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"botpontos/internal/adapters/postgres"
|
||||
"botpontos/internal/config"
|
||||
"botpontos/internal/domain"
|
||||
"botpontos/internal/metrics"
|
||||
)
|
||||
|
||||
// PointsProvider é o port da PontoMais (adapter: pontomais.Client).
|
||||
type PointsProvider interface {
|
||||
History(ctx context.Context, cred domain.Credential, loc *time.Location) ([]domain.Point, error)
|
||||
Validate(ctx context.Context, email, password string, loc *time.Location) error
|
||||
Invalidate(userID int64)
|
||||
}
|
||||
|
||||
// Messenger é o port do Telegram (adapter: telegram.Client).
|
||||
type Messenger interface {
|
||||
Send(ctx context.Context, chatID int64, text string) (int64, error)
|
||||
}
|
||||
|
||||
// HolidayProvider é o port da API de feriados (adapter: feriados.Client).
|
||||
type HolidayProvider interface {
|
||||
FetchYear(ctx context.Context, year int) ([]domain.Holiday, error)
|
||||
}
|
||||
|
||||
// Cipher é o port da criptografia das senhas PontoMais (adapter: crypto.Keyring).
|
||||
type Cipher interface {
|
||||
Encrypt(plain string) ([]byte, int16, error)
|
||||
Decrypt(data []byte, version int16) (string, error)
|
||||
CurrentVersion() int16
|
||||
}
|
||||
|
||||
type Deps struct {
|
||||
Store *postgres.Store
|
||||
Points PointsProvider // obrigatório para api e worker
|
||||
Messenger Messenger // pode estar sem token (envios falham com ErrNotConfigured)
|
||||
Holidays HolidayProvider // opcional: sem ele vale o fallback de segunda a sexta
|
||||
Cipher Cipher
|
||||
Config config.Config
|
||||
Logger *slog.Logger
|
||||
Metrics metrics.Recorder // opcional: sem ele, metrics.Noop
|
||||
Now func() time.Time // opcional (testes)
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
store *postgres.Store
|
||||
points PointsProvider
|
||||
messenger Messenger
|
||||
holidays HolidayProvider
|
||||
cipher Cipher
|
||||
cfg config.Config
|
||||
log *slog.Logger
|
||||
metrics metrics.Recorder
|
||||
now func() time.Time
|
||||
location *time.Location // fuso padrão (configurações globais, retenção)
|
||||
|
||||
mu sync.Mutex
|
||||
syncedYears map[int]bool
|
||||
syncTried map[int]string // ano → último dia em que a sincronização foi tentada
|
||||
}
|
||||
|
||||
func New(d Deps) *Service {
|
||||
now := d.Now
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
var recorder metrics.Recorder = metrics.Noop{}
|
||||
if d.Metrics != nil {
|
||||
recorder = d.Metrics
|
||||
}
|
||||
return &Service{
|
||||
store: d.Store,
|
||||
points: d.Points,
|
||||
messenger: d.Messenger,
|
||||
holidays: d.Holidays,
|
||||
cipher: d.Cipher,
|
||||
cfg: d.Config,
|
||||
log: d.Logger,
|
||||
metrics: recorder,
|
||||
now: now,
|
||||
location: domain.LoadLocation(d.Config.DefaultTimezone),
|
||||
syncedYears: map[int]bool{},
|
||||
syncTried: map[int]string{},
|
||||
}
|
||||
}
|
||||
|
||||
// Store expõe o repositório para leituras simples da API (listas e detalhes).
|
||||
func (s *Service) Store() *postgres.Store { return s.store }
|
||||
|
||||
func (s *Service) Config() config.Config { return s.cfg }
|
||||
|
||||
func (s *Service) Now() time.Time { return s.now() }
|
||||
|
||||
// Location é o fuso padrão da instalação.
|
||||
func (s *Service) Location() *time.Location { return s.location }
|
||||
|
||||
// TodayDate devolve a data de hoje (AAAA-MM-DD) no fuso padrão.
|
||||
func (s *Service) TodayDate() string { return domain.LocalDay(s.now(), s.location) }
|
||||
|
||||
// Actor identifica quem executou uma ação, para a auditoria.
|
||||
type Actor struct {
|
||||
Source domain.AuditSource
|
||||
UserID *int64
|
||||
AccountID *int64
|
||||
}
|
||||
|
||||
func TelegramActor(userID int64) Actor { return Actor{Source: domain.SourceTelegram, UserID: &userID} }
|
||||
func WebActor(accountID int64) Actor { return Actor{Source: domain.SourceWeb, AccountID: &accountID} }
|
||||
|
||||
var (
|
||||
WorkerActor = Actor{Source: domain.SourceWorker}
|
||||
CLIActor = Actor{Source: domain.SourceCLI}
|
||||
)
|
||||
|
||||
// audit grava um evento; uma falha na auditoria é registrada no log e não interrompe a operação.
|
||||
func (s *Service) audit(ctx context.Context, actor Actor, target *int64, event string, success bool, details map[string]any) {
|
||||
err := s.store.InsertAudit(ctx, domain.AuditEntry{
|
||||
Source: actor.Source,
|
||||
ActorUserID: actor.UserID,
|
||||
ActorAccount: actor.AccountID,
|
||||
TargetUserID: target,
|
||||
EventType: event,
|
||||
Success: success,
|
||||
Details: details,
|
||||
})
|
||||
if err != nil {
|
||||
s.log.Error("falha ao gravar auditoria", "event", event, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// notifyAdmins envia um aviso a todos os admins ativos do bot (9.11).
|
||||
func (s *Service) notifyAdmins(ctx context.Context, text string) {
|
||||
admins, err := s.store.ListActiveBotAdmins(ctx)
|
||||
if err != nil {
|
||||
s.log.Error("falha ao listar admins para aviso", "error", err)
|
||||
return
|
||||
}
|
||||
if len(admins) == 0 {
|
||||
s.log.Warn("nenhum admin ativo no Telegram para receber o aviso", "text", text)
|
||||
return
|
||||
}
|
||||
for _, admin := range admins {
|
||||
if _, err := s.messenger.Send(ctx, admin.TelegramChatID, text); err != nil {
|
||||
s.log.Warn("falha ao avisar admin", "user_id", admin.ID, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// notifyUser envia uma mensagem avulsa ao usuário; falhas só vão para o log.
|
||||
func (s *Service) notifyUser(ctx context.Context, u domain.User, text string) {
|
||||
if _, err := s.messenger.Send(ctx, u.TelegramChatID, text); err != nil {
|
||||
s.log.Warn("falha ao enviar mensagem ao usuário", "user_id", u.ID, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func ptr[T any](v T) *T { return &v }
|
||||
|
||||
// shortError limita o tamanho de mensagens de erro gravadas no banco.
|
||||
func shortError(err error) string {
|
||||
return truncate(err.Error(), 300)
|
||||
}
|
||||
|
||||
// truncate corta em até max bytes sem quebrar um caractere UTF-8 (o Postgres
|
||||
// recusa texto com bytes inválidos).
|
||||
func truncate(s string, max int) string {
|
||||
if len(s) <= max {
|
||||
return s
|
||||
}
|
||||
n := max
|
||||
for n > 0 && !utf8.RuneStart(s[n]) {
|
||||
n--
|
||||
}
|
||||
return s[:n] + "…"
|
||||
}
|
||||
165
backend/internal/app/settings.go
Normal file
165
backend/internal/app/settings.go
Normal file
@@ -0,0 +1,165 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"botpontos/internal/domain"
|
||||
"botpontos/internal/rules"
|
||||
)
|
||||
|
||||
const (
|
||||
keyWindowStart = "query_window_start"
|
||||
keyWindowEnd = "query_window_end"
|
||||
keyCheckDeadline = "day_check_deadline"
|
||||
keyRecheckUntil = "no_points_recheck_until"
|
||||
)
|
||||
|
||||
func windowToSettings(w domain.QueryWindow) map[string]string {
|
||||
return map[string]string{
|
||||
keyWindowStart: w.Start.String(),
|
||||
keyWindowEnd: w.End.String(),
|
||||
keyCheckDeadline: w.DayCheckDeadline.String(),
|
||||
keyRecheckUntil: w.NoPointsRecheckUntil.String(),
|
||||
}
|
||||
}
|
||||
|
||||
// SeedSettings grava os padrões da janela na primeira subida (9.13).
|
||||
func (s *Service) SeedSettings(ctx context.Context) error {
|
||||
return s.store.SeedSettings(ctx, windowToSettings(s.cfg.DefaultWindow))
|
||||
}
|
||||
|
||||
// GlobalWindow lê a janela global do banco; valores ausentes ou inválidos usam o padrão.
|
||||
func (s *Service) GlobalWindow(ctx context.Context) (domain.QueryWindow, error) {
|
||||
values, err := s.store.GetSettings(ctx)
|
||||
if err != nil {
|
||||
return s.cfg.DefaultWindow, err
|
||||
}
|
||||
w := s.cfg.DefaultWindow
|
||||
read := func(key string, target *domain.ClockTime) {
|
||||
if v, ok := values[key]; ok {
|
||||
if c, err := domain.ParseClock(v); err == nil {
|
||||
*target = c
|
||||
}
|
||||
}
|
||||
}
|
||||
read(keyWindowStart, &w.Start)
|
||||
read(keyWindowEnd, &w.End)
|
||||
read(keyCheckDeadline, &w.DayCheckDeadline)
|
||||
read(keyRecheckUntil, &w.NoPointsRecheckUntil)
|
||||
return w, nil
|
||||
}
|
||||
|
||||
// UpdateGlobalWindow valida e grava a janela global; vale a partir do próximo tick.
|
||||
func (s *Service) UpdateGlobalWindow(ctx context.Context, actor Actor, w domain.QueryWindow) error {
|
||||
if errs := rules.ValidateWindow(w, s.cfg.Jobs.ReconciliationInterval, ""); len(errs) > 0 {
|
||||
return errs
|
||||
}
|
||||
previous, err := s.GlobalWindow(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.store.SaveSettings(ctx, windowToSettings(w), actor.AccountID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.store.ResetNextChecks(ctx, s.TodayDate()); err != nil {
|
||||
s.log.Warn("falha ao recalcular as consultas do dia", "error", err)
|
||||
}
|
||||
s.audit(ctx, actor, nil, domain.EventSettingsChanged, true, map[string]any{
|
||||
"before": windowToSettings(previous),
|
||||
"after": windowToSettings(w),
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// WindowPreview é o efeito estimado de uma nova janela global (9.13).
|
||||
type WindowPreview struct {
|
||||
Errors domain.ValidationErrors `json:"errors"`
|
||||
MaxQueriesPerUser int `json:"max_queries_per_user"`
|
||||
TypicalQueriesPerDay int `json:"typical_queries_per_user"`
|
||||
DayOffQueries int `json:"day_off_queries_per_user"`
|
||||
AffectedUsers int `json:"affected_users"`
|
||||
AffectedDays int `json:"affected_days"`
|
||||
CustomWindowUsers int `json:"custom_window_users"`
|
||||
}
|
||||
|
||||
func (s *Service) PreviewWindow(ctx context.Context, w domain.QueryWindow) (WindowPreview, error) {
|
||||
recon := s.cfg.Jobs.ReconciliationInterval
|
||||
p := WindowPreview{Errors: rules.ValidateWindow(w, recon, "")}
|
||||
if len(p.Errors) > 0 {
|
||||
return p, nil
|
||||
}
|
||||
p.MaxQueriesPerUser = int(time.Duration(w.End-w.Start)*time.Minute/recon) + 1
|
||||
p.TypicalQueriesPerDay = s.simulateDay(w, []string{"08:00", "12:00", "13:00", "17:00"})
|
||||
p.DayOffQueries = s.simulateDay(w, nil)
|
||||
|
||||
since := domain.LocalDay(s.now().AddDate(0, 0, -30), s.location)
|
||||
samples, err := s.store.Entrada2Samples(ctx, since)
|
||||
if err != nil {
|
||||
return p, err
|
||||
}
|
||||
users := map[int64]bool{}
|
||||
for _, x := range samples {
|
||||
if x.CustomWindow {
|
||||
continue
|
||||
}
|
||||
loc := domain.LoadLocation(x.Timezone)
|
||||
if x.ScheduledAt.Before(w.Start.On(x.Day, loc)) {
|
||||
users[x.UserID] = true
|
||||
p.AffectedDays++
|
||||
}
|
||||
}
|
||||
p.AffectedUsers = len(users)
|
||||
if p.CustomWindowUsers, err = s.store.CountCustomWindows(ctx); err != nil {
|
||||
return p, err
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// simulateDay conta quantas consultas a frequência adaptativa faria num dia
|
||||
// em que os pontos são batidos nos horários informados (jornada padrão).
|
||||
func (s *Service) simulateDay(w domain.QueryWindow, clocks []string) int {
|
||||
const day = "2026-01-07" // uma quarta-feira qualquer; só os horários importam
|
||||
loc := s.location
|
||||
schedule := domain.DefaultSchedule(0)
|
||||
recon := s.cfg.Jobs.ReconciliationInterval
|
||||
|
||||
var punches []domain.Point
|
||||
for _, c := range clocks {
|
||||
punches = append(punches, domain.Point{OccurredAt: domain.MustParseClock(c).On(day, loc)})
|
||||
}
|
||||
|
||||
queries := 0
|
||||
next := w.Start.On(day, loc)
|
||||
end := w.End.On(day, loc)
|
||||
for tick := next; !tick.After(end); tick = tick.Add(recon) {
|
||||
if tick.Before(next) {
|
||||
continue
|
||||
}
|
||||
queries++
|
||||
var visible []domain.Point
|
||||
for _, p := range punches {
|
||||
if !p.OccurredAt.After(tick) {
|
||||
visible = append(visible, p)
|
||||
}
|
||||
}
|
||||
wd := rules.CurrentWorkday(visible, day, loc)
|
||||
var saida2At *time.Time
|
||||
if wd.Count() == 3 {
|
||||
if t, err := rules.Saida2Time(wd, schedule.DailyWorkMinutes); err == nil {
|
||||
r := rules.ReminderAt(t, schedule.Saida2LeadMinutes)
|
||||
saida2At = &r
|
||||
}
|
||||
}
|
||||
plan := rules.PlanNextCheck(rules.PlanInput{
|
||||
Tick: tick, Day: day, Location: loc, Window: w, Workday: wd, Saida2At: saida2At,
|
||||
Reconciliation: recon, Slow: s.cfg.Jobs.SlowCheckInterval,
|
||||
ExitConfirmFrom: s.cfg.Jobs.ExitConfirmFrom, ExitConfirmTo: s.cfg.Jobs.ExitConfirmTo,
|
||||
})
|
||||
if plan.NextCheckAt == nil {
|
||||
break
|
||||
}
|
||||
next = *plan.NextCheckAt
|
||||
}
|
||||
return queries
|
||||
}
|
||||
214
backend/internal/app/today.go
Normal file
214
backend/internal/app/today.go
Normal file
@@ -0,0 +1,214 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"botpontos/internal/domain"
|
||||
"botpontos/internal/rules"
|
||||
)
|
||||
|
||||
// TodayView é o resultado de uma consulta ao vivo dos pontos do dia
|
||||
// (/entrada2, /saida2, botão "testar API" e tela do usuário).
|
||||
type TodayView struct {
|
||||
User domain.User
|
||||
Day string
|
||||
Points []time.Time
|
||||
Invalid bool
|
||||
Overtime bool
|
||||
Entrada2At *time.Time
|
||||
Saida2At *time.Time
|
||||
Saida2Error string
|
||||
LunchMinutes int
|
||||
DailyWorkMinutes int
|
||||
DayState domain.UserDay
|
||||
Window domain.QueryWindow
|
||||
CustomWindow bool
|
||||
Resumed bool // a consulta encontrou pontos e retomou um dia encerrado sem pontos
|
||||
}
|
||||
|
||||
// Today consulta a PontoMais agora. source indica quem pediu, para retomar
|
||||
// automaticamente um dia "sem pontos" quando a consulta manual encontra pontos (9.12).
|
||||
func (s *Service) Today(ctx context.Context, actor Actor, userID int64, source *domain.ResumeSource) (TodayView, error) {
|
||||
u, err := s.store.GetUser(ctx, userID)
|
||||
if err != nil {
|
||||
return TodayView{}, err
|
||||
}
|
||||
cred, err := s.requireValidatedCredential(ctx, userID)
|
||||
if err != nil {
|
||||
return TodayView{}, err
|
||||
}
|
||||
schedule, err := s.store.GetSchedule(ctx, userID)
|
||||
if err != nil {
|
||||
return TodayView{}, err
|
||||
}
|
||||
global, err := s.GlobalWindow(ctx)
|
||||
if err != nil {
|
||||
return TodayView{}, err
|
||||
}
|
||||
|
||||
loc := u.Location()
|
||||
points, err := s.points.History(ctx, cred, loc)
|
||||
if err != nil {
|
||||
return TodayView{}, err
|
||||
}
|
||||
|
||||
now := s.now()
|
||||
view := TodayView{
|
||||
User: u,
|
||||
Day: domain.LocalDay(now, loc),
|
||||
LunchMinutes: schedule.LunchMinutes,
|
||||
DailyWorkMinutes: schedule.DailyWorkMinutes,
|
||||
}
|
||||
view.Window, view.CustomWindow = rules.EffectiveWindow(global, schedule)
|
||||
|
||||
w := rules.CurrentWorkday(points, view.Day, loc)
|
||||
view.Invalid, view.Overtime = w.Invalid, w.Overtime
|
||||
for _, p := range w.Points {
|
||||
view.Points = append(view.Points, p.OccurredAt)
|
||||
}
|
||||
if !w.Invalid && !w.Overtime {
|
||||
if t, err := rules.Entrada2Time(w, schedule.LunchMinutes); err == nil {
|
||||
view.Entrada2At = &t
|
||||
}
|
||||
if w.Count() >= 3 {
|
||||
if t, err := rules.Saida2Time(w, schedule.DailyWorkMinutes); err == nil {
|
||||
view.Saida2At = &t
|
||||
} else {
|
||||
view.Saida2Error = err.Error()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if view.DayState, _, err = s.store.GetUserDay(ctx, userID, view.Day); err != nil {
|
||||
return view, err
|
||||
}
|
||||
if source != nil && w.Count() > 0 && view.DayState.IsClosedWithoutPoints() {
|
||||
if d, err := s.resumeDayIfClosed(ctx, actor, u, *source); err == nil {
|
||||
view.DayState, view.Resumed = d, true
|
||||
}
|
||||
}
|
||||
return view, nil
|
||||
}
|
||||
|
||||
// TestCredentials consulta a PontoMais com a credencial atual (botão do painel).
|
||||
func (s *Service) TestCredentials(ctx context.Context, actor Actor, userID int64) (TodayView, error) {
|
||||
view, err := s.Today(ctx, actor, userID, nil)
|
||||
if err != nil {
|
||||
if errors.Is(err, domain.ErrCredentialsRequired) {
|
||||
// Credencial gravada mas nunca validada também pode ser testada.
|
||||
if cred, cerr := s.store.GetCredential(ctx, userID); cerr == nil {
|
||||
return s.testUnvalidated(ctx, actor, userID, cred)
|
||||
}
|
||||
}
|
||||
s.audit(ctx, actor, &userID, domain.EventCredentialsTested, false, map[string]any{"error": shortError(err)})
|
||||
return view, err
|
||||
}
|
||||
if err := s.store.MarkCredentialValidated(ctx, userID, s.now()); err != nil {
|
||||
return view, err
|
||||
}
|
||||
s.audit(ctx, actor, &userID, domain.EventCredentialsTested, true, map[string]any{"points": len(view.Points)})
|
||||
return view, nil
|
||||
}
|
||||
|
||||
func (s *Service) testUnvalidated(ctx context.Context, actor Actor, userID int64, cred domain.Credential) (TodayView, error) {
|
||||
u, err := s.store.GetUser(ctx, userID)
|
||||
if err != nil {
|
||||
return TodayView{}, err
|
||||
}
|
||||
if _, err := s.points.History(ctx, cred, u.Location()); err != nil {
|
||||
s.audit(ctx, actor, &userID, domain.EventCredentialsTested, false, map[string]any{"error": shortError(err)})
|
||||
return TodayView{}, err
|
||||
}
|
||||
if err := s.store.MarkCredentialValidated(ctx, userID, s.now()); err != nil {
|
||||
return TodayView{}, err
|
||||
}
|
||||
return s.Today(ctx, actor, userID, nil)
|
||||
}
|
||||
|
||||
// ResumeDay retoma um dia encerrado sem pontos (botão do painel, 9.12).
|
||||
func (s *Service) ResumeDay(ctx context.Context, actor Actor, userID int64) (domain.UserDay, error) {
|
||||
u, err := s.store.GetUser(ctx, userID)
|
||||
if err != nil {
|
||||
return domain.UserDay{}, err
|
||||
}
|
||||
d, err := s.resumeDayIfClosed(ctx, actor, u, domain.ResumeWeb)
|
||||
if errors.Is(err, errNothingToResume) {
|
||||
return d, domain.InvalidState("o dia não está encerrado por falta de pontos")
|
||||
}
|
||||
return d, err
|
||||
}
|
||||
|
||||
var errNothingToResume = errors.New("nada a retomar")
|
||||
|
||||
func (s *Service) resumeDayIfClosed(ctx context.Context, actor Actor, u domain.User, source domain.ResumeSource) (domain.UserDay, error) {
|
||||
loc := u.Location()
|
||||
now := s.now()
|
||||
day := domain.LocalDay(now, loc)
|
||||
d, found, err := s.store.GetUserDay(ctx, u.ID, day)
|
||||
if err != nil {
|
||||
return d, err
|
||||
}
|
||||
if !found || !d.IsClosedWithoutPoints() {
|
||||
return d, errNothingToResume
|
||||
}
|
||||
window, err := s.windowFor(ctx, u.ID)
|
||||
if err != nil {
|
||||
return d, err
|
||||
}
|
||||
d.Status = domain.DayWorking
|
||||
d.ResumedBy = &source
|
||||
d.NextCheckAt = rules.ResumeNextCheck(now, window, day, loc, s.cfg.Jobs.ReconciliationInterval)
|
||||
if err := s.store.SaveUserDay(ctx, d); err != nil {
|
||||
return d, err
|
||||
}
|
||||
s.audit(ctx, actor, &u.ID, domain.EventDayResumed, true, map[string]any{"source": string(source)})
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// windowFor devolve a janela efetiva de um usuário.
|
||||
func (s *Service) windowFor(ctx context.Context, userID int64) (domain.QueryWindow, error) {
|
||||
global, err := s.GlobalWindow(ctx)
|
||||
if err != nil {
|
||||
return global, err
|
||||
}
|
||||
schedule, err := s.store.GetSchedule(ctx, userID)
|
||||
if err != nil {
|
||||
return global, err
|
||||
}
|
||||
w, _ := rules.EffectiveWindow(global, schedule)
|
||||
return w, nil
|
||||
}
|
||||
|
||||
// StatusInfo reúne o que o /status mostra.
|
||||
type StatusInfo struct {
|
||||
User domain.User
|
||||
HasCredential bool
|
||||
Validated bool
|
||||
Failures int
|
||||
Today domain.UserDay
|
||||
Window domain.QueryWindow
|
||||
CustomWindow bool
|
||||
BusinessDay bool
|
||||
}
|
||||
|
||||
func (s *Service) Status(ctx context.Context, userID int64) (StatusInfo, error) {
|
||||
detail, err := s.UserDetail(ctx, userID)
|
||||
if err != nil {
|
||||
return StatusInfo{}, err
|
||||
}
|
||||
info := StatusInfo{
|
||||
User: detail.User,
|
||||
Today: detail.Today,
|
||||
Window: detail.Window,
|
||||
CustomWindow: detail.CustomWindow,
|
||||
}
|
||||
if detail.Credential != nil {
|
||||
info.HasCredential = true
|
||||
info.Validated = detail.Credential.IsValidated()
|
||||
info.Failures = detail.Credential.ConsecutiveFailures
|
||||
}
|
||||
info.BusinessDay, _ = s.IsBusinessDay(ctx, s.now(), detail.User.Location())
|
||||
return info, nil
|
||||
}
|
||||
323
backend/internal/app/users.go
Normal file
323
backend/internal/app/users.go
Normal file
@@ -0,0 +1,323 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"botpontos/internal/domain"
|
||||
"botpontos/internal/rules"
|
||||
)
|
||||
|
||||
// Register cria o pedido de cadastro pelo /cadastrar (idempotente) e avisa os admins.
|
||||
func (s *Service) Register(ctx context.Context, chatID int64, name string) (domain.User, bool, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
name = "Sem nome"
|
||||
}
|
||||
u, created, err := s.store.CreatePendingUser(ctx, chatID, name, s.cfg.DefaultTimezone)
|
||||
if err != nil {
|
||||
return u, false, err
|
||||
}
|
||||
if created {
|
||||
s.audit(ctx, TelegramActor(u.ID), &u.ID, domain.EventUserRegistered, true, map[string]any{"name": u.DisplayName})
|
||||
s.notifyAdmins(ctx, msgNewRegistration(u))
|
||||
}
|
||||
return u, created, nil
|
||||
}
|
||||
|
||||
func (s *Service) UserByChatID(ctx context.Context, chatID int64) (domain.User, error) {
|
||||
return s.store.GetUserByChatID(ctx, chatID)
|
||||
}
|
||||
|
||||
// requireValidatedCredential é a regra única de ativação (Telegram e painel, 9.2).
|
||||
func (s *Service) requireValidatedCredential(ctx context.Context, userID int64) (domain.Credential, error) {
|
||||
cred, err := s.store.GetCredential(ctx, userID)
|
||||
if errors.Is(err, domain.ErrNotFound) || (err == nil && !cred.IsValidated()) {
|
||||
return cred, domain.ErrCredentialsRequired
|
||||
}
|
||||
return cred, err
|
||||
}
|
||||
|
||||
// ActivateUser aprova um cadastro. Exige credencial validada na PontoMais.
|
||||
func (s *Service) ActivateUser(ctx context.Context, actor Actor, userID int64) (domain.User, error) {
|
||||
u, err := s.store.GetUser(ctx, userID)
|
||||
if err != nil {
|
||||
return u, err
|
||||
}
|
||||
if u.IsActive() {
|
||||
return u, nil
|
||||
}
|
||||
if _, err := s.requireValidatedCredential(ctx, userID); err != nil {
|
||||
s.audit(ctx, actor, &userID, domain.EventUserActivated, false, map[string]any{"reason": "credencial ausente ou não validada"})
|
||||
return u, err
|
||||
}
|
||||
if err := s.store.SetUserStatus(ctx, userID, domain.UserActive); err != nil {
|
||||
return u, err
|
||||
}
|
||||
u.Status = domain.UserActive
|
||||
s.audit(ctx, actor, &userID, domain.EventUserActivated, true, nil)
|
||||
s.notifyUser(ctx, u, msgActivated)
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// DisableUser bloqueia o usuário, desliga os lembretes e cancela os agendados.
|
||||
func (s *Service) DisableUser(ctx context.Context, actor Actor, userID int64) error {
|
||||
u, err := s.store.GetUser(ctx, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.store.SetUserStatus(ctx, userID, domain.UserDisabled); err != nil {
|
||||
return err
|
||||
}
|
||||
if u.NotificationsEnabled {
|
||||
reason := domain.PauseUserDisabled
|
||||
if err := s.store.SetNotifications(ctx, userID, false, &reason); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
cancelled, err := s.store.CancelScheduledFrom(ctx, userID, domain.LocalDay(s.now(), u.Location()))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.audit(ctx, actor, &userID, domain.EventUserDisabled, true, map[string]any{"cancelled_notifications": cancelled})
|
||||
if u.IsActive() {
|
||||
s.notifyUser(ctx, u, msgDisabledByAdmin)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EnableNotifications liga os lembretes (/ativarponto ou painel), zera o
|
||||
// contador de falhas e retoma o dia se ele tinha sido encerrado sem pontos.
|
||||
func (s *Service) EnableNotifications(ctx context.Context, actor Actor, userID int64) (domain.User, error) {
|
||||
u, err := s.store.GetUser(ctx, userID)
|
||||
if err != nil {
|
||||
return u, err
|
||||
}
|
||||
if !u.IsActive() {
|
||||
return u, domain.ErrUserNotActive
|
||||
}
|
||||
if _, err := s.requireValidatedCredential(ctx, userID); err != nil {
|
||||
return u, err
|
||||
}
|
||||
if err := s.store.SetNotifications(ctx, userID, true, nil); err != nil {
|
||||
return u, err
|
||||
}
|
||||
if err := s.store.ResetCredentialFailures(ctx, userID); err != nil {
|
||||
return u, err
|
||||
}
|
||||
u.NotificationsEnabled, u.PausedReason = true, nil
|
||||
s.audit(ctx, actor, &userID, domain.EventNotificationsEnabled, true, nil)
|
||||
|
||||
source := domain.ResumeAtivarPonto
|
||||
if actor.Source == domain.SourceWeb {
|
||||
source = domain.ResumeWeb
|
||||
}
|
||||
if _, err := s.resumeDayIfClosed(ctx, actor, u, source); err != nil && !errors.Is(err, errNothingToResume) {
|
||||
s.log.Warn("falha ao retomar o dia ao ligar lembretes", "user_id", userID, "error", err)
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// DisableNotifications pausa os lembretes e cancela os agendados do dia em diante.
|
||||
func (s *Service) DisableNotifications(ctx context.Context, actor Actor, userID int64, reason domain.PauseReason) error {
|
||||
u, err := s.store.GetUser(ctx, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.store.SetNotifications(ctx, userID, false, &reason); err != nil {
|
||||
return err
|
||||
}
|
||||
cancelled, err := s.store.CancelScheduledFrom(ctx, userID, domain.LocalDay(s.now(), u.Location()))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.audit(ctx, actor, &userID, domain.EventNotificationsDisabled, true,
|
||||
map[string]any{"reason": string(reason), "cancelled_notifications": cancelled})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) SetBotAdmin(ctx context.Context, actor Actor, userID int64, enabled bool) error {
|
||||
if _, err := s.store.GetUser(ctx, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.store.SetBotAdmin(ctx, userID, enabled); err != nil {
|
||||
return err
|
||||
}
|
||||
s.audit(ctx, actor, &userID, domain.EventBotAdminChanged, true, map[string]any{"enabled": enabled})
|
||||
return nil
|
||||
}
|
||||
|
||||
// ScheduleInput são os campos editáveis da jornada no painel.
|
||||
type ScheduleInput struct {
|
||||
LunchMinutes int
|
||||
DailyWorkMinutes int
|
||||
Entrada2LeadMinutes int
|
||||
Saida2LeadMinutes int
|
||||
Timezone string
|
||||
Window *domain.QueryWindow // nil = usa a janela global
|
||||
}
|
||||
|
||||
// UpdateSchedule valida (9.8, 9.13, 9.14) e grava a jornada e a exceção de janela.
|
||||
func (s *Service) UpdateSchedule(ctx context.Context, actor Actor, userID int64, in ScheduleInput) (domain.WorkSchedule, error) {
|
||||
u, err := s.store.GetUser(ctx, userID)
|
||||
if err != nil {
|
||||
return domain.WorkSchedule{}, err
|
||||
}
|
||||
global, err := s.GlobalWindow(ctx)
|
||||
if err != nil {
|
||||
return domain.WorkSchedule{}, err
|
||||
}
|
||||
next := domain.WorkSchedule{
|
||||
UserID: userID,
|
||||
LunchMinutes: in.LunchMinutes,
|
||||
DailyWorkMinutes: in.DailyWorkMinutes,
|
||||
Entrada2LeadMinutes: in.Entrada2LeadMinutes,
|
||||
Saida2LeadMinutes: in.Saida2LeadMinutes,
|
||||
Window: in.Window,
|
||||
}
|
||||
errs := rules.ValidateSchedule(next, global, s.cfg.Jobs.ReconciliationInterval)
|
||||
var tzErrs domain.ValidationErrors
|
||||
if err := rules.ValidateTimezone(in.Timezone); errors.As(err, &tzErrs) {
|
||||
errs = append(errs, tzErrs...)
|
||||
}
|
||||
if len(errs) > 0 {
|
||||
return next, errs
|
||||
}
|
||||
|
||||
previous, err := s.store.GetSchedule(ctx, userID)
|
||||
if err != nil {
|
||||
return next, err
|
||||
}
|
||||
if err := s.store.SaveSchedule(ctx, next); err != nil {
|
||||
return next, err
|
||||
}
|
||||
if in.Timezone != u.Timezone {
|
||||
if err := s.store.SetTimezone(ctx, userID, in.Timezone); err != nil {
|
||||
return next, err
|
||||
}
|
||||
}
|
||||
|
||||
s.audit(ctx, actor, &userID, domain.EventScheduleChanged, true, map[string]any{
|
||||
"before": scheduleDetails(previous, u.Timezone),
|
||||
"after": scheduleDetails(next, in.Timezone),
|
||||
})
|
||||
if windowText(previous.Window) != windowText(next.Window) {
|
||||
s.audit(ctx, actor, &userID, domain.EventUserWindowChanged, true, map[string]any{
|
||||
"before": windowText(previous.Window),
|
||||
"after": windowText(next.Window),
|
||||
})
|
||||
s.recheckToday(ctx, u)
|
||||
}
|
||||
return next, nil
|
||||
}
|
||||
|
||||
// recheckToday antecipa a próxima consulta do dia, para a nova janela valer no próximo tick.
|
||||
func (s *Service) recheckToday(ctx context.Context, u domain.User) {
|
||||
day := domain.LocalDay(s.now(), u.Location())
|
||||
d, found, err := s.store.GetUserDay(ctx, u.ID, day)
|
||||
if err != nil || !found || d.NextCheckAt == nil {
|
||||
return
|
||||
}
|
||||
now := s.now()
|
||||
d.NextCheckAt = &now
|
||||
if err := s.store.SaveUserDay(ctx, d); err != nil {
|
||||
s.log.Warn("falha ao recalcular a próxima consulta", "user_id", u.ID, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func scheduleDetails(sch domain.WorkSchedule, timezone string) map[string]any {
|
||||
return map[string]any{
|
||||
"lunch_minutes": sch.LunchMinutes,
|
||||
"daily_work_minutes": sch.DailyWorkMinutes,
|
||||
"entrada2_lead_minutes": sch.Entrada2LeadMinutes,
|
||||
"saida2_lead_minutes": sch.Saida2LeadMinutes,
|
||||
"timezone": timezone,
|
||||
"window": windowText(sch.Window),
|
||||
}
|
||||
}
|
||||
|
||||
func windowText(w *domain.QueryWindow) string {
|
||||
if w == nil {
|
||||
return "global"
|
||||
}
|
||||
return fmt.Sprintf("%s–%s (confirmação %s, reverificações até %s)", w.Start, w.End, w.DayCheckDeadline, w.NoPointsRecheckUntil)
|
||||
}
|
||||
|
||||
// CredentialError indica que a PontoMais não aceitou a credencial informada.
|
||||
type CredentialError struct{ Err error }
|
||||
|
||||
func (e *CredentialError) Error() string { return "a PontoMais não aceitou a credencial: " + e.Err.Error() }
|
||||
func (e *CredentialError) Unwrap() error { return e.Err }
|
||||
|
||||
// SetCredentials valida a credencial na PontoMais e, só se der certo, cifra e
|
||||
// substitui a anterior (que é preservada em caso de falha).
|
||||
func (s *Service) SetCredentials(ctx context.Context, actor Actor, userID int64, email, password string) error {
|
||||
email = strings.TrimSpace(email)
|
||||
var errs domain.ValidationErrors
|
||||
if email == "" {
|
||||
errs.Add("email", "obrigatório")
|
||||
}
|
||||
if password == "" {
|
||||
errs.Add("password", "obrigatória")
|
||||
}
|
||||
if len(errs) > 0 {
|
||||
return errs
|
||||
}
|
||||
u, err := s.store.GetUser(ctx, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.points.Validate(ctx, email, password, u.Location()); err != nil {
|
||||
s.audit(ctx, actor, &userID, domain.EventCredentialsChanged, false, map[string]any{"error": shortError(err)})
|
||||
return &CredentialError{Err: err}
|
||||
}
|
||||
encrypted, version, err := s.cipher.Encrypt(password)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.store.SaveCredential(ctx, userID, email, encrypted, version, s.now()); err != nil {
|
||||
return err
|
||||
}
|
||||
s.points.Invalidate(userID)
|
||||
s.audit(ctx, actor, &userID, domain.EventCredentialsChanged, true, map[string]any{"email": email})
|
||||
return nil
|
||||
}
|
||||
|
||||
// UserDetail reúne o que a tela do usuário mostra (a senha nunca é exposta).
|
||||
type UserDetail struct {
|
||||
User domain.User
|
||||
Schedule domain.WorkSchedule
|
||||
Credential *domain.Credential
|
||||
Today domain.UserDay
|
||||
Window domain.QueryWindow
|
||||
CustomWindow bool
|
||||
}
|
||||
|
||||
func (s *Service) UserDetail(ctx context.Context, userID int64) (UserDetail, error) {
|
||||
u, err := s.store.GetUser(ctx, userID)
|
||||
if err != nil {
|
||||
return UserDetail{}, err
|
||||
}
|
||||
schedule, err := s.store.GetSchedule(ctx, userID)
|
||||
if err != nil {
|
||||
return UserDetail{}, err
|
||||
}
|
||||
detail := UserDetail{User: u, Schedule: schedule}
|
||||
if cred, err := s.store.GetCredential(ctx, userID); err == nil {
|
||||
cred.EncryptedPassword = nil
|
||||
detail.Credential = &cred
|
||||
} else if !errors.Is(err, domain.ErrNotFound) {
|
||||
return detail, err
|
||||
}
|
||||
if detail.Today, _, err = s.store.GetUserDay(ctx, userID, domain.LocalDay(s.now(), u.Location())); err != nil {
|
||||
return detail, err
|
||||
}
|
||||
global, err := s.GlobalWindow(ctx)
|
||||
if err != nil {
|
||||
return detail, err
|
||||
}
|
||||
detail.Window, detail.CustomWindow = rules.EffectiveWindow(global, schedule)
|
||||
return detail, nil
|
||||
}
|
||||
296
backend/internal/app/webauth.go
Normal file
296
backend/internal/app/webauth.go
Normal file
@@ -0,0 +1,296 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/netip"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"botpontos/internal/adapters/crypto"
|
||||
"botpontos/internal/adapters/postgres"
|
||||
"botpontos/internal/domain"
|
||||
)
|
||||
|
||||
type LoginResult struct {
|
||||
Token string
|
||||
CSRFToken string
|
||||
ExpiresAt time.Time
|
||||
Account domain.WebAccount
|
||||
}
|
||||
|
||||
// Login valida usuário e senha (Argon2id), aplica o bloqueio por tentativas e cria a sessão.
|
||||
func (s *Service) Login(ctx context.Context, username, password, ip, userAgent string) (LoginResult, error) {
|
||||
now := s.now()
|
||||
username = strings.TrimSpace(username)
|
||||
|
||||
account, err := s.store.GetWebAccountByUsername(ctx, username)
|
||||
if errors.Is(err, domain.ErrNotFound) {
|
||||
crypto.DummyVerify(password)
|
||||
s.audit(ctx, Actor{Source: domain.SourceWeb}, nil, domain.EventWebLoginFailed, false,
|
||||
map[string]any{"username": username, "reason": "usuário inexistente", "ip": ip})
|
||||
return LoginResult{}, domain.ErrInvalidLogin
|
||||
}
|
||||
if err != nil {
|
||||
return LoginResult{}, err
|
||||
}
|
||||
actor := WebActor(account.ID)
|
||||
if account.IsDisabled() {
|
||||
crypto.DummyVerify(password)
|
||||
s.audit(ctx, actor, nil, domain.EventWebLoginFailed, false, map[string]any{"reason": "conta desativada", "ip": ip})
|
||||
return LoginResult{}, domain.ErrInvalidLogin
|
||||
}
|
||||
if account.IsLocked(now) {
|
||||
s.audit(ctx, actor, nil, domain.EventWebLoginFailed, false, map[string]any{"reason": "conta bloqueada", "ip": ip})
|
||||
return LoginResult{}, domain.ErrAccountLocked
|
||||
}
|
||||
if !crypto.VerifyPassword(account.PasswordHash, password) {
|
||||
locked, err := s.store.RecordLoginFailure(ctx, account.ID, s.cfg.Web.LoginMaxFailures, now.Add(s.cfg.Web.LoginLock))
|
||||
if err != nil {
|
||||
return LoginResult{}, err
|
||||
}
|
||||
s.audit(ctx, actor, nil, domain.EventWebLoginFailed, false, map[string]any{"reason": "senha incorreta", "locked": locked, "ip": ip})
|
||||
if locked {
|
||||
return LoginResult{}, domain.ErrAccountLocked
|
||||
}
|
||||
return LoginResult{}, domain.ErrInvalidLogin
|
||||
}
|
||||
|
||||
if err := s.store.RecordLoginSuccess(ctx, account.ID, now); err != nil {
|
||||
return LoginResult{}, err
|
||||
}
|
||||
token, tokenHash, err := crypto.NewToken()
|
||||
if err != nil {
|
||||
return LoginResult{}, err
|
||||
}
|
||||
csrf, csrfHash, err := crypto.NewToken()
|
||||
if err != nil {
|
||||
return LoginResult{}, err
|
||||
}
|
||||
expires := now.Add(s.cfg.Web.SessionDuration)
|
||||
if err := s.store.CreateSession(ctx, postgres.NewSession{
|
||||
AccountID: account.ID,
|
||||
TokenHash: tokenHash,
|
||||
CSRFTokenHash: csrfHash,
|
||||
ExpiresAt: expires,
|
||||
UserAgent: limitPtr(userAgent, 300),
|
||||
IP: ipPtr(ip),
|
||||
}); err != nil {
|
||||
return LoginResult{}, err
|
||||
}
|
||||
s.audit(ctx, actor, nil, domain.EventWebLogin, true, map[string]any{"ip": ip})
|
||||
account.PasswordHash = ""
|
||||
return LoginResult{Token: token, CSRFToken: csrf, ExpiresAt: expires, Account: account}, nil
|
||||
}
|
||||
|
||||
// Authenticate valida o token de sessão: revogação, expiração absoluta e inatividade.
|
||||
func (s *Service) Authenticate(ctx context.Context, token string) (domain.WebSession, error) {
|
||||
if !crypto.ValidTokenFormat(token) {
|
||||
return domain.WebSession{}, domain.ErrUnauthenticated
|
||||
}
|
||||
session, err := s.store.GetSessionByTokenHash(ctx, crypto.HashToken(token))
|
||||
if errors.Is(err, domain.ErrNotFound) {
|
||||
return session, domain.ErrUnauthenticated
|
||||
}
|
||||
if err != nil {
|
||||
return session, err
|
||||
}
|
||||
now := s.now()
|
||||
switch {
|
||||
case session.RevokedAt != nil, now.After(session.ExpiresAt), session.Account.IsDisabled():
|
||||
return session, domain.ErrUnauthenticated
|
||||
case now.Sub(session.LastSeenAt) > s.cfg.Web.SessionIdle:
|
||||
_ = s.store.RevokeSession(ctx, session.ID)
|
||||
return session, domain.ErrUnauthenticated
|
||||
}
|
||||
if now.Sub(session.LastSeenAt) > time.Minute {
|
||||
if err := s.store.TouchSession(ctx, session.ID, now); err != nil {
|
||||
s.log.Warn("falha ao atualizar sessão", "error", err)
|
||||
}
|
||||
}
|
||||
session.Account.PasswordHash = ""
|
||||
return session, nil
|
||||
}
|
||||
|
||||
func (s *Service) CheckCSRF(session domain.WebSession, token string) bool {
|
||||
return token != "" && crypto.TokenMatches(token, session.CSRFTokenHash)
|
||||
}
|
||||
|
||||
func (s *Service) Logout(ctx context.Context, session domain.WebSession) error {
|
||||
if err := s.store.RevokeSession(ctx, session.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
s.audit(ctx, WebActor(session.AccountID), nil, domain.EventWebLogout, true, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
var usernamePattern = regexp.MustCompile(`^[a-zA-Z0-9._-]{3,50}$`)
|
||||
|
||||
type AccountInput struct {
|
||||
Username string
|
||||
Password string
|
||||
Role domain.Role
|
||||
UserID *int64
|
||||
}
|
||||
|
||||
func (s *Service) CreateAccount(ctx context.Context, actor Actor, in AccountInput) (domain.WebAccount, error) {
|
||||
var errs domain.ValidationErrors
|
||||
in.Username = strings.TrimSpace(in.Username)
|
||||
if !usernamePattern.MatchString(in.Username) {
|
||||
errs.Add("username", "use de 3 a 50 caracteres: letras, números, ponto, hífen ou sublinhado")
|
||||
}
|
||||
if !in.Role.Valid() {
|
||||
errs.Add("role", "use owner, admin ou viewer")
|
||||
}
|
||||
if err := crypto.ValidatePassword(in.Password); err != nil {
|
||||
errs.Add("password", err.Error())
|
||||
}
|
||||
if in.UserID != nil {
|
||||
if err := s.checkAccountUser(ctx, 0, *in.UserID); err != nil {
|
||||
errs.Add("user_id", err.Error())
|
||||
}
|
||||
}
|
||||
if len(errs) > 0 {
|
||||
return domain.WebAccount{}, errs
|
||||
}
|
||||
hash, err := crypto.HashPassword(in.Password)
|
||||
if err != nil {
|
||||
return domain.WebAccount{}, err
|
||||
}
|
||||
account, err := s.store.CreateWebAccount(ctx, in.Username, hash, in.Role, in.UserID)
|
||||
if errors.Is(err, domain.ErrConflict) {
|
||||
return account, domain.ValidationErrors{{Field: "username", Message: "já existe uma conta com esse nome"}}
|
||||
}
|
||||
if err != nil {
|
||||
return account, err
|
||||
}
|
||||
s.audit(ctx, actor, in.UserID, domain.EventWebAccountCreated, true,
|
||||
map[string]any{"account_id": account.ID, "username": account.Username, "role": string(account.Role)})
|
||||
account.PasswordHash = ""
|
||||
return account, nil
|
||||
}
|
||||
|
||||
// checkAccountUser confere que o usuário do Telegram existe e não está ligado a
|
||||
// outra conta do painel (accountID é a conta que está sendo alterada, 0 se nova).
|
||||
func (s *Service) checkAccountUser(ctx context.Context, accountID, userID int64) error {
|
||||
if _, err := s.store.GetUser(ctx, userID); err != nil {
|
||||
return errors.New("usuário do Telegram não encontrado")
|
||||
}
|
||||
accounts, err := s.store.ListWebAccounts(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, a := range accounts {
|
||||
if a.ID != accountID && a.UserID != nil && *a.UserID == userID {
|
||||
return errors.New("esse usuário já está ligado à conta " + a.Username)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AccountUserLink pede para trocar o vínculo da conta; UserID nil remove o vínculo.
|
||||
type AccountUserLink struct {
|
||||
UserID *int64
|
||||
}
|
||||
|
||||
// UpdateAccount altera papel, situação e/ou vínculo com o usuário do Telegram,
|
||||
// mantendo sempre um owner ativo (9.6).
|
||||
func (s *Service) UpdateAccount(ctx context.Context, actor Actor, id int64, role *domain.Role, disabled *bool, link *AccountUserLink) error {
|
||||
if role != nil && !role.Valid() {
|
||||
return domain.ValidationErrors{{Field: "role", Message: "use owner, admin ou viewer"}}
|
||||
}
|
||||
if link != nil && link.UserID != nil {
|
||||
if err := s.checkAccountUser(ctx, id, *link.UserID); err != nil {
|
||||
return domain.ValidationErrors{{Field: "user_id", Message: err.Error()}}
|
||||
}
|
||||
}
|
||||
account, err := s.store.GetWebAccount(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
losesOwner := account.Role == domain.RoleOwner && !account.IsDisabled() &&
|
||||
((role != nil && *role != domain.RoleOwner) || (disabled != nil && *disabled))
|
||||
if losesOwner {
|
||||
owners, err := s.store.CountActiveOwners(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if owners <= 1 {
|
||||
return domain.ErrLastOwner
|
||||
}
|
||||
}
|
||||
var userID *int64
|
||||
if link != nil {
|
||||
userID = link.UserID
|
||||
}
|
||||
if err := s.store.UpdateWebAccount(ctx, id, role, disabled, link != nil, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
if disabled != nil && *disabled {
|
||||
if err := s.store.RevokeAccountSessions(ctx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
details := map[string]any{"account_id": id}
|
||||
if role != nil {
|
||||
details["role"] = string(*role)
|
||||
}
|
||||
if disabled != nil {
|
||||
details["disabled"] = *disabled
|
||||
}
|
||||
if link != nil {
|
||||
details["user_id"] = link.UserID
|
||||
}
|
||||
s.audit(ctx, actor, userID, domain.EventWebAccountChanged, true, details)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ResetPassword troca a senha e encerra todas as sessões da conta.
|
||||
func (s *Service) ResetPassword(ctx context.Context, actor Actor, id int64, password string) error {
|
||||
if err := crypto.ValidatePassword(password); err != nil {
|
||||
return domain.ValidationErrors{{Field: "password", Message: err.Error()}}
|
||||
}
|
||||
if _, err := s.store.GetWebAccount(ctx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
hash, err := crypto.HashPassword(password)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.store.SetWebAccountPassword(ctx, id, hash); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.store.RevokeAccountSessions(ctx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
s.audit(ctx, actor, nil, domain.EventWebPasswordReset, true, map[string]any{"account_id": id})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) ResetPasswordByUsername(ctx context.Context, actor Actor, username, password string) error {
|
||||
account, err := s.store.GetWebAccountByUsername(ctx, username)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.ResetPassword(ctx, actor, account.ID, password)
|
||||
}
|
||||
|
||||
func limitPtr(v string, max int) *string {
|
||||
v = strings.TrimSpace(v)
|
||||
if v == "" {
|
||||
return nil
|
||||
}
|
||||
v = truncate(v, max)
|
||||
return &v
|
||||
}
|
||||
|
||||
// ipPtr só aceita endereços válidos (a coluna é inet).
|
||||
func ipPtr(ip string) *string {
|
||||
addr, err := netip.ParseAddr(ip)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
v := addr.String()
|
||||
return &v
|
||||
}
|
||||
Reference in New Issue
Block a user