admin_event_service.go 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490
  1. package service
  2. import (
  3. "context"
  4. "fmt"
  5. "net/http"
  6. "strings"
  7. "cmr-backend/internal/apperr"
  8. "cmr-backend/internal/platform/security"
  9. "cmr-backend/internal/store/postgres"
  10. )
  11. type AdminEventService struct {
  12. store *postgres.Store
  13. }
  14. type AdminEventSummary struct {
  15. ID string `json:"id"`
  16. TenantCode *string `json:"tenantCode,omitempty"`
  17. TenantName *string `json:"tenantName,omitempty"`
  18. Slug string `json:"slug"`
  19. DisplayName string `json:"displayName"`
  20. Summary *string `json:"summary,omitempty"`
  21. Status string `json:"status"`
  22. CurrentRelease *AdminEventReleaseRef `json:"currentRelease,omitempty"`
  23. }
  24. type AdminEventReleaseRef struct {
  25. ID string `json:"id"`
  26. ConfigLabel *string `json:"configLabel,omitempty"`
  27. ManifestURL *string `json:"manifestUrl,omitempty"`
  28. ManifestChecksumSha256 *string `json:"manifestChecksumSha256,omitempty"`
  29. RouteCode *string `json:"routeCode,omitempty"`
  30. }
  31. type AdminEventDetail struct {
  32. Event AdminEventSummary `json:"event"`
  33. LatestSource *EventConfigSourceView `json:"latestSource,omitempty"`
  34. SourceCount int `json:"sourceCount"`
  35. CurrentSource *AdminAssembledSource `json:"currentSource,omitempty"`
  36. }
  37. type CreateAdminEventInput struct {
  38. TenantCode *string `json:"tenantCode,omitempty"`
  39. Slug string `json:"slug"`
  40. DisplayName string `json:"displayName"`
  41. Summary *string `json:"summary,omitempty"`
  42. Status string `json:"status"`
  43. }
  44. type UpdateAdminEventInput struct {
  45. TenantCode *string `json:"tenantCode,omitempty"`
  46. Slug string `json:"slug"`
  47. DisplayName string `json:"displayName"`
  48. Summary *string `json:"summary,omitempty"`
  49. Status string `json:"status"`
  50. }
  51. type SaveAdminEventSourceInput struct {
  52. Map struct {
  53. MapID string `json:"mapId"`
  54. VersionID string `json:"versionId"`
  55. } `json:"map"`
  56. Playfield struct {
  57. PlayfieldID string `json:"playfieldId"`
  58. VersionID string `json:"versionId"`
  59. } `json:"playfield"`
  60. ResourcePack *struct {
  61. ResourcePackID string `json:"resourcePackId"`
  62. VersionID string `json:"versionId"`
  63. } `json:"resourcePack,omitempty"`
  64. GameModeCode string `json:"gameModeCode"`
  65. RouteCode *string `json:"routeCode,omitempty"`
  66. Overrides map[string]any `json:"overrides,omitempty"`
  67. Notes *string `json:"notes,omitempty"`
  68. }
  69. type AdminAssembledSource struct {
  70. Refs map[string]any `json:"refs"`
  71. Runtime map[string]any `json:"runtime"`
  72. Overrides map[string]any `json:"overrides,omitempty"`
  73. }
  74. func NewAdminEventService(store *postgres.Store) *AdminEventService {
  75. return &AdminEventService{store: store}
  76. }
  77. func (s *AdminEventService) ListEvents(ctx context.Context, limit int) ([]AdminEventSummary, error) {
  78. items, err := s.store.ListAdminEvents(ctx, limit)
  79. if err != nil {
  80. return nil, err
  81. }
  82. results := make([]AdminEventSummary, 0, len(items))
  83. for _, item := range items {
  84. results = append(results, buildAdminEventSummary(item))
  85. }
  86. return results, nil
  87. }
  88. func (s *AdminEventService) CreateEvent(ctx context.Context, input CreateAdminEventInput) (*AdminEventSummary, error) {
  89. input.Slug = strings.TrimSpace(input.Slug)
  90. input.DisplayName = strings.TrimSpace(input.DisplayName)
  91. if input.Slug == "" || input.DisplayName == "" {
  92. return nil, apperr.New(http.StatusBadRequest, "invalid_params", "slug and displayName are required")
  93. }
  94. var tenantID *string
  95. var tenantCode *string
  96. var tenantName *string
  97. if input.TenantCode != nil && strings.TrimSpace(*input.TenantCode) != "" {
  98. tenant, err := s.store.GetTenantByCode(ctx, strings.TrimSpace(*input.TenantCode))
  99. if err != nil {
  100. return nil, err
  101. }
  102. if tenant == nil {
  103. return nil, apperr.New(http.StatusNotFound, "tenant_not_found", "tenant not found")
  104. }
  105. tenantID = &tenant.ID
  106. tenantCode = &tenant.TenantCode
  107. tenantName = &tenant.Name
  108. }
  109. publicID, err := security.GeneratePublicID("evt")
  110. if err != nil {
  111. return nil, err
  112. }
  113. tx, err := s.store.Begin(ctx)
  114. if err != nil {
  115. return nil, err
  116. }
  117. defer tx.Rollback(ctx)
  118. item, err := s.store.CreateAdminEvent(ctx, tx, postgres.CreateAdminEventParams{
  119. PublicID: publicID,
  120. TenantID: tenantID,
  121. Slug: input.Slug,
  122. DisplayName: input.DisplayName,
  123. Summary: trimStringPtr(input.Summary),
  124. Status: normalizeEventCatalogStatus(input.Status),
  125. })
  126. if err != nil {
  127. return nil, err
  128. }
  129. if err := tx.Commit(ctx); err != nil {
  130. return nil, err
  131. }
  132. return &AdminEventSummary{
  133. ID: item.PublicID,
  134. TenantCode: tenantCode,
  135. TenantName: tenantName,
  136. Slug: item.Slug,
  137. DisplayName: item.DisplayName,
  138. Summary: item.Summary,
  139. Status: item.Status,
  140. }, nil
  141. }
  142. func (s *AdminEventService) UpdateEvent(ctx context.Context, eventPublicID string, input UpdateAdminEventInput) (*AdminEventSummary, error) {
  143. record, err := s.store.GetAdminEventByPublicID(ctx, strings.TrimSpace(eventPublicID))
  144. if err != nil {
  145. return nil, err
  146. }
  147. if record == nil {
  148. return nil, apperr.New(http.StatusNotFound, "event_not_found", "event not found")
  149. }
  150. input.Slug = strings.TrimSpace(input.Slug)
  151. input.DisplayName = strings.TrimSpace(input.DisplayName)
  152. if input.Slug == "" || input.DisplayName == "" {
  153. return nil, apperr.New(http.StatusBadRequest, "invalid_params", "slug and displayName are required")
  154. }
  155. var tenantID *string
  156. clearTenant := false
  157. if input.TenantCode != nil {
  158. if trimmed := strings.TrimSpace(*input.TenantCode); trimmed != "" {
  159. tenant, err := s.store.GetTenantByCode(ctx, trimmed)
  160. if err != nil {
  161. return nil, err
  162. }
  163. if tenant == nil {
  164. return nil, apperr.New(http.StatusNotFound, "tenant_not_found", "tenant not found")
  165. }
  166. tenantID = &tenant.ID
  167. } else {
  168. clearTenant = true
  169. }
  170. }
  171. tx, err := s.store.Begin(ctx)
  172. if err != nil {
  173. return nil, err
  174. }
  175. defer tx.Rollback(ctx)
  176. updated, err := s.store.UpdateAdminEvent(ctx, tx, postgres.UpdateAdminEventParams{
  177. EventID: record.ID,
  178. TenantID: tenantID,
  179. Slug: input.Slug,
  180. DisplayName: input.DisplayName,
  181. Summary: trimStringPtr(input.Summary),
  182. Status: normalizeEventCatalogStatus(input.Status),
  183. ClearTenant: clearTenant,
  184. })
  185. if err != nil {
  186. return nil, err
  187. }
  188. if err := tx.Commit(ctx); err != nil {
  189. return nil, err
  190. }
  191. refreshed, err := s.store.GetAdminEventByPublicID(ctx, updated.PublicID)
  192. if err != nil {
  193. return nil, err
  194. }
  195. if refreshed == nil {
  196. return nil, apperr.New(http.StatusNotFound, "event_not_found", "event not found")
  197. }
  198. summary := buildAdminEventSummary(*refreshed)
  199. return &summary, nil
  200. }
  201. func (s *AdminEventService) GetEventDetail(ctx context.Context, eventPublicID string) (*AdminEventDetail, error) {
  202. record, err := s.store.GetAdminEventByPublicID(ctx, strings.TrimSpace(eventPublicID))
  203. if err != nil {
  204. return nil, err
  205. }
  206. if record == nil {
  207. return nil, apperr.New(http.StatusNotFound, "event_not_found", "event not found")
  208. }
  209. sources, err := s.store.ListEventConfigSourcesByEventID(ctx, record.ID, 1)
  210. if err != nil {
  211. return nil, err
  212. }
  213. allSources, err := s.store.ListEventConfigSourcesByEventID(ctx, record.ID, 200)
  214. if err != nil {
  215. return nil, err
  216. }
  217. result := &AdminEventDetail{
  218. Event: buildAdminEventSummary(*record),
  219. SourceCount: len(allSources),
  220. }
  221. if len(sources) > 0 {
  222. latest, err := buildEventConfigSourceView(&sources[0], record.PublicID)
  223. if err != nil {
  224. return nil, err
  225. }
  226. result.LatestSource = latest
  227. result.CurrentSource = buildAdminAssembledSource(latest.Source)
  228. }
  229. return result, nil
  230. }
  231. func (s *AdminEventService) SaveEventSource(ctx context.Context, eventPublicID string, input SaveAdminEventSourceInput) (*EventConfigSourceView, error) {
  232. eventRecord, err := s.store.GetAdminEventByPublicID(ctx, strings.TrimSpace(eventPublicID))
  233. if err != nil {
  234. return nil, err
  235. }
  236. if eventRecord == nil {
  237. return nil, apperr.New(http.StatusNotFound, "event_not_found", "event not found")
  238. }
  239. input.GameModeCode = strings.TrimSpace(input.GameModeCode)
  240. input.Map.MapID = strings.TrimSpace(input.Map.MapID)
  241. input.Map.VersionID = strings.TrimSpace(input.Map.VersionID)
  242. input.Playfield.PlayfieldID = strings.TrimSpace(input.Playfield.PlayfieldID)
  243. input.Playfield.VersionID = strings.TrimSpace(input.Playfield.VersionID)
  244. if input.Map.MapID == "" || input.Map.VersionID == "" || input.Playfield.PlayfieldID == "" || input.Playfield.VersionID == "" || input.GameModeCode == "" {
  245. return nil, apperr.New(http.StatusBadRequest, "invalid_params", "map, playfield and gameModeCode are required")
  246. }
  247. mapVersion, err := s.store.GetResourceMapVersionByPublicID(ctx, input.Map.MapID, input.Map.VersionID)
  248. if err != nil {
  249. return nil, err
  250. }
  251. if mapVersion == nil {
  252. return nil, apperr.New(http.StatusNotFound, "map_version_not_found", "map version not found")
  253. }
  254. playfieldVersion, err := s.store.GetResourcePlayfieldVersionByPublicID(ctx, input.Playfield.PlayfieldID, input.Playfield.VersionID)
  255. if err != nil {
  256. return nil, err
  257. }
  258. if playfieldVersion == nil {
  259. return nil, apperr.New(http.StatusNotFound, "playfield_version_not_found", "playfield version not found")
  260. }
  261. var resourcePackVersion *postgres.ResourcePackVersion
  262. if input.ResourcePack != nil {
  263. input.ResourcePack.ResourcePackID = strings.TrimSpace(input.ResourcePack.ResourcePackID)
  264. input.ResourcePack.VersionID = strings.TrimSpace(input.ResourcePack.VersionID)
  265. if input.ResourcePack.ResourcePackID == "" || input.ResourcePack.VersionID == "" {
  266. return nil, apperr.New(http.StatusBadRequest, "invalid_params", "resourcePackId and versionId are required when resourcePack is provided")
  267. }
  268. resourcePackVersion, err = s.store.GetResourcePackVersionByPublicID(ctx, input.ResourcePack.ResourcePackID, input.ResourcePack.VersionID)
  269. if err != nil {
  270. return nil, err
  271. }
  272. if resourcePackVersion == nil {
  273. return nil, apperr.New(http.StatusNotFound, "resource_pack_version_not_found", "resource pack version not found")
  274. }
  275. }
  276. source := s.buildEventSource(eventRecord, mapVersion, playfieldVersion, resourcePackVersion, input)
  277. if err := validateSourceConfig(source); err != nil {
  278. return nil, err
  279. }
  280. nextVersion, err := s.store.NextEventConfigSourceVersion(ctx, eventRecord.ID)
  281. if err != nil {
  282. return nil, err
  283. }
  284. note := trimStringPtr(input.Notes)
  285. if note == nil {
  286. 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)
  287. note = &defaultNote
  288. }
  289. tx, err := s.store.Begin(ctx)
  290. if err != nil {
  291. return nil, err
  292. }
  293. defer tx.Rollback(ctx)
  294. record, err := s.store.UpsertEventConfigSource(ctx, tx, postgres.UpsertEventConfigSourceParams{
  295. EventID: eventRecord.ID,
  296. SourceVersionNo: nextVersion,
  297. SourceKind: "admin_assembled_bundle",
  298. SchemaID: "event-source",
  299. SchemaVersion: resolveSchemaVersion(source),
  300. Status: "active",
  301. Source: source,
  302. Notes: note,
  303. })
  304. if err != nil {
  305. return nil, err
  306. }
  307. if err := tx.Commit(ctx); err != nil {
  308. return nil, err
  309. }
  310. return buildEventConfigSourceView(record, eventRecord.PublicID)
  311. }
  312. func (s *AdminEventService) buildEventSource(event *postgres.AdminEventRecord, mapVersion *postgres.ResourceMapVersion, playfieldVersion *postgres.ResourcePlayfieldVersion, resourcePackVersion *postgres.ResourcePackVersion, input SaveAdminEventSourceInput) map[string]any {
  313. source := map[string]any{
  314. "schemaVersion": "1",
  315. "app": map[string]any{
  316. "id": event.PublicID,
  317. "title": event.DisplayName,
  318. },
  319. "refs": map[string]any{
  320. "map": map[string]any{
  321. "id": input.Map.MapID,
  322. "versionId": input.Map.VersionID,
  323. },
  324. "playfield": map[string]any{
  325. "id": input.Playfield.PlayfieldID,
  326. "versionId": input.Playfield.VersionID,
  327. },
  328. "gameMode": map[string]any{
  329. "code": input.GameModeCode,
  330. },
  331. },
  332. "map": map[string]any{
  333. "tiles": mapVersion.TilesRootURL,
  334. "mapmeta": mapVersion.MapmetaURL,
  335. },
  336. "playfield": map[string]any{
  337. "kind": "course",
  338. "source": map[string]any{
  339. "type": playfieldVersion.SourceType,
  340. "url": playfieldVersion.SourceURL,
  341. },
  342. },
  343. "game": map[string]any{
  344. "mode": input.GameModeCode,
  345. },
  346. }
  347. if event.Summary != nil && strings.TrimSpace(*event.Summary) != "" {
  348. source["summary"] = *event.Summary
  349. }
  350. if event.TenantCode != nil && strings.TrimSpace(*event.TenantCode) != "" {
  351. source["branding"] = map[string]any{
  352. "tenantCode": *event.TenantCode,
  353. }
  354. }
  355. if input.RouteCode != nil && strings.TrimSpace(*input.RouteCode) != "" {
  356. source["playfield"].(map[string]any)["metadata"] = map[string]any{
  357. "routeCode": strings.TrimSpace(*input.RouteCode),
  358. }
  359. }
  360. if resourcePackVersion != nil {
  361. source["refs"].(map[string]any)["resourcePack"] = map[string]any{
  362. "id": input.ResourcePack.ResourcePackID,
  363. "versionId": input.ResourcePack.VersionID,
  364. }
  365. resources := map[string]any{}
  366. assets := map[string]any{}
  367. if resourcePackVersion.ThemeProfileCode != nil && strings.TrimSpace(*resourcePackVersion.ThemeProfileCode) != "" {
  368. resources["themeProfile"] = *resourcePackVersion.ThemeProfileCode
  369. }
  370. if resourcePackVersion.ContentEntryURL != nil && strings.TrimSpace(*resourcePackVersion.ContentEntryURL) != "" {
  371. assets["contentHtml"] = *resourcePackVersion.ContentEntryURL
  372. }
  373. if resourcePackVersion.AudioRootURL != nil && strings.TrimSpace(*resourcePackVersion.AudioRootURL) != "" {
  374. resources["audioRoot"] = *resourcePackVersion.AudioRootURL
  375. }
  376. if len(resources) > 0 {
  377. source["resources"] = resources
  378. }
  379. if len(assets) > 0 {
  380. source["assets"] = assets
  381. }
  382. }
  383. if len(input.Overrides) > 0 {
  384. source["overrides"] = input.Overrides
  385. mergeJSONObject(source, input.Overrides)
  386. }
  387. return source
  388. }
  389. func buildAdminEventSummary(item postgres.AdminEventRecord) AdminEventSummary {
  390. summary := AdminEventSummary{
  391. ID: item.PublicID,
  392. TenantCode: item.TenantCode,
  393. TenantName: item.TenantName,
  394. Slug: item.Slug,
  395. DisplayName: item.DisplayName,
  396. Summary: item.Summary,
  397. Status: item.Status,
  398. }
  399. if item.CurrentReleasePubID != nil {
  400. summary.CurrentRelease = &AdminEventReleaseRef{
  401. ID: *item.CurrentReleasePubID,
  402. ConfigLabel: item.ConfigLabel,
  403. ManifestURL: item.ManifestURL,
  404. ManifestChecksumSha256: item.ManifestChecksum,
  405. RouteCode: item.RouteCode,
  406. }
  407. }
  408. return summary
  409. }
  410. func buildAdminAssembledSource(source map[string]any) *AdminAssembledSource {
  411. result := &AdminAssembledSource{}
  412. if refs, ok := source["refs"].(map[string]any); ok {
  413. result.Refs = refs
  414. }
  415. runtime := cloneJSONObject(source)
  416. delete(runtime, "refs")
  417. delete(runtime, "overrides")
  418. if overrides, ok := source["overrides"].(map[string]any); ok && len(overrides) > 0 {
  419. result.Overrides = overrides
  420. }
  421. result.Runtime = runtime
  422. return result
  423. }
  424. func normalizeEventCatalogStatus(value string) string {
  425. switch strings.TrimSpace(value) {
  426. case "active":
  427. return "active"
  428. case "disabled":
  429. return "disabled"
  430. case "archived":
  431. return "archived"
  432. default:
  433. return "draft"
  434. }
  435. }
  436. func mergeJSONObject(target map[string]any, overrides map[string]any) {
  437. for key, value := range overrides {
  438. if valueMap, ok := value.(map[string]any); ok {
  439. existing, ok := target[key].(map[string]any)
  440. if !ok {
  441. existing = map[string]any{}
  442. target[key] = existing
  443. }
  444. mergeJSONObject(existing, valueMap)
  445. continue
  446. }
  447. target[key] = value
  448. }
  449. }