production_store.go 36 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018
  1. package postgres
  2. import (
  3. "context"
  4. "encoding/json"
  5. "errors"
  6. "fmt"
  7. "time"
  8. "github.com/jackc/pgx/v5"
  9. )
  10. type Place struct {
  11. ID string
  12. PublicID string
  13. Code string
  14. Name string
  15. Region *string
  16. CoverURL *string
  17. Description *string
  18. CenterPoint json.RawMessage
  19. Status string
  20. CreatedAt time.Time
  21. UpdatedAt time.Time
  22. }
  23. type MapAsset struct {
  24. ID string
  25. PublicID string
  26. PlaceID string
  27. PlacePublicID *string
  28. PlaceName *string
  29. LegacyMapID *string
  30. LegacyMapPublicID *string
  31. Code string
  32. Name string
  33. MapType string
  34. CoverURL *string
  35. Description *string
  36. Status string
  37. CurrentTileReleaseID *string
  38. CreatedAt time.Time
  39. UpdatedAt time.Time
  40. }
  41. type TileRelease struct {
  42. ID string
  43. PublicID string
  44. MapAssetID string
  45. LegacyMapVersionID *string
  46. LegacyMapVersionPub *string
  47. VersionCode string
  48. Status string
  49. TileBaseURL string
  50. MetaURL string
  51. PublishedAssetRoot *string
  52. MetadataJSON json.RawMessage
  53. PublishedAt *time.Time
  54. CreatedAt time.Time
  55. UpdatedAt time.Time
  56. }
  57. type CourseSource struct {
  58. ID string
  59. PublicID string
  60. LegacyPlayfieldVersionID *string
  61. LegacyPlayfieldVersionPub *string
  62. SourceType string
  63. FileURL string
  64. Checksum *string
  65. ParserVersion *string
  66. ImportStatus string
  67. MetadataJSON json.RawMessage
  68. ImportedAt time.Time
  69. CreatedAt time.Time
  70. UpdatedAt time.Time
  71. }
  72. type CourseSet struct {
  73. ID string
  74. PublicID string
  75. PlaceID string
  76. MapAssetID string
  77. Code string
  78. Mode string
  79. Name string
  80. Description *string
  81. Status string
  82. CurrentVariantID *string
  83. CreatedAt time.Time
  84. UpdatedAt time.Time
  85. }
  86. type CourseVariant struct {
  87. ID string
  88. PublicID string
  89. CourseSetID string
  90. SourceID *string
  91. SourcePublicID *string
  92. Name string
  93. RouteCode *string
  94. Mode string
  95. ControlCount *int
  96. Difficulty *string
  97. Status string
  98. IsDefault bool
  99. ConfigPatch json.RawMessage
  100. MetadataJSON json.RawMessage
  101. CreatedAt time.Time
  102. UpdatedAt time.Time
  103. }
  104. type MapRuntimeBinding struct {
  105. ID string
  106. PublicID string
  107. EventID string
  108. EventPublicID string
  109. PlaceID string
  110. PlacePublicID string
  111. MapAssetID string
  112. MapAssetPublicID string
  113. TileReleaseID string
  114. TileReleasePublicID string
  115. CourseSetID string
  116. CourseSetPublicID string
  117. CourseVariantID string
  118. CourseVariantPublicID string
  119. Status string
  120. Notes *string
  121. CreatedAt time.Time
  122. UpdatedAt time.Time
  123. }
  124. type CreatePlaceParams struct {
  125. PublicID string
  126. Code string
  127. Name string
  128. Region *string
  129. CoverURL *string
  130. Description *string
  131. CenterPoint map[string]any
  132. Status string
  133. }
  134. type CreateMapAssetParams struct {
  135. PublicID string
  136. PlaceID string
  137. LegacyMapID *string
  138. Code string
  139. Name string
  140. MapType string
  141. CoverURL *string
  142. Description *string
  143. Status string
  144. }
  145. type UpdateMapAssetParams struct {
  146. MapAssetID string
  147. Code string
  148. Name string
  149. MapType string
  150. CoverURL *string
  151. Description *string
  152. Status string
  153. }
  154. type CreateTileReleaseParams struct {
  155. PublicID string
  156. MapAssetID string
  157. LegacyMapVersionID *string
  158. VersionCode string
  159. Status string
  160. TileBaseURL string
  161. MetaURL string
  162. PublishedAssetRoot *string
  163. MetadataJSON map[string]any
  164. PublishedAt *time.Time
  165. }
  166. type CreateCourseSourceParams struct {
  167. PublicID string
  168. LegacyPlayfieldVersionID *string
  169. SourceType string
  170. FileURL string
  171. Checksum *string
  172. ParserVersion *string
  173. ImportStatus string
  174. MetadataJSON map[string]any
  175. ImportedAt *time.Time
  176. }
  177. type CreateCourseSetParams struct {
  178. PublicID string
  179. PlaceID string
  180. MapAssetID string
  181. Code string
  182. Mode string
  183. Name string
  184. Description *string
  185. Status string
  186. }
  187. type CreateCourseVariantParams struct {
  188. PublicID string
  189. CourseSetID string
  190. SourceID *string
  191. Name string
  192. RouteCode *string
  193. Mode string
  194. ControlCount *int
  195. Difficulty *string
  196. Status string
  197. IsDefault bool
  198. ConfigPatch map[string]any
  199. MetadataJSON map[string]any
  200. }
  201. type CreateMapRuntimeBindingParams struct {
  202. PublicID string
  203. EventID string
  204. PlaceID string
  205. MapAssetID string
  206. TileReleaseID string
  207. CourseSetID string
  208. CourseVariantID string
  209. Status string
  210. Notes *string
  211. }
  212. type MapAssetLinkedEvent struct {
  213. EventPublicID string
  214. DisplayName string
  215. Summary *string
  216. Status string
  217. IsDefaultExperience bool
  218. ShowInEventList bool
  219. CurrentReleasePublicID *string
  220. ConfigLabel *string
  221. RouteCode *string
  222. CurrentPresentationID *string
  223. CurrentPresentationName *string
  224. CurrentContentBundleID *string
  225. CurrentContentBundleName *string
  226. }
  227. func (s *Store) ListMapAssets(ctx context.Context, limit int) ([]MapAsset, error) {
  228. if limit <= 0 || limit > 200 {
  229. limit = 50
  230. }
  231. rows, err := s.pool.Query(ctx, `
  232. SELECT ma.id, ma.map_asset_public_id, ma.place_id, p.place_public_id, p.name, ma.legacy_map_id, lm.map_public_id, ma.code, ma.name, ma.map_type,
  233. ma.cover_url, ma.description, ma.status, ma.current_tile_release_id, ma.created_at, ma.updated_at
  234. FROM map_assets ma
  235. JOIN places p ON p.id = ma.place_id
  236. LEFT JOIN maps lm ON lm.id = ma.legacy_map_id
  237. ORDER BY ma.created_at DESC
  238. LIMIT $1
  239. `, limit)
  240. if err != nil {
  241. return nil, fmt.Errorf("list all map assets: %w", err)
  242. }
  243. defer rows.Close()
  244. items := []MapAsset{}
  245. for rows.Next() {
  246. item, err := scanMapAssetFromRows(rows)
  247. if err != nil {
  248. return nil, err
  249. }
  250. items = append(items, *item)
  251. }
  252. if err := rows.Err(); err != nil {
  253. return nil, fmt.Errorf("iterate all map assets: %w", err)
  254. }
  255. return items, nil
  256. }
  257. func (s *Store) ListPlaces(ctx context.Context, limit int) ([]Place, error) {
  258. if limit <= 0 || limit > 200 {
  259. limit = 50
  260. }
  261. rows, err := s.pool.Query(ctx, `
  262. SELECT id, place_public_id, code, name, region, cover_url, description, center_point_jsonb::text, status, created_at, updated_at
  263. FROM places
  264. ORDER BY created_at DESC
  265. LIMIT $1
  266. `, limit)
  267. if err != nil {
  268. return nil, fmt.Errorf("list places: %w", err)
  269. }
  270. defer rows.Close()
  271. items := []Place{}
  272. for rows.Next() {
  273. item, err := scanPlaceFromRows(rows)
  274. if err != nil {
  275. return nil, err
  276. }
  277. items = append(items, *item)
  278. }
  279. if err := rows.Err(); err != nil {
  280. return nil, fmt.Errorf("iterate places: %w", err)
  281. }
  282. return items, nil
  283. }
  284. func (s *Store) GetPlaceByPublicID(ctx context.Context, publicID string) (*Place, error) {
  285. row := s.pool.QueryRow(ctx, `
  286. SELECT id, place_public_id, code, name, region, cover_url, description, center_point_jsonb::text, status, created_at, updated_at
  287. FROM places
  288. WHERE place_public_id = $1
  289. LIMIT 1
  290. `, publicID)
  291. return scanPlace(row)
  292. }
  293. func (s *Store) GetPlaceByCode(ctx context.Context, code string) (*Place, error) {
  294. row := s.pool.QueryRow(ctx, `
  295. SELECT id, place_public_id, code, name, region, cover_url, description, center_point_jsonb::text, status, created_at, updated_at
  296. FROM places
  297. WHERE code = $1
  298. LIMIT 1
  299. `, code)
  300. return scanPlace(row)
  301. }
  302. func (s *Store) CreatePlace(ctx context.Context, tx Tx, params CreatePlaceParams) (*Place, error) {
  303. centerPointJSON, err := marshalJSONMap(params.CenterPoint)
  304. if err != nil {
  305. return nil, fmt.Errorf("marshal place center point: %w", err)
  306. }
  307. row := tx.QueryRow(ctx, `
  308. INSERT INTO places (place_public_id, code, name, region, cover_url, description, center_point_jsonb, status)
  309. VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8)
  310. RETURNING id, place_public_id, code, name, region, cover_url, description, center_point_jsonb::text, status, created_at, updated_at
  311. `, params.PublicID, params.Code, params.Name, params.Region, params.CoverURL, params.Description, centerPointJSON, params.Status)
  312. return scanPlace(row)
  313. }
  314. func (s *Store) ListMapAssetsByPlaceID(ctx context.Context, placeID string) ([]MapAsset, error) {
  315. rows, err := s.pool.Query(ctx, `
  316. SELECT ma.id, ma.map_asset_public_id, ma.place_id, p.place_public_id, p.name, ma.legacy_map_id, lm.map_public_id, ma.code, ma.name, ma.map_type,
  317. ma.cover_url, ma.description, ma.status, ma.current_tile_release_id, ma.created_at, ma.updated_at
  318. FROM map_assets ma
  319. JOIN places p ON p.id = ma.place_id
  320. LEFT JOIN maps lm ON lm.id = ma.legacy_map_id
  321. WHERE ma.place_id = $1
  322. ORDER BY ma.created_at DESC
  323. `, placeID)
  324. if err != nil {
  325. return nil, fmt.Errorf("list map assets: %w", err)
  326. }
  327. defer rows.Close()
  328. items := []MapAsset{}
  329. for rows.Next() {
  330. item, err := scanMapAssetFromRows(rows)
  331. if err != nil {
  332. return nil, err
  333. }
  334. items = append(items, *item)
  335. }
  336. if err := rows.Err(); err != nil {
  337. return nil, fmt.Errorf("iterate map assets: %w", err)
  338. }
  339. return items, nil
  340. }
  341. func (s *Store) GetMapAssetByPublicID(ctx context.Context, publicID string) (*MapAsset, error) {
  342. row := s.pool.QueryRow(ctx, `
  343. SELECT ma.id, ma.map_asset_public_id, ma.place_id, p.place_public_id, p.name, ma.legacy_map_id, lm.map_public_id, ma.code, ma.name, ma.map_type,
  344. ma.cover_url, ma.description, ma.status, ma.current_tile_release_id, ma.created_at, ma.updated_at
  345. FROM map_assets ma
  346. JOIN places p ON p.id = ma.place_id
  347. LEFT JOIN maps lm ON lm.id = ma.legacy_map_id
  348. WHERE ma.map_asset_public_id = $1
  349. LIMIT 1
  350. `, publicID)
  351. return scanMapAsset(row)
  352. }
  353. func (s *Store) GetMapAssetByCode(ctx context.Context, code string) (*MapAsset, error) {
  354. row := s.pool.QueryRow(ctx, `
  355. SELECT ma.id, ma.map_asset_public_id, ma.place_id, p.place_public_id, p.name, ma.legacy_map_id, lm.map_public_id, ma.code, ma.name, ma.map_type,
  356. ma.cover_url, ma.description, ma.status, ma.current_tile_release_id, ma.created_at, ma.updated_at
  357. FROM map_assets ma
  358. JOIN places p ON p.id = ma.place_id
  359. LEFT JOIN maps lm ON lm.id = ma.legacy_map_id
  360. WHERE ma.code = $1
  361. LIMIT 1
  362. `, code)
  363. return scanMapAsset(row)
  364. }
  365. func (s *Store) CreateMapAsset(ctx context.Context, tx Tx, params CreateMapAssetParams) (*MapAsset, error) {
  366. row := tx.QueryRow(ctx, `
  367. INSERT INTO map_assets (map_asset_public_id, place_id, legacy_map_id, code, name, map_type, cover_url, description, status)
  368. VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
  369. RETURNING id, map_asset_public_id, place_id, NULL::text, NULL::text, legacy_map_id, NULL::text, code, name, map_type, cover_url, description, status, current_tile_release_id, created_at, updated_at
  370. `, params.PublicID, params.PlaceID, params.LegacyMapID, params.Code, params.Name, params.MapType, params.CoverURL, params.Description, params.Status)
  371. return scanMapAsset(row)
  372. }
  373. func (s *Store) UpdateMapAsset(ctx context.Context, tx Tx, params UpdateMapAssetParams) (*MapAsset, error) {
  374. row := tx.QueryRow(ctx, `
  375. UPDATE map_assets
  376. SET code = $2,
  377. name = $3,
  378. map_type = $4,
  379. cover_url = $5,
  380. description = $6,
  381. status = $7,
  382. updated_at = NOW()
  383. WHERE id = $1
  384. RETURNING id, map_asset_public_id, place_id, NULL::text, NULL::text, legacy_map_id, NULL::text, code, name, map_type, cover_url, description, status, current_tile_release_id, created_at, updated_at
  385. `, params.MapAssetID, params.Code, params.Name, params.MapType, params.CoverURL, params.Description, params.Status)
  386. return scanMapAsset(row)
  387. }
  388. func (s *Store) ListTileReleasesByMapAssetID(ctx context.Context, mapAssetID string) ([]TileRelease, error) {
  389. rows, err := s.pool.Query(ctx, `
  390. SELECT tr.id, tr.tile_release_public_id, tr.map_asset_id, tr.legacy_map_version_id, mv.version_public_id,
  391. tr.version_code, tr.status, tr.tile_base_url, tr.meta_url, tr.published_asset_root,
  392. tr.metadata_jsonb::text, tr.published_at, tr.created_at, tr.updated_at
  393. FROM tile_releases tr
  394. LEFT JOIN map_versions mv ON mv.id = tr.legacy_map_version_id
  395. WHERE tr.map_asset_id = $1
  396. ORDER BY tr.created_at DESC
  397. `, mapAssetID)
  398. if err != nil {
  399. return nil, fmt.Errorf("list tile releases: %w", err)
  400. }
  401. defer rows.Close()
  402. items := []TileRelease{}
  403. for rows.Next() {
  404. item, err := scanTileReleaseFromRows(rows)
  405. if err != nil {
  406. return nil, err
  407. }
  408. items = append(items, *item)
  409. }
  410. if err := rows.Err(); err != nil {
  411. return nil, fmt.Errorf("iterate tile releases: %w", err)
  412. }
  413. return items, nil
  414. }
  415. func (s *Store) GetTileReleaseByPublicID(ctx context.Context, publicID string) (*TileRelease, error) {
  416. row := s.pool.QueryRow(ctx, `
  417. SELECT tr.id, tr.tile_release_public_id, tr.map_asset_id, tr.legacy_map_version_id, mv.version_public_id,
  418. tr.version_code, tr.status, tr.tile_base_url, tr.meta_url, tr.published_asset_root,
  419. tr.metadata_jsonb::text, tr.published_at, tr.created_at, tr.updated_at
  420. FROM tile_releases tr
  421. LEFT JOIN map_versions mv ON mv.id = tr.legacy_map_version_id
  422. WHERE tr.tile_release_public_id = $1
  423. LIMIT 1
  424. `, publicID)
  425. return scanTileRelease(row)
  426. }
  427. func (s *Store) GetTileReleaseByMapAssetIDAndVersionCode(ctx context.Context, mapAssetID, versionCode string) (*TileRelease, error) {
  428. row := s.pool.QueryRow(ctx, `
  429. SELECT tr.id, tr.tile_release_public_id, tr.map_asset_id, tr.legacy_map_version_id, mv.version_public_id,
  430. tr.version_code, tr.status, tr.tile_base_url, tr.meta_url, tr.published_asset_root,
  431. tr.metadata_jsonb::text, tr.published_at, tr.created_at, tr.updated_at
  432. FROM tile_releases tr
  433. LEFT JOIN map_versions mv ON mv.id = tr.legacy_map_version_id
  434. WHERE tr.map_asset_id = $1 AND tr.version_code = $2
  435. LIMIT 1
  436. `, mapAssetID, versionCode)
  437. return scanTileRelease(row)
  438. }
  439. func (s *Store) CreateTileRelease(ctx context.Context, tx Tx, params CreateTileReleaseParams) (*TileRelease, error) {
  440. metadataJSON, err := marshalJSONMap(params.MetadataJSON)
  441. if err != nil {
  442. return nil, fmt.Errorf("marshal tile release metadata: %w", err)
  443. }
  444. row := tx.QueryRow(ctx, `
  445. INSERT INTO tile_releases (
  446. tile_release_public_id, map_asset_id, legacy_map_version_id, version_code, status,
  447. tile_base_url, meta_url, published_asset_root, metadata_jsonb, published_at
  448. )
  449. VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, $10)
  450. RETURNING id, tile_release_public_id, map_asset_id, legacy_map_version_id, NULL::text, version_code, status,
  451. tile_base_url, meta_url, published_asset_root, metadata_jsonb::text, published_at, created_at, updated_at
  452. `, params.PublicID, params.MapAssetID, params.LegacyMapVersionID, params.VersionCode, params.Status, params.TileBaseURL, params.MetaURL, params.PublishedAssetRoot, metadataJSON, params.PublishedAt)
  453. return scanTileRelease(row)
  454. }
  455. func (s *Store) SetMapAssetCurrentTileRelease(ctx context.Context, tx Tx, mapAssetID, tileReleaseID string) error {
  456. _, err := tx.Exec(ctx, `UPDATE map_assets SET current_tile_release_id = $2 WHERE id = $1`, mapAssetID, tileReleaseID)
  457. if err != nil {
  458. return fmt.Errorf("set map asset current tile release: %w", err)
  459. }
  460. return nil
  461. }
  462. func (s *Store) ListCourseSources(ctx context.Context, limit int) ([]CourseSource, error) {
  463. if limit <= 0 || limit > 200 {
  464. limit = 50
  465. }
  466. rows, err := s.pool.Query(ctx, `
  467. SELECT cs.id, cs.course_source_public_id, cs.legacy_playfield_version_id, pv.version_public_id, cs.source_type,
  468. cs.file_url, cs.checksum, cs.parser_version, cs.import_status, cs.metadata_jsonb::text, cs.imported_at, cs.created_at, cs.updated_at
  469. FROM course_sources cs
  470. LEFT JOIN playfield_versions pv ON pv.id = cs.legacy_playfield_version_id
  471. ORDER BY cs.created_at DESC
  472. LIMIT $1
  473. `, limit)
  474. if err != nil {
  475. return nil, fmt.Errorf("list course sources: %w", err)
  476. }
  477. defer rows.Close()
  478. items := []CourseSource{}
  479. for rows.Next() {
  480. item, err := scanCourseSourceFromRows(rows)
  481. if err != nil {
  482. return nil, err
  483. }
  484. items = append(items, *item)
  485. }
  486. if err := rows.Err(); err != nil {
  487. return nil, fmt.Errorf("iterate course sources: %w", err)
  488. }
  489. return items, nil
  490. }
  491. func (s *Store) GetCourseSourceByPublicID(ctx context.Context, publicID string) (*CourseSource, error) {
  492. row := s.pool.QueryRow(ctx, `
  493. SELECT cs.id, cs.course_source_public_id, cs.legacy_playfield_version_id, pv.version_public_id, cs.source_type,
  494. cs.file_url, cs.checksum, cs.parser_version, cs.import_status, cs.metadata_jsonb::text, cs.imported_at, cs.created_at, cs.updated_at
  495. FROM course_sources cs
  496. LEFT JOIN playfield_versions pv ON pv.id = cs.legacy_playfield_version_id
  497. WHERE cs.course_source_public_id = $1
  498. LIMIT 1
  499. `, publicID)
  500. return scanCourseSource(row)
  501. }
  502. func (s *Store) CreateCourseSource(ctx context.Context, tx Tx, params CreateCourseSourceParams) (*CourseSource, error) {
  503. metadataJSON, err := marshalJSONMap(params.MetadataJSON)
  504. if err != nil {
  505. return nil, fmt.Errorf("marshal course source metadata: %w", err)
  506. }
  507. importedAt := time.Now()
  508. if params.ImportedAt != nil {
  509. importedAt = *params.ImportedAt
  510. }
  511. row := tx.QueryRow(ctx, `
  512. INSERT INTO course_sources (
  513. course_source_public_id, legacy_playfield_version_id, source_type, file_url, checksum,
  514. parser_version, import_status, metadata_jsonb, imported_at
  515. )
  516. VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9)
  517. RETURNING id, course_source_public_id, legacy_playfield_version_id, NULL::text, source_type, file_url,
  518. checksum, parser_version, import_status, metadata_jsonb::text, imported_at, created_at, updated_at
  519. `, params.PublicID, params.LegacyPlayfieldVersionID, params.SourceType, params.FileURL, params.Checksum, params.ParserVersion, params.ImportStatus, metadataJSON, importedAt)
  520. return scanCourseSource(row)
  521. }
  522. func (s *Store) ListCourseSets(ctx context.Context, limit int) ([]CourseSet, error) {
  523. if limit <= 0 || limit > 200 {
  524. limit = 50
  525. }
  526. rows, err := s.pool.Query(ctx, `
  527. SELECT id, course_set_public_id, place_id, map_asset_id, code, mode, name, description, status, current_variant_id, created_at, updated_at
  528. FROM course_sets
  529. ORDER BY created_at DESC
  530. LIMIT $1
  531. `, limit)
  532. if err != nil {
  533. return nil, fmt.Errorf("list course sets: %w", err)
  534. }
  535. defer rows.Close()
  536. items := []CourseSet{}
  537. for rows.Next() {
  538. item, err := scanCourseSetFromRows(rows)
  539. if err != nil {
  540. return nil, err
  541. }
  542. items = append(items, *item)
  543. }
  544. if err := rows.Err(); err != nil {
  545. return nil, fmt.Errorf("iterate course sets: %w", err)
  546. }
  547. return items, nil
  548. }
  549. func (s *Store) ListCourseSetsByMapAssetID(ctx context.Context, mapAssetID string) ([]CourseSet, error) {
  550. rows, err := s.pool.Query(ctx, `
  551. SELECT id, course_set_public_id, place_id, map_asset_id, code, mode, name, description, status, current_variant_id, created_at, updated_at
  552. FROM course_sets
  553. WHERE map_asset_id = $1
  554. ORDER BY created_at DESC
  555. `, mapAssetID)
  556. if err != nil {
  557. return nil, fmt.Errorf("list course sets by map asset: %w", err)
  558. }
  559. defer rows.Close()
  560. items := []CourseSet{}
  561. for rows.Next() {
  562. item, err := scanCourseSetFromRows(rows)
  563. if err != nil {
  564. return nil, err
  565. }
  566. items = append(items, *item)
  567. }
  568. if err := rows.Err(); err != nil {
  569. return nil, fmt.Errorf("iterate course sets by map asset: %w", err)
  570. }
  571. return items, nil
  572. }
  573. func (s *Store) GetCourseSetByPublicID(ctx context.Context, publicID string) (*CourseSet, error) {
  574. row := s.pool.QueryRow(ctx, `
  575. SELECT id, course_set_public_id, place_id, map_asset_id, code, mode, name, description, status, current_variant_id, created_at, updated_at
  576. FROM course_sets
  577. WHERE course_set_public_id = $1
  578. LIMIT 1
  579. `, publicID)
  580. return scanCourseSet(row)
  581. }
  582. func (s *Store) GetCourseSetByCode(ctx context.Context, code string) (*CourseSet, error) {
  583. row := s.pool.QueryRow(ctx, `
  584. SELECT id, course_set_public_id, place_id, map_asset_id, code, mode, name, description, status, current_variant_id, created_at, updated_at
  585. FROM course_sets
  586. WHERE code = $1
  587. LIMIT 1
  588. `, code)
  589. return scanCourseSet(row)
  590. }
  591. func (s *Store) CreateCourseSet(ctx context.Context, tx Tx, params CreateCourseSetParams) (*CourseSet, error) {
  592. row := tx.QueryRow(ctx, `
  593. INSERT INTO course_sets (course_set_public_id, place_id, map_asset_id, code, mode, name, description, status)
  594. VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
  595. RETURNING id, course_set_public_id, place_id, map_asset_id, code, mode, name, description, status, current_variant_id, created_at, updated_at
  596. `, params.PublicID, params.PlaceID, params.MapAssetID, params.Code, params.Mode, params.Name, params.Description, params.Status)
  597. return scanCourseSet(row)
  598. }
  599. func (s *Store) ListCourseVariantsByCourseSetID(ctx context.Context, courseSetID string) ([]CourseVariant, error) {
  600. rows, err := s.pool.Query(ctx, `
  601. 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,
  602. cv.mode, cv.control_count, cv.difficulty, cv.status, cv.is_default,
  603. cv.config_patch_jsonb::text, cv.metadata_jsonb::text, cv.created_at, cv.updated_at
  604. FROM course_variants cv
  605. LEFT JOIN course_sources cs ON cs.id = cv.source_id
  606. WHERE cv.course_set_id = $1
  607. ORDER BY cv.created_at DESC
  608. `, courseSetID)
  609. if err != nil {
  610. return nil, fmt.Errorf("list course variants: %w", err)
  611. }
  612. defer rows.Close()
  613. items := []CourseVariant{}
  614. for rows.Next() {
  615. item, err := scanCourseVariantFromRows(rows)
  616. if err != nil {
  617. return nil, err
  618. }
  619. items = append(items, *item)
  620. }
  621. if err := rows.Err(); err != nil {
  622. return nil, fmt.Errorf("iterate course variants: %w", err)
  623. }
  624. return items, nil
  625. }
  626. func (s *Store) GetCourseVariantByPublicID(ctx context.Context, publicID string) (*CourseVariant, error) {
  627. row := s.pool.QueryRow(ctx, `
  628. 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,
  629. cv.mode, cv.control_count, cv.difficulty, cv.status, cv.is_default,
  630. cv.config_patch_jsonb::text, cv.metadata_jsonb::text, cv.created_at, cv.updated_at
  631. FROM course_variants cv
  632. LEFT JOIN course_sources cs ON cs.id = cv.source_id
  633. WHERE cv.course_variant_public_id = $1
  634. LIMIT 1
  635. `, publicID)
  636. return scanCourseVariant(row)
  637. }
  638. func (s *Store) GetCourseVariantByCourseSetIDAndRouteCode(ctx context.Context, courseSetID, routeCode string) (*CourseVariant, error) {
  639. row := s.pool.QueryRow(ctx, `
  640. 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,
  641. cv.mode, cv.control_count, cv.difficulty, cv.status, cv.is_default,
  642. cv.config_patch_jsonb::text, cv.metadata_jsonb::text, cv.created_at, cv.updated_at
  643. FROM course_variants cv
  644. LEFT JOIN course_sources cs ON cs.id = cv.source_id
  645. WHERE cv.course_set_id = $1 AND cv.route_code = $2
  646. LIMIT 1
  647. `, courseSetID, routeCode)
  648. return scanCourseVariant(row)
  649. }
  650. func (s *Store) CreateCourseVariant(ctx context.Context, tx Tx, params CreateCourseVariantParams) (*CourseVariant, error) {
  651. configPatchJSON, err := marshalJSONMap(params.ConfigPatch)
  652. if err != nil {
  653. return nil, fmt.Errorf("marshal course variant config patch: %w", err)
  654. }
  655. metadataJSON, err := marshalJSONMap(params.MetadataJSON)
  656. if err != nil {
  657. return nil, fmt.Errorf("marshal course variant metadata: %w", err)
  658. }
  659. row := tx.QueryRow(ctx, `
  660. INSERT INTO course_variants (
  661. course_variant_public_id, course_set_id, source_id, name, route_code, mode, control_count,
  662. difficulty, status, is_default, config_patch_jsonb, metadata_jsonb
  663. )
  664. VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11::jsonb, $12::jsonb)
  665. RETURNING id, course_variant_public_id, course_set_id, source_id, NULL::text, name, route_code, mode,
  666. control_count, difficulty, status, is_default, config_patch_jsonb::text, metadata_jsonb::text, created_at, updated_at
  667. `, params.PublicID, params.CourseSetID, params.SourceID, params.Name, params.RouteCode, params.Mode, params.ControlCount, params.Difficulty, params.Status, params.IsDefault, configPatchJSON, metadataJSON)
  668. return scanCourseVariant(row)
  669. }
  670. func (s *Store) SetCourseSetCurrentVariant(ctx context.Context, tx Tx, courseSetID, variantID string) error {
  671. _, err := tx.Exec(ctx, `UPDATE course_sets SET current_variant_id = $2 WHERE id = $1`, courseSetID, variantID)
  672. if err != nil {
  673. return fmt.Errorf("set course set current variant: %w", err)
  674. }
  675. return nil
  676. }
  677. func (s *Store) ListMapRuntimeBindings(ctx context.Context, limit int) ([]MapRuntimeBinding, error) {
  678. if limit <= 0 || limit > 200 {
  679. limit = 50
  680. }
  681. rows, err := s.pool.Query(ctx, `
  682. SELECT mrb.id, mrb.runtime_binding_public_id, mrb.event_id, e.event_public_id, mrb.place_id, p.place_public_id,
  683. mrb.map_asset_id, ma.map_asset_public_id, mrb.tile_release_id, tr.tile_release_public_id,
  684. mrb.course_set_id, cset.course_set_public_id, mrb.course_variant_id, cv.course_variant_public_id,
  685. mrb.status, mrb.notes, mrb.created_at, mrb.updated_at
  686. FROM map_runtime_bindings mrb
  687. JOIN events e ON e.id = mrb.event_id
  688. JOIN places p ON p.id = mrb.place_id
  689. JOIN map_assets ma ON ma.id = mrb.map_asset_id
  690. JOIN tile_releases tr ON tr.id = mrb.tile_release_id
  691. JOIN course_sets cset ON cset.id = mrb.course_set_id
  692. JOIN course_variants cv ON cv.id = mrb.course_variant_id
  693. ORDER BY mrb.created_at DESC
  694. LIMIT $1
  695. `, limit)
  696. if err != nil {
  697. return nil, fmt.Errorf("list runtime bindings: %w", err)
  698. }
  699. defer rows.Close()
  700. items := []MapRuntimeBinding{}
  701. for rows.Next() {
  702. item, err := scanMapRuntimeBindingFromRows(rows)
  703. if err != nil {
  704. return nil, err
  705. }
  706. items = append(items, *item)
  707. }
  708. if err := rows.Err(); err != nil {
  709. return nil, fmt.Errorf("iterate runtime bindings: %w", err)
  710. }
  711. return items, nil
  712. }
  713. func (s *Store) GetMapRuntimeBindingByPublicID(ctx context.Context, publicID string) (*MapRuntimeBinding, error) {
  714. row := s.pool.QueryRow(ctx, `
  715. SELECT mrb.id, mrb.runtime_binding_public_id, mrb.event_id, e.event_public_id, mrb.place_id, p.place_public_id,
  716. mrb.map_asset_id, ma.map_asset_public_id, mrb.tile_release_id, tr.tile_release_public_id,
  717. mrb.course_set_id, cset.course_set_public_id, mrb.course_variant_id, cv.course_variant_public_id,
  718. mrb.status, mrb.notes, mrb.created_at, mrb.updated_at
  719. FROM map_runtime_bindings mrb
  720. JOIN events e ON e.id = mrb.event_id
  721. JOIN places p ON p.id = mrb.place_id
  722. JOIN map_assets ma ON ma.id = mrb.map_asset_id
  723. JOIN tile_releases tr ON tr.id = mrb.tile_release_id
  724. JOIN course_sets cset ON cset.id = mrb.course_set_id
  725. JOIN course_variants cv ON cv.id = mrb.course_variant_id
  726. WHERE mrb.runtime_binding_public_id = $1
  727. LIMIT 1
  728. `, publicID)
  729. return scanMapRuntimeBinding(row)
  730. }
  731. func (s *Store) ListMapAssetLinkedEvents(ctx context.Context, mapAssetID string, limit int) ([]MapAssetLinkedEvent, error) {
  732. if limit <= 0 || limit > 200 {
  733. limit = 50
  734. }
  735. rows, err := s.pool.Query(ctx, `
  736. SELECT
  737. e.event_public_id,
  738. e.display_name,
  739. e.summary,
  740. e.status,
  741. COALESCE(e.is_default_experience, false),
  742. COALESCE(e.show_in_event_list, true),
  743. er.release_public_id,
  744. er.config_label,
  745. er.route_code,
  746. ep.presentation_public_id,
  747. ep.name,
  748. cb.content_bundle_public_id,
  749. cb.name
  750. FROM events e
  751. JOIN map_runtime_bindings mrb ON mrb.id = e.current_runtime_binding_id
  752. LEFT JOIN event_releases er ON er.id = e.current_release_id
  753. LEFT JOIN event_presentations ep ON ep.id = e.current_presentation_id
  754. LEFT JOIN content_bundles cb ON cb.id = e.current_content_bundle_id
  755. WHERE mrb.map_asset_id = $1
  756. ORDER BY COALESCE(e.is_default_experience, false) DESC, e.display_name ASC
  757. LIMIT $2
  758. `, mapAssetID, limit)
  759. if err != nil {
  760. return nil, fmt.Errorf("list map asset linked events: %w", err)
  761. }
  762. defer rows.Close()
  763. items := []MapAssetLinkedEvent{}
  764. for rows.Next() {
  765. var item MapAssetLinkedEvent
  766. if err := rows.Scan(
  767. &item.EventPublicID,
  768. &item.DisplayName,
  769. &item.Summary,
  770. &item.Status,
  771. &item.IsDefaultExperience,
  772. &item.ShowInEventList,
  773. &item.CurrentReleasePublicID,
  774. &item.ConfigLabel,
  775. &item.RouteCode,
  776. &item.CurrentPresentationID,
  777. &item.CurrentPresentationName,
  778. &item.CurrentContentBundleID,
  779. &item.CurrentContentBundleName,
  780. ); err != nil {
  781. return nil, fmt.Errorf("scan map asset linked event: %w", err)
  782. }
  783. items = append(items, item)
  784. }
  785. if err := rows.Err(); err != nil {
  786. return nil, fmt.Errorf("iterate map asset linked events: %w", err)
  787. }
  788. return items, nil
  789. }
  790. func (s *Store) CreateMapRuntimeBinding(ctx context.Context, tx Tx, params CreateMapRuntimeBindingParams) (*MapRuntimeBinding, error) {
  791. row := tx.QueryRow(ctx, `
  792. INSERT INTO map_runtime_bindings (
  793. runtime_binding_public_id, event_id, place_id, map_asset_id, tile_release_id, course_set_id, course_variant_id, status, notes
  794. )
  795. VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
  796. RETURNING id, runtime_binding_public_id, event_id, ''::text, place_id, ''::text, map_asset_id, ''::text,
  797. tile_release_id, ''::text, course_set_id, ''::text, course_variant_id, ''::text,
  798. status, notes, created_at, updated_at
  799. `, params.PublicID, params.EventID, params.PlaceID, params.MapAssetID, params.TileReleaseID, params.CourseSetID, params.CourseVariantID, params.Status, params.Notes)
  800. return scanMapRuntimeBinding(row)
  801. }
  802. func scanPlace(row pgx.Row) (*Place, error) {
  803. var item Place
  804. var centerPoint string
  805. err := row.Scan(&item.ID, &item.PublicID, &item.Code, &item.Name, &item.Region, &item.CoverURL, &item.Description, &centerPoint, &item.Status, &item.CreatedAt, &item.UpdatedAt)
  806. if errors.Is(err, pgx.ErrNoRows) {
  807. return nil, nil
  808. }
  809. if err != nil {
  810. return nil, fmt.Errorf("scan place: %w", err)
  811. }
  812. item.CenterPoint = json.RawMessage(centerPoint)
  813. return &item, nil
  814. }
  815. func scanPlaceFromRows(rows pgx.Rows) (*Place, error) {
  816. var item Place
  817. var centerPoint string
  818. err := rows.Scan(&item.ID, &item.PublicID, &item.Code, &item.Name, &item.Region, &item.CoverURL, &item.Description, &centerPoint, &item.Status, &item.CreatedAt, &item.UpdatedAt)
  819. if err != nil {
  820. return nil, fmt.Errorf("scan place row: %w", err)
  821. }
  822. item.CenterPoint = json.RawMessage(centerPoint)
  823. return &item, nil
  824. }
  825. func scanMapAsset(row pgx.Row) (*MapAsset, error) {
  826. var item MapAsset
  827. err := row.Scan(&item.ID, &item.PublicID, &item.PlaceID, &item.PlacePublicID, &item.PlaceName, &item.LegacyMapID, &item.LegacyMapPublicID, &item.Code, &item.Name, &item.MapType, &item.CoverURL, &item.Description, &item.Status, &item.CurrentTileReleaseID, &item.CreatedAt, &item.UpdatedAt)
  828. if errors.Is(err, pgx.ErrNoRows) {
  829. return nil, nil
  830. }
  831. if err != nil {
  832. return nil, fmt.Errorf("scan map asset: %w", err)
  833. }
  834. return &item, nil
  835. }
  836. func scanMapAssetFromRows(rows pgx.Rows) (*MapAsset, error) {
  837. var item MapAsset
  838. err := rows.Scan(&item.ID, &item.PublicID, &item.PlaceID, &item.PlacePublicID, &item.PlaceName, &item.LegacyMapID, &item.LegacyMapPublicID, &item.Code, &item.Name, &item.MapType, &item.CoverURL, &item.Description, &item.Status, &item.CurrentTileReleaseID, &item.CreatedAt, &item.UpdatedAt)
  839. if err != nil {
  840. return nil, fmt.Errorf("scan map asset row: %w", err)
  841. }
  842. return &item, nil
  843. }
  844. func scanTileRelease(row pgx.Row) (*TileRelease, error) {
  845. var item TileRelease
  846. var metadataJSON string
  847. 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)
  848. if errors.Is(err, pgx.ErrNoRows) {
  849. return nil, nil
  850. }
  851. if err != nil {
  852. return nil, fmt.Errorf("scan tile release: %w", err)
  853. }
  854. item.MetadataJSON = json.RawMessage(metadataJSON)
  855. return &item, nil
  856. }
  857. func scanTileReleaseFromRows(rows pgx.Rows) (*TileRelease, error) {
  858. var item TileRelease
  859. var metadataJSON string
  860. 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)
  861. if err != nil {
  862. return nil, fmt.Errorf("scan tile release row: %w", err)
  863. }
  864. item.MetadataJSON = json.RawMessage(metadataJSON)
  865. return &item, nil
  866. }
  867. func scanCourseSource(row pgx.Row) (*CourseSource, error) {
  868. var item CourseSource
  869. var metadataJSON string
  870. 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)
  871. if errors.Is(err, pgx.ErrNoRows) {
  872. return nil, nil
  873. }
  874. if err != nil {
  875. return nil, fmt.Errorf("scan course source: %w", err)
  876. }
  877. item.MetadataJSON = json.RawMessage(metadataJSON)
  878. return &item, nil
  879. }
  880. func scanCourseSourceFromRows(rows pgx.Rows) (*CourseSource, error) {
  881. var item CourseSource
  882. var metadataJSON string
  883. 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)
  884. if err != nil {
  885. return nil, fmt.Errorf("scan course source row: %w", err)
  886. }
  887. item.MetadataJSON = json.RawMessage(metadataJSON)
  888. return &item, nil
  889. }
  890. func scanCourseSet(row pgx.Row) (*CourseSet, error) {
  891. var item CourseSet
  892. 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)
  893. if errors.Is(err, pgx.ErrNoRows) {
  894. return nil, nil
  895. }
  896. if err != nil {
  897. return nil, fmt.Errorf("scan course set: %w", err)
  898. }
  899. return &item, nil
  900. }
  901. func scanCourseSetFromRows(rows pgx.Rows) (*CourseSet, error) {
  902. var item CourseSet
  903. 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)
  904. if err != nil {
  905. return nil, fmt.Errorf("scan course set row: %w", err)
  906. }
  907. return &item, nil
  908. }
  909. func scanCourseVariant(row pgx.Row) (*CourseVariant, error) {
  910. var item CourseVariant
  911. var configPatch string
  912. var metadataJSON string
  913. 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)
  914. if errors.Is(err, pgx.ErrNoRows) {
  915. return nil, nil
  916. }
  917. if err != nil {
  918. return nil, fmt.Errorf("scan course variant: %w", err)
  919. }
  920. item.ConfigPatch = json.RawMessage(configPatch)
  921. item.MetadataJSON = json.RawMessage(metadataJSON)
  922. return &item, nil
  923. }
  924. func scanCourseVariantFromRows(rows pgx.Rows) (*CourseVariant, error) {
  925. var item CourseVariant
  926. var configPatch string
  927. var metadataJSON string
  928. 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)
  929. if err != nil {
  930. return nil, fmt.Errorf("scan course variant row: %w", err)
  931. }
  932. item.ConfigPatch = json.RawMessage(configPatch)
  933. item.MetadataJSON = json.RawMessage(metadataJSON)
  934. return &item, nil
  935. }
  936. func scanMapRuntimeBinding(row pgx.Row) (*MapRuntimeBinding, error) {
  937. var item MapRuntimeBinding
  938. 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)
  939. if errors.Is(err, pgx.ErrNoRows) {
  940. return nil, nil
  941. }
  942. if err != nil {
  943. return nil, fmt.Errorf("scan runtime binding: %w", err)
  944. }
  945. return &item, nil
  946. }
  947. func scanMapRuntimeBindingFromRows(rows pgx.Rows) (*MapRuntimeBinding, error) {
  948. var item MapRuntimeBinding
  949. 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)
  950. if err != nil {
  951. return nil, fmt.Errorf("scan runtime binding row: %w", err)
  952. }
  953. return &item, nil
  954. }