@crombolo/server 0.16.0 → 0.18.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/src/index.ts CHANGED
@@ -25,6 +25,7 @@ export * from './bundles.ts'
25
25
  export * from './busy.ts'
26
26
  export * from './components.ts'
27
27
  export * from './config.ts'
28
+ export * from './content-notifications.ts'
28
29
  export * from './content-type-udt.ts'
29
30
  export * from './database.ts'
30
31
  export * from './diagnostics.ts'
@@ -37,6 +38,7 @@ export * from './email-notice.ts'
37
38
  export * from './email-postmark.ts'
38
39
  export * from './email-resend.ts'
39
40
  export * from './email-ses.ts'
41
+ export * from './email-text.ts'
40
42
  export * from './extensions-notice.ts'
41
43
  export * from './external-sign-in.ts'
42
44
  export * from './file-intake.ts'
@@ -76,6 +78,7 @@ export * from './secrets.ts'
76
78
  export * from './seo.ts'
77
79
  export * from './server.ts'
78
80
  export * from './server-events.ts'
81
+ export * from './settings.ts'
79
82
  export * from './setup.ts'
80
83
  export * from './site-file.ts'
81
84
  export * from './site-replace.ts'
package/src/jobs.ts CHANGED
@@ -1,9 +1,11 @@
1
1
  /**
2
2
  * Background jobs, as Umbraco runs them: scheduled publishing every minute and
3
3
  * version cleanup every hour when it is enabled, plus the search indexer
4
- * draining its queue and the audit log ageing out old entries hourly. Each run can also be called directly, which is
5
- * how tests drive them. On a multi-node Postgres site every node runs them;
6
- * each schedule and queued item is claimed atomically, so it runs once.
4
+ * draining its queue, the audit log ageing out old entries hourly, and the
5
+ * form, webhook and content notification queues. Each run can also be called
6
+ * directly, which is how tests drive them. Every node that owns writes (`all`
7
+ * and `api`) runs them; each schedule and queued item is claimed atomically, so
8
+ * it runs once.
7
9
  */
8
10
 
9
11
  import type { NodeSchemaState } from '@crombolo/data'
@@ -16,6 +18,11 @@ import {
16
18
  } from '@crombolo/data'
17
19
  import type { PublishedCache } from '@crombolo/render'
18
20
  import { auditRetainedSince, recordAudit } from './adapters/audit.ts'
21
+ import type {
22
+ ContentNotificationRunner,
23
+ ContentNotificationRunReport,
24
+ ContentNotifier,
25
+ } from './content-notifications.ts'
19
26
  import type { WorkflowRunner, WorkflowRunReport } from './form-workflows.ts'
20
27
  import { logger } from './logging.ts'
21
28
  import type { MediaFileStore } from './media-files.ts'
@@ -49,6 +56,11 @@ export interface JobsOptions {
49
56
  searchIndexingMs?: number
50
57
  /** Days the audit log keeps an entry; 0 or absent keeps them forever. */
51
58
  auditRetentionDays?: number
59
+ /** Content notifications (story 045): queued by changes, sent here. Absent without e-mail. */
60
+ contentNotifications?: ContentNotificationRunner
61
+ contentNotificationsMs?: number
62
+ /** Queues the notifications scheduled publishing makes, as an editor's publish does. */
63
+ notifier?: ContentNotifier
52
64
  }
53
65
 
54
66
  export interface ScheduledRun {
@@ -63,6 +75,8 @@ export interface BackgroundJobs {
63
75
  runFormWorkflows(now?: Date): Promise<WorkflowRunReport>
64
76
  /** Delivers the webhook and bundle events that are due. */
65
77
  runWebhooks(now?: Date): Promise<WorkflowRunReport>
78
+ /** Sends the content notifications that are due (story 045). */
79
+ runContentNotifications(now?: Date): Promise<ContentNotificationRunReport>
66
80
  /** Indexes every queued item, and returns how many. docs/search.md. */
67
81
  runSearchIndexing(): Promise<number>
68
82
  /** Something was queued here: index it shortly, rather than at the next tick. */
@@ -115,13 +129,24 @@ export function createBackgroundJobs(options: JobsOptions): BackgroundJobs {
115
129
  }
116
130
  }
117
131
  if (run.published.length + run.unpublished.length > 0) options.cache.invalidate()
118
- for (const key of run.published)
132
+ for (const key of run.published) {
119
133
  await options.webhooks?.raise({ alias: 'Umbraco.ContentPublish', key })
134
+ await options.notifier?.raise(
135
+ { actions: ['Umb.Document.Publish'], key, keyKind: 'document' },
136
+ { key: null, name: 'The schedule' },
137
+ 'scheduled',
138
+ )
139
+ }
120
140
  for (const key of run.unpublished)
121
141
  await options.webhooks?.raise({ alias: 'Umbraco.ContentUnpublish', key })
122
142
  return run
123
143
  }
