152 lines
5.3 KiB
Go
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
|
|
}
|