feat: notification service
This commit is contained in:
@@ -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")
|
||||
}
|
||||
Reference in New Issue
Block a user