// 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 }