Files
bot-pontos/backend/internal/app/dispatch.go
2026-10-23 22:31:02 -03:00

152 lines
5.3 KiB
Go

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
}