| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173 |
- package service
- import (
- "context"
- "encoding/json"
- "fmt"
- "net/http"
- "strings"
- "cmr-backend/internal/apperr"
- "cmr-backend/internal/platform/security"
- "cmr-backend/internal/store/postgres"
- )
- type AdminEventService struct {
- store *postgres.Store
- }
- type AdminEventSummary struct {
- ID string `json:"id"`
- TenantCode *string `json:"tenantCode,omitempty"`
- TenantName *string `json:"tenantName,omitempty"`
- Slug string `json:"slug"`
- DisplayName string `json:"displayName"`
- Summary *string `json:"summary,omitempty"`
- Status string `json:"status"`
- CurrentRelease *AdminEventReleaseRef `json:"currentRelease,omitempty"`
- }
- type AdminEventReleaseRef struct {
- ID string `json:"id"`
- ConfigLabel *string `json:"configLabel,omitempty"`
- ManifestURL *string `json:"manifestUrl,omitempty"`
- ManifestChecksumSha256 *string `json:"manifestChecksumSha256,omitempty"`
- RouteCode *string `json:"routeCode,omitempty"`
- Presentation *PresentationSummaryView `json:"presentation,omitempty"`
- ContentBundle *ContentBundleSummaryView `json:"contentBundle,omitempty"`
- }
- type AdminEventDetail struct {
- Event AdminEventSummary `json:"event"`
- LatestSource *EventConfigSourceView `json:"latestSource,omitempty"`
- SourceCount int `json:"sourceCount"`
- CurrentSource *AdminAssembledSource `json:"currentSource,omitempty"`
- PresentationCount int `json:"presentationCount"`
- ContentBundleCount int `json:"contentBundleCount"`
- CurrentPresentation *PresentationSummaryView `json:"currentPresentation,omitempty"`
- CurrentContentBundle *ContentBundleSummaryView `json:"currentContentBundle,omitempty"`
- CurrentRuntime *RuntimeSummaryView `json:"currentRuntime,omitempty"`
- }
- type CreateAdminEventInput struct {
- TenantCode *string `json:"tenantCode,omitempty"`
- Slug string `json:"slug"`
- DisplayName string `json:"displayName"`
- Summary *string `json:"summary,omitempty"`
- Status string `json:"status"`
- }
- type UpdateAdminEventInput struct {
- TenantCode *string `json:"tenantCode,omitempty"`
- Slug string `json:"slug"`
- DisplayName string `json:"displayName"`
- Summary *string `json:"summary,omitempty"`
- Status string `json:"status"`
- }
- type SaveAdminEventSourceInput struct {
- Map struct {
- MapID string `json:"mapId"`
- VersionID string `json:"versionId"`
- } `json:"map"`
- Playfield struct {
- PlayfieldID string `json:"playfieldId"`
- VersionID string `json:"versionId"`
- } `json:"playfield"`
- ResourcePack *struct {
- ResourcePackID string `json:"resourcePackId"`
- VersionID string `json:"versionId"`
- } `json:"resourcePack,omitempty"`
- GameModeCode string `json:"gameModeCode"`
- RouteCode *string `json:"routeCode,omitempty"`
- Overrides map[string]any `json:"overrides,omitempty"`
- Notes *string `json:"notes,omitempty"`
- }
- type AdminEventPresentationView struct {
- ID string `json:"id"`
- EventID string `json:"eventId"`
- Code string `json:"code"`
- Name string `json:"name"`
- PresentationType string `json:"presentationType"`
- Status string `json:"status"`
- IsDefault bool `json:"isDefault"`
- TemplateKey *string `json:"templateKey,omitempty"`
- Version *string `json:"version,omitempty"`
- SourceType *string `json:"sourceType,omitempty"`
- SchemaURL *string `json:"schemaUrl,omitempty"`
- Schema map[string]any `json:"schema"`
- }
- type CreateAdminEventPresentationInput struct {
- Code string `json:"code"`
- Name string `json:"name"`
- PresentationType string `json:"presentationType"`
- Status string `json:"status"`
- IsDefault bool `json:"isDefault"`
- Schema map[string]any `json:"schema,omitempty"`
- }
- type ImportAdminEventPresentationInput struct {
- Title string `json:"title"`
- TemplateKey string `json:"templateKey"`
- SourceType string `json:"sourceType"`
- SchemaURL string `json:"schemaUrl"`
- Version string `json:"version"`
- Status string `json:"status"`
- IsDefault bool `json:"isDefault"`
- }
- type AdminContentBundleView struct {
- ID string `json:"id"`
- EventID string `json:"eventId"`
- Code string `json:"code"`
- Name string `json:"name"`
- Status string `json:"status"`
- IsDefault bool `json:"isDefault"`
- BundleType *string `json:"bundleType,omitempty"`
- Version *string `json:"version,omitempty"`
- SourceType *string `json:"sourceType,omitempty"`
- ManifestURL *string `json:"manifestUrl,omitempty"`
- AssetManifest any `json:"assetManifest,omitempty"`
- EntryURL *string `json:"entryUrl,omitempty"`
- AssetRootURL *string `json:"assetRootUrl,omitempty"`
- Metadata map[string]any `json:"metadata"`
- }
- type CreateAdminContentBundleInput struct {
- Code string `json:"code"`
- Name string `json:"name"`
- Status string `json:"status"`
- IsDefault bool `json:"isDefault"`
- EntryURL *string `json:"entryUrl,omitempty"`
- AssetRootURL *string `json:"assetRootUrl,omitempty"`
- Metadata map[string]any `json:"metadata,omitempty"`
- }
- type ImportAdminContentBundleInput struct {
- Title string `json:"title"`
- BundleType string `json:"bundleType"`
- SourceType string `json:"sourceType"`
- ManifestURL string `json:"manifestUrl"`
- Version string `json:"version"`
- Status string `json:"status"`
- IsDefault bool `json:"isDefault"`
- AssetManifest map[string]any `json:"assetManifest,omitempty"`
- }
- type UpdateAdminEventDefaultsInput struct {
- PresentationID *string `json:"presentationId,omitempty"`
- ContentBundleID *string `json:"contentBundleId,omitempty"`
- RuntimeBindingID *string `json:"runtimeBindingId,omitempty"`
- }
- type AdminAssembledSource struct {
- Refs map[string]any `json:"refs"`
- Runtime map[string]any `json:"runtime"`
- Overrides map[string]any `json:"overrides,omitempty"`
- }
- func NewAdminEventService(store *postgres.Store) *AdminEventService {
- return &AdminEventService{store: store}
- }
- func (s *AdminEventService) ListEvents(ctx context.Context, limit int) ([]AdminEventSummary, error) {
- items, err := s.store.ListAdminEvents(ctx, limit)
- if err != nil {
- return nil, err
- }
- results := make([]AdminEventSummary, 0, len(items))
- for _, item := range items {
- results = append(results, buildAdminEventSummary(item))
- }
- return results, nil
- }
- func (s *AdminEventService) CreateEvent(ctx context.Context, input CreateAdminEventInput) (*AdminEventSummary, error) {
- input.Slug = strings.TrimSpace(input.Slug)
- input.DisplayName = strings.TrimSpace(input.DisplayName)
- if input.Slug == "" || input.DisplayName == "" {
- return nil, apperr.New(http.StatusBadRequest, "invalid_params", "slug and displayName are required")
- }
- var tenantID *string
- var tenantCode *string
- var tenantName *string
- if input.TenantCode != nil && strings.TrimSpace(*input.TenantCode) != "" {
- tenant, err := s.store.GetTenantByCode(ctx, strings.TrimSpace(*input.TenantCode))
- if err != nil {
- return nil, err
- }
- if tenant == nil {
- return nil, apperr.New(http.StatusNotFound, "tenant_not_found", "tenant not found")
- }
- tenantID = &tenant.ID
- tenantCode = &tenant.TenantCode
- tenantName = &tenant.Name
- }
- publicID, err := security.GeneratePublicID("evt")
- if err != nil {
- return nil, err
- }
- tx, err := s.store.Begin(ctx)
- if err != nil {
- return nil, err
- }
- defer tx.Rollback(ctx)
- item, err := s.store.CreateAdminEvent(ctx, tx, postgres.CreateAdminEventParams{
- PublicID: publicID,
- TenantID: tenantID,
- Slug: input.Slug,
- DisplayName: input.DisplayName,
- Summary: trimStringPtr(input.Summary),
- Status: normalizeEventCatalogStatus(input.Status),
- })
- if err != nil {
- return nil, err
- }
- if err := tx.Commit(ctx); err != nil {
- return nil, err
- }
- return &AdminEventSummary{
- ID: item.PublicID,
- TenantCode: tenantCode,
- TenantName: tenantName,
- Slug: item.Slug,
- DisplayName: item.DisplayName,
- Summary: item.Summary,
- Status: item.Status,
- }, nil
- }
- func (s *AdminEventService) UpdateEvent(ctx context.Context, eventPublicID string, input UpdateAdminEventInput) (*AdminEventSummary, error) {
- record, err := s.store.GetAdminEventByPublicID(ctx, strings.TrimSpace(eventPublicID))
- if err != nil {
- return nil, err
- }
- if record == nil {
- return nil, apperr.New(http.StatusNotFound, "event_not_found", "event not found")
- }
- input.Slug = strings.TrimSpace(input.Slug)
- input.DisplayName = strings.TrimSpace(input.DisplayName)
- if input.Slug == "" || input.DisplayName == "" {
- return nil, apperr.New(http.StatusBadRequest, "invalid_params", "slug and displayName are required")
- }
- var tenantID *string
- clearTenant := false
- if input.TenantCode != nil {
- if trimmed := strings.TrimSpace(*input.TenantCode); trimmed != "" {
- tenant, err := s.store.GetTenantByCode(ctx, trimmed)
- if err != nil {
- return nil, err
- }
- if tenant == nil {
- return nil, apperr.New(http.StatusNotFound, "tenant_not_found", "tenant not found")
- }
- tenantID = &tenant.ID
- } else {
- clearTenant = true
- }
- }
- tx, err := s.store.Begin(ctx)
- if err != nil {
- return nil, err
- }
- defer tx.Rollback(ctx)
- updated, err := s.store.UpdateAdminEvent(ctx, tx, postgres.UpdateAdminEventParams{
- EventID: record.ID,
- TenantID: tenantID,
- Slug: input.Slug,
- DisplayName: input.DisplayName,
- Summary: trimStringPtr(input.Summary),
- Status: normalizeEventCatalogStatus(input.Status),
- ClearTenant: clearTenant,
- })
- if err != nil {
- return nil, err
- }
- if err := tx.Commit(ctx); err != nil {
- return nil, err
- }
- refreshed, err := s.store.GetAdminEventByPublicID(ctx, updated.PublicID)
- if err != nil {
- return nil, err
- }
- if refreshed == nil {
- return nil, apperr.New(http.StatusNotFound, "event_not_found", "event not found")
- }
- summary := buildAdminEventSummary(*refreshed)
- return &summary, nil
- }
- func (s *AdminEventService) GetEventDetail(ctx context.Context, eventPublicID string) (*AdminEventDetail, error) {
- record, err := s.store.GetAdminEventByPublicID(ctx, strings.TrimSpace(eventPublicID))
- if err != nil {
- return nil, err
- }
- if record == nil {
- return nil, apperr.New(http.StatusNotFound, "event_not_found", "event not found")
- }
- sources, err := s.store.ListEventConfigSourcesByEventID(ctx, record.ID, 1)
- if err != nil {
- return nil, err
- }
- allSources, err := s.store.ListEventConfigSourcesByEventID(ctx, record.ID, 200)
- if err != nil {
- return nil, err
- }
- presentations, err := s.store.ListEventPresentationsByEventID(ctx, record.ID, 200)
- if err != nil {
- return nil, err
- }
- contentBundles, err := s.store.ListContentBundlesByEventID(ctx, record.ID, 200)
- if err != nil {
- return nil, err
- }
- result := &AdminEventDetail{
- Event: buildAdminEventSummary(*record),
- SourceCount: len(allSources),
- PresentationCount: len(presentations),
- ContentBundleCount: len(contentBundles),
- }
- if len(sources) > 0 {
- latest, err := buildEventConfigSourceView(&sources[0], record.PublicID)
- if err != nil {
- return nil, err
- }
- result.LatestSource = latest
- result.CurrentSource = buildAdminAssembledSource(latest.Source)
- }
- result.CurrentPresentation = buildPresentationSummaryFromEventRecord(record)
- result.CurrentContentBundle = buildContentBundleSummaryFromEventRecord(record)
- result.CurrentRuntime = buildRuntimeSummaryFromAdminEventRecord(record)
- if enrichedPresentation, err := loadPresentationSummaryByPublicID(ctx, s.store, record.CurrentPresentationID); err != nil {
- return nil, err
- } else if enrichedPresentation != nil {
- result.CurrentPresentation = enrichedPresentation
- }
- if enrichedBundle, err := loadContentBundleSummaryByPublicID(ctx, s.store, record.CurrentContentBundleID); err != nil {
- return nil, err
- } else if enrichedBundle != nil {
- result.CurrentContentBundle = enrichedBundle
- }
- return result, nil
- }
- func (s *AdminEventService) ListEventPresentations(ctx context.Context, eventPublicID string, limit int) ([]AdminEventPresentationView, error) {
- eventRecord, err := s.store.GetAdminEventByPublicID(ctx, strings.TrimSpace(eventPublicID))
- if err != nil {
- return nil, err
- }
- if eventRecord == nil {
- return nil, apperr.New(http.StatusNotFound, "event_not_found", "event not found")
- }
- items, err := s.store.ListEventPresentationsByEventID(ctx, eventRecord.ID, limit)
- if err != nil {
- return nil, err
- }
- result := make([]AdminEventPresentationView, 0, len(items))
- for _, item := range items {
- view, err := buildAdminEventPresentationView(item)
- if err != nil {
- return nil, err
- }
- result = append(result, view)
- }
- return result, nil
- }
- func (s *AdminEventService) CreateEventPresentation(ctx context.Context, eventPublicID string, input CreateAdminEventPresentationInput) (*AdminEventPresentationView, error) {
- eventRecord, err := s.store.GetAdminEventByPublicID(ctx, strings.TrimSpace(eventPublicID))
- if err != nil {
- return nil, err
- }
- if eventRecord == nil {
- return nil, apperr.New(http.StatusNotFound, "event_not_found", "event not found")
- }
- input.Code = strings.TrimSpace(input.Code)
- input.Name = strings.TrimSpace(input.Name)
- input.PresentationType = normalizePresentationType(input.PresentationType)
- if input.Code == "" || input.Name == "" {
- return nil, apperr.New(http.StatusBadRequest, "invalid_params", "code and name are required")
- }
- publicID, err := security.GeneratePublicID("pres")
- if err != nil {
- return nil, err
- }
- schema := input.Schema
- if schema == nil {
- schema = map[string]any{}
- }
- tx, err := s.store.Begin(ctx)
- if err != nil {
- return nil, err
- }
- defer tx.Rollback(ctx)
- record, err := s.store.CreateEventPresentation(ctx, tx, postgres.CreateEventPresentationParams{
- PublicID: publicID,
- EventID: eventRecord.ID,
- Code: input.Code,
- Name: input.Name,
- PresentationType: input.PresentationType,
- Status: normalizeEventCatalogStatus(input.Status),
- IsDefault: input.IsDefault,
- SchemaJSON: mustMarshalJSONObject(schema),
- })
- if err != nil {
- return nil, err
- }
- if err := tx.Commit(ctx); err != nil {
- return nil, err
- }
- view, err := buildAdminEventPresentationView(*record)
- if err != nil {
- return nil, err
- }
- return &view, nil
- }
- func (s *AdminEventService) ImportEventPresentation(ctx context.Context, eventPublicID string, input ImportAdminEventPresentationInput) (*AdminEventPresentationView, error) {
- eventRecord, err := s.store.GetAdminEventByPublicID(ctx, strings.TrimSpace(eventPublicID))
- if err != nil {
- return nil, err
- }
- if eventRecord == nil {
- return nil, apperr.New(http.StatusNotFound, "event_not_found", "event not found")
- }
- input.Title = strings.TrimSpace(input.Title)
- input.TemplateKey = strings.TrimSpace(input.TemplateKey)
- input.SourceType = strings.TrimSpace(input.SourceType)
- input.SchemaURL = strings.TrimSpace(input.SchemaURL)
- input.Version = strings.TrimSpace(input.Version)
- if input.Title == "" || input.TemplateKey == "" || input.SourceType == "" || input.SchemaURL == "" || input.Version == "" {
- return nil, apperr.New(http.StatusBadRequest, "invalid_params", "title, templateKey, sourceType, schemaUrl and version are required")
- }
- publicID, err := security.GeneratePublicID("pres")
- if err != nil {
- return nil, err
- }
- code := generateImportedPresentationCode(input.Title, publicID)
- status := normalizeEventCatalogStatus(input.Status)
- if strings.TrimSpace(input.Status) == "" {
- status = "active"
- }
- schema := map[string]any{
- "templateKey": input.TemplateKey,
- "sourceType": input.SourceType,
- "schemaUrl": input.SchemaURL,
- "version": input.Version,
- }
- tx, err := s.store.Begin(ctx)
- if err != nil {
- return nil, err
- }
- defer tx.Rollback(ctx)
- record, err := s.store.CreateEventPresentation(ctx, tx, postgres.CreateEventPresentationParams{
- PublicID: publicID,
- EventID: eventRecord.ID,
- Code: code,
- Name: input.Title,
- PresentationType: "generic",
- Status: status,
- IsDefault: input.IsDefault,
- SchemaJSON: mustMarshalJSONObject(schema),
- })
- if err != nil {
- return nil, err
- }
- if err := tx.Commit(ctx); err != nil {
- return nil, err
- }
- view, err := buildAdminEventPresentationView(*record)
- if err != nil {
- return nil, err
- }
- return &view, nil
- }
- func (s *AdminEventService) GetEventPresentation(ctx context.Context, presentationPublicID string) (*AdminEventPresentationView, error) {
- record, err := s.store.GetEventPresentationByPublicID(ctx, strings.TrimSpace(presentationPublicID))
- if err != nil {
- return nil, err
- }
- if record == nil {
- return nil, apperr.New(http.StatusNotFound, "presentation_not_found", "presentation not found")
- }
- view, err := buildAdminEventPresentationView(*record)
- if err != nil {
- return nil, err
- }
- return &view, nil
- }
- func (s *AdminEventService) ListContentBundles(ctx context.Context, eventPublicID string, limit int) ([]AdminContentBundleView, error) {
- eventRecord, err := s.store.GetAdminEventByPublicID(ctx, strings.TrimSpace(eventPublicID))
- if err != nil {
- return nil, err
- }
- if eventRecord == nil {
- return nil, apperr.New(http.StatusNotFound, "event_not_found", "event not found")
- }
- items, err := s.store.ListContentBundlesByEventID(ctx, eventRecord.ID, limit)
- if err != nil {
- return nil, err
- }
- result := make([]AdminContentBundleView, 0, len(items))
- for _, item := range items {
- view, err := buildAdminContentBundleView(item)
- if err != nil {
- return nil, err
- }
- result = append(result, view)
- }
- return result, nil
- }
- func (s *AdminEventService) CreateContentBundle(ctx context.Context, eventPublicID string, input CreateAdminContentBundleInput) (*AdminContentBundleView, error) {
- eventRecord, err := s.store.GetAdminEventByPublicID(ctx, strings.TrimSpace(eventPublicID))
- if err != nil {
- return nil, err
- }
- if eventRecord == nil {
- return nil, apperr.New(http.StatusNotFound, "event_not_found", "event not found")
- }
- input.Code = strings.TrimSpace(input.Code)
- input.Name = strings.TrimSpace(input.Name)
- if input.Code == "" || input.Name == "" {
- return nil, apperr.New(http.StatusBadRequest, "invalid_params", "code and name are required")
- }
- publicID, err := security.GeneratePublicID("bundle")
- if err != nil {
- return nil, err
- }
- metadata := input.Metadata
- if metadata == nil {
- metadata = map[string]any{}
- }
- tx, err := s.store.Begin(ctx)
- if err != nil {
- return nil, err
- }
- defer tx.Rollback(ctx)
- record, err := s.store.CreateContentBundle(ctx, tx, postgres.CreateContentBundleParams{
- PublicID: publicID,
- EventID: eventRecord.ID,
- Code: input.Code,
- Name: input.Name,
- Status: normalizeEventCatalogStatus(input.Status),
- IsDefault: input.IsDefault,
- EntryURL: trimStringPtr(input.EntryURL),
- AssetRootURL: trimStringPtr(input.AssetRootURL),
- MetadataJSON: mustMarshalJSONObject(metadata),
- })
- if err != nil {
- return nil, err
- }
- if err := tx.Commit(ctx); err != nil {
- return nil, err
- }
- view, err := buildAdminContentBundleView(*record)
- if err != nil {
- return nil, err
- }
- return &view, nil
- }
- func (s *AdminEventService) ImportContentBundle(ctx context.Context, eventPublicID string, input ImportAdminContentBundleInput) (*AdminContentBundleView, error) {
- eventRecord, err := s.store.GetAdminEventByPublicID(ctx, strings.TrimSpace(eventPublicID))
- if err != nil {
- return nil, err
- }
- if eventRecord == nil {
- return nil, apperr.New(http.StatusNotFound, "event_not_found", "event not found")
- }
- input.Title = strings.TrimSpace(input.Title)
- input.BundleType = strings.TrimSpace(input.BundleType)
- input.SourceType = strings.TrimSpace(input.SourceType)
- input.ManifestURL = strings.TrimSpace(input.ManifestURL)
- input.Version = strings.TrimSpace(input.Version)
- if input.Title == "" || input.BundleType == "" || input.SourceType == "" || input.ManifestURL == "" || input.Version == "" {
- return nil, apperr.New(http.StatusBadRequest, "invalid_params", "title, bundleType, sourceType, manifestUrl and version are required")
- }
- publicID, err := security.GeneratePublicID("bundle")
- if err != nil {
- return nil, err
- }
- code := generateImportedBundleCode(input.Title, publicID)
- assetManifest := input.AssetManifest
- if assetManifest == nil {
- assetManifest = map[string]any{
- "manifestUrl": input.ManifestURL,
- "sourceType": input.SourceType,
- }
- }
- metadata := map[string]any{
- "bundleType": input.BundleType,
- "sourceType": input.SourceType,
- "manifestUrl": input.ManifestURL,
- "version": input.Version,
- "assetManifest": assetManifest,
- }
- status := normalizeEventCatalogStatus(input.Status)
- if strings.TrimSpace(input.Status) == "" {
- status = "active"
- }
- tx, err := s.store.Begin(ctx)
- if err != nil {
- return nil, err
- }
- defer tx.Rollback(ctx)
- record, err := s.store.CreateContentBundle(ctx, tx, postgres.CreateContentBundleParams{
- PublicID: publicID,
- EventID: eventRecord.ID,
- Code: code,
- Name: input.Title,
- Status: status,
- IsDefault: input.IsDefault,
- EntryURL: nil,
- AssetRootURL: nil,
- MetadataJSON: mustMarshalJSONObject(metadata),
- })
- if err != nil {
- return nil, err
- }
- if err := tx.Commit(ctx); err != nil {
- return nil, err
- }
- view, err := buildAdminContentBundleView(*record)
- if err != nil {
- return nil, err
- }
- return &view, nil
- }
- func (s *AdminEventService) GetContentBundle(ctx context.Context, contentBundlePublicID string) (*AdminContentBundleView, error) {
- record, err := s.store.GetContentBundleByPublicID(ctx, strings.TrimSpace(contentBundlePublicID))
- if err != nil {
- return nil, err
- }
- if record == nil {
- return nil, apperr.New(http.StatusNotFound, "content_bundle_not_found", "content bundle not found")
- }
- view, err := buildAdminContentBundleView(*record)
- if err != nil {
- return nil, err
- }
- return &view, nil
- }
- func (s *AdminEventService) UpdateEventDefaults(ctx context.Context, eventPublicID string, input UpdateAdminEventDefaultsInput) (*AdminEventDetail, error) {
- record, err := s.store.GetAdminEventByPublicID(ctx, strings.TrimSpace(eventPublicID))
- if err != nil {
- return nil, err
- }
- if record == nil {
- return nil, apperr.New(http.StatusNotFound, "event_not_found", "event not found")
- }
- var presentationID *string
- updatePresentation := false
- if input.PresentationID != nil {
- updatePresentation = true
- trimmed := strings.TrimSpace(*input.PresentationID)
- if trimmed != "" {
- presentation, err := s.store.GetEventPresentationByPublicID(ctx, trimmed)
- if err != nil {
- return nil, err
- }
- if presentation == nil {
- return nil, apperr.New(http.StatusNotFound, "presentation_not_found", "presentation not found")
- }
- if presentation.EventID != record.ID {
- return nil, apperr.New(http.StatusConflict, "presentation_not_belong_to_event", "presentation does not belong to event")
- }
- presentationID = &presentation.ID
- }
- }
- var contentBundleID *string
- updateContent := false
- if input.ContentBundleID != nil {
- updateContent = true
- trimmed := strings.TrimSpace(*input.ContentBundleID)
- if trimmed != "" {
- contentBundle, err := s.store.GetContentBundleByPublicID(ctx, trimmed)
- if err != nil {
- return nil, err
- }
- if contentBundle == nil {
- return nil, apperr.New(http.StatusNotFound, "content_bundle_not_found", "content bundle not found")
- }
- if contentBundle.EventID != record.ID {
- return nil, apperr.New(http.StatusConflict, "content_bundle_not_belong_to_event", "content bundle does not belong to event")
- }
- contentBundleID = &contentBundle.ID
- }
- }
- var runtimeBindingID *string
- updateRuntime := false
- if input.RuntimeBindingID != nil {
- updateRuntime = true
- trimmed := strings.TrimSpace(*input.RuntimeBindingID)
- if trimmed != "" {
- runtimeBinding, err := s.store.GetMapRuntimeBindingByPublicID(ctx, trimmed)
- if err != nil {
- return nil, err
- }
- if runtimeBinding == nil {
- return nil, apperr.New(http.StatusNotFound, "runtime_binding_not_found", "runtime binding not found")
- }
- if runtimeBinding.EventID != record.ID {
- return nil, apperr.New(http.StatusConflict, "runtime_binding_not_belong_to_event", "runtime binding does not belong to event")
- }
- runtimeBindingID = &runtimeBinding.ID
- }
- }
- tx, err := s.store.Begin(ctx)
- if err != nil {
- return nil, err
- }
- defer tx.Rollback(ctx)
- if err := s.store.SetEventDefaultBindings(ctx, tx, postgres.SetEventDefaultBindingsParams{
- EventID: record.ID,
- PresentationID: presentationID,
- ContentBundleID: contentBundleID,
- RuntimeBindingID: runtimeBindingID,
- UpdatePresentation: updatePresentation,
- UpdateContent: updateContent,
- UpdateRuntime: updateRuntime,
- }); err != nil {
- return nil, err
- }
- if err := tx.Commit(ctx); err != nil {
- return nil, err
- }
- return s.GetEventDetail(ctx, eventPublicID)
- }
- func (s *AdminEventService) SaveEventSource(ctx context.Context, eventPublicID string, input SaveAdminEventSourceInput) (*EventConfigSourceView, error) {
- eventRecord, err := s.store.GetAdminEventByPublicID(ctx, strings.TrimSpace(eventPublicID))
- if err != nil {
- return nil, err
- }
- if eventRecord == nil {
- return nil, apperr.New(http.StatusNotFound, "event_not_found", "event not found")
- }
- input.GameModeCode = strings.TrimSpace(input.GameModeCode)
- input.Map.MapID = strings.TrimSpace(input.Map.MapID)
- input.Map.VersionID = strings.TrimSpace(input.Map.VersionID)
- input.Playfield.PlayfieldID = strings.TrimSpace(input.Playfield.PlayfieldID)
- input.Playfield.VersionID = strings.TrimSpace(input.Playfield.VersionID)
- if input.Map.MapID == "" || input.Map.VersionID == "" || input.Playfield.PlayfieldID == "" || input.Playfield.VersionID == "" || input.GameModeCode == "" {
- return nil, apperr.New(http.StatusBadRequest, "invalid_params", "map, playfield and gameModeCode are required")
- }
- mapVersion, err := s.store.GetResourceMapVersionByPublicID(ctx, input.Map.MapID, input.Map.VersionID)
- if err != nil {
- return nil, err
- }
- if mapVersion == nil {
- return nil, apperr.New(http.StatusNotFound, "map_version_not_found", "map version not found")
- }
- playfieldVersion, err := s.store.GetResourcePlayfieldVersionByPublicID(ctx, input.Playfield.PlayfieldID, input.Playfield.VersionID)
- if err != nil {
- return nil, err
- }
- if playfieldVersion == nil {
- return nil, apperr.New(http.StatusNotFound, "playfield_version_not_found", "playfield version not found")
- }
- var resourcePackVersion *postgres.ResourcePackVersion
- if input.ResourcePack != nil {
- input.ResourcePack.ResourcePackID = strings.TrimSpace(input.ResourcePack.ResourcePackID)
- input.ResourcePack.VersionID = strings.TrimSpace(input.ResourcePack.VersionID)
- if input.ResourcePack.ResourcePackID == "" || input.ResourcePack.VersionID == "" {
- return nil, apperr.New(http.StatusBadRequest, "invalid_params", "resourcePackId and versionId are required when resourcePack is provided")
- }
- resourcePackVersion, err = s.store.GetResourcePackVersionByPublicID(ctx, input.ResourcePack.ResourcePackID, input.ResourcePack.VersionID)
- if err != nil {
- return nil, err
- }
- if resourcePackVersion == nil {
- return nil, apperr.New(http.StatusNotFound, "resource_pack_version_not_found", "resource pack version not found")
- }
- }
- source := s.buildEventSource(eventRecord, mapVersion, playfieldVersion, resourcePackVersion, input)
- if err := validateSourceConfig(source); err != nil {
- return nil, err
- }
- nextVersion, err := s.store.NextEventConfigSourceVersion(ctx, eventRecord.ID)
- if err != nil {
- return nil, err
- }
- note := trimStringPtr(input.Notes)
- if note == nil {
- 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)
- note = &defaultNote
- }
- tx, err := s.store.Begin(ctx)
- if err != nil {
- return nil, err
- }
- defer tx.Rollback(ctx)
- record, err := s.store.UpsertEventConfigSource(ctx, tx, postgres.UpsertEventConfigSourceParams{
- EventID: eventRecord.ID,
- SourceVersionNo: nextVersion,
- SourceKind: "admin_assembled_bundle",
- SchemaID: "event-source",
- SchemaVersion: resolveSchemaVersion(source),
- Status: "active",
- Source: source,
- Notes: note,
- })
- if err != nil {
- return nil, err
- }
- if err := tx.Commit(ctx); err != nil {
- return nil, err
- }
- return buildEventConfigSourceView(record, eventRecord.PublicID)
- }
- func (s *AdminEventService) buildEventSource(event *postgres.AdminEventRecord, mapVersion *postgres.ResourceMapVersion, playfieldVersion *postgres.ResourcePlayfieldVersion, resourcePackVersion *postgres.ResourcePackVersion, input SaveAdminEventSourceInput) map[string]any {
- source := map[string]any{
- "schemaVersion": "1",
- "app": map[string]any{
- "id": event.PublicID,
- "title": event.DisplayName,
- },
- "refs": map[string]any{
- "map": map[string]any{
- "id": input.Map.MapID,
- "versionId": input.Map.VersionID,
- },
- "playfield": map[string]any{
- "id": input.Playfield.PlayfieldID,
- "versionId": input.Playfield.VersionID,
- },
- "gameMode": map[string]any{
- "code": input.GameModeCode,
- },
- },
- "map": map[string]any{
- "tiles": mapVersion.TilesRootURL,
- "mapmeta": mapVersion.MapmetaURL,
- },
- "playfield": map[string]any{
- "kind": "course",
- "source": map[string]any{
- "type": playfieldVersion.SourceType,
- "url": playfieldVersion.SourceURL,
- },
- },
- "game": map[string]any{
- "mode": input.GameModeCode,
- },
- }
- if event.Summary != nil && strings.TrimSpace(*event.Summary) != "" {
- source["summary"] = *event.Summary
- }
- if event.TenantCode != nil && strings.TrimSpace(*event.TenantCode) != "" {
- source["branding"] = map[string]any{
- "tenantCode": *event.TenantCode,
- }
- }
- if input.RouteCode != nil && strings.TrimSpace(*input.RouteCode) != "" {
- source["playfield"].(map[string]any)["metadata"] = map[string]any{
- "routeCode": strings.TrimSpace(*input.RouteCode),
- }
- }
- if resourcePackVersion != nil {
- source["refs"].(map[string]any)["resourcePack"] = map[string]any{
- "id": input.ResourcePack.ResourcePackID,
- "versionId": input.ResourcePack.VersionID,
- }
- resources := map[string]any{}
- assets := map[string]any{}
- if resourcePackVersion.ThemeProfileCode != nil && strings.TrimSpace(*resourcePackVersion.ThemeProfileCode) != "" {
- resources["themeProfile"] = *resourcePackVersion.ThemeProfileCode
- }
- if resourcePackVersion.ContentEntryURL != nil && strings.TrimSpace(*resourcePackVersion.ContentEntryURL) != "" {
- assets["contentHtml"] = *resourcePackVersion.ContentEntryURL
- }
- if resourcePackVersion.AudioRootURL != nil && strings.TrimSpace(*resourcePackVersion.AudioRootURL) != "" {
- resources["audioRoot"] = *resourcePackVersion.AudioRootURL
- }
- if len(resources) > 0 {
- source["resources"] = resources
- }
- if len(assets) > 0 {
- source["assets"] = assets
- }
- }
- if len(input.Overrides) > 0 {
- source["overrides"] = input.Overrides
- mergeJSONObject(source, input.Overrides)
- }
- return source
- }
- func buildAdminEventSummary(item postgres.AdminEventRecord) AdminEventSummary {
- summary := AdminEventSummary{
- ID: item.PublicID,
- TenantCode: item.TenantCode,
- TenantName: item.TenantName,
- Slug: item.Slug,
- DisplayName: item.DisplayName,
- Summary: item.Summary,
- Status: item.Status,
- }
- if item.CurrentReleasePubID != nil {
- summary.CurrentRelease = &AdminEventReleaseRef{
- ID: *item.CurrentReleasePubID,
- ConfigLabel: item.ConfigLabel,
- ManifestURL: item.ManifestURL,
- ManifestChecksumSha256: item.ManifestChecksum,
- RouteCode: item.RouteCode,
- Presentation: buildPresentationSummaryFromEventRecord(&item),
- ContentBundle: buildContentBundleSummaryFromEventRecord(&item),
- }
- }
- return summary
- }
- func buildPresentationSummaryFromEventRecord(item *postgres.AdminEventRecord) *PresentationSummaryView {
- if item == nil || item.CurrentPresentationID == nil {
- return nil
- }
- return &PresentationSummaryView{
- PresentationID: *item.CurrentPresentationID,
- Name: item.CurrentPresentationName,
- PresentationType: item.CurrentPresentationType,
- }
- }
- func buildContentBundleSummaryFromEventRecord(item *postgres.AdminEventRecord) *ContentBundleSummaryView {
- if item == nil || item.CurrentContentBundleID == nil {
- return nil
- }
- return &ContentBundleSummaryView{
- ContentBundleID: *item.CurrentContentBundleID,
- Name: item.CurrentContentBundleName,
- EntryURL: item.CurrentContentEntryURL,
- AssetRootURL: item.CurrentContentAssetRootURL,
- }
- }
- func buildRuntimeSummaryFromAdminEventRecord(item *postgres.AdminEventRecord) *RuntimeSummaryView {
- if item == nil ||
- item.CurrentRuntimeBindingID == nil ||
- item.CurrentPlaceID == nil ||
- item.CurrentMapAssetID == nil ||
- item.CurrentTileReleaseID == nil ||
- item.CurrentCourseSetID == nil ||
- item.CurrentCourseVariantID == nil {
- return nil
- }
- return &RuntimeSummaryView{
- RuntimeBindingID: *item.CurrentRuntimeBindingID,
- PlaceID: *item.CurrentPlaceID,
- MapID: *item.CurrentMapAssetID,
- TileReleaseID: *item.CurrentTileReleaseID,
- CourseSetID: *item.CurrentCourseSetID,
- CourseVariantID: *item.CurrentCourseVariantID,
- CourseVariantName: item.CurrentCourseVariantName,
- RouteCode: item.CurrentRuntimeRouteCode,
- }
- }
- func buildAdminEventPresentationView(item postgres.EventPresentation) (AdminEventPresentationView, error) {
- schema, err := decodeJSONObject(item.SchemaJSON)
- if err != nil {
- return AdminEventPresentationView{}, err
- }
- return AdminEventPresentationView{
- ID: item.PublicID,
- EventID: item.EventPublicID,
- Code: item.Code,
- Name: item.Name,
- PresentationType: item.PresentationType,
- Status: item.Status,
- IsDefault: item.IsDefault,
- TemplateKey: readStringField(schema, "templateKey"),
- Version: readStringField(schema, "version"),
- SourceType: readStringField(schema, "sourceType"),
- SchemaURL: readStringField(schema, "schemaUrl"),
- Schema: schema,
- }, nil
- }
- func buildAdminContentBundleView(item postgres.ContentBundle) (AdminContentBundleView, error) {
- metadata, err := decodeJSONObject(item.MetadataJSON)
- if err != nil {
- return AdminContentBundleView{}, err
- }
- return AdminContentBundleView{
- ID: item.PublicID,
- EventID: item.EventPublicID,
- Code: item.Code,
- Name: item.Name,
- Status: item.Status,
- IsDefault: item.IsDefault,
- BundleType: readStringField(metadata, "bundleType"),
- Version: readStringField(metadata, "version"),
- SourceType: readStringField(metadata, "sourceType"),
- ManifestURL: readStringField(metadata, "manifestUrl"),
- AssetManifest: metadata["assetManifest"],
- EntryURL: item.EntryURL,
- AssetRootURL: item.AssetRootURL,
- Metadata: metadata,
- }, nil
- }
- func generateImportedBundleCode(title, publicID string) string {
- var builder strings.Builder
- for _, r := range strings.ToLower(title) {
- switch {
- case r >= 'a' && r <= 'z':
- builder.WriteRune(r)
- case r >= '0' && r <= '9':
- builder.WriteRune(r)
- case r == ' ' || r == '-' || r == '_':
- if builder.Len() == 0 {
- continue
- }
- last := builder.String()[builder.Len()-1]
- if last != '-' {
- builder.WriteByte('-')
- }
- }
- }
- code := strings.Trim(builder.String(), "-")
- if code == "" {
- code = "bundle"
- }
- suffix := publicID
- if len(suffix) > 8 {
- suffix = suffix[len(suffix)-8:]
- }
- return code + "-" + suffix
- }
- func generateImportedPresentationCode(title, publicID string) string {
- var builder strings.Builder
- for _, r := range strings.ToLower(title) {
- switch {
- case r >= 'a' && r <= 'z':
- builder.WriteRune(r)
- case r >= '0' && r <= '9':
- builder.WriteRune(r)
- case r == ' ' || r == '-' || r == '_':
- if builder.Len() == 0 {
- continue
- }
- last := builder.String()[builder.Len()-1]
- if last != '-' {
- builder.WriteByte('-')
- }
- }
- }
- code := strings.Trim(builder.String(), "-")
- if code == "" {
- code = "presentation"
- }
- suffix := publicID
- if len(suffix) > 8 {
- suffix = suffix[len(suffix)-8:]
- }
- return code + "-" + suffix
- }
- func buildAdminAssembledSource(source map[string]any) *AdminAssembledSource {
- result := &AdminAssembledSource{}
- if refs, ok := source["refs"].(map[string]any); ok {
- result.Refs = refs
- }
- runtime := cloneJSONObject(source)
- delete(runtime, "refs")
- delete(runtime, "overrides")
- if overrides, ok := source["overrides"].(map[string]any); ok && len(overrides) > 0 {
- result.Overrides = overrides
- }
- result.Runtime = runtime
- return result
- }
- func normalizeEventCatalogStatus(value string) string {
- switch strings.TrimSpace(value) {
- case "active":
- return "active"
- case "disabled":
- return "disabled"
- case "archived":
- return "archived"
- default:
- return "draft"
- }
- }
- func normalizePresentationType(value string) string {
- switch strings.TrimSpace(value) {
- case "card":
- return "card"
- case "detail":
- return "detail"
- case "h5":
- return "h5"
- case "result":
- return "result"
- default:
- return "generic"
- }
- }
- func mustMarshalJSONObject(value map[string]any) string {
- raw, _ := json.Marshal(value)
- return string(raw)
- }
- func mergeJSONObject(target map[string]any, overrides map[string]any) {
- for key, value := range overrides {
- if valueMap, ok := value.(map[string]any); ok {
- existing, ok := target[key].(map[string]any)
- if !ok {
- existing = map[string]any{}
- target[key] = existing
- }
- mergeJSONObject(existing, valueMap)
- continue
- }
- target[key] = value
- }
- }
|