diff --git a/backend/internal/diplomail/service.go b/backend/internal/diplomail/service.go index 7c350b5..a79374a 100644 --- a/backend/internal/diplomail/service.go +++ b/backend/internal/diplomail/service.go @@ -316,10 +316,13 @@ func (s *Service) allowedKinds(ctx context.Context, gameID, userID uuid.UUID) (m return map[string]bool{KindAdmin: true}, nil } -// ListSent returns personal messages authored by senderUserID in -// gameID, newest first. Admin/system rows have no `sender_user_id` -// and are therefore excluded; the user surface does not need them. -func (s *Service) ListSent(ctx context.Context, gameID, senderUserID uuid.UUID) ([]Message, error) { +// ListSent returns the sender-side view of personal messages +// authored by senderUserID in gameID, newest first. Each entry pairs +// the message with one of its recipient rows; single sends contribute +// one entry per message, broadcasts contribute one entry per +// addressee. Admin and system rows have no `sender_user_id` and are +// therefore excluded; the user surface does not need them. +func (s *Service) ListSent(ctx context.Context, gameID, senderUserID uuid.UUID) ([]InboxEntry, error) { return s.deps.Store.ListSent(ctx, gameID, senderUserID) } diff --git a/backend/internal/diplomail/store.go b/backend/internal/diplomail/store.go index 6d4f5c9..d2394fc 100644 --- a/backend/internal/diplomail/store.go +++ b/backend/internal/diplomail/store.go @@ -243,25 +243,38 @@ func (s *Store) ListInbox(ctx context.Context, gameID, userID uuid.UUID) ([]Inbo return out, nil } -// ListSent returns messages authored by senderUserID in gameID, -// newest first. Personal messages only — admin/system rows have -// `sender_user_id IS NULL` and are filtered out by the WHERE clause. -func (s *Store) ListSent(ctx context.Context, gameID, senderUserID uuid.UUID) ([]Message, error) { +// ListSent returns the sender-side view of personal messages +// authored by senderUserID in gameID, newest first. Each +// `InboxEntry` carries the message together with one of its +// recipient rows — single sends produce one entry per message; +// game broadcasts produce one entry per addressee (the in-game +// mail UI collapses broadcast entries into a single stand-alone +// item by `message_id`). Admin / system rows have +// `sender_user_id IS NULL` and are excluded by the WHERE clause. +func (s *Store) ListSent(ctx context.Context, gameID, senderUserID uuid.UUID) ([]InboxEntry, error) { m := table.DiplomailMessages - stmt := postgres.SELECT(messageColumns()). - FROM(m). + r := table.DiplomailRecipients + cols := append(messageColumns(), recipientColumns()...) + stmt := postgres.SELECT(cols). + FROM(m.INNER_JOIN(r, r.MessageID.EQ(m.MessageID))). WHERE( m.GameID.EQ(postgres.UUID(gameID)). AND(m.SenderUserID.EQ(postgres.UUID(senderUserID))), ). - ORDER_BY(m.CreatedAt.DESC(), m.MessageID.DESC()) - var rows []model.DiplomailMessages - if err := stmt.QueryContext(ctx, s.db, &rows); err != nil { + ORDER_BY(m.CreatedAt.DESC(), m.MessageID.DESC(), r.RecipientID.ASC()) + var dest []struct { + model.DiplomailMessages + Recipient model.DiplomailRecipients `alias:"diplomail_recipients"` + } + if err := stmt.QueryContext(ctx, s.db, &dest); err != nil { return nil, fmt.Errorf("diplomail store: list sent %s/%s: %w", gameID, senderUserID, err) } - out := make([]Message, 0, len(rows)) - for _, row := range rows { - out = append(out, messageFromModel(row)) + out := make([]InboxEntry, 0, len(dest)) + for _, row := range dest { + out = append(out, InboxEntry{ + Message: messageFromModel(row.DiplomailMessages), + Recipient: recipientFromModel(row.Recipient), + }) } return out, nil } diff --git a/backend/internal/server/handlers_user_mail.go b/backend/internal/server/handlers_user_mail.go index 04fd8f8..7ab053a 100644 --- a/backend/internal/server/handlers_user_mail.go +++ b/backend/internal/server/handlers_user_mail.go @@ -194,9 +194,9 @@ func (h *UserMailHandlers) Sent() gin.HandlerFunc { respondDiplomailError(c, h.logger, "user mail sent", ctx, err) return } - out := userMailSentListWire{Items: make([]userMailSentSummaryWire, 0, len(items))} - for _, m := range items { - out.Items = append(out.Items, mailMessageSummaryToWire(m)) + out := userMailSentListWire{Items: make([]userMailMessageDetailWire, 0, len(items))} + for _, entry := range items { + out.Items = append(out.Items, mailMessageDetailToWire(entry, false)) } c.JSON(http.StatusOK, out) } @@ -555,27 +555,18 @@ type userMailMessageDetailWire struct { Translator *string `json:"translator,omitempty"` } -// userMailSentSummaryWire mirrors the response shape for the -// sender-side listing. Recipient state is intentionally omitted (one -// author may have N recipients per broadcast in later stages). -type userMailSentSummaryWire struct { - MessageID string `json:"message_id"` - GameID string `json:"game_id"` - GameName string `json:"game_name,omitempty"` - Kind string `json:"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"` -} - type userMailInboxListWire struct { Items []userMailMessageDetailWire `json:"items"` } +// userMailSentListWire mirrors the response shape for the +// sender-side listing. Phase 28's in-game UI threads sent messages +// by the recipient's race name, so the wire carries the full +// message detail (including the recipient snapshot) — single sends +// contribute one row per message, broadcasts contribute one row per +// addressee and the UI collapses them by `message_id`. type userMailSentListWire struct { - Items []userMailSentSummaryWire `json:"items"` + Items []userMailMessageDetailWire `json:"items"` } type userMailUnreadCountWire struct { @@ -643,20 +634,6 @@ func mailMessageDetailToWire(entry diplomail.InboxEntry, justCreated bool) userM return out } -func mailMessageSummaryToWire(m diplomail.Message) userMailSentSummaryWire { - return userMailSentSummaryWire{ - MessageID: m.MessageID.String(), - GameID: m.GameID.String(), - GameName: m.GameName, - Kind: m.Kind, - Subject: m.Subject, - Body: m.Body, - BodyLang: m.BodyLang, - BroadcastScope: m.BroadcastScope, - CreatedAt: m.CreatedAt.UTC().Format(timestampLayout), - } -} - // mailRecipientStateToWire renders the recipient row after a // mark-read or soft-delete call. The caller only needs the per-user // state, not the full message body again. diff --git a/backend/openapi.yaml b/backend/openapi.yaml index ae89e0b..4477cb2 100644 --- a/backend/openapi.yaml +++ b/backend/openapi.yaml @@ -4400,41 +4400,6 @@ components: translator: type: string description: Identifier of the translation engine that produced the cached row. - UserMailSentSummary: - type: object - additionalProperties: false - required: - - message_id - - game_id - - kind - - body - - body_lang - - broadcast_scope - - created_at - properties: - message_id: - type: string - format: uuid - game_id: - type: string - format: uuid - game_name: - type: string - kind: - type: string - enum: [personal, admin] - 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 UserMailInboxList: type: object additionalProperties: false @@ -4445,6 +4410,13 @@ components: items: $ref: "#/components/schemas/UserMailMessageDetail" UserMailSentList: + description: | + Sender-side listing of personal messages authored by the + caller. Each item carries the same shape as inbox entries + (including the recipient snapshot); single sends contribute + one row per message, broadcasts contribute one row per + addressee so the in-game UI can collapse them by + `message_id` into a single stand-alone item. type: object additionalProperties: false required: [items] @@ -4452,7 +4424,7 @@ components: items: type: array items: - $ref: "#/components/schemas/UserMailSentSummary" + $ref: "#/components/schemas/UserMailMessageDetail" UserMailUnreadCount: type: object additionalProperties: false