package publichttp import ( "log/slog" "net/http" "strings" "galaxy/lobby/internal/domain/common" "galaxy/lobby/internal/domain/invite" "galaxy/lobby/internal/service/createinvite" "galaxy/lobby/internal/service/declineinvite" "galaxy/lobby/internal/service/redeeminvite" "galaxy/lobby/internal/service/revokeinvite" "galaxy/lobby/internal/service/shared" ) // Public HTTP route patterns for the invite surface. const ( createInvitePath = "/api/v1/lobby/games/{game_id}/invites" redeemInvitePath = "/api/v1/lobby/games/{game_id}/invites/{invite_id}/redeem" declineInvitePath = "/api/v1/lobby/games/{game_id}/invites/{invite_id}/decline" revokeInvitePath = "/api/v1/lobby/games/{game_id}/invites/{invite_id}/revoke" inviteIDPathParamValue = "invite_id" ) // createInviteRequest is the JSON shape for // `POST /api/v1/lobby/games/{game_id}/invites`. type createInviteRequest struct { InviteeUserID string `json:"invitee_user_id"` } // redeemInviteRequest is the JSON shape for // `POST /api/v1/lobby/games/{game_id}/invites/{invite_id}/redeem`. type redeemInviteRequest struct { RaceName string `json:"race_name"` } // inviteRecordResponse mirrors the OpenAPI InviteRecord schema. RaceName is // omitted from the wire shape until the invite transitions to redeemed. type inviteRecordResponse struct { InviteID string `json:"invite_id"` GameID string `json:"game_id"` InviterUserID string `json:"inviter_user_id"` InviteeUserID string `json:"invitee_user_id"` RaceName string `json:"race_name,omitempty"` Status string `json:"status"` CreatedAt int64 `json:"created_at"` ExpiresAt int64 `json:"expires_at"` DecidedAt *int64 `json:"decided_at,omitempty"` } func encodeInviteRecord(record invite.Invite) inviteRecordResponse { resp := inviteRecordResponse{ InviteID: record.InviteID.String(), GameID: record.GameID.String(), InviterUserID: record.InviterUserID, InviteeUserID: record.InviteeUserID, RaceName: record.RaceName, Status: string(record.Status), CreatedAt: record.CreatedAt.UTC().UnixMilli(), ExpiresAt: record.ExpiresAt.UTC().UnixMilli(), } if record.DecidedAt != nil { decided := record.DecidedAt.UTC().UnixMilli() resp.DecidedAt = &decided } return resp } // registerInviteRoutes binds the four invite routes. func registerInviteRoutes(mux *http.ServeMux, deps Dependencies, logger *slog.Logger) { h := &inviteHandlers{ deps: deps, logger: logger.With("component", "public_http.invites"), } mux.HandleFunc("POST "+createInvitePath, h.handleCreate) mux.HandleFunc("POST "+redeemInvitePath, h.handleRedeem) mux.HandleFunc("POST "+declineInvitePath, h.handleDecline) mux.HandleFunc("POST "+revokeInvitePath, h.handleRevoke) } type inviteHandlers struct { deps Dependencies logger *slog.Logger } func (h *inviteHandlers) extractGameID(writer http.ResponseWriter, request *http.Request) (common.GameID, bool) { raw := request.PathValue(gameIDPathParamValue) if strings.TrimSpace(raw) == "" { writeError(writer, http.StatusBadRequest, "invalid_request", "game id is required") return "", false } return common.GameID(raw), true } func (h *inviteHandlers) extractInviteID(writer http.ResponseWriter, request *http.Request) (common.InviteID, bool) { raw := request.PathValue(inviteIDPathParamValue) if strings.TrimSpace(raw) == "" { writeError(writer, http.StatusBadRequest, "invalid_request", "invite id is required") return "", false } return common.InviteID(raw), true } func (h *inviteHandlers) requireUserActor(writer http.ResponseWriter, request *http.Request) (string, bool) { userID := strings.TrimSpace(request.Header.Get(xUserIDHeader)) if userID == "" { writeError(writer, http.StatusBadRequest, "invalid_request", "X-User-ID header is required") return "", false } return userID, true } func (h *inviteHandlers) handleCreate(writer http.ResponseWriter, request *http.Request) { if h.deps.CreateInvite == nil { writeError(writer, http.StatusInternalServerError, "internal_error", "create invite service is not wired") return } userID, ok := h.requireUserActor(writer, request) if !ok { return } gameID, ok := h.extractGameID(writer, request) if !ok { return } var body createInviteRequest if err := decodeStrictJSON(request.Body, &body); err != nil { writeError(writer, http.StatusBadRequest, "invalid_request", err.Error()) return } record, err := h.deps.CreateInvite.Handle(request.Context(), createinvite.Input{ Actor: shared.NewUserActor(userID), GameID: gameID, InviteeUserID: body.InviteeUserID, }) if err != nil { writeErrorFromService(writer, h.logger, err) return } writeJSON(writer, http.StatusCreated, encodeInviteRecord(record)) } func (h *inviteHandlers) handleRedeem(writer http.ResponseWriter, request *http.Request) { if h.deps.RedeemInvite == nil { writeError(writer, http.StatusInternalServerError, "internal_error", "redeem invite service is not wired") return } userID, ok := h.requireUserActor(writer, request) if !ok { return } gameID, ok := h.extractGameID(writer, request) if !ok { return } inviteID, ok := h.extractInviteID(writer, request) if !ok { return } var body redeemInviteRequest if err := decodeStrictJSON(request.Body, &body); err != nil { writeError(writer, http.StatusBadRequest, "invalid_request", err.Error()) return } record, err := h.deps.RedeemInvite.Handle(request.Context(), redeeminvite.Input{ Actor: shared.NewUserActor(userID), GameID: gameID, InviteID: inviteID, RaceName: body.RaceName, }) if err != nil { writeErrorFromService(writer, h.logger, err) return } writeJSON(writer, http.StatusOK, encodeMembershipRecord(record)) } func (h *inviteHandlers) handleDecline(writer http.ResponseWriter, request *http.Request) { if h.deps.DeclineInvite == nil { writeError(writer, http.StatusInternalServerError, "internal_error", "decline invite service is not wired") return } userID, ok := h.requireUserActor(writer, request) if !ok { return } gameID, ok := h.extractGameID(writer, request) if !ok { return } inviteID, ok := h.extractInviteID(writer, request) if !ok { return } record, err := h.deps.DeclineInvite.Handle(request.Context(), declineinvite.Input{ Actor: shared.NewUserActor(userID), GameID: gameID, InviteID: inviteID, }) if err != nil { writeErrorFromService(writer, h.logger, err) return } writeJSON(writer, http.StatusOK, encodeInviteRecord(record)) } func (h *inviteHandlers) handleRevoke(writer http.ResponseWriter, request *http.Request) { if h.deps.RevokeInvite == nil { writeError(writer, http.StatusInternalServerError, "internal_error", "revoke invite service is not wired") return } userID, ok := h.requireUserActor(writer, request) if !ok { return } gameID, ok := h.extractGameID(writer, request) if !ok { return } inviteID, ok := h.extractInviteID(writer, request) if !ok { return } record, err := h.deps.RevokeInvite.Handle(request.Context(), revokeinvite.Input{ Actor: shared.NewUserActor(userID), GameID: gameID, InviteID: inviteID, }) if err != nil { writeErrorFromService(writer, h.logger, err) return } writeJSON(writer, http.StatusOK, encodeInviteRecord(record)) }