diplomail (Stage A→D): backend in-game diplomatic mail #10
+114
-4
@@ -132,6 +132,7 @@ func run(ctx context.Context) (err error) {
|
|||||||
lobbyCascade := &lobbyCascadeAdapter{}
|
lobbyCascade := &lobbyCascadeAdapter{}
|
||||||
userNotifyCascade := &userNotificationCascadeAdapter{}
|
userNotifyCascade := &userNotificationCascadeAdapter{}
|
||||||
lobbyNotifyPublisher := &lobbyNotificationPublisherAdapter{}
|
lobbyNotifyPublisher := &lobbyNotificationPublisherAdapter{}
|
||||||
|
lobbyDiplomailPublisher := &lobbyDiplomailPublisherAdapter{}
|
||||||
runtimeNotifyPublisher := &runtimeNotificationPublisherAdapter{}
|
runtimeNotifyPublisher := &runtimeNotificationPublisherAdapter{}
|
||||||
|
|
||||||
userSvc := user.NewService(user.Deps{
|
userSvc := user.NewService(user.Deps{
|
||||||
@@ -198,6 +199,7 @@ func run(ctx context.Context) (err error) {
|
|||||||
Cache: lobbyCache,
|
Cache: lobbyCache,
|
||||||
Runtime: runtimeGateway,
|
Runtime: runtimeGateway,
|
||||||
Notification: lobbyNotifyPublisher,
|
Notification: lobbyNotifyPublisher,
|
||||||
|
Diplomail: lobbyDiplomailPublisher,
|
||||||
Entitlement: &userEntitlementAdapter{svc: userSvc},
|
Entitlement: &userEntitlementAdapter{svc: userSvc},
|
||||||
Config: cfg.Lobby,
|
Config: cfg.Lobby,
|
||||||
Logger: logger,
|
Logger: logger,
|
||||||
@@ -311,6 +313,7 @@ func run(ctx context.Context) (err error) {
|
|||||||
Config: cfg.Diplomail,
|
Config: cfg.Diplomail,
|
||||||
Logger: logger,
|
Logger: logger,
|
||||||
})
|
})
|
||||||
|
lobbyDiplomailPublisher.svc = diplomailSvc
|
||||||
if email := cfg.Notification.AdminEmail; email == "" {
|
if email := cfg.Notification.AdminEmail; email == "" {
|
||||||
logger.Info("notification admin email not configured (BACKEND_NOTIFICATION_ADMIN_EMAIL); admin-channel routes will be skipped")
|
logger.Info("notification admin email not configured (BACKEND_NOTIFICATION_ADMIN_EMAIL); admin-channel routes will be skipped")
|
||||||
} else {
|
} else {
|
||||||
@@ -335,10 +338,11 @@ func run(ctx context.Context) (err error) {
|
|||||||
adminEngineVersionsHandlers := backendserver.NewAdminEngineVersionsHandlers(engineVersionSvc, logger)
|
adminEngineVersionsHandlers := backendserver.NewAdminEngineVersionsHandlers(engineVersionSvc, logger)
|
||||||
adminRuntimesHandlers := backendserver.NewAdminRuntimesHandlers(runtimeSvc, logger)
|
adminRuntimesHandlers := backendserver.NewAdminRuntimesHandlers(runtimeSvc, logger)
|
||||||
adminMailHandlers := backendserver.NewAdminMailHandlers(mailSvc, logger)
|
adminMailHandlers := backendserver.NewAdminMailHandlers(mailSvc, logger)
|
||||||
|
adminDiplomailHandlers := backendserver.NewAdminDiplomailHandlers(diplomailSvc, logger)
|
||||||
adminNotificationsHandlers := backendserver.NewAdminNotificationsHandlers(notifSvc, logger)
|
adminNotificationsHandlers := backendserver.NewAdminNotificationsHandlers(notifSvc, logger)
|
||||||
adminGeoHandlers := backendserver.NewAdminGeoHandlers(geoSvc, logger)
|
adminGeoHandlers := backendserver.NewAdminGeoHandlers(geoSvc, logger)
|
||||||
userGamesHandlers := backendserver.NewUserGamesHandlers(runtimeSvc, engineCli, logger)
|
userGamesHandlers := backendserver.NewUserGamesHandlers(runtimeSvc, engineCli, logger)
|
||||||
userMailHandlers := backendserver.NewUserMailHandlers(diplomailSvc, logger)
|
userMailHandlers := backendserver.NewUserMailHandlers(diplomailSvc, lobbySvc, userSvc, logger)
|
||||||
|
|
||||||
ready := func() bool {
|
ready := func() bool {
|
||||||
return authCache.Ready() && userCache.Ready() && adminCache.Ready() && lobbyCache.Ready() && runtimeCache.Ready()
|
return authCache.Ready() && userCache.Ready() && adminCache.Ready() && lobbyCache.Ready() && runtimeCache.Ready()
|
||||||
@@ -367,6 +371,7 @@ func run(ctx context.Context) (err error) {
|
|||||||
AdminRuntimes: adminRuntimesHandlers,
|
AdminRuntimes: adminRuntimesHandlers,
|
||||||
AdminEngineVersions: adminEngineVersionsHandlers,
|
AdminEngineVersions: adminEngineVersionsHandlers,
|
||||||
AdminMail: adminMailHandlers,
|
AdminMail: adminMailHandlers,
|
||||||
|
AdminDiplomail: adminDiplomailHandlers,
|
||||||
AdminNotifications: adminNotificationsHandlers,
|
AdminNotifications: adminNotificationsHandlers,
|
||||||
AdminGeo: adminGeoHandlers,
|
AdminGeo: adminGeoHandlers,
|
||||||
UserGames: userGamesHandlers,
|
UserGames: userGamesHandlers,
|
||||||
@@ -593,9 +598,9 @@ func (a *runtimeNotificationPublisherAdapter) PublishRuntimeEvent(ctx context.Co
|
|||||||
}
|
}
|
||||||
|
|
||||||
// diplomailMembershipAdapter implements `diplomail.MembershipLookup`
|
// diplomailMembershipAdapter implements `diplomail.MembershipLookup`
|
||||||
// by walking the lobby cache for the active (game_id, user_id) row
|
// by walking the lobby cache (for active rows) and the lobby service
|
||||||
// and stitching the snapshot fields together with the immutable
|
// (for any-status rows) and stitching each membership row to the
|
||||||
// `user_name` read through `*user.Service`.
|
// immutable `accounts.user_name` resolved through `*user.Service`.
|
||||||
type diplomailMembershipAdapter struct {
|
type diplomailMembershipAdapter struct {
|
||||||
lobby *lobby.Service
|
lobby *lobby.Service
|
||||||
users *user.Service
|
users *user.Service
|
||||||
@@ -637,6 +642,111 @@ func (a *diplomailMembershipAdapter) GetActiveMembership(ctx context.Context, ga
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *diplomailMembershipAdapter) GetMembershipAnyStatus(ctx context.Context, gameID, userID uuid.UUID) (diplomail.MemberSnapshot, error) {
|
||||||
|
if a == nil || a.lobby == nil || a.users == nil {
|
||||||
|
return diplomail.MemberSnapshot{}, diplomail.ErrNotFound
|
||||||
|
}
|
||||||
|
game, ok := a.lobby.Cache().GetGame(gameID)
|
||||||
|
if !ok {
|
||||||
|
return diplomail.MemberSnapshot{}, diplomail.ErrNotFound
|
||||||
|
}
|
||||||
|
members, err := a.lobby.ListMembershipsForGame(ctx, gameID)
|
||||||
|
if err != nil {
|
||||||
|
return diplomail.MemberSnapshot{}, err
|
||||||
|
}
|
||||||
|
var found *lobby.Membership
|
||||||
|
for _, m := range members {
|
||||||
|
if m.UserID == userID {
|
||||||
|
mm := m
|
||||||
|
found = &mm
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if found == nil {
|
||||||
|
return diplomail.MemberSnapshot{}, diplomail.ErrNotFound
|
||||||
|
}
|
||||||
|
account, err := a.users.GetAccount(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return diplomail.MemberSnapshot{}, err
|
||||||
|
}
|
||||||
|
return diplomail.MemberSnapshot{
|
||||||
|
UserID: userID,
|
||||||
|
GameID: gameID,
|
||||||
|
GameName: game.GameName,
|
||||||
|
UserName: account.UserName,
|
||||||
|
RaceName: found.RaceName,
|
||||||
|
Status: found.Status,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *diplomailMembershipAdapter) ListMembers(ctx context.Context, gameID uuid.UUID, scope string) ([]diplomail.MemberSnapshot, error) {
|
||||||
|
if a == nil || a.lobby == nil || a.users == nil {
|
||||||
|
return nil, diplomail.ErrNotFound
|
||||||
|
}
|
||||||
|
game, ok := a.lobby.Cache().GetGame(gameID)
|
||||||
|
if !ok {
|
||||||
|
return nil, diplomail.ErrNotFound
|
||||||
|
}
|
||||||
|
members, err := a.lobby.ListMembershipsForGame(ctx, gameID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
matches := func(status string) bool {
|
||||||
|
switch scope {
|
||||||
|
case diplomail.RecipientScopeActive:
|
||||||
|
return status == lobby.MembershipStatusActive
|
||||||
|
case diplomail.RecipientScopeActiveAndRemoved:
|
||||||
|
return status == lobby.MembershipStatusActive || status == lobby.MembershipStatusRemoved
|
||||||
|
case diplomail.RecipientScopeAllMembers:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return status == lobby.MembershipStatusActive
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out := make([]diplomail.MemberSnapshot, 0, len(members))
|
||||||
|
for _, m := range members {
|
||||||
|
if !matches(m.Status) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
account, err := a.users.GetAccount(ctx, m.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("resolve user_name for %s: %w", m.UserID, err)
|
||||||
|
}
|
||||||
|
out = append(out, diplomail.MemberSnapshot{
|
||||||
|
UserID: m.UserID,
|
||||||
|
GameID: gameID,
|
||||||
|
GameName: game.GameName,
|
||||||
|
UserName: account.UserName,
|
||||||
|
RaceName: m.RaceName,
|
||||||
|
Status: m.Status,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// lobbyDiplomailPublisherAdapter implements `lobby.DiplomailPublisher`
|
||||||
|
// by translating each lobby.LifecycleEvent into the diplomail
|
||||||
|
// vocabulary and delegating to `*diplomail.Service.PublishLifecycle`.
|
||||||
|
// The svc pointer is patched once diplomailSvc exists — diplomail
|
||||||
|
// depends on lobby through MembershipLookup, so the lobby service
|
||||||
|
// is constructed first and patched up.
|
||||||
|
type lobbyDiplomailPublisherAdapter struct {
|
||||||
|
svc *diplomail.Service
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *lobbyDiplomailPublisherAdapter) PublishLifecycle(ctx context.Context, ev lobby.LifecycleEvent) error {
|
||||||
|
if a == nil || a.svc == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return a.svc.PublishLifecycle(ctx, diplomail.LifecycleEvent{
|
||||||
|
GameID: ev.GameID,
|
||||||
|
Kind: ev.Kind,
|
||||||
|
Actor: ev.Actor,
|
||||||
|
Reason: ev.Reason,
|
||||||
|
TargetUser: ev.TargetUser,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// diplomailNotificationPublisherAdapter implements
|
// diplomailNotificationPublisherAdapter implements
|
||||||
// `diplomail.NotificationPublisher` by translating each
|
// `diplomail.NotificationPublisher` by translating each
|
||||||
// DiplomailNotification into a notification.Intent and routing it
|
// DiplomailNotification into a notification.Intent and routing it
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ purge, and the language-detection / translation cache.
|
|||||||
| Stage | Scope | Status |
|
| Stage | Scope | Status |
|
||||||
|-------|-------|--------|
|
|-------|-------|--------|
|
||||||
| A | Schema, personal single-recipient send / read / delete, unread badge, push event with body-language `und` | shipped |
|
| 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 |
|
| C | Paid-tier personal broadcast + admin multi-game broadcast + bulk purge | planned |
|
||||||
| D | Body-language detection (whatlanggo) + translation cache + async worker | 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 |
|
| Action | Caller | Pre-conditions |
|
||||||
|--------|--------|----------------|
|
|--------|--------|----------------|
|
||||||
| Send personal | user | active membership in game; recipient is active member |
|
| 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 |
|
| Mark read | the recipient | row exists; idempotent if already marked |
|
||||||
| Soft delete | the recipient | `read_at IS NOT NULL` (open-then-delete, item 10) |
|
| 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
|
Stage C will add the paid-tier player broadcast and the bulk-purge
|
||||||
soft-access rule for kicked players (post-kick read access restricted
|
admin endpoint.
|
||||||
to `kind='admin'` rows). Stage C adds the paid-tier 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
|
## 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`
|
// roster metadata. The canonical implementation in `cmd/backend/main`
|
||||||
// adapts the `*lobby.Service` membership cache to this interface.
|
// 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
|
// when the user is not an active member of the game; the service
|
||||||
// boundary maps that to 403 forbidden.
|
// 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 {
|
type MembershipLookup interface {
|
||||||
GetActiveMembership(ctx context.Context, gameID, userID uuid.UUID) (ActiveMembership, error)
|
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
|
// 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
|
// staticMembershipLookup serves an in-memory fixture. The test seeds
|
||||||
// memberships up-front and the lookup is keyed on (gameID, userID).
|
// 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 {
|
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) {
|
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
|
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
|
// seedAccount inserts a minimal accounts row so memberships and mail
|
||||||
// recipient FKs are satisfiable.
|
// recipient FKs are satisfiable.
|
||||||
func seedAccount(t *testing.T, db *sql.DB, userID uuid.UUID) {
|
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) {
|
func TestDiplomailRejectsOverlongBody(t *testing.T) {
|
||||||
db := startPostgres(t)
|
db := startPostgres(t)
|
||||||
ctx := context.Background()
|
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
|
// GetMessage returns the InboxEntry for messageID addressed to
|
||||||
// userID. ErrNotFound is returned when the caller is not a recipient
|
// 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 — 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) {
|
func (s *Service) GetMessage(ctx context.Context, userID, messageID uuid.UUID) (InboxEntry, error) {
|
||||||
entry, err := s.deps.Store.LoadInboxEntry(ctx, messageID, userID)
|
entry, err := s.deps.Store.LoadInboxEntry(ctx, messageID, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return InboxEntry{}, err
|
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
|
return entry, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListInbox returns every non-deleted message addressed to userID in
|
// ListInbox returns every non-deleted message addressed to userID in
|
||||||
// gameID, newest first. Read state is preserved per entry; the HTTP
|
// 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) {
|
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
|
// ListSent returns personal messages authored by senderUserID in
|
||||||
|
|||||||
@@ -70,6 +70,75 @@ type SendPersonalInput struct {
|
|||||||
SenderIP string
|
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
|
// UnreadCount carries a per-game unread-count row returned by
|
||||||
// UnreadCountsForUser. The lobby badge UI consumes the slice plus the
|
// UnreadCountsForUser. The lobby badge UI consumes the slice plus the
|
||||||
// derived total.
|
// derived total.
|
||||||
|
|||||||
@@ -51,6 +51,37 @@ type NotificationPublisher interface {
|
|||||||
PublishLobbyEvent(ctx context.Context, intent LobbyNotification) error
|
PublishLobbyEvent(ctx context.Context, intent LobbyNotification) error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DiplomailPublisher is the outbound surface the lobby uses to drop a
|
||||||
|
// durable system mail entry whenever a game-state or
|
||||||
|
// membership-state transition needs to land in the affected players'
|
||||||
|
// inboxes. The real implementation in `cmd/backend/main` adapts the
|
||||||
|
// `*diplomail.Service.PublishLifecycle` call; tests and partial
|
||||||
|
// wiring fall back to `NewNoopDiplomailPublisher`.
|
||||||
|
type DiplomailPublisher interface {
|
||||||
|
PublishLifecycle(ctx context.Context, event LifecycleEvent) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// LifecycleEvent is the open shape carried by a system-mail intent.
|
||||||
|
// `Kind` is one of the lobby-internal constants
|
||||||
|
// (`LifecycleKindGamePaused`, etc.). `TargetUser` is populated only
|
||||||
|
// for membership-scoped events; the publisher derives the game-scoped
|
||||||
|
// recipient set itself.
|
||||||
|
type LifecycleEvent struct {
|
||||||
|
GameID uuid.UUID
|
||||||
|
Kind string
|
||||||
|
Actor string
|
||||||
|
Reason string
|
||||||
|
TargetUser *uuid.UUID
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lifecycle-event kinds the lobby emits.
|
||||||
|
const (
|
||||||
|
LifecycleKindGamePaused = "game.paused"
|
||||||
|
LifecycleKindGameCancelled = "game.cancelled"
|
||||||
|
LifecycleKindMembershipRemoved = "membership.removed"
|
||||||
|
LifecycleKindMembershipBlocked = "membership.blocked"
|
||||||
|
)
|
||||||
|
|
||||||
// LobbyNotification is the open shape carried by a notification intent.
|
// LobbyNotification is the open shape carried by a notification intent.
|
||||||
// The implementation emits a small set of `Kind` values matching the catalog in
|
// The implementation emits a small set of `Kind` values matching the catalog in
|
||||||
// `backend/README.md` §10. The `Payload` map is the kind-specific data
|
// `backend/README.md` §10. The `Payload` map is the kind-specific data
|
||||||
@@ -123,3 +154,26 @@ func (p *noopNotificationPublisher) PublishLobbyEvent(_ context.Context, intent
|
|||||||
)
|
)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewNoopDiplomailPublisher returns a DiplomailPublisher that logs
|
||||||
|
// every call at debug level and returns nil. Used by tests and by
|
||||||
|
// the lobby Service factory when the Deps.Diplomail field is left
|
||||||
|
// nil.
|
||||||
|
func NewNoopDiplomailPublisher(logger *zap.Logger) DiplomailPublisher {
|
||||||
|
if logger == nil {
|
||||||
|
logger = zap.NewNop()
|
||||||
|
}
|
||||||
|
return &noopDiplomailPublisher{logger: logger.Named("lobby.diplomail.noop")}
|
||||||
|
}
|
||||||
|
|
||||||
|
type noopDiplomailPublisher struct {
|
||||||
|
logger *zap.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *noopDiplomailPublisher) PublishLifecycle(_ context.Context, event LifecycleEvent) error {
|
||||||
|
p.logger.Debug("noop diplomail lifecycle",
|
||||||
|
zap.String("kind", event.Kind),
|
||||||
|
zap.String("game_id", event.GameID.String()),
|
||||||
|
)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"galaxy/cronutil"
|
"galaxy/cronutil"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
// CreateGameInput is the parameter struct for Service.CreateGame.
|
// CreateGameInput is the parameter struct for Service.CreateGame.
|
||||||
@@ -441,9 +442,43 @@ func (s *Service) transition(ctx context.Context, callerUserID *uuid.UUID, calle
|
|||||||
return updated, fmt.Errorf("post-commit %s: %w", rule.Reason, err)
|
return updated, fmt.Errorf("post-commit %s: %w", rule.Reason, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
s.emitGameLifecycleMail(ctx, updated, callerIsAdmin, rule)
|
||||||
return updated, nil
|
return updated, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// emitGameLifecycleMail asks the diplomail publisher to drop a
|
||||||
|
// system-mail entry whenever a state change is user-visible. Only
|
||||||
|
// the `paused` and `cancelled` transitions emit mail today (the spec
|
||||||
|
// names them explicitly); `running`/`finished`/etc. are signalled by
|
||||||
|
// other channels and do not need a durable inbox entry.
|
||||||
|
func (s *Service) emitGameLifecycleMail(ctx context.Context, game GameRecord, callerIsAdmin bool, rule transitionRule) {
|
||||||
|
var kind string
|
||||||
|
switch rule.To {
|
||||||
|
case GameStatusPaused:
|
||||||
|
kind = LifecycleKindGamePaused
|
||||||
|
case GameStatusCancelled:
|
||||||
|
kind = LifecycleKindGameCancelled
|
||||||
|
default:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
actor := "the game owner"
|
||||||
|
if callerIsAdmin {
|
||||||
|
actor = "an administrator"
|
||||||
|
}
|
||||||
|
ev := LifecycleEvent{
|
||||||
|
GameID: game.GameID,
|
||||||
|
Kind: kind,
|
||||||
|
Actor: actor,
|
||||||
|
Reason: rule.Reason,
|
||||||
|
}
|
||||||
|
if err := s.deps.Diplomail.PublishLifecycle(ctx, ev); err != nil {
|
||||||
|
s.deps.Logger.Warn("publish lifecycle mail failed",
|
||||||
|
zap.String("game_id", game.GameID.String()),
|
||||||
|
zap.String("kind", kind),
|
||||||
|
zap.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// checkOwner enforces ownership semantics:
|
// checkOwner enforces ownership semantics:
|
||||||
//
|
//
|
||||||
// - callerIsAdmin == true → always allowed (admin force-start, etc.).
|
// - callerIsAdmin == true → always allowed (admin force-start, etc.).
|
||||||
|
|||||||
@@ -124,6 +124,7 @@ type Deps struct {
|
|||||||
Cache *Cache
|
Cache *Cache
|
||||||
Runtime RuntimeGateway
|
Runtime RuntimeGateway
|
||||||
Notification NotificationPublisher
|
Notification NotificationPublisher
|
||||||
|
Diplomail DiplomailPublisher
|
||||||
Entitlement EntitlementProvider
|
Entitlement EntitlementProvider
|
||||||
Policy *Policy
|
Policy *Policy
|
||||||
Config config.LobbyConfig
|
Config config.LobbyConfig
|
||||||
@@ -156,6 +157,9 @@ func NewService(deps Deps) (*Service, error) {
|
|||||||
if deps.Notification == nil {
|
if deps.Notification == nil {
|
||||||
deps.Notification = NewNoopNotificationPublisher(deps.Logger)
|
deps.Notification = NewNoopNotificationPublisher(deps.Logger)
|
||||||
}
|
}
|
||||||
|
if deps.Diplomail == nil {
|
||||||
|
deps.Diplomail = NewNoopDiplomailPublisher(deps.Logger)
|
||||||
|
}
|
||||||
if deps.Policy == nil {
|
if deps.Policy == nil {
|
||||||
policy, err := NewPolicy()
|
policy, err := NewPolicy()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ func (s *Service) AdminBanMember(ctx context.Context, gameID, userID uuid.UUID,
|
|||||||
zap.String("membership_id", updated.MembershipID.String()),
|
zap.String("membership_id", updated.MembershipID.String()),
|
||||||
zap.Error(pubErr))
|
zap.Error(pubErr))
|
||||||
}
|
}
|
||||||
|
s.emitMembershipLifecycleMail(ctx, updated, MembershipStatusBlocked, true, reason)
|
||||||
_ = game
|
_ = game
|
||||||
return updated, nil
|
return updated, nil
|
||||||
}
|
}
|
||||||
@@ -142,9 +143,44 @@ func (s *Service) changeMembershipStatus(
|
|||||||
zap.String("kind", notificationKind),
|
zap.String("kind", notificationKind),
|
||||||
zap.Error(pubErr))
|
zap.Error(pubErr))
|
||||||
}
|
}
|
||||||
|
s.emitMembershipLifecycleMail(ctx, updated, newStatus, callerIsAdmin, "")
|
||||||
return updated, nil
|
return updated, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// emitMembershipLifecycleMail asks the diplomail publisher to drop a
|
||||||
|
// durable explanation into the kicked player's inbox. The mail
|
||||||
|
// survives the membership row going to `removed` / `blocked` so the
|
||||||
|
// player keeps read access to it (soft-access rule, item 8).
|
||||||
|
func (s *Service) emitMembershipLifecycleMail(ctx context.Context, membership Membership, newStatus string, callerIsAdmin bool, reason string) {
|
||||||
|
var kind string
|
||||||
|
switch newStatus {
|
||||||
|
case MembershipStatusRemoved:
|
||||||
|
kind = LifecycleKindMembershipRemoved
|
||||||
|
case MembershipStatusBlocked:
|
||||||
|
kind = LifecycleKindMembershipBlocked
|
||||||
|
default:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
actor := "the game owner"
|
||||||
|
if callerIsAdmin {
|
||||||
|
actor = "an administrator"
|
||||||
|
}
|
||||||
|
target := membership.UserID
|
||||||
|
ev := LifecycleEvent{
|
||||||
|
GameID: membership.GameID,
|
||||||
|
Kind: kind,
|
||||||
|
Actor: actor,
|
||||||
|
Reason: reason,
|
||||||
|
TargetUser: &target,
|
||||||
|
}
|
||||||
|
if err := s.deps.Diplomail.PublishLifecycle(ctx, ev); err != nil {
|
||||||
|
s.deps.Logger.Warn("publish membership lifecycle mail failed",
|
||||||
|
zap.String("membership_id", membership.MembershipID.String()),
|
||||||
|
zap.String("kind", kind),
|
||||||
|
zap.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) canManageMembership(game GameRecord, membership Membership, callerUserID *uuid.UUID, allowSelf bool) bool {
|
func (s *Service) canManageMembership(game GameRecord, membership Membership, callerUserID *uuid.UUID, allowSelf bool) bool {
|
||||||
if game.Visibility == VisibilityPublic {
|
if game.Visibility == VisibilityPublic {
|
||||||
// Public-game membership management is admin-only.
|
// Public-game membership management is admin-only.
|
||||||
|
|||||||
@@ -700,7 +700,7 @@ CREATE TABLE diplomail_messages (
|
|||||||
),
|
),
|
||||||
CONSTRAINT diplomail_messages_kind_sender_chk CHECK (
|
CONSTRAINT diplomail_messages_kind_sender_chk CHECK (
|
||||||
(kind = 'personal' AND sender_kind = 'player') OR
|
(kind = 'personal' AND sender_kind = 'player') OR
|
||||||
(kind = 'admin' AND sender_kind IN ('admin', 'system'))
|
(kind = 'admin' AND sender_kind IN ('player', 'admin', 'system'))
|
||||||
),
|
),
|
||||||
CONSTRAINT diplomail_messages_broadcast_scope_chk
|
CONSTRAINT diplomail_messages_broadcast_scope_chk
|
||||||
CHECK (broadcast_scope IN ('single', 'game_broadcast', 'multi_game_broadcast'))
|
CHECK (broadcast_scope IN ('single', 'game_broadcast', 'multi_game_broadcast'))
|
||||||
|
|||||||
@@ -155,6 +155,18 @@ var requestBodyStubs = map[string]map[string]any{
|
|||||||
"subject": "Contract test subject",
|
"subject": "Contract test subject",
|
||||||
"body": "Contract test body",
|
"body": "Contract test body",
|
||||||
},
|
},
|
||||||
|
"userMailSendAdmin": {
|
||||||
|
"target": "user",
|
||||||
|
"recipient_user_id": pathParamStubs["user_id"],
|
||||||
|
"subject": "Contract test admin subject",
|
||||||
|
"body": "Contract test admin body",
|
||||||
|
},
|
||||||
|
"adminDiplomailSend": {
|
||||||
|
"target": "user",
|
||||||
|
"recipient_user_id": pathParamStubs["user_id"],
|
||||||
|
"subject": "Contract test admin subject",
|
||||||
|
"body": "Contract test admin body",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestOpenAPIContract is the top-level OpenAPI contract test. It
|
// TestOpenAPIContract is the top-level OpenAPI contract test. It
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"galaxy/backend/internal/diplomail"
|
||||||
|
"galaxy/backend/internal/server/clientip"
|
||||||
|
"galaxy/backend/internal/server/handlers"
|
||||||
|
"galaxy/backend/internal/server/httperr"
|
||||||
|
"galaxy/backend/internal/server/middleware/basicauth"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AdminDiplomailHandlers groups the diplomatic-mail handlers exposed
|
||||||
|
// under `/api/v1/admin/games/{game_id}/mail` (per-game admin send /
|
||||||
|
// broadcast). The handler is intentionally separate from
|
||||||
|
// `AdminMailHandlers`, which owns the unrelated email outbox surface
|
||||||
|
// under `/api/v1/admin/mail/*`.
|
||||||
|
type AdminDiplomailHandlers struct {
|
||||||
|
svc *diplomail.Service
|
||||||
|
logger *zap.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAdminDiplomailHandlers constructs the handler set. svc may be
|
||||||
|
// nil — in that case every handler returns 501 not_implemented.
|
||||||
|
func NewAdminDiplomailHandlers(svc *diplomail.Service, logger *zap.Logger) *AdminDiplomailHandlers {
|
||||||
|
if logger == nil {
|
||||||
|
logger = zap.NewNop()
|
||||||
|
}
|
||||||
|
return &AdminDiplomailHandlers{svc: svc, logger: logger.Named("http.admin.diplomail")}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send handles POST /api/v1/admin/games/{game_id}/mail. The body
|
||||||
|
// shape mirrors the owner route: `target="user"` requires
|
||||||
|
// `recipient_user_id`; `target="all"` accepts an optional
|
||||||
|
// `recipients` scope. The authenticated admin username is captured
|
||||||
|
// from the basicauth context and persisted as `sender_username`.
|
||||||
|
func (h *AdminDiplomailHandlers) Send() gin.HandlerFunc {
|
||||||
|
if h.svc == nil {
|
||||||
|
return handlers.NotImplemented("adminDiplomailSend")
|
||||||
|
}
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
username, ok := basicauth.UsernameFromContext(c.Request.Context())
|
||||||
|
if !ok || username == "" {
|
||||||
|
httperr.Abort(c, http.StatusUnauthorized, httperr.CodeUnauthorized, "admin authentication is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
gameID, ok := parseGameIDParam(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req userMailSendAdminRequestWire
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "request body must be valid JSON")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
switch req.Target {
|
||||||
|
case "", "user":
|
||||||
|
recipientID, parseErr := uuid.Parse(req.RecipientUserID)
|
||||||
|
if parseErr != nil {
|
||||||
|
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "recipient_user_id must be a valid UUID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
msg, rcpt, sendErr := h.svc.SendAdminPersonal(ctx, diplomail.SendAdminPersonalInput{
|
||||||
|
GameID: gameID,
|
||||||
|
CallerKind: diplomail.CallerKindAdmin,
|
||||||
|
CallerUsername: username,
|
||||||
|
RecipientUserID: recipientID,
|
||||||
|
Subject: req.Subject,
|
||||||
|
Body: req.Body,
|
||||||
|
SenderIP: clientip.ExtractSourceIP(c),
|
||||||
|
})
|
||||||
|
if sendErr != nil {
|
||||||
|
respondDiplomailError(c, h.logger, "admin mail send personal", ctx, sendErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusCreated, mailMessageDetailToWire(diplomail.InboxEntry{Message: msg, Recipient: rcpt}, true))
|
||||||
|
case "all":
|
||||||
|
msg, recipients, sendErr := h.svc.SendAdminBroadcast(ctx, diplomail.SendAdminBroadcastInput{
|
||||||
|
GameID: gameID,
|
||||||
|
CallerKind: diplomail.CallerKindAdmin,
|
||||||
|
CallerUsername: username,
|
||||||
|
RecipientScope: req.Recipients,
|
||||||
|
Subject: req.Subject,
|
||||||
|
Body: req.Body,
|
||||||
|
SenderIP: clientip.ExtractSourceIP(c),
|
||||||
|
})
|
||||||
|
if sendErr != nil {
|
||||||
|
respondDiplomailError(c, h.logger, "admin mail send broadcast", ctx, sendErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusCreated, mailBroadcastReceiptToWire(msg, recipients))
|
||||||
|
default:
|
||||||
|
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "target must be 'user' or 'all'")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,11 +6,13 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"galaxy/backend/internal/diplomail"
|
"galaxy/backend/internal/diplomail"
|
||||||
|
"galaxy/backend/internal/lobby"
|
||||||
"galaxy/backend/internal/server/clientip"
|
"galaxy/backend/internal/server/clientip"
|
||||||
"galaxy/backend/internal/server/handlers"
|
"galaxy/backend/internal/server/handlers"
|
||||||
"galaxy/backend/internal/server/httperr"
|
"galaxy/backend/internal/server/httperr"
|
||||||
"galaxy/backend/internal/server/middleware/userid"
|
"galaxy/backend/internal/server/middleware/userid"
|
||||||
"galaxy/backend/internal/telemetry"
|
"galaxy/backend/internal/telemetry"
|
||||||
|
"galaxy/backend/internal/user"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@@ -19,20 +21,31 @@ import (
|
|||||||
|
|
||||||
// UserMailHandlers groups the diplomatic-mail handlers under
|
// UserMailHandlers groups the diplomatic-mail handlers under
|
||||||
// `/api/v1/user/games/{game_id}/mail/*` and the lobby-side
|
// `/api/v1/user/games/{game_id}/mail/*` and the lobby-side
|
||||||
// `/api/v1/user/lobby/mail/unread-counts`. Stage A wires only the
|
// `/api/v1/user/lobby/mail/unread-counts`. Stage A wires the
|
||||||
// personal-message subset.
|
// personal subset; Stage B adds the owner-only admin send path,
|
||||||
|
// which needs `*lobby.Service` to confirm ownership and `*user.Service`
|
||||||
|
// to resolve the owner's `user_name` for the `sender_username` column.
|
||||||
type UserMailHandlers struct {
|
type UserMailHandlers struct {
|
||||||
svc *diplomail.Service
|
svc *diplomail.Service
|
||||||
|
lobby *lobby.Service
|
||||||
|
users *user.Service
|
||||||
logger *zap.Logger
|
logger *zap.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewUserMailHandlers constructs the handler set. svc may be nil — in
|
// NewUserMailHandlers constructs the handler set. svc may be nil — in
|
||||||
// that case every handler returns 501 not_implemented.
|
// that case every handler returns 501 not_implemented. lobby and
|
||||||
func NewUserMailHandlers(svc *diplomail.Service, logger *zap.Logger) *UserMailHandlers {
|
// users are optional: when either is nil the admin-send handler
|
||||||
|
// degrades to 501 (the personal-send and read paths stay functional).
|
||||||
|
func NewUserMailHandlers(svc *diplomail.Service, lobbySvc *lobby.Service, users *user.Service, logger *zap.Logger) *UserMailHandlers {
|
||||||
if logger == nil {
|
if logger == nil {
|
||||||
logger = zap.NewNop()
|
logger = zap.NewNop()
|
||||||
}
|
}
|
||||||
return &UserMailHandlers{svc: svc, logger: logger.Named("http.user.mail")}
|
return &UserMailHandlers{
|
||||||
|
svc: svc,
|
||||||
|
lobby: lobbySvc,
|
||||||
|
users: users,
|
||||||
|
logger: logger.Named("http.user.mail"),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SendPersonal handles POST /api/v1/user/games/{game_id}/mail/messages.
|
// SendPersonal handles POST /api/v1/user/games/{game_id}/mail/messages.
|
||||||
@@ -219,6 +232,96 @@ func (h *UserMailHandlers) Delete() gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SendAdmin handles POST /api/v1/user/games/{game_id}/mail/admin.
|
||||||
|
//
|
||||||
|
// Owner-only: the caller must be the owner of the private game. The
|
||||||
|
// handler resolves the owner's `user_name` so the
|
||||||
|
// `sender_username` column carries a useful identity, then routes to
|
||||||
|
// SendAdminPersonal (for `target="user"`) or SendAdminBroadcast (for
|
||||||
|
// `target="all"`). Site administrators use the separate admin route
|
||||||
|
// in `handlers_admin_mail_send.go`.
|
||||||
|
func (h *UserMailHandlers) SendAdmin() gin.HandlerFunc {
|
||||||
|
if h.svc == nil || h.lobby == nil || h.users == nil {
|
||||||
|
return handlers.NotImplemented("userMailSendAdmin")
|
||||||
|
}
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
userID, ok := userid.FromContext(c.Request.Context())
|
||||||
|
if !ok {
|
||||||
|
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "X-User-ID header is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
gameID, ok := parseGameIDParam(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req userMailSendAdminRequestWire
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "request body must be valid JSON")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
|
||||||
|
game, err := h.lobby.GetGame(ctx, gameID)
|
||||||
|
if err != nil {
|
||||||
|
respondLobbyError(c, h.logger, "user mail send admin: load game", ctx, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if game.OwnerUserID == nil || *game.OwnerUserID != userID {
|
||||||
|
httperr.Abort(c, http.StatusForbidden, httperr.CodeForbidden, "caller is not the owner of this game")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
account, err := h.users.GetAccount(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
respondAccountError(c, h.logger, "user mail send admin: resolve user_name", ctx, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch req.Target {
|
||||||
|
case "", "user":
|
||||||
|
recipientID, parseErr := uuid.Parse(req.RecipientUserID)
|
||||||
|
if parseErr != nil {
|
||||||
|
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "recipient_user_id must be a valid UUID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
callerUserID := userID
|
||||||
|
msg, rcpt, sendErr := h.svc.SendAdminPersonal(ctx, diplomail.SendAdminPersonalInput{
|
||||||
|
GameID: gameID,
|
||||||
|
CallerKind: diplomail.CallerKindOwner,
|
||||||
|
CallerUserID: &callerUserID,
|
||||||
|
CallerUsername: account.UserName,
|
||||||
|
RecipientUserID: recipientID,
|
||||||
|
Subject: req.Subject,
|
||||||
|
Body: req.Body,
|
||||||
|
SenderIP: clientip.ExtractSourceIP(c),
|
||||||
|
})
|
||||||
|
if sendErr != nil {
|
||||||
|
respondDiplomailError(c, h.logger, "user mail send admin personal", ctx, sendErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusCreated, mailMessageDetailToWire(diplomail.InboxEntry{Message: msg, Recipient: rcpt}, true))
|
||||||
|
case "all":
|
||||||
|
callerUserID := userID
|
||||||
|
msg, recipients, sendErr := h.svc.SendAdminBroadcast(ctx, diplomail.SendAdminBroadcastInput{
|
||||||
|
GameID: gameID,
|
||||||
|
CallerKind: diplomail.CallerKindOwner,
|
||||||
|
CallerUserID: &callerUserID,
|
||||||
|
CallerUsername: account.UserName,
|
||||||
|
RecipientScope: req.Recipients,
|
||||||
|
Subject: req.Subject,
|
||||||
|
Body: req.Body,
|
||||||
|
SenderIP: clientip.ExtractSourceIP(c),
|
||||||
|
})
|
||||||
|
if sendErr != nil {
|
||||||
|
respondDiplomailError(c, h.logger, "user mail send admin broadcast", ctx, sendErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusCreated, mailBroadcastReceiptToWire(msg, recipients))
|
||||||
|
default:
|
||||||
|
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "target must be 'user' or 'all'")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// UnreadCounts handles GET /api/v1/user/lobby/mail/unread-counts.
|
// UnreadCounts handles GET /api/v1/user/lobby/mail/unread-counts.
|
||||||
func (h *UserMailHandlers) UnreadCounts() gin.HandlerFunc {
|
func (h *UserMailHandlers) UnreadCounts() gin.HandlerFunc {
|
||||||
if h.svc == nil {
|
if h.svc == nil {
|
||||||
@@ -289,6 +392,52 @@ type userMailSendRequestWire struct {
|
|||||||
Body string `json:"body"`
|
Body string `json:"body"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// userMailSendAdminRequestWire mirrors the request body for the
|
||||||
|
// owner-only admin send. `target="user"` requires
|
||||||
|
// `recipient_user_id`; `target="all"` accepts the optional
|
||||||
|
// `recipients` scope (default `active`).
|
||||||
|
type userMailSendAdminRequestWire struct {
|
||||||
|
Target string `json:"target"`
|
||||||
|
RecipientUserID string `json:"recipient_user_id,omitempty"`
|
||||||
|
Recipients string `json:"recipients,omitempty"`
|
||||||
|
Subject string `json:"subject,omitempty"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// userMailBroadcastReceiptWire is the response shape returned after a
|
||||||
|
// successful broadcast. It carries the canonical message metadata
|
||||||
|
// together with the count of materialised recipient rows so the
|
||||||
|
// caller (UI, admin tool) can confirm the fan-out happened.
|
||||||
|
type userMailBroadcastReceiptWire struct {
|
||||||
|
MessageID string `json:"message_id"`
|
||||||
|
GameID string `json:"game_id"`
|
||||||
|
GameName string `json:"game_name,omitempty"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
SenderKind string `json:"sender_kind"`
|
||||||
|
Subject string `json:"subject,omitempty"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
BodyLang string `json:"body_lang"`
|
||||||
|
BroadcastScope string `json:"broadcast_scope"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
RecipientCount int `json:"recipient_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func mailBroadcastReceiptToWire(m diplomail.Message, recipients []diplomail.Recipient) userMailBroadcastReceiptWire {
|
||||||
|
return userMailBroadcastReceiptWire{
|
||||||
|
MessageID: m.MessageID.String(),
|
||||||
|
GameID: m.GameID.String(),
|
||||||
|
GameName: m.GameName,
|
||||||
|
Kind: m.Kind,
|
||||||
|
SenderKind: m.SenderKind,
|
||||||
|
Subject: m.Subject,
|
||||||
|
Body: m.Body,
|
||||||
|
BodyLang: m.BodyLang,
|
||||||
|
BroadcastScope: m.BroadcastScope,
|
||||||
|
CreatedAt: m.CreatedAt.UTC().Format(timestampLayout),
|
||||||
|
RecipientCount: len(recipients),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// userMailMessageDetailWire mirrors the unified response shape for
|
// userMailMessageDetailWire mirrors the unified response shape for
|
||||||
// inbox listings and per-message reads. Sender identifiers are
|
// inbox listings and per-message reads. Sender identifiers are
|
||||||
// optional: system messages carry neither user id nor username.
|
// optional: system messages carry neither user id nor username.
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ type RouterDependencies struct {
|
|||||||
AdminRuntimes *AdminRuntimesHandlers
|
AdminRuntimes *AdminRuntimesHandlers
|
||||||
AdminEngineVersions *AdminEngineVersionsHandlers
|
AdminEngineVersions *AdminEngineVersionsHandlers
|
||||||
AdminMail *AdminMailHandlers
|
AdminMail *AdminMailHandlers
|
||||||
|
AdminDiplomail *AdminDiplomailHandlers
|
||||||
AdminNotifications *AdminNotificationsHandlers
|
AdminNotifications *AdminNotificationsHandlers
|
||||||
AdminGeo *AdminGeoHandlers
|
AdminGeo *AdminGeoHandlers
|
||||||
InternalSessions *InternalSessionsHandlers
|
InternalSessions *InternalSessionsHandlers
|
||||||
@@ -165,7 +166,7 @@ func withDefaultHandlers(deps RouterDependencies) RouterDependencies {
|
|||||||
deps.UserGames = NewUserGamesHandlers(nil, nil, deps.Logger)
|
deps.UserGames = NewUserGamesHandlers(nil, nil, deps.Logger)
|
||||||
}
|
}
|
||||||
if deps.UserMail == nil {
|
if deps.UserMail == nil {
|
||||||
deps.UserMail = NewUserMailHandlers(nil, deps.Logger)
|
deps.UserMail = NewUserMailHandlers(nil, nil, nil, deps.Logger)
|
||||||
}
|
}
|
||||||
if deps.UserSessions == nil {
|
if deps.UserSessions == nil {
|
||||||
deps.UserSessions = NewUserSessionsHandlers(nil, deps.Logger)
|
deps.UserSessions = NewUserSessionsHandlers(nil, deps.Logger)
|
||||||
@@ -188,6 +189,9 @@ func withDefaultHandlers(deps RouterDependencies) RouterDependencies {
|
|||||||
if deps.AdminMail == nil {
|
if deps.AdminMail == nil {
|
||||||
deps.AdminMail = NewAdminMailHandlers(nil, deps.Logger)
|
deps.AdminMail = NewAdminMailHandlers(nil, deps.Logger)
|
||||||
}
|
}
|
||||||
|
if deps.AdminDiplomail == nil {
|
||||||
|
deps.AdminDiplomail = NewAdminDiplomailHandlers(nil, deps.Logger)
|
||||||
|
}
|
||||||
if deps.AdminNotifications == nil {
|
if deps.AdminNotifications == nil {
|
||||||
deps.AdminNotifications = NewAdminNotificationsHandlers(nil, deps.Logger)
|
deps.AdminNotifications = NewAdminNotificationsHandlers(nil, deps.Logger)
|
||||||
}
|
}
|
||||||
@@ -274,6 +278,7 @@ func registerUserRoutes(router *gin.Engine, instruments *metrics.Instruments, de
|
|||||||
|
|
||||||
userMail := userGames.Group("/:game_id/mail")
|
userMail := userGames.Group("/:game_id/mail")
|
||||||
userMail.POST("/messages", deps.UserMail.SendPersonal())
|
userMail.POST("/messages", deps.UserMail.SendPersonal())
|
||||||
|
userMail.POST("/admin", deps.UserMail.SendAdmin())
|
||||||
userMail.GET("/messages/:message_id", deps.UserMail.Get())
|
userMail.GET("/messages/:message_id", deps.UserMail.Get())
|
||||||
userMail.POST("/messages/:message_id/read", deps.UserMail.MarkRead())
|
userMail.POST("/messages/:message_id/read", deps.UserMail.MarkRead())
|
||||||
userMail.DELETE("/messages/:message_id", deps.UserMail.Delete())
|
userMail.DELETE("/messages/:message_id", deps.UserMail.Delete())
|
||||||
@@ -314,6 +319,7 @@ func registerAdminRoutes(router *gin.Engine, instruments *metrics.Instruments, d
|
|||||||
games.POST("/:game_id/force-start", deps.AdminGames.ForceStart())
|
games.POST("/:game_id/force-start", deps.AdminGames.ForceStart())
|
||||||
games.POST("/:game_id/force-stop", deps.AdminGames.ForceStop())
|
games.POST("/:game_id/force-stop", deps.AdminGames.ForceStop())
|
||||||
games.POST("/:game_id/ban-member", deps.AdminGames.BanMember())
|
games.POST("/:game_id/ban-member", deps.AdminGames.BanMember())
|
||||||
|
games.POST("/:game_id/mail", deps.AdminDiplomail.Send())
|
||||||
|
|
||||||
runtimes := group.Group("/runtimes")
|
runtimes := group.Group("/runtimes")
|
||||||
runtimes.GET("/:game_id", deps.AdminRuntimes.Get())
|
runtimes.GET("/:game_id", deps.AdminRuntimes.Get())
|
||||||
|
|||||||
@@ -1183,6 +1183,47 @@ paths:
|
|||||||
$ref: "#/components/responses/NotImplementedError"
|
$ref: "#/components/responses/NotImplementedError"
|
||||||
"500":
|
"500":
|
||||||
$ref: "#/components/responses/InternalError"
|
$ref: "#/components/responses/InternalError"
|
||||||
|
/api/v1/user/games/{game_id}/mail/admin:
|
||||||
|
post:
|
||||||
|
tags: [User]
|
||||||
|
operationId: userMailSendAdmin
|
||||||
|
summary: Send a non-replyable admin notification (owner only)
|
||||||
|
description: |
|
||||||
|
Owner-only: the caller must be the owner of the private game.
|
||||||
|
`target="user"` requires `recipient_user_id`; `target="all"`
|
||||||
|
accepts an optional `recipients` scope (`active` by default,
|
||||||
|
plus `active_and_removed` and `all_members`). The message
|
||||||
|
carries `kind="admin"` and is therefore non-replyable.
|
||||||
|
security:
|
||||||
|
- UserHeader: []
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/XUserID"
|
||||||
|
- $ref: "#/components/parameters/GameID"
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/UserMailSendAdminRequest"
|
||||||
|
responses:
|
||||||
|
"201":
|
||||||
|
description: Admin message persisted; broadcasts return a fan-out receipt.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
oneOf:
|
||||||
|
- $ref: "#/components/schemas/UserMailMessageDetail"
|
||||||
|
- $ref: "#/components/schemas/UserMailBroadcastReceipt"
|
||||||
|
"400":
|
||||||
|
$ref: "#/components/responses/InvalidRequestError"
|
||||||
|
"403":
|
||||||
|
$ref: "#/components/responses/ForbiddenError"
|
||||||
|
"404":
|
||||||
|
$ref: "#/components/responses/NotFoundError"
|
||||||
|
"501":
|
||||||
|
$ref: "#/components/responses/NotImplementedError"
|
||||||
|
"500":
|
||||||
|
$ref: "#/components/responses/InternalError"
|
||||||
/api/v1/user/games/{game_id}/mail/messages/{message_id}:
|
/api/v1/user/games/{game_id}/mail/messages/{message_id}:
|
||||||
get:
|
get:
|
||||||
tags: [User]
|
tags: [User]
|
||||||
@@ -1913,6 +1954,49 @@ paths:
|
|||||||
$ref: "#/components/responses/NotImplementedError"
|
$ref: "#/components/responses/NotImplementedError"
|
||||||
"500":
|
"500":
|
||||||
$ref: "#/components/responses/InternalError"
|
$ref: "#/components/responses/InternalError"
|
||||||
|
/api/v1/admin/games/{game_id}/mail:
|
||||||
|
post:
|
||||||
|
tags: [Admin]
|
||||||
|
operationId: adminDiplomailSend
|
||||||
|
summary: Send a diplomatic-mail admin notification to one game
|
||||||
|
description: |
|
||||||
|
Site-admin send for the diplomatic-mail subsystem. Body shape
|
||||||
|
mirrors the owner-only `POST /api/v1/user/games/{game_id}/mail/admin`
|
||||||
|
endpoint. `target="user"` requires `recipient_user_id`;
|
||||||
|
`target="all"` accepts an optional `recipients` scope
|
||||||
|
(`active` / `active_and_removed` / `all_members`). The
|
||||||
|
authenticated admin username is persisted as `sender_username`.
|
||||||
|
security:
|
||||||
|
- AdminBasicAuth: []
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/GameID"
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/UserMailSendAdminRequest"
|
||||||
|
responses:
|
||||||
|
"201":
|
||||||
|
description: Admin message persisted; broadcasts return a fan-out receipt.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
oneOf:
|
||||||
|
- $ref: "#/components/schemas/UserMailMessageDetail"
|
||||||
|
- $ref: "#/components/schemas/UserMailBroadcastReceipt"
|
||||||
|
"400":
|
||||||
|
$ref: "#/components/responses/InvalidRequestError"
|
||||||
|
"401":
|
||||||
|
$ref: "#/components/responses/UnauthorizedError"
|
||||||
|
"403":
|
||||||
|
$ref: "#/components/responses/ForbiddenError"
|
||||||
|
"404":
|
||||||
|
$ref: "#/components/responses/NotFoundError"
|
||||||
|
"501":
|
||||||
|
$ref: "#/components/responses/NotImplementedError"
|
||||||
|
"500":
|
||||||
|
$ref: "#/components/responses/InternalError"
|
||||||
/api/v1/admin/runtimes/{game_id}:
|
/api/v1/admin/runtimes/{game_id}:
|
||||||
get:
|
get:
|
||||||
tags: [Admin]
|
tags: [Admin]
|
||||||
@@ -3831,6 +3915,74 @@ components:
|
|||||||
body:
|
body:
|
||||||
type: string
|
type: string
|
||||||
description: Plain UTF-8 body. HTML is not parsed on the server.
|
description: Plain UTF-8 body. HTML is not parsed on the server.
|
||||||
|
UserMailSendAdminRequest:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [target, body]
|
||||||
|
properties:
|
||||||
|
target:
|
||||||
|
type: string
|
||||||
|
enum: [user, all]
|
||||||
|
recipient_user_id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
description: |
|
||||||
|
Required when `target="user"`. Identifies the recipient
|
||||||
|
of the personal admin message; the recipient may be in
|
||||||
|
any membership status (admin notifications can reach
|
||||||
|
kicked players).
|
||||||
|
recipients:
|
||||||
|
type: string
|
||||||
|
enum: [active, active_and_removed, all_members]
|
||||||
|
description: |
|
||||||
|
Optional scope when `target="all"`. Defaults to `active`.
|
||||||
|
subject:
|
||||||
|
type: string
|
||||||
|
body:
|
||||||
|
type: string
|
||||||
|
UserMailBroadcastReceipt:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required:
|
||||||
|
- message_id
|
||||||
|
- game_id
|
||||||
|
- kind
|
||||||
|
- sender_kind
|
||||||
|
- body
|
||||||
|
- body_lang
|
||||||
|
- broadcast_scope
|
||||||
|
- created_at
|
||||||
|
- recipient_count
|
||||||
|
properties:
|
||||||
|
message_id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
game_id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
game_name:
|
||||||
|
type: string
|
||||||
|
kind:
|
||||||
|
type: string
|
||||||
|
enum: [personal, admin]
|
||||||
|
sender_kind:
|
||||||
|
type: string
|
||||||
|
enum: [player, admin, system]
|
||||||
|
subject:
|
||||||
|
type: string
|
||||||
|
body:
|
||||||
|
type: string
|
||||||
|
body_lang:
|
||||||
|
type: string
|
||||||
|
broadcast_scope:
|
||||||
|
type: string
|
||||||
|
enum: [single, game_broadcast, multi_game_broadcast]
|
||||||
|
created_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
recipient_count:
|
||||||
|
type: integer
|
||||||
|
minimum: 0
|
||||||
UserMailMessageDetail:
|
UserMailMessageDetail:
|
||||||
type: object
|
type: object
|
||||||
additionalProperties: false
|
additionalProperties: false
|
||||||
|
|||||||
Reference in New Issue
Block a user