@adminiumjs/manifest 0.3.27 → 0.3.29
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/dist/app-grants.d.ts +134 -0
- package/dist/app-grants.d.ts.map +1 -0
- package/dist/app-grants.js +263 -0
- package/dist/app-grants.js.map +1 -0
- package/dist/automations.d.ts +2255 -58
- package/dist/automations.d.ts.map +1 -1
- package/dist/automations.js +450 -25
- package/dist/automations.js.map +1 -1
- package/dist/compose.d.ts.map +1 -1
- package/dist/compose.js +3 -0
- package/dist/compose.js.map +1 -1
- package/dist/documents.d.ts +1 -0
- package/dist/documents.d.ts.map +1 -1
- package/dist/documents.js +2 -0
- package/dist/documents.js.map +1 -1
- package/dist/index.d.ts +11 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -4
- package/dist/index.js.map +1 -1
- package/dist/ledgers.d.ts.map +1 -1
- package/dist/ledgers.js +2 -0
- package/dist/ledgers.js.map +1 -1
- package/dist/modules.d.ts +93 -0
- package/dist/modules.d.ts.map +1 -0
- package/dist/modules.js +489 -0
- package/dist/modules.js.map +1 -0
- package/dist/occupies.d.ts +50 -0
- package/dist/occupies.d.ts.map +1 -0
- package/dist/occupies.js +123 -0
- package/dist/occupies.js.map +1 -0
- package/dist/outbox.d.ts +223 -17
- package/dist/outbox.d.ts.map +1 -1
- package/dist/outbox.js +125 -13
- package/dist/outbox.js.map +1 -1
- package/dist/page-board.d.ts +144 -0
- package/dist/page-board.d.ts.map +1 -0
- package/dist/page-board.js +280 -0
- package/dist/page-board.js.map +1 -0
- package/dist/plan-context.d.ts.map +1 -1
- package/dist/plan-context.js +3 -0
- package/dist/plan-context.js.map +1 -1
- package/dist/plan-model.d.ts +7 -0
- package/dist/plan-model.d.ts.map +1 -1
- package/dist/public-access.d.ts +6 -0
- package/dist/public-access.d.ts.map +1 -1
- package/dist/public-access.js +75 -1
- package/dist/public-access.js.map +1 -1
- package/dist/rail.d.ts +50 -0
- package/dist/rail.d.ts.map +1 -0
- package/dist/rail.js +122 -0
- package/dist/rail.js.map +1 -0
- package/dist/roles.d.ts +647 -1
- package/dist/roles.d.ts.map +1 -1
- package/dist/roles.js +459 -3
- package/dist/roles.js.map +1 -1
- package/dist/sample.d.ts +62 -0
- package/dist/sample.d.ts.map +1 -1
- package/dist/sample.js +203 -4
- package/dist/sample.js.map +1 -1
- package/dist/schema.d.ts +4349 -169
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +540 -41
- package/dist/schema.js.map +1 -1
- package/dist/shapes.d.ts +3 -2
- package/dist/shapes.d.ts.map +1 -1
- package/dist/shapes.js +76 -7
- package/dist/shapes.js.map +1 -1
- package/dist/state-actions.d.ts +4 -0
- package/dist/state-actions.d.ts.map +1 -1
- package/dist/state-actions.js +7 -0
- package/dist/state-actions.js.map +1 -1
- package/dist/states.d.ts +20 -1
- package/dist/states.d.ts.map +1 -1
- package/dist/states.js +47 -5
- package/dist/states.js.map +1 -1
- package/dist/validate.d.ts.map +1 -1
- package/dist/validate.js +110 -3
- package/dist/validate.js.map +1 -1
- package/dist/words.d.ts +1 -1
- package/dist/words.d.ts.map +1 -1
- package/dist/words.js +332 -1
- package/dist/words.js.map +1 -1
- package/package.json +2 -2
package/dist/automations.js
CHANGED
|
@@ -24,7 +24,9 @@ import { z } from 'zod';
|
|
|
24
24
|
import { placeholderNames, requiredNamesOfEmail, showWhenNames } from './placeholders.js';
|
|
25
25
|
import { bcp47TagSchema, refSchema } from './refs.js';
|
|
26
26
|
/** The most rules one manifest ships, and the most steps one rule holds. */
|
|
27
|
-
export const MANIFEST_AUTOMATIONS_MAX =
|
|
27
|
+
export const MANIFEST_AUTOMATIONS_MAX = 24;
|
|
28
|
+
/** How many a manifest could ship before: one with more says so, so a server that reads only these refuses it by its version. */
|
|
29
|
+
export const MANIFEST_AUTOMATIONS_FIRST_MAX = 12;
|
|
28
30
|
export const AUTOMATION_NODES_MAX = 40;
|
|
29
31
|
/** One text, or the same text in several languages (US English always among them). */
|
|
30
32
|
const localized = (max) => z.union([z.string().max(max), z.record(bcp47TagSchema, z.string().min(1).max(max)).refine((texts) => texts['en-US'] !== undefined, { message: 'a text in several languages includes en-US' })]);
|
|
@@ -35,7 +37,15 @@ export const AUTOMATION_CONDITION_OPS = ['is', 'is_not', 'contains', 'gt', 'lt',
|
|
|
35
37
|
const RELATIVE_OPS = ['within_next', 'within_last', 'more_than_ago', 'more_than_ahead'];
|
|
36
38
|
const NO_OPERAND_OPS = ['is_empty', 'not_empty'];
|
|
37
39
|
const COUNT_OPS = ['is', 'is_not', 'gt', 'lt'];
|
|
38
|
-
|
|
40
|
+
/**
|
|
41
|
+
* An amount of time; `setting` reads it from a whole-number column of the app's settings row when
|
|
42
|
+
* the conditions are read, and `amount` is used while that holds none.
|
|
43
|
+
*/
|
|
44
|
+
const operand = z.union([
|
|
45
|
+
z.string().max(2000),
|
|
46
|
+
z.number(),
|
|
47
|
+
z.object({ amount: z.number().int().min(1).max(3650), unit: durationUnit, setting: z.object({ table: refSchema, column: refSchema }).strict().optional() }).strict(),
|
|
48
|
+
]);
|
|
39
49
|
const fieldLeft = z.object({ field: refSchema }).strict();
|
|
40
50
|
const leaf = z.object({ left: fieldLeft, op: z.enum(AUTOMATION_CONDITION_OPS), right: operand.optional() }).strict();
|
|
41
51
|
const countLeft = z.object({ count: z.object({ table: refSchema, matchColumn: refSchema, equalsField: refSchema, where: leaf.optional() }).strict() }).strict();
|
|
@@ -99,22 +109,82 @@ export const manifestTriggerSchema = z.discriminatedUnion('kind', [
|
|
|
99
109
|
.object({
|
|
100
110
|
kind: z.literal('schedule'),
|
|
101
111
|
schedule: scheduleSchema,
|
|
102
|
-
|
|
112
|
+
/**
|
|
113
|
+
* The rows a run visits. `once: true` visits a row one time ever;
|
|
114
|
+
* `once: "per-spell"` one time for each spell the row matches: a row that
|
|
115
|
+
* stops matching and matches again later is visited again (a deal gone
|
|
116
|
+
* quiet, worked on, and quiet once more).
|
|
117
|
+
*/
|
|
118
|
+
forEach: z.object({ table: refSchema, where: z.array(manifestConditionSchema).max(8), once: z.union([z.boolean(), z.literal('per-spell')]) }).strict().optional(),
|
|
119
|
+
})
|
|
120
|
+
.strict(),
|
|
121
|
+
]);
|
|
122
|
+
/** A column of the app's settings row: the row of that table with the lowest key. */
|
|
123
|
+
const settingRef = z.object({ table: refSchema, column: refSchema }).strict();
|
|
124
|
+
/**
|
|
125
|
+
* A value a step writes: text, which may carry `{{record.<column>}}`; the
|
|
126
|
+
* moment it runs; what a column of the app's settings row holds when it runs
|
|
127
|
+
* (`setting`); or a day so many days after a date of the record, or after
|
|
128
|
+
* now, on the business's calendar (`daysAfter`), the days read from a
|
|
129
|
+
* whole-number setting when it holds some.
|
|
130
|
+
*/
|
|
131
|
+
const writeValue = z.union([
|
|
132
|
+
z.string().max(4000),
|
|
133
|
+
z.object({ now: z.literal(true) }).strict(),
|
|
134
|
+
z
|
|
135
|
+
.object({
|
|
136
|
+
setting: settingRef,
|
|
137
|
+
/** Who a staff column is given when the setting names nobody who signs in: a staff column of the record, or one link further. */
|
|
138
|
+
else: z.object({ staff: z.string().regex(/^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)?$/, 'a column, or <link column>.<column>') }).strict().optional(),
|
|
139
|
+
})
|
|
140
|
+
.strict(),
|
|
141
|
+
z
|
|
142
|
+
.object({
|
|
143
|
+
daysAfter: z
|
|
144
|
+
.object({
|
|
145
|
+
column: refSchema.optional(),
|
|
146
|
+
now: z.literal(true).optional(),
|
|
147
|
+
days: z.number().int().min(0).max(3650),
|
|
148
|
+
setting: settingRef.optional(),
|
|
149
|
+
/** The wall time on that day, for a column that holds a moment. */
|
|
150
|
+
at: z.string().regex(HHMM, 'a time such as 09:00').optional(),
|
|
151
|
+
})
|
|
152
|
+
.strict()
|
|
153
|
+
.refine((after) => (after.column === undefined) !== (after.now === undefined), { message: 'days after a column of the record ("column") or after now ("now": true): one of the two' }),
|
|
103
154
|
})
|
|
104
155
|
.strict(),
|
|
105
156
|
]);
|
|
106
|
-
/** A value a step writes: text, which may carry `{{record.<column>}}`, or the moment it runs. */
|
|
107
|
-
const writeValue = z.union([z.string().max(4000), z.object({ now: z.literal(true) }).strict()]);
|
|
108
157
|
const values = z.record(refSchema, writeValue);
|
|
109
158
|
const roleKey = z.string().regex(/^[a-z][a-z0-9-]*$/, 'a role key');
|
|
159
|
+
/** A staff column of the record, or one of the row a link of it names: `owner_id`, `deal_id.owner_id`. */
|
|
160
|
+
const staffPath = z.string().regex(/^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)?$/, 'a column, or <link column>.<column>');
|
|
161
|
+
/** What later steps call a row a step made. */
|
|
162
|
+
const stepName = z.string().regex(/^[a-z][a-z0-9_]{0,39}$/, 'a step name is snake_case, at most 40 characters');
|
|
110
163
|
export const manifestActionSchema = z.discriminatedUnion('kind', [
|
|
111
164
|
z
|
|
112
165
|
.object({
|
|
113
166
|
kind: z.literal('email'),
|
|
114
167
|
/** One of the manifest's own templates. */
|
|
115
168
|
templateKey: z.string().regex(/^[a-z][a-z0-9-]{1,79}$/, 'a template key'),
|
|
116
|
-
/**
|
|
117
|
-
|
|
169
|
+
/**
|
|
170
|
+
* Who it goes to: `field`, the record's column that holds the address;
|
|
171
|
+
* or `staff`, a staff column of the record (or `<link>.<staff column>`
|
|
172
|
+
* of the row a link of it names), whose person's address Adminium looks up;
|
|
173
|
+
* or `listed`, the people a table that lists people against the record
|
|
174
|
+
* (one of its `belongs` ways `through`) names for it: one mail each, to
|
|
175
|
+
* those of them who can open the record when it is sent.
|
|
176
|
+
*/
|
|
177
|
+
to: z.union([
|
|
178
|
+
z.object({ kind: z.literal('field'), column: refSchema }).strict(),
|
|
179
|
+
z.object({ kind: z.literal('staff'), column: staffPath }).strict(),
|
|
180
|
+
z.object({ kind: z.literal('listed'), table: refSchema }).strict(),
|
|
181
|
+
]),
|
|
182
|
+
/**
|
|
183
|
+
* Where a reply goes: to the person a staff column of the record names
|
|
184
|
+
* (or `<link>.<staff column>` of the row a link of it names), by the
|
|
185
|
+
* address they sign in with. Nobody there: no Reply-To.
|
|
186
|
+
*/
|
|
187
|
+
replyTo: z.object({ staff: staffPath }).strict().optional(),
|
|
118
188
|
/** What fills a placeholder of the template that the record does not. */
|
|
119
189
|
vars: z
|
|
120
190
|
.record(z.string().regex(/^[A-Za-z0-9_.-]{1,120}$/), z.string().max(2000))
|
|
@@ -122,8 +192,75 @@ export const manifestActionSchema = z.discriminatedUnion('kind', [
|
|
|
122
192
|
.optional(),
|
|
123
193
|
})
|
|
124
194
|
.strict(),
|
|
125
|
-
z
|
|
126
|
-
|
|
195
|
+
z
|
|
196
|
+
.object({
|
|
197
|
+
kind: z.literal('notification'),
|
|
198
|
+
/**
|
|
199
|
+
* Everybody who holds one of the roles; or the person a staff column of
|
|
200
|
+
* the record names (or `<link>.<staff column>`). With `lead: true`, not
|
|
201
|
+
* that person but whoever leads a team they are in (never the person
|
|
202
|
+
* themselves), and of those only who can open the record. `else`: the
|
|
203
|
+
* roles told when the column, or its lead, names nobody; a lead always
|
|
204
|
+
* has one. Or `listed`: the people a table that lists people against the
|
|
205
|
+
* record (one of its `belongs` ways `through`) names for it, of those
|
|
206
|
+
* only who can open the record when the step runs.
|
|
207
|
+
*/
|
|
208
|
+
to: z.union([
|
|
209
|
+
z.object({ roles: z.array(roleKey).min(1).max(20) }).strict(),
|
|
210
|
+
z.object({ listed: refSchema }).strict(),
|
|
211
|
+
z
|
|
212
|
+
.object({ column: staffPath, lead: z.literal(true).optional(), else: z.object({ roles: z.array(roleKey).min(1).max(20) }).strict().optional() })
|
|
213
|
+
.strict()
|
|
214
|
+
.refine((to) => to.lead !== true || to.else !== undefined, { message: 'a notice to a lead says who is told when there is none ("else": { "roles": [...] })', path: ['else'] }),
|
|
215
|
+
]),
|
|
216
|
+
title: localized(200),
|
|
217
|
+
body: localized(2000).optional(),
|
|
218
|
+
/** Told at once, also in the person's quiet hours (a change to today's day sheet). */
|
|
219
|
+
urgent: z.literal(true).optional(),
|
|
220
|
+
})
|
|
221
|
+
.strict(),
|
|
222
|
+
/** `name`: what later steps call the row this one makes, in `{{steps.<name>.<column>}}`. */
|
|
223
|
+
z.object({ kind: z.literal('record.create'), table: refSchema, values, name: stepName.optional() }).strict(),
|
|
224
|
+
/**
|
|
225
|
+
* A summary for each person: one email each, of the rows that are theirs.
|
|
226
|
+
* Each section lists the rows of a table whose `owner` (a staff column) is
|
|
227
|
+
* the person, that meet `where` and that the person may read; a block
|
|
228
|
+
* `email.rows` of the template with `"section": "<key>"` draws it.
|
|
229
|
+
* `"reach": "team"` adds the rows of the people the person leads when it is
|
|
230
|
+
* sent; `sum` adds columns up over every such row; `"by": "owner"` lists a
|
|
231
|
+
* line a person; `target` measures a sum against those people's targets.
|
|
232
|
+
* Nothing in it is read further than the person's own roles reach.
|
|
233
|
+
*/
|
|
234
|
+
z
|
|
235
|
+
.object({
|
|
236
|
+
kind: z.literal('digest'),
|
|
237
|
+
templateKey: z.string().regex(/^[a-z][a-z0-9-]{1,79}$/, 'a template key'),
|
|
238
|
+
to: z.object({ roles: z.array(roleKey).min(1).max(20) }).strict(),
|
|
239
|
+
sections: z
|
|
240
|
+
.array(z
|
|
241
|
+
.object({
|
|
242
|
+
key: z.string().regex(/^[a-z][a-z0-9-]{0,39}$/, 'a section key is kebab-case'),
|
|
243
|
+
table: refSchema,
|
|
244
|
+
owner: refSchema,
|
|
245
|
+
where: z.array(manifestConditionSchema).max(8).optional(),
|
|
246
|
+
orderBy: z.object({ column: refSchema, dir: z.enum(['asc', 'desc']).optional() }).strict().optional(),
|
|
247
|
+
limit: z.number().int().min(1).max(50).optional(),
|
|
248
|
+
/** `team`: the person's rows and those of the people they lead when it is sent. Never "all". */
|
|
249
|
+
reach: z.enum(['own', 'team']).optional(),
|
|
250
|
+
/** Number columns added up over every row of the section: `{{sum.<section>.<column>}}`. */
|
|
251
|
+
sum: z.array(refSchema).min(1).max(4).optional(),
|
|
252
|
+
/** One line a person (their name, how many, each sum) instead of one a row. */
|
|
253
|
+
by: z.literal('owner').optional(),
|
|
254
|
+
/** What the sum `of` is measured against: `column` of the rows of `table` whose `owner` names the same people. */
|
|
255
|
+
target: z.object({ table: refSchema, owner: refSchema, column: refSchema, year: refSchema.optional(), of: refSchema }).strict().optional(),
|
|
256
|
+
})
|
|
257
|
+
.strict())
|
|
258
|
+
.min(1)
|
|
259
|
+
.max(6),
|
|
260
|
+
/** Sent also to a person none of whose sections lists a row. Default: such a person gets no email. */
|
|
261
|
+
sendEmpty: z.literal(true).optional(),
|
|
262
|
+
})
|
|
263
|
+
.strict(),
|
|
127
264
|
z.object({ kind: z.literal('record.update'), values }).strict(),
|
|
128
265
|
/**
|
|
129
266
|
* A step an add-on gives (its `addOn.steps`): which add-on, which step, and
|
|
@@ -191,6 +328,8 @@ export const manifestAutomationSchema = z
|
|
|
191
328
|
enabled: z.boolean(),
|
|
192
329
|
trigger: manifestTriggerSchema,
|
|
193
330
|
graph: manifestGraphSchema,
|
|
331
|
+
/** The part of the app the rule belongs to: not run while it is off (see `modules.ts`). */
|
|
332
|
+
module: z.string().regex(/^[a-z][a-z0-9-]{0,39}$/, 'a module id is kebab-case').optional(),
|
|
194
333
|
})
|
|
195
334
|
.strict();
|
|
196
335
|
export const manifestAutomationsSchema = z
|
|
@@ -199,6 +338,8 @@ export const manifestAutomationsSchema = z
|
|
|
199
338
|
.refine((rules) => new Set(rules.map((rule) => rule.key)).size === rules.length, { message: 'two rules share a key' });
|
|
200
339
|
/** What every rule's email can read whatever its record is. */
|
|
201
340
|
export const RULE_EMAIL_VARS = ['now', 'ruleName', 'recordLabel', 'appName'];
|
|
341
|
+
/** What a summary's email can read beside its sections' figures: the person's name, and when the figures were read (`readAt`). */
|
|
342
|
+
export const DIGEST_EMAIL_VARS = ['now', 'ruleName', 'appName', 'name', 'readAt'];
|
|
202
343
|
/**
|
|
203
344
|
* Every placeholder a template reads in one language, in the order it reads
|
|
204
345
|
* them. A `{{row.*}}` is left out: it belongs to the rows a list block draws.
|
|
@@ -256,12 +397,30 @@ export function automationIssues(m) {
|
|
|
256
397
|
out.push({ path: [...path, 'right'], message: `"${of?.ref ?? ''}.${found.ref}" is a number: compare it with a number (${JSON.stringify(right)} is not one)` });
|
|
257
398
|
}
|
|
258
399
|
};
|
|
259
|
-
|
|
400
|
+
/** An amount read from a setting: a whole-number column of one of the manifest's tables, where the run can read it. */
|
|
401
|
+
const settingIssues = (condition, path, settingsOk) => {
|
|
402
|
+
const right = condition.right;
|
|
403
|
+
if (typeof right !== 'object' || right === null || right.setting === undefined)
|
|
404
|
+
return;
|
|
405
|
+
const at = [...path, 'right', 'setting'];
|
|
406
|
+
if (!settingsOk) {
|
|
407
|
+
out.push({ path: at, message: 'an amount read from a setting is read where a rule visits rows by the clock (forEach.where) and in a condition step, not here' });
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
const held = tableAt(right.setting.table, [...at, 'table']);
|
|
411
|
+
const found = column(held, right.setting.column, [...at, 'column']);
|
|
412
|
+
if (found !== undefined && !['int', 'bigint'].includes(found.type))
|
|
413
|
+
out.push({ path: [...at, 'column'], message: `"${right.setting.table}.${right.setting.column}" is not a whole-number column: it holds an amount of ${right.unit}` });
|
|
414
|
+
};
|
|
415
|
+
const conditionIssues = (of, condition, path, inScan, settingsOk = false) => {
|
|
416
|
+
settingIssues(condition, path, settingsOk);
|
|
260
417
|
if ('count' in condition.left) {
|
|
261
418
|
// A scan's filter is one query over the scanned table: it cannot count another table's rows.
|
|
262
419
|
if (inScan)
|
|
263
420
|
out.push({ path: [...path, 'left'], message: 'a rule that runs by the clock cannot filter the rows it visits by a count: keep the count in a column of the row (a rollup), and compare that' });
|
|
264
421
|
const count = condition.left.count;
|
|
422
|
+
if (typeof count.where?.right === 'object' && count.where.right.setting !== undefined)
|
|
423
|
+
out.push({ path: [...path, 'left', 'count', 'where', 'right', 'setting'], message: 'a count reads no setting: write the amount' });
|
|
265
424
|
const counted = tableAt(count.table, [...path, 'left', 'count', 'table']);
|
|
266
425
|
column(counted, count.matchColumn, [...path, 'left', 'count', 'matchColumn']);
|
|
267
426
|
column(of, count.equalsField, [...path, 'left', 'count', 'equalsField']);
|
|
@@ -296,8 +455,8 @@ export function automationIssues(m) {
|
|
|
296
455
|
else if (trigger.forEach !== undefined) {
|
|
297
456
|
const forEach = trigger.forEach;
|
|
298
457
|
record = tableAt(forEach.table, at('trigger', 'forEach', 'table'));
|
|
299
|
-
forEach.where.forEach((condition, c) => conditionIssues(record, condition, at('trigger', 'forEach', 'where', c), true));
|
|
300
|
-
if (forEach.once && forEach.where.length === 0)
|
|
458
|
+
forEach.where.forEach((condition, c) => conditionIssues(record, condition, at('trigger', 'forEach', 'where', c), true, true));
|
|
459
|
+
if (forEach.once !== false && forEach.where.length === 0)
|
|
301
460
|
out.push({ path: at('trigger', 'forEach', 'once'), message: 'a row visited once needs a condition that picks it: without one every row is visited on the first run' });
|
|
302
461
|
}
|
|
303
462
|
if (trigger.kind === 'schedule' && trigger.schedule.kind !== 'interval') {
|
|
@@ -307,10 +466,71 @@ export function automationIssues(m) {
|
|
|
307
466
|
if (schedule.kind === 'monthly' && schedule.dayOfMonth === undefined)
|
|
308
467
|
out.push({ path: at('trigger', 'schedule', 'dayOfMonth'), message: 'a monthly rule names its day (1 to 28)' });
|
|
309
468
|
}
|
|
310
|
-
|
|
469
|
+
/** A staff column of the rule's record, or of the row one of its links names: who a step tells. */
|
|
470
|
+
const staffAt = (path, where) => {
|
|
471
|
+
if (record === undefined) {
|
|
472
|
+
out.push({ path: where, message: 'this rule runs for no record, so there is no person on it to tell' });
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
const [first, second] = path.split('.');
|
|
476
|
+
let of = record;
|
|
477
|
+
let ref = first;
|
|
478
|
+
if (second !== undefined) {
|
|
479
|
+
const link = column(record, first, where);
|
|
480
|
+
if (link === undefined)
|
|
481
|
+
return;
|
|
482
|
+
if (link.references === undefined) {
|
|
483
|
+
out.push({ path: where, message: `"${record.ref}.${first}" is not a link to another table` });
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
of = tables.get(link.references);
|
|
487
|
+
// A link into an add-on's table, or one this manifest cannot see: checked where the rule is installed.
|
|
488
|
+
if (of === undefined)
|
|
489
|
+
return;
|
|
490
|
+
ref = second;
|
|
491
|
+
}
|
|
492
|
+
const found = column(of, ref, where);
|
|
493
|
+
if (found !== undefined && found.rules?.['staff'] !== true) {
|
|
494
|
+
out.push({ path: where, message: `"${of.ref}.${ref}" is not a staff column ("rules": { "staff": true }): only one names a person to tell` });
|
|
495
|
+
}
|
|
496
|
+
};
|
|
497
|
+
/** A table that lists people against the rule's record (a `belongs` way `through` it): who a step to "the people listed" tells. */
|
|
498
|
+
const listedAt = (ref, where) => {
|
|
499
|
+
if (record === undefined) {
|
|
500
|
+
out.push({ path: where, message: 'this rule runs for no record, so nobody is listed on it to tell' });
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
const lists = (record.belongs ?? []).some((way) => typeof way === 'object' && way !== null && 'through' in way && way.through?.table === ref);
|
|
504
|
+
if (!lists)
|
|
505
|
+
out.push({ path: where, message: `"${ref}" lists nobody against "${record.ref}": name a table one of its "belongs" ways reads "through"` });
|
|
506
|
+
};
|
|
507
|
+
/** `{{was.<column>}}`: a column of the record, in a rule run by its change. */
|
|
508
|
+
const changing = trigger.kind === 'record' && trigger.event === 'updated';
|
|
509
|
+
const wasIssue = (ref, path) => {
|
|
510
|
+
if (!changing)
|
|
511
|
+
out.push({ path, message: `{{was.${ref}}}: only a rule run when its record is updated reads the record as it was` });
|
|
512
|
+
else if (record !== undefined && !record.columns.some((candidate) => candidate.ref === ref))
|
|
513
|
+
out.push({ path, message: `{{was.${ref}}}: "${record.ref}" has no column "${ref}"` });
|
|
514
|
+
};
|
|
515
|
+
// The rows earlier steps made, by the name each step gave its row.
|
|
516
|
+
const made = new Map();
|
|
517
|
+
// Every `{{record.<column>}}` a text carries is a column of the rule's record; every `{{steps.<name>.<column>}}` a column of a row an earlier step made.
|
|
311
518
|
const tokens = (text, path) => {
|
|
312
519
|
const texts = typeof text === 'string' ? [text] : typeof text === 'object' && text !== null ? Object.values(text).filter((value) => typeof value === 'string') : [];
|
|
313
520
|
for (const each of texts) {
|
|
521
|
+
for (const match of each.matchAll(/\{\{\s*steps\.([A-Za-z0-9_]+)\.([A-Za-z0-9_]+)\s*(\|[^{}]*)?\}\}/g)) {
|
|
522
|
+
const name = match[1];
|
|
523
|
+
if (!made.has(name))
|
|
524
|
+
out.push({ path, message: `{{steps.${name}.${match[2]}}}: no earlier step makes a row called "${name}" (give a "record.create" step "name": "${name}")` });
|
|
525
|
+
else if (made.get(name) !== undefined && !made.get(name).columns.some((candidate) => candidate.ref === match[2]))
|
|
526
|
+
out.push({ path, message: `{{steps.${name}.${match[2]}}}: "${made.get(name).ref}" has no column "${match[2]}"` });
|
|
527
|
+
// A row that was not made has no value to stand in for: the run stops instead.
|
|
528
|
+
if (match[3] !== undefined)
|
|
529
|
+
out.push({ path, message: `{{steps.${name}.${match[2]}}} takes no backup: a row that was not made stops the run` });
|
|
530
|
+
}
|
|
531
|
+
// The record as it was before the change: only a rule about a change has one.
|
|
532
|
+
for (const match of each.matchAll(/\{\{\s*was\.([A-Za-z0-9_]+)\s*(?:\|[^{}]*)?\}\}/g))
|
|
533
|
+
wasIssue(match[1], path);
|
|
314
534
|
for (const match of each.matchAll(/\{\{\s*record\.([A-Za-z0-9_]+)\s*(?:\|[^{}]*)?\}\}/g)) {
|
|
315
535
|
if (record === undefined)
|
|
316
536
|
out.push({ path, message: `{{record.${match[1]}}}: this rule runs for no record (a clock with no rows to visit)` });
|
|
@@ -319,21 +539,91 @@ export function automationIssues(m) {
|
|
|
319
539
|
}
|
|
320
540
|
}
|
|
321
541
|
};
|
|
542
|
+
/** A setting a value reads: a column of one of the manifest's tables that is neither secret nor personal, of a table that is not private. */
|
|
543
|
+
const settingAt = (setting, path) => {
|
|
544
|
+
const held = tableAt(setting.table, [...path, 'table']);
|
|
545
|
+
if (held?.private === true)
|
|
546
|
+
out.push({ path: [...path, 'table'], message: `"${setting.table}" is private: no rule reads a value of it` });
|
|
547
|
+
const found = column(held, setting.column, [...path, 'column']);
|
|
548
|
+
if (found?.rules?.['secret'] === true || found?.rules?.['personal'] === true) {
|
|
549
|
+
out.push({ path: [...path, 'column'], message: `"${setting.table}.${setting.column}" is ${found.rules['secret'] === true ? 'a secret' : 'a personal'} column: a rule never writes it into another row` });
|
|
550
|
+
}
|
|
551
|
+
return found;
|
|
552
|
+
};
|
|
553
|
+
/** One value a step writes into `ref` of `into`: its placeholders, the setting it reads, the date it counts from. */
|
|
554
|
+
const valueIssues = (into, ref, value, path) => {
|
|
555
|
+
if (typeof value === 'string') {
|
|
556
|
+
tokens(value, path);
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
const target = into?.columns.find((candidate) => candidate.ref === ref);
|
|
560
|
+
if ('setting' in value) {
|
|
561
|
+
const found = settingAt(value.setting, [...path, 'setting']);
|
|
562
|
+
if (found === undefined || target === undefined)
|
|
563
|
+
return;
|
|
564
|
+
// A person is filled from a column that holds one; any other value from a column of its own kind.
|
|
565
|
+
if (target.rules?.['staff'] === true && found.rules?.['staff'] !== true)
|
|
566
|
+
out.push({ path: [...path, 'setting', 'column'], message: `"${into?.ref ?? ''}.${ref}" holds a staff member, and "${value.setting.table}.${value.setting.column}" is not a staff column` });
|
|
567
|
+
else if (found.type !== target.type || found.references !== target.references)
|
|
568
|
+
out.push({ path: [...path, 'setting', 'column'], message: `"${value.setting.table}.${value.setting.column}" is ${found.type} and "${into?.ref ?? ''}.${ref}" is ${target.type}: a setting fills a column of its own kind` });
|
|
569
|
+
if (value.else !== undefined) {
|
|
570
|
+
if (target.rules?.['staff'] !== true)
|
|
571
|
+
out.push({ path: [...path, 'else'], message: `"${into?.ref ?? ''}.${ref}" holds no staff member: "else" names a person, for a staff column only` });
|
|
572
|
+
else
|
|
573
|
+
staffAt(value.else.staff, [...path, 'else', 'staff']);
|
|
574
|
+
}
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
if ('daysAfter' in value) {
|
|
578
|
+
const after = value.daysAfter;
|
|
579
|
+
const at = [...path, 'daysAfter'];
|
|
580
|
+
if (target !== undefined && !DATES.includes(target.type))
|
|
581
|
+
out.push({ path, message: `"${into?.ref ?? ''}.${ref}" is ${target.type}: a day so many days on is written into a date column` });
|
|
582
|
+
if (after.at !== undefined && target?.type === 'date')
|
|
583
|
+
out.push({ path: [...at, 'at'], message: `"${into?.ref ?? ''}.${ref}" holds a day, which has no time of day` });
|
|
584
|
+
if (after.column !== undefined) {
|
|
585
|
+
if (record === undefined)
|
|
586
|
+
out.push({ path: [...at, 'column'], message: 'this rule runs for no record, so there is no date of it to count from' });
|
|
587
|
+
const from = column(record, after.column, [...at, 'column']);
|
|
588
|
+
if (from !== undefined && !DATES.includes(from.type))
|
|
589
|
+
out.push({ path: [...at, 'column'], message: `"${record?.ref ?? ''}.${after.column}" is ${from.type}: days are counted from a date` });
|
|
590
|
+
if (from?.rules?.['secret'] === true || from?.rules?.['personal'] === true)
|
|
591
|
+
out.push({ path: [...at, 'column'], message: `"${record?.ref ?? ''}.${after.column}" is a protected column: a day counted from it would tell it` });
|
|
592
|
+
}
|
|
593
|
+
if (after.setting !== undefined) {
|
|
594
|
+
const found = settingAt(after.setting, [...at, 'setting']);
|
|
595
|
+
if (found !== undefined && !['int', 'bigint'].includes(found.type))
|
|
596
|
+
out.push({ path: [...at, 'setting', 'column'], message: `"${after.setting.table}.${after.setting.column}" is not a whole-number column: it holds a number of days` });
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
};
|
|
322
600
|
const ids = [];
|
|
323
601
|
walk(rule.graph.nodes, at('graph', 'nodes'), (node, path) => {
|
|
324
602
|
tokens(node.title, [...path, 'title']);
|
|
325
603
|
tokens(node.sub, [...path, 'sub']);
|
|
326
604
|
if (node.kind === 'condition' || node.kind === 'branch')
|
|
327
|
-
conditionIssues(record, node.condition, [...path, 'condition'], false);
|
|
605
|
+
conditionIssues(record, node.condition, [...path, 'condition'], false, true);
|
|
328
606
|
if (node.kind !== 'action')
|
|
329
607
|
return;
|
|
330
608
|
const action = node.action;
|
|
331
609
|
const here = (...rest) => [...path, 'action', ...rest];
|
|
332
610
|
if (action.kind === 'notification') {
|
|
333
|
-
action.to
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
611
|
+
if ('roles' in action.to) {
|
|
612
|
+
action.to.roles.forEach((role, i) => {
|
|
613
|
+
if (!m.roles.includes(role))
|
|
614
|
+
out.push({ path: here('to', 'roles', i), message: `"${role}" is not one of this manifest's roles` });
|
|
615
|
+
});
|
|
616
|
+
}
|
|
617
|
+
else if ('listed' in action.to) {
|
|
618
|
+
listedAt(action.to.listed, here('to', 'listed'));
|
|
619
|
+
}
|
|
620
|
+
else {
|
|
621
|
+
staffAt(action.to.column, here('to', 'column'));
|
|
622
|
+
(action.to.else?.roles ?? []).forEach((role, i) => {
|
|
623
|
+
if (!m.roles.includes(role))
|
|
624
|
+
out.push({ path: here('to', 'else', 'roles', i), message: `"${role}" is not one of this manifest's roles` });
|
|
625
|
+
});
|
|
626
|
+
}
|
|
337
627
|
tokens(action.title, here('title'));
|
|
338
628
|
tokens(action.body, here('body'));
|
|
339
629
|
return;
|
|
@@ -342,7 +632,121 @@ export function automationIssues(m) {
|
|
|
342
632
|
const into = tableAt(action.table, here('table'));
|
|
343
633
|
for (const [ref, value] of Object.entries(action.values)) {
|
|
344
634
|
column(into, ref, here('values', ref));
|
|
345
|
-
|
|
635
|
+
valueIssues(into, ref, value, here('values', ref));
|
|
636
|
+
}
|
|
637
|
+
// Named after its own values are read: a step does not read the row it is about to make.
|
|
638
|
+
if (action.name !== undefined) {
|
|
639
|
+
if (made.has(action.name))
|
|
640
|
+
out.push({ path: here('name'), message: `"${action.name}" already names a row an earlier step makes` });
|
|
641
|
+
if (['record', 'steps', 'now'].includes(action.name))
|
|
642
|
+
out.push({ path: here('name'), message: `"${action.name}" is a word a rule already reads: choose another name` });
|
|
643
|
+
made.set(action.name, into);
|
|
644
|
+
}
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
if (action.kind === 'digest') {
|
|
648
|
+
action.to.roles.forEach((role, i) => {
|
|
649
|
+
if (!m.roles.includes(role))
|
|
650
|
+
out.push({ path: here('to', 'roles', i), message: `"${role}" is not one of this manifest's roles` });
|
|
651
|
+
});
|
|
652
|
+
const keys = new Set();
|
|
653
|
+
action.sections.forEach((section, s) => {
|
|
654
|
+
if (keys.has(section.key))
|
|
655
|
+
out.push({ path: here('sections', s, 'key'), message: `"${section.key}" is the key of an earlier section` });
|
|
656
|
+
keys.add(section.key);
|
|
657
|
+
const of = tableAt(section.table, here('sections', s, 'table'));
|
|
658
|
+
const owner = column(of, section.owner, here('sections', s, 'owner'));
|
|
659
|
+
if (owner !== undefined && owner.rules?.['staff'] !== true) {
|
|
660
|
+
out.push({ path: here('sections', s, 'owner'), message: `"${section.table}.${section.owner}" is not a staff column ("rules": { "staff": true }): a section lists the rows whose person is the one written to` });
|
|
661
|
+
}
|
|
662
|
+
(section.where ?? []).forEach((condition, c) => conditionIssues(of, condition, here('sections', s, 'where', c), true));
|
|
663
|
+
if (section.orderBy !== undefined)
|
|
664
|
+
column(of, section.orderBy.column, here('sections', s, 'orderBy', 'column'));
|
|
665
|
+
// A sum adds up numbers; the same column twice is one figure with two names.
|
|
666
|
+
(section.sum ?? []).forEach((ref, n) => {
|
|
667
|
+
const added = column(of, ref, here('sections', s, 'sum', n));
|
|
668
|
+
if (added !== undefined && !NUMBERS.includes(added.type))
|
|
669
|
+
out.push({ path: here('sections', s, 'sum', n), message: `"${section.table}.${ref}" is not a number column: a sum adds numbers up` });
|
|
670
|
+
if ((section.sum ?? []).indexOf(ref) !== n)
|
|
671
|
+
out.push({ path: here('sections', s, 'sum', n), message: `"${ref}" is summed twice` });
|
|
672
|
+
});
|
|
673
|
+
// A line a person has no row to sort by.
|
|
674
|
+
if (section.by !== undefined && section.orderBy !== undefined)
|
|
675
|
+
out.push({ path: here('sections', s, 'orderBy'), message: 'a section with one line a person ("by": "owner") is listed by name: it has no orderBy' });
|
|
676
|
+
if (section.target !== undefined) {
|
|
677
|
+
const target = section.target;
|
|
678
|
+
if (!(section.sum ?? []).includes(target.of))
|
|
679
|
+
out.push({ path: here('sections', s, 'target', 'of'), message: `"${target.of}" is not one of this section's sums ("sum")` });
|
|
680
|
+
const goals = tableAt(target.table, here('sections', s, 'target', 'table'));
|
|
681
|
+
const whose = column(goals, target.owner, here('sections', s, 'target', 'owner'));
|
|
682
|
+
if (whose !== undefined && whose.rules?.['staff'] !== true)
|
|
683
|
+
out.push({ path: here('sections', s, 'target', 'owner'), message: `"${target.table}.${target.owner}" is not a staff column ("rules": { "staff": true }): a target is somebody's` });
|
|
684
|
+
const goal = column(goals, target.column, here('sections', s, 'target', 'column'));
|
|
685
|
+
if (goal !== undefined && !NUMBERS.includes(goal.type))
|
|
686
|
+
out.push({ path: here('sections', s, 'target', 'column'), message: `"${target.table}.${target.column}" is not a number column` });
|
|
687
|
+
if (target.year !== undefined) {
|
|
688
|
+
const year = column(goals, target.year, here('sections', s, 'target', 'year'));
|
|
689
|
+
if (year !== undefined && !['int', 'bigint'].includes(year.type))
|
|
690
|
+
out.push({ path: here('sections', s, 'target', 'year'), message: `"${target.table}.${target.year}" is not a whole-number column: it holds a calendar year` });
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
});
|
|
694
|
+
const template = templates.get(action.templateKey);
|
|
695
|
+
if (template === undefined) {
|
|
696
|
+
out.push({ path: here('templateKey'), message: `"${action.templateKey}" is not one of this manifest's emailTemplates: a shipped rule sends only its own` });
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
if (template.attach !== undefined)
|
|
700
|
+
out.push({ path: here('templateKey'), message: `"${action.templateKey}" carries a document, which a summary does not` });
|
|
701
|
+
for (const [locale, content] of Object.entries(template.locales)) {
|
|
702
|
+
const drawn = new Set();
|
|
703
|
+
content.blocks.forEach((block) => {
|
|
704
|
+
if (block.block !== 'email.rows')
|
|
705
|
+
return;
|
|
706
|
+
const section = block.data?.['section'];
|
|
707
|
+
if (typeof section !== 'string' || !keys.has(section)) {
|
|
708
|
+
out.push({ path: here('templateKey'), message: `"${action.templateKey}" (${locale}) has a rows block that names no section of this step ("section": one of ${[...keys].join(', ')})` });
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
drawn.add(section);
|
|
712
|
+
const drawing = action.sections.find((one) => one.key === section);
|
|
713
|
+
// A line a person reads the person's name, how many rows, each sum, and the target with its cover.
|
|
714
|
+
if (drawing.by !== undefined) {
|
|
715
|
+
const lines = new Set(['row.owner', 'row.count', ...(drawing.sum ?? []).map((ref) => `row.sum.${ref}`), ...(drawing.target === undefined ? [] : ['row.target', 'row.cover'])]);
|
|
716
|
+
for (const name of placeholderNames([block.data?.['row']])) {
|
|
717
|
+
if (name.startsWith('row.') && !lines.has(name))
|
|
718
|
+
out.push({ path: here('templateKey'), message: `"${action.templateKey}" (${locale}) reads {{${name}}} in the section "${section}", which lists one line a person: a line reads ${[...lines].map((one) => `{{${one}}}`).join(', ')}` });
|
|
719
|
+
}
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
722
|
+
// Each line reads the columns of its section's table: {{row.<column>}}, or a form of one ({{row.<column>.money}}).
|
|
723
|
+
const of = tables.get(drawing.table);
|
|
724
|
+
for (const name of placeholderNames([block.data?.['row']])) {
|
|
725
|
+
if (!name.startsWith('row.'))
|
|
726
|
+
continue;
|
|
727
|
+
const ref = name.slice('row.'.length).split('.')[0];
|
|
728
|
+
if (of !== undefined && !of.columns.some((candidate) => candidate.ref === ref)) {
|
|
729
|
+
out.push({ path: here('templateKey'), message: `"${action.templateKey}" (${locale}) reads {{${name}}}, and "${of.ref}" has no column "${ref}"` });
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
});
|
|
733
|
+
for (const key of keys) {
|
|
734
|
+
if (!drawn.has(key))
|
|
735
|
+
out.push({ path: here('sections'), message: `"${action.templateKey}" (${locale}) draws no rows block for the section "${key}"` });
|
|
736
|
+
}
|
|
737
|
+
// A section's own figures: how many rows, each sum. They are there for everybody who reads the section.
|
|
738
|
+
const figures = new Set(action.sections.flatMap((one) => [`count.${one.key}`, ...(one.sum ?? []).map((ref) => `sum.${one.key}.${ref}`)]));
|
|
739
|
+
// A target and its cover are there only for a person who has one: a template that reads either says what it writes without.
|
|
740
|
+
const sometimes = new Set(action.sections.flatMap((one) => (one.target === undefined ? [] : [`target.${one.key}`, `cover.${one.key}`])));
|
|
741
|
+
const required = templateRequiredPlaceholdersOf(content);
|
|
742
|
+
const unfilled = required.filter((name) => !DIGEST_EMAIL_VARS.includes(name) && !figures.has(name) && !sometimes.has(name));
|
|
743
|
+
if (unfilled.length > 0) {
|
|
744
|
+
out.push({ path: here(), message: `"${action.templateKey}" (${locale}) reads ${unfilled.map((name) => `{{${name}}}`).join(', ')}, which a summary cannot fill: it fills {{name}}, {{appName}}, {{ruleName}}, {{now}}, {{readAt}}, {{count.<section>}}, and {{sum.<section>.<column>}}, {{target.<section>}} and {{cover.<section>}} of a section that asks for them` });
|
|
745
|
+
}
|
|
746
|
+
const bare = required.filter((name) => sometimes.has(name));
|
|
747
|
+
if (bare.length > 0) {
|
|
748
|
+
out.push({ path: here(), message: `"${action.templateKey}" (${locale}) reads ${bare.map((name) => `{{${name}}}`).join(', ')} with nothing to write for a person who has no target: give it a backup ({{${bare[0]}|none set}}), or tie its block to it with "showWhen"` });
|
|
749
|
+
}
|
|
346
750
|
}
|
|
347
751
|
return;
|
|
348
752
|
}
|
|
@@ -352,7 +756,7 @@ export function automationIssues(m) {
|
|
|
352
756
|
const decided = record === undefined ? new Set() : (m.decided?.(record.ref) ?? new Set());
|
|
353
757
|
for (const [ref, value] of Object.entries(action.values)) {
|
|
354
758
|
const found = column(record, ref, here('values', ref));
|
|
355
|
-
|
|
759
|
+
valueIssues(record, ref, value, here('values', ref));
|
|
356
760
|
const rule2 = DECIDING.find((name) => found?.rules?.[name] !== undefined);
|
|
357
761
|
if (found !== undefined && (rule2 !== undefined || decided.has(ref))) {
|
|
358
762
|
out.push({ path: here('values', ref), message: `"${record?.ref ?? ''}.${ref}" is decided by Adminium${rule2 === undefined ? '' : ` (its ${rule2} rule)`}: a rule cannot write it` });
|
|
@@ -384,7 +788,14 @@ export function automationIssues(m) {
|
|
|
384
788
|
// An email: one of the manifest's own templates, to a column of the record, and only one a rule can fill.
|
|
385
789
|
if (record === undefined)
|
|
386
790
|
out.push({ path: here(), message: 'this rule runs for no record, so there is no address to send to' });
|
|
387
|
-
|
|
791
|
+
if (action.to.kind === 'staff')
|
|
792
|
+
staffAt(action.to.column, here('to', 'column'));
|
|
793
|
+
else if (action.to.kind === 'listed')
|
|
794
|
+
listedAt(action.to.table, here('to', 'table'));
|
|
795
|
+
else
|
|
796
|
+
column(record, action.to.column, here('to', 'column'));
|
|
797
|
+
if (action.replyTo !== undefined)
|
|
798
|
+
staffAt(action.replyTo.staff, here('replyTo', 'staff'));
|
|
388
799
|
const template = templates.get(action.templateKey);
|
|
389
800
|
if (template === undefined) {
|
|
390
801
|
out.push({ path: here('templateKey'), message: `"${action.templateKey}" is not one of this manifest's emailTemplates: a shipped rule sends only its own` });
|
|
@@ -394,7 +805,17 @@ export function automationIssues(m) {
|
|
|
394
805
|
for (const [name, text] of Object.entries(vars))
|
|
395
806
|
tokens(text, here('vars', name));
|
|
396
807
|
const columns = new Set((record?.columns ?? []).map((candidate) => candidate.ref));
|
|
397
|
-
|
|
808
|
+
// The name of the person a staff column of the record names; the record's address, in a mail to a person.
|
|
809
|
+
const people = new Set((record?.columns ?? []).filter((candidate) => candidate.rules?.['staff'] === true).flatMap((candidate) => [`${candidate.ref}.name`, `record.${candidate.ref}.name`]));
|
|
810
|
+
// A mail to a person, or to each of the people listed on the record: each is somebody the address is worked out for.
|
|
811
|
+
const toPerson = action.to.kind === 'staff' || action.to.kind === 'listed';
|
|
812
|
+
const filled = (name) => RULE_EMAIL_VARS.includes(name) ||
|
|
813
|
+
Object.hasOwn(vars, name) ||
|
|
814
|
+
columns.has(name) ||
|
|
815
|
+
(name.startsWith('record.') && columns.has(name.slice('record.'.length))) ||
|
|
816
|
+
people.has(name) ||
|
|
817
|
+
(toPerson && name === 'recordUrl') ||
|
|
818
|
+
(changing && name.startsWith('was.') && columns.has(name.slice('was.'.length)));
|
|
398
819
|
const everywhere = new Set();
|
|
399
820
|
if (template.attach !== undefined)
|
|
400
821
|
out.push({ path: here('templateKey'), message: `"${action.templateKey}" carries a document, which only the outbox draws: send it from the outbox, or give the rule a template with no "attach"` });
|
|
@@ -407,11 +828,15 @@ export function automationIssues(m) {
|
|
|
407
828
|
for (const name of names)
|
|
408
829
|
everywhere.add(name);
|
|
409
830
|
const unfilled = templateRequiredPlaceholdersOf(content).filter((name) => !filled(name));
|
|
410
|
-
if (unfilled.
|
|
411
|
-
|
|
831
|
+
if (unfilled.includes('recordUrl') && !toPerson) {
|
|
832
|
+
out.push({ path: here(), message: `"${action.templateKey}" (${locale}) reads {{recordUrl}}, which only a mail to a person carries ("to": { "kind": "staff", … }): an address of the record is no link for anybody else` });
|
|
833
|
+
}
|
|
834
|
+
const others = unfilled.filter((name) => name !== 'recordUrl' || toPerson);
|
|
835
|
+
if (others.length > 0) {
|
|
836
|
+
const first = others[0];
|
|
412
837
|
out.push({
|
|
413
838
|
path: here(),
|
|
414
|
-
message: `"${action.templateKey}" (${locale}) reads ${
|
|
839
|
+
message: `"${action.templateKey}" (${locale}) reads ${others.map((name) => `{{${name}}}`).join(', ')}, which a rule cannot fill. Add "vars": { "${first}": "{{record.<column>}}" } to this step, or send this email from the outbox.`,
|
|
415
840
|
});
|
|
416
841
|
}
|
|
417
842
|
}
|