@managemint-solutions/sdk 0.56.0 → 0.65.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +209 -7
- package/dist/audit/document-changes.d.ts +39 -0
- package/dist/audit/document-changes.js +332 -0
- package/dist/audit/index.d.ts +50 -2
- package/dist/audit/index.js +87 -44
- package/dist/client.d.ts +6 -0
- package/dist/client.js +9 -0
- package/dist/clients/dto.d.ts +18 -3
- package/dist/clients/dto.js +72 -2
- package/dist/clients/index.js +16 -0
- package/dist/configs/dto.js +4 -12
- package/dist/documents/content.d.ts +39 -0
- package/dist/documents/content.js +443 -0
- package/dist/documents/document-templates.d.ts +37 -0
- package/dist/documents/document-templates.js +143 -0
- package/dist/documents/documents.d.ts +154 -0
- package/dist/documents/documents.js +330 -0
- package/dist/documents/dto.d.ts +138 -0
- package/dist/documents/dto.js +594 -0
- package/dist/documents/index.d.ts +4 -0
- package/dist/documents/index.js +24 -0
- package/dist/documents/render/html.d.ts +64 -0
- package/dist/documents/render/html.js +413 -0
- package/dist/documents/render/index.d.ts +2 -0
- package/dist/documents/render/index.js +23 -0
- package/dist/documents/render/values.d.ts +86 -0
- package/dist/documents/render/values.js +142 -0
- package/dist/feature-requests/dto.js +4 -13
- package/dist/index.d.ts +8 -1
- package/dist/index.js +31 -3
- package/dist/leads/index.js +1 -3
- package/dist/notifications/index.d.ts +6 -0
- package/dist/notifications/index.js +16 -0
- package/dist/organization-payment-methods/dto.d.ts +10 -0
- package/dist/organization-payment-methods/dto.js +56 -0
- package/dist/organization-payment-methods/index.d.ts +8 -1
- package/dist/organization-payment-methods/index.js +23 -0
- package/dist/payments/index.d.ts +67 -0
- package/dist/payments/index.js +137 -0
- package/dist/permissions/dto.d.ts +4 -0
- package/dist/permissions/dto.js +20 -8
- package/dist/service-client/index.d.ts +44 -0
- package/dist/service-client/index.js +77 -0
- package/dist/staff-client.d.ts +3 -0
- package/dist/staff-client.js +5 -0
- package/dist/supporting-files/index.js +4 -6
- package/dist/supporting-files/paths.d.ts +2 -0
- package/dist/supporting-files/paths.js +3 -1
- package/dist/validators.d.ts +12 -0
- package/dist/validators.js +23 -1
- package/dist/visibility/dto.d.ts +15 -0
- package/dist/visibility/dto.js +68 -0
- package/dist/visibility/index.d.ts +59 -0
- package/dist/visibility/index.js +165 -0
- package/package.json +6 -2
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.IsDocumentNumberPrefix = exports.IsDocumentCategory = exports.IsDocumentCurrency = exports.IsDocumentVariableValues = exports.IsDocumentBlocks = exports.IsDocumentVariables = exports.documentProblem = exports.currencyProblem = exports.variableValuesProblem = exports.blocksProblem = exports.variablesProblem = exports.IMAGE_PATH = exports.IMAGE_EXTENSIONS = void 0;
|
|
4
|
+
const enum_1 = require("@managemint-solutions/entities/documents/enum");
|
|
5
|
+
const enum_2 = require("@managemint-solutions/entities/supporting-files/enum");
|
|
6
|
+
const validators_1 = require("../validators");
|
|
7
|
+
const values_1 = require("./render/values");
|
|
8
|
+
// The rules a template's and a document's content is held to - its variables, its blocks, the
|
|
9
|
+
// values filled in and the currency - written once and used by every DTO that carries them.
|
|
10
|
+
// Each check returns the first problem it finds as the message the caller sees, or null, so a
|
|
11
|
+
// 400 always says exactly what is wrong and where ("Block 2 (line items): Line 1: ...").
|
|
12
|
+
// Blocks and variables are stored as JSON with no SQL checks, so these are the only gate.
|
|
13
|
+
/** The image types an image block may show - the image types supporting files accept. */
|
|
14
|
+
exports.IMAGE_EXTENSIONS = new Set([
|
|
15
|
+
enum_2.SupportingFilesExtensions.PNG,
|
|
16
|
+
enum_2.SupportingFilesExtensions.JPG,
|
|
17
|
+
enum_2.SupportingFilesExtensions.JPEG,
|
|
18
|
+
enum_2.SupportingFilesExtensions.GIF,
|
|
19
|
+
]);
|
|
20
|
+
/** The path `documents.createImageUploadUrl` hands out, relative to the organization's folder. */
|
|
21
|
+
exports.IMAGE_PATH = new RegExp(`^documents/images/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\\.(${[...exports.IMAGE_EXTENSIONS].join('|')})$`);
|
|
22
|
+
const KEY = /^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$/;
|
|
23
|
+
const CURRENCY = /^[A-Z]{3}$/;
|
|
24
|
+
const NUMBER_PREFIX = /^[A-Za-z0-9/_.-]*$/;
|
|
25
|
+
const MAX_KEY_LENGTH = 40;
|
|
26
|
+
const MAX_VARIABLES = 100;
|
|
27
|
+
const MAX_BLOCKS = 200;
|
|
28
|
+
/** Variable labels, field labels, column headers, signer labels, image alt text, each side of a header or footer. */
|
|
29
|
+
const MAX_LABEL_LENGTH = 200;
|
|
30
|
+
/** Field values, table cells, line descriptions. */
|
|
31
|
+
const MAX_TEXT_LENGTH = 2000;
|
|
32
|
+
/** A text variable's value, and one run of rich text. */
|
|
33
|
+
const MAX_LONG_TEXT_LENGTH = 10000;
|
|
34
|
+
const MAX_LINK_LENGTH = 2000;
|
|
35
|
+
/** Rich text levels: doc, list, item, list, item ... - deep enough for lists four levels in. */
|
|
36
|
+
const MAX_RICH_TEXT_DEPTH = 10;
|
|
37
|
+
/** A columns block's columns: "Bill from" and "Bill to", with room for a third. */
|
|
38
|
+
const MIN_SIDE_COLUMNS = 2;
|
|
39
|
+
const MAX_SIDE_COLUMNS = 3;
|
|
40
|
+
const MAX_CATEGORY_LENGTH = 50;
|
|
41
|
+
const MAX_NUMBER_PREFIX_LENGTH = 10;
|
|
42
|
+
const SYSTEM_VARIABLES = new Set(Object.values(enum_1.DocumentSystemVariable));
|
|
43
|
+
/** Printed per PDF page, so they mean something only in the header and footer. */
|
|
44
|
+
const PAGE_VARIABLES = new Set([enum_1.DocumentSystemVariable.PAGE_NUMBER, enum_1.DocumentSystemVariable.PAGE_COUNT]);
|
|
45
|
+
const PAGE_VARIABLES_ONLY = 'Page number and page count only work in a header or footer';
|
|
46
|
+
const VARIABLE_TYPES = new Set(Object.values(enum_1.DocumentVariableType));
|
|
47
|
+
const BLOCK_LABELS = {
|
|
48
|
+
[enum_1.DocumentBlockType.TEXT]: 'text',
|
|
49
|
+
[enum_1.DocumentBlockType.FIELDS]: 'fields',
|
|
50
|
+
[enum_1.DocumentBlockType.LINE_ITEMS]: 'line items',
|
|
51
|
+
[enum_1.DocumentBlockType.TABLE]: 'table',
|
|
52
|
+
[enum_1.DocumentBlockType.SIGNATURE]: 'signature',
|
|
53
|
+
[enum_1.DocumentBlockType.IMAGE]: 'image',
|
|
54
|
+
[enum_1.DocumentBlockType.PAGE_BREAK]: 'page break',
|
|
55
|
+
[enum_1.DocumentBlockType.HEADER]: 'header',
|
|
56
|
+
[enum_1.DocumentBlockType.FOOTER]: 'footer',
|
|
57
|
+
[enum_1.DocumentBlockType.COLUMNS]: 'columns',
|
|
58
|
+
};
|
|
59
|
+
/** The properties each block carries beside `id` and `type` - nothing else is stored. */
|
|
60
|
+
const BLOCK_PROPERTIES = {
|
|
61
|
+
[enum_1.DocumentBlockType.TEXT]: ['content'],
|
|
62
|
+
[enum_1.DocumentBlockType.FIELDS]: ['fields'],
|
|
63
|
+
[enum_1.DocumentBlockType.LINE_ITEMS]: ['items'],
|
|
64
|
+
[enum_1.DocumentBlockType.TABLE]: ['columns', 'rows'],
|
|
65
|
+
[enum_1.DocumentBlockType.SIGNATURE]: ['signers'],
|
|
66
|
+
[enum_1.DocumentBlockType.IMAGE]: ['path', 'alt'],
|
|
67
|
+
[enum_1.DocumentBlockType.PAGE_BREAK]: [],
|
|
68
|
+
[enum_1.DocumentBlockType.HEADER]: ['left', 'right'],
|
|
69
|
+
[enum_1.DocumentBlockType.FOOTER]: ['left', 'right'],
|
|
70
|
+
[enum_1.DocumentBlockType.COLUMNS]: ['columns'],
|
|
71
|
+
};
|
|
72
|
+
/** How many of each a block may hold. */
|
|
73
|
+
const MAX_FIELDS = 100;
|
|
74
|
+
const MAX_LINES = 200;
|
|
75
|
+
const MAX_COLUMNS = 12;
|
|
76
|
+
const MAX_ROWS = 500;
|
|
77
|
+
const MAX_SIGNERS = 10;
|
|
78
|
+
// Rich text: the editor's ProseMirror JSON, limited to what the api's renderer prints. Each node
|
|
79
|
+
// may hold only its listed properties and only the listed children. Attributes are checked where
|
|
80
|
+
// they matter (a heading's level, a variable's key, a link's href); any other attribute the
|
|
81
|
+
// editor writes is stored and never rendered.
|
|
82
|
+
const BLOCK_NODES = new Set(['paragraph', 'heading', 'bulletList', 'orderedList']);
|
|
83
|
+
const INLINE_NODES = new Set(['text', 'hardBreak', 'variable']);
|
|
84
|
+
const LIST_ITEMS = new Set(['listItem']);
|
|
85
|
+
const DOC = new Set(['doc']);
|
|
86
|
+
const RICH_TEXT_NODES = new Map([
|
|
87
|
+
['doc', { properties: ['content'], children: BLOCK_NODES }],
|
|
88
|
+
['paragraph', { properties: ['content', 'attrs'], children: INLINE_NODES }],
|
|
89
|
+
['heading', { properties: ['content', 'attrs'], children: INLINE_NODES }],
|
|
90
|
+
['bulletList', { properties: ['content', 'attrs'], children: LIST_ITEMS }],
|
|
91
|
+
['orderedList', { properties: ['content', 'attrs'], children: LIST_ITEMS }],
|
|
92
|
+
['listItem', { properties: ['content', 'attrs'], children: BLOCK_NODES }],
|
|
93
|
+
['text', { properties: ['text', 'marks'] }],
|
|
94
|
+
['hardBreak', { properties: ['marks'] }],
|
|
95
|
+
['variable', { properties: ['attrs', 'marks'] }],
|
|
96
|
+
]);
|
|
97
|
+
const MARKS = new Set(['bold', 'italic', 'underline', 'link']);
|
|
98
|
+
/** A property of `value` that is not one of `allowed`, as the message naming it. */
|
|
99
|
+
const unexpected = (value, allowed) => {
|
|
100
|
+
const extra = Object.keys(value).find((key) => !allowed.includes(key));
|
|
101
|
+
return extra === undefined ? null : `Unexpected property "${extra}"`;
|
|
102
|
+
};
|
|
103
|
+
/** The declared variables' types by key - what references are resolved against. */
|
|
104
|
+
const declaredTypes = (variables) => new Map((Array.isArray(variables) ? variables : [])
|
|
105
|
+
.filter((variable) => (0, values_1.isObject)(variable) && typeof variable.key === 'string')
|
|
106
|
+
.map((variable) => [variable.key, variable.type]));
|
|
107
|
+
/** Whether a value fits its variable's type, as the rest of a sentence ("must be a number"). */
|
|
108
|
+
const valueProblem = (type, value) => {
|
|
109
|
+
switch (type) {
|
|
110
|
+
case enum_1.DocumentVariableType.TEXT:
|
|
111
|
+
if (typeof value !== 'string')
|
|
112
|
+
return 'must be text';
|
|
113
|
+
return value.length > MAX_LONG_TEXT_LENGTH ? `cannot exceed ${MAX_LONG_TEXT_LENGTH} characters` : null;
|
|
114
|
+
case enum_1.DocumentVariableType.NUMBER:
|
|
115
|
+
return typeof value === 'number' && Number.isFinite(value) ? null : 'must be a number';
|
|
116
|
+
case enum_1.DocumentVariableType.MONEY:
|
|
117
|
+
return Number.isSafeInteger(value) && value >= 0 ? null : 'must be a whole number of cents, 0 or more';
|
|
118
|
+
default:
|
|
119
|
+
return (0, validators_1.isCalendarDate)(value) ? null : 'must be a date (YYYY-MM-DD)';
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
/** Plain text in a block: a string of at most `max` characters whose `{{key}}`s all resolve. */
|
|
123
|
+
const textProblem = (value, what, max, known) => {
|
|
124
|
+
if (typeof value !== 'string')
|
|
125
|
+
return `${what} must be text`;
|
|
126
|
+
if (value.length > max)
|
|
127
|
+
return `${what} cannot exceed ${max} characters`;
|
|
128
|
+
for (const [, key] of value.matchAll(values_1.DOCUMENT_VARIABLE_PATTERN)) {
|
|
129
|
+
if (!known(key))
|
|
130
|
+
return PAGE_VARIABLES.has(key) ? PAGE_VARIABLES_ONLY : `${what} uses an unknown variable {{${key}}}`;
|
|
131
|
+
}
|
|
132
|
+
return null;
|
|
133
|
+
};
|
|
134
|
+
/** A list of up to `max` entries, each checked by `check` and named by its position. */
|
|
135
|
+
const listProblem = (value, plural, singular, max, check) => {
|
|
136
|
+
if (!Array.isArray(value))
|
|
137
|
+
return `Expected a list of ${plural}`;
|
|
138
|
+
if (value.length > max)
|
|
139
|
+
return `Cannot have more than ${max} ${plural}`;
|
|
140
|
+
for (const [index, entry] of value.entries()) {
|
|
141
|
+
const problem = check(entry);
|
|
142
|
+
if (problem)
|
|
143
|
+
return `${singular} ${index + 1}: ${problem}`;
|
|
144
|
+
}
|
|
145
|
+
return null;
|
|
146
|
+
};
|
|
147
|
+
/** An object holding exactly `properties`, each checked in turn. */
|
|
148
|
+
const entryProblem = (entry, properties, ...checks) => {
|
|
149
|
+
if (!(0, values_1.isObject)(entry))
|
|
150
|
+
return 'Must be an object';
|
|
151
|
+
const extra = unexpected(entry, properties);
|
|
152
|
+
if (extra)
|
|
153
|
+
return extra;
|
|
154
|
+
for (const check of checks) {
|
|
155
|
+
const problem = check();
|
|
156
|
+
if (problem)
|
|
157
|
+
return problem;
|
|
158
|
+
}
|
|
159
|
+
return null;
|
|
160
|
+
};
|
|
161
|
+
const percentProblem = (value, what) => typeof value === 'number' && value >= 0 && value <= 100 ? null : `${what} must be a percentage from 0 to 100`;
|
|
162
|
+
/**
|
|
163
|
+
* A line's quantity or unit price: a number written in, or `{ variable }` naming a declared
|
|
164
|
+
* variable of `type` (a number for the quantity, money for the price).
|
|
165
|
+
*/
|
|
166
|
+
const amountProblem = (value, what, type, declared, rule) => {
|
|
167
|
+
if (typeof value === 'number')
|
|
168
|
+
return rule.holds(value) ? null : `${what} ${rule.message}`;
|
|
169
|
+
if (!(0, values_1.isObject)(value) || typeof value.variable !== 'string' || Object.keys(value).length !== 1) {
|
|
170
|
+
return `${what} must be a number or a variable`;
|
|
171
|
+
}
|
|
172
|
+
if (declared.get(value.variable) === type)
|
|
173
|
+
return null;
|
|
174
|
+
if (PAGE_VARIABLES.has(value.variable))
|
|
175
|
+
return PAGE_VARIABLES_ONLY;
|
|
176
|
+
return declared.has(value.variable) || SYSTEM_VARIABLES.has(value.variable)
|
|
177
|
+
? `${what} can only use a ${type} variable`
|
|
178
|
+
: `${what} uses an unknown variable "${value.variable}"`;
|
|
179
|
+
};
|
|
180
|
+
/** One rich text node and everything under it, placed where only `allowed` may go. */
|
|
181
|
+
const nodeProblem = (node, allowed, depth, known) => {
|
|
182
|
+
if (!(0, values_1.isObject)(node) || typeof node.type !== 'string')
|
|
183
|
+
return 'Text holds something that is not text';
|
|
184
|
+
const rule = RICH_TEXT_NODES.get(node.type);
|
|
185
|
+
if (!rule)
|
|
186
|
+
return `Unsupported content "${node.type}"`;
|
|
187
|
+
if (!allowed.has(node.type))
|
|
188
|
+
return `"${node.type}" cannot be placed there`;
|
|
189
|
+
const extra = unexpected(node, ['type', ...rule.properties]);
|
|
190
|
+
if (extra)
|
|
191
|
+
return `${extra} on "${node.type}"`;
|
|
192
|
+
const attrs = node.attrs === undefined ? {} : node.attrs;
|
|
193
|
+
if (!(0, values_1.isObject)(attrs))
|
|
194
|
+
return `Attributes of "${node.type}" must be an object`;
|
|
195
|
+
if (node.type === 'heading' && ![1, 2, 3].includes(attrs.level)) {
|
|
196
|
+
return 'Heading level must be 1, 2 or 3';
|
|
197
|
+
}
|
|
198
|
+
if (node.type === 'variable') {
|
|
199
|
+
if (typeof attrs.key !== 'string')
|
|
200
|
+
return 'A variable must name its key';
|
|
201
|
+
if (!known(attrs.key))
|
|
202
|
+
return PAGE_VARIABLES.has(attrs.key) ? PAGE_VARIABLES_ONLY : `Unknown variable "${attrs.key}"`;
|
|
203
|
+
}
|
|
204
|
+
if (node.type === 'text') {
|
|
205
|
+
if (typeof node.text !== 'string' || node.text === '')
|
|
206
|
+
return 'A text run must hold text';
|
|
207
|
+
if (node.text.length > MAX_LONG_TEXT_LENGTH)
|
|
208
|
+
return `A text run cannot exceed ${MAX_LONG_TEXT_LENGTH} characters`;
|
|
209
|
+
}
|
|
210
|
+
if (node.marks !== undefined) {
|
|
211
|
+
if (!Array.isArray(node.marks))
|
|
212
|
+
return 'Formatting must be a list';
|
|
213
|
+
for (const mark of node.marks) {
|
|
214
|
+
if (!(0, values_1.isObject)(mark) || typeof mark.type !== 'string' || !MARKS.has(mark.type)) {
|
|
215
|
+
return `Unsupported formatting "${(0, values_1.isObject)(mark) ? String(mark.type) : mark}"`;
|
|
216
|
+
}
|
|
217
|
+
const markExtra = unexpected(mark, ['type', 'attrs']);
|
|
218
|
+
if (markExtra)
|
|
219
|
+
return `${markExtra} on "${mark.type}"`;
|
|
220
|
+
if (mark.type === 'link') {
|
|
221
|
+
const href = (0, values_1.isObject)(mark.attrs) ? mark.attrs.href : undefined;
|
|
222
|
+
if (typeof href !== 'string' || href.length > MAX_LINK_LENGTH || (0, values_1.safeHref)(href) === null) {
|
|
223
|
+
return 'Links must start with http://, https:// or mailto:';
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
if (!rule.children || node.content === undefined)
|
|
229
|
+
return null;
|
|
230
|
+
if (!Array.isArray(node.content))
|
|
231
|
+
return `Content of "${node.type}" must be a list`;
|
|
232
|
+
if (depth >= MAX_RICH_TEXT_DEPTH)
|
|
233
|
+
return 'Text is nested too deeply';
|
|
234
|
+
for (const child of node.content) {
|
|
235
|
+
const problem = nodeProblem(child, rule.children, depth + 1, known);
|
|
236
|
+
if (problem)
|
|
237
|
+
return problem;
|
|
238
|
+
}
|
|
239
|
+
return null;
|
|
240
|
+
};
|
|
241
|
+
/**
|
|
242
|
+
* A template's or document's variables: each `{ key, label, type, required, default_value }`,
|
|
243
|
+
* keys lower snake_case (1 to 40 characters), unique, never a system variable's, and a default
|
|
244
|
+
* that fits the type or null.
|
|
245
|
+
*/
|
|
246
|
+
const variablesProblem = (variables) => {
|
|
247
|
+
if (!Array.isArray(variables))
|
|
248
|
+
return 'Variables must be a list';
|
|
249
|
+
if (variables.length > MAX_VARIABLES)
|
|
250
|
+
return `Cannot have more than ${MAX_VARIABLES} variables`;
|
|
251
|
+
const keys = new Set();
|
|
252
|
+
for (const [index, variable] of variables.entries()) {
|
|
253
|
+
const at = `Variable ${index + 1}`;
|
|
254
|
+
if (!(0, values_1.isObject)(variable))
|
|
255
|
+
return `${at} must be an object`;
|
|
256
|
+
const extra = unexpected(variable, ['key', 'label', 'type', 'required', 'default_value']);
|
|
257
|
+
if (extra)
|
|
258
|
+
return `${at}: ${extra}`;
|
|
259
|
+
const { key, label, type, required } = variable;
|
|
260
|
+
if (typeof key !== 'string' || key.length > MAX_KEY_LENGTH || !KEY.test(key)) {
|
|
261
|
+
return `${at}: Key must be lower snake_case, 1 to ${MAX_KEY_LENGTH} characters, such as client_reference`;
|
|
262
|
+
}
|
|
263
|
+
if (SYSTEM_VARIABLES.has(key))
|
|
264
|
+
return `${at}: "${key}" is a system variable and cannot be declared`;
|
|
265
|
+
if (keys.has(key))
|
|
266
|
+
return `${at}: Key "${key}" is already used`;
|
|
267
|
+
keys.add(key);
|
|
268
|
+
if (typeof label !== 'string' || label.trim() === '' || label.length > MAX_LABEL_LENGTH) {
|
|
269
|
+
return `${at}: Label must be 1 to ${MAX_LABEL_LENGTH} characters`;
|
|
270
|
+
}
|
|
271
|
+
if (typeof type !== 'string' || !VARIABLE_TYPES.has(type)) {
|
|
272
|
+
return `${at}: Type must be one of ${[...VARIABLE_TYPES].join(', ')}`;
|
|
273
|
+
}
|
|
274
|
+
if (typeof required !== 'boolean')
|
|
275
|
+
return `${at}: Required must be true or false`;
|
|
276
|
+
if (variable.default_value === undefined)
|
|
277
|
+
return `${at}: Default value is required (null for none)`;
|
|
278
|
+
const problem = variable.default_value === null ? null : valueProblem(type, variable.default_value);
|
|
279
|
+
if (problem)
|
|
280
|
+
return `${at}: Default value ${problem}`;
|
|
281
|
+
}
|
|
282
|
+
return null;
|
|
283
|
+
};
|
|
284
|
+
exports.variablesProblem = variablesProblem;
|
|
285
|
+
/**
|
|
286
|
+
* A template's or document's blocks: each block's shape for its type, numbers in range, and every
|
|
287
|
+
* variable it uses - `{{key}}` in plain text, a variable node in rich text, a line's quantity or
|
|
288
|
+
* price - declared in `variables` or a system variable; `{{page_number}}` and `{{page_count}}` only
|
|
289
|
+
* in the header and footer, of which a document has at most one each, anywhere in the list.
|
|
290
|
+
*/
|
|
291
|
+
const blocksProblem = (blocks, variables) => {
|
|
292
|
+
if (!Array.isArray(blocks))
|
|
293
|
+
return 'Blocks must be a list';
|
|
294
|
+
if (blocks.length > MAX_BLOCKS)
|
|
295
|
+
return `Cannot have more than ${MAX_BLOCKS} blocks`;
|
|
296
|
+
const declared = declaredTypes(variables);
|
|
297
|
+
const known = (key) => declared.has(key) || (SYSTEM_VARIABLES.has(key) && !PAGE_VARIABLES.has(key));
|
|
298
|
+
const knownOnEveryPage = (key) => known(key) || PAGE_VARIABLES.has(key);
|
|
299
|
+
const ids = new Set();
|
|
300
|
+
const types = new Set();
|
|
301
|
+
for (const [index, block] of blocks.entries()) {
|
|
302
|
+
const at = `Block ${index + 1}`;
|
|
303
|
+
if (!(0, values_1.isObject)(block))
|
|
304
|
+
return `${at} must be an object`;
|
|
305
|
+
if (typeof block.type !== 'string' || !Object.hasOwn(BLOCK_LABELS, block.type)) {
|
|
306
|
+
return `${at}: Type must be one of ${Object.values(enum_1.DocumentBlockType).join(', ')}`;
|
|
307
|
+
}
|
|
308
|
+
const type = block.type;
|
|
309
|
+
const where = `${at} (${BLOCK_LABELS[type]})`;
|
|
310
|
+
const extra = unexpected(block, ['id', 'type', ...BLOCK_PROPERTIES[type]]);
|
|
311
|
+
if (extra)
|
|
312
|
+
return `${where}: ${extra}`;
|
|
313
|
+
if (typeof block.id !== 'string' || block.id === '' || block.id.length > 64) {
|
|
314
|
+
return `${where}: Id must be 1 to 64 characters`;
|
|
315
|
+
}
|
|
316
|
+
if (ids.has(block.id))
|
|
317
|
+
return `${where}: Id "${block.id}" is used by another block`;
|
|
318
|
+
ids.add(block.id);
|
|
319
|
+
// A header and a footer print on every page, so a second of either has nowhere to go.
|
|
320
|
+
if ((type === enum_1.DocumentBlockType.HEADER || type === enum_1.DocumentBlockType.FOOTER) && types.has(type)) {
|
|
321
|
+
return `${where}: A document can have only one ${BLOCK_LABELS[type]}`;
|
|
322
|
+
}
|
|
323
|
+
types.add(type);
|
|
324
|
+
// A page break holds nothing more to check.
|
|
325
|
+
let problem = null;
|
|
326
|
+
switch (type) {
|
|
327
|
+
case enum_1.DocumentBlockType.TEXT:
|
|
328
|
+
problem =
|
|
329
|
+
(0, values_1.isObject)(block.content) && block.content.type === 'doc'
|
|
330
|
+
? nodeProblem(block.content, DOC, 0, known)
|
|
331
|
+
: 'Content must be a rich text document';
|
|
332
|
+
break;
|
|
333
|
+
case enum_1.DocumentBlockType.FIELDS:
|
|
334
|
+
problem = listProblem(block.fields, 'fields', 'Field', MAX_FIELDS, (field) => entryProblem(field, ['label', 'value'], () => textProblem(field.label, 'Label', MAX_LABEL_LENGTH, known), () => textProblem(field.value, 'Value', MAX_TEXT_LENGTH, known)));
|
|
335
|
+
break;
|
|
336
|
+
case enum_1.DocumentBlockType.LINE_ITEMS:
|
|
337
|
+
problem = listProblem(block.items, 'lines', 'Line', MAX_LINES, (line) => entryProblem(line, ['description', 'quantity', 'unit_price_minor', 'discount_percent', 'tax_rate'], () => textProblem(line.description, 'Description', MAX_TEXT_LENGTH, known), () => amountProblem(line.quantity, 'Quantity', enum_1.DocumentVariableType.NUMBER, declared, {
|
|
338
|
+
holds: (amount) => Number.isFinite(amount) && amount > 0,
|
|
339
|
+
message: 'must be greater than 0',
|
|
340
|
+
}), () => amountProblem(line.unit_price_minor, 'Unit price', enum_1.DocumentVariableType.MONEY, declared, {
|
|
341
|
+
holds: (amount) => Number.isSafeInteger(amount) && amount >= 0,
|
|
342
|
+
message: 'must be a whole number of cents, 0 or more',
|
|
343
|
+
}), () => percentProblem(line.discount_percent, 'Discount'), () => percentProblem(line.tax_rate, 'Tax rate')));
|
|
344
|
+
break;
|
|
345
|
+
case enum_1.DocumentBlockType.TABLE: {
|
|
346
|
+
const columns = block.columns;
|
|
347
|
+
problem =
|
|
348
|
+
Array.isArray(columns) && columns.length === 0
|
|
349
|
+
? 'A table needs at least one column'
|
|
350
|
+
: (listProblem(columns, 'columns', 'Column', MAX_COLUMNS, (column) => textProblem(column, 'Heading', MAX_LABEL_LENGTH, known)) ??
|
|
351
|
+
listProblem(block.rows, 'rows', 'Row', MAX_ROWS, (row) => Array.isArray(row) && row.length === columns.length
|
|
352
|
+
? listProblem(row, 'cells', 'Cell', MAX_COLUMNS, (cell) => textProblem(cell, 'Text', MAX_TEXT_LENGTH, known))
|
|
353
|
+
: `Must have one cell per column (${columns.length})`));
|
|
354
|
+
break;
|
|
355
|
+
}
|
|
356
|
+
case enum_1.DocumentBlockType.COLUMNS: {
|
|
357
|
+
const columns = block.columns;
|
|
358
|
+
problem =
|
|
359
|
+
Array.isArray(columns) && (columns.length < MIN_SIDE_COLUMNS || columns.length > MAX_SIDE_COLUMNS)
|
|
360
|
+
? `A columns block has ${MIN_SIDE_COLUMNS} or ${MAX_SIDE_COLUMNS} columns`
|
|
361
|
+
: listProblem(columns, 'columns', 'Column', MAX_SIDE_COLUMNS, (column) => entryProblem(column, ['heading', 'text'], () => textProblem(column.heading, 'Heading', MAX_LABEL_LENGTH, known), () => textProblem(column.text, 'Text', MAX_TEXT_LENGTH, known)));
|
|
362
|
+
break;
|
|
363
|
+
}
|
|
364
|
+
case enum_1.DocumentBlockType.SIGNATURE:
|
|
365
|
+
problem = listProblem(block.signers, 'signers', 'Signer', MAX_SIGNERS, (signer) => entryProblem(signer, ['label'], () => textProblem(signer.label, 'Label', MAX_LABEL_LENGTH, known)));
|
|
366
|
+
break;
|
|
367
|
+
case enum_1.DocumentBlockType.IMAGE:
|
|
368
|
+
problem =
|
|
369
|
+
typeof block.path !== 'string' || (block.path !== '' && !exports.IMAGE_PATH.test(block.path))
|
|
370
|
+
? 'Image must be one uploaded for documents'
|
|
371
|
+
: textProblem(block.alt, 'Alt text', MAX_LABEL_LENGTH, known);
|
|
372
|
+
break;
|
|
373
|
+
case enum_1.DocumentBlockType.HEADER:
|
|
374
|
+
case enum_1.DocumentBlockType.FOOTER:
|
|
375
|
+
problem =
|
|
376
|
+
textProblem(block.left, 'Left', MAX_LABEL_LENGTH, knownOnEveryPage) ??
|
|
377
|
+
textProblem(block.right, 'Right', MAX_LABEL_LENGTH, knownOnEveryPage);
|
|
378
|
+
break;
|
|
379
|
+
}
|
|
380
|
+
if (problem)
|
|
381
|
+
return `${where}: ${problem}`;
|
|
382
|
+
}
|
|
383
|
+
return null;
|
|
384
|
+
};
|
|
385
|
+
exports.blocksProblem = blocksProblem;
|
|
386
|
+
/**
|
|
387
|
+
* Filled-in values by variable key: each key a declared variable (system values are the api's to
|
|
388
|
+
* fill) and each value fitting its type - text, a number, whole cents 0 or more, a YYYY-MM-DD
|
|
389
|
+
* date. A variable with no value is simply left out.
|
|
390
|
+
*/
|
|
391
|
+
const variableValuesProblem = (values, variables) => {
|
|
392
|
+
if (!(0, values_1.isObject)(values))
|
|
393
|
+
return 'Variable values must be an object';
|
|
394
|
+
const declared = declaredTypes(variables);
|
|
395
|
+
for (const [key, value] of Object.entries(values)) {
|
|
396
|
+
const type = declared.get(key);
|
|
397
|
+
if (type === undefined) {
|
|
398
|
+
return SYSTEM_VARIABLES.has(key) ? `"${key}" is filled in by the system` : `Unknown variable "${key}"`;
|
|
399
|
+
}
|
|
400
|
+
const problem = valueProblem(type, value);
|
|
401
|
+
if (problem)
|
|
402
|
+
return `Value of "${key}" ${problem}`;
|
|
403
|
+
}
|
|
404
|
+
return null;
|
|
405
|
+
};
|
|
406
|
+
exports.variableValuesProblem = variableValuesProblem;
|
|
407
|
+
/** Three capital letters (ZAR), or nothing - unless a line items block needs one to print in. */
|
|
408
|
+
const currencyProblem = (currency, blocks) => {
|
|
409
|
+
if (currency === null || currency === undefined) {
|
|
410
|
+
const hasLineItems = Array.isArray(blocks) && blocks.some((block) => (0, values_1.isObject)(block) && block.type === enum_1.DocumentBlockType.LINE_ITEMS);
|
|
411
|
+
return hasLineItems ? 'Currency is required when the document has line items' : null;
|
|
412
|
+
}
|
|
413
|
+
return typeof currency === 'string' && CURRENCY.test(currency) ? null : 'Currency must be a three-letter code, such as ZAR';
|
|
414
|
+
};
|
|
415
|
+
exports.currencyProblem = currencyProblem;
|
|
416
|
+
/**
|
|
417
|
+
* Everything `UpdateDocumentDto` checks about a document's content, as one call - for a draft the
|
|
418
|
+
* api composes itself: `POST /documents` copies a template's variables and blocks and overlays the
|
|
419
|
+
* caller's values, which no DTO saw together. Null when the draft is sound.
|
|
420
|
+
*/
|
|
421
|
+
const documentProblem = (document) => (0, exports.variablesProblem)(document.variables) ??
|
|
422
|
+
(0, exports.blocksProblem)(document.blocks, document.variables) ??
|
|
423
|
+
(0, exports.variableValuesProblem)(document.variable_values, document.variables) ??
|
|
424
|
+
(0, exports.currencyProblem)(document.currency, document.blocks);
|
|
425
|
+
exports.documentProblem = documentProblem;
|
|
426
|
+
// The checks above as decorators; blocks, values and currency read the DTO's sibling fields.
|
|
427
|
+
exports.IsDocumentVariables = (0, validators_1.fromProblem)('isDocumentVariables', exports.variablesProblem);
|
|
428
|
+
exports.IsDocumentBlocks = (0, validators_1.fromProblem)('isDocumentBlocks', (value, object) => (0, exports.blocksProblem)(value, object.variables));
|
|
429
|
+
exports.IsDocumentVariableValues = (0, validators_1.fromProblem)('isDocumentVariableValues', (value, object) => (0, exports.variableValuesProblem)(value, object.variables));
|
|
430
|
+
/** On a DTO with `blocks`, also required when they hold line items; `@IsOptional()` makes it a format check only. */
|
|
431
|
+
exports.IsDocumentCurrency = (0, validators_1.fromProblem)('isDocumentCurrency', (value, object) => (0, exports.currencyProblem)(value, object.blocks));
|
|
432
|
+
exports.IsDocumentCategory = (0, validators_1.fromProblem)('isDocumentCategory', (value) => {
|
|
433
|
+
if (typeof value !== 'string')
|
|
434
|
+
return 'Category must be text';
|
|
435
|
+
return value.length > MAX_CATEGORY_LENGTH ? `Category cannot exceed ${MAX_CATEGORY_LENGTH} characters` : null;
|
|
436
|
+
});
|
|
437
|
+
exports.IsDocumentNumberPrefix = (0, validators_1.fromProblem)('isDocumentNumberPrefix', (value) => {
|
|
438
|
+
if (typeof value !== 'string')
|
|
439
|
+
return 'Number prefix must be text';
|
|
440
|
+
if (value.length > MAX_NUMBER_PREFIX_LENGTH)
|
|
441
|
+
return `Number prefix cannot exceed ${MAX_NUMBER_PREFIX_LENGTH} characters`;
|
|
442
|
+
return NUMBER_PREFIX.test(value) ? null : 'Number prefix can only hold letters, digits and - / _ .';
|
|
443
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { MmsList } from '@managemint-solutions/entities/common';
|
|
2
|
+
import type { DocumentTemplateEntity, DocumentTemplateSummary } from '@managemint-solutions/entities/documents';
|
|
3
|
+
import type { ClientContext } from '../auth';
|
|
4
|
+
import { EntityAuditTrail, type AuditResource } from '../audit';
|
|
5
|
+
import type { TypedSupabaseClient } from '../client';
|
|
6
|
+
import type { CreateDocumentTemplateDto, GetDocumentTemplatesDto, UpdateDocumentTemplateDto } from './dto';
|
|
7
|
+
/**
|
|
8
|
+
* A template as the sdk reads it. `my_role` - what the caller may do with it - is the api's to
|
|
9
|
+
* add, from the caller's visibility row and permissions.
|
|
10
|
+
*/
|
|
11
|
+
export type DocumentTemplateRecord = Omit<DocumentTemplateEntity, 'my_role'>;
|
|
12
|
+
/**
|
|
13
|
+
* Document templates: an ordered list of blocks plus the variables they use. A template is
|
|
14
|
+
* always read and saved whole. Deleting one keeps the documents made from it - the foreign key
|
|
15
|
+
* forgets their `template_id`.
|
|
16
|
+
*/
|
|
17
|
+
export declare class DocumentTemplatesResource {
|
|
18
|
+
private readonly supabase;
|
|
19
|
+
private readonly auth;
|
|
20
|
+
private readonly table;
|
|
21
|
+
/** This record's history - its own rows. */
|
|
22
|
+
readonly audit: EntityAuditTrail;
|
|
23
|
+
constructor(supabase: TypedSupabaseClient, auth: ClientContext, audit: AuditResource);
|
|
24
|
+
/** Newest first; `search` matches the name and the category. */
|
|
25
|
+
list(query: GetDocumentTemplatesDto & {
|
|
26
|
+
organization_id?: string | null;
|
|
27
|
+
}): Promise<MmsList<DocumentTemplateSummary>>;
|
|
28
|
+
getById(templateId: string): Promise<DocumentTemplateRecord>;
|
|
29
|
+
create(input: CreateDocumentTemplateDto): Promise<DocumentTemplateRecord>;
|
|
30
|
+
/** Replaces the whole template. Documents already made from it keep their own copy. */
|
|
31
|
+
update(templateId: string, input: UpdateDocumentTemplateDto): Promise<DocumentTemplateRecord>;
|
|
32
|
+
archive(templateId: string): Promise<DocumentTemplateRecord>;
|
|
33
|
+
/** The table keeps no unarchive stamps, so the archive ones are cleared; the history keeps both. */
|
|
34
|
+
unarchive(templateId: string): Promise<DocumentTemplateRecord>;
|
|
35
|
+
/** Hard delete. Its documents stay, with `template_id` set to null by the foreign key. */
|
|
36
|
+
remove(templateId: string): Promise<void>;
|
|
37
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.DocumentTemplatesResource = void 0;
|
|
4
|
+
const enum_1 = require("@managemint-solutions/entities/audit-trail/enum");
|
|
5
|
+
const audit_1 = require("../audit");
|
|
6
|
+
const errors_1 = require("../errors");
|
|
7
|
+
const tenancy_1 = require("../tenancy");
|
|
8
|
+
const transforms_1 = require("../transforms");
|
|
9
|
+
// The public contract is the shared DocumentTemplateSummary / DocumentTemplateEntity from
|
|
10
|
+
// @managemint-solutions/entities; the casts at the returns declare the shape the select strings
|
|
11
|
+
// below produce (the columns are defined in the schemas repo's migrations).
|
|
12
|
+
const TEMPLATE_LIST_SELECT = `
|
|
13
|
+
mms_id,
|
|
14
|
+
created_at,
|
|
15
|
+
name,
|
|
16
|
+
category,
|
|
17
|
+
number_prefix,
|
|
18
|
+
archived,
|
|
19
|
+
visibility
|
|
20
|
+
`;
|
|
21
|
+
const TEMPLATE_DETAIL_SELECT = `
|
|
22
|
+
mms_id,
|
|
23
|
+
created_at,
|
|
24
|
+
created_by:user_profiles!created_by ${transforms_1.USER_PROFILE_EMBED},
|
|
25
|
+
updated_at,
|
|
26
|
+
last_updated_by:user_profiles!last_updated_by ${transforms_1.USER_PROFILE_EMBED},
|
|
27
|
+
name,
|
|
28
|
+
category,
|
|
29
|
+
number_prefix,
|
|
30
|
+
variables,
|
|
31
|
+
blocks,
|
|
32
|
+
blocks_version,
|
|
33
|
+
visibility,
|
|
34
|
+
archived,
|
|
35
|
+
archived_at,
|
|
36
|
+
archived_by:user_profiles!archived_by ${transforms_1.USER_PROFILE_EMBED}
|
|
37
|
+
`;
|
|
38
|
+
/**
|
|
39
|
+
* What a save writes: the whole template, its two optional words stored as '' when left out.
|
|
40
|
+
* Never its visibility - members hold no grant on it after create.
|
|
41
|
+
*/
|
|
42
|
+
const templateRow = (input) => ({
|
|
43
|
+
name: input.name,
|
|
44
|
+
category: input.category ?? '',
|
|
45
|
+
number_prefix: input.number_prefix ?? '',
|
|
46
|
+
variables: input.variables,
|
|
47
|
+
blocks: input.blocks,
|
|
48
|
+
});
|
|
49
|
+
/**
|
|
50
|
+
* Document templates: an ordered list of blocks plus the variables they use. A template is
|
|
51
|
+
* always read and saved whole. Deleting one keeps the documents made from it - the foreign key
|
|
52
|
+
* forgets their `template_id`.
|
|
53
|
+
*/
|
|
54
|
+
class DocumentTemplatesResource {
|
|
55
|
+
supabase;
|
|
56
|
+
auth;
|
|
57
|
+
table;
|
|
58
|
+
/** This record's history - its own rows. */
|
|
59
|
+
audit;
|
|
60
|
+
constructor(supabase, auth, audit) {
|
|
61
|
+
this.supabase = supabase;
|
|
62
|
+
this.auth = auth;
|
|
63
|
+
this.table = (0, audit_1.auditedTable)(supabase, auth, audit, {
|
|
64
|
+
table: 'document_templates',
|
|
65
|
+
entityType: enum_1.AuditTrailEntityType.DOCUMENT_TEMPLATES,
|
|
66
|
+
documentContent: true,
|
|
67
|
+
});
|
|
68
|
+
this.audit = new audit_1.EntityAuditTrail(supabase, auth, enum_1.AuditTrailEntityType.DOCUMENT_TEMPLATES);
|
|
69
|
+
}
|
|
70
|
+
/** Newest first; `search` matches the name and the category. */
|
|
71
|
+
async list(query) {
|
|
72
|
+
const page = query.page;
|
|
73
|
+
const pageSize = query.page_size;
|
|
74
|
+
const offset = (page - 1) * pageSize;
|
|
75
|
+
const trimmedSearch = query.search?.trim();
|
|
76
|
+
// `order`/`range` return `this`, so the conditional filters below reassign onto the builder.
|
|
77
|
+
let templatesQuery = (0, tenancy_1.scopeToOrganization)(this.supabase
|
|
78
|
+
.from('document_templates')
|
|
79
|
+
.select((0, tenancy_1.withOrganization)(TEMPLATE_LIST_SELECT, this.auth), { count: 'estimated' }), this.auth)
|
|
80
|
+
.eq('archived', query.archive)
|
|
81
|
+
.order('created_at', { ascending: false })
|
|
82
|
+
.range(offset, offset + pageSize - 1);
|
|
83
|
+
if (query.organization_id) {
|
|
84
|
+
templatesQuery = templatesQuery.eq('organization_id', query.organization_id);
|
|
85
|
+
}
|
|
86
|
+
if (query.category) {
|
|
87
|
+
templatesQuery = templatesQuery.eq('category', query.category);
|
|
88
|
+
}
|
|
89
|
+
if (trimmedSearch) {
|
|
90
|
+
templatesQuery = templatesQuery.or((0, transforms_1.orIlike)(['name', 'category'], trimmedSearch));
|
|
91
|
+
}
|
|
92
|
+
const { data, error, count } = await templatesQuery;
|
|
93
|
+
if (error)
|
|
94
|
+
throw (0, errors_1.mapPostgrestError)(error);
|
|
95
|
+
return (0, transforms_1.toMmsList)(data, page, pageSize, count);
|
|
96
|
+
}
|
|
97
|
+
async getById(templateId) {
|
|
98
|
+
const { data, error } = await (0, tenancy_1.scopeToOrganization)(this.supabase.from('document_templates').select((0, tenancy_1.withOrganization)(TEMPLATE_DETAIL_SELECT, this.auth)), this.auth)
|
|
99
|
+
.eq('mms_id', templateId)
|
|
100
|
+
.single();
|
|
101
|
+
if (error)
|
|
102
|
+
throw (0, errors_1.mapPostgrestError)(error);
|
|
103
|
+
return data;
|
|
104
|
+
}
|
|
105
|
+
async create(input) {
|
|
106
|
+
const row = await this.table.insert({
|
|
107
|
+
...templateRow(input),
|
|
108
|
+
// Omitted: the column's default, shared with the organization.
|
|
109
|
+
...(input.visibility && { visibility: input.visibility }),
|
|
110
|
+
organization_id: (0, tenancy_1.requireOrganization)(this.auth),
|
|
111
|
+
created_by: this.auth.mms_id,
|
|
112
|
+
created_at: new Date().toISOString(),
|
|
113
|
+
});
|
|
114
|
+
return this.getById(row.mms_id);
|
|
115
|
+
}
|
|
116
|
+
/** Replaces the whole template. Documents already made from it keep their own copy. */
|
|
117
|
+
async update(templateId, input) {
|
|
118
|
+
await this.table.update(templateId, {
|
|
119
|
+
...templateRow(input),
|
|
120
|
+
last_updated_by: this.auth.mms_id,
|
|
121
|
+
updated_at: new Date().toISOString(),
|
|
122
|
+
});
|
|
123
|
+
return this.getById(templateId);
|
|
124
|
+
}
|
|
125
|
+
async archive(templateId) {
|
|
126
|
+
await this.table.transition(templateId, enum_1.AuditTrailAction.ARCHIVE, (0, transforms_1.archivedPatch)(this.auth.mms_id));
|
|
127
|
+
return this.getById(templateId);
|
|
128
|
+
}
|
|
129
|
+
/** The table keeps no unarchive stamps, so the archive ones are cleared; the history keeps both. */
|
|
130
|
+
async unarchive(templateId) {
|
|
131
|
+
await this.table.transition(templateId, enum_1.AuditTrailAction.UNARCHIVE, {
|
|
132
|
+
archived: false,
|
|
133
|
+
archived_at: null,
|
|
134
|
+
archived_by: null,
|
|
135
|
+
});
|
|
136
|
+
return this.getById(templateId);
|
|
137
|
+
}
|
|
138
|
+
/** Hard delete. Its documents stay, with `template_id` set to null by the foreign key. */
|
|
139
|
+
async remove(templateId) {
|
|
140
|
+
await this.table.remove(templateId);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
exports.DocumentTemplatesResource = DocumentTemplatesResource;
|