Files
cmr-mini/backend/internal/service/admin_event_service.go

1174 lines
38 KiB
Go

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
}
}