@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,784 @@
1
+ /**
2
+ * Content types: the tree, item lookups, and the aggregate CRUD the editor uses.
3
+ */
4
+
5
+ import type { ResponseOf } from '@crombolo/contracts'
6
+ import {
7
+ type ContentTypeAggregate,
8
+ invalidSkipTake,
9
+ isSystemContentType,
10
+ isSystemMediaType,
11
+ notFound,
12
+ parseSkipTake,
13
+ pendingLabel,
14
+ problemDetails,
15
+ problemResponse,
16
+ systemFieldViolation,
17
+ type TreeItem,
18
+ } from '@crombolo/core'
19
+ import type { ContentTypeKind, ContentTypePort, FolderPort, SkipTake } from '../ports-content.ts'
20
+ import type { ManagementApiRouter, RequestContext } from '../router.ts'
21
+
22
+ type DocumentTypeResponse = ResponseOf<'GetDocumentTypeById'>
23
+
24
+ /** Umbraco returns 201 with an empty body, `Location`, and the new id. */
25
+ export function created(location: string, id: string): Response {
26
+ return new Response(null, {
27
+ status: 201,
28
+ headers: { location, 'umb-generated-resource': id },
29
+ })
30
+ }
31
+
32
+ export function paging(ctx: RequestContext): SkipTake | undefined {
33
+ const parsed = parseSkipTake(ctx.url.searchParams)
34
+ return parsed.ok ? parsed.value : undefined
35
+ }
36
+
37
+ export function toTreeItemResponse(item: TreeItem) {
38
+ return {
39
+ hasChildren: item.hasChildren,
40
+ id: item.key,
41
+ parent: item.parentKey ? { id: item.parentKey } : null,
42
+ name: item.name,
43
+ icon: item.icon,
44
+ isFolder: item.isFolder,
45
+ isElement: item.isElement ?? false,
46
+ flags: [],
47
+ }
48
+ }
49
+
50
+ export function toContentTypeResponse(aggregate: ContentTypeAggregate): DocumentTypeResponse {
51
+ return {
52
+ id: aggregate.key,
53
+ alias: aggregate.alias,
54
+ name: aggregate.name,
55
+ description: aggregate.description,
56
+ icon: aggregate.icon,
57
+ allowedAsRoot: aggregate.allowedAsRoot,
58
+ variesByCulture: aggregate.variesByCulture,
59
+ variesBySegment: aggregate.variesBySegment,
60
+ isElement: aggregate.isElement,
61
+ allowedInLibrary: aggregate.allowedInLibrary,
62
+ collection: aggregate.collectionKey ? { id: aggregate.collectionKey } : null,
63
+ cleanup: {
64
+ preventCleanup: aggregate.cleanup.preventCleanup,
65
+ keepAllVersionsNewerThanDays: aggregate.cleanup.keepAllVersionsNewerThanDays,
66
+ keepLatestVersionPerDayForDays: aggregate.cleanup.keepLatestVersionPerDayForDays,
67
+ },
68
+ properties: aggregate.properties.map((property) => ({
69
+ id: property.key,
70
+ container: property.containerKey ? { id: property.containerKey } : null,
71
+ sortOrder: property.sortOrder,
72
+ alias: property.alias,
73
+ name: property.pending
74
+ ? `${property.name} (${pendingLabel(property.pending)})`
75
+ : property.name,
76
+ description: property.pending
77
+ ? `${pendingLabel(property.pending).replace(/^g/, 'G')}: editable now, not required or rendered until then.${property.description ? ` ${property.description}` : ''}`
78
+ : property.description,
79
+ dataType: { id: property.dataTypeKey },
80
+ variesByCulture: property.variesByCulture,
81
+ variesBySegment: property.variesBySegment,
82
+ validation: {
83
+ mandatory: property.mandatory,
84
+ mandatoryMessage: property.mandatoryMessage,
85
+ regEx: property.regEx,
86
+ regExMessage: property.regExMessage,
87
+ },
88
+ appearance: { labelOnTop: property.labelOnTop },
89
+ })),
90
+ containers: aggregate.containers.map((container) => ({
91
+ id: container.key,
92
+ parent: container.parentKey ? { id: container.parentKey } : null,
93
+ name: container.name,
94
+ type: container.type,
95
+ sortOrder: container.sortOrder,
96
+ })),
97
+ compositions: aggregate.compositions.map((composition) => ({
98
+ documentType: { id: composition.contentTypeKey },
99
+ compositionType: composition.compositionType,
100
+ })),
101
+ allowedDocumentTypes: aggregate.allowedContentTypes.map((allowed) => ({
102
+ documentType: { id: allowed.contentTypeKey },
103
+ sortOrder: allowed.sortOrder,
104
+ })),
105
+ allowedTemplates: aggregate.allowedComponentKeys.map((id) => ({ id })),
106
+ defaultTemplate: aggregate.defaultComponentKey ? { id: aggregate.defaultComponentKey } : null,
107
+ } as DocumentTypeResponse
108
+ }
109
+
110
+ /** The media-type wire shape: no templates or cleanup, references keyed `mediaType`. */
111
+ export function toMediaTypeResponse(aggregate: ContentTypeAggregate) {
112
+ const {
113
+ allowedTemplates: _templates,
114
+ defaultTemplate: _default,
115
+ cleanup: _cleanup,
116
+ allowedDocumentTypes,
117
+ compositions,
118
+ ...rest
119
+ } = toContentTypeResponse(aggregate) as DocumentTypeResponse & Record<string, unknown>
120
+ return {
121
+ ...rest,
122
+ compositions: (
123
+ compositions as Array<{ documentType: { id: string }; compositionType: string }>
124
+ ).map((c) => ({ mediaType: c.documentType, compositionType: c.compositionType })),
125
+ allowedMediaTypes: (
126
+ allowedDocumentTypes as Array<{ documentType: { id: string }; sortOrder: number }>
127
+ ).map((a) => ({ mediaType: a.documentType, sortOrder: a.sortOrder })),
128
+ isDeletable: !isSystemMediaType(aggregate.key),
129
+ aliasCanBeChanged: !isSystemMediaType(aggregate.key),
130
+ }
131
+ }
132
+
133
+ /**
134
+ * The member-type wire shape: no allowed list, templates or cleanup,
135
+ * references keyed `memberType`, and each property's sensitivity and what the
136
+ * member may see and edit of it.
137
+ */
138
+ export function toMemberTypeResponse(aggregate: ContentTypeAggregate) {
139
+ const {
140
+ allowedTemplates: _templates,
141
+ defaultTemplate: _default,
142
+ cleanup: _cleanup,
143
+ allowedDocumentTypes: _allowed,
144
+ compositions,
145
+ properties,
146
+ ...rest
147
+ } = toContentTypeResponse(aggregate) as DocumentTypeResponse & Record<string, unknown>
148
+ const flags = new Map(aggregate.properties.map((p) => [p.key, p]))
149
+ return {
150
+ ...rest,
151
+ compositions: (
152
+ compositions as Array<{ documentType: { id: string }; compositionType: string }>
153
+ ).map((c) => ({ memberType: c.documentType, compositionType: c.compositionType })),
154
+ properties: (properties as Array<Record<string, unknown> & { id: string }>).map((p) => ({
155
+ ...p,
156
+ isSensitive: flags.get(p.id)?.isSensitive === true,
157
+ visibility: {
158
+ memberCanView: flags.get(p.id)?.memberCanView === true,
159
+ memberCanEdit: flags.get(p.id)?.memberCanEdit === true,
160
+ },
161
+ })),
162
+ }
163
+ }
164
+
165
+ /** Maps a create/update request body onto the domain aggregate. */
166
+ export function fromContentTypeRequest(
167
+ key: string,
168
+ body: Record<string, unknown>,
169
+ kind: ContentTypeKind = 'document',
170
+ ): ContentTypeAggregate {
171
+ // References are `{ documentType: { id } }`, `{ mediaType: … }` or `{ memberType: … }`.
172
+ const refKey = { document: 'documentType', media: 'mediaType', member: 'memberType' }[kind]
173
+ // Member types allow no children, so the field is absent and the list empty.
174
+ const allowedKey = { document: 'allowedDocumentTypes', media: 'allowedMediaTypes', member: '' }[
175
+ kind
176
+ ]
177
+ const ref = (value: unknown): string | null =>
178
+ value && typeof value === 'object' && 'id' in value
179
+ ? String((value as { id: unknown }).id)
180
+ : null
181
+ const list = (value: unknown): unknown[] => (Array.isArray(value) ? value : [])
182
+ /**
183
+ * An element type is never routed and never sits in the content tree, so the
184
+ * fields a route needs are dropped rather than stored. The editor disables those
185
+ * controls for an element type, but a type flipped to one keeps whatever it had —
186
+ * and a file written with them would then be refused at boot by the schema
187
+ * validation that says an element type has no template.
188
+ */
189
+ const isElement = body.isElement === true
190
+
191
+ return {
192
+ key,
193
+ alias: String(body.alias ?? ''),
194
+ name: String(body.name ?? ''),
195
+ description: (body.description as string | null) ?? null,
196
+ icon: String(body.icon ?? 'icon-document'),
197
+ allowedAsRoot: !isElement && body.allowedAsRoot === true,
198
+ variesByCulture: body.variesByCulture === true,
199
+ variesBySegment: body.variesBySegment === true,
200
+ isElement,
201
+ allowedInLibrary: body.allowedInLibrary === true,
202
+ collectionKey: ref(body.collection),
203
+ cleanup: {
204
+ preventCleanup:
205
+ (body.cleanup as { preventCleanup?: unknown } | undefined)?.preventCleanup === true,
206
+ keepAllVersionsNewerThanDays: ((
207
+ body.cleanup as { keepAllVersionsNewerThanDays?: number | null } | undefined
208
+ )?.keepAllVersionsNewerThanDays ?? null) as number | null,
209
+ keepLatestVersionPerDayForDays: ((
210
+ body.cleanup as { keepLatestVersionPerDayForDays?: number | null } | undefined
211
+ )?.keepLatestVersionPerDayForDays ?? null) as number | null,
212
+ },
213
+ containers: list(body.containers).map((raw) => {
214
+ const container = raw as Record<string, unknown>
215
+ return {
216
+ key: String(container.id ?? crypto.randomUUID()),
217
+ name: (container.name as string | null) ?? null,
218
+ alias: (container.alias as string | null) ?? null,
219
+ type: String(container.type ?? 'Group'),
220
+ sortOrder: Number(container.sortOrder ?? 0),
221
+ parentKey: ref(container.parent),
222
+ }
223
+ }),
224
+ properties: list(body.properties).map((raw) => {
225
+ const property = raw as Record<string, unknown>
226
+ const validation = (property.validation ?? {}) as Record<string, unknown>
227
+ const appearance = (property.appearance ?? {}) as Record<string, unknown>
228
+ return {
229
+ key: String(property.id ?? crypto.randomUUID()),
230
+ alias: String(property.alias ?? ''),
231
+ name: String(property.name ?? ''),
232
+ description: (property.description as string | null) ?? null,
233
+ dataTypeKey: ref(property.dataType) ?? '',
234
+ containerKey: ref(property.container),
235
+ sortOrder: Number(property.sortOrder ?? 0),
236
+ variesByCulture: property.variesByCulture === true,
237
+ variesBySegment: property.variesBySegment === true,
238
+ mandatory: validation.mandatory === true,
239
+ mandatoryMessage: (validation.mandatoryMessage as string | null) ?? null,
240
+ regEx: (validation.regEx as string | null) ?? null,
241
+ regExMessage: (validation.regExMessage as string | null) ?? null,
242
+ labelOnTop: appearance.labelOnTop === true,
243
+ ...(kind === 'member'
244
+ ? {
245
+ isSensitive: property.isSensitive === true,
246
+ memberCanView:
247
+ (property.visibility as { memberCanView?: unknown } | undefined)?.memberCanView ===
248
+ true,
249
+ memberCanEdit:
250
+ (property.visibility as { memberCanEdit?: unknown } | undefined)?.memberCanEdit ===
251
+ true,
252
+ }
253
+ : {}),
254
+ }
255
+ }),
256
+ compositions: list(body.compositions).map((raw) => {
257
+ const composition = raw as Record<string, unknown>
258
+ return {
259
+ contentTypeKey: ref(composition[refKey]) ?? '',
260
+ compositionType: (composition.compositionType === 'Inheritance'
261
+ ? 'Inheritance'
262
+ : 'Composition') as 'Composition' | 'Inheritance',
263
+ }
264
+ }),
265
+ allowedContentTypes: (isElement ? [] : list(body[allowedKey])).map((raw) => {
266
+ const allowed = raw as Record<string, unknown>
267
+ return {
268
+ contentTypeKey: ref(allowed[refKey]) ?? '',
269
+ sortOrder: Number(allowed.sortOrder ?? 0),
270
+ }
271
+ }),
272
+ allowedComponentKeys: (isElement ? [] : list(body.allowedTemplates))
273
+ .map((raw) => ref(raw))
274
+ .filter((id): id is string => id !== null),
275
+ defaultComponentKey: isElement ? null : ref(body.defaultTemplate),
276
+ parentKey: ref(body.parent),
277
+ }
278
+ }
279
+
280
+ /** Aliases a property may not use: they collide with the document's own fields. */
281
+ export const RESERVED_FIELD_NAMES = [
282
+ 'id',
283
+ 'key',
284
+ 'name',
285
+ 'level',
286
+ 'path',
287
+ 'parent',
288
+ 'parentId',
289
+ 'sortOrder',
290
+ 'createDate',
291
+ 'updateDate',
292
+ 'template',
293
+ 'url',
294
+ 'children',
295
+ 'ancestors',
296
+ 'descendants',
297
+ 'siblings',
298
+ 'content',
299
+ 'value',
300
+ 'values',
301
+ 'creator',
302
+ 'writer',
303
+ 'published',
304
+ ]
305
+
306
+ export const refOf = (value: unknown): string | null =>
307
+ value && typeof value === 'object' && 'id' in value ? String((value as { id: unknown }).id) : null
308
+
309
+ export function toContentTypeItem(aggregate: ContentTypeAggregate) {
310
+ return {
311
+ id: aggregate.key,
312
+ name: aggregate.name,
313
+ icon: aggregate.icon,
314
+ description: aggregate.description,
315
+ isElement: aggregate.isElement,
316
+ allowedInLibrary: aggregate.allowedInLibrary,
317
+ flags: [],
318
+ }
319
+ }
320
+
321
+ /** The four folder operations, identical for content types and data types. */
322
+ export function registerFolderHandlers(
323
+ router: ManagementApiRouter,
324
+ area: 'DocumentType' | 'MediaType' | 'MemberType' | 'DataType' | 'DocumentBlueprint' | 'Element',
325
+ folders: FolderPort,
326
+ toTree: (item: TreeItem) => unknown = toTreeItemResponse,
327
+ ): void {
328
+ const path = {
329
+ DocumentType: 'document-type',
330
+ MediaType: 'media-type',
331
+ MemberType: 'member-type',
332
+ DataType: 'data-type',
333
+ DocumentBlueprint: 'document-blueprint',
334
+ Element: 'element',
335
+ }[area]
336
+ // Not every area has tree search in the contract (media types do not).
337
+ const inContract = (id: string) => router.operations.some((o) => o.operationId === id)
338
+ if (inContract(`GetTree${area}Search`))
339
+ router.handle(`GetTree${area}Search`, async (ctx) => {
340
+ const page = paging(ctx)
341
+ if (!page) return problemResponse(invalidSkipTake())
342
+ const kind = ctx.url.searchParams.get('itemKind')
343
+ const result = await folders.search(
344
+ ctx.url.searchParams.get('query') ?? '',
345
+ kind === 'Item' || kind === 'Folder' ? kind : 'All',
346
+ page,
347
+ )
348
+ return Response.json({ total: result.total, items: result.items.map(toTree) })
349
+ })
350
+ router.handle(`Get${area}FolderById`, async ({ params }) => {
351
+ const folder = await folders.folder(params.id as string)
352
+ if (!folder) return problemResponse(notFound('The folder could not be found'))
353
+ return Response.json({ id: folder.key, name: folder.name, isTrashed: false })
354
+ })
355
+ router.handle(`Post${area}Folder`, async (ctx) => {
356
+ const body = (await ctx.request.json()) as Record<string, unknown>
357
+ const result = await folders.createFolder({
358
+ key: typeof body.id === 'string' && body.id ? body.id : undefined,
359
+ name: String(body.name ?? ''),
360
+ parentKey: refOf(body.parent),
361
+ })
362
+ return created(
363
+ `${ctx.url.origin}/umbraco/management/api/v1/${path}/folder/${result.key}`,
364
+ result.key,
365
+ )
366
+ })
367
+ router.handle(`Put${area}FolderById`, async (ctx) => {
368
+ const body = (await ctx.request.json()) as Record<string, unknown>
369
+ const ok = await folders.renameFolder(ctx.params.id as string, String(body.name ?? ''))
370
+ if (!ok) return problemResponse(notFound('The folder could not be found'))
371
+ return new Response(null, { status: 200 })
372
+ })
373
+ router.handle(`Delete${area}FolderById`, async ({ params }) => {
374
+ const result = await folders.deleteFolder(params.id as string)
375
+ if (result === 'not-found') return problemResponse(notFound('The folder could not be found'))
376
+ if (result === 'not-empty')
377
+ return problemResponse(
378
+ problemDetails({
379
+ title: 'The folder is not empty',
380
+ status: 400,
381
+ operationStatus: 'NotEmpty',
382
+ }),
383
+ )
384
+ return new Response(null, { status: 200 })
385
+ })
386
+ }
387
+
388
+ export function registerContentTypeHandlers(
389
+ router: ManagementApiRouter,
390
+ port: ContentTypePort,
391
+ kind: ContentTypeKind = 'document',
392
+ temporaryFiles?: {
393
+ read(id: string): Promise<{ fileName: string; bytes: Uint8Array } | undefined>
394
+ },
395
+ ): void {
396
+ const T = ({ document: 'DocumentType', media: 'MediaType', member: 'MemberType' } as const)[kind]
397
+ const segment = { document: 'document-type', media: 'media-type', member: 'member-type' }[kind]
398
+ const noun = { document: 'content type', media: 'media type', member: 'member type' }[kind]
399
+ const title = { document: 'Content Type', media: 'Media Type', member: 'Member Type' }[kind]
400
+ // Registers only what this kind's contract has (member types: no allowed children, …).
401
+ const handle = (operationId: string, handler: Parameters<ManagementApiRouter['handle']>[1]) => {
402
+ if (router.operations.some((o) => o.operationId === operationId))
403
+ router.handle(operationId, handler)
404
+ }
405
+ const toResponse = (a: ContentTypeAggregate) =>
406
+ kind === 'document'
407
+ ? toContentTypeResponse(a)
408
+ : kind === 'media'
409
+ ? toMediaTypeResponse(a)
410
+ : toMemberTypeResponse(a)
411
+ const toTree = (item: TreeItem) =>
412
+ kind === 'document'
413
+ ? toTreeItemResponse(item)
414
+ : kind === 'member'
415
+ ? { ...toTreeItemResponse(item), noAccess: false }
416
+ : {
417
+ ...toTreeItemResponse(item),
418
+ isDeletable: item.isFolder || !isSystemMediaType(item.key),
419
+ noAccess: false,
420
+ }
421
+ const toItem = (a: ContentTypeAggregate) =>
422
+ kind === 'document'
423
+ ? toContentTypeItem(a)
424
+ : { id: a.key, name: a.name, icon: a.icon, flags: [] }
425
+ handle(`Get${T}ById`, async ({ params }) => {
426
+ const aggregate = await port.byKey(params.id as string)
427
+ if (!aggregate) return problemResponse(notFound(`The ${noun} could not be found`))
428
+ return Response.json(toResponse(aggregate))
429
+ })
430
+
431
+ handle(`Post${T}`, async (ctx) => {
432
+ const body = (await ctx.request.json()) as Record<string, unknown>
433
+ const key = typeof body.id === 'string' && body.id ? body.id : crypto.randomUUID()
434
+ const result = await port.create(
435
+ fromContentTypeRequest(key, body, kind),
436
+ ctx.principal as NonNullable<typeof ctx.principal>,
437
+ )
438
+ if (!result.ok) {
439
+ return problemResponse(
440
+ problemDetails({
441
+ title: `Could not create the ${noun}`,
442
+ status: result.status ?? 400,
443
+ detail: result.reason,
444
+ }),
445
+ )
446
+ }
447
+ ctx.notifications.push({
448
+ message: `${title} created`,
449
+ category: title,
450
+ type: 'Success',
451
+ })
452
+ return created(`${ctx.url.origin}/umbraco/management/api/v1/${segment}/${key}`, key)
453
+ })
454
+
455
+ handle(`Put${T}ById`, async (ctx) => {
456
+ const key = ctx.params.id as string
457
+ const existing = await port.byKey(key)
458
+ if (!existing) {
459
+ return problemResponse(notFound(`The ${noun} could not be found`))
460
+ }
461
+ const body = (await ctx.request.json()) as Record<string, unknown>
462
+ if (kind !== 'member' && isSystemContentType(kind, key) && body.alias !== existing.alias)
463
+ return problemResponse(
464
+ problemDetails({
465
+ title: `The alias of a built-in ${noun} cannot be changed`,
466
+ status: 400,
467
+ operationStatus: 'NotAllowed',
468
+ }),
469
+ )
470
+ const aggregate = fromContentTypeRequest(key, body, kind)
471
+ const violation =
472
+ kind === 'document' ? systemFieldViolation(key, aggregate.properties) : undefined
473
+ if (violation)
474
+ return problemResponse(
475
+ problemDetails({
476
+ title: `Could not save the ${noun}`,
477
+ status: 400,
478
+ detail: violation,
479
+ operationStatus: 'NotAllowed',
480
+ }),
481
+ )
482
+ const result = await port.update(
483
+ key,
484
+ aggregate,
485
+ ctx.principal as NonNullable<typeof ctx.principal>,
486
+ )
487
+ if (!result.ok) {
488
+ return problemResponse(
489
+ problemDetails({
490
+ title: `Could not save the ${noun}`,
491
+ status: result.status ?? 400,
492
+ detail: result.reason,
493
+ }),
494
+ )
495
+ }
496
+ ctx.notifications.push({
497
+ message: `${title} saved`,
498
+ category: title,
499
+ type: 'Success',
500
+ })
501
+ return new Response(null, { status: 200 })
502
+ })
503
+
504
+ handle(`Delete${T}ById`, async (ctx) => {
505
+ if (kind === 'document' && isSystemContentType('document', ctx.params.id as string))
506
+ return problemResponse(
507
+ problemDetails({
508
+ title: 'This content type cannot be deleted',
509
+ status: 400,
510
+ detail: 'It is provided by Crombolo and always present.',
511
+ operationStatus: 'NotAllowed',
512
+ }),
513
+ )
514
+ if (kind === 'media' && isSystemMediaType(ctx.params.id as string))
515
+ return problemResponse(
516
+ problemDetails({
517
+ title: 'System media types cannot be deleted',
518
+ status: 400,
519
+ detail: 'Folder, Image and File are built in and always present.',
520
+ operationStatus: 'NotAllowed',
521
+ }),
522
+ )
523
+ const removed = await port.remove(ctx.params.id as string)
524
+ if (!removed) return problemResponse(notFound(`The ${noun} could not be found`))
525
+ ctx.notifications.push({
526
+ message: `${title} deleted`,
527
+ category: title,
528
+ type: 'Success',
529
+ })
530
+ return new Response(null, { status: 200 })
531
+ })
532
+
533
+ const pagedAggregates = async (
534
+ ctx: RequestContext,
535
+ load: (paging: SkipTake) => Promise<{ total: number; items: ContentTypeAggregate[] }>,
536
+ ) => {
537
+ const page = paging(ctx)
538
+ if (!page) return problemResponse(invalidSkipTake())
539
+ const result = await load(page)
540
+ return Response.json({
541
+ total: result.total,
542
+ items: result.items.map(toResponse),
543
+ })
544
+ }
545
+
546
+ handle(`Get${T}AllowedAtRoot`, (ctx) => pagedAggregates(ctx, (page) => port.allowedAtRoot(page)))
547
+ // `parentKey` narrows the result through Umbraco's content type filters, an
548
+ // extensibility point with no implementations in core, so it is accepted and
549
+ // has no effect — as it does there.
550
+ handle(`Get${T}AllowedInLibrary`, (ctx) =>
551
+ pagedAggregates(ctx, (page) => port.allowedInLibrary(page)),
552
+ )
553
+ handle(`Get${T}ByIdAllowedChildren`, (ctx) =>
554
+ pagedAggregates(ctx, (page) => port.allowedChildren(ctx.params.id as string, page)),
555
+ )
556
+
557
+ handle(`Get${T}ByIdSchema`, async (ctx) => {
558
+ const schema = await port.jsonSchema(ctx.params.id as string)
559
+ if (!schema) return problemResponse(notFound(`The ${noun} could not be found`))
560
+ return Response.json(schema)
561
+ })
562
+
563
+ handle(`Get${T}ByIdExport`, async (ctx) => {
564
+ const xml = await port.exportUdt(ctx.params.id as string)
565
+ if (!xml) return problemResponse(notFound(`The ${noun} could not be found`))
566
+ const aggregate = await port.byKey(ctx.params.id as string)
567
+ return new Response(xml, {
568
+ headers: {
569
+ 'content-type': 'application/octet-stream',
570
+ 'content-disposition': `attachment; filename="${aggregate?.alias ?? 'export'}.udt"`,
571
+ },
572
+ })
573
+ })
574
+
575
+ const importFrom = async (ctx: RequestContext, targetKey?: string) => {
576
+ const body = ((await ctx.request.json().catch(() => ({}))) ?? {}) as Record<string, unknown>
577
+ const fileId = refOf(body.file)
578
+ if (!fileId)
579
+ return problemResponse(problemDetails({ title: 'A file is required', status: 400 }))
580
+ const file = await temporaryFiles?.read(fileId)
581
+ if (!file) return problemResponse(notFound('The file could not be found'))
582
+ const result = await port.importUdt(new TextDecoder().decode(file.bytes), targetKey)
583
+ if (result.status === 'not-found')
584
+ return problemResponse(notFound(`The ${noun} could not be found`))
585
+ if (result.status !== 'ok')
586
+ return problemResponse(
587
+ problemDetails({
588
+ title: result.status === 'mismatch' ? 'The file does not match' : 'The file is not valid',
589
+ detail: result.reason,
590
+ status: 400,
591
+ }),
592
+ )
593
+ ctx.notifications.push({ message: `${title} imported`, category: title, type: 'Success' })
594
+ return result
595
+ }
596
+
597
+ handle(`Post${T}Import`, async (ctx) => {
598
+ const result = await importFrom(ctx)
599
+ if (result instanceof Response) return result
600
+ return created(
601
+ `${ctx.url.origin}/umbraco/management/api/v1/${segment}/${result.key}`,
602
+ result.key,
603
+ )
604
+ })
605
+
606
+ handle(`Put${T}ByIdImport`, async (ctx) => {
607
+ const result = await importFrom(ctx, ctx.params.id as string)
608
+ return result instanceof Response ? result : new Response(null, { status: 200 })
609
+ })
610
+
611
+ const foldersOnly = (ctx: RequestContext) => ctx.url.searchParams.get('foldersOnly') === 'true'
612
+
613
+ handle(`GetTree${T}Root`, async (ctx) => {
614
+ const page = paging(ctx)
615
+ if (!page) return problemResponse(invalidSkipTake())
616
+ const result = await port.treeRoot({ ...page, foldersOnly: foldersOnly(ctx) })
617
+ return Response.json({ total: result.total, items: result.items.map(toTree) })
618
+ })
619
+
620
+ handle(`GetTree${T}Children`, async (ctx) => {
621
+ const page = paging(ctx)
622
+ if (!page) return problemResponse(invalidSkipTake())
623
+ const parentId = ctx.url.searchParams.get('parentId')
624
+ if (!parentId)
625
+ return problemResponse(problemDetails({ title: 'parentId is required', status: 400 }))
626
+ const result = await port.treeChildren(parentId, { ...page, foldersOnly: foldersOnly(ctx) })
627
+ return Response.json({ total: result.total, items: result.items.map(toTree) })
628
+ })
629
+
630
+ handle(`GetTree${T}Siblings`, async (ctx) => {
631
+ const target = ctx.url.searchParams.get('target')
632
+ if (!target)
633
+ return problemResponse(problemDetails({ title: 'target is required', status: 400 }))
634
+ const window = await port.folders.siblings(
635
+ target,
636
+ Number(ctx.url.searchParams.get('before') ?? 0),
637
+ Number(ctx.url.searchParams.get('after') ?? 0),
638
+ foldersOnly(ctx),
639
+ )
640
+ if (!window) return problemResponse(notFound(`The ${noun} could not be found`))
641
+ return Response.json({ ...window, items: window.items.map(toTree) })
642
+ })
643
+
644
+ handle(`Get${T}Configuration`, () =>
645
+ Response.json(
646
+ kind === 'document'
647
+ ? {
648
+ dataTypesCanBeChanged: 'True',
649
+ disableTemplates: false,
650
+ useSegments: false,
651
+ reservedFieldNames: RESERVED_FIELD_NAMES,
652
+ }
653
+ : { reservedFieldNames: RESERVED_FIELD_NAMES },
654
+ ),
655
+ )
656
+
657
+ handle(`Get${T}Batch`, async (ctx) => {
658
+ const items = await port.byKeys(ctx.url.searchParams.getAll('id'))
659
+ return Response.json({ total: items.length, items: items.map(toResponse) })
660
+ })
661
+
662
+ handle(`GetItem${T}Search`, async (ctx) => {
663
+ const page = paging(ctx)
664
+ if (!page) return problemResponse(invalidSkipTake())
665
+ const isElementParam = ctx.url.searchParams.get('isElement')
666
+ const result = await port.search(ctx.url.searchParams.get('query') ?? '', {
667
+ ...page,
668
+ isElement: isElementParam === null ? undefined : isElementParam === 'true',
669
+ })
670
+ return Response.json({ total: result.total, items: result.items.map(toItem) })
671
+ })
672
+
673
+ handle(`Post${T}AvailableCompositions`, async (ctx) => {
674
+ const body = (await ctx.request.json()) as Record<string, unknown>
675
+ const available = await port.availableCompositions({
676
+ key: typeof body.id === 'string' ? body.id : null,
677
+ isElement: body.isElement === true,
678
+ currentPropertyAliases: Array.isArray(body.currentPropertyAliases)
679
+ ? (body.currentPropertyAliases as unknown[]).map(String)
680
+ : [],
681
+ currentCompositeKeys: Array.isArray(body.currentCompositeIds)
682
+ ? (body.currentCompositeIds as unknown[]).map(String)
683
+ : [],
684
+ })
685
+ return Response.json(
686
+ available.map((a) => ({
687
+ id: a.type.key,
688
+ name: a.type.name,
689
+ icon: a.type.icon,
690
+ folderPath: a.folderPath,
691
+ isCompatible: a.isCompatible,
692
+ })),
693
+ )
694
+ })
695
+
696
+ handle(`Get${T}ByIdCompositionReferences`, async ({ params }) => {
697
+ const refs = await port.compositionReferences(params.id as string)
698
+ return Response.json(refs.map((t) => ({ id: t.key, name: t.name, icon: t.icon })))
699
+ })
700
+
701
+ handle(`Get${T}ByIdAllowedParents`, async ({ params }) => {
702
+ const ids = await port.allowedParents(params.id as string)
703
+ return Response.json({ allowedParentIds: ids.map((id) => ({ id })) })
704
+ })
705
+
706
+ handle(`Put${T}ByIdMove`, async (ctx) => {
707
+ const body = (await ctx.request.json()) as Record<string, unknown>
708
+ const moved = await port.move(ctx.params.id as string, refOf(body.target))
709
+ if (!moved) return problemResponse(notFound(`The ${noun} could not be found`))
710
+ return new Response(null, { status: 200 })
711
+ })
712
+
713
+ handle(`Post${T}ByIdCopy`, async (ctx) => {
714
+ const body = (await ctx.request.json()) as Record<string, unknown>
715
+ const copy = await port.copy(ctx.params.id as string, refOf(body.target))
716
+ if (!copy) return problemResponse(notFound(`The ${noun} could not be found`))
717
+ return created(`${ctx.url.origin}/umbraco/management/api/v1/${segment}/${copy.key}`, copy.key)
718
+ })
719
+
720
+ // Media types have no templates.
721
+ if (kind === 'document')
722
+ router.handle(`Post${T}ByIdTemplate`, async (ctx) => {
723
+ const body = (await ctx.request.json()) as Record<string, unknown>
724
+ const result = await port.createTemplate(
725
+ ctx.params.id as string,
726
+ {
727
+ alias: String(body.alias ?? ''),
728
+ name: String(body.name ?? body.alias ?? ''),
729
+ isDefault: body.isDefault === true,
730
+ },
731
+ ctx.principal as NonNullable<typeof ctx.principal>,
732
+ )
733
+ if (!result.ok)
734
+ return problemResponse(
735
+ problemDetails({
736
+ title: 'Could not create the template',
737
+ status: result.status ?? 400,
738
+ detail: result.reason,
739
+ }),
740
+ )
741
+ return created(
742
+ `${ctx.url.origin}/umbraco/management/api/v1/template/${result.key}`,
743
+ result.key,
744
+ )
745
+ })
746
+
747
+ registerFolderHandlers(router, T, port.folders, toTree)
748
+
749
+ handle(`GetTree${T}Ancestors`, async (ctx) => {
750
+ const descendantId = ctx.url.searchParams.get('descendantId')
751
+ // Umbraco binds a missing id to an empty GUID and answers with no ancestors.
752
+ if (!descendantId) return Response.json([])
753
+ return Response.json((await port.ancestors(descendantId)).map(toTree))
754
+ })
755
+
756
+ handle(`GetItem${T}Ancestors`, async (ctx) => {
757
+ const result = []
758
+ for (const id of ctx.url.searchParams.getAll('id'))
759
+ result.push({
760
+ id,
761
+ ancestors: (await port.ancestors(id)).map((a) => ({ id: a.key, name: a.name, flags: [] })),
762
+ })
763
+ return Response.json(result)
764
+ })
765
+
766
+ handle(`GetItem${T}`, async (ctx) => {
767
+ const keys = ctx.url.searchParams.getAll('id')
768
+ const items = await port.items(keys)
769
+ return Response.json(
770
+ items.map((item) =>
771
+ kind === 'document'
772
+ ? {
773
+ id: item.key,
774
+ name: item.name,
775
+ icon: item.icon,
776
+ isElement: item.isElement ?? false,
777
+ description: null,
778
+ flags: [],
779
+ }
780
+ : { id: item.key, name: item.name, icon: item.icon, flags: [] },
781
+ ),
782
+ )
783
+ })
784
+ }