feat: game lobby service

This commit is contained in:
Ilia Denisov
2026-04-25 23:20:55 +02:00
committed by GitHub
parent 32dc29359a
commit 48b0056b49
336 changed files with 57074 additions and 1418 deletions
@@ -0,0 +1,222 @@
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))
}