admin_event_service.go 38 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173
  1. package service
  2. import (
  3. "context"
  4. "encoding/json"
  5. "fmt"
  6. "net/http"
  7. "strings"
  8. "cmr-backend/internal/apperr"
  9. "cmr-backend/internal/platform/security"
  10. "cmr-backend/internal/store/postgres"
  11. )
  12. type AdminEventService struct {
  13. store *postgres.Store
  14. }
  15. type AdminEventSummary struct {
  16. ID string `json:"id"`
  17. TenantCode *string `json:"tenantCode,omitempty"`
  18. TenantName *string `json:"tenantName,omitempty"`
  19. Slug string `json:"slug"`
  20. DisplayName string `json:"displayName"`
  21. Summary *string `json:"summary,omitempty"`
  22. Status string `json:"status"`
  23. CurrentRelease *AdminEventReleaseRef `json:"currentRelease,omitempty"`
  24. }
  25. type AdminEventReleaseRef struct {
  26. ID string `json:"id"`
  27. ConfigLabel *string `json:"configLabel,omitempty"`
  28. ManifestURL *string `json:"manifestUrl,omitempty"`
  29. ManifestChecksumSha256 *string `json:"manifestChecksumSha256,omitempty"`
  30. RouteCode *string `json:"routeCode,omitempty"`
  31. Presentation *PresentationSummaryView `json:"presentation,omitempty"`
  32. ContentBundle *ContentBundleSummaryView `json:"contentBundle,omitempty"`
  33. }
  34. type AdminEventDetail struct {
  35. Event AdminEventSummary `json:"event"`
  36. LatestSource *EventConfigSourceView `json:"latestSource,omitempty"`
  37. SourceCount int `json:"sourceCount"`
  38. CurrentSource *AdminAssembledSource `json:"currentSource,omitempty"`
  39. PresentationCount int `json:"presentationCount"`
  40. ContentBundleCount int `json:"contentBundleCount"`
  41. CurrentPresentation *PresentationSummaryView `json:"currentPresentation,omitempty"`
  42. CurrentContentBundle *ContentBundleSummaryView `json:"currentContentBundle,omitempty"`
  43. CurrentRuntime *RuntimeSummaryView `json:"currentRuntime,omitempty"`
  44. }
  45. type CreateAdminEventInput struct {
  46. TenantCode *string `json:"tenantCode,omitempty"`
  47. Slug string `json:"slug"`
  48. DisplayName string `json:"displayName"`
  49. Summary *string `json:"summary,omitempty"`
  50. Status string `json:"status"`
  51. }
  52. type UpdateAdminEventInput struct {
  53. TenantCode *string `json:"tenantCode,omitempty"`
  54. Slug string `json:"slug"`
  55. DisplayName string `json:"displayName"`
  56. Summary *string `json:"summary,omitempty"`
  57. Status string `json:"status"`
  58. }
  59. type SaveAdminEventSourceInput struct {
  60. Map struct {
  61. MapID string `json:"mapId"`
  62. VersionID string `json:"versionId"`
  63. } `json:"map"`
  64. Playfield struct {
  65. PlayfieldID string `json:"playfieldId"`
  66. VersionID string `json:"versionId"`
  67. } `json:"playfield"`
  68. ResourcePack *struct {
  69. ResourcePackID string `json:"resourcePackId"`
  70. VersionID string `json:"versionId"`
  71. } `json:"resourcePack,omitempty"`
  72. GameModeCode string `json:"gameModeCode"`
  73. RouteCode *string `json:"routeCode,omitempty"`
  74. Overrides map[string]any `json:"overrides,omitempty"`
  75. Notes *string `json:"notes,omitempty"`
  76. }
  77. type AdminEventPresentationView struct {
  78. ID string `json:"id"`
  79. EventID string `json:"eventId"`
  80. Code string `json:"code"`
  81. Name string `json:"name"`
  82. PresentationType string `json:"presentationType"`
  83. Status string `json:"status"`
  84. IsDefault bool `json:"isDefault"`
  85. TemplateKey *string `json:"templateKey,omitempty"`
  86. Version *string `json:"version,omitempty"`
  87. SourceType *string `json:"sourceType,omitempty"`
  88. SchemaURL *string `json:"schemaUrl,omitempty"`
  89. Schema map[string]any `json:"schema"`
  90. }
  91. type CreateAdminEventPresentationInput struct {
  92. Code string `json:"code"`
  93. Name string `json:"name"`
  94. PresentationType string `json:"presentationType"`
  95. Status string `json:"status"`
  96. IsDefault bool `json:"isDefault"`
  97. Schema map[string]any `json:"schema,omitempty"`
  98. }
  99. type ImportAdminEventPresentationInput struct {
  100. Title string `json:"title"`
  101. TemplateKey string `json:"templateKey"`
  102. SourceType string `json:"sourceType"`
  103. SchemaURL string `json:"schemaUrl"`
  104. Version string `json:"version"`
  105. Status string `json:"status"`
  106. IsDefault bool `json:"isDefault"`
  107. }
  108. type AdminContentBundleView struct {
  109. ID string `json:"id"`
  110. EventID string `json:"eventId"`
  111. Code string `json:"code"`
  112. Name string `json:"name"`
  113. Status string `json:"status"`
  114. IsDefault bool `json:"isDefault"`
  115. BundleType *string `json:"bundleType,omitempty"`
  116. Version *string `json:"version,omitempty"`
  117. SourceType *string `json:"sourceType,omitempty"`
  118. ManifestURL *string `json:"manifestUrl,omitempty"`
  119. AssetManifest any `json:"assetManifest,omitempty"`
  120. EntryURL *string `json:"entryUrl,omitempty"`
  121. AssetRootURL *string `json:"assetRootUrl,omitempty"`
  122. Metadata map[string]any `json:"metadata"`
  123. }
  124. type CreateAdminContentBundleInput struct {
  125. Code string `json:"code"`
  126. Name string `json:"name"`
  127. Status string `json:"status"`
  128. IsDefault bool `json:"isDefault"`
  129. EntryURL *string `json:"entryUrl,omitempty"`
  130. AssetRootURL *string `json:"assetRootUrl,omitempty"`
  131. Metadata map[string]any `json:"metadata,omitempty"`
  132. }
  133. type ImportAdminContentBundleInput struct {
  134. Title string `json:"title"`
  135. BundleType string `json:"bundleType"`
  136. SourceType string `json:"sourceType"`
  137. ManifestURL string `json:"manifestUrl"`
  138. Version string `json:"version"`
  139. Status string `json:"status"`
  140. IsDefault bool `json:"isDefault"`
  141. AssetManifest map[string]any `json:"assetManifest,omitempty"`
  142. }
  143. type UpdateAdminEventDefaultsInput struct {
  144. PresentationID *string `json:"presentationId,omitempty"`
  145. ContentBundleID *string `json:"contentBundleId,omitempty"`
  146. RuntimeBindingID *string `json:"runtimeBindingId,omitempty"`
  147. }
  148. type AdminAssembledSource struct {
  149. Refs map[string]any `json:"refs"`
  150. Runtime map[string]any `json:"runtime"`
  151. Overrides map[string]any `json:"overrides,omitempty"`
  152. }
  153. func NewAdminEventService(store *postgres.Store) *AdminEventService {
  154. return &AdminEventService{store: store}
  155. }
  156. func (s *AdminEventService) ListEvents(ctx context.Context, limit int) ([]AdminEventSummary, error) {
  157. items, err := s.store.ListAdminEvents(ctx, limit)
  158. if err != nil {
  159. return nil, err
  160. }
  161. results := make([]AdminEventSummary, 0, len(items))
  162. for _, item := range items {
  163. results = append(results, buildAdminEventSummary(item))
  164. }
  165. return results, nil
  166. }
  167. func (s *AdminEventService) CreateEvent(ctx context.Context, input CreateAdminEventInput) (*AdminEventSummary, error) {
  168. input.Slug = strings.TrimSpace(input.Slug)
  169. input.DisplayName = strings.TrimSpace(input.DisplayName)
  170. if input.Slug == "" || input.DisplayName == "" {
  171. return nil, apperr.New(http.StatusBadRequest, "invalid_params", "slug and displayName are required")
  172. }
  173. var tenantID *string
  174. var tenantCode *string
  175. var tenantName *string
  176. if input.TenantCode != nil && strings.TrimSpace(*input.TenantCode) != "" {
  177. tenant, err := s.store.GetTenantByCode(ctx, strings.TrimSpace(*input.TenantCode))
  178. if err != nil {
  179. return nil, err
  180. }
  181. if tenant == nil {
  182. return nil, apperr.New(http.StatusNotFound, "tenant_not_found", "tenant not found")
  183. }
  184. tenantID = &tenant.ID
  185. tenantCode = &tenant.TenantCode
  186. tenantName = &tenant.Name
  187. }
  188. publicID, err := security.GeneratePublicID("evt")
  189. if err != nil {
  190. return nil, err
  191. }
  192. tx, err := s.store.Begin(ctx)
  193. if err != nil {
  194. return nil, err
  195. }
  196. defer tx.Rollback(ctx)
  197. item, err := s.store.CreateAdminEvent(ctx, tx, postgres.CreateAdminEventParams{
  198. PublicID: publicID,
  199. TenantID: tenantID,
  200. Slug: input.Slug,
  201. DisplayName: input.DisplayName,
  202. Summary: trimStringPtr(input.Summary),
  203. Status: normalizeEventCatalogStatus(input.Status),
  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 &AdminEventSummary{
  212. ID: item.PublicID,
  213. TenantCode: tenantCode,
  214. TenantName: tenantName,
  215. Slug: item.Slug,
  216. DisplayName: item.DisplayName,
  217. Summary: item.Summary,
  218. Status: item.Status,
  219. }, nil
  220. }
  221. func (s *AdminEventService) UpdateEvent(ctx context.Context, eventPublicID string, input UpdateAdminEventInput) (*AdminEventSummary, error) {
  222. record, err := s.store.GetAdminEventByPublicID(ctx, strings.TrimSpace(eventPublicID))
  223. if err != nil {
  224. return nil, err
  225. }
  226. if record == nil {
  227. return nil, apperr.New(http.StatusNotFound, "event_not_found", "event not found")
  228. }
  229. input.Slug = strings.TrimSpace(input.Slug)
  230. input.DisplayName = strings.TrimSpace(input.DisplayName)
  231. if input.Slug == "" || input.DisplayName == "" {
  232. return nil, apperr.New(http.StatusBadRequest, "invalid_params", "slug and displayName are required")
  233. }
  234. var tenantID *string
  235. clearTenant := false
  236. if input.TenantCode != nil {
  237. if trimmed := strings.TrimSpace(*input.TenantCode); trimmed != "" {
  238. tenant, err := s.store.GetTenantByCode(ctx, trimmed)
  239. if err != nil {
  240. return nil, err
  241. }
  242. if tenant == nil {
  243. return nil, apperr.New(http.StatusNotFound, "tenant_not_found", "tenant not found")
  244. }
  245. tenantID = &tenant.ID
  246. } else {
  247. clearTenant = true
  248. }
  249. }
  250. tx, err := s.store.Begin(ctx)
  251. if err != nil {
  252. return nil, err
  253. }
  254. defer tx.Rollback(ctx)
  255. updated, err := s.store.UpdateAdminEvent(ctx, tx, postgres.UpdateAdminEventParams{
  256. EventID: record.ID,
  257. TenantID: tenantID,
  258. Slug: input.Slug,
  259. DisplayName: input.DisplayName,
  260. Summary: trimStringPtr(input.Summary),
  261. Status: normalizeEventCatalogStatus(input.Status),
  262. ClearTenant: clearTenant,
  263. })
  264. if err != nil {
  265. return nil, err
  266. }
  267. if err := tx.Commit(ctx); err != nil {
  268. return nil, err
  269. }
  270. refreshed, err := s.store.GetAdminEventByPublicID(ctx, updated.PublicID)
  271. if err != nil {
  272. return nil, err
  273. }
  274. if refreshed == nil {
  275. return nil, apperr.New(http.StatusNotFound, "event_not_found", "event not found")
  276. }
  277. summary := buildAdminEventSummary(*refreshed)
  278. return &summary, nil
  279. }
  280. func (s *AdminEventService) GetEventDetail(ctx context.Context, eventPublicID string) (*AdminEventDetail, error) {
  281. record, err := s.store.GetAdminEventByPublicID(ctx, strings.TrimSpace(eventPublicID))
  282. if err != nil {
  283. return nil, err
  284. }
  285. if record == nil {
  286. return nil, apperr.New(http.StatusNotFound, "event_not_found", "event not found")
  287. }
  288. sources, err := s.store.ListEventConfigSourcesByEventID(ctx, record.ID, 1)
  289. if err != nil {
  290. return nil, err
  291. }
  292. allSources, err := s.store.ListEventConfigSourcesByEventID(ctx, record.ID, 200)
  293. if err != nil {
  294. return nil, err
  295. }
  296. presentations, err := s.store.ListEventPresentationsByEventID(ctx, record.ID, 200)
  297. if err != nil {
  298. return nil, err
  299. }
  300. contentBundles, err := s.store.ListContentBundlesByEventID(ctx, record.ID, 200)
  301. if err != nil {
  302. return nil, err
  303. }
  304. result := &AdminEventDetail{
  305. Event: buildAdminEventSummary(*record),
  306. SourceCount: len(allSources),
  307. PresentationCount: len(presentations),
  308. ContentBundleCount: len(contentBundles),
  309. }
  310. if len(sources) > 0 {
  311. latest, err := buildEventConfigSourceView(&sources[0], record.PublicID)
  312. if err != nil {
  313. return nil, err
  314. }
  315. result.LatestSource = latest
  316. result.CurrentSource = buildAdminAssembledSource(latest.Source)
  317. }
  318. result.CurrentPresentation = buildPresentationSummaryFromEventRecord(record)
  319. result.CurrentContentBundle = buildContentBundleSummaryFromEventRecord(record)
  320. result.CurrentRuntime = buildRuntimeSummaryFromAdminEventRecord(record)
  321. if enrichedPresentation, err := loadPresentationSummaryByPublicID(ctx, s.store, record.CurrentPresentationID); err != nil {
  322. return nil, err
  323. } else if enrichedPresentation != nil {
  324. result.CurrentPresentation = enrichedPresentation
  325. }
  326. if enrichedBundle, err := loadContentBundleSummaryByPublicID(ctx, s.store, record.CurrentContentBundleID); err != nil {
  327. return nil, err
  328. } else if enrichedBundle != nil {
  329. result.CurrentContentBundle = enrichedBundle
  330. }
  331. return result, nil
  332. }
  333. func (s *AdminEventService) ListEventPresentations(ctx context.Context, eventPublicID string, limit int) ([]AdminEventPresentationView, error) {
  334. eventRecord, err := s.store.GetAdminEventByPublicID(ctx, strings.TrimSpace(eventPublicID))
  335. if err != nil {
  336. return nil, err
  337. }
  338. if eventRecord == nil {
  339. return nil, apperr.New(http.StatusNotFound, "event_not_found", "event not found")
  340. }
  341. items, err := s.store.ListEventPresentationsByEventID(ctx, eventRecord.ID, limit)
  342. if err != nil {
  343. return nil, err
  344. }
  345. result := make([]AdminEventPresentationView, 0, len(items))
  346. for _, item := range items {
  347. view, err := buildAdminEventPresentationView(item)
  348. if err != nil {
  349. return nil, err
  350. }
  351. result = append(result, view)
  352. }
  353. return result, nil
  354. }
  355. func (s *AdminEventService) CreateEventPresentation(ctx context.Context, eventPublicID string, input CreateAdminEventPresentationInput) (*AdminEventPresentationView, error) {
  356. eventRecord, err := s.store.GetAdminEventByPublicID(ctx, strings.TrimSpace(eventPublicID))
  357. if err != nil {
  358. return nil, err
  359. }
  360. if eventRecord == nil {
  361. return nil, apperr.New(http.StatusNotFound, "event_not_found", "event not found")
  362. }
  363. input.Code = strings.TrimSpace(input.Code)
  364. input.Name = strings.TrimSpace(input.Name)
  365. input.PresentationType = normalizePresentationType(input.PresentationType)
  366. if input.Code == "" || input.Name == "" {
  367. return nil, apperr.New(http.StatusBadRequest, "invalid_params", "code and name are required")
  368. }
  369. publicID, err := security.GeneratePublicID("pres")
  370. if err != nil {
  371. return nil, err
  372. }
  373. schema := input.Schema
  374. if schema == nil {
  375. schema = map[string]any{}
  376. }
  377. tx, err := s.store.Begin(ctx)
  378. if err != nil {
  379. return nil, err
  380. }
  381. defer tx.Rollback(ctx)
  382. record, err := s.store.CreateEventPresentation(ctx, tx, postgres.CreateEventPresentationParams{
  383. PublicID: publicID,
  384. EventID: eventRecord.ID,
  385. Code: input.Code,
  386. Name: input.Name,
  387. PresentationType: input.PresentationType,
  388. Status: normalizeEventCatalogStatus(input.Status),
  389. IsDefault: input.IsDefault,
  390. SchemaJSON: mustMarshalJSONObject(schema),
  391. })
  392. if err != nil {
  393. return nil, err
  394. }
  395. if err := tx.Commit(ctx); err != nil {
  396. return nil, err
  397. }
  398. view, err := buildAdminEventPresentationView(*record)
  399. if err != nil {
  400. return nil, err
  401. }
  402. return &view, nil
  403. }
  404. func (s *AdminEventService) ImportEventPresentation(ctx context.Context, eventPublicID string, input ImportAdminEventPresentationInput) (*AdminEventPresentationView, error) {
  405. eventRecord, err := s.store.GetAdminEventByPublicID(ctx, strings.TrimSpace(eventPublicID))
  406. if err != nil {
  407. return nil, err
  408. }
  409. if eventRecord == nil {
  410. return nil, apperr.New(http.StatusNotFound, "event_not_found", "event not found")
  411. }
  412. input.Title = strings.TrimSpace(input.Title)
  413. input.TemplateKey = strings.TrimSpace(input.TemplateKey)
  414. input.SourceType = strings.TrimSpace(input.SourceType)
  415. input.SchemaURL = strings.TrimSpace(input.SchemaURL)
  416. input.Version = strings.TrimSpace(input.Version)
  417. if input.Title == "" || input.TemplateKey == "" || input.SourceType == "" || input.SchemaURL == "" || input.Version == "" {
  418. return nil, apperr.New(http.StatusBadRequest, "invalid_params", "title, templateKey, sourceType, schemaUrl and version are required")
  419. }
  420. publicID, err := security.GeneratePublicID("pres")
  421. if err != nil {
  422. return nil, err
  423. }
  424. code := generateImportedPresentationCode(input.Title, publicID)
  425. status := normalizeEventCatalogStatus(input.Status)
  426. if strings.TrimSpace(input.Status) == "" {
  427. status = "active"
  428. }
  429. schema := map[string]any{
  430. "templateKey": input.TemplateKey,
  431. "sourceType": input.SourceType,
  432. "schemaUrl": input.SchemaURL,
  433. "version": input.Version,
  434. }
  435. tx, err := s.store.Begin(ctx)
  436. if err != nil {
  437. return nil, err
  438. }
  439. defer tx.Rollback(ctx)
  440. record, err := s.store.CreateEventPresentation(ctx, tx, postgres.CreateEventPresentationParams{
  441. PublicID: publicID,
  442. EventID: eventRecord.ID,
  443. Code: code,
  444. Name: input.Title,
  445. PresentationType: "generic",
  446. Status: status,
  447. IsDefault: input.IsDefault,
  448. SchemaJSON: mustMarshalJSONObject(schema),
  449. })
  450. if err != nil {
  451. return nil, err
  452. }
  453. if err := tx.Commit(ctx); err != nil {
  454. return nil, err
  455. }
  456. view, err := buildAdminEventPresentationView(*record)
  457. if err != nil {
  458. return nil, err
  459. }
  460. return &view, nil
  461. }
  462. func (s *AdminEventService) GetEventPresentation(ctx context.Context, presentationPublicID string) (*AdminEventPresentationView, error) {
  463. record, err := s.store.GetEventPresentationByPublicID(ctx, strings.TrimSpace(presentationPublicID))
  464. if err != nil {
  465. return nil, err
  466. }
  467. if record == nil {
  468. return nil, apperr.New(http.StatusNotFound, "presentation_not_found", "presentation not found")
  469. }
  470. view, err := buildAdminEventPresentationView(*record)
  471. if err != nil {
  472. return nil, err
  473. }
  474. return &view, nil
  475. }
  476. func (s *AdminEventService) ListContentBundles(ctx context.Context, eventPublicID string, limit int) ([]AdminContentBundleView, error) {
  477. eventRecord, err := s.store.GetAdminEventByPublicID(ctx, strings.TrimSpace(eventPublicID))
  478. if err != nil {
  479. return nil, err
  480. }
  481. if eventRecord == nil {
  482. return nil, apperr.New(http.StatusNotFound, "event_not_found", "event not found")
  483. }
  484. items, err := s.store.ListContentBundlesByEventID(ctx, eventRecord.ID, limit)
  485. if err != nil {
  486. return nil, err
  487. }
  488. result := make([]AdminContentBundleView, 0, len(items))
  489. for _, item := range items {
  490. view, err := buildAdminContentBundleView(item)
  491. if err != nil {
  492. return nil, err
  493. }
  494. result = append(result, view)
  495. }
  496. return result, nil
  497. }
  498. func (s *AdminEventService) CreateContentBundle(ctx context.Context, eventPublicID string, input CreateAdminContentBundleInput) (*AdminContentBundleView, error) {
  499. eventRecord, err := s.store.GetAdminEventByPublicID(ctx, strings.TrimSpace(eventPublicID))
  500. if err != nil {
  501. return nil, err
  502. }
  503. if eventRecord == nil {
  504. return nil, apperr.New(http.StatusNotFound, "event_not_found", "event not found")
  505. }
  506. input.Code = strings.TrimSpace(input.Code)
  507. input.Name = strings.TrimSpace(input.Name)
  508. if input.Code == "" || input.Name == "" {
  509. return nil, apperr.New(http.StatusBadRequest, "invalid_params", "code and name are required")
  510. }
  511. publicID, err := security.GeneratePublicID("bundle")
  512. if err != nil {
  513. return nil, err
  514. }
  515. metadata := input.Metadata
  516. if metadata == nil {
  517. metadata = map[string]any{}
  518. }
  519. tx, err := s.store.Begin(ctx)
  520. if err != nil {
  521. return nil, err
  522. }
  523. defer tx.Rollback(ctx)
  524. record, err := s.store.CreateContentBundle(ctx, tx, postgres.CreateContentBundleParams{
  525. PublicID: publicID,
  526. EventID: eventRecord.ID,
  527. Code: input.Code,
  528. Name: input.Name,
  529. Status: normalizeEventCatalogStatus(input.Status),
  530. IsDefault: input.IsDefault,
  531. EntryURL: trimStringPtr(input.EntryURL),
  532. AssetRootURL: trimStringPtr(input.AssetRootURL),
  533. MetadataJSON: mustMarshalJSONObject(metadata),
  534. })
  535. if err != nil {
  536. return nil, err
  537. }
  538. if err := tx.Commit(ctx); err != nil {
  539. return nil, err
  540. }
  541. view, err := buildAdminContentBundleView(*record)
  542. if err != nil {
  543. return nil, err
  544. }
  545. return &view, nil
  546. }
  547. func (s *AdminEventService) ImportContentBundle(ctx context.Context, eventPublicID string, input ImportAdminContentBundleInput) (*AdminContentBundleView, error) {
  548. eventRecord, err := s.store.GetAdminEventByPublicID(ctx, strings.TrimSpace(eventPublicID))
  549. if err != nil {
  550. return nil, err
  551. }
  552. if eventRecord == nil {
  553. return nil, apperr.New(http.StatusNotFound, "event_not_found", "event not found")
  554. }
  555. input.Title = strings.TrimSpace(input.Title)
  556. input.BundleType = strings.TrimSpace(input.BundleType)
  557. input.SourceType = strings.TrimSpace(input.SourceType)
  558. input.ManifestURL = strings.TrimSpace(input.ManifestURL)
  559. input.Version = strings.TrimSpace(input.Version)
  560. if input.Title == "" || input.BundleType == "" || input.SourceType == "" || input.ManifestURL == "" || input.Version == "" {
  561. return nil, apperr.New(http.StatusBadRequest, "invalid_params", "title, bundleType, sourceType, manifestUrl and version are required")
  562. }
  563. publicID, err := security.GeneratePublicID("bundle")
  564. if err != nil {
  565. return nil, err
  566. }
  567. code := generateImportedBundleCode(input.Title, publicID)
  568. assetManifest := input.AssetManifest
  569. if assetManifest == nil {
  570. assetManifest = map[string]any{
  571. "manifestUrl": input.ManifestURL,
  572. "sourceType": input.SourceType,
  573. }
  574. }
  575. metadata := map[string]any{
  576. "bundleType": input.BundleType,
  577. "sourceType": input.SourceType,
  578. "manifestUrl": input.ManifestURL,
  579. "version": input.Version,
  580. "assetManifest": assetManifest,
  581. }
  582. status := normalizeEventCatalogStatus(input.Status)
  583. if strings.TrimSpace(input.Status) == "" {
  584. status = "active"
  585. }
  586. tx, err := s.store.Begin(ctx)
  587. if err != nil {
  588. return nil, err
  589. }
  590. defer tx.Rollback(ctx)
  591. record, err := s.store.CreateContentBundle(ctx, tx, postgres.CreateContentBundleParams{
  592. PublicID: publicID,
  593. EventID: eventRecord.ID,
  594. Code: code,
  595. Name: input.Title,
  596. Status: status,
  597. IsDefault: input.IsDefault,
  598. EntryURL: nil,
  599. AssetRootURL: nil,
  600. MetadataJSON: mustMarshalJSONObject(metadata),
  601. })
  602. if err != nil {
  603. return nil, err
  604. }
  605. if err := tx.Commit(ctx); err != nil {
  606. return nil, err
  607. }
  608. view, err := buildAdminContentBundleView(*record)
  609. if err != nil {
  610. return nil, err
  611. }
  612. return &view, nil
  613. }
  614. func (s *AdminEventService) GetContentBundle(ctx context.Context, contentBundlePublicID string) (*AdminContentBundleView, error) {
  615. record, err := s.store.GetContentBundleByPublicID(ctx, strings.TrimSpace(contentBundlePublicID))
  616. if err != nil {
  617. return nil, err
  618. }
  619. if record == nil {
  620. return nil, apperr.New(http.StatusNotFound, "content_bundle_not_found", "content bundle not found")
  621. }
  622. view, err := buildAdminContentBundleView(*record)
  623. if err != nil {
  624. return nil, err
  625. }
  626. return &view, nil
  627. }
  628. func (s *AdminEventService) UpdateEventDefaults(ctx context.Context, eventPublicID string, input UpdateAdminEventDefaultsInput) (*AdminEventDetail, error) {
  629. record, err := s.store.GetAdminEventByPublicID(ctx, strings.TrimSpace(eventPublicID))
  630. if err != nil {
  631. return nil, err
  632. }
  633. if record == nil {
  634. return nil, apperr.New(http.StatusNotFound, "event_not_found", "event not found")
  635. }
  636. var presentationID *string
  637. updatePresentation := false
  638. if input.PresentationID != nil {
  639. updatePresentation = true
  640. trimmed := strings.TrimSpace(*input.PresentationID)
  641. if trimmed != "" {
  642. presentation, err := s.store.GetEventPresentationByPublicID(ctx, trimmed)
  643. if err != nil {
  644. return nil, err
  645. }
  646. if presentation == nil {
  647. return nil, apperr.New(http.StatusNotFound, "presentation_not_found", "presentation not found")
  648. }
  649. if presentation.EventID != record.ID {
  650. return nil, apperr.New(http.StatusConflict, "presentation_not_belong_to_event", "presentation does not belong to event")
  651. }
  652. presentationID = &presentation.ID
  653. }
  654. }
  655. var contentBundleID *string
  656. updateContent := false
  657. if input.ContentBundleID != nil {
  658. updateContent = true
  659. trimmed := strings.TrimSpace(*input.ContentBundleID)
  660. if trimmed != "" {
  661. contentBundle, err := s.store.GetContentBundleByPublicID(ctx, trimmed)
  662. if err != nil {
  663. return nil, err
  664. }
  665. if contentBundle == nil {
  666. return nil, apperr.New(http.StatusNotFound, "content_bundle_not_found", "content bundle not found")
  667. }
  668. if contentBundle.EventID != record.ID {
  669. return nil, apperr.New(http.StatusConflict, "content_bundle_not_belong_to_event", "content bundle does not belong to event")
  670. }
  671. contentBundleID = &contentBundle.ID
  672. }
  673. }
  674. var runtimeBindingID *string
  675. updateRuntime := false
  676. if input.RuntimeBindingID != nil {
  677. updateRuntime = true
  678. trimmed := strings.TrimSpace(*input.RuntimeBindingID)
  679. if trimmed != "" {
  680. runtimeBinding, err := s.store.GetMapRuntimeBindingByPublicID(ctx, trimmed)
  681. if err != nil {
  682. return nil, err
  683. }
  684. if runtimeBinding == nil {
  685. return nil, apperr.New(http.StatusNotFound, "runtime_binding_not_found", "runtime binding not found")
  686. }
  687. if runtimeBinding.EventID != record.ID {
  688. return nil, apperr.New(http.StatusConflict, "runtime_binding_not_belong_to_event", "runtime binding does not belong to event")
  689. }
  690. runtimeBindingID = &runtimeBinding.ID
  691. }
  692. }
  693. tx, err := s.store.Begin(ctx)
  694. if err != nil {
  695. return nil, err
  696. }
  697. defer tx.Rollback(ctx)
  698. if err := s.store.SetEventDefaultBindings(ctx, tx, postgres.SetEventDefaultBindingsParams{
  699. EventID: record.ID,
  700. PresentationID: presentationID,
  701. ContentBundleID: contentBundleID,
  702. RuntimeBindingID: runtimeBindingID,
  703. UpdatePresentation: updatePresentation,
  704. UpdateContent: updateContent,
  705. UpdateRuntime: updateRuntime,
  706. }); err != nil {
  707. return nil, err
  708. }
  709. if err := tx.Commit(ctx); err != nil {
  710. return nil, err
  711. }
  712. return s.GetEventDetail(ctx, eventPublicID)
  713. }
  714. func (s *AdminEventService) SaveEventSource(ctx context.Context, eventPublicID string, input SaveAdminEventSourceInput) (*EventConfigSourceView, error) {
  715. eventRecord, err := s.store.GetAdminEventByPublicID(ctx, strings.TrimSpace(eventPublicID))
  716. if err != nil {
  717. return nil, err
  718. }
  719. if eventRecord == nil {
  720. return nil, apperr.New(http.StatusNotFound, "event_not_found", "event not found")
  721. }
  722. input.GameModeCode = strings.TrimSpace(input.GameModeCode)
  723. input.Map.MapID = strings.TrimSpace(input.Map.MapID)
  724. input.Map.VersionID = strings.TrimSpace(input.Map.VersionID)
  725. input.Playfield.PlayfieldID = strings.TrimSpace(input.Playfield.PlayfieldID)
  726. input.Playfield.VersionID = strings.TrimSpace(input.Playfield.VersionID)
  727. if input.Map.MapID == "" || input.Map.VersionID == "" || input.Playfield.PlayfieldID == "" || input.Playfield.VersionID == "" || input.GameModeCode == "" {
  728. return nil, apperr.New(http.StatusBadRequest, "invalid_params", "map, playfield and gameModeCode are required")
  729. }
  730. mapVersion, err := s.store.GetResourceMapVersionByPublicID(ctx, input.Map.MapID, input.Map.VersionID)
  731. if err != nil {
  732. return nil, err
  733. }
  734. if mapVersion == nil {
  735. return nil, apperr.New(http.StatusNotFound, "map_version_not_found", "map version not found")
  736. }
  737. playfieldVersion, err := s.store.GetResourcePlayfieldVersionByPublicID(ctx, input.Playfield.PlayfieldID, input.Playfield.VersionID)
  738. if err != nil {
  739. return nil, err
  740. }
  741. if playfieldVersion == nil {
  742. return nil, apperr.New(http.StatusNotFound, "playfield_version_not_found", "playfield version not found")
  743. }
  744. var resourcePackVersion *postgres.ResourcePackVersion
  745. if input.ResourcePack != nil {
  746. input.ResourcePack.ResourcePackID = strings.TrimSpace(input.ResourcePack.ResourcePackID)
  747. input.ResourcePack.VersionID = strings.TrimSpace(input.ResourcePack.VersionID)
  748. if input.ResourcePack.ResourcePackID == "" || input.ResourcePack.VersionID == "" {
  749. return nil, apperr.New(http.StatusBadRequest, "invalid_params", "resourcePackId and versionId are required when resourcePack is provided")
  750. }
  751. resourcePackVersion, err = s.store.GetResourcePackVersionByPublicID(ctx, input.ResourcePack.ResourcePackID, input.ResourcePack.VersionID)
  752. if err != nil {
  753. return nil, err
  754. }
  755. if resourcePackVersion == nil {
  756. return nil, apperr.New(http.StatusNotFound, "resource_pack_version_not_found", "resource pack version not found")
  757. }
  758. }
  759. source := s.buildEventSource(eventRecord, mapVersion, playfieldVersion, resourcePackVersion, input)
  760. if err := validateSourceConfig(source); err != nil {
  761. return nil, err
  762. }
  763. nextVersion, err := s.store.NextEventConfigSourceVersion(ctx, eventRecord.ID)
  764. if err != nil {
  765. return nil, err
  766. }
  767. note := trimStringPtr(input.Notes)
  768. if note == nil {
  769. 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)
  770. note = &defaultNote
  771. }
  772. tx, err := s.store.Begin(ctx)
  773. if err != nil {
  774. return nil, err
  775. }
  776. defer tx.Rollback(ctx)
  777. record, err := s.store.UpsertEventConfigSource(ctx, tx, postgres.UpsertEventConfigSourceParams{
  778. EventID: eventRecord.ID,
  779. SourceVersionNo: nextVersion,
  780. SourceKind: "admin_assembled_bundle",
  781. SchemaID: "event-source",
  782. SchemaVersion: resolveSchemaVersion(source),
  783. Status: "active",
  784. Source: source,
  785. Notes: note,
  786. })
  787. if err != nil {
  788. return nil, err
  789. }
  790. if err := tx.Commit(ctx); err != nil {
  791. return nil, err
  792. }
  793. return buildEventConfigSourceView(record, eventRecord.PublicID)
  794. }
  795. func (s *AdminEventService) buildEventSource(event *postgres.AdminEventRecord, mapVersion *postgres.ResourceMapVersion, playfieldVersion *postgres.ResourcePlayfieldVersion, resourcePackVersion *postgres.ResourcePackVersion, input SaveAdminEventSourceInput) map[string]any {
  796. source := map[string]any{
  797. "schemaVersion": "1",
  798. "app": map[string]any{
  799. "id": event.PublicID,
  800. "title": event.DisplayName,
  801. },
  802. "refs": map[string]any{
  803. "map": map[string]any{
  804. "id": input.Map.MapID,
  805. "versionId": input.Map.VersionID,
  806. },
  807. "playfield": map[string]any{
  808. "id": input.Playfield.PlayfieldID,
  809. "versionId": input.Playfield.VersionID,
  810. },
  811. "gameMode": map[string]any{
  812. "code": input.GameModeCode,
  813. },
  814. },
  815. "map": map[string]any{
  816. "tiles": mapVersion.TilesRootURL,
  817. "mapmeta": mapVersion.MapmetaURL,
  818. },
  819. "playfield": map[string]any{
  820. "kind": "course",
  821. "source": map[string]any{
  822. "type": playfieldVersion.SourceType,
  823. "url": playfieldVersion.SourceURL,
  824. },
  825. },
  826. "game": map[string]any{
  827. "mode": input.GameModeCode,
  828. },
  829. }
  830. if event.Summary != nil && strings.TrimSpace(*event.Summary) != "" {
  831. source["summary"] = *event.Summary
  832. }
  833. if event.TenantCode != nil && strings.TrimSpace(*event.TenantCode) != "" {
  834. source["branding"] = map[string]any{
  835. "tenantCode": *event.TenantCode,
  836. }
  837. }
  838. if input.RouteCode != nil && strings.TrimSpace(*input.RouteCode) != "" {
  839. source["playfield"].(map[string]any)["metadata"] = map[string]any{
  840. "routeCode": strings.TrimSpace(*input.RouteCode),
  841. }
  842. }
  843. if resourcePackVersion != nil {
  844. source["refs"].(map[string]any)["resourcePack"] = map[string]any{
  845. "id": input.ResourcePack.ResourcePackID,
  846. "versionId": input.ResourcePack.VersionID,
  847. }
  848. resources := map[string]any{}
  849. assets := map[string]any{}
  850. if resourcePackVersion.ThemeProfileCode != nil && strings.TrimSpace(*resourcePackVersion.ThemeProfileCode) != "" {
  851. resources["themeProfile"] = *resourcePackVersion.ThemeProfileCode
  852. }
  853. if resourcePackVersion.ContentEntryURL != nil && strings.TrimSpace(*resourcePackVersion.ContentEntryURL) != "" {
  854. assets["contentHtml"] = *resourcePackVersion.ContentEntryURL
  855. }
  856. if resourcePackVersion.AudioRootURL != nil && strings.TrimSpace(*resourcePackVersion.AudioRootURL) != "" {
  857. resources["audioRoot"] = *resourcePackVersion.AudioRootURL
  858. }
  859. if len(resources) > 0 {
  860. source["resources"] = resources
  861. }
  862. if len(assets) > 0 {
  863. source["assets"] = assets
  864. }
  865. }
  866. if len(input.Overrides) > 0 {
  867. source["overrides"] = input.Overrides
  868. mergeJSONObject(source, input.Overrides)
  869. }
  870. return source
  871. }
  872. func buildAdminEventSummary(item postgres.AdminEventRecord) AdminEventSummary {
  873. summary := AdminEventSummary{
  874. ID: item.PublicID,
  875. TenantCode: item.TenantCode,
  876. TenantName: item.TenantName,
  877. Slug: item.Slug,
  878. DisplayName: item.DisplayName,
  879. Summary: item.Summary,
  880. Status: item.Status,
  881. }
  882. if item.CurrentReleasePubID != nil {
  883. summary.CurrentRelease = &AdminEventReleaseRef{
  884. ID: *item.CurrentReleasePubID,
  885. ConfigLabel: item.ConfigLabel,
  886. ManifestURL: item.ManifestURL,
  887. ManifestChecksumSha256: item.ManifestChecksum,
  888. RouteCode: item.RouteCode,
  889. Presentation: buildPresentationSummaryFromEventRecord(&item),
  890. ContentBundle: buildContentBundleSummaryFromEventRecord(&item),
  891. }
  892. }
  893. return summary
  894. }
  895. func buildPresentationSummaryFromEventRecord(item *postgres.AdminEventRecord) *PresentationSummaryView {
  896. if item == nil || item.CurrentPresentationID == nil {
  897. return nil
  898. }
  899. return &PresentationSummaryView{
  900. PresentationID: *item.CurrentPresentationID,
  901. Name: item.CurrentPresentationName,
  902. PresentationType: item.CurrentPresentationType,
  903. }
  904. }
  905. func buildContentBundleSummaryFromEventRecord(item *postgres.AdminEventRecord) *ContentBundleSummaryView {
  906. if item == nil || item.CurrentContentBundleID == nil {
  907. return nil
  908. }
  909. return &ContentBundleSummaryView{
  910. ContentBundleID: *item.CurrentContentBundleID,
  911. Name: item.CurrentContentBundleName,
  912. EntryURL: item.CurrentContentEntryURL,
  913. AssetRootURL: item.CurrentContentAssetRootURL,
  914. }
  915. }
  916. func buildRuntimeSummaryFromAdminEventRecord(item *postgres.AdminEventRecord) *RuntimeSummaryView {
  917. if item == nil ||
  918. item.CurrentRuntimeBindingID == nil ||
  919. item.CurrentPlaceID == nil ||
  920. item.CurrentMapAssetID == nil ||
  921. item.CurrentTileReleaseID == nil ||
  922. item.CurrentCourseSetID == nil ||
  923. item.CurrentCourseVariantID == nil {
  924. return nil
  925. }
  926. return &RuntimeSummaryView{
  927. RuntimeBindingID: *item.CurrentRuntimeBindingID,
  928. PlaceID: *item.CurrentPlaceID,
  929. MapID: *item.CurrentMapAssetID,
  930. TileReleaseID: *item.CurrentTileReleaseID,
  931. CourseSetID: *item.CurrentCourseSetID,
  932. CourseVariantID: *item.CurrentCourseVariantID,
  933. CourseVariantName: item.CurrentCourseVariantName,
  934. RouteCode: item.CurrentRuntimeRouteCode,
  935. }
  936. }
  937. func buildAdminEventPresentationView(item postgres.EventPresentation) (AdminEventPresentationView, error) {
  938. schema, err := decodeJSONObject(item.SchemaJSON)
  939. if err != nil {
  940. return AdminEventPresentationView{}, err
  941. }
  942. return AdminEventPresentationView{
  943. ID: item.PublicID,
  944. EventID: item.EventPublicID,
  945. Code: item.Code,
  946. Name: item.Name,
  947. PresentationType: item.PresentationType,
  948. Status: item.Status,
  949. IsDefault: item.IsDefault,
  950. TemplateKey: readStringField(schema, "templateKey"),
  951. Version: readStringField(schema, "version"),
  952. SourceType: readStringField(schema, "sourceType"),
  953. SchemaURL: readStringField(schema, "schemaUrl"),
  954. Schema: schema,
  955. }, nil
  956. }
  957. func buildAdminContentBundleView(item postgres.ContentBundle) (AdminContentBundleView, error) {
  958. metadata, err := decodeJSONObject(item.MetadataJSON)
  959. if err != nil {
  960. return AdminContentBundleView{}, err
  961. }
  962. return AdminContentBundleView{
  963. ID: item.PublicID,
  964. EventID: item.EventPublicID,
  965. Code: item.Code,
  966. Name: item.Name,
  967. Status: item.Status,
  968. IsDefault: item.IsDefault,
  969. BundleType: readStringField(metadata, "bundleType"),
  970. Version: readStringField(metadata, "version"),
  971. SourceType: readStringField(metadata, "sourceType"),
  972. ManifestURL: readStringField(metadata, "manifestUrl"),
  973. AssetManifest: metadata["assetManifest"],
  974. EntryURL: item.EntryURL,
  975. AssetRootURL: item.AssetRootURL,
  976. Metadata: metadata,
  977. }, nil
  978. }
  979. func generateImportedBundleCode(title, publicID string) string {
  980. var builder strings.Builder
  981. for _, r := range strings.ToLower(title) {
  982. switch {
  983. case r >= 'a' && r <= 'z':
  984. builder.WriteRune(r)
  985. case r >= '0' && r <= '9':
  986. builder.WriteRune(r)
  987. case r == ' ' || r == '-' || r == '_':
  988. if builder.Len() == 0 {
  989. continue
  990. }
  991. last := builder.String()[builder.Len()-1]
  992. if last != '-' {
  993. builder.WriteByte('-')
  994. }
  995. }
  996. }
  997. code := strings.Trim(builder.String(), "-")
  998. if code == "" {
  999. code = "bundle"
  1000. }
  1001. suffix := publicID
  1002. if len(suffix) > 8 {
  1003. suffix = suffix[len(suffix)-8:]
  1004. }
  1005. return code + "-" + suffix
  1006. }
  1007. func generateImportedPresentationCode(title, publicID string) string {
  1008. var builder strings.Builder
  1009. for _, r := range strings.ToLower(title) {
  1010. switch {
  1011. case r >= 'a' && r <= 'z':
  1012. builder.WriteRune(r)
  1013. case r >= '0' && r <= '9':
  1014. builder.WriteRune(r)
  1015. case r == ' ' || r == '-' || r == '_':
  1016. if builder.Len() == 0 {
  1017. continue
  1018. }
  1019. last := builder.String()[builder.Len()-1]
  1020. if last != '-' {
  1021. builder.WriteByte('-')
  1022. }
  1023. }
  1024. }
  1025. code := strings.Trim(builder.String(), "-")
  1026. if code == "" {
  1027. code = "presentation"
  1028. }
  1029. suffix := publicID
  1030. if len(suffix) > 8 {
  1031. suffix = suffix[len(suffix)-8:]
  1032. }
  1033. return code + "-" + suffix
  1034. }
  1035. func buildAdminAssembledSource(source map[string]any) *AdminAssembledSource {
  1036. result := &AdminAssembledSource{}
  1037. if refs, ok := source["refs"].(map[string]any); ok {
  1038. result.Refs = refs
  1039. }
  1040. runtime := cloneJSONObject(source)
  1041. delete(runtime, "refs")
  1042. delete(runtime, "overrides")
  1043. if overrides, ok := source["overrides"].(map[string]any); ok && len(overrides) > 0 {
  1044. result.Overrides = overrides
  1045. }
  1046. result.Runtime = runtime
  1047. return result
  1048. }
  1049. func normalizeEventCatalogStatus(value string) string {
  1050. switch strings.TrimSpace(value) {
  1051. case "active":
  1052. return "active"
  1053. case "disabled":
  1054. return "disabled"
  1055. case "archived":
  1056. return "archived"
  1057. default:
  1058. return "draft"
  1059. }
  1060. }
  1061. func normalizePresentationType(value string) string {
  1062. switch strings.TrimSpace(value) {
  1063. case "card":
  1064. return "card"
  1065. case "detail":
  1066. return "detail"
  1067. case "h5":
  1068. return "h5"
  1069. case "result":
  1070. return "result"
  1071. default:
  1072. return "generic"
  1073. }
  1074. }
  1075. func mustMarshalJSONObject(value map[string]any) string {
  1076. raw, _ := json.Marshal(value)
  1077. return string(raw)
  1078. }
  1079. func mergeJSONObject(target map[string]any, overrides map[string]any) {
  1080. for key, value := range overrides {
  1081. if valueMap, ok := value.(map[string]any); ok {
  1082. existing, ok := target[key].(map[string]any)
  1083. if !ok {
  1084. existing = map[string]any{}
  1085. target[key] = existing
  1086. }
  1087. mergeJSONObject(existing, valueMap)
  1088. continue
  1089. }
  1090. target[key] = value
  1091. }
  1092. }