@adminiumjs/manifest 0.3.5 → 0.3.7
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/capacity.d.ts +786 -0
- package/dist/capacity.d.ts.map +1 -0
- package/dist/capacity.js +717 -0
- package/dist/capacity.js.map +1 -0
- package/dist/documents.d.ts +91 -5
- package/dist/documents.d.ts.map +1 -1
- package/dist/documents.js +129 -32
- package/dist/documents.js.map +1 -1
- package/dist/formula.d.ts +114 -2
- package/dist/formula.d.ts.map +1 -1
- package/dist/formula.js +291 -5
- package/dist/formula.js.map +1 -1
- package/dist/index.d.ts +12 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +12 -6
- package/dist/index.js.map +1 -1
- package/dist/marketplace-wire.d.ts +230 -0
- package/dist/marketplace-wire.d.ts.map +1 -0
- package/dist/marketplace-wire.js +138 -0
- package/dist/marketplace-wire.js.map +1 -0
- package/dist/outbox.d.ts +242 -27
- package/dist/outbox.d.ts.map +1 -1
- package/dist/outbox.js +553 -27
- package/dist/outbox.js.map +1 -1
- package/dist/plan-context.d.ts +28 -3
- package/dist/plan-context.d.ts.map +1 -1
- package/dist/plan-context.js +291 -14
- package/dist/plan-context.js.map +1 -1
- package/dist/plan-model.d.ts +58 -3
- package/dist/plan-model.d.ts.map +1 -1
- package/dist/public-access.d.ts +637 -0
- package/dist/public-access.d.ts.map +1 -1
- package/dist/public-access.js +1593 -12
- package/dist/public-access.js.map +1 -1
- package/dist/refs.d.ts +86 -0
- package/dist/refs.d.ts.map +1 -1
- package/dist/refs.js +186 -0
- package/dist/refs.js.map +1 -1
- package/dist/roles.d.ts +10 -2
- package/dist/roles.d.ts.map +1 -1
- package/dist/roles.js +27 -5
- package/dist/roles.js.map +1 -1
- package/dist/sample.d.ts +218 -2
- package/dist/sample.d.ts.map +1 -1
- package/dist/sample.js +145 -7
- package/dist/sample.js.map +1 -1
- package/dist/schema.d.ts +2860 -360
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +1002 -54
- package/dist/schema.js.map +1 -1
- package/dist/shapes.d.ts +5 -1
- package/dist/shapes.d.ts.map +1 -1
- package/dist/shapes.js +13 -3
- package/dist/shapes.js.map +1 -1
- package/dist/states.d.ts +342 -2
- package/dist/states.d.ts.map +1 -1
- package/dist/states.js +566 -7
- package/dist/states.js.map +1 -1
- package/dist/table-shapes.d.ts +47 -0
- package/dist/table-shapes.d.ts.map +1 -0
- package/dist/table-shapes.js +159 -0
- package/dist/table-shapes.js.map +1 -0
- package/dist/validate.d.ts.map +1 -1
- package/dist/validate.js +80 -2
- package/dist/validate.js.map +1 -1
- package/package.json +2 -2
package/dist/outbox.js
CHANGED
|
@@ -26,6 +26,8 @@
|
|
|
26
26
|
*/
|
|
27
27
|
import { z } from 'zod';
|
|
28
28
|
import { formulaColumns } from './formula.js';
|
|
29
|
+
import { personalColumn, unlistedColumn } from './public-access.js';
|
|
30
|
+
import { reachedOnlyByUndo } from './states.js';
|
|
29
31
|
import { bcp47TagSchema, refSchema, scalarSchema, settingRefSchema, settingSourceSchema, textOrLabels, valueFits, } from './refs.js';
|
|
30
32
|
/** The statuses an outbox table's status column must offer. */
|
|
31
33
|
export const OUTBOX_STATUSES = ['queued', 'sent', 'failed', 'skipped'];
|
|
@@ -83,7 +85,16 @@ const producerBase = {
|
|
|
83
85
|
* one, while that bool of the settings row is false (each of a studio's
|
|
84
86
|
* notices has its own switch).
|
|
85
87
|
*/
|
|
86
|
-
gate: z
|
|
88
|
+
gate: z
|
|
89
|
+
.union([
|
|
90
|
+
z.literal('enabled'),
|
|
91
|
+
z.object({ setting: settingRefSchema }).strict(),
|
|
92
|
+
/** Only while a feature of the app is on (its add-ons attached): a receipt, with Invoices & Receipts. */
|
|
93
|
+
z.object({ feature: z.string().regex(/^[a-z][a-z0-9-]{0,39}$/, 'a feature id') }).strict(),
|
|
94
|
+
/** Both at once: only while the feature is on AND that bool of the settings row is true (a receipt, when the manager wants one sent). */
|
|
95
|
+
z.object({ feature: z.string().regex(/^[a-z][a-z0-9-]{0,39}$/, 'a feature id'), setting: settingRefSchema }).strict(),
|
|
96
|
+
])
|
|
97
|
+
.optional(),
|
|
87
98
|
/** Skipped for a recipient whose `recipient.optIn` column is false. */
|
|
88
99
|
optIn: z.literal(true).optional(),
|
|
89
100
|
/** Written `held`: sent only once someone approves it. */
|
|
@@ -101,10 +112,47 @@ const producerBase = {
|
|
|
101
112
|
supersede: z.string().regex(/^[a-z][a-z0-9-]{0,39}$/, 'a group name').optional(),
|
|
102
113
|
/** While the source row meets one of these, its waiting messages are skipped, with the reason. */
|
|
103
114
|
dropWhen: z.array(dropConditionSchema).min(1).max(4).optional(),
|
|
104
|
-
/**
|
|
105
|
-
|
|
115
|
+
/**
|
|
116
|
+
* Sent instead of the outbox's recipient: to an address a setting holds
|
|
117
|
+
* (the studio's own), or to an address a text column of the producing row
|
|
118
|
+
* holds (a ticket's `send_to_email`, the friend it is offered to), with
|
|
119
|
+
* that row's `name` column as the name.
|
|
120
|
+
*/
|
|
121
|
+
recipient: z
|
|
122
|
+
.union([
|
|
123
|
+
z.object({ setting: settingSourceSchema }).strict(),
|
|
124
|
+
/** `language`: a text column of the same row, the language the message is written in. */
|
|
125
|
+
z.object({ column: refSchema, name: refSchema.optional(), language: refSchema.optional() }).strict(),
|
|
126
|
+
])
|
|
127
|
+
.optional(),
|
|
106
128
|
/** One message per linked row in each window of this many minutes. */
|
|
107
129
|
batchMinutes: z.number().int().min(1).max(240).optional(),
|
|
130
|
+
/**
|
|
131
|
+
* The message waits this many seconds before it may go, so a move taken
|
|
132
|
+
* back at once (an order marked ready by mistake) drops it by `dropWhen`
|
|
133
|
+
* before anyone is told. A message dropped so does not stop the next one.
|
|
134
|
+
*/
|
|
135
|
+
holdSeconds: z.number().int().min(1).max(3600).optional(),
|
|
136
|
+
/**
|
|
137
|
+
* A column of the row the message is about: one message for each value it
|
|
138
|
+
* holds, not one for the row for ever — a ticket offered again, its link
|
|
139
|
+
* made afresh, is emailed again. Kept as a digest in the outbox's
|
|
140
|
+
* `repeatKey` column.
|
|
141
|
+
*/
|
|
142
|
+
repeatBy: refSchema.optional(),
|
|
143
|
+
/**
|
|
144
|
+
* One message for each change it hears of, not one for the row for ever:
|
|
145
|
+
* "Resend tickets" twice is two messages, a stay's dates moved twice is two
|
|
146
|
+
* notices. On a producer that listens for changes (`onChange`).
|
|
147
|
+
*/
|
|
148
|
+
repeat: z.literal(true).optional(),
|
|
149
|
+
/**
|
|
150
|
+
* The columns of the changed row as they were before the change (a stay's
|
|
151
|
+
* old dates, its old total), kept on the message in the outbox's `was`
|
|
152
|
+
* column and read by its template as `{{was.<column>}}`, in every form the
|
|
153
|
+
* column has. On a producer that listens for changes (`onChange`).
|
|
154
|
+
*/
|
|
155
|
+
was: z.array(refSchema).min(1).max(8).optional(),
|
|
108
156
|
/**
|
|
109
157
|
* A change made once the message has gone, through the ordinary write: to
|
|
110
158
|
* the source row, or (with `via`) the row its foreign key points at.
|
|
@@ -129,15 +177,27 @@ export const outboxProducerSchema = z.union([
|
|
|
129
177
|
z
|
|
130
178
|
.object({
|
|
131
179
|
...producerBase,
|
|
132
|
-
onChange: z
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
180
|
+
onChange: z.union([
|
|
181
|
+
z
|
|
182
|
+
.object({
|
|
183
|
+
table: refSchema,
|
|
184
|
+
via: refSchema.optional(),
|
|
185
|
+
column: refSchema,
|
|
186
|
+
to: z.union([scalarSchema, z.array(scalarSchema).min(1).max(16)]),
|
|
187
|
+
where: conditionSchema.optional(),
|
|
188
|
+
})
|
|
189
|
+
.strict(),
|
|
190
|
+
/** Any change of one of these columns, compared with the row as it was stored (a stay's dates, whatever they became). */
|
|
191
|
+
z
|
|
192
|
+
.object({
|
|
193
|
+
table: refSchema,
|
|
194
|
+
via: refSchema.optional(),
|
|
195
|
+
columns: z.array(refSchema).min(1).max(8),
|
|
196
|
+
changed: z.literal(true),
|
|
197
|
+
where: conditionSchema.optional(),
|
|
198
|
+
})
|
|
199
|
+
.strict(),
|
|
200
|
+
]),
|
|
141
201
|
})
|
|
142
202
|
.strict(),
|
|
143
203
|
z
|
|
@@ -157,6 +217,12 @@ export const outboxProducerSchema = z.union([
|
|
|
157
217
|
fallback: settingRefSchema.optional(),
|
|
158
218
|
/** The largest lead anyone may choose, in hours — how far ahead Adminium looks. */
|
|
159
219
|
max: z.number().int().min(1).max(24 * 14),
|
|
220
|
+
/**
|
|
221
|
+
* Sent at this wall time on the venue's day the lead reaches,
|
|
222
|
+
* not at the hour itself: 24 hours before an 20:00 show, at
|
|
223
|
+
* 09:00, is 09:00 the day before.
|
|
224
|
+
*/
|
|
225
|
+
at: z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/, 'a time such as 09:00').optional(),
|
|
160
226
|
})
|
|
161
227
|
.strict(),
|
|
162
228
|
where: conditionSchema.optional(),
|
|
@@ -172,7 +238,12 @@ const recipientSchema = z
|
|
|
172
238
|
table: refSchema,
|
|
173
239
|
email: refSchema,
|
|
174
240
|
name: refSchema.optional(),
|
|
175
|
-
|
|
241
|
+
/**
|
|
242
|
+
* The language the message is written in: a text column of the person's
|
|
243
|
+
* row, or (`{column}`) of the row the message is about — an order placed
|
|
244
|
+
* in German is written to in German, whatever the person's own row says.
|
|
245
|
+
*/
|
|
246
|
+
language: z.union([refSchema, z.object({ column: refSchema }).strict()]).optional(),
|
|
176
247
|
/** A bool the person sets: false means no reminders. */
|
|
177
248
|
optIn: refSchema.optional(),
|
|
178
249
|
/**
|
|
@@ -207,6 +278,10 @@ export const outboxSchema = z
|
|
|
207
278
|
/** When `onSent`'s change was made, or why it was refused. */
|
|
208
279
|
effectAt: refSchema.optional(),
|
|
209
280
|
effectError: refSchema.optional(),
|
|
281
|
+
/** A text column (at least 43 characters) Adminium fills with the digest of a producer's `repeatBy` value. */
|
|
282
|
+
repeatKey: refSchema.optional(),
|
|
283
|
+
/** A text column (unbounded, or at least 1000 characters) Adminium fills with a producer's `was` columns, as they were. */
|
|
284
|
+
was: refSchema.optional(),
|
|
210
285
|
})
|
|
211
286
|
.strict(),
|
|
212
287
|
/**
|
|
@@ -222,7 +297,14 @@ export const outboxSchema = z
|
|
|
222
297
|
* the one sent to an old address after a change of email.
|
|
223
298
|
*/
|
|
224
299
|
settings: z
|
|
225
|
-
.object({
|
|
300
|
+
.object({
|
|
301
|
+
table: refSchema,
|
|
302
|
+
enabled: refSchema.optional(),
|
|
303
|
+
name: refSchema.optional(),
|
|
304
|
+
phone: refSchema.optional(),
|
|
305
|
+
/** The address a reply to any of the app's messages goes to (the house's own): a Reply-To on each. Empty or not an address: none. */
|
|
306
|
+
replyTo: refSchema.optional(),
|
|
307
|
+
})
|
|
226
308
|
.strict()
|
|
227
309
|
.optional(),
|
|
228
310
|
/**
|
|
@@ -286,7 +368,20 @@ export const emailTemplateSchema = z
|
|
|
286
368
|
* else the print copy. A message whose document cannot be drawn fails
|
|
287
369
|
* rather than going without it.
|
|
288
370
|
*/
|
|
289
|
-
attach: z
|
|
371
|
+
attach: z
|
|
372
|
+
.object({
|
|
373
|
+
kind: z.string().regex(/^[a-z][a-z0-9-]*$/, 'a document kind'),
|
|
374
|
+
link: z.string().min(1).max(40),
|
|
375
|
+
/**
|
|
376
|
+
* Sent without the document when no add-on the app has draws its
|
|
377
|
+
* kind (a receipt, without Invoices & Receipts): the part is left out,
|
|
378
|
+
* and so is every block marked `data.withAttachment: true`. Without
|
|
379
|
+
* it, such a message fails rather than go without its document.
|
|
380
|
+
*/
|
|
381
|
+
optional: z.literal(true).optional(),
|
|
382
|
+
})
|
|
383
|
+
.strict()
|
|
384
|
+
.optional(),
|
|
290
385
|
/** The variables it reads, for the editor's list. */
|
|
291
386
|
vars: z.array(templateVariableSchema).max(60).optional(),
|
|
292
387
|
/** The template in each language it ships, US English always among them. */
|
|
@@ -296,9 +391,15 @@ export const emailTemplateSchema = z
|
|
|
296
391
|
})
|
|
297
392
|
.strict();
|
|
298
393
|
/** The column rules that decide a value on the server (see `columnRulesSchema`). */
|
|
299
|
-
const DECIDING_RULES = ['copy', 'default', 'sequence', 'format', 'code', 'rollup', 'formula', 'stamp'];
|
|
394
|
+
const DECIDING_RULES = ['copy', 'default', 'sequence', 'format', 'code', 'rollup', 'formula', 'stamp', 'perNight'];
|
|
300
395
|
/** The column rules that refuse a value: Adminium's own writes to the column would be refused by them. */
|
|
301
396
|
const REFUSING_RULES = ['options', 'validation', 'required', 'requiredWhen', 'notAfter', 'notBefore'];
|
|
397
|
+
/** How long a `repeatKey` is: a SHA-256 digest in base64url. */
|
|
398
|
+
export const REPEAT_KEY_LENGTH = 43;
|
|
399
|
+
/** The fewest characters a bounded `was` column holds: the columns a message keeps, as they were. */
|
|
400
|
+
export const WAS_MIN_LENGTH = 1000;
|
|
401
|
+
/** The mark on a template's block that is sent only with the document the template carries (`attach.optional`). */
|
|
402
|
+
export const WITH_ATTACHMENT = 'withAttachment';
|
|
302
403
|
/** What the outbox writes, and which rules each refuses: everything, but a check of the address a person types. */
|
|
303
404
|
export const OUTBOX_WRITTEN = {
|
|
304
405
|
status: [...DECIDING_RULES, ...REFUSING_RULES],
|
|
@@ -308,6 +409,8 @@ export const OUTBOX_WRITTEN = {
|
|
|
308
409
|
approvedBy: [...DECIDING_RULES, ...REFUSING_RULES],
|
|
309
410
|
effectAt: [...DECIDING_RULES, ...REFUSING_RULES],
|
|
310
411
|
effectError: [...DECIDING_RULES, ...REFUSING_RULES],
|
|
412
|
+
repeatKey: [...DECIDING_RULES, ...REFUSING_RULES],
|
|
413
|
+
was: [...DECIDING_RULES, ...REFUSING_RULES],
|
|
311
414
|
// Left empty by a desk that asks Adminium to look the address up, and written when it sends.
|
|
312
415
|
to: [...DECIDING_RULES, 'options', 'required', 'requiredWhen'],
|
|
313
416
|
language: [...DECIDING_RULES, 'options', 'required', 'requiredWhen'],
|
|
@@ -346,6 +449,19 @@ export function outboxIssues(m, index) {
|
|
|
346
449
|
if (template.attach !== undefined && m.outbox?.links?.[template.attach.link] === undefined) {
|
|
347
450
|
out.push({ path: ['emailTemplates', i, 'attach', 'link'], message: `"${template.attach.link}" is not one of the outbox's links` });
|
|
348
451
|
}
|
|
452
|
+
// A block sent only with the document: marked `true`, and only where the document may be left out.
|
|
453
|
+
for (const [locale, content] of Object.entries(template.locales)) {
|
|
454
|
+
content.blocks.forEach((block, b) => {
|
|
455
|
+
const mark = block.data?.[WITH_ATTACHMENT];
|
|
456
|
+
if (mark === undefined)
|
|
457
|
+
return;
|
|
458
|
+
const path = ['emailTemplates', i, 'locales', locale, 'blocks', b, 'data', WITH_ATTACHMENT];
|
|
459
|
+
if (mark !== true)
|
|
460
|
+
out.push({ path, message: `${WITH_ATTACHMENT} is true or absent` });
|
|
461
|
+
else if (template.attach?.optional !== true)
|
|
462
|
+
out.push({ path, message: 'a block is sent only with the document when the template may go without it (attach.optional)' });
|
|
463
|
+
});
|
|
464
|
+
}
|
|
349
465
|
if (!template.key.startsWith(`${m.key}-`)) {
|
|
350
466
|
out.push({ path: ['emailTemplates', i, 'key'], message: `an app's template key starts with "${m.key}-"` });
|
|
351
467
|
}
|
|
@@ -425,6 +541,18 @@ export function outboxIssues(m, index) {
|
|
|
425
541
|
}
|
|
426
542
|
if (box.columns.effectAt !== undefined)
|
|
427
543
|
col(box.table, box.columns.effectAt, ['timestamptz'], at('columns', 'effectAt'), 'a timestamptz');
|
|
544
|
+
if (box.columns.repeatKey !== undefined) {
|
|
545
|
+
const found = col(box.table, box.columns.repeatKey, ['text'], at('columns', 'repeatKey'), 'a text column');
|
|
546
|
+
if (found !== undefined && found.maxLength !== undefined && found.maxLength < REPEAT_KEY_LENGTH) {
|
|
547
|
+
out.push({ path: at('columns', 'repeatKey'), message: `"${box.table}.${found.ref}" must hold ${REPEAT_KEY_LENGTH} characters (a digest)` });
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
if (box.columns.was !== undefined) {
|
|
551
|
+
const found = col(box.table, box.columns.was, ['text'], at('columns', 'was'), 'a text column');
|
|
552
|
+
if (found !== undefined && found.maxLength !== undefined && found.maxLength < WAS_MIN_LENGTH) {
|
|
553
|
+
out.push({ path: at('columns', 'was'), message: `"${box.table}.${found.ref}" keeps a message's values from before a change: it holds ${WAS_MIN_LENGTH} characters or more` });
|
|
554
|
+
}
|
|
555
|
+
}
|
|
428
556
|
/*
|
|
429
557
|
* The columns Adminium writes as it sends and as a person approves or skips
|
|
430
558
|
* a message — and the address and language it writes when it looks them up.
|
|
@@ -496,8 +624,11 @@ export function outboxIssues(m, index) {
|
|
|
496
624
|
col(r.table, r.email, ['text'], at('recipient', 'email'), 'a text column');
|
|
497
625
|
if (r.name !== undefined)
|
|
498
626
|
col(r.table, r.name, ['text'], at('recipient', 'name'), 'a text column');
|
|
499
|
-
if (r.language
|
|
627
|
+
if (typeof r.language === 'string')
|
|
500
628
|
col(r.table, r.language, ['text'], at('recipient', 'language'), 'a text column');
|
|
629
|
+
else if (r.language !== undefined && box.columns.language === undefined) {
|
|
630
|
+
out.push({ path: at('recipient', 'language'), message: 'a message\'s language read from the row it is about is kept in the outbox\'s language column: name it' });
|
|
631
|
+
}
|
|
501
632
|
if (r.optIn !== undefined)
|
|
502
633
|
col(r.table, r.optIn, ['bool'], at('recipient', 'optIn'), 'a bool');
|
|
503
634
|
if (r.fallback !== undefined) {
|
|
@@ -521,6 +652,8 @@ export function outboxIssues(m, index) {
|
|
|
521
652
|
col(box.settings.table, box.settings.name, ['text'], at('settings', 'name'), 'a text column');
|
|
522
653
|
if (box.settings.phone !== undefined)
|
|
523
654
|
col(box.settings.table, box.settings.phone, ['text'], at('settings', 'phone'), 'a text column');
|
|
655
|
+
if (box.settings.replyTo !== undefined)
|
|
656
|
+
col(box.settings.table, box.settings.replyTo, ['text'], at('settings', 'replyTo'), 'a text column (an address)');
|
|
524
657
|
}
|
|
525
658
|
for (const [value, template] of Object.entries(box.kinds)) {
|
|
526
659
|
if (kind !== undefined && !(kind.enum ?? []).includes(value)) {
|
|
@@ -529,6 +662,7 @@ export function outboxIssues(m, index) {
|
|
|
529
662
|
if (!templates.has(template))
|
|
530
663
|
out.push({ path: at('kinds', value), message: `"${template}" is not one of the app's emailTemplates` });
|
|
531
664
|
}
|
|
665
|
+
const unlisted = (table, column) => unlistedColumn(m, table, column);
|
|
532
666
|
(box.producers ?? []).forEach((producer, p) => {
|
|
533
667
|
const here = (...rest) => at('producers', p, ...rest);
|
|
534
668
|
if (box.kinds[producer.kind] === undefined)
|
|
@@ -537,7 +671,15 @@ export function outboxIssues(m, index) {
|
|
|
537
671
|
out.push({ path: here('gate'), message: 'the outbox names no settings column to be gated by' });
|
|
538
672
|
}
|
|
539
673
|
else if (typeof producer.gate === 'object') {
|
|
540
|
-
|
|
674
|
+
// Each half judged as it is alone: a feature the app declares, a bool of the settings row.
|
|
675
|
+
if ('feature' in producer.gate) {
|
|
676
|
+
const feature = producer.gate.feature;
|
|
677
|
+
if (!(m.addOns?.features ?? []).some((candidate) => candidate.id === feature)) {
|
|
678
|
+
out.push({ path: here('gate', 'feature'), message: `"${feature}" is not one of the app's addOns.features` });
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
if ('setting' in producer.gate)
|
|
682
|
+
col(producer.gate.setting.table, producer.gate.setting.column, ['bool'], here('gate', 'setting'), 'a bool');
|
|
541
683
|
}
|
|
542
684
|
if (producer.optIn === true && r.optIn === undefined) {
|
|
543
685
|
out.push({ path: here('optIn'), message: 'the recipient names no opt-in column' });
|
|
@@ -599,11 +741,43 @@ export function outboxIssues(m, index) {
|
|
|
599
741
|
out.push({ path: here('dropWhen', d), message: `${JSON.stringify(value)} is not a value of "${linked}.${drop.column}"` });
|
|
600
742
|
}
|
|
601
743
|
});
|
|
602
|
-
if (producer.dropWhen !== undefined && producer.hold !== true && producer.due === undefined && producer.batchMinutes === undefined) {
|
|
744
|
+
if (producer.dropWhen !== undefined && producer.hold !== true && producer.due === undefined && producer.batchMinutes === undefined && producer.holdSeconds === undefined) {
|
|
603
745
|
out.push({ path: here('dropWhen'), message: 'only a message that waits (held, or due later) can be dropped' });
|
|
604
746
|
}
|
|
605
|
-
|
|
606
|
-
|
|
747
|
+
// A short wait before it may go: kept in the due column, and the only wait it has.
|
|
748
|
+
if (producer.holdSeconds !== undefined) {
|
|
749
|
+
if (box.columns.due === undefined)
|
|
750
|
+
out.push({ path: here('holdSeconds'), message: 'a message waits in the outbox\'s due column, and none is named' });
|
|
751
|
+
const other = producer.hold === true ? 'hold' : producer.due !== undefined ? 'due' : producer.batchMinutes !== undefined ? 'batchMinutes' : 'before' in producer ? 'before' : null;
|
|
752
|
+
if (other !== null)
|
|
753
|
+
out.push({ path: here('holdSeconds'), message: `a message that waits a few seconds takes no ${other} as well` });
|
|
754
|
+
}
|
|
755
|
+
// One message per value of a column of the row it is about: kept as a digest in the outbox.
|
|
756
|
+
if (producer.repeatBy !== undefined) {
|
|
757
|
+
col(linked, producer.repeatBy, null, here('repeatBy'), '');
|
|
758
|
+
if (box.columns.repeatKey === undefined)
|
|
759
|
+
out.push({ path: here('repeatBy'), message: 'which value a message was sent for is kept in the outbox\'s repeatKey column, and none is named' });
|
|
760
|
+
const other = 'before' in producer ? 'before' : producer.batchMinutes !== undefined ? 'batchMinutes' : null;
|
|
761
|
+
if (other !== null)
|
|
762
|
+
out.push({ path: here('repeatBy'), message: `a message sent again for each value takes no ${other}: that is one per row or window already` });
|
|
763
|
+
}
|
|
764
|
+
if (producer.recipient !== undefined) {
|
|
765
|
+
if ('setting' in producer.recipient)
|
|
766
|
+
setting(producer.recipient.setting, here('recipient', 'setting'));
|
|
767
|
+
else {
|
|
768
|
+
col(linked, producer.recipient.column, ['text'], here('recipient', 'column'), 'a text column');
|
|
769
|
+
if (producer.recipient.name !== undefined)
|
|
770
|
+
col(linked, producer.recipient.name, ['text'], here('recipient', 'name'), 'a text column');
|
|
771
|
+
if (producer.recipient.language !== undefined) {
|
|
772
|
+
col(linked, producer.recipient.language, ['text'], here('recipient', 'language'), 'a text column');
|
|
773
|
+
if (box.columns.language === undefined)
|
|
774
|
+
out.push({ path: here('recipient', 'language'), message: 'a message\'s language is kept in the outbox\'s language column: name it' });
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
// The language read from the row a message is about: every producer's row has it.
|
|
779
|
+
if (typeof r.language === 'object')
|
|
780
|
+
col(linked, r.language.column, ['text'], here(), 'a text column (the message\'s language)');
|
|
607
781
|
if (producer.batchMinutes !== undefined && 'before' in producer) {
|
|
608
782
|
out.push({ path: here('batchMinutes'), message: 'a reminder before a moment is one per row already' });
|
|
609
783
|
}
|
|
@@ -620,8 +794,13 @@ export function outboxIssues(m, index) {
|
|
|
620
794
|
else {
|
|
621
795
|
fk(linked, effect.via, effect.table, here('onSent', 'via'));
|
|
622
796
|
}
|
|
797
|
+
const states = index.table(effect.table)?.states;
|
|
623
798
|
for (const [ref, value] of Object.entries(effect.set)) {
|
|
624
799
|
const found = col(effect.table, ref, null, here('onSent', 'set', ref), '');
|
|
800
|
+
// An email's change names no state it saw: a state only an undo reaches is never its to make.
|
|
801
|
+
if (states?.column === ref && value !== null && reachedOnlyByUndo(states, value)) {
|
|
802
|
+
out.push({ path: here('onSent', 'set', ref), message: `every move of "${effect.table}" to ${JSON.stringify(value)} is an undo, which only a person makes` });
|
|
803
|
+
}
|
|
625
804
|
if (found !== undefined && value === null && found.nullable !== true) {
|
|
626
805
|
out.push({ path: here('onSent', 'set', ref), message: `"${effect.table}.${ref}" is never empty` });
|
|
627
806
|
}
|
|
@@ -633,12 +812,50 @@ export function outboxIssues(m, index) {
|
|
|
633
812
|
}
|
|
634
813
|
if ('onChange' in producer) {
|
|
635
814
|
const change = producer.onChange;
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
if (
|
|
640
|
-
out.push({ path: here('onChange', '
|
|
641
|
-
}
|
|
815
|
+
if ('columns' in change) {
|
|
816
|
+
change.columns.forEach((column, c) => {
|
|
817
|
+
col(change.table, column, null, here('onChange', 'columns', c), '');
|
|
818
|
+
if (change.columns.indexOf(column) !== c)
|
|
819
|
+
out.push({ path: here('onChange', 'columns', c), message: `"${column}" is listed twice` });
|
|
820
|
+
});
|
|
821
|
+
}
|
|
822
|
+
else {
|
|
823
|
+
const watched = col(change.table, change.column, null, here('onChange', 'column'), '');
|
|
824
|
+
if (watched !== undefined)
|
|
825
|
+
for (const value of Array.isArray(change.to) ? change.to : [change.to]) {
|
|
826
|
+
if (!valueFits(watched, value))
|
|
827
|
+
out.push({ path: here('onChange', 'to'), message: `${JSON.stringify(value)} is not a value of "${change.table}.${change.column}"` });
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
// One message per change it hears of: a producer that listens for changes, and repeats no other way.
|
|
832
|
+
if (producer.repeat === true) {
|
|
833
|
+
if (!('onChange' in producer))
|
|
834
|
+
out.push({ path: here('repeat'), message: 'a message is sent for each change by a producer that listens for changes (onChange)' });
|
|
835
|
+
const other = producer.repeatBy !== undefined ? 'repeatBy' : producer.batchMinutes !== undefined ? 'batchMinutes' : null;
|
|
836
|
+
if (other !== null)
|
|
837
|
+
out.push({ path: here('repeat'), message: `a message sent for each change takes no ${other}` });
|
|
838
|
+
}
|
|
839
|
+
// The changed row's columns as they were: kept on the message, read as {{was.<column>}}.
|
|
840
|
+
if (producer.was !== undefined) {
|
|
841
|
+
if (!('onChange' in producer))
|
|
842
|
+
out.push({ path: here('was'), message: 'a message keeps what a row was before a change on a producer that listens for changes (onChange)' });
|
|
843
|
+
else {
|
|
844
|
+
const table = producer.onChange.table;
|
|
845
|
+
producer.was.forEach((column, c) => {
|
|
846
|
+
const found = col(table, column, null, here('was', c), '');
|
|
847
|
+
if (producer.was.indexOf(column) !== c)
|
|
848
|
+
out.push({ path: here('was', c), message: `"${column}" is listed twice` });
|
|
849
|
+
// Personal data as the install tells it too: by its mark, or guessed by its name — a message keeps it empty.
|
|
850
|
+
const tableDoc = m.requiredSchema.tables.find((candidate) => candidate.ref === table);
|
|
851
|
+
const guessed = tableDoc !== undefined && personalColumn(tableDoc, column) === 'guessed' ? 'personal data' : null;
|
|
852
|
+
const kept = found === undefined ? null : (unlisted(table, column) ?? guessed);
|
|
853
|
+
if (kept !== null)
|
|
854
|
+
out.push({ path: here('was', c), message: `"${table}.${column}" is ${kept}, which a message never keeps` });
|
|
855
|
+
});
|
|
856
|
+
}
|
|
857
|
+
if (box.columns.was === undefined)
|
|
858
|
+
out.push({ path: here('was'), message: 'a message keeps what a row was in the outbox\'s was column, and none is named' });
|
|
642
859
|
}
|
|
643
860
|
if ('before' in producer) {
|
|
644
861
|
const before = producer.before;
|
|
@@ -653,6 +870,315 @@ export function outboxIssues(m, index) {
|
|
|
653
870
|
}
|
|
654
871
|
}
|
|
655
872
|
});
|
|
873
|
+
out.push(...emailBlockIssues(m.emailTemplates ?? [], box, index, (table, column) => unlistedColumn(m, table, column)));
|
|
874
|
+
return out;
|
|
875
|
+
}
|
|
876
|
+
// ── blocks that list rows, and QR codes ──────────────────────────────────────
|
|
877
|
+
/**
|
|
878
|
+
* `email.rows`: the rows of a child table that link to the row an outbox link
|
|
879
|
+
* names, one line each (an order's tickets, with a QR code each). `from` is
|
|
880
|
+
* the documents' collection spelling: the link, the child table, the child's
|
|
881
|
+
* link back, its order, a filter and a bool that leaves a row out, and at
|
|
882
|
+
* most 50 rows. `joins` gathers up to two child lists of each row into one
|
|
883
|
+
* text (a dish's options). `row` is each line's text, reading `{{row.<column>}}`
|
|
884
|
+
* (`{{row.<link>.<column>}}` through one of the row's own links, and a join by
|
|
885
|
+
* its name); `image` is a QR code of a code column (`{{row.code.qr}}`) and
|
|
886
|
+
* nothing else. `empty` is said when there are no rows; without it the block
|
|
887
|
+
* is left out.
|
|
888
|
+
*/
|
|
889
|
+
export const emailRowsDataSchema = z
|
|
890
|
+
.object({
|
|
891
|
+
from: z
|
|
892
|
+
.object({
|
|
893
|
+
link: z.string().regex(/^[a-z][a-z_]*$/, 'a link name is snake_case'),
|
|
894
|
+
table: refSchema,
|
|
895
|
+
via: refSchema,
|
|
896
|
+
orderBy: refSchema.optional(),
|
|
897
|
+
where: z.object({ column: refSchema, in: z.array(scalarSchema).min(1).max(32) }).strict().optional(),
|
|
898
|
+
unless: refSchema.optional(),
|
|
899
|
+
limit: z.number().int().min(1).max(50).optional(),
|
|
900
|
+
})
|
|
901
|
+
.strict(),
|
|
902
|
+
joins: z
|
|
903
|
+
.record(refSchema, z
|
|
904
|
+
.object({ table: refSchema, via: refSchema, column: refSchema, orderBy: refSchema.optional(), separator: z.string().min(1).max(8).optional() })
|
|
905
|
+
.strict())
|
|
906
|
+
.refine((joins) => Object.keys(joins).length >= 1 && Object.keys(joins).length <= 2, { message: 'a row joins one or two lists' })
|
|
907
|
+
.optional(),
|
|
908
|
+
row: z
|
|
909
|
+
.object({
|
|
910
|
+
title: z.string().min(1).max(300).optional(),
|
|
911
|
+
meta: z.string().min(1).max(300).optional(),
|
|
912
|
+
amount: z.string().min(1).max(300).optional(),
|
|
913
|
+
note: z.string().min(1).max(300).optional(),
|
|
914
|
+
image: z.string().min(1).max(300).optional(),
|
|
915
|
+
})
|
|
916
|
+
.strict(),
|
|
917
|
+
empty: z.string().min(1).max(200).optional(),
|
|
918
|
+
})
|
|
919
|
+
.strict();
|
|
920
|
+
/** A QR code of a code column, as the whole value of an image: `{{ticket.code.qr}}`. */
|
|
921
|
+
const QR_VALUE = /^\{\{\s*([A-Za-z0-9_.-]+)\.qr\s*\}\}$/;
|
|
922
|
+
const PLACEHOLDERS = /\{\{\s*([A-Za-z0-9_.-]+)\s*\}\}/g;
|
|
923
|
+
/** Every string inside a value, with the path it sits at. */
|
|
924
|
+
function stringsIn(value, path, out = []) {
|
|
925
|
+
if (typeof value === 'string')
|
|
926
|
+
out.push({ text: value, path });
|
|
927
|
+
else if (Array.isArray(value))
|
|
928
|
+
value.forEach((item, i) => stringsIn(item, [...path, i], out));
|
|
929
|
+
else if (typeof value === 'object' && value !== null)
|
|
930
|
+
for (const [key, item] of Object.entries(value))
|
|
931
|
+
stringsIn(item, [...path, key], out);
|
|
932
|
+
return out;
|
|
933
|
+
}
|
|
934
|
+
/**
|
|
935
|
+
* Everything wrong with the blocks of the app's templates that list rows or
|
|
936
|
+
* draw a QR code: the rows' link, table and columns; each `{{row.*}}` a row
|
|
937
|
+
* can fill; the same rows in every language; and a QR code only as a whole
|
|
938
|
+
* image value, of a code column.
|
|
939
|
+
*/
|
|
940
|
+
function emailBlockIssues(templates, box, index, unlisted) {
|
|
941
|
+
const out = [];
|
|
942
|
+
const links = box.links ?? {};
|
|
943
|
+
/** The table a link names. */
|
|
944
|
+
const linkTable = (link) => {
|
|
945
|
+
const column = links[link];
|
|
946
|
+
return column === undefined ? undefined : index.column(box.table, column)?.references;
|
|
947
|
+
};
|
|
948
|
+
/** The table a template prefix reads (`ticket`, `order.event`), when the manifest can place it. */
|
|
949
|
+
const prefixTable = (prefix) => {
|
|
950
|
+
const [head, ...rest] = prefix.split('.');
|
|
951
|
+
let table = linkTable(head);
|
|
952
|
+
for (const base of rest) {
|
|
953
|
+
if (table === undefined)
|
|
954
|
+
return undefined;
|
|
955
|
+
table = index.column(table, `${base}_id`)?.references;
|
|
956
|
+
}
|
|
957
|
+
return table;
|
|
958
|
+
};
|
|
959
|
+
/** Why `{{<prefix>.<column>.qr}}` draws no QR code, or null. */
|
|
960
|
+
const qrIssue = (table, name) => {
|
|
961
|
+
const parts = name.split('.');
|
|
962
|
+
const column = parts.at(-1);
|
|
963
|
+
if (table === undefined)
|
|
964
|
+
return null;
|
|
965
|
+
const found = index.column(table, column);
|
|
966
|
+
if (found === undefined)
|
|
967
|
+
return `"${table}" has no column "${column}"`;
|
|
968
|
+
if (found.type !== 'text' || found.rules?.code === undefined)
|
|
969
|
+
return `"${table}.${column}" is not a code column, so it has no QR code`;
|
|
970
|
+
return null;
|
|
971
|
+
};
|
|
972
|
+
templates.forEach((template, t) => {
|
|
973
|
+
let rowsOf;
|
|
974
|
+
for (const [locale, content] of Object.entries(template.locales)) {
|
|
975
|
+
const here = (...rest) => ['emailTemplates', t, 'locales', locale, ...rest];
|
|
976
|
+
const rows = [];
|
|
977
|
+
content.blocks.forEach((block, b) => {
|
|
978
|
+
const at = (...rest) => here('blocks', b, ...rest);
|
|
979
|
+
const qrSlot = block.block === 'email.image' ? block.data?.['qr'] : undefined;
|
|
980
|
+
// A QR code is the whole value of an image, and nothing else.
|
|
981
|
+
for (const { text, path } of stringsIn({ subject: content.subject, preheader: content.preheader, footer: content.footer, data: block.data }, [])) {
|
|
982
|
+
if (!/\.qr\s*\}\}/.test(text))
|
|
983
|
+
continue;
|
|
984
|
+
const isSlot = (block.block === 'email.image' && path.join('.') === 'data.qr') || (block.block === 'email.rows' && path.join('.') === 'data.row.image');
|
|
985
|
+
if (!isSlot || !QR_VALUE.test(text))
|
|
986
|
+
out.push({ path: path[0] === 'data' ? at(...path) : here(path[0]), message: 'a QR code is the whole value of an image (email.image "qr", or a row\'s "image"), nowhere else' });
|
|
987
|
+
}
|
|
988
|
+
if (block.block === 'email.image' && qrSlot !== undefined) {
|
|
989
|
+
const match = typeof qrSlot === 'string' ? QR_VALUE.exec(qrSlot) : null;
|
|
990
|
+
if (match === null)
|
|
991
|
+
out.push({ path: at('data', 'qr'), message: 'an image\'s QR code is written {{<link>.<column>.qr}}' });
|
|
992
|
+
else if (match[1].startsWith('row.'))
|
|
993
|
+
out.push({ path: at('data', 'qr'), message: 'a row\'s QR code is drawn by an email.rows block' });
|
|
994
|
+
else {
|
|
995
|
+
const parts = match[1].split('.');
|
|
996
|
+
const issue = qrIssue(prefixTable(parts.slice(0, -1).join('.')), match[1]);
|
|
997
|
+
if (issue !== null)
|
|
998
|
+
out.push({ path: at('data', 'qr'), message: issue });
|
|
999
|
+
}
|
|
1000
|
+
const size = block.data?.['size'];
|
|
1001
|
+
if (size !== undefined && (typeof size !== 'number' || !Number.isInteger(size) || size < 80 || size > 200)) {
|
|
1002
|
+
out.push({ path: at('data', 'size'), message: 'a QR code is 80 to 200 pixels across' });
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
if (block.block !== 'email.rows') {
|
|
1006
|
+
// Only a block that lists rows has a row to read.
|
|
1007
|
+
for (const { text, path } of stringsIn(block.data, [])) {
|
|
1008
|
+
if ([...text.matchAll(PLACEHOLDERS)].some((m) => m[1].startsWith('row.'))) {
|
|
1009
|
+
out.push({ path: at('data', ...path), message: '{{row.…}} is read only inside an email.rows block' });
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
return;
|
|
1013
|
+
}
|
|
1014
|
+
const parsed = emailRowsDataSchema.safeParse(block.data ?? {});
|
|
1015
|
+
if (!parsed.success) {
|
|
1016
|
+
for (const issue of parsed.error.issues)
|
|
1017
|
+
out.push({ path: at('data', ...issue.path.map((p) => (typeof p === 'symbol' ? String(p) : p))), message: issue.message });
|
|
1018
|
+
return;
|
|
1019
|
+
}
|
|
1020
|
+
const data = parsed.data;
|
|
1021
|
+
rows.push(JSON.stringify({ from: data.from, joins: data.joins ?? null }));
|
|
1022
|
+
const from = data.from;
|
|
1023
|
+
const parent = linkTable(from.link);
|
|
1024
|
+
if (links[from.link] === undefined)
|
|
1025
|
+
out.push({ path: at('data', 'from', 'link'), message: `"${from.link}" is not one of the outbox's links` });
|
|
1026
|
+
if (index.table(from.table) === undefined) {
|
|
1027
|
+
out.push({ path: at('data', 'from', 'table'), message: `"${from.table}" is not a table of this app` });
|
|
1028
|
+
return;
|
|
1029
|
+
}
|
|
1030
|
+
const via = index.column(from.table, from.via);
|
|
1031
|
+
if (via?.type !== 'fk' || (parent !== undefined && via.references !== parent)) {
|
|
1032
|
+
out.push({ path: at('data', 'from', 'via'), message: `"${from.table}.${from.via}" does not point at the row "${from.link}" names` });
|
|
1033
|
+
}
|
|
1034
|
+
for (const [name, ref] of [['orderBy', from.orderBy], ['where', from.where?.column], ['unless', from.unless]]) {
|
|
1035
|
+
if (ref !== undefined && index.column(from.table, ref) === undefined)
|
|
1036
|
+
out.push({ path: at('data', 'from', name), message: `"${from.table}" has no column "${ref}"` });
|
|
1037
|
+
}
|
|
1038
|
+
if (from.unless !== undefined && index.column(from.table, from.unless) !== undefined && index.column(from.table, from.unless)?.type !== 'bool') {
|
|
1039
|
+
out.push({ path: at('data', 'from', 'unless'), message: `"${from.table}.${from.unless}" is not a bool` });
|
|
1040
|
+
}
|
|
1041
|
+
const where = from.where;
|
|
1042
|
+
const filter = where === undefined ? undefined : index.column(from.table, where.column);
|
|
1043
|
+
if (where !== undefined && filter !== undefined) {
|
|
1044
|
+
for (const value of where.in)
|
|
1045
|
+
if (!valueFits(filter, value))
|
|
1046
|
+
out.push({ path: at('data', 'from', 'where'), message: `${JSON.stringify(value)} is not a value of "${from.table}.${where.column}"` });
|
|
1047
|
+
}
|
|
1048
|
+
for (const [name, join] of Object.entries(data.joins ?? {})) {
|
|
1049
|
+
const path = at('data', 'joins', name);
|
|
1050
|
+
if (index.table(join.table) === undefined) {
|
|
1051
|
+
out.push({ path, message: `"${join.table}" is not a table of this app` });
|
|
1052
|
+
continue;
|
|
1053
|
+
}
|
|
1054
|
+
const back = index.column(join.table, join.via);
|
|
1055
|
+
if (back?.type !== 'fk' || back.references !== from.table)
|
|
1056
|
+
out.push({ path: [...path, 'via'], message: `"${join.table}.${join.via}" does not point at "${from.table}"` });
|
|
1057
|
+
const shown = index.column(join.table, join.column);
|
|
1058
|
+
if (shown === undefined)
|
|
1059
|
+
out.push({ path: [...path, 'column'], message: `"${join.table}" has no column "${join.column}"` });
|
|
1060
|
+
else if (shown.type !== 'text')
|
|
1061
|
+
out.push({ path: [...path, 'column'], message: `"${join.table}.${join.column}" is not a text column` });
|
|
1062
|
+
else {
|
|
1063
|
+
const kept = unlisted(join.table, join.column);
|
|
1064
|
+
if (kept !== null)
|
|
1065
|
+
out.push({ path: [...path, 'column'], message: `"${join.table}.${join.column}" is ${kept}, which an email never lists from another row` });
|
|
1066
|
+
}
|
|
1067
|
+
if (join.orderBy !== undefined && index.column(join.table, join.orderBy) === undefined)
|
|
1068
|
+
out.push({ path: [...path, 'orderBy'], message: `"${join.table}" has no column "${join.orderBy}"` });
|
|
1069
|
+
}
|
|
1070
|
+
// Each {{row.*}} names something a row fills, in a form its type has.
|
|
1071
|
+
for (const { text, path } of stringsIn({ row: data.row, empty: data.empty }, [])) {
|
|
1072
|
+
for (const m of text.matchAll(PLACEHOLDERS)) {
|
|
1073
|
+
const name = m[1];
|
|
1074
|
+
if (!name.startsWith('row.'))
|
|
1075
|
+
continue;
|
|
1076
|
+
const parts = name.split('.').slice(1);
|
|
1077
|
+
const where = at('data', ...path);
|
|
1078
|
+
if (parts.length === 1 && data.joins?.[parts[0]] !== undefined)
|
|
1079
|
+
continue;
|
|
1080
|
+
const qr = parts.at(-1) === 'qr';
|
|
1081
|
+
const bare = qr ? parts.slice(0, -1) : parts;
|
|
1082
|
+
// `row.col`, `row.col.form`, and one link of the row's own: `row.base.col`, `row.base.col.form` (through `base_id`).
|
|
1083
|
+
let table = from.table;
|
|
1084
|
+
let column = bare[0];
|
|
1085
|
+
let form = bare[1];
|
|
1086
|
+
if (index.column(from.table, column) === undefined) {
|
|
1087
|
+
const link = index.column(from.table, `${column}_id`);
|
|
1088
|
+
if (link?.type !== 'fk' || link.references === undefined) {
|
|
1089
|
+
out.push({ path: where, message: `{{${name}}}: "${from.table}" has no column "${column}", no link "${column}_id" and no join "${column}"` });
|
|
1090
|
+
continue;
|
|
1091
|
+
}
|
|
1092
|
+
table = link.references;
|
|
1093
|
+
column = bare[1] ?? '';
|
|
1094
|
+
form = bare[2];
|
|
1095
|
+
}
|
|
1096
|
+
const found = index.column(table, column);
|
|
1097
|
+
if (found === undefined) {
|
|
1098
|
+
out.push({ path: where, message: `{{${name}}}: "${table}" has no column "${column}"` });
|
|
1099
|
+
continue;
|
|
1100
|
+
}
|
|
1101
|
+
if (qr) {
|
|
1102
|
+
if (path.join('.') !== 'row.image')
|
|
1103
|
+
continue; // said above
|
|
1104
|
+
const issue = qrIssue(table, `${table}.${column}`);
|
|
1105
|
+
if (issue !== null)
|
|
1106
|
+
out.push({ path: where, message: issue });
|
|
1107
|
+
}
|
|
1108
|
+
else if (form !== undefined && !rowFormsOf(found).includes(form)) {
|
|
1109
|
+
out.push({
|
|
1110
|
+
path: where,
|
|
1111
|
+
message: form === 'time' && found.type === 'text'
|
|
1112
|
+
? `{{${name}}}: "${table}.${column}" is not a time of day kept as text (a text column of at most 8 characters), so it has no "time" form`
|
|
1113
|
+
: `{{${name}}}: "${table}.${column}" is a ${found.type} column, which has no "${form}" form`,
|
|
1114
|
+
});
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
if (data.row.image !== undefined && !QR_VALUE.test(data.row.image)) {
|
|
1119
|
+
out.push({ path: at('data', 'row', 'image'), message: 'a row\'s image is a QR code of a code column: {{row.<column>.qr}}' });
|
|
1120
|
+
}
|
|
1121
|
+
});
|
|
1122
|
+
// A translation changes the words, never which rows are listed.
|
|
1123
|
+
const key = rows.join('\n');
|
|
1124
|
+
if (rowsOf === undefined)
|
|
1125
|
+
rowsOf = key;
|
|
1126
|
+
else if (key !== rowsOf)
|
|
1127
|
+
out.push({ path: here('blocks'), message: 'every language lists the same rows: from and joins are the same in each' });
|
|
1128
|
+
}
|
|
1129
|
+
});
|
|
656
1130
|
return out;
|
|
657
1131
|
}
|
|
1132
|
+
/** The forms of a number: as a number, a percentage and an amount of money, in the message's language. */
|
|
1133
|
+
const NUMBER_FORMS = ['number', 'percent', 'money'];
|
|
1134
|
+
/** The forms of a value a row's column may be read in, by its type (as the sender fills a template's). */
|
|
1135
|
+
const ROW_FORMS = {
|
|
1136
|
+
timestamptz: ['date', 'time', 'day_month', 'relative_day'],
|
|
1137
|
+
date: ['day_month', 'days_since'],
|
|
1138
|
+
int: NUMBER_FORMS,
|
|
1139
|
+
bigint: NUMBER_FORMS,
|
|
1140
|
+
decimal: NUMBER_FORMS,
|
|
1141
|
+
money: NUMBER_FORMS,
|
|
1142
|
+
float: NUMBER_FORMS,
|
|
1143
|
+
// A choice by its label; a time of day kept as text (`15:00`) in the reader's clock.
|
|
1144
|
+
enum: ['label'],
|
|
1145
|
+
text: ['time'],
|
|
1146
|
+
};
|
|
1147
|
+
/** The most characters a time of day kept as text takes: `09:30:00`. */
|
|
1148
|
+
export const CLOCK_TEXT_MAX = 8;
|
|
1149
|
+
/**
|
|
1150
|
+
* Whether a text column keeps a time of day (`15:00`): one no longer than
|
|
1151
|
+
* {@link CLOCK_TEXT_MAX} characters. Only such a column has a `time` form —
|
|
1152
|
+
* the sender fills it from a value shaped like a clock, and a longer text
|
|
1153
|
+
* column (a note, a name) never holds one.
|
|
1154
|
+
*/
|
|
1155
|
+
export function clockShaped(column) {
|
|
1156
|
+
if (column.type !== 'text')
|
|
1157
|
+
return false;
|
|
1158
|
+
const widths = [column.maxLength, column.rules?.validation?.maxLength].filter((width) => typeof width === 'number');
|
|
1159
|
+
return widths.length > 0 && Math.min(...widths) <= CLOCK_TEXT_MAX;
|
|
1160
|
+
}
|
|
1161
|
+
/** The form of a code in groups of four (`K7QX-M2PD`), as a person reads it out. */
|
|
1162
|
+
export const GROUPED_FORM = 'grouped';
|
|
1163
|
+
/**
|
|
1164
|
+
* A code in groups of four joined by `-` (`K7QXM2PD` → `K7QX-M2PD`), the way
|
|
1165
|
+
* a person reads it out; separators already in it are dropped first. Empty
|
|
1166
|
+
* stays empty.
|
|
1167
|
+
*/
|
|
1168
|
+
export function groupedCode(value) {
|
|
1169
|
+
if (value === null || value === undefined)
|
|
1170
|
+
return '';
|
|
1171
|
+
const bare = String(value).replace(/[\s-]+/g, '');
|
|
1172
|
+
return (bare.match(/.{1,4}/g) ?? []).join('-');
|
|
1173
|
+
}
|
|
1174
|
+
/** Whether a column holds a code Adminium makes: a text column with a `code` rule. */
|
|
1175
|
+
export function isCodeColumn(column) {
|
|
1176
|
+
return column.type === 'text' && column.rules?.code !== undefined;
|
|
1177
|
+
}
|
|
1178
|
+
/** The forms a row's column may be read in: its type's, a text column's `time` only when it keeps a time of day, and a code's groups. */
|
|
1179
|
+
export function rowFormsOf(column) {
|
|
1180
|
+
const forms = ROW_FORMS[column.type] ?? [];
|
|
1181
|
+
const own = column.type === 'text' && !clockShaped(column) ? forms.filter((form) => form !== 'time') : forms;
|
|
1182
|
+
return isCodeColumn(column) ? [...own, GROUPED_FORM] : own;
|
|
1183
|
+
}
|
|
658
1184
|
//# sourceMappingURL=outbox.js.map
|