feat(adminalert): operator email on new feedback / word complaints

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.
This commit is contained in:
Ilia Denisov
2026-07-03 14:53:51 +02:00
parent f1a12c2f44
commit 8d2cd97e17
10 changed files with 287 additions and 11 deletions
+18
View File
@@ -12,6 +12,7 @@ import (
"fmt"
"log"
"os/signal"
"strings"
"syscall"
"time"
@@ -20,6 +21,7 @@ import (
"scrabble/backend/internal/account"
"scrabble/backend/internal/accountmerge"
"scrabble/backend/internal/adminalert"
"scrabble/backend/internal/ads"
"scrabble/backend/internal/banview"
"scrabble/backend/internal/config"
@@ -44,6 +46,10 @@ import (
// telemetryShutdownTimeout bounds the OpenTelemetry flush during process exit.
const telemetryShutdownTimeout = 5 * time.Second
// adminAlertInterval is how often the operator-alert worker checks for new feedback /
// complaints; a burst within one interval coalesces into a single digest email.
const adminAlertInterval = 5 * time.Minute
func main() {
cfg, err := config.Load()
if err != nil {
@@ -207,6 +213,18 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
feedbackSvc := feedback.NewService(feedback.NewStore(db), accounts)
feedbackSvc.SetNotifier(hub)
// Operator alert emails on new feedback / word complaints, coalesced into one digest
// per interval. Inert unless a distinct admin sender and recipient are configured.
if cfg.SMTP.AdminFrom != "" && cfg.SMTP.AdminTo != "" {
consoleURL := ""
if cfg.PublicBaseURL != "" {
consoleURL = strings.TrimRight(cfg.PublicBaseURL, "/") + "/_gm"
}
alerts := adminalert.New(mailer, feedbackSvc, games, cfg.SMTP.AdminFrom, cfg.SMTP.AdminTo, consoleURL, logger)
go alerts.Run(ctx, adminAlertInterval)
logger.Info("admin alert worker started", zap.Duration("interval", adminAlertInterval))
}
// Robot opponent: provision its durable account pool (a hard startup
// dependency, like the dictionaries) and start its move driver. The matchmaker
// substitutes a pooled robot for a missing human after the wait window.