@crombolo/api-management 0.10.0 → 0.12.1

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.
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Health checks, telemetry, profiling and the server's troubleshooting and
3
+ * upgrade-check answers. Telemetry and profiling are fixed: nothing is
4
+ * collected or sent, and there is no profiler. docs/diagnostics.md.
5
+ */
6
+ import { invalidSkipTake, notFound, problemDetails, problemResponse } from '@crombolo/core'
7
+ import type { DiagnosticsPort } from '../ports-diagnostics.ts'
8
+ import type { ManagementApiRouter } from '../router.ts'
9
+ import { paging } from './content-type.ts'
10
+ import { readBody } from './document.ts'
11
+
12
+ const TELEMETRY_LEVEL = 'Minimal'
13
+
14
+ const badRequest = (title: string, detail: string) => problemDetails({ title, detail, status: 400 })
15
+
16
+ export function registerTelemetryAndProfilingHandlers(router: ManagementApiRouter): void {
17
+ router.handle('GetTelemetry', (ctx) => {
18
+ const page = paging(ctx)
19
+ if (!page) return problemResponse(invalidSkipTake())
20
+ const items = [{ telemetryLevel: TELEMETRY_LEVEL }]
21
+ return Response.json({
22
+ total: items.length,
23
+ items: items.slice(page.skip, page.skip + page.take),
24
+ })
25
+ })
26
+ router.handle('GetTelemetryLevel', () => Response.json({ telemetryLevel: TELEMETRY_LEVEL }))
27
+ router.handle('PostTelemetryLevel', async (ctx) => {
28
+ const { telemetryLevel } = await readBody(ctx)
29
+ if (telemetryLevel === TELEMETRY_LEVEL) return new Response(null, { status: 200 })
30
+ return problemResponse(
31
+ badRequest(
32
+ 'Telemetry cannot be raised',
33
+ 'This site collects no telemetry and sends nothing anywhere, so the level stays at Minimal.',
34
+ ),
35
+ )
36
+ })
37
+ router.handle('GetProfilingStatus', () => Response.json({ enabled: false }))
38
+ router.handle('PutProfilingStatus', async (ctx) => {
39
+ const { enabled } = await readBody(ctx)
40
+ if (enabled === false) return new Response(null, { status: 200 })
41
+ return problemResponse(
42
+ badRequest('Profiling is not available', 'This site has no profiler to switch on.'),
43
+ )
44
+ })
45
+ }
46
+
47
+ export function registerDiagnosticsHandlers(
48
+ router: ManagementApiRouter,
49
+ diagnostics: DiagnosticsPort,
50
+ ): void {
51
+ const groupNamed = (name: string) =>
52
+ diagnostics.groups().find((group) => group.name.toLowerCase() === name.toLowerCase())
53
+ const noGroup = () => problemResponse(notFound('The health check group could not be found'))
54
+
55
+ router.handle('GetHealthCheckGroup', (ctx) => {
56
+ const page = paging(ctx)
57
+ if (!page) return problemResponse(invalidSkipTake())
58
+ const groups = diagnostics.groups()
59
+ return Response.json({
60
+ total: groups.length,
61
+ items: groups.slice(page.skip, page.skip + page.take).map(({ name }) => ({ name })),
62
+ })
63
+ })
64
+ router.handle('GetHealthCheckGroupByName', (ctx) => {
65
+ const group = groupNamed(String(ctx.params.name))
66
+ return group ? Response.json(group) : noGroup()
67
+ })
68
+ router.handle('PostHealthCheckGroupByNameCheck', async (ctx) => {
69
+ const group = groupNamed(String(ctx.params.name))
70
+ const checks = group && (await diagnostics.run(group.name))
71
+ return checks ? Response.json({ checks }) : noGroup()
72
+ })
73
+ // No check here offers an action: each one reports, and the fix is a change
74
+ // to the site's configuration rather than something a button can do.
75
+ router.handle('PostHealthCheckExecuteAction', () =>
76
+ problemResponse(
77
+ badRequest(
78
+ 'There is nothing to run',
79
+ 'These health checks report only; none offers an action.',
80
+ ),
81
+ ),
82
+ )
83
+ router.handle('GetServerTroubleshooting', async () =>
84
+ Response.json(await diagnostics.troubleshooting()),
85
+ )
86
+ router.handle('GetServerUpgradeCheck', async () =>
87
+ Response.json(await diagnostics.upgradeCheck()),
88
+ )
89
+ }
@@ -7,7 +7,7 @@ import { invalidSkipTake, notFound, problemDetails, problemResponse } from '@cro
7
7
  import type { BlueprintPort, PortFailure } from '../ports-content.ts'
