config_service.go 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678
  1. package service
  2. import (
  3. "context"
  4. "encoding/json"
  5. "fmt"
  6. "net/http"
  7. "os"
  8. "path/filepath"
  9. "sort"
  10. "strings"
  11. "cmr-backend/internal/apperr"
  12. "cmr-backend/internal/platform/security"
  13. "cmr-backend/internal/store/postgres"
  14. )
  15. type ConfigService struct {
  16. store *postgres.Store
  17. localEventDir string
  18. assetBaseURL string
  19. }
  20. type ConfigPipelineSummary struct {
  21. SourceTable string `json:"sourceTable"`
  22. BuildTable string `json:"buildTable"`
  23. ReleaseAssetsTable string `json:"releaseAssetsTable"`
  24. }
  25. type LocalEventFile struct {
  26. FileName string `json:"fileName"`
  27. FullPath string `json:"fullPath"`
  28. }
  29. type EventConfigSourceView struct {
  30. ID string `json:"id"`
  31. EventID string `json:"eventId"`
  32. SourceVersionNo int `json:"sourceVersionNo"`
  33. SourceKind string `json:"sourceKind"`
  34. SchemaID string `json:"schemaId"`
  35. SchemaVersion string `json:"schemaVersion"`
  36. Status string `json:"status"`
  37. Notes *string `json:"notes,omitempty"`
  38. Source map[string]any `json:"source"`
  39. }
  40. type EventConfigBuildView struct {
  41. ID string `json:"id"`
  42. EventID string `json:"eventId"`
  43. SourceID string `json:"sourceId"`
  44. BuildNo int `json:"buildNo"`
  45. BuildStatus string `json:"buildStatus"`
  46. BuildLog *string `json:"buildLog,omitempty"`
  47. Manifest map[string]any `json:"manifest"`
  48. AssetIndex []map[string]any `json:"assetIndex"`
  49. }
  50. type PublishedReleaseView struct {
  51. EventID string `json:"eventId"`
  52. Release ResolvedReleaseView `json:"release"`
  53. ReleaseNo int `json:"releaseNo"`
  54. PublishedAt string `json:"publishedAt"`
  55. }
  56. type ImportLocalEventConfigInput struct {
  57. EventPublicID string
  58. FileName string `json:"fileName"`
  59. Notes *string `json:"notes,omitempty"`
  60. }
  61. type BuildPreviewInput struct {
  62. SourceID string `json:"sourceId"`
  63. }
  64. type PublishBuildInput struct {
  65. BuildID string `json:"buildId"`
  66. }
  67. func NewConfigService(store *postgres.Store, localEventDir, assetBaseURL string) *ConfigService {
  68. return &ConfigService{
  69. store: store,
  70. localEventDir: localEventDir,
  71. assetBaseURL: strings.TrimRight(assetBaseURL, "/"),
  72. }
  73. }
  74. func (s *ConfigService) PipelineSummary() ConfigPipelineSummary {
  75. return ConfigPipelineSummary{
  76. SourceTable: "event_config_sources",
  77. BuildTable: "event_config_builds",
  78. ReleaseAssetsTable: "event_release_assets",
  79. }
  80. }
  81. func (s *ConfigService) ListLocalEventFiles() ([]LocalEventFile, error) {
  82. dir, err := filepath.Abs(s.localEventDir)
  83. if err != nil {
  84. return nil, apperr.New(http.StatusInternalServerError, "config_dir_invalid", "failed to resolve local event directory")
  85. }
  86. entries, err := os.ReadDir(dir)
  87. if err != nil {
  88. return nil, apperr.New(http.StatusInternalServerError, "config_dir_unavailable", "failed to read local event directory")
  89. }
  90. files := make([]LocalEventFile, 0)
  91. for _, entry := range entries {
  92. if entry.IsDir() {
  93. continue
  94. }
  95. if strings.ToLower(filepath.Ext(entry.Name())) != ".json" {
  96. continue
  97. }
  98. files = append(files, LocalEventFile{
  99. FileName: entry.Name(),
  100. FullPath: filepath.Join(dir, entry.Name()),
  101. })
  102. }
  103. sort.Slice(files, func(i, j int) bool {
  104. return files[i].FileName < files[j].FileName
  105. })
  106. return files, nil
  107. }
  108. func (s *ConfigService) ListEventConfigSources(ctx context.Context, eventPublicID string, limit int) ([]EventConfigSourceView, error) {
  109. event, err := s.requireEvent(ctx, eventPublicID)
  110. if err != nil {
  111. return nil, err
  112. }
  113. items, err := s.store.ListEventConfigSourcesByEventID(ctx, event.ID, limit)
  114. if err != nil {
  115. return nil, err
  116. }
  117. results := make([]EventConfigSourceView, 0, len(items))
  118. for i := range items {
  119. view, err := buildEventConfigSourceView(&items[i], event.PublicID)
  120. if err != nil {
  121. return nil, err
  122. }
  123. results = append(results, *view)
  124. }
  125. return results, nil
  126. }
  127. func (s *ConfigService) GetEventConfigSource(ctx context.Context, sourceID string) (*EventConfigSourceView, error) {
  128. record, err := s.store.GetEventConfigSourceByID(ctx, strings.TrimSpace(sourceID))
  129. if err != nil {
  130. return nil, err
  131. }
  132. if record == nil {
  133. return nil, apperr.New(http.StatusNotFound, "config_source_not_found", "config source not found")
  134. }
  135. return buildEventConfigSourceView(record, "")
  136. }
  137. func (s *ConfigService) GetEventConfigBuild(ctx context.Context, buildID string) (*EventConfigBuildView, error) {
  138. record, err := s.store.GetEventConfigBuildByID(ctx, strings.TrimSpace(buildID))
  139. if err != nil {
  140. return nil, err
  141. }
  142. if record == nil {
  143. return nil, apperr.New(http.StatusNotFound, "config_build_not_found", "config build not found")
  144. }
  145. return buildEventConfigBuildView(record)
  146. }
  147. func (s *ConfigService) ImportLocalEventConfig(ctx context.Context, input ImportLocalEventConfigInput) (*EventConfigSourceView, error) {
  148. event, err := s.requireEvent(ctx, input.EventPublicID)
  149. if err != nil {
  150. return nil, err
  151. }
  152. fileName := strings.TrimSpace(filepath.Base(input.FileName))
  153. if fileName == "" || strings.Contains(fileName, "..") || strings.ToLower(filepath.Ext(fileName)) != ".json" {
  154. return nil, apperr.New(http.StatusBadRequest, "invalid_params", "valid json fileName is required")
  155. }
  156. dir, err := filepath.Abs(s.localEventDir)
  157. if err != nil {
  158. return nil, apperr.New(http.StatusInternalServerError, "config_dir_invalid", "failed to resolve local event directory")
  159. }
  160. path := filepath.Join(dir, fileName)
  161. raw, err := os.ReadFile(path)
  162. if err != nil {
  163. return nil, apperr.New(http.StatusNotFound, "config_file_not_found", "local config file not found")
  164. }
  165. source := map[string]any{}
  166. if err := json.Unmarshal(raw, &source); err != nil {
  167. return nil, apperr.New(http.StatusBadRequest, "config_json_invalid", "local config file is not valid json")
  168. }
  169. if err := validateSourceConfig(source); err != nil {
  170. return nil, err
  171. }
  172. nextVersion, err := s.store.NextEventConfigSourceVersion(ctx, event.ID)
  173. if err != nil {
  174. return nil, err
  175. }
  176. note := input.Notes
  177. if note == nil || strings.TrimSpace(*note) == "" {
  178. defaultNote := "imported from local event file: " + fileName
  179. note = &defaultNote
  180. }
  181. tx, err := s.store.Begin(ctx)
  182. if err != nil {
  183. return nil, err
  184. }
  185. defer tx.Rollback(ctx)
  186. record, err := s.store.UpsertEventConfigSource(ctx, tx, postgres.UpsertEventConfigSourceParams{
  187. EventID: event.ID,
  188. SourceVersionNo: nextVersion,
  189. SourceKind: "event_bundle",
  190. SchemaID: "event-source",
  191. SchemaVersion: resolveSchemaVersion(source),
  192. Status: "active",
  193. Source: source,
  194. Notes: note,
  195. })
  196. if err != nil {
  197. return nil, err
  198. }
  199. if err := tx.Commit(ctx); err != nil {
  200. return nil, err
  201. }
  202. return buildEventConfigSourceView(record, event.PublicID)
  203. }
  204. func (s *ConfigService) BuildPreview(ctx context.Context, input BuildPreviewInput) (*EventConfigBuildView, error) {
  205. sourceRecord, err := s.store.GetEventConfigSourceByID(ctx, strings.TrimSpace(input.SourceID))
  206. if err != nil {
  207. return nil, err
  208. }
  209. if sourceRecord == nil {
  210. return nil, apperr.New(http.StatusNotFound, "config_source_not_found", "config source not found")
  211. }
  212. source, err := decodeJSONObject(sourceRecord.SourceJSON)
  213. if err != nil {
  214. return nil, apperr.New(http.StatusInternalServerError, "config_source_invalid", "stored source config is invalid")
  215. }
  216. if err := validateSourceConfig(source); err != nil {
  217. return nil, err
  218. }
  219. buildNo, err := s.store.NextEventConfigBuildNo(ctx, sourceRecord.EventID)
  220. if err != nil {
  221. return nil, err
  222. }
  223. previewReleaseID := fmt.Sprintf("preview_%d", buildNo)
  224. manifest := s.buildPreviewManifest(source, previewReleaseID)
  225. assetIndex := s.buildAssetIndex(manifest)
  226. buildLog := "preview build generated from source " + sourceRecord.ID
  227. tx, err := s.store.Begin(ctx)
  228. if err != nil {
  229. return nil, err
  230. }
  231. defer tx.Rollback(ctx)
  232. record, err := s.store.UpsertEventConfigBuild(ctx, tx, postgres.UpsertEventConfigBuildParams{
  233. EventID: sourceRecord.EventID,
  234. SourceID: sourceRecord.ID,
  235. BuildNo: buildNo,
  236. BuildStatus: "success",
  237. BuildLog: &buildLog,
  238. Manifest: manifest,
  239. AssetIndex: assetIndex,
  240. })
  241. if err != nil {
  242. return nil, err
  243. }
  244. if err := tx.Commit(ctx); err != nil {
  245. return nil, err
  246. }
  247. return buildEventConfigBuildView(record)
  248. }
  249. func (s *ConfigService) PublishBuild(ctx context.Context, input PublishBuildInput) (*PublishedReleaseView, error) {
  250. buildRecord, err := s.store.GetEventConfigBuildByID(ctx, strings.TrimSpace(input.BuildID))
  251. if err != nil {
  252. return nil, err
  253. }
  254. if buildRecord == nil {
  255. return nil, apperr.New(http.StatusNotFound, "config_build_not_found", "config build not found")
  256. }
  257. if buildRecord.BuildStatus != "success" {
  258. return nil, apperr.New(http.StatusConflict, "config_build_not_publishable", "config build is not publishable")
  259. }
  260. event, err := s.store.GetEventByID(ctx, buildRecord.EventID)
  261. if err != nil {
  262. return nil, err
  263. }
  264. if event == nil {
  265. return nil, apperr.New(http.StatusNotFound, "event_not_found", "event not found")
  266. }
  267. manifest, err := decodeJSONObject(buildRecord.ManifestJSON)
  268. if err != nil {
  269. return nil, apperr.New(http.StatusInternalServerError, "config_build_invalid", "stored build manifest is invalid")
  270. }
  271. assetIndex, err := decodeJSONArray(buildRecord.AssetIndexJSON)
  272. if err != nil {
  273. return nil, apperr.New(http.StatusInternalServerError, "config_build_invalid", "stored build asset index is invalid")
  274. }
  275. releaseNo, err := s.store.NextEventReleaseNo(ctx, event.ID)
  276. if err != nil {
  277. return nil, err
  278. }
  279. releasePublicID, err := security.GeneratePublicID("rel")
  280. if err != nil {
  281. return nil, err
  282. }
  283. configLabel := deriveConfigLabel(event, manifest, releaseNo)
  284. manifestURL := fmt.Sprintf("%s/event/releases/%s/%s/manifest.json", s.assetBaseURL, event.PublicID, releasePublicID)
  285. checksum := security.HashText(buildRecord.ManifestJSON)
  286. routeCode := deriveRouteCode(manifest)
  287. tx, err := s.store.Begin(ctx)
  288. if err != nil {
  289. return nil, err
  290. }
  291. defer tx.Rollback(ctx)
  292. releaseRecord, err := s.store.CreateEventRelease(ctx, tx, postgres.CreateEventReleaseParams{
  293. PublicID: releasePublicID,
  294. EventID: event.ID,
  295. ReleaseNo: releaseNo,
  296. ConfigLabel: configLabel,
  297. ManifestURL: manifestURL,
  298. ManifestChecksum: &checksum,
  299. RouteCode: routeCode,
  300. BuildID: &buildRecord.ID,
  301. Status: "published",
  302. PayloadJSON: buildRecord.ManifestJSON,
  303. })
  304. if err != nil {
  305. return nil, err
  306. }
  307. if err := s.store.ReplaceEventReleaseAssets(ctx, tx, releaseRecord.ID, s.mapBuildAssetsToReleaseAssets(releaseRecord.ID, manifestURL, &checksum, assetIndex)); err != nil {
  308. return nil, err
  309. }
  310. if err := s.store.SetCurrentEventRelease(ctx, tx, event.ID, releaseRecord.ID); err != nil {
  311. return nil, err
  312. }
  313. if err := tx.Commit(ctx); err != nil {
  314. return nil, err
  315. }
  316. return &PublishedReleaseView{
  317. EventID: event.PublicID,
  318. Release: ResolvedReleaseView{
  319. LaunchMode: LaunchModeManifestRelease,
  320. Source: LaunchSourceEventCurrentRelease,
  321. EventID: event.PublicID,
  322. ReleaseID: releaseRecord.PublicID,
  323. ConfigLabel: releaseRecord.ConfigLabel,
  324. ManifestURL: releaseRecord.ManifestURL,
  325. ManifestChecksumSha256: releaseRecord.ManifestChecksum,
  326. RouteCode: releaseRecord.RouteCode,
  327. },
  328. ReleaseNo: releaseRecord.ReleaseNo,
  329. PublishedAt: releaseRecord.PublishedAt.Format(timeRFC3339),
  330. }, nil
  331. }
  332. func (s *ConfigService) requireEvent(ctx context.Context, eventPublicID string) (*postgres.Event, error) {
  333. eventPublicID = strings.TrimSpace(eventPublicID)
  334. if eventPublicID == "" {
  335. return nil, apperr.New(http.StatusBadRequest, "invalid_params", "event id is required")
  336. }
  337. event, err := s.store.GetEventByPublicID(ctx, eventPublicID)
  338. if err != nil {
  339. return nil, err
  340. }
  341. if event == nil {
  342. return nil, apperr.New(http.StatusNotFound, "event_not_found", "event not found")
  343. }
  344. return event, nil
  345. }
  346. func buildEventConfigSourceView(record *postgres.EventConfigSource, eventPublicID string) (*EventConfigSourceView, error) {
  347. source, err := decodeJSONObject(record.SourceJSON)
  348. if err != nil {
  349. return nil, err
  350. }
  351. view := &EventConfigSourceView{
  352. ID: record.ID,
  353. EventID: eventPublicID,
  354. SourceVersionNo: record.SourceVersionNo,
  355. SourceKind: record.SourceKind,
  356. SchemaID: record.SchemaID,
  357. SchemaVersion: record.SchemaVersion,
  358. Status: record.Status,
  359. Notes: record.Notes,
  360. Source: source,
  361. }
  362. return view, nil
  363. }
  364. func buildEventConfigBuildView(record *postgres.EventConfigBuild) (*EventConfigBuildView, error) {
  365. manifest, err := decodeJSONObject(record.ManifestJSON)
  366. if err != nil {
  367. return nil, err
  368. }
  369. assetIndex, err := decodeJSONArray(record.AssetIndexJSON)
  370. if err != nil {
  371. return nil, err
  372. }
  373. return &EventConfigBuildView{
  374. ID: record.ID,
  375. EventID: record.EventID,
  376. SourceID: record.SourceID,
  377. BuildNo: record.BuildNo,
  378. BuildStatus: record.BuildStatus,
  379. BuildLog: record.BuildLog,
  380. Manifest: manifest,
  381. AssetIndex: assetIndex,
  382. }, nil
  383. }
  384. func validateSourceConfig(source map[string]any) error {
  385. requiredMap := func(parent map[string]any, key string) (map[string]any, error) {
  386. value, ok := parent[key]
  387. if !ok {
  388. return nil, apperr.New(http.StatusBadRequest, "config_missing_field", "missing required field: "+key)
  389. }
  390. asMap, ok := value.(map[string]any)
  391. if !ok {
  392. return nil, apperr.New(http.StatusBadRequest, "config_invalid_field", "invalid object field: "+key)
  393. }
  394. return asMap, nil
  395. }
  396. requiredString := func(parent map[string]any, key string) error {
  397. value, ok := parent[key]
  398. if !ok {
  399. return apperr.New(http.StatusBadRequest, "config_missing_field", "missing required field: "+key)
  400. }
  401. text, ok := value.(string)
  402. if !ok || strings.TrimSpace(text) == "" {
  403. return apperr.New(http.StatusBadRequest, "config_invalid_field", "invalid string field: "+key)
  404. }
  405. return nil
  406. }
  407. if err := requiredString(source, "schemaVersion"); err != nil {
  408. return err
  409. }
  410. app, err := requiredMap(source, "app")
  411. if err != nil {
  412. return err
  413. }
  414. if err := requiredString(app, "id"); err != nil {
  415. return err
  416. }
  417. if err := requiredString(app, "title"); err != nil {
  418. return err
  419. }
  420. m, err := requiredMap(source, "map")
  421. if err != nil {
  422. return err
  423. }
  424. if err := requiredString(m, "tiles"); err != nil {
  425. return err
  426. }
  427. if err := requiredString(m, "mapmeta"); err != nil {
  428. return err
  429. }
  430. playfield, err := requiredMap(source, "playfield")
  431. if err != nil {
  432. return err
  433. }
  434. if err := requiredString(playfield, "kind"); err != nil {
  435. return err
  436. }
  437. playfieldSource, err := requiredMap(playfield, "source")
  438. if err != nil {
  439. return err
  440. }
  441. if err := requiredString(playfieldSource, "type"); err != nil {
  442. return err
  443. }
  444. if err := requiredString(playfieldSource, "url"); err != nil {
  445. return err
  446. }
  447. game, err := requiredMap(source, "game")
  448. if err != nil {
  449. return err
  450. }
  451. if err := requiredString(game, "mode"); err != nil {
  452. return err
  453. }
  454. return nil
  455. }
  456. func resolveSchemaVersion(source map[string]any) string {
  457. if value, ok := source["schemaVersion"].(string); ok && strings.TrimSpace(value) != "" {
  458. return value
  459. }
  460. return "1"
  461. }
  462. func (s *ConfigService) buildPreviewManifest(source map[string]any, previewReleaseID string) map[string]any {
  463. manifest := cloneJSONObject(source)
  464. manifest["releaseId"] = previewReleaseID
  465. manifest["preview"] = true
  466. manifest["assetBaseUrl"] = s.assetBaseURL
  467. if version, ok := manifest["version"]; !ok || version == "" {
  468. manifest["version"] = "preview"
  469. }
  470. if m, ok := manifest["map"].(map[string]any); ok {
  471. if tiles, ok := m["tiles"].(string); ok {
  472. m["tiles"] = s.normalizeAssetURL(tiles)
  473. }
  474. if meta, ok := m["mapmeta"].(string); ok {
  475. m["mapmeta"] = s.normalizeAssetURL(meta)
  476. }
  477. }
  478. if playfield, ok := manifest["playfield"].(map[string]any); ok {
  479. if src, ok := playfield["source"].(map[string]any); ok {
  480. if url, ok := src["url"].(string); ok {
  481. src["url"] = s.normalizeAssetURL(url)
  482. }
  483. }
  484. }
  485. if assets, ok := manifest["assets"].(map[string]any); ok {
  486. for key, value := range assets {
  487. if text, ok := value.(string); ok {
  488. assets[key] = s.normalizeAssetURL(text)
  489. }
  490. }
  491. }
  492. return manifest
  493. }
  494. func (s *ConfigService) buildAssetIndex(manifest map[string]any) []map[string]any {
  495. var assets []map[string]any
  496. if m, ok := manifest["map"].(map[string]any); ok {
  497. if tiles, ok := m["tiles"].(string); ok {
  498. assets = append(assets, map[string]any{"assetType": "tiles", "assetKey": "tiles-root", "assetUrl": tiles})
  499. }
  500. if meta, ok := m["mapmeta"].(string); ok {
  501. assets = append(assets, map[string]any{"assetType": "mapmeta", "assetKey": "mapmeta", "assetUrl": meta})
  502. }
  503. }
  504. if playfield, ok := manifest["playfield"].(map[string]any); ok {
  505. if src, ok := playfield["source"].(map[string]any); ok {
  506. if url, ok := src["url"].(string); ok {
  507. assets = append(assets, map[string]any{"assetType": "playfield", "assetKey": "playfield-source", "assetUrl": url})
  508. }
  509. }
  510. }
  511. if rawAssets, ok := manifest["assets"].(map[string]any); ok {
  512. keys := make([]string, 0, len(rawAssets))
  513. for key := range rawAssets {
  514. keys = append(keys, key)
  515. }
  516. sort.Strings(keys)
  517. for _, key := range keys {
  518. if url, ok := rawAssets[key].(string); ok {
  519. assets = append(assets, map[string]any{"assetType": "other", "assetKey": key, "assetUrl": url})
  520. }
  521. }
  522. }
  523. return assets
  524. }
  525. func (s *ConfigService) normalizeAssetURL(value string) string {
  526. value = strings.TrimSpace(value)
  527. if value == "" {
  528. return value
  529. }
  530. if strings.HasPrefix(value, "http://") || strings.HasPrefix(value, "https://") {
  531. return value
  532. }
  533. trimmed := strings.TrimPrefix(value, "../")
  534. trimmed = strings.TrimPrefix(trimmed, "./")
  535. trimmed = strings.TrimLeft(trimmed, "/")
  536. return s.assetBaseURL + "/" + trimmed
  537. }
  538. func cloneJSONObject(source map[string]any) map[string]any {
  539. raw, _ := json.Marshal(source)
  540. cloned := map[string]any{}
  541. _ = json.Unmarshal(raw, &cloned)
  542. return cloned
  543. }
  544. func decodeJSONObject(raw string) (map[string]any, error) {
  545. result := map[string]any{}
  546. if err := json.Unmarshal([]byte(raw), &result); err != nil {
  547. return nil, err
  548. }
  549. return result, nil
  550. }
  551. func decodeJSONArray(raw string) ([]map[string]any, error) {
  552. if strings.TrimSpace(raw) == "" {
  553. return []map[string]any{}, nil
  554. }
  555. var result []map[string]any
  556. if err := json.Unmarshal([]byte(raw), &result); err != nil {
  557. return nil, err
  558. }
  559. return result, nil
  560. }
  561. func deriveConfigLabel(event *postgres.Event, manifest map[string]any, releaseNo int) string {
  562. if app, ok := manifest["app"].(map[string]any); ok {
  563. if title, ok := app["title"].(string); ok && strings.TrimSpace(title) != "" {
  564. return fmt.Sprintf("%s Release %d", strings.TrimSpace(title), releaseNo)
  565. }
  566. }
  567. if event != nil && strings.TrimSpace(event.DisplayName) != "" {
  568. return fmt.Sprintf("%s Release %d", event.DisplayName, releaseNo)
  569. }
  570. return fmt.Sprintf("Release %d", releaseNo)
  571. }
  572. func deriveRouteCode(manifest map[string]any) *string {
  573. if playfield, ok := manifest["playfield"].(map[string]any); ok {
  574. if value, ok := playfield["kind"].(string); ok && strings.TrimSpace(value) != "" {
  575. route := strings.TrimSpace(value)
  576. return &route
  577. }
  578. }
  579. return nil
  580. }
  581. func (s *ConfigService) mapBuildAssetsToReleaseAssets(eventReleaseID, manifestURL string, checksum *string, assetIndex []map[string]any) []postgres.UpsertEventReleaseAssetParams {
  582. assets := []postgres.UpsertEventReleaseAssetParams{
  583. {
  584. EventReleaseID: eventReleaseID,
  585. AssetType: "manifest",
  586. AssetKey: "manifest",
  587. AssetURL: manifestURL,
  588. Checksum: checksum,
  589. Meta: map[string]any{"source": "published-build"},
  590. },
  591. }
  592. for _, asset := range assetIndex {
  593. assetType, _ := asset["assetType"].(string)
  594. assetKey, _ := asset["assetKey"].(string)
  595. assetURL, _ := asset["assetUrl"].(string)
  596. if strings.TrimSpace(assetType) == "" || strings.TrimSpace(assetKey) == "" || strings.TrimSpace(assetURL) == "" {
  597. continue
  598. }
  599. mappedType := assetType
  600. if mappedType != "manifest" && mappedType != "mapmeta" && mappedType != "tiles" && mappedType != "playfield" && mappedType != "content_html" && mappedType != "media" {
  601. mappedType = "other"
  602. }
  603. assets = append(assets, postgres.UpsertEventReleaseAssetParams{
  604. EventReleaseID: eventReleaseID,
  605. AssetType: mappedType,
  606. AssetKey: assetKey,
  607. AssetURL: assetURL,
  608. Meta: asset,
  609. })
  610. }
  611. return assets
  612. }