@btst/stack 1.8.0 → 1.9.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 (44) hide show
  1. package/dist/packages/better-stack/src/plugins/cms/api/plugin.cjs +445 -16
  2. package/dist/packages/better-stack/src/plugins/cms/api/plugin.mjs +445 -16
  3. package/dist/packages/better-stack/src/plugins/cms/client/components/forms/content-form.cjs +24 -7
  4. package/dist/packages/better-stack/src/plugins/cms/client/components/forms/content-form.mjs +25 -8
  5. package/dist/packages/better-stack/src/plugins/cms/client/components/forms/relation-field.cjs +224 -0
  6. package/dist/packages/better-stack/src/plugins/cms/client/components/forms/relation-field.mjs +222 -0
  7. package/dist/packages/better-stack/src/plugins/cms/client/components/inverse-relations-panel.cjs +243 -0
  8. package/dist/packages/better-stack/src/plugins/cms/client/components/inverse-relations-panel.mjs +241 -0
  9. package/dist/packages/better-stack/src/plugins/cms/client/components/pages/content-editor-page.internal.cjs +56 -2
  10. package/dist/packages/better-stack/src/plugins/cms/client/components/pages/content-editor-page.internal.mjs +56 -2
  11. package/dist/packages/better-stack/src/plugins/cms/client/hooks/cms-hooks.cjs +190 -0
  12. package/dist/packages/better-stack/src/plugins/cms/client/hooks/cms-hooks.mjs +187 -1
  13. package/dist/packages/better-stack/src/plugins/cms/db.cjs +38 -0
  14. package/dist/packages/better-stack/src/plugins/cms/db.mjs +38 -0
  15. package/dist/packages/ui/src/components/auto-form/fields/object.cjs +81 -1
  16. package/dist/packages/ui/src/components/auto-form/fields/object.mjs +81 -1
  17. package/dist/packages/ui/src/components/dialog.cjs +6 -0
  18. package/dist/packages/ui/src/components/dialog.mjs +6 -1
  19. package/dist/plugins/cms/api/index.d.cts +67 -3
  20. package/dist/plugins/cms/api/index.d.mts +67 -3
  21. package/dist/plugins/cms/api/index.d.ts +67 -3
  22. package/dist/plugins/cms/client/hooks/index.cjs +4 -0
  23. package/dist/plugins/cms/client/hooks/index.d.cts +82 -3
  24. package/dist/plugins/cms/client/hooks/index.d.mts +82 -3
  25. package/dist/plugins/cms/client/hooks/index.d.ts +82 -3
  26. package/dist/plugins/cms/client/hooks/index.mjs +1 -1
  27. package/dist/plugins/cms/query-keys.d.cts +1 -1
  28. package/dist/plugins/cms/query-keys.d.mts +1 -1
  29. package/dist/plugins/cms/query-keys.d.ts +1 -1
  30. package/dist/plugins/form-builder/api/index.d.cts +1 -1
  31. package/dist/plugins/form-builder/api/index.d.mts +1 -1
  32. package/dist/plugins/form-builder/api/index.d.ts +1 -1
  33. package/dist/shared/{stack.L-UFwz2G.d.cts → stack.oGOteE6g.d.cts} +27 -5
  34. package/dist/shared/{stack.L-UFwz2G.d.mts → stack.oGOteE6g.d.mts} +27 -5
  35. package/dist/shared/{stack.L-UFwz2G.d.ts → stack.oGOteE6g.d.ts} +27 -5
  36. package/package.json +1 -1
  37. package/src/plugins/cms/api/plugin.ts +667 -21
  38. package/src/plugins/cms/client/components/forms/content-form.tsx +60 -18
  39. package/src/plugins/cms/client/components/forms/relation-field.tsx +299 -0
  40. package/src/plugins/cms/client/components/inverse-relations-panel.tsx +329 -0
  41. package/src/plugins/cms/client/components/pages/content-editor-page.internal.tsx +127 -1
  42. package/src/plugins/cms/client/hooks/cms-hooks.tsx +344 -0
  43. package/src/plugins/cms/db.ts +38 -0
  44. package/src/plugins/cms/types.ts +99 -10
@@ -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
- // Validate data against content type schema
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(data);
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 modify data or deny operation
452
- let finalData = validation.data;
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
- validation.data as Record<string, unknown>,
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(finalData),
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: finalData,
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
- // Validate data if provided
554
- let validatedData = data;
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(data);
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 modify data or deny operation
568
- let finalData = validatedData;
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
- // Use returned data if provided (hook can modify data)
579
- if (result && typeof result === "object") {
580
- finalData = result;
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 (finalData) updateData.data = JSON.stringify(finalData);
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
  });