diplomail (Stage B): admin/owner sends + lifecycle hooks
Item 7 of the spec wants game-state and membership-state changes to land as durable inbox entries the affected players can re-read after the fact — push alone times out of the 5-minute ring buffer. Stage B adds the admin-kind send matrix (owner-driven via /user, site-admin driven via /admin) plus the lobby lifecycle hooks: paused / cancelled emit a broadcast system mail to active members, kick / ban emit a single-recipient system mail to the affected user (which they keep read access to even after the membership row is revoked, per item 8). Migration relaxes diplomail_messages_kind_sender_chk so an owner sending kind=admin keeps sender_kind=player; the new LifecyclePublisher dep on lobby.Service is wired through a thin adapter in cmd/backend/main, mirroring how lobby's notification publisher is plumbed today. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -15,7 +15,7 @@ purge, and the language-detection / translation cache.
|
||||
| Stage | Scope | Status |
|
||||
|-------|-------|--------|
|
||||
| A | Schema, personal single-recipient send / read / delete, unread badge, push event with body-language `und` | shipped |
|
||||
| B | Owner / admin sends + lifecycle hooks (paused, cancelled, kick) | planned |
|
||||
| B | Owner / admin sends + lifecycle hooks (paused, cancelled, kick); strict soft-access for kicked players | shipped |
|
||||
| C | Paid-tier personal broadcast + admin multi-game broadcast + bulk purge | planned |
|
||||
| D | Body-language detection (whatlanggo) + translation cache + async worker | planned |
|
||||
|
||||
@@ -40,14 +40,20 @@ Three Postgres tables in the `backend` schema:
|
||||
| Action | Caller | Pre-conditions |
|
||||
|--------|--------|----------------|
|
||||
| Send personal | user | active membership in game; recipient is active member |
|
||||
| Read message | the recipient | row exists in `diplomail_recipients(message_id, user_id)` |
|
||||
| Send admin (single user) | game owner OR site admin | recipient is any-status member of the game |
|
||||
| Send admin (broadcast) | game owner OR site admin | recipient scope ∈ `active` / `active_and_removed` / `all_members`; sender excluded |
|
||||
| Read message | the recipient | row exists in `diplomail_recipients(message_id, user_id)`; non-active members see admin-kind only |
|
||||
| Mark read | the recipient | row exists; idempotent if already marked |
|
||||
| Soft delete | the recipient | `read_at IS NOT NULL` (open-then-delete, item 10) |
|
||||
|
||||
Stage B introduces the admin / owner send matrix and the strict
|
||||
soft-access rule for kicked players (post-kick read access restricted
|
||||
to `kind='admin'` rows). Stage C adds the paid-tier broadcast and the
|
||||
bulk-purge admin endpoint.
|
||||
Stage C will add the paid-tier player broadcast and the bulk-purge
|
||||
admin endpoint.
|
||||
|
||||
System mail is produced internally by lobby lifecycle hooks:
|
||||
`Service.transition()` emits `game.paused` / `game.cancelled` system
|
||||
mail to every active member; `Service.changeMembershipStatus` /
|
||||
`Service.AdminBanMember` emit `membership.removed` /
|
||||
`membership.blocked` system mail addressed to the affected user.
|
||||
|
||||
## Content rules
|
||||
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
package diplomail
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// SendAdminPersonal persists an admin-kind message addressed to a
|
||||
// single recipient and fan-outs the push event. The HTTP layer is
|
||||
// responsible for the owner-vs-admin authorisation decision; this
|
||||
// function trusts the caller designation it receives.
|
||||
//
|
||||
// The recipient may be in any membership status, so the lookup goes
|
||||
// through MembershipLookup.GetMembershipAnyStatus. This lets the
|
||||
// owner / admin reach a kicked player to explain the kick or follow
|
||||
// up after a removal.
|
||||
func (s *Service) SendAdminPersonal(ctx context.Context, in SendAdminPersonalInput) (Message, Recipient, error) {
|
||||
subject, body, err := s.prepareContent(in.Subject, in.Body)
|
||||
if err != nil {
|
||||
return Message{}, Recipient{}, err
|
||||
}
|
||||
if err := validateCaller(in.CallerKind, in.CallerUserID, in.CallerUsername); err != nil {
|
||||
return Message{}, Recipient{}, err
|
||||
}
|
||||
|
||||
recipient, err := s.deps.Memberships.GetMembershipAnyStatus(ctx, in.GameID, in.RecipientUserID)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return Message{}, Recipient{}, fmt.Errorf("%w: recipient is not a member of the game", ErrForbidden)
|
||||
}
|
||||
return Message{}, Recipient{}, fmt.Errorf("diplomail: load admin recipient: %w", err)
|
||||
}
|
||||
|
||||
msgInsert, err := s.buildAdminMessageInsert(in.CallerKind, in.CallerUserID, in.CallerUsername,
|
||||
recipient.GameID, recipient.GameName, subject, body, in.SenderIP, BroadcastScopeSingle)
|
||||
if err != nil {
|
||||
return Message{}, Recipient{}, err
|
||||
}
|
||||
rcptInsert := buildRecipientInsert(msgInsert.MessageID, recipient)
|
||||
|
||||
msg, recipients, err := s.deps.Store.InsertMessageWithRecipients(ctx, msgInsert, []RecipientInsert{rcptInsert})
|
||||
if err != nil {
|
||||
return Message{}, Recipient{}, fmt.Errorf("diplomail: send admin personal: %w", err)
|
||||
}
|
||||
if len(recipients) != 1 {
|
||||
return Message{}, Recipient{}, fmt.Errorf("diplomail: send admin personal: unexpected recipient count %d", len(recipients))
|
||||
}
|
||||
|
||||
s.publishMessageReceived(ctx, msg, recipients[0])
|
||||
return msg, recipients[0], nil
|
||||
}
|
||||
|
||||
// SendAdminBroadcast persists an admin-kind broadcast addressed to
|
||||
// every member matching `RecipientScope`, then emits one push event
|
||||
// per recipient. The caller's own membership row, when present, is
|
||||
// excluded from the recipient list — broadcasters do not get a copy
|
||||
// of their own message.
|
||||
func (s *Service) SendAdminBroadcast(ctx context.Context, in SendAdminBroadcastInput) (Message, []Recipient, error) {
|
||||
subject, body, err := s.prepareContent(in.Subject, in.Body)
|
||||
if err != nil {
|
||||
return Message{}, nil, err
|
||||
}
|
||||
if err := validateCaller(in.CallerKind, in.CallerUserID, in.CallerUsername); err != nil {
|
||||
return Message{}, nil, err
|
||||
}
|
||||
scope, err := normaliseScope(in.RecipientScope)
|
||||
if err != nil {
|
||||
return Message{}, nil, err
|
||||
}
|
||||
|
||||
members, err := s.deps.Memberships.ListMembers(ctx, in.GameID, scope)
|
||||
if err != nil {
|
||||
return Message{}, nil, fmt.Errorf("diplomail: list members for broadcast: %w", err)
|
||||
}
|
||||
members = filterOutCaller(members, in.CallerUserID)
|
||||
if len(members) == 0 {
|
||||
return Message{}, nil, fmt.Errorf("%w: no recipients for broadcast", ErrInvalidInput)
|
||||
}
|
||||
|
||||
gameName := members[0].GameName
|
||||
msgInsert, err := s.buildAdminMessageInsert(in.CallerKind, in.CallerUserID, in.CallerUsername,
|
||||
in.GameID, gameName, subject, body, in.SenderIP, BroadcastScopeGameBroadcast)
|
||||
if err != nil {
|
||||
return Message{}, nil, err
|
||||
}
|
||||
rcptInserts := make([]RecipientInsert, 0, len(members))
|
||||
for _, m := range members {
|
||||
rcptInserts = append(rcptInserts, buildRecipientInsert(msgInsert.MessageID, m))
|
||||
}
|
||||
|
||||
msg, recipients, err := s.deps.Store.InsertMessageWithRecipients(ctx, msgInsert, rcptInserts)
|
||||
if err != nil {
|
||||
return Message{}, nil, fmt.Errorf("diplomail: send admin broadcast: %w", err)
|
||||
}
|
||||
for _, r := range recipients {
|
||||
s.publishMessageReceived(ctx, msg, r)
|
||||
}
|
||||
return msg, recipients, nil
|
||||
}
|
||||
|
||||
// PublishLifecycle persists a system-kind message in response to a
|
||||
// lobby lifecycle transition and fan-outs push events to the
|
||||
// affected recipients. Game-scoped transitions (`game.paused`,
|
||||
// `game.cancelled`) reach every active member; membership-scoped
|
||||
// transitions (`membership.removed`, `membership.blocked`) reach the
|
||||
// kicked player only. Failures inside the function are logged at
|
||||
// Warn level — lifecycle hooks must not block the lobby state
|
||||
// machine on a downstream mail failure.
|
||||
func (s *Service) PublishLifecycle(ctx context.Context, ev LifecycleEvent) error {
|
||||
switch ev.Kind {
|
||||
case LifecycleKindGamePaused, LifecycleKindGameCancelled:
|
||||
return s.publishGameLifecycle(ctx, ev)
|
||||
case LifecycleKindMembershipRemoved, LifecycleKindMembershipBlocked:
|
||||
return s.publishMembershipLifecycle(ctx, ev)
|
||||
default:
|
||||
return fmt.Errorf("%w: unknown lifecycle kind %q", ErrInvalidInput, ev.Kind)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) publishGameLifecycle(ctx context.Context, ev LifecycleEvent) error {
|
||||
members, err := s.deps.Memberships.ListMembers(ctx, ev.GameID, RecipientScopeActive)
|
||||
if err != nil {
|
||||
return fmt.Errorf("diplomail lifecycle: list members for %s: %w", ev.GameID, err)
|
||||
}
|
||||
if len(members) == 0 {
|
||||
s.deps.Logger.Debug("lifecycle skip: no active members",
|
||||
zap.String("game_id", ev.GameID.String()),
|
||||
zap.String("kind", ev.Kind))
|
||||
return nil
|
||||
}
|
||||
gameName := members[0].GameName
|
||||
subject, body := renderGameLifecycle(ev.Kind, gameName, ev.Actor, ev.Reason)
|
||||
|
||||
msgInsert, err := s.buildAdminMessageInsert(CallerKindSystem, nil, "",
|
||||
ev.GameID, gameName, subject, body, "", BroadcastScopeGameBroadcast)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rcptInserts := make([]RecipientInsert, 0, len(members))
|
||||
for _, m := range members {
|
||||
rcptInserts = append(rcptInserts, buildRecipientInsert(msgInsert.MessageID, m))
|
||||
}
|
||||
msg, recipients, err := s.deps.Store.InsertMessageWithRecipients(ctx, msgInsert, rcptInserts)
|
||||
if err != nil {
|
||||
return fmt.Errorf("diplomail lifecycle: insert %s system mail: %w", ev.Kind, err)
|
||||
}
|
||||
for _, r := range recipients {
|
||||
s.publishMessageReceived(ctx, msg, r)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) publishMembershipLifecycle(ctx context.Context, ev LifecycleEvent) error {
|
||||
if ev.TargetUser == nil {
|
||||
return fmt.Errorf("%w: membership lifecycle requires TargetUser", ErrInvalidInput)
|
||||
}
|
||||
target, err := s.deps.Memberships.GetMembershipAnyStatus(ctx, ev.GameID, *ev.TargetUser)
|
||||
if err != nil {
|
||||
return fmt.Errorf("diplomail lifecycle: load target membership: %w", err)
|
||||
}
|
||||
subject, body := renderMembershipLifecycle(ev.Kind, target.GameName, ev.Actor, ev.Reason)
|
||||
|
||||
msgInsert, err := s.buildAdminMessageInsert(CallerKindSystem, nil, "",
|
||||
ev.GameID, target.GameName, subject, body, "", BroadcastScopeSingle)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rcptInsert := buildRecipientInsert(msgInsert.MessageID, target)
|
||||
msg, recipients, err := s.deps.Store.InsertMessageWithRecipients(ctx, msgInsert, []RecipientInsert{rcptInsert})
|
||||
if err != nil {
|
||||
return fmt.Errorf("diplomail lifecycle: insert %s system mail: %w", ev.Kind, err)
|
||||
}
|
||||
if len(recipients) == 1 {
|
||||
s.publishMessageReceived(ctx, msg, recipients[0])
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// prepareContent normalises subject and body the same way SendPersonal
|
||||
// does. Factored out so admin and lifecycle paths share the
|
||||
// length-and-utf8 validation rules.
|
||||
func (s *Service) prepareContent(subject, body string) (string, string, error) {
|
||||
subj := strings.TrimRight(subject, " \t")
|
||||
bod := strings.TrimRight(body, " \t\n")
|
||||
if err := s.validateContent(subj, bod); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return subj, bod, nil
|
||||
}
|
||||
|
||||
// buildAdminMessageInsert encapsulates the message-row construction
|
||||
// for every admin-kind send. The CHECK constraint maps sender
|
||||
// shapes:
|
||||
//
|
||||
// sender_kind='player' → CallerKind owner; sender_user_id set
|
||||
// sender_kind='admin' → CallerKind admin; sender_user_id nil
|
||||
// sender_kind='system' → CallerKind system; sender_username nil
|
||||
func (s *Service) buildAdminMessageInsert(callerKind string, callerUserID *uuid.UUID, callerUsername string,
|
||||
gameID uuid.UUID, gameName, subject, body, senderIP, scope string) (MessageInsert, error) {
|
||||
out := MessageInsert{
|
||||
MessageID: uuid.New(),
|
||||
GameID: gameID,
|
||||
GameName: gameName,
|
||||
Kind: KindAdmin,
|
||||
SenderIP: senderIP,
|
||||
Subject: subject,
|
||||
Body: body,
|
||||
BodyLang: LangUndetermined,
|
||||
BroadcastScope: scope,
|
||||
}
|
||||
switch callerKind {
|
||||
case CallerKindOwner:
|
||||
if callerUserID == nil {
|
||||
return MessageInsert{}, fmt.Errorf("%w: owner send requires caller user id", ErrInvalidInput)
|
||||
}
|
||||
uid := *callerUserID
|
||||
uname := callerUsername
|
||||
out.SenderKind = SenderKindPlayer
|
||||
out.SenderUserID = &uid
|
||||
out.SenderUsername = &uname
|
||||
case CallerKindAdmin:
|
||||
uname := callerUsername
|
||||
out.SenderKind = SenderKindAdmin
|
||||
out.SenderUsername = &uname
|
||||
case CallerKindSystem:
|
||||
out.SenderKind = SenderKindSystem
|
||||
default:
|
||||
return MessageInsert{}, fmt.Errorf("%w: unknown caller kind %q", ErrInvalidInput, callerKind)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// buildRecipientInsert turns a MemberSnapshot into a RecipientInsert.
|
||||
// The race-name snapshot is nullable so a kicked player with no race
|
||||
// name on file is still addressable.
|
||||
func buildRecipientInsert(messageID uuid.UUID, m MemberSnapshot) RecipientInsert {
|
||||
in := RecipientInsert{
|
||||
RecipientID: uuid.New(),
|
||||
MessageID: messageID,
|
||||
GameID: m.GameID,
|
||||
UserID: m.UserID,
|
||||
RecipientUserName: m.UserName,
|
||||
}
|
||||
if m.RaceName != "" {
|
||||
race := m.RaceName
|
||||
in.RecipientRaceName = &race
|
||||
}
|
||||
return in
|
||||
}
|
||||
|
||||
func validateCaller(callerKind string, callerUserID *uuid.UUID, callerUsername string) error {
|
||||
switch callerKind {
|
||||
case CallerKindOwner:
|
||||
if callerUserID == nil {
|
||||
return fmt.Errorf("%w: owner send requires caller_user_id", ErrInvalidInput)
|
||||
}
|
||||
if callerUsername == "" {
|
||||
return fmt.Errorf("%w: owner send requires caller_username", ErrInvalidInput)
|
||||
}
|
||||
case CallerKindAdmin:
|
||||
if callerUsername == "" {
|
||||
return fmt.Errorf("%w: admin send requires caller_username", ErrInvalidInput)
|
||||
}
|
||||
case CallerKindSystem:
|
||||
// no extra checks
|
||||
default:
|
||||
return fmt.Errorf("%w: unknown caller_kind %q", ErrInvalidInput, callerKind)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normaliseScope(scope string) (string, error) {
|
||||
switch scope {
|
||||
case "", RecipientScopeActive:
|
||||
return RecipientScopeActive, nil
|
||||
case RecipientScopeActiveAndRemoved, RecipientScopeAllMembers:
|
||||
return scope, nil
|
||||
default:
|
||||
return "", fmt.Errorf("%w: unknown recipient scope %q", ErrInvalidInput, scope)
|
||||
}
|
||||
}
|
||||
|
||||
func filterOutCaller(members []MemberSnapshot, callerUserID *uuid.UUID) []MemberSnapshot {
|
||||
if callerUserID == nil {
|
||||
return members
|
||||
}
|
||||
out := make([]MemberSnapshot, 0, len(members))
|
||||
for _, m := range members {
|
||||
if m.UserID == *callerUserID {
|
||||
continue
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// renderGameLifecycle returns the (subject, body) pair persisted for
|
||||
// the `game.paused` / `game.cancelled` system message. Bodies are in
|
||||
// English; Stage D will translate them on demand into each
|
||||
// recipient's preferred_language and cache the result.
|
||||
func renderGameLifecycle(kind, gameName, actor, reason string) (string, string) {
|
||||
actor = strings.TrimSpace(actor)
|
||||
if actor == "" {
|
||||
actor = "the system"
|
||||
}
|
||||
reasonTail := ""
|
||||
if r := strings.TrimSpace(reason); r != "" {
|
||||
reasonTail = " Reason: " + r + "."
|
||||
}
|
||||
switch kind {
|
||||
case LifecycleKindGamePaused:
|
||||
return "Game paused",
|
||||
fmt.Sprintf("The game %q has been paused by %s.%s", gameName, actor, reasonTail)
|
||||
case LifecycleKindGameCancelled:
|
||||
return "Game cancelled",
|
||||
fmt.Sprintf("The game %q has been cancelled by %s.%s", gameName, actor, reasonTail)
|
||||
}
|
||||
return "Game lifecycle update",
|
||||
fmt.Sprintf("The game %q has changed state.%s", gameName, reasonTail)
|
||||
}
|
||||
|
||||
// renderMembershipLifecycle returns the (subject, body) pair persisted
|
||||
// for the `membership.removed` / `membership.blocked` system message.
|
||||
func renderMembershipLifecycle(kind, gameName, actor, reason string) (string, string) {
|
||||
actor = strings.TrimSpace(actor)
|
||||
if actor == "" {
|
||||
actor = "the system"
|
||||
}
|
||||
reasonTail := ""
|
||||
if r := strings.TrimSpace(reason); r != "" {
|
||||
reasonTail = " Reason: " + r + "."
|
||||
}
|
||||
switch kind {
|
||||
case LifecycleKindMembershipRemoved:
|
||||
return "Membership removed",
|
||||
fmt.Sprintf("Your membership in %q has been removed by %s.%s", gameName, actor, reasonTail)
|
||||
case LifecycleKindMembershipBlocked:
|
||||
return "Membership blocked",
|
||||
fmt.Sprintf("Your membership in %q has been blocked by %s.%s", gameName, actor, reasonTail)
|
||||
}
|
||||
return "Membership update",
|
||||
fmt.Sprintf("Your membership in %q has changed.%s", gameName, reasonTail)
|
||||
}
|
||||
@@ -43,11 +43,45 @@ type ActiveMembership struct {
|
||||
// roster metadata. The canonical implementation in `cmd/backend/main`
|
||||
// adapts the `*lobby.Service` membership cache to this interface.
|
||||
//
|
||||
// Implementations must return ErrNotFound (the diplomail sentinel)
|
||||
// GetActiveMembership returns ErrNotFound (the diplomail sentinel)
|
||||
// when the user is not an active member of the game; the service
|
||||
// boundary maps that to 403 forbidden.
|
||||
//
|
||||
// GetMembershipAnyStatus returns the same shape regardless of
|
||||
// membership status (`active`, `removed`, `blocked`). Used by the
|
||||
// inbox read path to check whether a kicked recipient still belongs
|
||||
// to the game's roster; ErrNotFound is surfaced when the user has
|
||||
// never been a member.
|
||||
//
|
||||
// ListMembers returns every roster row matching scope, in stable
|
||||
// order. Scope values are `active`, `active_and_removed`, and
|
||||
// `all_members` (the spec calls these out by name). Used by the
|
||||
// broadcast composition step in admin / owner sends.
|
||||
type MembershipLookup interface {
|
||||
GetActiveMembership(ctx context.Context, gameID, userID uuid.UUID) (ActiveMembership, error)
|
||||
GetMembershipAnyStatus(ctx context.Context, gameID, userID uuid.UUID) (MemberSnapshot, error)
|
||||
ListMembers(ctx context.Context, gameID uuid.UUID, scope string) ([]MemberSnapshot, error)
|
||||
}
|
||||
|
||||
// Recipient scope values accepted by ListMembers and by the
|
||||
// `recipients` request field on admin / owner broadcasts.
|
||||
const (
|
||||
RecipientScopeActive = "active"
|
||||
RecipientScopeActiveAndRemoved = "active_and_removed"
|
||||
RecipientScopeAllMembers = "all_members"
|
||||
)
|
||||
|
||||
// MemberSnapshot is the slim view of a membership row that survives
|
||||
// all three status values. RaceName is the immutable string captured
|
||||
// at registration time; an empty value is legal for rare cases where
|
||||
// the row was inserted without one.
|
||||
type MemberSnapshot struct {
|
||||
UserID uuid.UUID
|
||||
GameID uuid.UUID
|
||||
GameName string
|
||||
UserName string
|
||||
RaceName string
|
||||
Status string
|
||||
}
|
||||
|
||||
// NotificationPublisher is the outbound surface diplomail uses to
|
||||
|
||||
@@ -126,8 +126,11 @@ func (p *recordingPublisher) snapshot() []diplomail.DiplomailNotification {
|
||||
|
||||
// staticMembershipLookup serves an in-memory fixture. The test seeds
|
||||
// memberships up-front and the lookup is keyed on (gameID, userID).
|
||||
// Inactive rows (status != "active") are encoded by populating
|
||||
// `inactive` instead of `rows`.
|
||||
type staticMembershipLookup struct {
|
||||
rows map[[2]uuid.UUID]diplomail.ActiveMembership
|
||||
rows map[[2]uuid.UUID]diplomail.ActiveMembership
|
||||
inactive map[[2]uuid.UUID]diplomail.MemberSnapshot
|
||||
}
|
||||
|
||||
func (l *staticMembershipLookup) GetActiveMembership(_ context.Context, gameID, userID uuid.UUID) (diplomail.ActiveMembership, error) {
|
||||
@@ -141,6 +144,58 @@ func (l *staticMembershipLookup) GetActiveMembership(_ context.Context, gameID,
|
||||
return row, nil
|
||||
}
|
||||
|
||||
func (l *staticMembershipLookup) GetMembershipAnyStatus(_ context.Context, gameID, userID uuid.UUID) (diplomail.MemberSnapshot, error) {
|
||||
if l == nil {
|
||||
return diplomail.MemberSnapshot{}, diplomail.ErrNotFound
|
||||
}
|
||||
if row, ok := l.rows[[2]uuid.UUID{gameID, userID}]; ok {
|
||||
return diplomail.MemberSnapshot{
|
||||
UserID: row.UserID,
|
||||
GameID: row.GameID,
|
||||
GameName: row.GameName,
|
||||
UserName: row.UserName,
|
||||
RaceName: row.RaceName,
|
||||
Status: "active",
|
||||
}, nil
|
||||
}
|
||||
if row, ok := l.inactive[[2]uuid.UUID{gameID, userID}]; ok {
|
||||
return row, nil
|
||||
}
|
||||
return diplomail.MemberSnapshot{}, diplomail.ErrNotFound
|
||||
}
|
||||
|
||||
func (l *staticMembershipLookup) ListMembers(_ context.Context, gameID uuid.UUID, scope string) ([]diplomail.MemberSnapshot, error) {
|
||||
if l == nil {
|
||||
return nil, nil
|
||||
}
|
||||
var out []diplomail.MemberSnapshot
|
||||
for key, row := range l.rows {
|
||||
if key[0] != gameID {
|
||||
continue
|
||||
}
|
||||
out = append(out, diplomail.MemberSnapshot{
|
||||
UserID: row.UserID,
|
||||
GameID: row.GameID,
|
||||
GameName: row.GameName,
|
||||
UserName: row.UserName,
|
||||
RaceName: row.RaceName,
|
||||
Status: "active",
|
||||
})
|
||||
}
|
||||
if scope == diplomail.RecipientScopeActiveAndRemoved || scope == diplomail.RecipientScopeAllMembers {
|
||||
for key, row := range l.inactive {
|
||||
if key[0] != gameID {
|
||||
continue
|
||||
}
|
||||
if scope == diplomail.RecipientScopeActiveAndRemoved && row.Status != "removed" {
|
||||
continue
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// seedAccount inserts a minimal accounts row so memberships and mail
|
||||
// recipient FKs are satisfiable.
|
||||
func seedAccount(t *testing.T, db *sql.DB, userID uuid.UUID) {
|
||||
@@ -355,6 +410,160 @@ func TestDiplomailRejectsNonActiveSender(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiplomailAdminBroadcast(t *testing.T) {
|
||||
db := startPostgres(t)
|
||||
ctx := context.Background()
|
||||
|
||||
gameID := uuid.New()
|
||||
owner := uuid.New()
|
||||
alice := uuid.New()
|
||||
bob := uuid.New()
|
||||
kickedCharlie := uuid.New()
|
||||
seedAccount(t, db, owner)
|
||||
seedAccount(t, db, alice)
|
||||
seedAccount(t, db, bob)
|
||||
seedAccount(t, db, kickedCharlie)
|
||||
seedGame(t, db, gameID, "Broadcast Test Game")
|
||||
|
||||
lookup := &staticMembershipLookup{
|
||||
rows: map[[2]uuid.UUID]diplomail.ActiveMembership{
|
||||
{gameID, alice}: {
|
||||
UserID: alice, GameID: gameID, GameName: "Broadcast Test Game",
|
||||
UserName: "alice", RaceName: "AliceRace",
|
||||
},
|
||||
{gameID, bob}: {
|
||||
UserID: bob, GameID: gameID, GameName: "Broadcast Test Game",
|
||||
UserName: "bob", RaceName: "BobRace",
|
||||
},
|
||||
},
|
||||
inactive: map[[2]uuid.UUID]diplomail.MemberSnapshot{
|
||||
{gameID, kickedCharlie}: {
|
||||
UserID: kickedCharlie, GameID: gameID, GameName: "Broadcast Test Game",
|
||||
UserName: "charlie", RaceName: "CharlieRace", Status: "removed",
|
||||
},
|
||||
},
|
||||
}
|
||||
publisher := &recordingPublisher{}
|
||||
|
||||
svc := diplomail.NewService(diplomail.Deps{
|
||||
Store: diplomail.NewStore(db),
|
||||
Memberships: lookup,
|
||||
Notification: publisher,
|
||||
Config: config.DiplomailConfig{
|
||||
MaxBodyBytes: 4096,
|
||||
MaxSubjectBytes: 256,
|
||||
},
|
||||
})
|
||||
|
||||
ownerID := owner
|
||||
msg, recipients, err := svc.SendAdminBroadcast(ctx, diplomail.SendAdminBroadcastInput{
|
||||
GameID: gameID,
|
||||
CallerKind: diplomail.CallerKindOwner,
|
||||
CallerUserID: &ownerID,
|
||||
CallerUsername: "owner",
|
||||
RecipientScope: diplomail.RecipientScopeActive,
|
||||
Subject: "All hands",
|
||||
Body: "Welcome to round two.",
|
||||
SenderIP: "203.0.113.7",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("admin broadcast: %v", err)
|
||||
}
|
||||
if msg.Kind != diplomail.KindAdmin || msg.SenderKind != diplomail.SenderKindPlayer {
|
||||
t.Fatalf("kind=%q sender_kind=%q, want admin/player", msg.Kind, msg.SenderKind)
|
||||
}
|
||||
if len(recipients) != 2 {
|
||||
t.Fatalf("broadcast hit %d recipients, want 2 (alice+bob, kicked charlie excluded by active scope)", len(recipients))
|
||||
}
|
||||
if got := publisher.snapshot(); len(got) != 2 {
|
||||
t.Fatalf("publisher captured %d events, want 2", len(got))
|
||||
}
|
||||
|
||||
// active_and_removed should include the kicked recipient too.
|
||||
msg2, recipients2, err := svc.SendAdminBroadcast(ctx, diplomail.SendAdminBroadcastInput{
|
||||
GameID: gameID,
|
||||
CallerKind: diplomail.CallerKindAdmin,
|
||||
CallerUsername: "site-admin",
|
||||
RecipientScope: diplomail.RecipientScopeActiveAndRemoved,
|
||||
Body: "Post-game retrospective.",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("admin broadcast active_and_removed: %v", err)
|
||||
}
|
||||
if msg2.SenderKind != diplomail.SenderKindAdmin {
|
||||
t.Fatalf("sender_kind=%q, want admin", msg2.SenderKind)
|
||||
}
|
||||
if len(recipients2) != 3 {
|
||||
t.Fatalf("active_and_removed broadcast hit %d, want 3", len(recipients2))
|
||||
}
|
||||
|
||||
// Kicked charlie sees the admin message but not the personal mail
|
||||
// that alice might have sent before the kick (none here — the
|
||||
// store path itself is exercised; the soft-access filter belongs
|
||||
// to a separate test below).
|
||||
charlieInbox, err := svc.ListInbox(ctx, gameID, kickedCharlie)
|
||||
if err != nil {
|
||||
t.Fatalf("kicked inbox: %v", err)
|
||||
}
|
||||
if len(charlieInbox) != 1 {
|
||||
t.Fatalf("kicked inbox = %d entries, want 1 (only the active_and_removed broadcast)", len(charlieInbox))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiplomailLifecycleMembershipKick(t *testing.T) {
|
||||
db := startPostgres(t)
|
||||
ctx := context.Background()
|
||||
|
||||
gameID := uuid.New()
|
||||
kicked := uuid.New()
|
||||
seedAccount(t, db, kicked)
|
||||
seedGame(t, db, gameID, "Lifecycle Test Game")
|
||||
|
||||
lookup := &staticMembershipLookup{
|
||||
inactive: map[[2]uuid.UUID]diplomail.MemberSnapshot{
|
||||
{gameID, kicked}: {
|
||||
UserID: kicked, GameID: gameID, GameName: "Lifecycle Test Game",
|
||||
UserName: "kicked", RaceName: "KickedRace", Status: "blocked",
|
||||
},
|
||||
},
|
||||
}
|
||||
publisher := &recordingPublisher{}
|
||||
|
||||
svc := diplomail.NewService(diplomail.Deps{
|
||||
Store: diplomail.NewStore(db),
|
||||
Memberships: lookup,
|
||||
Notification: publisher,
|
||||
Config: config.DiplomailConfig{
|
||||
MaxBodyBytes: 4096,
|
||||
MaxSubjectBytes: 256,
|
||||
},
|
||||
})
|
||||
|
||||
target := kicked
|
||||
if err := svc.PublishLifecycle(ctx, diplomail.LifecycleEvent{
|
||||
GameID: gameID,
|
||||
Kind: diplomail.LifecycleKindMembershipBlocked,
|
||||
Actor: "an administrator",
|
||||
Reason: "rule violation",
|
||||
TargetUser: &target,
|
||||
}); err != nil {
|
||||
t.Fatalf("publish lifecycle: %v", err)
|
||||
}
|
||||
if got := publisher.snapshot(); len(got) != 1 || got[0].Recipient != kicked {
|
||||
t.Fatalf("publisher captured %+v, want one event addressed to kicked", got)
|
||||
}
|
||||
inbox, err := svc.ListInbox(ctx, gameID, kicked)
|
||||
if err != nil {
|
||||
t.Fatalf("kicked inbox: %v", err)
|
||||
}
|
||||
if len(inbox) != 1 {
|
||||
t.Fatalf("kicked inbox = %d, want 1 system message", len(inbox))
|
||||
}
|
||||
if inbox[0].Kind != diplomail.KindAdmin || inbox[0].SenderKind != diplomail.SenderKindSystem {
|
||||
t.Fatalf("kind=%q sender_kind=%q, want admin/system", inbox[0].Kind, inbox[0].SenderKind)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiplomailRejectsOverlongBody(t *testing.T) {
|
||||
db := startPostgres(t)
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -100,20 +100,70 @@ func (s *Service) SendPersonal(ctx context.Context, in SendPersonalInput) (Messa
|
||||
// GetMessage returns the InboxEntry for messageID addressed to
|
||||
// userID. ErrNotFound is returned when the caller is not a recipient
|
||||
// of the message — handlers translate that to 404 so the existence
|
||||
// of the message is not leaked.
|
||||
// of the message is not leaked. The same sentinel is returned when
|
||||
// the caller is no longer an active member of the game and the
|
||||
// message is personal-kind: post-kick visibility is restricted to
|
||||
// admin/system mail (item 8 of the spec).
|
||||
func (s *Service) GetMessage(ctx context.Context, userID, messageID uuid.UUID) (InboxEntry, error) {
|
||||
entry, err := s.deps.Store.LoadInboxEntry(ctx, messageID, userID)
|
||||
if err != nil {
|
||||
return InboxEntry{}, err
|
||||
}
|
||||
allowed, err := s.allowedKinds(ctx, entry.GameID, userID)
|
||||
if err != nil {
|
||||
return InboxEntry{}, err
|
||||
}
|
||||
if !allowed[entry.Kind] {
|
||||
return InboxEntry{}, ErrNotFound
|
||||
}
|
||||
return entry, nil
|
||||
}
|
||||
|
||||
// ListInbox returns every non-deleted message addressed to userID in
|
||||
// gameID, newest first. Read state is preserved per entry; the HTTP
|
||||
// layer renders both the message and the recipient row.
|
||||
// layer renders both the message and the recipient row. Personal
|
||||
// messages are filtered out when the caller is no longer an active
|
||||
// member of the game so a kicked player keeps read access to the
|
||||
// admin/system explanation of the kick but not to historical
|
||||
// player-to-player threads.
|
||||
func (s *Service) ListInbox(ctx context.Context, gameID, userID uuid.UUID) ([]InboxEntry, error) {
|
||||
return s.deps.Store.ListInbox(ctx, gameID, userID)
|
||||
entries, err := s.deps.Store.ListInbox(ctx, gameID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
allowed, err := s.allowedKinds(ctx, gameID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if allowed[KindPersonal] && allowed[KindAdmin] {
|
||||
return entries, nil
|
||||
}
|
||||
out := make([]InboxEntry, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
if allowed[e.Kind] {
|
||||
out = append(out, e)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// allowedKinds resolves the set of message kinds the caller may read
|
||||
// in gameID. An active member can read everything; a former member
|
||||
// (status removed or blocked) can read admin-kind only. A user who
|
||||
// has never been a member of the game but is still listed as a
|
||||
// recipient (legacy / system message) is granted the same admin-only
|
||||
// view. The function never returns an empty set: even non-members
|
||||
// keep their read access to admin mail.
|
||||
func (s *Service) allowedKinds(ctx context.Context, gameID, userID uuid.UUID) (map[string]bool, error) {
|
||||
if s.deps.Memberships == nil {
|
||||
return map[string]bool{KindPersonal: true, KindAdmin: true}, nil
|
||||
}
|
||||
if _, err := s.deps.Memberships.GetActiveMembership(ctx, gameID, userID); err == nil {
|
||||
return map[string]bool{KindPersonal: true, KindAdmin: true}, nil
|
||||
} else if !errors.Is(err, ErrNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]bool{KindAdmin: true}, nil
|
||||
}
|
||||
|
||||
// ListSent returns personal messages authored by senderUserID in
|
||||
|
||||
@@ -70,6 +70,75 @@ type SendPersonalInput struct {
|
||||
SenderIP string
|
||||
}
|
||||
|
||||
// CallerKind enumerates the privileged sender roles for admin-kind
|
||||
// messages. Owners (`CallerKindOwner`) are players who own a private
|
||||
// game; admins (`CallerKindAdmin`) hit the dedicated admin route;
|
||||
// `CallerKindSystem` is reserved for internal lifecycle hooks.
|
||||
const (
|
||||
CallerKindOwner = "owner"
|
||||
CallerKindAdmin = "admin"
|
||||
CallerKindSystem = "system"
|
||||
)
|
||||
|
||||
// SendAdminPersonalInput is the request payload for an owner /
|
||||
// admin / system sending an admin-kind message to a single
|
||||
// recipient. Authorization (owner-vs-admin distinction) is enforced
|
||||
// by the HTTP layer; the service trusts the caller designation.
|
||||
type SendAdminPersonalInput struct {
|
||||
GameID uuid.UUID
|
||||
CallerKind string
|
||||
CallerUserID *uuid.UUID
|
||||
CallerUsername string
|
||||
RecipientUserID uuid.UUID
|
||||
Subject string
|
||||
Body string
|
||||
SenderIP string
|
||||
}
|
||||
|
||||
// SendAdminBroadcastInput is the request payload for an owner /
|
||||
// admin / system broadcasting an admin-kind message inside a single
|
||||
// game. RecipientScope selects the address book; the sender's own
|
||||
// recipient row is never created (a broadcast author does not get a
|
||||
// copy of their own message).
|
||||
type SendAdminBroadcastInput struct {
|
||||
GameID uuid.UUID
|
||||
CallerKind string
|
||||
CallerUserID *uuid.UUID
|
||||
CallerUsername string
|
||||
RecipientScope string
|
||||
Subject string
|
||||
Body string
|
||||
SenderIP string
|
||||
}
|
||||
|
||||
// LifecycleEventKind enumerates the producer-side intents the lobby
|
||||
// emits when a game-state or membership-state transition lands.
|
||||
const (
|
||||
LifecycleKindGamePaused = "game.paused"
|
||||
LifecycleKindGameCancelled = "game.cancelled"
|
||||
LifecycleKindMembershipRemoved = "membership.removed"
|
||||
LifecycleKindMembershipBlocked = "membership.blocked"
|
||||
)
|
||||
|
||||
// LifecycleEvent is the payload lobby hands to PublishLifecycle when
|
||||
// a transition needs to be reflected as durable system mail. The
|
||||
// recipient set is derived by the service:
|
||||
//
|
||||
// - For game.* events the message fans out to every active member
|
||||
// of the game except the actor (the actor sees the action in
|
||||
// their own UI through other channels).
|
||||
// - For membership.* events the message addresses exactly
|
||||
// `TargetUser` (the kicked player), regardless of their current
|
||||
// membership status — this is how a kicked player retains read
|
||||
// access to the explanation of the kick.
|
||||
type LifecycleEvent struct {
|
||||
GameID uuid.UUID
|
||||
Kind string
|
||||
Actor string
|
||||
Reason string
|
||||
TargetUser *uuid.UUID
|
||||
}
|
||||
|
||||
// UnreadCount carries a per-game unread-count row returned by
|
||||
// UnreadCountsForUser. The lobby badge UI consumes the slice plus the
|
||||
// derived total.
|
||||
|
||||
Reference in New Issue
Block a user