完善活动运营域与联调标准化

This commit is contained in:
2026-04-03 13:11:41 +08:00
parent 0e28f70bad
commit 129ea935db
56 changed files with 11004 additions and 196 deletions

View File

@@ -1,6 +1,6 @@
# Backend
> 文档版本v1.1
> 最后更新2026-04-02 09:35:44
> 文档版本v1.11
> 最后更新2026-04-03 13:04:32
这套后端现在已经能支撑一条完整主链:
@@ -34,7 +34,7 @@
```powershell
cd D:\dev\cmr-mini\backend
go run .\cmd\api
.\start-backend.ps1
```
## 当前重点
@@ -45,9 +45,23 @@ go run .\cmd\api
- 配置驱动启动:`/events/{id}/play``/events/{id}/launch`
- 局生命周期:`start / finish / detail`
- 局后结果:`/sessions/{id}/result``/me/results`
- 第一阶段生产骨架:`places / map-assets / tile-releases / course-sources / course-sets / course-variants / runtime-bindings`
- 第三刀最小接线:`runtimeBinding -> eventRelease -> launch.runtime`
- 第四刀发布闭环:`publish(runtimeBindingId) -> eventRelease -> launch.runtime`
- 活动运营域第二阶段:`event_presentations / content_bundles / event_release -> presentation,bundle,runtime`
- 活动运营域第二阶段第二刀:`event detail / event play / launch -> presentation,bundle 摘要`
- 活动运营域第二阶段第三刀:`release 摘要闭环 + content bundle import`
- 活动运营域第二阶段第四刀:`presentation import + event 默认 active 绑定 + publish 默认继承`
- 开发工作台:`/dev/workbench`
- 用户主链调试
- 资源对象与 Event 组装调试
- Build / Publish / Rollback 调试
- Release / RuntimeBinding 最小挂接验证
- Event Presentation / Content Bundle 最小挂接验证
- Content Bundle Import 最小导入验证
- Presentation Import / Event 默认绑定 / Publish 默认继承验证
- Runtime 自动补齐 + 默认绑定发布一键验证
- Bootstrap Demo 自动回填最小生产骨架 ID
- 一键测试环境:可从空白状态自动准备 demo event、source/build/release、presentation、content bundle、place、map asset、tile release、course source、course set、course variant、runtime binding并输出逐步日志与预期判定

View File

@@ -1,6 +1,6 @@
# 后台管理最小方案
> 文档版本v1.0
> 最后更新2026-04-02 09:01:17
> 文档版本v1.1
> 最后更新2026-04-03 11:02:42
## 1. 目标
@@ -132,6 +132,8 @@
- 当前选用资源包版本
- 当前玩法模式
- 少量覆盖项
- 展示定义(`EventPresentation`
- 内容包(`ContentBundle`
第一版只开放少量覆盖项:
@@ -160,6 +162,7 @@
- build 状态
- release 列表
- 当前生效 release
- 当前绑定的 `presentation / bundle / runtime`
- 发布人
- 发布时间
@@ -169,6 +172,7 @@
- 查看 build 产物
- 发布 build
- 回滚当前 release
- 查看 release 当前绑定的 `presentation / bundle / runtime`
## 4. 后台第一版页面建议
@@ -183,6 +187,14 @@
这 6 页够把“资源录入 -> Event 组装 -> 发布 -> launch”跑通。
补充:
- 当前第二阶段已经把 `EventPresentation``ContentBundle` 收成正式最小对象
- `EventRelease` 现在允许同时绑定:
- `presentation`
- `content bundle`
- `runtime binding`
## 5. 对象模型建议
后台第一版建议围绕这些对象展开:

View File

@@ -1,6 +1,6 @@
# 开发说明
> 文档版本v1.1
> 最后更新2026-04-02 09:35:44
> 文档版本v1.12
> 最后更新2026-04-03 13:04:32
## 1. 环境变量
@@ -29,7 +29,7 @@
```powershell
cd D:\dev\cmr-mini\backend
go run .\cmd\api
.\start-backend.ps1
```
如果你想固定跑开发工作台常用端口 `18090`,直接执行:
@@ -55,6 +55,55 @@ cd D:\dev\cmr-mini\backend
- 用户主链:`bootstrap -> auth -> entry/home -> event play/launch -> session -> result`
- 后台运营链:`maps/playfields/resource-packs -> admin event source -> build -> publish -> rollback`
- 第一阶段生产骨架联调台:`places -> map-assets -> tile-releases -> course-sources -> course-sets -> course-variants -> runtime-bindings`
- 第三刀最小接线验证:`runtimeBinding -> release -> launch.runtime`
- 第四刀发布闭环验证:`runtimeBinding -> publish(runtimeBindingId) -> release -> launch.runtime`
- 活动运营域第二阶段验证:`presentation -> content bundle -> publish(presentationId, contentBundleId, runtimeBindingId) -> release`
- 活动运营域第二阶段第二刀验证:`event detail / play / launch -> presentation + content bundle 摘要`
- 活动运营域第二阶段第三刀验证:`release 摘要闭环 + content bundle import`
- 活动运营域第二阶段第四刀验证:`presentation import -> event 默认 active 绑定 -> publish 空参继承`
- workbench 一键验证增强:`一键默认绑定发布``一键补齐 Runtime 并发布`
- `/dev/bootstrap-demo` 现在也会回填最小生产骨架:`place / map asset / tile release / course source / course set / course variant / runtime binding`
### 2.1 当前推荐验证方式
如果目标是验证“从测试数据准备到 release 继承是否完整”,优先使用 workbench 的一键流,而不是手工逐个点按钮。
当前推荐顺序:
1. `Bootstrap Demo`
2. `一键补齐 Runtime 并发布`
当前这条一键链会自动完成:
- demo event / source / build / release 准备
- presentation 导入
- content bundle 导入
- event 默认 active 绑定保存
- 最小生产骨架准备:
- `place`
- `map asset`
- `tile release`
- `course source`
- `course set`
- `course variant`
- `runtime binding`
- publish
- release 回读校验
当前日志能力:
- 每一步都会写到“响应日志”
- 失败时会直接输出:
- 错误消息
- stack
- 最后一次 curl
- 成功时“预期结果”面板会直接给出:
- `Release ID`
- `Presentation`
- `Content Bundle`
- `Runtime Binding`
- `判定`
## 3. 当前开发约定
@@ -134,6 +183,7 @@ Redis 后面只在需要性能优化、限流或短期票据缓存时再接。
- 入口解析
- 首页聚合
- event play
- 第一阶段生产骨架对象
- 配置导入、preview build、publish build
- launch
- session start / finish
@@ -144,6 +194,11 @@ Redis 后面只在需要性能优化、限流或短期票据缓存时再接。
- `publish build` 现在会真实上传 `manifest.json``asset-index.json` 到 OSS
- 如果上传失败,接口会直接报错,不再出现“数据库里已有 release但 OSS 上没有对象”的假成功
- `Save Event Defaults` 会把当前 event 的默认 active 绑定写入:
- `currentPresentationId`
- `currentContentBundleId`
- `currentRuntimeBindingId`
- 之后 `Publish Build` 如果不显式填写这三项,会优先继承 event 默认 active 绑定
并且支持:
@@ -152,6 +207,29 @@ Redis 后面只在需要性能优化、限流或短期票据缓存时再接。
- curl 导出
- request history
当前第一阶段生产骨架联调台只做:
- `list`
- `create`
- `detail`
- `binding`
明确不做:
- 正式后台 UI
- `edit`
- `delete`
- `batch`
- 审核流
活动运营域第二阶段当前也只做最小动作:
- `list`
- `create`
- `detail`
- `publish 绑定`
- `import`
## 6. 当前推荐联调顺序
### 场景一:小程序快速进入
@@ -190,6 +268,164 @@ Redis 后面只在需要性能优化、限流或短期票据缓存时再接。
5. `events/{id}`
6. `events/{id}/launch`
### 场景五:第一阶段生产骨架最小闭环
`/dev/workbench``后台运营` 模式中,按下面顺序操作:
1. `List Places``Create Place`
2. 在该 `Place``Create Map Asset`
3. 在该 `MapAsset``Create Tile Release`
4. `Create Course Source`
5. 在该 `MapAsset``Create Course Set`
6. 在该 `CourseSet``Create Variant`
7. `Create Runtime Binding`
成功后应能拿到这些 ID
- `placeId`
- `mapAssetId`
- `tileReleaseId`
- `courseSourceId`
- `courseSetId`
- `courseVariantId`
- `runtimeBindingId`
建议第一次联调时用这组最小规则:
- `Place` 先建 1 个
- 每个 `Place` 先只建 1 个 `MapAsset`
- 每个 `MapAsset` 先只建 1 个 `TileRelease`
- 每个 `CourseSet` 先只建 1 个默认 `CourseVariant`
- `RuntimeBinding` 先只绑定当前正在验证的 `Event`
这条链当前只验证对象关系闭环,不验证:
- 发布链切换
- `launch` 返回运行对象字段
- `EventPresentation`
- `ContentBundle`
### 场景六:第三刀最小接线验证
`/dev/workbench``后台运营` 模式中,先完成“场景五”,再按下面顺序操作:
1. `Get Pipeline`
2. 确认当前 `Release ID`
3. 填或复用 `Runtime Binding ID`
4. `Bind Runtime`
5. `Get Release`
6. 切回 `前台联调`
7. 对同一个 `event` 执行 `Launch`
### 场景七:活动运营域第二阶段最小闭环
`/dev/workbench``后台运营` 模式中,按下面顺序操作:
1. `Get Event`
2. `Create Presentation`
3. `Create Bundle`
4. `Assemble Source`
5. `Build Source`
6. 在发布区填:
- `Runtime Binding ID`
- `Presentation ID`
- `Content Bundle ID`
7. `Publish Build`
8. `Get Release`
成功后应能在 release 返回中看到:
- `runtime`
- `presentation`
- `contentBundle`
并且这 3 类绑定当前都已固化到 `event_release`
成功后应能看到:
- `GET /admin/releases/{releasePublicID}` 返回 `runtime`
- `POST /events/{eventPublicID}/launch` 返回 `launch.runtime`
当前阶段的约束是:
- 只新增 `runtime` 字段块
- 不改旧的:
- `resolvedRelease`
- `business`
- `variant`
- release 如果没挂 `runtimeBindingId`,则 `launch.runtime` 为空
### 场景八:活动运营域第二阶段第三刀验证
`/dev/workbench``后台运营` 模式中,先完成“场景七”,再按下面顺序操作:
1. `Create Presentation` 或直接复用现有 `Presentation ID`
2. `Import Bundle`
3. `Get Bundle`
4. `Get Pipeline`
5. `Publish Build`
6. `Get Release`
7. 切回 `前台联调`
8. `Event Detail`
9. `Event Play`
10. `Launch`
成功后应能同时看到这三组摘要:
- `release.presentation.templateKey / version`
- `release.contentBundle.bundleType / version`
- `release.runtime.placeId / mapId / tileReleaseId / courseVariantId`
同时客户端消费侧应保持一致:
- `GET /events/{eventPublicID}`
- `GET /events/{eventPublicID}/play`
- `POST /events/{eventPublicID}/launch`
当前 Content Bundle Import 只做统一导入入口,不做复杂资源平台:
- 输入:
- `title`
- `bundleType`
- `sourceType`
- `manifestUrl`
- `version`
- `assetManifest`
- 输出:
- `bundleId`
- `bundleType`
- `version`
- `assetManifest`
- `status`
### 场景七:第四刀发布闭环验证
`/dev/workbench``后台运营` 模式中,先完成“场景五”,再按下面顺序操作:
1. `Create Runtime Binding`
2. `Get Pipeline`
3. 确认 `Build ID`
4. 在发布区填 `Runtime Binding ID`
5. `Publish Build`
6. `Get Release`
7. 切回 `前台联调`
8. 对同一个 `event` 执行 `Launch`
成功后应能看到:
- `POST /admin/builds/{buildID}/publish` 返回带 `runtime`
- `GET /admin/releases/{releasePublicID}` 返回同一条 `runtime`
- `POST /events/{eventPublicID}/launch` 返回同一条 `launch.runtime`
当前第四刀的兼容要求是:
- 旧的“先 `publish`,再 `bind runtime`”路径继续可用
- 新的“`publish` 时直接传 `runtimeBindingId`”优先推荐
- 不修改旧的:
- `resolvedRelease`
- `business`
- `variant`
## 7. 当前后续开发建议
文档整理完之后,后面建议按这个顺序继续:

View File

@@ -1,6 +1,6 @@
# API 清单
> 文档版本v1.1
> 最后更新2026-04-02 11:05:32
> 文档版本v1.8
> 最后更新2026-04-03 12:36:15
本文档只记录当前 backend 已实现接口,不写未来规划接口。
@@ -140,6 +140,18 @@
- `event`
- `release`
- `resolvedRelease`
- `runtime`
- `currentPresentation`
- `currentContentBundle`
当前摘要字段最少包括:
- `currentPresentation.presentationId`
- `currentPresentation.templateKey`
- `currentPresentation.version`
- `currentContentBundle.contentBundleId`
- `currentContentBundle.bundleType`
- `currentContentBundle.version`
### `GET /events/{eventPublicID}/play`
@@ -156,6 +168,9 @@
- `event`
- `release`
- `resolvedRelease`
- `runtime`
- `currentPresentation`
- `currentContentBundle`
- `play.assignmentMode`
- `play.courseVariants`
- `play.canLaunch`
@@ -164,6 +179,15 @@
- `play.ongoingSession`
- `play.recentSession`
当前摘要字段最少包括:
- `currentPresentation.presentationId`
- `currentPresentation.templateKey`
- `currentPresentation.version`
- `currentContentBundle.contentBundleId`
- `currentContentBundle.bundleType`
- `currentContentBundle.version`
### `POST /events/{eventPublicID}/launch`
鉴权:
@@ -192,10 +216,33 @@
- `launch.source`
- `launch.resolvedRelease`
- `launch.variant`
- `launch.runtime`
- `launch.presentation`
- `launch.contentBundle`
- `launch.config`
- `launch.business.sessionId`
- `launch.business.sessionToken`
当前活动运营摘要最少包括:
- `launch.presentation.presentationId`
- `launch.presentation.templateKey`
- `launch.presentation.version`
- `launch.contentBundle.contentBundleId`
- `launch.contentBundle.bundleType`
- `launch.contentBundle.version`
`launch.runtime` 当前为兼容新增字段,最少会带:
- `runtimeBindingId`
- `placeId`
- `mapId`
- `tileReleaseId`
- `courseSetId`
- `courseVariantId`
如当前 release 尚未挂接 runtime binding则该字段为空。
### `GET /events/{eventPublicID}/config-sources`
鉴权:
@@ -466,12 +513,16 @@
请求体重点:
- `buildId`
- `runtimeBindingId` 可选
- `presentationId` 可选
- `contentBundleId` 可选
返回重点:
- `release.releaseId`
- `release.manifestUrl`
- `release.configLabel`
- `runtime.runtimeBindingId` 可选
## 9. Admin 资源对象
@@ -738,6 +789,145 @@
- `overrides`
- `notes`
### `GET /admin/events/{eventPublicID}/presentations`
鉴权:
- Bearer token
用途:
- 查看某个 event 下的展示定义列表
### `POST /admin/events/{eventPublicID}/presentations`
鉴权:
- Bearer token
用途:
- 为 event 创建一条最小 presentation 定义
请求体重点:
- `code`
- `name`
- `presentationType`
- `schema`
### `POST /admin/events/{eventPublicID}/presentations/import`
鉴权:
- Bearer token
用途:
- 通过统一导入入口创建展示定义
- 第一阶段只记录:
- `templateKey`
- `sourceType`
- `schemaUrl`
- `version`
- `title`
核心参数:
- `title`
- `templateKey`
- `sourceType`
- `schemaUrl`
- `version`
### `GET /admin/presentations/{presentationPublicID}`
鉴权:
- Bearer token
用途:
- 查看单条 presentation 明细
### `GET /admin/events/{eventPublicID}/content-bundles`
鉴权:
- Bearer token
用途:
- 查看某个 event 下的内容包列表
### `POST /admin/events/{eventPublicID}/content-bundles`
鉴权:
- Bearer token
用途:
- 为 event 创建一条最小 content bundle
请求体重点:
- `code`
- `name`
- `entryUrl`
- `assetRootUrl`
- `metadata`
### `POST /admin/events/{eventPublicID}/content-bundles/import`
鉴权:
- Bearer token
用途:
- 通过统一导入入口为 event 创建内容包
- 先记录 `bundleType / sourceType / manifestUrl / version / assetManifest`
请求体重点:
- `title`
- `bundleType`
- `sourceType`
- `manifestUrl`
- `version`
- `assetManifest`
### `GET /admin/content-bundles/{contentBundlePublicID}`
鉴权:
- Bearer token
用途:
- 查看单条 content bundle 明细
### `POST /admin/events/{eventPublicID}/defaults`
鉴权:
- Bearer token
用途:
- 固化 event 当前默认 active 绑定
- 后续 publish 在未显式传参时,优先继承:
- `presentationId`
- `contentBundleId`
- `runtimeBindingId`
核心参数:
- `presentationId`
- `contentBundleId`
- `runtimeBindingId`
### `GET /admin/events/{eventPublicID}/pipeline`
鉴权:
@@ -782,6 +972,57 @@
- 将某次成功 build 发布成正式 release
- 自动切换 event 当前 release
- 可选在发布时直接挂接:
- `runtimeBindingId`
- `presentationId`
- `contentBundleId`
- 如果未显式传入 `runtimeBindingId / presentationId / contentBundleId`,会优先按 event 当前默认 active 绑定自动补齐
请求体重点:
- `runtimeBindingId` 可选
- `presentationId` 可选
- `contentBundleId` 可选
### `GET /admin/releases/{releasePublicID}`
鉴权:
- Bearer token
用途:
- 查看单个 release 明细
- 带出当前已挂接的最小 runtime / presentation / content bundle 摘要
当前 release 摘要最少包括:
- `presentation.presentationId`
- `presentation.templateKey`
- `presentation.version`
- `contentBundle.contentBundleId`
- `contentBundle.bundleType`
- `contentBundle.version`
- `runtime.runtimeBindingId`
- `runtime.placeId`
- `runtime.mapId`
- `runtime.tileReleaseId`
- `runtime.courseVariantId`
### `POST /admin/releases/{releasePublicID}/runtime-binding`
鉴权:
- Bearer token
用途:
- 将某个 `runtimeBindingId` 挂接到指定 release
- 为后续 `launch.runtime` 提供运行对象来源
请求体重点:
- `runtimeBindingId`
### `POST /admin/events/{eventPublicID}/rollback`
@@ -797,4 +1038,247 @@
- `releaseId`
## 10. Admin 生产骨架
说明:
- 当前是总控确认后的第一阶段生产骨架接口
- 重点先覆盖:
- `Place`
- `MapAsset`
- `TileRelease`
- `CourseSource`
- `CourseSet`
- `CourseVariant`
- `MapRuntimeBinding`
- 这批接口不会替换现有 `events / event_releases / launch` 主链,而是增量补运行域对象
### `GET /admin/places`
鉴权:
- Bearer token
用途:
- 获取地点对象列表
### `POST /admin/places`
鉴权:
- Bearer token
用途:
- 新建地点对象
请求体重点:
- `code`
- `name`
- `region`
- `coverUrl`
- `description`
- `centerPoint`
- `status`
### `GET /admin/places/{placePublicID}`
鉴权:
- Bearer token
用途:
- 查看地点详情
- 同时带出该地点下的地图资产列表
### `POST /admin/places/{placePublicID}/map-assets`
鉴权:
- Bearer token
用途:
- 在某个地点下创建地图资产
请求体重点:
- `code`
- `name`
- `mapType`
- `legacyMapId`
- `coverUrl`
- `description`
- `status`
### `GET /admin/map-assets/{mapAssetPublicID}`
鉴权:
- Bearer token
用途:
- 查看地图资产详情
- 同时带出瓦片版本和赛道集合摘要
### `POST /admin/map-assets/{mapAssetPublicID}/tile-releases`
鉴权:
- Bearer token
用途:
- 为某个地图资产创建瓦片版本
请求体重点:
- `legacyVersionId`
- `versionCode`
- `tileBaseUrl`
- `metaUrl`
- `publishedAssetRoot`
- `metadata`
- `status`
- `setAsCurrent`
### `GET /admin/course-sources`
鉴权:
- Bearer token
用途:
- 获取赛道原始输入源列表
### `POST /admin/course-sources`
鉴权:
- Bearer token
用途:
- 新建赛道原始输入源
- 用于承接 KML / GeoJSON 等输入
请求体重点:
- `legacyPlayfieldId`
- `legacyVersionId`
- `sourceType`
- `fileUrl`
- `checksum`
- `parserVersion`
- `importStatus`
- `metadata`
### `GET /admin/course-sources/{sourcePublicID}`
鉴权:
- Bearer token
用途:
- 查看单个赛道输入源详情
### `POST /admin/map-assets/{mapAssetPublicID}/course-sets`
鉴权:
- Bearer token
用途:
- 在某个地图资产下创建赛道集合
请求体重点:
- `code`
- `mode`
- `name`
- `description`
- `status`
### `GET /admin/course-sets/{courseSetPublicID}`
鉴权:
- Bearer token
用途:
- 查看赛道集合详情
- 同时带出它的 variant 列表
### `POST /admin/course-sets/{courseSetPublicID}/variants`
鉴权:
- Bearer token
用途:
- 为某个赛道集合创建具体可运行赛道方案
请求体重点:
- `sourceId`
- `name`
- `routeCode`
- `mode`
- `controlCount`
- `difficulty`
- `configPatch`
- `metadata`
- `status`
- `isDefault`
### `GET /admin/runtime-bindings`
鉴权:
- Bearer token
用途:
- 获取活动运行绑定列表
### `POST /admin/runtime-bindings`
鉴权:
- Bearer token
用途:
- 把活动和地点、地图资产、瓦片版本、赛道方案正式绑定起来
请求体重点:
- `eventId`
- `placeId`
- `mapAssetId`
- `tileReleaseId`
- `courseSetId`
- `courseVariantId`
- `status`
- `notes`
### `GET /admin/runtime-bindings/{runtimeBindingPublicID}`
鉴权:
- Bearer token
用途:
- 查看单个运行绑定详情

View File

@@ -1,9 +1,8 @@
# 数据模型
> 文档版本v1.0
> 最后更新2026-04-02 08:28:05
> 文档版本v1.3
> 最后更新2026-04-03 12:36:15
当前 migration 共 6 版。
当前 migration 共 10 版。
## 1. 迁移清单
@@ -13,6 +12,10 @@
- [0004_results.sql](D:/dev/cmr-mini/backend/migrations/0004_results.sql)
- [0005_config_pipeline.sql](D:/dev/cmr-mini/backend/migrations/0005_config_pipeline.sql)
- [0006_resource_objects.sql](D:/dev/cmr-mini/backend/migrations/0006_resource_objects.sql)
- [0007_variant_minimal.sql](D:/dev/cmr-mini/backend/migrations/0007_variant_minimal.sql)
- [0008_production_skeleton.sql](D:/dev/cmr-mini/backend/migrations/0008_production_skeleton.sql)
- [0009_event_ops_phase2.sql](D:/dev/cmr-mini/backend/migrations/0009_event_ops_phase2.sql)
- [0010_event_default_bindings.sql](D:/dev/cmr-mini/backend/migrations/0010_event_default_bindings.sql)
## 2. 表分组
@@ -114,6 +117,51 @@
- 支撑后台第一版按“资源对象 + 版本”管理
- 给后续 event 引用组装和发布流程提供稳定边界
### 2.8 第一阶段生产骨架
- `places`
- `map_assets`
- `tile_releases`
- `course_sources`
- `course_sets`
- `course_variants`
- `map_runtime_bindings`
职责:
- 把地图运行域和活动运行绑定正式落库
- 把 KML 输入源和最终赛道方案拆开
- 在不推翻当前 `events / event_releases / game_sessions` 主链的前提下,增量补生产骨架
### 2.9 活动运营域第二阶段
- `event_presentations`
- `content_bundles`
职责:
- 把活动展示定义和内容包从临时 JSON 概念收成正式对象
-`event_releases` 明确绑定:
- `presentation_id`
- `content_bundle_id`
- `runtime_binding_id`
- 保持现有 `resolvedRelease / business / variant / runtime` 稳定返回不变
### 2.10 Event 默认 active 绑定
- `events.current_presentation_id`
- `events.current_content_bundle_id`
- `events.current_runtime_binding_id`
职责:
- 固化 event 当前默认 active
- `presentation`
- `content bundle`
- `runtime binding`
- 支撑 publish 在未显式传入时的默认继承
- 不改变前端当前稳定消费的 release / launch 字段语义
## 3. 当前最关键的关系
### `tenant -> entry_channel`
@@ -160,6 +208,42 @@
一套内容/音频/主题资源可有多个版本。
### `place -> map_asset -> tile_release`
- `Place` 是地点上层对象
- `MapAsset` 是地点下的一张具体地图资产
- `TileRelease` 是某张地图的具体瓦片发布版本
### `course_source -> course_variant -> course_set`
- `CourseSource` 是原始输入源,例如 KML
- `CourseVariant` 是最终可运行赛道方案
- `CourseSet` 是一组方案集合
### `event_release -> map_runtime_binding`
- `event_releases.runtime_binding_id` 已预留给第一阶段生产骨架
- 当前客户端联调仍以 `resolvedRelease` 为主
- 第二阶段会继续把 `placeId / mapId / tileReleaseId / courseVariantId` 收到 `launch` 稳定返回中
### `event -> event_presentation`
- 一个 `event` 可有多条展示定义
- 当前最小用途是给 `event_release` 提供明确绑定目标
### `event -> content_bundle`
- 一个 `event` 可有多条内容包
- 当前最小用途是给 `event_release` 提供内容资源绑定目标
### `event_release -> presentation / content_bundle / runtime`
- 这是当前活动运营域第二阶段的最小闭环
- `release` 现在可以稳定固化:
- 展示定义
- 内容包
- 运行绑定
## 4. 当前已落库但仍应注意的边界
### 4.1 不要把玩法细节塞回事件主表

View File

@@ -1,6 +1,6 @@
# 核心流程
> 文档版本v1.1
> 最后更新2026-04-02 11:03:02
> 文档版本v1.2
> 最后更新2026-04-03 11:22:50
## 1. 总流程
@@ -113,6 +113,8 @@ APP 当前主链是手机号验证码:
- `event`
- `release`
- `resolvedRelease`
- `currentPresentation`
- `currentContentBundle`
- `play.assignmentMode`
- `play.courseVariants[]`
- `play.canLaunch`
@@ -160,6 +162,8 @@ APP 当前主链是手机号验证码:
- `launch.source`
- `launch.resolvedRelease`
- `launch.variant`
- `launch.presentation`
- `launch.contentBundle`
- `launch.config`
- `launch.business.sessionId`
- `launch.business.sessionToken`
@@ -179,6 +183,11 @@ APP 当前主链是手机号验证码:
- `launch.variant.id`
- `launch.variant.assignmentMode`
活动运营域第二阶段第二刀新增建议消费摘要:
- `launch.presentation.presentationId`
- `launch.contentBundle.contentBundleId`
补充说明:
- 如果活动声明了多赛道 variant`launch` 会返回本局最终绑定的 `variant`

View File

@@ -38,6 +38,7 @@ func New(ctx context.Context, cfg Config) (*App, error) {
entryService := service.NewEntryService(store)
entryHomeService := service.NewEntryHomeService(store)
adminResourceService := service.NewAdminResourceService(store)
adminProductionService := service.NewAdminProductionService(store)
adminEventService := service.NewAdminEventService(store)
eventService := service.NewEventService(store)
eventPlayService := service.NewEventPlayService(store)
@@ -50,7 +51,7 @@ func New(ctx context.Context, cfg Config) (*App, error) {
sessionService := service.NewSessionService(store)
devService := service.NewDevService(cfg.AppEnv, store)
meService := service.NewMeService(store)
router := httpapi.NewRouter(cfg.AppEnv, jwtManager, authService, entryService, entryHomeService, adminResourceService, adminEventService, adminPipelineService, eventService, eventPlayService, configService, homeService, profileService, resultService, sessionService, devService, meService)
router := httpapi.NewRouter(cfg.AppEnv, jwtManager, authService, entryService, entryHomeService, adminResourceService, adminProductionService, adminEventService, adminPipelineService, eventService, eventPlayService, configService, homeService, profileService, resultService, sessionService, devService, meService)
return &App{
router: router,

View File

@@ -82,3 +82,121 @@ func (h *AdminEventHandler) SaveSource(w http.ResponseWriter, r *http.Request) {
}
httpx.WriteJSON(w, http.StatusCreated, map[string]any{"data": result})
}
func (h *AdminEventHandler) ListPresentations(w http.ResponseWriter, r *http.Request) {
limit := 50
if raw := r.URL.Query().Get("limit"); raw != "" {
if parsed, err := strconv.Atoi(raw); err == nil {
limit = parsed
}
}
result, err := h.service.ListEventPresentations(r.Context(), r.PathValue("eventPublicID"), limit)
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}
func (h *AdminEventHandler) CreatePresentation(w http.ResponseWriter, r *http.Request) {
var req service.CreateAdminEventPresentationInput
if err := httpx.DecodeJSON(r, &req); err != nil {
httpx.WriteError(w, apperr.New(http.StatusBadRequest, "invalid_json", "invalid request body: "+err.Error()))
return
}
result, err := h.service.CreateEventPresentation(r.Context(), r.PathValue("eventPublicID"), req)
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusCreated, map[string]any{"data": result})
}
func (h *AdminEventHandler) ImportPresentation(w http.ResponseWriter, r *http.Request) {
var req service.ImportAdminEventPresentationInput
if err := httpx.DecodeJSON(r, &req); err != nil {
httpx.WriteError(w, apperr.New(http.StatusBadRequest, "invalid_json", "invalid request body: "+err.Error()))
return
}
result, err := h.service.ImportEventPresentation(r.Context(), r.PathValue("eventPublicID"), req)
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusCreated, map[string]any{"data": result})
}
func (h *AdminEventHandler) GetPresentation(w http.ResponseWriter, r *http.Request) {
result, err := h.service.GetEventPresentation(r.Context(), r.PathValue("presentationPublicID"))
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}
func (h *AdminEventHandler) ListContentBundles(w http.ResponseWriter, r *http.Request) {
limit := 50
if raw := r.URL.Query().Get("limit"); raw != "" {
if parsed, err := strconv.Atoi(raw); err == nil {
limit = parsed
}
}
result, err := h.service.ListContentBundles(r.Context(), r.PathValue("eventPublicID"), limit)
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}
func (h *AdminEventHandler) CreateContentBundle(w http.ResponseWriter, r *http.Request) {
var req service.CreateAdminContentBundleInput
if err := httpx.DecodeJSON(r, &req); err != nil {
httpx.WriteError(w, apperr.New(http.StatusBadRequest, "invalid_json", "invalid request body: "+err.Error()))
return
}
result, err := h.service.CreateContentBundle(r.Context(), r.PathValue("eventPublicID"), req)
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusCreated, map[string]any{"data": result})
}
func (h *AdminEventHandler) ImportContentBundle(w http.ResponseWriter, r *http.Request) {
var req service.ImportAdminContentBundleInput
if err := httpx.DecodeJSON(r, &req); err != nil {
httpx.WriteError(w, apperr.New(http.StatusBadRequest, "invalid_json", "invalid request body: "+err.Error()))
return
}
result, err := h.service.ImportContentBundle(r.Context(), r.PathValue("eventPublicID"), req)
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusCreated, map[string]any{"data": result})
}
func (h *AdminEventHandler) GetContentBundle(w http.ResponseWriter, r *http.Request) {
result, err := h.service.GetContentBundle(r.Context(), r.PathValue("contentBundlePublicID"))
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}
func (h *AdminEventHandler) UpdateEventDefaults(w http.ResponseWriter, r *http.Request) {
var req service.UpdateAdminEventDefaultsInput
if err := httpx.DecodeJSON(r, &req); err != nil {
httpx.WriteError(w, apperr.New(http.StatusBadRequest, "invalid_json", "invalid request body: "+err.Error()))
return
}
result, err := h.service.UpdateEventDefaults(r.Context(), r.PathValue("eventPublicID"), req)
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}

