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
|
||||
}
|
||||
Reference in New Issue
Block a user