8
8
  import type { ManagementApiRouter, RequestContext } from '../router.ts'
9
9
  import { created, paging, registerFolderHandlers } from './content-type.ts'
10
- import { auditLogResponse, readBody, toDocumentResponse, toSaveDocument } from './document.ts'
10
+ import { readBody, toDocumentResponse, toSaveDocument } from './document.ts'
11
11
 
12
12
  const API = '/umbraco/management/api/v1'
13
13
 
@@ -126,8 +126,6 @@ export function registerDocumentBlueprintHandlers(
126
126
  return new Response(null, { status: 200 })
127
127
  })
128
128
 
129
- router.handle('GetDocumentBlueprintByIdAuditLog', (ctx) => auditLogResponse(ctx, port))
130
-
131
129
  router.handle('GetDocumentTypeByIdBlueprint', async (ctx) => {
132
130
  const page = paging(ctx)
133
131
  if (!page) return problemResponse(invalidSkipTake())
@@ -7,11 +7,14 @@ import type {
7
7
  DocumentTreeItem,
8
8
  DocumentValidationError,
9
9
  DocumentValue,
10
- DocumentVersionSummary,
10
+ PatchOperation,
11
11
  } from '@crombolo/core'
12
12
  import {
13
+ applyJsonPatch,
14
+ DocumentPermissions,
13
15
  invalidSkipTake,
14
16
  notFound,
17
+ permissionsForPath,
15
18
  problemDetails,
16
19
  problemResponse,
17
20
  ROOT_ACCESS,
@@ -24,6 +27,7 @@ import {
24
27
  type TreeSource,
25
28
  visibleChildren,
26
29
  visibleRoot,
30
+ visibleSearch,
27
31
  visibleSiblings,
28
32
  } from './start-node-trees.ts'
29
33
 
@@ -385,11 +389,28 @@ export function registerDocumentHandlers(
385
389
  const page = paging(ctx)
386
390
  if (!page) return problemResponse(invalidSkipTake())
387
391
  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
- })
392
+ const groups = ctx.principal?.groups ?? []
393
+ // Browse is checked per item only when some group could withhold it somewhere.
394
+ const browsesEverything =
395
+ !ctx.principal ||
396
+ groups.some(
397
+ (group) =>
398
+ group.defaults.includes(DocumentPermissions.Read) &&
399
+ [...group.granular.values()].every((verbs) => verbs.includes(DocumentPermissions.Read)),
400
+ )
401
+ const result = await visibleSearch(
402
+ documentStart(ctx),
403
+ browsesEverything
404
+ ? undefined
405
+ : (chain) => permissionsForPath(groups, chain).has(DocumentPermissions.Read),
406
+ (paging) =>
407
+ port.search(query, {
408
+ ...paging,
409
+ trashed: ctx.url.searchParams.get('trashed') === 'true',
410
+ parentKey: ctx.url.searchParams.get('parentId'),
411
+ }),
412
+ page,
413
+ )
393
414
  return Response.json({ total: result.total, items: result.items.map(toDocumentItemResponse) })
394
415
  })
395
416
 
@@ -452,11 +473,12 @@ export function registerDocumentHandlers(
452
473
  return created(`${ctx.url.origin}/umbraco/management/api/v1/document/${result.key}`, result.key)
453
474
  })
