061366da5a
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 1m8s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m40s
Two email changes, per the owner: 1. Code-only login from an installed PWA. A login requested while running as a standalone PWA now omits the one-tap confirm link from the email — the link would open in a separate browser whose minted session cannot reach the PWA, stranding the login. The code is typed in the same window instead. The client sends a `pwa` flag (isStandalone) on the email-code request (a new FBS field, threaded through the gateway); the backend omits the deeplink when it is set, reusing the existing deletion-code link-omission path. Non-PWA browser logins keep the one-tap link. No polling, no migration. 2. Security: the operator alert digest no longer embeds the admin-console (/_gm) URL. An admin link must never travel in an email, where a mail provider could cache or index it; the operator opens the console directly. Tests: an inttest asserting the PWA login email omits the link (and a browser one keeps it); a codec round-trip of the new pwa field; the alert-digest test flipped to guard the admin link is absent. Docs: ARCHITECTURE + FUNCTIONAL (+ru).
116 lines
3.7 KiB
Go
116 lines
3.7 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
|
|
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,
|
|
}
|
|
}
|