@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.
Files changed (83) hide show
  1. package/dist/app-grants.d.ts +134 -0
  2. package/dist/app-grants.d.ts.map +1 -0
  3. package/dist/app-grants.js +263 -0
  4. package/dist/app-grants.js.map +1 -0
  5. package/dist/automations.d.ts +2255 -58
  6. package/dist/automations.d.ts.map +1 -1
  7. package/dist/automations.js +450 -25
  8. package/dist/automations.js.map +1 -1
  9. package/dist/compose.d.ts.map +1 -1
  10. package/dist/compose.js +3 -0
  11. package/dist/compose.js.map +1 -1
  12. package/dist/documents.d.ts +1 -0
  13. package/dist/documents.d.ts.map +1 -1
  14. package/dist/documents.js +2 -0
  15. package/dist/documents.js.map +1 -1
  16. package/dist/index.d.ts +11 -5
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +10 -4
  19. package/dist/index.js.map +1 -1
  20. package/dist/ledgers.d.ts.map +1 -1
  21. package/dist/ledgers.js +2 -0
  22. package/dist/ledgers.js.map +1 -1
  23. package/dist/modules.d.ts +93 -0
  24. package/dist/modules.d.ts.map +1 -0
  25. package/dist/modules.js +489 -0
  26. package/dist/modules.js.map +1 -0
  27. package/dist/occupies.d.ts +50 -0
  28. package/dist/occupies.d.ts.map +1 -0
  29. package/dist/occupies.js +123 -0
  30. package/dist/occupies.js.map +1 -0
  31. package/dist/outbox.d.ts +223 -17
  32. package/dist/outbox.d.ts.map +1 -1
  33. package/dist/outbox.js +125 -13
  34. package/dist/outbox.js.map +1 -1
  35. package/dist/page-board.d.ts +144 -0
  36. package/dist/page-board.d.ts.map +1 -0
  37. package/dist/page-board.js +280 -0
  38. package/dist/page-board.js.map +1 -0
  39. package/dist/plan-context.d.ts.map +1 -1
  40. package/dist/plan-context.js +3 -0
  41. package/dist/plan-context.js.map +1 -1
  42. package/dist/plan-model.d.ts +7 -0
  43. package/dist/plan-model.d.ts.map +1 -1
  44. package/dist/public-access.d.ts +6 -0
  45. package/dist/public-access.d.ts.map +1 -1
  46. package/dist/public-access.js +75 -1
  47. package/dist/public-access.js.map +1 -1
  48. package/dist/rail.d.ts +50 -0
  49. package/dist/rail.d.ts.map +1 -0
  50. package/dist/rail.js +122 -0
  51. package/dist/rail.js.map +1 -0
  52. package/dist/roles.d.ts +647 -1
  53. package/dist/roles.d.ts.map +1 -1
  54. package/dist/roles.js +459 -3
  55. package/dist/roles.js.map +1 -1
  56. package/dist/sample.d.ts +62 -0
  57. package/dist/sample.d.ts.map +1 -1
  58. package/dist/sample.js +203 -4
  59. package/dist/sample.js.map +1 -1
  60. package/dist/schema.d.ts +4349 -169
  61. package/dist/schema.d.ts.map +1 -1
  62. package/dist/schema.js +540 -41
  63. package/dist/schema.js.map +1 -1
  64. package/dist/shapes.d.ts +3 -2
  65. package/dist/shapes.d.ts.map +1 -1
  66. package/dist/shapes.js +76 -7
  67. package/dist/shapes.js.map +1 -1
  68. package/dist/state-actions.d.ts +4 -0
  69. package/dist/state-actions.d.ts.map +1 -1
  70. package/dist/state-actions.js +7 -0
  71. package/dist/state-actions.js.map +1 -1
  72. package/dist/states.d.ts +20 -1
  73. package/dist/states.d.ts.map +1 -1
  74. package/dist/states.js +47 -5
  75. package/dist/states.js.map +1 -1
  76. package/dist/validate.d.ts.map +1 -1
  77. package/dist/validate.js +110 -3
  78. package/dist/validate.js.map +1 -1
  79. package/dist/words.d.ts +1 -1
  80. package/dist/words.d.ts.map +1 -1
  81. package/dist/words.js +332 -1
  82. package/dist/words.js.map +1 -1
  83. package/package.json +2 -2
@@ -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 = 12;
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
- const operand = z.union([z.string().max(2000), z.number(), z.object({ amount: z.number().int().min(1).max(3650), unit: durationUnit }).strict()]);
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
- forEach: z.object({ table: refSchema, where: z.array(manifestConditionSchema).max(8), once: z.boolean() }).strict().optional(),
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
- /** The record's column that holds the address. */
117
- to: z.object({ kind: z.literal('field'), column: refSchema }).strict(),
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.object({ kind: z.literal('notification'), to: z.object({ roles: z.array(roleKey).min(1).max(20) }).strict(), title: localized(200), body: localized(2000).optional() }).strict(),
126
- z.object({ kind: z.literal('record.create'), table: refSchema, values }).strict(),
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
- const conditionIssues = (of, condition, path, inScan) => {
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
- // Every `{{record.<column>}}` a text carries is a column of the rule's record.
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.roles.forEach((role, i) => {
334
- if (!m.roles.includes(role))
335
- out.push({ path: here('to', 'roles', i), message: `"${role}" is not one of this manifest's roles` });
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
- tokens(value, here('values', ref));
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
- tokens(value, here('values', ref));
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
- column(record, action.to.column, here('to', 'column'));
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
- const filled = (name) => RULE_EMAIL_VARS.includes(name) || Object.hasOwn(vars, name) || columns.has(name) || (name.startsWith('record.') && columns.has(name.slice('record.'.length)));
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.length > 0) {
411
- const first = unfilled[0];
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 ${unfilled.map((name) => `{{${name}}}`).join(', ')}, which a rule cannot fill. Add "vars": { "${first}": "{{record.<column>}}" } to this step, or send this email from the outbox.`,
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
  }