454
475
 
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)
476
+ const saveUpdate = async (
477
+ ctx: RequestContext,
478
+ key: string,
479
+ existing: DocumentAggregate,
480
+ body: Record<string, unknown>,
481
+ ) => {
460
482
  const result = await port.update(
461
483
  key,
462
484
  {
@@ -477,6 +499,48 @@ export function registerDocumentHandlers(
477
499
  }
478
500
  ctx.notifications.push({ message: 'Document saved', category: 'Content', type: 'Success' })
479
501
  return new Response(null, { status: 200 })
502
+ }
503
+
504
+ router.handle('PutDocumentById', async (ctx) => {
505
+ const key = ctx.params.id as string
506
+ const existing = await port.byKey(key)
507
+ if (!existing) return problemResponse(notFound('The document could not be found'))
508
+ return saveUpdate(ctx, key, existing, await readBody(ctx))
509
+ })
510
+
511
+ // JSON Patch over the update model, Umbraco's filter segments included; the
512
+ // patched model then saves exactly as a PUT would.
513
+ router.handle('PatchDocumentByIdPatch', async (ctx) => {
514
+ const key = ctx.params.id as string
515
+ const existing = await port.byKey(key)
516
+ if (!existing) return problemResponse(notFound('The document could not be found'))
517
+ const invalid = (detail: string, status = 400) =>
518
+ problemResponse(problemDetails({ title: 'Could not patch the document', status, detail }))
519
+ const { operations } = await readBody(ctx)
520
+ if (!Array.isArray(operations) || operations.length === 0)
521
+ return invalid('A patch needs at least one operation.')
522
+ const current = {
523
+ template: existing.componentKey ? { id: existing.componentKey } : null,
524
+ values: existing.values.map(({ alias, culture, segment, value }) => ({
525
+ alias,
526
+ culture,
527
+ segment,
528
+ value,
529
+ })),
530
+ variants: existing.variants.map(({ culture, segment, name }) => ({ culture, segment, name })),
531
+ }
532
+ const patched = applyJsonPatch(current, operations as PatchOperation[])
533
+ if (!patched.ok) return invalid(patched.reason)
534
+ const body = patched.value as Record<string, unknown>
535
+ if (!Array.isArray(body.values) || !Array.isArray(body.variants))
536
+ return invalid('A patch may change values and variants, but not remove them.')
537
+ const aliases = new Set((await port.propertyAliases?.(key)) ?? [])
538
+ const unknown = (body.values as Array<{ alias?: unknown }>).find(
539
+ (value) => !aliases.has(String(value?.alias)),
540
+ )
541
+ if (unknown)
542
+ return invalid(`This document's type has no property "${String(unknown.alias)}".`, 422)
543
+ return saveUpdate(ctx, key, existing, body)
480
544
  })
481
545
 
482
546
  router.handle('PutDocumentByIdPublish', async (ctx) => {
@@ -849,7 +913,6 @@ export function registerDocumentHandlers(
849
913
  })
850
914
 
851
915
  /** The page's history, from its versions: who saved, published or rolled back, and when. */
852
- router.handle('GetDocumentByIdAuditLog', (ctx) => auditLogResponse(ctx, port))
853
916
 
854
917
  router.handle('GetDocumentVersion', async (ctx) => {
855
918
  const documentId = ctx.url.searchParams.get('documentId')
@@ -922,40 +985,3 @@ export function registerDocumentHandlers(
922
985
  })
923
986
  })
924
987
  }
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
- }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Dynamic roots: a content picker's start node, resolved from the document being
3
+ * edited by an origin and a list of steps.
4
+ */
5
+ import { problemDetails, problemResponse } from '@crombolo/core'
6
+ import type { DynamicRootPort } from '../ports.ts'
7
+ import type { ManagementApiRouter } from '../router.ts'
8
+ import { readBody } from './document.ts'
9
+
10
+ export const DYNAMIC_ROOT_STEP_ALIASES = [
11
+ 'NearestAncestorOrSelf',
12
+ 'FurthestAncestorOrSelf',
13
+ 'NearestDescendantOrSelf',
14
+ 'FurthestDescendantOrSelf',
15
+ ]
16
+
17
+ const EMPTY_GUID = '00000000-0000-0000-0000-000000000000'
18
+
19
+ const keyOf = (value: unknown): string | null => {
20
+ const id = (value as { id?: unknown } | null | undefined)?.id ?? value
21
+ return typeof id === 'string' && id !== '' && id !== EMPTY_GUID ? id : null
22
+ }
23
+
24
+ export function registerDynamicRootHandlers(
25
+ router: ManagementApiRouter,
26
+ port: DynamicRootPort,
27
+ ): void {
28
+ router.handle('GetDynamicRootSteps', () => Response.json(DYNAMIC_ROOT_STEP_ALIASES))
29
+ router.handle('PostDynamicRootQuery', async (ctx) => {
30
+ const body = await readBody(ctx)
31
+ const context = body.context as { id?: unknown; parent?: unknown } | undefined
32
+ const query = body.query as
33
+ | { origin?: { alias?: unknown; id?: unknown }; steps?: unknown }
34
+ | undefined
35
+ if (!context || typeof query?.origin?.alias !== 'string')
36
+ return problemResponse(
37
+ problemDetails({
38
+ title: 'Invalid dynamic root query',
39
+ detail: 'A query needs a context and an origin.',
40
+ status: 400,
41
+ }),
42
+ )
43
+ const steps = Array.isArray(query.steps) ? query.steps : []
44
+ const roots = await port.resolve({
45
+ currentKey: keyOf(context.id),
46
+ parentKey: keyOf(context.parent),
47
+ origin: { alias: query.origin.alias, key: keyOf(query.origin.id) },
48
+ steps: steps.map((step) => {
49
+ const { alias, documentTypeIds } = (step ?? {}) as {
50
+ alias?: unknown
51
+ documentTypeIds?: unknown
52
+ }
53
+ return {
54
+ alias: String(alias ?? ''),
55
+ documentTypeKeys: Array.isArray(documentTypeIds)
56
+ ? documentTypeIds.filter((id): id is string => typeof id === 'string')
57
+ : [],
58
+ }
59
+ }),
60
+ })
61
+ return Response.json({ roots })
62
+ })
63
+ }
@@ -17,7 +17,7 @@ import { invalidSkipTake, notFound, problemDetails, problemResponse } from '@cro
17
17
  import type { ElementPort, PortFailure, ReferencePort } from '../ports-content.ts'
