@crombolo/api-management 0.0.0-stage → 0.10.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 +21 -0
- package/NOTICE +69 -0
- package/package.json +32 -4
- package/src/authorization.ts +453 -0
- package/src/handlers/content-type.ts +784 -0
- package/src/handlers/data-type.ts +267 -0
- package/src/handlers/dictionary.ts +245 -0
- package/src/handlers/document-blueprint.ts +193 -0
- package/src/handlers/document.ts +961 -0
- package/src/handlers/editor-services.ts +59 -0
- package/src/handlers/element.ts +492 -0
- package/src/handlers/file-systems.ts +207 -0
- package/src/handlers/import.ts +46 -0
- package/src/handlers/localization.ts +116 -0
- package/src/handlers/log-viewer.ts +125 -0
- package/src/handlers/manifest.ts +15 -0
- package/src/handlers/media.ts +453 -0
- package/src/handlers/member-group.ts +87 -0
- package/src/handlers/member.ts +189 -0
- package/src/handlers/models-builder.ts +29 -0
- package/src/handlers/package.ts +193 -0
- package/src/handlers/public-access.ts +107 -0
- package/src/handlers/published-cache.ts +27 -0
- package/src/handlers/redirects.ts +78 -0
- package/src/handlers/references.ts +44 -0
- package/src/handlers/server.ts +25 -0
- package/src/handlers/settings-stubs.ts +76 -0
- package/src/handlers/start-node-trees.ts +84 -0
- package/src/handlers/template-query.ts +191 -0
- package/src/handlers/template.ts +150 -0
- package/src/handlers/temporary-file.ts +62 -0
- package/src/handlers/user.ts +604 -0
- package/src/index.ts +17 -0
- package/src/ports-content.ts +564 -0
- package/src/ports-files.ts +51 -0
- package/src/ports-logs.ts +38 -0
- package/src/ports-members.ts +111 -0
- package/src/ports-models-builder.ts +26 -0
- package/src/ports-packages.ts +59 -0
- package/src/ports-users.ts +230 -0
- package/src/ports.ts +225 -0
- package/src/router.ts +216 -0
- package/src/routes.ts +99 -0
- package/src/server-events.ts +71 -0
- package/README.md +0 -3
|
@@ -0,0 +1,961 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Documents: the tree, the editor's read/write cycle, publishing and versions.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type {
|
|
6
|
+
DocumentAggregate,
|
|
7
|
+
DocumentTreeItem,
|
|
8
|
+
DocumentValidationError,
|
|
9
|
+
DocumentValue,
|
|
10
|
+
DocumentVersionSummary,
|
|
11
|
+
} from '@crombolo/core'
|
|
12
|
+
import {
|
|
13
|
+
invalidSkipTake,
|
|
14
|
+
notFound,
|
|
15
|
+
problemDetails,
|
|
16
|
+
problemResponse,
|
|
17
|
+
ROOT_ACCESS,
|
|
18
|
+
} from '@crombolo/core'
|
|
19
|
+
import type { PreviewPort } from '../ports.ts'
|
|
20
|
+
import type { DocumentPort, PortFailure, SaveDocument } from '../ports-content.ts'
|
|
21
|
+
import type { ManagementApiRouter, RequestContext } from '../router.ts'
|
|
22
|
+
import { created, paging } from './content-type.ts'
|
|
23
|
+
import {
|
|
24
|
+
type TreeSource,
|
|
25
|
+
visibleChildren,
|
|
26
|
+
visibleRoot,
|
|
27
|
+
visibleSiblings,
|
|
28
|
+
} from './start-node-trees.ts'
|
|
29
|
+
|
|
30
|
+
const ref = (value: unknown): string | null =>
|
|
31
|
+
value && typeof value === 'object' && 'id' in value ? String((value as { id: unknown }).id) : null
|
|
32
|
+
|
|
33
|
+
function toVariantResponse(aggregate: DocumentAggregate) {
|
|
34
|
+
return aggregate.variants.map((variant) => ({
|
|
35
|
+
id: aggregate.key,
|
|
36
|
+
flags: [],
|
|
37
|
+
state: variant.state,
|
|
38
|
+
publishDate: variant.publishDate?.toISOString() ?? null,
|
|
39
|
+
scheduledPublishDate: variant.scheduledPublishDate?.toISOString() ?? null,
|
|
40
|
+
scheduledUnpublishDate: variant.scheduledUnpublishDate?.toISOString() ?? null,
|
|
41
|
+
createDate: variant.createDate.toISOString(),
|
|
42
|
+
updateDate: variant.updateDate.toISOString(),
|
|
43
|
+
culture: variant.culture,
|
|
44
|
+
segment: variant.segment,
|
|
45
|
+
name: variant.name,
|
|
46
|
+
}))
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function toDocumentResponse(aggregate: DocumentAggregate) {
|
|
50
|
+
return {
|
|
51
|
+
id: aggregate.key,
|
|
52
|
+
isTrashed: aggregate.isTrashed,
|
|
53
|
+
documentType: {
|
|
54
|
+
id: aggregate.contentTypeKey,
|
|
55
|
+
icon: aggregate.contentTypeIcon,
|
|
56
|
+
collection: aggregate.contentTypeCollectionKey
|
|
57
|
+
? { id: aggregate.contentTypeCollectionKey }
|
|
58
|
+
: null,
|
|
59
|
+
},
|
|
60
|
+
template: aggregate.componentKey ? { id: aggregate.componentKey } : null,
|
|
61
|
+
flags: [],
|
|
62
|
+
values: aggregate.values.map((value) => ({
|
|
63
|
+
editorAlias: value.editorAlias ?? '',
|
|
64
|
+
culture: value.culture,
|
|
65
|
+
segment: value.segment,
|
|
66
|
+
alias: value.alias,
|
|
67
|
+
value: value.value,
|
|
68
|
+
})),
|
|
69
|
+
variants: toVariantResponse(aggregate),
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function toDocumentItemResponse(item: DocumentTreeItem) {
|
|
74
|
+
return {
|
|
75
|
+
id: item.key,
|
|
76
|
+
isTrashed: item.isTrashed,
|
|
77
|
+
isProtected: false,
|
|
78
|
+
hasChildren: item.hasChildren,
|
|
79
|
+
parent: item.parentKey ? { id: item.parentKey } : null,
|
|
80
|
+
documentType: {
|
|
81
|
+
id: item.contentTypeKey,
|
|
82
|
+
icon: item.icon,
|
|
83
|
+
collection: item.contentTypeCollectionKey ? { id: item.contentTypeCollectionKey } : null,
|
|
84
|
+
},
|
|
85
|
+
variants: item.variants.map((v) => ({
|
|
86
|
+
id: item.key,
|
|
87
|
+
name: v.name,
|
|
88
|
+
culture: v.culture,
|
|
89
|
+
state: v.state,
|
|
90
|
+
flags: [],
|
|
91
|
+
})),
|
|
92
|
+
flags: [],
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function toRecycleBinItemResponse(item: DocumentTreeItem) {
|
|
97
|
+
return {
|
|
98
|
+
id: item.key,
|
|
99
|
+
hasChildren: item.hasChildren,
|
|
100
|
+
parent: item.parentKey ? { id: item.parentKey } : null,
|
|
101
|
+
createDate: item.createDate.toISOString(),
|
|
102
|
+
documentType: {
|
|
103
|
+
id: item.contentTypeKey,
|
|
104
|
+
icon: item.icon,
|
|
105
|
+
collection: item.contentTypeCollectionKey ? { id: item.contentTypeCollectionKey } : null,
|
|
106
|
+
},
|
|
107
|
+
variants: item.variants.map((v) => ({
|
|
108
|
+
id: item.key,
|
|
109
|
+
name: v.name,
|
|
110
|
+
culture: v.culture,
|
|
111
|
+
state: v.state,
|
|
112
|
+
flags: [],
|
|
113
|
+
})),
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function readValues(body: Record<string, unknown>): DocumentValue[] {
|
|
118
|
+
if (!Array.isArray(body.values)) return []
|
|
119
|
+
return (body.values as Array<Record<string, unknown>>).map((value) => ({
|
|
120
|
+
alias: String(value.alias ?? ''),
|
|
121
|
+
culture: (value.culture as string | null) ?? null,
|
|
122
|
+
segment: (value.segment as string | null) ?? null,
|
|
123
|
+
value: value.value,
|
|
124
|
+
}))
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function readVariants(
|
|
128
|
+
body: Record<string, unknown>,
|
|
129
|
+
): Array<{ culture: string | null; segment: string | null; name: string }> {
|
|
130
|
+
if (!Array.isArray(body.variants)) return []
|
|
131
|
+
return (body.variants as Array<Record<string, unknown>>).map((variant) => ({
|
|
132
|
+
culture: (variant.culture as string | null) ?? null,
|
|
133
|
+
segment: (variant.segment as string | null) ?? null,
|
|
134
|
+
name: String(variant.name ?? ''),
|
|
135
|
+
}))
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function toSaveDocument(key: string, body: Record<string, unknown>): SaveDocument {
|
|
139
|
+
return {
|
|
140
|
+
key,
|
|
141
|
+
contentTypeKey: ref(body.documentType) ?? '',
|
|
142
|
+
componentKey: ref(body.template),
|
|
143
|
+
parentKey: ref(body.parent),
|
|
144
|
+
values: readValues(body),
|
|
145
|
+
variants: readVariants(body),
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Cultures named in a publish request; an empty list means every culture. */
|
|
150
|
+
function readCultures(body: unknown): string[] | null {
|
|
151
|
+
if (!body || typeof body !== 'object') return null
|
|
152
|
+
const record = body as Record<string, unknown>
|
|
153
|
+
if (Array.isArray(record.culturesToPublish)) {
|
|
154
|
+
return (record.culturesToPublish as unknown[]).map(String)
|
|
155
|
+
}
|
|
156
|
+
if (Array.isArray(record.cultures)) return (record.cultures as unknown[]).map(String)
|
|
157
|
+
if (Array.isArray(record.publishSchedules)) {
|
|
158
|
+
const cultures = (record.publishSchedules as Array<Record<string, unknown>>)
|
|
159
|
+
.map((schedule) => schedule.culture)
|
|
160
|
+
.filter((culture): culture is string => typeof culture === 'string')
|
|
161
|
+
return cultures.length > 0 ? cultures : null
|
|
162
|
+
}
|
|
163
|
+
return null
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** `publishSchedules` as dates, or 'invalid' when one does not parse. */
|
|
167
|
+
function readSchedules(
|
|
168
|
+
body: Record<string, unknown>,
|
|
169
|
+
):
|
|
170
|
+
| Array<{ culture: string | null; publishTime: Date | null; unpublishTime: Date | null }>
|
|
171
|
+
| 'invalid' {
|
|
172
|
+
if (!Array.isArray(body.publishSchedules)) return []
|
|
173
|
+
const parse = (value: unknown): Date | null | 'invalid' => {
|
|
174
|
+
if (value === null || value === undefined || value === '') return null
|
|
175
|
+
const date = new Date(String(value))
|
|
176
|
+
return Number.isNaN(date.getTime()) ? 'invalid' : date
|
|
177
|
+
}
|
|
178
|
+
const result = []
|
|
179
|
+
for (const entry of body.publishSchedules as Array<Record<string, unknown>>) {
|
|
180
|
+
const schedule = (entry.schedule ?? null) as Record<string, unknown> | null
|
|
181
|
+
const publishTime = parse(schedule?.publishTime)
|
|
182
|
+
const unpublishTime = parse(schedule?.unpublishTime)
|
|
183
|
+
if (publishTime === 'invalid' || unpublishTime === 'invalid') return 'invalid'
|
|
184
|
+
result.push({
|
|
185
|
+
culture: typeof entry.culture === 'string' ? entry.culture : null,
|
|
186
|
+
publishTime,
|
|
187
|
+
unpublishTime,
|
|
188
|
+
})
|
|
189
|
+
}
|
|
190
|
+
return result
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export async function readBody(ctx: RequestContext): Promise<Record<string, unknown>> {
|
|
194
|
+
try {
|
|
195
|
+
return (await ctx.request.json()) as Record<string, unknown>
|
|
196
|
+
} catch {
|
|
197
|
+
return {}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Umbraco's validation problem: `errors` keyed by JSON path into the request —
|
|
203
|
+
* the value's index when it was sent, a filter expression when it was not — so
|
|
204
|
+
* the editor can mark the exact field.
|
|
205
|
+
*/
|
|
206
|
+
export function validationProblem(
|
|
207
|
+
errors: readonly DocumentValidationError[],
|
|
208
|
+
sentValues: readonly DocumentValue[],
|
|
209
|
+
) {
|
|
210
|
+
const byPath: Record<string, string[]> = {}
|
|
211
|
+
for (const error of errors) {
|
|
212
|
+
const index = sentValues.findIndex(
|
|
213
|
+
(v) =>
|
|
214
|
+
v.alias === error.alias &&
|
|
215
|
+
(v.culture ?? null) === error.culture &&
|
|
216
|
+
(v.segment ?? null) === error.segment,
|
|
217
|
+
)
|
|
218
|
+
const q = (s: string | null) => (s === null ? 'null' : `'${s}'`)
|
|
219
|
+
const path =
|
|
220
|
+
index >= 0
|
|
221
|
+
? `$.values[${index}].value`
|
|
222
|
+
: `$.values[?(@.alias == '${error.alias}' && @.culture == ${q(error.culture)} && @.segment == ${q(error.segment)})].value`
|
|
223
|
+
byPath[path] = [...(byPath[path] ?? []), ...error.messages]
|
|
224
|
+
}
|
|
225
|
+
return problemDetails({
|
|
226
|
+
title: 'Validation failed',
|
|
227
|
+
status: 400,
|
|
228
|
+
detail: 'One or more properties did not pass validation',
|
|
229
|
+
operationStatus: 'PropertyValidationError',
|
|
230
|
+
errors: byPath,
|
|
231
|
+
})
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** A port failure as a response: its validation errors when it has them, else its reason. */
|
|
235
|
+
export function failureResponse(
|
|
236
|
+
title: string,
|
|
237
|
+
result: PortFailure,
|
|
238
|
+
sent: readonly DocumentValue[],
|
|
239
|
+
) {
|
|
240
|
+
if (result.errors) return problemResponse(validationProblem(result.errors, sent))
|
|
241
|
+
return problemResponse(
|
|
242
|
+
problemDetails({ title, status: result.status ?? 400, detail: result.reason }),
|
|
243
|
+
)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export function toDocumentTreeItemResponse(item: DocumentTreeItem, noAccess = false) {
|
|
247
|
+
return {
|
|
248
|
+
id: item.key,
|
|
249
|
+
parent: item.parentKey ? { id: item.parentKey } : null,
|
|
250
|
+
hasChildren: item.hasChildren,
|
|
251
|
+
noAccess,
|
|
252
|
+
isProtected: false,
|
|
253
|
+
isTrashed: item.isTrashed,
|
|
254
|
+
createDate: item.createDate.toISOString(),
|
|
255
|
+
ancestors: item.ancestorKeys.map((id) => ({ id })),
|
|
256
|
+
documentType: {
|
|
257
|
+
id: item.contentTypeKey,
|
|
258
|
+
icon: item.icon,
|
|
259
|
+
collection: item.contentTypeCollectionKey ? { id: item.contentTypeCollectionKey } : null,
|
|
260
|
+
},
|
|
261
|
+
variants: item.variants.map((v) => ({
|
|
262
|
+
id: item.key,
|
|
263
|
+
name: v.name,
|
|
264
|
+
culture: v.culture,
|
|
265
|
+
state: v.state,
|
|
266
|
+
flags: [],
|
|
267
|
+
})),
|
|
268
|
+
flags: [],
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export function registerDocumentHandlers(
|
|
273
|
+
router: ManagementApiRouter,
|
|
274
|
+
port: DocumentPort,
|
|
275
|
+
preview?: PreviewPort,
|
|
276
|
+
): void {
|
|
277
|
+
const trees: TreeSource = {
|
|
278
|
+
treeRoot: (paging) => port.treeRoot(paging),
|
|
279
|
+
treeChildren: (key, paging) => port.treeChildren(key, paging),
|
|
280
|
+
items: (keys) => port.documentItems(keys),
|
|
281
|
+
}
|
|
282
|
+
const documentStart = (ctx: RequestContext) => ctx.principal?.startNodes.document ?? ROOT_ACCESS
|
|
283
|
+
|
|
284
|
+
router.handle('GetDocumentById', async ({ params }) => {
|
|
285
|
+
const aggregate = await port.byKey(params.id as string)
|
|
286
|
+
if (!aggregate) return problemResponse(notFound('The document could not be found'))
|
|
287
|
+
return Response.json(toDocumentResponse(aggregate))
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
router.handle('GetDocumentByIdPublished', async ({ params }) => {
|
|
291
|
+
const aggregate = await port.byKeyPublished(params.id as string)
|
|
292
|
+
if (!aggregate) return problemResponse(notFound('The document is not published'))
|
|
293
|
+
return Response.json(toDocumentResponse(aggregate))
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
router.handle('GetDocumentConfiguration', () =>
|
|
297
|
+
Response.json({
|
|
298
|
+
disableDeleteWhenReferenced: false,
|
|
299
|
+
disableUnpublishWhenReferenced: false,
|
|
300
|
+
allowEditInvariantFromNonDefault: false,
|
|
301
|
+
allowNonExistingSegmentsCreation: false,
|
|
302
|
+
}),
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
router.handle('PostDocumentValidate', async (ctx) => {
|
|
306
|
+
const body = await readBody(ctx)
|
|
307
|
+
const input = toSaveDocument(typeof body.id === 'string' ? body.id : crypto.randomUUID(), body)
|
|
308
|
+
const errors = await port.validate(input, null)
|
|
309
|
+
if (errors.length > 0) return problemResponse(validationProblem(errors, input.values))
|
|
310
|
+
return new Response(null, { status: 200 })
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
router.handle('PutDocumentByIdValidate', async (ctx) => {
|
|
314
|
+
const body = await readBody(ctx)
|
|
315
|
+
const key = ctx.params.id as string
|
|
316
|
+
if (!(await port.byKey(key)))
|
|
317
|
+
return problemResponse(notFound('The document could not be found'))
|
|
318
|
+
const input = toSaveDocument(key, body)
|
|
319
|
+
const cultures = Array.isArray(body.cultures) ? (body.cultures as unknown[]).map(String) : null
|
|
320
|
+
const errors = await port.validate(input, cultures)
|
|
321
|
+
if (errors.length > 0) return problemResponse(validationProblem(errors, input.values))
|
|
322
|
+
return new Response(null, { status: 200 })
|
|
323
|
+
})
|
|
324
|
+
|
|
325
|
+
router.handle('PostDocumentCreateAndPublish', async (ctx) => {
|
|
326
|
+
const body = await readBody(ctx)
|
|
327
|
+
const key = typeof body.id === 'string' && body.id ? body.id : crypto.randomUUID()
|
|
328
|
+
const input = toSaveDocument(key, body)
|
|
329
|
+
const result = await port.createAndPublish(
|
|
330
|
+
input,
|
|
331
|
+
readCultures(body),
|
|
332
|
+
ctx.principal as NonNullable<typeof ctx.principal>,
|
|
333
|
+
)
|
|
334
|
+
if (!result.ok) return failureResponse('Could not create the document', result, input.values)
|
|
335
|
+
return created(
|
|
336
|
+
`${ctx.url.pathname.replace(/\/create-and-publish$/, '')}/${result.key}`,
|
|
337
|
+
result.key,
|
|
338
|
+
)
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
router.handle('PutDocumentByIdUpdateAndPublish', async (ctx) => {
|
|
342
|
+
const body = await readBody(ctx)
|
|
343
|
+
const key = ctx.params.id as string
|
|
344
|
+
const input = toSaveDocument(key, body)
|
|
345
|
+
const result = await port.updateAndPublish(
|
|
346
|
+
key,
|
|
347
|
+
input,
|
|
348
|
+
readCultures(body),
|
|
349
|
+
ctx.principal as NonNullable<typeof ctx.principal>,
|
|
350
|
+
)
|
|
351
|
+
if (!result.ok) {
|
|
352
|
+
if (result.reason === 'The document could not be found.')
|
|
353
|
+
return problemResponse(notFound(result.reason))
|
|
354
|
+
return failureResponse('Could not publish the document', result, input.values)
|
|
355
|
+
}
|
|
356
|
+
return new Response(null, { status: 200 })
|
|
357
|
+
})
|
|
358
|
+
|
|
359
|
+
router.handle('GetTreeDocumentAncestors', async (ctx) => {
|
|
360
|
+
const descendantId = ctx.url.searchParams.get('descendantId')
|
|
361
|
+
// Umbraco binds a missing id to an empty GUID and answers with no ancestors.
|
|
362
|
+
if (!descendantId) return Response.json([])
|
|
363
|
+
return Response.json(
|
|
364
|
+
(await port.treeAncestors(descendantId)).map((item) => toDocumentTreeItemResponse(item)),
|
|
365
|
+
)
|
|
366
|
+
})
|
|
367
|
+
|
|
368
|
+
router.handle('GetTreeDocumentSiblings', async (ctx) => {
|
|
369
|
+
const target = ctx.url.searchParams.get('target')
|
|
370
|
+
if (!target)
|
|
371
|
+
return problemResponse(problemDetails({ title: 'target is required', status: 400 }))
|
|
372
|
+
const before = Number(ctx.url.searchParams.get('before') ?? 0)
|
|
373
|
+
const after = Number(ctx.url.searchParams.get('after') ?? 0)
|
|
374
|
+
const found = await port.treeSiblings(target, before, after)
|
|
375
|
+
if (!found) return problemResponse(notFound('The document could not be found'))
|
|
376
|
+
const window = await visibleSiblings(documentStart(ctx), trees, found)
|
|
377
|
+
return Response.json({
|
|
378
|
+
totalBefore: window.totalBefore,
|
|
379
|
+
totalAfter: window.totalAfter,
|
|
380
|
+
items: window.items.map((v) => toDocumentTreeItemResponse(v.item, v.noAccess)),
|
|
381
|
+
})
|
|
382
|
+
})
|
|
383
|
+
|
|
384
|
+
router.handle('GetItemDocumentSearch', async (ctx) => {
|
|
385
|
+
const page = paging(ctx)
|
|
386
|
+
if (!page) return problemResponse(invalidSkipTake())
|
|
387
|
+
const query = ctx.url.searchParams.get('query') ?? ''
|
|
388
|
+
const result = await port.search(query, {
|
|
389
|
+
...page,
|
|
390
|
+
trashed: ctx.url.searchParams.get('trashed') === 'true',
|
|
391
|
+
parentKey: ctx.url.searchParams.get('parentId'),
|
|
392
|
+
})
|
|
393
|
+
return Response.json({ total: result.total, items: result.items.map(toDocumentItemResponse) })
|
|
394
|
+
})
|
|
395
|
+
|
|
396
|
+
router.handle('GetRecycleBinDocumentRoot', async (ctx) => {
|
|
397
|
+
const page = paging(ctx)
|
|
398
|
+
if (!page) return problemResponse(invalidSkipTake())
|
|
399
|
+
// Only root access reaches the recycle bin
|
|
400
|
+
if (!documentStart(ctx).root) return Response.json({ total: 0, items: [] })
|
|
401
|
+
const result = await port.recycleBin(null, page)
|
|
402
|
+
return Response.json({ total: result.total, items: result.items.map(toRecycleBinItemResponse) })
|
|
403
|
+
})
|
|
404
|
+
|
|
405
|
+
router.handle('GetRecycleBinDocumentChildren', async (ctx) => {
|
|
406
|
+
const page = paging(ctx)
|
|
407
|
+
if (!page) return problemResponse(invalidSkipTake())
|
|
408
|
+
const parentId = ctx.url.searchParams.get('parentId')
|
|
409
|
+
if (!parentId)
|
|
410
|
+
return problemResponse(problemDetails({ title: 'parentId is required', status: 400 }))
|
|
411
|
+
if (!documentStart(ctx).root) return Response.json({ total: 0, items: [] })
|
|
412
|
+
const result = await port.recycleBin(parentId, page)
|
|
413
|
+
return Response.json({ total: result.total, items: result.items.map(toRecycleBinItemResponse) })
|
|
414
|
+
})
|
|
415
|
+
|
|
416
|
+
router.handle('GetRecycleBinDocumentSiblings', async (ctx) => {
|
|
417
|
+
const target = ctx.url.searchParams.get('target')
|
|
418
|
+
if (!target)
|
|
419
|
+
return problemResponse(problemDetails({ title: 'target is required', status: 400 }))
|
|
420
|
+
const window = await port.recycleBinSiblings(
|
|
421
|
+
target,
|
|
422
|
+
Number(ctx.url.searchParams.get('before') ?? 0),
|
|
423
|
+
Number(ctx.url.searchParams.get('after') ?? 0),
|
|
424
|
+
)
|
|
425
|
+
if (!window) return problemResponse(notFound('The document could not be found'))
|
|
426
|
+
return Response.json({ ...window, items: window.items.map(toRecycleBinItemResponse) })
|
|
427
|
+
})
|
|
428
|
+
|
|
429
|
+
router.handle('GetRecycleBinDocumentByIdOriginalParent', async ({ params }) => {
|
|
430
|
+
const parent = await port.originalParent(params.id as string)
|
|
431
|
+
if (parent === undefined) return problemResponse(notFound('The document could not be found'))
|
|
432
|
+
return Response.json(parent ? { id: parent } : null)
|
|
433
|
+
})
|
|
434
|
+
|
|
435
|
+
router.handle('PostDocument', async (ctx) => {
|
|
436
|
+
const body = await readBody(ctx)
|
|
437
|
+
const key = typeof body.id === 'string' && body.id ? body.id : crypto.randomUUID()
|
|
438
|
+
const result = await port.create(
|
|
439
|
+
toSaveDocument(key, body),
|
|
440
|
+
ctx.principal as NonNullable<typeof ctx.principal>,
|
|
441
|
+
)
|
|
442
|
+
if (!result.ok) {
|
|
443
|
+
return problemResponse(
|
|
444
|
+
problemDetails({
|
|
445
|
+
title: 'Could not create the document',
|
|
446
|
+
status: result.status ?? 400,
|
|
447
|
+
detail: result.reason,
|
|
448
|
+
}),
|
|
449
|
+
)
|
|
450
|
+
}
|
|
451
|
+
ctx.notifications.push({ message: 'Document created', category: 'Content', type: 'Success' })
|
|
452
|
+
return created(`${ctx.url.origin}/umbraco/management/api/v1/document/${result.key}`, result.key)
|
|
453
|
+
})
|
|
454
|
+
|
|
455
|
+
router.handle('PutDocumentById', async (ctx) => {
|
|
456
|
+
const key = ctx.params.id as string
|
|
457
|
+
const existing = await port.byKey(key)
|
|
458
|
+
if (!existing) return problemResponse(notFound('The document could not be found'))
|
|
459
|
+
const body = await readBody(ctx)
|
|
460
|
+
const result = await port.update(
|
|
461
|
+
key,
|
|
462
|
+
{
|
|
463
|
+
...toSaveDocument(key, body),
|
|
464
|
+
// The content type never changes on update.
|
|
465
|
+
contentTypeKey: existing.contentTypeKey,
|
|
466
|
+
},
|
|
467
|
+
ctx.principal as NonNullable<typeof ctx.principal>,
|
|
468
|
+
)
|
|
469
|
+
if (!result.ok) {
|
|
470
|
+
return problemResponse(
|
|
471
|
+
problemDetails({
|
|
472
|
+
title: 'Could not save the document',
|
|
473
|
+
status: result.status ?? 400,
|
|
474
|
+
detail: result.reason,
|
|
475
|
+
}),
|
|
476
|
+
)
|
|
477
|
+
}
|
|
478
|
+
ctx.notifications.push({ message: 'Document saved', category: 'Content', type: 'Success' })
|
|
479
|
+
return new Response(null, { status: 200 })
|
|
480
|
+
})
|
|
481
|
+
|
|
482
|
+
router.handle('PutDocumentByIdPublish', async (ctx) => {
|
|
483
|
+
const key = ctx.params.id as string
|
|
484
|
+
if (!(await port.byKey(key)))
|
|
485
|
+
return problemResponse(notFound('The document could not be found'))
|
|
486
|
+
const body = await readBody(ctx)
|
|
487
|
+
const schedules = readSchedules(body)
|
|
488
|
+
if (schedules === 'invalid')
|
|
489
|
+
return problemResponse(
|
|
490
|
+
problemDetails({ title: 'A schedule date is not a valid date', status: 400 }),
|
|
491
|
+
)
|
|
492
|
+
const now = Date.now()
|
|
493
|
+
// A future publish time schedules the variant instead of publishing it now.
|
|
494
|
+
const later = (s: (typeof schedules)[number]) =>
|
|
495
|
+
s.publishTime !== null && s.publishTime.getTime() > now
|
|
496
|
+
if (schedules.length > 0)
|
|
497
|
+
await port.schedule(
|
|
498
|
+
key,
|
|
499
|
+
schedules.map((s) => ({ ...s, publishTime: later(s) ? s.publishTime : null })),
|
|
500
|
+
)
|
|
501
|
+
const immediate = schedules.filter((s) => !later(s))
|
|
502
|
+
if (schedules.length > 0 && immediate.length === 0) {
|
|
503
|
+
ctx.notifications.push({
|
|
504
|
+
message: 'Publishing scheduled',
|
|
505
|
+
category: 'Content',
|
|
506
|
+
type: 'Success',
|
|
507
|
+
})
|
|
508
|
+
return new Response(null, { status: 200 })
|
|
509
|
+
}
|
|
510
|
+
const cultures = immediate.some((s) => s.culture !== null)
|
|
511
|
+
? immediate.flatMap((s) => (s.culture === null ? [] : [s.culture]))
|
|
512
|
+
: readCultures(body)
|
|
513
|
+
const result = await port.publish(key, cultures)
|
|
514
|
+
if (!result.ok) {
|
|
515
|
+
return problemResponse(
|
|
516
|
+
problemDetails({
|
|
517
|
+
title: 'Could not publish the document',
|
|
518
|
+
status: result.status ?? 400,
|
|
519
|
+
detail: result.reason,
|
|
520
|
+
operationStatus: 'PathNotPublished',
|
|
521
|
+
}),
|
|
522
|
+
)
|
|
523
|
+
}
|
|
524
|
+
return new Response(null, { status: 200 })
|
|
525
|
+
})
|
|
526
|
+
|
|
527
|
+
router.handle('PutDocumentByIdUnpublish', async (ctx) => {
|
|
528
|
+
const key = ctx.params.id as string
|
|
529
|
+
if (!(await port.byKey(key)))
|
|
530
|
+
return problemResponse(notFound('The document could not be found'))
|
|
531
|
+
const result = await port.unpublish(key, readCultures(await readBody(ctx)))
|
|
532
|
+
if (!result.ok) {
|
|
533
|
+
return problemResponse(
|
|
534
|
+
problemDetails({
|
|
535
|
+
title: 'Could not unpublish the document',
|
|
536
|
+
status: result.status ?? 400,
|
|
537
|
+
detail: result.reason,
|
|
538
|
+
}),
|
|
539
|
+
)
|
|
540
|
+
}
|
|
541
|
+
return new Response(null, { status: 200 })
|
|
542
|
+
})
|
|
543
|
+
|
|
544
|
+
router.handle('PutDocumentByIdMoveToRecycleBin', async (ctx) => {
|
|
545
|
+
const moved = await port.moveToRecycleBin(ctx.params.id as string)
|
|
546
|
+
if (!moved) return problemResponse(notFound('The document could not be found'))
|
|
547
|
+
ctx.notifications.push({
|
|
548
|
+
message: 'Moved to the recycle bin',
|
|
549
|
+
category: 'Content',
|
|
550
|
+
type: 'Success',
|
|
551
|
+
})
|
|
552
|
+
return new Response(null, { status: 200 })
|
|
553
|
+
})
|
|
554
|
+
|
|
555
|
+
router.handle('DeleteDocumentById', async (ctx) => {
|
|
556
|
+
const removed = await port.remove(ctx.params.id as string)
|
|
557
|
+
if (!removed) return problemResponse(notFound('The document could not be found'))
|
|
558
|
+
return new Response(null, { status: 200 })
|
|
559
|
+
})
|
|
560
|
+
|
|
561
|
+
const refId = (value: unknown): string | null => {
|
|
562
|
+
if (!value || typeof value !== 'object') return null
|
|
563
|
+
const id = (value as Record<string, unknown>).id
|
|
564
|
+
return typeof id === 'string' && id ? id : null
|
|
565
|
+
}
|
|
566
|
+
const failed = (title: string, result: PortFailure) =>
|
|
567
|
+
problemResponse(problemDetails({ title, status: result.status ?? 400, detail: result.reason }))
|
|
568
|
+
const done = (ctx: RequestContext, message: string) => {
|
|
569
|
+
ctx.notifications.push({ message, category: 'Content', type: 'Success' })
|
|
570
|
+
return new Response(null, { status: 200 })
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
router.handle('PutDocumentByIdMove', async (ctx) => {
|
|
574
|
+
const result = await port.move(ctx.params.id as string, refId((await readBody(ctx)).target))
|
|
575
|
+
return result.ok ? done(ctx, 'Document moved') : failed('Could not move the document', result)
|
|
576
|
+
})
|
|
577
|
+
|
|
578
|
+
router.handle('PostDocumentByIdCopy', async (ctx) => {
|
|
579
|
+
const body = await readBody(ctx)
|
|
580
|
+
const result = await port.copy(
|
|
581
|
+
ctx.params.id as string,
|
|
582
|
+
refId(body.target),
|
|
583
|
+
{ includeDescendants: body.includeDescendants === true },
|
|
584
|
+
ctx.principal as NonNullable<typeof ctx.principal>,
|
|
585
|
+
)
|
|
586
|
+
if (!result.ok) return failed('Could not copy the document', result)
|
|
587
|
+
ctx.notifications.push({ message: 'Document copied', category: 'Content', type: 'Success' })
|
|
588
|
+
return created(`${ctx.url.origin}/umbraco/management/api/v1/document/${result.key}`, result.key)
|
|
589
|
+
})
|
|
590
|
+
|
|
591
|
+
router.handle('PutDocumentSort', async (ctx) => {
|
|
592
|
+
const body = await readBody(ctx)
|
|
593
|
+
if (!Array.isArray(body.sorting))
|
|
594
|
+
return problemResponse(problemDetails({ title: 'sorting is required', status: 400 }))
|
|
595
|
+
const sorting = (body.sorting as Array<Record<string, unknown>>).map((item) => ({
|
|
596
|
+
key: String(item.id ?? ''),
|
|
597
|
+
sortOrder: Number(item.sortOrder ?? 0),
|
|
598
|
+
}))
|
|
599
|
+
const result = await port.sort(refId(body.parent), sorting)
|
|
600
|
+
return result.ok ? done(ctx, 'Sort order saved') : failed('Could not sort', result)
|
|
601
|
+
})
|
|
602
|
+
|
|
603
|
+
const sortByField = async (ctx: RequestContext, parentKey: string | null) => {
|
|
604
|
+
const body = await readBody(ctx)
|
|
605
|
+
const field = String(body.field ?? '')
|
|
606
|
+
const direction = String(body.direction ?? '')
|
|
607
|
+
if (!['Name', 'CreateDate', 'UpdateDate'].includes(field))
|
|
608
|
+
return problemResponse(problemDetails({ title: 'Unknown sort field', status: 400 }))
|
|
609
|
+
if (!['Ascending', 'Descending'].includes(direction))
|
|
610
|
+
return problemResponse(problemDetails({ title: 'Unknown sort direction', status: 400 }))
|
|
611
|
+
const result = await port.sortChildrenBy(
|
|
612
|
+
parentKey,
|
|
613
|
+
field as 'Name' | 'CreateDate' | 'UpdateDate',
|
|
614
|
+
direction as 'Ascending' | 'Descending',
|
|
615
|
+
)
|
|
616
|
+
return result.ok ? done(ctx, 'Sort order saved') : failed('Could not sort', result)
|
|
617
|
+
}
|
|
618
|
+
router.handle('PutDocumentByIdSortChildren', (ctx) => sortByField(ctx, ctx.params.id as string))
|
|
619
|
+
router.handle('PutDocumentRootSortChildren', (ctx) => sortByField(ctx, null))
|
|
620
|
+
|
|
621
|
+
router.handle('PutRecycleBinDocumentByIdRestore', async (ctx) => {
|
|
622
|
+
const body = await readBody(ctx)
|
|
623
|
+
const target = 'target' in body ? refId(body.target) : undefined
|
|
624
|
+
const result = await port.restore(ctx.params.id as string, target ?? undefined)
|
|
625
|
+
return result.ok ? done(ctx, 'Document restored') : failed('Could not restore', result)
|
|
626
|
+
})
|
|
627
|
+
|
|
628
|
+
router.handle('DeleteRecycleBinDocumentById', async (ctx) => {
|
|
629
|
+
const key = ctx.params.id as string
|
|
630
|
+
const document = await port.byKey(key)
|
|
631
|
+
if (!document) return problemResponse(notFound('The document could not be found'))
|
|
632
|
+
if (!document.isTrashed)
|
|
633
|
+
return problemResponse(
|
|
634
|
+
problemDetails({ title: 'The document is not in the recycle bin', status: 400 }),
|
|
635
|
+
)
|
|
636
|
+
await port.remove(key)
|
|
637
|
+
return done(ctx, 'Document deleted')
|
|
638
|
+
})
|
|
639
|
+
|
|
640
|
+
router.handle('DeleteRecycleBinDocument', async (ctx) => {
|
|
641
|
+
await port.emptyRecycleBin()
|
|
642
|
+
return done(ctx, 'Recycle bin emptied')
|
|
643
|
+
})
|
|
644
|
+
|
|
645
|
+
/** Branch publishing runs inline, so its task is complete when it is first reported. */
|
|
646
|
+
const branchTasks = new Map<string, { documentKey: string }>()
|
|
647
|
+
router.handle('PutDocumentByIdPublishWithDescendants', async (ctx) => {
|
|
648
|
+
const key = ctx.params.id as string
|
|
649
|
+
const body = await readBody(ctx)
|
|
650
|
+
const cultures = Array.isArray(body.cultures) ? (body.cultures as unknown[]).map(String) : []
|
|
651
|
+
const result = await port.publishBranch(
|
|
652
|
+
key,
|
|
653
|
+
cultures.length > 0 ? cultures : null,
|
|
654
|
+
body.includeUnpublishedDescendants === true,
|
|
655
|
+
)
|
|
656
|
+
if (!result.ok) return failed('Could not publish the branch', result)
|
|
657
|
+
const taskId = crypto.randomUUID()
|
|
658
|
+
branchTasks.set(taskId, { documentKey: key })
|
|
659
|
+
ctx.notifications.push({
|
|
660
|
+
message: `${result.published.length} published`,
|
|
661
|
+
category: 'Content',
|
|
662
|
+
type: 'Success',
|
|
663
|
+
})
|
|
664
|
+
for (const failure of result.failed)
|
|
665
|
+
ctx.notifications.push({ message: failure.reason, category: 'Content', type: 'Warning' })
|
|
666
|
+
return Response.json({ taskId, isComplete: true })
|
|
667
|
+
})
|
|
668
|
+
|
|
669
|
+
router.handle('GetDocumentByIdPublishWithDescendantsResultByTaskId', (ctx) => {
|
|
670
|
+
const taskId = ctx.params.taskId as string
|
|
671
|
+
const task = branchTasks.get(taskId)
|
|
672
|
+
if (!task || task.documentKey.toLowerCase() !== String(ctx.params.id).toLowerCase())
|
|
673
|
+
return problemResponse(notFound('The task could not be found'))
|
|
674
|
+
return Response.json({ taskId, isComplete: true })
|
|
675
|
+
})
|
|
676
|
+
|
|
677
|
+
router.handle('GetDocumentByIdDomains', async (ctx) => {
|
|
678
|
+
const found = await port.domains(ctx.params.id as string)
|
|
679
|
+
if (!found) return problemResponse(notFound('The document could not be found'))
|
|
680
|
+
return Response.json(found)
|
|
681
|
+
})
|
|
682
|
+
|
|
683
|
+
router.handle('PutDocumentByIdDomains', async (ctx) => {
|
|
684
|
+
const body = await readBody(ctx)
|
|
685
|
+
const domains = Array.isArray(body.domains)
|
|
686
|
+
? (body.domains as Array<Record<string, unknown>>).map((d) => ({
|
|
687
|
+
domainName: String(d.domainName ?? ''),
|
|
688
|
+
isoCode: String(d.isoCode ?? ''),
|
|
689
|
+
}))
|
|
690
|
+
: []
|
|
691
|
+
const result = await port.setDomains(ctx.params.id as string, {
|
|
692
|
+
defaultIsoCode: typeof body.defaultIsoCode === 'string' ? body.defaultIsoCode : null,
|
|
693
|
+
domains,
|
|
694
|
+
})
|
|
695
|
+
switch (result.status) {
|
|
696
|
+
case 'ok':
|
|
697
|
+
// A site that deploys its hostnames from a file has had that file
|
|
698
|
+
// written too, and it is worth saying: the change outlives this node
|
|
699
|
+
// only if the file is committed.
|
|
700
|
+
return done(
|
|
701
|
+
ctx,
|
|
702
|
+
result.file
|
|
703
|
+
? // ASCII only: this goes out as an HTTP header, which rejects the
|
|
704
|
+
// punctuation the rest of the codebase writes freely.
|
|
705
|
+
`Culture and hostnames saved, and written to ${result.file}. Commit it to keep them.`
|
|
706
|
+
: 'Culture and hostnames saved',
|
|
707
|
+
)
|
|
708
|
+
case 'notFound':
|
|
709
|
+
return problemResponse(notFound('The document could not be found'))
|
|
710
|
+
case 'conflict':
|
|
711
|
+
return problemResponse(
|
|
712
|
+
problemDetails({
|
|
713
|
+
title: 'Duplicate domain name detected',
|
|
714
|
+
detail: `The domain '${result.domainName}' is already assigned.`,
|
|
715
|
+
status: 409,
|
|
716
|
+
operationStatus: 'DuplicateDomainName',
|
|
717
|
+
}),
|
|
718
|
+
)
|
|
719
|
+
case 'invalidLanguage':
|
|
720
|
+
return problemResponse(
|
|
721
|
+
problemDetails({
|
|
722
|
+
title: 'Invalid language',
|
|
723
|
+
detail: `No language with the ISO code '${result.isoCode}' exists.`,
|
|
724
|
+
status: 400,
|
|
725
|
+
operationStatus: 'LanguageNotFound',
|
|
726
|
+
}),
|
|
727
|
+
)
|
|
728
|
+
default:
|
|
729
|
+
return problemResponse(
|
|
730
|
+
problemDetails({
|
|
731
|
+
title: 'Invalid domain name',
|
|
732
|
+
detail: `'${result.domainName}' is not a valid domain name.`,
|
|
733
|
+
status: 400,
|
|
734
|
+
operationStatus: 'InvalidDomainName',
|
|
735
|
+
}),
|
|
736
|
+
)
|
|
737
|
+
}
|
|
738
|
+
})
|
|
739
|
+
|
|
740
|
+
router.handle('GetDocumentByIdNotifications', async (ctx) => {
|
|
741
|
+
const found = await port.notifications(
|
|
742
|
+
ctx.params.id as string,
|
|
743
|
+
ctx.principal as NonNullable<typeof ctx.principal>,
|
|
744
|
+
)
|
|
745
|
+
if (!found) return problemResponse(notFound('The document could not be found'))
|
|
746
|
+
return Response.json(found)
|
|
747
|
+
})
|
|
748
|
+
|
|
749
|
+
router.handle('PutDocumentByIdNotifications', async (ctx) => {
|
|
750
|
+
const body = await readBody(ctx)
|
|
751
|
+
const ids = Array.isArray(body.subscribedActionIds)
|
|
752
|
+
? (body.subscribedActionIds as unknown[]).map(String)
|
|
753
|
+
: []
|
|
754
|
+
const saved = await port.setNotifications(
|
|
755
|
+
ctx.params.id as string,
|
|
756
|
+
ctx.principal as NonNullable<typeof ctx.principal>,
|
|
757
|
+
ids,
|
|
758
|
+
)
|
|
759
|
+
if (!saved) return problemResponse(notFound('The document could not be found'))
|
|
760
|
+
return done(ctx, 'Notification settings saved')
|
|
761
|
+
})
|
|
762
|
+
|
|
763
|
+
if (preview) {
|
|
764
|
+
/**
|
|
765
|
+
* Umbraco's default provider: the backoffice's own preview app at
|
|
766
|
+
* `preview?id=…`, relative to the backoffice, which frames the page. Asking
|
|
767
|
+
* for it enters preview for this editor.
|
|
768
|
+
*/
|
|
769
|
+
router.handle('GetDocumentByIdPreviewUrl', async (ctx) => {
|
|
770
|
+
const key = ctx.params.id as string
|
|
771
|
+
if (!(await port.byKey(key)))
|
|
772
|
+
return problemResponse(
|
|
773
|
+
notFound('Document not found', 'The requested document did not exist.'),
|
|
774
|
+
)
|
|
775
|
+
const culture = ctx.url.searchParams.get('culture') || null
|
|
776
|
+
const segment = ctx.url.searchParams.get('segment') || null
|
|
777
|
+
const query = new URLSearchParams({ id: key, culture: culture ?? '', segment: segment ?? '' })
|
|
778
|
+
const headers = new Headers({ 'content-type': 'application/json' })
|
|
779
|
+
for (const cookie of preview.enter()) headers.append('set-cookie', cookie)
|
|
780
|
+
return new Response(
|
|
781
|
+
JSON.stringify({
|
|
782
|
+
url: `preview?${query.toString()}`,
|
|
783
|
+
provider: ctx.url.searchParams.get('providerAlias') || 'umbDocumentUrlProvider',
|
|
784
|
+
isExternal: false,
|
|
785
|
+
culture,
|
|
786
|
+
message: null,
|
|
787
|
+
}),
|
|
788
|
+
{ headers },
|
|
789
|
+
)
|
|
790
|
+
})
|
|
791
|
+
|
|
792
|
+
router.handle('DeletePreview', () => {
|
|
793
|
+
const headers = new Headers()
|
|
794
|
+
for (const cookie of preview.exit()) headers.append('set-cookie', cookie)
|
|
795
|
+
return new Response(null, { status: 200, headers })
|
|
796
|
+
})
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
router.handle('GetDocumentVersionById', async (ctx) => {
|
|
800
|
+
const found = await port.atVersion(ctx.params.id as string)
|
|
801
|
+
if (!found) return problemResponse(notFound('The version could not be found'))
|
|
802
|
+
const document = toDocumentResponse(found.document)
|
|
803
|
+
return Response.json({
|
|
804
|
+
id: found.versionId,
|
|
805
|
+
document: { id: found.document.key },
|
|
806
|
+
documentType: document.documentType,
|
|
807
|
+
values: document.values,
|
|
808
|
+
variants: document.variants,
|
|
809
|
+
flags: [],
|
|
810
|
+
})
|
|
811
|
+
})
|
|
812
|
+
|
|
813
|
+
router.handle('GetItemDocumentAncestors', async (ctx) => {
|
|
814
|
+
const ids = ctx.url.searchParams.getAll('id')
|
|
815
|
+
const result = []
|
|
816
|
+
for (const id of ids) {
|
|
817
|
+
const ancestors = await port.treeAncestors(id)
|
|
818
|
+
result.push({ id, ancestors: ancestors.map(toDocumentItemResponse) })
|
|
819
|
+
}
|
|
820
|
+
return Response.json(result)
|
|
821
|
+
})
|
|
822
|
+
|
|
823
|
+
router.handle('GetTreeDocumentRoot', async (ctx) => {
|
|
824
|
+
const page = paging(ctx)
|
|
825
|
+
if (!page) return problemResponse(invalidSkipTake())
|
|
826
|
+
const result = await visibleRoot(documentStart(ctx), trees, page)
|
|
827
|
+
return Response.json({
|
|
828
|
+
total: result.total,
|
|
829
|
+
items: result.items.map((v) => toDocumentTreeItemResponse(v.item, v.noAccess)),
|
|
830
|
+
})
|
|
831
|
+
})
|
|
832
|
+
|
|
833
|
+
router.handle('GetTreeDocumentChildren', async (ctx) => {
|
|
834
|
+
const page = paging(ctx)
|
|
835
|
+
if (!page) return problemResponse(invalidSkipTake())
|
|
836
|
+
const parentId = ctx.url.searchParams.get('parentId')
|
|
837
|
+
if (!parentId)
|
|
838
|
+
return problemResponse(problemDetails({ title: 'parentId is required', status: 400 }))
|
|
839
|
+
const result = await visibleChildren(documentStart(ctx), trees, parentId, page)
|
|
840
|
+
return Response.json({
|
|
841
|
+
total: result.total,
|
|
842
|
+
items: result.items.map((v) => toDocumentTreeItemResponse(v.item, v.noAccess)),
|
|
843
|
+
})
|
|
844
|
+
})
|
|
845
|
+
|
|
846
|
+
router.handle('GetItemDocument', async (ctx) => {
|
|
847
|
+
const items = await port.documentItems(ctx.url.searchParams.getAll('id'))
|
|
848
|
+
return Response.json(items.map(toDocumentItemResponse))
|
|
849
|
+
})
|
|
850
|
+
|
|
851
|
+
/** The page's history, from its versions: who saved, published or rolled back, and when. */
|
|
852
|
+
router.handle('GetDocumentByIdAuditLog', (ctx) => auditLogResponse(ctx, port))
|
|
853
|
+
|
|
854
|
+
router.handle('GetDocumentVersion', async (ctx) => {
|
|
855
|
+
const documentId = ctx.url.searchParams.get('documentId')
|
|
856
|
+
if (!documentId) {
|
|
857
|
+
return problemResponse(problemDetails({ title: 'documentId is required', status: 400 }))
|
|
858
|
+
}
|
|
859
|
+
const page = paging(ctx)
|
|
860
|
+
if (!page) return problemResponse(invalidSkipTake())
|
|
861
|
+
const versions = await port.versions(documentId)
|
|
862
|
+
const window = versions.slice(page.skip, page.skip + page.take)
|
|
863
|
+
const document = await port.byKey(documentId)
|
|
864
|
+
const documentType = document
|
|
865
|
+
? toDocumentResponse(document).documentType
|
|
866
|
+
: { id: '', icon: null, collection: null }
|
|
867
|
+
return Response.json({
|
|
868
|
+
total: versions.length,
|
|
869
|
+
items: window.map((version) => ({
|
|
870
|
+
id: version.id,
|
|
871
|
+
document: { id: version.documentKey },
|
|
872
|
+
documentType,
|
|
873
|
+
user: version.userKey ? { id: version.userKey } : null,
|
|
874
|
+
versionDate: version.versionDate.toISOString(),
|
|
875
|
+
isCurrentPublishedVersion: version.isCurrentPublished,
|
|
876
|
+
isCurrentDraftVersion: version.isCurrentDraft,
|
|
877
|
+
preventCleanup: version.preventCleanup,
|
|
878
|
+
})),
|
|
879
|
+
})
|
|
880
|
+
})
|
|
881
|
+
|
|
882
|
+
router.handle('PostDocumentVersionByIdRollback', async (ctx) => {
|
|
883
|
+
const rolled = await port.rollback(ctx.params.id as string)
|
|
884
|
+
if (!rolled) return problemResponse(notFound('The version could not be found'))
|
|
885
|
+
return new Response(null, { status: 200 })
|
|
886
|
+
})
|
|
887
|
+
|
|
888
|
+
router.handle('PutDocumentVersionByIdPreventCleanup', async (ctx) => {
|
|
889
|
+
const prevent = ctx.url.searchParams.get('preventCleanup') !== 'false'
|
|
890
|
+
const updated = await port.setPreventCleanup(ctx.params.id as string, prevent)
|
|
891
|
+
if (!updated) return problemResponse(notFound('The version could not be found'))
|
|
892
|
+
return new Response(null, { status: 200 })
|
|
893
|
+
})
|
|
894
|
+
|
|
895
|
+
router.handle('GetDocumentUrls', async (ctx) => {
|
|
896
|
+
const keys = ctx.url.searchParams.getAll('id')
|
|
897
|
+
const result = []
|
|
898
|
+
for (const key of keys) {
|
|
899
|
+
result.push({
|
|
900
|
+
id: key,
|
|
901
|
+
urlInfos: (await port.urls(key)).map((url) => ({ culture: url.culture, url: url.url })),
|
|
902
|
+
})
|
|
903
|
+
}
|
|
904
|
+
return Response.json(result)
|
|
905
|
+
})
|
|
906
|
+
|
|
907
|
+
router.handle('GetCollectionDocumentById', async (ctx) => {
|
|
908
|
+
const page = paging(ctx)
|
|
909
|
+
if (!page) return problemResponse(invalidSkipTake())
|
|
910
|
+
const result = await port.collection(ctx.params.id as string, page)
|
|
911
|
+
return Response.json({
|
|
912
|
+
total: result.total,
|
|
913
|
+
items: result.items.map((aggregate) => ({
|
|
914
|
+
...toDocumentResponse(aggregate),
|
|
915
|
+
creator: null,
|
|
916
|
+
sortOrder: aggregate.sortOrder,
|
|
917
|
+
hasChildren: false,
|
|
918
|
+
updater: null,
|
|
919
|
+
isProtected: false,
|
|
920
|
+
ancestors: [],
|
|
921
|
+
})),
|
|
922
|
+
})
|
|
923
|
+
})
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
const AUDIT_TYPE = {
|
|
927
|
+
save: 'Save',
|
|
928
|
+
publish: 'Publish',
|
|
929
|
+
rollback: 'RollBack',
|
|
930
|
+
migrate: 'System',
|
|
931
|
+
} as const
|
|
932
|
+
|
|
933
|
+
/** An audit log built from an item's versions, paged and filtered as the contract asks. */
|
|
934
|
+
export async function auditLogResponse(
|
|
935
|
+
ctx: RequestContext,
|
|
936
|
+
port: {
|
|
937
|
+
versions(key: string): Promise<DocumentVersionSummary[]>
|
|
938
|
+
systemUserKey(): Promise<string | null>
|
|
939
|
+
},
|
|
940
|
+
): Promise<Response> {
|
|
941
|
+
const page = paging(ctx)
|
|
942
|
+
if (!page) return problemResponse(invalidSkipTake())
|
|
943
|
+
const since = ctx.url.searchParams.get('sinceDate')
|
|
944
|
+
const sinceTime = since ? Date.parse(since) : Number.NaN
|
|
945
|
+
let versions = await port.versions(ctx.params.id as string)
|
|
946
|
+
if (!Number.isNaN(sinceTime))
|
|
947
|
+
versions = versions.filter((v) => v.versionDate.getTime() >= sinceTime)
|
|
948
|
+
if (ctx.url.searchParams.get('orderDirection') === 'Ascending') versions = versions.reverse()
|
|
949
|
+
const fallbackUser = await port.systemUserKey()
|
|
950
|
+
const window = versions.slice(page.skip, page.skip + page.take)
|
|
951
|
+
return Response.json({
|
|
952
|
+
total: versions.length,
|
|
953
|
+
items: window.map((version) => ({
|
|
954
|
+
user: { id: version.userKey ?? fallbackUser },
|
|
955
|
+
timestamp: version.versionDate.toISOString(),
|
|
956
|
+
logType: AUDIT_TYPE[version.kind ?? 'save'],
|
|
957
|
+
comment: null,
|
|
958
|
+
parameters: null,
|
|
959
|
+
})),
|
|
960
|
+
})
|
|
961
|
+
}
|