package postgres import ( "context" "errors" "fmt" "github.com/jackc/pgx/v5" ) type EventPresentation struct { ID string PublicID string EventID string EventPublicID string Code string Name string PresentationType string Status string IsDefault bool SchemaJSON string CreatedAt string UpdatedAt string } type ContentBundle struct { ID string PublicID string EventID string EventPublicID string Code string Name string Status string IsDefault bool EntryURL *string AssetRootURL *string MetadataJSON string CreatedAt string UpdatedAt string } type CreateEventPresentationParams struct { PublicID string EventID string Code string Name string PresentationType string Status string IsDefault bool SchemaJSON string } type CreateContentBundleParams struct { PublicID string EventID string Code string Name string Status string IsDefault bool EntryURL *string AssetRootURL *string MetadataJSON string } type EventDefaultBindings struct { EventID string EventPublicID string PresentationID *string PresentationPublicID *string PresentationName *string PresentationType *string ContentBundleID *string ContentBundlePublicID *string ContentBundleName *string ContentEntryURL *string ContentAssetRootURL *string RuntimeBindingID *string RuntimeBindingPublicID *string PlacePublicID *string PlaceName *string MapAssetPublicID *string MapAssetName *string TileReleasePublicID *string CourseSetPublicID *string CourseVariantPublicID *string CourseVariantName *string RuntimeRouteCode *string } type SetEventDefaultBindingsParams struct { EventID string PresentationID *string ContentBundleID *string RuntimeBindingID *string UpdatePresentation bool UpdateContent bool UpdateRuntime bool } func (s *Store) ListEventPresentationsByEventID(ctx context.Context, eventID string, limit int) ([]EventPresentation, error) { if limit <= 0 || limit > 200 { limit = 50 } rows, err := s.pool.Query(ctx, ` SELECT ep.id, ep.presentation_public_id, ep.event_id, e.event_public_id, ep.code, ep.name, ep.presentation_type, ep.status, ep.is_default, ep.schema_jsonb::text, ep.created_at::text, ep.updated_at::text FROM event_presentations ep JOIN events e ON e.id = ep.event_id WHERE ep.event_id = $1 ORDER BY ep.is_default DESC, ep.created_at DESC LIMIT $2 `, eventID, limit) if err != nil { return nil, fmt.Errorf("list event presentations: %w", err) } defer rows.Close() items := []EventPresentation{} for rows.Next() { item, err := scanEventPresentationFromRows(rows) if err != nil { return nil, err } items = append(items, *item) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("iterate event presentations: %w", err) } return items, nil } func (s *Store) GetEventPresentationByPublicID(ctx context.Context, publicID string) (*EventPresentation, error) { row := s.pool.QueryRow(ctx, ` SELECT ep.id, ep.presentation_public_id, ep.event_id, e.event_public_id, ep.code, ep.name, ep.presentation_type, ep.status, ep.is_default, ep.schema_jsonb::text, ep.created_at::text, ep.updated_at::text FROM event_presentations ep JOIN events e ON e.id = ep.event_id WHERE ep.presentation_public_id = $1 LIMIT 1 `, publicID) return scanEventPresentation(row) } func (s *Store) GetDefaultEventPresentationByEventID(ctx context.Context, eventID string) (*EventPresentation, error) { row := s.pool.QueryRow(ctx, ` SELECT ep.id, ep.presentation_public_id, ep.event_id, e.event_public_id, ep.code, ep.name, ep.presentation_type, ep.status, ep.is_default, ep.schema_jsonb::text, ep.created_at::text, ep.updated_at::text FROM event_presentations ep JOIN events e ON e.id = ep.event_id WHERE ep.event_id = $1 AND ep.status = 'active' ORDER BY ep.is_default DESC, ep.updated_at DESC, ep.created_at DESC LIMIT 1 `, eventID) return scanEventPresentation(row) } func (s *Store) CreateEventPresentation(ctx context.Context, tx Tx, params CreateEventPresentationParams) (*EventPresentation, error) { row := tx.QueryRow(ctx, ` INSERT INTO event_presentations ( presentation_public_id, event_id, code, name, presentation_type, status, is_default, schema_jsonb ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb) RETURNING id, presentation_public_id, event_id, code, name, presentation_type, status, is_default, schema_jsonb::text, created_at::text, updated_at::text `, params.PublicID, params.EventID, params.Code, params.Name, params.PresentationType, params.Status, params.IsDefault, params.SchemaJSON) var item EventPresentation if err := row.Scan( &item.ID, &item.PublicID, &item.EventID, &item.Code, &item.Name, &item.PresentationType, &item.Status, &item.IsDefault, &item.SchemaJSON, &item.CreatedAt, &item.UpdatedAt, ); err != nil { return nil, fmt.Errorf("create event presentation: %w", err) } return &item, nil } func (s *Store) GetEventDefaultBindingsByEventID(ctx context.Context, eventID string) (*EventDefaultBindings, error) { row := s.pool.QueryRow(ctx, ` SELECT e.id, e.event_public_id, e.current_presentation_id, ep.presentation_public_id, ep.name, ep.presentation_type, e.current_content_bundle_id, cb.content_bundle_public_id, cb.name, cb.entry_url, cb.asset_root_url, e.current_runtime_binding_id, mrb.runtime_binding_public_id, p.place_public_id, p.name, ma.map_asset_public_id, ma.name, tr.tile_release_public_id, cset.course_set_public_id, cv.course_variant_public_id, cv.name, cv.route_code FROM events e LEFT JOIN event_presentations ep ON ep.id = e.current_presentation_id LEFT JOIN content_bundles cb ON cb.id = e.current_content_bundle_id LEFT JOIN map_runtime_bindings mrb ON mrb.id = e.current_runtime_binding_id LEFT JOIN places p ON p.id = mrb.place_id LEFT JOIN map_assets ma ON ma.id = mrb.map_asset_id LEFT JOIN tile_releases tr ON tr.id = mrb.tile_release_id LEFT JOIN course_sets cset ON cset.id = mrb.course_set_id LEFT JOIN course_variants cv ON cv.id = mrb.course_variant_id WHERE e.id = $1 LIMIT 1 `, eventID) return scanEventDefaultBindings(row) } func (s *Store) SetEventDefaultBindings(ctx context.Context, tx Tx, params SetEventDefaultBindingsParams) error { if _, err := tx.Exec(ctx, ` UPDATE events SET current_presentation_id = CASE WHEN $5 THEN $2 ELSE current_presentation_id END, current_content_bundle_id = CASE WHEN $6 THEN $3 ELSE current_content_bundle_id END, current_runtime_binding_id = CASE WHEN $7 THEN $4 ELSE current_runtime_binding_id END WHERE id = $1 `, params.EventID, params.PresentationID, params.ContentBundleID, params.RuntimeBindingID, params.UpdatePresentation, params.UpdateContent, params.UpdateRuntime); err != nil { return fmt.Errorf("set event default bindings: %w", err) } return nil } func (s *Store) ListContentBundlesByEventID(ctx context.Context, eventID string, limit int) ([]ContentBundle, error) { if limit <= 0 || limit > 200 { limit = 50 } rows, err := s.pool.Query(ctx, ` SELECT cb.id, cb.content_bundle_public_id, cb.event_id, e.event_public_id, cb.code, cb.name, cb.status, cb.is_default, cb.entry_url, cb.asset_root_url, cb.metadata_jsonb::text, cb.created_at::text, cb.updated_at::text FROM content_bundles cb JOIN events e ON e.id = cb.event_id WHERE cb.event_id = $1 ORDER BY cb.is_default DESC, cb.created_at DESC LIMIT $2 `, eventID, limit) if err != nil { return nil, fmt.Errorf("list content bundles: %w", err) } defer rows.Close() items := []ContentBundle{} for rows.Next() { item, err := scanContentBundleFromRows(rows) if err != nil { return nil, err } items = append(items, *item) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("iterate content bundles: %w", err) } return items, nil } func (s *Store) GetContentBundleByPublicID(ctx context.Context, publicID string) (*ContentBundle, error) { row := s.pool.QueryRow(ctx, ` SELECT cb.id, cb.content_bundle_public_id, cb.event_id, e.event_public_id, cb.code, cb.name, cb.status, cb.is_default, cb.entry_url, cb.asset_root_url, cb.metadata_jsonb::text, cb.created_at::text, cb.updated_at::text FROM content_bundles cb JOIN events e ON e.id = cb.event_id WHERE cb.content_bundle_public_id = $1 LIMIT 1 `, publicID) return scanContentBundle(row) } func (s *Store) GetDefaultContentBundleByEventID(ctx context.Context, eventID string) (*ContentBundle, error) { row := s.pool.QueryRow(ctx, ` SELECT cb.id, cb.content_bundle_public_id, cb.event_id, e.event_public_id, cb.code, cb.name, cb.status, cb.is_default, cb.entry_url, cb.asset_root_url, cb.metadata_jsonb::text, cb.created_at::text, cb.updated_at::text FROM content_bundles cb JOIN events e ON e.id = cb.event_id WHERE cb.event_id = $1 AND cb.status = 'active' ORDER BY cb.is_default DESC, cb.updated_at DESC, cb.created_at DESC LIMIT 1 `, eventID) return scanContentBundle(row) } func (s *Store) CreateContentBundle(ctx context.Context, tx Tx, params CreateContentBundleParams) (*ContentBundle, error) { row := tx.QueryRow(ctx, ` INSERT INTO content_bundles ( content_bundle_public_id, event_id, code, name, status, is_default, entry_url, asset_root_url, metadata_jsonb ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb) RETURNING id, content_bundle_public_id, event_id, code, name, status, is_default, entry_url, asset_root_url, metadata_jsonb::text, created_at::text, updated_at::text `, params.PublicID, params.EventID, params.Code, params.Name, params.Status, params.IsDefault, params.EntryURL, params.AssetRootURL, params.MetadataJSON) var item ContentBundle if err := row.Scan( &item.ID, &item.PublicID, &item.EventID, &item.Code, &item.Name, &item.Status, &item.IsDefault, &item.EntryURL, &item.AssetRootURL, &item.MetadataJSON, &item.CreatedAt, &item.UpdatedAt, ); err != nil { return nil, fmt.Errorf("create content bundle: %w", err) } return &item, nil } func scanEventPresentation(row pgx.Row) (*EventPresentation, error) { var item EventPresentation err := row.Scan( &item.ID, &item.PublicID, &item.EventID, &item.EventPublicID, &item.Code, &item.Name, &item.PresentationType, &item.Status, &item.IsDefault, &item.SchemaJSON, &item.CreatedAt, &item.UpdatedAt, ) if errors.Is(err, pgx.ErrNoRows) { return nil, nil } if err != nil { return nil, fmt.Errorf("scan event presentation: %w", err) } return &item, nil } func scanEventPresentationFromRows(rows pgx.Rows) (*EventPresentation, error) { var item EventPresentation if err := rows.Scan( &item.ID, &item.PublicID, &item.EventID, &item.EventPublicID, &item.Code, &item.Name, &item.PresentationType, &item.Status, &item.IsDefault, &item.SchemaJSON, &item.CreatedAt, &item.UpdatedAt, ); err != nil { return nil, fmt.Errorf("scan event presentation row: %w", err) } return &item, nil } func scanContentBundle(row pgx.Row) (*ContentBundle, error) { var item ContentBundle err := row.Scan( &item.ID, &item.PublicID, &item.EventID, &item.EventPublicID, &item.Code, &item.Name, &item.Status, &item.IsDefault, &item.EntryURL, &item.AssetRootURL, &item.MetadataJSON, &item.CreatedAt, &item.UpdatedAt, ) if errors.Is(err, pgx.ErrNoRows) { return nil, nil } if err != nil { return nil, fmt.Errorf("scan content bundle: %w", err) } return &item, nil } func scanContentBundleFromRows(rows pgx.Rows) (*ContentBundle, error) { var item ContentBundle if err := rows.Scan( &item.ID, &item.PublicID, &item.EventID, &item.EventPublicID, &item.Code, &item.Name, &item.Status, &item.IsDefault, &item.EntryURL, &item.AssetRootURL, &item.MetadataJSON, &item.CreatedAt, &item.UpdatedAt, ); err != nil { return nil, fmt.Errorf("scan content bundle row: %w", err) } return &item, nil } func scanEventDefaultBindings(row pgx.Row) (*EventDefaultBindings, error) { var item EventDefaultBindings err := row.Scan( &item.EventID, &item.EventPublicID, &item.PresentationID, &item.PresentationPublicID, &item.PresentationName, &item.PresentationType, &item.ContentBundleID, &item.ContentBundlePublicID, &item.ContentBundleName, &item.ContentEntryURL, &item.ContentAssetRootURL, &item.RuntimeBindingID, &item.RuntimeBindingPublicID, &item.PlacePublicID, &item.PlaceName, &item.MapAssetPublicID, &item.MapAssetName, &item.TileReleasePublicID, &item.CourseSetPublicID, &item.CourseVariantPublicID, &item.CourseVariantName, &item.RuntimeRouteCode, ) if errors.Is(err, pgx.ErrNoRows) { return nil, nil } if err != nil { return nil, fmt.Errorf("scan event default bindings: %w", err) } return &item, nil }