package notification import ( "fmt" "galaxy/backend/push" "galaxy/transcoder" "github.com/google/uuid" ) // preMarshaledEvent adapts a pre-encoded FlatBuffers payload to the // push.Event interface. The factory below pre-encodes the payload at // construction time so the kind-specific build error surfaces inside // the dispatcher (where it can drive retry / dead-letter logic) rather // than inside push.Service.PublishClientEvent. type preMarshaledEvent struct { kind string payload []byte } func (e preMarshaledEvent) Kind() string { return e.kind } func (e preMarshaledEvent) Marshal() ([]byte, error) { return e.payload, nil } // buildClientPushEvent maps a catalog kind together with the producer // payload map onto a typed push.Event. Every catalog kind has a // FlatBuffers schema in `pkg/schema/fbs/notification.fbs`; an unknown // kind falls back to push.JSONEvent so a misconfigured producer keeps // the pipeline flowing while the catalog catches up. func buildClientPushEvent(kind string, payload map[string]any) (push.Event, error) { switch kind { case KindLobbyInviteReceived: gameID, err := mapUUID(payload, "game_id") if err != nil { return nil, err } inviter, err := mapUUID(payload, "inviter_user_id") if err != nil { return nil, err } bytes, err := transcoder.LobbyInviteReceivedEventToPayload(&transcoder.LobbyInviteReceivedEvent{ GameID: gameID, InviterUserID: inviter, }) if err != nil { return nil, err } return preMarshaledEvent{kind: kind, payload: bytes}, nil case KindLobbyInviteRevoked: gameID, err := mapUUID(payload, "game_id") if err != nil { return nil, err } bytes, err := transcoder.LobbyInviteRevokedEventToPayload(&transcoder.LobbyInviteRevokedEvent{GameID: gameID}) if err != nil { return nil, err } return preMarshaledEvent{kind: kind, payload: bytes}, nil case KindLobbyApplicationSubmitted: gameID, err := mapUUID(payload, "game_id") if err != nil { return nil, err } appID, err := mapUUID(payload, "application_id") if err != nil { return nil, err } bytes, err := transcoder.LobbyApplicationSubmittedEventToPayload(&transcoder.LobbyApplicationSubmittedEvent{ GameID: gameID, ApplicationID: appID, }) if err != nil { return nil, err } return preMarshaledEvent{kind: kind, payload: bytes}, nil case KindLobbyApplicationApproved: gameID, err := mapUUID(payload, "game_id") if err != nil { return nil, err } bytes, err := transcoder.LobbyApplicationApprovedEventToPayload(&transcoder.LobbyApplicationApprovedEvent{GameID: gameID}) if err != nil { return nil, err } return preMarshaledEvent{kind: kind, payload: bytes}, nil case KindLobbyApplicationRejected: gameID, err := mapUUID(payload, "game_id") if err != nil { return nil, err } bytes, err := transcoder.LobbyApplicationRejectedEventToPayload(&transcoder.LobbyApplicationRejectedEvent{GameID: gameID}) if err != nil { return nil, err } return preMarshaledEvent{kind: kind, payload: bytes}, nil case KindLobbyMembershipRemoved: bytes, err := transcoder.LobbyMembershipRemovedEventToPayload(&transcoder.LobbyMembershipRemovedEvent{ Reason: mapStringOpt(payload, "reason"), }) if err != nil { return nil, err } return preMarshaledEvent{kind: kind, payload: bytes}, nil case KindLobbyMembershipBlocked: gameID, err := mapUUID(payload, "game_id") if err != nil { return nil, err } bytes, err := transcoder.LobbyMembershipBlockedEventToPayload(&transcoder.LobbyMembershipBlockedEvent{ GameID: gameID, Reason: mapStringOpt(payload, "reason"), }) if err != nil { return nil, err } return preMarshaledEvent{kind: kind, payload: bytes}, nil case KindLobbyRaceNameRegistered: raceName, err := mapString(payload, "race_name") if err != nil { return nil, err } bytes, err := transcoder.LobbyRaceNameRegisteredEventToPayload(&transcoder.LobbyRaceNameRegisteredEvent{RaceName: raceName}) if err != nil { return nil, err } return preMarshaledEvent{kind: kind, payload: bytes}, nil case KindLobbyRaceNamePending: raceName, err := mapString(payload, "race_name") if err != nil { return nil, err } bytes, err := transcoder.LobbyRaceNamePendingEventToPayload(&transcoder.LobbyRaceNamePendingEvent{ RaceName: raceName, ExpiresAt: mapStringOpt(payload, "expires_at"), }) if err != nil { return nil, err } return preMarshaledEvent{kind: kind, payload: bytes}, nil case KindLobbyRaceNameExpired: raceName, err := mapString(payload, "race_name") if err != nil { return nil, err } bytes, err := transcoder.LobbyRaceNameExpiredEventToPayload(&transcoder.LobbyRaceNameExpiredEvent{RaceName: raceName}) if err != nil { return nil, err } return preMarshaledEvent{kind: kind, payload: bytes}, nil case KindRuntimeImagePullFailed: gameID, err := mapUUID(payload, "game_id") if err != nil { return nil, err } bytes, err := transcoder.RuntimeImagePullFailedEventToPayload(&transcoder.RuntimeImagePullFailedEvent{ GameID: gameID, ImageRef: mapStringOpt(payload, "image_ref"), }) if err != nil { return nil, err } return preMarshaledEvent{kind: kind, payload: bytes}, nil case KindRuntimeContainerStartFailed: gameID, err := mapUUID(payload, "game_id") if err != nil { return nil, err } bytes, err := transcoder.RuntimeContainerStartFailedEventToPayload(&transcoder.RuntimeContainerStartFailedEvent{GameID: gameID}) if err != nil { return nil, err } return preMarshaledEvent{kind: kind, payload: bytes}, nil case KindRuntimeStartConfigInvalid: gameID, err := mapUUID(payload, "game_id") if err != nil { return nil, err } bytes, err := transcoder.RuntimeStartConfigInvalidEventToPayload(&transcoder.RuntimeStartConfigInvalidEvent{ GameID: gameID, Reason: mapStringOpt(payload, "reason"), }) if err != nil { return nil, err } return preMarshaledEvent{kind: kind, payload: bytes}, nil } return push.JSONEvent{EventKind: kind, Payload: payload}, nil } // mapUUID extracts a required UUID-shaped field from the producer // payload. Producers stringify uuid values before assembling Intent // payloads, so the JSON-roundtripped form is `string`. func mapUUID(payload map[string]any, key string) (uuid.UUID, error) { raw, ok := payload[key] if !ok { return uuid.Nil, fmt.Errorf("notification payload: %s is missing", key) } str, ok := raw.(string) if !ok { return uuid.Nil, fmt.Errorf("notification payload: %s must be a string, got %T", key, raw) } parsed, err := uuid.Parse(str) if err != nil { return uuid.Nil, fmt.Errorf("notification payload: %s is not a uuid: %w", key, err) } return parsed, nil } // mapString extracts a required string field from the producer payload. func mapString(payload map[string]any, key string) (string, error) { raw, ok := payload[key] if !ok { return "", fmt.Errorf("notification payload: %s is missing", key) } str, ok := raw.(string) if !ok { return "", fmt.Errorf("notification payload: %s must be a string, got %T", key, raw) } if str == "" { return "", fmt.Errorf("notification payload: %s is empty", key) } return str, nil } // mapStringOpt returns the string value for key, or "" when the key is // missing or carries a non-string value. func mapStringOpt(payload map[string]any, key string) string { raw, ok := payload[key] if !ok { return "" } str, _ := raw.(string) return str }