@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/package.json +12 -12
- package/src/adapters/documents.ts +9 -2
- package/src/adapters/users.ts +45 -3
- package/src/approvals.ts +10 -0
- package/src/components.ts +77 -1
- package/src/config.ts +82 -81
- package/src/content-notifications.ts +310 -0
- package/src/email-ses.ts +4 -3
- package/src/email-text.ts +61 -0
- package/src/email.ts +1 -0
- package/src/form-files.ts +3 -0
- package/src/form-workflows.ts +159 -8
- package/src/index.ts +3 -0
- package/src/jobs.ts +36 -4
- package/src/ports.ts +33 -0
- package/src/server.ts +40 -1
- package/src/settings.ts +921 -0
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
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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)
|