@crombolo/api-management 0.0.0-stage → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/NOTICE +69 -0
- package/package.json +32 -4
- package/src/authorization.ts +453 -0
- package/src/handlers/content-type.ts +784 -0
- package/src/handlers/data-type.ts +267 -0
- package/src/handlers/dictionary.ts +245 -0
- package/src/handlers/document-blueprint.ts +193 -0
- package/src/handlers/document.ts +961 -0
- package/src/handlers/editor-services.ts +59 -0
- package/src/handlers/element.ts +492 -0
- package/src/handlers/file-systems.ts +207 -0
- package/src/handlers/import.ts +46 -0
- package/src/handlers/localization.ts +116 -0
- package/src/handlers/log-viewer.ts +125 -0
- package/src/handlers/manifest.ts +15 -0
- package/src/handlers/media.ts +453 -0
- package/src/handlers/member-group.ts +87 -0
- package/src/handlers/member.ts +189 -0
- package/src/handlers/models-builder.ts +29 -0
- package/src/handlers/package.ts +193 -0
- package/src/handlers/public-access.ts +107 -0
- package/src/handlers/published-cache.ts +27 -0
- package/src/handlers/redirects.ts +78 -0
- package/src/handlers/references.ts +44 -0
- package/src/handlers/server.ts +25 -0
- package/src/handlers/settings-stubs.ts +76 -0
- package/src/handlers/start-node-trees.ts +84 -0
- package/src/handlers/template-query.ts +191 -0
- package/src/handlers/template.ts +150 -0
- package/src/handlers/temporary-file.ts +62 -0
- package/src/handlers/user.ts +604 -0
- package/src/index.ts +17 -0
- package/src/ports-content.ts +564 -0
- package/src/ports-files.ts +51 -0
- package/src/ports-logs.ts +38 -0
- package/src/ports-members.ts +111 -0
- package/src/ports-models-builder.ts +26 -0
- package/src/ports-packages.ts +59 -0
- package/src/ports-users.ts +230 -0
- package/src/ports.ts +225 -0
- package/src/router.ts +216 -0
- package/src/routes.ts +99 -0
- package/src/server-events.ts +71 -0
- package/README.md +0 -3
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Media section: the tree, the editor, the collection view, the recycle bin
|
|
3
|
+
* and the file URLs its thumbnails and pickers need.
|
|
4
|
+
*/
|
|
5
|
+
import type { DocumentAggregate, DocumentTreeItem } from '@crombolo/core'
|
|
6
|
+
import {
|
|
7
|
+
invalidSkipTake,
|
|
8
|
+
notFound,
|
|
9
|
+
problemDetails,
|
|
10
|
+
problemResponse,
|
|
11
|
+
ROOT_ACCESS,
|
|
12
|
+
} from '@crombolo/core'
|
|
13
|
+
import type { MediaPort, PortFailure } from '../ports-content.ts'
|
|
14
|
+
import type { ManagementApiRouter, RequestContext } from '../router.ts'
|
|
15
|
+
import { created, paging } from './content-type.ts'
|
|
16
|
+
import { auditLogResponse, readBody, toSaveDocument, validationProblem } from './document.ts'
|
|
17
|
+
import { visibleChildren, visibleRoot, visibleSiblings } from './start-node-trees.ts'
|
|
18
|
+
|
|
19
|
+
const API = '/umbraco/management/api/v1'
|
|
20
|
+
|
|
21
|
+
const mediaTypeRef = (key: string, icon: string | null, collection?: string | null) => ({
|
|
22
|
+
id: key,
|
|
23
|
+
icon: icon ?? 'icon-picture',
|
|
24
|
+
collection: collection ? { id: collection } : null,
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
export function toMediaResponse(media: DocumentAggregate) {
|
|
28
|
+
return {
|
|
29
|
+
id: media.key,
|
|
30
|
+
isTrashed: media.isTrashed,
|
|
31
|
+
mediaType: mediaTypeRef(
|
|
32
|
+
media.contentTypeKey,
|
|
33
|
+
media.contentTypeIcon,
|
|
34
|
+
media.contentTypeCollectionKey,
|
|
35
|
+
),
|
|
36
|
+
values: media.values.map((v) => ({
|
|
37
|
+
alias: v.alias,
|
|
38
|
+
culture: v.culture,
|
|
39
|
+
segment: v.segment,
|
|
40
|
+
value: v.value,
|
|
41
|
+
editorAlias: v.editorAlias ?? '',
|
|
42
|
+
})),
|
|
43
|
+
variants: media.variants.map((v) => ({
|
|
44
|
+
culture: v.culture,
|
|
45
|
+
segment: v.segment,
|
|
46
|
+
name: v.name,
|
|
47
|
+
createDate: v.createDate.toISOString(),
|
|
48
|
+
updateDate: v.updateDate.toISOString(),
|
|
49
|
+
})),
|
|
50
|
+
flags: [],
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const variantsOf = (item: DocumentTreeItem) =>
|
|
55
|
+
item.variants.map((v) => ({ name: v.name, culture: v.culture }))
|
|
56
|
+
|
|
57
|
+
function toMediaTreeItem(item: DocumentTreeItem, noAccess = false) {
|
|
58
|
+
return {
|
|
59
|
+
id: item.key,
|
|
60
|
+
parent: item.parentKey ? { id: item.parentKey } : null,
|
|
61
|
+
hasChildren: item.hasChildren,
|
|
62
|
+
noAccess,
|
|
63
|
+
isTrashed: item.isTrashed,
|
|
64
|
+
createDate: item.createDate.toISOString(),
|
|
65
|
+
mediaType: mediaTypeRef(item.contentTypeKey, item.icon, item.contentTypeCollectionKey),
|
|
66
|
+
variants: variantsOf(item),
|
|
67
|
+
flags: [],
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function toMediaItem(item: DocumentTreeItem) {
|
|
72
|
+
return {
|
|
73
|
+
id: item.key,
|
|
74
|
+
isTrashed: item.isTrashed,
|
|
75
|
+
parent: item.parentKey ? { id: item.parentKey } : null,
|
|
76
|
+
hasChildren: item.hasChildren,
|
|
77
|
+
mediaType: mediaTypeRef(item.contentTypeKey, item.icon, item.contentTypeCollectionKey),
|
|
78
|
+
variants: variantsOf(item),
|
|
79
|
+
flags: [],
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function toMediaRecycleBinItem(item: DocumentTreeItem) {
|
|
84
|
+
return {
|
|
85
|
+
id: item.key,
|
|
86
|
+
createDate: item.createDate.toISOString(),
|
|
87
|
+
hasChildren: item.hasChildren,
|
|
88
|
+
parent: item.parentKey ? { id: item.parentKey } : null,
|
|
89
|
+
mediaType: mediaTypeRef(item.contentTypeKey, item.icon, item.contentTypeCollectionKey),
|
|
90
|
+
variants: variantsOf(item),
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const refId = (value: unknown): string | null => {
|
|
95
|
+
if (!value || typeof value !== 'object') return null
|
|
96
|
+
const id = (value as Record<string, unknown>).id
|
|
97
|
+
return typeof id === 'string' && id ? id : null
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const failed = (title: string, result: PortFailure) =>
|
|
101
|
+
problemResponse(problemDetails({ title, status: result.status ?? 400, detail: result.reason }))
|
|
102
|
+
|
|
103
|
+
/** A media request as the document save shape: `mediaType` names the type. */
|
|
104
|
+
function toSaveMedia(key: string, body: Record<string, unknown>) {
|
|
105
|
+
return {
|
|
106
|
+
...toSaveDocument(key, body),
|
|
107
|
+
contentTypeKey: refId(body.mediaType) ?? '',
|
|
108
|
+
componentKey: null,
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function registerMediaHandlers(router: ManagementApiRouter, port: MediaPort): void {
|
|
113
|
+
const mediaStart = (ctx: RequestContext) => ctx.principal?.startNodes.media ?? ROOT_ACCESS
|
|
114
|
+
|
|
115
|
+
const principal = (ctx: RequestContext) => ctx.principal as NonNullable<typeof ctx.principal>
|
|
116
|
+
const done = (ctx: RequestContext, message?: string) => {
|
|
117
|
+
if (message) ctx.notifications.push({ message, category: 'Media', type: 'Success' })
|
|
118
|
+
return new Response(null, { status: 200 })
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
router.handle('GetItemMediaTypeAllowed', async (ctx) => {
|
|
122
|
+
const page = paging(ctx)
|
|
123
|
+
if (!page) return problemResponse(invalidSkipTake())
|
|
124
|
+
const found = await port.typesForExtension(ctx.url.searchParams.get('fileExtension') ?? '')
|
|
125
|
+
return Response.json({
|
|
126
|
+
total: found.length,
|
|
127
|
+
items: found.slice(page.skip, page.skip + page.take).map((t) => ({
|
|
128
|
+
id: t.key,
|
|
129
|
+
name: t.name,
|
|
130
|
+
icon: t.icon,
|
|
131
|
+
matchedFileExtension: t.matched,
|
|
132
|
+
flags: [],
|
|
133
|
+
})),
|
|
134
|
+
})
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
router.handle('GetItemMediaTypeFolders', async (ctx) => {
|
|
138
|
+
const page = paging(ctx)
|
|
139
|
+
if (!page) return problemResponse(invalidSkipTake())
|
|
140
|
+
const found = await port.folderTypes()
|
|
141
|
+
return Response.json({
|
|
142
|
+
total: found.length,
|
|
143
|
+
items: found
|
|
144
|
+
.slice(page.skip, page.skip + page.take)
|
|
145
|
+
.map((t) => ({ id: t.key, name: t.name, icon: t.icon, flags: [] })),
|
|
146
|
+
})
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
router.handle('GetMediaConfiguration', () =>
|
|
150
|
+
Response.json({ disableDeleteWhenReferenced: false, disableUnpublishWhenReferenced: false }),
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
router.handle('GetMediaById', async ({ params }) => {
|
|
154
|
+
const media = await port.byKey(params.id as string)
|
|
155
|
+
if (!media) return problemResponse(notFound('The media item could not be found'))
|
|
156
|
+
return Response.json(toMediaResponse(media))
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
router.handle('PostMediaValidate', async (ctx) => {
|
|
160
|
+
const body = await readBody(ctx)
|
|
161
|
+
const input = toSaveMedia(crypto.randomUUID(), body)
|
|
162
|
+
const errors = await port.validate(input)
|
|
163
|
+
if (errors.length > 0) return problemResponse(validationProblem(errors, input.values))
|
|
164
|
+
return new Response(null, { status: 200 })
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
router.handle('PutMediaByIdValidate', async (ctx) => {
|
|
168
|
+
const key = ctx.params.id as string
|
|
169
|
+
const existing = await port.byKey(key)
|
|
170
|
+
if (!existing) return problemResponse(notFound('The media item could not be found'))
|
|
171
|
+
const input = {
|
|
172
|
+
...toSaveMedia(key, await readBody(ctx)),
|
|
173
|
+
contentTypeKey: existing.contentTypeKey,
|
|
174
|
+
}
|
|
175
|
+
const errors = await port.validate(input)
|
|
176
|
+
if (errors.length > 0) return problemResponse(validationProblem(errors, input.values))
|
|
177
|
+
return new Response(null, { status: 200 })
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
router.handle('PostMedia', async (ctx) => {
|
|
181
|
+
const body = await readBody(ctx)
|
|
182
|
+
const key = typeof body.id === 'string' && body.id ? body.id : crypto.randomUUID()
|
|
183
|
+
const input = toSaveMedia(key, body)
|
|
184
|
+
const errors = await port.validate(input)
|
|
185
|
+
if (errors.length > 0) return problemResponse(validationProblem(errors, input.values))
|
|
186
|
+
const result = await port.create(input, principal(ctx))
|
|
187
|
+
if (!result.ok) return failed('Could not create the media item', result)
|
|
188
|
+
return created(`${ctx.url.origin}${API}/media/${result.key}`, result.key)
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
router.handle('PutMediaById', async (ctx) => {
|
|
192
|
+
const key = ctx.params.id as string
|
|
193
|
+
const existing = await port.byKey(key)
|
|
194
|
+
if (!existing) return problemResponse(notFound('The media item could not be found'))
|
|
195
|
+
const input = {
|
|
196
|
+
...toSaveMedia(key, await readBody(ctx)),
|
|
197
|
+
contentTypeKey: existing.contentTypeKey,
|
|
198
|
+
parentKey: existing.parentKey,
|
|
199
|
+
}
|
|
200
|
+
const errors = await port.validate(input)
|
|
201
|
+
if (errors.length > 0) return problemResponse(validationProblem(errors, input.values))
|
|
202
|
+
const result = await port.update(key, input, principal(ctx))
|
|
203
|
+
if (!result.ok) return failed('Could not save the media item', result)
|
|
204
|
+
return done(ctx)
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
router.handle('DeleteMediaById', async (ctx) => {
|
|
208
|
+
if (!(await port.remove(ctx.params.id as string)))
|
|
209
|
+
return problemResponse(notFound('The media item could not be found'))
|
|
210
|
+
return done(ctx)
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
router.handle('PutMediaByIdMoveToRecycleBin', async (ctx) => {
|
|
214
|
+
if (!(await port.moveToRecycleBin(ctx.params.id as string)))
|
|
215
|
+
return problemResponse(notFound('The media item could not be found'))
|
|
216
|
+
return done(ctx)
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
router.handle('PutMediaByIdMove', async (ctx) => {
|
|
220
|
+
const result = await port.move(ctx.params.id as string, refId((await readBody(ctx)).target))
|
|
221
|
+
return result.ok ? done(ctx) : failed('Could not move the media item', result)
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
router.handle('PutMediaSort', async (ctx) => {
|
|
225
|
+
const body = await readBody(ctx)
|
|
226
|
+
if (!Array.isArray(body.sorting))
|
|
227
|
+
return problemResponse(problemDetails({ title: 'sorting is required', status: 400 }))
|
|
228
|
+
const sorting = (body.sorting as Array<Record<string, unknown>>).map((item) => ({
|
|
229
|
+
key: String(item.id ?? ''),
|
|
230
|
+
sortOrder: Number(item.sortOrder ?? 0),
|
|
231
|
+
}))
|
|
232
|
+
const result = await port.sort(refId(body.parent), sorting)
|
|
233
|
+
return result.ok ? done(ctx) : failed('Could not sort', result)
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
const sortByField = async (ctx: RequestContext, parentKey: string | null) => {
|
|
237
|
+
const body = await readBody(ctx)
|
|
238
|
+
const field = String(body.field ?? '')
|
|
239
|
+
const direction = String(body.direction ?? '')
|
|
240
|
+
if (!['Name', 'CreateDate', 'UpdateDate'].includes(field))
|
|
241
|
+
return problemResponse(problemDetails({ title: 'Unknown sort field', status: 400 }))
|
|
242
|
+
if (!['Ascending', 'Descending'].includes(direction))
|
|
243
|
+
return problemResponse(problemDetails({ title: 'Unknown sort direction', status: 400 }))
|
|
244
|
+
const result = await port.sortChildrenBy(
|
|
245
|
+
parentKey,
|
|
246
|
+
field as 'Name' | 'CreateDate' | 'UpdateDate',
|
|
247
|
+
direction as 'Ascending' | 'Descending',
|
|
248
|
+
)
|
|
249
|
+
return result.ok ? done(ctx) : failed('Could not sort', result)
|
|
250
|
+
}
|
|
251
|
+
router.handle('PutMediaByIdSortChildren', (ctx) => sortByField(ctx, ctx.params.id as string))
|
|
252
|
+
router.handle('PutMediaRootSortChildren', (ctx) => sortByField(ctx, null))
|
|
253
|
+
|
|
254
|
+
router.handle('GetMediaByIdAuditLog', (ctx) => auditLogResponse(ctx, port))
|
|
255
|
+
|
|
256
|
+
/** Thumbnail and crop URLs in the query vocabulary the media route understands. */
|
|
257
|
+
router.handle('GetImagingResizeUrls', async (ctx) => {
|
|
258
|
+
const params = new URLSearchParams()
|
|
259
|
+
const width = ctx.url.searchParams.get('width')
|
|
260
|
+
const height = ctx.url.searchParams.get('height')
|
|
261
|
+
const mode = ctx.url.searchParams.get('mode')
|
|
262
|
+
const format = ctx.url.searchParams.get('format')
|
|
263
|
+
if (width) params.set('width', width)
|
|
264
|
+
if (height) params.set('height', height)
|
|
265
|
+
if (mode) params.set('rmode', mode.toLowerCase())
|
|
266
|
+
if (format) params.set('format', format)
|
|
267
|
+
const query = params.toString()
|
|
268
|
+
const found = await port.urls(ctx.url.searchParams.getAll('id'))
|
|
269
|
+
return Response.json(
|
|
270
|
+
found.map((m) => ({
|
|
271
|
+
id: m.key,
|
|
272
|
+
urlInfos: m.url ? [{ culture: null, url: query ? `${m.url}?${query}` : m.url }] : [],
|
|
273
|
+
})),
|
|
274
|
+
)
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
router.handle('GetMediaUrls', async (ctx) => {
|
|
278
|
+
const found = await port.urls(ctx.url.searchParams.getAll('id'))
|
|
279
|
+
return Response.json(
|
|
280
|
+
found.map((m) => ({
|
|
281
|
+
id: m.key,
|
|
282
|
+
urlInfos: m.url ? [{ culture: null, url: m.url }] : [],
|
|
283
|
+
})),
|
|
284
|
+
)
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
// The tree and items
|
|
288
|
+
router.handle('GetTreeMediaRoot', async (ctx) => {
|
|
289
|
+
const page = paging(ctx)
|
|
290
|
+
if (!page) return problemResponse(invalidSkipTake())
|
|
291
|
+
const result = await visibleRoot(mediaStart(ctx), port, page)
|
|
292
|
+
return Response.json({
|
|
293
|
+
total: result.total,
|
|
294
|
+
items: result.items.map((v) => toMediaTreeItem(v.item, v.noAccess)),
|
|
295
|
+
})
|
|
296
|
+
})
|
|
297
|
+
router.handle('GetTreeMediaChildren', async (ctx) => {
|
|
298
|
+
const page = paging(ctx)
|
|
299
|
+
if (!page) return problemResponse(invalidSkipTake())
|
|
300
|
+
const parentId = ctx.url.searchParams.get('parentId')
|
|
301
|
+
if (!parentId)
|
|
302
|
+
return problemResponse(problemDetails({ title: 'parentId is required', status: 400 }))
|
|
303
|
+
const result = await visibleChildren(mediaStart(ctx), port, parentId, page)
|
|
304
|
+
return Response.json({
|
|
305
|
+
total: result.total,
|
|
306
|
+
items: result.items.map((v) => toMediaTreeItem(v.item, v.noAccess)),
|
|
307
|
+
})
|
|
308
|
+
})
|
|
309
|
+
router.handle('GetTreeMediaAncestors', async (ctx) => {
|
|
310
|
+
const descendantId = ctx.url.searchParams.get('descendantId')
|
|
311
|
+
if (!descendantId) return Response.json([])
|
|
312
|
+
return Response.json((await port.treeAncestors(descendantId)).map((i) => toMediaTreeItem(i)))
|
|
313
|
+
})
|
|
314
|
+
router.handle('GetTreeMediaSiblings', async (ctx) => {
|
|
315
|
+
const target = ctx.url.searchParams.get('target')
|
|
316
|
+
if (!target)
|
|
317
|
+
return problemResponse(problemDetails({ title: 'target is required', status: 400 }))
|
|
318
|
+
const found = await port.treeSiblings(
|
|
319
|
+
target,
|
|
320
|
+
Number(ctx.url.searchParams.get('before') ?? 0),
|
|
321
|
+
Number(ctx.url.searchParams.get('after') ?? 0),
|
|
322
|
+
)
|
|
323
|
+
if (!found) return problemResponse(notFound('The media item could not be found'))
|
|
324
|
+
const window = await visibleSiblings(mediaStart(ctx), port, found)
|
|
325
|
+
return Response.json({
|
|
326
|
+
totalBefore: window.totalBefore,
|
|
327
|
+
totalAfter: window.totalAfter,
|
|
328
|
+
items: window.items.map((v) => toMediaTreeItem(v.item, v.noAccess)),
|
|
329
|
+
})
|
|
330
|
+
})
|
|
331
|
+
router.handle('GetItemMedia', async (ctx) =>
|
|
332
|
+
Response.json((await port.items(ctx.url.searchParams.getAll('id'))).map(toMediaItem)),
|
|
333
|
+
)
|
|
334
|
+
router.handle('GetItemMediaAncestors', async (ctx) => {
|
|
335
|
+
const result = []
|
|
336
|
+
for (const id of ctx.url.searchParams.getAll('id'))
|
|
337
|
+
result.push({ id, ancestors: (await port.treeAncestors(id)).map(toMediaItem) })
|
|
338
|
+
return Response.json(result)
|
|
339
|
+
})
|
|
340
|
+
router.handle('GetItemMediaSearch', async (ctx) => {
|
|
341
|
+
const page = paging(ctx)
|
|
342
|
+
if (!page) return problemResponse(invalidSkipTake())
|
|
343
|
+
const parentId = ctx.url.searchParams.get('parentId')
|
|
344
|
+
const result = await port.search(ctx.url.searchParams.get('query') ?? '', {
|
|
345
|
+
trashed: ctx.url.searchParams.get('trashed') === 'true',
|
|
346
|
+
parentKey: parentId,
|
|
347
|
+
...page,
|
|
348
|
+
})
|
|
349
|
+
return Response.json({ total: result.total, items: result.items.map(toMediaItem) })
|
|
350
|
+
})
|
|
351
|
+
|
|
352
|
+
router.handle('GetCollectionMedia', async (ctx) => {
|
|
353
|
+
const page = paging(ctx)
|
|
354
|
+
if (!page) return problemResponse(invalidSkipTake())
|
|
355
|
+
const direction =
|
|
356
|
+
ctx.url.searchParams.get('orderDirection') === 'Descending' ? 'Descending' : 'Ascending'
|
|
357
|
+
const result = await port.collection(ctx.url.searchParams.get('id'), {
|
|
358
|
+
...page,
|
|
359
|
+
filter: ctx.url.searchParams.get('filter') ?? undefined,
|
|
360
|
+
orderBy: ctx.url.searchParams.get('orderBy') ?? 'sortOrder',
|
|
361
|
+
orderDirection: direction,
|
|
362
|
+
})
|
|
363
|
+
return Response.json({
|
|
364
|
+
total: result.total,
|
|
365
|
+
items: result.items.map((media) => ({
|
|
366
|
+
...toMediaResponse(media),
|
|
367
|
+
mediaType: {
|
|
368
|
+
...mediaTypeRef(
|
|
369
|
+
media.contentTypeKey,
|
|
370
|
+
media.contentTypeIcon,
|
|
371
|
+
media.contentTypeCollectionKey,
|
|
372
|
+
),
|
|
373
|
+
alias: media.contentTypeAlias,
|
|
374
|
+
},
|
|
375
|
+
creator: media.creator,
|
|
376
|
+
sortOrder: media.sortOrder,
|
|
377
|
+
})),
|
|
378
|
+
})
|
|
379
|
+
})
|
|
380
|
+
|
|
381
|
+
// The recycle bin
|
|
382
|
+
router.handle('GetRecycleBinMediaRoot', async (ctx) => {
|
|
383
|
+
const page = paging(ctx)
|
|
384
|
+
if (!page) return problemResponse(invalidSkipTake())
|
|
385
|
+
// Only root access reaches the recycle bin
|
|
386
|
+
if (!mediaStart(ctx).root) return Response.json({ total: 0, items: [] })
|
|
387
|
+
const result = await port.recycleBin(null, page)
|
|
388
|
+
return Response.json({ total: result.total, items: result.items.map(toMediaRecycleBinItem) })
|
|
389
|
+
})
|
|
390
|
+
router.handle('GetRecycleBinMediaChildren', async (ctx) => {
|
|
391
|
+
const page = paging(ctx)
|
|
392
|
+
if (!page) return problemResponse(invalidSkipTake())
|
|
393
|
+
const parentId = ctx.url.searchParams.get('parentId')
|
|
394
|
+
if (!parentId)
|
|
395
|
+
return problemResponse(problemDetails({ title: 'parentId is required', status: 400 }))
|
|
396
|
+
if (!mediaStart(ctx).root) return Response.json({ total: 0, items: [] })
|
|
397
|
+
const result = await port.recycleBin(parentId, page)
|
|
398
|
+
return Response.json({ total: result.total, items: result.items.map(toMediaRecycleBinItem) })
|
|
399
|
+
})
|
|
400
|
+
router.handle('GetRecycleBinMediaSiblings', async (ctx) => {
|
|
401
|
+
const target = ctx.url.searchParams.get('target')
|
|
402
|
+
if (!target)
|
|
403
|
+
return problemResponse(problemDetails({ title: 'target is required', status: 400 }))
|
|
404
|
+
const window = await port.treeSiblings(
|
|
405
|
+
target,
|
|
406
|
+
Number(ctx.url.searchParams.get('before') ?? 0),
|
|
407
|
+
Number(ctx.url.searchParams.get('after') ?? 0),
|
|
408
|
+
)
|
|
409
|
+
if (!window) return problemResponse(notFound('The media item could not be found'))
|
|
410
|
+
return Response.json({
|
|
411
|
+
totalBefore: window.totalBefore,
|
|
412
|
+
totalAfter: window.totalAfter,
|
|
413
|
+
items: window.items.map(toMediaRecycleBinItem),
|
|
414
|
+
})
|
|
415
|
+
})
|
|
416
|
+
router.handle('GetRecycleBinMediaByIdOriginalParent', async ({ params }) => {
|
|
417
|
+
const parent = await port.originalParent(params.id as string)
|
|
418
|
+
if (parent === undefined) return problemResponse(notFound('The media item could not be found'))
|
|
419
|
+
return Response.json(parent ? { id: parent } : null)
|
|
420
|
+
})
|
|
421
|
+
router.handle('PutRecycleBinMediaByIdRestore', async (ctx) => {
|
|
422
|
+
const body = await readBody(ctx)
|
|
423
|
+
const target = 'target' in body ? refId(body.target) : undefined
|
|
424
|
+
const result = await port.restore(ctx.params.id as string, target ?? undefined)
|
|
425
|
+
return result.ok ? done(ctx) : failed('Could not restore', result)
|
|
426
|
+
})
|
|
427
|
+
router.handle('DeleteRecycleBinMediaById', async (ctx) => {
|
|
428
|
+
const key = ctx.params.id as string
|
|
429
|
+
const media = await port.byKey(key)
|
|
430
|
+
if (!media) return problemResponse(notFound('The media item could not be found'))
|
|
431
|
+
if (!media.isTrashed)
|
|
432
|
+
return problemResponse(
|
|
433
|
+
problemDetails({ title: 'The media item is not in the recycle bin', status: 400 }),
|
|
434
|
+
)
|
|
435
|
+
await port.remove(key)
|
|
436
|
+
return done(ctx)
|
|
437
|
+
})
|
|
438
|
+
router.handle('DeleteRecycleBinMedia', async (ctx) => {
|
|
439
|
+
await port.emptyRecycleBin()
|
|
440
|
+
return done(ctx)
|
|
441
|
+
})
|
|
442
|
+
|
|
443
|
+
// References arrive with relations (story 046); nothing is known to refer yet.
|
|
444
|
+
for (const operation of [
|
|
445
|
+
'GetMediaByIdReferencedBy',
|
|
446
|
+
'GetMediaByIdReferencedDescendants',
|
|
447
|
+
'GetMediaAreReferenced',
|
|
448
|
+
])
|
|
449
|
+
router.handle(operation, (ctx) => {
|
|
450
|
+
if (!paging(ctx)) return problemResponse(invalidSkipTake())
|
|
451
|
+
return Response.json({ total: 0, items: [] })
|
|
452
|
+
})
|
|
453
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Member groups: the seven operations the contract declares for them — CRUD,
|
|
3
|
+
* the item lookup pickers use, and a tree root. There is no children, ancestors
|
|
4
|
+
* or siblings operation, because member groups are a flat list.
|
|
5
|
+
*/
|
|
6
|
+
import {
|
|
7
|
+
invalidSkipTake,
|
|
8
|
+
notFound,
|
|
9
|
+
paged,
|
|
10
|
+
parseSkipTake,
|
|
11
|
+
problemDetails,
|
|
12
|
+
problemResponse,
|
|
13
|
+
} from '@crombolo/core'
|
|
14
|
+
import type { MemberGroupPort, MemberGroupStatus } from '../ports-members.ts'
|
|
15
|
+
import type { ManagementApiRouter, RequestContext } from '../router.ts'
|
|
16
|
+
import { created } from './content-type.ts'
|
|
17
|
+
|
|
18
|
+
const V1 = '/umbraco/management/api/v1'
|
|
19
|
+
|
|
20
|
+
const keysOf = (ctx: RequestContext) => ctx.url.searchParams.getAll('id')
|
|
21
|
+
|
|
22
|
+
function statusResponse(status: MemberGroupStatus): Response {
|
|
23
|
+
if (status === 'ok') return new Response(null, { status: 200 })
|
|
24
|
+
if (status === 'not-found')
|
|
25
|
+
return problemResponse(notFound('The member group could not be found'))
|
|
26
|
+
return problemResponse(
|
|
27
|
+
problemDetails({
|
|
28
|
+
title: 'Duplicate member group name',
|
|
29
|
+
detail: 'Another member group already uses this name.',
|
|
30
|
+
status: 400,
|
|
31
|
+
operationStatus: 'DuplicateName',
|
|
32
|
+
}),
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function registerMemberGroupHandlers(
|
|
37
|
+
router: ManagementApiRouter,
|
|
38
|
+
groups: MemberGroupPort,
|
|
39
|
+
): void {
|
|
40
|
+
const paging = (ctx: RequestContext) => parseSkipTake(ctx.url.searchParams)
|
|
41
|
+
|
|
42
|
+
router.handle('GetMemberGroup', async (ctx) => {
|
|
43
|
+
const page = paging(ctx)
|
|
44
|
+
if (!page.ok) return problemResponse(invalidSkipTake())
|
|
45
|
+
const result = await groups.list(page.value)
|
|
46
|
+
return Response.json(paged(result.items, result.total))
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
router.handle('GetMemberGroupById', async (ctx) => {
|
|
50
|
+
const group = await groups.byKey(ctx.params.id as string)
|
|
51
|
+
// The contract gives this one a bare 404, with no problem details.
|
|
52
|
+
if (!group) return new Response(null, { status: 404 })
|
|
53
|
+
return Response.json(group)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
router.handle('PostMemberGroup', async (ctx) => {
|
|
57
|
+
const body = ((await ctx.request.json().catch(() => ({}))) ?? {}) as Record<string, unknown>
|
|
58
|
+
const name = typeof body.name === 'string' ? body.name.trim() : ''
|
|
59
|
+
if (!name) return problemResponse(problemDetails({ title: 'A name is required', status: 400 }))
|
|
60
|
+
const result = await groups.create({
|
|
61
|
+
key: typeof body.id === 'string' && body.id ? body.id : undefined,
|
|
62
|
+
name,
|
|
63
|
+
})
|
|
64
|
+
if (result.status !== 'ok' || !result.key) return statusResponse(result.status)
|
|
65
|
+
return created(`${ctx.url.origin}${V1}/member-group/${result.key}`, result.key)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
router.handle('PutMemberGroupById', async (ctx) => {
|
|
69
|
+
const body = ((await ctx.request.json().catch(() => ({}))) ?? {}) as Record<string, unknown>
|
|
70
|
+
const name = typeof body.name === 'string' ? body.name.trim() : ''
|
|
71
|
+
if (!name) return problemResponse(problemDetails({ title: 'A name is required', status: 400 }))
|
|
72
|
+
return statusResponse(await groups.rename(ctx.params.id as string, name))
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
router.handle('DeleteMemberGroupById', async (ctx) =>
|
|
76
|
+
statusResponse(await groups.remove(ctx.params.id as string)),
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
router.handle('GetItemMemberGroup', async (ctx) => Response.json(await groups.items(keysOf(ctx))))
|
|
80
|
+
|
|
81
|
+
router.handle('GetTreeMemberGroupRoot', async (ctx) => {
|
|
82
|
+
const page = paging(ctx)
|
|
83
|
+
if (!page.ok) return problemResponse(invalidSkipTake())
|
|
84
|
+
const result = await groups.tree(page.value)
|
|
85
|
+
return Response.json(paged(result.items, result.total))
|
|
86
|
+
})
|
|
87
|
+
}
|