feat: backend service
This commit is contained in:
@@ -80,6 +80,13 @@ type EntitlementSnapshot struct {
|
||||
|
||||
// UpdatedAt stores when the snapshot was last recomputed.
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
|
||||
// MaxRegisteredRaceNames mirrors the per-tier quota carried in the
|
||||
// backend HTTP response (`backend.EntitlementSnapshot`). Gateway
|
||||
// re-validates the response shape with strict-unknown-field
|
||||
// decoding, so the field must be present here even when the
|
||||
// FlatBuffers schema does not yet carry it.
|
||||
MaxRegisteredRaceNames int32 `json:"max_registered_race_names"`
|
||||
}
|
||||
|
||||
// ActiveSanction stores one transport-ready active sanction returned in the
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
module galaxy/notificationintent
|
||||
|
||||
go 1.26.1
|
||||
|
||||
require (
|
||||
github.com/alicebob/miniredis/v2 v2.37.0
|
||||
github.com/redis/go-redis/v9 v9.18.0
|
||||
github.com/stretchr/testify v1.11.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/kr/pretty v0.3.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||
github.com/yuin/gopher-lua v1.1.1 // indirect
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
golang.org/x/sys v0.43.0 // indirect
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
@@ -1,31 +0,0 @@
|
||||
github.com/alicebob/miniredis/v2 v2.37.0 h1:RheObYW32G1aiJIj81XVt78ZHJpHonHLHW7OLIshq68=
|
||||
github.com/alicebob/miniredis/v2 v2.37.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs=
|
||||
github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
|
||||
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
|
||||
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
|
||||
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
|
||||
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -1,958 +0,0 @@
|
||||
// Package notificationintent defines the shared producer-facing contract for
|
||||
// publishing normalized notification intents into Notification Service.
|
||||
package notificationintent
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
fieldNotificationType = "notification_type"
|
||||
fieldProducer = "producer"
|
||||
fieldAudienceKind = "audience_kind"
|
||||
fieldRecipientUserIDs = "recipient_user_ids_json"
|
||||
fieldIdempotencyKey = "idempotency_key"
|
||||
fieldOccurredAtMS = "occurred_at_ms"
|
||||
fieldRequestID = "request_id"
|
||||
fieldTraceID = "trace_id"
|
||||
fieldPayloadJSON = "payload_json"
|
||||
|
||||
// DefaultIntentsStream stores the frozen Redis Stream name consumed by
|
||||
// Notification Service.
|
||||
DefaultIntentsStream = "notification:intents"
|
||||
)
|
||||
|
||||
var (
|
||||
requiredFieldNames = map[string]struct{}{
|
||||
fieldNotificationType: {},
|
||||
fieldProducer: {},
|
||||
fieldAudienceKind: {},
|
||||
fieldIdempotencyKey: {},
|
||||
fieldOccurredAtMS: {},
|
||||
fieldPayloadJSON: {},
|
||||
}
|
||||
optionalFieldNames = map[string]struct{}{
|
||||
fieldRecipientUserIDs: {},
|
||||
fieldRequestID: {},
|
||||
fieldTraceID: {},
|
||||
}
|
||||
)
|
||||
|
||||
// NotificationType identifies one supported normalized notification type.
|
||||
type NotificationType string
|
||||
|
||||
const (
|
||||
// NotificationTypeGeoReviewRecommended identifies the
|
||||
// `geo.review_recommended` notification.
|
||||
NotificationTypeGeoReviewRecommended NotificationType = "geo.review_recommended"
|
||||
|
||||
// NotificationTypeGameTurnReady identifies the `game.turn.ready`
|
||||
// notification.
|
||||
NotificationTypeGameTurnReady NotificationType = "game.turn.ready"
|
||||
|
||||
// NotificationTypeGameFinished identifies the `game.finished`
|
||||
// notification.
|
||||
NotificationTypeGameFinished NotificationType = "game.finished"
|
||||
|
||||
// NotificationTypeGameGenerationFailed identifies the
|
||||
// `game.generation_failed` notification.
|
||||
NotificationTypeGameGenerationFailed NotificationType = "game.generation_failed"
|
||||
|
||||
// NotificationTypeLobbyRuntimePausedAfterStart identifies the
|
||||
// `lobby.runtime_paused_after_start` notification.
|
||||
NotificationTypeLobbyRuntimePausedAfterStart NotificationType = "lobby.runtime_paused_after_start"
|
||||
|
||||
// NotificationTypeLobbyApplicationSubmitted identifies the
|
||||
// `lobby.application.submitted` notification.
|
||||
NotificationTypeLobbyApplicationSubmitted NotificationType = "lobby.application.submitted"
|
||||
|
||||
// NotificationTypeLobbyMembershipApproved identifies the
|
||||
// `lobby.membership.approved` notification.
|
||||
NotificationTypeLobbyMembershipApproved NotificationType = "lobby.membership.approved"
|
||||
|
||||
// NotificationTypeLobbyMembershipRejected identifies the
|
||||
// `lobby.membership.rejected` notification.
|
||||
NotificationTypeLobbyMembershipRejected NotificationType = "lobby.membership.rejected"
|
||||
|
||||
// NotificationTypeLobbyMembershipBlocked identifies the
|
||||
// `lobby.membership.blocked` notification published by Game Lobby
|
||||
// to the private-game owner when an active membership is blocked
|
||||
// by the user-lifecycle cascade reacting to a `permanent_block` or
|
||||
// `DeleteUser` event.
|
||||
NotificationTypeLobbyMembershipBlocked NotificationType = "lobby.membership.blocked"
|
||||
|
||||
// NotificationTypeLobbyInviteCreated identifies the
|
||||
// `lobby.invite.created` notification.
|
||||
NotificationTypeLobbyInviteCreated NotificationType = "lobby.invite.created"
|
||||
|
||||
// NotificationTypeLobbyInviteRedeemed identifies the
|
||||
// `lobby.invite.redeemed` notification.
|
||||
NotificationTypeLobbyInviteRedeemed NotificationType = "lobby.invite.redeemed"
|
||||
|
||||
// NotificationTypeLobbyInviteExpired identifies the
|
||||
// `lobby.invite.expired` notification.
|
||||
NotificationTypeLobbyInviteExpired NotificationType = "lobby.invite.expired"
|
||||
|
||||
// NotificationTypeLobbyRaceNameRegistrationEligible identifies the
|
||||
// `lobby.race_name.registration_eligible` notification published by
|
||||
// Game Lobby when capability evaluation at game finish promotes a
|
||||
// reservation to `pending_registration`.
|
||||
NotificationTypeLobbyRaceNameRegistrationEligible NotificationType = "lobby.race_name.registration_eligible"
|
||||
|
||||
// NotificationTypeLobbyRaceNameRegistered identifies the
|
||||
// `lobby.race_name.registered` notification published by Game Lobby
|
||||
// when a user converts a `pending_registration` into a permanent
|
||||
// registered race name.
|
||||
NotificationTypeLobbyRaceNameRegistered NotificationType = "lobby.race_name.registered"
|
||||
|
||||
// NotificationTypeLobbyRaceNameRegistrationDenied identifies the
|
||||
// `lobby.race_name.registration_denied` notification published by
|
||||
// Game Lobby when capability evaluation at game finish releases a
|
||||
// reservation because the member did not meet the capability rule.
|
||||
NotificationTypeLobbyRaceNameRegistrationDenied NotificationType = "lobby.race_name.registration_denied"
|
||||
|
||||
// NotificationTypeRuntimeImagePullFailed identifies the
|
||||
// `runtime.image_pull_failed` administrator notification published by
|
||||
// Runtime Manager when the engine image cannot be pulled during a
|
||||
// start operation.
|
||||
NotificationTypeRuntimeImagePullFailed NotificationType = "runtime.image_pull_failed"
|
||||
|
||||
// NotificationTypeRuntimeContainerStartFailed identifies the
|
||||
// `runtime.container_start_failed` administrator notification published
|
||||
// by Runtime Manager when `docker create` or `docker start` returns an
|
||||
// error during a start operation.
|
||||
NotificationTypeRuntimeContainerStartFailed NotificationType = "runtime.container_start_failed"
|
||||
|
||||
// NotificationTypeRuntimeStartConfigInvalid identifies the
|
||||
// `runtime.start_config_invalid` administrator notification published by
|
||||
// Runtime Manager when start configuration validation fails (invalid
|
||||
// `image_ref`, missing Docker network, unwritable state directory).
|
||||
NotificationTypeRuntimeStartConfigInvalid NotificationType = "runtime.start_config_invalid"
|
||||
)
|
||||
|
||||
// String returns the wire value for notificationType.
|
||||
func (notificationType NotificationType) String() string {
|
||||
return string(notificationType)
|
||||
}
|
||||
|
||||
// IsKnown reports whether notificationType belongs to the frozen catalog.
|
||||
func (notificationType NotificationType) IsKnown() bool {
|
||||
switch notificationType {
|
||||
case NotificationTypeGeoReviewRecommended,
|
||||
NotificationTypeGameTurnReady,
|
||||
NotificationTypeGameFinished,
|
||||
NotificationTypeGameGenerationFailed,
|
||||
NotificationTypeLobbyRuntimePausedAfterStart,
|
||||
NotificationTypeLobbyApplicationSubmitted,
|
||||
NotificationTypeLobbyMembershipApproved,
|
||||
NotificationTypeLobbyMembershipRejected,
|
||||
NotificationTypeLobbyMembershipBlocked,
|
||||
NotificationTypeLobbyInviteCreated,
|
||||
NotificationTypeLobbyInviteRedeemed,
|
||||
NotificationTypeLobbyInviteExpired,
|
||||
NotificationTypeLobbyRaceNameRegistrationEligible,
|
||||
NotificationTypeLobbyRaceNameRegistered,
|
||||
NotificationTypeLobbyRaceNameRegistrationDenied,
|
||||
NotificationTypeRuntimeImagePullFailed,
|
||||
NotificationTypeRuntimeContainerStartFailed,
|
||||
NotificationTypeRuntimeStartConfigInvalid:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ExpectedProducer returns the frozen producer for notificationType.
|
||||
func (notificationType NotificationType) ExpectedProducer() Producer {
|
||||
switch notificationType {
|
||||
case NotificationTypeGeoReviewRecommended:
|
||||
return ProducerGeoProfile
|
||||
case NotificationTypeGameTurnReady,
|
||||
NotificationTypeGameFinished,
|
||||
NotificationTypeGameGenerationFailed:
|
||||
return ProducerGameMaster
|
||||
case NotificationTypeLobbyRuntimePausedAfterStart,
|
||||
NotificationTypeLobbyApplicationSubmitted,
|
||||
NotificationTypeLobbyMembershipApproved,
|
||||
NotificationTypeLobbyMembershipRejected,
|
||||
NotificationTypeLobbyMembershipBlocked,
|
||||
NotificationTypeLobbyInviteCreated,
|
||||
NotificationTypeLobbyInviteRedeemed,
|
||||
NotificationTypeLobbyInviteExpired,
|
||||
NotificationTypeLobbyRaceNameRegistrationEligible,
|
||||
NotificationTypeLobbyRaceNameRegistered,
|
||||
NotificationTypeLobbyRaceNameRegistrationDenied:
|
||||
return ProducerGameLobby
|
||||
case NotificationTypeRuntimeImagePullFailed,
|
||||
NotificationTypeRuntimeContainerStartFailed,
|
||||
NotificationTypeRuntimeStartConfigInvalid:
|
||||
return ProducerRuntimeManager
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// SupportsAudience reports whether notificationType supports audienceKind.
|
||||
func (notificationType NotificationType) SupportsAudience(audienceKind AudienceKind) bool {
|
||||
switch notificationType {
|
||||
case NotificationTypeGeoReviewRecommended,
|
||||
NotificationTypeGameGenerationFailed,
|
||||
NotificationTypeLobbyRuntimePausedAfterStart,
|
||||
NotificationTypeRuntimeImagePullFailed,
|
||||
NotificationTypeRuntimeContainerStartFailed,
|
||||
NotificationTypeRuntimeStartConfigInvalid:
|
||||
return audienceKind == AudienceKindAdminEmail
|
||||
case NotificationTypeLobbyApplicationSubmitted:
|
||||
return audienceKind == AudienceKindUser || audienceKind == AudienceKindAdminEmail
|
||||
default:
|
||||
return audienceKind == AudienceKindUser
|
||||
}
|
||||
}
|
||||
|
||||
// SupportsChannel reports whether notificationType uses channel for
|
||||
// audienceKind.
|
||||
func (notificationType NotificationType) SupportsChannel(audienceKind AudienceKind, channel Channel) bool {
|
||||
switch notificationType {
|
||||
case NotificationTypeGeoReviewRecommended,
|
||||
NotificationTypeGameGenerationFailed,
|
||||
NotificationTypeLobbyRuntimePausedAfterStart,
|
||||
NotificationTypeRuntimeImagePullFailed,
|
||||
NotificationTypeRuntimeContainerStartFailed,
|
||||
NotificationTypeRuntimeStartConfigInvalid:
|
||||
return audienceKind == AudienceKindAdminEmail && channel == ChannelEmail
|
||||
case NotificationTypeLobbyApplicationSubmitted:
|
||||
if audienceKind == AudienceKindAdminEmail {
|
||||
return channel == ChannelEmail
|
||||
}
|
||||
return channel == ChannelPush || channel == ChannelEmail
|
||||
case NotificationTypeLobbyInviteExpired,
|
||||
NotificationTypeLobbyRaceNameRegistrationDenied:
|
||||
return audienceKind == AudienceKindUser && channel == ChannelEmail
|
||||
default:
|
||||
return audienceKind == AudienceKindUser && (channel == ChannelPush || channel == ChannelEmail)
|
||||
}
|
||||
}
|
||||
|
||||
// Producer identifies one supported upstream producer.
|
||||
type Producer string
|
||||
|
||||
const (
|
||||
// ProducerGeoProfile identifies Geo Profile Service.
|
||||
ProducerGeoProfile Producer = "geoprofile"
|
||||
|
||||
// ProducerGameMaster identifies Game Master.
|
||||
ProducerGameMaster Producer = "game_master"
|
||||
|
||||
// ProducerGameLobby identifies Game Lobby.
|
||||
ProducerGameLobby Producer = "game_lobby"
|
||||
|
||||
// ProducerRuntimeManager identifies Runtime Manager.
|
||||
ProducerRuntimeManager Producer = "runtime_manager"
|
||||
)
|
||||
|
||||
// String returns the wire value for producer.
|
||||
func (producer Producer) String() string {
|
||||
return string(producer)
|
||||
}
|
||||
|
||||
// IsKnown reports whether producer belongs to the frozen producer set.
|
||||
func (producer Producer) IsKnown() bool {
|
||||
switch producer {
|
||||
case ProducerGeoProfile, ProducerGameMaster, ProducerGameLobby, ProducerRuntimeManager:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// AudienceKind identifies one supported target-audience kind.
|
||||
type AudienceKind string
|
||||
|
||||
const (
|
||||
// AudienceKindUser identifies user-targeted notifications.
|
||||
AudienceKindUser AudienceKind = "user"
|
||||
|
||||
// AudienceKindAdminEmail identifies administrator-email notifications.
|
||||
AudienceKindAdminEmail AudienceKind = "admin_email"
|
||||
)
|
||||
|
||||
// String returns the wire value for audienceKind.
|
||||
func (audienceKind AudienceKind) String() string {
|
||||
return string(audienceKind)
|
||||
}
|
||||
|
||||
// IsKnown reports whether audienceKind belongs to the frozen audience set.
|
||||
func (audienceKind AudienceKind) IsKnown() bool {
|
||||
switch audienceKind {
|
||||
case AudienceKindUser, AudienceKindAdminEmail:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Channel identifies one durable notification-delivery channel slot.
|
||||
type Channel string
|
||||
|
||||
const (
|
||||
// ChannelPush identifies the push-delivery channel.
|
||||
ChannelPush Channel = "push"
|
||||
|
||||
// ChannelEmail identifies the email-delivery channel.
|
||||
ChannelEmail Channel = "email"
|
||||
)
|
||||
|
||||
// String returns the wire value for channel.
|
||||
func (channel Channel) String() string {
|
||||
return string(channel)
|
||||
}
|
||||
|
||||
// IsKnown reports whether channel belongs to the frozen channel vocabulary.
|
||||
func (channel Channel) IsKnown() bool {
|
||||
switch channel {
|
||||
case ChannelPush, ChannelEmail:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Metadata stores producer-owned envelope fields shared by every notification
|
||||
// intent.
|
||||
type Metadata struct {
|
||||
// IdempotencyKey stores the producer-owned idempotency key scoped together
|
||||
// with the producer name.
|
||||
IdempotencyKey string
|
||||
|
||||
// OccurredAt stores when the producer says the underlying business event
|
||||
// happened. Constructors normalize the value to UTC millisecond precision.
|
||||
OccurredAt time.Time
|
||||
|
||||
// RequestID stores the optional producer-side request identifier.
|
||||
RequestID string
|
||||
|
||||
// TraceID stores the optional producer-side trace identifier.
|
||||
TraceID string
|
||||
}
|
||||
|
||||
// Intent stores one normalized notification intent accepted by Notification
|
||||
// Service.
|
||||
type Intent struct {
|
||||
// NotificationType stores the frozen notification vocabulary value.
|
||||
NotificationType NotificationType
|
||||
|
||||
// Producer stores the frozen producer identifier.
|
||||
Producer Producer
|
||||
|
||||
// AudienceKind stores the normalized target audience kind.
|
||||
AudienceKind AudienceKind
|
||||
|
||||
// RecipientUserIDs stores the normalized sorted unique user-recipient set
|
||||
// when AudienceKind is AudienceKindUser.
|
||||
RecipientUserIDs []string
|
||||
|
||||
// IdempotencyKey stores the producer-owned idempotency key.
|
||||
IdempotencyKey string
|
||||
|
||||
// OccurredAt stores when the producer says the underlying business event
|
||||
// happened.
|
||||
OccurredAt time.Time
|
||||
|
||||
// RequestID stores the optional producer-side request identifier.
|
||||
RequestID string
|
||||
|
||||
// TraceID stores the optional producer-side trace identifier.
|
||||
TraceID string
|
||||
|
||||
// PayloadJSON stores the canonical normalized payload JSON string used for
|
||||
// duplicate detection.
|
||||
PayloadJSON string
|
||||
}
|
||||
|
||||
// Validate reports whether intent contains a complete normalized intake
|
||||
// request.
|
||||
func (intent Intent) Validate() error {
|
||||
if !intent.NotificationType.IsKnown() {
|
||||
return fmt.Errorf("intent notification type %q is unsupported", intent.NotificationType)
|
||||
}
|
||||
if !intent.Producer.IsKnown() {
|
||||
return fmt.Errorf("intent producer %q is unsupported", intent.Producer)
|
||||
}
|
||||
if expected := intent.NotificationType.ExpectedProducer(); intent.Producer != expected {
|
||||
return fmt.Errorf(
|
||||
"intent producer %q does not match notification type %q",
|
||||
intent.Producer,
|
||||
intent.NotificationType,
|
||||
)
|
||||
}
|
||||
if !intent.AudienceKind.IsKnown() {
|
||||
return fmt.Errorf("intent audience kind %q is unsupported", intent.AudienceKind)
|
||||
}
|
||||
if !intent.NotificationType.SupportsAudience(intent.AudienceKind) {
|
||||
return fmt.Errorf(
|
||||
"intent notification type %q does not support audience kind %q",
|
||||
intent.NotificationType,
|
||||
intent.AudienceKind,
|
||||
)
|
||||
}
|
||||
if strings.TrimSpace(intent.IdempotencyKey) == "" {
|
||||
return errors.New("intent idempotency key must not be empty")
|
||||
}
|
||||
if err := validateTimestamp("intent occurred at", intent.OccurredAt); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(intent.PayloadJSON) == "" {
|
||||
return errors.New("intent payload json must not be empty")
|
||||
}
|
||||
|
||||
switch intent.AudienceKind {
|
||||
case AudienceKindUser:
|
||||
if len(intent.RecipientUserIDs) == 0 {
|
||||
return errors.New("intent recipient user ids must not be empty for audience kind user")
|
||||
}
|
||||
for index, userID := range intent.RecipientUserIDs {
|
||||
if userID == "" {
|
||||
return fmt.Errorf("intent recipient user ids[%d] must not be empty", index)
|
||||
}
|
||||
if index > 0 && intent.RecipientUserIDs[index-1] >= userID {
|
||||
return errors.New("intent recipient user ids must be sorted strictly ascending")
|
||||
}
|
||||
}
|
||||
case AudienceKindAdminEmail:
|
||||
if len(intent.RecipientUserIDs) > 0 {
|
||||
return errors.New("intent recipient user ids must be empty for audience kind admin_email")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Values returns Redis Stream field values for intent. It validates and
|
||||
// normalizes the recipient set, event timestamp, and payload before building
|
||||
// the field map.
|
||||
func (intent Intent) Values() (map[string]any, error) {
|
||||
normalized, err := normalizeIntent(intent)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
values := map[string]any{
|
||||
fieldNotificationType: normalized.NotificationType.String(),
|
||||
fieldProducer: normalized.Producer.String(),
|
||||
fieldAudienceKind: normalized.AudienceKind.String(),
|
||||
fieldIdempotencyKey: normalized.IdempotencyKey,
|
||||
fieldOccurredAtMS: strconv.FormatInt(normalized.OccurredAt.UnixMilli(), 10),
|
||||
fieldPayloadJSON: normalized.PayloadJSON,
|
||||
}
|
||||
if normalized.AudienceKind == AudienceKindUser {
|
||||
recipientUserIDs, err := json.Marshal(normalized.RecipientUserIDs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal recipient_user_ids_json: %w", err)
|
||||
}
|
||||
values[fieldRecipientUserIDs] = string(recipientUserIDs)
|
||||
}
|
||||
if normalized.RequestID != "" {
|
||||
values[fieldRequestID] = normalized.RequestID
|
||||
}
|
||||
if normalized.TraceID != "" {
|
||||
values[fieldTraceID] = normalized.TraceID
|
||||
}
|
||||
|
||||
return values, nil
|
||||
}
|
||||
|
||||
// DecodeIntent validates one raw Redis Stream entry and returns the normalized
|
||||
// notification intent frozen by the producer contract.
|
||||
func DecodeIntent(fields map[string]any) (Intent, error) {
|
||||
if fields == nil {
|
||||
return Intent{}, errors.New("intent fields must not be nil")
|
||||
}
|
||||
|
||||
if err := validateFieldSet(fields); err != nil {
|
||||
return Intent{}, err
|
||||
}
|
||||
|
||||
notificationTypeValue, err := requiredString(fields, fieldNotificationType)
|
||||
if err != nil {
|
||||
return Intent{}, err
|
||||
}
|
||||
producerValue, err := requiredString(fields, fieldProducer)
|
||||
if err != nil {
|
||||
return Intent{}, err
|
||||
}
|
||||
audienceKindValue, err := requiredString(fields, fieldAudienceKind)
|
||||
if err != nil {
|
||||
return Intent{}, err
|
||||
}
|
||||
idempotencyKeyValue, err := requiredString(fields, fieldIdempotencyKey)
|
||||
if err != nil {
|
||||
return Intent{}, err
|
||||
}
|
||||
occurredAtValue, err := requiredString(fields, fieldOccurredAtMS)
|
||||
if err != nil {
|
||||
return Intent{}, err
|
||||
}
|
||||
payloadJSONValue, err := requiredString(fields, fieldPayloadJSON)
|
||||
if err != nil {
|
||||
return Intent{}, err
|
||||
}
|
||||
|
||||
intent := Intent{
|
||||
NotificationType: NotificationType(notificationTypeValue),
|
||||
Producer: Producer(producerValue),
|
||||
AudienceKind: AudienceKind(audienceKindValue),
|
||||
IdempotencyKey: idempotencyKeyValue,
|
||||
}
|
||||
|
||||
if requestIDValue, ok, err := optionalString(fields, fieldRequestID); err != nil {
|
||||
return Intent{}, err
|
||||
} else if ok {
|
||||
intent.RequestID = requestIDValue
|
||||
}
|
||||
if traceIDValue, ok, err := optionalString(fields, fieldTraceID); err != nil {
|
||||
return Intent{}, err
|
||||
} else if ok {
|
||||
intent.TraceID = traceIDValue
|
||||
}
|
||||
|
||||
occurredAt, err := parseUnixMilliseconds(occurredAtValue)
|
||||
if err != nil {
|
||||
return Intent{}, err
|
||||
}
|
||||
intent.OccurredAt = occurredAt
|
||||
|
||||
if !intent.NotificationType.IsKnown() {
|
||||
return Intent{}, fmt.Errorf("stream field %q value %q is unsupported", fieldNotificationType, notificationTypeValue)
|
||||
}
|
||||
if !intent.Producer.IsKnown() {
|
||||
return Intent{}, fmt.Errorf("stream field %q value %q is unsupported", fieldProducer, producerValue)
|
||||
}
|
||||
if !intent.AudienceKind.IsKnown() {
|
||||
return Intent{}, fmt.Errorf("stream field %q value %q is unsupported", fieldAudienceKind, audienceKindValue)
|
||||
}
|
||||
if intent.NotificationType.ExpectedProducer() != intent.Producer {
|
||||
return Intent{}, fmt.Errorf(
|
||||
"stream field %q value %q does not match notification type %q",
|
||||
fieldProducer,
|
||||
producerValue,
|
||||
intent.NotificationType,
|
||||
)
|
||||
}
|
||||
if !intent.NotificationType.SupportsAudience(intent.AudienceKind) {
|
||||
return Intent{}, fmt.Errorf(
|
||||
"stream field %q value %q is unsupported for notification type %q",
|
||||
fieldAudienceKind,
|
||||
audienceKindValue,
|
||||
intent.NotificationType,
|
||||
)
|
||||
}
|
||||
|
||||
switch intent.AudienceKind {
|
||||
case AudienceKindUser:
|
||||
recipientUserIDsValue, err := requiredString(fields, fieldRecipientUserIDs)
|
||||
if err != nil {
|
||||
return Intent{}, err
|
||||
}
|
||||
recipientUserIDs, err := normalizeRecipientUserIDs(recipientUserIDsValue)
|
||||
if err != nil {
|
||||
return Intent{}, err
|
||||
}
|
||||
intent.RecipientUserIDs = recipientUserIDs
|
||||
case AudienceKindAdminEmail:
|
||||
if _, found := fields[fieldRecipientUserIDs]; found {
|
||||
return Intent{}, fmt.Errorf("stream field %q must not be present for audience kind %q", fieldRecipientUserIDs, intent.AudienceKind)
|
||||
}
|
||||
}
|
||||
|
||||
canonicalPayloadJSON, err := validateAndNormalizePayload(intent.NotificationType, payloadJSONValue)
|
||||
if err != nil {
|
||||
return Intent{}, err
|
||||
}
|
||||
intent.PayloadJSON = canonicalPayloadJSON
|
||||
|
||||
if err := intent.Validate(); err != nil {
|
||||
return Intent{}, err
|
||||
}
|
||||
|
||||
return intent, nil
|
||||
}
|
||||
|
||||
func newIntent(
|
||||
notificationType NotificationType,
|
||||
producer Producer,
|
||||
audienceKind AudienceKind,
|
||||
recipientUserIDs []string,
|
||||
metadata Metadata,
|
||||
payload any,
|
||||
) (Intent, error) {
|
||||
payloadJSON, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return Intent{}, fmt.Errorf("marshal payload_json: %w", err)
|
||||
}
|
||||
|
||||
return normalizeIntent(Intent{
|
||||
NotificationType: notificationType,
|
||||
Producer: producer,
|
||||
AudienceKind: audienceKind,
|
||||
RecipientUserIDs: append([]string(nil), recipientUserIDs...),
|
||||
IdempotencyKey: metadata.IdempotencyKey,
|
||||
OccurredAt: normalizeTimestamp(metadata.OccurredAt),
|
||||
RequestID: metadata.RequestID,
|
||||
TraceID: metadata.TraceID,
|
||||
PayloadJSON: string(payloadJSON),
|
||||
})
|
||||
}
|
||||
|
||||
func normalizeIntent(intent Intent) (Intent, error) {
|
||||
normalized := intent
|
||||
normalized.OccurredAt = normalizeTimestamp(intent.OccurredAt)
|
||||
|
||||
switch normalized.AudienceKind {
|
||||
case AudienceKindUser:
|
||||
recipientUserIDs, err := normalizeRecipientUserIDValues(normalized.RecipientUserIDs)
|
||||
if err != nil {
|
||||
return Intent{}, err
|
||||
}
|
||||
normalized.RecipientUserIDs = recipientUserIDs
|
||||
case AudienceKindAdminEmail:
|
||||
if len(normalized.RecipientUserIDs) > 0 {
|
||||
return Intent{}, errors.New("intent recipient user ids must be empty for audience kind admin_email")
|
||||
}
|
||||
default:
|
||||
if len(normalized.RecipientUserIDs) > 0 {
|
||||
recipientUserIDs, err := normalizeRecipientUserIDValues(normalized.RecipientUserIDs)
|
||||
if err != nil {
|
||||
return Intent{}, err
|
||||
}
|
||||
normalized.RecipientUserIDs = recipientUserIDs
|
||||
}
|
||||
}
|
||||
|
||||
canonicalPayloadJSON, err := validateAndNormalizePayload(normalized.NotificationType, normalized.PayloadJSON)
|
||||
if err != nil {
|
||||
return Intent{}, err
|
||||
}
|
||||
normalized.PayloadJSON = canonicalPayloadJSON
|
||||
|
||||
if err := normalized.Validate(); err != nil {
|
||||
return Intent{}, err
|
||||
}
|
||||
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func normalizeTimestamp(value time.Time) time.Time {
|
||||
if value.IsZero() {
|
||||
return value
|
||||
}
|
||||
|
||||
return value.UTC().Truncate(time.Millisecond)
|
||||
}
|
||||
|
||||
func validateFieldSet(fields map[string]any) error {
|
||||
missing := make([]string, 0, len(requiredFieldNames))
|
||||
for name := range requiredFieldNames {
|
||||
if _, ok := fields[name]; !ok {
|
||||
missing = append(missing, name)
|
||||
}
|
||||
}
|
||||
sort.Strings(missing)
|
||||
if len(missing) > 0 {
|
||||
return fmt.Errorf("intent is missing required fields: %s", strings.Join(missing, ", "))
|
||||
}
|
||||
|
||||
unexpected := make([]string, 0)
|
||||
for name := range fields {
|
||||
if _, ok := requiredFieldNames[name]; ok {
|
||||
continue
|
||||
}
|
||||
if _, ok := optionalFieldNames[name]; ok {
|
||||
continue
|
||||
}
|
||||
unexpected = append(unexpected, name)
|
||||
}
|
||||
sort.Strings(unexpected)
|
||||
if len(unexpected) > 0 {
|
||||
return fmt.Errorf("intent contains unsupported fields: %s", strings.Join(unexpected, ", "))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func requiredString(fields map[string]any, name string) (string, error) {
|
||||
value, ok := fields[name]
|
||||
if !ok {
|
||||
return "", fmt.Errorf("stream field %q is required", name)
|
||||
}
|
||||
|
||||
result, ok := rawString(value)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("stream field %q must be a string", name)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func optionalString(fields map[string]any, name string) (string, bool, error) {
|
||||
value, ok := fields[name]
|
||||
if !ok {
|
||||
return "", false, nil
|
||||
}
|
||||
|
||||
result, ok := rawString(value)
|
||||
if !ok {
|
||||
return "", false, fmt.Errorf("stream field %q must be a string", name)
|
||||
}
|
||||
|
||||
return result, true, nil
|
||||
}
|
||||
|
||||
func rawString(value any) (string, bool) {
|
||||
switch typed := value.(type) {
|
||||
case string:
|
||||
return typed, true
|
||||
case []byte:
|
||||
return string(typed), true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func parseUnixMilliseconds(raw string) (time.Time, error) {
|
||||
if raw == "" {
|
||||
return time.Time{}, fmt.Errorf("stream field %q must be a base-10 Unix milliseconds string", fieldOccurredAtMS)
|
||||
}
|
||||
for _, r := range raw {
|
||||
if r < '0' || r > '9' {
|
||||
return time.Time{}, fmt.Errorf("stream field %q must be a base-10 Unix milliseconds string", fieldOccurredAtMS)
|
||||
}
|
||||
}
|
||||
|
||||
value, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil {
|
||||
return time.Time{}, fmt.Errorf("stream field %q must be a base-10 Unix milliseconds string", fieldOccurredAtMS)
|
||||
}
|
||||
|
||||
return time.UnixMilli(value).UTC(), nil
|
||||
}
|
||||
|
||||
func normalizeRecipientUserIDs(raw string) ([]string, error) {
|
||||
var values []string
|
||||
if err := decodeStrictJSON("decode recipient_user_ids_json", raw, &values, false); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return normalizeRecipientUserIDValues(values)
|
||||
}
|
||||
|
||||
func normalizeRecipientUserIDValues(values []string) ([]string, error) {
|
||||
if len(values) == 0 {
|
||||
return nil, errors.New("recipient_user_ids_json must contain at least one user id")
|
||||
}
|
||||
|
||||
seen := make(map[string]struct{}, len(values))
|
||||
normalized := make([]string, 0, len(values))
|
||||
for index, value := range values {
|
||||
if value == "" {
|
||||
return nil, fmt.Errorf("recipient_user_ids_json[%d] must not be empty", index)
|
||||
}
|
||||
if _, ok := seen[value]; ok {
|
||||
return nil, fmt.Errorf("recipient_user_ids_json[%d] duplicates user id %q", index, value)
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
normalized = append(normalized, value)
|
||||
}
|
||||
|
||||
sort.Strings(normalized)
|
||||
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func validateAndNormalizePayload(notificationType NotificationType, raw string) (string, error) {
|
||||
payloadObject, err := decodeJSONObjectRaw("decode payload_json", raw)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := validatePayloadObject(notificationType, payloadObject); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
normalizedValue, err := decodeNormalizedJSONValue("decode payload_json", raw)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
normalizedPayload, err := json.Marshal(normalizedValue)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("normalize payload_json: %w", err)
|
||||
}
|
||||
|
||||
return string(normalizedPayload), nil
|
||||
}
|
||||
|
||||
func validatePayloadObject(notificationType NotificationType, payload map[string]json.RawMessage) error {
|
||||
switch notificationType {
|
||||
case NotificationTypeGeoReviewRecommended:
|
||||
return validateStringFields(payload, "user_id", "user_email", "observed_country", "usual_connection_country", "review_reason")
|
||||
case NotificationTypeGameTurnReady:
|
||||
if err := validateStringFields(payload, "game_id", "game_name"); err != nil {
|
||||
return err
|
||||
}
|
||||
return validatePositiveIntFields(payload, "turn_number")
|
||||
case NotificationTypeGameFinished:
|
||||
if err := validateStringFields(payload, "game_id", "game_name"); err != nil {
|
||||
return err
|
||||
}
|
||||
return validatePositiveIntFields(payload, "final_turn_number")
|
||||
case NotificationTypeGameGenerationFailed:
|
||||
return validateStringFields(payload, "game_id", "game_name", "failure_reason")
|
||||
case NotificationTypeLobbyRuntimePausedAfterStart:
|
||||
return validateStringFields(payload, "game_id", "game_name")
|
||||
case NotificationTypeLobbyApplicationSubmitted:
|
||||
return validateStringFields(payload, "game_id", "game_name", "applicant_user_id", "applicant_name")
|
||||
case NotificationTypeLobbyMembershipApproved, NotificationTypeLobbyMembershipRejected:
|
||||
return validateStringFields(payload, "game_id", "game_name")
|
||||
case NotificationTypeLobbyMembershipBlocked:
|
||||
return validateStringFields(payload, "game_id", "game_name", "membership_user_id", "membership_user_name", "reason")
|
||||
case NotificationTypeLobbyInviteCreated:
|
||||
return validateStringFields(payload, "game_id", "game_name", "inviter_user_id", "inviter_name")
|
||||
case NotificationTypeLobbyInviteRedeemed, NotificationTypeLobbyInviteExpired:
|
||||
return validateStringFields(payload, "game_id", "game_name", "invitee_user_id", "invitee_name")
|
||||
case NotificationTypeLobbyRaceNameRegistrationEligible:
|
||||
if err := validateStringFields(payload, "game_id", "game_name", "race_name"); err != nil {
|
||||
return err
|
||||
}
|
||||
return validatePositiveIntFields(payload, "eligible_until_ms")
|
||||
case NotificationTypeLobbyRaceNameRegistered:
|
||||
return validateStringFields(payload, "race_name")
|
||||
case NotificationTypeLobbyRaceNameRegistrationDenied:
|
||||
return validateStringFields(payload, "game_id", "game_name", "race_name", "reason")
|
||||
case NotificationTypeRuntimeImagePullFailed,
|
||||
NotificationTypeRuntimeContainerStartFailed,
|
||||
NotificationTypeRuntimeStartConfigInvalid:
|
||||
if err := validateStringFields(payload, "game_id", "image_ref", "error_code", "error_message"); err != nil {
|
||||
return err
|
||||
}
|
||||
return validatePositiveIntFields(payload, "attempted_at_ms")
|
||||
default:
|
||||
return fmt.Errorf("payload_json notification type %q is unsupported", notificationType)
|
||||
}
|
||||
}
|
||||
|
||||
func validateStringFields(payload map[string]json.RawMessage, names ...string) error {
|
||||
for _, name := range names {
|
||||
var value string
|
||||
if err := decodeRequiredJSONField(payload, name, &value); err != nil {
|
||||
return err
|
||||
}
|
||||
if value == "" {
|
||||
return fmt.Errorf("payload_json.%s must not be empty", name)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func validatePositiveIntFields(payload map[string]json.RawMessage, names ...string) error {
|
||||
for _, name := range names {
|
||||
var value int64
|
||||
if err := decodeRequiredJSONField(payload, name, &value); err != nil {
|
||||
return err
|
||||
}
|
||||
if value < 1 {
|
||||
return fmt.Errorf("payload_json.%s must be at least 1", name)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func decodeRequiredJSONField(payload map[string]json.RawMessage, name string, target any) error {
|
||||
raw, ok := payload[name]
|
||||
if !ok {
|
||||
return fmt.Errorf("payload_json.%s is required", name)
|
||||
}
|
||||
|
||||
if err := decodeStrictJSON("decode payload_json."+name, string(raw), target, false); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func decodeJSONObjectRaw(label string, raw string) (map[string]json.RawMessage, error) {
|
||||
var value map[string]json.RawMessage
|
||||
if err := decodeStrictJSON(label, raw, &value, false); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if value == nil {
|
||||
return nil, errors.New("payload_json must be a JSON object")
|
||||
}
|
||||
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func decodeNormalizedJSONValue(label string, raw string) (any, error) {
|
||||
decoder := json.NewDecoder(bytes.NewBufferString(raw))
|
||||
decoder.UseNumber()
|
||||
|
||||
var value any
|
||||
if err := decoder.Decode(&value); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", label, err)
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); err != io.EOF {
|
||||
if err == nil {
|
||||
return nil, fmt.Errorf("%s: unexpected trailing JSON input", label)
|
||||
}
|
||||
return nil, fmt.Errorf("%s: %w", label, err)
|
||||
}
|
||||
|
||||
object, ok := value.(map[string]any)
|
||||
if !ok || object == nil {
|
||||
return nil, errors.New("payload_json must be a JSON object")
|
||||
}
|
||||
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func decodeStrictJSON(label string, raw string, target any, useNumber bool) error {
|
||||
decoder := json.NewDecoder(bytes.NewBufferString(raw))
|
||||
if useNumber {
|
||||
decoder.UseNumber()
|
||||
}
|
||||
|
||||
if err := decoder.Decode(target); err != nil {
|
||||
return fmt.Errorf("%s: %w", label, err)
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); err != io.EOF {
|
||||
if err == nil {
|
||||
return fmt.Errorf("%s: unexpected trailing JSON input", label)
|
||||
}
|
||||
|
||||
return fmt.Errorf("%s: %w", label, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateTimestamp(name string, value time.Time) error {
|
||||
if value.IsZero() {
|
||||
return fmt.Errorf("%s must not be zero", name)
|
||||
}
|
||||
if !value.Equal(value.UTC()) {
|
||||
return fmt.Errorf("%s must be UTC", name)
|
||||
}
|
||||
if !value.Equal(value.Truncate(time.Millisecond)) {
|
||||
return fmt.Errorf("%s must use millisecond precision", name)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,428 +0,0 @@
|
||||
package notificationintent
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestConstructorsBuildExpectedIntentValues(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
metadata := Metadata{
|
||||
IdempotencyKey: "idempotency-1",
|
||||
OccurredAt: time.UnixMilli(1775121700000).Add(123 * time.Nanosecond),
|
||||
RequestID: "request-1",
|
||||
TraceID: "trace-1",
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
build func() (Intent, error)
|
||||
notificationType NotificationType
|
||||
producer Producer
|
||||
audienceKind AudienceKind
|
||||
recipientUserIDs []string
|
||||
payloadJSON string
|
||||
}{
|
||||
{
|
||||
name: "geo review recommended",
|
||||
build: func() (Intent, error) {
|
||||
return NewGeoReviewRecommendedIntent(metadata, GeoReviewRecommendedPayload{
|
||||
UserID: "user-1",
|
||||
UserEmail: "pilot@example.com",
|
||||
ObservedCountry: "DE",
|
||||
UsualConnectionCountry: "PL",
|
||||
ReviewReason: "country_mismatch",
|
||||
})
|
||||
},
|
||||
notificationType: NotificationTypeGeoReviewRecommended,
|
||||
producer: ProducerGeoProfile,
|
||||
audienceKind: AudienceKindAdminEmail,
|
||||
payloadJSON: `{"user_id":"user-1","user_email":"pilot@example.com","observed_country":"DE","usual_connection_country":"PL","review_reason":"country_mismatch"}`,
|
||||
},
|
||||
{
|
||||
name: "game turn ready",
|
||||
build: func() (Intent, error) {
|
||||
return NewGameTurnReadyIntent(metadata, []string{"user-2", "user-1"}, GameTurnReadyPayload{
|
||||
GameID: "game-1",
|
||||
GameName: "Nebula Clash",
|
||||
TurnNumber: 54,
|
||||
})
|
||||
},
|
||||
notificationType: NotificationTypeGameTurnReady,
|
||||
producer: ProducerGameMaster,
|
||||
audienceKind: AudienceKindUser,
|
||||
recipientUserIDs: []string{"user-1", "user-2"},
|
||||
payloadJSON: `{"game_id":"game-1","game_name":"Nebula Clash","turn_number":54}`,
|
||||
},
|
||||
{
|
||||
name: "game finished",
|
||||
build: func() (Intent, error) {
|
||||
return NewGameFinishedIntent(metadata, []string{"user-1", "user-2"}, GameFinishedPayload{
|
||||
GameID: "game-1",
|
||||
GameName: "Nebula Clash",
|
||||
FinalTurnNumber: 55,
|
||||
})
|
||||
},
|
||||
notificationType: NotificationTypeGameFinished,
|
||||
producer: ProducerGameMaster,
|
||||
audienceKind: AudienceKindUser,
|
||||
recipientUserIDs: []string{"user-1", "user-2"},
|
||||
payloadJSON: `{"game_id":"game-1","game_name":"Nebula Clash","final_turn_number":55}`,
|
||||
},
|
||||
{
|
||||
name: "game generation failed",
|
||||
build: func() (Intent, error) {
|
||||
return NewGameGenerationFailedIntent(metadata, GameGenerationFailedPayload{
|
||||
GameID: "game-1",
|
||||
GameName: "Nebula Clash",
|
||||
FailureReason: "engine_timeout",
|
||||
})
|
||||
},
|
||||
notificationType: NotificationTypeGameGenerationFailed,
|
||||
producer: ProducerGameMaster,
|
||||
audienceKind: AudienceKindAdminEmail,
|
||||
payloadJSON: `{"game_id":"game-1","game_name":"Nebula Clash","failure_reason":"engine_timeout"}`,
|
||||
},
|
||||
{
|
||||
name: "lobby runtime paused after start",
|
||||
build: func() (Intent, error) {
|
||||
return NewLobbyRuntimePausedAfterStartIntent(metadata, LobbyRuntimePausedAfterStartPayload{
|
||||
GameID: "game-1",
|
||||
GameName: "Nebula Clash",
|
||||
})
|
||||
},
|
||||
notificationType: NotificationTypeLobbyRuntimePausedAfterStart,
|
||||
producer: ProducerGameLobby,
|
||||
audienceKind: AudienceKindAdminEmail,
|
||||
payloadJSON: `{"game_id":"game-1","game_name":"Nebula Clash"}`,
|
||||
},
|
||||
{
|
||||
name: "private lobby application submitted",
|
||||
build: func() (Intent, error) {
|
||||
return NewPrivateLobbyApplicationSubmittedIntent(metadata, "owner-1", LobbyApplicationSubmittedPayload{
|
||||
GameID: "game-1",
|
||||
GameName: "Nebula Clash",
|
||||
ApplicantUserID: "user-2",
|
||||
ApplicantName: "Nova Pilot",
|
||||
})
|
||||
},
|
||||
notificationType: NotificationTypeLobbyApplicationSubmitted,
|
||||
producer: ProducerGameLobby,
|
||||
audienceKind: AudienceKindUser,
|
||||
recipientUserIDs: []string{"owner-1"},
|
||||
payloadJSON: `{"game_id":"game-1","game_name":"Nebula Clash","applicant_user_id":"user-2","applicant_name":"Nova Pilot"}`,
|
||||
},
|
||||
{
|
||||
name: "public lobby application submitted",
|
||||
build: func() (Intent, error) {
|
||||
return NewPublicLobbyApplicationSubmittedIntent(metadata, LobbyApplicationSubmittedPayload{
|
||||
GameID: "game-1",
|
||||
GameName: "Nebula Clash",
|
||||
ApplicantUserID: "user-2",
|
||||
ApplicantName: "Nova Pilot",
|
||||
})
|
||||
},
|
||||
notificationType: NotificationTypeLobbyApplicationSubmitted,
|
||||
producer: ProducerGameLobby,
|
||||
audienceKind: AudienceKindAdminEmail,
|
||||
payloadJSON: `{"game_id":"game-1","game_name":"Nebula Clash","applicant_user_id":"user-2","applicant_name":"Nova Pilot"}`,
|
||||
},
|
||||
{
|
||||
name: "lobby membership approved",
|
||||
build: func() (Intent, error) {
|
||||
return NewLobbyMembershipApprovedIntent(metadata, "applicant-1", LobbyMembershipApprovedPayload{
|
||||
GameID: "game-1",
|
||||
GameName: "Nebula Clash",
|
||||
})
|
||||
},
|
||||
notificationType: NotificationTypeLobbyMembershipApproved,
|
||||
producer: ProducerGameLobby,
|
||||
audienceKind: AudienceKindUser,
|
||||
recipientUserIDs: []string{"applicant-1"},
|
||||
payloadJSON: `{"game_id":"game-1","game_name":"Nebula Clash"}`,
|
||||
},
|
||||
{
|
||||
name: "lobby membership rejected",
|
||||
build: func() (Intent, error) {
|
||||
return NewLobbyMembershipRejectedIntent(metadata, "applicant-1", LobbyMembershipRejectedPayload{
|
||||
GameID: "game-1",
|
||||
GameName: "Nebula Clash",
|
||||
})
|
||||
},
|
||||
notificationType: NotificationTypeLobbyMembershipRejected,
|
||||
producer: ProducerGameLobby,
|
||||
audienceKind: AudienceKindUser,
|
||||
recipientUserIDs: []string{"applicant-1"},
|
||||
payloadJSON: `{"game_id":"game-1","game_name":"Nebula Clash"}`,
|
||||
},
|
||||
{
|
||||
name: "lobby membership blocked",
|
||||
build: func() (Intent, error) {
|
||||
return NewLobbyMembershipBlockedIntent(metadata, "owner-1", LobbyMembershipBlockedPayload{
|
||||
GameID: "game-1",
|
||||
GameName: "Nebula Clash",
|
||||
MembershipUserID: "user-2",
|
||||
MembershipUserName: "player-aabbccdd",
|
||||
Reason: "permanent_blocked",
|
||||
})
|
||||
},
|
||||
notificationType: NotificationTypeLobbyMembershipBlocked,
|
||||
producer: ProducerGameLobby,
|
||||
audienceKind: AudienceKindUser,
|
||||
recipientUserIDs: []string{"owner-1"},
|
||||
payloadJSON: `{"game_id":"game-1","game_name":"Nebula Clash","membership_user_id":"user-2","membership_user_name":"player-aabbccdd","reason":"permanent_blocked"}`,
|
||||
},
|
||||
{
|
||||
name: "lobby invite created",
|
||||
build: func() (Intent, error) {
|
||||
return NewLobbyInviteCreatedIntent(metadata, "invited-1", LobbyInviteCreatedPayload{
|
||||
GameID: "game-1",
|
||||
GameName: "Nebula Clash",
|
||||
InviterUserID: "owner-1",
|
||||
InviterName: "Owner Pilot",
|
||||
})
|
||||
},
|
||||
notificationType: NotificationTypeLobbyInviteCreated,
|
||||
producer: ProducerGameLobby,
|
||||
audienceKind: AudienceKindUser,
|
||||
recipientUserIDs: []string{"invited-1"},
|
||||
payloadJSON: `{"game_id":"game-1","game_name":"Nebula Clash","inviter_user_id":"owner-1","inviter_name":"Owner Pilot"}`,
|
||||
},
|
||||
{
|
||||
name: "lobby invite redeemed",
|
||||
build: func() (Intent, error) {
|
||||
return NewLobbyInviteRedeemedIntent(metadata, "owner-1", LobbyInviteRedeemedPayload{
|
||||
GameID: "game-1",
|
||||
GameName: "Nebula Clash",
|
||||
InviteeUserID: "invitee-1",
|
||||
InviteeName: "Nova Pilot",
|
||||
})
|
||||
},
|
||||
notificationType: NotificationTypeLobbyInviteRedeemed,
|
||||
producer: ProducerGameLobby,
|
||||
audienceKind: AudienceKindUser,
|
||||
recipientUserIDs: []string{"owner-1"},
|
||||
payloadJSON: `{"game_id":"game-1","game_name":"Nebula Clash","invitee_user_id":"invitee-1","invitee_name":"Nova Pilot"}`,
|
||||
},
|
||||
{
|
||||
name: "lobby invite expired",
|
||||
build: func() (Intent, error) {
|
||||
return NewLobbyInviteExpiredIntent(metadata, "owner-1", LobbyInviteExpiredPayload{
|
||||
GameID: "game-1",
|
||||
GameName: "Nebula Clash",
|
||||
InviteeUserID: "invitee-1",
|
||||
InviteeName: "Nova Pilot",
|
||||
})
|
||||
},
|
||||
notificationType: NotificationTypeLobbyInviteExpired,
|
||||
producer: ProducerGameLobby,
|
||||
audienceKind: AudienceKindUser,
|
||||
recipientUserIDs: []string{"owner-1"},
|
||||
payloadJSON: `{"game_id":"game-1","game_name":"Nebula Clash","invitee_user_id":"invitee-1","invitee_name":"Nova Pilot"}`,
|
||||
},
|
||||
{
|
||||
name: "lobby race name registration eligible",
|
||||
build: func() (Intent, error) {
|
||||
return NewLobbyRaceNameRegistrationEligibleIntent(metadata, "user-7", LobbyRaceNameRegistrationEligiblePayload{
|
||||
GameID: "game-1",
|
||||
GameName: "Nebula Clash",
|
||||
RaceName: "Skylancer",
|
||||
EligibleUntilMs: 1775208100000,
|
||||
})
|
||||
},
|
||||
notificationType: NotificationTypeLobbyRaceNameRegistrationEligible,
|
||||
producer: ProducerGameLobby,
|
||||
audienceKind: AudienceKindUser,
|
||||
recipientUserIDs: []string{"user-7"},
|
||||
payloadJSON: `{"game_id":"game-1","game_name":"Nebula Clash","race_name":"Skylancer","eligible_until_ms":1775208100000}`,
|
||||
},
|
||||
{
|
||||
name: "lobby race name registered",
|
||||
build: func() (Intent, error) {
|
||||
return NewLobbyRaceNameRegisteredIntent(metadata, "user-8", LobbyRaceNameRegisteredPayload{
|
||||
RaceName: "Skylancer",
|
||||
})
|
||||
},
|
||||
notificationType: NotificationTypeLobbyRaceNameRegistered,
|
||||
producer: ProducerGameLobby,
|
||||
audienceKind: AudienceKindUser,
|
||||
recipientUserIDs: []string{"user-8"},
|
||||
payloadJSON: `{"race_name":"Skylancer"}`,
|
||||
},
|
||||
{
|
||||
name: "lobby race name registration denied",
|
||||
build: func() (Intent, error) {
|
||||
return NewLobbyRaceNameRegistrationDeniedIntent(metadata, "user-9", LobbyRaceNameRegistrationDeniedPayload{
|
||||
GameID: "game-1",
|
||||
GameName: "Nebula Clash",
|
||||
RaceName: "Skylancer",
|
||||
Reason: "capability_not_met",
|
||||
})
|
||||
},
|
||||
notificationType: NotificationTypeLobbyRaceNameRegistrationDenied,
|
||||
producer: ProducerGameLobby,
|
||||
audienceKind: AudienceKindUser,
|
||||
recipientUserIDs: []string{"user-9"},
|
||||
payloadJSON: `{"game_id":"game-1","game_name":"Nebula Clash","race_name":"Skylancer","reason":"capability_not_met"}`,
|
||||
},
|
||||
{
|
||||
name: "runtime image pull failed",
|
||||
build: func() (Intent, error) {
|
||||
return NewRuntimeImagePullFailedIntent(metadata, RuntimeImagePullFailedPayload{
|
||||
GameID: "game-1",
|
||||
ImageRef: "galaxy/game:1.4.7",
|
||||
ErrorCode: "image_pull_failed",
|
||||
ErrorMessage: "manifest unknown",
|
||||
AttemptedAtMs: 1775121700000,
|
||||
})
|
||||
},
|
||||
notificationType: NotificationTypeRuntimeImagePullFailed,
|
||||
producer: ProducerRuntimeManager,
|
||||
audienceKind: AudienceKindAdminEmail,
|
||||
payloadJSON: `{"game_id":"game-1","image_ref":"galaxy/game:1.4.7","error_code":"image_pull_failed","error_message":"manifest unknown","attempted_at_ms":1775121700000}`,
|
||||
},
|
||||
{
|
||||
name: "runtime container start failed",
|
||||
build: func() (Intent, error) {
|
||||
return NewRuntimeContainerStartFailedIntent(metadata, RuntimeContainerStartFailedPayload{
|
||||
GameID: "game-1",
|
||||
ImageRef: "galaxy/game:1.4.7",
|
||||
ErrorCode: "container_start_failed",
|
||||
ErrorMessage: "OCI runtime create failed",
|
||||
AttemptedAtMs: 1775121700001,
|
||||
})
|
||||
},
|
||||
notificationType: NotificationTypeRuntimeContainerStartFailed,
|
||||
producer: ProducerRuntimeManager,
|
||||
audienceKind: AudienceKindAdminEmail,
|
||||
payloadJSON: `{"game_id":"game-1","image_ref":"galaxy/game:1.4.7","error_code":"container_start_failed","error_message":"OCI runtime create failed","attempted_at_ms":1775121700001}`,
|
||||
},
|
||||
{
|
||||
name: "runtime start config invalid",
|
||||
build: func() (Intent, error) {
|
||||
return NewRuntimeStartConfigInvalidIntent(metadata, RuntimeStartConfigInvalidPayload{
|
||||
GameID: "game-1",
|
||||
ImageRef: "galaxy/game:1.4.7",
|
||||
ErrorCode: "start_config_invalid",
|
||||
ErrorMessage: "docker network galaxy-net not found",
|
||||
AttemptedAtMs: 1775121700002,
|
||||
})
|
||||
},
|
||||
notificationType: NotificationTypeRuntimeStartConfigInvalid,
|
||||
producer: ProducerRuntimeManager,
|
||||
audienceKind: AudienceKindAdminEmail,
|
||||
payloadJSON: `{"game_id":"game-1","image_ref":"galaxy/game:1.4.7","error_code":"start_config_invalid","error_message":"docker network galaxy-net not found","attempted_at_ms":1775121700002}`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
intent, err := tt.build()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tt.notificationType, intent.NotificationType)
|
||||
require.Equal(t, tt.producer, intent.Producer)
|
||||
require.Equal(t, tt.audienceKind, intent.AudienceKind)
|
||||
require.Equal(t, tt.recipientUserIDs, intent.RecipientUserIDs)
|
||||
|
||||
values, err := intent.Values()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tt.notificationType.String(), values[fieldNotificationType])
|
||||
require.Equal(t, tt.producer.String(), values[fieldProducer])
|
||||
require.Equal(t, tt.audienceKind.String(), values[fieldAudienceKind])
|
||||
require.Equal(t, metadata.IdempotencyKey, values[fieldIdempotencyKey])
|
||||
require.Equal(t, "1775121700000", values[fieldOccurredAtMS])
|
||||
require.Equal(t, metadata.RequestID, values[fieldRequestID])
|
||||
require.Equal(t, metadata.TraceID, values[fieldTraceID])
|
||||
require.JSONEq(t, tt.payloadJSON, values[fieldPayloadJSON].(string))
|
||||
|
||||
if len(tt.recipientUserIDs) == 0 {
|
||||
require.NotContains(t, values, fieldRecipientUserIDs)
|
||||
return
|
||||
}
|
||||
|
||||
var recipientUserIDs []string
|
||||
require.NoError(t, json.Unmarshal([]byte(values[fieldRecipientUserIDs].(string)), &recipientUserIDs))
|
||||
require.Equal(t, tt.recipientUserIDs, recipientUserIDs)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserRecipientConstructorsRejectDuplicates(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := NewGameTurnReadyIntent(defaultMetadata(), []string{"user-1", "user-1"}, GameTurnReadyPayload{
|
||||
GameID: "game-1",
|
||||
GameName: "Nebula Clash",
|
||||
TurnNumber: 54,
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "duplicates user id")
|
||||
}
|
||||
|
||||
func TestConstructorsRejectInvalidPayloads(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := NewGameTurnReadyIntent(defaultMetadata(), []string{"user-1"}, GameTurnReadyPayload{
|
||||
GameName: "Nebula Clash",
|
||||
TurnNumber: 54,
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "payload_json.game_id must not be empty")
|
||||
|
||||
_, err = NewGameTurnReadyIntent(defaultMetadata(), []string{"user-1"}, GameTurnReadyPayload{
|
||||
GameID: "game-1",
|
||||
GameName: "Nebula Clash",
|
||||
TurnNumber: 0,
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "payload_json.turn_number must be at least 1")
|
||||
|
||||
_, err = NewRuntimeImagePullFailedIntent(defaultMetadata(), RuntimeImagePullFailedPayload{
|
||||
GameID: "game-1",
|
||||
ImageRef: "galaxy/game:1.4.7",
|
||||
ErrorCode: "",
|
||||
ErrorMessage: "manifest unknown",
|
||||
AttemptedAtMs: 1775121700000,
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "payload_json.error_code must not be empty")
|
||||
|
||||
_, err = NewRuntimeContainerStartFailedIntent(defaultMetadata(), RuntimeContainerStartFailedPayload{
|
||||
GameID: "game-1",
|
||||
ImageRef: "galaxy/game:1.4.7",
|
||||
ErrorCode: "container_start_failed",
|
||||
ErrorMessage: "OCI runtime create failed",
|
||||
AttemptedAtMs: 0,
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "payload_json.attempted_at_ms must be at least 1")
|
||||
}
|
||||
|
||||
func TestDecodeIntentRejectsMissingRequiredTopLevelField(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := DecodeIntent(map[string]any{
|
||||
fieldNotificationType: NotificationTypeGameTurnReady.String(),
|
||||
fieldProducer: ProducerGameMaster.String(),
|
||||
fieldAudienceKind: AudienceKindUser.String(),
|
||||
fieldRecipientUserIDs: `["user-1"]`,
|
||||
fieldIdempotencyKey: "game-1:turn-54",
|
||||
fieldOccurredAtMS: "1775121700000",
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), fieldPayloadJSON)
|
||||
}
|
||||
|
||||
func defaultMetadata() Metadata {
|
||||
return Metadata{
|
||||
IdempotencyKey: "idempotency-1",
|
||||
OccurredAt: time.UnixMilli(1775121700000),
|
||||
}
|
||||
}
|
||||
@@ -1,283 +0,0 @@
|
||||
package notificationintent
|
||||
|
||||
// GeoReviewRecommendedPayload stores the normalized payload for
|
||||
// `geo.review_recommended`.
|
||||
type GeoReviewRecommendedPayload struct {
|
||||
UserID string `json:"user_id"`
|
||||
UserEmail string `json:"user_email"`
|
||||
ObservedCountry string `json:"observed_country"`
|
||||
UsualConnectionCountry string `json:"usual_connection_country"`
|
||||
ReviewReason string `json:"review_reason"`
|
||||
}
|
||||
|
||||
// GameTurnReadyPayload stores the normalized payload for `game.turn.ready`.
|
||||
type GameTurnReadyPayload struct {
|
||||
GameID string `json:"game_id"`
|
||||
GameName string `json:"game_name"`
|
||||
TurnNumber int64 `json:"turn_number"`
|
||||
}
|
||||
|
||||
// GameFinishedPayload stores the normalized payload for `game.finished`.
|
||||
type GameFinishedPayload struct {
|
||||
GameID string `json:"game_id"`
|
||||
GameName string `json:"game_name"`
|
||||
FinalTurnNumber int64 `json:"final_turn_number"`
|
||||
}
|
||||
|
||||
// GameGenerationFailedPayload stores the normalized payload for
|
||||
// `game.generation_failed`.
|
||||
type GameGenerationFailedPayload struct {
|
||||
GameID string `json:"game_id"`
|
||||
GameName string `json:"game_name"`
|
||||
FailureReason string `json:"failure_reason"`
|
||||
}
|
||||
|
||||
// LobbyRuntimePausedAfterStartPayload stores the normalized payload for
|
||||
// `lobby.runtime_paused_after_start`.
|
||||
type LobbyRuntimePausedAfterStartPayload struct {
|
||||
GameID string `json:"game_id"`
|
||||
GameName string `json:"game_name"`
|
||||
}
|
||||
|
||||
// LobbyApplicationSubmittedPayload stores the normalized payload for
|
||||
// `lobby.application.submitted`.
|
||||
type LobbyApplicationSubmittedPayload struct {
|
||||
GameID string `json:"game_id"`
|
||||
GameName string `json:"game_name"`
|
||||
ApplicantUserID string `json:"applicant_user_id"`
|
||||
ApplicantName string `json:"applicant_name"`
|
||||
}
|
||||
|
||||
// LobbyMembershipApprovedPayload stores the normalized payload for
|
||||
// `lobby.membership.approved`.
|
||||
type LobbyMembershipApprovedPayload struct {
|
||||
GameID string `json:"game_id"`
|
||||
GameName string `json:"game_name"`
|
||||
}
|
||||
|
||||
// LobbyMembershipRejectedPayload stores the normalized payload for
|
||||
// `lobby.membership.rejected`.
|
||||
type LobbyMembershipRejectedPayload struct {
|
||||
GameID string `json:"game_id"`
|
||||
GameName string `json:"game_name"`
|
||||
}
|
||||
|
||||
// LobbyMembershipBlockedPayload stores the normalized payload for
|
||||
// `lobby.membership.blocked` published by the user-lifecycle cascade
|
||||
// when an active membership is blocked because the underlying user was
|
||||
// permanently blocked or deleted.
|
||||
type LobbyMembershipBlockedPayload struct {
|
||||
GameID string `json:"game_id"`
|
||||
GameName string `json:"game_name"`
|
||||
MembershipUserID string `json:"membership_user_id"`
|
||||
MembershipUserName string `json:"membership_user_name"`
|
||||
// Reason captures the upstream lifecycle event that triggered the
|
||||
// cascade. Frozen vocabulary: `permanent_blocked`, `deleted`.
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
// LobbyInviteCreatedPayload stores the normalized payload for
|
||||
// `lobby.invite.created`.
|
||||
type LobbyInviteCreatedPayload struct {
|
||||
GameID string `json:"game_id"`
|
||||
GameName string `json:"game_name"`
|
||||
InviterUserID string `json:"inviter_user_id"`
|
||||
InviterName string `json:"inviter_name"`
|
||||
}
|
||||
|
||||
// LobbyInviteRedeemedPayload stores the normalized payload for
|
||||
// `lobby.invite.redeemed`.
|
||||
type LobbyInviteRedeemedPayload struct {
|
||||
GameID string `json:"game_id"`
|
||||
GameName string `json:"game_name"`
|
||||
InviteeUserID string `json:"invitee_user_id"`
|
||||
InviteeName string `json:"invitee_name"`
|
||||
}
|
||||
|
||||
// LobbyInviteExpiredPayload stores the normalized payload for
|
||||
// `lobby.invite.expired`.
|
||||
type LobbyInviteExpiredPayload struct {
|
||||
GameID string `json:"game_id"`
|
||||
GameName string `json:"game_name"`
|
||||
InviteeUserID string `json:"invitee_user_id"`
|
||||
InviteeName string `json:"invitee_name"`
|
||||
}
|
||||
|
||||
// LobbyRaceNameRegistrationEligiblePayload stores the normalized payload
|
||||
// for `lobby.race_name.registration_eligible`.
|
||||
type LobbyRaceNameRegistrationEligiblePayload struct {
|
||||
GameID string `json:"game_id"`
|
||||
GameName string `json:"game_name"`
|
||||
RaceName string `json:"race_name"`
|
||||
EligibleUntilMs int64 `json:"eligible_until_ms"`
|
||||
}
|
||||
|
||||
// LobbyRaceNameRegisteredPayload stores the normalized payload for
|
||||
// `lobby.race_name.registered`.
|
||||
type LobbyRaceNameRegisteredPayload struct {
|
||||
RaceName string `json:"race_name"`
|
||||
}
|
||||
|
||||
// LobbyRaceNameRegistrationDeniedPayload stores the normalized payload for
|
||||
// `lobby.race_name.registration_denied`.
|
||||
type LobbyRaceNameRegistrationDeniedPayload struct {
|
||||
GameID string `json:"game_id"`
|
||||
GameName string `json:"game_name"`
|
||||
RaceName string `json:"race_name"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
// RuntimeImagePullFailedPayload stores the normalized payload for
|
||||
// `runtime.image_pull_failed`. AttemptedAtMs carries Unix milliseconds in
|
||||
// UTC of the failed pull attempt.
|
||||
type RuntimeImagePullFailedPayload struct {
|
||||
GameID string `json:"game_id"`
|
||||
ImageRef string `json:"image_ref"`
|
||||
ErrorCode string `json:"error_code"`
|
||||
ErrorMessage string `json:"error_message"`
|
||||
AttemptedAtMs int64 `json:"attempted_at_ms"`
|
||||
}
|
||||
|
||||
// RuntimeContainerStartFailedPayload stores the normalized payload for
|
||||
// `runtime.container_start_failed`. AttemptedAtMs carries Unix milliseconds
|
||||
// in UTC of the failed start attempt.
|
||||
type RuntimeContainerStartFailedPayload struct {
|
||||
GameID string `json:"game_id"`
|
||||
ImageRef string `json:"image_ref"`
|
||||
ErrorCode string `json:"error_code"`
|
||||
ErrorMessage string `json:"error_message"`
|
||||
AttemptedAtMs int64 `json:"attempted_at_ms"`
|
||||
}
|
||||
|
||||
// RuntimeStartConfigInvalidPayload stores the normalized payload for
|
||||
// `runtime.start_config_invalid`. AttemptedAtMs carries Unix milliseconds
|
||||
// in UTC of the rejected start attempt.
|
||||
type RuntimeStartConfigInvalidPayload struct {
|
||||
GameID string `json:"game_id"`
|
||||
ImageRef string `json:"image_ref"`
|
||||
ErrorCode string `json:"error_code"`
|
||||
ErrorMessage string `json:"error_message"`
|
||||
AttemptedAtMs int64 `json:"attempted_at_ms"`
|
||||
}
|
||||
|
||||
// NewGeoReviewRecommendedIntent builds the admin-email intent published by Geo
|
||||
// Profile Service when a user becomes review-worthy.
|
||||
func NewGeoReviewRecommendedIntent(metadata Metadata, payload GeoReviewRecommendedPayload) (Intent, error) {
|
||||
return newIntent(NotificationTypeGeoReviewRecommended, ProducerGeoProfile, AudienceKindAdminEmail, nil, metadata, payload)
|
||||
}
|
||||
|
||||
// NewGameTurnReadyIntent builds the user-targeted intent published by Game
|
||||
// Master when a new turn is ready for active accepted participants.
|
||||
func NewGameTurnReadyIntent(metadata Metadata, recipientUserIDs []string, payload GameTurnReadyPayload) (Intent, error) {
|
||||
return newIntent(NotificationTypeGameTurnReady, ProducerGameMaster, AudienceKindUser, recipientUserIDs, metadata, payload)
|
||||
}
|
||||
|
||||
// NewGameFinishedIntent builds the user-targeted intent published by Game
|
||||
// Master when a running game finishes.
|
||||
func NewGameFinishedIntent(metadata Metadata, recipientUserIDs []string, payload GameFinishedPayload) (Intent, error) {
|
||||
return newIntent(NotificationTypeGameFinished, ProducerGameMaster, AudienceKindUser, recipientUserIDs, metadata, payload)
|
||||
}
|
||||
|
||||
// NewGameGenerationFailedIntent builds the admin-email intent published by
|
||||
// Game Master when turn generation fails.
|
||||
func NewGameGenerationFailedIntent(metadata Metadata, payload GameGenerationFailedPayload) (Intent, error) {
|
||||
return newIntent(NotificationTypeGameGenerationFailed, ProducerGameMaster, AudienceKindAdminEmail, nil, metadata, payload)
|
||||
}
|
||||
|
||||
// NewLobbyRuntimePausedAfterStartIntent builds the admin-email intent
|
||||
// published by Game Lobby when a game is paused after runtime startup.
|
||||
func NewLobbyRuntimePausedAfterStartIntent(metadata Metadata, payload LobbyRuntimePausedAfterStartPayload) (Intent, error) {
|
||||
return newIntent(NotificationTypeLobbyRuntimePausedAfterStart, ProducerGameLobby, AudienceKindAdminEmail, nil, metadata, payload)
|
||||
}
|
||||
|
||||
// NewPrivateLobbyApplicationSubmittedIntent builds the private-game owner
|
||||
// intent published by Game Lobby when an application is submitted.
|
||||
func NewPrivateLobbyApplicationSubmittedIntent(metadata Metadata, ownerUserID string, payload LobbyApplicationSubmittedPayload) (Intent, error) {
|
||||
return newIntent(NotificationTypeLobbyApplicationSubmitted, ProducerGameLobby, AudienceKindUser, []string{ownerUserID}, metadata, payload)
|
||||
}
|
||||
|
||||
// NewPublicLobbyApplicationSubmittedIntent builds the public-game admin-email
|
||||
// intent published by Game Lobby when an application is submitted.
|
||||
func NewPublicLobbyApplicationSubmittedIntent(metadata Metadata, payload LobbyApplicationSubmittedPayload) (Intent, error) {
|
||||
return newIntent(NotificationTypeLobbyApplicationSubmitted, ProducerGameLobby, AudienceKindAdminEmail, nil, metadata, payload)
|
||||
}
|
||||
|
||||
// NewLobbyMembershipApprovedIntent builds the applicant-user intent published
|
||||
// by Game Lobby when membership is approved.
|
||||
func NewLobbyMembershipApprovedIntent(metadata Metadata, applicantUserID string, payload LobbyMembershipApprovedPayload) (Intent, error) {
|
||||
return newIntent(NotificationTypeLobbyMembershipApproved, ProducerGameLobby, AudienceKindUser, []string{applicantUserID}, metadata, payload)
|
||||
}
|
||||
|
||||
// NewLobbyMembershipRejectedIntent builds the applicant-user intent published
|
||||
// by Game Lobby when membership is rejected.
|
||||
func NewLobbyMembershipRejectedIntent(metadata Metadata, applicantUserID string, payload LobbyMembershipRejectedPayload) (Intent, error) {
|
||||
return newIntent(NotificationTypeLobbyMembershipRejected, ProducerGameLobby, AudienceKindUser, []string{applicantUserID}, metadata, payload)
|
||||
}
|
||||
|
||||
// NewLobbyMembershipBlockedIntent builds the private-game owner intent
|
||||
// published by Game Lobby when an active membership is blocked by the
|
||||
// user-lifecycle cascade. ownerUserID is the recipient (private-game
|
||||
// owner whose roster lost the affected member).
|
||||
func NewLobbyMembershipBlockedIntent(metadata Metadata, ownerUserID string, payload LobbyMembershipBlockedPayload) (Intent, error) {
|
||||
return newIntent(NotificationTypeLobbyMembershipBlocked, ProducerGameLobby, AudienceKindUser, []string{ownerUserID}, metadata, payload)
|
||||
}
|
||||
|
||||
// NewLobbyInviteCreatedIntent builds the invited-user intent published by Game
|
||||
// Lobby when a private-game invite is created.
|
||||
func NewLobbyInviteCreatedIntent(metadata Metadata, invitedUserID string, payload LobbyInviteCreatedPayload) (Intent, error) {
|
||||
return newIntent(NotificationTypeLobbyInviteCreated, ProducerGameLobby, AudienceKindUser, []string{invitedUserID}, metadata, payload)
|
||||
}
|
||||
|
||||
// NewLobbyInviteRedeemedIntent builds the private-game owner intent published
|
||||
// by Game Lobby when an invite is redeemed.
|
||||
func NewLobbyInviteRedeemedIntent(metadata Metadata, ownerUserID string, payload LobbyInviteRedeemedPayload) (Intent, error) {
|
||||
return newIntent(NotificationTypeLobbyInviteRedeemed, ProducerGameLobby, AudienceKindUser, []string{ownerUserID}, metadata, payload)
|
||||
}
|
||||
|
||||
// NewLobbyInviteExpiredIntent builds the private-game owner intent published
|
||||
// by Game Lobby when an invite expires.
|
||||
func NewLobbyInviteExpiredIntent(metadata Metadata, ownerUserID string, payload LobbyInviteExpiredPayload) (Intent, error) {
|
||||
return newIntent(NotificationTypeLobbyInviteExpired, ProducerGameLobby, AudienceKindUser, []string{ownerUserID}, metadata, payload)
|
||||
}
|
||||
|
||||
// NewLobbyRaceNameRegistrationEligibleIntent builds the capable-member intent
|
||||
// published by Game Lobby at game finish when a reservation is promoted to
|
||||
// `pending_registration`.
|
||||
func NewLobbyRaceNameRegistrationEligibleIntent(metadata Metadata, recipientUserID string, payload LobbyRaceNameRegistrationEligiblePayload) (Intent, error) {
|
||||
return newIntent(NotificationTypeLobbyRaceNameRegistrationEligible, ProducerGameLobby, AudienceKindUser, []string{recipientUserID}, metadata, payload)
|
||||
}
|
||||
|
||||
// NewLobbyRaceNameRegisteredIntent builds the registering-user intent
|
||||
// published by Game Lobby on successful `lobby.race_name.register` commit.
|
||||
func NewLobbyRaceNameRegisteredIntent(metadata Metadata, recipientUserID string, payload LobbyRaceNameRegisteredPayload) (Intent, error) {
|
||||
return newIntent(NotificationTypeLobbyRaceNameRegistered, ProducerGameLobby, AudienceKindUser, []string{recipientUserID}, metadata, payload)
|
||||
}
|
||||
|
||||
// NewLobbyRaceNameRegistrationDeniedIntent builds the incapable-member intent
|
||||
// published by Game Lobby at game finish when a reservation is released
|
||||
// without a pending-registration window.
|
||||
func NewLobbyRaceNameRegistrationDeniedIntent(metadata Metadata, recipientUserID string, payload LobbyRaceNameRegistrationDeniedPayload) (Intent, error) {
|
||||
return newIntent(NotificationTypeLobbyRaceNameRegistrationDenied, ProducerGameLobby, AudienceKindUser, []string{recipientUserID}, metadata, payload)
|
||||
}
|
||||
|
||||
// NewRuntimeImagePullFailedIntent builds the administrator-email intent
|
||||
// published by Runtime Manager when a start operation fails because the
|
||||
// engine image cannot be pulled.
|
||||
func NewRuntimeImagePullFailedIntent(metadata Metadata, payload RuntimeImagePullFailedPayload) (Intent, error) {
|
||||
return newIntent(NotificationTypeRuntimeImagePullFailed, ProducerRuntimeManager, AudienceKindAdminEmail, nil, metadata, payload)
|
||||
}
|
||||
|
||||
// NewRuntimeContainerStartFailedIntent builds the administrator-email
|
||||
// intent published by Runtime Manager when a start operation fails because
|
||||
// `docker create` or `docker start` returns an error.
|
||||
func NewRuntimeContainerStartFailedIntent(metadata Metadata, payload RuntimeContainerStartFailedPayload) (Intent, error) {
|
||||
return newIntent(NotificationTypeRuntimeContainerStartFailed, ProducerRuntimeManager, AudienceKindAdminEmail, nil, metadata, payload)
|
||||
}
|
||||
|
||||
// NewRuntimeStartConfigInvalidIntent builds the administrator-email intent
|
||||
// published by Runtime Manager when start configuration validation rejects
|
||||
// the request (invalid image reference, missing Docker network, unwritable
|
||||
// state directory).
|
||||
func NewRuntimeStartConfigInvalidIntent(metadata Metadata, payload RuntimeStartConfigInvalidPayload) (Intent, error) {
|
||||
return newIntent(NotificationTypeRuntimeStartConfigInvalid, ProducerRuntimeManager, AudienceKindAdminEmail, nil, metadata, payload)
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
package notificationintent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// RedisClient stores the minimal Redis command surface required by Publisher.
|
||||
type RedisClient interface {
|
||||
// XAdd appends one entry to a Redis Stream.
|
||||
XAdd(context.Context, *redis.XAddArgs) *redis.StringCmd
|
||||
}
|
||||
|
||||
// PublisherConfig stores the dependencies and stream name used by Publisher.
|
||||
type PublisherConfig struct {
|
||||
// Client appends normalized intents to Redis Streams.
|
||||
Client RedisClient
|
||||
|
||||
// Stream stores the Redis Stream name. When empty, DefaultIntentsStream is
|
||||
// used.
|
||||
Stream string
|
||||
}
|
||||
|
||||
// Publisher publishes normalized notification intents into the Notification
|
||||
// Service ingress stream.
|
||||
type Publisher struct {
|
||||
client RedisClient
|
||||
stream string
|
||||
}
|
||||
|
||||
// NewPublisher constructs a Publisher from cfg.
|
||||
func NewPublisher(cfg PublisherConfig) (*Publisher, error) {
|
||||
if cfg.Client == nil {
|
||||
return nil, errors.New("new notification intent publisher: nil redis client")
|
||||
}
|
||||
if cfg.Stream == "" {
|
||||
cfg.Stream = DefaultIntentsStream
|
||||
}
|
||||
|
||||
return &Publisher{
|
||||
client: cfg.Client,
|
||||
stream: cfg.Stream,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Publish validates intent and appends it with plain XADD. It does not trim
|
||||
// the stream and does not perform hidden retries.
|
||||
func (publisher *Publisher) Publish(ctx context.Context, intent Intent) (string, error) {
|
||||
if ctx == nil {
|
||||
return "", errors.New("publish notification intent: nil context")
|
||||
}
|
||||
if publisher == nil || publisher.client == nil {
|
||||
return "", errors.New("publish notification intent: nil publisher")
|
||||
}
|
||||
|
||||
values, err := intent.Values()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("publish notification intent: %w", err)
|
||||
}
|
||||
|
||||
entryID, err := publisher.client.XAdd(ctx, &redis.XAddArgs{
|
||||
Stream: publisher.stream,
|
||||
Values: values,
|
||||
}).Result()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("publish notification intent: xadd: %w", err)
|
||||
}
|
||||
|
||||
return entryID, nil
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
package notificationintent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestPublisherPublishAppendsIntentToDefaultStream(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
redisServer := miniredis.RunT(t)
|
||||
redisClient := redis.NewClient(&redis.Options{Addr: redisServer.Addr()})
|
||||
t.Cleanup(func() {
|
||||
require.NoError(t, redisClient.Close())
|
||||
})
|
||||
|
||||
publisher, err := NewPublisher(PublisherConfig{Client: redisClient})
|
||||
require.NoError(t, err)
|
||||
|
||||
intent, err := NewGameTurnReadyIntent(defaultMetadata(), []string{"user-1"}, GameTurnReadyPayload{
|
||||
GameID: "game-1",
|
||||
GameName: "Nebula Clash",
|
||||
TurnNumber: 54,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
entryID, err := publisher.Publish(context.Background(), intent)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, entryID)
|
||||
|
||||
messages, err := redisClient.XRange(context.Background(), DefaultIntentsStream, "-", "+").Result()
|
||||
require.NoError(t, err)
|
||||
require.Len(t, messages, 1)
|
||||
require.Equal(t, entryID, messages[0].ID)
|
||||
require.Equal(t, NotificationTypeGameTurnReady.String(), messages[0].Values[fieldNotificationType])
|
||||
require.Equal(t, ProducerGameMaster.String(), messages[0].Values[fieldProducer])
|
||||
require.Equal(t, AudienceKindUser.String(), messages[0].Values[fieldAudienceKind])
|
||||
require.Equal(t, `["user-1"]`, messages[0].Values[fieldRecipientUserIDs])
|
||||
require.Equal(t, `{"game_id":"game-1","game_name":"Nebula Clash","turn_number":54}`, messages[0].Values[fieldPayloadJSON])
|
||||
}
|
||||
@@ -26,9 +26,8 @@ const (
|
||||
)
|
||||
|
||||
// Config stores the connection and pool tuning used to open a primary plus
|
||||
// zero-or-more replica `*sql.DB` instances. Stage 1 wires only the primary;
|
||||
// the replica list is preserved so future read-routing is a non-breaking
|
||||
// change.
|
||||
// zero-or-more replica `*sql.DB` instances. The replica list is preserved
|
||||
// so future read-routing is a non-breaking change.
|
||||
type Config struct {
|
||||
// PrimaryDSN stores the DSN used by the primary connection. Required.
|
||||
PrimaryDSN string
|
||||
|
||||
@@ -25,8 +25,8 @@ const (
|
||||
)
|
||||
|
||||
// Config stores the connection settings for one master plus zero-or-more
|
||||
// replica Redis instances. Stage 1 wires only the master; the replica list is
|
||||
// preserved so future read-routing is a non-breaking change.
|
||||
// replica Redis instances. The replica list is preserved so future read-routing
|
||||
// is a non-breaking change.
|
||||
type Config struct {
|
||||
// MasterAddr stores the Redis network address in host:port form. Required.
|
||||
MasterAddr string
|
||||
|
||||
Reference in New Issue
Block a user