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
+243
View File
@@ -0,0 +1,243 @@
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))
}