feat: notification service

This commit is contained in:
Ilia Denisov
2026-04-22 08:49:45 +02:00
committed by GitHub
parent 5b7593e6f6
commit 32dc29359a
135 changed files with 21828 additions and 130 deletions
@@ -0,0 +1,147 @@
// Package intentstream defines the frozen Redis Stream contract used for
// Notification Service intent intake.
package intentstream
import (
"strings"
"galaxy/notification/internal/service/malformedintent"
"galaxy/notificationintent"
)
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"
defaultResolvedLocale = "en"
)
// NotificationType identifies one supported normalized notification type.
type NotificationType = notificationintent.NotificationType
const (
// NotificationTypeGeoReviewRecommended identifies the
// `geo.review_recommended` notification.
NotificationTypeGeoReviewRecommended = notificationintent.NotificationTypeGeoReviewRecommended
// NotificationTypeGameTurnReady identifies the `game.turn.ready`
// notification.
NotificationTypeGameTurnReady = notificationintent.NotificationTypeGameTurnReady
// NotificationTypeGameFinished identifies the `game.finished`
// notification.
NotificationTypeGameFinished = notificationintent.NotificationTypeGameFinished
// NotificationTypeGameGenerationFailed identifies the
// `game.generation_failed` notification.
NotificationTypeGameGenerationFailed = notificationintent.NotificationTypeGameGenerationFailed
// NotificationTypeLobbyRuntimePausedAfterStart identifies the
// `lobby.runtime_paused_after_start` notification.
NotificationTypeLobbyRuntimePausedAfterStart = notificationintent.NotificationTypeLobbyRuntimePausedAfterStart
// NotificationTypeLobbyApplicationSubmitted identifies the
// `lobby.application.submitted` notification.
NotificationTypeLobbyApplicationSubmitted = notificationintent.NotificationTypeLobbyApplicationSubmitted
// NotificationTypeLobbyMembershipApproved identifies the
// `lobby.membership.approved` notification.
NotificationTypeLobbyMembershipApproved = notificationintent.NotificationTypeLobbyMembershipApproved
// NotificationTypeLobbyMembershipRejected identifies the
// `lobby.membership.rejected` notification.
NotificationTypeLobbyMembershipRejected = notificationintent.NotificationTypeLobbyMembershipRejected
// NotificationTypeLobbyInviteCreated identifies the
// `lobby.invite.created` notification.
NotificationTypeLobbyInviteCreated = notificationintent.NotificationTypeLobbyInviteCreated
// NotificationTypeLobbyInviteRedeemed identifies the
// `lobby.invite.redeemed` notification.
NotificationTypeLobbyInviteRedeemed = notificationintent.NotificationTypeLobbyInviteRedeemed
// NotificationTypeLobbyInviteExpired identifies the
// `lobby.invite.expired` notification.
NotificationTypeLobbyInviteExpired = notificationintent.NotificationTypeLobbyInviteExpired
)
// Producer identifies one supported upstream producer.
type Producer = notificationintent.Producer
const (
// ProducerGeoProfile identifies Geo Profile Service.
ProducerGeoProfile = notificationintent.ProducerGeoProfile
// ProducerGameMaster identifies Game Master.
ProducerGameMaster = notificationintent.ProducerGameMaster
// ProducerGameLobby identifies Game Lobby.
ProducerGameLobby = notificationintent.ProducerGameLobby
)
// AudienceKind identifies one supported target-audience kind.
type AudienceKind = notificationintent.AudienceKind
const (
// AudienceKindUser identifies user-targeted notifications.
AudienceKindUser = notificationintent.AudienceKindUser
// AudienceKindAdminEmail identifies administrator-email notifications.
AudienceKindAdminEmail = notificationintent.AudienceKindAdminEmail
)
// Channel identifies one durable notification-delivery channel slot.
type Channel = notificationintent.Channel
const (
// ChannelPush identifies the push-delivery channel.
ChannelPush = notificationintent.ChannelPush
// ChannelEmail identifies the email-delivery channel.
ChannelEmail = notificationintent.ChannelEmail
)
// Intent stores one normalized notification intent accepted from the Redis
// Stream ingress contract.
type Intent = notificationintent.Intent
// DecodeIntent validates one raw Redis Stream entry and returns the normalized
// notification intent frozen by the shared producer contract.
func DecodeIntent(fields map[string]any) (Intent, error) {
return notificationintent.DecodeIntent(fields)
}
// ClassifyDecodeError maps one intake decoding or validation error to the
// stable malformed-intent failure surface.
func ClassifyDecodeError(err error) malformedintent.FailureCode {
if err == nil {
return malformedintent.FailureCodeInvalidIntent
}
message := err.Error()
switch {
case strings.Contains(message, "payload_json"),
strings.Contains(message, "turn_number"),
strings.Contains(message, "final_turn_number"),
strings.Contains(message, "failure_reason"),
strings.Contains(message, "applicant_name"),
strings.Contains(message, "inviter_name"),
strings.Contains(message, "invitee_name"),
strings.Contains(message, "review_reason"):
return malformedintent.FailureCodeInvalidPayload
default:
return malformedintent.FailureCodeInvalidIntent
}
}
// DefaultResolvedLocale returns the frozen fallback locale assigned when the
// current rollout has no supported exact user locale other than English.
func DefaultResolvedLocale() string {
return defaultResolvedLocale
}
@@ -0,0 +1,145 @@
package intentstream
import (
"testing"
"time"
"github.com/stretchr/testify/require"
)
func TestDecodeIntentNormalizesUserRecipientsAndPayload(t *testing.T) {
t.Parallel()
fields := map[string]any{
fieldNotificationType: NotificationTypeGameTurnReady.String(),
fieldProducer: ProducerGameMaster.String(),
fieldAudienceKind: AudienceKindUser.String(),
fieldRecipientUserIDs: `["user-2","user-1"]`,
fieldIdempotencyKey: "game-123:turn-54",
fieldOccurredAtMS: "1775121700000",
fieldPayloadJSON: `{"turn_number":54,"game_name":"Nebula Clash","game_id":"game-123"}`,
fieldRequestID: "request-123",
fieldTraceID: "trace-123",
}
intent, err := DecodeIntent(fields)
require.NoError(t, err)
require.Equal(t, []string{"user-1", "user-2"}, intent.RecipientUserIDs)
require.Equal(t, `{"game_id":"game-123","game_name":"Nebula Clash","turn_number":54}`, intent.PayloadJSON)
require.Equal(t, time.UnixMilli(1775121700000).UTC(), intent.OccurredAt)
}
func TestDecodeIntentCanonicalizesEquivalentPayloadJSON(t *testing.T) {
t.Parallel()
fieldsA := map[string]any{
fieldNotificationType: NotificationTypeGameFinished.String(),
fieldProducer: ProducerGameMaster.String(),
fieldAudienceKind: AudienceKindUser.String(),
fieldRecipientUserIDs: `["user-1"]`,
fieldIdempotencyKey: "game-123:finished",
fieldOccurredAtMS: "1775121700001",
fieldPayloadJSON: `{"game_id":"game-123","game_name":"Nebula Clash","final_turn_number":54}`,
}
fieldsB := map[string]any{
fieldNotificationType: NotificationTypeGameFinished.String(),
fieldProducer: ProducerGameMaster.String(),
fieldAudienceKind: AudienceKindUser.String(),
fieldRecipientUserIDs: `["user-1"]`,
fieldIdempotencyKey: "game-123:finished",
fieldOccurredAtMS: "1775121709999",
fieldPayloadJSON: `{"final_turn_number":54,"game_name":"Nebula Clash","game_id":"game-123"}`,
}
intentA, err := DecodeIntent(fieldsA)
require.NoError(t, err)
intentB, err := DecodeIntent(fieldsB)
require.NoError(t, err)
require.Equal(t, intentA.PayloadJSON, intentB.PayloadJSON)
}
func TestDecodeIntentRejectsUnsupportedTopLevelField(t *testing.T) {
t.Parallel()
fields := map[string]any{
fieldNotificationType: NotificationTypeGameTurnReady.String(),
fieldProducer: ProducerGameMaster.String(),
fieldAudienceKind: AudienceKindUser.String(),
fieldRecipientUserIDs: `["user-1"]`,
fieldIdempotencyKey: "game-123:turn-54",
fieldOccurredAtMS: "1775121700000",
fieldPayloadJSON: `{"game_id":"game-123","game_name":"Nebula Clash","turn_number":54}`,
"unexpected": "boom",
}
_, err := DecodeIntent(fields)
require.Error(t, err)
require.Contains(t, err.Error(), "unsupported fields")
require.Equal(t, malformedFailureCodeInvalidIntent(), string(ClassifyDecodeError(err)))
}
func TestDecodeIntentRejectsDuplicateRecipientUserIDs(t *testing.T) {
t.Parallel()
fields := map[string]any{
fieldNotificationType: NotificationTypeGameTurnReady.String(),
fieldProducer: ProducerGameMaster.String(),
fieldAudienceKind: AudienceKindUser.String(),
fieldRecipientUserIDs: `["user-1","user-1"]`,
fieldIdempotencyKey: "game-123:turn-54",
fieldOccurredAtMS: "1775121700000",
fieldPayloadJSON: `{"game_id":"game-123","game_name":"Nebula Clash","turn_number":54}`,
}
_, err := DecodeIntent(fields)
require.Error(t, err)
require.Contains(t, err.Error(), "duplicates user id")
require.Equal(t, malformedFailureCodeInvalidIntent(), string(ClassifyDecodeError(err)))
}
func TestDecodeIntentRejectsInvalidPayloadJSON(t *testing.T) {
t.Parallel()
fields := map[string]any{
fieldNotificationType: NotificationTypeLobbyInviteCreated.String(),
fieldProducer: ProducerGameLobby.String(),
fieldAudienceKind: AudienceKindUser.String(),
fieldRecipientUserIDs: `["user-1"]`,
fieldIdempotencyKey: "invite-created:user-1",
fieldOccurredAtMS: "1775121700000",
fieldPayloadJSON: `{"game_id":"game-123","game_name":"Nebula Clash","inviter_user_id":"user-2"}`,
}
_, err := DecodeIntent(fields)
require.Error(t, err)
require.Contains(t, err.Error(), "payload_json.inviter_name is required")
require.Equal(t, malformedFailureCodeInvalidPayload(), string(ClassifyDecodeError(err)))
}
func TestDecodeIntentRejectsAdminRecipientsField(t *testing.T) {
t.Parallel()
fields := map[string]any{
fieldNotificationType: NotificationTypeGeoReviewRecommended.String(),
fieldProducer: ProducerGeoProfile.String(),
fieldAudienceKind: AudienceKindAdminEmail.String(),
fieldRecipientUserIDs: `["user-1"]`,
fieldIdempotencyKey: "geo:user-1",
fieldOccurredAtMS: "1775121700000",
fieldPayloadJSON: `{"user_id":"user-1","user_email":"pilot@example.com","observed_country":"DE","usual_connection_country":"PL","review_reason":"country_mismatch"}`,
}
_, err := DecodeIntent(fields)
require.Error(t, err)
require.Contains(t, err.Error(), "must not be present")
require.Equal(t, malformedFailureCodeInvalidIntent(), string(ClassifyDecodeError(err)))
}
func malformedFailureCodeInvalidIntent() string {
return "invalid_intent"
}
func malformedFailureCodeInvalidPayload() string {
return "invalid_payload"
}