@coursebuilder/adapter-drizzle 2.0.0 → 2.1.0

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