18
18
  import type { ManagementApiRouter, RequestContext } from '../router.ts'
19
19
  import { created, paging, registerFolderHandlers } from './content-type.ts'
20
- import { auditLogResponse, failureResponse, readBody, toSaveDocument } from './document.ts'
20
+ import { failureResponse, readBody, toSaveDocument } from './document.ts'
21
21
 
22
22
  const API = '/umbraco/management/api/v1'
23
23
 
@@ -444,7 +444,6 @@ export function registerElementHandlers(
444
444
  })
445
445
 
446
446
  // ── history ───────────────────────────────────────────────────────────────
447
- handle('GetElementByIdAuditLog', (ctx) => auditLogResponse(ctx, port))
448
447
 
449
448
  handle('GetElementVersion', async (ctx) => {
450
449
  const elementId = ctx.url.searchParams.get('elementId')
@@ -13,8 +13,8 @@ import {
13
13
  import type { MediaPort, PortFailure } from '../ports-content.ts'
14
14
  import type { ManagementApiRouter, RequestContext } from '../router.ts'
15
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'
16
+ import { readBody, toSaveDocument, validationProblem } from './document.ts'
17
+ import { visibleChildren, visibleRoot, visibleSearch, visibleSiblings } from './start-node-trees.ts'
18
18
 
19
19
  const API = '/umbraco/management/api/v1'
20
20
 
@@ -251,8 +251,6 @@ export function registerMediaHandlers(router: ManagementApiRouter, port: MediaPo
251
251
  router.handle('PutMediaByIdSortChildren', (ctx) => sortByField(ctx, ctx.params.id as string))
252
252
  router.handle('PutMediaRootSortChildren', (ctx) => sortByField(ctx, null))
253
253
 
254
- router.handle('GetMediaByIdAuditLog', (ctx) => auditLogResponse(ctx, port))
255
-
256
254
  /** Thumbnail and crop URLs in the query vocabulary the media route understands. */
257
255
  router.handle('GetImagingResizeUrls', async (ctx) => {
258
256
  const params = new URLSearchParams()
@@ -341,11 +339,17 @@ export function registerMediaHandlers(router: ManagementApiRouter, port: MediaPo
341
339
  const page = paging(ctx)
342
340
  if (!page) return problemResponse(invalidSkipTake())
343
341
  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
- })
342
+ const result = await visibleSearch(
343
+ mediaStart(ctx),
344
+ undefined,
345
+ (paging) =>
346
+ port.search(ctx.url.searchParams.get('query') ?? '', {
347
+ trashed: ctx.url.searchParams.get('trashed') === 'true',
348
+ parentKey: parentId,
349
+ ...paging,
350
+ }),
351
+ page,
352
+ )
349
353
  return Response.json({ total: result.total, items: result.items.map(toMediaItem) })
350
354
  })
351
355
 
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Fixed reference answers: the node object types a relation type can join, and
3
+ * the help pages. Umbraco relays its help pages from our.umbraco.com; there is
4
+ * nothing to relay here, so the list is honestly empty.
5
+ */
6
+ import { invalidSkipTake, ObjectTypes, problemResponse } from '@crombolo/core'
7
+ import type { ManagementApiRouter } from '../router.ts'
8
+ import { paging } from './content-type.ts'
9
+
10
+ /** Internal markers rather than things a relation can point at. */
11
+ const HIDDEN = new Set<string>(['SystemRoot', 'LockObject'])
12
+
13
+ /** `DocumentTypeContainer` → `Document Type Container`. */
14
+ const spaced = (name: string) => name.replace(/([a-z])([A-Z])/g, '$1 $2')
15
+
16
+ export const OBJECT_TYPES = Object.entries(ObjectTypes)
17
+ .filter(([name]) => !HIDDEN.has(name))
18
+ .map(([name, id]) => ({ id, name: spaced(name) }))
19
+ .sort((a, b) => a.name.localeCompare(b.name))
20
+
21
+ export function registerReferenceDataHandlers(router: ManagementApiRouter): void {
22
+ router.handle('GetObjectTypes', (ctx) => {
23
+ const page = paging(ctx)
24
+ if (!page) return problemResponse(invalidSkipTake())
25
+ return Response.json({
26
+ total: OBJECT_TYPES.length,
27
+ items: OBJECT_TYPES.slice(page.skip, page.skip + page.take),
28
+ })
29
+ })
30
+ router.handle('GetHelp', (ctx) => {
31
+ const page = paging(ctx)
32
+ if (!page) return problemResponse(invalidSkipTake())
33
+ return Response.json({ total: 0, items: [] })
34
+ })
35
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Relation types and their relations, read-only and derived (story 046), and
3
+ * the recycle bins' "what refers to what is in here" lists.
4
+ */
5
+ import { invalidSkipTake, notFound, paged, parseSkipTake, problemResponse } from '@crombolo/core'
6
+ import type { ReferencePort, RelationPort } from '../ports-content.ts'
7
+ import type { ManagementApiRouter } from '../router.ts'
8
+
9
+ const missing = () => problemResponse(notFound('The relation type could not be found'))
10
+
11
+ export function registerRelationHandlers(router: ManagementApiRouter, port: RelationPort): void {
12
+ router.handle('GetRelationType', (ctx) => {
13
+ const page = parseSkipTake(ctx.url.searchParams)
14
+ if (!page.ok) return problemResponse(invalidSkipTake())
15
+ const types = port.types()
16
+ return Response.json(
17
+ paged(types.slice(page.value.skip, page.value.skip + page.value.take), types.length),
18
+ )
19
+ })
20
+
21
+ router.handle('GetRelationTypeById', (ctx) => {
22
+ const id = String(ctx.params.id).toLowerCase()
23
+ const type = port.types().find((t) => t.id === id)
24
+ return type ? Response.json(type) : missing()
25
+ })
26
+
27
+ router.handle('GetItemRelationType', (ctx) => {
28
+ const ids = new Set(ctx.url.searchParams.getAll('id').map((id) => id.toLowerCase()))
29
+ // Built in, so none of them can be deleted.
30
+ return Response.json(
31
+ port
32
+ .types()
33
+ .filter((t) => ids.has(t.id))
34
+ .map((t) => ({ id: t.id, name: t.name, isDeletable: false, flags: [] })),
35
+ )
36
+ })
37
+
38
+ router.handle('GetRelationByRelationTypeId', async (ctx) => {
39
+ const page = parseSkipTake(ctx.url.searchParams)
40
+ if (!page.ok) return problemResponse(invalidSkipTake())
41
+ const result = await port.relations(String(ctx.params.id), page.value)
42
+ return result ? Response.json(paged(result.items, result.total)) : missing()
43
+ })
44
+ }
45
+
46
+ export function registerRecycleBinReferenceHandlers(
47
+ router: ManagementApiRouter,
48
+ port: ReferencePort,
49
+ ): void {
50
+ const ask = port.recycleBinReferencedBy?.bind(port)
51
+ if (!ask) return
52
+ for (const area of ['Document', 'Media', 'Element'] as const)
53
+ router.handle(`GetRecycleBin${area}ReferencedBy`, async (ctx) => {
54
+ // Umbraco pages these 20 at a time, as it does every references list.
55
+ const page = parseSkipTake(ctx.url.searchParams, 20)
56
+ if (!page.ok) return problemResponse(invalidSkipTake())
57
+ const result = await ask(area, page.value)
58
+ return Response.json(paged(result.items, result.total))
59
+ })
60
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * The Examine dashboard: the indexes, their health, rebuilding one, and searching
3
+ * one directly. An index with items still waiting reports itself rebuilding, which
4
+ * is what makes the dashboard poll until they are done. docs/search.md.
5
+ */
6
+ import { invalidSkipTake, problemDetails, problemResponse } from '@crombolo/core'
7
+ import type { SearchIndexInfo, SearchIndexPort } from '../ports.ts'
8
+ import type { ManagementApiRouter } from '../router.ts'
9
+ import { paging } from './content-type.ts'
10
+
11
+ const waiting = (n: number) => `${n} ${n === 1 ? 'item is' : 'items are'} waiting to be indexed`
12
+
13
+ function toIndexResponse(index: SearchIndexInfo) {
14
+ return {
15
+ name: index.name,
16
+ healthStatus:
17
+ index.pending > 0
18
+ ? { status: 'Rebuilding', message: waiting(index.pending) }
19
+ : { status: 'Healthy', message: null },
20
+ canRebuild: true,
21
+ searcherName: index.searcherName,
22
+ documentCount: index.documentCount,
23
+ fieldCount: index.fieldCount,
24
+ providerProperties: null,
25
+ uniqueKeyFieldName: '__Key',
26
+ }
27
+ }
28
+
29
+ const indexNotFound = (name: string) =>
30
+ problemResponse(
31
+ problemDetails({
32
+ title: 'Index Not Found',
33
+ detail: `No index found with name = ${name}`,
34
+ status: 400,
35
+ }),
36
+ )
37
+
38
+ export function registerSearchIndexHandlers(
39
+ router: ManagementApiRouter,
40
+ port: SearchIndexPort,
41
+ ): void {
42
+ router.handle('GetIndexer', async (ctx) => {
43
+ const page = paging(ctx)
44
+ if (!page) return problemResponse(invalidSkipTake())
45
+ const indexes = await port.indexes()
46
+ return Response.json({
47
+ total: indexes.length,
48
+ items: indexes.slice(page.skip, page.skip + page.take).map(toIndexResponse),
49
+ })
50
+ })
51
+
52
+ router.handle('GetIndexerByIndexName', async ({ params }) => {
53
+ const name = params.indexName as string
54
+ const index = await port.index(name)
55
+ return index ? Response.json(toIndexResponse(index)) : indexNotFound(name)
56
+ })
57
+
58
+ router.handle('PostIndexerByIndexNameRebuild', async ({ params }) => {
59
+ const name = params.indexName as string
60
+ return (await port.rebuild(name)) ? new Response(null, { status: 200 }) : indexNotFound(name)
61
+ })
62
+
63
+ router.handle('GetSearcher', (ctx) => {
64
+ const page = paging(ctx)
65
+ if (!page) return problemResponse(invalidSkipTake())
66
+ const names = port.searchers()
67
+ return Response.json({
68
+ total: names.length,
69
+ items: names.slice(page.skip, page.skip + page.take).map((name) => ({ name })),
70
+ })
71
+ })
72
+
73
+ router.handle('GetSearcherBySearcherNameQuery', async (ctx) => {
74
+ const page = paging(ctx)
75
+ if (!page) return problemResponse(invalidSkipTake())
76
+ const name = ctx.params.searcherName as string
77
+ const result = await port.query(name, ctx.url.searchParams.get('term') ?? '', page)
78
+ if (!result)
79
+ return problemResponse(
80
+ problemDetails({
81
+ title: 'Searcher Not Found',
82
+ detail: `No searcher found with name = ${name}`,
83
+ status: 404,
84
+ }),
85
+ )
86
+ return Response.json({
87
+ total: result.total,
88
+ items: result.items.map((hit) => ({
89
+ id: hit.key,
90
+ score: hit.score,
91
+ fieldCount: hit.fields.length,
92
+ fields: hit.fields,
93
+ })),
94
+ })
95
+ })
96
+ }
@@ -23,18 +23,9 @@ const PAGED = [
23
23
  `GetRecycleBin${b}Children`,
24
24
  `GetRecycleBin${b}ReferencedBy`,
25
25
  ]),
26
- 'GetRecycleBinDocumentReferencedBy', // needs relations, story 046
27
- 'GetRecycleBinMediaReferencedBy',
28
- 'GetRecycleBinElementReferencedBy',
29
- // The recycle bin and the trash and delete dialogs; needs relations, story 046. Until
30
- // then they cannot warn that other content links to what is being removed.
31
- 'GetTreeStaticFileRoot',
32
- 'GetTreeStaticFileChildren',
33
- 'GetRelationType',
34
- 'GetWebhook',
35
26
  ]
36
27
  /** Ancestor lookups: a bare array. */
37
- const ANCESTORS = [...STUB_TREES.map((t) => `GetTree${t}Ancestors`), 'GetTreeStaticFileAncestors']
28
+ const ANCESTORS = STUB_TREES.map((t) => `GetTree${t}Ancestors`)
38
29
  /** Sibling windows: `{ totalBefore, totalAfter, items }`. */
39
30
  const SIBLINGS = [
40
31
  ...STUB_TREES.map((t) => `GetTree${t}Siblings`),
@@ -82,3 +82,29 @@ export async function visibleSiblings(
82
82
  const visible = await onTheWay(start, source, window.items)
83
83
  return { items: visible.items, totalBefore: 0, totalAfter: 0 }
84
84
  }
85
+
86
+ /** How far a filtered search looks before paging: past it, an editor refines the query. */
87
+ const SEARCH_WINDOW = 1_000
88
+
89
+ /**
90
+ * A page of search results as a user may see them: under a start node, and
91
+ * passing `allowed` (Browse, for documents). Unrestricted access pages in the
92
+ * database; otherwise a window of the best matches is filtered, then paged.
93
+ */
94
+ export async function visibleSearch(
95
+ start: StartNodes,
96
+ allowed: ((chain: readonly string[]) => boolean) | undefined,
97
+ search: (paging: SkipTake) => Promise<Page<DocumentTreeItem>>,
98
+ paging: SkipTake,
99
+ ): Promise<Page<DocumentTreeItem>> {
100
+ if (start.root && !allowed) return search(paging)
101
+ const window = await search({ skip: 0, take: SEARCH_WINDOW })
102
+ const visible = window.items.filter((item) => {
103
+ const chain = chainOf(item)
104
+ return hasPathAccess(start, chain, item.isTrashed) && (allowed?.(chain) ?? true)
105
+ })
106
+ return {
107
+ total: visible.length,
108
+ items: visible.slice(paging.skip, paging.skip + paging.take),
109
+ }
110
+ }