@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,59 @@
1
+ /**
2
+ * Services property editors call while editing: tag suggestions and embed
3
+ * markup for the rich text editor.
4
+ */
5
+ import { invalidSkipTake, problemDetails, problemResponse } from '@crombolo/core'
6
+ import type { OEmbedPort, TagPort } from '../ports.ts'
7
+ import type { ManagementApiRouter } from '../router.ts'
8
+ import { paging } from './content-type.ts'
9
+
10
+ export function registerEditorServiceHandlers(
11
+ router: ManagementApiRouter,
12
+ services: { tags?: TagPort; oembed?: OEmbedPort },
13
+ ): void {
14
+ const { tags, oembed } = services
15
+ if (tags)
16
+ router.handle('GetTag', async (ctx) => {
17
+ const page = paging(ctx)
18
+ if (!page) return problemResponse(invalidSkipTake())
19
+ const culture = ctx.url.searchParams.get('culture')
20
+ const found = await tags.list({
21
+ query: ctx.url.searchParams.get('query') ?? undefined,
22
+ group: ctx.url.searchParams.get('tagGroup') ?? undefined,
23
+ culture: culture === null ? undefined : culture || null,
24
+ })
25
+ return Response.json({
26
+ total: found.length,
27
+ items: found.slice(page.skip, page.skip + page.take).map((t) => ({
28
+ id: t.id,
29
+ text: t.text,
30
+ group: t.group,
31
+ nodeCount: t.nodeCount,
32
+ })),
33
+ })
34
+ })
35
+
36
+ if (oembed)
37
+ router.handle('GetOembedQuery', async (ctx) => {
38
+ const url = ctx.url.searchParams.get('url') ?? ''
39
+ const number = (name: string) => {
40
+ const value = Number(ctx.url.searchParams.get(name))
41
+ return Number.isFinite(value) && value > 0 ? value : undefined
42
+ }
43
+ const result = await oembed.markup(url, number('maxWidth'), number('maxHeight'))
44
+ if (result.ok) return Response.json({ markup: result.markup })
45
+ return problemResponse(
46
+ result.status === 'unsupported'
47
+ ? problemDetails({
48
+ title: 'The specified url is not supported.',
49
+ detail: result.reason,
50
+ status: 400,
51
+ })
52
+ : problemDetails({
53
+ title: 'The embed could not be retrieved.',
54
+ detail: result.reason,
55
+ status: 502,
56
+ }),
57
+ )
58
+ })
59
+ }
@@ -0,0 +1,492 @@
1
+ /**
2
+ * The Library section: Umbraco 18's Elements, and the folders they sit in.
3
+ *
4
+ * An element is publishable, versioned content with no URL and no template, so
5
+ * this mirrors the document handlers without the parts a route needs — no URLs,
6
+ * no preview, no schedules, no domains. The contract keys an element's type as
7
+ * `documentType`, because an element type *is* a content type carrying
8
+ * `isElement`; the Library offers the ones that also carry `allowedInLibrary`,
9
+ * which `document-type/allowed-in-library` answers.
10
+ *
11
+ * The tree mixes folders and elements. A folder reports `documentType: null` and a
12
+ * single `NotCreated` variant, which is the contract's way of saying there is no
13
+ * content at that row.
14
+ */
15
+ import type { DocumentAggregate, ElementTreeItem } from '@crombolo/core'
16
+ import { invalidSkipTake, notFound, problemDetails, problemResponse } from '@crombolo/core'
17
+ import type { ElementPort, PortFailure, ReferencePort } from '../ports-content.ts'
18
+ import type { ManagementApiRouter, RequestContext } from '../router.ts'
19
+ import { created, paging, registerFolderHandlers } from './content-type.ts'
20
+ import { auditLogResponse, failureResponse, readBody, toSaveDocument } from './document.ts'
21
+
22
+ const API = '/umbraco/management/api/v1'
23
+
24
+ const typeRef = (key: string, icon: string | null, collection?: string | null) => ({
25
+ id: key,
26
+ icon: icon ?? 'icon-document',
27
+ collection: collection ? { id: collection } : null,
28
+ })
29
+
30
+ export function toElementResponse(element: DocumentAggregate) {
31
+ return {
32
+ id: element.key,
33
+ isTrashed: element.isTrashed,
34
+ documentType: typeRef(
35
+ element.contentTypeKey,
36
+ element.contentTypeIcon,
37
+ element.contentTypeCollectionKey,
38
+ ),
39
+ values: element.values.map((v) => ({
40
+ alias: v.alias,
41
+ culture: v.culture,
42
+ segment: v.segment,
43
+ value: v.value,
44
+ editorAlias: v.editorAlias ?? '',
45
+ })),
46
+ variants: element.variants.map((v) => ({
47
+ id: element.key,
48
+ culture: v.culture,
49
+ segment: v.segment,
50
+ name: v.name,
51
+ state: v.state,
52
+ createDate: v.createDate.toISOString(),
53
+ updateDate: v.updateDate.toISOString(),
54
+ publishDate: v.publishDate?.toISOString() ?? null,
55
+ scheduledPublishDate: null,
56
+ scheduledUnpublishDate: null,
57
+ flags: [],
58
+ })),
59
+ flags: [],
60
+ }
61
+ }
62
+
63
+ const variantItems = (item: ElementTreeItem) =>
64
+ item.variants.map((v) => ({
65
+ id: item.key,
66
+ name: v.name,
67
+ culture: v.culture,
68
+ state: v.state,
69
+ flags: [],
70
+ }))
71
+
72
+ /** One shape serves the tree and the recycle bin; the contract's models match. */
73
+ function toElementTreeItem(item: ElementTreeItem, noAccess = false) {
74
+ return {
75
+ id: item.key,
76
+ name: item.name,
77
+ parent: item.parentKey ? { id: item.parentKey } : null,
78
+ hasChildren: item.hasChildren,
79
+ isFolder: item.isFolder,
80
+ noAccess,
81
+ createDate: item.createDate.toISOString(),
82
+ documentType: item.isFolder
83
+ ? null
84
+ : typeRef(item.contentTypeKey, item.icon, item.contentTypeCollectionKey),
85
+ variants: variantItems(item),
86
+ flags: [],
87
+ }
88
+ }
89
+
90
+ function toElementItem(item: ElementTreeItem) {
91
+ return {
92
+ id: item.key,
93
+ isTrashed: item.isTrashed,
94
+ parent: item.parentKey ? { id: item.parentKey } : null,
95
+ hasChildren: item.hasChildren,
96
+ documentType: item.isFolder
97
+ ? null
98
+ : typeRef(item.contentTypeKey, item.icon, item.contentTypeCollectionKey),
99
+ variants: variantItems(item),
100
+ flags: [],
101
+ }
102
+ }
103
+
104
+ const readCultures = (body: Record<string, unknown>): string[] | null => {
105
+ const raw = body.cultures ?? body.culturesToPublish
106
+ if (!Array.isArray(raw)) return null
107
+ const cultures = raw.filter((c): c is string => typeof c === 'string' && c.length > 0)
108
+ return cultures.length > 0 ? cultures : null
109
+ }
110
+
111
+ const refId = (value: unknown): string | null => {
112
+ if (!value || typeof value !== 'object') return null
113
+ const id = (value as Record<string, unknown>).id
114
+ return typeof id === 'string' && id ? id : null
115
+ }
116
+
117
+ export function registerElementHandlers(
118
+ router: ManagementApiRouter,
119
+ port: ElementPort,
120
+ /** For the warning a folder delete shows: what points into the branch. */
121
+ references?: ReferencePort,
122
+ ): void {
123
+ const has = (id: string) => router.operations.some((o) => o.operationId === id)
124
+ const handle = (id: string, handler: Parameters<ManagementApiRouter['handle']>[1]) => {
125
+ if (has(id)) router.handle(id, handler)
126
+ }
127
+ const foldersOnly = (ctx: RequestContext) => ctx.url.searchParams.get('foldersOnly') === 'true'
128
+ const failed = (title: string, result: PortFailure) =>
129
+ problemResponse(problemDetails({ title, status: result.status ?? 400, detail: result.reason }))
130
+ const done = (ctx: RequestContext, message: string) => {
131
+ ctx.notifications.push({ message, category: 'Element', type: 'Success' })
132
+ return new Response(null, { status: 200 })
133
+ }
134
+
135
+ registerFolderHandlers(router, 'Element', port.folders)
136
+
137
+ // ── the tree ──────────────────────────────────────────────────────────────
138
+ handle('GetTreeElementRoot', async (ctx) => {
139
+ const page = paging(ctx)
140
+ if (!page) return problemResponse(invalidSkipTake())
141
+ const result = await port.treeRoot({ ...page, foldersOnly: foldersOnly(ctx) })
142
+ return Response.json({
143
+ total: result.total,
144
+ items: result.items.map((i) => toElementTreeItem(i)),
145
+ })
146
+ })
147
+
148
+ handle('GetTreeElementChildren', async (ctx) => {
149
+ const page = paging(ctx)
150
+ if (!page) return problemResponse(invalidSkipTake())
151
+ const parentId = ctx.url.searchParams.get('parentId')
152
+ if (!parentId)
153
+ return problemResponse(problemDetails({ title: 'parentId is required', status: 400 }))
154
+ const result = await port.treeChildren(parentId, { ...page, foldersOnly: foldersOnly(ctx) })
155
+ return Response.json({
156
+ total: result.total,
157
+ items: result.items.map((i) => toElementTreeItem(i)),
158
+ })
159
+ })
160
+
161
+ handle('GetTreeElementAncestors', async (ctx) => {
162
+ const descendantId = ctx.url.searchParams.get('descendantId')
163
+ if (!descendantId) return Response.json([])
164
+ return Response.json((await port.ancestors(descendantId)).map((i) => toElementTreeItem(i)))
165
+ })
166
+
167
+ handle('GetTreeElementSiblings', async (ctx) => {
168
+ const target = ctx.url.searchParams.get('target')
169
+ if (!target)
170
+ return problemResponse(problemDetails({ title: 'target is required', status: 400 }))
171
+ const before = Number(ctx.url.searchParams.get('before') ?? 0)
172
+ const after = Number(ctx.url.searchParams.get('after') ?? 0)
173
+ const found = await port.treeSiblings(target, before, after)
174
+ if (!found) return problemResponse(notFound('The element could not be found'))
175
+ return Response.json({
176
+ totalBefore: found.totalBefore,
177
+ totalAfter: found.totalAfter,
178
+ items: found.items.map((i) => toElementTreeItem(i)),
179
+ })
180
+ })
181
+
182
+ // ── item lookups a picker makes ───────────────────────────────────────────
183
+ handle('GetItemElement', async (ctx) => {
184
+ const keys = ctx.url.searchParams.getAll('id')
185
+ return Response.json((await port.items(keys)).map(toElementItem))
186
+ })
187
+
188
+ handle('GetItemElementAncestors', async (ctx) => {
189
+ const descendantId = ctx.url.searchParams.get('descendantId')
190
+ if (!descendantId) return Response.json([])
191
+ return Response.json((await port.ancestors(descendantId)).map(toElementItem))
192
+ })
193
+
194
+ handle('GetItemElementSearch', async (ctx) => {
195
+ const page = paging(ctx)
196
+ if (!page) return problemResponse(invalidSkipTake())
197
+ const query = ctx.url.searchParams.get('query') ?? ''
198
+ const parentId = ctx.url.searchParams.get('parentId')
199
+ const result = await port.search(query, { ...page, parentKey: parentId ?? undefined })
200
+ return Response.json({ total: result.total, items: result.items.map(toElementItem) })
201
+ })
202
+
203
+ // ── the editor ────────────────────────────────────────────────────────────
204
+ handle('GetElementConfiguration', () =>
205
+ Response.json({
206
+ disableDeleteWhenReferenced: false,
207
+ disableUnpublishWhenReferenced: false,
208
+ allowEditInvariantFromNonDefault: true,
209
+ allowNonExistingSegmentsCreation: false,
210
+ }),
211
+ )
212
+
213
+ handle('GetElementById', async (ctx) => {
214
+ const element = await port.byKey(ctx.params.id as string)
215
+ if (!element) return problemResponse(notFound('The element could not be found'))
216
+ return Response.json(toElementResponse(element))
217
+ })
218
+
219
+ handle('GetElementByIdPublished', async (ctx) => {
220
+ const element = await port.byKeyPublished(ctx.params.id as string)
221
+ if (!element) return problemResponse(notFound('The element is not published'))
222
+ return Response.json(toElementResponse(element))
223
+ })
224
+
225
+ handle('PostElement', async (ctx) => {
226
+ const body = await readBody(ctx)
227
+ const key = typeof body.id === 'string' && body.id ? body.id : crypto.randomUUID()
228
+ const input = toSaveDocument(key, body)
229
+ const result = await port.create(input, ctx.principal as NonNullable<typeof ctx.principal>)
230
+ if (!result.ok) return failureResponse('Could not create the element', result, input.values)
231
+ ctx.notifications.push({ message: 'Element created', category: 'Element', type: 'Success' })
232
+ return created(`${ctx.url.origin}${API}/element/${result.key}`, result.key)
233
+ })
234
+
235
+ handle('PutElementById', async (ctx) => {
236
+ const key = ctx.params.id as string
237
+ const existing = await port.byKey(key)
238
+ if (!existing) return problemResponse(notFound('The element could not be found'))
239
+ const body = await readBody(ctx)
240
+ const input = { ...toSaveDocument(key, body), contentTypeKey: existing.contentTypeKey }
241
+ const result = await port.update(key, input, ctx.principal as NonNullable<typeof ctx.principal>)
242
+ if (!result.ok) return failureResponse('Could not save the element', result, input.values)
243
+ return done(ctx, 'Element saved')
244
+ })
245
+
246
+ handle('PostElementCreateAndPublish', async (ctx) => {
247
+ const body = await readBody(ctx)
248
+ const key = typeof body.id === 'string' && body.id ? body.id : crypto.randomUUID()
249
+ const input = toSaveDocument(key, body)
250
+ const result = await port.createAndPublish(
251
+ input,
252
+ readCultures(body),
253
+ ctx.principal as NonNullable<typeof ctx.principal>,
254
+ )
255
+ if (!result.ok) return failureResponse('Could not create the element', result, input.values)
256
+ ctx.notifications.push({ message: 'Element published', category: 'Element', type: 'Success' })
257
+ return created(`${ctx.url.origin}${API}/element/${result.key}`, result.key)
258
+ })
259
+
260
+ handle('PutElementByIdUpdateAndPublish', async (ctx) => {
261
+ const key = ctx.params.id as string
262
+ const body = await readBody(ctx)
263
+ const existing = await port.byKey(key)
264
+ if (!existing) return problemResponse(notFound('The element could not be found'))
265
+ const input = { ...toSaveDocument(key, body), contentTypeKey: existing.contentTypeKey }
266
+ const result = await port.updateAndPublish(
267
+ key,
268
+ input,
269
+ readCultures(body),
270
+ ctx.principal as NonNullable<typeof ctx.principal>,
271
+ )
272
+ if (!result.ok) return failureResponse('Could not publish the element', result, input.values)
273
+ return done(ctx, 'Element published')
274
+ })
275
+
276
+ const validateHandler = async (ctx: RequestContext) => {
277
+ const body = await readBody(ctx)
278
+ const key = (ctx.params.id as string | undefined) ?? 'validate'
279
+ const input = toSaveDocument(key, body)
280
+ const errors = await port.validate(input, readCultures(body))
281
+ if (errors.length === 0) return new Response(null, { status: 200 })
282
+ return failureResponse(
283
+ 'One or more properties did not pass validation',
284
+ { ok: false, reason: 'invalid', status: 400, errors },
285
+ input.values,
286
+ )
287
+ }
288
+ handle('PostElementValidate', validateHandler)
289
+ handle('PutElementByIdValidate', validateHandler)
290
+
291
+ handle('PutElementByIdPublish', async (ctx) => {
292
+ const key = ctx.params.id as string
293
+ if (!(await port.byKey(key))) return problemResponse(notFound('The element could not be found'))
294
+ const result = await port.publish(key, readCultures(await readBody(ctx)))
295
+ return result.ok
296
+ ? done(ctx, 'Element published')
297
+ : failed('Could not publish the element', result)
298
+ })
299
+
300
+ handle('PutElementByIdUnpublish', async (ctx) => {
301
+ const key = ctx.params.id as string
302
+ if (!(await port.byKey(key))) return problemResponse(notFound('The element could not be found'))
303
+ const result = await port.unpublish(key, readCultures(await readBody(ctx)))
304
+ return result.ok
305
+ ? done(ctx, 'Element unpublished')
306
+ : failed('Could not unpublish the element', result)
307
+ })
308
+
309
+ // ── moving, copying and the bin ───────────────────────────────────────────
310
+ handle('PutElementByIdMove', async (ctx) => {
311
+ const result = await port.move(ctx.params.id as string, refId((await readBody(ctx)).target))
312
+ return result.ok ? done(ctx, 'Element moved') : failed('Could not move the element', result)
313
+ })
314
+
315
+ handle('PostElementByIdCopy', async (ctx) => {
316
+ const body = await readBody(ctx)
317
+ const result = await port.copy(
318
+ ctx.params.id as string,
319
+ refId(body.target),
320
+ { includeDescendants: body.includeDescendants === true },
321
+ ctx.principal as NonNullable<typeof ctx.principal>,
322
+ )
323
+ if (!result.ok) return failed('Could not copy the element', result)
324
+ ctx.notifications.push({ message: 'Element copied', category: 'Element', type: 'Success' })
325
+ return created(`${ctx.url.origin}${API}/element/${result.key}`, result.key)
326
+ })
327
+
328
+ handle('PutElementByIdMoveToRecycleBin', async (ctx) => {
329
+ const moved = await port.moveToRecycleBin(ctx.params.id as string)
330
+ if (!moved) return problemResponse(notFound('The element could not be found'))
331
+ return done(ctx, 'Moved to the recycle bin')
332
+ })
333
+
334
+ handle('DeleteElementById', async (ctx) => {
335
+ const removed = await port.remove(ctx.params.id as string)
336
+ if (!removed) return problemResponse(notFound('The element could not be found'))
337
+ return done(ctx, 'Element deleted')
338
+ })
339
+
340
+ handle('PutElementFolderByIdMove', async (ctx) => {
341
+ const result = await port.moveFolder(
342
+ ctx.params.id as string,
343
+ refId((await readBody(ctx)).target),
344
+ )
345
+ return result.ok ? done(ctx, 'Folder moved') : failed('Could not move the folder', result)
346
+ })
347
+
348
+ handle('PutElementFolderByIdMoveToRecycleBin', async (ctx) => {
349
+ const moved = await port.folderToRecycleBin(ctx.params.id as string)
350
+ if (!moved) return problemResponse(notFound('The folder could not be found'))
351
+ return done(ctx, 'Moved to the recycle bin')
352
+ })
353
+
354
+ // The contract scopes this to a folder rather than to an element, so the generic
355
+ // reference handlers (which register `Get<Area>ByIdReferencedDescendants`) do not
356
+ // cover it.
357
+ if (references)
358
+ handle('GetElementFolderByIdReferencedDescendants', async (ctx) => {
359
+ const page = paging(ctx)
360
+ if (!page) return problemResponse(invalidSkipTake())
361
+ const result = await references.referencedDescendants(ctx.params.id as string, page)
362
+ return Response.json({ total: result.total, items: result.items })
363
+ })
364
+
365
+ handle('GetItemElementFolder', async (ctx) =>
366
+ Response.json((await port.folderItems(ctx.url.searchParams.getAll('id'))).map(toElementItem)),
367
+ )
368
+
369
+ handle('GetRecycleBinElementRoot', async (ctx) => {
370
+ const page = paging(ctx)
371
+ if (!page) return problemResponse(invalidSkipTake())
372
+ const result = await port.recycleBin(null, page)
373
+ return Response.json({
374
+ total: result.total,
375
+ items: result.items.map((i) => toElementTreeItem(i)),
376
+ })
377
+ })
378
+
379
+ handle('GetRecycleBinElementChildren', async (ctx) => {
380
+ const page = paging(ctx)
381
+ if (!page) return problemResponse(invalidSkipTake())
382
+ const parentId = ctx.url.searchParams.get('parentId')
383
+ if (!parentId)
384
+ return problemResponse(problemDetails({ title: 'parentId is required', status: 400 }))
385
+ const result = await port.recycleBin(parentId, page)
386
+ return Response.json({
387
+ total: result.total,
388
+ items: result.items.map((i) => toElementTreeItem(i)),
389
+ })
390
+ })
391
+
392
+ handle('GetRecycleBinElementSiblings', async (ctx) => {
393
+ const target = ctx.url.searchParams.get('target')
394
+ if (!target)
395
+ return problemResponse(problemDetails({ title: 'target is required', status: 400 }))
396
+ const found = await port.recycleBinSiblings(
397
+ target,
398
+ Number(ctx.url.searchParams.get('before') ?? 0),
399
+ Number(ctx.url.searchParams.get('after') ?? 0),
400
+ )
401
+ if (!found) return problemResponse(notFound('The element could not be found'))
402
+ return Response.json({
403
+ totalBefore: found.totalBefore,
404
+ totalAfter: found.totalAfter,
405
+ items: found.items.map((i) => toElementTreeItem(i)),
406
+ })
407
+ })
408
+
409
+ for (const operation of [
410
+ 'GetRecycleBinElementByIdOriginalParent',
411
+ 'GetRecycleBinElementFolderByIdOriginalParent',
412
+ ])
413
+ handle(operation, async (ctx) => {
414
+ const parent = await port.originalParent(ctx.params.id as string)
415
+ if (parent === undefined) return problemResponse(notFound('The item could not be found'))
416
+ return Response.json(parent ? { id: parent } : null)
417
+ })
418
+
419
+ for (const operation of [
420
+ 'PutRecycleBinElementByIdRestore',
421
+ 'PutRecycleBinElementFolderByIdRestore',
422
+ ])
423
+ handle(operation, async (ctx) => {
424
+ const body = await readBody(ctx)
425
+ const result = await port.restore(
426
+ ctx.params.id as string,
427
+ 'target' in body ? refId(body.target) : undefined,
428
+ )
429
+ return result.ok
430
+ ? done(ctx, 'Element restored')
431
+ : failed('Could not restore the element', result)
432
+ })
433
+
434
+ handle('DeleteRecycleBinElement', async (ctx) => {
435
+ await port.emptyRecycleBin()
436
+ return done(ctx, 'Recycle bin emptied')
437
+ })
438
+
439
+ for (const operation of ['DeleteRecycleBinElementById', 'DeleteRecycleBinElementFolderById'])
440
+ handle(operation, async (ctx) => {
441
+ const removed = await port.remove(ctx.params.id as string)
442
+ if (!removed) return problemResponse(notFound('The item could not be found'))
443
+ return done(ctx, 'Element deleted')
444
+ })
445
+
446
+ // ── history ───────────────────────────────────────────────────────────────
447
+ handle('GetElementByIdAuditLog', (ctx) => auditLogResponse(ctx, port))
448
+
449
+ handle('GetElementVersion', async (ctx) => {
450
+ const elementId = ctx.url.searchParams.get('elementId')
451
+ if (!elementId)
452
+ return problemResponse(problemDetails({ title: 'elementId is required', status: 400 }))
453
+ const page = paging(ctx)
454
+ if (!page) return problemResponse(invalidSkipTake())
455
+ const versions = await port.versions(elementId)
456
+ const element = await port.byKey(elementId)
457
+ const documentType = { id: element?.contentTypeKey ?? '' }
458
+ const fallbackUser = await port.systemUserKey()
459
+ return Response.json({
460
+ total: versions.length,
461
+ items: versions.slice(page.skip, page.skip + page.take).map((version) => ({
462
+ id: version.id,
463
+ element: { id: version.documentKey },
464
+ documentType,
465
+ user: { id: version.userKey ?? fallbackUser },
466
+ versionDate: version.versionDate.toISOString(),
467
+ isCurrentPublishedVersion: version.isCurrentPublished,
468
+ isCurrentDraftVersion: version.isCurrentDraft,
469
+ preventCleanup: version.preventCleanup,
470
+ })),
471
+ })
472
+ })
473
+
474
+ handle('GetElementVersionById', async (ctx) => {
475
+ const found = await port.atVersion(ctx.params.id as string)
476
+ if (!found) return problemResponse(notFound('The version could not be found'))
477
+ return Response.json(toElementResponse(found.document))
478
+ })
479
+
480
+ handle('PostElementVersionByIdRollback', async (ctx) => {
481
+ const rolled = await port.rollback(ctx.params.id as string)
482
+ if (!rolled) return problemResponse(notFound('The version could not be found'))
483
+ return done(ctx, 'Element rolled back')
484
+ })
485
+
486
+ handle('PutElementVersionByIdPreventCleanup', async (ctx) => {
487
+ const body = await readBody(ctx)
488
+ const set = await port.setPreventCleanup(ctx.params.id as string, body.preventCleanup === true)
489
+ if (!set) return problemResponse(notFound('The version could not be found'))
490
+ return new Response(null, { status: 200 })
491
+ })
492
+ }