@isardsat/editorial-server 6.19.4 → 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.
- package/dist/lib/storage.d.ts +6 -2
- package/dist/lib/storage.js +19 -1
- package/dist/lib/utils/references.d.ts +19 -0
- package/dist/lib/utils/references.js +135 -0
- package/dist/routes/data.js +65 -6
- package/package.json +3 -3
package/dist/lib/storage.d.ts
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/dist/lib/storage.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/dist/routes/data.js
CHANGED
|
@@ -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/
|
|
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
|
|
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:
|
|
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
|
-
|
|
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(
|
|
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.
|
|
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
|
-
"@isardsat/editorial-common": "^6.
|
|
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",
|