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
}