Phase 28 (Step 3a): /sent returns full message detail per recipient
Tests · Go / test (push) Successful in 2m5s
Tests · Go / test (pull_request) Successful in 2m10s
Tests · Integration / integration (pull_request) Successful in 1m54s
Tests · UI / test (pull_request) Successful in 2m53s

Phase 28's in-game mail UI threads sent messages by the recipient
race name, so the bulk `/sent` endpoint now returns the same
`UserMailMessageDetail` shape as `/inbox` — single sends contribute
one row per message, broadcasts contribute one row per addressee
and the UI collapses them by `message_id` into a stand-alone item.

- `Store.ListSent` / `Service.ListSent` switched from `[]Message`
  to `[]InboxEntry`. SQL grows an INNER JOIN with
  `diplomail_recipients`.
- Handler emits `userMailMessageDetailWire` items; the deprecated
  `userMailSentSummaryWire` is removed.
- `openapi.yaml`: `UserMailSentList.items` now reference
  `UserMailMessageDetail`; the standalone `UserMailSentSummary`
  schema is dropped.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ilia Denisov
2026-05-15 22:27:39 +02:00
parent fed282f2d2
commit 57d2286f5e
4 changed files with 50 additions and 85 deletions
+7 -4
View File
@@ -316,10 +316,13 @@ func (s *Service) allowedKinds(ctx context.Context, gameID, userID uuid.UUID) (m
return map[string]bool{KindAdmin: true}, nil return map[string]bool{KindAdmin: true}, nil
} }
// ListSent returns personal messages authored by senderUserID in // ListSent returns the sender-side view of personal messages
// gameID, newest first. Admin/system rows have no `sender_user_id` // authored by senderUserID in gameID, newest first. Each entry pairs
// and are therefore excluded; the user surface does not need them. // the message with one of its recipient rows; single sends contribute
func (s *Service) ListSent(ctx context.Context, gameID, senderUserID uuid.UUID) ([]Message, error) { // 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) return s.deps.Store.ListSent(ctx, gameID, senderUserID)
} }
+25 -12
View File
@@ -243,25 +243,38 @@ func (s *Store) ListInbox(ctx context.Context, gameID, userID uuid.UUID) ([]Inbo
return out, nil return out, nil
} }
// ListSent returns messages authored by senderUserID in gameID, // ListSent returns the sender-side view of personal messages
// newest first. Personal messages only — admin/system rows have // authored by senderUserID in gameID, newest first. Each
// `sender_user_id IS NULL` and are filtered out by the WHERE clause. // `InboxEntry` carries the message together with one of its
func (s *Store) ListSent(ctx context.Context, gameID, senderUserID uuid.UUID) ([]Message, error) { // 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 m := table.DiplomailMessages
stmt := postgres.SELECT(messageColumns()). r := table.DiplomailRecipients
FROM(m). cols := append(messageColumns(), recipientColumns()...)
stmt := postgres.SELECT(cols).
FROM(m.INNER_JOIN(r, r.MessageID.EQ(m.MessageID))).
WHERE( WHERE(
m.GameID.EQ(postgres.UUID(gameID)). m.GameID.EQ(postgres.UUID(gameID)).
AND(m.SenderUserID.EQ(postgres.UUID(senderUserID))), AND(m.SenderUserID.EQ(postgres.UUID(senderUserID))),
). ).
ORDER_BY(m.CreatedAt.DESC(), m.MessageID.DESC()) ORDER_BY(m.CreatedAt.DESC(), m.MessageID.DESC(), r.RecipientID.ASC())
var rows []model.DiplomailMessages var dest []struct {
if err := stmt.QueryContext(ctx, s.db, &rows); err != nil { 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) return nil, fmt.Errorf("diplomail store: list sent %s/%s: %w", gameID, senderUserID, err)
} }
out := make([]Message, 0, len(rows)) out := make([]InboxEntry, 0, len(dest))
for _, row := range rows { for _, row := range dest {
out = append(out, messageFromModel(row)) out = append(out, InboxEntry{
Message: messageFromModel(row.DiplomailMessages),
Recipient: recipientFromModel(row.Recipient),
})
} }
return out, nil return out, nil
} }
+10 -33
View File
@@ -194,9 +194,9 @@ func (h *UserMailHandlers) Sent() gin.HandlerFunc {
respondDiplomailError(c, h.logger, "user mail sent", ctx, err) respondDiplomailError(c, h.logger, "user mail sent", ctx, err)
return return
} }
out := userMailSentListWire{Items: make([]userMailSentSummaryWire, 0, len(items))} out := userMailSentListWire{Items: make([]userMailMessageDetailWire, 0, len(items))}
for _, m := range items { for _, entry := range items {
out.Items = append(out.Items, mailMessageSummaryToWire(m)) out.Items = append(out.Items, mailMessageDetailToWire(entry, false))
} }
c.JSON(http.StatusOK, out) c.JSON(http.StatusOK, out)
} }
@@ -555,27 +555,18 @@ type userMailMessageDetailWire struct {
Translator *string `json:"translator,omitempty"` 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 { type userMailInboxListWire struct {
Items []userMailMessageDetailWire `json:"items"` 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 { type userMailSentListWire struct {
Items []userMailSentSummaryWire `json:"items"` Items []userMailMessageDetailWire `json:"items"`
} }
type userMailUnreadCountWire struct { type userMailUnreadCountWire struct {
@@ -643,20 +634,6 @@ func mailMessageDetailToWire(entry diplomail.InboxEntry, justCreated bool) userM
return out 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 // mailRecipientStateToWire renders the recipient row after a
// mark-read or soft-delete call. The caller only needs the per-user // mark-read or soft-delete call. The caller only needs the per-user
// state, not the full message body again. // state, not the full message body again.
+8 -36
View File
@@ -4400,41 +4400,6 @@ components:
translator: translator:
type: string type: string
description: Identifier of the translation engine that produced the cached row. 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: UserMailInboxList:
type: object type: object
additionalProperties: false additionalProperties: false
@@ -4445,6 +4410,13 @@ components:
items: items:
$ref: "#/components/schemas/UserMailMessageDetail" $ref: "#/components/schemas/UserMailMessageDetail"
UserMailSentList: 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 type: object
additionalProperties: false additionalProperties: false
required: [items] required: [items]
@@ -4452,7 +4424,7 @@ components:
items: items:
type: array type: array
items: items:
$ref: "#/components/schemas/UserMailSentSummary" $ref: "#/components/schemas/UserMailMessageDetail"
UserMailUnreadCount: UserMailUnreadCount:
type: object type: object
additionalProperties: false additionalProperties: false