Phase 28 (Step 3a): /sent returns full message detail per recipient
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:
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user