@crombolo/server 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 +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 +70 -1
- package/src/config.ts +1 -1
- package/src/content-notifications.ts +310 -0
- 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 +2 -0
- package/src/jobs.ts +36 -4
- package/src/ports.ts +33 -0
- package/src/server.ts +40 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crombolo/server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.0",
|
|
4
4
|
"description": "The crombolo HTTP server: routing, configuration, media stores and background jobs.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Crombolo",
|
|
@@ -30,17 +30,17 @@
|
|
|
30
30
|
"access": "public"
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
|
-
"@crombolo/api-delivery": "0.
|
|
34
|
-
"@crombolo/api-management": "0.
|
|
35
|
-
"@crombolo/assistant": "0.
|
|
36
|
-
"@crombolo/auth": "0.
|
|
37
|
-
"@crombolo/backoffice-host": "0.
|
|
38
|
-
"@crombolo/contracts": "0.
|
|
39
|
-
"@crombolo/core": "0.
|
|
40
|
-
"@crombolo/data": "0.
|
|
41
|
-
"@crombolo/render": "0.
|
|
42
|
-
"@crombolo/schema": "0.
|
|
43
|
-
"@crombolo/transfer": "0.
|
|
33
|
+
"@crombolo/api-delivery": "0.17.0",
|
|
34
|
+
"@crombolo/api-management": "0.17.0",
|
|
35
|
+
"@crombolo/assistant": "0.17.0",
|
|
36
|
+
"@crombolo/auth": "0.17.0",
|
|
37
|
+
"@crombolo/backoffice-host": "0.17.0",
|
|
38
|
+
"@crombolo/contracts": "0.17.0",
|
|
39
|
+
"@crombolo/core": "0.17.0",
|
|
40
|
+
"@crombolo/data": "0.17.0",
|
|
41
|
+
"@crombolo/render": "0.17.0",
|
|
42
|
+
"@crombolo/schema": "0.17.0",
|
|
43
|
+
"@crombolo/transfer": "0.17.0",
|
|
44
44
|
"@logtape/logtape": "^2.3.8"
|
|
45
45
|
}
|
|
46
46
|
}
|
|
@@ -75,6 +75,8 @@ export function createDocumentPort(
|
|
|
75
75
|
tracker: RedirectTracker = NO_REDIRECT_TRACKING,
|
|
76
76
|
/** The site's own directory: where `domains.toml` is, when the site keeps one. */
|
|
77
77
|
siteDir?: string,
|
|
78
|
+
/** Whether the site can send the e-mails a subscription asks for (story 045). */
|
|
79
|
+
notificationsAvailable = true,
|
|
78
80
|
): DocumentPort {
|
|
79
81
|
const repo = new DocumentRepository(db, options)
|
|
80
82
|
const domains = new DomainRepository(db)
|
|
@@ -415,9 +417,14 @@ export function createDocumentPort(
|
|
|
415
417
|
async setNotifications(key, principal, actionIds) {
|
|
416
418
|
const node = await repo.nodes.byKey(key)
|
|
417
419
|
const userId = await userIdOf(principal)
|
|
418
|
-
if (!node || node.objectType !== repo.objectType || userId === undefined) return
|
|
420
|
+
if (!node || node.objectType !== repo.objectType || userId === undefined) return 'notFound'
|
|
421
|
+
// Clearing is always allowed; asking for something the site cannot send is not.
|
|
422
|
+
if (!notificationsAvailable) {
|
|
423
|
+
const had = new Set(await notifications.subscribed(userId, node.id))
|
|
424
|
+
if (actionIds.some((id) => !had.has(id))) return 'unavailable'
|
|
425
|
+
}
|
|
419
426
|
await notifications.set(userId, node.id, actionIds)
|
|
420
|
-
return
|
|
427
|
+
return 'saved'
|
|
421
428
|
},
|
|
422
429
|
|
|
423
430
|
versions: (key) => repo.versions(key),
|
package/src/adapters/users.ts
CHANGED
|
@@ -119,6 +119,8 @@ export interface UserPortOptions {
|
|
|
119
119
|
nodes: NodeLookup
|
|
120
120
|
/** Signs a user out everywhere, as a password change set by someone else does. */
|
|
121
121
|
endSessions: (userId: number) => Promise<void>
|
|
122
|
+
/** A group save changed what it may do on these pages (story 045: the "rights" notification). */
|
|
123
|
+
onPagePermissionsChanged?: (principal: Principal, nodeKeys: string[]) => void | Promise<void>
|
|
122
124
|
}
|
|
123
125
|
|
|
124
126
|
export function createUserPorts(
|
|
@@ -661,7 +663,11 @@ export function createUserPorts(
|
|
|
661
663
|
|
|
662
664
|
return {
|
|
663
665
|
users: userPort,
|
|
664
|
-
groups: createUserGroupPort(db, {
|
|
666
|
+
groups: createUserGroupPort(db, {
|
|
667
|
+
adminKey,
|
|
668
|
+
nodes,
|
|
669
|
+
onPagePermissionsChanged: options.onPagePermissionsChanged,
|
|
670
|
+
}),
|
|
665
671
|
userData: createUserDataPort(db),
|
|
666
672
|
}
|
|
667
673
|
}
|
|
@@ -763,7 +769,11 @@ function fromPermissions(permissions: UserGroupInput['permissions']): GranularPe
|
|
|
763
769
|
|
|
764
770
|
function createUserGroupPort(
|
|
765
771
|
db: Db,
|
|
766
|
-
deps: {
|
|
772
|
+
deps: {
|
|
773
|
+
adminKey: () => Promise<string | undefined>
|
|
774
|
+
nodes: NodeLookup
|
|
775
|
+
onPagePermissionsChanged?: UserPortOptions['onPagePermissionsChanged']
|
|
776
|
+
},
|
|
767
777
|
): UserGroupPort {
|
|
768
778
|
const groups = new UserGroupRepository(db)
|
|
769
779
|
const users = new UserRepository(db)
|
|
@@ -827,6 +837,34 @@ function createUserGroupPort(
|
|
|
827
837
|
return 'Success'
|
|
828
838
|
}
|
|
829
839
|
|
|
840
|
+
/** Each page's verbs for the group, by page key. */
|
|
841
|
+
const pageVerbs = (group: { granular: GranularPermission[] } | undefined) => {
|
|
842
|
+
const out = new Map<string, Set<string>>()
|
|
843
|
+
for (const row of group?.granular ?? [])
|
|
844
|
+
if (row.context === 'Document' && row.key) {
|
|
845
|
+
const verbs = out.get(row.key) ?? new Set<string>()
|
|
846
|
+
verbs.add(row.permission)
|
|
847
|
+
out.set(row.key, verbs)
|
|
848
|
+
}
|
|
849
|
+
return out
|
|
850
|
+
}
|
|
851
|
+
/** The pages whose verbs for the group a save changed. */
|
|
852
|
+
const changedPages = (before: Map<string, Set<string>>, after: Map<string, Set<string>>) =>
|
|
853
|
+
[...new Set([...before.keys(), ...after.keys()])].filter((key) => {
|
|
854
|
+
const a = before.get(key) ?? new Set<string>()
|
|
855
|
+
const b = after.get(key) ?? new Set<string>()
|
|
856
|
+
return a.size !== b.size || [...a].some((verb) => !b.has(verb))
|
|
857
|
+
})
|
|
858
|
+
const announcePermissions = async (
|
|
859
|
+
principal: Principal,
|
|
860
|
+
before: Map<string, Set<string>>,
|
|
861
|
+
key: string,
|
|
862
|
+
) => {
|
|
863
|
+
if (!deps.onPagePermissionsChanged) return
|
|
864
|
+
const changed = changedPages(before, pageVerbs(await groups.byKey(key)))
|
|
865
|
+
if (changed.length > 0) await deps.onPagePermissionsChanged(principal, changed)
|
|
866
|
+
}
|
|
867
|
+
|
|
830
868
|
const save = async (input: UserGroupInput, key: string): Promise<UserGroupStatus> => {
|
|
831
869
|
const result = await groups.save({
|
|
832
870
|
key,
|
|
@@ -906,6 +944,7 @@ function createUserGroupPort(
|
|
|
906
944
|
if (await groups.byKey(key)) return { status: 'DuplicateAlias' }
|
|
907
945
|
const saved = await save(input, key)
|
|
908
946
|
if (saved !== 'Success') return { status: saved }
|
|
947
|
+
await announcePermissions(principal, new Map(), key)
|
|
909
948
|
// A non-admin who creates a group joins it, as in Umbraco
|
|
910
949
|
if (!principal.isAdmin) await users.addToGroup(key, [principal.id])
|
|
911
950
|
return { status: 'Success', key }
|
|
@@ -917,7 +956,10 @@ function createUserGroupPort(
|
|
|
917
956
|
if (!isMember(principal, group.key)) return 'Forbidden'
|
|
918
957
|
const valid = await validate(principal, input, group)
|
|
919
958
|
if (valid !== 'Success') return valid
|
|
920
|
-
|
|
959
|
+
const before = pageVerbs(group)
|
|
960
|
+
const saved = await save(input, group.key)
|
|
961
|
+
if (saved === 'Success') await announcePermissions(principal, before, group.key)
|
|
962
|
+
return saved
|
|
921
963
|
},
|
|
922
964
|
|
|
923
965
|
async delete(principal, keys) {
|
package/src/approvals.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* This decides: who may act, what an edit does to a waiting request, when a
|
|
8
8
|
* stage is done, and when the page is published.
|
|
9
9
|
*/
|
|
10
|
+
|
|
10
11
|
import type { Principal } from '@crombolo/api-management'
|
|
11
12
|
import {
|
|
12
13
|
approvalsNeeded,
|
|
@@ -36,6 +37,7 @@ import {
|
|
|
36
37
|
type StoredWorkflow,
|
|
37
38
|
UserGroupRepository,
|
|
38
39
|
} from '@crombolo/data'
|
|
40
|
+
import type { ContentNotifier } from './content-notifications.ts'
|
|
39
41
|
import type { EmailPort } from './email.ts'
|
|
40
42
|
|
|
41
43
|
/** What publishing and unpublishing go through when approval says yes. */
|
|
@@ -57,6 +59,8 @@ export interface ApprovalsOptions {
|
|
|
57
59
|
email?: EmailPort
|
|
58
60
|
/** Where a link in an e-mail points, e.g. `https://site.example/crombolo`. */
|
|
59
61
|
backOfficeUrl?: string
|
|
62
|
+
/** Queues the content notifications a publish on approval makes (story 045). */
|
|
63
|
+
notifier?: ContentNotifier
|
|
60
64
|
}
|
|
61
65
|
|
|
62
66
|
/** A refusal a person reads: why, and what to do instead. */
|
|
@@ -426,6 +430,12 @@ export function createApprovals(options: ApprovalsOptions): Approvals {
|
|
|
426
430
|
request.action === 'publish' ? 'Published on approval' : 'Unpublished on approval',
|
|
427
431
|
`through "${request.workflow.name}"`,
|
|
428
432
|
)
|
|
433
|
+
if (request.action === 'publish')
|
|
434
|
+
await options.notifier?.raise(
|
|
435
|
+
{ actions: ['Umb.Document.Publish'], key: request.nodeKey, keyKind: 'document' },
|
|
436
|
+
principal ? { key: principal.id, name: principal.name } : null,
|
|
437
|
+
`approved through "${request.workflow.name}"`,
|
|
438
|
+
)
|
|
429
439
|
await notify(
|
|
430
440
|
'editor',
|
|
431
441
|
request,
|
package/src/components.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, readdirSync, statSync } from 'node:fs'
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
|
|
2
2
|
import { join, relative, resolve, sep } from 'node:path'
|
|
3
3
|
|
|
4
4
|
/**
|
|
@@ -196,6 +196,75 @@ export function componentFileFor(root: string, alias: string): string {
|
|
|
196
196
|
return join(root, `${alias}.tsx`)
|
|
197
197
|
}
|
|
198
198
|
|
|
199
|
+
/** What an e-mail template looks like: a default export over `EmailProps`. */
|
|
200
|
+
export function emailScaffold(alias: string): string {
|
|
201
|
+
const name = componentName(alias)
|
|
202
|
+
return `import type { EmailProps } from '@crombolo/crombolo'
|
|
203
|
+
|
|
204
|
+
export default function ${name}({ form, fields, site }: EmailProps) {
|
|
205
|
+
return (
|
|
206
|
+
<html lang="en">
|
|
207
|
+
<body>
|
|
208
|
+
<h1>{form.name}</h1>
|
|
209
|
+
<table>
|
|
210
|
+
{fields.map((field) => (
|
|
211
|
+
<tr>
|
|
212
|
+
<th>{field.caption}</th>
|
|
213
|
+
<td>
|
|
214
|
+
{field.leftOut
|
|
215
|
+
? 'Not included'
|
|
216
|
+
: field.answered
|
|
217
|
+
? field.values.join(', ')
|
|
218
|
+
: 'Not answered'}
|
|
219
|
+
</td>
|
|
220
|
+
</tr>
|
|
221
|
+
))}
|
|
222
|
+
</table>
|
|
223
|
+
<p>
|
|
224
|
+
Sent from <a href={site.url}>{site.name}</a>
|
|
225
|
+
</p>
|
|
226
|
+
</body>
|
|
227
|
+
</html>
|
|
228
|
+
)
|
|
229
|
+
}
|
|
230
|
+
`
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* The components the forms designer offers as e-mail designs: those written
|
|
235
|
+
* over `EmailProps`, and any a form already names, so a picker never loses the
|
|
236
|
+
* one that is set. A layout takes `EmailLayoutProps`, so it is not offered.
|
|
237
|
+
*/
|
|
238
|
+
export function listEmailTemplates(
|
|
239
|
+
root: string,
|
|
240
|
+
named: Iterable<string> = [],
|
|
241
|
+
): { alias: string; name: string }[] {
|
|
242
|
+
const components = listComponents(root)
|
|
243
|
+
const found = new Set<string>()
|
|
244
|
+
for (const component of components)
|
|
245
|
+
if (/\bEmailProps\b/.test(readFileSync(component.path, 'utf8'))) found.add(component.alias)
|
|
246
|
+
const present = new Set(components.map((component) => component.alias))
|
|
247
|
+
for (const alias of named) if (present.has(alias)) found.add(alias)
|
|
248
|
+
const items = [...found].sort().map((alias) => ({ alias, name: readableName(alias) }))
|
|
249
|
+
// Two folders with the same file name would read alike, so those keep their path.
|
|
250
|
+
const counts = new Map<string, number>()
|
|
251
|
+
for (const item of items) counts.set(item.name, (counts.get(item.name) ?? 0) + 1)
|
|
252
|
+
return items.map((item) =>
|
|
253
|
+
(counts.get(item.name) ?? 0) > 1 ? { ...item, name: `${item.name} (${item.alias})` } : item,
|
|
254
|
+
)
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** `emails/contactReceipt` → "Contact receipt". */
|
|
258
|
+
function readableName(alias: string): string {
|
|
259
|
+
const base = alias.split('/').pop() ?? alias
|
|
260
|
+
const words = base
|
|
261
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
|
262
|
+
.replace(/[-_]+/g, ' ')
|
|
263
|
+
.trim()
|
|
264
|
+
.toLowerCase()
|
|
265
|
+
return words.charAt(0).toUpperCase() + words.slice(1)
|
|
266
|
+
}
|
|
267
|
+
|
|
199
268
|
/** What a component looks like: a function, taking whatever imports it hands it. */
|
|
200
269
|
export function componentScaffold(alias: string): string {
|
|
201
270
|
const name = componentName(alias)
|
package/src/config.ts
CHANGED
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content notifications, delivered (story 045): an editor who asked to hear
|
|
3
|
+
* when a page is published — or moved, sorted, rolled back, and the rest of
|
|
4
|
+
* Umbraco's eleven — gets an e-mail when it is.
|
|
5
|
+
*
|
|
6
|
+
* A change is turned into outbox rows when it happens, one per person,
|
|
7
|
+
* resolved against the subscriptions on the page and the pages above it, and
|
|
8
|
+
* never for the person who made it. The rows wait a minute, so a save and a
|
|
9
|
+
* publish arrive as one e-mail, and a background job on the nodes that run
|
|
10
|
+
* jobs sends them; each person's rows are claimed by one node.
|
|
11
|
+
*/
|
|
12
|
+
import type { RaisedContentChange } from '@crombolo/api-management'
|
|
13
|
+
import { hasPathAccess, isValidEmail, ObjectTypes, permissionsForPath } from '@crombolo/core'
|
|
14
|
+
import {
|
|
15
|
+
type ContentNotification,
|
|
16
|
+
ContentNotificationRepository,
|
|
17
|
+
type Db,
|
|
18
|
+
DocumentRepository,
|
|
19
|
+
NOTIFIABLE_ACTIONS,
|
|
20
|
+
NodeRepository,
|
|
21
|
+
NotificationRepository,
|
|
22
|
+
UserRepository,
|
|
23
|
+
} from '@crombolo/data'
|
|
24
|
+
import { createNodeLookup, loadAccess } from './access.ts'
|
|
25
|
+
import type { EmailPort } from './email.ts'
|
|
26
|
+
import { retryAfter } from './form-workflows.ts'
|
|
27
|
+
import { logger } from './logging.ts'
|
|
28
|
+
|
|
29
|
+
const log = logger('notifications')
|
|
30
|
+
|
|
31
|
+
/** How long a change waits for others to the same person, so they arrive as one e-mail. */
|
|
32
|
+
export const DIGEST_DELAY_MS = 60_000
|
|
33
|
+
/** A row a node took and never finished is retried after this long: that node died. */
|
|
34
|
+
export const STALE_AFTER_MS = 15 * 60_000
|
|
35
|
+
|
|
36
|
+
export interface Actor {
|
|
37
|
+
key: string | null
|
|
38
|
+
name: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface ContentNotifierOptions {
|
|
42
|
+
/** False when the site cannot send e-mail: nothing is queued, and nothing piles up. */
|
|
43
|
+
canSend: boolean
|
|
44
|
+
delayMs?: number
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface ContentNotifier {
|
|
48
|
+
raise(change: RaisedContentChange, actor: Actor | null, detail?: string): Promise<void>
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The node ids of a path, root first, without the root's own -1 or the recycle bin's. */
|
|
52
|
+
const pathIds = (path: string): number[] =>
|
|
53
|
+
path
|
|
54
|
+
.split(',')
|
|
55
|
+
.map(Number)
|
|
56
|
+
.filter((id) => Number.isFinite(id) && id > 0)
|
|
57
|
+
|
|
58
|
+
export function createContentNotifier(db: Db, options: ContentNotifierOptions): ContentNotifier {
|
|
59
|
+
const nodes = new NodeRepository(db)
|
|
60
|
+
const documents = new DocumentRepository(db)
|
|
61
|
+
const subscriptions = new NotificationRepository(db)
|
|
62
|
+
const outbox = new ContentNotificationRepository(db)
|
|
63
|
+
|
|
64
|
+
const resolve = async (change: RaisedContentChange) => {
|
|
65
|
+
if (change.keyKind === 'version') {
|
|
66
|
+
const version = await documents.atVersion(change.key)
|
|
67
|
+
return version ? nodes.byKey(version.document.key) : undefined
|
|
68
|
+
}
|
|
69
|
+
return nodes.byKey(change.key)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
async raise(change, actor, detail) {
|
|
74
|
+
if (!options.canSend) return
|
|
75
|
+
try {
|
|
76
|
+
const node = await resolve(change)
|
|
77
|
+
if (!node || node.objectType !== ObjectTypes.Document) return
|
|
78
|
+
// A trashed page sits under the recycle bin now; its subscribers are
|
|
79
|
+
// the ones where it was.
|
|
80
|
+
let path = pathIds(node.path)
|
|
81
|
+
if (node.trashed) {
|
|
82
|
+
const parentKey = await documents.originalParent(node.key)
|
|
83
|
+
const parent = parentKey ? await nodes.byKey(parentKey) : undefined
|
|
84
|
+
path = [...(parent ? pathIds(parent.path) : []), node.id]
|
|
85
|
+
}
|
|
86
|
+
let summary = detail ?? null
|
|
87
|
+
if (change.withDescendants) {
|
|
88
|
+
const [row] = await db.query<{ n: number }>(
|
|
89
|
+
'SELECT COUNT(*) AS n FROM node WHERE path LIKE ? AND node_object_type = ? AND trashed = ?',
|
|
90
|
+
[`${node.path},%`, ObjectTypes.Document, db.dialect.boolValue(false)],
|
|
91
|
+
)
|
|
92
|
+
const under = Number(row?.n ?? 0)
|
|
93
|
+
if (under > 0) summary = `and ${under} page${under === 1 ? '' : 's'} under it`
|
|
94
|
+
}
|
|
95
|
+
const actorId = actor?.key ? (await new UserRepository(db).byKey(actor.key))?.id : undefined
|
|
96
|
+
const occurredAt = new Date()
|
|
97
|
+
const runAfter = new Date(occurredAt.getTime() + (options.delayMs ?? DIGEST_DELAY_MS))
|
|
98
|
+
const rows = []
|
|
99
|
+
for (const actionId of change.actions) {
|
|
100
|
+
const userIds = (await subscriptions.subscribersFor(actionId, path)).filter(
|
|
101
|
+
(id) => id !== actorId,
|
|
102
|
+
)
|
|
103
|
+
if (userIds.length === 0) continue
|
|
104
|
+
const keys = await db.query<{ key: string }>(
|
|
105
|
+
`SELECT key FROM user_account WHERE id IN (${userIds.map(() => '?').join(', ')})`,
|
|
106
|
+
userIds,
|
|
107
|
+
)
|
|
108
|
+
for (const { key } of keys)
|
|
109
|
+
rows.push({
|
|
110
|
+
recipientUserKey: String(key),
|
|
111
|
+
actionId,
|
|
112
|
+
nodeKey: node.key,
|
|
113
|
+
nodeName: node.text ?? node.key,
|
|
114
|
+
actorUserKey: actor?.key ?? null,
|
|
115
|
+
actorName: actor?.name ?? null,
|
|
116
|
+
detail: summary,
|
|
117
|
+
occurredAt,
|
|
118
|
+
runAfter,
|
|
119
|
+
})
|
|
120
|
+
}
|
|
121
|
+
await outbox.enqueue(rows)
|
|
122
|
+
} catch (error) {
|
|
123
|
+
// Like the audit log: a notification that cannot be queued never costs the change.
|
|
124
|
+
log.error('A content notification could not be queued: {error}', { error: String(error) })
|
|
125
|
+
}
|
|
126
|
+
},
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const VERBS: Record<string, string> = {
|
|
131
|
+
'Umb.Document.Duplicate': 'copied',
|
|
132
|
+
'Umb.Document.Delete': 'moved to the recycle bin',
|
|
133
|
+
'Umb.Document.Move': 'moved',
|
|
134
|
+
'Umb.Document.Create': 'created',
|
|
135
|
+
'Umb.Document.PublicAccess': 'changed who can see',
|
|
136
|
+
'Umb.Document.Publish': 'published',
|
|
137
|
+
'Umb.DocumentRecycleBin.Restore': 'restored',
|
|
138
|
+
'Umb.Document.Permissions': 'changed the permissions on',
|
|
139
|
+
'Umb.Document.Rollback': 'rolled back',
|
|
140
|
+
'Umb.Document.Sort': 'sorted the pages under',
|
|
141
|
+
'Umb.Document.Update': 'saved',
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** What the subscriber asked to hear of, as a reader would say it: "published". */
|
|
145
|
+
export const verbFor = (actionId: string): string => VERBS[actionId] ?? 'changed'
|
|
146
|
+
|
|
147
|
+
export interface NotificationEmailContext {
|
|
148
|
+
siteName: string
|
|
149
|
+
/** The page's address in the back office; undefined when the site's address is not known. */
|
|
150
|
+
editUrl: (nodeKey: string) => string | undefined
|
|
151
|
+
/** The recipient's language. Only English so far; the others fall back to it. */
|
|
152
|
+
language?: string | null
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const when = (date: Date) =>
|
|
156
|
+
date.toLocaleString('en-GB', {
|
|
157
|
+
hour: '2-digit',
|
|
158
|
+
minute: '2-digit',
|
|
159
|
+
day: 'numeric',
|
|
160
|
+
month: 'short',
|
|
161
|
+
year: 'numeric',
|
|
162
|
+
timeZone: 'UTC',
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
/** One plain-text e-mail for everything due to one person, oldest first. */
|
|
166
|
+
export function notificationEmail(
|
|
167
|
+
recipient: { name: string },
|
|
168
|
+
items: readonly ContentNotification[],
|
|
169
|
+
context: NotificationEmailContext,
|
|
170
|
+
): { subject: string; text: string } {
|
|
171
|
+
const who = (item: ContentNotification) => item.actorName ?? 'Someone'
|
|
172
|
+
const first = items[0] as ContentNotification
|
|
173
|
+
const subject =
|
|
174
|
+
items.length === 1
|
|
175
|
+
? `[${context.siteName}] ${who(first)} ${verbFor(first.actionId)} "${first.nodeName}"`
|
|
176
|
+
: `[${context.siteName}] ${items.length} changes to pages you follow`
|
|
177
|
+
const lines = items.map((item) => {
|
|
178
|
+
const link = context.editUrl(item.nodeKey)
|
|
179
|
+
return [
|
|
180
|
+
`${who(item)} ${verbFor(item.actionId)} "${item.nodeName}"${item.detail ? ` (${item.detail})` : ''}, ${when(item.occurredAt)} UTC.`,
|
|
181
|
+
...(link ? [` ${link}`] : []),
|
|
182
|
+
].join('\n')
|
|
183
|
+
})
|
|
184
|
+
const verbs = [...new Set(items.map((item) => verbFor(item.actionId)))]
|
|
185
|
+
const text = [
|
|
186
|
+
`Hello ${recipient.name},`,
|
|
187
|
+
'',
|
|
188
|
+
...lines,
|
|
189
|
+
'',
|
|
190
|
+
`You get this because you asked to hear when pages here are ${verbs.join(', ')}. To change that, open the page's Notifications in the back office.`,
|
|
191
|
+
].join('\n')
|
|
192
|
+
return { subject, text }
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export interface ContentNotificationRunReport {
|
|
196
|
+
claimed: number
|
|
197
|
+
sent: number
|
|
198
|
+
skipped: number
|
|
199
|
+
retrying: number
|
|
200
|
+
failed: number
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export interface ContentNotificationRunner {
|
|
204
|
+
runDue(now?: Date, limit?: number): Promise<ContentNotificationRunReport>
|
|
205
|
+
prune(before: Date): Promise<number>
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function createContentNotificationRunner(deps: {
|
|
209
|
+
db: Db
|
|
210
|
+
email: EmailPort | undefined
|
|
211
|
+
siteName: string
|
|
212
|
+
editUrl: (nodeKey: string) => string | undefined
|
|
213
|
+
}): ContentNotificationRunner {
|
|
214
|
+
const { db } = deps
|
|
215
|
+
const outbox = new ContentNotificationRepository(db)
|
|
216
|
+
const users = new UserRepository(db)
|
|
217
|
+
const lookup = createNodeLookup(db)
|
|
218
|
+
|
|
219
|
+
/** Whether the person may still read the page: a change made since may have taken it away. */
|
|
220
|
+
const mayRead = async (userId: number, item: ContentNotification) => {
|
|
221
|
+
const found = (await lookup.documents([item.nodeKey])).get(item.nodeKey)
|
|
222
|
+
if (!found) return false
|
|
223
|
+
const access = await loadAccess(db, userId)
|
|
224
|
+
// Moved to the recycle bin is about where the page was, so the bin does not count against it.
|
|
225
|
+
const trashed = found.trashed && item.actionId !== 'Umb.Document.Delete'
|
|
226
|
+
return (
|
|
227
|
+
hasPathAccess(access.startNodes.document, found.chain, trashed) &&
|
|
228
|
+
permissionsForPath(access.groups, found.chain).has('Umb.Document.Read')
|
|
229
|
+
)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return {
|
|
233
|
+
async runDue(now = new Date(), limit = 25) {
|
|
234
|
+
const report: ContentNotificationRunReport = {
|
|
235
|
+
claimed: 0,
|
|
236
|
+
sent: 0,
|
|
237
|
+
skipped: 0,
|
|
238
|
+
retrying: 0,
|
|
239
|
+
failed: 0,
|
|
240
|
+
}
|
|
241
|
+
await outbox.releaseStale(new Date(now.getTime() - STALE_AFTER_MS))
|
|
242
|
+
for (const recipientKey of await outbox.dueRecipients(now, limit)) {
|
|
243
|
+
const items = await outbox.claimFor(recipientKey, now)
|
|
244
|
+
if (items.length === 0) continue
|
|
245
|
+
report.claimed += items.length
|
|
246
|
+
const skip = async (rows: readonly ContentNotification[], reason: string) => {
|
|
247
|
+
await outbox.skip(
|
|
248
|
+
rows.map((row) => row.id),
|
|
249
|
+
reason,
|
|
250
|
+
)
|
|
251
|
+
report.skipped += rows.length
|
|
252
|
+
}
|
|
253
|
+
const user = await users.byKey(recipientKey)
|
|
254
|
+
if (!user) {
|
|
255
|
+
await skip(items, 'The user no longer exists.')
|
|
256
|
+
continue
|
|
257
|
+
}
|
|
258
|
+
if (!user.isApproved) {
|
|
259
|
+
await skip(items, 'The user is disabled.')
|
|
260
|
+
continue
|
|
261
|
+
}
|
|
262
|
+
if (!isValidEmail(user.email)) {
|
|
263
|
+
await skip(items, 'The user has no e-mail address.')
|
|
264
|
+
continue
|
|
265
|
+
}
|
|
266
|
+
const allowed: ContentNotification[] = []
|
|
267
|
+
for (const item of items) {
|
|
268
|
+
if (await mayRead(user.id, item)) allowed.push(item)
|
|
269
|
+
else await skip([item], 'The user can no longer see the page.')
|
|
270
|
+
}
|
|
271
|
+
if (allowed.length === 0) continue
|
|
272
|
+
const ids = allowed.map((item) => item.id)
|
|
273
|
+
if (!deps.email) {
|
|
274
|
+
await outbox.fail(ids, 'This site has no e-mail provider.')
|
|
275
|
+
report.failed += ids.length
|
|
276
|
+
continue
|
|
277
|
+
}
|
|
278
|
+
const message = notificationEmail(user, allowed, {
|
|
279
|
+
siteName: deps.siteName,
|
|
280
|
+
editUrl: deps.editUrl,
|
|
281
|
+
language: user.languageIsoCode,
|
|
282
|
+
})
|
|
283
|
+
const result = await deps.email.send({
|
|
284
|
+
to: [{ email: user.email, name: user.name }],
|
|
285
|
+
subject: message.subject,
|
|
286
|
+
text: message.text,
|
|
287
|
+
})
|
|
288
|
+
if (result.ok) {
|
|
289
|
+
await outbox.succeed(ids)
|
|
290
|
+
report.sent += ids.length
|
|
291
|
+
continue
|
|
292
|
+
}
|
|
293
|
+
const attempts = Math.max(...allowed.map((item) => item.attempts))
|
|
294
|
+
const retryAt = retryAfter(attempts, now)
|
|
295
|
+
await outbox.fail(ids, result.error, retryAt)
|
|
296
|
+
if (retryAt) report.retrying += ids.length
|
|
297
|
+
else report.failed += ids.length
|
|
298
|
+
log.warning('A content notification to {email} was not sent: {error}', {
|
|
299
|
+
email: user.email,
|
|
300
|
+
error: result.error,
|
|
301
|
+
})
|
|
302
|
+
}
|
|
303
|
+
return report
|
|
304
|
+
},
|
|
305
|
+
prune: (before) => outbox.prune(before),
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** Every action a person can subscribe to, so a caller can say which one a change was. */
|
|
310
|
+
export const NOTIFICATION_ACTION_IDS: readonly string[] = NOTIFIABLE_ACTIONS.map((a) => a.actionId)
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The plain-text part of an e-mail, derived from its HTML. `docs/forms.md`.
|
|
3
|
+
*
|
|
4
|
+
* A reader whose client shows text, and every spam filter that weighs an HTML
|
|
5
|
+
* part with no text beside it, read this. It is a reading of the markup, not a
|
|
6
|
+
* rendering: block elements become lines, list items "- ", links "text (url)".
|
|
7
|
+
*/
|
|
8
|
+
import { decodeEntities } from '@crombolo/data'
|
|
9
|
+
|
|
10
|
+
const PARAGRAPH =
|
|
11
|
+
/<\/?(p|h[1-6]|table|ul|ol|blockquote|pre|hr|section|article|header|footer)\b[^>]*>/gi
|
|
12
|
+
const LINE = /<\/?(div|tr|br|address|dl|dt|dd|figure|figcaption|thead|tbody|tfoot)\b[^>]*\/?>/gi
|
|
13
|
+
|
|
14
|
+
// Stand-ins for the breaks the markup asks for, kept apart from the source's
|
|
15
|
+
// own whitespace, which HTML collapses to a space.
|
|
16
|
+
const BLANK = '\u0001'
|
|
17
|
+
const BREAK = '\u0002'
|
|
18
|
+
const CELL = '\u0003'
|
|
19
|
+
/** A run of breaks with the spaces around them: a blank line if any asked for one. */
|
|
20
|
+
const BREAKS = new RegExp(`[ \u00a0]*[${BLANK}${BREAK}][${BLANK}${BREAK} \u00a0]*`, 'g')
|
|
21
|
+
|
|
22
|
+
const attribute = (tag: string, name: string): string | undefined => {
|
|
23
|
+
const found = new RegExp(`\\s${name}\\s*=\\s*("([^"]*)"|'([^']*)'|([^\\s>]+))`, 'i').exec(tag)
|
|
24
|
+
return found ? (found[2] ?? found[3] ?? found[4]) : undefined
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The text a reader of `html` would see, as lines. */
|
|
28
|
+
export function htmlToText(html: string): string {
|
|
29
|
+
const marked = html
|
|
30
|
+
.replace(/<!--[\s\S]*?-->/g, '')
|
|
31
|
+
.replace(/<(head|style|script|title)\b[^>]*>[\s\S]*?<\/\1>/gi, '')
|
|
32
|
+
.replace(/<a\b([^>]*)>([\s\S]*?)<\/a>/gi, (_whole, attrs: string, inner: string) => {
|
|
33
|
+
const href = (attribute(attrs, 'href') ?? '').trim()
|
|
34
|
+
if (!href || href.startsWith('#')) return inner
|
|
35
|
+
const address = href.replace(/^mailto:/i, '')
|
|
36
|
+
const label = decodeEntities(inner.replace(/<[^>]*>/g, '')).trim()
|
|
37
|
+
return label && label !== decodeEntities(address) ? `${inner} (${address})` : address
|
|
38
|
+
})
|
|
39
|
+
.replace(/<li\b[^>]*>/gi, `${BREAK}- `)
|
|
40
|
+
.replace(/<\/li>/gi, BREAK)
|
|
41
|
+
.replace(PARAGRAPH, BLANK)
|
|
42
|
+
.replace(LINE, BREAK)
|
|
43
|
+
// A cell is a column: the row stays one line, its cells spaced apart.
|
|
44
|
+
.replace(/<\/t[dh]>/gi, CELL)
|
|
45
|
+
.replace(/<[^>]*>/g, '')
|
|
46
|
+
.replace(/\s+/g, ' ')
|
|
47
|
+
|
|
48
|
+
return decodeEntities(marked)
|
|
49
|
+
.replace(BREAKS, (run) => (run.includes(BLANK) ? '\n\n' : '\n'))
|
|
50
|
+
.split('\n')
|
|
51
|
+
.map((line) =>
|
|
52
|
+
line
|
|
53
|
+
.split(CELL)
|
|
54
|
+
.map((cell) => cell.replace(/[ \u00a0]+/g, ' ').trim())
|
|
55
|
+
.filter(Boolean)
|
|
56
|
+
.join(' '),
|
|
57
|
+
)
|
|
58
|
+
.join('\n')
|
|
59
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
60
|
+
.trim()
|
|
61
|
+
}
|
package/src/email.ts
CHANGED
package/src/form-files.ts
CHANGED
|
@@ -30,6 +30,8 @@ export interface FormFileOptions {
|
|
|
30
30
|
onChanged?: () => Promise<void>
|
|
31
31
|
/** The field and workflow types the site's wired bundles add. */
|
|
32
32
|
formTypes?: FormTypes
|
|
33
|
+
/** The components on disk, so a Send email `template` is held to one that exists. */
|
|
34
|
+
componentAliases?: () => ReadonlySet<string>
|
|
33
35
|
}
|
|
34
36
|
|
|
35
37
|
export type FormWriteResult =
|
|
@@ -243,6 +245,7 @@ export async function saveForm(
|
|
|
243
245
|
const problems = validateForms([...others, form], {
|
|
244
246
|
contentTypeAliases: new Set(loaded.set.contentTypes.map((type) => type.alias)),
|
|
245
247
|
types: options.formTypes,
|
|
248
|
+
componentAliases: options.componentAliases?.(),
|
|
246
249
|
})
|
|
247
250
|
if (problems.length > 0) return { ok: false, status: 'invalid', problems }
|
|
248
251
|
|
package/src/form-workflows.ts
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
import {
|
|
15
15
|
allFormFields,
|
|
16
16
|
type FormValueToStore,
|
|
17
|
+
formFieldType,
|
|
17
18
|
type SchemaForm,
|
|
18
19
|
type SchemaFormWorkflow,
|
|
19
20
|
} from '@crombolo/core'
|
|
@@ -25,7 +26,9 @@ import {
|
|
|
25
26
|
type FormWorkflowRun,
|
|
26
27
|
type NodeSchemaState,
|
|
27
28
|
} from '@crombolo/data'
|
|
29
|
+
import type { EmailField, EmailProps, EmailRenderResult } from '@crombolo/render'
|
|
28
30
|
import type { EmailAttachment, EmailPort } from './email.ts'
|
|
31
|
+
import { htmlToText } from './email-text.ts'
|
|
29
32
|
import type { FormWorkflowTypeDefinition } from './form-types.ts'
|
|
30
33
|
import { logger } from './logging.ts'
|
|
31
34
|
import type { MediaFileStore } from './media-files.ts'
|
|
@@ -43,8 +46,13 @@ export interface WorkflowContext {
|
|
|
43
46
|
entryId: string | null
|
|
44
47
|
/** Which attempt this is, from 1. */
|
|
45
48
|
attempt?: number
|
|
49
|
+
/** When the run was queued; now when absent. */
|
|
50
|
+
submittedAt?: Date
|
|
46
51
|
}
|
|
47
52
|
|
|
53
|
+
/** What an e-mail template is rendered from: its props, less the signal the renderer adds. */
|
|
54
|
+
export type EmailTemplateProps = Omit<EmailProps, 'signal'>
|
|
55
|
+
|
|
48
56
|
/** How long a bundle's workflow may run before its signal aborts. */
|
|
49
57
|
const BUNDLE_WORKFLOW_TIMEOUT_MS = 60_000
|
|
50
58
|
|
|
@@ -60,6 +68,10 @@ export interface WorkflowDeps {
|
|
|
60
68
|
onContentChanged?: () => void
|
|
61
69
|
/** The workflow types wired bundles add, by type. */
|
|
62
70
|
bundleWorkflows?: ReadonlyMap<string, FormWorkflowTypeDefinition>
|
|
71
|
+
/** Renders a `template` component to HTML; absent, a workflow naming one cannot send. */
|
|
72
|
+
renderEmail?: (alias: string, props: EmailTemplateProps) => Promise<EmailRenderResult>
|
|
73
|
+
/** The site's name and address, for an e-mail template. */
|
|
74
|
+
site?: () => Promise<{ name: string; url: string }>
|
|
63
75
|
}
|
|
64
76
|
|
|
65
77
|
export type WorkflowResult =
|
|
@@ -105,20 +117,94 @@ const asList = (value: unknown): string[] =>
|
|
|
105
117
|
.filter(Boolean)
|
|
106
118
|
: []
|
|
107
119
|
|
|
108
|
-
/**
|
|
109
|
-
|
|
120
|
+
/**
|
|
121
|
+
* The submission as a reader sees it: caption, then what was entered. A field
|
|
122
|
+
* in `leftOut` (lower-cased aliases) says it was left out rather than showing
|
|
123
|
+
* its answer.
|
|
124
|
+
*/
|
|
125
|
+
export function plainSummary(
|
|
126
|
+
context: WorkflowContext,
|
|
127
|
+
leftOut: ReadonlySet<string> = new Set(),
|
|
128
|
+
): string {
|
|
110
129
|
const captions = new Map(
|
|
111
130
|
allFormFields(context.form).map((field) => [field.alias, field.caption || field.alias]),
|
|
112
131
|
)
|
|
113
132
|
const lines = context.values.map(
|
|
114
133
|
(value) =>
|
|
115
134
|
`${captions.get(value.fieldAlias) ?? value.fieldAlias}: ${
|
|
116
|
-
|
|
135
|
+
leftOut.has(value.fieldAlias.toLowerCase())
|
|
136
|
+
? '(sensitive, not included)'
|
|
137
|
+
: value.values.length > 0
|
|
138
|
+
? shown(value).join(', ')
|
|
139
|
+
: '(not answered)'
|
|
117
140
|
}`,
|
|
118
141
|
)
|
|
119
142
|
return [`${context.form.name}`, '', ...lines].join('\n')
|
|
120
143
|
}
|
|
121
144
|
|
|
145
|
+
/**
|
|
146
|
+
* The sensitive fields a workflow leaves out, lower-cased: all of them unless it
|
|
147
|
+
* sets `include-sensitive = true`.
|
|
148
|
+
*/
|
|
149
|
+
export function leftOutFields(form: SchemaForm, workflow: SchemaFormWorkflow): Set<string> {
|
|
150
|
+
if (workflow.settings['include-sensitive'] === true) return new Set()
|
|
151
|
+
return new Set(
|
|
152
|
+
allFormFields(form)
|
|
153
|
+
.filter((field) => field.sensitive)
|
|
154
|
+
.map((field) => field.alias.toLowerCase()),
|
|
155
|
+
)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** The values with every left-out field emptied, so substitution shows nothing for it. */
|
|
159
|
+
const withoutLeftOut = (
|
|
160
|
+
values: readonly FormValueToStore[],
|
|
161
|
+
leftOut: ReadonlySet<string>,
|
|
162
|
+
): FormValueToStore[] =>
|
|
163
|
+
values.map((value) =>
|
|
164
|
+
leftOut.has(value.fieldAlias.toLowerCase())
|
|
165
|
+
? { fieldAlias: value.fieldAlias, values: [] }
|
|
166
|
+
: value,
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
/** An e-mail template's props: the form, its fields in order, and where it came from. */
|
|
170
|
+
export function emailTemplateProps(
|
|
171
|
+
context: WorkflowContext,
|
|
172
|
+
leftOut: ReadonlySet<string>,
|
|
173
|
+
subject: string,
|
|
174
|
+
site: { name: string; url: string },
|
|
175
|
+
): EmailTemplateProps {
|
|
176
|
+
const sent = withoutLeftOut(context.values, leftOut)
|
|
177
|
+
const byAlias = new Map(sent.map((value) => [value.fieldAlias.toLowerCase(), value]))
|
|
178
|
+
const fields: EmailField[] = allFormFields(context.form)
|
|
179
|
+
// A heading or a paragraph collects nothing; a bundle's type is kept, since
|
|
180
|
+
// only its own declaration could say it is presentational.
|
|
181
|
+
.filter((field) => formFieldType(field.type)?.presentational !== true)
|
|
182
|
+
.map((field) => {
|
|
183
|
+
const found = byAlias.get(field.alias.toLowerCase())
|
|
184
|
+
const values = found ? shown(found) : []
|
|
185
|
+
return {
|
|
186
|
+
alias: field.alias,
|
|
187
|
+
caption: field.caption || field.alias,
|
|
188
|
+
type: field.type,
|
|
189
|
+
values,
|
|
190
|
+
answered: values.length > 0,
|
|
191
|
+
sensitive: field.sensitive,
|
|
192
|
+
leftOut: leftOut.has(field.alias.toLowerCase()),
|
|
193
|
+
}
|
|
194
|
+
})
|
|
195
|
+
return {
|
|
196
|
+
form: context.form,
|
|
197
|
+
fields,
|
|
198
|
+
value: (alias) => {
|
|
199
|
+
const found = byAlias.get(alias.toLowerCase())
|
|
200
|
+
return found ? shown(found).join(', ') : ''
|
|
201
|
+
},
|
|
202
|
+
entry: { id: context.entryId, submittedAt: context.submittedAt ?? new Date() },
|
|
203
|
+
site,
|
|
204
|
+
subject,
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
122
208
|
// ------------------------------------------------------------------ send email
|
|
123
209
|
|
|
124
210
|
async function sendEmail(
|
|
@@ -136,8 +222,26 @@ async function sendEmail(
|
|
|
136
222
|
}
|
|
137
223
|
|
|
138
224
|
const settings = workflow.settings
|
|
139
|
-
|
|
225
|
+
// Sensitive answers stay out of every part of the e-mail unless the workflow
|
|
226
|
+
// asks for them: an inbox is not covered by the sensitive-data permission.
|
|
227
|
+
const leftOut = leftOutFields(context.form, workflow)
|
|
228
|
+
const values = withoutLeftOut(context.values, leftOut)
|
|
229
|
+
const to = asList(substitute(asString(settings.to) ?? '', values))
|
|
140
230
|
if (to.length === 0) return { ok: false, retry: false, error: 'The workflow has no recipient.' }
|
|
231
|
+
const subject = substitute(asString(settings.subject) ?? context.form.name, values).trim()
|
|
232
|
+
|
|
233
|
+
let html: string | undefined
|
|
234
|
+
let text: string
|
|
235
|
+
const template = asString(settings.template)
|
|
236
|
+
if (template) {
|
|
237
|
+
const rendered = await renderTemplate(template, context, leftOut, subject, deps)
|
|
238
|
+
if (!rendered.ok) return rendered
|
|
239
|
+
html = rendered.html
|
|
240
|
+
text = htmlToText(html)
|
|
241
|
+
} else
|
|
242
|
+
text = settings.body
|
|
243
|
+
? substitute(String(settings.body), values)
|
|
244
|
+
: plainSummary(context, leftOut)
|
|
141
245
|
|
|
142
246
|
const attachments: EmailAttachment[] = []
|
|
143
247
|
if (settings['attach-uploads'] === true && deps.mediaFiles) {
|
|
@@ -146,7 +250,7 @@ async function sendEmail(
|
|
|
146
250
|
.filter((field) => field.type === 'fileUpload')
|
|
147
251
|
.map((field) => field.alias),
|
|
148
252
|
)
|
|
149
|
-
for (const value of
|
|
253
|
+
for (const value of values) {
|
|
150
254
|
if (!uploadAliases.has(value.fieldAlias)) continue
|
|
151
255
|
for (const src of value.values) {
|
|
152
256
|
const file = await deps.mediaFiles.open(src)
|
|
@@ -166,21 +270,67 @@ async function sendEmail(
|
|
|
166
270
|
bcc: asList(asString(settings.bcc) ?? '').map((email) => ({ email })),
|
|
167
271
|
replyTo: (() => {
|
|
168
272
|
const replyTo = asString(settings['reply-to'])
|
|
169
|
-
const resolved = replyTo ? substitute(replyTo,
|
|
273
|
+
const resolved = replyTo ? substitute(replyTo, values) : undefined
|
|
170
274
|
return resolved ? { email: resolved } : undefined
|
|
171
275
|
})(),
|
|
172
276
|
from: (() => {
|
|
173
277
|
const from = asString(settings.from)
|
|
174
278
|
return from ? { email: from } : undefined
|
|
175
279
|
})(),
|
|
176
|
-
subject
|
|
177
|
-
text
|
|
280
|
+
subject,
|
|
281
|
+
text,
|
|
282
|
+
...(html === undefined ? {} : { html }),
|
|
178
283
|
attachments: attachments.length > 0 ? attachments : undefined,
|
|
179
284
|
})
|
|
180
285
|
// A provider that refused is worth another go: a 500 or a timeout is weather.
|
|
181
286
|
return result.ok ? { ok: true } : { ok: false, retry: true, error: result.error }
|
|
182
287
|
}
|
|
183
288
|
|
|
289
|
+
/**
|
|
290
|
+
* The HTML of a workflow's e-mail template, or why there is none.
|
|
291
|
+
*
|
|
292
|
+
* Both failures are retried. A template missing now may be one a node has not
|
|
293
|
+
* caught up with after a deploy; one that throws may be reading something that
|
|
294
|
+
* is briefly unavailable. After the last attempt the run is left failed, with
|
|
295
|
+
* the reason a person can act on.
|
|
296
|
+
*/
|
|
297
|
+
async function renderTemplate(
|
|
298
|
+
template: string,
|
|
299
|
+
context: WorkflowContext,
|
|
300
|
+
leftOut: ReadonlySet<string>,
|
|
301
|
+
subject: string,
|
|
302
|
+
deps: WorkflowDeps,
|
|
303
|
+
): Promise<{ ok: true; html: string } | (WorkflowResult & { ok: false })> {
|
|
304
|
+
if (!deps.renderEmail)
|
|
305
|
+
return {
|
|
306
|
+
ok: false,
|
|
307
|
+
retry: false,
|
|
308
|
+
error: `The e-mail template '${template}' cannot be rendered here: this server renders no templates.`,
|
|
309
|
+
}
|
|
310
|
+
try {
|
|
311
|
+
const site = (await deps.site?.()) ?? { name: '', url: '' }
|
|
312
|
+
const rendered = await deps.renderEmail(
|
|
313
|
+
template,
|
|
314
|
+
emailTemplateProps(context, leftOut, subject, site),
|
|
315
|
+
)
|
|
316
|
+
if (rendered.status === 'ok') return { ok: true, html: rendered.html }
|
|
317
|
+
return {
|
|
318
|
+
ok: false,
|
|
319
|
+
retry: true,
|
|
320
|
+
error:
|
|
321
|
+
rendered.status === 'missing'
|
|
322
|
+
? `There is no e-mail template '${template}': no components/${template}.tsx in the views this node serves.`
|
|
323
|
+
: rendered.message,
|
|
324
|
+
}
|
|
325
|
+
} catch (error) {
|
|
326
|
+
return {
|
|
327
|
+
ok: false,
|
|
328
|
+
retry: true,
|
|
329
|
+
error: `The e-mail template '${template}' failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
184
334
|
// ------------------------------------------------------------------ save as content
|
|
185
335
|
|
|
186
336
|
async function saveAsContent(
|
|
@@ -404,6 +554,7 @@ export function createWorkflowRunner(
|
|
|
404
554
|
values: run.payload,
|
|
405
555
|
entryId: run.entryId,
|
|
406
556
|
attempt: run.attempts,
|
|
557
|
+
submittedAt: run.createDate,
|
|
407
558
|
}
|
|
408
559
|
let result: WorkflowResult
|
|
409
560
|
try {
|
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'
|
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)
|