View File

@@ -1,8 +1,10 @@
package handlers
import (
"io"
"net/http"
"strconv"
"strings"
"cmr-backend/internal/apperr"
"cmr-backend/internal/httpx"
@@ -51,7 +53,46 @@ func (h *AdminPipelineHandler) GetBuild(w http.ResponseWriter, r *http.Request)
}
func (h *AdminPipelineHandler) PublishBuild(w http.ResponseWriter, r *http.Request) {
result, err := h.service.PublishBuild(r.Context(), r.PathValue("buildID"))
var req service.AdminPublishBuildInput
if r.Body != nil {
raw, err := io.ReadAll(r.Body)
if err != nil {
httpx.WriteError(w, apperr.New(http.StatusBadRequest, "invalid_json", "failed to read request body: "+err.Error()))
return
}
if len(raw) > 0 {
r.Body = io.NopCloser(strings.NewReader(string(raw)))
if err := httpx.DecodeJSON(r, &req); err != nil {
httpx.WriteError(w, apperr.New(http.StatusBadRequest, "invalid_json", "invalid request body: "+err.Error()))
return
}
}
}
result, err := h.service.PublishBuild(r.Context(), r.PathValue("buildID"), req)
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}
func (h *AdminPipelineHandler) GetRelease(w http.ResponseWriter, r *http.Request) {
result, err := h.service.GetRelease(r.Context(), r.PathValue("releasePublicID"))
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}
func (h *AdminPipelineHandler) BindReleaseRuntime(w http.ResponseWriter, r *http.Request) {
var req service.AdminBindReleaseRuntimeInput
if err := httpx.DecodeJSON(r, &req); err != nil {
httpx.WriteError(w, apperr.New(http.StatusBadRequest, "invalid_json", "invalid request body: "+err.Error()))
return
}
result, err := h.service.BindReleaseRuntime(r.Context(), r.PathValue("releasePublicID"), req)
if err != nil {
httpx.WriteError(w, err)
return

View File

@@ -0,0 +1,187 @@
package handlers
import (
"net/http"
"cmr-backend/internal/apperr"
"cmr-backend/internal/httpx"
"cmr-backend/internal/service"
)
type AdminProductionHandler struct {
service *service.AdminProductionService
}
func NewAdminProductionHandler(service *service.AdminProductionService) *AdminProductionHandler {
return &AdminProductionHandler{service: service}
}
func (h *AdminProductionHandler) ListPlaces(w http.ResponseWriter, r *http.Request) {
result, err := h.service.ListPlaces(r.Context(), parseAdminLimit(r))
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}
func (h *AdminProductionHandler) CreatePlace(w http.ResponseWriter, r *http.Request) {
var req service.CreateAdminPlaceInput
if err := httpx.DecodeJSON(r, &req); err != nil {
httpx.WriteError(w, apperr.New(http.StatusBadRequest, "invalid_json", "invalid request body: "+err.Error()))
return
}
result, err := h.service.CreatePlace(r.Context(), req)
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusCreated, map[string]any{"data": result})
}
func (h *AdminProductionHandler) GetPlace(w http.ResponseWriter, r *http.Request) {
result, err := h.service.GetPlaceDetail(r.Context(), r.PathValue("placePublicID"))
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}
func (h *AdminProductionHandler) CreateMapAsset(w http.ResponseWriter, r *http.Request) {
var req service.CreateAdminMapAssetInput
if err := httpx.DecodeJSON(r, &req); err != nil {
httpx.WriteError(w, apperr.New(http.StatusBadRequest, "invalid_json", "invalid request body: "+err.Error()))
return
}
result, err := h.service.CreateMapAsset(r.Context(), r.PathValue("placePublicID"), req)
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusCreated, map[string]any{"data": result})
}
func (h *AdminProductionHandler) GetMapAsset(w http.ResponseWriter, r *http.Request) {
result, err := h.service.GetMapAssetDetail(r.Context(), r.PathValue("mapAssetPublicID"))
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}
func (h *AdminProductionHandler) CreateTileRelease(w http.ResponseWriter, r *http.Request) {
var req service.CreateAdminTileReleaseInput
if err := httpx.DecodeJSON(r, &req); err != nil {
httpx.WriteError(w, apperr.New(http.StatusBadRequest, "invalid_json", "invalid request body: "+err.Error()))
return
}
result, err := h.service.CreateTileRelease(r.Context(), r.PathValue("mapAssetPublicID"), req)
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusCreated, map[string]any{"data": result})
}
func (h *AdminProductionHandler) ListCourseSources(w http.ResponseWriter, r *http.Request) {
result, err := h.service.ListCourseSources(r.Context(), parseAdminLimit(r))
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}
func (h *AdminProductionHandler) CreateCourseSource(w http.ResponseWriter, r *http.Request) {
var req service.CreateAdminCourseSourceInput
if err := httpx.DecodeJSON(r, &req); err != nil {
httpx.WriteError(w, apperr.New(http.StatusBadRequest, "invalid_json", "invalid request body: "+err.Error()))
return
}
result, err := h.service.CreateCourseSource(r.Context(), req)
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusCreated, map[string]any{"data": result})
}
func (h *AdminProductionHandler) GetCourseSource(w http.ResponseWriter, r *http.Request) {
result, err := h.service.GetCourseSource(r.Context(), r.PathValue("sourcePublicID"))
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}
func (h *AdminProductionHandler) CreateCourseSet(w http.ResponseWriter, r *http.Request) {
var req service.CreateAdminCourseSetInput
if err := httpx.DecodeJSON(r, &req); err != nil {
httpx.WriteError(w, apperr.New(http.StatusBadRequest, "invalid_json", "invalid request body: "+err.Error()))
return
}
result, err := h.service.CreateCourseSet(r.Context(), r.PathValue("mapAssetPublicID"), req)
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusCreated, map[string]any{"data": result})
}
func (h *AdminProductionHandler) GetCourseSet(w http.ResponseWriter, r *http.Request) {
result, err := h.service.GetCourseSetDetail(r.Context(), r.PathValue("courseSetPublicID"))
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}
func (h *AdminProductionHandler) CreateCourseVariant(w http.ResponseWriter, r *http.Request) {
var req service.CreateAdminCourseVariantInput
if err := httpx.DecodeJSON(r, &req); err != nil {
httpx.WriteError(w, apperr.New(http.StatusBadRequest, "invalid_json", "invalid request body: "+err.Error()))
return
}
result, err := h.service.CreateCourseVariant(r.Context(), r.PathValue("courseSetPublicID"), req)
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusCreated, map[string]any{"data": result})
}
func (h *AdminProductionHandler) ListRuntimeBindings(w http.ResponseWriter, r *http.Request) {
result, err := h.service.ListRuntimeBindings(r.Context(), parseAdminLimit(r))
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}
func (h *AdminProductionHandler) CreateRuntimeBinding(w http.ResponseWriter, r *http.Request) {
var req service.CreateAdminRuntimeBindingInput
if err := httpx.DecodeJSON(r, &req); err != nil {
httpx.WriteError(w, apperr.New(http.StatusBadRequest, "invalid_json", "invalid request body: "+err.Error()))
return
}
result, err := h.service.CreateRuntimeBinding(r.Context(), req)
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusCreated, map[string]any{"data": result})
}
func (h *AdminProductionHandler) GetRuntimeBinding(w http.ResponseWriter, r *http.Request) {
result, err := h.service.GetRuntimeBinding(r.Context(), r.PathValue("runtimeBindingPublicID"))
if err != nil {
httpx.WriteError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"data": result})
}

File diff suppressed because it is too large Load Diff

View File

