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) }