@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.
Files changed (45) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE +69 -0
  3. package/package.json +32 -4
  4. package/src/authorization.ts +453 -0
  5. package/src/handlers/content-type.ts +784 -0
  6. package/src/handlers/data-type.ts +267 -0
  7. package/src/handlers/dictionary.ts +245 -0
  8. package/src/handlers/document-blueprint.ts +193 -0
  9. package/src/handlers/document.ts +961 -0
  10. package/src/handlers/editor-services.ts +59 -0
  11. package/src/handlers/element.ts +492 -0
  12. package/src/handlers/file-systems.ts +207 -0
  13. package/src/handlers/import.ts +46 -0
  14. package/src/handlers/localization.ts +116 -0
  15. package/src/handlers/log-viewer.ts +125 -0
  16. package/src/handlers/manifest.ts +15 -0
  17. package/src/handlers/media.ts +453 -0
  18. package/src/handlers/member-group.ts +87 -0
  19. package/src/handlers/member.ts +189 -0
  20. package/src/handlers/models-builder.ts +29 -0
  21. package/src/handlers/package.ts +193 -0
  22. package/src/handlers/public-access.ts +107 -0
  23. package/src/handlers/published-cache.ts +27 -0
  24. package/src/handlers/redirects.ts +78 -0
  25. package/src/handlers/references.ts +44 -0
  26. package/src/handlers/server.ts +25 -0
  27. package/src/handlers/settings-stubs.ts +76 -0
  28. package/src/handlers/start-node-trees.ts +84 -0
  29. package/src/handlers/template-query.ts +191 -0
  30. package/src/handlers/template.ts +150 -0
  31. package/src/handlers/temporary-file.ts +62 -0
  32. package/src/handlers/user.ts +604 -0
  33. package/src/index.ts +17 -0
  34. package/src/ports-content.ts +564 -0
  35. package/src/ports-files.ts +51 -0
  36. package/src/ports-logs.ts +38 -0
  37. package/src/ports-members.ts +111 -0
  38. package/src/ports-models-builder.ts +26 -0
  39. package/src/ports-packages.ts +59 -0
  40. package/src/ports-users.ts +230 -0
  41. package/src/ports.ts +225 -0
  42. package/src/router.ts +216 -0
  43. package/src/routes.ts +99 -0
  44. package/src/server-events.ts +71 -0
  45. 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
+ }