config_service.go 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870
  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/assets"
  13. "cmr-backend/internal/platform/security"
  14. "cmr-backend/internal/store/postgres"
  15. )
  16. type ConfigService struct {
  17. store *postgres.Store
  18. localEventDir string
  19. assetBaseURL string
  20. publisher *assets.OSSUtilPublisher
  21. }
  22. type ConfigPipelineSummary struct {
  23. SourceTable string `json:"sourceTable"`
  24. BuildTable string `json:"buildTable"`
  25. ReleaseAssetsTable string `json:"releaseAssetsTable"`
  26. }
  27. type LocalEventFile struct {
  28. FileName string `json:"fileName"`
  29. FullPath string `json:"fullPath"`
  30. }
  31. type EventConfigSourceView struct {
  32. ID string `json:"id"`
  33. EventID string `json:"eventId"`
  34. SourceVersionNo int `json:"sourceVersionNo"`
  35. SourceKind string `json:"sourceKind"`
  36. SchemaID string `json:"schemaId"`
  37. SchemaVersion string `json:"schemaVersion"`
  38. Status string `json:"status"`
  39. Notes *string `json:"notes,omitempty"`
  40. Source map[string]any `json:"source"`
  41. }
  42. type EventConfigBuildView struct {
  43. ID string `json:"id"`
  44. EventID string `json:"eventId"`
  45. SourceID string `json:"sourceId"`
  46. BuildNo int `json:"buildNo"`
  47. BuildStatus string `json:"buildStatus"`
  48. BuildLog *string `json:"buildLog,omitempty"`
  49. Manifest map[string]any `json:"manifest"`
  50. AssetIndex []map[string]any `json:"assetIndex"`
  51. }
  52. type PublishedReleaseView struct {
  53. EventID string `json:"eventId"`
  54. Release ResolvedReleaseView `json:"release"`
  55. ReleaseNo int `json:"releaseNo"`
  56. PublishedAt string `json:"publishedAt"`
  57. Runtime *RuntimeSummaryView `json:"runtime,omitempty"`
  58. Presentation *PresentationSummaryView `json:"presentation,omitempty"`
  59. ContentBundle *ContentBundleSummaryView `json:"contentBundle,omitempty"`
  60. }
  61. type ImportLocalEventConfigInput struct {
  62. EventPublicID string
  63. FileName string `json:"fileName"`
  64. Notes *string `json:"notes,omitempty"`
  65. }
  66. type BuildPreviewInput struct {
  67. SourceID string `json:"sourceId"`
  68. }
  69. type PublishBuildInput struct {
  70. BuildID string `json:"buildId"`
  71. RuntimeBindingID string `json:"runtimeBindingId,omitempty"`
  72. PresentationID string `json:"presentationId,omitempty"`
  73. ContentBundleID string `json:"contentBundleId,omitempty"`
  74. }
  75. func NewConfigService(store *postgres.Store, localEventDir, assetBaseURL string, publisher *assets.OSSUtilPublisher) *ConfigService {
  76. return &ConfigService{
  77. store: store,
  78. localEventDir: localEventDir,
  79. assetBaseURL: strings.TrimRight(assetBaseURL, "/"),
  80. publisher: publisher,
  81. }
  82. }
  83. func (s *ConfigService) PipelineSummary() ConfigPipelineSummary {
  84. return ConfigPipelineSummary{
  85. SourceTable: "event_config_sources",
  86. BuildTable: "event_config_builds",
  87. ReleaseAssetsTable: "event_release_assets",
  88. }
  89. }
  90. func (s *ConfigService) ListLocalEventFiles() ([]LocalEventFile, error) {
  91. dir, err := filepath.Abs(s.localEventDir)
  92. if err != nil {
  93. return nil, apperr.New(http.StatusInternalServerError, "config_dir_invalid", "failed to resolve local event directory")
  94. }
  95. entries, err := os.ReadDir(dir)
  96. if err != nil {
  97. return nil, apperr.New(http.StatusInternalServerError, "config_dir_unavailable", "failed to read local event directory")
  98. }
  99. files := make([]LocalEventFile, 0)
  100. for _, entry := range entries {
  101. if entry.IsDir() {
  102. continue
  103. }
  104. if strings.ToLower(filepath.Ext(entry.Name())) != ".json" {
  105. continue
  106. }
  107. files = append(files, LocalEventFile{
  108. FileName: entry.Name(),
  109. FullPath: filepath.Join(dir, entry.Name()),
  110. })
  111. }
  112. sort.Slice(files, func(i, j int) bool {
  113. return files[i].FileName < files[j].FileName
  114. })
  115. return files, nil
  116. }
  117. func (s *ConfigService) ListEventConfigSources(ctx context.Context, eventPublicID string, limit int) ([]EventConfigSourceView, error) {
  118. event, err := s.requireEvent(ctx, eventPublicID)
  119. if err != nil {
  120. return nil, err
  121. }
  122. items, err := s.store.ListEventConfigSourcesByEventID(ctx, event.ID, limit)
  123. if err != nil {
  124. return nil, err
  125. }
  126. results := make([]EventConfigSourceView, 0, len(items))
  127. for i := range items {
  128. view, err := buildEventConfigSourceView(&items[i], event.PublicID)
  129. if err != nil {
  130. return nil, err
  131. }
  132. results = append(results, *view)
  133. }
  134. return results, nil
  135. }
  136. func (s *ConfigService) GetEventConfigSource(ctx context.Context, sourceID string) (*EventConfigSourceView, error) {
  137. record, err := s.store.GetEventConfigSourceByID(ctx, strings.TrimSpace(sourceID))
  138. if err != nil {
  139. return nil, err
  140. }
  141. if record == nil {
  142. return nil, apperr.New(http.StatusNotFound, "config_source_not_found", "config source not found")
  143. }
  144. return buildEventConfigSourceView(record, "")
  145. }
  146. func (s *ConfigService) GetEventConfigBuild(ctx context.Context, buildID string) (*EventConfigBuildView, error) {
  147. record, err := s.store.GetEventConfigBuildByID(ctx, strings.TrimSpace(buildID))
  148. if err != nil {
  149. return nil, err
  150. }
  151. if record == nil {
  152. return nil, apperr.New(http.StatusNotFound, "config_build_not_found", "config build not found")
  153. }
  154. return buildEventConfigBuildView(record)
  155. }
  156. func (s *ConfigService) ImportLocalEventConfig(ctx context.Context, input ImportLocalEventConfigInput) (*EventConfigSourceView, error) {
  157. event, err := s.requireEvent(ctx, input.EventPublicID)
  158. if err != nil {
  159. return nil, err
  160. }
  161. fileName := strings.TrimSpace(filepath.Base(input.FileName))
  162. if fileName == "" || strings.Contains(fileName, "..") || strings.ToLower(filepath.Ext(fileName)) != ".json" {
  163. return nil, apperr.New(http.StatusBadRequest, "invalid_params", "valid json fileName is required")
  164. }
  165. dir, err := filepath.Abs(s.localEventDir)
  166. if err != nil {
  167. return nil, apperr.New(http.StatusInternalServerError, "config_dir_invalid", "failed to resolve local event directory")
  168. }
  169. path := filepath.Join(dir, fileName)
  170. raw, err := os.ReadFile(path)
  171. if err != nil {
  172. return nil, apperr.New(http.StatusNotFound, "config_file_not_found", "local config file not found")
  173. }
  174. source := map[string]any{}
  175. if err := json.Unmarshal(raw, &source); err != nil {
  176. return nil, apperr.New(http.StatusBadRequest, "config_json_invalid", "local config file is not valid json")
  177. }
  178. if err := validateSourceConfig(source); err != nil {
  179. return nil, err
  180. }
  181. nextVersion, err := s.store.NextEventConfigSourceVersion(ctx, event.ID)
  182. if err != nil {
  183. return nil, err
  184. }
  185. note := input.Notes
  186. if note == nil || strings.TrimSpace(*note) == "" {
  187. defaultNote := "imported from local event file: " + fileName
  188. note = &defaultNote
  189. }
  190. tx, err := s.store.Begin(ctx)
  191. if err != nil {
  192. return nil, err
  193. }
  194. defer tx.Rollback(ctx)
  195. record, err := s.store.UpsertEventConfigSource(ctx, tx, postgres.UpsertEventConfigSourceParams{
  196. EventID: event.ID,
  197. SourceVersionNo: nextVersion,
  198. SourceKind: "event_bundle",
  199. SchemaID: "event-source",
  200. SchemaVersion: resolveSchemaVersion(source),
  201. Status: "active",
  202. Source: source,
  203. Notes: note,
  204. })
  205. if err != nil {
  206. return nil, err
  207. }
  208. if err := tx.Commit(ctx); err != nil {
  209. return nil, err
  210. }
  211. return buildEventConfigSourceView(record, event.PublicID)
  212. }
  213. func (s *ConfigService) BuildPreview(ctx context.Context, input BuildPreviewInput) (*EventConfigBuildView, error) {
  214. sourceRecord, err := s.store.GetEventConfigSourceByID(ctx, strings.TrimSpace(input.SourceID))
  215. if err != nil {
  216. return nil, err
  217. }
  218. if sourceRecord == nil {
  219. return nil, apperr.New(http.StatusNotFound, "config_source_not_found", "config source not found")
  220. }
  221. source, err := decodeJSONObject(sourceRecord.SourceJSON)
  222. if err != nil {
  223. return nil, apperr.New(http.StatusInternalServerError, "config_source_invalid", "stored source config is invalid")
  224. }
  225. if err := validateSourceConfig(source); err != nil {
  226. return nil, err
  227. }
  228. buildNo, err := s.store.NextEventConfigBuildNo(ctx, sourceRecord.EventID)
  229. if err != nil {
  230. return nil, err
  231. }
  232. previewReleaseID := fmt.Sprintf("preview_%d", buildNo)
  233. manifest := s.buildPreviewManifest(source, previewReleaseID)
  234. assetIndex := s.buildAssetIndex(manifest)
  235. buildLog := "preview build generated from source " + sourceRecord.ID
  236. tx, err := s.store.Begin(ctx)
  237. if err != nil {
  238. return nil, err
  239. }
  240. defer tx.Rollback(ctx)
  241. record, err := s.store.UpsertEventConfigBuild(ctx, tx, postgres.UpsertEventConfigBuildParams{
  242. EventID: sourceRecord.EventID,
  243. SourceID: sourceRecord.ID,
  244. BuildNo: buildNo,
  245. BuildStatus: "success",
  246. BuildLog: &buildLog,
  247. Manifest: manifest,
  248. AssetIndex: assetIndex,
  249. })
  250. if err != nil {
  251. return nil, err
  252. }
  253. if err := tx.Commit(ctx); err != nil {
  254. return nil, err
  255. }
  256. return buildEventConfigBuildView(record)
  257. }
  258. func (s *ConfigService) PublishBuild(ctx context.Context, input PublishBuildInput) (*PublishedReleaseView, error) {
  259. buildRecord, err := s.store.GetEventConfigBuildByID(ctx, strings.TrimSpace(input.BuildID))
  260. if err != nil {
  261. return nil, err
  262. }
  263. if buildRecord == nil {
  264. return nil, apperr.New(http.StatusNotFound, "config_build_not_found", "config build not found")
  265. }
  266. if buildRecord.BuildStatus != "success" {
  267. return nil, apperr.New(http.StatusConflict, "config_build_not_publishable", "config build is not publishable")
  268. }
  269. event, err := s.store.GetEventByID(ctx, buildRecord.EventID)
  270. if err != nil {
  271. return nil, err
  272. }
  273. if event == nil {
  274. return nil, apperr.New(http.StatusNotFound, "event_not_found", "event not found")
  275. }
  276. runtimeBindingID, runtimeSummary, err := s.resolvePublishRuntimeBinding(ctx, event.ID, input.RuntimeBindingID)
  277. if err != nil {
  278. return nil, err
  279. }
  280. presentationID, presentationSummary, err := s.resolvePublishPresentation(ctx, event.ID, input.PresentationID)
  281. if err != nil {
  282. return nil, err
  283. }
  284. contentBundleID, contentBundleSummary, err := s.resolvePublishContentBundle(ctx, event.ID, input.ContentBundleID)
  285. if err != nil {
  286. return nil, err
  287. }
  288. manifest, err := decodeJSONObject(buildRecord.ManifestJSON)
  289. if err != nil {
  290. return nil, apperr.New(http.StatusInternalServerError, "config_build_invalid", "stored build manifest is invalid")
  291. }
  292. assetIndex, err := decodeJSONArray(buildRecord.AssetIndexJSON)
  293. if err != nil {
  294. return nil, apperr.New(http.StatusInternalServerError, "config_build_invalid", "stored build asset index is invalid")
  295. }
  296. releaseNo, err := s.store.NextEventReleaseNo(ctx, event.ID)
  297. if err != nil {
  298. return nil, err
  299. }
  300. releasePublicID, err := security.GeneratePublicID("rel")
  301. if err != nil {
  302. return nil, err
  303. }
  304. configLabel := deriveConfigLabel(event, manifest, releaseNo)
  305. manifestURL := fmt.Sprintf("%s/event/releases/%s/%s/manifest.json", s.assetBaseURL, event.PublicID, releasePublicID)
  306. assetIndexURL := fmt.Sprintf("%s/event/releases/%s/%s/asset-index.json", s.assetBaseURL, event.PublicID, releasePublicID)
  307. checksum := security.HashText(buildRecord.ManifestJSON)
  308. routeCode := deriveRouteCode(manifest)
  309. if s.publisher == nil || !s.publisher.Enabled() {
  310. return nil, apperr.New(http.StatusInternalServerError, "asset_publish_unavailable", "asset publisher is not configured")
  311. }
  312. if err := s.publisher.UploadJSON(ctx, manifestURL, []byte(buildRecord.ManifestJSON)); err != nil {
  313. return nil, apperr.New(http.StatusInternalServerError, "asset_publish_failed", "failed to upload manifest: "+err.Error())
  314. }
  315. if err := s.publisher.UploadJSON(ctx, assetIndexURL, []byte(buildRecord.AssetIndexJSON)); err != nil {
  316. return nil, apperr.New(http.StatusInternalServerError, "asset_publish_failed", "failed to upload asset index: "+err.Error())
  317. }
  318. tx, err := s.store.Begin(ctx)
  319. if err != nil {
  320. return nil, err
  321. }
  322. defer tx.Rollback(ctx)
  323. releaseRecord, err := s.store.CreateEventRelease(ctx, tx, postgres.CreateEventReleaseParams{
  324. PublicID: releasePublicID,
  325. EventID: event.ID,
  326. ReleaseNo: releaseNo,
  327. ConfigLabel: configLabel,
  328. ManifestURL: manifestURL,
  329. ManifestChecksum: &checksum,
  330. RouteCode: routeCode,
  331. BuildID: &buildRecord.ID,
  332. RuntimeBindingID: runtimeBindingID,
  333. PresentationID: presentationID,
  334. ContentBundleID: contentBundleID,
  335. Status: "published",
  336. PayloadJSON: buildRecord.ManifestJSON,
  337. })
  338. if err != nil {
  339. return nil, err
  340. }
  341. if err := s.store.ReplaceEventReleaseAssets(ctx, tx, releaseRecord.ID, s.mapBuildAssetsToReleaseAssets(releaseRecord.ID, manifestURL, assetIndexURL, &checksum, assetIndex)); err != nil {
  342. return nil, err
  343. }
  344. if err := s.store.SetCurrentEventRelease(ctx, tx, event.ID, releaseRecord.ID); err != nil {
  345. return nil, err
  346. }
  347. if err := tx.Commit(ctx); err != nil {
  348. return nil, err
  349. }
  350. return &PublishedReleaseView{
  351. EventID: event.PublicID,
  352. Release: ResolvedReleaseView{
  353. LaunchMode: LaunchModeManifestRelease,
  354. Source: LaunchSourceEventCurrentRelease,
  355. EventID: event.PublicID,
  356. ReleaseID: releaseRecord.PublicID,
  357. ConfigLabel: releaseRecord.ConfigLabel,
  358. ManifestURL: releaseRecord.ManifestURL,
  359. ManifestChecksumSha256: releaseRecord.ManifestChecksum,
  360. RouteCode: releaseRecord.RouteCode,
  361. },
  362. ReleaseNo: releaseRecord.ReleaseNo,
  363. PublishedAt: releaseRecord.PublishedAt.Format(timeRFC3339),
  364. Runtime: runtimeSummary,
  365. Presentation: presentationSummary,
  366. ContentBundle: contentBundleSummary,
  367. }, nil
  368. }
  369. func (s *ConfigService) resolvePublishRuntimeBinding(ctx context.Context, eventID string, runtimeBindingPublicID string) (*string, *RuntimeSummaryView, error) {
  370. runtimeBindingPublicID = strings.TrimSpace(runtimeBindingPublicID)
  371. if runtimeBindingPublicID == "" {
  372. defaults, err := s.store.GetEventDefaultBindingsByEventID(ctx, eventID)
  373. if err != nil {
  374. return nil, nil, err
  375. }
  376. if defaults == nil || defaults.RuntimeBindingID == nil || defaults.RuntimeBindingPublicID == nil || defaults.PlacePublicID == nil || defaults.MapAssetPublicID == nil || defaults.TileReleasePublicID == nil || defaults.CourseSetPublicID == nil || defaults.CourseVariantPublicID == nil {
  377. return nil, nil, nil
  378. }
  379. return defaults.RuntimeBindingID, &RuntimeSummaryView{
  380. RuntimeBindingID: *defaults.RuntimeBindingPublicID,
  381. PlaceID: *defaults.PlacePublicID,
  382. PlaceName: defaults.PlaceName,
  383. MapID: *defaults.MapAssetPublicID,
  384. MapName: defaults.MapAssetName,
  385. TileReleaseID: *defaults.TileReleasePublicID,
  386. CourseSetID: *defaults.CourseSetPublicID,
  387. CourseVariantID: *defaults.CourseVariantPublicID,
  388. CourseVariantName: defaults.CourseVariantName,
  389. RouteCode: defaults.RuntimeRouteCode,
  390. }, nil
  391. }
  392. runtimeBinding, err := s.store.GetMapRuntimeBindingByPublicID(ctx, runtimeBindingPublicID)
  393. if err != nil {
  394. return nil, nil, err
  395. }
  396. if runtimeBinding == nil {
  397. return nil, nil, apperr.New(http.StatusNotFound, "runtime_binding_not_found", "runtime binding not found")
  398. }
  399. if runtimeBinding.EventID != eventID {
  400. return nil, nil, apperr.New(http.StatusConflict, "runtime_binding_not_belong_to_event", "runtime binding does not belong to build event")
  401. }
  402. return &runtimeBinding.ID, &RuntimeSummaryView{
  403. RuntimeBindingID: runtimeBinding.PublicID,
  404. PlaceID: runtimeBinding.PlacePublicID,
  405. MapID: runtimeBinding.MapAssetPublicID,
  406. TileReleaseID: runtimeBinding.TileReleasePublicID,
  407. CourseSetID: runtimeBinding.CourseSetPublicID,
  408. CourseVariantID: runtimeBinding.CourseVariantPublicID,
  409. RouteCode: nil,
  410. }, nil
  411. }
  412. func (s *ConfigService) resolvePublishPresentation(ctx context.Context, eventID string, presentationPublicID string) (*string, *PresentationSummaryView, error) {
  413. presentationPublicID = strings.TrimSpace(presentationPublicID)
  414. if presentationPublicID == "" {
  415. defaults, err := s.store.GetEventDefaultBindingsByEventID(ctx, eventID)
  416. if err != nil {
  417. return nil, nil, err
  418. }
  419. if defaults != nil && defaults.PresentationID != nil && defaults.PresentationPublicID != nil {
  420. record, err := s.store.GetEventPresentationByPublicID(ctx, *defaults.PresentationPublicID)
  421. if err != nil {
  422. return nil, nil, err
  423. }
  424. if record != nil {
  425. summary, err := buildPresentationSummaryFromRecord(record)
  426. if err != nil {
  427. return nil, nil, err
  428. }
  429. return defaults.PresentationID, summary, nil
  430. }
  431. }
  432. record, err := s.store.GetDefaultEventPresentationByEventID(ctx, eventID)
  433. if err != nil {
  434. return nil, nil, err
  435. }
  436. if record == nil {
  437. return nil, nil, nil
  438. }
  439. summary, err := buildPresentationSummaryFromRecord(record)
  440. if err != nil {
  441. return nil, nil, err
  442. }
  443. return &record.ID, summary, nil
  444. }
  445. record, err := s.store.GetEventPresentationByPublicID(ctx, presentationPublicID)
  446. if err != nil {
  447. return nil, nil, err
  448. }
  449. if record == nil {
  450. return nil, nil, apperr.New(http.StatusNotFound, "presentation_not_found", "presentation not found")
  451. }
  452. if record.EventID != eventID {
  453. return nil, nil, apperr.New(http.StatusConflict, "presentation_not_belong_to_event", "presentation does not belong to build event")
  454. }
  455. summary, err := buildPresentationSummaryFromRecord(record)
  456. if err != nil {
  457. return nil, nil, err
  458. }
  459. return &record.ID, summary, nil
  460. }
  461. func (s *ConfigService) resolvePublishContentBundle(ctx context.Context, eventID string, contentBundlePublicID string) (*string, *ContentBundleSummaryView, error) {
  462. contentBundlePublicID = strings.TrimSpace(contentBundlePublicID)
  463. if contentBundlePublicID == "" {
  464. defaults, err := s.store.GetEventDefaultBindingsByEventID(ctx, eventID)
  465. if err != nil {
  466. return nil, nil, err
  467. }
  468. if defaults != nil && defaults.ContentBundleID != nil && defaults.ContentBundlePublicID != nil {
  469. record, err := s.store.GetContentBundleByPublicID(ctx, *defaults.ContentBundlePublicID)
  470. if err != nil {
  471. return nil, nil, err
  472. }
  473. if record != nil {
  474. summary, err := buildContentBundleSummaryFromRecord(record)
  475. if err != nil {
  476. return nil, nil, err
  477. }
  478. return defaults.ContentBundleID, summary, nil
  479. }
  480. }
  481. record, err := s.store.GetDefaultContentBundleByEventID(ctx, eventID)
  482. if err != nil {
  483. return nil, nil, err
  484. }
  485. if record == nil {
  486. return nil, nil, nil
  487. }
  488. summary, err := buildContentBundleSummaryFromRecord(record)
  489. if err != nil {
  490. return nil, nil, err
  491. }
  492. return &record.ID, summary, nil
  493. }
  494. record, err := s.store.GetContentBundleByPublicID(ctx, contentBundlePublicID)
  495. if err != nil {
  496. return nil, nil, err
  497. }
  498. if record == nil {
  499. return nil, nil, apperr.New(http.StatusNotFound, "content_bundle_not_found", "content bundle not found")
  500. }
  501. if record.EventID != eventID {
  502. return nil, nil, apperr.New(http.StatusConflict, "content_bundle_not_belong_to_event", "content bundle does not belong to build event")
  503. }
  504. summary, err := buildContentBundleSummaryFromRecord(record)
  505. if err != nil {
  506. return nil, nil, err
  507. }
  508. return &record.ID, summary, nil
  509. }
  510. func (s *ConfigService) requireEvent(ctx context.Context, eventPublicID string) (*postgres.Event, error) {
  511. eventPublicID = strings.TrimSpace(eventPublicID)
  512. if eventPublicID == "" {
  513. return nil, apperr.New(http.StatusBadRequest, "invalid_params", "event id is required")
  514. }
  515. event, err := s.store.GetEventByPublicID(ctx, eventPublicID)
  516. if err != nil {
  517. return nil, err
  518. }
  519. if event == nil {
  520. return nil, apperr.New(http.StatusNotFound, "event_not_found", "event not found")
  521. }
  522. return event, nil
  523. }
  524. func buildEventConfigSourceView(record *postgres.EventConfigSource, eventPublicID string) (*EventConfigSourceView, error) {
  525. source, err := decodeJSONObject(record.SourceJSON)
  526. if err != nil {
  527. return nil, err
  528. }
  529. view := &EventConfigSourceView{
  530. ID: record.ID,
  531. EventID: eventPublicID,
  532. SourceVersionNo: record.SourceVersionNo,
  533. SourceKind: record.SourceKind,
  534. SchemaID: record.SchemaID,
  535. SchemaVersion: record.SchemaVersion,
  536. Status: record.Status,
  537. Notes: record.Notes,
  538. Source: source,
  539. }
  540. return view, nil
  541. }
  542. func buildEventConfigBuildView(record *postgres.EventConfigBuild) (*EventConfigBuildView, error) {
  543. manifest, err := decodeJSONObject(record.ManifestJSON)
  544. if err != nil {
  545. return nil, err
  546. }
  547. assetIndex, err := decodeJSONArray(record.AssetIndexJSON)
  548. if err != nil {
  549. return nil, err
  550. }
  551. return &EventConfigBuildView{
  552. ID: record.ID,
  553. EventID: record.EventID,
  554. SourceID: record.SourceID,
  555. BuildNo: record.BuildNo,
  556. BuildStatus: record.BuildStatus,
  557. BuildLog: record.BuildLog,
  558. Manifest: manifest,
  559. AssetIndex: assetIndex,
  560. }, nil
  561. }
  562. func validateSourceConfig(source map[string]any) error {
  563. requiredMap := func(parent map[string]any, key string) (map[string]any, error) {
  564. value, ok := parent[key]
  565. if !ok {
  566. return nil, apperr.New(http.StatusBadRequest, "config_missing_field", "missing required field: "+key)
  567. }
  568. asMap, ok := value.(map[string]any)
  569. if !ok {
  570. return nil, apperr.New(http.StatusBadRequest, "config_invalid_field", "invalid object field: "+key)
  571. }
  572. return asMap, nil
  573. }
  574. requiredString := func(parent map[string]any, key string) error {
  575. value, ok := parent[key]
  576. if !ok {
  577. return apperr.New(http.StatusBadRequest, "config_missing_field", "missing required field: "+key)
  578. }
  579. text, ok := value.(string)
  580. if !ok || strings.TrimSpace(text) == "" {
  581. return apperr.New(http.StatusBadRequest, "config_invalid_field", "invalid string field: "+key)
  582. }
  583. return nil
  584. }
  585. if err := requiredString(source, "schemaVersion"); err != nil {
  586. return err
  587. }
  588. app, err := requiredMap(source, "app")
  589. if err != nil {
  590. return err
  591. }
  592. if err := requiredString(app, "id"); err != nil {
  593. return err
  594. }
  595. if err := requiredString(app, "title"); err != nil {
  596. return err
  597. }
  598. m, err := requiredMap(source, "map")
  599. if err != nil {
  600. return err
  601. }
  602. if err := requiredString(m, "tiles"); err != nil {
  603. return err
  604. }
  605. if err := requiredString(m, "mapmeta"); err != nil {
  606. return err
  607. }
  608. playfield, err := requiredMap(source, "playfield")
  609. if err != nil {
  610. return err
  611. }
  612. if err := requiredString(playfield, "kind"); err != nil {
  613. return err
  614. }
  615. playfieldSource, err := requiredMap(playfield, "source")
  616. if err != nil {
  617. return err
  618. }
  619. if err := requiredString(playfieldSource, "type"); err != nil {
  620. return err
  621. }
  622. if err := requiredString(playfieldSource, "url"); err != nil {
  623. return err
  624. }
  625. game, err := requiredMap(source, "game")
  626. if err != nil {
  627. return err
  628. }
  629. if err := requiredString(game, "mode"); err != nil {
  630. return err
  631. }
  632. return nil
  633. }
  634. func resolveSchemaVersion(source map[string]any) string {
  635. if value, ok := source["schemaVersion"].(string); ok && strings.TrimSpace(value) != "" {
  636. return value
  637. }
  638. return "1"
  639. }
  640. func (s *ConfigService) buildPreviewManifest(source map[string]any, previewReleaseID string) map[string]any {
  641. manifest := cloneJSONObject(source)
  642. manifest["releaseId"] = previewReleaseID
  643. manifest["preview"] = true
  644. manifest["assetBaseUrl"] = s.assetBaseURL
  645. if version, ok := manifest["version"]; !ok || version == "" {
  646. manifest["version"] = "preview"
  647. }
  648. if m, ok := manifest["map"].(map[string]any); ok {
  649. if tiles, ok := m["tiles"].(string); ok {
  650. m["tiles"] = s.normalizeAssetURL(tiles)
  651. }
  652. if meta, ok := m["mapmeta"].(string); ok {
  653. m["mapmeta"] = s.normalizeAssetURL(meta)
  654. }
  655. }
  656. if playfield, ok := manifest["playfield"].(map[string]any); ok {
  657. if src, ok := playfield["source"].(map[string]any); ok {
  658. if url, ok := src["url"].(string); ok {
  659. src["url"] = s.normalizeAssetURL(url)
  660. }
  661. }
  662. }
  663. if assets, ok := manifest["assets"].(map[string]any); ok {
  664. for key, value := range assets {
  665. if text, ok := value.(string); ok {
  666. assets[key] = s.normalizeAssetURL(text)
  667. }
  668. }
  669. }
  670. return manifest
  671. }
  672. func (s *ConfigService) buildAssetIndex(manifest map[string]any) []map[string]any {
  673. var assets []map[string]any
  674. if m, ok := manifest["map"].(map[string]any); ok {
  675. if tiles, ok := m["tiles"].(string); ok {
  676. assets = append(assets, map[string]any{"assetType": "tiles", "assetKey": "tiles-root", "assetUrl": tiles})
  677. }
  678. if meta, ok := m["mapmeta"].(string); ok {
  679. assets = append(assets, map[string]any{"assetType": "mapmeta", "assetKey": "mapmeta", "assetUrl": meta})
  680. }
  681. }
  682. if playfield, ok := manifest["playfield"].(map[string]any); ok {
  683. if src, ok := playfield["source"].(map[string]any); ok {
  684. if url, ok := src["url"].(string); ok {
  685. assets = append(assets, map[string]any{"assetType": "playfield", "assetKey": "playfield-source", "assetUrl": url})
  686. }
  687. }
  688. }
  689. if rawAssets, ok := manifest["assets"].(map[string]any); ok {
  690. keys := make([]string, 0, len(rawAssets))
  691. for key := range rawAssets {
  692. keys = append(keys, key)
  693. }
  694. sort.Strings(keys)
  695. for _, key := range keys {
  696. if url, ok := rawAssets[key].(string); ok {
  697. assets = append(assets, map[string]any{"assetType": "other", "assetKey": key, "assetUrl": url})
  698. }
  699. }
  700. }
  701. return assets
  702. }
  703. func (s *ConfigService) normalizeAssetURL(value string) string {
  704. value = strings.TrimSpace(value)
  705. if value == "" {
  706. return value
  707. }
  708. if strings.HasPrefix(value, "http://") || strings.HasPrefix(value, "https://") {
  709. return value
  710. }
  711. trimmed := strings.TrimPrefix(value, "../")
  712. trimmed = strings.TrimPrefix(trimmed, "./")
  713. trimmed = strings.TrimLeft(trimmed, "/")
  714. return s.assetBaseURL + "/" + trimmed
  715. }
  716. func cloneJSONObject(source map[string]any) map[string]any {
  717. raw, _ := json.Marshal(source)
  718. cloned := map[string]any{}
  719. _ = json.Unmarshal(raw, &cloned)
  720. return cloned
  721. }
  722. func decodeJSONObject(raw string) (map[string]any, error) {
  723. result := map[string]any{}
  724. if err := json.Unmarshal([]byte(raw), &result); err != nil {
  725. return nil, err
  726. }
  727. return result, nil
  728. }
  729. func decodeJSONArray(raw string) ([]map[string]any, error) {
  730. if strings.TrimSpace(raw) == "" {
  731. return []map[string]any{}, nil
  732. }
  733. var result []map[string]any
  734. if err := json.Unmarshal([]byte(raw), &result); err != nil {
  735. return nil, err
  736. }
  737. return result, nil
  738. }
  739. func deriveConfigLabel(event *postgres.Event, manifest map[string]any, releaseNo int) string {
  740. if app, ok := manifest["app"].(map[string]any); ok {
  741. if title, ok := app["title"].(string); ok && strings.TrimSpace(title) != "" {
  742. return fmt.Sprintf("%s Release %d", strings.TrimSpace(title), releaseNo)
  743. }
  744. }
  745. if event != nil && strings.TrimSpace(event.DisplayName) != "" {
  746. return fmt.Sprintf("%s Release %d", event.DisplayName, releaseNo)
  747. }
  748. return fmt.Sprintf("Release %d", releaseNo)
  749. }
  750. func deriveRouteCode(manifest map[string]any) *string {
  751. if playfield, ok := manifest["playfield"].(map[string]any); ok {
  752. if value, ok := playfield["kind"].(string); ok && strings.TrimSpace(value) != "" {
  753. route := strings.TrimSpace(value)
  754. return &route
  755. }
  756. }
  757. return nil
  758. }
  759. func (s *ConfigService) mapBuildAssetsToReleaseAssets(eventReleaseID, manifestURL, assetIndexURL string, checksum *string, assetIndex []map[string]any) []postgres.UpsertEventReleaseAssetParams {
  760. assets := []postgres.UpsertEventReleaseAssetParams{
  761. {
  762. EventReleaseID: eventReleaseID,
  763. AssetType: "manifest",
  764. AssetKey: "manifest",
  765. AssetURL: manifestURL,
  766. Checksum: checksum,
  767. Meta: map[string]any{"source": "published-build"},
  768. },
  769. {
  770. EventReleaseID: eventReleaseID,
  771. AssetType: "other",
  772. AssetKey: "asset-index",
  773. AssetURL: assetIndexURL,
  774. Meta: map[string]any{"source": "published-build"},
  775. },
  776. }
  777. for _, asset := range assetIndex {
  778. assetType, _ := asset["assetType"].(string)
  779. assetKey, _ := asset["assetKey"].(string)
  780. assetURL, _ := asset["assetUrl"].(string)
  781. if strings.TrimSpace(assetType) == "" || strings.TrimSpace(assetKey) == "" || strings.TrimSpace(assetURL) == "" {
  782. continue
  783. }
  784. mappedType := assetType
  785. if mappedType != "manifest" && mappedType != "mapmeta" && mappedType != "tiles" && mappedType != "playfield" && mappedType != "content_html" && mappedType != "media" {
  786. mappedType = "other"
  787. }
  788. assets = append(assets, postgres.UpsertEventReleaseAssetParams{
  789. EventReleaseID: eventReleaseID,
  790. AssetType: mappedType,
  791. AssetKey: assetKey,
  792. AssetURL: assetURL,
  793. Meta: asset,
  794. })
  795. }
  796. return assets
  797. }