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
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user