@@ -16,6 +16,7 @@ func NewRouter(
entryService *service.EntryService,
entryHomeService *service.EntryHomeService,
adminResourceService *service.AdminResourceService,
adminProductionService *service.AdminProductionService,
adminEventService *service.AdminEventService,
adminPipelineService *service.AdminPipelineService,
eventService *service.EventService,
@@ -35,6 +36,7 @@ func NewRouter(
entryHandler := handlers.NewEntryHandler(entryService)
entryHomeHandler := handlers.NewEntryHomeHandler(entryHomeService)
adminResourceHandler := handlers.NewAdminResourceHandler(adminResourceService)
adminProductionHandler := handlers.NewAdminProductionHandler(adminProductionService)
adminEventHandler := handlers.NewAdminEventHandler(adminEventService)
adminPipelineHandler := handlers.NewAdminPipelineHandler(adminPipelineService)
eventHandler := handlers.NewEventHandler(eventService)
@@ -56,6 +58,21 @@ func NewRouter(
mux.Handle("POST /admin/maps", authMiddleware(http.HandlerFunc(adminResourceHandler.CreateMap)))
mux.Handle("GET /admin/maps/{mapPublicID}", authMiddleware(http.HandlerFunc(adminResourceHandler.GetMap)))
mux.Handle("POST /admin/maps/{mapPublicID}/versions", authMiddleware(http.HandlerFunc(adminResourceHandler.CreateMapVersion)))
mux.Handle("GET /admin/places", authMiddleware(http.HandlerFunc(adminProductionHandler.ListPlaces)))
mux.Handle("POST /admin/places", authMiddleware(http.HandlerFunc(adminProductionHandler.CreatePlace)))
mux.Handle("GET /admin/places/{placePublicID}", authMiddleware(http.HandlerFunc(adminProductionHandler.GetPlace)))
mux.Handle("POST /admin/places/{placePublicID}/map-assets", authMiddleware(http.HandlerFunc(adminProductionHandler.CreateMapAsset)))
mux.Handle("GET /admin/map-assets/{mapAssetPublicID}", authMiddleware(http.HandlerFunc(adminProductionHandler.GetMapAsset)))
mux.Handle("POST /admin/map-assets/{mapAssetPublicID}/tile-releases", authMiddleware(http.HandlerFunc(adminProductionHandler.CreateTileRelease)))
mux.Handle("POST /admin/map-assets/{mapAssetPublicID}/course-sets", authMiddleware(http.HandlerFunc(adminProductionHandler.CreateCourseSet)))
mux.Handle("GET /admin/course-sources", authMiddleware(http.HandlerFunc(adminProductionHandler.ListCourseSources)))
mux.Handle("POST /admin/course-sources", authMiddleware(http.HandlerFunc(adminProductionHandler.CreateCourseSource)))
mux.Handle("GET /admin/course-sources/{sourcePublicID}", authMiddleware(http.HandlerFunc(adminProductionHandler.GetCourseSource)))
mux.Handle("GET /admin/course-sets/{courseSetPublicID}", authMiddleware(http.HandlerFunc(adminProductionHandler.GetCourseSet)))
mux.Handle("POST /admin/course-sets/{courseSetPublicID}/variants", authMiddleware(http.HandlerFunc(adminProductionHandler.CreateCourseVariant)))
mux.Handle("GET /admin/runtime-bindings", authMiddleware(http.HandlerFunc(adminProductionHandler.ListRuntimeBindings)))
mux.Handle("POST /admin/runtime-bindings", authMiddleware(http.HandlerFunc(adminProductionHandler.CreateRuntimeBinding)))
mux.Handle("GET /admin/runtime-bindings/{runtimeBindingPublicID}", authMiddleware(http.HandlerFunc(adminProductionHandler.GetRuntimeBinding)))
mux.Handle("GET /admin/playfields", authMiddleware(http.HandlerFunc(adminResourceHandler.ListPlayfields)))
mux.Handle("POST /admin/playfields", authMiddleware(http.HandlerFunc(adminResourceHandler.CreatePlayfield)))
mux.Handle("GET /admin/playfields/{playfieldPublicID}", authMiddleware(http.HandlerFunc(adminResourceHandler.GetPlayfield)))
@@ -69,10 +86,21 @@ func NewRouter(
mux.Handle("GET /admin/events/{eventPublicID}", authMiddleware(http.HandlerFunc(adminEventHandler.GetEvent)))
mux.Handle("PUT /admin/events/{eventPublicID}", authMiddleware(http.HandlerFunc(adminEventHandler.UpdateEvent)))
mux.Handle("POST /admin/events/{eventPublicID}/source", authMiddleware(http.HandlerFunc(adminEventHandler.SaveSource)))
mux.Handle("GET /admin/events/{eventPublicID}/presentations", authMiddleware(http.HandlerFunc(adminEventHandler.ListPresentations)))
mux.Handle("POST /admin/events/{eventPublicID}/presentations", authMiddleware(http.HandlerFunc(adminEventHandler.CreatePresentation)))
mux.Handle("POST /admin/events/{eventPublicID}/presentations/import", authMiddleware(http.HandlerFunc(adminEventHandler.ImportPresentation)))
mux.Handle("GET /admin/presentations/{presentationPublicID}", authMiddleware(http.HandlerFunc(adminEventHandler.GetPresentation)))
mux.Handle("GET /admin/events/{eventPublicID}/content-bundles", authMiddleware(http.HandlerFunc(adminEventHandler.ListContentBundles)))
mux.Handle("POST /admin/events/{eventPublicID}/content-bundles", authMiddleware(http.HandlerFunc(adminEventHandler.CreateContentBundle)))
mux.Handle("POST /admin/events/{eventPublicID}/content-bundles/import", authMiddleware(http.HandlerFunc(adminEventHandler.ImportContentBundle)))
mux.Handle("GET /admin/content-bundles/{contentBundlePublicID}", authMiddleware(http.HandlerFunc(adminEventHandler.GetContentBundle)))
mux.Handle("POST /admin/events/{eventPublicID}/defaults", authMiddleware(http.HandlerFunc(adminEventHandler.UpdateEventDefaults)))
mux.Handle("GET /admin/events/{eventPublicID}/pipeline", authMiddleware(http.HandlerFunc(adminPipelineHandler.GetEventPipeline)))
mux.Handle("POST /admin/sources/{sourceID}/build", authMiddleware(http.HandlerFunc(adminPipelineHandler.BuildSource)))
mux.Handle("GET /admin/builds/{buildID}", authMiddleware(http.HandlerFunc(adminPipelineHandler.GetBuild)))
mux.Handle("POST /admin/builds/{buildID}/publish", authMiddleware(http.HandlerFunc(adminPipelineHandler.PublishBuild)))
mux.Handle("GET /admin/releases/{releasePublicID}", authMiddleware(http.HandlerFunc(adminPipelineHandler.GetRelease)))
mux.Handle("POST /admin/releases/{releasePublicID}/runtime-binding", authMiddleware(http.HandlerFunc(adminPipelineHandler.BindReleaseRuntime)))
mux.Handle("POST /admin/events/{eventPublicID}/rollback", authMiddleware(http.HandlerFunc(adminPipelineHandler.RollbackRelease)))
if appEnv != "production" {
mux.HandleFunc("GET /dev/workbench", devHandler.Workbench)

View File

@@ -2,6 +2,7 @@ package service
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
@@ -27,18 +28,25 @@ type AdminEventSummary struct {
}
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"`
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"`
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 {
@@ -76,6 +84,84 @@ type SaveAdminEventSourceInput struct {
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"`
@@ -240,10 +326,20 @@ func (s *AdminEventService) GetEventDetail(ctx context.Context, eventPublicID st
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),
Event: buildAdminEventSummary(*record),
SourceCount: len(allSources),
PresentationCount: len(presentations),
ContentBundleCount: len(contentBundles),
}
if len(sources) > 0 {
latest, err := buildEventConfigSourceView(&sources[0], record.PublicID)
@@ -253,9 +349,427 @@ func (s *AdminEventService) GetEventDetail(ctx context.Context, eventPublicID st
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 {
@@ -441,11 +955,160 @@ func buildAdminEventSummary(item postgres.AdminEventRecord) AdminEventSummary {
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 {
@@ -474,6 +1137,26 @@ func normalizeEventCatalogStatus(value string) string {
}
}
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 {

View File

@@ -15,15 +15,18 @@ type AdminPipelineService struct {
}
type AdminReleaseView struct {
ID string `json:"id"`
ReleaseNo int `json:"releaseNo"`
ConfigLabel string `json:"configLabel"`
ManifestURL string `json:"manifestUrl"`
ManifestChecksumSha256 *string `json:"manifestChecksumSha256,omitempty"`
RouteCode *string `json:"routeCode,omitempty"`
BuildID *string `json:"buildId,omitempty"`
Status string `json:"status"`
PublishedAt string `json:"publishedAt"`
ID string `json:"id"`
ReleaseNo int `json:"releaseNo"`
ConfigLabel string `json:"configLabel"`
ManifestURL string `json:"manifestUrl"`
ManifestChecksumSha256 *string `json:"manifestChecksumSha256,omitempty"`
RouteCode *string `json:"routeCode,omitempty"`
BuildID *string `json:"buildId,omitempty"`
Status string `json:"status"`
PublishedAt string `json:"publishedAt"`
Runtime *RuntimeSummaryView `json:"runtime,omitempty"`
Presentation *PresentationSummaryView `json:"presentation,omitempty"`
ContentBundle *ContentBundleSummaryView `json:"contentBundle,omitempty"`
}
type AdminEventPipelineView struct {
@@ -38,6 +41,16 @@ type AdminRollbackReleaseInput struct {
ReleaseID string `json:"releaseId"`
}
type AdminBindReleaseRuntimeInput struct {
RuntimeBindingID string `json:"runtimeBindingId"`
}
type AdminPublishBuildInput struct {
RuntimeBindingID string `json:"runtimeBindingId,omitempty"`
PresentationID string `json:"presentationId,omitempty"`
ContentBundleID string `json:"contentBundleId,omitempty"`
}
func NewAdminPipelineService(store *postgres.Store, configService *ConfigService) *AdminPipelineService {
return &AdminPipelineService{
store: store,
@@ -77,7 +90,18 @@ func (s *AdminPipelineService) GetEventPipeline(ctx context.Context, eventPublic
}
releases := make([]AdminReleaseView, 0, len(releaseRecords))
for _, item := range releaseRecords {
releases = append(releases, buildAdminReleaseView(item))
view := buildAdminReleaseView(item)
if enrichedPresentation, err := loadPresentationSummaryByPublicID(ctx, s.store, item.PresentationID); err != nil {
return nil, err
} else if enrichedPresentation != nil {
view.Presentation = enrichedPresentation
}
if enrichedBundle, err := loadContentBundleSummaryByPublicID(ctx, s.store, item.ContentBundleID); err != nil {
return nil, err
} else if enrichedBundle != nil {
view.ContentBundle = enrichedBundle
}
releases = append(releases, view)
}
result := &AdminEventPipelineView{
@@ -94,6 +118,19 @@ func (s *AdminPipelineService) GetEventPipeline(ctx context.Context, eventPublic
ManifestChecksumSha256: event.ManifestChecksum,
RouteCode: event.RouteCode,
Status: "published",
Runtime: buildRuntimeSummaryFromEvent(event),
Presentation: buildPresentationSummaryFromEvent(event),
ContentBundle: buildContentBundleSummaryFromEvent(event),
}
if enrichedPresentation, err := loadPresentationSummaryByPublicID(ctx, s.store, event.PresentationID); err != nil {
return nil, err
} else if enrichedPresentation != nil {
result.CurrentRelease.Presentation = enrichedPresentation
}
if enrichedBundle, err := loadContentBundleSummaryByPublicID(ctx, s.store, event.ContentBundleID); err != nil {
return nil, err
} else if enrichedBundle != nil {
result.CurrentRelease.ContentBundle = enrichedBundle
}
}
return result, nil
@@ -107,8 +144,84 @@ func (s *AdminPipelineService) GetBuild(ctx context.Context, buildID string) (*E
return s.configService.GetEventConfigBuild(ctx, buildID)
}
func (s *AdminPipelineService) PublishBuild(ctx context.Context, buildID string) (*PublishedReleaseView, error) {
return s.configService.PublishBuild(ctx, PublishBuildInput{BuildID: buildID})
func (s *AdminPipelineService) PublishBuild(ctx context.Context, buildID string, input AdminPublishBuildInput) (*PublishedReleaseView, error) {
return s.configService.PublishBuild(ctx, PublishBuildInput{
BuildID: buildID,
RuntimeBindingID: input.RuntimeBindingID,
PresentationID: input.PresentationID,
ContentBundleID: input.ContentBundleID,
})
}
func (s *AdminPipelineService) GetRelease(ctx context.Context, releasePublicID string) (*AdminReleaseView, error) {
release, err := s.store.GetEventReleaseByPublicID(ctx, strings.TrimSpace(releasePublicID))
if err != nil {
return nil, err
}
if release == nil {
return nil, apperr.New(http.StatusNotFound, "release_not_found", "release not found")
}
view := buildAdminReleaseView(*release)
if enrichedPresentation, err := loadPresentationSummaryByPublicID(ctx, s.store, release.PresentationID); err != nil {
return nil, err
} else if enrichedPresentation != nil {
view.Presentation = enrichedPresentation
}
if enrichedBundle, err := loadContentBundleSummaryByPublicID(ctx, s.store, release.ContentBundleID); err != nil {
return nil, err
} else if enrichedBundle != nil {
view.ContentBundle = enrichedBundle
}
return &view, nil
}
func (s *AdminPipelineService) BindReleaseRuntime(ctx context.Context, releasePublicID string, input AdminBindReleaseRuntimeInput) (*AdminReleaseView, error) {
release, err := s.store.GetEventReleaseByPublicID(ctx, strings.TrimSpace(releasePublicID))
if err != nil {
return nil, err
}
if release == nil {
return nil, apperr.New(http.StatusNotFound, "release_not_found", "release not found")
}
input.RuntimeBindingID = strings.TrimSpace(input.RuntimeBindingID)
if input.RuntimeBindingID == "" {
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "runtimeBindingId is required")
}
runtimeBinding, err := s.store.GetMapRuntimeBindingByPublicID(ctx, input.RuntimeBindingID)
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 != release.EventID {
return nil, apperr.New(http.StatusConflict, "runtime_binding_not_belong_to_event", "runtime binding does not belong to release event")
}
tx, err := s.store.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
if err := s.store.SetEventReleaseRuntimeBinding(ctx, tx, release.ID, &runtimeBinding.ID); err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
updated, err := s.store.GetEventReleaseByPublicID(ctx, release.PublicID)
if err != nil {
return nil, err
}
if updated == nil {
return nil, apperr.New(http.StatusNotFound, "release_not_found", "release not found")
}
view := buildAdminReleaseView(*updated)
return &view, nil
}
func (s *AdminPipelineService) RollbackRelease(ctx context.Context, eventPublicID string, input AdminRollbackReleaseInput) (*AdminReleaseView, error) {
@@ -167,6 +280,9 @@ func buildAdminReleaseView(item postgres.EventRelease) AdminReleaseView {
BuildID: item.BuildID,
Status: item.Status,
PublishedAt: item.PublishedAt.Format(timeRFC3339),
Runtime: buildRuntimeSummaryFromRelease(&item),
Presentation: buildPresentationSummaryFromRelease(&item),
ContentBundle: buildContentBundleSummaryFromRelease(&item),
}
}

View File

@@ -0,0 +1,935 @@
package service
import (
"context"
"net/http"
"strings"
"time"
"cmr-backend/internal/apperr"
"cmr-backend/internal/platform/security"
"cmr-backend/internal/store/postgres"
)
type AdminProductionService struct {
store *postgres.Store
}
type AdminPlaceSummary struct {
ID string `json:"id"`
Code string `json:"code"`
Name string `json:"name"`
Region *string `json:"region,omitempty"`
CoverURL *string `json:"coverUrl,omitempty"`
Description *string `json:"description,omitempty"`
CenterPoint map[string]any `json:"centerPoint,omitempty"`
Status string `json:"status"`
}
type AdminPlaceDetail struct {
Place AdminPlaceSummary `json:"place"`
MapAssets []AdminMapAssetSummary `json:"mapAssets"`
}
type CreateAdminPlaceInput struct {
Code string `json:"code"`
Name string `json:"name"`
Region *string `json:"region,omitempty"`
CoverURL *string `json:"coverUrl,omitempty"`
Description *string `json:"description,omitempty"`
CenterPoint map[string]any `json:"centerPoint,omitempty"`
Status string `json:"status"`
}
type AdminMapAssetSummary struct {
ID string `json:"id"`
PlaceID string `json:"placeId"`
LegacyMapID *string `json:"legacyMapId,omitempty"`
Code string `json:"code"`
Name string `json:"name"`
MapType string `json:"mapType"`
CoverURL *string `json:"coverUrl,omitempty"`
Description *string `json:"description,omitempty"`
Status string `json:"status"`
CurrentTileRelease *AdminTileReleaseBrief `json:"currentTileRelease,omitempty"`
}
type AdminTileReleaseBrief struct {
ID string `json:"id"`
VersionCode string `json:"versionCode"`
Status string `json:"status"`
}
type AdminMapAssetDetail struct {
MapAsset AdminMapAssetSummary `json:"mapAsset"`
TileReleases []AdminTileReleaseView `json:"tileReleases"`
CourseSets []AdminCourseSetBrief `json:"courseSets"`
}
type CreateAdminMapAssetInput struct {
Code string `json:"code"`
Name string `json:"name"`
MapType string `json:"mapType"`
LegacyMapID *string `json:"legacyMapId,omitempty"`
CoverURL *string `json:"coverUrl,omitempty"`
Description *string `json:"description,omitempty"`
Status string `json:"status"`
}
type AdminTileReleaseView struct {
ID string `json:"id"`
LegacyVersionID *string `json:"legacyVersionId,omitempty"`
VersionCode string `json:"versionCode"`
Status string `json:"status"`
TileBaseURL string `json:"tileBaseUrl"`
MetaURL string `json:"metaUrl"`
PublishedAssetRoot *string `json:"publishedAssetRoot,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
PublishedAt *time.Time `json:"publishedAt,omitempty"`
}
type CreateAdminTileReleaseInput struct {
LegacyVersionID *string `json:"legacyVersionId,omitempty"`
VersionCode string `json:"versionCode"`
Status string `json:"status"`
TileBaseURL string `json:"tileBaseUrl"`
MetaURL string `json:"metaUrl"`
PublishedAssetRoot *string `json:"publishedAssetRoot,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
SetAsCurrent bool `json:"setAsCurrent"`
}
type AdminCourseSourceSummary struct {
ID string `json:"id"`
LegacyVersionID *string `json:"legacyVersionId,omitempty"`
SourceType string `json:"sourceType"`
FileURL string `json:"fileUrl"`
Checksum *string `json:"checksum,omitempty"`
ParserVersion *string `json:"parserVersion,omitempty"`
ImportStatus string `json:"importStatus"`
Metadata map[string]any `json:"metadata,omitempty"`
ImportedAt time.Time `json:"importedAt"`
}
type CreateAdminCourseSourceInput struct {
LegacyPlayfieldID *string `json:"legacyPlayfieldId,omitempty"`
LegacyVersionID *string `json:"legacyVersionId,omitempty"`
SourceType string `json:"sourceType"`
FileURL string `json:"fileUrl"`
Checksum *string `json:"checksum,omitempty"`
ParserVersion *string `json:"parserVersion,omitempty"`
ImportStatus string `json:"importStatus"`
Metadata map[string]any `json:"metadata,omitempty"`
}
type AdminCourseSetBrief struct {
ID string `json:"id"`
Code string `json:"code"`
Mode string `json:"mode"`
Name string `json:"name"`
Description *string `json:"description,omitempty"`
Status string `json:"status"`
CurrentVariant *AdminCourseVariantBrief `json:"currentVariant,omitempty"`
}
type AdminCourseVariantBrief struct {
ID string `json:"id"`
Name string `json:"name"`
RouteCode *string `json:"routeCode,omitempty"`
Status string `json:"status"`
}
type AdminCourseSetDetail struct {
CourseSet AdminCourseSetBrief `json:"courseSet"`
Variants []AdminCourseVariantView `json:"variants"`
}
type CreateAdminCourseSetInput struct {
Code string `json:"code"`
Mode string `json:"mode"`
Name string `json:"name"`
Description *string `json:"description,omitempty"`
Status string `json:"status"`
}
type AdminCourseVariantView struct {
ID string `json:"id"`
SourceID *string `json:"sourceId,omitempty"`
Name string `json:"name"`
RouteCode *string `json:"routeCode,omitempty"`
Mode string `json:"mode"`
ControlCount *int `json:"controlCount,omitempty"`
Difficulty *string `json:"difficulty,omitempty"`
Status string `json:"status"`
IsDefault bool `json:"isDefault"`
ConfigPatch map[string]any `json:"configPatch,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
}
type CreateAdminCourseVariantInput struct {
SourceID *string `json:"sourceId,omitempty"`
Name string `json:"name"`
RouteCode *string `json:"routeCode,omitempty"`
Mode string `json:"mode"`
ControlCount *int `json:"controlCount,omitempty"`
Difficulty *string `json:"difficulty,omitempty"`
Status string `json:"status"`
IsDefault bool `json:"isDefault"`
ConfigPatch map[string]any `json:"configPatch,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
}
type AdminRuntimeBindingSummary struct {
ID string `json:"id"`
EventID string `json:"eventId"`
PlaceID string `json:"placeId"`
MapAssetID string `json:"mapAssetId"`
TileReleaseID string `json:"tileReleaseId"`
CourseSetID string `json:"courseSetId"`
CourseVariantID string `json:"courseVariantId"`
Status string `json:"status"`
Notes *string `json:"notes,omitempty"`
}
type CreateAdminRuntimeBindingInput struct {
EventID string `json:"eventId"`
PlaceID string `json:"placeId"`
MapAssetID string `json:"mapAssetId"`
TileReleaseID string `json:"tileReleaseId"`
CourseSetID string `json:"courseSetId"`
CourseVariantID string `json:"courseVariantId"`
Status string `json:"status"`
Notes *string `json:"notes,omitempty"`
}
func NewAdminProductionService(store *postgres.Store) *AdminProductionService {
return &AdminProductionService{store: store}
}
func (s *AdminProductionService) ListPlaces(ctx context.Context, limit int) ([]AdminPlaceSummary, error) {
items, err := s.store.ListPlaces(ctx, limit)
if err != nil {
return nil, err
}
result := make([]AdminPlaceSummary, 0, len(items))
for _, item := range items {
result = append(result, buildAdminPlaceSummary(item))
}
return result, nil
}
func (s *AdminProductionService) CreatePlace(ctx context.Context, input CreateAdminPlaceInput) (*AdminPlaceSummary, error) {
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("place")
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.CreatePlace(ctx, tx, postgres.CreatePlaceParams{
PublicID: publicID,
Code: input.Code,
Name: input.Name,
Region: trimStringPtr(input.Region),
CoverURL: trimStringPtr(input.CoverURL),
Description: trimStringPtr(input.Description),
CenterPoint: input.CenterPoint,
Status: normalizeCatalogStatus(input.Status),
})
if err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
result := buildAdminPlaceSummary(*item)
return &result, nil
}
func (s *AdminProductionService) GetPlaceDetail(ctx context.Context, placePublicID string) (*AdminPlaceDetail, error) {
place, err := s.store.GetPlaceByPublicID(ctx, strings.TrimSpace(placePublicID))
if err != nil {
return nil, err
}
if place == nil {
return nil, apperr.New(http.StatusNotFound, "place_not_found", "place not found")
}
mapAssets, err := s.store.ListMapAssetsByPlaceID(ctx, place.ID)
if err != nil {
return nil, err
}
result := &AdminPlaceDetail{
Place: buildAdminPlaceSummary(*place),
MapAssets: make([]AdminMapAssetSummary, 0, len(mapAssets)),
}
for _, item := range mapAssets {
summary, err := s.buildAdminMapAssetSummary(ctx, item)
if err != nil {
return nil, err
}
result.MapAssets = append(result.MapAssets, summary)
}
return result, nil
}
func (s *AdminProductionService) CreateMapAsset(ctx context.Context, placePublicID string, input CreateAdminMapAssetInput) (*AdminMapAssetSummary, error) {
place, err := s.store.GetPlaceByPublicID(ctx, strings.TrimSpace(placePublicID))
if err != nil {
return nil, err
}
if place == nil {
return nil, apperr.New(http.StatusNotFound, "place_not_found", "place not found")
}
input.Code = strings.TrimSpace(input.Code)
input.Name = strings.TrimSpace(input.Name)
mapType := strings.TrimSpace(input.MapType)
if mapType == "" {
mapType = "standard"
}
if input.Code == "" || input.Name == "" {
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "code and name are required")
}
var legacyMapID *string
if input.LegacyMapID != nil && strings.TrimSpace(*input.LegacyMapID) != "" {
legacyMap, err := s.store.GetResourceMapByPublicID(ctx, strings.TrimSpace(*input.LegacyMapID))
if err != nil {
return nil, err
}
if legacyMap == nil {
return nil, apperr.New(http.StatusNotFound, "legacy_map_not_found", "legacy map not found")
}
legacyMapID = &legacyMap.ID
}
publicID, err := security.GeneratePublicID("mapasset")
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.CreateMapAsset(ctx, tx, postgres.CreateMapAssetParams{
PublicID: publicID,
PlaceID: place.ID,
LegacyMapID: legacyMapID,
Code: input.Code,
Name: input.Name,
MapType: mapType,
CoverURL: trimStringPtr(input.CoverURL),
Description: trimStringPtr(input.Description),
Status: normalizeCatalogStatus(input.Status),
})
if err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
result, err := s.buildAdminMapAssetSummary(ctx, *item)
if err != nil {
return nil, err
}
return &result, nil
}
func (s *AdminProductionService) GetMapAssetDetail(ctx context.Context, mapAssetPublicID string) (*AdminMapAssetDetail, error) {
item, err := s.store.GetMapAssetByPublicID(ctx, strings.TrimSpace(mapAssetPublicID))
if err != nil {
return nil, err
}
if item == nil {
return nil, apperr.New(http.StatusNotFound, "map_asset_not_found", "map asset not found")
}
summary, err := s.buildAdminMapAssetSummary(ctx, *item)
if err != nil {
return nil, err
}
tileReleases, err := s.store.ListTileReleasesByMapAssetID(ctx, item.ID)
if err != nil {
return nil, err
}
courseSets, err := s.store.ListCourseSetsByMapAssetID(ctx, item.ID)
if err != nil {
return nil, err
}
result := &AdminMapAssetDetail{
MapAsset: summary,
TileReleases: make([]AdminTileReleaseView, 0, len(tileReleases)),
CourseSets: make([]AdminCourseSetBrief, 0, len(courseSets)),
}
for _, release := range tileReleases {
result.TileReleases = append(result.TileReleases, buildAdminTileReleaseView(release))
}
for _, courseSet := range courseSets {
brief, err := s.buildAdminCourseSetBrief(ctx, courseSet)
if err != nil {
return nil, err
}
result.CourseSets = append(result.CourseSets, brief)
}
return result, nil
}
func (s *AdminProductionService) CreateTileRelease(ctx context.Context, mapAssetPublicID string, input CreateAdminTileReleaseInput) (*AdminTileReleaseView, error) {
mapAsset, err := s.store.GetMapAssetByPublicID(ctx, strings.TrimSpace(mapAssetPublicID))
if err != nil {
return nil, err
}
if mapAsset == nil {
return nil, apperr.New(http.StatusNotFound, "map_asset_not_found", "map asset not found")
}
input.VersionCode = strings.TrimSpace(input.VersionCode)
input.TileBaseURL = strings.TrimSpace(input.TileBaseURL)
input.MetaURL = strings.TrimSpace(input.MetaURL)
if input.VersionCode == "" || input.TileBaseURL == "" || input.MetaURL == "" {
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "versionCode, tileBaseUrl and metaUrl are required")
}
var legacyVersionID *string
if input.LegacyVersionID != nil && strings.TrimSpace(*input.LegacyVersionID) != "" {
if mapAsset.LegacyMapPublicID == nil || strings.TrimSpace(*mapAsset.LegacyMapPublicID) == "" {
return nil, apperr.New(http.StatusBadRequest, "legacy_map_missing", "map asset has no linked legacy map")
}
legacyVersion, err := s.store.GetResourceMapVersionByPublicID(ctx, *mapAsset.LegacyMapPublicID, strings.TrimSpace(*input.LegacyVersionID))
if err != nil {
return nil, err
}
if legacyVersion == nil {
return nil, apperr.New(http.StatusNotFound, "legacy_tile_version_not_found", "legacy map version not found")
}
legacyVersionID = &legacyVersion.ID
}
publicID, err := security.GeneratePublicID("tile")
if err != nil {
return nil, err
}
tx, err := s.store.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
publishedAt := time.Now()
release, err := s.store.CreateTileRelease(ctx, tx, postgres.CreateTileReleaseParams{
PublicID: publicID,
MapAssetID: mapAsset.ID,
LegacyMapVersionID: legacyVersionID,
VersionCode: input.VersionCode,
Status: normalizeReleaseStatus(input.Status),
TileBaseURL: input.TileBaseURL,
MetaURL: input.MetaURL,
PublishedAssetRoot: trimStringPtr(input.PublishedAssetRoot),
MetadataJSON: input.Metadata,
PublishedAt: &publishedAt,
})
if err != nil {
return nil, err
}
if input.SetAsCurrent {
if err := s.store.SetMapAssetCurrentTileRelease(ctx, tx, mapAsset.ID, release.ID); err != nil {
return nil, err
}
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
view := buildAdminTileReleaseView(*release)
return &view, nil
}
func (s *AdminProductionService) ListCourseSources(ctx context.Context, limit int) ([]AdminCourseSourceSummary, error) {
items, err := s.store.ListCourseSources(ctx, limit)
if err != nil {
return nil, err
}
result := make([]AdminCourseSourceSummary, 0, len(items))
for _, item := range items {
result = append(result, buildAdminCourseSourceSummary(item))
}
return result, nil
}
func (s *AdminProductionService) CreateCourseSource(ctx context.Context, input CreateAdminCourseSourceInput) (*AdminCourseSourceSummary, error) {
sourceType := strings.TrimSpace(input.SourceType)
fileURL := strings.TrimSpace(input.FileURL)
if sourceType == "" || fileURL == "" {
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "sourceType and fileUrl are required")
}
var legacyPlayfieldVersionID *string
if input.LegacyPlayfieldID != nil && input.LegacyVersionID != nil && strings.TrimSpace(*input.LegacyPlayfieldID) != "" && strings.TrimSpace(*input.LegacyVersionID) != "" {
version, err := s.store.GetResourcePlayfieldVersionByPublicID(ctx, strings.TrimSpace(*input.LegacyPlayfieldID), strings.TrimSpace(*input.LegacyVersionID))
if err != nil {
return nil, err
}
if version == nil {
return nil, apperr.New(http.StatusNotFound, "legacy_playfield_version_not_found", "legacy playfield version not found")
}
legacyPlayfieldVersionID = &version.ID
}
publicID, err := security.GeneratePublicID("csrc")
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.CreateCourseSource(ctx, tx, postgres.CreateCourseSourceParams{
PublicID: publicID,
LegacyPlayfieldVersionID: legacyPlayfieldVersionID,
SourceType: sourceType,
FileURL: fileURL,
Checksum: trimStringPtr(input.Checksum),
ParserVersion: trimStringPtr(input.ParserVersion),
ImportStatus: normalizeCourseSourceStatus(input.ImportStatus),
MetadataJSON: input.Metadata,
})
if err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
result := buildAdminCourseSourceSummary(*item)
return &result, nil
}
func (s *AdminProductionService) GetCourseSource(ctx context.Context, sourcePublicID string) (*AdminCourseSourceSummary, error) {
item, err := s.store.GetCourseSourceByPublicID(ctx, strings.TrimSpace(sourcePublicID))
if err != nil {
return nil, err
}
if item == nil {
return nil, apperr.New(http.StatusNotFound, "course_source_not_found", "course source not found")
}
result := buildAdminCourseSourceSummary(*item)
return &result, nil
}
func (s *AdminProductionService) CreateCourseSet(ctx context.Context, mapAssetPublicID string, input CreateAdminCourseSetInput) (*AdminCourseSetBrief, error) {
mapAsset, err := s.store.GetMapAssetByPublicID(ctx, strings.TrimSpace(mapAssetPublicID))
if err != nil {
return nil, err
}
if mapAsset == nil {
return nil, apperr.New(http.StatusNotFound, "map_asset_not_found", "map asset not found")
}
input.Code = strings.TrimSpace(input.Code)
input.Mode = strings.TrimSpace(input.Mode)
input.Name = strings.TrimSpace(input.Name)
if input.Code == "" || input.Mode == "" || input.Name == "" {
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "code, mode and name are required")
}
publicID, err := security.GeneratePublicID("cset")
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.CreateCourseSet(ctx, tx, postgres.CreateCourseSetParams{
PublicID: publicID,
PlaceID: mapAsset.PlaceID,
MapAssetID: mapAsset.ID,
Code: input.Code,
Mode: input.Mode,
Name: input.Name,
Description: trimStringPtr(input.Description),
Status: normalizeCatalogStatus(input.Status),
})
if err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
brief, err := s.buildAdminCourseSetBrief(ctx, *item)
if err != nil {
return nil, err
}
return &brief, nil
}
func (s *AdminProductionService) GetCourseSetDetail(ctx context.Context, courseSetPublicID string) (*AdminCourseSetDetail, error) {
item, err := s.store.GetCourseSetByPublicID(ctx, strings.TrimSpace(courseSetPublicID))
if err != nil {
return nil, err
}
if item == nil {
return nil, apperr.New(http.StatusNotFound, "course_set_not_found", "course set not found")
}
brief, err := s.buildAdminCourseSetBrief(ctx, *item)
if err != nil {
return nil, err
}
variants, err := s.store.ListCourseVariantsByCourseSetID(ctx, item.ID)
if err != nil {
return nil, err
}
result := &AdminCourseSetDetail{
CourseSet: brief,
Variants: make([]AdminCourseVariantView, 0, len(variants)),
}
for _, variant := range variants {
result.Variants = append(result.Variants, buildAdminCourseVariantView(variant))
}
return result, nil
}
func (s *AdminProductionService) CreateCourseVariant(ctx context.Context, courseSetPublicID string, input CreateAdminCourseVariantInput) (*AdminCourseVariantView, error) {
courseSet, err := s.store.GetCourseSetByPublicID(ctx, strings.TrimSpace(courseSetPublicID))
if err != nil {
return nil, err
}
if courseSet == nil {
return nil, apperr.New(http.StatusNotFound, "course_set_not_found", "course set not found")
}
input.Name = strings.TrimSpace(input.Name)
input.Mode = strings.TrimSpace(input.Mode)
if input.Name == "" || input.Mode == "" {
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "name and mode are required")
}
var sourceID *string
if input.SourceID != nil && strings.TrimSpace(*input.SourceID) != "" {
source, err := s.store.GetCourseSourceByPublicID(ctx, strings.TrimSpace(*input.SourceID))
if err != nil {
return nil, err
}
if source == nil {
return nil, apperr.New(http.StatusNotFound, "course_source_not_found", "course source not found")
}
sourceID = &source.ID
}
publicID, err := security.GeneratePublicID("cvar")
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.CreateCourseVariant(ctx, tx, postgres.CreateCourseVariantParams{
PublicID: publicID,
CourseSetID: courseSet.ID,
SourceID: sourceID,
Name: input.Name,
RouteCode: trimStringPtr(input.RouteCode),
Mode: input.Mode,
ControlCount: input.ControlCount,
Difficulty: trimStringPtr(input.Difficulty),
Status: normalizeCatalogStatus(input.Status),
IsDefault: input.IsDefault,
ConfigPatch: input.ConfigPatch,
MetadataJSON: input.Metadata,
})
if err != nil {
return nil, err
}
if input.IsDefault {
if err := s.store.SetCourseSetCurrentVariant(ctx, tx, courseSet.ID, item.ID); err != nil {
return nil, err
}
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
view := buildAdminCourseVariantView(*item)
return &view, nil
}
func (s *AdminProductionService) ListRuntimeBindings(ctx context.Context, limit int) ([]AdminRuntimeBindingSummary, error) {
items, err := s.store.ListMapRuntimeBindings(ctx, limit)
if err != nil {
return nil, err
}
result := make([]AdminRuntimeBindingSummary, 0, len(items))
for _, item := range items {
result = append(result, buildAdminRuntimeBindingSummary(item))
}
return result, nil
}
func (s *AdminProductionService) CreateRuntimeBinding(ctx context.Context, input CreateAdminRuntimeBindingInput) (*AdminRuntimeBindingSummary, error) {
eventRecord, err := s.store.GetAdminEventByPublicID(ctx, strings.TrimSpace(input.EventID))
if err != nil {
return nil, err
}
if eventRecord == nil {
return nil, apperr.New(http.StatusNotFound, "event_not_found", "event not found")
}
place, err := s.store.GetPlaceByPublicID(ctx, strings.TrimSpace(input.PlaceID))
if err != nil {
return nil, err
}
if place == nil {
return nil, apperr.New(http.StatusNotFound, "place_not_found", "place not found")
}
mapAsset, err := s.store.GetMapAssetByPublicID(ctx, strings.TrimSpace(input.MapAssetID))
if err != nil {
return nil, err
}
if mapAsset == nil || mapAsset.PlaceID != place.ID {
return nil, apperr.New(http.StatusBadRequest, "map_asset_mismatch", "map asset does not belong to place")
}
tileRelease, err := s.store.GetTileReleaseByPublicID(ctx, strings.TrimSpace(input.TileReleaseID))
if err != nil {
return nil, err
}
if tileRelease == nil || tileRelease.MapAssetID != mapAsset.ID {
return nil, apperr.New(http.StatusBadRequest, "tile_release_mismatch", "tile release does not belong to map asset")
}
courseSet, err := s.store.GetCourseSetByPublicID(ctx, strings.TrimSpace(input.CourseSetID))
if err != nil {
return nil, err
}
if courseSet == nil || courseSet.PlaceID != place.ID || courseSet.MapAssetID != mapAsset.ID {
return nil, apperr.New(http.StatusBadRequest, "course_set_mismatch", "course set does not match place/map asset")
}
courseVariant, err := s.store.GetCourseVariantByPublicID(ctx, strings.TrimSpace(input.CourseVariantID))
if err != nil {
return nil, err
}
if courseVariant == nil || courseVariant.CourseSetID != courseSet.ID {
return nil, apperr.New(http.StatusBadRequest, "course_variant_mismatch", "course variant does not belong to course set")
}
publicID, err := security.GeneratePublicID("rtbind")
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.CreateMapRuntimeBinding(ctx, tx, postgres.CreateMapRuntimeBindingParams{
PublicID: publicID,
EventID: eventRecord.ID,
PlaceID: place.ID,
MapAssetID: mapAsset.ID,
TileReleaseID: tileRelease.ID,
CourseSetID: courseSet.ID,
CourseVariantID: courseVariant.ID,
Status: normalizeRuntimeBindingStatus(input.Status),
Notes: trimStringPtr(input.Notes),
})
if err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
created, err := s.store.GetMapRuntimeBindingByPublicID(ctx, item.PublicID)
if err != nil {
return nil, err
}
if created == nil {
return nil, apperr.New(http.StatusNotFound, "runtime_binding_not_found", "runtime binding not found")
}
result := buildAdminRuntimeBindingSummary(*created)
return &result, nil
}
func (s *AdminProductionService) GetRuntimeBinding(ctx context.Context, runtimeBindingPublicID string) (*AdminRuntimeBindingSummary, error) {
item, err := s.store.GetMapRuntimeBindingByPublicID(ctx, strings.TrimSpace(runtimeBindingPublicID))
if err != nil {
return nil, err
}
if item == nil {
return nil, apperr.New(http.StatusNotFound, "runtime_binding_not_found", "runtime binding not found")
}
result := buildAdminRuntimeBindingSummary(*item)
return &result, nil
}
func (s *AdminProductionService) buildAdminMapAssetSummary(ctx context.Context, item postgres.MapAsset) (AdminMapAssetSummary, error) {
result := AdminMapAssetSummary{
ID: item.PublicID,
PlaceID: item.PlaceID,
LegacyMapID: item.LegacyMapPublicID,
Code: item.Code,
Name: item.Name,
MapType: item.MapType,
CoverURL: item.CoverURL,
Description: item.Description,
Status: item.Status,
}
if item.CurrentTileReleaseID != nil {
releases, err := s.store.ListTileReleasesByMapAssetID(ctx, item.ID)
if err != nil {
return result, err
}
for _, release := range releases {
if release.ID == *item.CurrentTileReleaseID {
result.CurrentTileRelease = &AdminTileReleaseBrief{
ID: release.PublicID,
VersionCode: release.VersionCode,
Status: release.Status,
}
break
}
}
}
return result, nil
}
func (s *AdminProductionService) buildAdminCourseSetBrief(ctx context.Context, item postgres.CourseSet) (AdminCourseSetBrief, error) {
result := AdminCourseSetBrief{
ID: item.PublicID,
Code: item.Code,
Mode: item.Mode,
Name: item.Name,
Description: item.Description,
Status: item.Status,
}
if item.CurrentVariantID != nil {
variants, err := s.store.ListCourseVariantsByCourseSetID(ctx, item.ID)
if err != nil {
return result, err
}
for _, variant := range variants {
if variant.ID == *item.CurrentVariantID {
result.CurrentVariant = &AdminCourseVariantBrief{
ID: variant.PublicID,
Name: variant.Name,
RouteCode: variant.RouteCode,
Status: variant.Status,
}
break
}
}
}
return result, nil
}
func buildAdminPlaceSummary(item postgres.Place) AdminPlaceSummary {
return AdminPlaceSummary{
ID: item.PublicID,
Code: item.Code,
Name: item.Name,
Region: item.Region,
CoverURL: item.CoverURL,
Description: item.Description,
CenterPoint: decodeJSONMap(item.CenterPoint),
Status: item.Status,
}
}
func buildAdminTileReleaseView(item postgres.TileRelease) AdminTileReleaseView {
return AdminTileReleaseView{
ID: item.PublicID,
LegacyVersionID: item.LegacyMapVersionPub,
VersionCode: item.VersionCode,
Status: item.Status,
TileBaseURL: item.TileBaseURL,
MetaURL: item.MetaURL,
PublishedAssetRoot: item.PublishedAssetRoot,
Metadata: decodeJSONMap(item.MetadataJSON),
PublishedAt: item.PublishedAt,
}
}
func buildAdminCourseSourceSummary(item postgres.CourseSource) AdminCourseSourceSummary {
return AdminCourseSourceSummary{
ID: item.PublicID,
LegacyVersionID: item.LegacyPlayfieldVersionPub,
SourceType: item.SourceType,
FileURL: item.FileURL,
Checksum: item.Checksum,
ParserVersion: item.ParserVersion,
ImportStatus: item.ImportStatus,
Metadata: decodeJSONMap(item.MetadataJSON),
ImportedAt: item.ImportedAt,
}
}
func buildAdminCourseVariantView(item postgres.CourseVariant) AdminCourseVariantView {
return AdminCourseVariantView{
ID: item.PublicID,
SourceID: item.SourcePublicID,
Name: item.Name,
RouteCode: item.RouteCode,
Mode: item.Mode,
ControlCount: item.ControlCount,
Difficulty: item.Difficulty,
Status: item.Status,
IsDefault: item.IsDefault,
ConfigPatch: decodeJSONMap(item.ConfigPatch),
Metadata: decodeJSONMap(item.MetadataJSON),
}
}
func buildAdminRuntimeBindingSummary(item postgres.MapRuntimeBinding) AdminRuntimeBindingSummary {
return AdminRuntimeBindingSummary{
ID: item.PublicID,
EventID: item.EventPublicID,
PlaceID: item.PlacePublicID,
MapAssetID: item.MapAssetPublicID,
TileReleaseID: item.TileReleasePublicID,
CourseSetID: item.CourseSetPublicID,
CourseVariantID: item.CourseVariantPublicID,
Status: item.Status,
Notes: item.Notes,
}
}
func normalizeCourseSourceStatus(value string) string {
switch strings.TrimSpace(value) {
case "draft":
return "draft"
case "parsed":
return "parsed"
case "failed":
return "failed"
case "archived":
return "archived"
default:
return "imported"
}
}
func normalizeRuntimeBindingStatus(value string) string {
switch strings.TrimSpace(value) {
case "active":
return "active"
case "disabled":
return "disabled"
case "archived":
return "archived"
default:
return "draft"
}
}
func normalizeReleaseStatus(value string) string {
switch strings.TrimSpace(value) {
case "active":
return "active"
case "published":
return "published"
case "retired":
return "retired"
case "archived":
return "archived"
default:
return "draft"
}
}

View File

@@ -58,10 +58,13 @@ type EventConfigBuildView struct {
}
type PublishedReleaseView struct {
EventID string `json:"eventId"`
Release ResolvedReleaseView `json:"release"`
ReleaseNo int `json:"releaseNo"`
PublishedAt string `json:"publishedAt"`
EventID string `json:"eventId"`
Release ResolvedReleaseView `json:"release"`
ReleaseNo int `json:"releaseNo"`
PublishedAt string `json:"publishedAt"`
Runtime *RuntimeSummaryView `json:"runtime,omitempty"`
Presentation *PresentationSummaryView `json:"presentation,omitempty"`
ContentBundle *ContentBundleSummaryView `json:"contentBundle,omitempty"`
}
type ImportLocalEventConfigInput struct {
@@ -75,7 +78,10 @@ type BuildPreviewInput struct {
}
type PublishBuildInput struct {
BuildID string `json:"buildId"`
BuildID string `json:"buildId"`
RuntimeBindingID string `json:"runtimeBindingId,omitempty"`
PresentationID string `json:"presentationId,omitempty"`
ContentBundleID string `json:"contentBundleId,omitempty"`
}
func NewConfigService(store *postgres.Store, localEventDir, assetBaseURL string, publisher *assets.OSSUtilPublisher) *ConfigService {
@@ -306,6 +312,19 @@ func (s *ConfigService) PublishBuild(ctx context.Context, input PublishBuildInpu
return nil, apperr.New(http.StatusNotFound, "event_not_found", "event not found")
}
runtimeBindingID, runtimeSummary, err := s.resolvePublishRuntimeBinding(ctx, event.ID, input.RuntimeBindingID)
if err != nil {
return nil, err
}
presentationID, presentationSummary, err := s.resolvePublishPresentation(ctx, event.ID, input.PresentationID)
if err != nil {
return nil, err
}
contentBundleID, contentBundleSummary, err := s.resolvePublishContentBundle(ctx, event.ID, input.ContentBundleID)
if err != nil {
return nil, err
}
manifest, err := decodeJSONObject(buildRecord.ManifestJSON)
if err != nil {
return nil, apperr.New(http.StatusInternalServerError, "config_build_invalid", "stored build manifest is invalid")
@@ -355,6 +374,9 @@ func (s *ConfigService) PublishBuild(ctx context.Context, input PublishBuildInpu
ManifestChecksum: &checksum,
RouteCode: routeCode,
BuildID: &buildRecord.ID,
RuntimeBindingID: runtimeBindingID,
PresentationID: presentationID,
ContentBundleID: contentBundleID,
Status: "published",
PayloadJSON: buildRecord.ManifestJSON,
})
@@ -386,11 +408,160 @@ func (s *ConfigService) PublishBuild(ctx context.Context, input PublishBuildInpu
ManifestChecksumSha256: releaseRecord.ManifestChecksum,
RouteCode: releaseRecord.RouteCode,
},
ReleaseNo: releaseRecord.ReleaseNo,
PublishedAt: releaseRecord.PublishedAt.Format(timeRFC3339),
ReleaseNo: releaseRecord.ReleaseNo,
PublishedAt: releaseRecord.PublishedAt.Format(timeRFC3339),
Runtime: runtimeSummary,
Presentation: presentationSummary,
ContentBundle: contentBundleSummary,
}, nil
}
func (s *ConfigService) resolvePublishRuntimeBinding(ctx context.Context, eventID string, runtimeBindingPublicID string) (*string, *RuntimeSummaryView, error) {
runtimeBindingPublicID = strings.TrimSpace(runtimeBindingPublicID)
if runtimeBindingPublicID == "" {
defaults, err := s.store.GetEventDefaultBindingsByEventID(ctx, eventID)
if err != nil {
return nil, nil, err
}
if defaults == nil || defaults.RuntimeBindingID == nil || defaults.RuntimeBindingPublicID == nil || defaults.PlacePublicID == nil || defaults.MapAssetPublicID == nil || defaults.TileReleasePublicID == nil || defaults.CourseSetPublicID == nil || defaults.CourseVariantPublicID == nil {
return nil, nil, nil
}
return defaults.RuntimeBindingID, &RuntimeSummaryView{
RuntimeBindingID: *defaults.RuntimeBindingPublicID,
PlaceID: *defaults.PlacePublicID,
PlaceName: defaults.PlaceName,
MapID: *defaults.MapAssetPublicID,
MapName: defaults.MapAssetName,
TileReleaseID: *defaults.TileReleasePublicID,
CourseSetID: *defaults.CourseSetPublicID,
CourseVariantID: *defaults.CourseVariantPublicID,
CourseVariantName: defaults.CourseVariantName,
RouteCode: defaults.RuntimeRouteCode,
}, nil
}
runtimeBinding, err := s.store.GetMapRuntimeBindingByPublicID(ctx, runtimeBindingPublicID)
if err != nil {
return nil, nil, err
}
if runtimeBinding == nil {
return nil, nil, apperr.New(http.StatusNotFound, "runtime_binding_not_found", "runtime binding not found")
}
if runtimeBinding.EventID != eventID {
return nil, nil, apperr.New(http.StatusConflict, "runtime_binding_not_belong_to_event", "runtime binding does not belong to build event")
}
return &runtimeBinding.ID, &RuntimeSummaryView{
RuntimeBindingID: runtimeBinding.PublicID,
PlaceID: runtimeBinding.PlacePublicID,
MapID: runtimeBinding.MapAssetPublicID,
TileReleaseID: runtimeBinding.TileReleasePublicID,
CourseSetID: runtimeBinding.CourseSetPublicID,
CourseVariantID: runtimeBinding.CourseVariantPublicID,
RouteCode: nil,
}, nil
}
func (s *ConfigService) resolvePublishPresentation(ctx context.Context, eventID string, presentationPublicID string) (*string, *PresentationSummaryView, error) {
presentationPublicID = strings.TrimSpace(presentationPublicID)
if presentationPublicID == "" {
defaults, err := s.store.GetEventDefaultBindingsByEventID(ctx, eventID)
if err != nil {
return nil, nil, err
}
if defaults != nil && defaults.PresentationID != nil && defaults.PresentationPublicID != nil {
record, err := s.store.GetEventPresentationByPublicID(ctx, *defaults.PresentationPublicID)
if err != nil {
return nil, nil, err
}
if record != nil {
summary, err := buildPresentationSummaryFromRecord(record)
if err != nil {
return nil, nil, err
}
return defaults.PresentationID, summary, nil
}
}
record, err := s.store.GetDefaultEventPresentationByEventID(ctx, eventID)
if err != nil {
return nil, nil, err
}
if record == nil {
return nil, nil, nil
}
summary, err := buildPresentationSummaryFromRecord(record)
if err != nil {
return nil, nil, err
}
return &record.ID, summary, nil
}
record, err := s.store.GetEventPresentationByPublicID(ctx, presentationPublicID)
if err != nil {
return nil, nil, err
}
if record == nil {
return nil, nil, apperr.New(http.StatusNotFound, "presentation_not_found", "presentation not found")
}
if record.EventID != eventID {
return nil, nil, apperr.New(http.StatusConflict, "presentation_not_belong_to_event", "presentation does not belong to build event")
}
summary, err := buildPresentationSummaryFromRecord(record)
if err != nil {
return nil, nil, err
}
return &record.ID, summary, nil
}
func (s *ConfigService) resolvePublishContentBundle(ctx context.Context, eventID string, contentBundlePublicID string) (*string, *ContentBundleSummaryView, error) {
contentBundlePublicID = strings.TrimSpace(contentBundlePublicID)
if contentBundlePublicID == "" {
defaults, err := s.store.GetEventDefaultBindingsByEventID(ctx, eventID)
if err != nil {
return nil, nil, err
}
if defaults != nil && defaults.ContentBundleID != nil && defaults.ContentBundlePublicID != nil {
record, err := s.store.GetContentBundleByPublicID(ctx, *defaults.ContentBundlePublicID)
if err != nil {
return nil, nil, err
}
if record != nil {
summary, err := buildContentBundleSummaryFromRecord(record)
if err != nil {
return nil, nil, err
}
return defaults.ContentBundleID, summary, nil
}
}
record, err := s.store.GetDefaultContentBundleByEventID(ctx, eventID)
if err != nil {
return nil, nil, err
}
if record == nil {
return nil, nil, nil
}
summary, err := buildContentBundleSummaryFromRecord(record)
if err != nil {
return nil, nil, err
}
return &record.ID, summary, nil
}
record, err := s.store.GetContentBundleByPublicID(ctx, contentBundlePublicID)
if err != nil {
return nil, nil, err
}
if record == nil {
return nil, nil, apperr.New(http.StatusNotFound, "content_bundle_not_found", "content bundle not found")
}
if record.EventID != eventID {
return nil, nil, apperr.New(http.StatusConflict, "content_bundle_not_belong_to_event", "content bundle does not belong to build event")
}
summary, err := buildContentBundleSummaryFromRecord(record)
if err != nil {
return nil, nil, err
}
return &record.ID, summary, nil
}
func (s *ConfigService) requireEvent(ctx context.Context, eventPublicID string) (*postgres.Event, error) {
eventPublicID = strings.TrimSpace(eventPublicID)
if eventPublicID == "" {

View File

@@ -33,8 +33,11 @@ type EventPlayResult struct {
ManifestChecksumSha256 *string `json:"manifestChecksumSha256,omitempty"`
RouteCode *string `json:"routeCode,omitempty"`
} `json:"release,omitempty"`
ResolvedRelease *ResolvedReleaseView `json:"resolvedRelease,omitempty"`
Play struct {
ResolvedRelease *ResolvedReleaseView `json:"resolvedRelease,omitempty"`
Runtime *RuntimeSummaryView `json:"runtime,omitempty"`
CurrentPresentation *PresentationSummaryView `json:"currentPresentation,omitempty"`
CurrentContentBundle *ContentBundleSummaryView `json:"currentContentBundle,omitempty"`
Play struct {
AssignmentMode *string `json:"assignmentMode,omitempty"`
CourseVariants []CourseVariantView `json:"courseVariants,omitempty"`
CanLaunch bool `json:"canLaunch"`
@@ -100,6 +103,19 @@ func (s *EventPlayService) GetEventPlay(ctx context.Context, input EventPlayInpu
}
}
result.ResolvedRelease = buildResolvedReleaseFromEvent(event, LaunchSourceEventCurrentRelease)
result.Runtime = buildRuntimeSummaryFromEvent(event)
result.CurrentPresentation = buildPresentationSummaryFromEvent(event)
if enrichedPresentation, err := loadPresentationSummaryByPublicID(ctx, s.store, event.PresentationID); err != nil {
return nil, err
} else if enrichedPresentation != nil {
result.CurrentPresentation = enrichedPresentation
}
result.CurrentContentBundle = buildContentBundleSummaryFromEvent(event)
if enrichedBundle, err := loadContentBundleSummaryByPublicID(ctx, s.store, event.ContentBundleID); err != nil {
return nil, err
} else if enrichedBundle != nil {
result.CurrentContentBundle = enrichedBundle
}
if len(sessions) > 0 {
recent := buildEntrySessionSummary(&sessions[0])

View File

@@ -30,7 +30,10 @@ type EventDetailResult struct {
ManifestChecksumSha256 *string `json:"manifestChecksumSha256,omitempty"`
RouteCode *string `json:"routeCode,omitempty"`
} `json:"release,omitempty"`
ResolvedRelease *ResolvedReleaseView `json:"resolvedRelease,omitempty"`
ResolvedRelease *ResolvedReleaseView `json:"resolvedRelease,omitempty"`
Runtime *RuntimeSummaryView `json:"runtime,omitempty"`
CurrentPresentation *PresentationSummaryView `json:"currentPresentation,omitempty"`
CurrentContentBundle *ContentBundleSummaryView `json:"currentContentBundle,omitempty"`
}
type LaunchEventInput struct {
@@ -48,9 +51,12 @@ type LaunchEventResult struct {
DisplayName string `json:"displayName"`
} `json:"event"`
Launch struct {
Source string `json:"source"`
ResolvedRelease *ResolvedReleaseView `json:"resolvedRelease,omitempty"`
Variant *VariantBindingView `json:"variant,omitempty"`
Source string `json:"source"`
ResolvedRelease *ResolvedReleaseView `json:"resolvedRelease,omitempty"`
Variant *VariantBindingView `json:"variant,omitempty"`
Runtime *RuntimeSummaryView `json:"runtime,omitempty"`
Presentation *PresentationSummaryView `json:"presentation,omitempty"`
ContentBundle *ContentBundleSummaryView `json:"contentBundle,omitempty"`
Config struct {
ConfigURL string `json:"configUrl"`
ConfigLabel string `json:"configLabel"`
@@ -110,6 +116,19 @@ func (s *EventService) GetEventDetail(ctx context.Context, eventPublicID string)
}
}
result.ResolvedRelease = buildResolvedReleaseFromEvent(event, LaunchSourceEventCurrentRelease)
result.Runtime = buildRuntimeSummaryFromEvent(event)
result.CurrentPresentation = buildPresentationSummaryFromEvent(event)
if enrichedPresentation, err := loadPresentationSummaryByPublicID(ctx, s.store, event.PresentationID); err != nil {
return nil, err
} else if enrichedPresentation != nil {
result.CurrentPresentation = enrichedPresentation
}
result.CurrentContentBundle = buildContentBundleSummaryFromEvent(event)
if enrichedBundle, err := loadContentBundleSummaryByPublicID(ctx, s.store, event.ContentBundleID); err != nil {
return nil, err
} else if enrichedBundle != nil {
result.CurrentContentBundle = enrichedBundle
}
return result, nil
}
@@ -205,6 +224,19 @@ func (s *EventService) LaunchEvent(ctx context.Context, input LaunchEventInput)
result.Launch.Source = LaunchSourceEventCurrentRelease
result.Launch.ResolvedRelease = buildResolvedReleaseFromEvent(event, LaunchSourceEventCurrentRelease)
result.Launch.Variant = variant
result.Launch.Runtime = buildRuntimeSummaryFromEvent(event)
result.Launch.Presentation = buildPresentationSummaryFromEvent(event)
if enrichedPresentation, err := loadPresentationSummaryByPublicID(ctx, s.store, event.PresentationID); err != nil {
return nil, err
} else if enrichedPresentation != nil {
result.Launch.Presentation = enrichedPresentation
}
result.Launch.ContentBundle = buildContentBundleSummaryFromEvent(event)
if enrichedBundle, err := loadContentBundleSummaryByPublicID(ctx, s.store, event.ContentBundleID); err != nil {
return nil, err
} else if enrichedBundle != nil {
result.Launch.ContentBundle = enrichedBundle
}
result.Launch.Config.ConfigURL = *event.ManifestURL
result.Launch.Config.ConfigLabel = *event.ConfigLabel
result.Launch.Config.ConfigChecksumSha256 = event.ManifestChecksum

View File

@@ -1,6 +1,11 @@
package service
import "cmr-backend/internal/store/postgres"
import (
"context"
"strings"
"cmr-backend/internal/store/postgres"
)
const (
LaunchSourceEventCurrentRelease = "event_current_release"
@@ -18,6 +23,36 @@ type ResolvedReleaseView struct {
RouteCode *string `json:"routeCode,omitempty"`
}
type RuntimeSummaryView struct {
RuntimeBindingID string `json:"runtimeBindingId"`
PlaceID string `json:"placeId"`
PlaceName *string `json:"placeName,omitempty"`
MapID string `json:"mapId"`
MapName *string `json:"mapName,omitempty"`
TileReleaseID string `json:"tileReleaseId"`
CourseSetID string `json:"courseSetId"`
CourseVariantID string `json:"courseVariantId"`
CourseVariantName *string `json:"courseVariantName,omitempty"`
RouteCode *string `json:"routeCode,omitempty"`
}
type PresentationSummaryView struct {
PresentationID string `json:"presentationId"`
Name *string `json:"name,omitempty"`
PresentationType *string `json:"presentationType,omitempty"`
TemplateKey *string `json:"templateKey,omitempty"`
Version *string `json:"version,omitempty"`
}
type ContentBundleSummaryView struct {
ContentBundleID string `json:"contentBundleId"`
Name *string `json:"name,omitempty"`
BundleType *string `json:"bundleType,omitempty"`
Version *string `json:"version,omitempty"`
EntryURL *string `json:"entryUrl,omitempty"`
AssetRootURL *string `json:"assetRootUrl,omitempty"`
}
func buildResolvedReleaseFromEvent(event *postgres.Event, source string) *ResolvedReleaseView {
if event == nil || event.CurrentReleasePubID == nil || event.ConfigLabel == nil || event.ManifestURL == nil {
return nil
@@ -35,6 +70,102 @@ func buildResolvedReleaseFromEvent(event *postgres.Event, source string) *Resolv
}
}
func buildRuntimeSummaryFromEvent(event *postgres.Event) *RuntimeSummaryView {
if event == nil ||
event.RuntimeBindingID == nil ||
event.PlacePublicID == nil ||
event.MapAssetPublicID == nil ||
event.TileReleasePublicID == nil ||
event.CourseSetPublicID == nil ||
event.CourseVariantID == nil {
return nil
}
return &RuntimeSummaryView{
RuntimeBindingID: *event.RuntimeBindingID,
PlaceID: *event.PlacePublicID,
PlaceName: event.PlaceName,
MapID: *event.MapAssetPublicID,
MapName: event.MapAssetName,
TileReleaseID: *event.TileReleasePublicID,
CourseSetID: *event.CourseSetPublicID,
CourseVariantID: *event.CourseVariantID,
CourseVariantName: event.CourseVariantName,
RouteCode: firstNonNilString(event.RuntimeRouteCode, event.RouteCode),
}
}
func buildRuntimeSummaryFromRelease(release *postgres.EventRelease) *RuntimeSummaryView {
if release == nil ||
release.RuntimeBindingID == nil ||
release.PlacePublicID == nil ||
release.MapAssetPublicID == nil ||
release.TileReleaseID == nil ||
release.CourseSetID == nil ||
release.CourseVariantID == nil {
return nil
}
return &RuntimeSummaryView{
RuntimeBindingID: *release.RuntimeBindingID,
PlaceID: *release.PlacePublicID,
PlaceName: release.PlaceName,
MapID: *release.MapAssetPublicID,
MapName: release.MapAssetName,
TileReleaseID: *release.TileReleaseID,
CourseSetID: *release.CourseSetID,
CourseVariantID: *release.CourseVariantID,
CourseVariantName: release.CourseVariantName,
RouteCode: firstNonNilString(release.RuntimeRouteCode, release.RouteCode),
}
}
func buildPresentationSummaryFromEvent(event *postgres.Event) *PresentationSummaryView {
if event == nil || event.PresentationID == nil {
return nil
}
return &PresentationSummaryView{
PresentationID: *event.PresentationID,
Name: event.PresentationName,
PresentationType: event.PresentationType,
}
}
func buildPresentationSummaryFromRelease(release *postgres.EventRelease) *PresentationSummaryView {
if release == nil || release.PresentationID == nil {
return nil
}
return &PresentationSummaryView{
PresentationID: *release.PresentationID,
Name: release.PresentationName,
PresentationType: release.PresentationType,
}
}
func buildContentBundleSummaryFromEvent(event *postgres.Event) *ContentBundleSummaryView {
if event == nil || event.ContentBundleID == nil {
return nil
}
return &ContentBundleSummaryView{
ContentBundleID: *event.ContentBundleID,
Name: event.ContentBundleName,
EntryURL: event.ContentEntryURL,
AssetRootURL: event.ContentAssetRootURL,
}
}
func buildContentBundleSummaryFromRelease(release *postgres.EventRelease) *ContentBundleSummaryView {
if release == nil || release.ContentBundleID == nil {
return nil
}
return &ContentBundleSummaryView{
ContentBundleID: *release.ContentBundleID,
Name: release.ContentBundleName,
EntryURL: release.ContentEntryURL,
AssetRootURL: release.ContentAssetURL,
}
}
func buildResolvedReleaseFromSession(session *postgres.Session, source string) *ResolvedReleaseView {
if session == nil || session.ReleasePublicID == nil || session.ConfigLabel == nil || session.ManifestURL == nil {
return nil
@@ -54,3 +185,96 @@ func buildResolvedReleaseFromSession(session *postgres.Session, source string) *
}
return view
}
func loadPresentationSummaryByPublicID(ctx context.Context, store *postgres.Store, publicID *string) (*PresentationSummaryView, error) {
if store == nil || publicID == nil || strings.TrimSpace(*publicID) == "" {
return nil, nil
}
record, err := store.GetEventPresentationByPublicID(ctx, strings.TrimSpace(*publicID))
if err != nil {
return nil, err
}
if record == nil {
return nil, nil
}
return buildPresentationSummaryFromRecord(record)
}
func loadContentBundleSummaryByPublicID(ctx context.Context, store *postgres.Store, publicID *string) (*ContentBundleSummaryView, error) {
if store == nil || publicID == nil || strings.TrimSpace(*publicID) == "" {
return nil, nil
}
record, err := store.GetContentBundleByPublicID(ctx, strings.TrimSpace(*publicID))
if err != nil {
return nil, err
}
if record == nil {
return nil, nil
}
return buildContentBundleSummaryFromRecord(record)
}
func buildPresentationSummaryFromRecord(record *postgres.EventPresentation) (*PresentationSummaryView, error) {
if record == nil {
return nil, nil
}
summary := &PresentationSummaryView{
PresentationID: record.PublicID,
Name: &record.Name,
PresentationType: &record.PresentationType,
}
schema, err := decodeJSONObject(record.SchemaJSON)
if err != nil {
return nil, err
}
summary.TemplateKey = readStringField(schema, "templateKey")
summary.Version = readStringField(schema, "version")
return summary, nil
}
func buildContentBundleSummaryFromRecord(record *postgres.ContentBundle) (*ContentBundleSummaryView, error) {
if record == nil {
return nil, nil
}
summary := &ContentBundleSummaryView{
ContentBundleID: record.PublicID,
Name: &record.Name,
EntryURL: record.EntryURL,
AssetRootURL: record.AssetRootURL,
}
metadata, err := decodeJSONObject(record.MetadataJSON)
if err != nil {
return nil, err
}
summary.BundleType = readStringField(metadata, "bundleType")
summary.Version = readStringField(metadata, "version")
return summary, nil
}
func readStringField(object map[string]any, key string) *string {
if object == nil {
return nil
}
value, ok := object[key]
if !ok {
return nil
}
text, ok := value.(string)
if !ok {
return nil
}
text = strings.TrimSpace(text)
if text == "" {
return nil
}
return &text
}
func firstNonNilString(values ...*string) *string {
for _, value := range values {
if value != nil {
return value
}
}
return nil
}

View File

@@ -16,21 +16,43 @@ type Tenant struct {
}
type AdminEventRecord struct {
ID string
PublicID string
TenantID *string
TenantCode *string
TenantName *string
Slug string
DisplayName string
Summary *string
Status string
CurrentReleaseID *string
CurrentReleasePubID *string
ConfigLabel *string
ManifestURL *string
ManifestChecksum *string
RouteCode *string
ID string
PublicID string
TenantID *string
TenantCode *string
TenantName *string
Slug string
DisplayName string
Summary *string
Status string
CurrentReleaseID *string
CurrentReleasePubID *string
ConfigLabel *string
ManifestURL *string
ManifestChecksum *string
RouteCode *string
PresentationID *string
PresentationName *string
PresentationType *string
ContentBundleID *string
ContentBundleName *string
ContentEntryURL *string
ContentAssetRootURL *string
CurrentPresentationID *string
CurrentPresentationName *string
CurrentPresentationType *string
CurrentContentBundleID *string
CurrentContentBundleName *string
CurrentContentEntryURL *string
CurrentContentAssetRootURL *string
CurrentRuntimeBindingID *string
CurrentPlaceID *string
CurrentMapAssetID *string
CurrentTileReleaseID *string
CurrentCourseSetID *string
CurrentCourseVariantID *string
CurrentCourseVariantName *string
CurrentRuntimeRouteCode *string
}
type CreateAdminEventParams struct {
@@ -90,10 +112,42 @@ func (s *Store) ListAdminEvents(ctx context.Context, limit int) ([]AdminEventRec
er.config_label,
er.manifest_url,
er.manifest_checksum_sha256,
er.route_code
er.route_code,
ep.presentation_public_id,
ep.name,
ep.presentation_type,
cb.content_bundle_public_id,
cb.name,
cb.entry_url,
cb.asset_root_url,
epc.presentation_public_id,
epc.name,
epc.presentation_type,
cbc.content_bundle_public_id,
cbc.name,
cbc.entry_url,
cbc.asset_root_url,
mrb.runtime_binding_public_id,
p.place_public_id,
ma.map_asset_public_id,
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 tenants t ON t.id = e.tenant_id
LEFT JOIN event_releases er ON er.id = e.current_release_id
LEFT JOIN event_presentations ep ON ep.id = er.presentation_id
LEFT JOIN content_bundles cb ON cb.id = er.content_bundle_id
LEFT JOIN event_presentations epc ON epc.id = e.current_presentation_id
LEFT JOIN content_bundles cbc ON cbc.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
ORDER BY e.created_at DESC
LIMIT $1
`, limit)
@@ -133,10 +187,42 @@ func (s *Store) GetAdminEventByPublicID(ctx context.Context, eventPublicID strin
er.config_label,
er.manifest_url,
er.manifest_checksum_sha256,
er.route_code
er.route_code,
ep.presentation_public_id,
ep.name,
ep.presentation_type,
cb.content_bundle_public_id,
cb.name,
cb.entry_url,
cb.asset_root_url,
epc.presentation_public_id,
epc.name,
epc.presentation_type,
cbc.content_bundle_public_id,
cbc.name,
cbc.entry_url,
cbc.asset_root_url,
mrb.runtime_binding_public_id,
p.place_public_id,
ma.map_asset_public_id,
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 tenants t ON t.id = e.tenant_id
LEFT JOIN event_releases er ON er.id = e.current_release_id
LEFT JOIN event_presentations ep ON ep.id = er.presentation_id
LEFT JOIN content_bundles cb ON cb.id = er.content_bundle_id
LEFT JOIN event_presentations epc ON epc.id = e.current_presentation_id
LEFT JOIN content_bundles cbc ON cbc.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.event_public_id = $1
LIMIT 1
`, eventPublicID)
@@ -212,6 +298,28 @@ func scanAdminEvent(row pgx.Row) (*AdminEventRecord, error) {
&item.ManifestURL,
&item.ManifestChecksum,
&item.RouteCode,
&item.PresentationID,
&item.PresentationName,
&item.PresentationType,
&item.ContentBundleID,
&item.ContentBundleName,
&item.ContentEntryURL,
&item.ContentAssetRootURL,
&item.CurrentPresentationID,
&item.CurrentPresentationName,
&item.CurrentPresentationType,
&item.CurrentContentBundleID,
&item.CurrentContentBundleName,
&item.CurrentContentEntryURL,
&item.CurrentContentAssetRootURL,
&item.CurrentRuntimeBindingID,
&item.CurrentPlaceID,
&item.CurrentMapAssetID,
&item.CurrentTileReleaseID,
&item.CurrentCourseSetID,
&item.CurrentCourseVariantID,
&item.CurrentCourseVariantName,
&item.CurrentRuntimeRouteCode,
)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
@@ -240,6 +348,28 @@ func scanAdminEventFromRows(rows pgx.Rows) (*AdminEventRecord, error) {
&item.ManifestURL,
&item.ManifestChecksum,
&item.RouteCode,
&item.PresentationID,
&item.PresentationName,
&item.PresentationType,
&item.ContentBundleID,
&item.ContentBundleName,
&item.ContentEntryURL,
&item.ContentAssetRootURL,
&item.CurrentPresentationID,
&item.CurrentPresentationName,
&item.CurrentPresentationType,
&item.CurrentContentBundleID,
&item.CurrentContentBundleName,
&item.CurrentContentEntryURL,
&item.CurrentContentAssetRootURL,
&item.CurrentRuntimeBindingID,
&item.CurrentPlaceID,
&item.CurrentMapAssetID,
&item.CurrentTileReleaseID,
&item.CurrentCourseSetID,
&item.CurrentCourseVariantID,
&item.CurrentCourseVariantName,
&item.CurrentRuntimeRouteCode,
)
if err != nil {
return nil, fmt.Errorf("scan admin event row: %w", err)

View File

@@ -13,6 +13,13 @@ type DemoBootstrapSummary struct {
SourceID string `json:"sourceId"`
BuildID string `json:"buildId"`
CardID string `json:"cardId"`
PlaceID string `json:"placeId"`
MapAssetID string `json:"mapAssetId"`
TileReleaseID string `json:"tileReleaseId"`
CourseSourceID string `json:"courseSourceId"`
CourseSetID string `json:"courseSetId"`
CourseVariantID string `json:"courseVariantId"`
RuntimeBindingID string `json:"runtimeBindingId"`
VariantManualEventID string `json:"variantManualEventId"`
VariantManualRelease string `json:"variantManualReleaseId"`
VariantManualCardID string `json:"variantManualCardId"`
@@ -311,6 +318,156 @@ func (s *Store) EnsureDemoData(ctx context.Context) (*DemoBootstrapSummary, erro
return nil, fmt.Errorf("ensure demo card: %w", err)
}
var placeID, placePublicID string
if err := tx.QueryRow(ctx, `
INSERT INTO places (
place_public_id, code, name, region, status
)
VALUES (
'place_demo_001', 'place-demo-001', 'Demo Park', 'Shanghai', 'active'
)
ON CONFLICT (code) DO UPDATE SET
name = EXCLUDED.name,
region = EXCLUDED.region,
status = EXCLUDED.status
RETURNING id, place_public_id
`).Scan(&placeID, &placePublicID); err != nil {
return nil, fmt.Errorf("ensure demo place: %w", err)
}
var mapAssetID, mapAssetPublicID string
if err := tx.QueryRow(ctx, `
INSERT INTO map_assets (
map_asset_public_id, place_id, code, name, map_type, status
)
VALUES (
'mapasset_demo_001', $1, 'mapasset-demo-001', 'Demo Asset Map', 'standard', 'active'
)
ON CONFLICT (code) DO UPDATE SET
place_id = EXCLUDED.place_id,
name = EXCLUDED.name,
map_type = EXCLUDED.map_type,
status = EXCLUDED.status
RETURNING id, map_asset_public_id
`, placeID).Scan(&mapAssetID, &mapAssetPublicID); err != nil {
return nil, fmt.Errorf("ensure demo map asset: %w", err)
}
var tileReleaseID, tileReleasePublicID string
if err := tx.QueryRow(ctx, `
INSERT INTO tile_releases (
tile_release_public_id, map_asset_id, version_code, status, tile_base_url, meta_url, published_at
)
VALUES (
'tile_demo_001', $1, 'v2026-04-03', 'published',
'https://example.com/tiles/demo/', 'https://example.com/tiles/demo/meta.json', NOW()
)
ON CONFLICT (map_asset_id, version_code) DO UPDATE SET
status = EXCLUDED.status,
tile_base_url = EXCLUDED.tile_base_url,
meta_url = EXCLUDED.meta_url,
published_at = EXCLUDED.published_at
RETURNING id, tile_release_public_id
`, mapAssetID).Scan(&tileReleaseID, &tileReleasePublicID); err != nil {
return nil, fmt.Errorf("ensure demo tile release: %w", err)
}
if _, err := tx.Exec(ctx, `
UPDATE map_assets
SET current_tile_release_id = $2
WHERE id = $1
`, mapAssetID, tileReleaseID); err != nil {
return nil, fmt.Errorf("attach demo tile release: %w", err)
}
var courseSourceID, courseSourcePublicID string
if err := tx.QueryRow(ctx, `
INSERT INTO course_sources (
course_source_public_id, source_type, file_url, import_status
)
VALUES (
'csource_demo_001', 'kml', 'https://example.com/course/demo.kml', 'imported'
)
ON CONFLICT (course_source_public_id) DO UPDATE SET
source_type = EXCLUDED.source_type,
file_url = EXCLUDED.file_url,
import_status = EXCLUDED.import_status
RETURNING id, course_source_public_id
`).Scan(&courseSourceID, &courseSourcePublicID); err != nil {
return nil, fmt.Errorf("ensure demo course source: %w", err)
}
var courseSetID, courseSetPublicID string
if err := tx.QueryRow(ctx, `
INSERT INTO course_sets (
course_set_public_id, place_id, map_asset_id, code, mode, name, status
)
VALUES (
'cset_demo_001', $1, $2, 'cset-demo-001', 'classic-sequential', 'Demo Course Set', 'active'
)
ON CONFLICT (code) DO UPDATE SET
place_id = EXCLUDED.place_id,
map_asset_id = EXCLUDED.map_asset_id,
mode = EXCLUDED.mode,
name = EXCLUDED.name,
status = EXCLUDED.status
RETURNING id, course_set_public_id
`, placeID, mapAssetID).Scan(&courseSetID, &courseSetPublicID); err != nil {
return nil, fmt.Errorf("ensure demo course set: %w", err)
}
var courseVariantID, courseVariantPublicID string
if err := tx.QueryRow(ctx, `
INSERT INTO course_variants (
course_variant_public_id, course_set_id, source_id, name, route_code, mode, control_count, status, is_default
)
VALUES (
'cvariant_demo_001', $1, $2, 'Demo Variant A', 'route-demo-a', 'classic-sequential', 8, 'active', true
)
ON CONFLICT (course_variant_public_id) DO UPDATE SET
course_set_id = EXCLUDED.course_set_id,
source_id = EXCLUDED.source_id,
name = EXCLUDED.name,
route_code = EXCLUDED.route_code,
mode = EXCLUDED.mode,
control_count = EXCLUDED.control_count,
status = EXCLUDED.status,
is_default = EXCLUDED.is_default
RETURNING id, course_variant_public_id
`, courseSetID, courseSourceID).Scan(&courseVariantID, &courseVariantPublicID); err != nil {
return nil, fmt.Errorf("ensure demo course variant: %w", err)
}
if _, err := tx.Exec(ctx, `
UPDATE course_sets
SET current_variant_id = $2
WHERE id = $1
`, courseSetID, courseVariantID); err != nil {
return nil, fmt.Errorf("attach demo course variant: %w", err)
}
var runtimeBindingID, runtimeBindingPublicID string
if err := tx.QueryRow(ctx, `
INSERT INTO map_runtime_bindings (
runtime_binding_public_id, event_id, place_id, map_asset_id, tile_release_id, course_set_id, course_variant_id, status, notes
)
VALUES (
'runtime_demo_001', $1, $2, $3, $4, $5, $6, 'active', 'demo runtime binding'
)
ON CONFLICT (runtime_binding_public_id) DO UPDATE SET
event_id = EXCLUDED.event_id,
place_id = EXCLUDED.place_id,
map_asset_id = EXCLUDED.map_asset_id,
tile_release_id = EXCLUDED.tile_release_id,
course_set_id = EXCLUDED.course_set_id,
course_variant_id = EXCLUDED.course_variant_id,
status = EXCLUDED.status,
notes = EXCLUDED.notes
RETURNING id, runtime_binding_public_id
`, eventID, placeID, mapAssetID, tileReleaseID, courseSetID, courseVariantID).Scan(&runtimeBindingID, &runtimeBindingPublicID); err != nil {
return nil, fmt.Errorf("ensure demo runtime binding: %w", err)
}
var manualEventID string
if err := tx.QueryRow(ctx, `
INSERT INTO events (
@@ -452,6 +609,13 @@ func (s *Store) EnsureDemoData(ctx context.Context) (*DemoBootstrapSummary, erro
SourceID: source.ID,
BuildID: build.ID,
CardID: cardPublicID,
PlaceID: placePublicID,
MapAssetID: mapAssetPublicID,
TileReleaseID: tileReleasePublicID,
CourseSourceID: courseSourcePublicID,
CourseSetID: courseSetPublicID,
CourseVariantID: courseVariantPublicID,
RuntimeBindingID: runtimeBindingPublicID,
VariantManualEventID: "evt_demo_variant_manual_001",
VariantManualRelease: manualReleaseRow.PublicID,
VariantManualCardID: manualCardPublicID,

View File

@@ -0,0 +1,560 @@
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
}

View File

@@ -23,20 +23,54 @@ type Event struct {
ManifestChecksum *string
RouteCode *string
ReleasePayloadJSON *string
RuntimeBindingID *string
PlacePublicID *string
PlaceName *string
MapAssetPublicID *string
MapAssetName *string
TileReleasePublicID *string
CourseSetPublicID *string
CourseVariantID *string
CourseVariantName *string
RuntimeRouteCode *string
PresentationID *string
PresentationName *string
PresentationType *string
ContentBundleID *string
ContentBundleName *string
ContentEntryURL *string
ContentAssetRootURL *string
}
type EventRelease struct {
ID string
PublicID string
EventID string
ReleaseNo int
ConfigLabel string
ManifestURL string
ManifestChecksum *string
RouteCode *string
BuildID *string
Status string
PublishedAt time.Time
ID string
PublicID string
EventID string
ReleaseNo int
ConfigLabel string
ManifestURL string
ManifestChecksum *string
RouteCode *string
BuildID *string
Status string
PublishedAt time.Time
RuntimeBindingID *string
PlacePublicID *string
PlaceName *string
MapAssetPublicID *string
MapAssetName *string
TileReleaseID *string
CourseSetID *string
CourseVariantID *string
CourseVariantName *string
RuntimeRouteCode *string
PresentationID *string
PresentationName *string
PresentationType *string
ContentBundleID *string
ContentBundleName *string
ContentEntryURL *string
ContentAssetURL *string
}
type CreateGameSessionParams struct {
@@ -85,9 +119,34 @@ func (s *Store) GetEventByPublicID(ctx context.Context, eventPublicID string) (*
er.manifest_url,
er.manifest_checksum_sha256,
er.route_code,
er.payload_jsonb::text
er.payload_jsonb::text,
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,
ep.presentation_public_id,
ep.name,
ep.presentation_type,
cb.content_bundle_public_id,
cb.name,
cb.entry_url,
cb.asset_root_url
FROM events e
LEFT JOIN event_releases er ON er.id = e.current_release_id
LEFT JOIN map_runtime_bindings mrb ON mrb.id = er.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
LEFT JOIN event_presentations ep ON ep.id = er.presentation_id
LEFT JOIN content_bundles cb ON cb.id = er.content_bundle_id
WHERE e.event_public_id = $1
LIMIT 1
`, eventPublicID)
@@ -107,6 +166,23 @@ func (s *Store) GetEventByPublicID(ctx context.Context, eventPublicID string) (*
&event.ManifestChecksum,
&event.RouteCode,
&event.ReleasePayloadJSON,
&event.RuntimeBindingID,
&event.PlacePublicID,
&event.PlaceName,
&event.MapAssetPublicID,
&event.MapAssetName,
&event.TileReleasePublicID,
&event.CourseSetPublicID,
&event.CourseVariantID,
&event.CourseVariantName,
&event.RuntimeRouteCode,
&event.PresentationID,
&event.PresentationName,
&event.PresentationType,
&event.ContentBundleID,
&event.ContentBundleName,
&event.ContentEntryURL,
&event.ContentAssetRootURL,
)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
@@ -132,9 +208,34 @@ func (s *Store) GetEventByID(ctx context.Context, eventID string) (*Event, error
er.manifest_url,
er.manifest_checksum_sha256,
er.route_code,
er.payload_jsonb::text
er.payload_jsonb::text,
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,
ep.presentation_public_id,
ep.name,
ep.presentation_type,
cb.content_bundle_public_id,
cb.name,
cb.entry_url,
cb.asset_root_url
FROM events e
LEFT JOIN event_releases er ON er.id = e.current_release_id
LEFT JOIN map_runtime_bindings mrb ON mrb.id = er.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
LEFT JOIN event_presentations ep ON ep.id = er.presentation_id
LEFT JOIN content_bundles cb ON cb.id = er.content_bundle_id
WHERE e.id = $1
LIMIT 1
`, eventID)
@@ -154,6 +255,23 @@ func (s *Store) GetEventByID(ctx context.Context, eventID string) (*Event, error
&event.ManifestChecksum,
&event.RouteCode,
&event.ReleasePayloadJSON,
&event.RuntimeBindingID,
&event.PlacePublicID,
&event.PlaceName,
&event.MapAssetPublicID,
&event.MapAssetName,
&event.TileReleasePublicID,
&event.CourseSetPublicID,
&event.CourseVariantID,
&event.CourseVariantName,
&event.RuntimeRouteCode,
&event.PresentationID,
&event.PresentationName,
&event.PresentationType,
&event.ContentBundleID,
&event.ContentBundleName,
&event.ContentEntryURL,
&event.ContentAssetRootURL,
)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
@@ -168,7 +286,7 @@ func (s *Store) NextEventReleaseNo(ctx context.Context, eventID string) (int, er
var next int
if err := s.pool.QueryRow(ctx, `
SELECT COALESCE(MAX(release_no), 0) + 1
FROM event_releases
FROM event_releases er
WHERE event_id = $1
`, eventID).Scan(&next); err != nil {
return 0, fmt.Errorf("next event release no: %w", err)
@@ -185,6 +303,9 @@ type CreateEventReleaseParams struct {
ManifestChecksum *string
RouteCode *string
BuildID *string
RuntimeBindingID *string
PresentationID *string
ContentBundleID *string
Status string
PayloadJSON string
}
@@ -200,12 +321,15 @@ func (s *Store) CreateEventRelease(ctx context.Context, tx Tx, params CreateEven
manifest_checksum_sha256,
route_code,
build_id,
runtime_binding_id,
presentation_id,
content_bundle_id,
status,
payload_jsonb
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13::jsonb)
RETURNING id, release_public_id, event_id, release_no, config_label, manifest_url, manifest_checksum_sha256, route_code, build_id, status, published_at
`, params.PublicID, params.EventID, params.ReleaseNo, params.ConfigLabel, params.ManifestURL, params.ManifestChecksum, params.RouteCode, params.BuildID, params.Status, params.PayloadJSON)
`, params.PublicID, params.EventID, params.ReleaseNo, params.ConfigLabel, params.ManifestURL, params.ManifestChecksum, params.RouteCode, params.BuildID, params.RuntimeBindingID, params.PresentationID, params.ContentBundleID, params.Status, params.PayloadJSON)
var item EventRelease
if err := row.Scan(
@@ -284,10 +408,46 @@ func (s *Store) ListEventReleasesByEventID(ctx context.Context, eventID string,
limit = 20
}
rows, err := s.pool.Query(ctx, `
SELECT id, release_public_id, event_id, release_no, config_label, manifest_url, manifest_checksum_sha256, route_code, build_id, status, published_at
FROM event_releases
WHERE event_id = $1
ORDER BY release_no DESC
SELECT
er.id,
er.release_public_id,
er.event_id,
er.release_no,
er.config_label,
er.manifest_url,
er.manifest_checksum_sha256,
er.route_code,
er.build_id,
er.status,
er.published_at,
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,
ep.presentation_public_id,
ep.name,
ep.presentation_type,
cb.content_bundle_public_id,
cb.name,
cb.entry_url,
cb.asset_root_url
FROM event_releases er
LEFT JOIN map_runtime_bindings mrb ON mrb.id = er.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
LEFT JOIN event_presentations ep ON ep.id = er.presentation_id
LEFT JOIN content_bundles cb ON cb.id = er.content_bundle_id
WHERE er.event_id = $1
ORDER BY er.release_no DESC
LIMIT $2
`, eventID, limit)
if err != nil {
@@ -311,9 +471,45 @@ func (s *Store) ListEventReleasesByEventID(ctx context.Context, eventID string,
func (s *Store) GetEventReleaseByPublicID(ctx context.Context, releasePublicID string) (*EventRelease, error) {
row := s.pool.QueryRow(ctx, `
SELECT id, release_public_id, event_id, release_no, config_label, manifest_url, manifest_checksum_sha256, route_code, build_id, status, published_at
FROM event_releases
WHERE release_public_id = $1
SELECT
er.id,
er.release_public_id,
er.event_id,
er.release_no,
er.config_label,
er.manifest_url,
er.manifest_checksum_sha256,
er.route_code,
er.build_id,
er.status,
er.published_at,
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,
ep.presentation_public_id,
ep.name,
ep.presentation_type,
cb.content_bundle_public_id,
cb.name,
cb.entry_url,
cb.asset_root_url
FROM event_releases er
LEFT JOIN map_runtime_bindings mrb ON mrb.id = er.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
LEFT JOIN event_presentations ep ON ep.id = er.presentation_id
LEFT JOIN content_bundles cb ON cb.id = er.content_bundle_id
WHERE er.release_public_id = $1
LIMIT 1
`, releasePublicID)
@@ -330,6 +526,23 @@ func (s *Store) GetEventReleaseByPublicID(ctx context.Context, releasePublicID s
&item.BuildID,
&item.Status,
&item.PublishedAt,
&item.RuntimeBindingID,
&item.PlacePublicID,
&item.PlaceName,
&item.MapAssetPublicID,
&item.MapAssetName,
&item.TileReleaseID,
&item.CourseSetID,
&item.CourseVariantID,
&item.CourseVariantName,
&item.RuntimeRouteCode,
&item.PresentationID,
&item.PresentationName,
&item.PresentationType,
&item.ContentBundleID,
&item.ContentBundleName,
&item.ContentEntryURL,
&item.ContentAssetURL,
)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
@@ -354,9 +567,37 @@ func scanEventReleaseFromRows(rows pgx.Rows) (*EventRelease, error) {
&item.BuildID,
&item.Status,
&item.PublishedAt,
&item.RuntimeBindingID,
&item.PlacePublicID,
&item.PlaceName,
&item.MapAssetPublicID,
&item.MapAssetName,
&item.TileReleaseID,
&item.CourseSetID,
&item.CourseVariantID,
&item.CourseVariantName,
&item.RuntimeRouteCode,
&item.PresentationID,
&item.PresentationName,
&item.PresentationType,
&item.ContentBundleID,
&item.ContentBundleName,
&item.ContentEntryURL,
&item.ContentAssetURL,
)
if err != nil {
return nil, fmt.Errorf("scan event release row: %w", err)
}
return &item, nil
}
func (s *Store) SetEventReleaseRuntimeBinding(ctx context.Context, tx Tx, releaseID string, runtimeBindingID *string) error {
if _, err := tx.Exec(ctx, `
UPDATE event_releases
SET runtime_binding_id = $2
WHERE id = $1
`, releaseID, runtimeBindingID); err != nil {
return fmt.Errorf("set event release runtime binding: %w", err)
}
return nil
}

View File

@@ -0,0 +1,822 @@
package postgres
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
)
type Place struct {
ID string
PublicID string
Code string
Name string
Region *string
CoverURL *string
Description *string
CenterPoint json.RawMessage
Status string
CreatedAt time.Time
UpdatedAt time.Time
}
type MapAsset struct {
ID string
PublicID string
PlaceID string
LegacyMapID *string
LegacyMapPublicID *string
Code string
Name string
MapType string
CoverURL *string
Description *string
Status string
CurrentTileReleaseID *string
CreatedAt time.Time
UpdatedAt time.Time
}
type TileRelease struct {
ID string
PublicID string
MapAssetID string
LegacyMapVersionID *string
LegacyMapVersionPub *string
VersionCode string
Status string
TileBaseURL string
MetaURL string
PublishedAssetRoot *string
MetadataJSON json.RawMessage
PublishedAt *time.Time
CreatedAt time.Time
UpdatedAt time.Time
}
type CourseSource struct {
ID string
PublicID string
LegacyPlayfieldVersionID *string
LegacyPlayfieldVersionPub *string
SourceType string
FileURL string
Checksum *string
ParserVersion *string
ImportStatus string
MetadataJSON json.RawMessage
ImportedAt time.Time
CreatedAt time.Time
UpdatedAt time.Time
}
type CourseSet struct {
ID string
PublicID string
PlaceID string
MapAssetID string
Code string
Mode string
Name string
Description *string
Status string
CurrentVariantID *string
CreatedAt time.Time
UpdatedAt time.Time
}
type CourseVariant struct {
ID string
PublicID string
CourseSetID string
SourceID *string
SourcePublicID *string
Name string
RouteCode *string
Mode string
ControlCount *int
Difficulty *string
Status string
IsDefault bool
ConfigPatch json.RawMessage
MetadataJSON json.RawMessage
CreatedAt time.Time
UpdatedAt time.Time
}
type MapRuntimeBinding struct {
ID string
PublicID string
EventID string
EventPublicID string
PlaceID string
PlacePublicID string
MapAssetID string
MapAssetPublicID string
TileReleaseID string
TileReleasePublicID string
CourseSetID string
CourseSetPublicID string
CourseVariantID string
CourseVariantPublicID string
Status string
Notes *string
CreatedAt time.Time
UpdatedAt time.Time
}
type CreatePlaceParams struct {
PublicID string
Code string
Name string
Region *string
CoverURL *string
Description *string
CenterPoint map[string]any
Status string
}
type CreateMapAssetParams struct {
PublicID string
PlaceID string
LegacyMapID *string
Code string
Name string
MapType string
CoverURL *string
Description *string
Status string
}
type CreateTileReleaseParams struct {
PublicID string
MapAssetID string
LegacyMapVersionID *string
VersionCode string
Status string
TileBaseURL string
MetaURL string
PublishedAssetRoot *string
MetadataJSON map[string]any
PublishedAt *time.Time
}
type CreateCourseSourceParams struct {
PublicID string
LegacyPlayfieldVersionID *string
SourceType string
FileURL string
Checksum *string
ParserVersion *string
ImportStatus string
MetadataJSON map[string]any
ImportedAt *time.Time
}
type CreateCourseSetParams struct {
PublicID string
PlaceID string
MapAssetID string
Code string
Mode string
Name string
Description *string
Status string
}
type CreateCourseVariantParams struct {
PublicID string
CourseSetID string
SourceID *string
Name string
RouteCode *string
Mode string
ControlCount *int
Difficulty *string
Status string
IsDefault bool
ConfigPatch map[string]any
MetadataJSON map[string]any
}
type CreateMapRuntimeBindingParams struct {
PublicID string
EventID string
PlaceID string
MapAssetID string
TileReleaseID string
CourseSetID string
CourseVariantID string
Status string
Notes *string
}
func (s *Store) ListPlaces(ctx context.Context, limit int) ([]Place, error) {
if limit <= 0 || limit > 200 {
limit = 50
}
rows, err := s.pool.Query(ctx, `
SELECT id, place_public_id, code, name, region, cover_url, description, center_point_jsonb::text, status, created_at, updated_at
FROM places
ORDER BY created_at DESC
LIMIT $1
`, limit)
if err != nil {
return nil, fmt.Errorf("list places: %w", err)
}
defer rows.Close()
items := []Place{}
for rows.Next() {
item, err := scanPlaceFromRows(rows)
if err != nil {
return nil, err
}
items = append(items, *item)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate places: %w", err)
}
return items, nil
}
func (s *Store) GetPlaceByPublicID(ctx context.Context, publicID string) (*Place, error) {
row := s.pool.QueryRow(ctx, `
SELECT id, place_public_id, code, name, region, cover_url, description, center_point_jsonb::text, status, created_at, updated_at
FROM places
WHERE place_public_id = $1
LIMIT 1
`, publicID)
return scanPlace(row)
}
func (s *Store) CreatePlace(ctx context.Context, tx Tx, params CreatePlaceParams) (*Place, error) {
centerPointJSON, err := marshalJSONMap(params.CenterPoint)
if err != nil {
return nil, fmt.Errorf("marshal place center point: %w", err)
}
row := tx.QueryRow(ctx, `
INSERT INTO places (place_public_id, code, name, region, cover_url, description, center_point_jsonb, status)
VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8)
RETURNING id, place_public_id, code, name, region, cover_url, description, center_point_jsonb::text, status, created_at, updated_at
`, params.PublicID, params.Code, params.Name, params.Region, params.CoverURL, params.Description, centerPointJSON, params.Status)
return scanPlace(row)
}
func (s *Store) ListMapAssetsByPlaceID(ctx context.Context, placeID string) ([]MapAsset, error) {
rows, err := s.pool.Query(ctx, `
SELECT ma.id, ma.map_asset_public_id, ma.place_id, ma.legacy_map_id, lm.map_public_id, ma.code, ma.name, ma.map_type,
ma.cover_url, ma.description, ma.status, ma.current_tile_release_id, ma.created_at, ma.updated_at
FROM map_assets ma
LEFT JOIN maps lm ON lm.id = ma.legacy_map_id
WHERE ma.place_id = $1
ORDER BY ma.created_at DESC
`, placeID)
if err != nil {
return nil, fmt.Errorf("list map assets: %w", err)
}
defer rows.Close()
items := []MapAsset{}
for rows.Next() {
item, err := scanMapAssetFromRows(rows)
if err != nil {
return nil, err
}
items = append(items, *item)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate map assets: %w", err)
}
return items, nil
}
func (s *Store) GetMapAssetByPublicID(ctx context.Context, publicID string) (*MapAsset, error) {
row := s.pool.QueryRow(ctx, `
SELECT ma.id, ma.map_asset_public_id, ma.place_id, ma.legacy_map_id, lm.map_public_id, ma.code, ma.name, ma.map_type,
ma.cover_url, ma.description, ma.status, ma.current_tile_release_id, ma.created_at, ma.updated_at
FROM map_assets ma
LEFT JOIN maps lm ON lm.id = ma.legacy_map_id
WHERE ma.map_asset_public_id = $1
LIMIT 1
`, publicID)
return scanMapAsset(row)
}
func (s *Store) CreateMapAsset(ctx context.Context, tx Tx, params CreateMapAssetParams) (*MapAsset, error) {
row := tx.QueryRow(ctx, `
INSERT INTO map_assets (map_asset_public_id, place_id, legacy_map_id, code, name, map_type, cover_url, description, status)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING id, map_asset_public_id, place_id, legacy_map_id, NULL::text, code, name, map_type, cover_url, description, status, current_tile_release_id, created_at, updated_at
`, params.PublicID, params.PlaceID, params.LegacyMapID, params.Code, params.Name, params.MapType, params.CoverURL, params.Description, params.Status)
return scanMapAsset(row)
}
func (s *Store) ListTileReleasesByMapAssetID(ctx context.Context, mapAssetID string) ([]TileRelease, error) {
rows, err := s.pool.Query(ctx, `
SELECT tr.id, tr.tile_release_public_id, tr.map_asset_id, tr.legacy_map_version_id, mv.version_public_id,
tr.version_code, tr.status, tr.tile_base_url, tr.meta_url, tr.published_asset_root,
tr.metadata_jsonb::text, tr.published_at, tr.created_at, tr.updated_at
FROM tile_releases tr
LEFT JOIN map_versions mv ON mv.id = tr.legacy_map_version_id
WHERE tr.map_asset_id = $1
ORDER BY tr.created_at DESC
`, mapAssetID)
if err != nil {
return nil, fmt.Errorf("list tile releases: %w", err)
}
defer rows.Close()
items := []TileRelease{}
for rows.Next() {
item, err := scanTileReleaseFromRows(rows)
if err != nil {
return nil, err
}
items = append(items, *item)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate tile releases: %w", err)
}
return items, nil
}
func (s *Store) GetTileReleaseByPublicID(ctx context.Context, publicID string) (*TileRelease, error) {
row := s.pool.QueryRow(ctx, `
SELECT tr.id, tr.tile_release_public_id, tr.map_asset_id, tr.legacy_map_version_id, mv.version_public_id,
tr.version_code, tr.status, tr.tile_base_url, tr.meta_url, tr.published_asset_root,
tr.metadata_jsonb::text, tr.published_at, tr.created_at, tr.updated_at
FROM tile_releases tr
LEFT JOIN map_versions mv ON mv.id = tr.legacy_map_version_id
WHERE tr.tile_release_public_id = $1
LIMIT 1
`, publicID)
return scanTileRelease(row)
}
func (s *Store) CreateTileRelease(ctx context.Context, tx Tx, params CreateTileReleaseParams) (*TileRelease, error) {
metadataJSON, err := marshalJSONMap(params.MetadataJSON)
if err != nil {
return nil, fmt.Errorf("marshal tile release metadata: %w", err)
}
row := tx.QueryRow(ctx, `
INSERT INTO tile_releases (
tile_release_public_id, map_asset_id, legacy_map_version_id, version_code, status,
tile_base_url, meta_url, published_asset_root, metadata_jsonb, published_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, $10)
RETURNING id, tile_release_public_id, map_asset_id, legacy_map_version_id, NULL::text, version_code, status,
tile_base_url, meta_url, published_asset_root, metadata_jsonb::text, published_at, created_at, updated_at
`, params.PublicID, params.MapAssetID, params.LegacyMapVersionID, params.VersionCode, params.Status, params.TileBaseURL, params.MetaURL, params.PublishedAssetRoot, metadataJSON, params.PublishedAt)
return scanTileRelease(row)
}
func (s *Store) SetMapAssetCurrentTileRelease(ctx context.Context, tx Tx, mapAssetID, tileReleaseID string) error {
_, err := tx.Exec(ctx, `UPDATE map_assets SET current_tile_release_id = $2 WHERE id = $1`, mapAssetID, tileReleaseID)
if err != nil {
return fmt.Errorf("set map asset current tile release: %w", err)
}
return nil
}
func (s *Store) ListCourseSources(ctx context.Context, limit int) ([]CourseSource, error) {
if limit <= 0 || limit > 200 {
limit = 50
}
rows, err := s.pool.Query(ctx, `
SELECT cs.id, cs.course_source_public_id, cs.legacy_playfield_version_id, pv.version_public_id, cs.source_type,
cs.file_url, cs.checksum, cs.parser_version, cs.import_status, cs.metadata_jsonb::text, cs.imported_at, cs.created_at, cs.updated_at
FROM course_sources cs
LEFT JOIN playfield_versions pv ON pv.id = cs.legacy_playfield_version_id
ORDER BY cs.created_at DESC
LIMIT $1
`, limit)
if err != nil {
return nil, fmt.Errorf("list course sources: %w", err)
}
defer rows.Close()
items := []CourseSource{}
for rows.Next() {
item, err := scanCourseSourceFromRows(rows)
if err != nil {
return nil, err
}
items = append(items, *item)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate course sources: %w", err)
}
return items, nil
}
func (s *Store) GetCourseSourceByPublicID(ctx context.Context, publicID string) (*CourseSource, error) {
row := s.pool.QueryRow(ctx, `
SELECT cs.id, cs.course_source_public_id, cs.legacy_playfield_version_id, pv.version_public_id, cs.source_type,
cs.file_url, cs.checksum, cs.parser_version, cs.import_status, cs.metadata_jsonb::text, cs.imported_at, cs.created_at, cs.updated_at
FROM course_sources cs
LEFT JOIN playfield_versions pv ON pv.id = cs.legacy_playfield_version_id
WHERE cs.course_source_public_id = $1
LIMIT 1
`, publicID)
return scanCourseSource(row)
}
func (s *Store) CreateCourseSource(ctx context.Context, tx Tx, params CreateCourseSourceParams) (*CourseSource, error) {
metadataJSON, err := marshalJSONMap(params.MetadataJSON)
if err != nil {
return nil, fmt.Errorf("marshal course source metadata: %w", err)
}
importedAt := time.Now()
if params.ImportedAt != nil {
importedAt = *params.ImportedAt
}
row := tx.QueryRow(ctx, `
INSERT INTO course_sources (
course_source_public_id, legacy_playfield_version_id, source_type, file_url, checksum,
parser_version, import_status, metadata_jsonb, imported_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9)
RETURNING id, course_source_public_id, legacy_playfield_version_id, NULL::text, source_type, file_url,
checksum, parser_version, import_status, metadata_jsonb::text, imported_at, created_at, updated_at
`, params.PublicID, params.LegacyPlayfieldVersionID, params.SourceType, params.FileURL, params.Checksum, params.ParserVersion, params.ImportStatus, metadataJSON, importedAt)
return scanCourseSource(row)
}
func (s *Store) ListCourseSets(ctx context.Context, limit int) ([]CourseSet, error) {
if limit <= 0 || limit > 200 {
limit = 50
}
rows, err := s.pool.Query(ctx, `
SELECT id, course_set_public_id, place_id, map_asset_id, code, mode, name, description, status, current_variant_id, created_at, updated_at
FROM course_sets
ORDER BY created_at DESC
LIMIT $1
`, limit)
if err != nil {
return nil, fmt.Errorf("list course sets: %w", err)
}
defer rows.Close()
items := []CourseSet{}
for rows.Next() {
item, err := scanCourseSetFromRows(rows)
if err != nil {
return nil, err
}
items = append(items, *item)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate course sets: %w", err)
}
return items, nil
}
func (s *Store) ListCourseSetsByMapAssetID(ctx context.Context, mapAssetID string) ([]CourseSet, error) {
rows, err := s.pool.Query(ctx, `
SELECT id, course_set_public_id, place_id, map_asset_id, code, mode, name, description, status, current_variant_id, created_at, updated_at
FROM course_sets
WHERE map_asset_id = $1
ORDER BY created_at DESC
`, mapAssetID)
if err != nil {
return nil, fmt.Errorf("list course sets by map asset: %w", err)
}
defer rows.Close()
items := []CourseSet{}
for rows.Next() {
item, err := scanCourseSetFromRows(rows)
if err != nil {
return nil, err
}
items = append(items, *item)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate course sets by map asset: %w", err)
}
return items, nil
}
func (s *Store) GetCourseSetByPublicID(ctx context.Context, publicID string) (*CourseSet, error) {
row := s.pool.QueryRow(ctx, `
SELECT id, course_set_public_id, place_id, map_asset_id, code, mode, name, description, status, current_variant_id, created_at, updated_at
FROM course_sets
WHERE course_set_public_id = $1
LIMIT 1
`, publicID)
return scanCourseSet(row)
}
func (s *Store) CreateCourseSet(ctx context.Context, tx Tx, params CreateCourseSetParams) (*CourseSet, error) {
row := tx.QueryRow(ctx, `
INSERT INTO course_sets (course_set_public_id, place_id, map_asset_id, code, mode, name, description, status)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id, course_set_public_id, place_id, map_asset_id, code, mode, name, description, status, current_variant_id, created_at, updated_at
`, params.PublicID, params.PlaceID, params.MapAssetID, params.Code, params.Mode, params.Name, params.Description, params.Status)
return scanCourseSet(row)
}
func (s *Store) ListCourseVariantsByCourseSetID(ctx context.Context, courseSetID string) ([]CourseVariant, error) {
rows, err := s.pool.Query(ctx, `
SELECT cv.id, cv.course_variant_public_id, cv.course_set_id, cv.source_id, cs.course_source_public_id, cv.name, cv.route_code,
cv.mode, cv.control_count, cv.difficulty, cv.status, cv.is_default,
cv.config_patch_jsonb::text, cv.metadata_jsonb::text, cv.created_at, cv.updated_at
FROM course_variants cv
LEFT JOIN course_sources cs ON cs.id = cv.source_id
WHERE cv.course_set_id = $1
ORDER BY cv.created_at DESC
`, courseSetID)
if err != nil {
return nil, fmt.Errorf("list course variants: %w", err)
}
defer rows.Close()
items := []CourseVariant{}
for rows.Next() {
item, err := scanCourseVariantFromRows(rows)
if err != nil {
return nil, err
}
items = append(items, *item)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate course variants: %w", err)
}
return items, nil
}
func (s *Store) GetCourseVariantByPublicID(ctx context.Context, publicID string) (*CourseVariant, error) {
row := s.pool.QueryRow(ctx, `
SELECT cv.id, cv.course_variant_public_id, cv.course_set_id, cv.source_id, cs.course_source_public_id, cv.name, cv.route_code,
cv.mode, cv.control_count, cv.difficulty, cv.status, cv.is_default,
cv.config_patch_jsonb::text, cv.metadata_jsonb::text, cv.created_at, cv.updated_at
FROM course_variants cv
LEFT JOIN course_sources cs ON cs.id = cv.source_id
WHERE cv.course_variant_public_id = $1
LIMIT 1
`, publicID)
return scanCourseVariant(row)
}
func (s *Store) CreateCourseVariant(ctx context.Context, tx Tx, params CreateCourseVariantParams) (*CourseVariant, error) {
configPatchJSON, err := marshalJSONMap(params.ConfigPatch)
if err != nil {
return nil, fmt.Errorf("marshal course variant config patch: %w", err)
}
metadataJSON, err := marshalJSONMap(params.MetadataJSON)
if err != nil {
return nil, fmt.Errorf("marshal course variant metadata: %w", err)
}
row := tx.QueryRow(ctx, `
INSERT INTO course_variants (
course_variant_public_id, course_set_id, source_id, name, route_code, mode, control_count,
difficulty, status, is_default, config_patch_jsonb, metadata_jsonb
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11::jsonb, $12::jsonb)
RETURNING id, course_variant_public_id, course_set_id, source_id, NULL::text, name, route_code, mode,
control_count, difficulty, status, is_default, config_patch_jsonb::text, metadata_jsonb::text, created_at, updated_at
`, params.PublicID, params.CourseSetID, params.SourceID, params.Name, params.RouteCode, params.Mode, params.ControlCount, params.Difficulty, params.Status, params.IsDefault, configPatchJSON, metadataJSON)
return scanCourseVariant(row)
}
func (s *Store) SetCourseSetCurrentVariant(ctx context.Context, tx Tx, courseSetID, variantID string) error {
_, err := tx.Exec(ctx, `UPDATE course_sets SET current_variant_id = $2 WHERE id = $1`, courseSetID, variantID)
if err != nil {
return fmt.Errorf("set course set current variant: %w", err)
}
return nil
}
func (s *Store) ListMapRuntimeBindings(ctx context.Context, limit int) ([]MapRuntimeBinding, error) {
if limit <= 0 || limit > 200 {
limit = 50
}
rows, err := s.pool.Query(ctx, `
SELECT mrb.id, mrb.runtime_binding_public_id, mrb.event_id, e.event_public_id, mrb.place_id, p.place_public_id,
mrb.map_asset_id, ma.map_asset_public_id, mrb.tile_release_id, tr.tile_release_public_id,
mrb.course_set_id, cset.course_set_public_id, mrb.course_variant_id, cv.course_variant_public_id,
mrb.status, mrb.notes, mrb.created_at, mrb.updated_at
FROM map_runtime_bindings mrb
JOIN events e ON e.id = mrb.event_id
JOIN places p ON p.id = mrb.place_id
JOIN map_assets ma ON ma.id = mrb.map_asset_id
JOIN tile_releases tr ON tr.id = mrb.tile_release_id
JOIN course_sets cset ON cset.id = mrb.course_set_id
JOIN course_variants cv ON cv.id = mrb.course_variant_id
ORDER BY mrb.created_at DESC
LIMIT $1
`, limit)
if err != nil {
return nil, fmt.Errorf("list runtime bindings: %w", err)
}
defer rows.Close()
items := []MapRuntimeBinding{}
for rows.Next() {
item, err := scanMapRuntimeBindingFromRows(rows)
if err != nil {
return nil, err
}
items = append(items, *item)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate runtime bindings: %w", err)
}
return items, nil
}
func (s *Store) GetMapRuntimeBindingByPublicID(ctx context.Context, publicID string) (*MapRuntimeBinding, error) {
row := s.pool.QueryRow(ctx, `
SELECT mrb.id, mrb.runtime_binding_public_id, mrb.event_id, e.event_public_id, mrb.place_id, p.place_public_id,
mrb.map_asset_id, ma.map_asset_public_id, mrb.tile_release_id, tr.tile_release_public_id,
mrb.course_set_id, cset.course_set_public_id, mrb.course_variant_id, cv.course_variant_public_id,
mrb.status, mrb.notes, mrb.created_at, mrb.updated_at
FROM map_runtime_bindings mrb
JOIN events e ON e.id = mrb.event_id
JOIN places p ON p.id = mrb.place_id
JOIN map_assets ma ON ma.id = mrb.map_asset_id
JOIN tile_releases tr ON tr.id = mrb.tile_release_id
JOIN course_sets cset ON cset.id = mrb.course_set_id
JOIN course_variants cv ON cv.id = mrb.course_variant_id
WHERE mrb.runtime_binding_public_id = $1
LIMIT 1
`, publicID)
return scanMapRuntimeBinding(row)
}
func (s *Store) CreateMapRuntimeBinding(ctx context.Context, tx Tx, params CreateMapRuntimeBindingParams) (*MapRuntimeBinding, error) {
row := tx.QueryRow(ctx, `
INSERT INTO map_runtime_bindings (
runtime_binding_public_id, event_id, place_id, map_asset_id, tile_release_id, course_set_id, course_variant_id, status, notes
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING id, runtime_binding_public_id, event_id, ''::text, place_id, ''::text, map_asset_id, ''::text,
tile_release_id, ''::text, course_set_id, ''::text, course_variant_id, ''::text,
status, notes, created_at, updated_at
`, params.PublicID, params.EventID, params.PlaceID, params.MapAssetID, params.TileReleaseID, params.CourseSetID, params.CourseVariantID, params.Status, params.Notes)
return scanMapRuntimeBinding(row)
}
func scanPlace(row pgx.Row) (*Place, error) {
var item Place
var centerPoint string
err := row.Scan(&item.ID, &item.PublicID, &item.Code, &item.Name, &item.Region, &item.CoverURL, &item.Description, &centerPoint, &item.Status, &item.CreatedAt, &item.UpdatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("scan place: %w", err)
}
item.CenterPoint = json.RawMessage(centerPoint)
return &item, nil
}
func scanPlaceFromRows(rows pgx.Rows) (*Place, error) {
var item Place
var centerPoint string
err := rows.Scan(&item.ID, &item.PublicID, &item.Code, &item.Name, &item.Region, &item.CoverURL, &item.Description, &centerPoint, &item.Status, &item.CreatedAt, &item.UpdatedAt)
if err != nil {
return nil, fmt.Errorf("scan place row: %w", err)
}
item.CenterPoint = json.RawMessage(centerPoint)
return &item, nil
}
func scanMapAsset(row pgx.Row) (*MapAsset, error) {
var item MapAsset
err := row.Scan(&item.ID, &item.PublicID, &item.PlaceID, &item.LegacyMapID, &item.LegacyMapPublicID, &item.Code, &item.Name, &item.MapType, &item.CoverURL, &item.Description, &item.Status, &item.CurrentTileReleaseID, &item.CreatedAt, &item.UpdatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("scan map asset: %w", err)
}
return &item, nil
}
func scanMapAssetFromRows(rows pgx.Rows) (*MapAsset, error) {
var item MapAsset
err := rows.Scan(&item.ID, &item.PublicID, &item.PlaceID, &item.LegacyMapID, &item.LegacyMapPublicID, &item.Code, &item.Name, &item.MapType, &item.CoverURL, &item.Description, &item.Status, &item.CurrentTileReleaseID, &item.CreatedAt, &item.UpdatedAt)
if err != nil {
return nil, fmt.Errorf("scan map asset row: %w", err)
}
return &item, nil
}
func scanTileRelease(row pgx.Row) (*TileRelease, error) {
var item TileRelease
var metadataJSON string
err := row.Scan(&item.ID, &item.PublicID, &item.MapAssetID, &item.LegacyMapVersionID, &item.LegacyMapVersionPub, &item.VersionCode, &item.Status, &item.TileBaseURL, &item.MetaURL, &item.PublishedAssetRoot, &metadataJSON, &item.PublishedAt, &item.CreatedAt, &item.UpdatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("scan tile release: %w", err)
}
item.MetadataJSON = json.RawMessage(metadataJSON)
return &item, nil
}
func scanTileReleaseFromRows(rows pgx.Rows) (*TileRelease, error) {
var item TileRelease
var metadataJSON string
err := rows.Scan(&item.ID, &item.PublicID, &item.MapAssetID, &item.LegacyMapVersionID, &item.LegacyMapVersionPub, &item.VersionCode, &item.Status, &item.TileBaseURL, &item.MetaURL, &item.PublishedAssetRoot, &metadataJSON, &item.PublishedAt, &item.CreatedAt, &item.UpdatedAt)
if err != nil {
return nil, fmt.Errorf("scan tile release row: %w", err)
}
item.MetadataJSON = json.RawMessage(metadataJSON)
return &item, nil
}
func scanCourseSource(row pgx.Row) (*CourseSource, error) {
var item CourseSource
var metadataJSON string
err := row.Scan(&item.ID, &item.PublicID, &item.LegacyPlayfieldVersionID, &item.LegacyPlayfieldVersionPub, &item.SourceType, &item.FileURL, &item.Checksum, &item.ParserVersion, &item.ImportStatus, &metadataJSON, &item.ImportedAt, &item.CreatedAt, &item.UpdatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("scan course source: %w", err)
}
item.MetadataJSON = json.RawMessage(metadataJSON)
return &item, nil
}
func scanCourseSourceFromRows(rows pgx.Rows) (*CourseSource, error) {
var item CourseSource
var metadataJSON string
err := rows.Scan(&item.ID, &item.PublicID, &item.LegacyPlayfieldVersionID, &item.LegacyPlayfieldVersionPub, &item.SourceType, &item.FileURL, &item.Checksum, &item.ParserVersion, &item.ImportStatus, &metadataJSON, &item.ImportedAt, &item.CreatedAt, &item.UpdatedAt)
if err != nil {
return nil, fmt.Errorf("scan course source row: %w", err)
}
item.MetadataJSON = json.RawMessage(metadataJSON)
return &item, nil
}
func scanCourseSet(row pgx.Row) (*CourseSet, error) {
var item CourseSet
err := row.Scan(&item.ID, &item.PublicID, &item.PlaceID, &item.MapAssetID, &item.Code, &item.Mode, &item.Name, &item.Description, &item.Status, &item.CurrentVariantID, &item.CreatedAt, &item.UpdatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("scan course set: %w", err)
}
return &item, nil
}
func scanCourseSetFromRows(rows pgx.Rows) (*CourseSet, error) {
var item CourseSet
err := rows.Scan(&item.ID, &item.PublicID, &item.PlaceID, &item.MapAssetID, &item.Code, &item.Mode, &item.Name, &item.Description, &item.Status, &item.CurrentVariantID, &item.CreatedAt, &item.UpdatedAt)
if err != nil {
return nil, fmt.Errorf("scan course set row: %w", err)
}
return &item, nil
}
func scanCourseVariant(row pgx.Row) (*CourseVariant, error) {
var item CourseVariant
var configPatch string
var metadataJSON string
err := row.Scan(&item.ID, &item.PublicID, &item.CourseSetID, &item.SourceID, &item.SourcePublicID, &item.Name, &item.RouteCode, &item.Mode, &item.ControlCount, &item.Difficulty, &item.Status, &item.IsDefault, &configPatch, &metadataJSON, &item.CreatedAt, &item.UpdatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("scan course variant: %w", err)
}
item.ConfigPatch = json.RawMessage(configPatch)
item.MetadataJSON = json.RawMessage(metadataJSON)
return &item, nil
}
func scanCourseVariantFromRows(rows pgx.Rows) (*CourseVariant, error) {
var item CourseVariant
var configPatch string
var metadataJSON string
err := rows.Scan(&item.ID, &item.PublicID, &item.CourseSetID, &item.SourceID, &item.SourcePublicID, &item.Name, &item.RouteCode, &item.Mode, &item.ControlCount, &item.Difficulty, &item.Status, &item.IsDefault, &configPatch, &metadataJSON, &item.CreatedAt, &item.UpdatedAt)
if err != nil {
return nil, fmt.Errorf("scan course variant row: %w", err)
}
item.ConfigPatch = json.RawMessage(configPatch)
item.MetadataJSON = json.RawMessage(metadataJSON)
return &item, nil
}
func scanMapRuntimeBinding(row pgx.Row) (*MapRuntimeBinding, error) {
var item MapRuntimeBinding
err := row.Scan(&item.ID, &item.PublicID, &item.EventID, &item.EventPublicID, &item.PlaceID, &item.PlacePublicID, &item.MapAssetID, &item.MapAssetPublicID, &item.TileReleaseID, &item.TileReleasePublicID, &item.CourseSetID, &item.CourseSetPublicID, &item.CourseVariantID, &item.CourseVariantPublicID, &item.Status, &item.Notes, &item.CreatedAt, &item.UpdatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("scan runtime binding: %w", err)
}
return &item, nil
}
func scanMapRuntimeBindingFromRows(rows pgx.Rows) (*MapRuntimeBinding, error) {
var item MapRuntimeBinding
err := rows.Scan(&item.ID, &item.PublicID, &item.EventID, &item.EventPublicID, &item.PlaceID, &item.PlacePublicID, &item.MapAssetID, &item.MapAssetPublicID, &item.TileReleaseID, &item.TileReleasePublicID, &item.CourseSetID, &item.CourseSetPublicID, &item.CourseVariantID, &item.CourseVariantPublicID, &item.Status, &item.Notes, &item.CreatedAt, &item.UpdatedAt)
if err != nil {
return nil, fmt.Errorf("scan runtime binding row: %w", err)
}
return &item, nil
}

View File

@@ -0,0 +1,185 @@
BEGIN;
CREATE TABLE places (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
place_public_id TEXT NOT NULL UNIQUE,
code TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
region TEXT,
cover_url TEXT,
description TEXT,
center_point_jsonb JSONB NOT NULL DEFAULT '{}'::jsonb,
status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'active', 'disabled', 'archived')),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX places_status_idx ON places(status);
CREATE TRIGGER places_set_updated_at
BEFORE UPDATE ON places
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
CREATE TABLE map_assets (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
map_asset_public_id TEXT NOT NULL UNIQUE,
place_id UUID NOT NULL REFERENCES places(id) ON DELETE CASCADE,
legacy_map_id UUID REFERENCES maps(id) ON DELETE SET NULL,
code TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
map_type TEXT NOT NULL DEFAULT 'standard',
cover_url TEXT,
description TEXT,
status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'active', 'disabled', 'archived')),
current_tile_release_id UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX map_assets_place_id_idx ON map_assets(place_id);
CREATE INDEX map_assets_legacy_map_id_idx ON map_assets(legacy_map_id);
CREATE INDEX map_assets_status_idx ON map_assets(status);
CREATE TRIGGER map_assets_set_updated_at
BEFORE UPDATE ON map_assets
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
CREATE TABLE tile_releases (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tile_release_public_id TEXT NOT NULL UNIQUE,
map_asset_id UUID NOT NULL REFERENCES map_assets(id) ON DELETE CASCADE,
legacy_map_version_id UUID REFERENCES map_versions(id) ON DELETE SET NULL,
version_code TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'active', 'published', 'retired', 'archived')),
tile_base_url TEXT NOT NULL,
meta_url TEXT NOT NULL,
published_asset_root TEXT,
metadata_jsonb JSONB NOT NULL DEFAULT '{}'::jsonb,
published_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (map_asset_id, version_code)
);
CREATE INDEX tile_releases_map_asset_id_idx ON tile_releases(map_asset_id);
CREATE INDEX tile_releases_legacy_map_version_id_idx ON tile_releases(legacy_map_version_id);
CREATE INDEX tile_releases_status_idx ON tile_releases(status);
CREATE TRIGGER tile_releases_set_updated_at
BEFORE UPDATE ON tile_releases
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
ALTER TABLE map_assets
ADD CONSTRAINT map_assets_current_tile_release_fk
FOREIGN KEY (current_tile_release_id) REFERENCES tile_releases(id) ON DELETE SET NULL;
CREATE TABLE course_sources (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
course_source_public_id TEXT NOT NULL UNIQUE,
legacy_playfield_version_id UUID REFERENCES playfield_versions(id) ON DELETE SET NULL,
source_type TEXT NOT NULL CHECK (source_type IN ('kml', 'geojson', 'control_set', 'json')),
file_url TEXT NOT NULL,
checksum TEXT,
parser_version TEXT,
import_status TEXT NOT NULL DEFAULT 'imported' CHECK (import_status IN ('draft', 'imported', 'parsed', 'failed', 'archived')),
metadata_jsonb JSONB NOT NULL DEFAULT '{}'::jsonb,
imported_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX course_sources_legacy_playfield_version_id_idx ON course_sources(legacy_playfield_version_id);
CREATE INDEX course_sources_source_type_idx ON course_sources(source_type);
CREATE INDEX course_sources_import_status_idx ON course_sources(import_status);
CREATE TRIGGER course_sources_set_updated_at
BEFORE UPDATE ON course_sources
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
CREATE TABLE course_sets (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
course_set_public_id TEXT NOT NULL UNIQUE,
place_id UUID NOT NULL REFERENCES places(id) ON DELETE RESTRICT,
map_asset_id UUID NOT NULL REFERENCES map_assets(id) ON DELETE RESTRICT,
code TEXT NOT NULL UNIQUE,
mode TEXT NOT NULL,
name TEXT NOT NULL,
description TEXT,
status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'active', 'disabled', 'archived')),
current_variant_id UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX course_sets_place_id_idx ON course_sets(place_id);
CREATE INDEX course_sets_map_asset_id_idx ON course_sets(map_asset_id);
CREATE INDEX course_sets_status_idx ON course_sets(status);
CREATE TRIGGER course_sets_set_updated_at
BEFORE UPDATE ON course_sets
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
CREATE TABLE course_variants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
course_variant_public_id TEXT NOT NULL UNIQUE,
course_set_id UUID NOT NULL REFERENCES course_sets(id) ON DELETE CASCADE,
source_id UUID REFERENCES course_sources(id) ON DELETE SET NULL,
name TEXT NOT NULL,
route_code TEXT,
mode TEXT NOT NULL,
control_count INTEGER,
difficulty TEXT,
status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'active', 'disabled', 'archived')),
is_default BOOLEAN NOT NULL DEFAULT FALSE,
config_patch_jsonb JSONB NOT NULL DEFAULT '{}'::jsonb,
metadata_jsonb JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX course_variants_course_set_id_idx ON course_variants(course_set_id);
CREATE INDEX course_variants_source_id_idx ON course_variants(source_id);
CREATE INDEX course_variants_status_idx ON course_variants(status);
CREATE INDEX course_variants_route_code_idx ON course_variants(route_code);
CREATE TRIGGER course_variants_set_updated_at
BEFORE UPDATE ON course_variants
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
ALTER TABLE course_sets
ADD CONSTRAINT course_sets_current_variant_fk
FOREIGN KEY (current_variant_id) REFERENCES course_variants(id) ON DELETE SET NULL;
CREATE TABLE map_runtime_bindings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
runtime_binding_public_id TEXT NOT NULL UNIQUE,
event_id UUID NOT NULL REFERENCES events(id) ON DELETE CASCADE,
place_id UUID NOT NULL REFERENCES places(id) ON DELETE RESTRICT,
map_asset_id UUID NOT NULL REFERENCES map_assets(id) ON DELETE RESTRICT,
tile_release_id UUID NOT NULL REFERENCES tile_releases(id) ON DELETE RESTRICT,
course_set_id UUID NOT NULL REFERENCES course_sets(id) ON DELETE RESTRICT,
course_variant_id UUID NOT NULL REFERENCES course_variants(id) ON DELETE RESTRICT,
status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'active', 'disabled', 'archived')),
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX map_runtime_bindings_event_id_idx ON map_runtime_bindings(event_id);
CREATE INDEX map_runtime_bindings_place_id_idx ON map_runtime_bindings(place_id);
CREATE INDEX map_runtime_bindings_map_asset_id_idx ON map_runtime_bindings(map_asset_id);
CREATE INDEX map_runtime_bindings_tile_release_id_idx ON map_runtime_bindings(tile_release_id);
CREATE INDEX map_runtime_bindings_course_set_id_idx ON map_runtime_bindings(course_set_id);
CREATE INDEX map_runtime_bindings_course_variant_id_idx ON map_runtime_bindings(course_variant_id);
CREATE INDEX map_runtime_bindings_status_idx ON map_runtime_bindings(status);
CREATE TRIGGER map_runtime_bindings_set_updated_at
BEFORE UPDATE ON map_runtime_bindings
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
ALTER TABLE event_releases
ADD COLUMN runtime_binding_id UUID REFERENCES map_runtime_bindings(id) ON DELETE SET NULL;
CREATE INDEX event_releases_runtime_binding_id_idx ON event_releases(runtime_binding_id);
COMMIT;

View File

@@ -0,0 +1,55 @@
BEGIN;
CREATE TABLE event_presentations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
presentation_public_id TEXT NOT NULL UNIQUE,
event_id UUID NOT NULL REFERENCES events(id) ON DELETE CASCADE,
code TEXT NOT NULL,
name TEXT NOT NULL,
presentation_type TEXT NOT NULL CHECK (presentation_type IN ('card', 'detail', 'h5', 'result', 'generic')),
status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'active', 'disabled', 'archived')),
is_default BOOLEAN NOT NULL DEFAULT FALSE,
schema_jsonb JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (event_id, code)
);
CREATE INDEX event_presentations_event_id_idx ON event_presentations(event_id);
CREATE INDEX event_presentations_status_idx ON event_presentations(status);
CREATE TRIGGER event_presentations_set_updated_at
BEFORE UPDATE ON event_presentations
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
CREATE TABLE content_bundles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
content_bundle_public_id TEXT NOT NULL UNIQUE,
event_id UUID NOT NULL REFERENCES events(id) ON DELETE CASCADE,
code TEXT NOT NULL,
name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'active', 'disabled', 'archived')),
is_default BOOLEAN NOT NULL DEFAULT FALSE,
entry_url TEXT,
asset_root_url TEXT,
metadata_jsonb JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (event_id, code)
);
CREATE INDEX content_bundles_event_id_idx ON content_bundles(event_id);
CREATE INDEX content_bundles_status_idx ON content_bundles(status);
CREATE TRIGGER content_bundles_set_updated_at
BEFORE UPDATE ON content_bundles
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
ALTER TABLE event_releases
ADD COLUMN presentation_id UUID REFERENCES event_presentations(id) ON DELETE SET NULL,
ADD COLUMN content_bundle_id UUID REFERENCES content_bundles(id) ON DELETE SET NULL;
CREATE INDEX event_releases_presentation_id_idx ON event_releases(presentation_id);
CREATE INDEX event_releases_content_bundle_id_idx ON event_releases(content_bundle_id);
COMMIT;

View File

@@ -0,0 +1,12 @@
BEGIN;
ALTER TABLE events
ADD COLUMN current_presentation_id UUID REFERENCES event_presentations(id) ON DELETE SET NULL,
ADD COLUMN current_content_bundle_id UUID REFERENCES content_bundles(id) ON DELETE SET NULL,
ADD COLUMN current_runtime_binding_id UUID REFERENCES map_runtime_bindings(id) ON DELETE SET NULL;
CREATE INDEX events_current_presentation_id_idx ON events(current_presentation_id);
CREATE INDEX events_current_content_bundle_id_idx ON events(current_content_bundle_id);
CREATE INDEX events_current_runtime_binding_id_idx ON events(current_runtime_binding_id);
COMMIT;