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,221 @@
// Package publishpush encodes user-facing notification routes into Gateway
// client-event payloads.
package publishpush
import (
"encoding/json"
"errors"
"fmt"
"strings"
"galaxy/notification/internal/api/intentstream"
"galaxy/notification/internal/service/acceptintent"
"galaxy/transcoder"
)
// Event stores one Gateway-compatible client event produced from a
// user-targeted notification route.
type Event struct {
// UserID stores the authenticated user fan-out target.
UserID string
// EventType stores the stable client-facing event type.
EventType string
// EventID stores the stable route-level event identifier.
EventID string
// PayloadBytes stores the encoded FlatBuffers payload bytes.
PayloadBytes []byte
// RequestID stores the optional correlation identifier.
RequestID string
// TraceID stores the optional tracing correlation identifier.
TraceID string
}
// Encoder maps one supported notification_type to the corresponding checked-in
// FlatBuffers payload encoder.
type Encoder struct{}
// Encode converts one accepted notification record plus its push route into a
// Gateway-compatible client event.
func (Encoder) Encode(notification acceptintent.NotificationRecord, route acceptintent.NotificationRoute) (Event, error) {
if err := notification.Validate(); err != nil {
return Event{}, fmt.Errorf("encode push event: %w", err)
}
if err := route.Validate(); err != nil {
return Event{}, fmt.Errorf("encode push event: %w", err)
}
if route.Channel != intentstream.ChannelPush {
return Event{}, fmt.Errorf("encode push event: route channel %q is unsupported", route.Channel)
}
userID, err := userIDFromRecipientRef(route.RecipientRef)
if err != nil {
return Event{}, fmt.Errorf("encode push event: %w", err)
}
payloadBytes, err := encodePayload(notification.NotificationType, notification.PayloadJSON)
if err != nil {
return Event{}, fmt.Errorf("encode push event: %w", err)
}
return Event{
UserID: userID,
EventType: string(notification.NotificationType),
EventID: notification.NotificationID + "/" + route.RouteID,
PayloadBytes: payloadBytes,
RequestID: notification.RequestID,
TraceID: notification.TraceID,
}, nil
}
func encodePayload(notificationType intentstream.NotificationType, payloadJSON string) ([]byte, error) {
switch notificationType {
case intentstream.NotificationTypeGameTurnReady:
var payload struct {
GameID string `json:"game_id"`
TurnNumber int64 `json:"turn_number"`
}
if err := decodePayload(payloadJSON, &payload); err != nil {
return nil, err
}
if payload.GameID == "" {
return nil, errors.New("payload_encoding_failed: game_id is empty")
}
if payload.TurnNumber < 1 {
return nil, errors.New("payload_encoding_failed: turn_number must be at least 1")
}
return wrapPayloadEncoding(transcoder.GameTurnReadyEventToPayload(&transcoder.GameTurnReadyEvent{
GameID: payload.GameID,
TurnNumber: payload.TurnNumber,
}))
case intentstream.NotificationTypeGameFinished:
var payload struct {
GameID string `json:"game_id"`
FinalTurnNumber int64 `json:"final_turn_number"`
}
if err := decodePayload(payloadJSON, &payload); err != nil {
return nil, err
}
if payload.GameID == "" {
return nil, errors.New("payload_encoding_failed: game_id is empty")
}
if payload.FinalTurnNumber < 1 {
return nil, errors.New("payload_encoding_failed: final_turn_number must be at least 1")
}
return wrapPayloadEncoding(transcoder.GameFinishedEventToPayload(&transcoder.GameFinishedEvent{
GameID: payload.GameID,
FinalTurnNumber: payload.FinalTurnNumber,
}))
case intentstream.NotificationTypeLobbyApplicationSubmitted:
var payload struct {
GameID string `json:"game_id"`
ApplicantUserID string `json:"applicant_user_id"`
}
if err := decodePayload(payloadJSON, &payload); err != nil {
return nil, err
}
if payload.GameID == "" {
return nil, errors.New("payload_encoding_failed: game_id is empty")
}
if payload.ApplicantUserID == "" {
return nil, errors.New("payload_encoding_failed: applicant_user_id is empty")
}
return wrapPayloadEncoding(transcoder.LobbyApplicationSubmittedEventToPayload(&transcoder.LobbyApplicationSubmittedEvent{
GameID: payload.GameID,
ApplicantUserID: payload.ApplicantUserID,
}))
case intentstream.NotificationTypeLobbyMembershipApproved:
var payload struct {
GameID string `json:"game_id"`
}
if err := decodePayload(payloadJSON, &payload); err != nil {
return nil, err
}
if payload.GameID == "" {
return nil, errors.New("payload_encoding_failed: game_id is empty")
}
return wrapPayloadEncoding(transcoder.LobbyMembershipApprovedEventToPayload(&transcoder.LobbyMembershipApprovedEvent{
GameID: payload.GameID,
}))
case intentstream.NotificationTypeLobbyMembershipRejected:
var payload struct {
GameID string `json:"game_id"`
}
if err := decodePayload(payloadJSON, &payload); err != nil {
return nil, err
}
if payload.GameID == "" {
return nil, errors.New("payload_encoding_failed: game_id is empty")
}
return wrapPayloadEncoding(transcoder.LobbyMembershipRejectedEventToPayload(&transcoder.LobbyMembershipRejectedEvent{
GameID: payload.GameID,
}))
case intentstream.NotificationTypeLobbyInviteCreated:
var payload struct {
GameID string `json:"game_id"`
InviterUserID string `json:"inviter_user_id"`
}
if err := decodePayload(payloadJSON, &payload); err != nil {
return nil, err
}
if payload.GameID == "" {
return nil, errors.New("payload_encoding_failed: game_id is empty")
}
if payload.InviterUserID == "" {
return nil, errors.New("payload_encoding_failed: inviter_user_id is empty")
}
return wrapPayloadEncoding(transcoder.LobbyInviteCreatedEventToPayload(&transcoder.LobbyInviteCreatedEvent{
GameID: payload.GameID,
InviterUserID: payload.InviterUserID,
}))
case intentstream.NotificationTypeLobbyInviteRedeemed:
var payload struct {
GameID string `json:"game_id"`
InviteeUserID string `json:"invitee_user_id"`
}
if err := decodePayload(payloadJSON, &payload); err != nil {
return nil, err
}
if payload.GameID == "" {
return nil, errors.New("payload_encoding_failed: game_id is empty")
}
if payload.InviteeUserID == "" {
return nil, errors.New("payload_encoding_failed: invitee_user_id is empty")
}
return wrapPayloadEncoding(transcoder.LobbyInviteRedeemedEventToPayload(&transcoder.LobbyInviteRedeemedEvent{
GameID: payload.GameID,
InviteeUserID: payload.InviteeUserID,
}))
default:
return nil, fmt.Errorf("payload_encoding_failed: notification type %q does not support push", notificationType)
}
}
func decodePayload(payloadJSON string, target any) error {
if err := json.Unmarshal([]byte(payloadJSON), target); err != nil {
return fmt.Errorf("payload_encoding_failed: decode payload_json: %w", err)
}
return nil
}
func wrapPayloadEncoding(payload []byte, err error) ([]byte, error) {
if err != nil {
return nil, fmt.Errorf("payload_encoding_failed: %w", err)
}
return payload, nil
}
func userIDFromRecipientRef(recipientRef string) (string, error) {
userID, ok := strings.CutPrefix(recipientRef, "user:")
if !ok || userID == "" {
return "", fmt.Errorf("recipient_ref %q is not user-targeted", recipientRef)
}
return userID, nil
}
@@ -0,0 +1,186 @@
package publishpush
import (
"testing"
"time"
"galaxy/notification/internal/api/intentstream"
"galaxy/notification/internal/service/acceptintent"
"galaxy/transcoder"
"github.com/stretchr/testify/require"
)
func TestEncoderEncodesSupportedPushNotificationTypes(t *testing.T) {
t.Parallel()
now := time.UnixMilli(1775121700000).UTC()
tests := []struct {
name string
notificationType intentstream.NotificationType
payloadJSON string
assertPayload func(*testing.T, []byte)
}{
{
name: "game turn ready",
notificationType: intentstream.NotificationTypeGameTurnReady,
payloadJSON: `{"game_id":"game-1","game_name":"Nebula Clash","turn_number":54}`,
assertPayload: func(t *testing.T, payload []byte) {
t.Helper()
event, err := transcoder.PayloadToGameTurnReadyEvent(payload)
require.NoError(t, err)
require.Equal(t, "game-1", event.GameID)
require.Equal(t, int64(54), event.TurnNumber)
},
},
{
name: "game finished",
notificationType: intentstream.NotificationTypeGameFinished,
payloadJSON: `{"final_turn_number":81,"game_id":"game-2","game_name":"Nova"}`,
assertPayload: func(t *testing.T, payload []byte) {
t.Helper()
event, err := transcoder.PayloadToGameFinishedEvent(payload)
require.NoError(t, err)
require.Equal(t, "game-2", event.GameID)
require.Equal(t, int64(81), event.FinalTurnNumber)
},
},
{
name: "lobby application submitted",
notificationType: intentstream.NotificationTypeLobbyApplicationSubmitted,
payloadJSON: `{"applicant_name":"Nova Pilot","applicant_user_id":"user-2","game_id":"game-3","game_name":"Orion Front"}`,
assertPayload: func(t *testing.T, payload []byte) {
t.Helper()
event, err := transcoder.PayloadToLobbyApplicationSubmittedEvent(payload)
require.NoError(t, err)
require.Equal(t, "game-3", event.GameID)
require.Equal(t, "user-2", event.ApplicantUserID)
},
},
{
name: "lobby membership approved",
notificationType: intentstream.NotificationTypeLobbyMembershipApproved,
payloadJSON: `{"game_id":"game-4","game_name":"Ares"}`,
assertPayload: func(t *testing.T, payload []byte) {
t.Helper()
event, err := transcoder.PayloadToLobbyMembershipApprovedEvent(payload)
require.NoError(t, err)
require.Equal(t, "game-4", event.GameID)
},
},
{
name: "lobby membership rejected",
notificationType: intentstream.NotificationTypeLobbyMembershipRejected,
payloadJSON: `{"game_id":"game-5","game_name":"Atlas"}`,
assertPayload: func(t *testing.T, payload []byte) {
t.Helper()
event, err := transcoder.PayloadToLobbyMembershipRejectedEvent(payload)
require.NoError(t, err)
require.Equal(t, "game-5", event.GameID)
},
},
{
name: "lobby invite created",
notificationType: intentstream.NotificationTypeLobbyInviteCreated,
payloadJSON: `{"game_id":"game-6","game_name":"Vega","inviter_name":"Nova Pilot","inviter_user_id":"user-9"}`,
assertPayload: func(t *testing.T, payload []byte) {
t.Helper()
event, err := transcoder.PayloadToLobbyInviteCreatedEvent(payload)
require.NoError(t, err)
require.Equal(t, "game-6", event.GameID)
require.Equal(t, "user-9", event.InviterUserID)
},
},
{
name: "lobby invite redeemed",
notificationType: intentstream.NotificationTypeLobbyInviteRedeemed,
payloadJSON: `{"game_id":"game-7","game_name":"Lyra","invitee_name":"Skipper","invitee_user_id":"user-10"}`,
assertPayload: func(t *testing.T, payload []byte) {
t.Helper()
event, err := transcoder.PayloadToLobbyInviteRedeemedEvent(payload)
require.NoError(t, err)
require.Equal(t, "game-7", event.GameID)
require.Equal(t, "user-10", event.InviteeUserID)
},
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
event, err := Encoder{}.Encode(
acceptintent.NotificationRecord{
NotificationID: "1775121700000-0",
NotificationType: tt.notificationType,
Producer: tt.notificationType.ExpectedProducer(),
AudienceKind: intentstream.AudienceKindUser,
RecipientUserIDs: []string{"user-1"},
PayloadJSON: tt.payloadJSON,
IdempotencyKey: "idem-1",
RequestFingerprint: "sha256:deadbeef",
RequestID: "request-1",
TraceID: "trace-1",
OccurredAt: now,
AcceptedAt: now,
UpdatedAt: now,
},
acceptintent.NotificationRoute{
NotificationID: "1775121700000-0",
RouteID: "push:user:user-1",
Channel: intentstream.ChannelPush,
RecipientRef: "user:user-1",
Status: acceptintent.RouteStatusPending,
MaxAttempts: 3,
NextAttemptAt: now,
CreatedAt: now,
UpdatedAt: now,
},
)
require.NoError(t, err)
require.Equal(t, "user-1", event.UserID)
require.Equal(t, string(tt.notificationType), event.EventType)
require.Equal(t, "1775121700000-0/push:user:user-1", event.EventID)
require.Equal(t, "request-1", event.RequestID)
require.Equal(t, "trace-1", event.TraceID)
require.NotEmpty(t, event.PayloadBytes)
tt.assertPayload(t, event.PayloadBytes)
})
}
}
func TestEncoderRejectsInvalidStoredPayload(t *testing.T) {
t.Parallel()
now := time.UnixMilli(1775121700000).UTC()
_, err := Encoder{}.Encode(
acceptintent.NotificationRecord{
NotificationID: "1775121700000-0",
NotificationType: intentstream.NotificationTypeGameTurnReady,
Producer: intentstream.ProducerGameMaster,
AudienceKind: intentstream.AudienceKindUser,
RecipientUserIDs: []string{"user-1"},
PayloadJSON: `{"game_id":"","game_name":"Nebula Clash","turn_number":0}`,
IdempotencyKey: "idem-1",
RequestFingerprint: "sha256:deadbeef",
OccurredAt: now,
AcceptedAt: now,
UpdatedAt: now,
},
acceptintent.NotificationRoute{
NotificationID: "1775121700000-0",
RouteID: "push:user:user-1",
Channel: intentstream.ChannelPush,
RecipientRef: "user:user-1",
Status: acceptintent.RouteStatusPending,
MaxAttempts: 3,
NextAttemptAt: now,
CreatedAt: now,
UpdatedAt: now,
},
)
require.Error(t, err)
require.ErrorContains(t, err, "payload_encoding_failed")
}