package publichttp import ( "log/slog" "net/http" "strings" "galaxy/lobby/internal/domain/application" "galaxy/lobby/internal/domain/common" "galaxy/lobby/internal/domain/membership" "galaxy/lobby/internal/service/approveapplication" "galaxy/lobby/internal/service/rejectapplication" "galaxy/lobby/internal/service/shared" "galaxy/lobby/internal/service/submitapplication" ) // Public HTTP route patterns for the application surface. const ( submitApplicationPath = "/api/v1/lobby/games/{game_id}/applications" approveApplicationPath = "/api/v1/lobby/games/{game_id}/applications/{application_id}/approve" rejectApplicationPath = "/api/v1/lobby/games/{game_id}/applications/{application_id}/reject" applicationIDPathParamValue = "application_id" ) // submitApplicationRequest is the JSON shape for // `POST /api/v1/lobby/games/{game_id}/applications`. type submitApplicationRequest struct { RaceName string `json:"race_name"` } // applicationRecordResponse mirrors the OpenAPI ApplicationRecord schema. type applicationRecordResponse struct { ApplicationID string `json:"application_id"` GameID string `json:"game_id"` ApplicantUserID string `json:"applicant_user_id"` RaceName string `json:"race_name"` Status string `json:"status"` CreatedAt int64 `json:"created_at"` DecidedAt *int64 `json:"decided_at,omitempty"` } func encodeApplicationRecord(record application.Application) applicationRecordResponse { resp := applicationRecordResponse{ ApplicationID: record.ApplicationID.String(), GameID: record.GameID.String(), ApplicantUserID: record.ApplicantUserID, RaceName: record.RaceName, Status: string(record.Status), CreatedAt: record.CreatedAt.UTC().UnixMilli(), } if record.DecidedAt != nil { decided := record.DecidedAt.UTC().UnixMilli() resp.DecidedAt = &decided } return resp } // membershipRecordResponse mirrors the OpenAPI MembershipRecord schema. // canonical_key is intentionally omitted from the wire shape; it is a // lobby-internal field per design. type membershipRecordResponse struct { MembershipID string `json:"membership_id"` GameID string `json:"game_id"` UserID string `json:"user_id"` RaceName string `json:"race_name"` Status string `json:"status"` JoinedAt int64 `json:"joined_at"` RemovedAt *int64 `json:"removed_at,omitempty"` } func encodeMembershipRecord(record membership.Membership) membershipRecordResponse { resp := membershipRecordResponse{ MembershipID: record.MembershipID.String(), GameID: record.GameID.String(), UserID: record.UserID, RaceName: record.RaceName, Status: string(record.Status), JoinedAt: record.JoinedAt.UTC().UnixMilli(), } if record.RemovedAt != nil { removed := record.RemovedAt.UTC().UnixMilli() resp.RemovedAt = &removed } return resp } // registerApplicationRoutes binds the three application routes. func registerApplicationRoutes(mux *http.ServeMux, deps Dependencies, logger *slog.Logger) { h := &applicationHandlers{ deps: deps, logger: logger.With("component", "public_http.applications"), } mux.HandleFunc("POST "+submitApplicationPath, h.handleSubmit) mux.HandleFunc("POST "+approveApplicationPath, h.handleApprove) mux.HandleFunc("POST "+rejectApplicationPath, h.handleReject) } type applicationHandlers struct { deps Dependencies logger *slog.Logger } func (h *applicationHandlers) 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 *applicationHandlers) extractApplicationID(writer http.ResponseWriter, request *http.Request) (common.ApplicationID, bool) { raw := request.PathValue(applicationIDPathParamValue) if strings.TrimSpace(raw) == "" { writeError(writer, http.StatusBadRequest, "invalid_request", "application id is required") return "", false } return common.ApplicationID(raw), true } func (h *applicationHandlers) 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 *applicationHandlers) handleSubmit(writer http.ResponseWriter, request *http.Request) { if h.deps.SubmitApplication == nil { writeError(writer, http.StatusInternalServerError, "internal_error", "submit application 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 submitApplicationRequest if err := decodeStrictJSON(request.Body, &body); err != nil { writeError(writer, http.StatusBadRequest, "invalid_request", err.Error()) return } record, err := h.deps.SubmitApplication.Handle(request.Context(), submitapplication.Input{ Actor: shared.NewUserActor(userID), GameID: gameID, RaceName: body.RaceName, }) if err != nil { writeErrorFromService(writer, h.logger, err) return } writeJSON(writer, http.StatusCreated, encodeApplicationRecord(record)) } func (h *applicationHandlers) handleApprove(writer http.ResponseWriter, request *http.Request) { if h.deps.ApproveApplication == nil { writeError(writer, http.StatusInternalServerError, "internal_error", "approve application service is not wired") return } userID, ok := h.requireUserActor(writer, request) if !ok { return } gameID, ok := h.extractGameID(writer, request) if !ok { return } applicationID, ok := h.extractApplicationID(writer, request) if !ok { return } record, err := h.deps.ApproveApplication.Handle(request.Context(), approveapplication.Input{ Actor: shared.NewUserActor(userID), GameID: gameID, ApplicationID: applicationID, }) if err != nil { writeErrorFromService(writer, h.logger, err) return } writeJSON(writer, http.StatusOK, encodeMembershipRecord(record)) } func (h *applicationHandlers) handleReject(writer http.ResponseWriter, request *http.Request) { if h.deps.RejectApplication == nil { writeError(writer, http.StatusInternalServerError, "internal_error", "reject application service is not wired") return } userID, ok := h.requireUserActor(writer, request) if !ok { return } gameID, ok := h.extractGameID(writer, request) if !ok { return } applicationID, ok := h.extractApplicationID(writer, request) if !ok { return } record, err := h.deps.RejectApplication.Handle(request.Context(), rejectapplication.Input{ Actor: shared.NewUserActor(userID), GameID: gameID, ApplicationID: applicationID, }) if err != nil { writeErrorFromService(writer, h.logger, err) return } writeJSON(writer, http.StatusOK, encodeApplicationRecord(record)) }