package service import ( "context" "net/http" "strings" "time" "cmr-backend/internal/apperr" "cmr-backend/internal/platform/security" "cmr-backend/internal/store/postgres" ) type AdminProductionService struct { store *postgres.Store } type AdminPlaceSummary struct { ID string `json:"id"` Code string `json:"code"` Name string `json:"name"` Region *string `json:"region,omitempty"` CoverURL *string `json:"coverUrl,omitempty"` Description *string `json:"description,omitempty"` CenterPoint map[string]any `json:"centerPoint,omitempty"` Status string `json:"status"` } type AdminPlaceDetail struct { Place AdminPlaceSummary `json:"place"` MapAssets []AdminMapAssetSummary `json:"mapAssets"` } type CreateAdminPlaceInput struct { Code string `json:"code"` Name string `json:"name"` Region *string `json:"region,omitempty"` CoverURL *string `json:"coverUrl,omitempty"` Description *string `json:"description,omitempty"` CenterPoint map[string]any `json:"centerPoint,omitempty"` Status string `json:"status"` } type AdminMapAssetSummary struct { ID string `json:"id"` PlaceID string `json:"placeId"` LegacyMapID *string `json:"legacyMapId,omitempty"` Code string `json:"code"` Name string `json:"name"` MapType string `json:"mapType"` CoverURL *string `json:"coverUrl,omitempty"` Description *string `json:"description,omitempty"` Status string `json:"status"` CurrentTileRelease *AdminTileReleaseBrief `json:"currentTileRelease,omitempty"` } type AdminTileReleaseBrief struct { ID string `json:"id"` VersionCode string `json:"versionCode"` Status string `json:"status"` } type AdminMapAssetDetail struct { MapAsset AdminMapAssetSummary `json:"mapAsset"` TileReleases []AdminTileReleaseView `json:"tileReleases"` CourseSets []AdminCourseSetBrief `json:"courseSets"` } type CreateAdminMapAssetInput struct { Code string `json:"code"` Name string `json:"name"` MapType string `json:"mapType"` LegacyMapID *string `json:"legacyMapId,omitempty"` CoverURL *string `json:"coverUrl,omitempty"` Description *string `json:"description,omitempty"` Status string `json:"status"` } type AdminTileReleaseView struct { ID string `json:"id"` LegacyVersionID *string `json:"legacyVersionId,omitempty"` VersionCode string `json:"versionCode"` Status string `json:"status"` TileBaseURL string `json:"tileBaseUrl"` MetaURL string `json:"metaUrl"` PublishedAssetRoot *string `json:"publishedAssetRoot,omitempty"` Metadata map[string]any `json:"metadata,omitempty"` PublishedAt *time.Time `json:"publishedAt,omitempty"` } type CreateAdminTileReleaseInput struct { LegacyVersionID *string `json:"legacyVersionId,omitempty"` VersionCode string `json:"versionCode"` Status string `json:"status"` TileBaseURL string `json:"tileBaseUrl"` MetaURL string `json:"metaUrl"` PublishedAssetRoot *string `json:"publishedAssetRoot,omitempty"` Metadata map[string]any `json:"metadata,omitempty"` SetAsCurrent bool `json:"setAsCurrent"` } type AdminCourseSourceSummary struct { ID string `json:"id"` LegacyVersionID *string `json:"legacyVersionId,omitempty"` SourceType string `json:"sourceType"` FileURL string `json:"fileUrl"` Checksum *string `json:"checksum,omitempty"` ParserVersion *string `json:"parserVersion,omitempty"` ImportStatus string `json:"importStatus"` Metadata map[string]any `json:"metadata,omitempty"` ImportedAt time.Time `json:"importedAt"` } type CreateAdminCourseSourceInput struct { LegacyPlayfieldID *string `json:"legacyPlayfieldId,omitempty"` LegacyVersionID *string `json:"legacyVersionId,omitempty"` SourceType string `json:"sourceType"` FileURL string `json:"fileUrl"` Checksum *string `json:"checksum,omitempty"` ParserVersion *string `json:"parserVersion,omitempty"` ImportStatus string `json:"importStatus"` Metadata map[string]any `json:"metadata,omitempty"` } type AdminCourseSetBrief struct { ID string `json:"id"` Code string `json:"code"` Mode string `json:"mode"` Name string `json:"name"` Description *string `json:"description,omitempty"` Status string `json:"status"` CurrentVariant *AdminCourseVariantBrief `json:"currentVariant,omitempty"` } type AdminCourseVariantBrief struct { ID string `json:"id"` Name string `json:"name"` RouteCode *string `json:"routeCode,omitempty"` Status string `json:"status"` } type AdminCourseSetDetail struct { CourseSet AdminCourseSetBrief `json:"courseSet"` Variants []AdminCourseVariantView `json:"variants"` } type CreateAdminCourseSetInput struct { Code string `json:"code"` Mode string `json:"mode"` Name string `json:"name"` Description *string `json:"description,omitempty"` Status string `json:"status"` } type AdminCourseVariantView struct { ID string `json:"id"` SourceID *string `json:"sourceId,omitempty"` Name string `json:"name"` RouteCode *string `json:"routeCode,omitempty"` Mode string `json:"mode"` ControlCount *int `json:"controlCount,omitempty"` Difficulty *string `json:"difficulty,omitempty"` Status string `json:"status"` IsDefault bool `json:"isDefault"` ConfigPatch map[string]any `json:"configPatch,omitempty"` Metadata map[string]any `json:"metadata,omitempty"` } type CreateAdminCourseVariantInput struct { SourceID *string `json:"sourceId,omitempty"` Name string `json:"name"` RouteCode *string `json:"routeCode,omitempty"` Mode string `json:"mode"` ControlCount *int `json:"controlCount,omitempty"` Difficulty *string `json:"difficulty,omitempty"` Status string `json:"status"` IsDefault bool `json:"isDefault"` ConfigPatch map[string]any `json:"configPatch,omitempty"` Metadata map[string]any `json:"metadata,omitempty"` } type AdminRuntimeBindingSummary struct { ID string `json:"id"` EventID string `json:"eventId"` PlaceID string `json:"placeId"` MapAssetID string `json:"mapAssetId"` TileReleaseID string `json:"tileReleaseId"` CourseSetID string `json:"courseSetId"` CourseVariantID string `json:"courseVariantId"` Status string `json:"status"` Notes *string `json:"notes,omitempty"` } type CreateAdminRuntimeBindingInput struct { EventID string `json:"eventId"` PlaceID string `json:"placeId"` MapAssetID string `json:"mapAssetId"` TileReleaseID string `json:"tileReleaseId"` CourseSetID string `json:"courseSetId"` CourseVariantID string `json:"courseVariantId"` Status string `json:"status"` Notes *string `json:"notes,omitempty"` } func NewAdminProductionService(store *postgres.Store) *AdminProductionService { return &AdminProductionService{store: store} } func (s *AdminProductionService) ListPlaces(ctx context.Context, limit int) ([]AdminPlaceSummary, error) { items, err := s.store.ListPlaces(ctx, limit) if err != nil { return nil, err } result := make([]AdminPlaceSummary, 0, len(items)) for _, item := range items { result = append(result, buildAdminPlaceSummary(item)) } return result, nil } func (s *AdminProductionService) CreatePlace(ctx context.Context, input CreateAdminPlaceInput) (*AdminPlaceSummary, 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("place") 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.CreatePlace(ctx, tx, postgres.CreatePlaceParams{ PublicID: publicID, Code: input.Code, Name: input.Name, Region: trimStringPtr(input.Region), CoverURL: trimStringPtr(input.CoverURL), Description: trimStringPtr(input.Description), CenterPoint: input.CenterPoint, Status: normalizeCatalogStatus(input.Status), }) if err != nil { return nil, err } if err := tx.Commit(ctx); err != nil { return nil, err } result := buildAdminPlaceSummary(*item) return &result, nil } func (s *AdminProductionService) GetPlaceDetail(ctx context.Context, placePublicID string) (*AdminPlaceDetail, error) { place, err := s.store.GetPlaceByPublicID(ctx, strings.TrimSpace(placePublicID)) if err != nil { return nil, err } if place == nil { return nil, apperr.New(http.StatusNotFound, "place_not_found", "place not found") } mapAssets, err := s.store.ListMapAssetsByPlaceID(ctx, place.ID) if err != nil { return nil, err } result := &AdminPlaceDetail{ Place: buildAdminPlaceSummary(*place), MapAssets: make([]AdminMapAssetSummary, 0, len(mapAssets)), } for _, item := range mapAssets { summary, err := s.buildAdminMapAssetSummary(ctx, item) if err != nil { return nil, err } result.MapAssets = append(result.MapAssets, summary) } return result, nil } func (s *AdminProductionService) CreateMapAsset(ctx context.Context, placePublicID string, input CreateAdminMapAssetInput) (*AdminMapAssetSummary, error) { place, err := s.store.GetPlaceByPublicID(ctx, strings.TrimSpace(placePublicID)) if err != nil { return nil, err } if place == nil { return nil, apperr.New(http.StatusNotFound, "place_not_found", "place not found") } input.Code = strings.TrimSpace(input.Code) input.Name = strings.TrimSpace(input.Name) mapType := strings.TrimSpace(input.MapType) if mapType == "" { mapType = "standard" } if input.Code == "" || input.Name == "" { return nil, apperr.New(http.StatusBadRequest, "invalid_params", "code and name are required") } var legacyMapID *string if input.LegacyMapID != nil && strings.TrimSpace(*input.LegacyMapID) != "" { legacyMap, err := s.store.GetResourceMapByPublicID(ctx, strings.TrimSpace(*input.LegacyMapID)) if err != nil { return nil, err } if legacyMap == nil { return nil, apperr.New(http.StatusNotFound, "legacy_map_not_found", "legacy map not found") } legacyMapID = &legacyMap.ID } publicID, err := security.GeneratePublicID("mapasset") 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.CreateMapAsset(ctx, tx, postgres.CreateMapAssetParams{ PublicID: publicID, PlaceID: place.ID, LegacyMapID: legacyMapID, Code: input.Code, Name: input.Name, MapType: mapType, CoverURL: trimStringPtr(input.CoverURL), Description: trimStringPtr(input.Description), Status: normalizeCatalogStatus(input.Status), }) if err != nil { return nil, err } if err := tx.Commit(ctx); err != nil { return nil, err } result, err := s.buildAdminMapAssetSummary(ctx, *item) if err != nil { return nil, err } return &result, nil } func (s *AdminProductionService) GetMapAssetDetail(ctx context.Context, mapAssetPublicID string) (*AdminMapAssetDetail, error) { item, err := s.store.GetMapAssetByPublicID(ctx, strings.TrimSpace(mapAssetPublicID)) if err != nil { return nil, err } if item == nil { return nil, apperr.New(http.StatusNotFound, "map_asset_not_found", "map asset not found") } summary, err := s.buildAdminMapAssetSummary(ctx, *item) if err != nil { return nil, err } tileReleases, err := s.store.ListTileReleasesByMapAssetID(ctx, item.ID) if err != nil { return nil, err } courseSets, err := s.store.ListCourseSetsByMapAssetID(ctx, item.ID) if err != nil { return nil, err } result := &AdminMapAssetDetail{ MapAsset: summary, TileReleases: make([]AdminTileReleaseView, 0, len(tileReleases)), CourseSets: make([]AdminCourseSetBrief, 0, len(courseSets)), } for _, release := range tileReleases { result.TileReleases = append(result.TileReleases, buildAdminTileReleaseView(release)) } for _, courseSet := range courseSets { brief, err := s.buildAdminCourseSetBrief(ctx, courseSet) if err != nil { return nil, err } result.CourseSets = append(result.CourseSets, brief) } return result, nil } func (s *AdminProductionService) CreateTileRelease(ctx context.Context, mapAssetPublicID string, input CreateAdminTileReleaseInput) (*AdminTileReleaseView, error) { mapAsset, err := s.store.GetMapAssetByPublicID(ctx, strings.TrimSpace(mapAssetPublicID)) if err != nil { return nil, err } if mapAsset == nil { return nil, apperr.New(http.StatusNotFound, "map_asset_not_found", "map asset not found") } input.VersionCode = strings.TrimSpace(input.VersionCode) input.TileBaseURL = strings.TrimSpace(input.TileBaseURL) input.MetaURL = strings.TrimSpace(input.MetaURL) if input.VersionCode == "" || input.TileBaseURL == "" || input.MetaURL == "" { return nil, apperr.New(http.StatusBadRequest, "invalid_params", "versionCode, tileBaseUrl and metaUrl are required") } var legacyVersionID *string if input.LegacyVersionID != nil && strings.TrimSpace(*input.LegacyVersionID) != "" { if mapAsset.LegacyMapPublicID == nil || strings.TrimSpace(*mapAsset.LegacyMapPublicID) == "" { return nil, apperr.New(http.StatusBadRequest, "legacy_map_missing", "map asset has no linked legacy map") } legacyVersion, err := s.store.GetResourceMapVersionByPublicID(ctx, *mapAsset.LegacyMapPublicID, strings.TrimSpace(*input.LegacyVersionID)) if err != nil { return nil, err } if legacyVersion == nil { return nil, apperr.New(http.StatusNotFound, "legacy_tile_version_not_found", "legacy map version not found") } legacyVersionID = &legacyVersion.ID } publicID, err := security.GeneratePublicID("tile") if err != nil { return nil, err } tx, err := s.store.Begin(ctx) if err != nil { return nil, err } defer tx.Rollback(ctx) publishedAt := time.Now() release, err := s.store.CreateTileRelease(ctx, tx, postgres.CreateTileReleaseParams{ PublicID: publicID, MapAssetID: mapAsset.ID, LegacyMapVersionID: legacyVersionID, VersionCode: input.VersionCode, Status: normalizeReleaseStatus(input.Status), TileBaseURL: input.TileBaseURL, MetaURL: input.MetaURL, PublishedAssetRoot: trimStringPtr(input.PublishedAssetRoot), MetadataJSON: input.Metadata, PublishedAt: &publishedAt, }) if err != nil { return nil, err } if input.SetAsCurrent { if err := s.store.SetMapAssetCurrentTileRelease(ctx, tx, mapAsset.ID, release.ID); err != nil { return nil, err } } if err := tx.Commit(ctx); err != nil { return nil, err } view := buildAdminTileReleaseView(*release) return &view, nil } func (s *AdminProductionService) ListCourseSources(ctx context.Context, limit int) ([]AdminCourseSourceSummary, error) { items, err := s.store.ListCourseSources(ctx, limit) if err != nil { return nil, err } result := make([]AdminCourseSourceSummary, 0, len(items)) for _, item := range items { result = append(result, buildAdminCourseSourceSummary(item)) } return result, nil } func (s *AdminProductionService) CreateCourseSource(ctx context.Context, input CreateAdminCourseSourceInput) (*AdminCourseSourceSummary, error) { sourceType := strings.TrimSpace(input.SourceType) fileURL := strings.TrimSpace(input.FileURL) if sourceType == "" || fileURL == "" { return nil, apperr.New(http.StatusBadRequest, "invalid_params", "sourceType and fileUrl are required") } var legacyPlayfieldVersionID *string if input.LegacyPlayfieldID != nil && input.LegacyVersionID != nil && strings.TrimSpace(*input.LegacyPlayfieldID) != "" && strings.TrimSpace(*input.LegacyVersionID) != "" { version, err := s.store.GetResourcePlayfieldVersionByPublicID(ctx, strings.TrimSpace(*input.LegacyPlayfieldID), strings.TrimSpace(*input.LegacyVersionID)) if err != nil { return nil, err } if version == nil { return nil, apperr.New(http.StatusNotFound, "legacy_playfield_version_not_found", "legacy playfield version not found") } legacyPlayfieldVersionID = &version.ID } publicID, err := security.GeneratePublicID("csrc") 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.CreateCourseSource(ctx, tx, postgres.CreateCourseSourceParams{ PublicID: publicID, LegacyPlayfieldVersionID: legacyPlayfieldVersionID, SourceType: sourceType, FileURL: fileURL, Checksum: trimStringPtr(input.Checksum), ParserVersion: trimStringPtr(input.ParserVersion), ImportStatus: normalizeCourseSourceStatus(input.ImportStatus), MetadataJSON: input.Metadata, }) if err != nil { return nil, err } if err := tx.Commit(ctx); err != nil { return nil, err } result := buildAdminCourseSourceSummary(*item) return &result, nil } func (s *AdminProductionService) GetCourseSource(ctx context.Context, sourcePublicID string) (*AdminCourseSourceSummary, error) { item, err := s.store.GetCourseSourceByPublicID(ctx, strings.TrimSpace(sourcePublicID)) if err != nil { return nil, err } if item == nil { return nil, apperr.New(http.StatusNotFound, "course_source_not_found", "course source not found") } result := buildAdminCourseSourceSummary(*item) return &result, nil } func (s *AdminProductionService) CreateCourseSet(ctx context.Context, mapAssetPublicID string, input CreateAdminCourseSetInput) (*AdminCourseSetBrief, error) { mapAsset, err := s.store.GetMapAssetByPublicID(ctx, strings.TrimSpace(mapAssetPublicID)) if err != nil { return nil, err } if mapAsset == nil { return nil, apperr.New(http.StatusNotFound, "map_asset_not_found", "map asset not found") } input.Code = strings.TrimSpace(input.Code) input.Mode = strings.TrimSpace(input.Mode) input.Name = strings.TrimSpace(input.Name) if input.Code == "" || input.Mode == "" || input.Name == "" { return nil, apperr.New(http.StatusBadRequest, "invalid_params", "code, mode and name are required") } publicID, err := security.GeneratePublicID("cset") 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.CreateCourseSet(ctx, tx, postgres.CreateCourseSetParams{ PublicID: publicID, PlaceID: mapAsset.PlaceID, MapAssetID: mapAsset.ID, Code: input.Code, Mode: input.Mode, Name: input.Name, Description: trimStringPtr(input.Description), Status: normalizeCatalogStatus(input.Status), }) if err != nil { return nil, err } if err := tx.Commit(ctx); err != nil { return nil, err } brief, err := s.buildAdminCourseSetBrief(ctx, *item) if err != nil { return nil, err } return &brief, nil } func (s *AdminProductionService) GetCourseSetDetail(ctx context.Context, courseSetPublicID string) (*AdminCourseSetDetail, error) { item, err := s.store.GetCourseSetByPublicID(ctx, strings.TrimSpace(courseSetPublicID)) if err != nil { return nil, err } if item == nil { return nil, apperr.New(http.StatusNotFound, "course_set_not_found", "course set not found") } brief, err := s.buildAdminCourseSetBrief(ctx, *item) if err != nil { return nil, err } variants, err := s.store.ListCourseVariantsByCourseSetID(ctx, item.ID) if err != nil { return nil, err } result := &AdminCourseSetDetail{ CourseSet: brief, Variants: make([]AdminCourseVariantView, 0, len(variants)), } for _, variant := range variants { result.Variants = append(result.Variants, buildAdminCourseVariantView(variant)) } return result, nil } func (s *AdminProductionService) CreateCourseVariant(ctx context.Context, courseSetPublicID string, input CreateAdminCourseVariantInput) (*AdminCourseVariantView, error) { courseSet, err := s.store.GetCourseSetByPublicID(ctx, strings.TrimSpace(courseSetPublicID)) if err != nil { return nil, err } if courseSet == nil { return nil, apperr.New(http.StatusNotFound, "course_set_not_found", "course set not found") } input.Name = strings.TrimSpace(input.Name) input.Mode = strings.TrimSpace(input.Mode) if input.Name == "" || input.Mode == "" { return nil, apperr.New(http.StatusBadRequest, "invalid_params", "name and mode are required") } var sourceID *string if input.SourceID != nil && strings.TrimSpace(*input.SourceID) != "" { source, err := s.store.GetCourseSourceByPublicID(ctx, strings.TrimSpace(*input.SourceID)) if err != nil { return nil, err } if source == nil { return nil, apperr.New(http.StatusNotFound, "course_source_not_found", "course source not found") } sourceID = &source.ID } publicID, err := security.GeneratePublicID("cvar") 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.CreateCourseVariant(ctx, tx, postgres.CreateCourseVariantParams{ PublicID: publicID, CourseSetID: courseSet.ID, SourceID: sourceID, Name: input.Name, RouteCode: trimStringPtr(input.RouteCode), Mode: input.Mode, ControlCount: input.ControlCount, Difficulty: trimStringPtr(input.Difficulty), Status: normalizeCatalogStatus(input.Status), IsDefault: input.IsDefault, ConfigPatch: input.ConfigPatch, MetadataJSON: input.Metadata, }) if err != nil { return nil, err } if input.IsDefault { if err := s.store.SetCourseSetCurrentVariant(ctx, tx, courseSet.ID, item.ID); err != nil { return nil, err } } if err := tx.Commit(ctx); err != nil { return nil, err } view := buildAdminCourseVariantView(*item) return &view, nil } func (s *AdminProductionService) ListRuntimeBindings(ctx context.Context, limit int) ([]AdminRuntimeBindingSummary, error) { items, err := s.store.ListMapRuntimeBindings(ctx, limit) if err != nil { return nil, err } result := make([]AdminRuntimeBindingSummary, 0, len(items)) for _, item := range items { result = append(result, buildAdminRuntimeBindingSummary(item)) } return result, nil } func (s *AdminProductionService) CreateRuntimeBinding(ctx context.Context, input CreateAdminRuntimeBindingInput) (*AdminRuntimeBindingSummary, error) { eventRecord, err := s.store.GetAdminEventByPublicID(ctx, strings.TrimSpace(input.EventID)) if err != nil { return nil, err } if eventRecord == nil { return nil, apperr.New(http.StatusNotFound, "event_not_found", "event not found") } place, err := s.store.GetPlaceByPublicID(ctx, strings.TrimSpace(input.PlaceID)) if err != nil { return nil, err } if place == nil { return nil, apperr.New(http.StatusNotFound, "place_not_found", "place not found") } mapAsset, err := s.store.GetMapAssetByPublicID(ctx, strings.TrimSpace(input.MapAssetID)) if err != nil { return nil, err } if mapAsset == nil || mapAsset.PlaceID != place.ID { return nil, apperr.New(http.StatusBadRequest, "map_asset_mismatch", "map asset does not belong to place") } tileRelease, err := s.store.GetTileReleaseByPublicID(ctx, strings.TrimSpace(input.TileReleaseID)) if err != nil { return nil, err } if tileRelease == nil || tileRelease.MapAssetID != mapAsset.ID { return nil, apperr.New(http.StatusBadRequest, "tile_release_mismatch", "tile release does not belong to map asset") } courseSet, err := s.store.GetCourseSetByPublicID(ctx, strings.TrimSpace(input.CourseSetID)) if err != nil { return nil, err } if courseSet == nil || courseSet.PlaceID != place.ID || courseSet.MapAssetID != mapAsset.ID { return nil, apperr.New(http.StatusBadRequest, "course_set_mismatch", "course set does not match place/map asset") } courseVariant, err := s.store.GetCourseVariantByPublicID(ctx, strings.TrimSpace(input.CourseVariantID)) if err != nil { return nil, err } if courseVariant == nil || courseVariant.CourseSetID != courseSet.ID { return nil, apperr.New(http.StatusBadRequest, "course_variant_mismatch", "course variant does not belong to course set") } publicID, err := security.GeneratePublicID("rtbind") 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.CreateMapRuntimeBinding(ctx, tx, postgres.CreateMapRuntimeBindingParams{ PublicID: publicID, EventID: eventRecord.ID, PlaceID: place.ID, MapAssetID: mapAsset.ID, TileReleaseID: tileRelease.ID, CourseSetID: courseSet.ID, CourseVariantID: courseVariant.ID, Status: normalizeRuntimeBindingStatus(input.Status), Notes: trimStringPtr(input.Notes), }) if err != nil { return nil, err } if err := tx.Commit(ctx); err != nil { return nil, err } created, err := s.store.GetMapRuntimeBindingByPublicID(ctx, item.PublicID) if err != nil { return nil, err } if created == nil { return nil, apperr.New(http.StatusNotFound, "runtime_binding_not_found", "runtime binding not found") } result := buildAdminRuntimeBindingSummary(*created) return &result, nil } func (s *AdminProductionService) GetRuntimeBinding(ctx context.Context, runtimeBindingPublicID string) (*AdminRuntimeBindingSummary, error) { item, err := s.store.GetMapRuntimeBindingByPublicID(ctx, strings.TrimSpace(runtimeBindingPublicID)) if err != nil { return nil, err } if item == nil { return nil, apperr.New(http.StatusNotFound, "runtime_binding_not_found", "runtime binding not found") } result := buildAdminRuntimeBindingSummary(*item) return &result, nil } func (s *AdminProductionService) buildAdminMapAssetSummary(ctx context.Context, item postgres.MapAsset) (AdminMapAssetSummary, error) { result := AdminMapAssetSummary{ ID: item.PublicID, PlaceID: item.PlaceID, LegacyMapID: item.LegacyMapPublicID, Code: item.Code, Name: item.Name, MapType: item.MapType, CoverURL: item.CoverURL, Description: item.Description, Status: item.Status, } if item.CurrentTileReleaseID != nil { releases, err := s.store.ListTileReleasesByMapAssetID(ctx, item.ID) if err != nil { return result, err } for _, release := range releases { if release.ID == *item.CurrentTileReleaseID { result.CurrentTileRelease = &AdminTileReleaseBrief{ ID: release.PublicID, VersionCode: release.VersionCode, Status: release.Status, } break } } } return result, nil } func (s *AdminProductionService) buildAdminCourseSetBrief(ctx context.Context, item postgres.CourseSet) (AdminCourseSetBrief, error) { result := AdminCourseSetBrief{ ID: item.PublicID, Code: item.Code, Mode: item.Mode, Name: item.Name, Description: item.Description, Status: item.Status, } if item.CurrentVariantID != nil { variants, err := s.store.ListCourseVariantsByCourseSetID(ctx, item.ID) if err != nil { return result, err } for _, variant := range variants { if variant.ID == *item.CurrentVariantID { result.CurrentVariant = &AdminCourseVariantBrief{ ID: variant.PublicID, Name: variant.Name, RouteCode: variant.RouteCode, Status: variant.Status, } break } } } return result, nil } func buildAdminPlaceSummary(item postgres.Place) AdminPlaceSummary { return AdminPlaceSummary{ ID: item.PublicID, Code: item.Code, Name: item.Name, Region: item.Region, CoverURL: item.CoverURL, Description: item.Description, CenterPoint: decodeJSONMap(item.CenterPoint), Status: item.Status, } } func buildAdminTileReleaseView(item postgres.TileRelease) AdminTileReleaseView { return AdminTileReleaseView{ ID: item.PublicID, LegacyVersionID: item.LegacyMapVersionPub, VersionCode: item.VersionCode, Status: item.Status, TileBaseURL: item.TileBaseURL, MetaURL: item.MetaURL, PublishedAssetRoot: item.PublishedAssetRoot, Metadata: decodeJSONMap(item.MetadataJSON), PublishedAt: item.PublishedAt, } } func buildAdminCourseSourceSummary(item postgres.CourseSource) AdminCourseSourceSummary { return AdminCourseSourceSummary{ ID: item.PublicID, LegacyVersionID: item.LegacyPlayfieldVersionPub, SourceType: item.SourceType, FileURL: item.FileURL, Checksum: item.Checksum, ParserVersion: item.ParserVersion, ImportStatus: item.ImportStatus, Metadata: decodeJSONMap(item.MetadataJSON), ImportedAt: item.ImportedAt, } } func buildAdminCourseVariantView(item postgres.CourseVariant) AdminCourseVariantView { return AdminCourseVariantView{ ID: item.PublicID, SourceID: item.SourcePublicID, Name: item.Name, RouteCode: item.RouteCode, Mode: item.Mode, ControlCount: item.ControlCount, Difficulty: item.Difficulty, Status: item.Status, IsDefault: item.IsDefault, ConfigPatch: decodeJSONMap(item.ConfigPatch), Metadata: decodeJSONMap(item.MetadataJSON), } } func buildAdminRuntimeBindingSummary(item postgres.MapRuntimeBinding) AdminRuntimeBindingSummary { return AdminRuntimeBindingSummary{ ID: item.PublicID, EventID: item.EventPublicID, PlaceID: item.PlacePublicID, MapAssetID: item.MapAssetPublicID, TileReleaseID: item.TileReleasePublicID, CourseSetID: item.CourseSetPublicID, CourseVariantID: item.CourseVariantPublicID, Status: item.Status, Notes: item.Notes, } } func normalizeCourseSourceStatus(value string) string { switch strings.TrimSpace(value) { case "draft": return "draft" case "parsed": return "parsed" case "failed": return "failed" case "archived": return "archived" default: return "imported" } } func normalizeRuntimeBindingStatus(value string) string { switch strings.TrimSpace(value) { case "active": return "active" case "disabled": return "disabled" case "archived": return "archived" default: return "draft" } } func normalizeReleaseStatus(value string) string { switch strings.TrimSpace(value) { case "active": return "active" case "published": return "published" case "retired": return "retired" case "archived": return "archived" default: return "draft" } }