同步前后端联调与文档更新
This commit is contained in:
@@ -37,17 +37,20 @@ func New(ctx context.Context, cfg Config) (*App, error) {
|
||||
}, store, jwtManager)
|
||||
entryService := service.NewEntryService(store)
|
||||
entryHomeService := service.NewEntryHomeService(store)
|
||||
adminResourceService := service.NewAdminResourceService(store)
|
||||
adminEventService := service.NewAdminEventService(store)
|
||||
eventService := service.NewEventService(store)
|
||||
eventPlayService := service.NewEventPlayService(store)
|
||||
assetPublisher := assets.NewOSSUtilPublisher(cfg.OSSUtilPath, cfg.OSSUtilConfigFile, cfg.AssetBucketRoot, cfg.AssetPublicBaseURL)
|
||||
configService := service.NewConfigService(store, cfg.LocalEventDir, cfg.AssetBaseURL, assetPublisher)
|
||||
adminPipelineService := service.NewAdminPipelineService(store, configService)
|
||||
homeService := service.NewHomeService(store)
|
||||
profileService := service.NewProfileService(store)
|
||||
resultService := service.NewResultService(store)
|
||||
sessionService := service.NewSessionService(store)
|
||||
devService := service.NewDevService(cfg.AppEnv, store)
|
||||
meService := service.NewMeService(store)
|
||||
router := httpapi.NewRouter(cfg.AppEnv, jwtManager, authService, entryService, entryHomeService, eventService, eventPlayService, configService, homeService, profileService, resultService, sessionService, devService, meService)
|
||||
router := httpapi.NewRouter(cfg.AppEnv, jwtManager, authService, entryService, entryHomeService, adminResourceService, adminEventService, adminPipelineService, eventService, eventPlayService, configService, homeService, profileService, resultService, sessionService, devService, meService)
|
||||
|
||||
return &App{
|
||||
router: router,
|
||||
|
||||
84
backend/internal/httpapi/handlers/admin_event_handler.go
Normal file
84
backend/internal/httpapi/handlers/admin_event_handler.go
Normal file
@@ -0,0 +1,84 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"cmr-backend/internal/apperr"
|
||||
"cmr-backend/internal/httpx"
|
||||
"cmr-backend/internal/service"
|
||||
)
|
||||
|
||||
type AdminEventHandler struct {
|
||||
service *service.AdminEventService
|
||||
}
|
||||
|
||||
func NewAdminEventHandler(service *service.AdminEventService) *AdminEventHandler {
|
||||
return &AdminEventHandler{service: service}
|
||||
}
|
||||
|
||||
func (h *AdminEventHandler) ListEvents(w http.ResponseWriter, r *http.Request) {
|
||||
limit := 50
|
||||
if raw := r.URL.Query().Get("limit"); raw != "" {
|
||||
if parsed, err := strconv.Atoi(raw); err == nil {
|
||||
limit = parsed
|
||||
}
|
||||
}
|
||||
result, err := h.service.ListEvents(r.Context(), limit)
|
||||
if err != nil {
|
||||
httpx.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
|
||||
}
|
||||
|
||||
func (h *AdminEventHandler) CreateEvent(w http.ResponseWriter, r *http.Request) {
|
||||
var req service.CreateAdminEventInput
|
||||
if err := httpx.DecodeJSON(r, &req); err != nil {
|
||||
httpx.WriteError(w, apperr.New(http.StatusBadRequest, "invalid_json", "invalid request body: "+err.Error()))
|
||||
return
|
||||
}
|
||||
result, err := h.service.CreateEvent(r.Context(), req)
|
||||
if err != nil {
|
||||
httpx.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusCreated, map[string]any{"data": result})
|
||||
}
|
||||
|
||||
func (h *AdminEventHandler) GetEvent(w http.ResponseWriter, r *http.Request) {
|
||||
result, err := h.service.GetEventDetail(r.Context(), r.PathValue("eventPublicID"))
|
||||
if err != nil {
|
||||
httpx.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
|
||||
}
|
||||
|
||||
func (h *AdminEventHandler) UpdateEvent(w http.ResponseWriter, r *http.Request) {
|
||||
var req service.UpdateAdminEventInput
|
||||
if err := httpx.DecodeJSON(r, &req); err != nil {
|
||||
httpx.WriteError(w, apperr.New(http.StatusBadRequest, "invalid_json", "invalid request body: "+err.Error()))
|
||||
return
|
||||
}
|
||||
result, err := h.service.UpdateEvent(r.Context(), r.PathValue("eventPublicID"), req)
|
||||
if err != nil {
|
||||
httpx.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
|
||||
}
|
||||
|
||||
func (h *AdminEventHandler) SaveSource(w http.ResponseWriter, r *http.Request) {
|
||||
var req service.SaveAdminEventSourceInput
|
||||
if err := httpx.DecodeJSON(r, &req); err != nil {
|
||||
httpx.WriteError(w, apperr.New(http.StatusBadRequest, "invalid_json", "invalid request body: "+err.Error()))
|
||||
return
|
||||
}
|
||||
result, err := h.service.SaveEventSource(r.Context(), r.PathValue("eventPublicID"), req)
|
||||
if err != nil {
|
||||
httpx.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusCreated, map[string]any{"data": result})
|
||||
}
|
||||
74
backend/internal/httpapi/handlers/admin_pipeline_handler.go
Normal file
74
backend/internal/httpapi/handlers/admin_pipeline_handler.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"cmr-backend/internal/apperr"
|
||||
"cmr-backend/internal/httpx"
|
||||
"cmr-backend/internal/service"
|
||||
)
|
||||
|
||||
type AdminPipelineHandler struct {
|
||||
service *service.AdminPipelineService
|
||||
}
|
||||
|
||||
func NewAdminPipelineHandler(service *service.AdminPipelineService) *AdminPipelineHandler {
|
||||
return &AdminPipelineHandler{service: service}
|
||||
}
|
||||
|
||||
func (h *AdminPipelineHandler) GetEventPipeline(w http.ResponseWriter, r *http.Request) {
|
||||
limit := 20
|
||||
if raw := r.URL.Query().Get("limit"); raw != "" {
|
||||
if parsed, err := strconv.Atoi(raw); err == nil {
|
||||
limit = parsed
|
||||
}
|
||||
}
|
||||
result, err := h.service.GetEventPipeline(r.Context(), r.PathValue("eventPublicID"), limit)
|
||||
if err != nil {
|
||||
httpx.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
|
||||
}
|
||||
|
||||
func (h *AdminPipelineHandler) BuildSource(w http.ResponseWriter, r *http.Request) {
|
||||
result, err := h.service.BuildSource(r.Context(), r.PathValue("sourceID"))
|
||||
if err != nil {
|
||||
httpx.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
|
||||
}
|
||||
|
||||
func (h *AdminPipelineHandler) GetBuild(w http.ResponseWriter, r *http.Request) {
|
||||
result, err := h.service.GetBuild(r.Context(), r.PathValue("buildID"))
|
||||
if err != nil {
|
||||
httpx.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
|
||||
}
|
||||
|
||||
func (h *AdminPipelineHandler) PublishBuild(w http.ResponseWriter, r *http.Request) {
|
||||
result, err := h.service.PublishBuild(r.Context(), r.PathValue("buildID"))
|
||||
if err != nil {
|
||||
httpx.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
|
||||
}
|
||||
|
||||
func (h *AdminPipelineHandler) RollbackRelease(w http.ResponseWriter, r *http.Request) {
|
||||
var req service.AdminRollbackReleaseInput
|
||||
if err := httpx.DecodeJSON(r, &req); err != nil {
|
||||
httpx.WriteError(w, apperr.New(http.StatusBadRequest, "invalid_json", "invalid request body: "+err.Error()))
|
||||
return
|
||||
}
|
||||
result, err := h.service.RollbackRelease(r.Context(), r.PathValue("eventPublicID"), req)
|
||||
if err != nil {
|
||||
httpx.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
|
||||
}
|
||||
169
backend/internal/httpapi/handlers/admin_resource_handler.go
Normal file
169
backend/internal/httpapi/handlers/admin_resource_handler.go
Normal file
@@ -0,0 +1,169 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"cmr-backend/internal/apperr"
|
||||
"cmr-backend/internal/httpx"
|
||||
"cmr-backend/internal/service"
|
||||
)
|
||||
|
||||
type AdminResourceHandler struct {
|
||||
service *service.AdminResourceService
|
||||
}
|
||||
|
||||
func NewAdminResourceHandler(service *service.AdminResourceService) *AdminResourceHandler {
|
||||
return &AdminResourceHandler{service: service}
|
||||
}
|
||||
|
||||
func (h *AdminResourceHandler) ListMaps(w http.ResponseWriter, r *http.Request) {
|
||||
limit := parseAdminLimit(r)
|
||||
result, err := h.service.ListMaps(r.Context(), limit)
|
||||
if err != nil {
|
||||
httpx.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
|
||||
}
|
||||
|
||||
func (h *AdminResourceHandler) CreateMap(w http.ResponseWriter, r *http.Request) {
|
||||
var req service.CreateAdminMapInput
|
||||
if err := httpx.DecodeJSON(r, &req); err != nil {
|
||||
httpx.WriteError(w, apperr.New(http.StatusBadRequest, "invalid_json", "invalid request body: "+err.Error()))
|
||||
return
|
||||
}
|
||||
result, err := h.service.CreateMap(r.Context(), req)
|
||||
if err != nil {
|
||||
httpx.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusCreated, map[string]any{"data": result})
|
||||
}
|
||||
|
||||
func (h *AdminResourceHandler) GetMap(w http.ResponseWriter, r *http.Request) {
|
||||
result, err := h.service.GetMapDetail(r.Context(), r.PathValue("mapPublicID"))
|
||||
if err != nil {
|
||||
httpx.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
|
||||
}
|
||||
|
||||
func (h *AdminResourceHandler) CreateMapVersion(w http.ResponseWriter, r *http.Request) {
|
||||
var req service.CreateAdminMapVersionInput
|
||||
if err := httpx.DecodeJSON(r, &req); err != nil {
|
||||
httpx.WriteError(w, apperr.New(http.StatusBadRequest, "invalid_json", "invalid request body: "+err.Error()))
|
||||
return
|
||||
}
|
||||
result, err := h.service.CreateMapVersion(r.Context(), r.PathValue("mapPublicID"), req)
|
||||
if err != nil {
|
||||
httpx.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusCreated, map[string]any{"data": result})
|
||||
}
|
||||
|
||||
func (h *AdminResourceHandler) ListPlayfields(w http.ResponseWriter, r *http.Request) {
|
||||
limit := parseAdminLimit(r)
|
||||
result, err := h.service.ListPlayfields(r.Context(), limit)
|
||||
if err != nil {
|
||||
httpx.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
|
||||
}
|
||||
|
||||
func (h *AdminResourceHandler) CreatePlayfield(w http.ResponseWriter, r *http.Request) {
|
||||
var req service.CreateAdminPlayfieldInput
|
||||
if err := httpx.DecodeJSON(r, &req); err != nil {
|
||||
httpx.WriteError(w, apperr.New(http.StatusBadRequest, "invalid_json", "invalid request body: "+err.Error()))
|
||||
return
|
||||
}
|
||||
result, err := h.service.CreatePlayfield(r.Context(), req)
|
||||
if err != nil {
|
||||
httpx.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusCreated, map[string]any{"data": result})
|
||||
}
|
||||
|
||||
func (h *AdminResourceHandler) GetPlayfield(w http.ResponseWriter, r *http.Request) {
|
||||
result, err := h.service.GetPlayfieldDetail(r.Context(), r.PathValue("playfieldPublicID"))
|
||||
if err != nil {
|
||||
httpx.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
|
||||
}
|
||||
|
||||
func (h *AdminResourceHandler) CreatePlayfieldVersion(w http.ResponseWriter, r *http.Request) {
|
||||
var req service.CreateAdminPlayfieldVersionInput
|
||||
if err := httpx.DecodeJSON(r, &req); err != nil {
|
||||
httpx.WriteError(w, apperr.New(http.StatusBadRequest, "invalid_json", "invalid request body: "+err.Error()))
|
||||
return
|
||||
}
|
||||
result, err := h.service.CreatePlayfieldVersion(r.Context(), r.PathValue("playfieldPublicID"), req)
|
||||
if err != nil {
|
||||
httpx.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusCreated, map[string]any{"data": result})
|
||||
}
|
||||
|
||||
func (h *AdminResourceHandler) ListResourcePacks(w http.ResponseWriter, r *http.Request) {
|
||||
limit := parseAdminLimit(r)
|
||||
result, err := h.service.ListResourcePacks(r.Context(), limit)
|
||||
if err != nil {
|
||||
httpx.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
|
||||
}
|
||||
|
||||
func (h *AdminResourceHandler) CreateResourcePack(w http.ResponseWriter, r *http.Request) {
|
||||
var req service.CreateAdminResourcePackInput
|
||||
if err := httpx.DecodeJSON(r, &req); err != nil {
|
||||
httpx.WriteError(w, apperr.New(http.StatusBadRequest, "invalid_json", "invalid request body: "+err.Error()))
|
||||
return
|
||||
}
|
||||
result, err := h.service.CreateResourcePack(r.Context(), req)
|
||||
if err != nil {
|
||||
httpx.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusCreated, map[string]any{"data": result})
|
||||
}
|
||||
|
||||
func (h *AdminResourceHandler) GetResourcePack(w http.ResponseWriter, r *http.Request) {
|
||||
result, err := h.service.GetResourcePackDetail(r.Context(), r.PathValue("resourcePackPublicID"))
|
||||
if err != nil {
|
||||
httpx.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
|
||||
}
|
||||
|
||||
func (h *AdminResourceHandler) CreateResourcePackVersion(w http.ResponseWriter, r *http.Request) {
|
||||
var req service.CreateAdminResourcePackVersionInput
|
||||
if err := httpx.DecodeJSON(r, &req); err != nil {
|
||||
httpx.WriteError(w, apperr.New(http.StatusBadRequest, "invalid_json", "invalid request body: "+err.Error()))
|
||||
return
|
||||
}
|
||||
result, err := h.service.CreateResourcePackVersion(r.Context(), r.PathValue("resourcePackPublicID"), req)
|
||||
if err != nil {
|
||||
httpx.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusCreated, map[string]any{"data": result})
|
||||
}
|
||||
|
||||
func parseAdminLimit(r *http.Request) int {
|
||||
limit := 50
|
||||
if raw := r.URL.Query().Get("limit"); raw != "" {
|
||||
if parsed, err := strconv.Atoi(raw); err == nil {
|
||||
limit = parsed
|
||||
}
|
||||
}
|
||||
return limit
|
||||
}
|
||||
@@ -15,6 +15,9 @@ func NewRouter(
|
||||
authService *service.AuthService,
|
||||
entryService *service.EntryService,
|
||||
entryHomeService *service.EntryHomeService,
|
||||
adminResourceService *service.AdminResourceService,
|
||||
adminEventService *service.AdminEventService,
|
||||
adminPipelineService *service.AdminPipelineService,
|
||||
eventService *service.EventService,
|
||||
eventPlayService *service.EventPlayService,
|
||||
configService *service.ConfigService,
|
||||
@@ -31,6 +34,9 @@ func NewRouter(
|
||||
authHandler := handlers.NewAuthHandler(authService)
|
||||
entryHandler := handlers.NewEntryHandler(entryService)
|
||||
entryHomeHandler := handlers.NewEntryHomeHandler(entryHomeService)
|
||||
adminResourceHandler := handlers.NewAdminResourceHandler(adminResourceService)
|
||||
adminEventHandler := handlers.NewAdminEventHandler(adminEventService)
|
||||
adminPipelineHandler := handlers.NewAdminPipelineHandler(adminPipelineService)
|
||||
eventHandler := handlers.NewEventHandler(eventService)
|
||||
eventPlayHandler := handlers.NewEventPlayHandler(eventPlayService)
|
||||
configHandler := handlers.NewConfigHandler(configService)
|
||||
@@ -46,6 +52,28 @@ func NewRouter(
|
||||
mux.HandleFunc("GET /home", homeHandler.GetHome)
|
||||
mux.HandleFunc("GET /cards", homeHandler.GetCards)
|
||||
mux.HandleFunc("GET /entry/resolve", entryHandler.Resolve)
|
||||
mux.Handle("GET /admin/maps", authMiddleware(http.HandlerFunc(adminResourceHandler.ListMaps)))
|
||||
mux.Handle("POST /admin/maps", authMiddleware(http.HandlerFunc(adminResourceHandler.CreateMap)))
|
||||
mux.Handle("GET /admin/maps/{mapPublicID}", authMiddleware(http.HandlerFunc(adminResourceHandler.GetMap)))
|
||||
mux.Handle("POST /admin/maps/{mapPublicID}/versions", authMiddleware(http.HandlerFunc(adminResourceHandler.CreateMapVersion)))
|
||||
mux.Handle("GET /admin/playfields", authMiddleware(http.HandlerFunc(adminResourceHandler.ListPlayfields)))
|
||||
mux.Handle("POST /admin/playfields", authMiddleware(http.HandlerFunc(adminResourceHandler.CreatePlayfield)))
|
||||
mux.Handle("GET /admin/playfields/{playfieldPublicID}", authMiddleware(http.HandlerFunc(adminResourceHandler.GetPlayfield)))
|
||||
mux.Handle("POST /admin/playfields/{playfieldPublicID}/versions", authMiddleware(http.HandlerFunc(adminResourceHandler.CreatePlayfieldVersion)))
|
||||
mux.Handle("GET /admin/resource-packs", authMiddleware(http.HandlerFunc(adminResourceHandler.ListResourcePacks)))
|
||||
mux.Handle("POST /admin/resource-packs", authMiddleware(http.HandlerFunc(adminResourceHandler.CreateResourcePack)))
|
||||
mux.Handle("GET /admin/resource-packs/{resourcePackPublicID}", authMiddleware(http.HandlerFunc(adminResourceHandler.GetResourcePack)))
|
||||
mux.Handle("POST /admin/resource-packs/{resourcePackPublicID}/versions", authMiddleware(http.HandlerFunc(adminResourceHandler.CreateResourcePackVersion)))
|
||||
mux.Handle("GET /admin/events", authMiddleware(http.HandlerFunc(adminEventHandler.ListEvents)))
|
||||
mux.Handle("POST /admin/events", authMiddleware(http.HandlerFunc(adminEventHandler.CreateEvent)))
|
||||
mux.Handle("GET /admin/events/{eventPublicID}", authMiddleware(http.HandlerFunc(adminEventHandler.GetEvent)))
|
||||
mux.Handle("PUT /admin/events/{eventPublicID}", authMiddleware(http.HandlerFunc(adminEventHandler.UpdateEvent)))
|
||||
mux.Handle("POST /admin/events/{eventPublicID}/source", authMiddleware(http.HandlerFunc(adminEventHandler.SaveSource)))
|
||||
mux.Handle("GET /admin/events/{eventPublicID}/pipeline", authMiddleware(http.HandlerFunc(adminPipelineHandler.GetEventPipeline)))
|
||||
mux.Handle("POST /admin/sources/{sourceID}/build", authMiddleware(http.HandlerFunc(adminPipelineHandler.BuildSource)))
|
||||
mux.Handle("GET /admin/builds/{buildID}", authMiddleware(http.HandlerFunc(adminPipelineHandler.GetBuild)))
|
||||
mux.Handle("POST /admin/builds/{buildID}/publish", authMiddleware(http.HandlerFunc(adminPipelineHandler.PublishBuild)))
|
||||
mux.Handle("POST /admin/events/{eventPublicID}/rollback", authMiddleware(http.HandlerFunc(adminPipelineHandler.RollbackRelease)))
|
||||
if appEnv != "production" {
|
||||
mux.HandleFunc("GET /dev/workbench", devHandler.Workbench)
|
||||
mux.HandleFunc("POST /dev/bootstrap-demo", devHandler.BootstrapDemo)
|
||||
|
||||
490
backend/internal/service/admin_event_service.go
Normal file
490
backend/internal/service/admin_event_service.go
Normal file
@@ -0,0 +1,490 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"cmr-backend/internal/apperr"
|
||||
"cmr-backend/internal/platform/security"
|
||||
"cmr-backend/internal/store/postgres"
|
||||
)
|
||||
|
||||
type AdminEventService struct {
|
||||
store *postgres.Store
|
||||
}
|
||||
|
||||
type AdminEventSummary struct {
|
||||
ID string `json:"id"`
|
||||
TenantCode *string `json:"tenantCode,omitempty"`
|
||||
TenantName *string `json:"tenantName,omitempty"`
|
||||
Slug string `json:"slug"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Summary *string `json:"summary,omitempty"`
|
||||
Status string `json:"status"`
|
||||
CurrentRelease *AdminEventReleaseRef `json:"currentRelease,omitempty"`
|
||||
}
|
||||
|
||||
type AdminEventReleaseRef struct {
|
||||
ID string `json:"id"`
|
||||
ConfigLabel *string `json:"configLabel,omitempty"`
|
||||
ManifestURL *string `json:"manifestUrl,omitempty"`
|
||||
ManifestChecksumSha256 *string `json:"manifestChecksumSha256,omitempty"`
|
||||
RouteCode *string `json:"routeCode,omitempty"`
|
||||
}
|
||||
|
||||
type AdminEventDetail struct {
|
||||
Event AdminEventSummary `json:"event"`
|
||||
LatestSource *EventConfigSourceView `json:"latestSource,omitempty"`
|
||||
SourceCount int `json:"sourceCount"`
|
||||
CurrentSource *AdminAssembledSource `json:"currentSource,omitempty"`
|
||||
}
|
||||
|
||||
type CreateAdminEventInput struct {
|
||||
TenantCode *string `json:"tenantCode,omitempty"`
|
||||
Slug string `json:"slug"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Summary *string `json:"summary,omitempty"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type UpdateAdminEventInput struct {
|
||||
TenantCode *string `json:"tenantCode,omitempty"`
|
||||
Slug string `json:"slug"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Summary *string `json:"summary,omitempty"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type SaveAdminEventSourceInput struct {
|
||||
Map struct {
|
||||
MapID string `json:"mapId"`
|
||||
VersionID string `json:"versionId"`
|
||||
} `json:"map"`
|
||||
Playfield struct {
|
||||
PlayfieldID string `json:"playfieldId"`
|
||||
VersionID string `json:"versionId"`
|
||||
} `json:"playfield"`
|
||||
ResourcePack *struct {
|
||||
ResourcePackID string `json:"resourcePackId"`
|
||||
VersionID string `json:"versionId"`
|
||||
} `json:"resourcePack,omitempty"`
|
||||
GameModeCode string `json:"gameModeCode"`
|
||||
RouteCode *string `json:"routeCode,omitempty"`
|
||||
Overrides map[string]any `json:"overrides,omitempty"`
|
||||
Notes *string `json:"notes,omitempty"`
|
||||
}
|
||||
|
||||
type AdminAssembledSource struct {
|
||||
Refs map[string]any `json:"refs"`
|
||||
Runtime map[string]any `json:"runtime"`
|
||||
Overrides map[string]any `json:"overrides,omitempty"`
|
||||
}
|
||||
|
||||
func NewAdminEventService(store *postgres.Store) *AdminEventService {
|
||||
return &AdminEventService{store: store}
|
||||
}
|
||||
|
||||
func (s *AdminEventService) ListEvents(ctx context.Context, limit int) ([]AdminEventSummary, error) {
|
||||
items, err := s.store.ListAdminEvents(ctx, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
results := make([]AdminEventSummary, 0, len(items))
|
||||
for _, item := range items {
|
||||
results = append(results, buildAdminEventSummary(item))
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func (s *AdminEventService) CreateEvent(ctx context.Context, input CreateAdminEventInput) (*AdminEventSummary, error) {
|
||||
input.Slug = strings.TrimSpace(input.Slug)
|
||||
input.DisplayName = strings.TrimSpace(input.DisplayName)
|
||||
if input.Slug == "" || input.DisplayName == "" {
|
||||
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "slug and displayName are required")
|
||||
}
|
||||
|
||||
var tenantID *string
|
||||
var tenantCode *string
|
||||
var tenantName *string
|
||||
if input.TenantCode != nil && strings.TrimSpace(*input.TenantCode) != "" {
|
||||
tenant, err := s.store.GetTenantByCode(ctx, strings.TrimSpace(*input.TenantCode))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if tenant == nil {
|
||||
return nil, apperr.New(http.StatusNotFound, "tenant_not_found", "tenant not found")
|
||||
}
|
||||
tenantID = &tenant.ID
|
||||
tenantCode = &tenant.TenantCode
|
||||
tenantName = &tenant.Name
|
||||
}
|
||||
|
||||
publicID, err := security.GeneratePublicID("evt")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tx, err := s.store.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
item, err := s.store.CreateAdminEvent(ctx, tx, postgres.CreateAdminEventParams{
|
||||
PublicID: publicID,
|
||||
TenantID: tenantID,
|
||||
Slug: input.Slug,
|
||||
DisplayName: input.DisplayName,
|
||||
Summary: trimStringPtr(input.Summary),
|
||||
Status: normalizeEventCatalogStatus(input.Status),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &AdminEventSummary{
|
||||
ID: item.PublicID,
|
||||
TenantCode: tenantCode,
|
||||
TenantName: tenantName,
|
||||
Slug: item.Slug,
|
||||
DisplayName: item.DisplayName,
|
||||
Summary: item.Summary,
|
||||
Status: item.Status,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *AdminEventService) UpdateEvent(ctx context.Context, eventPublicID string, input UpdateAdminEventInput) (*AdminEventSummary, error) {
|
||||
record, err := s.store.GetAdminEventByPublicID(ctx, strings.TrimSpace(eventPublicID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if record == nil {
|
||||
return nil, apperr.New(http.StatusNotFound, "event_not_found", "event not found")
|
||||
}
|
||||
|
||||
input.Slug = strings.TrimSpace(input.Slug)
|
||||
input.DisplayName = strings.TrimSpace(input.DisplayName)
|
||||
if input.Slug == "" || input.DisplayName == "" {
|
||||
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "slug and displayName are required")
|
||||
}
|
||||
|
||||
var tenantID *string
|
||||
clearTenant := false
|
||||
if input.TenantCode != nil {
|
||||
if trimmed := strings.TrimSpace(*input.TenantCode); trimmed != "" {
|
||||
tenant, err := s.store.GetTenantByCode(ctx, trimmed)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if tenant == nil {
|
||||
return nil, apperr.New(http.StatusNotFound, "tenant_not_found", "tenant not found")
|
||||
}
|
||||
tenantID = &tenant.ID
|
||||
} else {
|
||||
clearTenant = true
|
||||
}
|
||||
}
|
||||
|
||||
tx, err := s.store.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
updated, err := s.store.UpdateAdminEvent(ctx, tx, postgres.UpdateAdminEventParams{
|
||||
EventID: record.ID,
|
||||
TenantID: tenantID,
|
||||
Slug: input.Slug,
|
||||
DisplayName: input.DisplayName,
|
||||
Summary: trimStringPtr(input.Summary),
|
||||
Status: normalizeEventCatalogStatus(input.Status),
|
||||
ClearTenant: clearTenant,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
refreshed, err := s.store.GetAdminEventByPublicID(ctx, updated.PublicID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if refreshed == nil {
|
||||
return nil, apperr.New(http.StatusNotFound, "event_not_found", "event not found")
|
||||
}
|
||||
summary := buildAdminEventSummary(*refreshed)
|
||||
return &summary, nil
|
||||
}
|
||||
|
||||
func (s *AdminEventService) GetEventDetail(ctx context.Context, eventPublicID string) (*AdminEventDetail, error) {
|
||||
record, err := s.store.GetAdminEventByPublicID(ctx, strings.TrimSpace(eventPublicID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if record == nil {
|
||||
return nil, apperr.New(http.StatusNotFound, "event_not_found", "event not found")
|
||||
}
|
||||
|
||||
sources, err := s.store.ListEventConfigSourcesByEventID(ctx, record.ID, 1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
allSources, err := s.store.ListEventConfigSourcesByEventID(ctx, record.ID, 200)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := &AdminEventDetail{
|
||||
Event: buildAdminEventSummary(*record),
|
||||
SourceCount: len(allSources),
|
||||
}
|
||||
if len(sources) > 0 {
|
||||
latest, err := buildEventConfigSourceView(&sources[0], record.PublicID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result.LatestSource = latest
|
||||
result.CurrentSource = buildAdminAssembledSource(latest.Source)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *AdminEventService) SaveEventSource(ctx context.Context, eventPublicID string, input SaveAdminEventSourceInput) (*EventConfigSourceView, error) {
|
||||
eventRecord, err := s.store.GetAdminEventByPublicID(ctx, strings.TrimSpace(eventPublicID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if eventRecord == nil {
|
||||
return nil, apperr.New(http.StatusNotFound, "event_not_found", "event not found")
|
||||
}
|
||||
|
||||
input.GameModeCode = strings.TrimSpace(input.GameModeCode)
|
||||
input.Map.MapID = strings.TrimSpace(input.Map.MapID)
|
||||
input.Map.VersionID = strings.TrimSpace(input.Map.VersionID)
|
||||
input.Playfield.PlayfieldID = strings.TrimSpace(input.Playfield.PlayfieldID)
|
||||
input.Playfield.VersionID = strings.TrimSpace(input.Playfield.VersionID)
|
||||
if input.Map.MapID == "" || input.Map.VersionID == "" || input.Playfield.PlayfieldID == "" || input.Playfield.VersionID == "" || input.GameModeCode == "" {
|
||||
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "map, playfield and gameModeCode are required")
|
||||
}
|
||||
|
||||
mapVersion, err := s.store.GetResourceMapVersionByPublicID(ctx, input.Map.MapID, input.Map.VersionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if mapVersion == nil {
|
||||
return nil, apperr.New(http.StatusNotFound, "map_version_not_found", "map version not found")
|
||||
}
|
||||
playfieldVersion, err := s.store.GetResourcePlayfieldVersionByPublicID(ctx, input.Playfield.PlayfieldID, input.Playfield.VersionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if playfieldVersion == nil {
|
||||
return nil, apperr.New(http.StatusNotFound, "playfield_version_not_found", "playfield version not found")
|
||||
}
|
||||
|
||||
var resourcePackVersion *postgres.ResourcePackVersion
|
||||
if input.ResourcePack != nil {
|
||||
input.ResourcePack.ResourcePackID = strings.TrimSpace(input.ResourcePack.ResourcePackID)
|
||||
input.ResourcePack.VersionID = strings.TrimSpace(input.ResourcePack.VersionID)
|
||||
if input.ResourcePack.ResourcePackID == "" || input.ResourcePack.VersionID == "" {
|
||||
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "resourcePackId and versionId are required when resourcePack is provided")
|
||||
}
|
||||
resourcePackVersion, err = s.store.GetResourcePackVersionByPublicID(ctx, input.ResourcePack.ResourcePackID, input.ResourcePack.VersionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resourcePackVersion == nil {
|
||||
return nil, apperr.New(http.StatusNotFound, "resource_pack_version_not_found", "resource pack version not found")
|
||||
}
|
||||
}
|
||||
|
||||
source := s.buildEventSource(eventRecord, mapVersion, playfieldVersion, resourcePackVersion, input)
|
||||
if err := validateSourceConfig(source); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
nextVersion, err := s.store.NextEventConfigSourceVersion(ctx, eventRecord.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
note := trimStringPtr(input.Notes)
|
||||
if note == nil {
|
||||
defaultNote := fmt.Sprintf("assembled from admin refs: map=%s/%s playfield=%s/%s", input.Map.MapID, input.Map.VersionID, input.Playfield.PlayfieldID, input.Playfield.VersionID)
|
||||
note = &defaultNote
|
||||
}
|
||||
|
||||
tx, err := s.store.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
record, err := s.store.UpsertEventConfigSource(ctx, tx, postgres.UpsertEventConfigSourceParams{
|
||||
EventID: eventRecord.ID,
|
||||
SourceVersionNo: nextVersion,
|
||||
SourceKind: "admin_assembled_bundle",
|
||||
SchemaID: "event-source",
|
||||
SchemaVersion: resolveSchemaVersion(source),
|
||||
Status: "active",
|
||||
Source: source,
|
||||
Notes: note,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buildEventConfigSourceView(record, eventRecord.PublicID)
|
||||
}
|
||||
|
||||
func (s *AdminEventService) buildEventSource(event *postgres.AdminEventRecord, mapVersion *postgres.ResourceMapVersion, playfieldVersion *postgres.ResourcePlayfieldVersion, resourcePackVersion *postgres.ResourcePackVersion, input SaveAdminEventSourceInput) map[string]any {
|
||||
source := map[string]any{
|
||||
"schemaVersion": "1",
|
||||
"app": map[string]any{
|
||||
"id": event.PublicID,
|
||||
"title": event.DisplayName,
|
||||
},
|
||||
"refs": map[string]any{
|
||||
"map": map[string]any{
|
||||
"id": input.Map.MapID,
|
||||
"versionId": input.Map.VersionID,
|
||||
},
|
||||
"playfield": map[string]any{
|
||||
"id": input.Playfield.PlayfieldID,
|
||||
"versionId": input.Playfield.VersionID,
|
||||
},
|
||||
"gameMode": map[string]any{
|
||||
"code": input.GameModeCode,
|
||||
},
|
||||
},
|
||||
"map": map[string]any{
|
||||
"tiles": mapVersion.TilesRootURL,
|
||||
"mapmeta": mapVersion.MapmetaURL,
|
||||
},
|
||||
"playfield": map[string]any{
|
||||
"kind": "course",
|
||||
"source": map[string]any{
|
||||
"type": playfieldVersion.SourceType,
|
||||
"url": playfieldVersion.SourceURL,
|
||||
},
|
||||
},
|
||||
"game": map[string]any{
|
||||
"mode": input.GameModeCode,
|
||||
},
|
||||
}
|
||||
if event.Summary != nil && strings.TrimSpace(*event.Summary) != "" {
|
||||
source["summary"] = *event.Summary
|
||||
}
|
||||
if event.TenantCode != nil && strings.TrimSpace(*event.TenantCode) != "" {
|
||||
source["branding"] = map[string]any{
|
||||
"tenantCode": *event.TenantCode,
|
||||
}
|
||||
}
|
||||
if input.RouteCode != nil && strings.TrimSpace(*input.RouteCode) != "" {
|
||||
source["playfield"].(map[string]any)["metadata"] = map[string]any{
|
||||
"routeCode": strings.TrimSpace(*input.RouteCode),
|
||||
}
|
||||
}
|
||||
if resourcePackVersion != nil {
|
||||
source["refs"].(map[string]any)["resourcePack"] = map[string]any{
|
||||
"id": input.ResourcePack.ResourcePackID,
|
||||
"versionId": input.ResourcePack.VersionID,
|
||||
}
|
||||
resources := map[string]any{}
|
||||
assets := map[string]any{}
|
||||
if resourcePackVersion.ThemeProfileCode != nil && strings.TrimSpace(*resourcePackVersion.ThemeProfileCode) != "" {
|
||||
resources["themeProfile"] = *resourcePackVersion.ThemeProfileCode
|
||||
}
|
||||
if resourcePackVersion.ContentEntryURL != nil && strings.TrimSpace(*resourcePackVersion.ContentEntryURL) != "" {
|
||||
assets["contentHtml"] = *resourcePackVersion.ContentEntryURL
|
||||
}
|
||||
if resourcePackVersion.AudioRootURL != nil && strings.TrimSpace(*resourcePackVersion.AudioRootURL) != "" {
|
||||
resources["audioRoot"] = *resourcePackVersion.AudioRootURL
|
||||
}
|
||||
if len(resources) > 0 {
|
||||
source["resources"] = resources
|
||||
}
|
||||
if len(assets) > 0 {
|
||||
source["assets"] = assets
|
||||
}
|
||||
}
|
||||
if len(input.Overrides) > 0 {
|
||||
source["overrides"] = input.Overrides
|
||||
mergeJSONObject(source, input.Overrides)
|
||||
}
|
||||
return source
|
||||
}
|
||||
|
||||
func buildAdminEventSummary(item postgres.AdminEventRecord) AdminEventSummary {
|
||||
summary := AdminEventSummary{
|
||||
ID: item.PublicID,
|
||||
TenantCode: item.TenantCode,
|
||||
TenantName: item.TenantName,
|
||||
Slug: item.Slug,
|
||||
DisplayName: item.DisplayName,
|
||||
Summary: item.Summary,
|
||||
Status: item.Status,
|
||||
}
|
||||
if item.CurrentReleasePubID != nil {
|
||||
summary.CurrentRelease = &AdminEventReleaseRef{
|
||||
ID: *item.CurrentReleasePubID,
|
||||
ConfigLabel: item.ConfigLabel,
|
||||
ManifestURL: item.ManifestURL,
|
||||
ManifestChecksumSha256: item.ManifestChecksum,
|
||||
RouteCode: item.RouteCode,
|
||||
}
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
func buildAdminAssembledSource(source map[string]any) *AdminAssembledSource {
|
||||
result := &AdminAssembledSource{}
|
||||
if refs, ok := source["refs"].(map[string]any); ok {
|
||||
result.Refs = refs
|
||||
}
|
||||
runtime := cloneJSONObject(source)
|
||||
delete(runtime, "refs")
|
||||
delete(runtime, "overrides")
|
||||
if overrides, ok := source["overrides"].(map[string]any); ok && len(overrides) > 0 {
|
||||
result.Overrides = overrides
|
||||
}
|
||||
result.Runtime = runtime
|
||||
return result
|
||||
}
|
||||
|
||||
func normalizeEventCatalogStatus(value string) string {
|
||||
switch strings.TrimSpace(value) {
|
||||
case "active":
|
||||
return "active"
|
||||
case "disabled":
|
||||
return "disabled"
|
||||
case "archived":
|
||||
return "archived"
|
||||
default:
|
||||
return "draft"
|
||||
}
|
||||
}
|
||||
|
||||
func mergeJSONObject(target map[string]any, overrides map[string]any) {
|
||||
for key, value := range overrides {
|
||||
if valueMap, ok := value.(map[string]any); ok {
|
||||
existing, ok := target[key].(map[string]any)
|
||||
if !ok {
|
||||
existing = map[string]any{}
|
||||
target[key] = existing
|
||||
}
|
||||
mergeJSONObject(existing, valueMap)
|
||||
continue
|
||||
}
|
||||
target[key] = value
|
||||
}
|
||||
}
|
||||
178
backend/internal/service/admin_pipeline_service.go
Normal file
178
backend/internal/service/admin_pipeline_service.go
Normal file
@@ -0,0 +1,178 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"cmr-backend/internal/apperr"
|
||||
"cmr-backend/internal/store/postgres"
|
||||
)
|
||||
|
||||
type AdminPipelineService struct {
|
||||
store *postgres.Store
|
||||
configService *ConfigService
|
||||
}
|
||||
|
||||
type AdminReleaseView struct {
|
||||
ID string `json:"id"`
|
||||
ReleaseNo int `json:"releaseNo"`
|
||||
ConfigLabel string `json:"configLabel"`
|
||||
ManifestURL string `json:"manifestUrl"`
|
||||
ManifestChecksumSha256 *string `json:"manifestChecksumSha256,omitempty"`
|
||||
RouteCode *string `json:"routeCode,omitempty"`
|
||||
BuildID *string `json:"buildId,omitempty"`
|
||||
Status string `json:"status"`
|
||||
PublishedAt string `json:"publishedAt"`
|
||||
}
|
||||
|
||||
type AdminEventPipelineView struct {
|
||||
EventID string `json:"eventId"`
|
||||
CurrentRelease *AdminReleaseView `json:"currentRelease,omitempty"`
|
||||
Sources []EventConfigSourceView `json:"sources"`
|
||||
Builds []EventConfigBuildView `json:"builds"`
|
||||
Releases []AdminReleaseView `json:"releases"`
|
||||
}
|
||||
|
||||
type AdminRollbackReleaseInput struct {
|
||||
ReleaseID string `json:"releaseId"`
|
||||
}
|
||||
|
||||
func NewAdminPipelineService(store *postgres.Store, configService *ConfigService) *AdminPipelineService {
|
||||
return &AdminPipelineService{
|
||||
store: store,
|
||||
configService: configService,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *AdminPipelineService) GetEventPipeline(ctx context.Context, eventPublicID string, limit int) (*AdminEventPipelineView, error) {
|
||||
event, err := s.store.GetEventByPublicID(ctx, strings.TrimSpace(eventPublicID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if event == nil {
|
||||
return nil, apperr.New(http.StatusNotFound, "event_not_found", "event not found")
|
||||
}
|
||||
|
||||
sources, err := s.configService.ListEventConfigSources(ctx, event.PublicID, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
buildRecords, err := s.store.ListEventConfigBuildsByEventID(ctx, event.ID, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
releaseRecords, err := s.store.ListEventReleasesByEventID(ctx, event.ID, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
builds := make([]EventConfigBuildView, 0, len(buildRecords))
|
||||
for i := range buildRecords {
|
||||
item, err := buildEventConfigBuildView(&buildRecords[i])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
builds = append(builds, *item)
|
||||
}
|
||||
releases := make([]AdminReleaseView, 0, len(releaseRecords))
|
||||
for _, item := range releaseRecords {
|
||||
releases = append(releases, buildAdminReleaseView(item))
|
||||
}
|
||||
|
||||
result := &AdminEventPipelineView{
|
||||
EventID: event.PublicID,
|
||||
Sources: sources,
|
||||
Builds: builds,
|
||||
Releases: releases,
|
||||
}
|
||||
if event.CurrentReleasePubID != nil {
|
||||
result.CurrentRelease = &AdminReleaseView{
|
||||
ID: *event.CurrentReleasePubID,
|
||||
ConfigLabel: derefStringOrEmpty(event.ConfigLabel),
|
||||
ManifestURL: derefStringOrEmpty(event.ManifestURL),
|
||||
ManifestChecksumSha256: event.ManifestChecksum,
|
||||
RouteCode: event.RouteCode,
|
||||
Status: "published",
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *AdminPipelineService) BuildSource(ctx context.Context, sourceID string) (*EventConfigBuildView, error) {
|
||||
return s.configService.BuildPreview(ctx, BuildPreviewInput{SourceID: sourceID})
|
||||
}
|
||||
|
||||
func (s *AdminPipelineService) GetBuild(ctx context.Context, buildID string) (*EventConfigBuildView, error) {
|
||||
return s.configService.GetEventConfigBuild(ctx, buildID)
|
||||
}
|
||||
|
||||
func (s *AdminPipelineService) PublishBuild(ctx context.Context, buildID string) (*PublishedReleaseView, error) {
|
||||
return s.configService.PublishBuild(ctx, PublishBuildInput{BuildID: buildID})
|
||||
}
|
||||
|
||||
func (s *AdminPipelineService) RollbackRelease(ctx context.Context, eventPublicID string, input AdminRollbackReleaseInput) (*AdminReleaseView, error) {
|
||||
event, err := s.store.GetEventByPublicID(ctx, strings.TrimSpace(eventPublicID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if event == nil {
|
||||
return nil, apperr.New(http.StatusNotFound, "event_not_found", "event not found")
|
||||
}
|
||||
|
||||
input.ReleaseID = strings.TrimSpace(input.ReleaseID)
|
||||
if input.ReleaseID == "" {
|
||||
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "releaseId is required")
|
||||
}
|
||||
|
||||
release, err := s.store.GetEventReleaseByPublicID(ctx, input.ReleaseID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if release == nil {
|
||||
return nil, apperr.New(http.StatusNotFound, "release_not_found", "release not found")
|
||||
}
|
||||
if release.EventID != event.ID {
|
||||
return nil, apperr.New(http.StatusConflict, "release_not_belong_to_event", "release does not belong to event")
|
||||
}
|
||||
if release.Status != "published" {
|
||||
return nil, apperr.New(http.StatusConflict, "release_not_publishable", "release is not published")
|
||||
}
|
||||
|
||||
tx, err := s.store.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
if err := s.store.SetCurrentEventRelease(ctx, tx, event.ID, release.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
view := buildAdminReleaseView(*release)
|
||||
return &view, nil
|
||||
}
|
||||
|
||||
func buildAdminReleaseView(item postgres.EventRelease) AdminReleaseView {
|
||||
return AdminReleaseView{
|
||||
ID: item.PublicID,
|
||||
ReleaseNo: item.ReleaseNo,
|
||||
ConfigLabel: item.ConfigLabel,
|
||||
ManifestURL: item.ManifestURL,
|
||||
ManifestChecksumSha256: item.ManifestChecksum,
|
||||
RouteCode: item.RouteCode,
|
||||
BuildID: item.BuildID,
|
||||
Status: item.Status,
|
||||
PublishedAt: item.PublishedAt.Format(timeRFC3339),
|
||||
}
|
||||
}
|
||||
|
||||
func derefStringOrEmpty(value *string) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
return *value
|
||||
}
|
||||
719
backend/internal/service/admin_resource_service.go
Normal file
719
backend/internal/service/admin_resource_service.go
Normal file
@@ -0,0 +1,719 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"cmr-backend/internal/apperr"
|
||||
"cmr-backend/internal/platform/security"
|
||||
"cmr-backend/internal/store/postgres"
|
||||
)
|
||||
|
||||
type AdminResourceService struct {
|
||||
store *postgres.Store
|
||||
}
|
||||
|
||||
type AdminMapSummary struct {
|
||||
ID string `json:"id"`
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
CurrentVersionID *string `json:"currentVersionId,omitempty"`
|
||||
CurrentVersion *AdminMapVersionBrief `json:"currentVersion,omitempty"`
|
||||
}
|
||||
|
||||
type AdminMapVersionBrief struct {
|
||||
ID string `json:"id"`
|
||||
VersionCode string `json:"versionCode"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type AdminMapVersion struct {
|
||||
ID string `json:"id"`
|
||||
VersionCode string `json:"versionCode"`
|
||||
Status string `json:"status"`
|
||||
MapmetaURL string `json:"mapmetaUrl"`
|
||||
TilesRootURL string `json:"tilesRootUrl"`
|
||||
PublishedAssetRoot *string `json:"publishedAssetRoot,omitempty"`
|
||||
Bounds map[string]any `json:"bounds,omitempty"`
|
||||
Metadata map[string]any `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
type AdminMapDetail struct {
|
||||
Map AdminMapSummary `json:"map"`
|
||||
Versions []AdminMapVersion `json:"versions"`
|
||||
}
|
||||
|
||||
type CreateAdminMapInput struct {
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
type CreateAdminMapVersionInput struct {
|
||||
VersionCode string `json:"versionCode"`
|
||||
Status string `json:"status"`
|
||||
MapmetaURL string `json:"mapmetaUrl"`
|
||||
TilesRootURL string `json:"tilesRootUrl"`
|
||||
PublishedAssetRoot *string `json:"publishedAssetRoot,omitempty"`
|
||||
Bounds map[string]any `json:"bounds,omitempty"`
|
||||
Metadata map[string]any `json:"metadata,omitempty"`
|
||||
SetAsCurrent bool `json:"setAsCurrent"`
|
||||
}
|
||||
|
||||
type AdminPlayfieldSummary struct {
|
||||
ID string `json:"id"`
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"`
|
||||
Status string `json:"status"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
CurrentVersionID *string `json:"currentVersionId,omitempty"`
|
||||
CurrentVersion *AdminPlayfieldVersionBrief `json:"currentVersion,omitempty"`
|
||||
}
|
||||
|
||||
type AdminPlayfieldVersionBrief struct {
|
||||
ID string `json:"id"`
|
||||
VersionCode string `json:"versionCode"`
|
||||
Status string `json:"status"`
|
||||
SourceType string `json:"sourceType"`
|
||||
}
|
||||
|
||||
type AdminPlayfieldVersion struct {
|
||||
ID string `json:"id"`
|
||||
VersionCode string `json:"versionCode"`
|
||||
Status string `json:"status"`
|
||||
SourceType string `json:"sourceType"`
|
||||
SourceURL string `json:"sourceUrl"`
|
||||
PublishedAssetRoot *string `json:"publishedAssetRoot,omitempty"`
|
||||
ControlCount *int `json:"controlCount,omitempty"`
|
||||
Bounds map[string]any `json:"bounds,omitempty"`
|
||||
Metadata map[string]any `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
type AdminPlayfieldDetail struct {
|
||||
Playfield AdminPlayfieldSummary `json:"playfield"`
|
||||
Versions []AdminPlayfieldVersion `json:"versions"`
|
||||
}
|
||||
|
||||
type CreateAdminPlayfieldInput struct {
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"`
|
||||
Status string `json:"status"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
type CreateAdminPlayfieldVersionInput struct {
|
||||
VersionCode string `json:"versionCode"`
|
||||
Status string `json:"status"`
|
||||
SourceType string `json:"sourceType"`
|
||||
SourceURL string `json:"sourceUrl"`
|
||||
PublishedAssetRoot *string `json:"publishedAssetRoot,omitempty"`
|
||||
ControlCount *int `json:"controlCount,omitempty"`
|
||||
Bounds map[string]any `json:"bounds,omitempty"`
|
||||
Metadata map[string]any `json:"metadata,omitempty"`
|
||||
SetAsCurrent bool `json:"setAsCurrent"`
|
||||
}
|
||||
|
||||
type AdminResourcePackSummary struct {
|
||||
ID string `json:"id"`
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
CurrentVersionID *string `json:"currentVersionId,omitempty"`
|
||||
CurrentVersion *AdminResourcePackVersionBrief `json:"currentVersion,omitempty"`
|
||||
}
|
||||
|
||||
type AdminResourcePackVersionBrief struct {
|
||||
ID string `json:"id"`
|
||||
VersionCode string `json:"versionCode"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type AdminResourcePackVersion struct {
|
||||
ID string `json:"id"`
|
||||
VersionCode string `json:"versionCode"`
|
||||
Status string `json:"status"`
|
||||
ContentEntryURL *string `json:"contentEntryUrl,omitempty"`
|
||||
AudioRootURL *string `json:"audioRootUrl,omitempty"`
|
||||
ThemeProfileCode *string `json:"themeProfileCode,omitempty"`
|
||||
PublishedAssetRoot *string `json:"publishedAssetRoot,omitempty"`
|
||||
Metadata map[string]any `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
type AdminResourcePackDetail struct {
|
||||
ResourcePack AdminResourcePackSummary `json:"resourcePack"`
|
||||
Versions []AdminResourcePackVersion `json:"versions"`
|
||||
}
|
||||
|
||||
type CreateAdminResourcePackInput struct {
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
type CreateAdminResourcePackVersionInput struct {
|
||||
VersionCode string `json:"versionCode"`
|
||||
Status string `json:"status"`
|
||||
ContentEntryURL *string `json:"contentEntryUrl,omitempty"`
|
||||
AudioRootURL *string `json:"audioRootUrl,omitempty"`
|
||||
ThemeProfileCode *string `json:"themeProfileCode,omitempty"`
|
||||
PublishedAssetRoot *string `json:"publishedAssetRoot,omitempty"`
|
||||
Metadata map[string]any `json:"metadata,omitempty"`
|
||||
SetAsCurrent bool `json:"setAsCurrent"`
|
||||
}
|
||||
|
||||
func NewAdminResourceService(store *postgres.Store) *AdminResourceService {
|
||||
return &AdminResourceService{store: store}
|
||||
}
|
||||
|
||||
func (s *AdminResourceService) ListMaps(ctx context.Context, limit int) ([]AdminMapSummary, error) {
|
||||
items, err := s.store.ListResourceMaps(ctx, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
results := make([]AdminMapSummary, 0, len(items))
|
||||
for _, item := range items {
|
||||
results = append(results, AdminMapSummary{
|
||||
ID: item.PublicID,
|
||||
Code: item.Code,
|
||||
Name: item.Name,
|
||||
Status: item.Status,
|
||||
Description: item.Description,
|
||||
CurrentVersionID: item.CurrentVersionID,
|
||||
})
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func (s *AdminResourceService) CreateMap(ctx context.Context, input CreateAdminMapInput) (*AdminMapSummary, error) {
|
||||
input.Code = strings.TrimSpace(input.Code)
|
||||
input.Name = strings.TrimSpace(input.Name)
|
||||
status := normalizeCatalogStatus(input.Status)
|
||||
if input.Code == "" || input.Name == "" {
|
||||
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "code and name are required")
|
||||
}
|
||||
|
||||
publicID, err := security.GeneratePublicID("map")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tx, err := s.store.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
item, err := s.store.CreateResourceMap(ctx, tx, postgres.CreateResourceMapParams{
|
||||
PublicID: publicID,
|
||||
Code: input.Code,
|
||||
Name: input.Name,
|
||||
Status: status,
|
||||
Description: trimStringPtr(input.Description),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &AdminMapSummary{
|
||||
ID: item.PublicID,
|
||||
Code: item.Code,
|
||||
Name: item.Name,
|
||||
Status: item.Status,
|
||||
Description: item.Description,
|
||||
CurrentVersionID: item.CurrentVersionID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *AdminResourceService) GetMapDetail(ctx context.Context, mapPublicID string) (*AdminMapDetail, error) {
|
||||
item, err := s.store.GetResourceMapByPublicID(ctx, strings.TrimSpace(mapPublicID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if item == nil {
|
||||
return nil, apperr.New(http.StatusNotFound, "map_not_found", "map not found")
|
||||
}
|
||||
versions, err := s.store.ListResourceMapVersions(ctx, item.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := &AdminMapDetail{
|
||||
Map: AdminMapSummary{
|
||||
ID: item.PublicID,
|
||||
Code: item.Code,
|
||||
Name: item.Name,
|
||||
Status: item.Status,
|
||||
Description: item.Description,
|
||||
CurrentVersionID: item.CurrentVersionID,
|
||||
},
|
||||
Versions: make([]AdminMapVersion, 0, len(versions)),
|
||||
}
|
||||
for _, version := range versions {
|
||||
view := AdminMapVersion{
|
||||
ID: version.PublicID,
|
||||
VersionCode: version.VersionCode,
|
||||
Status: version.Status,
|
||||
MapmetaURL: version.MapmetaURL,
|
||||
TilesRootURL: version.TilesRootURL,
|
||||
PublishedAssetRoot: version.PublishedAssetRoot,
|
||||
Bounds: decodeJSONMap(version.BoundsJSON),
|
||||
Metadata: decodeJSONMap(version.MetadataJSON),
|
||||
}
|
||||
result.Versions = append(result.Versions, view)
|
||||
if item.CurrentVersionID != nil && *item.CurrentVersionID == version.ID {
|
||||
result.Map.CurrentVersion = &AdminMapVersionBrief{
|
||||
ID: version.PublicID,
|
||||
VersionCode: version.VersionCode,
|
||||
Status: version.Status,
|
||||
}
|
||||
result.Map.CurrentVersionID = &view.ID
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *AdminResourceService) CreateMapVersion(ctx context.Context, mapPublicID string, input CreateAdminMapVersionInput) (*AdminMapVersion, error) {
|
||||
mapItem, err := s.store.GetResourceMapByPublicID(ctx, strings.TrimSpace(mapPublicID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if mapItem == nil {
|
||||
return nil, apperr.New(http.StatusNotFound, "map_not_found", "map not found")
|
||||
}
|
||||
input.VersionCode = strings.TrimSpace(input.VersionCode)
|
||||
input.MapmetaURL = strings.TrimSpace(input.MapmetaURL)
|
||||
input.TilesRootURL = strings.TrimSpace(input.TilesRootURL)
|
||||
if input.VersionCode == "" || input.MapmetaURL == "" || input.TilesRootURL == "" {
|
||||
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "versionCode, mapmetaUrl and tilesRootUrl are required")
|
||||
}
|
||||
|
||||
publicID, err := security.GeneratePublicID("mapv")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tx, err := s.store.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
version, err := s.store.CreateResourceMapVersion(ctx, tx, postgres.CreateResourceMapVersionParams{
|
||||
PublicID: publicID,
|
||||
MapID: mapItem.ID,
|
||||
VersionCode: input.VersionCode,
|
||||
Status: normalizeVersionStatus(input.Status),
|
||||
MapmetaURL: input.MapmetaURL,
|
||||
TilesRootURL: input.TilesRootURL,
|
||||
PublishedAssetRoot: trimStringPtr(input.PublishedAssetRoot),
|
||||
BoundsJSON: input.Bounds,
|
||||
MetadataJSON: input.Metadata,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if input.SetAsCurrent {
|
||||
if err := s.store.SetResourceMapCurrentVersion(ctx, tx, mapItem.ID, version.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &AdminMapVersion{
|
||||
ID: version.PublicID,
|
||||
VersionCode: version.VersionCode,
|
||||
Status: version.Status,
|
||||
MapmetaURL: version.MapmetaURL,
|
||||
TilesRootURL: version.TilesRootURL,
|
||||
PublishedAssetRoot: version.PublishedAssetRoot,
|
||||
Bounds: decodeJSONMap(version.BoundsJSON),
|
||||
Metadata: decodeJSONMap(version.MetadataJSON),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *AdminResourceService) ListPlayfields(ctx context.Context, limit int) ([]AdminPlayfieldSummary, error) {
|
||||
items, err := s.store.ListResourcePlayfields(ctx, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
results := make([]AdminPlayfieldSummary, 0, len(items))
|
||||
for _, item := range items {
|
||||
results = append(results, AdminPlayfieldSummary{
|
||||
ID: item.PublicID,
|
||||
Code: item.Code,
|
||||
Name: item.Name,
|
||||
Kind: item.Kind,
|
||||
Status: item.Status,
|
||||
Description: item.Description,
|
||||
CurrentVersionID: item.CurrentVersionID,
|
||||
})
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func (s *AdminResourceService) CreatePlayfield(ctx context.Context, input CreateAdminPlayfieldInput) (*AdminPlayfieldSummary, error) {
|
||||
input.Code = strings.TrimSpace(input.Code)
|
||||
input.Name = strings.TrimSpace(input.Name)
|
||||
kind := strings.TrimSpace(input.Kind)
|
||||
if kind == "" {
|
||||
kind = "course"
|
||||
}
|
||||
if input.Code == "" || input.Name == "" {
|
||||
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "code and name are required")
|
||||
}
|
||||
publicID, err := security.GeneratePublicID("pf")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tx, err := s.store.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
item, err := s.store.CreateResourcePlayfield(ctx, tx, postgres.CreateResourcePlayfieldParams{
|
||||
PublicID: publicID,
|
||||
Code: input.Code,
|
||||
Name: input.Name,
|
||||
Kind: kind,
|
||||
Status: normalizeCatalogStatus(input.Status),
|
||||
Description: trimStringPtr(input.Description),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &AdminPlayfieldSummary{
|
||||
ID: item.PublicID,
|
||||
Code: item.Code,
|
||||
Name: item.Name,
|
||||
Kind: item.Kind,
|
||||
Status: item.Status,
|
||||
Description: item.Description,
|
||||
CurrentVersionID: item.CurrentVersionID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *AdminResourceService) GetPlayfieldDetail(ctx context.Context, publicID string) (*AdminPlayfieldDetail, error) {
|
||||
item, err := s.store.GetResourcePlayfieldByPublicID(ctx, strings.TrimSpace(publicID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if item == nil {
|
||||
return nil, apperr.New(http.StatusNotFound, "playfield_not_found", "playfield not found")
|
||||
}
|
||||
versions, err := s.store.ListResourcePlayfieldVersions(ctx, item.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := &AdminPlayfieldDetail{
|
||||
Playfield: AdminPlayfieldSummary{
|
||||
ID: item.PublicID,
|
||||
Code: item.Code,
|
||||
Name: item.Name,
|
||||
Kind: item.Kind,
|
||||
Status: item.Status,
|
||||
Description: item.Description,
|
||||
CurrentVersionID: item.CurrentVersionID,
|
||||
},
|
||||
Versions: make([]AdminPlayfieldVersion, 0, len(versions)),
|
||||
}
|
||||
for _, version := range versions {
|
||||
view := AdminPlayfieldVersion{
|
||||
ID: version.PublicID,
|
||||
VersionCode: version.VersionCode,
|
||||
Status: version.Status,
|
||||
SourceType: version.SourceType,
|
||||
SourceURL: version.SourceURL,
|
||||
PublishedAssetRoot: version.PublishedAssetRoot,
|
||||
ControlCount: version.ControlCount,
|
||||
Bounds: decodeJSONMap(version.BoundsJSON),
|
||||
Metadata: decodeJSONMap(version.MetadataJSON),
|
||||
}
|
||||
result.Versions = append(result.Versions, view)
|
||||
if item.CurrentVersionID != nil && *item.CurrentVersionID == version.ID {
|
||||
result.Playfield.CurrentVersion = &AdminPlayfieldVersionBrief{
|
||||
ID: version.PublicID,
|
||||
VersionCode: version.VersionCode,
|
||||
Status: version.Status,
|
||||
SourceType: version.SourceType,
|
||||
}
|
||||
result.Playfield.CurrentVersionID = &view.ID
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *AdminResourceService) CreatePlayfieldVersion(ctx context.Context, publicID string, input CreateAdminPlayfieldVersionInput) (*AdminPlayfieldVersion, error) {
|
||||
item, err := s.store.GetResourcePlayfieldByPublicID(ctx, strings.TrimSpace(publicID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if item == nil {
|
||||
return nil, apperr.New(http.StatusNotFound, "playfield_not_found", "playfield not found")
|
||||
}
|
||||
input.VersionCode = strings.TrimSpace(input.VersionCode)
|
||||
input.SourceType = strings.TrimSpace(input.SourceType)
|
||||
input.SourceURL = strings.TrimSpace(input.SourceURL)
|
||||
if input.VersionCode == "" || input.SourceType == "" || input.SourceURL == "" {
|
||||
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "versionCode, sourceType and sourceUrl are required")
|
||||
}
|
||||
publicVersionID, err := security.GeneratePublicID("pfv")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tx, err := s.store.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
version, err := s.store.CreateResourcePlayfieldVersion(ctx, tx, postgres.CreateResourcePlayfieldVersionParams{
|
||||
PublicID: publicVersionID,
|
||||
PlayfieldID: item.ID,
|
||||
VersionCode: input.VersionCode,
|
||||
Status: normalizeVersionStatus(input.Status),
|
||||
SourceType: input.SourceType,
|
||||
SourceURL: input.SourceURL,
|
||||
PublishedAssetRoot: trimStringPtr(input.PublishedAssetRoot),
|
||||
ControlCount: input.ControlCount,
|
||||
BoundsJSON: input.Bounds,
|
||||
MetadataJSON: input.Metadata,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if input.SetAsCurrent {
|
||||
if err := s.store.SetResourcePlayfieldCurrentVersion(ctx, tx, item.ID, version.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &AdminPlayfieldVersion{
|
||||
ID: version.PublicID,
|
||||
VersionCode: version.VersionCode,
|
||||
Status: version.Status,
|
||||
SourceType: version.SourceType,
|
||||
SourceURL: version.SourceURL,
|
||||
PublishedAssetRoot: version.PublishedAssetRoot,
|
||||
ControlCount: version.ControlCount,
|
||||
Bounds: decodeJSONMap(version.BoundsJSON),
|
||||
Metadata: decodeJSONMap(version.MetadataJSON),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *AdminResourceService) ListResourcePacks(ctx context.Context, limit int) ([]AdminResourcePackSummary, error) {
|
||||
items, err := s.store.ListResourcePacks(ctx, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
results := make([]AdminResourcePackSummary, 0, len(items))
|
||||
for _, item := range items {
|
||||
results = append(results, AdminResourcePackSummary{
|
||||
ID: item.PublicID,
|
||||
Code: item.Code,
|
||||
Name: item.Name,
|
||||
Status: item.Status,
|
||||
Description: item.Description,
|
||||
CurrentVersionID: item.CurrentVersionID,
|
||||
})
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func (s *AdminResourceService) CreateResourcePack(ctx context.Context, input CreateAdminResourcePackInput) (*AdminResourcePackSummary, error) {
|
||||
input.Code = strings.TrimSpace(input.Code)
|
||||
input.Name = strings.TrimSpace(input.Name)
|
||||
if input.Code == "" || input.Name == "" {
|
||||
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "code and name are required")
|
||||
}
|
||||
publicID, err := security.GeneratePublicID("rp")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tx, err := s.store.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
item, err := s.store.CreateResourcePack(ctx, tx, postgres.CreateResourcePackParams{
|
||||
PublicID: publicID,
|
||||
Code: input.Code,
|
||||
Name: input.Name,
|
||||
Status: normalizeCatalogStatus(input.Status),
|
||||
Description: trimStringPtr(input.Description),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &AdminResourcePackSummary{
|
||||
ID: item.PublicID,
|
||||
Code: item.Code,
|
||||
Name: item.Name,
|
||||
Status: item.Status,
|
||||
Description: item.Description,
|
||||
CurrentVersionID: item.CurrentVersionID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *AdminResourceService) GetResourcePackDetail(ctx context.Context, publicID string) (*AdminResourcePackDetail, error) {
|
||||
item, err := s.store.GetResourcePackByPublicID(ctx, strings.TrimSpace(publicID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if item == nil {
|
||||
return nil, apperr.New(http.StatusNotFound, "resource_pack_not_found", "resource pack not found")
|
||||
}
|
||||
versions, err := s.store.ListResourcePackVersions(ctx, item.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := &AdminResourcePackDetail{
|
||||
ResourcePack: AdminResourcePackSummary{
|
||||
ID: item.PublicID,
|
||||
Code: item.Code,
|
||||
Name: item.Name,
|
||||
Status: item.Status,
|
||||
Description: item.Description,
|
||||
CurrentVersionID: item.CurrentVersionID,
|
||||
},
|
||||
Versions: make([]AdminResourcePackVersion, 0, len(versions)),
|
||||
}
|
||||
for _, version := range versions {
|
||||
view := AdminResourcePackVersion{
|
||||
ID: version.PublicID,
|
||||
VersionCode: version.VersionCode,
|
||||
Status: version.Status,
|
||||
ContentEntryURL: version.ContentEntryURL,
|
||||
AudioRootURL: version.AudioRootURL,
|
||||
ThemeProfileCode: version.ThemeProfileCode,
|
||||
PublishedAssetRoot: version.PublishedAssetRoot,
|
||||
Metadata: decodeJSONMap(version.MetadataJSON),
|
||||
}
|
||||
result.Versions = append(result.Versions, view)
|
||||
if item.CurrentVersionID != nil && *item.CurrentVersionID == version.ID {
|
||||
result.ResourcePack.CurrentVersion = &AdminResourcePackVersionBrief{
|
||||
ID: version.PublicID,
|
||||
VersionCode: version.VersionCode,
|
||||
Status: version.Status,
|
||||
}
|
||||
result.ResourcePack.CurrentVersionID = &view.ID
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *AdminResourceService) CreateResourcePackVersion(ctx context.Context, publicID string, input CreateAdminResourcePackVersionInput) (*AdminResourcePackVersion, error) {
|
||||
item, err := s.store.GetResourcePackByPublicID(ctx, strings.TrimSpace(publicID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if item == nil {
|
||||
return nil, apperr.New(http.StatusNotFound, "resource_pack_not_found", "resource pack not found")
|
||||
}
|
||||
input.VersionCode = strings.TrimSpace(input.VersionCode)
|
||||
if input.VersionCode == "" {
|
||||
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "versionCode is required")
|
||||
}
|
||||
publicVersionID, err := security.GeneratePublicID("rpv")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tx, err := s.store.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
version, err := s.store.CreateResourcePackVersion(ctx, tx, postgres.CreateResourcePackVersionParams{
|
||||
PublicID: publicVersionID,
|
||||
ResourcePackID: item.ID,
|
||||
VersionCode: input.VersionCode,
|
||||
Status: normalizeVersionStatus(input.Status),
|
||||
ContentEntryURL: trimStringPtr(input.ContentEntryURL),
|
||||
AudioRootURL: trimStringPtr(input.AudioRootURL),
|
||||
ThemeProfileCode: trimStringPtr(input.ThemeProfileCode),
|
||||
PublishedAssetRoot: trimStringPtr(input.PublishedAssetRoot),
|
||||
MetadataJSON: input.Metadata,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if input.SetAsCurrent {
|
||||
if err := s.store.SetResourcePackCurrentVersion(ctx, tx, item.ID, version.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &AdminResourcePackVersion{
|
||||
ID: version.PublicID,
|
||||
VersionCode: version.VersionCode,
|
||||
Status: version.Status,
|
||||
ContentEntryURL: version.ContentEntryURL,
|
||||
AudioRootURL: version.AudioRootURL,
|
||||
ThemeProfileCode: version.ThemeProfileCode,
|
||||
PublishedAssetRoot: version.PublishedAssetRoot,
|
||||
Metadata: decodeJSONMap(version.MetadataJSON),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func normalizeCatalogStatus(value string) string {
|
||||
switch strings.TrimSpace(value) {
|
||||
case "active":
|
||||
return "active"
|
||||
case "disabled":
|
||||
return "disabled"
|
||||
case "archived":
|
||||
return "archived"
|
||||
default:
|
||||
return "draft"
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeVersionStatus(value string) string {
|
||||
switch strings.TrimSpace(value) {
|
||||
case "active":
|
||||
return "active"
|
||||
case "archived":
|
||||
return "archived"
|
||||
default:
|
||||
return "draft"
|
||||
}
|
||||
}
|
||||
|
||||
func trimStringPtr(value *string) *string {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
trimmed := strings.TrimSpace(*value)
|
||||
if trimmed == "" {
|
||||
return nil
|
||||
}
|
||||
return &trimmed
|
||||
}
|
||||
|
||||
func decodeJSONMap(raw json.RawMessage) map[string]any {
|
||||
if len(raw) == 0 {
|
||||
return nil
|
||||
}
|
||||
result := map[string]any{}
|
||||
if err := json.Unmarshal(raw, &result); err != nil || len(result) == 0 {
|
||||
return nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -127,7 +127,7 @@ func (s *EntryHomeService) GetEntryHome(ctx context.Context, input EntryHomeInpu
|
||||
}
|
||||
|
||||
for i := range sessions {
|
||||
if sessions[i].Status == "launched" || sessions[i].Status == "running" {
|
||||
if isSessionOngoingStatus(sessions[i].Status) {
|
||||
ongoing := buildEntrySessionSummary(&sessions[i])
|
||||
result.OngoingSession = &ongoing
|
||||
break
|
||||
|
||||
@@ -99,7 +99,7 @@ func (s *EventPlayService) GetEventPlay(ctx context.Context, input EventPlayInpu
|
||||
result.Play.RecentSession = &recent
|
||||
}
|
||||
for i := range sessions {
|
||||
if sessions[i].Status == "launched" || sessions[i].Status == "running" {
|
||||
if isSessionOngoingStatus(sessions[i].Status) {
|
||||
ongoing := buildEntrySessionSummary(&sessions[i])
|
||||
result.Play.OngoingSession = &ongoing
|
||||
break
|
||||
|
||||
@@ -16,6 +16,10 @@ type SessionService struct {
|
||||
store *postgres.Store
|
||||
}
|
||||
|
||||
type sessionTokenPolicy struct {
|
||||
AllowExpired bool
|
||||
}
|
||||
|
||||
type SessionResult struct {
|
||||
Session struct {
|
||||
ID string `json:"id"`
|
||||
@@ -99,57 +103,11 @@ func (s *SessionService) ListMySessions(ctx context.Context, userID string, limi
|
||||
}
|
||||
|
||||
func (s *SessionService) StartSession(ctx context.Context, input SessionActionInput) (*SessionResult, error) {
|
||||
session, err := s.validateSessionAction(ctx, input.SessionPublicID, input.SessionToken)
|
||||
session, err := s.validateSessionAction(ctx, input.SessionPublicID, input.SessionToken, sessionTokenPolicy{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if session.Status == "finished" || session.Status == "cancelled" || session.Status == "failed" {
|
||||
return nil, apperr.New(http.StatusConflict, "session_not_startable", "session cannot be started")
|
||||
}
|
||||
|
||||
tx, err := s.store.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
locked, err := s.store.GetSessionByPublicIDForUpdate(ctx, tx, input.SessionPublicID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if locked == nil {
|
||||
return nil, apperr.New(http.StatusNotFound, "session_not_found", "session not found")
|
||||
}
|
||||
if err := s.verifySessionToken(locked, input.SessionToken); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if locked.Status == "finished" || locked.Status == "cancelled" || locked.Status == "failed" {
|
||||
return nil, apperr.New(http.StatusConflict, "session_not_startable", "session cannot be started")
|
||||
}
|
||||
|
||||
if err := s.store.StartSession(ctx, tx, locked.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
updated, err := s.store.GetSessionByPublicIDForUpdate(ctx, tx, input.SessionPublicID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buildSessionResult(updated), nil
|
||||
}
|
||||
|
||||
func (s *SessionService) FinishSession(ctx context.Context, input FinishSessionInput) (*SessionResult, error) {
|
||||
input.Status = normalizeFinishStatus(input.Status)
|
||||
session, err := s.validateSessionAction(ctx, input.SessionPublicID, input.SessionToken)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if session.Status == "finished" || session.Status == "cancelled" || session.Status == "failed" {
|
||||
if session.Status == SessionStatusRunning || isSessionTerminalStatus(session.Status) {
|
||||
return buildSessionResult(session), nil
|
||||
}
|
||||
|
||||
@@ -166,11 +124,73 @@ func (s *SessionService) FinishSession(ctx context.Context, input FinishSessionI
|
||||
if locked == nil {
|
||||
return nil, apperr.New(http.StatusNotFound, "session_not_found", "session not found")
|
||||
}
|
||||
if err := s.verifySessionToken(locked, input.SessionToken); err != nil {
|
||||
if err := s.verifySessionToken(locked, input.SessionToken, sessionTokenPolicy{}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if locked.Status == SessionStatusRunning || isSessionTerminalStatus(locked.Status) {
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buildSessionResult(locked), nil
|
||||
}
|
||||
|
||||
if locked.Status == SessionStatusLaunched {
|
||||
if err := s.store.StartSession(ctx, tx, locked.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
updated, err := s.store.GetSessionByPublicIDForUpdate(ctx, tx, input.SessionPublicID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if updated == nil {
|
||||
return nil, apperr.New(http.StatusNotFound, "session_not_found", "session not found")
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buildSessionResult(updated), nil
|
||||
}
|
||||
|
||||
func (s *SessionService) FinishSession(ctx context.Context, input FinishSessionInput) (*SessionResult, error) {
|
||||
status, err := normalizeFinishStatus(input.Status)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
input.Status = status
|
||||
|
||||
session, err := s.validateSessionAction(ctx, input.SessionPublicID, input.SessionToken, sessionTokenPolicy{
|
||||
AllowExpired: input.Status == SessionStatusCancelled,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if locked.Status == "finished" || locked.Status == "cancelled" || locked.Status == "failed" {
|
||||
if isSessionTerminalStatus(session.Status) {
|
||||
return buildSessionResult(session), nil
|
||||
}
|
||||
|
||||
tx, err := s.store.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
locked, err := s.store.GetSessionByPublicIDForUpdate(ctx, tx, input.SessionPublicID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if locked == nil {
|
||||
return nil, apperr.New(http.StatusNotFound, "session_not_found", "session not found")
|
||||
}
|
||||
if err := s.verifySessionToken(locked, input.SessionToken, sessionTokenPolicy{
|
||||
AllowExpired: input.Status == SessionStatusCancelled || isSessionTerminalStatus(locked.Status),
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if isSessionTerminalStatus(locked.Status) {
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -208,7 +228,7 @@ func (s *SessionService) FinishSession(ctx context.Context, input FinishSessionI
|
||||
return buildSessionResult(updated), nil
|
||||
}
|
||||
|
||||
func (s *SessionService) validateSessionAction(ctx context.Context, sessionPublicID, sessionToken string) (*postgres.Session, error) {
|
||||
func (s *SessionService) validateSessionAction(ctx context.Context, sessionPublicID, sessionToken string, policy sessionTokenPolicy) (*postgres.Session, error) {
|
||||
sessionPublicID = strings.TrimSpace(sessionPublicID)
|
||||
sessionToken = strings.TrimSpace(sessionToken)
|
||||
if sessionPublicID == "" || sessionToken == "" {
|
||||
@@ -222,19 +242,19 @@ func (s *SessionService) validateSessionAction(ctx context.Context, sessionPubli
|
||||
if session == nil {
|
||||
return nil, apperr.New(http.StatusNotFound, "session_not_found", "session not found")
|
||||
}
|
||||
if err := s.verifySessionToken(session, sessionToken); err != nil {
|
||||
if err := s.verifySessionToken(session, sessionToken, policy); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return session, nil
|
||||
}
|
||||
|
||||
func (s *SessionService) verifySessionToken(session *postgres.Session, sessionToken string) error {
|
||||
if session.SessionTokenExpiresAt.Before(time.Now().UTC()) {
|
||||
return apperr.New(http.StatusUnauthorized, "session_token_expired", "session token expired")
|
||||
}
|
||||
func (s *SessionService) verifySessionToken(session *postgres.Session, sessionToken string, policy sessionTokenPolicy) error {
|
||||
if session.SessionTokenHash != security.HashText(sessionToken) {
|
||||
return apperr.New(http.StatusUnauthorized, "invalid_session_token", "invalid session token")
|
||||
}
|
||||
if !policy.AllowExpired && session.SessionTokenExpiresAt.Before(time.Now().UTC()) {
|
||||
return apperr.New(http.StatusUnauthorized, "session_token_expired", "session token expired")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -265,14 +285,16 @@ func buildSessionResult(session *postgres.Session) *SessionResult {
|
||||
return result
|
||||
}
|
||||
|
||||
func normalizeFinishStatus(value string) string {
|
||||
func normalizeFinishStatus(value string) (string, error) {
|
||||
switch strings.TrimSpace(value) {
|
||||
case "failed":
|
||||
return "failed"
|
||||
case "cancelled":
|
||||
return "cancelled"
|
||||
case "", SessionStatusFinished:
|
||||
return SessionStatusFinished, nil
|
||||
case SessionStatusFailed:
|
||||
return SessionStatusFailed, nil
|
||||
case SessionStatusCancelled:
|
||||
return SessionStatusCancelled, nil
|
||||
default:
|
||||
return "finished"
|
||||
return "", apperr.New(http.StatusBadRequest, "invalid_finish_status", "status must be finished, failed or cancelled")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
27
backend/internal/service/session_status.go
Normal file
27
backend/internal/service/session_status.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package service
|
||||
|
||||
const (
|
||||
SessionStatusLaunched = "launched"
|
||||
SessionStatusRunning = "running"
|
||||
SessionStatusFinished = "finished"
|
||||
SessionStatusFailed = "failed"
|
||||
SessionStatusCancelled = "cancelled"
|
||||
)
|
||||
|
||||
func isSessionTerminalStatus(status string) bool {
|
||||
switch status {
|
||||
case SessionStatusFinished, SessionStatusFailed, SessionStatusCancelled:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isSessionOngoingStatus(status string) bool {
|
||||
switch status {
|
||||
case SessionStatusLaunched, SessionStatusRunning:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
248
backend/internal/store/postgres/admin_event_store.go
Normal file
248
backend/internal/store/postgres/admin_event_store.go
Normal file
@@ -0,0 +1,248 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
type Tenant struct {
|
||||
ID string
|
||||
TenantCode string
|
||||
Name string
|
||||
Status string
|
||||
}
|
||||
|
||||
type AdminEventRecord struct {
|
||||
ID string
|
||||
PublicID string
|
||||
TenantID *string
|
||||
TenantCode *string
|
||||
TenantName *string
|
||||
Slug string
|
||||
DisplayName string
|
||||
Summary *string
|
||||
Status string
|
||||
CurrentReleaseID *string
|
||||
CurrentReleasePubID *string
|
||||
ConfigLabel *string
|
||||
ManifestURL *string
|
||||
ManifestChecksum *string
|
||||
RouteCode *string
|
||||
}
|
||||
|
||||
type CreateAdminEventParams struct {
|
||||
PublicID string
|
||||
TenantID *string
|
||||
Slug string
|
||||
DisplayName string
|
||||
Summary *string
|
||||
Status string
|
||||
}
|
||||
|
||||
type UpdateAdminEventParams struct {
|
||||
EventID string
|
||||
TenantID *string
|
||||
Slug string
|
||||
DisplayName string
|
||||
Summary *string
|
||||
Status string
|
||||
ClearTenant bool
|
||||
}
|
||||
|
||||
func (s *Store) GetTenantByCode(ctx context.Context, tenantCode string) (*Tenant, error) {
|
||||
row := s.pool.QueryRow(ctx, `
|
||||
SELECT id, tenant_code, name, status
|
||||
FROM tenants
|
||||
WHERE tenant_code = $1
|
||||
LIMIT 1
|
||||
`, tenantCode)
|
||||
var item Tenant
|
||||
err := row.Scan(&item.ID, &item.TenantCode, &item.Name, &item.Status)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get tenant by code: %w", err)
|
||||
}
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListAdminEvents(ctx context.Context, limit int) ([]AdminEventRecord, error) {
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 50
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT
|
||||
e.id,
|
||||
e.event_public_id,
|
||||
e.tenant_id,
|
||||
t.tenant_code,
|
||||
t.name,
|
||||
e.slug,
|
||||
e.display_name,
|
||||
e.summary,
|
||||
e.status,
|
||||
e.current_release_id,
|
||||
er.release_public_id,
|
||||
er.config_label,
|
||||
er.manifest_url,
|
||||
er.manifest_checksum_sha256,
|
||||
er.route_code
|
||||
FROM events e
|
||||
LEFT JOIN tenants t ON t.id = e.tenant_id
|
||||
LEFT JOIN event_releases er ON er.id = e.current_release_id
|
||||
ORDER BY e.created_at DESC
|
||||
LIMIT $1
|
||||
`, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list admin events: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
items := []AdminEventRecord{}
|
||||
for rows.Next() {
|
||||
item, err := scanAdminEventFromRows(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, *item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate admin events: %w", err)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetAdminEventByPublicID(ctx context.Context, eventPublicID string) (*AdminEventRecord, error) {
|
||||
row := s.pool.QueryRow(ctx, `
|
||||
SELECT
|
||||
e.id,
|
||||
e.event_public_id,
|
||||
e.tenant_id,
|
||||
t.tenant_code,
|
||||
t.name,
|
||||
e.slug,
|
||||
e.display_name,
|
||||
e.summary,
|
||||
e.status,
|
||||
e.current_release_id,
|
||||
er.release_public_id,
|
||||
er.config_label,
|
||||
er.manifest_url,
|
||||
er.manifest_checksum_sha256,
|
||||
er.route_code
|
||||
FROM events e
|
||||
LEFT JOIN tenants t ON t.id = e.tenant_id
|
||||
LEFT JOIN event_releases er ON er.id = e.current_release_id
|
||||
WHERE e.event_public_id = $1
|
||||
LIMIT 1
|
||||
`, eventPublicID)
|
||||
return scanAdminEvent(row)
|
||||
}
|
||||
|
||||
func (s *Store) CreateAdminEvent(ctx context.Context, tx Tx, params CreateAdminEventParams) (*AdminEventRecord, error) {
|
||||
row := tx.QueryRow(ctx, `
|
||||
INSERT INTO events (tenant_id, event_public_id, slug, display_name, summary, status)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id, event_public_id, tenant_id, slug, display_name, summary, status, current_release_id
|
||||
`, params.TenantID, params.PublicID, params.Slug, params.DisplayName, params.Summary, params.Status)
|
||||
|
||||
var item AdminEventRecord
|
||||
if err := row.Scan(
|
||||
&item.ID,
|
||||
&item.PublicID,
|
||||
&item.TenantID,
|
||||
&item.Slug,
|
||||
&item.DisplayName,
|
||||
&item.Summary,
|
||||
&item.Status,
|
||||
&item.CurrentReleaseID,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("create admin event: %w", err)
|
||||
}
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
func (s *Store) UpdateAdminEvent(ctx context.Context, tx Tx, params UpdateAdminEventParams) (*AdminEventRecord, error) {
|
||||
row := tx.QueryRow(ctx, `
|
||||
UPDATE events
|
||||
SET tenant_id = CASE WHEN $7 THEN NULL ELSE $2 END,
|
||||
slug = $3,
|
||||
display_name = $4,
|
||||
summary = $5,
|
||||
status = $6
|
||||
WHERE id = $1
|
||||
RETURNING id, event_public_id, tenant_id, slug, display_name, summary, status, current_release_id
|
||||
`, params.EventID, params.TenantID, params.Slug, params.DisplayName, params.Summary, params.Status, params.ClearTenant)
|
||||
|
||||
var item AdminEventRecord
|
||||
if err := row.Scan(
|
||||
&item.ID,
|
||||
&item.PublicID,
|
||||
&item.TenantID,
|
||||
&item.Slug,
|
||||
&item.DisplayName,
|
||||
&item.Summary,
|
||||
&item.Status,
|
||||
&item.CurrentReleaseID,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("update admin event: %w", err)
|
||||
}
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
func scanAdminEvent(row pgx.Row) (*AdminEventRecord, error) {
|
||||
var item AdminEventRecord
|
||||
err := row.Scan(
|
||||
&item.ID,
|
||||
&item.PublicID,
|
||||
&item.TenantID,
|
||||
&item.TenantCode,
|
||||
&item.TenantName,
|
||||
&item.Slug,
|
||||
&item.DisplayName,
|
||||
&item.Summary,
|
||||
&item.Status,
|
||||
&item.CurrentReleaseID,
|
||||
&item.CurrentReleasePubID,
|
||||
&item.ConfigLabel,
|
||||
&item.ManifestURL,
|
||||
&item.ManifestChecksum,
|
||||
&item.RouteCode,
|
||||
)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan admin event: %w", err)
|
||||
}
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
func scanAdminEventFromRows(rows pgx.Rows) (*AdminEventRecord, error) {
|
||||
var item AdminEventRecord
|
||||
err := rows.Scan(
|
||||
&item.ID,
|
||||
&item.PublicID,
|
||||
&item.TenantID,
|
||||
&item.TenantCode,
|
||||
&item.TenantName,
|
||||
&item.Slug,
|
||||
&item.DisplayName,
|
||||
&item.Summary,
|
||||
&item.Status,
|
||||
&item.CurrentReleaseID,
|
||||
&item.CurrentReleasePubID,
|
||||
&item.ConfigLabel,
|
||||
&item.ManifestURL,
|
||||
&item.ManifestChecksum,
|
||||
&item.RouteCode,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan admin event row: %w", err)
|
||||
}
|
||||
return &item, nil
|
||||
}
|
||||
@@ -261,6 +261,36 @@ func (s *Store) GetEventConfigBuildByID(ctx context.Context, buildID string) (*E
|
||||
return scanEventConfigBuild(row)
|
||||
}
|
||||
|
||||
func (s *Store) ListEventConfigBuildsByEventID(ctx context.Context, eventID string, limit int) ([]EventConfigBuild, error) {
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 20
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id, event_id, source_id, build_no, build_status, build_log, manifest_jsonb::text, asset_index_jsonb::text
|
||||
FROM event_config_builds
|
||||
WHERE event_id = $1
|
||||
ORDER BY build_no DESC
|
||||
LIMIT $2
|
||||
`, eventID, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list event config builds: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
items := []EventConfigBuild{}
|
||||
for rows.Next() {
|
||||
item, err := scanEventConfigBuildFromRows(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, *item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate event config builds: %w", err)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func scanEventConfigSource(row pgx.Row) (*EventConfigSource, error) {
|
||||
var item EventConfigSource
|
||||
err := row.Scan(
|
||||
@@ -321,3 +351,21 @@ func scanEventConfigBuild(row pgx.Row) (*EventConfigBuild, error) {
|
||||
}
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
func scanEventConfigBuildFromRows(rows pgx.Rows) (*EventConfigBuild, error) {
|
||||
var item EventConfigBuild
|
||||
err := rows.Scan(
|
||||
&item.ID,
|
||||
&item.EventID,
|
||||
&item.SourceID,
|
||||
&item.BuildNo,
|
||||
&item.BuildStatus,
|
||||
&item.BuildLog,
|
||||
&item.ManifestJSON,
|
||||
&item.AssetIndexJSON,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan event config build row: %w", err)
|
||||
}
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
@@ -261,3 +261,85 @@ func (s *Store) CreateGameSession(ctx context.Context, tx Tx, params CreateGameS
|
||||
}
|
||||
return &session, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListEventReleasesByEventID(ctx context.Context, eventID string, limit int) ([]EventRelease, error) {
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 20
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id, release_public_id, event_id, release_no, config_label, manifest_url, manifest_checksum_sha256, route_code, build_id, status, published_at
|
||||
FROM event_releases
|
||||
WHERE event_id = $1
|
||||
ORDER BY release_no DESC
|
||||
LIMIT $2
|
||||
`, eventID, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list event releases by event id: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
items := []EventRelease{}
|
||||
for rows.Next() {
|
||||
item, err := scanEventReleaseFromRows(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, *item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate event releases by event id: %w", err)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetEventReleaseByPublicID(ctx context.Context, releasePublicID string) (*EventRelease, error) {
|
||||
row := s.pool.QueryRow(ctx, `
|
||||
SELECT id, release_public_id, event_id, release_no, config_label, manifest_url, manifest_checksum_sha256, route_code, build_id, status, published_at
|
||||
FROM event_releases
|
||||
WHERE release_public_id = $1
|
||||
LIMIT 1
|
||||
`, releasePublicID)
|
||||
|
||||
var item EventRelease
|
||||
err := row.Scan(
|
||||
&item.ID,
|
||||
&item.PublicID,
|
||||
&item.EventID,
|
||||
&item.ReleaseNo,
|
||||
&item.ConfigLabel,
|
||||
&item.ManifestURL,
|
||||
&item.ManifestChecksum,
|
||||
&item.RouteCode,
|
||||
&item.BuildID,
|
||||
&item.Status,
|
||||
&item.PublishedAt,
|
||||
)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get event release by public id: %w", err)
|
||||
}
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
func scanEventReleaseFromRows(rows pgx.Rows) (*EventRelease, error) {
|
||||
var item EventRelease
|
||||
err := rows.Scan(
|
||||
&item.ID,
|
||||
&item.PublicID,
|
||||
&item.EventID,
|
||||
&item.ReleaseNo,
|
||||
&item.ConfigLabel,
|
||||
&item.ManifestURL,
|
||||
&item.ManifestChecksum,
|
||||
&item.RouteCode,
|
||||
&item.BuildID,
|
||||
&item.Status,
|
||||
&item.PublishedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan event release row: %w", err)
|
||||
}
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
660
backend/internal/store/postgres/resource_store.go
Normal file
660
backend/internal/store/postgres/resource_store.go
Normal file
@@ -0,0 +1,660 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
type ResourceMap struct {
|
||||
ID string
|
||||
PublicID string
|
||||
Code string
|
||||
Name string
|
||||
Status string
|
||||
Description *string
|
||||
CurrentVersionID *string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type ResourceMapVersion struct {
|
||||
ID string
|
||||
PublicID string
|
||||
MapID string
|
||||
VersionCode string
|
||||
Status string
|
||||
MapmetaURL string
|
||||
TilesRootURL string
|
||||
PublishedAssetRoot *string
|
||||
BoundsJSON json.RawMessage
|
||||
MetadataJSON json.RawMessage
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type ResourcePlayfield struct {
|
||||
ID string
|
||||
PublicID string
|
||||
Code string
|
||||
Name string
|
||||
Kind string
|
||||
Status string
|
||||
Description *string
|
||||
CurrentVersionID *string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type ResourcePlayfieldVersion struct {
|
||||
ID string
|
||||
PublicID string
|
||||
PlayfieldID string
|
||||
VersionCode string
|
||||
Status string
|
||||
SourceType string
|
||||
SourceURL string
|
||||
PublishedAssetRoot *string
|
||||
ControlCount *int
|
||||
BoundsJSON json.RawMessage
|
||||
MetadataJSON json.RawMessage
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type ResourcePack struct {
|
||||
ID string
|
||||
PublicID string
|
||||
Code string
|
||||
Name string
|
||||
Status string
|
||||
Description *string
|
||||
CurrentVersionID *string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type ResourcePackVersion struct {
|
||||
ID string
|
||||
PublicID string
|
||||
ResourcePackID string
|
||||
VersionCode string
|
||||
Status string
|
||||
ContentEntryURL *string
|
||||
AudioRootURL *string
|
||||
ThemeProfileCode *string
|
||||
PublishedAssetRoot *string
|
||||
MetadataJSON json.RawMessage
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type CreateResourceMapParams struct {
|
||||
PublicID string
|
||||
Code string
|
||||
Name string
|
||||
Status string
|
||||
Description *string
|
||||
}
|
||||
|
||||
type CreateResourceMapVersionParams struct {
|
||||
PublicID string
|
||||
MapID string
|
||||
VersionCode string
|
||||
Status string
|
||||
MapmetaURL string
|
||||
TilesRootURL string
|
||||
PublishedAssetRoot *string
|
||||
BoundsJSON map[string]any
|
||||
MetadataJSON map[string]any
|
||||
}
|
||||
|
||||
type CreateResourcePlayfieldParams struct {
|
||||
PublicID string
|
||||
Code string
|
||||
Name string
|
||||
Kind string
|
||||
Status string
|
||||
Description *string
|
||||
}
|
||||
|
||||
type CreateResourcePlayfieldVersionParams struct {
|
||||
PublicID string
|
||||
PlayfieldID string
|
||||
VersionCode string
|
||||
Status string
|
||||
SourceType string
|
||||
SourceURL string
|
||||
PublishedAssetRoot *string
|
||||
ControlCount *int
|
||||
BoundsJSON map[string]any
|
||||
MetadataJSON map[string]any
|
||||
}
|
||||
|
||||
type CreateResourcePackParams struct {
|
||||
PublicID string
|
||||
Code string
|
||||
Name string
|
||||
Status string
|
||||
Description *string
|
||||
}
|
||||
|
||||
type CreateResourcePackVersionParams struct {
|
||||
PublicID string
|
||||
ResourcePackID string
|
||||
VersionCode string
|
||||
Status string
|
||||
ContentEntryURL *string
|
||||
AudioRootURL *string
|
||||
ThemeProfileCode *string
|
||||
PublishedAssetRoot *string
|
||||
MetadataJSON map[string]any
|
||||
}
|
||||
|
||||
func (s *Store) ListResourceMaps(ctx context.Context, limit int) ([]ResourceMap, error) {
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 50
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id, map_public_id, code, name, status, description, current_version_id, created_at, updated_at
|
||||
FROM maps
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $1
|
||||
`, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list resource maps: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
items := []ResourceMap{}
|
||||
for rows.Next() {
|
||||
item, err := scanResourceMapFromRows(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, *item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate resource maps: %w", err)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetResourceMapByPublicID(ctx context.Context, publicID string) (*ResourceMap, error) {
|
||||
row := s.pool.QueryRow(ctx, `
|
||||
SELECT id, map_public_id, code, name, status, description, current_version_id, created_at, updated_at
|
||||
FROM maps
|
||||
WHERE map_public_id = $1
|
||||
LIMIT 1
|
||||
`, publicID)
|
||||
return scanResourceMap(row)
|
||||
}
|
||||
|
||||
func (s *Store) CreateResourceMap(ctx context.Context, tx Tx, params CreateResourceMapParams) (*ResourceMap, error) {
|
||||
row := tx.QueryRow(ctx, `
|
||||
INSERT INTO maps (map_public_id, code, name, status, description)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id, map_public_id, code, name, status, description, current_version_id, created_at, updated_at
|
||||
`, params.PublicID, params.Code, params.Name, params.Status, params.Description)
|
||||
return scanResourceMap(row)
|
||||
}
|
||||
|
||||
func (s *Store) ListResourceMapVersions(ctx context.Context, mapID string) ([]ResourceMapVersion, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id, version_public_id, map_id, version_code, status, mapmeta_url, tiles_root_url, published_asset_root,
|
||||
bounds_jsonb::text, metadata_jsonb::text, created_at, updated_at
|
||||
FROM map_versions
|
||||
WHERE map_id = $1
|
||||
ORDER BY created_at DESC
|
||||
`, mapID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list resource map versions: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
items := []ResourceMapVersion{}
|
||||
for rows.Next() {
|
||||
item, err := scanResourceMapVersionFromRows(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, *item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate resource map versions: %w", err)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetResourceMapVersionByPublicID(ctx context.Context, mapPublicID, versionPublicID string) (*ResourceMapVersion, error) {
|
||||
row := s.pool.QueryRow(ctx, `
|
||||
SELECT mv.id, mv.version_public_id, mv.map_id, mv.version_code, mv.status, mv.mapmeta_url, mv.tiles_root_url, mv.published_asset_root,
|
||||
mv.bounds_jsonb::text, mv.metadata_jsonb::text, mv.created_at, mv.updated_at
|
||||
FROM map_versions mv
|
||||
JOIN maps m ON m.id = mv.map_id
|
||||
WHERE m.map_public_id = $1
|
||||
AND mv.version_public_id = $2
|
||||
LIMIT 1
|
||||
`, mapPublicID, versionPublicID)
|
||||
return scanResourceMapVersion(row)
|
||||
}
|
||||
|
||||
func (s *Store) CreateResourceMapVersion(ctx context.Context, tx Tx, params CreateResourceMapVersionParams) (*ResourceMapVersion, error) {
|
||||
boundsJSON, err := marshalJSONMap(params.BoundsJSON)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal map bounds: %w", err)
|
||||
}
|
||||
metadataJSON, err := marshalJSONMap(params.MetadataJSON)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal map metadata: %w", err)
|
||||
}
|
||||
row := tx.QueryRow(ctx, `
|
||||
INSERT INTO map_versions (
|
||||
version_public_id, map_id, version_code, status, mapmeta_url, tiles_root_url,
|
||||
published_asset_root, bounds_jsonb, metadata_jsonb
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9::jsonb)
|
||||
RETURNING id, version_public_id, map_id, version_code, status, mapmeta_url, tiles_root_url, published_asset_root,
|
||||
bounds_jsonb::text, metadata_jsonb::text, created_at, updated_at
|
||||
`, params.PublicID, params.MapID, params.VersionCode, params.Status, params.MapmetaURL, params.TilesRootURL, params.PublishedAssetRoot, boundsJSON, metadataJSON)
|
||||
return scanResourceMapVersion(row)
|
||||
}
|
||||
|
||||
func (s *Store) SetResourceMapCurrentVersion(ctx context.Context, tx Tx, mapID, versionID string) error {
|
||||
_, err := tx.Exec(ctx, `UPDATE maps SET current_version_id = $2 WHERE id = $1`, mapID, versionID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set resource map current version: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) ListResourcePlayfields(ctx context.Context, limit int) ([]ResourcePlayfield, error) {
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 50
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id, playfield_public_id, code, name, kind, status, description, current_version_id, created_at, updated_at
|
||||
FROM playfields
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $1
|
||||
`, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list resource playfields: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
items := []ResourcePlayfield{}
|
||||
for rows.Next() {
|
||||
item, err := scanResourcePlayfieldFromRows(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, *item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate resource playfields: %w", err)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetResourcePlayfieldByPublicID(ctx context.Context, publicID string) (*ResourcePlayfield, error) {
|
||||
row := s.pool.QueryRow(ctx, `
|
||||
SELECT id, playfield_public_id, code, name, kind, status, description, current_version_id, created_at, updated_at
|
||||
FROM playfields
|
||||
WHERE playfield_public_id = $1
|
||||
LIMIT 1
|
||||
`, publicID)
|
||||
return scanResourcePlayfield(row)
|
||||
}
|
||||
|
||||
func (s *Store) CreateResourcePlayfield(ctx context.Context, tx Tx, params CreateResourcePlayfieldParams) (*ResourcePlayfield, error) {
|
||||
row := tx.QueryRow(ctx, `
|
||||
INSERT INTO playfields (playfield_public_id, code, name, kind, status, description)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id, playfield_public_id, code, name, kind, status, description, current_version_id, created_at, updated_at
|
||||
`, params.PublicID, params.Code, params.Name, params.Kind, params.Status, params.Description)
|
||||
return scanResourcePlayfield(row)
|
||||
}
|
||||
|
||||
func (s *Store) ListResourcePlayfieldVersions(ctx context.Context, playfieldID string) ([]ResourcePlayfieldVersion, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id, version_public_id, playfield_id, version_code, status, source_type, source_url, published_asset_root,
|
||||
control_count, bounds_jsonb::text, metadata_jsonb::text, created_at, updated_at
|
||||
FROM playfield_versions
|
||||
WHERE playfield_id = $1
|
||||
ORDER BY created_at DESC
|
||||
`, playfieldID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list resource playfield versions: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
items := []ResourcePlayfieldVersion{}
|
||||
for rows.Next() {
|
||||
item, err := scanResourcePlayfieldVersionFromRows(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, *item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate resource playfield versions: %w", err)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetResourcePlayfieldVersionByPublicID(ctx context.Context, playfieldPublicID, versionPublicID string) (*ResourcePlayfieldVersion, error) {
|
||||
row := s.pool.QueryRow(ctx, `
|
||||
SELECT pv.id, pv.version_public_id, pv.playfield_id, pv.version_code, pv.status, pv.source_type, pv.source_url, pv.published_asset_root,
|
||||
pv.control_count, pv.bounds_jsonb::text, pv.metadata_jsonb::text, pv.created_at, pv.updated_at
|
||||
FROM playfield_versions pv
|
||||
JOIN playfields p ON p.id = pv.playfield_id
|
||||
WHERE p.playfield_public_id = $1
|
||||
AND pv.version_public_id = $2
|
||||
LIMIT 1
|
||||
`, playfieldPublicID, versionPublicID)
|
||||
return scanResourcePlayfieldVersion(row)
|
||||
}
|
||||
|
||||
func (s *Store) CreateResourcePlayfieldVersion(ctx context.Context, tx Tx, params CreateResourcePlayfieldVersionParams) (*ResourcePlayfieldVersion, error) {
|
||||
boundsJSON, err := marshalJSONMap(params.BoundsJSON)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal playfield bounds: %w", err)
|
||||
}
|
||||
metadataJSON, err := marshalJSONMap(params.MetadataJSON)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal playfield metadata: %w", err)
|
||||
}
|
||||
row := tx.QueryRow(ctx, `
|
||||
INSERT INTO playfield_versions (
|
||||
version_public_id, playfield_id, version_code, status, source_type, source_url,
|
||||
published_asset_root, control_count, bounds_jsonb, metadata_jsonb
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, $10::jsonb)
|
||||
RETURNING id, version_public_id, playfield_id, version_code, status, source_type, source_url, published_asset_root,
|
||||
control_count, bounds_jsonb::text, metadata_jsonb::text, created_at, updated_at
|
||||
`, params.PublicID, params.PlayfieldID, params.VersionCode, params.Status, params.SourceType, params.SourceURL, params.PublishedAssetRoot, params.ControlCount, boundsJSON, metadataJSON)
|
||||
return scanResourcePlayfieldVersion(row)
|
||||
}
|
||||
|
||||
func (s *Store) SetResourcePlayfieldCurrentVersion(ctx context.Context, tx Tx, playfieldID, versionID string) error {
|
||||
_, err := tx.Exec(ctx, `UPDATE playfields SET current_version_id = $2 WHERE id = $1`, playfieldID, versionID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set resource playfield current version: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) ListResourcePacks(ctx context.Context, limit int) ([]ResourcePack, error) {
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 50
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id, resource_pack_public_id, code, name, status, description, current_version_id, created_at, updated_at
|
||||
FROM resource_packs
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $1
|
||||
`, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list resource packs: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
items := []ResourcePack{}
|
||||
for rows.Next() {
|
||||
item, err := scanResourcePackFromRows(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, *item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate resource packs: %w", err)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetResourcePackByPublicID(ctx context.Context, publicID string) (*ResourcePack, error) {
|
||||
row := s.pool.QueryRow(ctx, `
|
||||
SELECT id, resource_pack_public_id, code, name, status, description, current_version_id, created_at, updated_at
|
||||
FROM resource_packs
|
||||
WHERE resource_pack_public_id = $1
|
||||
LIMIT 1
|
||||
`, publicID)
|
||||
return scanResourcePack(row)
|
||||
}
|
||||
|
||||
func (s *Store) CreateResourcePack(ctx context.Context, tx Tx, params CreateResourcePackParams) (*ResourcePack, error) {
|
||||
row := tx.QueryRow(ctx, `
|
||||
INSERT INTO resource_packs (resource_pack_public_id, code, name, status, description)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id, resource_pack_public_id, code, name, status, description, current_version_id, created_at, updated_at
|
||||
`, params.PublicID, params.Code, params.Name, params.Status, params.Description)
|
||||
return scanResourcePack(row)
|
||||
}
|
||||
|
||||
func (s *Store) ListResourcePackVersions(ctx context.Context, resourcePackID string) ([]ResourcePackVersion, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id, version_public_id, resource_pack_id, version_code, status, content_entry_url, audio_root_url,
|
||||
theme_profile_code, published_asset_root, metadata_jsonb::text, created_at, updated_at
|
||||
FROM resource_pack_versions
|
||||
WHERE resource_pack_id = $1
|
||||
ORDER BY created_at DESC
|
||||
`, resourcePackID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list resource pack versions: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
items := []ResourcePackVersion{}
|
||||
for rows.Next() {
|
||||
item, err := scanResourcePackVersionFromRows(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, *item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate resource pack versions: %w", err)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetResourcePackVersionByPublicID(ctx context.Context, resourcePackPublicID, versionPublicID string) (*ResourcePackVersion, error) {
|
||||
row := s.pool.QueryRow(ctx, `
|
||||
SELECT pv.id, pv.version_public_id, pv.resource_pack_id, pv.version_code, pv.status, pv.content_entry_url, pv.audio_root_url,
|
||||
pv.theme_profile_code, pv.published_asset_root, pv.metadata_jsonb::text, pv.created_at, pv.updated_at
|
||||
FROM resource_pack_versions pv
|
||||
JOIN resource_packs rp ON rp.id = pv.resource_pack_id
|
||||
WHERE rp.resource_pack_public_id = $1
|
||||
AND pv.version_public_id = $2
|
||||
LIMIT 1
|
||||
`, resourcePackPublicID, versionPublicID)
|
||||
return scanResourcePackVersion(row)
|
||||
}
|
||||
|
||||
func (s *Store) CreateResourcePackVersion(ctx context.Context, tx Tx, params CreateResourcePackVersionParams) (*ResourcePackVersion, error) {
|
||||
metadataJSON, err := marshalJSONMap(params.MetadataJSON)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal resource pack metadata: %w", err)
|
||||
}
|
||||
row := tx.QueryRow(ctx, `
|
||||
INSERT INTO resource_pack_versions (
|
||||
version_public_id, resource_pack_id, version_code, status, content_entry_url,
|
||||
audio_root_url, theme_profile_code, published_asset_root, metadata_jsonb
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb)
|
||||
RETURNING id, version_public_id, resource_pack_id, version_code, status, content_entry_url, audio_root_url,
|
||||
theme_profile_code, published_asset_root, metadata_jsonb::text, created_at, updated_at
|
||||
`, params.PublicID, params.ResourcePackID, params.VersionCode, params.Status, params.ContentEntryURL, params.AudioRootURL, params.ThemeProfileCode, params.PublishedAssetRoot, metadataJSON)
|
||||
return scanResourcePackVersion(row)
|
||||
}
|
||||
|
||||
func (s *Store) SetResourcePackCurrentVersion(ctx context.Context, tx Tx, resourcePackID, versionID string) error {
|
||||
_, err := tx.Exec(ctx, `UPDATE resource_packs SET current_version_id = $2 WHERE id = $1`, resourcePackID, versionID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set resource pack current version: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func scanResourceMap(row pgx.Row) (*ResourceMap, error) {
|
||||
var item ResourceMap
|
||||
err := row.Scan(&item.ID, &item.PublicID, &item.Code, &item.Name, &item.Status, &item.Description, &item.CurrentVersionID, &item.CreatedAt, &item.UpdatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan resource map: %w", err)
|
||||
}
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
func scanResourceMapFromRows(rows pgx.Rows) (*ResourceMap, error) {
|
||||
var item ResourceMap
|
||||
err := rows.Scan(&item.ID, &item.PublicID, &item.Code, &item.Name, &item.Status, &item.Description, &item.CurrentVersionID, &item.CreatedAt, &item.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan resource map row: %w", err)
|
||||
}
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
func scanResourceMapVersion(row pgx.Row) (*ResourceMapVersion, error) {
|
||||
var item ResourceMapVersion
|
||||
var boundsJSON string
|
||||
var metadataJSON string
|
||||
err := row.Scan(&item.ID, &item.PublicID, &item.MapID, &item.VersionCode, &item.Status, &item.MapmetaURL, &item.TilesRootURL, &item.PublishedAssetRoot, &boundsJSON, &metadataJSON, &item.CreatedAt, &item.UpdatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan resource map version: %w", err)
|
||||
}
|
||||
item.BoundsJSON = json.RawMessage(boundsJSON)
|
||||
item.MetadataJSON = json.RawMessage(metadataJSON)
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
func scanResourceMapVersionFromRows(rows pgx.Rows) (*ResourceMapVersion, error) {
|
||||
var item ResourceMapVersion
|
||||
var boundsJSON string
|
||||
var metadataJSON string
|
||||
err := rows.Scan(&item.ID, &item.PublicID, &item.MapID, &item.VersionCode, &item.Status, &item.MapmetaURL, &item.TilesRootURL, &item.PublishedAssetRoot, &boundsJSON, &metadataJSON, &item.CreatedAt, &item.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan resource map version row: %w", err)
|
||||
}
|
||||
item.BoundsJSON = json.RawMessage(boundsJSON)
|
||||
item.MetadataJSON = json.RawMessage(metadataJSON)
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
func scanResourcePlayfield(row pgx.Row) (*ResourcePlayfield, error) {
|
||||
var item ResourcePlayfield
|
||||
err := row.Scan(&item.ID, &item.PublicID, &item.Code, &item.Name, &item.Kind, &item.Status, &item.Description, &item.CurrentVersionID, &item.CreatedAt, &item.UpdatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan resource playfield: %w", err)
|
||||
}
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
func scanResourcePlayfieldFromRows(rows pgx.Rows) (*ResourcePlayfield, error) {
|
||||
var item ResourcePlayfield
|
||||
err := rows.Scan(&item.ID, &item.PublicID, &item.Code, &item.Name, &item.Kind, &item.Status, &item.Description, &item.CurrentVersionID, &item.CreatedAt, &item.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan resource playfield row: %w", err)
|
||||
}
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
func scanResourcePlayfieldVersion(row pgx.Row) (*ResourcePlayfieldVersion, error) {
|
||||
var item ResourcePlayfieldVersion
|
||||
var boundsJSON string
|
||||
var metadataJSON string
|
||||
err := row.Scan(&item.ID, &item.PublicID, &item.PlayfieldID, &item.VersionCode, &item.Status, &item.SourceType, &item.SourceURL, &item.PublishedAssetRoot, &item.ControlCount, &boundsJSON, &metadataJSON, &item.CreatedAt, &item.UpdatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan resource playfield version: %w", err)
|
||||
}
|
||||
item.BoundsJSON = json.RawMessage(boundsJSON)
|
||||
item.MetadataJSON = json.RawMessage(metadataJSON)
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
func scanResourcePlayfieldVersionFromRows(rows pgx.Rows) (*ResourcePlayfieldVersion, error) {
|
||||
var item ResourcePlayfieldVersion
|
||||
var boundsJSON string
|
||||
var metadataJSON string
|
||||
err := rows.Scan(&item.ID, &item.PublicID, &item.PlayfieldID, &item.VersionCode, &item.Status, &item.SourceType, &item.SourceURL, &item.PublishedAssetRoot, &item.ControlCount, &boundsJSON, &metadataJSON, &item.CreatedAt, &item.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan resource playfield version row: %w", err)
|
||||
}
|
||||
item.BoundsJSON = json.RawMessage(boundsJSON)
|
||||
item.MetadataJSON = json.RawMessage(metadataJSON)
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
func scanResourcePack(row pgx.Row) (*ResourcePack, error) {
|
||||
var item ResourcePack
|
||||
err := row.Scan(&item.ID, &item.PublicID, &item.Code, &item.Name, &item.Status, &item.Description, &item.CurrentVersionID, &item.CreatedAt, &item.UpdatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan resource pack: %w", err)
|
||||
}
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
func scanResourcePackFromRows(rows pgx.Rows) (*ResourcePack, error) {
|
||||
var item ResourcePack
|
||||
err := rows.Scan(&item.ID, &item.PublicID, &item.Code, &item.Name, &item.Status, &item.Description, &item.CurrentVersionID, &item.CreatedAt, &item.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan resource pack row: %w", err)
|
||||
}
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
func scanResourcePackVersion(row pgx.Row) (*ResourcePackVersion, error) {
|
||||
var item ResourcePackVersion
|
||||
var metadataJSON string
|
||||
err := row.Scan(&item.ID, &item.PublicID, &item.ResourcePackID, &item.VersionCode, &item.Status, &item.ContentEntryURL, &item.AudioRootURL, &item.ThemeProfileCode, &item.PublishedAssetRoot, &metadataJSON, &item.CreatedAt, &item.UpdatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan resource pack version: %w", err)
|
||||
}
|
||||
item.MetadataJSON = json.RawMessage(metadataJSON)
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
func scanResourcePackVersionFromRows(rows pgx.Rows) (*ResourcePackVersion, error) {
|
||||
var item ResourcePackVersion
|
||||
var metadataJSON string
|
||||
err := rows.Scan(&item.ID, &item.PublicID, &item.ResourcePackID, &item.VersionCode, &item.Status, &item.ContentEntryURL, &item.AudioRootURL, &item.ThemeProfileCode, &item.PublishedAssetRoot, &metadataJSON, &item.CreatedAt, &item.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan resource pack version row: %w", err)
|
||||
}
|
||||
item.MetadataJSON = json.RawMessage(metadataJSON)
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
func marshalJSONMap(value map[string]any) (string, error) {
|
||||
if value == nil {
|
||||
value = map[string]any{}
|
||||
}
|
||||
raw, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(raw), nil
|
||||
}
|
||||
Reference in New Issue
Block a user