@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.
- package/dist/{chunk-ULJCXOOM.js → chunk-25RKHMRM.js} +521 -4993
- package/dist/{chunk-WPDVDHLH.js → chunk-2AW5KYAK.js} +2 -2
- package/dist/{chunk-QDWLBCD3.js → chunk-3AF6MZQ7.js} +2 -2
- package/dist/{chunk-RDAVEULA.js → chunk-3EF5ZXLG.js} +2 -2
- package/dist/{chunk-4SMVVOU5.js → chunk-4SWTV7Z3.js} +2 -2
- package/dist/{chunk-6VDGZ4KA.js → chunk-7FBSP3VD.js} +2 -2
- package/dist/chunk-AP5HBIV4.js +112 -0
- package/dist/chunk-CFSRMUNC.js +78 -0
- package/dist/{chunk-Q5NZ5UFZ.js → chunk-DUFH5L2F.js} +2 -2
- package/dist/{chunk-DHII2GRW.js → chunk-EZEQQJNH.js} +2 -2
- package/dist/chunk-F56AI52P.js +49 -0
- package/dist/chunk-FJGNLVE4.js +101 -0
- package/dist/chunk-HAQZSRJL.js +4696 -0
- package/dist/chunk-I2U5PTWN.js +79 -0
- package/dist/chunk-IBQGXFEX.js +8729 -0
- package/dist/chunk-KIQLUFJX.js +101 -0
- package/dist/{chunk-3XHKEHGF.js → chunk-M3WM5Q7W.js} +2 -2
- package/dist/{chunk-52A4W4YI.js → chunk-MYG6PCE3.js} +2 -2
- package/dist/chunk-PJORQUQP.js +58 -0
- package/dist/{chunk-DB473RZJ.js → chunk-PNPTKVJ4.js} +4 -4
- package/dist/chunk-W6RPAAKG.js +2144 -0
- package/dist/chunk-WKJTU24D.js +88 -0
- package/dist/chunk-WWBSNLRC.js +63 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +18 -17
- package/dist/lib/mysql/cms-resource-actions.cjs +4715 -0
- package/dist/lib/mysql/cms-resource-actions.cjs.map +1 -0
- package/dist/lib/mysql/cms-resource-actions.d.cts +140 -0
- package/dist/lib/mysql/cms-resource-actions.d.ts +140 -0
- package/dist/lib/mysql/cms-resource-actions.js +8 -0
- package/dist/lib/mysql/cms-resource-actions.js.map +1 -0
- package/dist/lib/mysql/index.cjs +398 -186
- package/dist/lib/mysql/index.cjs.map +1 -1
- package/dist/lib/mysql/index.d.cts +5 -0
- package/dist/lib/mysql/index.d.ts +5 -0
- package/dist/lib/mysql/index.js +21 -17
- package/dist/lib/mysql/schemas/auth/accounts.js +5 -5
- package/dist/lib/mysql/schemas/auth/device-access-token.js +6 -6
- package/dist/lib/mysql/schemas/auth/device-verification.js +6 -6
- package/dist/lib/mysql/schemas/auth/personal-access-token.js +6 -6
- package/dist/lib/mysql/schemas/auth/profiles.js +5 -5
- package/dist/lib/mysql/schemas/auth/role-permissions.js +6 -6
- package/dist/lib/mysql/schemas/auth/roles.js +5 -5
- package/dist/lib/mysql/schemas/auth/sessions.js +6 -6
- package/dist/lib/mysql/schemas/auth/user-permissions.js +5 -5
- package/dist/lib/mysql/schemas/auth/user-prefs.js +5 -5
- package/dist/lib/mysql/schemas/auth/user-roles.js +5 -5
- package/dist/lib/mysql/schemas/auth/users.js +5 -5
- package/dist/lib/mysql/schemas/commerce/coupon.js +5 -5
- package/dist/lib/mysql/schemas/commerce/merchant-charge.js +5 -5
- package/dist/lib/mysql/schemas/commerce/merchant-subscription.js +5 -5
- package/dist/lib/mysql/schemas/commerce/price.js +5 -5
- package/dist/lib/mysql/schemas/commerce/product.js +5 -5
- package/dist/lib/mysql/schemas/commerce/purchase-user-transfer.js +6 -6
- package/dist/lib/mysql/schemas/commerce/purchase.js +5 -5
- package/dist/lib/mysql/schemas/commerce/subscription.js +5 -5
- package/dist/lib/mysql/schemas/commerce/upgradable-products.js +6 -6
- package/dist/lib/mysql/schemas/communication/comment.js +5 -5
- package/dist/lib/mysql/schemas/communication/communication-preferences.js +5 -5
- package/dist/lib/mysql/schemas/communication/question-response.js +6 -6
- package/dist/lib/mysql/schemas/content/content-contributions.js +5 -5
- package/dist/lib/mysql/schemas/content/content-resource-product.js +5 -5
- package/dist/lib/mysql/schemas/content/content-resource-resource.js +5 -5
- package/dist/lib/mysql/schemas/content/content-resource-tag.js +5 -5
- package/dist/lib/mysql/schemas/content/content-resource-version.js +5 -5
- package/dist/lib/mysql/schemas/content/content-resource.js +5 -5
- package/dist/lib/mysql/schemas/content/tag-tag.js +5 -5
- package/dist/lib/mysql/schemas/content/tag.js +5 -5
- package/dist/lib/mysql/schemas/entitlements/entitlement.js +6 -6
- package/dist/lib/mysql/schemas/org/organization-membership-roles.js +5 -5
- package/dist/lib/mysql/schemas/org/organization-memberships.js +5 -5
- package/dist/lib/mysql/schemas/org/organizations.js +5 -5
- package/dist/lib/utils.d.cts +5 -0
- package/dist/lib/utils.d.ts +5 -0
- package/package.json +2 -2
- package/src/lib/mysql/cms-resource-actions.ts +490 -0
- 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
|
+
}
|
package/src/lib/mysql/index.ts
CHANGED
|
@@ -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) => {
|