@coursebuilder/adapter-drizzle 2.0.1-canary.cd34f124e → 2.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/dist/{chunk-WPDVDHLH.js → chunk-2AW5KYAK.js} +2 -2
  2. package/dist/chunk-62VSIVME.js +4714 -0
  3. package/dist/{chunk-6VDGZ4KA.js → chunk-7FBSP3VD.js} +2 -2
  4. package/dist/{chunk-QDWLBCD3.js → chunk-AP5HBIV4.js} +2 -2
  5. package/dist/{chunk-DHII2GRW.js → chunk-EZEQQJNH.js} +2 -2
  6. package/dist/{chunk-Q5NZ5UFZ.js → chunk-F56AI52P.js} +2 -2
  7. package/dist/{chunk-RDAVEULA.js → chunk-KIQLUFJX.js} +2 -2
  8. package/dist/{chunk-3XHKEHGF.js → chunk-M3WM5Q7W.js} +2 -2
  9. package/dist/{chunk-4SMVVOU5.js → chunk-PJORQUQP.js} +2 -2
  10. package/dist/{chunk-DB473RZJ.js → chunk-PNPTKVJ4.js} +4 -4
  11. package/dist/{chunk-ULJCXOOM.js → chunk-RVXF3JZ7.js} +627 -5096
  12. package/dist/chunk-WEJL5OFI.js +8732 -0
  13. package/dist/{chunk-52A4W4YI.js → chunk-WWBSNLRC.js} +2 -2
  14. package/dist/index.d.ts +5 -0
  15. package/dist/index.js +14 -13
  16. package/dist/lib/mysql/cms-resource-actions.cjs +4733 -0
  17. package/dist/lib/mysql/cms-resource-actions.cjs.map +1 -0
  18. package/dist/lib/mysql/cms-resource-actions.d.cts +155 -0
  19. package/dist/lib/mysql/cms-resource-actions.d.ts +155 -0
  20. package/dist/lib/mysql/cms-resource-actions.js +8 -0
  21. package/dist/lib/mysql/cms-resource-actions.js.map +1 -0
  22. package/dist/lib/mysql/index.cjs +420 -187
  23. package/dist/lib/mysql/index.cjs.map +1 -1
  24. package/dist/lib/mysql/index.d.cts +5 -0
  25. package/dist/lib/mysql/index.d.ts +5 -0
  26. package/dist/lib/mysql/index.js +17 -13
  27. package/dist/lib/mysql/schemas/auth/accounts.js +2 -2
  28. package/dist/lib/mysql/schemas/auth/device-access-token.js +3 -3
  29. package/dist/lib/mysql/schemas/auth/device-verification.js +3 -3
  30. package/dist/lib/mysql/schemas/auth/personal-access-token.js +3 -3
  31. package/dist/lib/mysql/schemas/auth/profiles.js +2 -2
  32. package/dist/lib/mysql/schemas/auth/role-permissions.js +3 -3
  33. package/dist/lib/mysql/schemas/auth/roles.js +2 -2
  34. package/dist/lib/mysql/schemas/auth/sessions.js +3 -3
  35. package/dist/lib/mysql/schemas/auth/user-permissions.js +2 -2
  36. package/dist/lib/mysql/schemas/auth/user-prefs.js +2 -2
  37. package/dist/lib/mysql/schemas/auth/user-roles.js +2 -2
  38. package/dist/lib/mysql/schemas/auth/users.js +2 -2
  39. package/dist/lib/mysql/schemas/commerce/coupon.js +2 -2
  40. package/dist/lib/mysql/schemas/commerce/merchant-charge.js +2 -2
  41. package/dist/lib/mysql/schemas/commerce/merchant-subscription.js +2 -2
  42. package/dist/lib/mysql/schemas/commerce/price.js +2 -2
  43. package/dist/lib/mysql/schemas/commerce/product.js +2 -2
  44. package/dist/lib/mysql/schemas/commerce/purchase-user-transfer.js +3 -3
  45. package/dist/lib/mysql/schemas/commerce/purchase.js +2 -2
  46. package/dist/lib/mysql/schemas/commerce/subscription.js +2 -2
  47. package/dist/lib/mysql/schemas/commerce/upgradable-products.js +3 -3
  48. package/dist/lib/mysql/schemas/communication/comment.js +2 -2
  49. package/dist/lib/mysql/schemas/communication/communication-preferences.js +2 -2
  50. package/dist/lib/mysql/schemas/communication/question-response.js +3 -3
  51. package/dist/lib/mysql/schemas/content/content-contributions.js +2 -2
  52. package/dist/lib/mysql/schemas/content/content-resource-product.js +2 -2
  53. package/dist/lib/mysql/schemas/content/content-resource-resource.js +2 -2
  54. package/dist/lib/mysql/schemas/content/content-resource-tag.js +2 -2
  55. package/dist/lib/mysql/schemas/content/content-resource-version.js +2 -2
  56. package/dist/lib/mysql/schemas/content/content-resource.js +2 -2
  57. package/dist/lib/mysql/schemas/content/tag-tag.js +2 -2
  58. package/dist/lib/mysql/schemas/content/tag.js +2 -2
  59. package/dist/lib/mysql/schemas/entitlements/entitlement.js +3 -3
  60. package/dist/lib/mysql/schemas/org/organization-membership-roles.js +2 -2
  61. package/dist/lib/mysql/schemas/org/organization-memberships.js +2 -2
  62. package/dist/lib/mysql/schemas/org/organizations.js +2 -2
  63. package/dist/lib/utils.d.cts +5 -0
  64. package/dist/lib/utils.d.ts +5 -0
  65. package/package.json +3 -3
  66. package/src/lib/mysql/cms-resource-actions.ts +527 -0
  67. package/src/lib/mysql/index.ts +3 -0
