2ca47eb4df
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>
138 lines
4.6 KiB
Go
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,
|
|
}
|
|
}
|