@crombolo/api-management 0.16.0 → 0.17.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crombolo/api-management",
3
- "version": "0.16.0",
3
+ "version": "0.17.0",
4
4
  "description": "Umbraco-compatible Management API handlers for crombolo.",
5
5
  "license": "MIT",
6
6
  "author": "Crombolo",
@@ -28,7 +28,7 @@
28
28
  "access": "public"
29
29
  },
30
30
  "dependencies": {
31
- "@crombolo/core": "0.16.0",
32
- "@crombolo/contracts": "0.16.0"
31
+ "@crombolo/core": "0.17.0",
32
+ "@crombolo/contracts": "0.17.0"
33
33
  }
34
34
  }
@@ -847,7 +847,16 @@ export function registerDocumentHandlers(
847
847
  ctx.principal as NonNullable<typeof ctx.principal>,
848
848
  ids,
849
849
  )
850
- if (!saved) return problemResponse(notFound('The document could not be found'))
850
+ if (saved === 'notFound') return problemResponse(notFound('The document could not be found'))
851
+ if (saved === 'unavailable')
852
+ return problemResponse(
853
+ problemDetails({
854
+ title: 'Notifications are unavailable',
855
+ status: 400,
856
+ detail:
857
+ "This site can't send e-mail yet, so it can't tell you when pages change. Ask an administrator to set up e-mail.",
858
+ }),
859
+ )
851
860
  return done(ctx, 'Notification settings saved')
852
861
  })
853
862
 
package/src/index.ts CHANGED
@@ -6,6 +6,7 @@ export * from './handlers/package.ts'
6
6
  export * from './handlers/settings-stubs.ts'
7
7
  export * from './handlers/template-query.ts'
8
8
  export * from './handlers/user.ts'
9
+ export * from './notification-events.ts'
9
10
  export * from './ports.ts'
10
11
  export * from './ports-content.ts'
