@btst/stack 1.8.0 → 1.9.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.
- package/dist/packages/better-stack/src/plugins/cms/api/plugin.cjs +445 -16
- package/dist/packages/better-stack/src/plugins/cms/api/plugin.mjs +445 -16
- package/dist/packages/better-stack/src/plugins/cms/client/components/forms/content-form.cjs +28 -11
- package/dist/packages/better-stack/src/plugins/cms/client/components/forms/content-form.mjs +26 -9
- package/dist/packages/better-stack/src/plugins/cms/client/components/forms/relation-field.cjs +224 -0
- package/dist/packages/better-stack/src/plugins/cms/client/components/forms/relation-field.mjs +222 -0
- package/dist/packages/better-stack/src/plugins/cms/client/components/inverse-relations-panel.cjs +243 -0
- package/dist/packages/better-stack/src/plugins/cms/client/components/inverse-relations-panel.mjs +241 -0
- package/dist/packages/better-stack/src/plugins/cms/client/components/pages/content-editor-page.internal.cjs +56 -2
- package/dist/packages/better-stack/src/plugins/cms/client/components/pages/content-editor-page.internal.mjs +56 -2
- package/dist/packages/better-stack/src/plugins/cms/client/hooks/cms-hooks.cjs +190 -0
- package/dist/packages/better-stack/src/plugins/cms/client/hooks/cms-hooks.mjs +187 -1
- package/dist/packages/better-stack/src/plugins/cms/db.cjs +38 -0
- package/dist/packages/better-stack/src/plugins/cms/db.mjs +38 -0
- package/dist/packages/better-stack/src/plugins/form-builder/client/components/forms/form-renderer.cjs +2 -2
- package/dist/packages/better-stack/src/plugins/form-builder/client/components/forms/form-renderer.mjs +1 -1
- package/dist/packages/ui/src/components/auto-form/fields/array.cjs +2 -2
- package/dist/packages/ui/src/components/auto-form/fields/array.mjs +1 -1
- package/dist/packages/ui/src/components/auto-form/fields/date.cjs +2 -2
- package/dist/packages/ui/src/components/auto-form/fields/date.mjs +1 -1
- package/dist/packages/ui/src/components/auto-form/fields/enum.cjs +2 -2
- package/dist/packages/ui/src/components/auto-form/fields/enum.mjs +1 -1
- package/dist/packages/ui/src/components/auto-form/fields/object.cjs +88 -8
- package/dist/packages/ui/src/components/auto-form/fields/object.mjs +82 -2
- package/dist/packages/ui/src/components/auto-form/fields/radio-group.cjs +2 -2
- package/dist/packages/ui/src/components/auto-form/fields/radio-group.mjs +1 -1
- package/dist/packages/ui/src/components/auto-form/index.cjs +5 -5
- package/dist/packages/ui/src/components/auto-form/index.mjs +1 -1
- package/dist/packages/ui/src/components/button.cjs +4 -2
- package/dist/packages/ui/src/components/button.mjs +4 -2
- package/dist/packages/ui/src/components/dialog.cjs +7 -1
- package/dist/packages/ui/src/components/dialog.mjs +7 -2
- package/dist/packages/ui/src/components/form-builder/edit-field-dialog.cjs +2 -2
- package/dist/packages/ui/src/components/form-builder/edit-field-dialog.mjs +1 -1
- package/dist/packages/ui/src/components/form-builder/form-preview.cjs +5 -5
- package/dist/packages/ui/src/components/form-builder/form-preview.mjs +1 -1
- package/dist/packages/ui/src/components/select.cjs +9 -2
- package/dist/packages/ui/src/components/select.mjs +9 -2
- package/dist/plugins/blog/api/index.d.cts +1 -1
- package/dist/plugins/blog/api/index.d.mts +1 -1
- package/dist/plugins/blog/api/index.d.ts +1 -1
- package/dist/plugins/blog/client/hooks/index.d.cts +2 -2
- package/dist/plugins/blog/client/hooks/index.d.mts +2 -2
- package/dist/plugins/blog/client/hooks/index.d.ts +2 -2
- package/dist/plugins/blog/client/index.d.cts +1 -1
- package/dist/plugins/blog/client/index.d.mts +1 -1
- package/dist/plugins/blog/client/index.d.ts +1 -1
- package/dist/plugins/blog/query-keys.d.cts +2 -2
- package/dist/plugins/blog/query-keys.d.mts +2 -2
- package/dist/plugins/blog/query-keys.d.ts +2 -2
- package/dist/plugins/cms/api/index.d.cts +66 -2
- package/dist/plugins/cms/api/index.d.mts +66 -2
- package/dist/plugins/cms/api/index.d.ts +66 -2
- package/dist/plugins/cms/client/hooks/index.cjs +4 -0
- package/dist/plugins/cms/client/hooks/index.d.cts +82 -3
- package/dist/plugins/cms/client/hooks/index.d.mts +82 -3
- package/dist/plugins/cms/client/hooks/index.d.ts +82 -3
- package/dist/plugins/cms/client/hooks/index.mjs +1 -1
- package/dist/plugins/cms/client/index.d.cts +2 -2
- package/dist/plugins/cms/client/index.d.mts +2 -2
- package/dist/plugins/cms/client/index.d.ts +2 -2
- package/dist/plugins/cms/query-keys.d.cts +1 -1
- package/dist/plugins/cms/query-keys.d.mts +1 -1
- package/dist/plugins/cms/query-keys.d.ts +1 -1
- package/dist/plugins/form-builder/client/components/index.d.cts +1 -1
- package/dist/plugins/form-builder/client/components/index.d.mts +1 -1
- package/dist/plugins/form-builder/client/components/index.d.ts +1 -1
- package/dist/plugins/form-builder/client/index.d.cts +2 -2
- package/dist/plugins/form-builder/client/index.d.mts +2 -2
- package/dist/plugins/form-builder/client/index.d.ts +2 -2
- package/dist/shared/{stack.AX5nZ6A3.d.ts → stack.Co034Fpm.d.cts} +0 -21
- package/dist/shared/{stack.AX5nZ6A3.d.cts → stack.Co034Fpm.d.mts} +0 -21
- package/dist/shared/{stack.AX5nZ6A3.d.mts → stack.Co034Fpm.d.ts} +0 -21
- package/dist/shared/{stack.BIh2AXaW.d.cts → stack.DGjhPqmF.d.cts} +0 -9
- package/dist/shared/{stack.BIh2AXaW.d.mts → stack.DGjhPqmF.d.mts} +0 -9
- package/dist/shared/{stack.BIh2AXaW.d.ts → stack.DGjhPqmF.d.ts} +0 -9
- package/dist/shared/{stack.L-UFwz2G.d.mts → stack.oGOteE6g.d.cts} +27 -5
- package/dist/shared/{stack.L-UFwz2G.d.cts → stack.oGOteE6g.d.mts} +27 -5
- package/dist/shared/{stack.L-UFwz2G.d.ts → stack.oGOteE6g.d.ts} +27 -5
- package/package.json +1 -1
- package/src/plugins/cms/api/plugin.ts +667 -21
- package/src/plugins/cms/client/components/forms/content-form.tsx +62 -20
- package/src/plugins/cms/client/components/forms/relation-field.tsx +299 -0
- package/src/plugins/cms/client/components/inverse-relations-panel.tsx +329 -0
- package/src/plugins/cms/client/components/pages/content-editor-page.internal.tsx +127 -1
- package/src/plugins/cms/client/hooks/cms-hooks.tsx +344 -0
- package/src/plugins/cms/db.ts +38 -0
- package/src/plugins/cms/types.ts +99 -10
- package/src/plugins/form-builder/client/components/forms/form-renderer.tsx +1 -1
- package/dist/packages/ui/src/components/auto-form/{utils.cjs → helpers.cjs} +0 -0
- package/dist/packages/ui/src/components/auto-form/{utils.mjs → helpers.mjs} +0 -0
- package/dist/shared/{stack.DLhzx1-D.d.mts → stack.CcI4sYJP.d.cts} +1 -1
- package/dist/shared/{stack.DLhzx1-D.d.cts → stack.CcI4sYJP.d.mts} +1 -1
- package/dist/shared/{stack.DLhzx1-D.d.ts → stack.CcI4sYJP.d.ts} +1 -1
|
@@ -11,11 +11,15 @@ import type {
|
|
|
11
11
|
ContentType,
|
|
12
12
|
ContentItem,
|
|
13
13
|
ContentItemWithType,
|
|
14
|
+
ContentRelation,
|
|
14
15
|
CMSBackendConfig,
|
|
15
16
|
CMSHookContext,
|
|
16
17
|
SerializedContentType,
|
|
17
18
|
SerializedContentItem,
|
|
18
19
|
SerializedContentItemWithType,
|
|
20
|
+
RelationConfig,
|
|
21
|
+
RelationValue,
|
|
22
|
+
InverseRelation,
|
|
19
23
|
} from "../types";
|
|
20
24
|
import { listContentQuerySchema } from "../schemas";
|
|
21
25
|
import { slugify } from "../utils";
|
|
@@ -196,6 +200,311 @@ function getContentTypeZodSchema(contentType: ContentType): z.ZodTypeAny {
|
|
|
196
200
|
return formSchemaToZod(jsonSchema);
|
|
197
201
|
}
|
|
198
202
|
|
|
203
|
+
// ========== Relation Helpers ==========
|
|
204
|
+
|
|
205
|
+
interface JsonSchemaProperty {
|
|
206
|
+
fieldType?: string;
|
|
207
|
+
relation?: RelationConfig;
|
|
208
|
+
type?: string;
|
|
209
|
+
items?: JsonSchemaProperty;
|
|
210
|
+
[key: string]: unknown;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
interface JsonSchemaWithProperties {
|
|
214
|
+
properties?: Record<string, JsonSchemaProperty>;
|
|
215
|
+
[key: string]: unknown;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Extract relation field configurations from a content type's JSON Schema
|
|
220
|
+
*/
|
|
221
|
+
function extractRelationFields(
|
|
222
|
+
contentType: ContentType,
|
|
223
|
+
): Record<string, RelationConfig> {
|
|
224
|
+
const jsonSchema = JSON.parse(
|
|
225
|
+
contentType.jsonSchema,
|
|
226
|
+
) as JsonSchemaWithProperties;
|
|
227
|
+
const properties = jsonSchema.properties || {};
|
|
228
|
+
const relationFields: Record<string, RelationConfig> = {};
|
|
229
|
+
|
|
230
|
+
for (const [fieldName, fieldSchema] of Object.entries(properties)) {
|
|
231
|
+
if (fieldSchema.fieldType === "relation" && fieldSchema.relation) {
|
|
232
|
+
relationFields[fieldName] = fieldSchema.relation;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return relationFields;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Check if a value is a "new" relation item (to be created)
|
|
241
|
+
*/
|
|
242
|
+
function isNewRelationValue(
|
|
243
|
+
value: unknown,
|
|
244
|
+
): value is { _new: true; data: Record<string, unknown> } {
|
|
245
|
+
return (
|
|
246
|
+
typeof value === "object" &&
|
|
247
|
+
value !== null &&
|
|
248
|
+
"_new" in value &&
|
|
249
|
+
(value as { _new: unknown })._new === true &&
|
|
250
|
+
"data" in value
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Check if a value is an existing relation reference
|
|
256
|
+
*/
|
|
257
|
+
function isExistingRelationValue(value: unknown): value is { id: string } {
|
|
258
|
+
return (
|
|
259
|
+
typeof value === "object" &&
|
|
260
|
+
value !== null &&
|
|
261
|
+
"id" in value &&
|
|
262
|
+
typeof (value as { id: unknown }).id === "string"
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Process relation fields in content data:
|
|
268
|
+
* 1. Create new items from _new values
|
|
269
|
+
* 2. Extract IDs for junction table
|
|
270
|
+
* 3. Return cleaned data with only IDs stored
|
|
271
|
+
*
|
|
272
|
+
* Only processes relation fields that are explicitly present in the data.
|
|
273
|
+
* Fields not present in data are skipped entirely - this preserves existing
|
|
274
|
+
* relations during partial updates.
|
|
275
|
+
*
|
|
276
|
+
* @returns Object with processedData (for storing) and relationIds (for junction table sync)
|
|
277
|
+
*/
|
|
278
|
+
async function processRelationsInData(
|
|
279
|
+
adapter: Adapter,
|
|
280
|
+
contentType: ContentType,
|
|
281
|
+
data: Record<string, unknown>,
|
|
282
|
+
getContentTypeFn: (slug: string) => Promise<ContentType | null>,
|
|
283
|
+
): Promise<{
|
|
284
|
+
processedData: Record<string, unknown>;
|
|
285
|
+
relationIds: Record<string, string[]>;
|
|
286
|
+
}> {
|
|
287
|
+
const relationFields = extractRelationFields(contentType);
|
|
288
|
+
const processedData = { ...data };
|
|
289
|
+
const relationIds: Record<string, string[]> = {};
|
|
290
|
+
|
|
291
|
+
for (const [fieldName, relationConfig] of Object.entries(relationFields)) {
|
|
292
|
+
// Skip fields not present in the data - this preserves existing relations
|
|
293
|
+
// during partial updates. Only process fields explicitly included.
|
|
294
|
+
if (!(fieldName in data)) {
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const fieldValue = data[fieldName];
|
|
299
|
+
// Field is present but null/undefined/empty - clear relations for this field
|
|
300
|
+
if (!fieldValue) {
|
|
301
|
+
relationIds[fieldName] = [];
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Get target content type
|
|
306
|
+
const targetContentType = await getContentTypeFn(relationConfig.targetType);
|
|
307
|
+
if (!targetContentType) {
|
|
308
|
+
throw new Error(
|
|
309
|
+
`Target content type "${relationConfig.targetType}" not found for relation field "${fieldName}"`,
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const ids: string[] = [];
|
|
314
|
+
|
|
315
|
+
if (relationConfig.type === "belongsTo") {
|
|
316
|
+
// Single relation
|
|
317
|
+
const value = fieldValue as RelationValue;
|
|
318
|
+
if (isNewRelationValue(value)) {
|
|
319
|
+
// Create the new item
|
|
320
|
+
const newItem = await createRelatedItem(
|
|
321
|
+
adapter,
|
|
322
|
+
targetContentType,
|
|
323
|
+
value.data,
|
|
324
|
+
);
|
|
325
|
+
ids.push(newItem.id);
|
|
326
|
+
// Store only the ID in processedData
|
|
327
|
+
processedData[fieldName] = { id: newItem.id };
|
|
328
|
+
} else if (isExistingRelationValue(value)) {
|
|
329
|
+
ids.push(value.id);
|
|
330
|
+
// Keep as-is (already an ID reference)
|
|
331
|
+
}
|
|
332
|
+
} else {
|
|
333
|
+
// Array relation (hasMany / manyToMany)
|
|
334
|
+
const values = (
|
|
335
|
+
Array.isArray(fieldValue) ? fieldValue : []
|
|
336
|
+
) as RelationValue[];
|
|
337
|
+
const processedValues: Array<{ id: string }> = [];
|
|
338
|
+
|
|
339
|
+
for (const value of values) {
|
|
340
|
+
if (isNewRelationValue(value)) {
|
|
341
|
+
// Create the new item
|
|
342
|
+
const newItem = await createRelatedItem(
|
|
343
|
+
adapter,
|
|
344
|
+
targetContentType,
|
|
345
|
+
value.data,
|
|
346
|
+
);
|
|
347
|
+
ids.push(newItem.id);
|
|
348
|
+
processedValues.push({ id: newItem.id });
|
|
349
|
+
} else if (isExistingRelationValue(value)) {
|
|
350
|
+
ids.push(value.id);
|
|
351
|
+
processedValues.push({ id: value.id });
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
processedData[fieldName] = processedValues;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
relationIds[fieldName] = ids;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
return { processedData, relationIds };
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Create a related content item
|
|
366
|
+
*/
|
|
367
|
+
async function createRelatedItem(
|
|
368
|
+
adapter: Adapter,
|
|
369
|
+
targetContentType: ContentType,
|
|
370
|
+
data: Record<string, unknown>,
|
|
371
|
+
): Promise<ContentItem> {
|
|
372
|
+
// Generate slug from common name fields or use timestamp
|
|
373
|
+
const slug = slugify(
|
|
374
|
+
(data.slug as string) ||
|
|
375
|
+
(data.name as string) ||
|
|
376
|
+
(data.title as string) ||
|
|
377
|
+
`item-${Date.now()}`,
|
|
378
|
+
);
|
|
379
|
+
|
|
380
|
+
// Validate against target content type schema
|
|
381
|
+
const zodSchema = getContentTypeZodSchema(targetContentType);
|
|
382
|
+
const validation = zodSchema.safeParse(data);
|
|
383
|
+
if (!validation.success) {
|
|
384
|
+
throw new Error(
|
|
385
|
+
`Validation failed for new ${targetContentType.slug}: ${JSON.stringify(validation.error.issues)}`,
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Check for duplicate slug
|
|
390
|
+
const existing = await adapter.findOne<ContentItem>({
|
|
391
|
+
model: "contentItem",
|
|
392
|
+
where: [
|
|
393
|
+
{
|
|
394
|
+
field: "contentTypeId",
|
|
395
|
+
value: targetContentType.id,
|
|
396
|
+
operator: "eq" as const,
|
|
397
|
+
},
|
|
398
|
+
{ field: "slug", value: slug, operator: "eq" as const },
|
|
399
|
+
],
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
if (existing) {
|
|
403
|
+
// If item with same slug exists, return it instead of creating duplicate
|
|
404
|
+
return existing;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// Create the item
|
|
408
|
+
const item = await adapter.create<ContentItem>({
|
|
409
|
+
model: "contentItem",
|
|
410
|
+
data: {
|
|
411
|
+
contentTypeId: targetContentType.id,
|
|
412
|
+
slug,
|
|
413
|
+
data: JSON.stringify(validation.data),
|
|
414
|
+
createdAt: new Date(),
|
|
415
|
+
updatedAt: new Date(),
|
|
416
|
+
},
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
return item;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Sync relations in the junction table for a content item.
|
|
424
|
+
*
|
|
425
|
+
* Only updates relations for fields explicitly present in relationIds.
|
|
426
|
+
* Fields not in relationIds are left unchanged - this preserves existing
|
|
427
|
+
* relations during partial updates.
|
|
428
|
+
*/
|
|
429
|
+
async function syncRelations(
|
|
430
|
+
adapter: Adapter,
|
|
431
|
+
sourceId: string,
|
|
432
|
+
relationIds: Record<string, string[]>,
|
|
433
|
+
): Promise<void> {
|
|
434
|
+
// Only sync fields that are explicitly included in relationIds
|
|
435
|
+
for (const [fieldName, targetIds] of Object.entries(relationIds)) {
|
|
436
|
+
// Delete existing relations for this specific field only
|
|
437
|
+
await adapter.delete({
|
|
438
|
+
model: "contentRelation",
|
|
439
|
+
where: [
|
|
440
|
+
{ field: "sourceId", value: sourceId, operator: "eq" as const },
|
|
441
|
+
{ field: "fieldName", value: fieldName, operator: "eq" as const },
|
|
442
|
+
],
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
// Create new relations for this field
|
|
446
|
+
for (const targetId of targetIds) {
|
|
447
|
+
await adapter.create<ContentRelation>({
|
|
448
|
+
model: "contentRelation",
|
|
449
|
+
data: {
|
|
450
|
+
sourceId,
|
|
451
|
+
targetId,
|
|
452
|
+
fieldName,
|
|
453
|
+
createdAt: new Date(),
|
|
454
|
+
},
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Populate relations for a content item by fetching related items
|
|
462
|
+
*/
|
|
463
|
+
async function populateRelations(
|
|
464
|
+
adapter: Adapter,
|
|
465
|
+
item: ContentItemWithType,
|
|
466
|
+
): Promise<Record<string, SerializedContentItemWithType[]>> {
|
|
467
|
+
const relations: Record<string, SerializedContentItemWithType[]> = {};
|
|
468
|
+
|
|
469
|
+
// Get all relations for this item
|
|
470
|
+
const contentRelations = await adapter.findMany<ContentRelation>({
|
|
471
|
+
model: "contentRelation",
|
|
472
|
+
where: [{ field: "sourceId", value: item.id, operator: "eq" as const }],
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
// Group by field name
|
|
476
|
+
const relationsByField: Record<string, string[]> = {};
|
|
477
|
+
for (const rel of contentRelations) {
|
|
478
|
+
if (!relationsByField[rel.fieldName]) {
|
|
479
|
+
relationsByField[rel.fieldName] = [];
|
|
480
|
+
}
|
|
481
|
+
relationsByField[rel.fieldName]!.push(rel.targetId);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// Fetch related items for each field
|
|
485
|
+
for (const [fieldName, targetIds] of Object.entries(relationsByField)) {
|
|
486
|
+
if (targetIds.length === 0) {
|
|
487
|
+
relations[fieldName] = [];
|
|
488
|
+
continue;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
const relatedItems: SerializedContentItemWithType[] = [];
|
|
492
|
+
for (const targetId of targetIds) {
|
|
493
|
+
const relatedItem = await adapter.findOne<ContentItemWithType>({
|
|
494
|
+
model: "contentItem",
|
|
495
|
+
where: [{ field: "id", value: targetId, operator: "eq" as const }],
|
|
496
|
+
join: { contentType: true },
|
|
497
|
+
});
|
|
498
|
+
if (relatedItem) {
|
|
499
|
+
relatedItems.push(serializeContentItemWithType(relatedItem));
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
relations[fieldName] = relatedItems;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
return relations;
|
|
506
|
+
}
|
|
507
|
+
|
|
199
508
|
/**
|
|
200
509
|
* CMS backend plugin
|
|
201
510
|
* Provides API endpoints for managing content types and content items
|
|
@@ -420,9 +729,19 @@ export const cmsBackendPlugin = (config: CMSBackendConfig) =>
|
|
|
420
729
|
throw ctx.error(404, { message: "Content type not found" });
|
|
421
730
|
}
|
|
422
731
|
|
|
423
|
-
//
|
|
732
|
+
// Process relation fields FIRST - this creates new items from _new values
|
|
733
|
+
// and converts them to ID references before Zod validation
|
|
734
|
+
const { processedData: dataWithResolvedRelations, relationIds } =
|
|
735
|
+
await processRelationsInData(
|
|
736
|
+
adapter,
|
|
737
|
+
contentType,
|
|
738
|
+
data as Record<string, unknown>,
|
|
739
|
+
getContentType,
|
|
740
|
+
);
|
|
741
|
+
|
|
742
|
+
// Validate data against content type schema (now with resolved relations)
|
|
424
743
|
const zodSchema = getContentTypeZodSchema(contentType);
|
|
425
|
-
const validation = zodSchema.safeParse(
|
|
744
|
+
const validation = zodSchema.safeParse(dataWithResolvedRelations);
|
|
426
745
|
if (!validation.success) {
|
|
427
746
|
throw ctx.error(400, {
|
|
428
747
|
message: "Validation failed",
|
|
@@ -448,20 +767,16 @@ export const cmsBackendPlugin = (config: CMSBackendConfig) =>
|
|
|
448
767
|
});
|
|
449
768
|
}
|
|
450
769
|
|
|
451
|
-
// Call before hook - may
|
|
452
|
-
|
|
770
|
+
// Call before hook - may deny operation
|
|
771
|
+
const processedData = validation.data as Record<string, unknown>;
|
|
453
772
|
if (config.hooks?.onBeforeCreate) {
|
|
454
773
|
const result = await config.hooks.onBeforeCreate(
|
|
455
|
-
|
|
774
|
+
processedData,
|
|
456
775
|
context,
|
|
457
776
|
);
|
|
458
777
|
if (result === false) {
|
|
459
778
|
throw ctx.error(403, { message: "Create operation denied" });
|
|
460
779
|
}
|
|
461
|
-
// Use returned data if provided (hook can modify data)
|
|
462
|
-
if (result && typeof result === "object") {
|
|
463
|
-
finalData = result;
|
|
464
|
-
}
|
|
465
780
|
}
|
|
466
781
|
|
|
467
782
|
const item = await adapter.create<ContentItem>({
|
|
@@ -469,12 +784,15 @@ export const cmsBackendPlugin = (config: CMSBackendConfig) =>
|
|
|
469
784
|
data: {
|
|
470
785
|
contentTypeId: contentType.id,
|
|
471
786
|
slug,
|
|
472
|
-
data: JSON.stringify(
|
|
787
|
+
data: JSON.stringify(processedData),
|
|
473
788
|
createdAt: new Date(),
|
|
474
789
|
updatedAt: new Date(),
|
|
475
790
|
},
|
|
476
791
|
});
|
|
477
792
|
|
|
793
|
+
// Sync relations to junction table
|
|
794
|
+
await syncRelations(adapter, item.id, relationIds);
|
|
795
|
+
|
|
478
796
|
const serialized = serializeContentItem(item);
|
|
479
797
|
|
|
480
798
|
// Call after hook
|
|
@@ -484,7 +802,7 @@ export const cmsBackendPlugin = (config: CMSBackendConfig) =>
|
|
|
484
802
|
|
|
485
803
|
return {
|
|
486
804
|
...serialized,
|
|
487
|
-
parsedData:
|
|
805
|
+
parsedData: processedData,
|
|
488
806
|
};
|
|
489
807
|
},
|
|
490
808
|
);
|
|
@@ -550,11 +868,39 @@ export const cmsBackendPlugin = (config: CMSBackendConfig) =>
|
|
|
550
868
|
}
|
|
551
869
|
}
|
|
552
870
|
|
|
553
|
-
//
|
|
554
|
-
|
|
871
|
+
// Process relation fields FIRST if data is being updated
|
|
872
|
+
// This creates new items from _new values before Zod validation
|
|
873
|
+
let dataWithResolvedRelations: Record<string, unknown> | undefined;
|
|
874
|
+
let relationIds: Record<string, string[]> | undefined;
|
|
555
875
|
if (data) {
|
|
876
|
+
const result = await processRelationsInData(
|
|
877
|
+
adapter,
|
|
878
|
+
contentType,
|
|
879
|
+
data as Record<string, unknown>,
|
|
880
|
+
getContentType,
|
|
881
|
+
);
|
|
882
|
+
dataWithResolvedRelations = result.processedData;
|
|
883
|
+
relationIds = result.relationIds;
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
// Validate data if provided (now with resolved relations)
|
|
887
|
+
// IMPORTANT: Merge with existing data BEFORE Zod validation to prevent
|
|
888
|
+
// schema defaults (like .default([])) from overwriting existing values
|
|
889
|
+
// for fields not included in the partial update.
|
|
890
|
+
let validatedData = dataWithResolvedRelations;
|
|
891
|
+
if (dataWithResolvedRelations) {
|
|
892
|
+
// Parse existing data and merge with update data
|
|
893
|
+
// Update data takes precedence, but existing fields are preserved
|
|
894
|
+
const existingData = existing.data
|
|
895
|
+
? (JSON.parse(existing.data) as Record<string, unknown>)
|
|
896
|
+
: {};
|
|
897
|
+
const mergedData = {
|
|
898
|
+
...existingData,
|
|
899
|
+
...dataWithResolvedRelations,
|
|
900
|
+
};
|
|
901
|
+
|
|
556
902
|
const zodSchema = getContentTypeZodSchema(contentType);
|
|
557
|
-
const validation = zodSchema.safeParse(
|
|
903
|
+
const validation = zodSchema.safeParse(mergedData);
|
|
558
904
|
if (!validation.success) {
|
|
559
905
|
throw ctx.error(400, {
|
|
560
906
|
message: "Validation failed",
|
|
@@ -564,8 +910,8 @@ export const cmsBackendPlugin = (config: CMSBackendConfig) =>
|
|
|
564
910
|
validatedData = validation.data as Record<string, unknown>;
|
|
565
911
|
}
|
|
566
912
|
|
|
567
|
-
// Call before hook - may
|
|
568
|
-
|
|
913
|
+
// Call before hook - may deny operation
|
|
914
|
+
const processedData = validatedData;
|
|
569
915
|
if (config.hooks?.onBeforeUpdate && validatedData) {
|
|
570
916
|
const result = await config.hooks.onBeforeUpdate(
|
|
571
917
|
id,
|
|
@@ -575,17 +921,18 @@ export const cmsBackendPlugin = (config: CMSBackendConfig) =>
|
|
|
575
921
|
if (result === false) {
|
|
576
922
|
throw ctx.error(403, { message: "Update operation denied" });
|
|
577
923
|
}
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
// Sync relations to junction table if data was updated
|
|
927
|
+
if (relationIds) {
|
|
928
|
+
await syncRelations(adapter, id, relationIds);
|
|
582
929
|
}
|
|
583
930
|
|
|
584
931
|
const updateData: Partial<ContentItem> = {
|
|
585
932
|
updatedAt: new Date(),
|
|
586
933
|
};
|
|
587
934
|
if (slug) updateData.slug = slug;
|
|
588
|
-
if (
|
|
935
|
+
if (processedData) updateData.data = JSON.stringify(processedData);
|
|
589
936
|
|
|
590
937
|
await adapter.update({
|
|
591
938
|
model: "contentItem",
|
|
@@ -660,6 +1007,301 @@ export const cmsBackendPlugin = (config: CMSBackendConfig) =>
|
|
|
660
1007
|
},
|
|
661
1008
|
);
|
|
662
1009
|
|
|
1010
|
+
// ========== Relation Endpoints ==========
|
|
1011
|
+
|
|
1012
|
+
const getContentItemPopulated = createEndpoint(
|
|
1013
|
+
"/content/:typeSlug/:id/populated",
|
|
1014
|
+
{
|
|
1015
|
+
method: "GET",
|
|
1016
|
+
params: z.object({ typeSlug: z.string(), id: z.string() }),
|
|
1017
|
+
},
|
|
1018
|
+
async (ctx) => {
|
|
1019
|
+
const { typeSlug, id } = ctx.params;
|
|
1020
|
+
|
|
1021
|
+
const contentType = await getContentType(typeSlug);
|
|
1022
|
+
if (!contentType) {
|
|
1023
|
+
throw ctx.error(404, { message: "Content type not found" });
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
const item = await adapter.findOne<ContentItemWithType>({
|
|
1027
|
+
model: "contentItem",
|
|
1028
|
+
where: [{ field: "id", value: id, operator: "eq" as const }],
|
|
1029
|
+
join: { contentType: true },
|
|
1030
|
+
});
|
|
1031
|
+
|
|
1032
|
+
if (!item || item.contentTypeId !== contentType.id) {
|
|
1033
|
+
throw ctx.error(404, { message: "Content item not found" });
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
// Populate relations
|
|
1037
|
+
const _relations = await populateRelations(adapter, item);
|
|
1038
|
+
|
|
1039
|
+
return {
|
|
1040
|
+
...serializeContentItemWithType(item),
|
|
1041
|
+
_relations,
|
|
1042
|
+
};
|
|
1043
|
+
},
|
|
1044
|
+
);
|
|
1045
|
+
|
|
1046
|
+
const listContentByRelation = createEndpoint(
|
|
1047
|
+
"/content/:typeSlug/by-relation",
|
|
1048
|
+
{
|
|
1049
|
+
method: "GET",
|
|
1050
|
+
params: z.object({ typeSlug: z.string() }),
|
|
1051
|
+
query: z.object({
|
|
1052
|
+
field: z.string(),
|
|
1053
|
+
targetId: z.string(),
|
|
1054
|
+
limit: z.coerce.number().min(1).max(100).optional().default(20),
|
|
1055
|
+
offset: z.coerce.number().min(0).optional().default(0),
|
|
1056
|
+
}),
|
|
1057
|
+
},
|
|
1058
|
+
async (ctx) => {
|
|
1059
|
+
const { typeSlug } = ctx.params;
|
|
1060
|
+
const { field, targetId, limit, offset } = ctx.query;
|
|
1061
|
+
|
|
1062
|
+
const contentType = await getContentType(typeSlug);
|
|
1063
|
+
if (!contentType) {
|
|
1064
|
+
throw ctx.error(404, { message: "Content type not found" });
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
// Find all content relations where the target matches
|
|
1068
|
+
const contentRelations = await adapter.findMany<ContentRelation>({
|
|
1069
|
+
model: "contentRelation",
|
|
1070
|
+
where: [
|
|
1071
|
+
{ field: "targetId", value: targetId, operator: "eq" as const },
|
|
1072
|
+
{ field: "fieldName", value: field, operator: "eq" as const },
|
|
1073
|
+
],
|
|
1074
|
+
});
|
|
1075
|
+
|
|
1076
|
+
// Get unique source IDs that belong to this content type
|
|
1077
|
+
const sourceIds = [
|
|
1078
|
+
...new Set(contentRelations.map((r) => r.sourceId)),
|
|
1079
|
+
];
|
|
1080
|
+
|
|
1081
|
+
if (sourceIds.length === 0) {
|
|
1082
|
+
return {
|
|
1083
|
+
items: [],
|
|
1084
|
+
total: 0,
|
|
1085
|
+
limit,
|
|
1086
|
+
offset,
|
|
1087
|
+
};
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
// Fetch all matching items (filtering by content type)
|
|
1091
|
+
const allItems: ContentItemWithType[] = [];
|
|
1092
|
+
for (const sourceId of sourceIds) {
|
|
1093
|
+
const item = await adapter.findOne<ContentItemWithType>({
|
|
1094
|
+
model: "contentItem",
|
|
1095
|
+
where: [
|
|
1096
|
+
{ field: "id", value: sourceId, operator: "eq" as const },
|
|
1097
|
+
{
|
|
1098
|
+
field: "contentTypeId",
|
|
1099
|
+
value: contentType.id,
|
|
1100
|
+
operator: "eq" as const,
|
|
1101
|
+
},
|
|
1102
|
+
],
|
|
1103
|
+
join: { contentType: true },
|
|
1104
|
+
});
|
|
1105
|
+
if (item) {
|
|
1106
|
+
allItems.push(item);
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
// Sort by createdAt desc
|
|
1111
|
+
allItems.sort(
|
|
1112
|
+
(a, b) => b.createdAt.getTime() - a.createdAt.getTime(),
|
|
1113
|
+
);
|
|
1114
|
+
|
|
1115
|
+
const total = allItems.length;
|
|
1116
|
+
const paginatedItems = allItems.slice(offset, offset + limit);
|
|
1117
|
+
|
|
1118
|
+
return {
|
|
1119
|
+
items: paginatedItems.map(serializeContentItemWithType),
|
|
1120
|
+
total,
|
|
1121
|
+
limit,
|
|
1122
|
+
offset,
|
|
1123
|
+
};
|
|
1124
|
+
},
|
|
1125
|
+
);
|
|
1126
|
+
|
|
1127
|
+
// ========== Inverse Relation Endpoints ==========
|
|
1128
|
+
|
|
1129
|
+
const getInverseRelations = createEndpoint(
|
|
1130
|
+
"/content-types/:slug/inverse-relations",
|
|
1131
|
+
{
|
|
1132
|
+
method: "GET",
|
|
1133
|
+
params: z.object({ slug: z.string() }),
|
|
1134
|
+
query: z.object({
|
|
1135
|
+
itemId: z.string().optional(),
|
|
1136
|
+
}),
|
|
1137
|
+
},
|
|
1138
|
+
async (ctx) => {
|
|
1139
|
+
const { slug } = ctx.params;
|
|
1140
|
+
const { itemId } = ctx.query;
|
|
1141
|
+
|
|
1142
|
+
await ensureSynced();
|
|
1143
|
+
|
|
1144
|
+
// Get the target content type
|
|
1145
|
+
const targetContentType = await getContentType(slug);
|
|
1146
|
+
if (!targetContentType) {
|
|
1147
|
+
throw ctx.error(404, { message: "Content type not found" });
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
// Find all content types that have belongsTo relations pointing to this type
|
|
1151
|
+
const allContentTypes = await adapter.findMany<ContentType>({
|
|
1152
|
+
model: "contentType",
|
|
1153
|
+
});
|
|
1154
|
+
|
|
1155
|
+
const inverseRelations: InverseRelation[] = [];
|
|
1156
|
+
|
|
1157
|
+
for (const contentType of allContentTypes) {
|
|
1158
|
+
const relationFields = extractRelationFields(contentType);
|
|
1159
|
+
|
|
1160
|
+
for (const [fieldName, relationConfig] of Object.entries(
|
|
1161
|
+
relationFields,
|
|
1162
|
+
)) {
|
|
1163
|
+
// Only include belongsTo relations that point to the target type
|
|
1164
|
+
if (
|
|
1165
|
+
relationConfig.type === "belongsTo" &&
|
|
1166
|
+
relationConfig.targetType === slug
|
|
1167
|
+
) {
|
|
1168
|
+
let count = 0;
|
|
1169
|
+
|
|
1170
|
+
// If itemId is provided, count items that reference this specific item
|
|
1171
|
+
if (itemId) {
|
|
1172
|
+
const relations = await adapter.findMany<ContentRelation>({
|
|
1173
|
+
model: "contentRelation",
|
|
1174
|
+
where: [
|
|
1175
|
+
{
|
|
1176
|
+
field: "targetId",
|
|
1177
|
+
value: itemId,
|
|
1178
|
+
operator: "eq" as const,
|
|
1179
|
+
},
|
|
1180
|
+
{
|
|
1181
|
+
field: "fieldName",
|
|
1182
|
+
value: fieldName,
|
|
1183
|
+
operator: "eq" as const,
|
|
1184
|
+
},
|
|
1185
|
+
],
|
|
1186
|
+
});
|
|
1187
|
+
// Filter to only include relations from this content type
|
|
1188
|
+
const itemIds = relations.map((r) => r.sourceId);
|
|
1189
|
+
for (const sourceId of itemIds) {
|
|
1190
|
+
const item = await adapter.findOne<ContentItem>({
|
|
1191
|
+
model: "contentItem",
|
|
1192
|
+
where: [
|
|
1193
|
+
{
|
|
1194
|
+
field: "id",
|
|
1195
|
+
value: sourceId,
|
|
1196
|
+
operator: "eq" as const,
|
|
1197
|
+
},
|
|
1198
|
+
{
|
|
1199
|
+
field: "contentTypeId",
|
|
1200
|
+
value: contentType.id,
|
|
1201
|
+
operator: "eq" as const,
|
|
1202
|
+
},
|
|
1203
|
+
],
|
|
1204
|
+
});
|
|
1205
|
+
if (item) count++;
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
inverseRelations.push({
|
|
1210
|
+
sourceType: contentType.slug,
|
|
1211
|
+
sourceTypeName: contentType.name,
|
|
1212
|
+
fieldName,
|
|
1213
|
+
count,
|
|
1214
|
+
});
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
return { inverseRelations };
|
|
1220
|
+
},
|
|
1221
|
+
);
|
|
1222
|
+
|
|
1223
|
+
const listInverseRelationItems = createEndpoint(
|
|
1224
|
+
"/content-types/:slug/inverse-relations/:sourceType",
|
|
1225
|
+
{
|
|
1226
|
+
method: "GET",
|
|
1227
|
+
params: z.object({
|
|
1228
|
+
slug: z.string(),
|
|
1229
|
+
sourceType: z.string(),
|
|
1230
|
+
}),
|
|
1231
|
+
query: z.object({
|
|
1232
|
+
itemId: z.string(),
|
|
1233
|
+
fieldName: z.string(),
|
|
1234
|
+
limit: z.coerce.number().min(1).max(100).optional().default(20),
|
|
1235
|
+
offset: z.coerce.number().min(0).optional().default(0),
|
|
1236
|
+
}),
|
|
1237
|
+
},
|
|
1238
|
+
async (ctx) => {
|
|
1239
|
+
const { slug, sourceType } = ctx.params;
|
|
1240
|
+
const { itemId, fieldName, limit, offset } = ctx.query;
|
|
1241
|
+
|
|
1242
|
+
await ensureSynced();
|
|
1243
|
+
|
|
1244
|
+
// Verify target content type exists
|
|
1245
|
+
const targetContentType = await getContentType(slug);
|
|
1246
|
+
if (!targetContentType) {
|
|
1247
|
+
throw ctx.error(404, { message: "Target content type not found" });
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
// Verify source content type exists
|
|
1251
|
+
const sourceContentType = await getContentType(sourceType);
|
|
1252
|
+
if (!sourceContentType) {
|
|
1253
|
+
throw ctx.error(404, { message: "Source content type not found" });
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
// Find all relations pointing to this item
|
|
1257
|
+
const relations = await adapter.findMany<ContentRelation>({
|
|
1258
|
+
model: "contentRelation",
|
|
1259
|
+
where: [
|
|
1260
|
+
{ field: "targetId", value: itemId, operator: "eq" as const },
|
|
1261
|
+
{ field: "fieldName", value: fieldName, operator: "eq" as const },
|
|
1262
|
+
],
|
|
1263
|
+
});
|
|
1264
|
+
|
|
1265
|
+
// Get unique source IDs
|
|
1266
|
+
const sourceIds = [...new Set(relations.map((r) => r.sourceId))];
|
|
1267
|
+
|
|
1268
|
+
// Fetch all matching items from the source content type
|
|
1269
|
+
const allItems: ContentItemWithType[] = [];
|
|
1270
|
+
for (const sourceId of sourceIds) {
|
|
1271
|
+
const item = await adapter.findOne<ContentItemWithType>({
|
|
1272
|
+
model: "contentItem",
|
|
1273
|
+
where: [
|
|
1274
|
+
{ field: "id", value: sourceId, operator: "eq" as const },
|
|
1275
|
+
{
|
|
1276
|
+
field: "contentTypeId",
|
|
1277
|
+
value: sourceContentType.id,
|
|
1278
|
+
operator: "eq" as const,
|
|
1279
|
+
},
|
|
1280
|
+
],
|
|
1281
|
+
join: { contentType: true },
|
|
1282
|
+
});
|
|
1283
|
+
if (item) {
|
|
1284
|
+
allItems.push(item);
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
// Sort by createdAt desc
|
|
1289
|
+
allItems.sort(
|
|
1290
|
+
(a, b) => b.createdAt.getTime() - a.createdAt.getTime(),
|
|
1291
|
+
);
|
|
1292
|
+
|
|
1293
|
+
const total = allItems.length;
|
|
1294
|
+
const paginatedItems = allItems.slice(offset, offset + limit);
|
|
1295
|
+
|
|
1296
|
+
return {
|
|
1297
|
+
items: paginatedItems.map(serializeContentItemWithType),
|
|
1298
|
+
total,
|
|
1299
|
+
limit,
|
|
1300
|
+
offset,
|
|
1301
|
+
};
|
|
1302
|
+
},
|
|
1303
|
+
);
|
|
1304
|
+
|
|
663
1305
|
return {
|
|
664
1306
|
listContentTypes,
|
|
665
1307
|
getContentTypeBySlug,
|
|
@@ -668,6 +1310,10 @@ export const cmsBackendPlugin = (config: CMSBackendConfig) =>
|
|
|
668
1310
|
createContentItem,
|
|
669
1311
|
updateContentItem,
|
|
670
1312
|
deleteContentItem,
|
|
1313
|
+
getContentItemPopulated,
|
|
1314
|
+
listContentByRelation,
|
|
1315
|
+
getInverseRelations,
|
|
1316
|
+
listInverseRelationItems,
|
|
671
1317
|
};
|
|
672
1318
|
},
|
|
673
1319
|
});
|