@btst/plugin-cms 0.0.0-stage → 1.0.0-beta.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/LICENSE.md +17 -0
- package/dist/api/getters.cjs +328 -0
- package/dist/api/getters.mjs +314 -0
- package/dist/api/index.cjs +29 -0
- package/dist/api/index.d.cts +209 -0
- package/dist/api/index.d.mts +209 -0
- package/dist/api/index.d.ts +209 -0
- package/dist/api/index.mjs +7 -0
- package/dist/api/lifecycle-migrations.cjs +13 -0
- package/dist/api/lifecycle-migrations.mjs +11 -0
- package/dist/api/mutations.cjs +59 -0
- package/dist/api/mutations.mjs +57 -0
- package/dist/api/operations.cjs +1769 -0
- package/dist/api/operations.mjs +1762 -0
- package/dist/api/plugin.cjs +337 -0
- package/dist/api/plugin.mjs +331 -0
- package/dist/api/query-key-defs.cjs +44 -0
- package/dist/api/query-key-defs.mjs +39 -0
- package/dist/api/relations.cjs +76 -0
- package/dist/api/relations.mjs +70 -0
- package/dist/client/components/forms/content-form.cjs +286 -0
- package/dist/client/components/forms/content-form.mjs +284 -0
- package/dist/client/components/forms/file-upload.cjs +160 -0
- package/dist/client/components/forms/file-upload.mjs +158 -0
- package/dist/client/components/forms/relation-field.cjs +237 -0
- package/dist/client/components/forms/relation-field.mjs +235 -0
- package/dist/client/components/index.cjs +14 -0
- package/dist/client/components/index.d.cts +20 -0
- package/dist/client/components/index.d.mts +20 -0
- package/dist/client/components/index.d.ts +20 -0
- package/dist/client/components/index.mjs +10 -0
- package/dist/client/components/inverse-relations-panel.cjs +272 -0
- package/dist/client/components/inverse-relations-panel.mjs +270 -0
- package/dist/client/components/loading/dashboard-skeleton.cjs +18 -0
- package/dist/client/components/loading/dashboard-skeleton.mjs +16 -0
- package/dist/client/components/loading/editor-skeleton.cjs +32 -0
- package/dist/client/components/loading/editor-skeleton.mjs +30 -0
- package/dist/client/components/loading/list-skeleton.cjs +32 -0
- package/dist/client/components/loading/list-skeleton.mjs +30 -0
- package/dist/client/components/pages/404-page.cjs +26 -0
- package/dist/client/components/pages/404-page.mjs +24 -0
- package/dist/client/components/pages/content-editor-page.cjs +84 -0
- package/dist/client/components/pages/content-editor-page.internal.cjs +170 -0
- package/dist/client/components/pages/content-editor-page.internal.mjs +168 -0
- package/dist/client/components/pages/content-editor-page.mjs +82 -0
- package/dist/client/components/pages/content-list-page.cjs +49 -0
- package/dist/client/components/pages/content-list-page.internal.cjs +334 -0
- package/dist/client/components/pages/content-list-page.internal.mjs +332 -0
- package/dist/client/components/pages/content-list-page.mjs +47 -0
- package/dist/client/components/pages/dashboard-page.cjs +42 -0
- package/dist/client/components/pages/dashboard-page.internal.cjs +102 -0
- package/dist/client/components/pages/dashboard-page.internal.mjs +100 -0
- package/dist/client/components/pages/dashboard-page.mjs +40 -0
- package/dist/client/components/shared/default-error.cjs +17 -0
- package/dist/client/components/shared/default-error.mjs +15 -0
- package/dist/client/components/shared/empty-state.cjs +16 -0
- package/dist/client/components/shared/empty-state.mjs +14 -0
- package/dist/client/components/shared/page-wrapper.cjs +29 -0
- package/dist/client/components/shared/page-wrapper.mjs +27 -0
- package/dist/client/constants.cjs +5 -0
- package/dist/client/constants.mjs +3 -0
- package/dist/client/hooks/cms-hooks.cjs +300 -0
- package/dist/client/hooks/cms-hooks.mjs +280 -0
- package/dist/client/hooks/cms-resource.cjs +13 -0
- package/dist/client/hooks/cms-resource.mjs +11 -0
- package/dist/client/hooks/index.cjs +25 -0
- package/dist/client/hooks/index.d.cts +693 -0
- package/dist/client/hooks/index.d.mts +693 -0
- package/dist/client/hooks/index.d.ts +693 -0
- package/dist/client/hooks/index.mjs +1 -0
- package/dist/client/index.cjs +15 -0
- package/dist/client/index.d.cts +654 -0
- package/dist/client/index.d.mts +654 -0
- package/dist/client/index.d.ts +654 -0
- package/dist/client/index.mjs +3 -0
- package/dist/client/overrides.cjs +26 -0
- package/dist/client/overrides.mjs +24 -0
- package/dist/client/plugin.cjs +316 -0
- package/dist/client/plugin.mjs +314 -0
- package/dist/client.css +3 -0
- package/dist/db.cjs +118 -0
- package/dist/db.mjs +116 -0
- package/dist/permissions.cjs +46 -0
- package/dist/permissions.d.cts +42 -0
- package/dist/permissions.d.mts +42 -0
- package/dist/permissions.d.ts +42 -0
- package/dist/permissions.mjs +44 -0
- package/dist/query-keys.cjs +229 -0
- package/dist/query-keys.d.cts +8 -0
- package/dist/query-keys.d.mts +8 -0
- package/dist/query-keys.d.ts +8 -0
- package/dist/query-keys.mjs +225 -0
- package/dist/schemas.cjs +81 -0
- package/dist/schemas.mjs +72 -0
- package/dist/shared/plugin-cms.BBshPRkb.d.cts +6 -0
- package/dist/shared/plugin-cms.BBshPRkb.d.mts +6 -0
- package/dist/shared/plugin-cms.BBshPRkb.d.ts +6 -0
- package/dist/shared/plugin-cms.Bptwe33B.d.cts +1749 -0
- package/dist/shared/plugin-cms.Csx7tNDO.d.mts +1749 -0
- package/dist/shared/plugin-cms.eNW9_FO5.d.ts +1749 -0
- package/dist/style.css +6 -0
- package/dist/types.cjs +2 -0
- package/dist/types.d.cts +406 -0
- package/dist/types.d.mts +406 -0
- package/dist/types.d.ts +406 -0
- package/dist/types.mjs +1 -0
- package/dist/ui/src/components/accordion.cjs +81 -0
- package/dist/ui/src/components/accordion.mjs +62 -0
- package/dist/ui/src/components/alert-dialog.cjs +157 -0
- package/dist/ui/src/components/alert-dialog.mjs +132 -0
- package/dist/ui/src/components/auto-form/common/label.cjs +18 -0
- package/dist/ui/src/components/auto-form/common/label.mjs +16 -0
- package/dist/ui/src/components/auto-form/common/tooltip.cjs +9 -0
- package/dist/ui/src/components/auto-form/common/tooltip.mjs +7 -0
- package/dist/ui/src/components/auto-form/config.cjs +40 -0
- package/dist/ui/src/components/auto-form/config.mjs +37 -0
- package/dist/ui/src/components/auto-form/dependencies.cjs +48 -0
- package/dist/ui/src/components/auto-form/dependencies.mjs +46 -0
- package/dist/ui/src/components/auto-form/fields/array.cjs +266 -0
- package/dist/ui/src/components/auto-form/fields/array.mjs +264 -0
- package/dist/ui/src/components/auto-form/fields/checkbox.cjs +38 -0
- package/dist/ui/src/components/auto-form/fields/checkbox.mjs +36 -0
- package/dist/ui/src/components/auto-form/fields/date.cjs +67 -0
- package/dist/ui/src/components/auto-form/fields/date.mjs +65 -0
- package/dist/ui/src/components/auto-form/fields/enum.cjs +73 -0
- package/dist/ui/src/components/auto-form/fields/enum.mjs +71 -0
- package/dist/ui/src/components/auto-form/fields/input.cjs +32 -0
- package/dist/ui/src/components/auto-form/fields/input.mjs +30 -0
- package/dist/ui/src/components/auto-form/fields/number.cjs +31 -0
- package/dist/ui/src/components/auto-form/fields/number.mjs +29 -0
- package/dist/ui/src/components/auto-form/fields/object.cjs +233 -0
- package/dist/ui/src/components/auto-form/fields/object.mjs +217 -0
- package/dist/ui/src/components/auto-form/fields/radio-group.cjs +67 -0
- package/dist/ui/src/components/auto-form/fields/radio-group.mjs +65 -0
- package/dist/ui/src/components/auto-form/fields/switch.cjs +38 -0
- package/dist/ui/src/components/auto-form/fields/switch.mjs +36 -0
- package/dist/ui/src/components/auto-form/fields/textarea.cjs +31 -0
- package/dist/ui/src/components/auto-form/fields/textarea.mjs +29 -0
- package/dist/ui/src/components/auto-form/helpers.cjs +270 -0
- package/dist/ui/src/components/auto-form/helpers.mjs +259 -0
- package/dist/ui/src/components/auto-form/index.cjs +92 -0
- package/dist/ui/src/components/auto-form/index.mjs +86 -0
- package/dist/ui/src/components/auto-form/stepped-auto-form.cjs +374 -0
- package/dist/ui/src/components/auto-form/stepped-auto-form.mjs +368 -0
- package/dist/ui/src/components/auto-form/types.cjs +11 -0
- package/dist/ui/src/components/auto-form/types.mjs +9 -0
- package/dist/ui/src/components/badge.cjs +42 -0
- package/dist/ui/src/components/badge.mjs +39 -0
- package/dist/ui/src/components/button.cjs +56 -0
- package/dist/ui/src/components/button.mjs +53 -0
- package/dist/ui/src/components/calendar.cjs +207 -0
- package/dist/ui/src/components/calendar.mjs +190 -0
- package/dist/ui/src/components/card.cjs +56 -0
- package/dist/ui/src/components/card.mjs +51 -0
- package/dist/ui/src/components/checkbox.cjs +48 -0
- package/dist/ui/src/components/checkbox.mjs +32 -0
- package/dist/ui/src/components/command.cjs +78 -0
- package/dist/ui/src/components/command.mjs +73 -0
- package/dist/ui/src/components/date-picker.cjs +41 -0
- package/dist/ui/src/components/date-picker.mjs +39 -0
- package/dist/ui/src/components/dialog.cjs +119 -0
- package/dist/ui/src/components/dialog.mjs +97 -0
- package/dist/ui/src/components/form.cjs +120 -0
- package/dist/ui/src/components/form.mjs +98 -0
- package/dist/ui/src/components/input.cjs +23 -0
- package/dist/ui/src/components/input.mjs +21 -0
- package/dist/ui/src/components/label.cjs +39 -0
- package/dist/ui/src/components/label.mjs +23 -0
- package/dist/ui/src/components/multi-select.cjs +555 -0
- package/dist/ui/src/components/multi-select.mjs +540 -0
- package/dist/ui/src/components/page-layout.cjs +25 -0
- package/dist/ui/src/components/page-layout.mjs +23 -0
- package/dist/ui/src/components/page-wrapper.cjs +20 -0
- package/dist/ui/src/components/page-wrapper.mjs +18 -0
- package/dist/ui/src/components/popover.cjs +60 -0
- package/dist/ui/src/components/popover.mjs +42 -0
- package/dist/ui/src/components/radio-group.cjs +62 -0
- package/dist/ui/src/components/radio-group.mjs +45 -0
- package/dist/ui/src/components/select.cjs +161 -0
- package/dist/ui/src/components/select.mjs +139 -0
- package/dist/ui/src/components/separator.cjs +43 -0
- package/dist/ui/src/components/separator.mjs +27 -0
- package/dist/ui/src/components/skeleton.cjs +17 -0
- package/dist/ui/src/components/skeleton.mjs +15 -0
- package/dist/ui/src/components/stack-attribution.cjs +24 -0
- package/dist/ui/src/components/stack-attribution.mjs +22 -0
- package/dist/ui/src/components/switch.cjs +48 -0
- package/dist/ui/src/components/switch.mjs +32 -0
- package/dist/ui/src/components/table.cjs +89 -0
- package/dist/ui/src/components/table.mjs +82 -0
- package/dist/ui/src/components/textarea.cjs +20 -0
- package/dist/ui/src/components/textarea.mjs +18 -0
- package/dist/ui/src/hooks/use-debounce.cjs +16 -0
- package/dist/ui/src/hooks/use-debounce.mjs +14 -0
- package/dist/ui/src/hooks/use-route-lifecycle.cjs +31 -0
- package/dist/ui/src/hooks/use-route-lifecycle.mjs +29 -0
- package/dist/ui/src/lib/schema-converter.cjs +133 -0
- package/dist/ui/src/lib/schema-converter.mjs +127 -0
- package/dist/ui/src/lib/utils.cjs +10 -0
- package/dist/ui/src/lib/utils.mjs +8 -0
- package/dist/utils.cjs +19 -0
- package/dist/utils.mjs +12 -0
- package/package.json +170 -3
- package/README.md +0 -3
|
@@ -0,0 +1,1769 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const api = require('@btst/stack/plugins/api');
|
|
4
|
+
const z = require('zod');
|
|
5
|
+
const schemaConverter = require('../ui/src/lib/schema-converter.cjs');
|
|
6
|
+
const permissions = require('../permissions.cjs');
|
|
7
|
+
const schemas = require('../schemas.cjs');
|
|
8
|
+
const utils = require('../utils.cjs');
|
|
9
|
+
const getters = require('./getters.cjs');
|
|
10
|
+
const relations = require('./relations.cjs');
|
|
11
|
+
|
|
12
|
+
function operationContentType(contentType) {
|
|
13
|
+
return { ...contentType };
|
|
14
|
+
}
|
|
15
|
+
function operationContentItem(item) {
|
|
16
|
+
const {
|
|
17
|
+
parsedData,
|
|
18
|
+
contentType,
|
|
19
|
+
_relations,
|
|
20
|
+
...serializedItem
|
|
21
|
+
} = item;
|
|
22
|
+
return {
|
|
23
|
+
...serializedItem,
|
|
24
|
+
parsedData,
|
|
25
|
+
...contentType ? { contentType: operationContentType(contentType) } : {}
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
function operationContentList(result, options) {
|
|
29
|
+
return {
|
|
30
|
+
items: result.items.map(
|
|
31
|
+
(item) => getters.applyContentReadOptions(operationContentItem(item), options)
|
|
32
|
+
),
|
|
33
|
+
total: result.total,
|
|
34
|
+
...result.limit !== void 0 ? { limit: result.limit } : {},
|
|
35
|
+
...result.offset !== void 0 ? { offset: result.offset } : {}
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
const JSONValueSchema = z.z.lazy(
|
|
39
|
+
() => z.z.union([
|
|
40
|
+
z.z.string(),
|
|
41
|
+
z.z.number(),
|
|
42
|
+
z.z.boolean(),
|
|
43
|
+
z.z.null(),
|
|
44
|
+
z.z.array(JSONValueSchema),
|
|
45
|
+
z.z.record(z.z.string(), JSONValueSchema)
|
|
46
|
+
])
|
|
47
|
+
);
|
|
48
|
+
const CMSDataSchema = z.z.record(
|
|
49
|
+
z.z.string(),
|
|
50
|
+
JSONValueSchema
|
|
51
|
+
);
|
|
52
|
+
const EmptyInputSchema = z.z.object({});
|
|
53
|
+
const CMSContentTypeParamsSchema = z.z.object({
|
|
54
|
+
slug: z.z.string().min(1)
|
|
55
|
+
});
|
|
56
|
+
const CMSContentItemParamsSchema = z.z.object({
|
|
57
|
+
typeSlug: z.z.string().min(1),
|
|
58
|
+
id: z.z.string().min(1)
|
|
59
|
+
});
|
|
60
|
+
const CMSCreateContentItemBodySchema = z.z.object({
|
|
61
|
+
slug: z.z.string().min(1),
|
|
62
|
+
data: CMSDataSchema
|
|
63
|
+
});
|
|
64
|
+
const CMSUpdateContentItemBodySchema = z.z.object({
|
|
65
|
+
slug: z.z.string().min(1).optional(),
|
|
66
|
+
data: CMSDataSchema.optional()
|
|
67
|
+
});
|
|
68
|
+
const ListContentTypesInputSchema = EmptyInputSchema;
|
|
69
|
+
const GetContentTypeInputSchema = CMSContentTypeParamsSchema;
|
|
70
|
+
const GetContentItemInputSchema = CMSContentItemParamsSchema.extend(
|
|
71
|
+
schemas.contentReadOptionsSchema.shape
|
|
72
|
+
);
|
|
73
|
+
const CreateContentItemInputSchema = z.z.object({
|
|
74
|
+
typeSlug: z.z.string().min(1),
|
|
75
|
+
body: CMSCreateContentItemBodySchema
|
|
76
|
+
});
|
|
77
|
+
const UpdateContentItemInputSchema = z.z.object({
|
|
78
|
+
typeSlug: z.z.string().min(1),
|
|
79
|
+
id: z.z.string().min(1),
|
|
80
|
+
body: CMSUpdateContentItemBodySchema
|
|
81
|
+
});
|
|
82
|
+
const DeleteContentItemInputSchema = CMSContentItemParamsSchema;
|
|
83
|
+
class CMSOperationError extends api.OperationHttpError {
|
|
84
|
+
constructor(statusCode, message, code = "CMS_OPERATION_ERROR", issues) {
|
|
85
|
+
super(statusCode, message, code, issues);
|
|
86
|
+
this.name = "CMSOperationError";
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
function validateOperationContentReadOptions(contentType, options) {
|
|
90
|
+
try {
|
|
91
|
+
getters.validateContentReadOptions(contentType, options);
|
|
92
|
+
} catch (error) {
|
|
93
|
+
if (error instanceof getters.CMSContentSelectionError) {
|
|
94
|
+
throw new CMSOperationError(
|
|
95
|
+
400,
|
|
96
|
+
error.message,
|
|
97
|
+
"INVALID_CONTENT_FIELDS",
|
|
98
|
+
[{ path: ["fields"], message: error.message }]
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
throw error;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
function normalizeOperationError(error, fallback) {
|
|
105
|
+
if (error instanceof Error) return error;
|
|
106
|
+
return new Error(typeof error === "string" ? error : fallback, {
|
|
107
|
+
cause: error
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
async function runBeforeHook(hook, defaultMessage) {
|
|
111
|
+
if (!hook) return;
|
|
112
|
+
try {
|
|
113
|
+
await hook();
|
|
114
|
+
} catch (error) {
|
|
115
|
+
throw new CMSOperationError(
|
|
116
|
+
403,
|
|
117
|
+
error instanceof Error ? error.message : defaultMessage,
|
|
118
|
+
"HOOK_DENIED"
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
function requestFields(context) {
|
|
123
|
+
return {
|
|
124
|
+
...context.request ? { request: context.request, headers: context.request.headers } : {},
|
|
125
|
+
...context.identity ? { userId: context.identity.id } : {}
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
function createContext(context) {
|
|
129
|
+
return Object.freeze({
|
|
130
|
+
...context,
|
|
131
|
+
typeSlug: context.facts.contentType,
|
|
132
|
+
body: context.input.body,
|
|
133
|
+
...requestFields(context)
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
function updateContext(context) {
|
|
137
|
+
return Object.freeze({
|
|
138
|
+
...context,
|
|
139
|
+
typeSlug: context.facts.contentType,
|
|
140
|
+
params: Object.freeze({
|
|
141
|
+
typeSlug: context.facts.contentType,
|
|
142
|
+
id: context.facts.recordId
|
|
143
|
+
}),
|
|
144
|
+
body: context.input.body,
|
|
145
|
+
...requestFields(context)
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
function deleteContext(context) {
|
|
149
|
+
return Object.freeze({
|
|
150
|
+
...context,
|
|
151
|
+
typeSlug: context.facts.contentType,
|
|
152
|
+
params: Object.freeze({
|
|
153
|
+
typeSlug: context.facts.contentType,
|
|
154
|
+
id: context.facts.recordId
|
|
155
|
+
}),
|
|
156
|
+
...requestFields(context)
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
function genericContext(context, typeSlug) {
|
|
160
|
+
return Object.freeze({
|
|
161
|
+
...context,
|
|
162
|
+
...typeSlug ? { typeSlug } : {},
|
|
163
|
+
...requestFields(context)
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
function operationErrorContext(context, error) {
|
|
167
|
+
return Object.freeze({ ...context, error });
|
|
168
|
+
}
|
|
169
|
+
function getContentTypeZodSchema(contentType) {
|
|
170
|
+
return schemaConverter.formSchemaToZod(JSON.parse(contentType.jsonSchema));
|
|
171
|
+
}
|
|
172
|
+
async function getContentTypeOrThrow(adapter, ensureSynced, slug, message = "Content type not found") {
|
|
173
|
+
await ensureSynced();
|
|
174
|
+
const contentType = await adapter.findOne({
|
|
175
|
+
model: "contentType",
|
|
176
|
+
where: [{ field: "slug", value: slug, operator: "eq" }]
|
|
177
|
+
});
|
|
178
|
+
if (!contentType) {
|
|
179
|
+
throw new CMSOperationError(404, message, "CONTENT_TYPE_NOT_FOUND");
|
|
180
|
+
}
|
|
181
|
+
return contentType;
|
|
182
|
+
}
|
|
183
|
+
async function getRecordOrThrow(adapter, ensureSynced, typeSlug, id) {
|
|
184
|
+
await ensureSynced();
|
|
185
|
+
const item = await adapter.findOne({
|
|
186
|
+
model: "contentItem",
|
|
187
|
+
where: [{ field: "id", value: id, operator: "eq" }],
|
|
188
|
+
join: { contentType: true }
|
|
189
|
+
});
|
|
190
|
+
if (!item || item.contentType?.slug !== typeSlug) {
|
|
191
|
+
throw new CMSOperationError(
|
|
192
|
+
404,
|
|
193
|
+
"Content item not found",
|
|
194
|
+
"RECORD_NOT_FOUND"
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
return item;
|
|
198
|
+
}
|
|
199
|
+
function recordFacts(item) {
|
|
200
|
+
if (!item.contentType) {
|
|
201
|
+
throw new CMSOperationError(
|
|
202
|
+
500,
|
|
203
|
+
"Content item is missing its content type",
|
|
204
|
+
"CONTENT_TYPE_JOIN_MISSING"
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
return {
|
|
208
|
+
contentType: item.contentType.slug,
|
|
209
|
+
scope: "record",
|
|
210
|
+
recordId: item.id,
|
|
211
|
+
...item.authorId ? { authorId: item.authorId } : {}
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
function collectionFacts(contentType) {
|
|
215
|
+
return { contentType, scope: "collection" };
|
|
216
|
+
}
|
|
217
|
+
function assertRecordFacts(item, facts) {
|
|
218
|
+
if (!facts.recordId || item.id !== facts.recordId || item.contentType?.slug !== facts.contentType || (item.authorId ?? void 0) !== facts.authorId) {
|
|
219
|
+
throw staleRecordError();
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
const AFFECTED_ROW_KEYS = [
|
|
223
|
+
"rowCount",
|
|
224
|
+
"affectedRows",
|
|
225
|
+
"rowsAffected",
|
|
226
|
+
"changes",
|
|
227
|
+
"numUpdatedRows"
|
|
228
|
+
];
|
|
229
|
+
function hasPositiveCount(value) {
|
|
230
|
+
if (typeof value === "number") return Number.isFinite(value) && value > 0;
|
|
231
|
+
if (typeof value === "bigint") return value > 0n;
|
|
232
|
+
return false;
|
|
233
|
+
}
|
|
234
|
+
function didUpdateContentItem(result, expectedId) {
|
|
235
|
+
if (typeof result === "number" || typeof result === "bigint") {
|
|
236
|
+
return hasPositiveCount(result);
|
|
237
|
+
}
|
|
238
|
+
if (!result || typeof result !== "object") return false;
|
|
239
|
+
const record = result;
|
|
240
|
+
if ("count" in record) return hasPositiveCount(record.count);
|
|
241
|
+
if (Array.isArray(result)) {
|
|
242
|
+
return result.length > 0 && didUpdateContentItem(result[0], expectedId);
|
|
243
|
+
}
|
|
244
|
+
for (const key of AFFECTED_ROW_KEYS) {
|
|
245
|
+
if (key in record) return hasPositiveCount(record[key]);
|
|
246
|
+
}
|
|
247
|
+
if ("meta" in record) {
|
|
248
|
+
const meta = record.meta;
|
|
249
|
+
return Boolean(
|
|
250
|
+
meta && typeof meta === "object" && "changes" in meta && hasPositiveCount(meta.changes)
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
return record.id === expectedId;
|
|
254
|
+
}
|
|
255
|
+
function staleRecordError() {
|
|
256
|
+
return new CMSOperationError(
|
|
257
|
+
409,
|
|
258
|
+
"Content item changed while authorization was being evaluated. Retry the operation.",
|
|
259
|
+
"RECORD_STATE_CHANGED"
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
function assertRecordSnapshot(current, expected) {
|
|
263
|
+
if (current.id !== expected.id || current.contentTypeId !== expected.contentTypeId || current.contentType?.slug !== expected.contentType?.slug || (current.authorId ?? void 0) !== (expected.authorId ?? void 0) || current.slug !== expected.slug || current.data !== expected.data || current.updatedAt.getTime() !== expected.updatedAt.getTime()) {
|
|
264
|
+
throw staleRecordError();
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
async function planRelatedItem(adapter, targetContentType, slug, data, authorId) {
|
|
268
|
+
const validation = getContentTypeZodSchema(targetContentType).safeParse(data);
|
|
269
|
+
if (!validation.success) {
|
|
270
|
+
throw new CMSOperationError(
|
|
271
|
+
400,
|
|
272
|
+
`Validation failed for new ${targetContentType.slug}`,
|
|
273
|
+
"RELATED_RECORD_VALIDATION_FAILED",
|
|
274
|
+
validation.error.issues
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
const existing = await adapter.findOne({
|
|
278
|
+
model: "contentItem",
|
|
279
|
+
where: [
|
|
280
|
+
{
|
|
281
|
+
field: "contentTypeId",
|
|
282
|
+
value: targetContentType.id,
|
|
283
|
+
operator: "eq"
|
|
284
|
+
},
|
|
285
|
+
{ field: "slug", value: slug, operator: "eq" }
|
|
286
|
+
]
|
|
287
|
+
});
|
|
288
|
+
if (existing) {
|
|
289
|
+
throw relatedRecordSlugConflictError();
|
|
290
|
+
}
|
|
291
|
+
const now = /* @__PURE__ */ new Date();
|
|
292
|
+
return {
|
|
293
|
+
id: globalThis.crypto.randomUUID(),
|
|
294
|
+
contentTypeId: targetContentType.id,
|
|
295
|
+
slug,
|
|
296
|
+
data: JSON.stringify(validation.data),
|
|
297
|
+
...authorId ? { authorId } : {},
|
|
298
|
+
createdAt: now,
|
|
299
|
+
updatedAt: now
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
function relatedRecordSlugConflictError() {
|
|
303
|
+
return new CMSOperationError(
|
|
304
|
+
409,
|
|
305
|
+
"Inline related record could not be created because its slug is unavailable.",
|
|
306
|
+
"RELATED_RECORD_SLUG_CONFLICT"
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
async function planRelationsInData(adapter, contentType, data, ensureSynced, authorId) {
|
|
310
|
+
const relationFields = relations.extractRelationFields(contentType);
|
|
311
|
+
const processedData = { ...data };
|
|
312
|
+
const relationIds = {};
|
|
313
|
+
const newItems = [];
|
|
314
|
+
const plannedSlugs = /* @__PURE__ */ new Set();
|
|
315
|
+
for (const [fieldName, relationConfig] of Object.entries(relationFields)) {
|
|
316
|
+
if (!(fieldName in data)) continue;
|
|
317
|
+
const fieldValue = data[fieldName];
|
|
318
|
+
if (!fieldValue) {
|
|
319
|
+
relationIds[fieldName] = [];
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
const targetContentType = await getContentTypeOrThrow(
|
|
323
|
+
adapter,
|
|
324
|
+
ensureSynced,
|
|
325
|
+
relationConfig.targetType,
|
|
326
|
+
`Target content type "${relationConfig.targetType}" not found for relation field "${fieldName}"`
|
|
327
|
+
);
|
|
328
|
+
const ids = [];
|
|
329
|
+
if (relationConfig.type === "belongsTo") {
|
|
330
|
+
const value = fieldValue;
|
|
331
|
+
if (relations.isNewRelationValue(value)) {
|
|
332
|
+
const item = await resolvePlannedRelatedItem(
|
|
333
|
+
adapter,
|
|
334
|
+
targetContentType,
|
|
335
|
+
value.data,
|
|
336
|
+
authorId,
|
|
337
|
+
plannedSlugs,
|
|
338
|
+
newItems
|
|
339
|
+
);
|
|
340
|
+
ids.push(item.id);
|
|
341
|
+
processedData[fieldName] = { id: item.id };
|
|
342
|
+
} else if (relations.isExistingRelationValue(value)) {
|
|
343
|
+
ids.push(value.id);
|
|
344
|
+
}
|
|
345
|
+
} else {
|
|
346
|
+
const values = Array.isArray(fieldValue) ? fieldValue : [];
|
|
347
|
+
const processedValues = [];
|
|
348
|
+
for (const value of values) {
|
|
349
|
+
if (relations.isNewRelationValue(value)) {
|
|
350
|
+
const item = await resolvePlannedRelatedItem(
|
|
351
|
+
adapter,
|
|
352
|
+
targetContentType,
|
|
353
|
+
value.data,
|
|
354
|
+
authorId,
|
|
355
|
+
plannedSlugs,
|
|
356
|
+
newItems
|
|
357
|
+
);
|
|
358
|
+
ids.push(item.id);
|
|
359
|
+
processedValues.push({ id: item.id });
|
|
360
|
+
} else if (relations.isExistingRelationValue(value)) {
|
|
361
|
+
ids.push(value.id);
|
|
362
|
+
processedValues.push({ id: value.id });
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
processedData[fieldName] = processedValues;
|
|
366
|
+
}
|
|
367
|
+
relationIds[fieldName] = ids;
|
|
368
|
+
}
|
|
369
|
+
return { processedData, relationIds, newItems };
|
|
370
|
+
}
|
|
371
|
+
async function resolvePlannedRelatedItem(adapter, targetContentType, data, authorId, plannedSlugs, newItems) {
|
|
372
|
+
const slug = utils.slugify(
|
|
373
|
+
data.slug || data.name || data.title || `item-${Date.now()}`
|
|
374
|
+
);
|
|
375
|
+
const key = `${targetContentType.id}\0${slug}`;
|
|
376
|
+
if (plannedSlugs.has(key)) throw relatedRecordSlugConflictError();
|
|
377
|
+
const planned = await planRelatedItem(
|
|
378
|
+
adapter,
|
|
379
|
+
targetContentType,
|
|
380
|
+
slug,
|
|
381
|
+
data,
|
|
382
|
+
authorId
|
|
383
|
+
);
|
|
384
|
+
plannedSlugs.add(key);
|
|
385
|
+
newItems.push(planned);
|
|
386
|
+
return planned;
|
|
387
|
+
}
|
|
388
|
+
async function applyRelationWritePlan(adapter, plan) {
|
|
389
|
+
for (const item of plan.newItems) {
|
|
390
|
+
const existing = await adapter.findOne({
|
|
391
|
+
model: "contentItem",
|
|
392
|
+
where: [
|
|
393
|
+
{
|
|
394
|
+
field: "contentTypeId",
|
|
395
|
+
value: item.contentTypeId,
|
|
396
|
+
operator: "eq"
|
|
397
|
+
},
|
|
398
|
+
{ field: "slug", value: item.slug, operator: "eq" }
|
|
399
|
+
]
|
|
400
|
+
});
|
|
401
|
+
if (existing) throw staleRecordError();
|
|
402
|
+
await adapter.create({
|
|
403
|
+
model: "contentItem",
|
|
404
|
+
data: item
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
function relationFilterSnapshot(sourceContentType, fieldName, relation, targetContentType) {
|
|
409
|
+
return JSON.stringify([
|
|
410
|
+
sourceContentType.id,
|
|
411
|
+
sourceContentType.slug,
|
|
412
|
+
fieldName,
|
|
413
|
+
relation.type,
|
|
414
|
+
targetContentType.id,
|
|
415
|
+
targetContentType.slug
|
|
416
|
+
]);
|
|
417
|
+
}
|
|
418
|
+
async function deriveRelationFilterAuthorization(adapter, ensureSynced, sourceContentType, fieldName, targetId) {
|
|
419
|
+
const relation = relations.extractRelationFields(sourceContentType)[fieldName];
|
|
420
|
+
if (!relation) {
|
|
421
|
+
throw new CMSOperationError(
|
|
422
|
+
404,
|
|
423
|
+
`Relation field "${fieldName}" was not found on content type "${sourceContentType.slug}".`,
|
|
424
|
+
"RELATION_FIELD_NOT_FOUND"
|
|
425
|
+
);
|
|
426
|
+
}
|
|
427
|
+
const targetContentType = await getContentTypeOrThrow(
|
|
428
|
+
adapter,
|
|
429
|
+
ensureSynced,
|
|
430
|
+
relation.targetType,
|
|
431
|
+
`Target content type "${relation.targetType}" not found for relation field "${fieldName}"`
|
|
432
|
+
);
|
|
433
|
+
const target = await getRecordOrThrow(
|
|
434
|
+
adapter,
|
|
435
|
+
ensureSynced,
|
|
436
|
+
targetContentType.slug,
|
|
437
|
+
targetId
|
|
438
|
+
);
|
|
439
|
+
const targetFacts = recordFacts(target);
|
|
440
|
+
return {
|
|
441
|
+
permission: permissions.cmsPermissions.record.read(targetFacts),
|
|
442
|
+
snapshot: relationFilterSnapshot(
|
|
443
|
+
sourceContentType,
|
|
444
|
+
fieldName,
|
|
445
|
+
relation,
|
|
446
|
+
targetContentType
|
|
447
|
+
),
|
|
448
|
+
targetFacts
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
function relationTargetSnapshot(facts) {
|
|
452
|
+
return JSON.stringify([
|
|
453
|
+
facts.contentType,
|
|
454
|
+
facts.recordId,
|
|
455
|
+
facts.authorId ?? null
|
|
456
|
+
]);
|
|
457
|
+
}
|
|
458
|
+
async function deriveRelationWriteAuthorization(adapter, contentType, data, ensureSynced, expectedSchemaSnapshot) {
|
|
459
|
+
const permissions$1 = [];
|
|
460
|
+
const snapshot = [];
|
|
461
|
+
const targets = [];
|
|
462
|
+
const seenCreateTargetTypes = /* @__PURE__ */ new Set();
|
|
463
|
+
const seenReadTargets = /* @__PURE__ */ new Set();
|
|
464
|
+
const resolvedRelations = [];
|
|
465
|
+
for (const [fieldName, relationConfig] of Object.entries(
|
|
466
|
+
relations.extractRelationFields(contentType)
|
|
467
|
+
)) {
|
|
468
|
+
if (!(fieldName in data)) continue;
|
|
469
|
+
const value = data[fieldName];
|
|
470
|
+
const values = relationConfig.type === "belongsTo" ? [value] : Array.isArray(value) ? value : [];
|
|
471
|
+
const targetContentType = await getContentTypeOrThrow(
|
|
472
|
+
adapter,
|
|
473
|
+
ensureSynced,
|
|
474
|
+
relationConfig.targetType,
|
|
475
|
+
`Target content type "${relationConfig.targetType}" not found for relation field "${fieldName}"`
|
|
476
|
+
);
|
|
477
|
+
snapshot.push(
|
|
478
|
+
JSON.stringify([
|
|
479
|
+
fieldName,
|
|
480
|
+
relationConfig.type,
|
|
481
|
+
targetContentType.id,
|
|
482
|
+
targetContentType.slug
|
|
483
|
+
])
|
|
484
|
+
);
|
|
485
|
+
resolvedRelations.push({ values, targetContentType });
|
|
486
|
+
}
|
|
487
|
+
snapshot.sort();
|
|
488
|
+
if (expectedSchemaSnapshot) {
|
|
489
|
+
assertRelationSchemaSnapshot(expectedSchemaSnapshot, snapshot);
|
|
490
|
+
}
|
|
491
|
+
for (const { values, targetContentType } of resolvedRelations) {
|
|
492
|
+
if (values.some(relations.isNewRelationValue) && !seenCreateTargetTypes.has(targetContentType.slug)) {
|
|
493
|
+
seenCreateTargetTypes.add(targetContentType.slug);
|
|
494
|
+
permissions$1.push(
|
|
495
|
+
permissions.cmsPermissions.record.create({ contentType: targetContentType.slug })
|
|
496
|
+
);
|
|
497
|
+
}
|
|
498
|
+
for (const reference of values) {
|
|
499
|
+
if (relations.isNewRelationValue(reference) || !relations.isExistingRelationValue(reference)) {
|
|
500
|
+
continue;
|
|
501
|
+
}
|
|
502
|
+
const target = await getRecordOrThrow(
|
|
503
|
+
adapter,
|
|
504
|
+
ensureSynced,
|
|
505
|
+
targetContentType.slug,
|
|
506
|
+
reference.id
|
|
507
|
+
);
|
|
508
|
+
const facts = recordFacts(target);
|
|
509
|
+
const targetKey = relationTargetSnapshot(facts);
|
|
510
|
+
if (seenReadTargets.has(targetKey)) continue;
|
|
511
|
+
seenReadTargets.add(targetKey);
|
|
512
|
+
targets.push(facts);
|
|
513
|
+
permissions$1.push(permissions.cmsPermissions.record.read(facts));
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
return {
|
|
517
|
+
permissions: permissions$1,
|
|
518
|
+
snapshot,
|
|
519
|
+
targets: targets.sort(
|
|
520
|
+
(left, right) => relationTargetSnapshot(left).localeCompare(relationTargetSnapshot(right))
|
|
521
|
+
)
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
function assertRelationSchemaSnapshot(authorized, current) {
|
|
525
|
+
if (JSON.stringify(authorized) !== JSON.stringify(current)) {
|
|
526
|
+
throw relationSchemaChangedError();
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
function assertRelationWriteAuthorization(authorized, current) {
|
|
530
|
+
assertRelationSchemaSnapshot(authorized.snapshot, current.snapshot);
|
|
531
|
+
if (JSON.stringify(authorized.targets.map(relationTargetSnapshot)) !== JSON.stringify(current.targets.map(relationTargetSnapshot))) {
|
|
532
|
+
throw staleRecordError();
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
function relationSchemaChangedError() {
|
|
536
|
+
return new CMSOperationError(
|
|
537
|
+
409,
|
|
538
|
+
"Content type relations changed while authorization was being evaluated. Retry the operation.",
|
|
539
|
+
"RELATION_SCHEMA_CHANGED"
|
|
540
|
+
);
|
|
541
|
+
}
|
|
542
|
+
async function getPopulatedRelationSnapshot(adapter, sourceId) {
|
|
543
|
+
const relations = await adapter.findMany({
|
|
544
|
+
model: "contentRelation",
|
|
545
|
+
where: [{ field: "sourceId", value: sourceId, operator: "eq" }]
|
|
546
|
+
});
|
|
547
|
+
const targets = [];
|
|
548
|
+
const seenTargets = /* @__PURE__ */ new Set();
|
|
549
|
+
for (const relation of relations) {
|
|
550
|
+
if (seenTargets.has(relation.targetId)) continue;
|
|
551
|
+
const target = await adapter.findOne({
|
|
552
|
+
model: "contentItem",
|
|
553
|
+
where: [
|
|
554
|
+
{ field: "id", value: relation.targetId, operator: "eq" }
|
|
555
|
+
],
|
|
556
|
+
join: { contentType: true }
|
|
557
|
+
});
|
|
558
|
+
if (!target) continue;
|
|
559
|
+
seenTargets.add(target.id);
|
|
560
|
+
targets.push({
|
|
561
|
+
item: getters.serializeContentItemWithType(target),
|
|
562
|
+
facts: recordFacts(target)
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
return {
|
|
566
|
+
relationKeys: relations.map((relation) => `${relation.fieldName}\0${relation.targetId}`).sort(),
|
|
567
|
+
targets: targets.sort(
|
|
568
|
+
(left, right) => (left.facts.recordId ?? "").localeCompare(right.facts.recordId ?? "")
|
|
569
|
+
)
|
|
570
|
+
};
|
|
571
|
+
}
|
|
572
|
+
function assertPopulatedRelationSnapshot(authorized, current) {
|
|
573
|
+
const factsKey = (facts) => JSON.stringify([facts.contentType, facts.recordId, facts.authorId ?? null]);
|
|
574
|
+
if (JSON.stringify(authorized.relationKeys) !== JSON.stringify(current.relationKeys) || JSON.stringify(authorized.targets.map(({ facts }) => factsKey(facts))) !== JSON.stringify(current.targets.map(({ facts }) => factsKey(facts)))) {
|
|
575
|
+
throw new CMSOperationError(
|
|
576
|
+
409,
|
|
577
|
+
"Related content changed while authorization was being evaluated. Retry the operation.",
|
|
578
|
+
"RECORD_STATE_CHANGED"
|
|
579
|
+
);
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
function populatedRelationsFromSnapshot(snapshot) {
|
|
583
|
+
const targetsById = new Map(
|
|
584
|
+
snapshot.targets.map((target) => [target.facts.recordId, target.item])
|
|
585
|
+
);
|
|
586
|
+
const populated = {};
|
|
587
|
+
for (const relationKey of snapshot.relationKeys) {
|
|
588
|
+
const [fieldName, targetId] = relationKey.split("\0");
|
|
589
|
+
if (!fieldName || !targetId) continue;
|
|
590
|
+
const target = targetsById.get(targetId);
|
|
591
|
+
if (target) (populated[fieldName] ??= []).push(target);
|
|
592
|
+
}
|
|
593
|
+
return populated;
|
|
594
|
+
}
|
|
595
|
+
function inverseSourceKey(source) {
|
|
596
|
+
return JSON.stringify([
|
|
597
|
+
source.contentTypeId,
|
|
598
|
+
source.contentTypeSlug,
|
|
599
|
+
source.contentTypeName,
|
|
600
|
+
source.fieldName
|
|
601
|
+
]);
|
|
602
|
+
}
|
|
603
|
+
async function deriveInverseSourceAuthorization(adapter, ensureSynced, targetType) {
|
|
604
|
+
await ensureSynced();
|
|
605
|
+
const permissions$1 = [];
|
|
606
|
+
const sources = [];
|
|
607
|
+
const seenSourceTypes = /* @__PURE__ */ new Set();
|
|
608
|
+
for (const contentType of await adapter.findMany({
|
|
609
|
+
model: "contentType"
|
|
610
|
+
})) {
|
|
611
|
+
for (const [fieldName, relation] of Object.entries(
|
|
612
|
+
relations.extractRelationFields(contentType)
|
|
613
|
+
)) {
|
|
614
|
+
if (relation.type !== "belongsTo" || relation.targetType !== targetType) {
|
|
615
|
+
continue;
|
|
616
|
+
}
|
|
617
|
+
sources.push({
|
|
618
|
+
contentTypeId: contentType.id,
|
|
619
|
+
contentTypeSlug: contentType.slug,
|
|
620
|
+
contentTypeName: contentType.name,
|
|
621
|
+
fieldName
|
|
622
|
+
});
|
|
623
|
+
if (seenSourceTypes.has(contentType.slug)) continue;
|
|
624
|
+
seenSourceTypes.add(contentType.slug);
|
|
625
|
+
permissions$1.push(
|
|
626
|
+
permissions.cmsPermissions.contentType.read({ contentType: contentType.slug })
|
|
627
|
+
);
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
sources.sort(
|
|
631
|
+
(left, right) => inverseSourceKey(left).localeCompare(inverseSourceKey(right))
|
|
632
|
+
);
|
|
633
|
+
return {
|
|
634
|
+
permissions: permissions$1,
|
|
635
|
+
snapshot: sources.map(inverseSourceKey),
|
|
636
|
+
sources
|
|
637
|
+
};
|
|
638
|
+
}
|
|
639
|
+
function createCMSOperations(adapter, options) {
|
|
640
|
+
const { ensureSynced, getContentTypesWithCounts, hooks } = options;
|
|
641
|
+
const populatedRelationSnapshots = /* @__PURE__ */ new WeakMap();
|
|
642
|
+
const relationWriteAuthorizationSnapshots = /* @__PURE__ */ new WeakMap();
|
|
643
|
+
const relationFilterAuthorizationSnapshots = /* @__PURE__ */ new WeakMap();
|
|
644
|
+
const inverseSourceAuthorizationSnapshots = /* @__PURE__ */ new WeakMap();
|
|
645
|
+
const inverseTargetAuthorizationSnapshots = /* @__PURE__ */ new WeakMap();
|
|
646
|
+
const contentTypeListSnapshots = /* @__PURE__ */ new WeakMap();
|
|
647
|
+
const assertCurrentRelationWriteAuthorization = async (currentAdapter, authorized, contentTypeSlug, data) => {
|
|
648
|
+
let current;
|
|
649
|
+
try {
|
|
650
|
+
const contentType = await getContentTypeOrThrow(
|
|
651
|
+
currentAdapter,
|
|
652
|
+
ensureSynced,
|
|
653
|
+
contentTypeSlug
|
|
654
|
+
);
|
|
655
|
+
current = await deriveRelationWriteAuthorization(
|
|
656
|
+
currentAdapter,
|
|
657
|
+
contentType,
|
|
658
|
+
data,
|
|
659
|
+
ensureSynced,
|
|
660
|
+
authorized.snapshot
|
|
661
|
+
);
|
|
662
|
+
} catch (error) {
|
|
663
|
+
if (error instanceof CMSOperationError) {
|
|
664
|
+
if (error.code === "CONTENT_TYPE_NOT_FOUND") {
|
|
665
|
+
throw relationSchemaChangedError();
|
|
666
|
+
}
|
|
667
|
+
if (error.code === "RECORD_NOT_FOUND") throw staleRecordError();
|
|
668
|
+
}
|
|
669
|
+
throw error;
|
|
670
|
+
}
|
|
671
|
+
assertRelationWriteAuthorization(authorized, current);
|
|
672
|
+
};
|
|
673
|
+
const recheckRelationWriteAuthorization = async (input, contentTypeSlug, data) => {
|
|
674
|
+
const authorized = relationWriteAuthorizationSnapshots.get(input);
|
|
675
|
+
relationWriteAuthorizationSnapshots.delete(input);
|
|
676
|
+
if (!authorized) {
|
|
677
|
+
throw new CMSOperationError(
|
|
678
|
+
500,
|
|
679
|
+
"Relation authorization snapshot is unavailable.",
|
|
680
|
+
"AUTHORIZATION_SNAPSHOT_MISSING"
|
|
681
|
+
);
|
|
682
|
+
}
|
|
683
|
+
await assertCurrentRelationWriteAuthorization(
|
|
684
|
+
adapter,
|
|
685
|
+
authorized,
|
|
686
|
+
contentTypeSlug,
|
|
687
|
+
data
|
|
688
|
+
);
|
|
689
|
+
return authorized;
|
|
690
|
+
};
|
|
691
|
+
const recheckInverseSourceAuthorization = async (input, targetType) => {
|
|
692
|
+
const authorized = inverseSourceAuthorizationSnapshots.get(input);
|
|
693
|
+
inverseSourceAuthorizationSnapshots.delete(input);
|
|
694
|
+
if (!authorized) {
|
|
695
|
+
throw new CMSOperationError(
|
|
696
|
+
500,
|
|
697
|
+
"Inverse relation authorization snapshot is unavailable.",
|
|
698
|
+
"AUTHORIZATION_SNAPSHOT_MISSING"
|
|
699
|
+
);
|
|
700
|
+
}
|
|
701
|
+
const current = await deriveInverseSourceAuthorization(
|
|
702
|
+
adapter,
|
|
703
|
+
ensureSynced,
|
|
704
|
+
targetType
|
|
705
|
+
);
|
|
706
|
+
assertRelationSchemaSnapshot(authorized, current.snapshot);
|
|
707
|
+
return current.sources;
|
|
708
|
+
};
|
|
709
|
+
const recheckRelationFilterAuthorization = async (input, sourceType, fieldName, targetId) => {
|
|
710
|
+
const authorized = relationFilterAuthorizationSnapshots.get(input);
|
|
711
|
+
relationFilterAuthorizationSnapshots.delete(input);
|
|
712
|
+
if (!authorized) {
|
|
713
|
+
throw new CMSOperationError(
|
|
714
|
+
500,
|
|
715
|
+
"Relation-filter authorization snapshot is unavailable.",
|
|
716
|
+
"AUTHORIZATION_SNAPSHOT_MISSING"
|
|
717
|
+
);
|
|
718
|
+
}
|
|
719
|
+
let sourceContentType;
|
|
720
|
+
try {
|
|
721
|
+
sourceContentType = await getContentTypeOrThrow(
|
|
722
|
+
adapter,
|
|
723
|
+
ensureSynced,
|
|
724
|
+
sourceType
|
|
725
|
+
);
|
|
726
|
+
} catch (error) {
|
|
727
|
+
if (error instanceof CMSOperationError && error.code === "CONTENT_TYPE_NOT_FOUND") {
|
|
728
|
+
throw relationSchemaChangedError();
|
|
729
|
+
}
|
|
730
|
+
throw error;
|
|
731
|
+
}
|
|
732
|
+
const relation = relations.extractRelationFields(sourceContentType)[fieldName];
|
|
733
|
+
if (!relation) throw relationSchemaChangedError();
|
|
734
|
+
let targetContentType;
|
|
735
|
+
try {
|
|
736
|
+
targetContentType = await getContentTypeOrThrow(
|
|
737
|
+
adapter,
|
|
738
|
+
ensureSynced,
|
|
739
|
+
relation.targetType
|
|
740
|
+
);
|
|
741
|
+
} catch (error) {
|
|
742
|
+
if (error instanceof CMSOperationError && error.code === "CONTENT_TYPE_NOT_FOUND") {
|
|
743
|
+
throw relationSchemaChangedError();
|
|
744
|
+
}
|
|
745
|
+
throw error;
|
|
746
|
+
}
|
|
747
|
+
const currentSnapshot = relationFilterSnapshot(
|
|
748
|
+
sourceContentType,
|
|
749
|
+
fieldName,
|
|
750
|
+
relation,
|
|
751
|
+
targetContentType
|
|
752
|
+
);
|
|
753
|
+
if (authorized.snapshot !== currentSnapshot) {
|
|
754
|
+
throw relationSchemaChangedError();
|
|
755
|
+
}
|
|
756
|
+
let target;
|
|
757
|
+
try {
|
|
758
|
+
target = await getRecordOrThrow(
|
|
759
|
+
adapter,
|
|
760
|
+
ensureSynced,
|
|
761
|
+
targetContentType.slug,
|
|
762
|
+
targetId
|
|
763
|
+
);
|
|
764
|
+
} catch (error) {
|
|
765
|
+
if (error instanceof CMSOperationError && error.code === "RECORD_NOT_FOUND") {
|
|
766
|
+
throw staleRecordError();
|
|
767
|
+
}
|
|
768
|
+
throw error;
|
|
769
|
+
}
|
|
770
|
+
const currentFacts = recordFacts(target);
|
|
771
|
+
if (authorized.targetFacts.contentType !== currentFacts.contentType || authorized.targetFacts.recordId !== currentFacts.recordId || authorized.targetFacts.authorId !== currentFacts.authorId) {
|
|
772
|
+
throw staleRecordError();
|
|
773
|
+
}
|
|
774
|
+
return sourceContentType;
|
|
775
|
+
};
|
|
776
|
+
const recheckInverseTargetAuthorization = async (input, targetType, targetId) => {
|
|
777
|
+
const authorized = inverseTargetAuthorizationSnapshots.get(input);
|
|
778
|
+
if (!authorized || authorized.contentType !== targetType || authorized.recordId !== targetId) {
|
|
779
|
+
throw new CMSOperationError(
|
|
780
|
+
500,
|
|
781
|
+
"Inverse-relation target authorization snapshot is unavailable.",
|
|
782
|
+
"AUTHORIZATION_SNAPSHOT_MISSING"
|
|
783
|
+
);
|
|
784
|
+
}
|
|
785
|
+
const current = await adapter.findOne({
|
|
786
|
+
model: "contentItem",
|
|
787
|
+
where: [{ field: "id", value: targetId, operator: "eq" }],
|
|
788
|
+
join: { contentType: true }
|
|
789
|
+
});
|
|
790
|
+
if (!current) throw staleRecordError();
|
|
791
|
+
assertRecordFacts(current, authorized);
|
|
792
|
+
};
|
|
793
|
+
const listQuerySchema = schemas.createListContentQuerySchema(options.maxPageSize);
|
|
794
|
+
const paginationSchema = z.z.object({
|
|
795
|
+
limit: z.z.coerce.number().min(1).max(options.maxPageSize ?? schemas.DEFAULT_MAX_PAGE_SIZE).optional().default(20),
|
|
796
|
+
offset: z.z.coerce.number().min(0).optional().default(0)
|
|
797
|
+
});
|
|
798
|
+
const ListContentItemsInputSchema = z.z.object({
|
|
799
|
+
typeSlug: z.z.string().min(1),
|
|
800
|
+
query: listQuerySchema
|
|
801
|
+
});
|
|
802
|
+
const ListContentByRelationInputSchema = z.z.object({
|
|
803
|
+
typeSlug: z.z.string().min(1),
|
|
804
|
+
query: z.z.object({ field: z.z.string().min(1), targetId: z.z.string().min(1) }).merge(paginationSchema).merge(schemas.contentReadOptionsSchema)
|
|
805
|
+
});
|
|
806
|
+
const GetInverseRelationsInputSchema = z.z.object({
|
|
807
|
+
slug: z.z.string().min(1),
|
|
808
|
+
query: z.z.object({ itemId: z.z.string().min(1).optional() })
|
|
809
|
+
});
|
|
810
|
+
const ListInverseRelationItemsInputSchema = z.z.object({
|
|
811
|
+
slug: z.z.string().min(1),
|
|
812
|
+
sourceType: z.z.string().min(1),
|
|
813
|
+
query: z.z.object({
|
|
814
|
+
itemId: z.z.string().min(1),
|
|
815
|
+
fieldName: z.z.string().min(1)
|
|
816
|
+
}).merge(paginationSchema).merge(schemas.contentReadOptionsSchema)
|
|
817
|
+
});
|
|
818
|
+
const notifyError = async (error, operation, context) => {
|
|
819
|
+
await hooks?.onErrorExecuteContentOperation?.(
|
|
820
|
+
normalizeOperationError(error, `CMS ${operation} operation failed.`),
|
|
821
|
+
operation,
|
|
822
|
+
operationErrorContext(context, error)
|
|
823
|
+
);
|
|
824
|
+
};
|
|
825
|
+
const listContentTypes = api.defineOperation({
|
|
826
|
+
input: ListContentTypesInputSchema,
|
|
827
|
+
permission: permissions.cmsPermissions.contentType.read,
|
|
828
|
+
facts: async () => {
|
|
829
|
+
await ensureSynced();
|
|
830
|
+
return {};
|
|
831
|
+
},
|
|
832
|
+
additionalPermissions: async ({ input }) => {
|
|
833
|
+
const contentTypes = await getContentTypesWithCounts();
|
|
834
|
+
contentTypeListSnapshots.set(input, contentTypes);
|
|
835
|
+
return contentTypes.map(
|
|
836
|
+
(contentType) => permissions.cmsPermissions.record.read(collectionFacts(contentType.slug))
|
|
837
|
+
);
|
|
838
|
+
},
|
|
839
|
+
execute: ({ input }) => {
|
|
840
|
+
const contentTypes = contentTypeListSnapshots.get(input);
|
|
841
|
+
contentTypeListSnapshots.delete(input);
|
|
842
|
+
if (!contentTypes) {
|
|
843
|
+
throw new CMSOperationError(
|
|
844
|
+
500,
|
|
845
|
+
"Content-type catalog authorization snapshot is unavailable.",
|
|
846
|
+
"AUTHORIZATION_SNAPSHOT_MISSING"
|
|
847
|
+
);
|
|
848
|
+
}
|
|
849
|
+
return contentTypes.map((contentType) => ({
|
|
850
|
+
...operationContentType(contentType),
|
|
851
|
+
itemCount: contentType.itemCount
|
|
852
|
+
}));
|
|
853
|
+
},
|
|
854
|
+
onError: ({ error, ...context }) => notifyError(error, "list", genericContext(context))
|
|
855
|
+
});
|
|
856
|
+
const getContentTypeBySlug = api.defineOperation({
|
|
857
|
+
input: GetContentTypeInputSchema,
|
|
858
|
+
permission: permissions.cmsPermissions.contentType.read,
|
|
859
|
+
facts: async ({ input }) => {
|
|
860
|
+
const contentType = await getContentTypeOrThrow(
|
|
861
|
+
adapter,
|
|
862
|
+
ensureSynced,
|
|
863
|
+
input.slug
|
|
864
|
+
);
|
|
865
|
+
return { contentType: contentType.slug };
|
|
866
|
+
},
|
|
867
|
+
execute: async ({ facts }) => operationContentType(
|
|
868
|
+
getters.serializeContentType(
|
|
869
|
+
await getContentTypeOrThrow(
|
|
870
|
+
adapter,
|
|
871
|
+
ensureSynced,
|
|
872
|
+
facts.contentType ?? ""
|
|
873
|
+
)
|
|
874
|
+
)
|
|
875
|
+
),
|
|
876
|
+
onError: ({ error, ...context }) => notifyError(
|
|
877
|
+
error,
|
|
878
|
+
"get",
|
|
879
|
+
genericContext(context, context.facts.contentType)
|
|
880
|
+
)
|
|
881
|
+
});
|
|
882
|
+
const listContentItems = api.defineOperation({
|
|
883
|
+
input: ListContentItemsInputSchema,
|
|
884
|
+
permission: permissions.cmsPermissions.record.read,
|
|
885
|
+
facts: async ({ input }) => {
|
|
886
|
+
const contentType = await getContentTypeOrThrow(
|
|
887
|
+
adapter,
|
|
888
|
+
ensureSynced,
|
|
889
|
+
input.typeSlug
|
|
890
|
+
);
|
|
891
|
+
if (!input.query.slug) {
|
|
892
|
+
return collectionFacts(contentType.slug);
|
|
893
|
+
}
|
|
894
|
+
const item = await getters.getContentItemBySlug(
|
|
895
|
+
adapter,
|
|
896
|
+
contentType.slug,
|
|
897
|
+
input.query.slug
|
|
898
|
+
);
|
|
899
|
+
const facts = {
|
|
900
|
+
contentType: contentType.slug,
|
|
901
|
+
scope: "record",
|
|
902
|
+
...item ? {
|
|
903
|
+
recordId: item.id,
|
|
904
|
+
...item.authorId ? { authorId: item.authorId } : {}
|
|
905
|
+
} : {}
|
|
906
|
+
};
|
|
907
|
+
return facts;
|
|
908
|
+
},
|
|
909
|
+
additionalPermissions: ({ facts }) => [
|
|
910
|
+
permissions.cmsPermissions.contentType.read({ contentType: facts.contentType })
|
|
911
|
+
],
|
|
912
|
+
execute: async ({ input, facts }) => {
|
|
913
|
+
if (input.query.fields !== void 0) {
|
|
914
|
+
validateOperationContentReadOptions(
|
|
915
|
+
await getContentTypeOrThrow(adapter, ensureSynced, facts.contentType),
|
|
916
|
+
input.query
|
|
917
|
+
);
|
|
918
|
+
}
|
|
919
|
+
const result = await getters.getAllContentItems(adapter, facts.contentType, {
|
|
920
|
+
...input.query,
|
|
921
|
+
view: void 0,
|
|
922
|
+
fields: void 0
|
|
923
|
+
});
|
|
924
|
+
if (facts.scope === "record") {
|
|
925
|
+
if (!facts.recordId) {
|
|
926
|
+
if (result.items.length > 0) throw staleRecordError();
|
|
927
|
+
if (await getters.getContentItemBySlug(
|
|
928
|
+
adapter,
|
|
929
|
+
facts.contentType,
|
|
930
|
+
input.query.slug ?? ""
|
|
931
|
+
)) {
|
|
932
|
+
throw staleRecordError();
|
|
933
|
+
}
|
|
934
|
+
return operationContentList(result, input.query);
|
|
935
|
+
}
|
|
936
|
+
if (result.items.length > 1) throw staleRecordError();
|
|
937
|
+
if (result.items.length === 0) {
|
|
938
|
+
const current = await getters.getContentItemBySlug(
|
|
939
|
+
adapter,
|
|
940
|
+
facts.contentType,
|
|
941
|
+
input.query.slug ?? ""
|
|
942
|
+
);
|
|
943
|
+
if (!current) throw staleRecordError();
|
|
944
|
+
assertRecordFacts(current, facts);
|
|
945
|
+
} else {
|
|
946
|
+
assertRecordFacts(result.items[0], facts);
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
return operationContentList(result, input.query);
|
|
950
|
+
},
|
|
951
|
+
onError: ({ error, ...context }) => notifyError(
|
|
952
|
+
error,
|
|
953
|
+
"list",
|
|
954
|
+
genericContext(context, context.facts.contentType)
|
|
955
|
+
)
|
|
956
|
+
});
|
|
957
|
+
const getContentItem = api.defineOperation({
|
|
958
|
+
input: GetContentItemInputSchema,
|
|
959
|
+
permission: permissions.cmsPermissions.record.read,
|
|
960
|
+
facts: async ({ input }) => recordFacts(
|
|
961
|
+
await getRecordOrThrow(adapter, ensureSynced, input.typeSlug, input.id)
|
|
962
|
+
),
|
|
963
|
+
additionalPermissions: ({ facts }) => [
|
|
964
|
+
permissions.cmsPermissions.contentType.read({ contentType: facts.contentType })
|
|
965
|
+
],
|
|
966
|
+
execute: async ({ input, facts }) => {
|
|
967
|
+
const item = await getRecordOrThrow(
|
|
968
|
+
adapter,
|
|
969
|
+
ensureSynced,
|
|
970
|
+
facts.contentType,
|
|
971
|
+
facts.recordId ?? ""
|
|
972
|
+
);
|
|
973
|
+
assertRecordFacts(item, facts);
|
|
974
|
+
validateOperationContentReadOptions(item.contentType, input);
|
|
975
|
+
return getters.applyContentReadOptions(
|
|
976
|
+
operationContentItem(getters.serializeContentItemWithType(item)),
|
|
977
|
+
input
|
|
978
|
+
);
|
|
979
|
+
},
|
|
980
|
+
onError: ({ error, ...context }) => notifyError(
|
|
981
|
+
error,
|
|
982
|
+
"get",
|
|
983
|
+
genericContext(context, context.facts.contentType)
|
|
984
|
+
)
|
|
985
|
+
});
|
|
986
|
+
const createContentItem = api.defineOperation({
|
|
987
|
+
input: CreateContentItemInputSchema,
|
|
988
|
+
permission: permissions.cmsPermissions.record.create,
|
|
989
|
+
facts: async ({ input }) => ({
|
|
990
|
+
contentType: (await getContentTypeOrThrow(adapter, ensureSynced, input.typeSlug)).slug
|
|
991
|
+
}),
|
|
992
|
+
additionalPermissions: async ({ input, facts }) => {
|
|
993
|
+
const authorization = await deriveRelationWriteAuthorization(
|
|
994
|
+
adapter,
|
|
995
|
+
await getContentTypeOrThrow(adapter, ensureSynced, facts.contentType),
|
|
996
|
+
input.body.data,
|
|
997
|
+
ensureSynced
|
|
998
|
+
);
|
|
999
|
+
relationWriteAuthorizationSnapshots.set(input, authorization);
|
|
1000
|
+
return [
|
|
1001
|
+
permissions.cmsPermissions.contentType.read({ contentType: facts.contentType }),
|
|
1002
|
+
...authorization.permissions
|
|
1003
|
+
];
|
|
1004
|
+
},
|
|
1005
|
+
execute: async (context) => {
|
|
1006
|
+
const lifecycleContext = createContext(context);
|
|
1007
|
+
const contentType = await getContentTypeOrThrow(
|
|
1008
|
+
adapter,
|
|
1009
|
+
ensureSynced,
|
|
1010
|
+
context.facts.contentType
|
|
1011
|
+
);
|
|
1012
|
+
const slug = utils.slugify(context.input.body.slug);
|
|
1013
|
+
if (!slug) {
|
|
1014
|
+
throw new CMSOperationError(
|
|
1015
|
+
400,
|
|
1016
|
+
"Invalid slug: must contain at least one alphanumeric character",
|
|
1017
|
+
"INVALID_SLUG"
|
|
1018
|
+
);
|
|
1019
|
+
}
|
|
1020
|
+
const relationAuthorization = await recheckRelationWriteAuthorization(
|
|
1021
|
+
context.input,
|
|
1022
|
+
contentType.slug,
|
|
1023
|
+
context.input.body.data
|
|
1024
|
+
);
|
|
1025
|
+
const relationPlan = await planRelationsInData(
|
|
1026
|
+
adapter,
|
|
1027
|
+
contentType,
|
|
1028
|
+
context.input.body.data,
|
|
1029
|
+
ensureSynced,
|
|
1030
|
+
context.identity?.id
|
|
1031
|
+
);
|
|
1032
|
+
const validation = getContentTypeZodSchema(contentType).safeParse(
|
|
1033
|
+
relationPlan.processedData
|
|
1034
|
+
);
|
|
1035
|
+
if (!validation.success) {
|
|
1036
|
+
throw new CMSOperationError(
|
|
1037
|
+
400,
|
|
1038
|
+
"Validation failed",
|
|
1039
|
+
"RECORD_VALIDATION_FAILED",
|
|
1040
|
+
validation.error.issues
|
|
1041
|
+
);
|
|
1042
|
+
}
|
|
1043
|
+
const existing = await adapter.findOne({
|
|
1044
|
+
model: "contentItem",
|
|
1045
|
+
where: [
|
|
1046
|
+
{
|
|
1047
|
+
field: "contentTypeId",
|
|
1048
|
+
value: contentType.id,
|
|
1049
|
+
operator: "eq"
|
|
1050
|
+
},
|
|
1051
|
+
{ field: "slug", value: slug, operator: "eq" }
|
|
1052
|
+
]
|
|
1053
|
+
});
|
|
1054
|
+
if (existing) {
|
|
1055
|
+
throw new CMSOperationError(
|
|
1056
|
+
409,
|
|
1057
|
+
"Content item with this slug already exists",
|
|
1058
|
+
"DUPLICATE_SLUG"
|
|
1059
|
+
);
|
|
1060
|
+
}
|
|
1061
|
+
const validatedData = validation.data;
|
|
1062
|
+
await runBeforeHook(
|
|
1063
|
+
() => hooks?.onBeforeCreateContent?.(validatedData, lifecycleContext),
|
|
1064
|
+
"Create operation denied"
|
|
1065
|
+
);
|
|
1066
|
+
return adapter.transaction(async (tx) => {
|
|
1067
|
+
await assertCurrentRelationWriteAuthorization(
|
|
1068
|
+
tx,
|
|
1069
|
+
relationAuthorization,
|
|
1070
|
+
contentType.slug,
|
|
1071
|
+
context.input.body.data
|
|
1072
|
+
);
|
|
1073
|
+
const currentDuplicate = await tx.findOne({
|
|
1074
|
+
model: "contentItem",
|
|
1075
|
+
where: [
|
|
1076
|
+
{
|
|
1077
|
+
field: "contentTypeId",
|
|
1078
|
+
value: contentType.id,
|
|
1079
|
+
operator: "eq"
|
|
1080
|
+
},
|
|
1081
|
+
{ field: "slug", value: slug, operator: "eq" }
|
|
1082
|
+
]
|
|
1083
|
+
});
|
|
1084
|
+
if (currentDuplicate) {
|
|
1085
|
+
throw new CMSOperationError(
|
|
1086
|
+
409,
|
|
1087
|
+
"Content item with this slug already exists",
|
|
1088
|
+
"DUPLICATE_SLUG"
|
|
1089
|
+
);
|
|
1090
|
+
}
|
|
1091
|
+
await applyRelationWritePlan(tx, relationPlan);
|
|
1092
|
+
const item = await tx.create({
|
|
1093
|
+
model: "contentItem",
|
|
1094
|
+
data: {
|
|
1095
|
+
contentTypeId: contentType.id,
|
|
1096
|
+
slug,
|
|
1097
|
+
data: JSON.stringify(validatedData),
|
|
1098
|
+
...context.identity ? { authorId: context.identity.id } : {},
|
|
1099
|
+
createdAt: /* @__PURE__ */ new Date(),
|
|
1100
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
1101
|
+
}
|
|
1102
|
+
});
|
|
1103
|
+
await relations.syncRelations(tx, item.id, relationPlan.relationIds);
|
|
1104
|
+
const joined = await getRecordOrThrow(
|
|
1105
|
+
tx,
|
|
1106
|
+
ensureSynced,
|
|
1107
|
+
contentType.slug,
|
|
1108
|
+
item.id
|
|
1109
|
+
);
|
|
1110
|
+
return operationContentItem(getters.serializeContentItemWithType(joined));
|
|
1111
|
+
});
|
|
1112
|
+
},
|
|
1113
|
+
after: async (context) => {
|
|
1114
|
+
const base = createContext(context);
|
|
1115
|
+
const afterCreateContent = hooks?.onAfterCreateContent;
|
|
1116
|
+
await afterCreateContent?.(
|
|
1117
|
+
context.result,
|
|
1118
|
+
Object.freeze({ ...base, result: context.result })
|
|
1119
|
+
);
|
|
1120
|
+
},
|
|
1121
|
+
onError: async ({ error, ...context }) => {
|
|
1122
|
+
await notifyError(error, "create", createContext(context));
|
|
1123
|
+
}
|
|
1124
|
+
});
|
|
1125
|
+
const updateContentItem = api.defineOperation({
|
|
1126
|
+
input: UpdateContentItemInputSchema,
|
|
1127
|
+
permission: permissions.cmsPermissions.record.update,
|
|
1128
|
+
facts: async ({ input }) => {
|
|
1129
|
+
const item = await getRecordOrThrow(
|
|
1130
|
+
adapter,
|
|
1131
|
+
ensureSynced,
|
|
1132
|
+
input.typeSlug,
|
|
1133
|
+
input.id
|
|
1134
|
+
);
|
|
1135
|
+
const facts = recordFacts(item);
|
|
1136
|
+
return {
|
|
1137
|
+
contentType: facts.contentType,
|
|
1138
|
+
recordId: facts.recordId ?? input.id,
|
|
1139
|
+
...facts.authorId ? { authorId: facts.authorId } : {}
|
|
1140
|
+
};
|
|
1141
|
+
},
|
|
1142
|
+
additionalPermissions: async ({ input, facts }) => {
|
|
1143
|
+
const contentTypePermission = permissions.cmsPermissions.contentType.read({
|
|
1144
|
+
contentType: facts.contentType
|
|
1145
|
+
});
|
|
1146
|
+
if (!input.body.data) return [contentTypePermission];
|
|
1147
|
+
const authorization = await deriveRelationWriteAuthorization(
|
|
1148
|
+
adapter,
|
|
1149
|
+
await getContentTypeOrThrow(adapter, ensureSynced, facts.contentType),
|
|
1150
|
+
input.body.data,
|
|
1151
|
+
ensureSynced
|
|
1152
|
+
);
|
|
1153
|
+
relationWriteAuthorizationSnapshots.set(input, authorization);
|
|
1154
|
+
return [contentTypePermission, ...authorization.permissions];
|
|
1155
|
+
},
|
|
1156
|
+
execute: async (context) => {
|
|
1157
|
+
const lifecycleContext = updateContext(context);
|
|
1158
|
+
const contentType = await getContentTypeOrThrow(
|
|
1159
|
+
adapter,
|
|
1160
|
+
ensureSynced,
|
|
1161
|
+
context.facts.contentType
|
|
1162
|
+
);
|
|
1163
|
+
const rawSlug = context.input.body.slug;
|
|
1164
|
+
const slug = rawSlug ? utils.slugify(rawSlug) : void 0;
|
|
1165
|
+
if (rawSlug && !slug) {
|
|
1166
|
+
throw new CMSOperationError(
|
|
1167
|
+
400,
|
|
1168
|
+
"Invalid slug: must contain at least one alphanumeric character",
|
|
1169
|
+
"INVALID_SLUG"
|
|
1170
|
+
);
|
|
1171
|
+
}
|
|
1172
|
+
const bodyData = context.input.body.data;
|
|
1173
|
+
const relationAuthorization = bodyData ? await recheckRelationWriteAuthorization(
|
|
1174
|
+
context.input,
|
|
1175
|
+
contentType.slug,
|
|
1176
|
+
bodyData
|
|
1177
|
+
) : void 0;
|
|
1178
|
+
const authorizedRecord = await getRecordOrThrow(
|
|
1179
|
+
adapter,
|
|
1180
|
+
ensureSynced,
|
|
1181
|
+
context.facts.contentType,
|
|
1182
|
+
context.facts.recordId
|
|
1183
|
+
);
|
|
1184
|
+
assertRecordFacts(authorizedRecord, context.facts);
|
|
1185
|
+
if (slug && slug !== authorizedRecord.slug) {
|
|
1186
|
+
const duplicate = await adapter.findOne({
|
|
1187
|
+
model: "contentItem",
|
|
1188
|
+
where: [
|
|
1189
|
+
{
|
|
1190
|
+
field: "contentTypeId",
|
|
1191
|
+
value: contentType.id,
|
|
1192
|
+
operator: "eq"
|
|
1193
|
+
},
|
|
1194
|
+
{ field: "slug", value: slug, operator: "eq" }
|
|
1195
|
+
]
|
|
1196
|
+
});
|
|
1197
|
+
if (duplicate) {
|
|
1198
|
+
throw new CMSOperationError(
|
|
1199
|
+
409,
|
|
1200
|
+
"Content item with this slug already exists",
|
|
1201
|
+
"DUPLICATE_SLUG"
|
|
1202
|
+
);
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
const relationPlan = bodyData ? await planRelationsInData(
|
|
1206
|
+
adapter,
|
|
1207
|
+
contentType,
|
|
1208
|
+
bodyData,
|
|
1209
|
+
ensureSynced,
|
|
1210
|
+
context.identity?.id
|
|
1211
|
+
) : void 0;
|
|
1212
|
+
const currentData = authorizedRecord.data ? JSON.parse(authorizedRecord.data) : {};
|
|
1213
|
+
const validation = getContentTypeZodSchema(contentType).safeParse({
|
|
1214
|
+
...currentData,
|
|
1215
|
+
...relationPlan?.processedData ?? {}
|
|
1216
|
+
});
|
|
1217
|
+
if (!validation.success) {
|
|
1218
|
+
throw new CMSOperationError(
|
|
1219
|
+
400,
|
|
1220
|
+
"Validation failed",
|
|
1221
|
+
"RECORD_VALIDATION_FAILED",
|
|
1222
|
+
validation.error.issues
|
|
1223
|
+
);
|
|
1224
|
+
}
|
|
1225
|
+
const mergedData = validation.data;
|
|
1226
|
+
await runBeforeHook(
|
|
1227
|
+
() => hooks?.onBeforeUpdateContent?.(
|
|
1228
|
+
context.facts.recordId,
|
|
1229
|
+
mergedData,
|
|
1230
|
+
lifecycleContext
|
|
1231
|
+
),
|
|
1232
|
+
"Update operation denied"
|
|
1233
|
+
);
|
|
1234
|
+
return adapter.transaction(async (tx) => {
|
|
1235
|
+
if (bodyData && relationAuthorization) {
|
|
1236
|
+
await assertCurrentRelationWriteAuthorization(
|
|
1237
|
+
tx,
|
|
1238
|
+
relationAuthorization,
|
|
1239
|
+
contentType.slug,
|
|
1240
|
+
bodyData
|
|
1241
|
+
);
|
|
1242
|
+
}
|
|
1243
|
+
const current = await getRecordOrThrow(
|
|
1244
|
+
tx,
|
|
1245
|
+
ensureSynced,
|
|
1246
|
+
context.facts.contentType,
|
|
1247
|
+
context.facts.recordId
|
|
1248
|
+
);
|
|
1249
|
+
assertRecordFacts(current, context.facts);
|
|
1250
|
+
assertRecordSnapshot(current, authorizedRecord);
|
|
1251
|
+
if (slug && slug !== current.slug) {
|
|
1252
|
+
const duplicate = await tx.findOne({
|
|
1253
|
+
model: "contentItem",
|
|
1254
|
+
where: [
|
|
1255
|
+
{
|
|
1256
|
+
field: "contentTypeId",
|
|
1257
|
+
value: contentType.id,
|
|
1258
|
+
operator: "eq"
|
|
1259
|
+
},
|
|
1260
|
+
{ field: "slug", value: slug, operator: "eq" }
|
|
1261
|
+
]
|
|
1262
|
+
});
|
|
1263
|
+
if (duplicate) {
|
|
1264
|
+
throw new CMSOperationError(
|
|
1265
|
+
409,
|
|
1266
|
+
"Content item with this slug already exists",
|
|
1267
|
+
"DUPLICATE_SLUG"
|
|
1268
|
+
);
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
if (relationPlan) await applyRelationWritePlan(tx, relationPlan);
|
|
1272
|
+
const update = { updatedAt: /* @__PURE__ */ new Date() };
|
|
1273
|
+
if (slug) update.slug = slug;
|
|
1274
|
+
if (bodyData) update.data = JSON.stringify(mergedData);
|
|
1275
|
+
const matched = await tx.updateMany({
|
|
1276
|
+
model: "contentItem",
|
|
1277
|
+
where: [
|
|
1278
|
+
{
|
|
1279
|
+
field: "id",
|
|
1280
|
+
value: context.facts.recordId,
|
|
1281
|
+
operator: "eq"
|
|
1282
|
+
},
|
|
1283
|
+
{
|
|
1284
|
+
field: "contentTypeId",
|
|
1285
|
+
value: contentType.id,
|
|
1286
|
+
operator: "eq"
|
|
1287
|
+
},
|
|
1288
|
+
{
|
|
1289
|
+
field: "authorId",
|
|
1290
|
+
value: context.facts.authorId ?? null,
|
|
1291
|
+
operator: "eq"
|
|
1292
|
+
},
|
|
1293
|
+
{
|
|
1294
|
+
field: "updatedAt",
|
|
1295
|
+
value: current.updatedAt,
|
|
1296
|
+
operator: "eq"
|
|
1297
|
+
}
|
|
1298
|
+
],
|
|
1299
|
+
update
|
|
1300
|
+
});
|
|
1301
|
+
if (!didUpdateContentItem(matched, context.facts.recordId)) {
|
|
1302
|
+
throw staleRecordError();
|
|
1303
|
+
}
|
|
1304
|
+
if (relationPlan) {
|
|
1305
|
+
await relations.syncRelations(
|
|
1306
|
+
tx,
|
|
1307
|
+
context.facts.recordId,
|
|
1308
|
+
relationPlan.relationIds
|
|
1309
|
+
);
|
|
1310
|
+
}
|
|
1311
|
+
const updated = await getRecordOrThrow(
|
|
1312
|
+
tx,
|
|
1313
|
+
ensureSynced,
|
|
1314
|
+
context.facts.contentType,
|
|
1315
|
+
context.facts.recordId
|
|
1316
|
+
);
|
|
1317
|
+
return operationContentItem(getters.serializeContentItemWithType(updated));
|
|
1318
|
+
});
|
|
1319
|
+
},
|
|
1320
|
+
after: async (context) => {
|
|
1321
|
+
const base = updateContext(context);
|
|
1322
|
+
const afterUpdateContent = hooks?.onAfterUpdateContent;
|
|
1323
|
+
await afterUpdateContent?.(
|
|
1324
|
+
context.result,
|
|
1325
|
+
Object.freeze({ ...base, result: context.result })
|
|
1326
|
+
);
|
|
1327
|
+
},
|
|
1328
|
+
onError: async ({ error, ...context }) => {
|
|
1329
|
+
await notifyError(error, "update", updateContext(context));
|
|
1330
|
+
}
|
|
1331
|
+
});
|
|
1332
|
+
const deleteContentItem = api.defineOperation({
|
|
1333
|
+
input: DeleteContentItemInputSchema,
|
|
1334
|
+
permission: permissions.cmsPermissions.record.delete,
|
|
1335
|
+
facts: async ({ input }) => {
|
|
1336
|
+
const item = await getRecordOrThrow(
|
|
1337
|
+
adapter,
|
|
1338
|
+
ensureSynced,
|
|
1339
|
+
input.typeSlug,
|
|
1340
|
+
input.id
|
|
1341
|
+
);
|
|
1342
|
+
const facts = recordFacts(item);
|
|
1343
|
+
return {
|
|
1344
|
+
contentType: facts.contentType,
|
|
1345
|
+
recordId: facts.recordId ?? input.id,
|
|
1346
|
+
...facts.authorId ? { authorId: facts.authorId } : {}
|
|
1347
|
+
};
|
|
1348
|
+
},
|
|
1349
|
+
before: async (context) => {
|
|
1350
|
+
const current = await getRecordOrThrow(
|
|
1351
|
+
adapter,
|
|
1352
|
+
ensureSynced,
|
|
1353
|
+
context.facts.contentType,
|
|
1354
|
+
context.facts.recordId
|
|
1355
|
+
);
|
|
1356
|
+
assertRecordFacts(current, context.facts);
|
|
1357
|
+
await runBeforeHook(
|
|
1358
|
+
() => hooks?.onBeforeDeleteContent?.(
|
|
1359
|
+
context.facts.recordId,
|
|
1360
|
+
deleteContext(context)
|
|
1361
|
+
),
|
|
1362
|
+
"Delete operation denied"
|
|
1363
|
+
);
|
|
1364
|
+
},
|
|
1365
|
+
execute: async ({ facts }) => adapter.transaction(async (tx) => {
|
|
1366
|
+
const existing = await getRecordOrThrow(
|
|
1367
|
+
tx,
|
|
1368
|
+
ensureSynced,
|
|
1369
|
+
facts.contentType,
|
|
1370
|
+
facts.recordId
|
|
1371
|
+
);
|
|
1372
|
+
assertRecordFacts(existing, facts);
|
|
1373
|
+
const matched = await tx.updateMany({
|
|
1374
|
+
model: "contentItem",
|
|
1375
|
+
where: [
|
|
1376
|
+
{ field: "id", value: facts.recordId, operator: "eq" },
|
|
1377
|
+
{
|
|
1378
|
+
field: "contentTypeId",
|
|
1379
|
+
value: existing.contentTypeId,
|
|
1380
|
+
operator: "eq"
|
|
1381
|
+
},
|
|
1382
|
+
{
|
|
1383
|
+
field: "authorId",
|
|
1384
|
+
value: facts.authorId ?? null,
|
|
1385
|
+
operator: "eq"
|
|
1386
|
+
},
|
|
1387
|
+
{
|
|
1388
|
+
field: "updatedAt",
|
|
1389
|
+
value: existing.updatedAt,
|
|
1390
|
+
operator: "eq"
|
|
1391
|
+
}
|
|
1392
|
+
],
|
|
1393
|
+
update: { updatedAt: /* @__PURE__ */ new Date() }
|
|
1394
|
+
});
|
|
1395
|
+
if (!didUpdateContentItem(matched, facts.recordId)) {
|
|
1396
|
+
throw staleRecordError();
|
|
1397
|
+
}
|
|
1398
|
+
await tx.delete({
|
|
1399
|
+
model: "contentItem",
|
|
1400
|
+
where: [
|
|
1401
|
+
{ field: "id", value: facts.recordId, operator: "eq" },
|
|
1402
|
+
{
|
|
1403
|
+
field: "contentTypeId",
|
|
1404
|
+
value: existing.contentTypeId,
|
|
1405
|
+
operator: "eq"
|
|
1406
|
+
}
|
|
1407
|
+
]
|
|
1408
|
+
});
|
|
1409
|
+
return { success: true };
|
|
1410
|
+
}),
|
|
1411
|
+
after: async (context) => {
|
|
1412
|
+
const base = deleteContext(context);
|
|
1413
|
+
await hooks?.onAfterDeleteContent?.(
|
|
1414
|
+
context.facts.recordId,
|
|
1415
|
+
Object.freeze({ ...base, result: context.result })
|
|
1416
|
+
);
|
|
1417
|
+
},
|
|
1418
|
+
onError: async ({ error, ...context }) => {
|
|
1419
|
+
await notifyError(error, "delete", deleteContext(context));
|
|
1420
|
+
}
|
|
1421
|
+
});
|
|
1422
|
+
const getContentItemPopulated = api.defineOperation({
|
|
1423
|
+
input: CMSContentItemParamsSchema,
|
|
1424
|
+
permission: permissions.cmsPermissions.record.read,
|
|
1425
|
+
facts: async ({ input }) => recordFacts(
|
|
1426
|
+
await getRecordOrThrow(adapter, ensureSynced, input.typeSlug, input.id)
|
|
1427
|
+
),
|
|
1428
|
+
additionalPermissions: async ({ input, facts }) => {
|
|
1429
|
+
const snapshot = await getPopulatedRelationSnapshot(
|
|
1430
|
+
adapter,
|
|
1431
|
+
facts.recordId ?? ""
|
|
1432
|
+
);
|
|
1433
|
+
populatedRelationSnapshots.set(input, snapshot);
|
|
1434
|
+
const contentTypes = /* @__PURE__ */ new Set([
|
|
1435
|
+
facts.contentType,
|
|
1436
|
+
...snapshot.targets.map(
|
|
1437
|
+
({ facts: targetFacts }) => targetFacts.contentType
|
|
1438
|
+
)
|
|
1439
|
+
]);
|
|
1440
|
+
return [
|
|
1441
|
+
...[...contentTypes].map(
|
|
1442
|
+
(contentType) => permissions.cmsPermissions.contentType.read({ contentType })
|
|
1443
|
+
),
|
|
1444
|
+
...snapshot.targets.map(
|
|
1445
|
+
({ facts: targetFacts }) => permissions.cmsPermissions.record.read(targetFacts)
|
|
1446
|
+
)
|
|
1447
|
+
];
|
|
1448
|
+
},
|
|
1449
|
+
execute: async ({ input, facts }) => {
|
|
1450
|
+
try {
|
|
1451
|
+
const item = await getRecordOrThrow(
|
|
1452
|
+
adapter,
|
|
1453
|
+
ensureSynced,
|
|
1454
|
+
facts.contentType,
|
|
1455
|
+
facts.recordId ?? ""
|
|
1456
|
+
);
|
|
1457
|
+
assertRecordFacts(item, facts);
|
|
1458
|
+
const authorizedSnapshot = populatedRelationSnapshots.get(
|
|
1459
|
+
input
|
|
1460
|
+
);
|
|
1461
|
+
if (!authorizedSnapshot) {
|
|
1462
|
+
throw new CMSOperationError(
|
|
1463
|
+
500,
|
|
1464
|
+
"Related-content authorization snapshot is unavailable.",
|
|
1465
|
+
"AUTHORIZATION_SNAPSHOT_MISSING"
|
|
1466
|
+
);
|
|
1467
|
+
}
|
|
1468
|
+
const currentSnapshot = await getPopulatedRelationSnapshot(
|
|
1469
|
+
adapter,
|
|
1470
|
+
facts.recordId ?? ""
|
|
1471
|
+
);
|
|
1472
|
+
assertPopulatedRelationSnapshot(authorizedSnapshot, currentSnapshot);
|
|
1473
|
+
const relations = populatedRelationsFromSnapshot(currentSnapshot);
|
|
1474
|
+
return {
|
|
1475
|
+
...operationContentItem(getters.serializeContentItemWithType(item)),
|
|
1476
|
+
_relations: Object.fromEntries(
|
|
1477
|
+
Object.entries(relations).map(([field, records]) => [
|
|
1478
|
+
field,
|
|
1479
|
+
records.map(operationContentItem)
|
|
1480
|
+
])
|
|
1481
|
+
)
|
|
1482
|
+
};
|
|
1483
|
+
} finally {
|
|
1484
|
+
populatedRelationSnapshots.delete(input);
|
|
1485
|
+
}
|
|
1486
|
+
},
|
|
1487
|
+
onError: ({ error, ...context }) => notifyError(
|
|
1488
|
+
error,
|
|
1489
|
+
"get",
|
|
1490
|
+
genericContext(context, context.facts.contentType)
|
|
1491
|
+
)
|
|
1492
|
+
});
|
|
1493
|
+
const listContentByRelation = api.defineOperation({
|
|
1494
|
+
input: ListContentByRelationInputSchema,
|
|
1495
|
+
permission: permissions.cmsPermissions.record.read,
|
|
1496
|
+
facts: async ({ input }) => collectionFacts(
|
|
1497
|
+
(await getContentTypeOrThrow(adapter, ensureSynced, input.typeSlug)).slug
|
|
1498
|
+
),
|
|
1499
|
+
additionalPermissions: async ({ input, facts }) => {
|
|
1500
|
+
const sourceContentType = await getContentTypeOrThrow(
|
|
1501
|
+
adapter,
|
|
1502
|
+
ensureSynced,
|
|
1503
|
+
facts.contentType
|
|
1504
|
+
);
|
|
1505
|
+
const authorization = await deriveRelationFilterAuthorization(
|
|
1506
|
+
adapter,
|
|
1507
|
+
ensureSynced,
|
|
1508
|
+
sourceContentType,
|
|
1509
|
+
input.query.field,
|
|
1510
|
+
input.query.targetId
|
|
1511
|
+
);
|
|
1512
|
+
relationFilterAuthorizationSnapshots.set(input, authorization);
|
|
1513
|
+
return [
|
|
1514
|
+
permissions.cmsPermissions.contentType.read({ contentType: facts.contentType }),
|
|
1515
|
+
authorization.permission
|
|
1516
|
+
];
|
|
1517
|
+
},
|
|
1518
|
+
execute: async ({ input, facts }) => {
|
|
1519
|
+
const sourceContentType = await recheckRelationFilterAuthorization(
|
|
1520
|
+
input,
|
|
1521
|
+
facts.contentType,
|
|
1522
|
+
input.query.field,
|
|
1523
|
+
input.query.targetId
|
|
1524
|
+
);
|
|
1525
|
+
validateOperationContentReadOptions(sourceContentType, input.query);
|
|
1526
|
+
const result = await getters.getContentItemsByRelationForType(
|
|
1527
|
+
adapter,
|
|
1528
|
+
sourceContentType,
|
|
1529
|
+
input.query
|
|
1530
|
+
);
|
|
1531
|
+
return {
|
|
1532
|
+
...result,
|
|
1533
|
+
items: result.items.map(
|
|
1534
|
+
(item) => getters.applyContentReadOptions(operationContentItem(item), input.query)
|
|
1535
|
+
)
|
|
1536
|
+
};
|
|
1537
|
+
},
|
|
1538
|
+
onError: ({ error, ...context }) => notifyError(
|
|
1539
|
+
error,
|
|
1540
|
+
"list",
|
|
1541
|
+
genericContext(context, context.facts.contentType)
|
|
1542
|
+
)
|
|
1543
|
+
});
|
|
1544
|
+
const getInverseRelations = api.defineOperation({
|
|
1545
|
+
input: GetInverseRelationsInputSchema,
|
|
1546
|
+
permission: permissions.cmsPermissions.contentType.read,
|
|
1547
|
+
facts: async ({ input }) => ({
|
|
1548
|
+
contentType: (await getContentTypeOrThrow(adapter, ensureSynced, input.slug)).slug
|
|
1549
|
+
}),
|
|
1550
|
+
additionalPermissions: async ({ input, facts }) => {
|
|
1551
|
+
const authorization = await deriveInverseSourceAuthorization(
|
|
1552
|
+
adapter,
|
|
1553
|
+
ensureSynced,
|
|
1554
|
+
facts.contentType ?? ""
|
|
1555
|
+
);
|
|
1556
|
+
inverseSourceAuthorizationSnapshots.set(
|
|
1557
|
+
input,
|
|
1558
|
+
authorization.snapshot
|
|
1559
|
+
);
|
|
1560
|
+
const permissions$1 = [...authorization.permissions];
|
|
1561
|
+
if (input.query.itemId) {
|
|
1562
|
+
const targetFacts = recordFacts(
|
|
1563
|
+
await getRecordOrThrow(
|
|
1564
|
+
adapter,
|
|
1565
|
+
ensureSynced,
|
|
1566
|
+
facts.contentType ?? "",
|
|
1567
|
+
input.query.itemId
|
|
1568
|
+
)
|
|
1569
|
+
);
|
|
1570
|
+
inverseTargetAuthorizationSnapshots.set(input, targetFacts);
|
|
1571
|
+
permissions$1.push(permissions.cmsPermissions.record.read(targetFacts));
|
|
1572
|
+
const authorizedSourceTypes = /* @__PURE__ */ new Set();
|
|
1573
|
+
for (const source of authorization.sources) {
|
|
1574
|
+
if (authorizedSourceTypes.has(source.contentTypeSlug)) continue;
|
|
1575
|
+
authorizedSourceTypes.add(source.contentTypeSlug);
|
|
1576
|
+
permissions$1.push(
|
|
1577
|
+
permissions.cmsPermissions.record.read(collectionFacts(source.contentTypeSlug))
|
|
1578
|
+
);
|
|
1579
|
+
}
|
|
1580
|
+
}
|
|
1581
|
+
return permissions$1;
|
|
1582
|
+
},
|
|
1583
|
+
execute: async ({ input, facts }) => {
|
|
1584
|
+
try {
|
|
1585
|
+
const sources = await recheckInverseSourceAuthorization(
|
|
1586
|
+
input,
|
|
1587
|
+
facts.contentType ?? ""
|
|
1588
|
+
);
|
|
1589
|
+
if (input.query.itemId) {
|
|
1590
|
+
await recheckInverseTargetAuthorization(
|
|
1591
|
+
input,
|
|
1592
|
+
facts.contentType ?? "",
|
|
1593
|
+
input.query.itemId
|
|
1594
|
+
);
|
|
1595
|
+
}
|
|
1596
|
+
const inverseRelations = [];
|
|
1597
|
+
for (const source of sources) {
|
|
1598
|
+
let count = 0;
|
|
1599
|
+
if (input.query.itemId) {
|
|
1600
|
+
const relations = await adapter.findMany({
|
|
1601
|
+
model: "contentRelation",
|
|
1602
|
+
where: [
|
|
1603
|
+
{
|
|
1604
|
+
field: "targetId",
|
|
1605
|
+
value: input.query.itemId,
|
|
1606
|
+
operator: "eq"
|
|
1607
|
+
},
|
|
1608
|
+
{
|
|
1609
|
+
field: "fieldName",
|
|
1610
|
+
value: source.fieldName,
|
|
1611
|
+
operator: "eq"
|
|
1612
|
+
}
|
|
1613
|
+
]
|
|
1614
|
+
});
|
|
1615
|
+
for (const sourceId of relations.map((value) => value.sourceId)) {
|
|
1616
|
+
const item = await adapter.findOne({
|
|
1617
|
+
model: "contentItem",
|
|
1618
|
+
where: [
|
|
1619
|
+
{
|
|
1620
|
+
field: "id",
|
|
1621
|
+
value: sourceId,
|
|
1622
|
+
operator: "eq"
|
|
1623
|
+
},
|
|
1624
|
+
{
|
|
1625
|
+
field: "contentTypeId",
|
|
1626
|
+
value: source.contentTypeId,
|
|
1627
|
+
operator: "eq"
|
|
1628
|
+
}
|
|
1629
|
+
]
|
|
1630
|
+
});
|
|
1631
|
+
if (item) count += 1;
|
|
1632
|
+
}
|
|
1633
|
+
}
|
|
1634
|
+
inverseRelations.push({
|
|
1635
|
+
sourceType: source.contentTypeSlug,
|
|
1636
|
+
sourceTypeName: source.contentTypeName,
|
|
1637
|
+
fieldName: source.fieldName,
|
|
1638
|
+
count
|
|
1639
|
+
});
|
|
1640
|
+
}
|
|
1641
|
+
return {
|
|
1642
|
+
inverseRelations: inverseRelations.map((relation) => ({
|
|
1643
|
+
...relation
|
|
1644
|
+
}))
|
|
1645
|
+
};
|
|
1646
|
+
} finally {
|
|
1647
|
+
inverseTargetAuthorizationSnapshots.delete(input);
|
|
1648
|
+
}
|
|
1649
|
+
},
|
|
1650
|
+
onError: ({ error, ...context }) => notifyError(
|
|
1651
|
+
error,
|
|
1652
|
+
"get",
|
|
1653
|
+
genericContext(context, context.facts.contentType)
|
|
1654
|
+
)
|
|
1655
|
+
});
|
|
1656
|
+
const listInverseRelationItems = api.defineOperation({
|
|
1657
|
+
input: ListInverseRelationItemsInputSchema,
|
|
1658
|
+
permission: permissions.cmsPermissions.record.read,
|
|
1659
|
+
facts: async ({ input }) => {
|
|
1660
|
+
await getContentTypeOrThrow(adapter, ensureSynced, input.slug);
|
|
1661
|
+
const sourceType = await getContentTypeOrThrow(
|
|
1662
|
+
adapter,
|
|
1663
|
+
ensureSynced,
|
|
1664
|
+
input.sourceType
|
|
1665
|
+
);
|
|
1666
|
+
return collectionFacts(sourceType.slug);
|
|
1667
|
+
},
|
|
1668
|
+
additionalPermissions: async ({ input, facts }) => {
|
|
1669
|
+
const authorization = await deriveInverseSourceAuthorization(
|
|
1670
|
+
adapter,
|
|
1671
|
+
ensureSynced,
|
|
1672
|
+
input.slug
|
|
1673
|
+
);
|
|
1674
|
+
const source = authorization.sources.find(
|
|
1675
|
+
(value) => value.contentTypeSlug === facts.contentType && value.fieldName === input.query.fieldName
|
|
1676
|
+
);
|
|
1677
|
+
if (!source) {
|
|
1678
|
+
throw new CMSOperationError(
|
|
1679
|
+
404,
|
|
1680
|
+
"Inverse relation not found",
|
|
1681
|
+
"INVERSE_RELATION_NOT_FOUND"
|
|
1682
|
+
);
|
|
1683
|
+
}
|
|
1684
|
+
inverseSourceAuthorizationSnapshots.set(
|
|
1685
|
+
input,
|
|
1686
|
+
authorization.snapshot
|
|
1687
|
+
);
|
|
1688
|
+
const targetFacts = recordFacts(
|
|
1689
|
+
await getRecordOrThrow(
|
|
1690
|
+
adapter,
|
|
1691
|
+
ensureSynced,
|
|
1692
|
+
input.slug,
|
|
1693
|
+
input.query.itemId
|
|
1694
|
+
)
|
|
1695
|
+
);
|
|
1696
|
+
inverseTargetAuthorizationSnapshots.set(input, targetFacts);
|
|
1697
|
+
return [
|
|
1698
|
+
permissions.cmsPermissions.contentType.read({ contentType: facts.contentType }),
|
|
1699
|
+
permissions.cmsPermissions.record.read(targetFacts)
|
|
1700
|
+
];
|
|
1701
|
+
},
|
|
1702
|
+
execute: async ({ input, facts }) => {
|
|
1703
|
+
try {
|
|
1704
|
+
const sources = await recheckInverseSourceAuthorization(
|
|
1705
|
+
input,
|
|
1706
|
+
input.slug
|
|
1707
|
+
);
|
|
1708
|
+
const source = sources.find(
|
|
1709
|
+
(value) => value.contentTypeSlug === facts.contentType && value.fieldName === input.query.fieldName
|
|
1710
|
+
);
|
|
1711
|
+
if (!source) throw relationSchemaChangedError();
|
|
1712
|
+
await recheckInverseTargetAuthorization(
|
|
1713
|
+
input,
|
|
1714
|
+
input.slug,
|
|
1715
|
+
input.query.itemId
|
|
1716
|
+
);
|
|
1717
|
+
const sourceContentType = await getContentTypeOrThrow(
|
|
1718
|
+
adapter,
|
|
1719
|
+
ensureSynced,
|
|
1720
|
+
facts.contentType
|
|
1721
|
+
);
|
|
1722
|
+
validateOperationContentReadOptions(sourceContentType, input.query);
|
|
1723
|
+
const result = await getters.getContentItemsByRelationForType(
|
|
1724
|
+
adapter,
|
|
1725
|
+
sourceContentType,
|
|
1726
|
+
{
|
|
1727
|
+
field: source.fieldName,
|
|
1728
|
+
targetId: input.query.itemId,
|
|
1729
|
+
limit: input.query.limit,
|
|
1730
|
+
offset: input.query.offset
|
|
1731
|
+
}
|
|
1732
|
+
);
|
|
1733
|
+
return {
|
|
1734
|
+
...result,
|
|
1735
|
+
items: result.items.map(
|
|
1736
|
+
(item) => getters.applyContentReadOptions(operationContentItem(item), input.query)
|
|
1737
|
+
)
|
|
1738
|
+
};
|
|
1739
|
+
} finally {
|
|
1740
|
+
inverseTargetAuthorizationSnapshots.delete(input);
|
|
1741
|
+
}
|
|
1742
|
+
},
|
|
1743
|
+
onError: ({ error, ...context }) => notifyError(
|
|
1744
|
+
error,
|
|
1745
|
+
"list",
|
|
1746
|
+
genericContext(context, context.facts.contentType)
|
|
1747
|
+
)
|
|
1748
|
+
});
|
|
1749
|
+
return {
|
|
1750
|
+
listContentTypes,
|
|
1751
|
+
getContentTypeBySlug,
|
|
1752
|
+
listContentItems,
|
|
1753
|
+
getContentItem,
|
|
1754
|
+
createContentItem,
|
|
1755
|
+
updateContentItem,
|
|
1756
|
+
deleteContentItem,
|
|
1757
|
+
getContentItemPopulated,
|
|
1758
|
+
listContentByRelation,
|
|
1759
|
+
getInverseRelations,
|
|
1760
|
+
listInverseRelationItems
|
|
1761
|
+
};
|
|
1762
|
+
}
|
|
1763
|
+
|
|
1764
|
+
exports.CMSContentItemParamsSchema = CMSContentItemParamsSchema;
|
|
1765
|
+
exports.CMSContentTypeParamsSchema = CMSContentTypeParamsSchema;
|
|
1766
|
+
exports.CMSCreateContentItemBodySchema = CMSCreateContentItemBodySchema;
|
|
1767
|
+
exports.CMSOperationError = CMSOperationError;
|
|
1768
|
+
exports.CMSUpdateContentItemBodySchema = CMSUpdateContentItemBodySchema;
|
|
1769
|
+
exports.createCMSOperations = createCMSOperations;
|