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 } }