@@ -0,0 +1,527 @@
1
+ import {
2
+ and,
3
+ asc,
4
+ desc,
5
+ eq,
6
+ inArray,
7
+ isNull,
8
+ notInArray,
9
+ sql,
10
+ } from 'drizzle-orm'
11
+ import { MySqlDatabase, MySqlTableFn } from 'drizzle-orm/mysql-core'
12
+ import { z } from 'zod'
13
+
14
+ import { getContentResourceProductSchema } from './schemas/content/content-resource-product.js'
15
+ import { getContentResourceResourceSchema } from './schemas/content/content-resource-resource.js'
16
+ import { getContentResourceSchema } from './schemas/content/content-resource.js'
17
+
18
+ /**
19
+ * Shared CMS-editor query implementations (ai-hero `resources-query.ts`
20
+ * lineage) behind every app's `src/lib/cms/resource-actions.ts` server-action
21
+ * module: the ONE recent-first picker query powering every add-surface, the
22
+ * video-library picker, the post → list membership seed, and the reverse
23
+ * parent lookup behind the "Part of" strip.
24
+ *
25
+ * This is a FACTORY, not a server-action module: `'use server'` exports must
26
+ * live in each app (they bind to the app bundle), so apps instantiate this
27
+ * with their db/auth and re-export thin `'use server'` wrappers with
28
+ * identical signatures.
29
+ */
30
+
31
+ type ContentResourceTable = ReturnType<typeof getContentResourceSchema>
32
+ type ContentResourceResourceTable = ReturnType<
33
+ typeof getContentResourceResourceSchema
34
+ >
35
+ type ContentResourceProductTable = ReturnType<
36
+ typeof getContentResourceProductSchema
37
+ >
38
+
39
+ /** Minimal session+ability surface the actions gate on. */
40
+ export interface CmsAuthContext {
41
+ session: { user?: unknown | null } | null
42
+ // `any` params on purpose — CASL abilities type `can` with app-specific
43
+ // Actions/Subjects unions, which would otherwise fail contravariance.
44
+ ability: { can: (action: any, subject: any) => boolean }
45
+ }
46
+
47
+ /** Row shape for the ResourcePicker (mirrors `PickerItem` in the ui kit). */
48
+ export interface CmsPickerItem {
49
+ id: string
50
+ type: string
51
+ title: string
52
+ slug?: string
53
+ state?: string
54
+ updatedAt?: string | Date
55
+ detail?: string
56
+ thumbnailUrl?: string
57
+ }
58
+
59
+ /** Row shape for the "Part of" strip (mirrors `ResourceParent` in the ui kit). */
60
+ export interface CmsResourceParent {
61
+ id: string
62
+ type: string
63
+ title: string
64
+ href: string
65
+ detail?: string
66
+ }
67
+
68
+ /** Row shape for the post editor's lists field (mirrors `ListMembership`). */
69
+ export interface CmsListMembership {
70
+ listId: string
71
+ title: string
72
+ slug?: string
73
+ href?: string
74
+ position: number
75
+ }
76
+
77
+ /** Lean video-library row for the body editor's "Video…" picker. */
78
+ export interface VideoPickerRow {
79
+ id: string
80
+ title: string
81
+ state: string
82
+ thumbnailUrl?: string
83
+ duration: number | null
84
+ createdAt: Date | null
85
+ }
86
+
87
+ /**
88
+ * Null-preserving numeric coercion for JSON_EXTRACT columns — `null` stays
89
+ * `null`, numbers/strings become numbers, garbage fails the row (and the
90
+ * page fails closed, matching the all-or-nothing guard below).
91
+ */
92
+ const jsonNumber = z.preprocess(
93
+ (value) => (value == null ? null : Number(value)),
94
+ z.number().nullable().optional(),
95
+ )
96
+
97
+ /** Row shape for the Media tab's image grid (mirrors `ImageLibraryRow` in the ui kit, plus `id`). */
98
+ const CmsImageResourceRowSchema = z.object({
99
+ id: z.string(),
100
+ url: z.string(),
101
+ alt: z.string().optional().nullable(),
102
+ // Human filename (the uploaded file's name) — the media grid prefers it
103
+ // over `alt` for the tile label; absent on older rows.
104
+ name: z.string().optional().nullable(),
105
+ // Row creation time — the media tab's unified grid sorts by it.
106
+ createdAt: z.coerce.date().optional().nullable(),
107
+ // Cloudinary metadata — optional/nullable so pre-metadata rows keep
108
+ // working (JSON_EXTRACT returns NULL when the key is absent). Numeric
109
+ // coercion preserves null (plain z.coerce.number() would turn it into 0);
110
+ // MySQL JSON numerics can arrive as strings through some drivers.
111
+ width: jsonNumber,
112
+ height: jsonNumber,
113
+ bytes: jsonNumber,
114
+ format: z.string().optional().nullable(),
115
+ })
116
+
117
+ export type CmsImageResourceRow = z.infer<typeof CmsImageResourceRowSchema>
118
+
119
+ const ListResourcesForPickerInputSchema = z.object({
120
+ types: z.array(z.string()).min(1),
121
+ search: z.string().trim().max(200).optional(),
122
+ excludeIds: z.array(z.string()).default([]),
123
+ limit: z.number().int().min(1).max(100).default(20),
124
+ })
125
+
126
+ export type ListResourcesForPickerInput = z.input<
127
+ typeof ListResourcesForPickerInputSchema
128
+ >
129
+
130
+ export interface CreateCmsResourceActionsOptions {
131
+ /**
132
+ * The app's drizzle client. Loosely typed on purpose — each app's client
133
+ * carries its own full-schema generic, but the relational keys used here
134
+ * (`contentResourceResource`, `contentResourceProduct`) are guaranteed by
135
+ * `getCourseBuilderSchema`.
136
+ */
137
+ db: InstanceType<typeof MySqlDatabase>
138
+ contentResource: ContentResourceTable
139
+ contentResourceResource: ContentResourceResourceTable
140
+ contentResourceProduct: ContentResourceProductTable
141
+ /** The app's session+ability getter (e.g. `getServerAuthSession`). */
142
+ getAuthContext: () => Promise<CmsAuthContext>
143
+ /**
144
+ * App route registry for parent edit links — apps with the shared
145
+ * resource-paths registry pass `(type, slug) => getResourcePath(type,
146
+ * slug, 'edit')`; apps without one pass their own switch.
147
+ */
148
+ getEditHref: (type: string, slug: string) => string
149
+ }
150
+
151
+ export function createCmsResourceActions(
152
+ options: CreateCmsResourceActionsOptions,
153
+ ) {
154
+ const {
155
+ db,
156
+ contentResource,
157
+ contentResourceResource,
158
+ contentResourceProduct,
159
+ getAuthContext,
160
+ getEditHref,
161
+ } = options
162
+ // The relational API is untyped through the loose `db` param — the keys
163
+ // exist on every `getCourseBuilderSchema` client.
164
+ const dbQuery = (db as any).query
165
+
166
+ async function assertCanUpdateContent() {
167
+ const { session, ability } = await getAuthContext()
168
+ // 'update' (not 'create'): picking an existing resource to attach is an
169
+ // update to the parent — consistent with every other binding gate here.
170
+ if (!session?.user || !ability.can('update', 'Content')) {
171
+ throw new Error('Unauthorized')
172
+ }
173
+ }
174
+
175
+ /**
176
+ * Recent-first, DB-backed resource query for the editor's ResourcePicker —
177
+ * ONE query powering every add-surface. `ORDER BY updatedAt DESC` so
178
+ * recently-touched resources surface first; optional title substring
179
+ * search as a secondary path (TypeSense remains the primary site search
180
+ * surface and is untouched). Lean select — no bodies shipped to the
181
+ * client.
182
+ */
183
+ async function listResourcesForPicker(
184
+ input: ListResourcesForPickerInput,
185
+ ): Promise<CmsPickerItem[]> {
186
+ await assertCanUpdateContent()
187
+
188
+ const { types, search, excludeIds, limit } =
189
+ ListResourcesForPickerInputSchema.parse(input)
190
+
191
+ const conditions = [
192
+ inArray(contentResource.type, types),
193
+ isNull(contentResource.deletedAt),
194
+ ]
195
+ if (excludeIds.length > 0) {
196
+ conditions.push(notInArray(contentResource.id, excludeIds))
197
+ }
198
+ if (search) {
199
+ // MySQL LIKE is case-insensitive under the default collation;
200
+ // escape LIKE metacharacters so user input matches literally.
201
+ const escaped = search.replace(/[\\%_]/g, '\\$&')
202
+ conditions.push(
203
+ sql`JSON_UNQUOTE(JSON_EXTRACT(${contentResource.fields}, '$.title')) LIKE ${`%${escaped}%`}`,
204
+ )
205
+ }
206
+
207
+ const rows = await db
208
+ .select({
209
+ id: contentResource.id,
210
+ type: contentResource.type,
211
+ title: sql<
212
+ string | null
213
+ >`JSON_UNQUOTE(JSON_EXTRACT(${contentResource.fields}, '$.title'))`,
214
+ // Generated (indexed) column — cheaper than a JSON_EXTRACT here.
215
+ slug: contentResource.slug,
216
+ state: sql<
217
+ string | null
218
+ >`JSON_UNQUOTE(JSON_EXTRACT(${contentResource.fields}, '$.state'))`,
219
+ postType: sql<
220
+ string | null
221
+ >`JSON_UNQUOTE(JSON_EXTRACT(${contentResource.fields}, '$.postType'))`,
222
+ updatedAt: contentResource.updatedAt,
223
+ })
224
+ .from(contentResource)
225
+ .where(and(...conditions))
226
+ .orderBy(desc(contentResource.updatedAt))
227
+ .limit(limit)
228
+
229
+ return rows.map((row) => ({
230
+ id: row.id,
231
+ type: row.type,
232
+ title: row.title ?? row.slug ?? row.id,
233
+ slug: row.slug ?? '',
234
+ state: row.state ?? 'draft',
235
+ detail: row.postType ?? undefined,
236
+ updatedAt: row.updatedAt ?? undefined,
237
+ }))
238
+ }
239
+
240
+ /**
241
+ * Recent-first video library for the body editor's "Video…" insert.
242
+ *
243
+ * A sibling of `listResourcesForPicker` because videoResources don't fit
244
+ * its shape: they have no `fields.title`/slug (the id IS the upload
245
+ * filename, so search matches the id), their `fields.state` is a video
246
+ * lifecycle ('processing'/'ready'/…) rather than editorial, and picker
247
+ * rows want the Mux poster + duration. Same auth gate; newest-first by
248
+ * `createdAt` (uploads don't get edited, they get created).
249
+ */
250
+ async function listVideoResourcesForPicker(input: {
251
+ search?: string
252
+ excludeIds?: string[]
253
+ limit?: number
254
+ offset?: number
255
+ }): Promise<VideoPickerRow[]> {
256
+ await assertCanUpdateContent()
257
+
258
+ const excludeIds = input.excludeIds ?? []
259
+ // The Media tab pages by offset ('Load more' appends the next page) —
260
+ // `limit` is a per-page clamp, not a total ceiling; picker popovers
261
+ // still default to 20 with no offset.
262
+ const limit = Math.min(Math.max(input.limit ?? 20, 1), 200)
263
+ const offset = Math.max(0, Math.floor(input.offset ?? 0))
264
+
265
+ // JSON-free conditions ONLY: even an id-only sorted query hits Vitess's
266
+ // "Out of sort memory" (errno 1038) when the WHERE computes JSON_EXTRACT
267
+ // over a large library — the lifecycle-state filter moves to app code
268
+ // below (with over-fetch so the page still fills).
269
+ const conditions = [
270
+ eq(contentResource.type, 'videoResource'),
271
+ isNull(contentResource.deletedAt),
272
+ ]
273
+ if (excludeIds.length > 0) {
274
+ conditions.push(notInArray(contentResource.id, excludeIds))
275
+ }
276
+ if (input.search) {
277
+ // Escape LIKE metacharacters so user input matches literally.
278
+ const escaped = input.search.replace(/[\\%_]/g, '\\$&')
279
+ conditions.push(sql`${contentResource.id} LIKE ${`%${escaped}%`}`)
280
+ }
281
+
282
+ // Two-step on purpose: ORDER BY over rows that also compute
283
+ // JSON_EXTRACTs blows MySQL's sort buffer on large libraries ("Out of
284
+ // sort memory", errno 1038 on PlanetScale) — so sort/limit a minimal
285
+ // id+createdAt page first, then pull the JSON fields for just that page.
286
+ const pageRows = await db
287
+ .select({ id: contentResource.id, createdAt: contentResource.createdAt })
288
+ .from(contentResource)
289
+ .where(and(...conditions))
290
+ .orderBy(desc(contentResource.createdAt))
291
+ // The lifecycle-state filter is app-side, so `offset` must apply
292
+ // AFTER filtering — fetch the requested page's whole filtered prefix
293
+ // (the detail lookup below stays a sort-free IN(), safe for Vitess)
294
+ // and slice. Over-fetch: the state filter below may drop rows.
295
+ .limit(offset + limit + 50)
296
+ if (pageRows.length === 0) return []
297
+
298
+ const detailRows = await db
299
+ .select({
300
+ id: contentResource.id,
301
+ state: sql<
302
+ string | null
303
+ >`JSON_UNQUOTE(JSON_EXTRACT(${contentResource.fields}, '$.state'))`,
304
+ muxPlaybackId: sql<
305
+ string | null
306
+ >`JSON_UNQUOTE(JSON_EXTRACT(${contentResource.fields}, '$.muxPlaybackId'))`,
307
+ duration: sql<
308
+ number | string | null
309
+ >`JSON_EXTRACT(${contentResource.fields}, '$.duration')`,
310
+ createdAt: contentResource.createdAt,
311
+ })
312
+ .from(contentResource)
313
+ .where(
314
+ inArray(
315
+ contentResource.id,
316
+ pageRows.map((row) => row.id),
317
+ ),
318
+ )
319
+ // Re-impose the page order — the IN() fetch returns rows unordered.
320
+ const byId = new Map(detailRows.map((row) => [row.id, row]))
321
+ const rows = pageRows
322
+ .flatMap((page) => byId.get(page.id) ?? [])
323
+ // Hide unusable videos; keep processing ones (attach works
324
+ // mid-process).
325
+ .filter((row) => row.state !== 'deleted' && row.state !== 'errored')
326
+ .slice(offset, offset + limit)
327
+
328
+ return rows.map((row) => ({
329
+ id: row.id,
330
+ // videoResources have no title/slug — the filename-derived id is the
331
+ // human-readable handle.
332
+ title: row.id,
333
+ state: row.state ?? 'processing',
334
+ thumbnailUrl: row.muxPlaybackId
335
+ ? `https://image.mux.com/${row.muxPlaybackId}/thumbnail.png?width=96&height=54&fit_mode=smartcrop`
336
+ : undefined,
337
+ duration: row.duration != null ? Number(row.duration) : null,
338
+ createdAt: row.createdAt,
339
+ }))
340
+ }
341
+
342
+ /**
343
+ * Newest-first image page for the media library. `limit` is a PER-PAGE
344
+ * clamp (200 — same ceiling as the video picker query, not a total cap);
345
+ * `offset` (default 0) pages past it — the Media tab's "Load more"
346
+ * fetches the next offset page and appends.
347
+ *
348
+ * Deliberately UNGATED (unlike the sibling pickers): the app-side query
349
+ * this replaces had no auth check — it's only reachable through the
350
+ * editor's server bindings.
351
+ */
352
+ async function listImageResources({
353
+ limit,
354
+ offset = 0,
355
+ }: {
356
+ limit: number
357
+ offset?: number
358
+ }): Promise<CmsImageResourceRow[]> {
359
+ const capped = Math.min(Math.max(1, Math.floor(limit)), 200)
360
+ const skip = Math.max(0, Math.floor(offset))
361
+
362
+ const rows = await db
363
+ .select({
364
+ id: contentResource.id,
365
+ url: sql<
366
+ string | null
367
+ >`JSON_UNQUOTE(JSON_EXTRACT(${contentResource.fields}, '$.url'))`,
368
+ alt: sql<
369
+ string | null
370
+ >`JSON_UNQUOTE(JSON_EXTRACT(${contentResource.fields}, '$.alt'))`,
371
+ name: sql<
372
+ string | null
373
+ >`JSON_UNQUOTE(JSON_EXTRACT(${contentResource.fields}, '$.name'))`,
374
+ createdAt: contentResource.createdAt,
375
+ // Cloudinary metadata — NULL on pre-metadata rows (backfillable).
376
+ width: sql<
377
+ number | null
378
+ >`JSON_EXTRACT(${contentResource.fields}, '$.width')`,
379
+ height: sql<
380
+ number | null
381
+ >`JSON_EXTRACT(${contentResource.fields}, '$.height')`,
382
+ bytes: sql<
383
+ number | null
384
+ >`JSON_EXTRACT(${contentResource.fields}, '$.bytes')`,
385
+ format: sql<
386
+ string | null
387
+ >`JSON_UNQUOTE(JSON_EXTRACT(${contentResource.fields}, '$.format'))`,
388
+ })
389
+ .from(contentResource)
390
+ .where(eq(contentResource.type, 'imageResource'))
391
+ .orderBy(desc(contentResource.createdAt))
392
+ .limit(capped)
393
+ .offset(skip)
394
+
395
+ // Same all-or-nothing guard the app-side query had: a malformed row
396
+ // (e.g. missing `fields.url`) fails the whole page closed to [].
397
+ const parsed = z.array(CmsImageResourceRowSchema).safeParse(rows)
398
+ return parsed.success ? parsed.data : []
399
+ }
400
+
401
+ /**
402
+ * Lists a post belongs to — the seed for the post editor's
403
+ * `{ kind: 'lists' }` membership field (ai-hero `getPostLists`
404
+ * equivalent; the legacy `getListForPost` returned only the oldest
405
+ * PUBLISHED list, which would hide draft-list memberships from editors).
406
+ * Reverse join-row lookup, list parents only.
407
+ */
408
+ async function getPostListMemberships(
409
+ postId: string,
410
+ ): Promise<CmsListMembership[]> {
411
+ await assertCanUpdateContent()
412
+
413
+ const links = await dbQuery.contentResourceResource.findMany({
414
+ where: and(
415
+ eq(contentResourceResource.resourceId, postId),
416
+ isNull(contentResourceResource.deletedAt),
417
+ ),
418
+ with: { resourceOf: true },
419
+ })
420
+
421
+ return links
422
+ .filter(
423
+ (link: any) =>
424
+ link.resourceOf?.type === 'list' && !link.resourceOf.deletedAt,
425
+ )
426
+ .map((link: any) => {
427
+ const fields = (link.resourceOf.fields ?? {}) as Record<string, any>
428
+ return {
429
+ listId: link.resourceOf.id,
430
+ title: fields.title ?? fields.slug ?? link.resourceOf.id,
431
+ slug: fields.slug ?? undefined,
432
+ href: fields.slug ? getEditHref('list', fields.slug) : undefined,
433
+ position: link.position,
434
+ }
435
+ })
436
+ }
437
+
438
+ /**
439
+ * One-level reverse lookup: every resource this resource is directly part
440
+ * of. Covers both membership mechanisms:
441
+ * - `ContentResourceResource` (child → parent via `resourceOf`): post ∈
442
+ * list, lesson ∈ workshop/section, workshop ∈ cohort, event ∈
443
+ * event-series
444
+ * - `ContentResourceProduct`: cohort/workshop/event ∈ product
445
+ *
446
+ * Powers the "Part of" strip; `href` is the parent's EDIT path.
447
+ */
448
+ async function getResourceParents(
449
+ resourceId: string,
450
+ ): Promise<CmsResourceParent[]> {
451
+ await assertCanUpdateContent()
452
+
453
+ const [containerLinks, productLinks] = await Promise.all([
454
+ dbQuery.contentResourceResource.findMany({
455
+ where: eq(contentResourceResource.resourceId, resourceId),
456
+ with: {
457
+ resourceOf: {
458
+ with: {
459
+ // Sibling JOIN rows only (no nested resource objects) —
460
+ // cheap, and enough to compute this resource's ordinal.
461
+ resources: {
462
+ orderBy: asc(contentResourceResource.position),
463
+ },
464
+ },
465
+ },
466
+ },
467
+ }),
468
+ dbQuery.contentResourceProduct.findMany({
469
+ where: and(
470
+ eq(contentResourceProduct.resourceId, resourceId),
471
+ isNull(contentResourceProduct.deletedAt),
472
+ ),
473
+ with: {
474
+ product: true,
475
+ },
476
+ }),
477
+ ])
478
+
479
+ const parents: CmsResourceParent[] = []
480
+
481
+ for (const link of containerLinks) {
482
+ const parent = link.resourceOf
483
+ if (!parent || parent.deletedAt) continue
484
+ // videoResource attachments etc. aren't "parents" in the strip's
485
+ // sense — only container types with real edit surfaces qualify.
486
+ if (parent.type === 'videoResource') continue
487
+ const slug: string = parent.fields?.slug ?? ''
488
+ const siblings = parent.resources ?? []
489
+ const index = siblings.findIndex(
490
+ (sibling: any) => sibling.resourceId === resourceId,
491
+ )
492
+ parents.push({
493
+ id: parent.id,
494
+ type: parent.type,
495
+ title: parent.fields?.title ?? (slug || parent.id),
496
+ href: getEditHref(parent.type, slug || parent.id),
497
+ ...(index !== -1 && {
498
+ detail: `#${index + 1} of ${siblings.length}`,
499
+ }),
500
+ })
501
+ }
502
+
503
+ for (const link of productLinks) {
504
+ const product = link.product
505
+ if (!product) continue
506
+ const slug: string = product.fields?.slug ?? ''
507
+ parents.push({
508
+ id: product.id,
509
+ type: 'product',
510
+ title: product.name,
511
+ // Products aren't in the resource-paths registries; every app's
512
+ // product editor lives at /products/[slug]/edit.
513
+ href: `/products/${slug || product.id}/edit`,
514
+ })
515
+ }
516
+
517
+ return parents
518
+ }
519
+
520
+ return {
521
+ listResourcesForPicker,
522
+ listVideoResourcesForPicker,
523
+ listImageResources,
524
+ getPostListMemberships,
525
+ getResourceParents,
526
+ }
527
+ }
@@ -250,6 +250,8 @@ import {
250
250
  getOrganizationsSchema,
251
251
  } from './schemas/org/organizations.js'
252
252
 
253
+ export * from './cms-resource-actions.js'
254
+
253
255
  export const guid = customAlphabet('1234567890abcdefghijklmnopqrstuvwxyz', 5)
254
256
 
255
257
  export const normalizeExpirationDate = (date: Date | undefined) => {
@@ -3072,6 +3074,7 @@ export function mySqlDrizzleAdapter(
3072
3074
  CAST(createdAt AS DATETIME) as createdAt,
3073
3075
  JSON_EXTRACT (${contentResource.fields}, "$.state") AS state,
3074
3076
  JSON_EXTRACT (${contentResource.fields}, "$.duration") AS duration,
3077
+ JSON_UNQUOTE(JSON_EXTRACT (${contentResource.fields}, "$.resolution")) AS resolution,
3075
3078
  JSON_EXTRACT (${contentResource.fields}, "$.muxPlaybackId") AS muxPlaybackId,
3076
3079
  JSON_EXTRACT (${contentResource.fields}, "$.muxAssetId") AS muxAssetId,
3077
3080
  JSON_EXTRACT (${contentResource.fields}, "$.transcript") AS transcript,