124
144
 
145
+ async function runContentNotifications(now = new Date()): Promise<ContentNotificationRunReport> {
146
+ const runner = options.contentNotifications
147
+ return runner ? runner.runDue(now) : { claimed: 0, sent: 0, skipped: 0, retrying: 0, failed: 0 }
148
+ }
149
+
125
150
  async function runAuditRetention(now = new Date()): Promise<number> {
126
151
  const before = auditRetainedSince(options.auditRetentionDays ?? 0, now)
127
152
  return before ? new AuditLog(options.db).prune(before) : 0
@@ -184,6 +209,7 @@ export function createBackgroundJobs(options: JobsOptions): BackgroundJobs {
184
209
  runAuditRetention,
185
210
  runFormWorkflows,
186
211
  runWebhooks,
212
+ runContentNotifications,
187
213
  runSearchIndexing,
188
214
  kickSearchIndexing() {
189
215
  if (timers.length === 0 || kicked) return
@@ -204,6 +230,12 @@ export function createBackgroundJobs(options: JobsOptions): BackgroundJobs {
204
230
  if (options.formWorkflows) every(options.formWorkflowsMs ?? 15_000, runFormWorkflows)
205
231
  // As often, for the same reason: a receiver that is waiting notices a minute.
206
232
  if (options.webhooks) every(options.webhooksMs ?? 15_000, runWebhooks)
233
+ // A digest waits a minute anyway; this is how soon after that it goes.
234
+ const notifications = options.contentNotifications
235
+ if (notifications) {
236
+ every(options.contentNotificationsMs ?? 15_000, runContentNotifications)
237
+ every(60 * 60_000, () => notifications.prune(new Date(Date.now() - 30 * 86_400_000)))
238
+ }
207
239
  if (options.versionCleanup.enabled) every(60 * 60_000, runVersionCleanup)
208
240
  if (options.auditRetentionDays) every(60 * 60_000, runAuditRetention)
209
241
  const files = options.mediaFiles
package/src/ports.ts CHANGED
@@ -74,6 +74,7 @@ import { createWebhookPort } from './adapters/webhooks.ts'
74
74
  import { createApprovals } from './approvals.ts'
75
75
  import { COMPONENT_SNIPPETS } from './component-snippets.ts'
76
76
  import { type CromboloConfig, type UserLinkSender, VERSION } from './config.ts'
77
+ import type { ContentNotifier } from './content-notifications.ts'
77
78
  import { type EmailPort, resolveEmailPort } from './email.ts'
78
79
  import { createFileIntake } from './file-intake.ts'
79
80
  import { formTypesFor } from './form-types.ts'
@@ -405,6 +406,26 @@ export interface DepsOptions {
405
406
  onViewsChange?: (alias: string) => void | Promise<void>
406
407
  /** Items were queued for the search index; the indexer can start on them now. */
407
408
  onSearchQueued?: () => void
409
+ /** Queues content notifications for changes made outside the router (story 045). */
410
+ notifier?: ContentNotifier
411
+ }
412
+
413
+ /** The back office's address, e.g. `https://site.example/crombolo`; undefined when the site's own is not set. */
414
+ export function backOfficeUrl(
415
+ config: Pick<CromboloConfig, 'applicationUrl' | 'backOfficePath'>,
416
+ ): string | undefined {
417
+ return config.applicationUrl
418
+ ? `${config.applicationUrl.replace(/\/$/, '')}${config.backOfficePath}`
419
+ : undefined
420
+ }
421
+
422
+ /** A page's address in the back office, for a link in an e-mail. */
423
+ export function documentEditUrl(
424
+ config: Pick<CromboloConfig, 'applicationUrl' | 'backOfficePath'>,
425
+ key: string,
426
+ ): string | undefined {
427
+ const base = backOfficeUrl(config)
428
+ return base ? `${base}/section/content/workspace/document/edit/${key}` : undefined
408
429
  }
409
430
 
410
431
  function createTemporaryFilePort(files: MediaFileStore): TemporaryFilePort {
@@ -493,6 +514,15 @@ export function createDeps(options: DepsOptions): ManagementApiDeps {
493
514
  endSessions: async (userId) => {
494
515
  await store.endAllSessionsForUser(userId)
495
516
  },
517
+ onPagePermissionsChanged: (principal, nodeKeys) =>
518
+ Promise.all(
519
+ nodeKeys.map((key) =>
520
+ options.notifier?.raise(
521
+ { actions: ['Umb.Document.Permissions'], key, keyKind: 'document' },
522
+ { key: principal.id, name: principal.name },
523
+ ),
524
+ ),
525
+ ).then(() => undefined),
496
526
  })
497
527
  // Story 032: the authenticator provider, offered only where secrets can be sealed.
498
528
  const twoFactor = new TwoFactorService(options.db, {
@@ -620,6 +650,7 @@ export function createDeps(options: DepsOptions): ManagementApiDeps {
620
650
  enabled: options.config.trackRedirects,
621
651
  }),
622
652
  options.config.siteDir,
653
+ Boolean(email),
623
654
  )
624
655
  if (deps.documents) {
625
656
  const documents = deps.documents
@@ -627,6 +658,8 @@ export function createDeps(options: DepsOptions): ManagementApiDeps {
627
658
  db: options.db,
628
659
  nodeState: options.schema?.nodeState,
629
660
  email,
661
+ backOfficeUrl: backOfficeUrl(options.config),
662
+ notifier: options.notifier,
630
663
  // Through the same port an editor's publish takes, so what approval
631
664
  // publishes is cached, tracked and announced as any publish is.
632
665
  publisher: {
package/src/server.ts CHANGED
@@ -98,6 +98,7 @@ import { CommandError, installBundleContent } from './bundle-install.ts'
98
98
  import { bundleChains, bundleTablesReport } from './bundle-tables.ts'
99
99
  import { createBundleRedirects, createServerBundles, validateServerBundles } from './bundles.ts'
100
100
  import { busyResponse } from './busy.ts'
101
+ import { listEmailTemplates } from './components.ts'
101
102
  import {
102
103
  type CromboloConfig,
103
104
  listeningBodyLimit,
@@ -105,6 +106,7 @@ import {
105
106
  type ServerRole,
106
107
  VERSION,
107
108
  } from './config.ts'
109
+ import { createContentNotificationRunner, createContentNotifier } from './content-notifications.ts'
108
110
  import { attachDatabase, bootstrapDatabase } from './database.ts'
109
111
  import { createDeliveryMemberSecurity } from './delivery-members.ts'
110
112
  import { createDiagnostics } from './diagnostics.ts'
@@ -143,7 +145,7 @@ import { mediaStoreFromEnvironment } from './media-store.ts'
143
145
  import { createMemberAuth, MEMBER_ROUTE_PREFIX } from './member-auth.ts'
144
146
  import { noNodesPage } from './no-nodes.ts'
145
147
  import { NotImplementedLog } from './not-implemented.ts'
146
- import { createDeps, PREVIEW_COOKIE, resolveUserLinkSender } from './ports.ts'
148
+ import { createDeps, documentEditUrl, PREVIEW_COOKIE, resolveUserLinkSender } from './ports.ts'
147
149
  import { createRedirectTracker } from './redirects.ts'
148
150
  import { createRemote } from './remote.ts'
149
151
  import { withRemoteMedia } from './remote-media.ts'
@@ -167,6 +169,7 @@ import { syncConfiguredRedirects } from './redirects.ts'
167
169
  import {
168
170
  bootSchema,
169
171
  CacheInstructionPoller,
172
+ componentAliasesIn,
170
173
  importSchema,
171
174
  type SchemaBoot,
172
175
  watchSchema,
@@ -751,6 +754,9 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
751
754
  // gates the options rather than `emailStatus.available`: development delivers
752
755
  // to the console, and that is a working flow for the person using it.
753
756
  const canSendUserLinks = Boolean(resolveUserLinkSender(config, email))
757
+ // Story 045: a change queues the e-mails its subscribers asked for. Nothing is
758
+ // queued on a site that cannot send; development sends to the console.
759
+ const notifier = createContentNotifier(db, { canSend: Boolean(email) })
754
760
  const mediaFiles = new MediaFileStore(mediaStore)
755
761
  const images = new ImageProcessor(mediaStore)
756
762
  const webhooks = createWebhookService({
@@ -775,6 +781,7 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
775
781
  schema,
776
782
  mediaFiles,
777
783
  email,
784
+ notifier,
778
785
  onSearchQueued: () => jobs.kickSearchIndexing(),
779
786
  // This node looks again at once; the others are told to. The write has
780
787
  // already landed wherever `componentsDir` points, so there is nothing to
@@ -812,6 +819,10 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
812
819
  onWebhookEvent: (event) => {
813
820
  void webhooks.raise(event)
814
821
  },
822
+ // After the response too: the subscribers are worked out and queued, and sent a minute later.
823
+ onContentChange: (change, principal) => {
824
+ void notifier.raise(change, principal ? { key: principal.id, name: principal.name } : null)
825
+ },
815
826
  onServerEvent: (event) => {
816
827
  events.broadcast('notify', [event])
817
828
  // Saves, deletes and moves all announce themselves; whatever they queued gets indexed now.
@@ -874,7 +885,21 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
874
885
  nodeId: schema.nodeId,
875
886
  onContentChanged: () => cache.invalidate(),
876
887
  bundleWorkflows: formTypes.workflows,
888
+ renderEmail: (alias, props) => renderer.renderEmail(alias, props),
889
+ site: async () => ({
890
+ name: (await seo.siteDefaults()).siteName || config.siteName,
891
+ url: config.applicationUrl,
892
+ }),
877
893
  }),
894
+ notifier,
895
+ contentNotifications: email
896
+ ? createContentNotificationRunner({
897
+ db,
898
+ email,
899
+ siteName: config.siteName,
900
+ editUrl: (key) => documentEditUrl(config, key),
901
+ })
902
+ : undefined,
878
903
  onDocumentChanged: (key) =>
879
904
  events.broadcast('notify', [
880
905
  { eventSource: 'Umbraco:CMS:Document', eventType: 'Updated', key },
@@ -1273,6 +1298,7 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
1273
1298
  reason: emailStatus.reason,
1274
1299
  affects: emailStatus.affects,
1275
1300
  canSendUserLinks,
1301
+ canSendNotifications: Boolean(email),
1276
1302
  })
1277
1303
  }
1278
1304
 
@@ -1329,6 +1355,18 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
1329
1355
  // Hyphenated, so no form alias (letters and digits) can be mistaken for it.
1330
1356
  if (action === 'available-types' && request.method === 'GET')
1331
1357
  return Response.json(describeFormTypes(formTypes.types))
1358
+ // The designer's "E-mail design" picker.
1359
+ if (action === 'email-templates' && request.method === 'GET')
1360
+ return Response.json({
1361
+ items: listEmailTemplates(
1362
+ config.componentsDir,
1363
+ definitions.flatMap((form) =>
1364
+ form.workflows.flatMap((workflow) =>
1365
+ typeof workflow.settings.template === 'string' ? [workflow.settings.template] : [],
1366
+ ),
1367
+ ),
1368
+ ),
1369
+ })
1332
1370
 
1333
1371
  const entries = new FormEntryRepository(db)
1334
1372
  const byKey = formsByKey(definitions)
@@ -1340,6 +1378,7 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
1340
1378
  schemaDir: config.schemaDir,
1341
1379
  writable: config.schemaWritable,
1342
1380
  formTypes: formTypes.types,
1381
+ componentAliases: () => componentAliasesIn(config.componentsDir),
1343
1382
  onChanged: config.schemaStore
1344
1383
  ? async () => {
1345
1384
  await publishSchema(config.schemaStore as SchemaStore, config.schemaDir)