11
12
  export * from './ports-diagnostics.ts'
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Which of Umbraco's eleven notification actions a Management API operation
3
+ * performs, and on which item (story 045). Pure: the router hands the server
4
+ * what it says, and the server finds who asked to hear of it.
5
+ */
6
+
7
+ /** What a successful operation did, for the people subscribed to its page. */
8
+ export interface RaisedContentChange {
9
+ /** `Umb.Document.Publish` and the rest; create-and-publish is two. */
10
+ actions: string[]
11
+ /** The item it names: a document's key, or for a rollback the version's. */
12
+ key: string
13
+ keyKind: 'document' | 'version'
14
+ /** A branch publish: one change for the page, "and the pages under it". */
15
+ withDescendants?: boolean
16
+ }
17
+
18
+ interface Mapping {
19
+ actions: string[]
20
+ from: 'created' | 'id' | 'body-parent'
21
+ /** A publish that only sets a schedule publishes nothing yet: the job notifies when it does. */
22
+ scheduleOnly?: boolean
23
+ keyKind?: 'document' | 'version'
24
+ withDescendants?: boolean
25
+ }
26
+
27
+ const CREATE = 'Umb.Document.Create'
28
+ const UPDATE = 'Umb.Document.Update'
29
+ const PUBLISH = 'Umb.Document.Publish'
30
+
31
+ const BY_OPERATION: Record<string, Mapping> = {
32
+ PostDocument: { actions: [CREATE], from: 'created' },
33
+ PostDocumentCreateAndPublish: { actions: [CREATE, PUBLISH], from: 'created' },
34
+ PutDocumentById: { actions: [UPDATE], from: 'id' },
35
+ PutDocumentByIdUpdateAndPublish: { actions: [UPDATE, PUBLISH], from: 'id' },
36
+ PutDocumentByIdPublish: { actions: [PUBLISH], from: 'id', scheduleOnly: true },
37
+ PutDocumentByIdPublishWithDescendants: {
38
+ actions: [PUBLISH],
39
+ from: 'id',
40
+ withDescendants: true,
41
+ },
42
+ PutDocumentByIdMoveToRecycleBin: { actions: ['Umb.Document.Delete'], from: 'id' },
43
+ PutDocumentByIdMove: { actions: ['Umb.Document.Move'], from: 'id' },
44
+ // Notified on the original, as Umbraco does.
45
+ PostDocumentByIdCopy: { actions: ['Umb.Document.Duplicate'], from: 'id' },
46
+ // Notified on the parent whose children were sorted; sorting at the root notifies nobody.
47
+ PutDocumentByIdSortChildren: { actions: ['Umb.Document.Sort'], from: 'id' },
48
+ PutDocumentSort: { actions: ['Umb.Document.Sort'], from: 'body-parent' },
49
+ PostDocumentVersionByIdRollback: {
50
+ actions: ['Umb.Document.Rollback'],
51
+ from: 'id',
52
+ keyKind: 'version',
53
+ },
54
+ PutRecycleBinDocumentByIdRestore: { actions: ['Umb.DocumentRecycleBin.Restore'], from: 'id' },
55
+ PostDocumentByIdPublicAccess: { actions: ['Umb.Document.PublicAccess'], from: 'id' },
56
+ PutDocumentByIdPublicAccess: { actions: ['Umb.Document.PublicAccess'], from: 'id' },
57
+ DeleteDocumentByIdPublicAccess: { actions: ['Umb.Document.PublicAccess'], from: 'id' },
58
+ }
59
+
60
+ /** Whether the router has to keep the request body for this operation. */
61
+ export const changeReadsBody = (operationId: string): boolean =>
62
+ BY_OPERATION[operationId]?.from === 'body-parent' ||
63
+ BY_OPERATION[operationId]?.scheduleOnly === true
64
+
65
+ /** Whether every culture in a publish request is scheduled for later rather than published now. */
66
+ const onlySchedules = (body: unknown): boolean => {
67
+ const schedules = (
68
+ body as { publishSchedules?: Array<{ schedule?: { publishTime?: unknown } | null }> }
69
+ )?.publishSchedules
70
+ return (
71
+ Array.isArray(schedules) &&
72
+ schedules.length > 0 &&
73
+ schedules.every((entry) => Boolean(entry?.schedule?.publishTime))
74
+ )
75
+ }
76
+
77
+ /** The change a successful operation made, if it is one anybody can subscribe to. */
78
+ export function contentChangeFor(
79
+ operationId: string,
80
+ params: Readonly<Record<string, string>>,
81
+ response: Pick<Response, 'status' | 'headers'>,
82
+ body: unknown,
83
+ ): RaisedContentChange | undefined {
84
+ if (response.status < 200 || response.status >= 300) return undefined
85
+ const mapping = BY_OPERATION[operationId]
86
+ if (!mapping) return undefined
87
+ if (mapping.scheduleOnly && onlySchedules(body)) return undefined
88
+ const key =
89
+ mapping.from === 'created'
90
+ ? response.headers.get('umb-generated-resource')
91
+ : mapping.from === 'body-parent'
92
+ ? ((body as { parent?: { id?: unknown } | null } | undefined)?.parent?.id as
93
+ | string
94
+ | undefined)
95
+ : params.id
96
+ if (!key) return undefined
97
+ return {
98
+ actions: mapping.actions,
99
+ key: key.toLowerCase(),
100
+ keyKind: mapping.keyKind ?? 'document',
101
+ ...(mapping.withDescendants ? { withDescendants: true } : {}),
102
+ }
103
+ }
@@ -370,7 +370,15 @@ export interface DocumentPort extends TreePort {
370
370
  key: string,
371
371
  principal: Principal,
372
372
  ): Promise<Array<{ actionId: string; alias: string; subscribed: boolean }> | undefined>
373
- setNotifications(key: string, principal: Principal, actionIds: string[]): Promise<boolean>
373
+ /**
374
+ * Replaces the user's subscriptions. `unavailable` refuses a new one on a
375
+ * site that cannot send e-mail; clearing them is always allowed.
376
+ */
377
+ setNotifications(
378
+ key: string,
379
+ principal: Principal,
380
+ actionIds: string[],
381
+ ): Promise<'saved' | 'notFound' | 'unavailable'>
374
382
  /** The document as it stood at a version. */
