Files
galaxy-game/backend/internal/notification/catalog.go
T
Ilia Denisov 2ca47eb4df ui/phase-25: backend turn-cutoff guard + auto-pause + UI sync protocol
Backend now owns the turn-cutoff and pause guards the order tab
relies on: the scheduler flips runtime_status between
generation_in_progress and running around every engine tick, a
failed tick auto-pauses the game through OnRuntimeSnapshot, and a
new game.paused notification kind fans out alongside
game.turn.ready. The user-games handlers reject submits with
HTTP 409 turn_already_closed or game_paused depending on the
runtime state.

UI delegates auto-sync to a new OrderQueue: offline detection,
single retry on reconnect, conflict / paused classification.
OrderDraftStore surfaces conflictBanner / pausedBanner runes,
clears them on local mutation or on a game.turn.ready push via
resetForNewTurn. The order tab renders the matching banners and
the new conflict per-row badge; i18n bundles cover en + ru.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 22:00:16 +02:00

138 lines
4.6 KiB
Go

package notification
// Kind constants name every supported notification kind. The implementation // trims the README §10 catalog to the set with active producers in
// the codebase; further kinds (`game.*`, `mail.dead_lettered`) require
// an additive change here together with a producer.
const (
KindLobbyInviteReceived = "lobby.invite.received"
KindLobbyInviteRevoked = "lobby.invite.revoked"
KindLobbyApplicationSubmitted = "lobby.application.submitted"
KindLobbyApplicationApproved = "lobby.application.approved"
KindLobbyApplicationRejected = "lobby.application.rejected"
KindLobbyMembershipRemoved = "lobby.membership.removed"
KindLobbyMembershipBlocked = "lobby.membership.blocked"
KindLobbyRaceNameRegistered = "lobby.race_name.registered"
KindLobbyRaceNamePending = "lobby.race_name.pending"
KindLobbyRaceNameExpired = "lobby.race_name.expired"
KindRuntimeImagePullFailed = "runtime.image_pull_failed"
KindRuntimeContainerStartFailed = "runtime.container_start_failed"
KindRuntimeStartConfigInvalid = "runtime.start_config_invalid"
KindGameTurnReady = "game.turn.ready"
KindGamePaused = "game.paused"
)
// CatalogEntry describes the per-kind delivery policy: which channels
// fan out and whether the kind targets the platform admin recipient
// instead of per-user accounts.
type CatalogEntry struct {
// Channels lists the channels this kind fans out to, in the order
// rows are materialised in `notification_routes`. The closed set is
// {`push`, `email`}.
Channels []string
// Admin reports whether the email channel targets the configured
// admin recipient (`BACKEND_NOTIFICATION_ADMIN_EMAIL`) rather than
// per-user accounts. Admin-targeted kinds carry an empty Recipients
// slice on the producer side.
Admin bool
// MailTemplateID is the template_id passed to `mail.EnqueueTemplate`
// for email routes. The catalog uses the kind itself by convention,
// matching `mail.TemplateLoginCode`'s use of `auth.login_code`.
MailTemplateID string
}
// catalog maps each supported kind to its delivery policy. The map is
// queried by Submit and by the dispatcher worker; producers do not
// inspect it directly.
var catalog = map[string]CatalogEntry{
KindLobbyInviteReceived: {
Channels: []string{ChannelPush, ChannelEmail},
MailTemplateID: KindLobbyInviteReceived,
},
KindLobbyInviteRevoked: {
Channels: []string{ChannelPush},
},
KindLobbyApplicationSubmitted: {
Channels: []string{ChannelPush},
},
KindLobbyApplicationApproved: {
Channels: []string{ChannelPush, ChannelEmail},
MailTemplateID: KindLobbyApplicationApproved,
},
KindLobbyApplicationRejected: {
Channels: []string{ChannelPush, ChannelEmail},
MailTemplateID: KindLobbyApplicationRejected,
},
KindLobbyMembershipRemoved: {
Channels: []string{ChannelPush, ChannelEmail},
MailTemplateID: KindLobbyMembershipRemoved,
},
KindLobbyMembershipBlocked: {
Channels: []string{ChannelPush, ChannelEmail},
MailTemplateID: KindLobbyMembershipBlocked,
},
KindLobbyRaceNameRegistered: {
Channels: []string{ChannelPush},
},
KindLobbyRaceNamePending: {
Channels: []string{ChannelPush, ChannelEmail},
MailTemplateID: KindLobbyRaceNamePending,
},
KindLobbyRaceNameExpired: {
Channels: []string{ChannelPush},
},
KindRuntimeImagePullFailed: {
Channels: []string{ChannelEmail},
Admin: true,
MailTemplateID: KindRuntimeImagePullFailed,
},
KindRuntimeContainerStartFailed: {
Channels: []string{ChannelEmail},
Admin: true,
MailTemplateID: KindRuntimeContainerStartFailed,
},
KindRuntimeStartConfigInvalid: {
Channels: []string{ChannelEmail},
Admin: true,
MailTemplateID: KindRuntimeStartConfigInvalid,
},
KindGameTurnReady: {
Channels: []string{ChannelPush},
},
KindGamePaused: {
Channels: []string{ChannelPush},
},
}
// LookupCatalog returns the per-kind policy and a boolean reporting
// whether the kind exists. Callers (Submit, Worker) branch on the
// boolean rather than receiving a sentinel error.
func LookupCatalog(kind string) (CatalogEntry, bool) {
entry, ok := catalog[kind]
return entry, ok
}
// SupportedKinds returns the closed kind set in deterministic order.
// The function exists to back tests and the migration CHECK constraint
// audit; it is not on the hot path.
func SupportedKinds() []string {
return []string{
KindLobbyInviteReceived,
KindLobbyInviteRevoked,
KindLobbyApplicationSubmitted,
KindLobbyApplicationApproved,
KindLobbyApplicationRejected,
KindLobbyMembershipRemoved,
KindLobbyMembershipBlocked,
KindLobbyRaceNameRegistered,
KindLobbyRaceNamePending,
KindLobbyRaceNameExpired,
KindRuntimeImagePullFailed,
KindRuntimeContainerStartFailed,
KindRuntimeStartConfigInvalid,
KindGameTurnReady,
KindGamePaused,
}
}