package service import ( "context" "encoding/json" "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"` Presentation *PresentationSummaryView `json:"presentation,omitempty"` ContentBundle *ContentBundleSummaryView `json:"contentBundle,omitempty"` } type AdminEventDetail struct { Event AdminEventSummary `json:"event"` LatestSource *EventConfigSourceView `json:"latestSource,omitempty"` SourceCount int `json:"sourceCount"` CurrentSource *AdminAssembledSource `json:"currentSource,omitempty"` PresentationCount int `json:"presentationCount"` ContentBundleCount int `json:"contentBundleCount"` CurrentPresentation *PresentationSummaryView `json:"currentPresentation,omitempty"` CurrentContentBundle *ContentBundleSummaryView `json:"currentContentBundle,omitempty"` CurrentRuntime *RuntimeSummaryView `json:"currentRuntime,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 AdminEventPresentationView struct { ID string `json:"id"` EventID string `json:"eventId"` Code string `json:"code"` Name string `json:"name"` PresentationType string `json:"presentationType"` Status string `json:"status"` IsDefault bool `json:"isDefault"` TemplateKey *string `json:"templateKey,omitempty"` Version *string `json:"version,omitempty"` SourceType *string `json:"sourceType,omitempty"` SchemaURL *string `json:"schemaUrl,omitempty"` Schema map[string]any `json:"schema"` } type CreateAdminEventPresentationInput struct { Code string `json:"code"` Name string `json:"name"` PresentationType string `json:"presentationType"` Status string `json:"status"` IsDefault bool `json:"isDefault"` Schema map[string]any `json:"schema,omitempty"` } type ImportAdminEventPresentationInput struct { Title string `json:"title"` TemplateKey string `json:"templateKey"` SourceType string `json:"sourceType"` SchemaURL string `json:"schemaUrl"` Version string `json:"version"` Status string `json:"status"` IsDefault bool `json:"isDefault"` } type AdminContentBundleView struct { ID string `json:"id"` EventID string `json:"eventId"` Code string `json:"code"` Name string `json:"name"` Status string `json:"status"` IsDefault bool `json:"isDefault"` BundleType *string `json:"bundleType,omitempty"` Version *string `json:"version,omitempty"` SourceType *string `json:"sourceType,omitempty"` ManifestURL *string `json:"manifestUrl,omitempty"` AssetManifest any `json:"assetManifest,omitempty"` EntryURL *string `json:"entryUrl,omitempty"` AssetRootURL *string `json:"assetRootUrl,omitempty"` Metadata map[string]any `json:"metadata"` } type CreateAdminContentBundleInput struct { Code string `json:"code"` Name string `json:"name"` Status string `json:"status"` IsDefault bool `json:"isDefault"` EntryURL *string `json:"entryUrl,omitempty"` AssetRootURL *string `json:"assetRootUrl,omitempty"` Metadata map[string]any `json:"metadata,omitempty"` } type ImportAdminContentBundleInput struct { Title string `json:"title"` BundleType string `json:"bundleType"` SourceType string `json:"sourceType"` ManifestURL string `json:"manifestUrl"` Version string `json:"version"` Status string `json:"status"` IsDefault bool `json:"isDefault"` AssetManifest map[string]any `json:"assetManifest,omitempty"` } type UpdateAdminEventDefaultsInput struct { PresentationID *string `json:"presentationId,omitempty"` ContentBundleID *string `json:"contentBundleId,omitempty"` RuntimeBindingID *string `json:"runtimeBindingId,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 } presentations, err := s.store.ListEventPresentationsByEventID(ctx, record.ID, 200) if err != nil { return nil, err } contentBundles, err := s.store.ListContentBundlesByEventID(ctx, record.ID, 200) if err != nil { return nil, err } result := &AdminEventDetail{ Event: buildAdminEventSummary(*record), SourceCount: len(allSources), PresentationCount: len(presentations), ContentBundleCount: len(contentBundles), } 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) } result.CurrentPresentation = buildPresentationSummaryFromEventRecord(record) result.CurrentContentBundle = buildContentBundleSummaryFromEventRecord(record) result.CurrentRuntime = buildRuntimeSummaryFromAdminEventRecord(record) if enrichedPresentation, err := loadPresentationSummaryByPublicID(ctx, s.store, record.CurrentPresentationID); err != nil { return nil, err } else if enrichedPresentation != nil { result.CurrentPresentation = enrichedPresentation } if enrichedBundle, err := loadContentBundleSummaryByPublicID(ctx, s.store, record.CurrentContentBundleID); err != nil { return nil, err } else if enrichedBundle != nil { result.CurrentContentBundle = enrichedBundle } return result, nil } func (s *AdminEventService) ListEventPresentations(ctx context.Context, eventPublicID string, limit int) ([]AdminEventPresentationView, 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") } items, err := s.store.ListEventPresentationsByEventID(ctx, eventRecord.ID, limit) if err != nil { return nil, err } result := make([]AdminEventPresentationView, 0, len(items)) for _, item := range items { view, err := buildAdminEventPresentationView(item) if err != nil { return nil, err } result = append(result, view) } return result, nil } func (s *AdminEventService) CreateEventPresentation(ctx context.Context, eventPublicID string, input CreateAdminEventPresentationInput) (*AdminEventPresentationView, 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.Code = strings.TrimSpace(input.Code) input.Name = strings.TrimSpace(input.Name) input.PresentationType = normalizePresentationType(input.PresentationType) if input.Code == "" || input.Name == "" { return nil, apperr.New(http.StatusBadRequest, "invalid_params", "code and name are required") } publicID, err := security.GeneratePublicID("pres") if err != nil { return nil, err } schema := input.Schema if schema == nil { schema = map[string]any{} } tx, err := s.store.Begin(ctx) if err != nil { return nil, err } defer tx.Rollback(ctx) record, err := s.store.CreateEventPresentation(ctx, tx, postgres.CreateEventPresentationParams{ PublicID: publicID, EventID: eventRecord.ID, Code: input.Code, Name: input.Name, PresentationType: input.PresentationType, Status: normalizeEventCatalogStatus(input.Status), IsDefault: input.IsDefault, SchemaJSON: mustMarshalJSONObject(schema), }) if err != nil { return nil, err } if err := tx.Commit(ctx); err != nil { return nil, err } view, err := buildAdminEventPresentationView(*record) if err != nil { return nil, err } return &view, nil } func (s *AdminEventService) ImportEventPresentation(ctx context.Context, eventPublicID string, input ImportAdminEventPresentationInput) (*AdminEventPresentationView, 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.Title = strings.TrimSpace(input.Title) input.TemplateKey = strings.TrimSpace(input.TemplateKey) input.SourceType = strings.TrimSpace(input.SourceType) input.SchemaURL = strings.TrimSpace(input.SchemaURL) input.Version = strings.TrimSpace(input.Version) if input.Title == "" || input.TemplateKey == "" || input.SourceType == "" || input.SchemaURL == "" || input.Version == "" { return nil, apperr.New(http.StatusBadRequest, "invalid_params", "title, templateKey, sourceType, schemaUrl and version are required") } publicID, err := security.GeneratePublicID("pres") if err != nil { return nil, err } code := generateImportedPresentationCode(input.Title, publicID) status := normalizeEventCatalogStatus(input.Status) if strings.TrimSpace(input.Status) == "" { status = "active" } schema := map[string]any{ "templateKey": input.TemplateKey, "sourceType": input.SourceType, "schemaUrl": input.SchemaURL, "version": input.Version, } tx, err := s.store.Begin(ctx) if err != nil { return nil, err } defer tx.Rollback(ctx) record, err := s.store.CreateEventPresentation(ctx, tx, postgres.CreateEventPresentationParams{ PublicID: publicID, EventID: eventRecord.ID, Code: code, Name: input.Title, PresentationType: "generic", Status: status, IsDefault: input.IsDefault, SchemaJSON: mustMarshalJSONObject(schema), }) if err != nil { return nil, err } if err := tx.Commit(ctx); err != nil { return nil, err } view, err := buildAdminEventPresentationView(*record) if err != nil { return nil, err } return &view, nil } func (s *AdminEventService) GetEventPresentation(ctx context.Context, presentationPublicID string) (*AdminEventPresentationView, error) { record, err := s.store.GetEventPresentationByPublicID(ctx, strings.TrimSpace(presentationPublicID)) if err != nil { return nil, err } if record == nil { return nil, apperr.New(http.StatusNotFound, "presentation_not_found", "presentation not found") } view, err := buildAdminEventPresentationView(*record) if err != nil { return nil, err } return &view, nil } func (s *AdminEventService) ListContentBundles(ctx context.Context, eventPublicID string, limit int) ([]AdminContentBundleView, 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") } items, err := s.store.ListContentBundlesByEventID(ctx, eventRecord.ID, limit) if err != nil { return nil, err } result := make([]AdminContentBundleView, 0, len(items)) for _, item := range items { view, err := buildAdminContentBundleView(item) if err != nil { return nil, err } result = append(result, view) } return result, nil } func (s *AdminEventService) CreateContentBundle(ctx context.Context, eventPublicID string, input CreateAdminContentBundleInput) (*AdminContentBundleView, 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.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("bundle") if err != nil { return nil, err } metadata := input.Metadata if metadata == nil { metadata = map[string]any{} } tx, err := s.store.Begin(ctx) if err != nil { return nil, err } defer tx.Rollback(ctx) record, err := s.store.CreateContentBundle(ctx, tx, postgres.CreateContentBundleParams{ PublicID: publicID, EventID: eventRecord.ID, Code: input.Code, Name: input.Name, Status: normalizeEventCatalogStatus(input.Status), IsDefault: input.IsDefault, EntryURL: trimStringPtr(input.EntryURL), AssetRootURL: trimStringPtr(input.AssetRootURL), MetadataJSON: mustMarshalJSONObject(metadata), }) if err != nil { return nil, err } if err := tx.Commit(ctx); err != nil { return nil, err } view, err := buildAdminContentBundleView(*record) if err != nil { return nil, err } return &view, nil } func (s *AdminEventService) ImportContentBundle(ctx context.Context, eventPublicID string, input ImportAdminContentBundleInput) (*AdminContentBundleView, 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.Title = strings.TrimSpace(input.Title) input.BundleType = strings.TrimSpace(input.BundleType) input.SourceType = strings.TrimSpace(input.SourceType) input.ManifestURL = strings.TrimSpace(input.ManifestURL) input.Version = strings.TrimSpace(input.Version) if input.Title == "" || input.BundleType == "" || input.SourceType == "" || input.ManifestURL == "" || input.Version == "" { return nil, apperr.New(http.StatusBadRequest, "invalid_params", "title, bundleType, sourceType, manifestUrl and version are required") } publicID, err := security.GeneratePublicID("bundle") if err != nil { return nil, err } code := generateImportedBundleCode(input.Title, publicID) assetManifest := input.AssetManifest if assetManifest == nil { assetManifest = map[string]any{ "manifestUrl": input.ManifestURL, "sourceType": input.SourceType, } } metadata := map[string]any{ "bundleType": input.BundleType, "sourceType": input.SourceType, "manifestUrl": input.ManifestURL, "version": input.Version, "assetManifest": assetManifest, } status := normalizeEventCatalogStatus(input.Status) if strings.TrimSpace(input.Status) == "" { status = "active" } tx, err := s.store.Begin(ctx) if err != nil { return nil, err } defer tx.Rollback(ctx) record, err := s.store.CreateContentBundle(ctx, tx, postgres.CreateContentBundleParams{ PublicID: publicID, EventID: eventRecord.ID, Code: code, Name: input.Title, Status: status, IsDefault: input.IsDefault, EntryURL: nil, AssetRootURL: nil, MetadataJSON: mustMarshalJSONObject(metadata), }) if err != nil { return nil, err } if err := tx.Commit(ctx); err != nil { return nil, err } view, err := buildAdminContentBundleView(*record) if err != nil { return nil, err } return &view, nil } func (s *AdminEventService) GetContentBundle(ctx context.Context, contentBundlePublicID string) (*AdminContentBundleView, error) { record, err := s.store.GetContentBundleByPublicID(ctx, strings.TrimSpace(contentBundlePublicID)) if err != nil { return nil, err } if record == nil { return nil, apperr.New(http.StatusNotFound, "content_bundle_not_found", "content bundle not found") } view, err := buildAdminContentBundleView(*record) if err != nil { return nil, err } return &view, nil } func (s *AdminEventService) UpdateEventDefaults(ctx context.Context, eventPublicID string, input UpdateAdminEventDefaultsInput) (*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") } var presentationID *string updatePresentation := false if input.PresentationID != nil { updatePresentation = true trimmed := strings.TrimSpace(*input.PresentationID) if trimmed != "" { presentation, err := s.store.GetEventPresentationByPublicID(ctx, trimmed) if err != nil { return nil, err } if presentation == nil { return nil, apperr.New(http.StatusNotFound, "presentation_not_found", "presentation not found") } if presentation.EventID != record.ID { return nil, apperr.New(http.StatusConflict, "presentation_not_belong_to_event", "presentation does not belong to event") } presentationID = &presentation.ID } } var contentBundleID *string updateContent := false if input.ContentBundleID != nil { updateContent = true trimmed := strings.TrimSpace(*input.ContentBundleID) if trimmed != "" { contentBundle, err := s.store.GetContentBundleByPublicID(ctx, trimmed) if err != nil { return nil, err } if contentBundle == nil { return nil, apperr.New(http.StatusNotFound, "content_bundle_not_found", "content bundle not found") } if contentBundle.EventID != record.ID { return nil, apperr.New(http.StatusConflict, "content_bundle_not_belong_to_event", "content bundle does not belong to event") } contentBundleID = &contentBundle.ID } } var runtimeBindingID *string updateRuntime := false if input.RuntimeBindingID != nil { updateRuntime = true trimmed := strings.TrimSpace(*input.RuntimeBindingID) if trimmed != "" { runtimeBinding, err := s.store.GetMapRuntimeBindingByPublicID(ctx, trimmed) if err != nil { return nil, err } if runtimeBinding == nil { return nil, apperr.New(http.StatusNotFound, "runtime_binding_not_found", "runtime binding not found") } if runtimeBinding.EventID != record.ID { return nil, apperr.New(http.StatusConflict, "runtime_binding_not_belong_to_event", "runtime binding does not belong to event") } runtimeBindingID = &runtimeBinding.ID } } tx, err := s.store.Begin(ctx) if err != nil { return nil, err } defer tx.Rollback(ctx) if err := s.store.SetEventDefaultBindings(ctx, tx, postgres.SetEventDefaultBindingsParams{ EventID: record.ID, PresentationID: presentationID, ContentBundleID: contentBundleID, RuntimeBindingID: runtimeBindingID, UpdatePresentation: updatePresentation, UpdateContent: updateContent, UpdateRuntime: updateRuntime, }); err != nil { return nil, err } if err := tx.Commit(ctx); err != nil { return nil, err } return s.GetEventDetail(ctx, eventPublicID) } 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, Presentation: buildPresentationSummaryFromEventRecord(&item), ContentBundle: buildContentBundleSummaryFromEventRecord(&item), } } return summary } func buildPresentationSummaryFromEventRecord(item *postgres.AdminEventRecord) *PresentationSummaryView { if item == nil || item.CurrentPresentationID == nil { return nil } return &PresentationSummaryView{ PresentationID: *item.CurrentPresentationID, Name: item.CurrentPresentationName, PresentationType: item.CurrentPresentationType, } } func buildContentBundleSummaryFromEventRecord(item *postgres.AdminEventRecord) *ContentBundleSummaryView { if item == nil || item.CurrentContentBundleID == nil { return nil } return &ContentBundleSummaryView{ ContentBundleID: *item.CurrentContentBundleID, Name: item.CurrentContentBundleName, EntryURL: item.CurrentContentEntryURL, AssetRootURL: item.CurrentContentAssetRootURL, } } func buildRuntimeSummaryFromAdminEventRecord(item *postgres.AdminEventRecord) *RuntimeSummaryView { if item == nil || item.CurrentRuntimeBindingID == nil || item.CurrentPlaceID == nil || item.CurrentMapAssetID == nil || item.CurrentTileReleaseID == nil || item.CurrentCourseSetID == nil || item.CurrentCourseVariantID == nil { return nil } return &RuntimeSummaryView{ RuntimeBindingID: *item.CurrentRuntimeBindingID, PlaceID: *item.CurrentPlaceID, MapID: *item.CurrentMapAssetID, TileReleaseID: *item.CurrentTileReleaseID, CourseSetID: *item.CurrentCourseSetID, CourseVariantID: *item.CurrentCourseVariantID, CourseVariantName: item.CurrentCourseVariantName, RouteCode: item.CurrentRuntimeRouteCode, } } func buildAdminEventPresentationView(item postgres.EventPresentation) (AdminEventPresentationView, error) { schema, err := decodeJSONObject(item.SchemaJSON) if err != nil { return AdminEventPresentationView{}, err } return AdminEventPresentationView{ ID: item.PublicID, EventID: item.EventPublicID, Code: item.Code, Name: item.Name, PresentationType: item.PresentationType, Status: item.Status, IsDefault: item.IsDefault, TemplateKey: readStringField(schema, "templateKey"), Version: readStringField(schema, "version"), SourceType: readStringField(schema, "sourceType"), SchemaURL: readStringField(schema, "schemaUrl"), Schema: schema, }, nil } func buildAdminContentBundleView(item postgres.ContentBundle) (AdminContentBundleView, error) { metadata, err := decodeJSONObject(item.MetadataJSON) if err != nil { return AdminContentBundleView{}, err } return AdminContentBundleView{ ID: item.PublicID, EventID: item.EventPublicID, Code: item.Code, Name: item.Name, Status: item.Status, IsDefault: item.IsDefault, BundleType: readStringField(metadata, "bundleType"), Version: readStringField(metadata, "version"), SourceType: readStringField(metadata, "sourceType"), ManifestURL: readStringField(metadata, "manifestUrl"), AssetManifest: metadata["assetManifest"], EntryURL: item.EntryURL, AssetRootURL: item.AssetRootURL, Metadata: metadata, }, nil } func generateImportedBundleCode(title, publicID string) string { var builder strings.Builder for _, r := range strings.ToLower(title) { switch { case r >= 'a' && r <= 'z': builder.WriteRune(r) case r >= '0' && r <= '9': builder.WriteRune(r) case r == ' ' || r == '-' || r == '_': if builder.Len() == 0 { continue } last := builder.String()[builder.Len()-1] if last != '-' { builder.WriteByte('-') } } } code := strings.Trim(builder.String(), "-") if code == "" { code = "bundle" } suffix := publicID if len(suffix) > 8 { suffix = suffix[len(suffix)-8:] } return code + "-" + suffix } func generateImportedPresentationCode(title, publicID string) string { var builder strings.Builder for _, r := range strings.ToLower(title) { switch { case r >= 'a' && r <= 'z': builder.WriteRune(r) case r >= '0' && r <= '9': builder.WriteRune(r) case r == ' ' || r == '-' || r == '_': if builder.Len() == 0 { continue } last := builder.String()[builder.Len()-1] if last != '-' { builder.WriteByte('-') } } } code := strings.Trim(builder.String(), "-") if code == "" { code = "presentation" } suffix := publicID if len(suffix) > 8 { suffix = suffix[len(suffix)-8:] } return code + "-" + suffix } 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 normalizePresentationType(value string) string { switch strings.TrimSpace(value) { case "card": return "card" case "detail": return "detail" case "h5": return "h5" case "result": return "result" default: return "generic" } } func mustMarshalJSONObject(value map[string]any) string { raw, _ := json.Marshal(value) return string(raw) } 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 } }