package service import ( "context" "encoding/json" "fmt" "net/http" "os" "path/filepath" "sort" "strings" "cmr-backend/internal/apperr" "cmr-backend/internal/platform/assets" "cmr-backend/internal/platform/security" "cmr-backend/internal/store/postgres" ) type ConfigService struct { store *postgres.Store localEventDir string assetBaseURL string publisher *assets.OSSUtilPublisher } type ConfigPipelineSummary struct { SourceTable string `json:"sourceTable"` BuildTable string `json:"buildTable"` ReleaseAssetsTable string `json:"releaseAssetsTable"` } type LocalEventFile struct { FileName string `json:"fileName"` FullPath string `json:"fullPath"` } type EventConfigSourceView struct { ID string `json:"id"` EventID string `json:"eventId"` SourceVersionNo int `json:"sourceVersionNo"` SourceKind string `json:"sourceKind"` SchemaID string `json:"schemaId"` SchemaVersion string `json:"schemaVersion"` Status string `json:"status"` Notes *string `json:"notes,omitempty"` Source map[string]any `json:"source"` } type EventConfigBuildView struct { ID string `json:"id"` EventID string `json:"eventId"` SourceID string `json:"sourceId"` BuildNo int `json:"buildNo"` BuildStatus string `json:"buildStatus"` BuildLog *string `json:"buildLog,omitempty"` Manifest map[string]any `json:"manifest"` AssetIndex []map[string]any `json:"assetIndex"` } type PublishedReleaseView struct { EventID string `json:"eventId"` Release ResolvedReleaseView `json:"release"` ReleaseNo int `json:"releaseNo"` PublishedAt string `json:"publishedAt"` } type ImportLocalEventConfigInput struct { EventPublicID string FileName string `json:"fileName"` Notes *string `json:"notes,omitempty"` } type BuildPreviewInput struct { SourceID string `json:"sourceId"` } type PublishBuildInput struct { BuildID string `json:"buildId"` } func NewConfigService(store *postgres.Store, localEventDir, assetBaseURL string, publisher *assets.OSSUtilPublisher) *ConfigService { return &ConfigService{ store: store, localEventDir: localEventDir, assetBaseURL: strings.TrimRight(assetBaseURL, "/"), publisher: publisher, } } func (s *ConfigService) PipelineSummary() ConfigPipelineSummary { return ConfigPipelineSummary{ SourceTable: "event_config_sources", BuildTable: "event_config_builds", ReleaseAssetsTable: "event_release_assets", } } func (s *ConfigService) ListLocalEventFiles() ([]LocalEventFile, error) { dir, err := filepath.Abs(s.localEventDir) if err != nil { return nil, apperr.New(http.StatusInternalServerError, "config_dir_invalid", "failed to resolve local event directory") } entries, err := os.ReadDir(dir) if err != nil { return nil, apperr.New(http.StatusInternalServerError, "config_dir_unavailable", "failed to read local event directory") } files := make([]LocalEventFile, 0) for _, entry := range entries { if entry.IsDir() { continue } if strings.ToLower(filepath.Ext(entry.Name())) != ".json" { continue } files = append(files, LocalEventFile{ FileName: entry.Name(), FullPath: filepath.Join(dir, entry.Name()), }) } sort.Slice(files, func(i, j int) bool { return files[i].FileName < files[j].FileName }) return files, nil } func (s *ConfigService) ListEventConfigSources(ctx context.Context, eventPublicID string, limit int) ([]EventConfigSourceView, error) { event, err := s.requireEvent(ctx, eventPublicID) if err != nil { return nil, err } items, err := s.store.ListEventConfigSourcesByEventID(ctx, event.ID, limit) if err != nil { return nil, err } results := make([]EventConfigSourceView, 0, len(items)) for i := range items { view, err := buildEventConfigSourceView(&items[i], event.PublicID) if err != nil { return nil, err } results = append(results, *view) } return results, nil } func (s *ConfigService) GetEventConfigSource(ctx context.Context, sourceID string) (*EventConfigSourceView, error) { record, err := s.store.GetEventConfigSourceByID(ctx, strings.TrimSpace(sourceID)) if err != nil { return nil, err } if record == nil { return nil, apperr.New(http.StatusNotFound, "config_source_not_found", "config source not found") } return buildEventConfigSourceView(record, "") } func (s *ConfigService) GetEventConfigBuild(ctx context.Context, buildID string) (*EventConfigBuildView, error) { record, err := s.store.GetEventConfigBuildByID(ctx, strings.TrimSpace(buildID)) if err != nil { return nil, err } if record == nil { return nil, apperr.New(http.StatusNotFound, "config_build_not_found", "config build not found") } return buildEventConfigBuildView(record) } func (s *ConfigService) ImportLocalEventConfig(ctx context.Context, input ImportLocalEventConfigInput) (*EventConfigSourceView, error) { event, err := s.requireEvent(ctx, input.EventPublicID) if err != nil { return nil, err } fileName := strings.TrimSpace(filepath.Base(input.FileName)) if fileName == "" || strings.Contains(fileName, "..") || strings.ToLower(filepath.Ext(fileName)) != ".json" { return nil, apperr.New(http.StatusBadRequest, "invalid_params", "valid json fileName is required") } dir, err := filepath.Abs(s.localEventDir) if err != nil { return nil, apperr.New(http.StatusInternalServerError, "config_dir_invalid", "failed to resolve local event directory") } path := filepath.Join(dir, fileName) raw, err := os.ReadFile(path) if err != nil { return nil, apperr.New(http.StatusNotFound, "config_file_not_found", "local config file not found") } source := map[string]any{} if err := json.Unmarshal(raw, &source); err != nil { return nil, apperr.New(http.StatusBadRequest, "config_json_invalid", "local config file is not valid json") } if err := validateSourceConfig(source); err != nil { return nil, err } nextVersion, err := s.store.NextEventConfigSourceVersion(ctx, event.ID) if err != nil { return nil, err } note := input.Notes if note == nil || strings.TrimSpace(*note) == "" { defaultNote := "imported from local event file: " + fileName 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: event.ID, SourceVersionNo: nextVersion, SourceKind: "event_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, event.PublicID) } func (s *ConfigService) BuildPreview(ctx context.Context, input BuildPreviewInput) (*EventConfigBuildView, error) { sourceRecord, err := s.store.GetEventConfigSourceByID(ctx, strings.TrimSpace(input.SourceID)) if err != nil { return nil, err } if sourceRecord == nil { return nil, apperr.New(http.StatusNotFound, "config_source_not_found", "config source not found") } source, err := decodeJSONObject(sourceRecord.SourceJSON) if err != nil { return nil, apperr.New(http.StatusInternalServerError, "config_source_invalid", "stored source config is invalid") } if err := validateSourceConfig(source); err != nil { return nil, err } buildNo, err := s.store.NextEventConfigBuildNo(ctx, sourceRecord.EventID) if err != nil { return nil, err } previewReleaseID := fmt.Sprintf("preview_%d", buildNo) manifest := s.buildPreviewManifest(source, previewReleaseID) assetIndex := s.buildAssetIndex(manifest) buildLog := "preview build generated from source " + sourceRecord.ID tx, err := s.store.Begin(ctx) if err != nil { return nil, err } defer tx.Rollback(ctx) record, err := s.store.UpsertEventConfigBuild(ctx, tx, postgres.UpsertEventConfigBuildParams{ EventID: sourceRecord.EventID, SourceID: sourceRecord.ID, BuildNo: buildNo, BuildStatus: "success", BuildLog: &buildLog, Manifest: manifest, AssetIndex: assetIndex, }) if err != nil { return nil, err } if err := tx.Commit(ctx); err != nil { return nil, err } return buildEventConfigBuildView(record) } func (s *ConfigService) PublishBuild(ctx context.Context, input PublishBuildInput) (*PublishedReleaseView, error) { buildRecord, err := s.store.GetEventConfigBuildByID(ctx, strings.TrimSpace(input.BuildID)) if err != nil { return nil, err } if buildRecord == nil { return nil, apperr.New(http.StatusNotFound, "config_build_not_found", "config build not found") } if buildRecord.BuildStatus != "success" { return nil, apperr.New(http.StatusConflict, "config_build_not_publishable", "config build is not publishable") } event, err := s.store.GetEventByID(ctx, buildRecord.EventID) if err != nil { return nil, err } if event == nil { return nil, apperr.New(http.StatusNotFound, "event_not_found", "event not found") } manifest, err := decodeJSONObject(buildRecord.ManifestJSON) if err != nil { return nil, apperr.New(http.StatusInternalServerError, "config_build_invalid", "stored build manifest is invalid") } assetIndex, err := decodeJSONArray(buildRecord.AssetIndexJSON) if err != nil { return nil, apperr.New(http.StatusInternalServerError, "config_build_invalid", "stored build asset index is invalid") } releaseNo, err := s.store.NextEventReleaseNo(ctx, event.ID) if err != nil { return nil, err } releasePublicID, err := security.GeneratePublicID("rel") if err != nil { return nil, err } configLabel := deriveConfigLabel(event, manifest, releaseNo) manifestURL := fmt.Sprintf("%s/event/releases/%s/%s/manifest.json", s.assetBaseURL, event.PublicID, releasePublicID) assetIndexURL := fmt.Sprintf("%s/event/releases/%s/%s/asset-index.json", s.assetBaseURL, event.PublicID, releasePublicID) checksum := security.HashText(buildRecord.ManifestJSON) routeCode := deriveRouteCode(manifest) if s.publisher == nil || !s.publisher.Enabled() { return nil, apperr.New(http.StatusInternalServerError, "asset_publish_unavailable", "asset publisher is not configured") } if err := s.publisher.UploadJSON(ctx, manifestURL, []byte(buildRecord.ManifestJSON)); err != nil { return nil, apperr.New(http.StatusInternalServerError, "asset_publish_failed", "failed to upload manifest: "+err.Error()) } if err := s.publisher.UploadJSON(ctx, assetIndexURL, []byte(buildRecord.AssetIndexJSON)); err != nil { return nil, apperr.New(http.StatusInternalServerError, "asset_publish_failed", "failed to upload asset index: "+err.Error()) } tx, err := s.store.Begin(ctx) if err != nil { return nil, err } defer tx.Rollback(ctx) releaseRecord, err := s.store.CreateEventRelease(ctx, tx, postgres.CreateEventReleaseParams{ PublicID: releasePublicID, EventID: event.ID, ReleaseNo: releaseNo, ConfigLabel: configLabel, ManifestURL: manifestURL, ManifestChecksum: &checksum, RouteCode: routeCode, BuildID: &buildRecord.ID, Status: "published", PayloadJSON: buildRecord.ManifestJSON, }) if err != nil { return nil, err } if err := s.store.ReplaceEventReleaseAssets(ctx, tx, releaseRecord.ID, s.mapBuildAssetsToReleaseAssets(releaseRecord.ID, manifestURL, assetIndexURL, &checksum, assetIndex)); err != nil { return nil, err } if err := s.store.SetCurrentEventRelease(ctx, tx, event.ID, releaseRecord.ID); err != nil { return nil, err } if err := tx.Commit(ctx); err != nil { return nil, err } return &PublishedReleaseView{ EventID: event.PublicID, Release: ResolvedReleaseView{ LaunchMode: LaunchModeManifestRelease, Source: LaunchSourceEventCurrentRelease, EventID: event.PublicID, ReleaseID: releaseRecord.PublicID, ConfigLabel: releaseRecord.ConfigLabel, ManifestURL: releaseRecord.ManifestURL, ManifestChecksumSha256: releaseRecord.ManifestChecksum, RouteCode: releaseRecord.RouteCode, }, ReleaseNo: releaseRecord.ReleaseNo, PublishedAt: releaseRecord.PublishedAt.Format(timeRFC3339), }, nil } func (s *ConfigService) requireEvent(ctx context.Context, eventPublicID string) (*postgres.Event, error) { eventPublicID = strings.TrimSpace(eventPublicID) if eventPublicID == "" { return nil, apperr.New(http.StatusBadRequest, "invalid_params", "event id is required") } event, err := s.store.GetEventByPublicID(ctx, eventPublicID) if err != nil { return nil, err } if event == nil { return nil, apperr.New(http.StatusNotFound, "event_not_found", "event not found") } return event, nil } func buildEventConfigSourceView(record *postgres.EventConfigSource, eventPublicID string) (*EventConfigSourceView, error) { source, err := decodeJSONObject(record.SourceJSON) if err != nil { return nil, err } view := &EventConfigSourceView{ ID: record.ID, EventID: eventPublicID, SourceVersionNo: record.SourceVersionNo, SourceKind: record.SourceKind, SchemaID: record.SchemaID, SchemaVersion: record.SchemaVersion, Status: record.Status, Notes: record.Notes, Source: source, } return view, nil } func buildEventConfigBuildView(record *postgres.EventConfigBuild) (*EventConfigBuildView, error) { manifest, err := decodeJSONObject(record.ManifestJSON) if err != nil { return nil, err } assetIndex, err := decodeJSONArray(record.AssetIndexJSON) if err != nil { return nil, err } return &EventConfigBuildView{ ID: record.ID, EventID: record.EventID, SourceID: record.SourceID, BuildNo: record.BuildNo, BuildStatus: record.BuildStatus, BuildLog: record.BuildLog, Manifest: manifest, AssetIndex: assetIndex, }, nil } func validateSourceConfig(source map[string]any) error { requiredMap := func(parent map[string]any, key string) (map[string]any, error) { value, ok := parent[key] if !ok { return nil, apperr.New(http.StatusBadRequest, "config_missing_field", "missing required field: "+key) } asMap, ok := value.(map[string]any) if !ok { return nil, apperr.New(http.StatusBadRequest, "config_invalid_field", "invalid object field: "+key) } return asMap, nil } requiredString := func(parent map[string]any, key string) error { value, ok := parent[key] if !ok { return apperr.New(http.StatusBadRequest, "config_missing_field", "missing required field: "+key) } text, ok := value.(string) if !ok || strings.TrimSpace(text) == "" { return apperr.New(http.StatusBadRequest, "config_invalid_field", "invalid string field: "+key) } return nil } if err := requiredString(source, "schemaVersion"); err != nil { return err } app, err := requiredMap(source, "app") if err != nil { return err } if err := requiredString(app, "id"); err != nil { return err } if err := requiredString(app, "title"); err != nil { return err } m, err := requiredMap(source, "map") if err != nil { return err } if err := requiredString(m, "tiles"); err != nil { return err } if err := requiredString(m, "mapmeta"); err != nil { return err } playfield, err := requiredMap(source, "playfield") if err != nil { return err } if err := requiredString(playfield, "kind"); err != nil { return err } playfieldSource, err := requiredMap(playfield, "source") if err != nil { return err } if err := requiredString(playfieldSource, "type"); err != nil { return err } if err := requiredString(playfieldSource, "url"); err != nil { return err } game, err := requiredMap(source, "game") if err != nil { return err } if err := requiredString(game, "mode"); err != nil { return err } return nil } func resolveSchemaVersion(source map[string]any) string { if value, ok := source["schemaVersion"].(string); ok && strings.TrimSpace(value) != "" { return value } return "1" } func (s *ConfigService) buildPreviewManifest(source map[string]any, previewReleaseID string) map[string]any { manifest := cloneJSONObject(source) manifest["releaseId"] = previewReleaseID manifest["preview"] = true manifest["assetBaseUrl"] = s.assetBaseURL if version, ok := manifest["version"]; !ok || version == "" { manifest["version"] = "preview" } if m, ok := manifest["map"].(map[string]any); ok { if tiles, ok := m["tiles"].(string); ok { m["tiles"] = s.normalizeAssetURL(tiles) } if meta, ok := m["mapmeta"].(string); ok { m["mapmeta"] = s.normalizeAssetURL(meta) } } if playfield, ok := manifest["playfield"].(map[string]any); ok { if src, ok := playfield["source"].(map[string]any); ok { if url, ok := src["url"].(string); ok { src["url"] = s.normalizeAssetURL(url) } } } if assets, ok := manifest["assets"].(map[string]any); ok { for key, value := range assets { if text, ok := value.(string); ok { assets[key] = s.normalizeAssetURL(text) } } } return manifest } func (s *ConfigService) buildAssetIndex(manifest map[string]any) []map[string]any { var assets []map[string]any if m, ok := manifest["map"].(map[string]any); ok { if tiles, ok := m["tiles"].(string); ok { assets = append(assets, map[string]any{"assetType": "tiles", "assetKey": "tiles-root", "assetUrl": tiles}) } if meta, ok := m["mapmeta"].(string); ok { assets = append(assets, map[string]any{"assetType": "mapmeta", "assetKey": "mapmeta", "assetUrl": meta}) } } if playfield, ok := manifest["playfield"].(map[string]any); ok { if src, ok := playfield["source"].(map[string]any); ok { if url, ok := src["url"].(string); ok { assets = append(assets, map[string]any{"assetType": "playfield", "assetKey": "playfield-source", "assetUrl": url}) } } } if rawAssets, ok := manifest["assets"].(map[string]any); ok { keys := make([]string, 0, len(rawAssets)) for key := range rawAssets { keys = append(keys, key) } sort.Strings(keys) for _, key := range keys { if url, ok := rawAssets[key].(string); ok { assets = append(assets, map[string]any{"assetType": "other", "assetKey": key, "assetUrl": url}) } } } return assets } func (s *ConfigService) normalizeAssetURL(value string) string { value = strings.TrimSpace(value) if value == "" { return value } if strings.HasPrefix(value, "http://") || strings.HasPrefix(value, "https://") { return value } trimmed := strings.TrimPrefix(value, "../") trimmed = strings.TrimPrefix(trimmed, "./") trimmed = strings.TrimLeft(trimmed, "/") return s.assetBaseURL + "/" + trimmed } func cloneJSONObject(source map[string]any) map[string]any { raw, _ := json.Marshal(source) cloned := map[string]any{} _ = json.Unmarshal(raw, &cloned) return cloned } func decodeJSONObject(raw string) (map[string]any, error) { result := map[string]any{} if err := json.Unmarshal([]byte(raw), &result); err != nil { return nil, err } return result, nil } func decodeJSONArray(raw string) ([]map[string]any, error) { if strings.TrimSpace(raw) == "" { return []map[string]any{}, nil } var result []map[string]any if err := json.Unmarshal([]byte(raw), &result); err != nil { return nil, err } return result, nil } func deriveConfigLabel(event *postgres.Event, manifest map[string]any, releaseNo int) string { if app, ok := manifest["app"].(map[string]any); ok { if title, ok := app["title"].(string); ok && strings.TrimSpace(title) != "" { return fmt.Sprintf("%s Release %d", strings.TrimSpace(title), releaseNo) } } if event != nil && strings.TrimSpace(event.DisplayName) != "" { return fmt.Sprintf("%s Release %d", event.DisplayName, releaseNo) } return fmt.Sprintf("Release %d", releaseNo) } func deriveRouteCode(manifest map[string]any) *string { if playfield, ok := manifest["playfield"].(map[string]any); ok { if value, ok := playfield["kind"].(string); ok && strings.TrimSpace(value) != "" { route := strings.TrimSpace(value) return &route } } return nil } func (s *ConfigService) mapBuildAssetsToReleaseAssets(eventReleaseID, manifestURL, assetIndexURL string, checksum *string, assetIndex []map[string]any) []postgres.UpsertEventReleaseAssetParams { assets := []postgres.UpsertEventReleaseAssetParams{ { EventReleaseID: eventReleaseID, AssetType: "manifest", AssetKey: "manifest", AssetURL: manifestURL, Checksum: checksum, Meta: map[string]any{"source": "published-build"}, }, { EventReleaseID: eventReleaseID, AssetType: "other", AssetKey: "asset-index", AssetURL: assetIndexURL, Meta: map[string]any{"source": "published-build"}, }, } for _, asset := range assetIndex { assetType, _ := asset["assetType"].(string) assetKey, _ := asset["assetKey"].(string) assetURL, _ := asset["assetUrl"].(string) if strings.TrimSpace(assetType) == "" || strings.TrimSpace(assetKey) == "" || strings.TrimSpace(assetURL) == "" { continue } mappedType := assetType if mappedType != "manifest" && mappedType != "mapmeta" && mappedType != "tiles" && mappedType != "playfield" && mappedType != "content_html" && mappedType != "media" { mappedType = "other" } assets = append(assets, postgres.UpsertEventReleaseAssetParams{ EventReleaseID: eventReleaseID, AssetType: mappedType, AssetKey: assetKey, AssetURL: assetURL, Meta: asset, }) } return assets }