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 }