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 }