@isardsat/editorial-server 6.19.5 → 6.20.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.
@@ -1,4 +1,4 @@
1
- import type { EditorialData, EditorialDataObjectWithType } from "@isardsat/editorial-common";
1
+ import type { EditorialData, EditorialDataObjectWithType, EditorialUpdateDataItem } from "@isardsat/editorial-common";
2
2
  export declare function createStorage(dataDirectory: string): {
3
3
  getSchema: () => Promise<Record<string, {
4
4
  displayName: string;
@@ -31,7 +31,11 @@ export declare function createStorage(dataDirectory: string): {
31
31
  createdAt: string;
32
32
  updatedAt: string;
33
33
  }>;
34
- updateItem: (item: EditorialDataObjectWithType) => Promise<{
34
+ checkItemExists: ({ type, id }: {
35
+ type: string;
36
+ id: string;
37
+ }) => Promise<boolean>;
38
+ updateItem: (item: EditorialUpdateDataItem) => Promise<{
35
39
  [x: string]: unknown;
36
40
  id: string;
37
41
  isDraft: boolean;
@@ -90,6 +90,10 @@ export function createStorage(dataDirectory) {
90
90
  }
91
91
  return true;
92
92
  }
93
+ async function checkItemExists({ type, id }) {
94
+ const content = await getContent({ production: false });
95
+ return !!content[type]?.[id];
96
+ }
93
97
  async function createItem(item) {
94
98
  const content = await getContent({ production: false });
95
99
  content[item.type] = content[item.type] ?? {};
@@ -100,6 +104,9 @@ export function createStorage(dataDirectory) {
100
104
  return parsedItem;
101
105
  }
102
106
  async function updateItem(item) {
107
+ if (!item.id) {
108
+ throw new Error("Item ID is required for update.");
109
+ }
103
110
  const content = await getContent({ production: false });
104
111
  content[item.type] = content[item.type] ?? {};
105
112
  const oldItem = content[item.type][item.id];
@@ -108,7 +115,17 @@ export function createStorage(dataDirectory) {
108
115
  ...item,
109
116
  updatedAt: new Date().toISOString(),
110
117
  });
111
- content[item.type][item.id] = newItem;
118
+ // Handle ID change: if the ID has changed, we need to delete the old item and create a new one with the new ID
119
+ if (newItem.hasOwnProperty("newId")) {
120
+ delete content[item.type][item.id];
121
+ const newId = item.newId || item.id;
122
+ delete newItem.newId;
123
+ newItem.id = newId;
124
+ content[item.type][newId] = newItem;
125
+ }
126
+ else {
127
+ content[item.type][item.id] = newItem;
128
+ }
112
129
  // TODO: Use superjson to safely encode different types.
113
130
  await writeFileSafe(dataPath, JSON.stringify(content, null, 2));
114
131
  return newItem;
@@ -126,6 +143,7 @@ export function createStorage(dataDirectory) {
126
143
  getLocalisationMessages,
127
144
  saveLocalisationMessages,
128
145
  createItem,
146
+ checkItemExists,
129
147
  updateItem,
130
148
  deleteItem,
131
149
  saveContent,
@@ -0,0 +1,19 @@
1
+ import { type EditorialData, type EditorialDataItem, type EditorialSchema } from "@isardsat/editorial-common";
2
+ import { type Storage } from "../storage.js";
3
+ /**
4
+ * Resolves referenced fields in an item, replacing IDs with full objects.
5
+ * Recursively resolves nested references.
6
+ */
7
+ export declare function resolveReferences(item: EditorialDataItem, schema: EditorialSchema, itemType: string, content: EditorialData, origin: string, resolvedIds?: Set<string>): EditorialDataItem;
8
+ /**
9
+ * Resolves all references in a collection.
10
+ */
11
+ export declare function resolveCollectionReferences(collection: Record<string, EditorialDataItem>, schema: EditorialSchema, itemType: string, content: EditorialData, origin: string): Record<string, EditorialDataItem>;
12
+ export declare function updateOrRemoveReferences(params: {
13
+ schema: any;
14
+ content: Record<string, Record<string, any>>;
15
+ storage: Storage;
16
+ targetType: string;
17
+ oldId: string;
18
+ newId?: string | null;
19
+ }): Promise<void>;
@@ -0,0 +1,135 @@
1
+ import { getChoicesReference, } from "@isardsat/editorial-common";
2
+ import {} from "../storage.js";
3
+ /**
4
+ * Resolves uploaded file paths to full URLs for an item.
5
+ */
6
+ function resolveFileUrls(item, schema, itemType, origin) {
7
+ const resolvedItem = { ...item };
8
+ const itemSchema = schema[itemType];
9
+ if (!itemSchema)
10
+ return resolvedItem;
11
+ for (const [key, value] of Object.entries(resolvedItem)) {
12
+ if (!itemSchema.fields[key]?.isUploadedFile)
13
+ continue;
14
+ if (typeof value !== "string")
15
+ continue;
16
+ if (value.startsWith("http"))
17
+ continue;
18
+ resolvedItem[key] = `${origin}/${value}`;
19
+ }
20
+ return resolvedItem;
21
+ }
22
+ /**
23
+ * Resolves referenced fields in an item, replacing IDs with full objects.
24
+ * Recursively resolves nested references.
25
+ */
26
+ export function resolveReferences(item, schema, itemType, content, origin, resolvedIds = new Set()) {
27
+ const itemIdentifier = `${itemType}:${item.id}`;
28
+ // Prevent circular references
29
+ if (resolvedIds.has(itemIdentifier)) {
30
+ return resolveFileUrls(item, schema, itemType, origin);
31
+ }
32
+ resolvedIds.add(itemIdentifier);
33
+ // First resolve file URLs for the current item
34
+ let resolvedItem = resolveFileUrls(item, schema, itemType, origin);
35
+ const itemSchema = schema[itemType];
36
+ if (!itemSchema)
37
+ return resolvedItem;
38
+ for (const [fieldKey, fieldConfig] of Object.entries(itemSchema.fields)) {
39
+ if (fieldConfig.type !== "select" && fieldConfig.type !== "multiselect") {
40
+ continue;
41
+ }
42
+ const referencedType = getChoicesReference(fieldConfig.choicesFixed);
43
+ if (!referencedType)
44
+ continue;
45
+ const referencedCollection = content[referencedType];
46
+ if (!referencedCollection)
47
+ continue;
48
+ const fieldValue = item[fieldKey];
49
+ if (fieldConfig.type === "select" && typeof fieldValue === "string") {
50
+ // Single reference - replace ID with full object
51
+ const referencedItem = referencedCollection[fieldValue];
52
+ if (referencedItem) {
53
+ // Recursively resolve nested references
54
+ resolvedItem[fieldKey] = resolveReferences(referencedItem, schema, referencedType, content, origin, new Set(resolvedIds));
55
+ }
56
+ }
57
+ else if (fieldConfig.type === "multiselect" &&
58
+ Array.isArray(fieldValue)) {
59
+ // Multiple references - replace IDs with full objects
60
+ resolvedItem[fieldKey] = fieldValue
61
+ .map((id) => {
62
+ const referencedItem = referencedCollection[id];
63
+ if (!referencedItem)
64
+ return null;
65
+ // Recursively resolve nested references
66
+ return resolveReferences(referencedItem, schema, referencedType, content, origin, new Set(resolvedIds));
67
+ })
68
+ .filter(Boolean);
69
+ }
70
+ }
71
+ return resolvedItem;
72
+ }
73
+ /**
74
+ * Resolves all references in a collection.
75
+ */
76
+ export function resolveCollectionReferences(collection, schema, itemType, content, origin) {
77
+ const resolvedCollection = {};
78
+ for (const [itemKey, item] of Object.entries(collection)) {
79
+ resolvedCollection[itemKey] = resolveReferences(item, schema, itemType, content, origin);
80
+ }
81
+ return resolvedCollection;
82
+ }
83
+ export async function updateOrRemoveReferences(params) {
84
+ const { schema, content, storage, targetType, oldId, newId } = params;
85
+ for (const [containerType, containerSchema] of Object.entries(schema)) {
86
+ const fields = containerSchema.fields || {};
87
+ const collection = content[containerType] || {};
88
+ // Find fields in this type that reference the target type
89
+ const referenceFields = Object.entries(fields).filter(([, fieldConfig]) => {
90
+ if (fieldConfig.type !== "select" &&
91
+ fieldConfig.type !== "multiselect") {
92
+ return false;
93
+ }
94
+ return getChoicesReference(fieldConfig.choicesFixed) === targetType;
95
+ });
96
+ if (referenceFields.length === 0)
97
+ continue;
98
+ for (const [itemId, item] of Object.entries(collection)) {
99
+ let changed = false;
100
+ const patchedItem = { ...item };
101
+ for (const [fieldKey, fieldConfig] of referenceFields) {
102
+ if (fieldConfig.type === "select") {
103
+ if (patchedItem[fieldKey] === oldId) {
104
+ patchedItem[fieldKey] = newId ?? null; // rename or remove
105
+ changed = true;
106
+ }
107
+ }
108
+ else if (fieldConfig.type === "multiselect") {
109
+ const value = patchedItem[fieldKey];
110
+ if (Array.isArray(value)) {
111
+ let next;
112
+ if (newId != null) {
113
+ next = value.map((v) => (v === oldId ? newId : v));
114
+ }
115
+ else {
116
+ next = value.filter((v) => v !== oldId); // remove
117
+ }
118
+ if (next.length !== value.length ||
119
+ next.some((v, i) => v !== value[i])) {
120
+ patchedItem[fieldKey] = next;
121
+ changed = true;
122
+ }
123
+ }
124
+ }
125
+ }
126
+ if (changed) {
127
+ await storage.updateItem({
128
+ ...patchedItem,
129
+ type: containerType,
130
+ id: itemId,
131
+ });
132
+ }
133
+ }
134
+ }
135
+ }
@@ -1,9 +1,9 @@
1
1
  import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi";
2
- import { EditorialDataItemSchema, EditorialDataSchema, EditorialDiffResponseSchema, EditorialSchemaSchema, } from "@isardsat/editorial-common";
2
+ import { EditorialDataItemSchema, EditorialDataSchema, EditorialDiffResponseSchema, EditorialSchemaSchema, EditorialUpdateDataItemSchema, } from "@isardsat/editorial-common";
3
3
  import { createCache } from "../lib/cache.js";
4
4
  import { firebaseAuth } from "../lib/middleware/auth.js";
5
5
  import { getChangedFields } from "../lib/utils/diff.js";
6
- import { resolveCollectionReferences, resolveReferences, } from "../lib/utils/resolve.js";
6
+ import { resolveCollectionReferences, resolveReferences, updateOrRemoveReferences, } from "../lib/utils/references.js";
7
7
  import { generateMetaSchema } from "../lib/utils/schema.js";
8
8
  export function createDataRoutes(config, storage) {
9
9
  const app = new OpenAPIHono();
@@ -294,7 +294,7 @@ export function createDataRoutes(config, storage) {
294
294
  app.openapi(createRoute({
295
295
  method: "put",
296
296
  path: "/data/{itemType}/{id}",
297
- summary: "Create or update object data by type and id",
297
+ summary: "Create object data by type and id",
298
298
  request: {
299
299
  params: z.object({
300
300
  itemType: z.string().openapi({
@@ -324,12 +324,22 @@ export function createDataRoutes(config, storage) {
324
324
  },
325
325
  description: "Create a new object",
326
326
  },
327
+ 409: {
328
+ description: "Item with this type and id already exists",
329
+ },
327
330
  },
328
331
  tags: ["Data"],
329
332
  middleware: [firebaseAuth(config.firebase?.projectId || "")],
330
333
  security: [{ bearerAuth: [] }],
331
334
  }), async (c) => {
332
335
  const itemAtts = await c.req.json();
336
+ const itemExists = await storage.checkItemExists({
337
+ type: itemAtts.type,
338
+ id: itemAtts.id,
339
+ });
340
+ if (itemExists) {
341
+ return c.json({ error: "Item with this type and id already exists" }, 409);
342
+ }
333
343
  const newItem = await storage.createItem(itemAtts);
334
344
  cache.invalidateContent();
335
345
  return c.json(newItem);
@@ -352,7 +362,7 @@ export function createDataRoutes(config, storage) {
352
362
  body: {
353
363
  content: {
354
364
  "application/json": {
355
- schema: EditorialDataItemSchema,
365
+ schema: EditorialUpdateDataItemSchema,
356
366
  },
357
367
  },
358
368
  required: true,
@@ -367,15 +377,51 @@ export function createDataRoutes(config, storage) {
367
377
  },
368
378
  description: "Update object",
369
379
  },
380
+ 409: {
381
+ description: "Item with this type and id already exists",
382
+ },
370
383
  },
371
384
  tags: ["Data"],
372
385
  middleware: [firebaseAuth(config.firebase?.projectId || "")],
373
386
  security: [{ bearerAuth: [] }],
374
387
  }), async (c) => {
388
+ const { itemType, id } = c.req.valid("param");
375
389
  const itemAtts = await c.req.json();
376
- const newItem = await storage.updateItem(itemAtts);
390
+ if (itemAtts.newId) {
391
+ const itemWithNewIdExists = await storage.checkItemExists({
392
+ type: itemAtts.type,
393
+ id: itemAtts.newId,
394
+ });
395
+ if (itemWithNewIdExists) {
396
+ return c.json({ error: "Item with this type and this new id already exists" }, 409);
397
+ }
398
+ }
399
+ const oldId = id;
400
+ const newId = itemAtts.newId || itemAtts.id || id;
401
+ const renamed = oldId !== newId;
402
+ // Update renamed item first
403
+ const updatedItem = await storage.updateItem({
404
+ ...itemAtts,
405
+ type: itemType,
406
+ id: oldId,
407
+ });
408
+ // Repair references if needed
409
+ if (renamed) {
410
+ const [schema, content] = await Promise.all([
411
+ storage.getSchema(),
412
+ storage.getContent({ production: false }), // preview/content being edited
413
+ ]);
414
+ await updateOrRemoveReferences({
415
+ schema,
416
+ content,
417
+ storage,
418
+ targetType: itemType,
419
+ oldId,
420
+ newId,
421
+ });
422
+ }
377
423
  cache.invalidateContent();
378
- return c.json(newItem);
424
+ return c.json(updatedItem);
379
425
  });
380
426
  app.openapi(createRoute({
381
427
  method: "delete",
@@ -409,6 +455,19 @@ export function createDataRoutes(config, storage) {
409
455
  }), async (c) => {
410
456
  const { itemType, id } = c.req.valid("param");
411
457
  await storage.deleteItem({ type: itemType, id });
458
+ // Remove references to the deleted item
459
+ const [schema, content] = await Promise.all([
460
+ storage.getSchema(),
461
+ storage.getContent({ production: false }),
462
+ ]);
463
+ await updateOrRemoveReferences({
464
+ schema,
465
+ content,
466
+ storage,
467
+ targetType: itemType,
468
+ oldId: id,
469
+ newId: null,
470
+ });
412
471
  cache.invalidateContent();
413
472
  return c.json(true, 200);
414
473
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@isardsat/editorial-server",
3
- "version": "6.19.5",
3
+ "version": "6.20.0",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -15,8 +15,8 @@
15
15
  "jose": "^6.1.3",
16
16
  "yaml": "^2.8.1",
17
17
  "zod": "^4.1.11",
18
- "@isardsat/editorial-admin": "^6.19.5",
19
- "@isardsat/editorial-common": "^6.19.5"
18
+ "@isardsat/editorial-admin": "^6.20.0",
19
+ "@isardsat/editorial-common": "^6.20.0"
20
20
  },
21
21
  "devDependencies": {
22
22
  "@tsconfig/node22": "^22.0.0",