feat: game lobby service
This commit is contained in:
@@ -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))
|
||||
}
|
||||
Reference in New Issue
Block a user