8d2cd97e17
New adminalert worker polls for feedback + word complaints arriving since the last check and coalesces a burst into one digest email per interval (5 min), inert unless a distinct admin sender (BACKEND_SMTP_ADMIN_FROM) and recipient (BACKEND_ADMIN_EMAIL, comma-separated allowed) are configured. The mailer gains a per-message From override and splits a comma-separated To into separate recipients (go-mail needs them as a list). Feedback/game stores gain CountSince/CountComplaintsSince. Unit tests cover the digest, the skip-when- empty, and the recipient split.
117 lines
3.6 KiB
Go
117 lines
3.6 KiB
Go
// 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
|
|
consoleURL 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); consoleURL,
|
|
// when non-empty, is the admin-console link included in the email. log may be nil. The
|
|
// watermark starts at "now", so only items arriving after start-up are reported.
|
|
func New(mailer account.Mailer, fb FeedbackCounter, cp ComplaintCounter, from, to, consoleURL string, log *zap.Logger) *Notifier {
|
|
if log == nil {
|
|
log = zap.NewNop()
|
|
}
|
|
return &Notifier{
|
|
mailer: mailer, feedback: fb, complaints: cp, from: from, to: to, consoleURL: consoleURL,
|
|
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, ", ")
|
|
text := summary + "."
|
|
if n.consoleURL != "" {
|
|
text += "\n\nOpen the admin console: " + n.consoleURL
|
|
}
|
|
return account.Message{
|
|
From: n.from,
|
|
To: n.to,
|
|
Subject: "Erudit — " + summary,
|
|
Text: text,
|
|
}
|
|
}
|