// Package adminalert emails the operator when new player feedback or word complaints // arrive, coalescing a burst into a single digest per interval so a flood is one email, // not N. It is inert unless an admin sender and recipient are configured. The sender is // distinct from the user-facing confirm-code From, and the recipient may be several // comma-separated addresses (the mailer splits them). package adminalert import ( "context" "fmt" "strings" "time" "go.uber.org/zap" "scrabble/backend/internal/account" ) // FeedbackCounter counts feedback created since a time (satisfied by feedback.Service). type FeedbackCounter interface { CountSince(ctx context.Context, since time.Time) (int, error) } // ComplaintCounter counts word complaints filed since a time (satisfied by game.Service). type ComplaintCounter interface { CountComplaintsSince(ctx context.Context, since time.Time) (int, error) } // Notifier polls for new feedback and complaints and emails the operator a digest. type Notifier struct { mailer account.Mailer feedback FeedbackCounter complaints ComplaintCounter from string to string clock func() time.Time log *zap.Logger last time.Time } // New constructs a Notifier. from and to are the alert sender and recipient(s); log may be // nil. The watermark starts at "now", so only items arriving after start-up are reported. The // digest deliberately carries no admin-console link — an admin URL must never travel in an // email, where a mail provider could cache or index it. func New(mailer account.Mailer, fb FeedbackCounter, cp ComplaintCounter, from, to string, log *zap.Logger) *Notifier { if log == nil { log = zap.NewNop() } return &Notifier{ mailer: mailer, feedback: fb, complaints: cp, from: from, to: to, clock: func() time.Time { return time.Now().UTC() }, log: log, last: time.Now().UTC(), } } // Run polls on each tick until ctx is cancelled. func (n *Notifier) Run(ctx context.Context, interval time.Duration) { ticker := time.NewTicker(interval) defer ticker.Stop() for { select { case <-ctx.Done(): return case <-ticker.C: n.tick(ctx) } } } // tick counts what arrived since the last watermark and, if anything did, emails one // digest. The watermark only advances after a successful send (or a quiet tick), so a // transient send failure is retried on the next tick — the counts simply grow. func (n *Notifier) tick(ctx context.Context) { now := n.clock() fb, err := n.feedback.CountSince(ctx, n.last) if err != nil { n.log.Warn("admin alert: count feedback failed", zap.Error(err)) return } cp, err := n.complaints.CountComplaintsSince(ctx, n.last) if err != nil { n.log.Warn("admin alert: count complaints failed", zap.Error(err)) return } if fb == 0 && cp == 0 { n.last = now return } if err := n.mailer.Send(ctx, n.digest(fb, cp)); err != nil { n.log.Warn("admin alert: send failed", zap.Error(err)) return } n.log.Info("admin alert sent", zap.Int("feedback", fb), zap.Int("complaints", cp)) n.last = now } // digest builds the operator alert email for fb new feedback and cp new complaints. func (n *Notifier) digest(fb, cp int) account.Message { var parts []string if fb > 0 { parts = append(parts, fmt.Sprintf("%d new feedback message(s)", fb)) } if cp > 0 { parts = append(parts, fmt.Sprintf("%d new word complaint(s)", cp)) } summary := strings.Join(parts, ", ") // No admin-console link in the body: an admin URL must never travel in an email (a mail // provider could cache or index it). The operator opens the console directly. text := summary + "." return account.Message{ From: n.from, To: n.to, Subject: "Erudit — " + summary, Text: text, } }