375
383
  atVersion(
376
384
  versionId: string,
package/src/router.ts CHANGED
@@ -25,6 +25,11 @@ import {
25
25
  auditReadsBody,
26
26
  } from './audit.ts'
27
27
  import { authorize, LOCAL_LOGIN_OPERATIONS, type NodeLookup } from './authorization.ts'
28
+ import {
29
+ changeReadsBody,
30
+ contentChangeFor,
31
+ type RaisedContentChange,
32
+ } from './notification-events.ts'
28
33
  import { type ServerEvent, serverEventFor } from './server-events.ts'
29
34
  import { type RaisedWebhookEvent, webhookEventFor } from './webhook-events.ts'
30
35
 
@@ -94,6 +99,8 @@ export interface RouterOptions {
94
99
  onError?: (error: unknown) => Response | undefined
95
100
  /** Told about every webhook event a successful operation raises. */
96
101
  onWebhookEvent?: (event: RaisedWebhookEvent) => void
102
+ /** A change to a page that people can subscribe to, and who made it (story 045). */
103
+ onContentChange?: (change: RaisedContentChange, principal: Principal | undefined) => void
97
104
  }
98
105
 
99
106
  export interface ClientAddress {
@@ -130,6 +137,7 @@ export class ManagementApiRouter {
130
137
  readonly #clientAddress: (request: Request) => ClientAddress
131
138
  readonly #onError: RouterOptions['onError']
132
139
  readonly #onWebhookEvent: RouterOptions['onWebhookEvent']
140
+ readonly #onContentChange: RouterOptions['onContentChange']
133
141
 
134
142
  constructor(options: RouterOptions = {}) {
135
143
  const operations = options.operations ?? listOperations(loadSpec())
@@ -143,6 +151,7 @@ export class ManagementApiRouter {
143
151
  this.#clientAddress = options.clientAddress ?? forwardedOnly
144
152
  this.#onError = options.onError
145
153
  this.#onWebhookEvent = options.onWebhookEvent
154
+ this.#onContentChange = options.onContentChange
146
155
  for (const operation of operations) {
147
156
  const segments = operation.path.split('/').filter((s) => s.length > 0)
148
157
  const paramNames = segments.map((s) =>
@@ -242,6 +251,13 @@ export class ManagementApiRouter {
242
251
  if (!handler) this.#onNotImplemented?.(operation, request)
243
252
  const plan = handler && this.#audit ? auditPlanFor(operation.operationId) : undefined
244
253
  const audit = plan ? await this.#beforeAudit(plan, params, request) : undefined
254
+ const changeBody =
255
+ handler && this.#onContentChange && changeReadsBody(operation.operationId)
256
+ ? await request
257
+ .clone()
258
+ .json()
259
+ .catch(() => undefined)
260
+ : undefined
245
261
  const response = handler
246
262
  ? await this.#run(handler, { request, url, params, operation, notifications, principal })
247
263
  : problemResponse(notImplemented(operation.operationId))
@@ -255,6 +271,10 @@ export class ManagementApiRouter {
255
271
  const event = webhookEventFor(operation.operationId, params, response)
256
272
  if (event) this.#onWebhookEvent(event)
257
273
  }
274
+ if (handler && this.#onContentChange) {
275
+ const change = contentChangeFor(operation.operationId, params, response, changeBody)
276
+ if (change) this.#onContentChange(change, principal)
277
+ }
258
278
  const headers = new Headers(response.headers)
259
279
  applyNotifications(headers, request.method, notifications)
260
280
  headers.set('cache-control', 'no-store')
package/src/routes.ts CHANGED
@@ -57,6 +57,7 @@ export interface ManagementApiOptions {
57
57
  clientAddress?: RouterOptions['clientAddress']
58
58
  onError?: RouterOptions['onError']
59
59
  onWebhookEvent?: RouterOptions['onWebhookEvent']
60
+ onContentChange?: RouterOptions['onContentChange']
60
61
  }
61
62
 
62
63
  export function createManagementApiRouter(options: ManagementApiOptions = {}): ManagementApiRouter {
@@ -70,6 +71,7 @@ export function createManagementApiRouter(options: ManagementApiOptions = {}): M
70
71
  clientAddress: options.clientAddress,
71
72
  onError: options.onError,
72
73
  onWebhookEvent: options.onWebhookEvent,
74
+ onContentChange: options.onContentChange,
73
75
  })
74
76
  const { deps } = options
75
77
  if (!deps) return router