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 }