@canonmsg/backend-contracts 1.4.0 → 1.6.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/dist/cjs/index.js +1 -0
- package/dist/cjs/media.js +15 -4
- package/dist/cjs/runtimeCardFields.js +223 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/media.js +15 -4
- package/dist/runtimeCardFields.d.ts +54 -0
- package/dist/runtimeCardFields.js +219 -0
- package/package.json +1 -1
package/dist/cjs/index.js
CHANGED
|
@@ -16,6 +16,7 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
|
16
16
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
17
|
__exportStar(require("./media.js"), exports);
|
|
18
18
|
__exportStar(require("./message.js"), exports);
|
|
19
|
+
__exportStar(require("./runtimeCardFields.js"), exports);
|
|
19
20
|
__exportStar(require("./runtimeCardStorage.js"), exports);
|
|
20
21
|
__exportStar(require("./turnProtocol.js"), exports);
|
|
21
22
|
__exportStar(require("./agentBehaviorPolicy.js"), exports);
|
package/dist/cjs/media.js
CHANGED
|
@@ -59,6 +59,13 @@ function getStoredFileExtension(input) {
|
|
|
59
59
|
}
|
|
60
60
|
return 'bin';
|
|
61
61
|
}
|
|
62
|
+
// Numeric attachment metadata flows straight into every client's duration and
|
|
63
|
+
// size formatting; NaN/Infinity/negatives are dropped rather than stored.
|
|
64
|
+
function normalizeAttachmentNumber(value) {
|
|
65
|
+
return typeof value === 'number' && Number.isFinite(value) && value >= 0
|
|
66
|
+
? value
|
|
67
|
+
: undefined;
|
|
68
|
+
}
|
|
62
69
|
function normalizeStoredAttachments(value) {
|
|
63
70
|
if (!Array.isArray(value))
|
|
64
71
|
return null;
|
|
@@ -76,15 +83,19 @@ function normalizeStoredAttachments(value) {
|
|
|
76
83
|
if (typeof candidate.url !== 'string' || candidate.url.length === 0) {
|
|
77
84
|
return null;
|
|
78
85
|
}
|
|
86
|
+
const sizeBytes = normalizeAttachmentNumber(candidate.sizeBytes);
|
|
87
|
+
const width = normalizeAttachmentNumber(candidate.width);
|
|
88
|
+
const height = normalizeAttachmentNumber(candidate.height);
|
|
89
|
+
const durationMs = normalizeAttachmentNumber(candidate.durationMs);
|
|
79
90
|
attachments.push({
|
|
80
91
|
kind: candidate.kind,
|
|
81
92
|
url: candidate.url,
|
|
82
93
|
...(typeof candidate.mimeType === 'string' && candidate.mimeType ? { mimeType: candidate.mimeType } : {}),
|
|
83
94
|
...(typeof candidate.fileName === 'string' && candidate.fileName ? { fileName: candidate.fileName } : {}),
|
|
84
|
-
...(
|
|
85
|
-
...(
|
|
86
|
-
...(
|
|
87
|
-
...(
|
|
95
|
+
...(sizeBytes !== undefined ? { sizeBytes } : {}),
|
|
96
|
+
...(width !== undefined ? { width } : {}),
|
|
97
|
+
...(height !== undefined ? { height } : {}),
|
|
98
|
+
...(durationMs !== undefined ? { durationMs } : {}),
|
|
88
99
|
});
|
|
89
100
|
}
|
|
90
101
|
return attachments.length > 0 ? attachments : [];
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.RUNTIME_CARD_LIMITS = void 0;
|
|
4
|
+
exports.validateRuntimeCardFieldValues = validateRuntimeCardFieldValues;
|
|
5
|
+
/**
|
|
6
|
+
* Caps shared across runtime-card normalizers, response validation, and
|
|
7
|
+
* authoring tools. Keep these backend-safe so Functions can import them.
|
|
8
|
+
*/
|
|
9
|
+
exports.RUNTIME_CARD_LIMITS = {
|
|
10
|
+
detailsRows: 32,
|
|
11
|
+
lineItemRows: 50,
|
|
12
|
+
lineItemColumns: 8,
|
|
13
|
+
searchSelectChoices: 100,
|
|
14
|
+
};
|
|
15
|
+
const ISO_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
|
16
|
+
function isRecord(value) {
|
|
17
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
18
|
+
}
|
|
19
|
+
function isValidCalendarDate(value) {
|
|
20
|
+
if (!ISO_DATE_PATTERN.test(value))
|
|
21
|
+
return false;
|
|
22
|
+
const [year, month, day] = value.split('-').map((part) => Number.parseInt(part, 10));
|
|
23
|
+
if (month < 1 || month > 12 || day < 1 || day > 31)
|
|
24
|
+
return false;
|
|
25
|
+
const date = new Date(Date.UTC(year, month - 1, day));
|
|
26
|
+
return date.getUTCFullYear() === year
|
|
27
|
+
&& date.getUTCMonth() === month - 1
|
|
28
|
+
&& date.getUTCDate() === day;
|
|
29
|
+
}
|
|
30
|
+
function fieldChoiceValue(choice) {
|
|
31
|
+
return choice.value ?? choice.label;
|
|
32
|
+
}
|
|
33
|
+
function isMissingScalar(value) {
|
|
34
|
+
if (value === undefined || value === null)
|
|
35
|
+
return true;
|
|
36
|
+
if (typeof value === 'string')
|
|
37
|
+
return value.trim().length === 0;
|
|
38
|
+
if (Array.isArray(value))
|
|
39
|
+
return value.length === 0;
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
function decimalPlaces(value) {
|
|
43
|
+
if (Number.isInteger(value))
|
|
44
|
+
return 0;
|
|
45
|
+
const text = Math.abs(value).toString();
|
|
46
|
+
if (text.includes('e') || text.includes('E')) {
|
|
47
|
+
const match = /e-?(\d+)/i.exec(text);
|
|
48
|
+
return match ? Number.parseInt(match[1], 10) : 0;
|
|
49
|
+
}
|
|
50
|
+
const dot = text.indexOf('.');
|
|
51
|
+
return dot < 0 ? 0 : text.length - dot - 1;
|
|
52
|
+
}
|
|
53
|
+
function alignsToStep(value, step) {
|
|
54
|
+
if (!(step > 0))
|
|
55
|
+
return true;
|
|
56
|
+
const quotient = value / step;
|
|
57
|
+
return Math.abs(quotient - Math.round(quotient)) < 1e-9;
|
|
58
|
+
}
|
|
59
|
+
function numericBound(value) {
|
|
60
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
|
61
|
+
}
|
|
62
|
+
function dateBound(value) {
|
|
63
|
+
return typeof value === 'string' && isValidCalendarDate(value) ? value : undefined;
|
|
64
|
+
}
|
|
65
|
+
function validateScalarValue(spec, value) {
|
|
66
|
+
switch (spec.type) {
|
|
67
|
+
case 'boolean': {
|
|
68
|
+
if (typeof value !== 'boolean')
|
|
69
|
+
return `${spec.label} must be true or false`;
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
case 'multiSelect': {
|
|
73
|
+
if (!Array.isArray(value) || !value.every((entry) => typeof entry === 'string' && entry.trim())) {
|
|
74
|
+
return `${spec.label} must be a list of choices`;
|
|
75
|
+
}
|
|
76
|
+
if (!spec.allowOther) {
|
|
77
|
+
const allowed = new Set((spec.choices ?? []).map(fieldChoiceValue));
|
|
78
|
+
for (const entry of value) {
|
|
79
|
+
if (!allowed.has(entry))
|
|
80
|
+
return `${spec.label} contains an invalid choice`;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
case 'date': {
|
|
86
|
+
if (typeof value !== 'string' || !isValidCalendarDate(value.trim())) {
|
|
87
|
+
return `${spec.label} must be a valid date (YYYY-MM-DD)`;
|
|
88
|
+
}
|
|
89
|
+
const date = value.trim();
|
|
90
|
+
const min = dateBound(spec.min);
|
|
91
|
+
const max = dateBound(spec.max);
|
|
92
|
+
if (min && date < min)
|
|
93
|
+
return `${spec.label} must be on or after ${min}`;
|
|
94
|
+
if (max && date > max)
|
|
95
|
+
return `${spec.label} must be on or before ${max}`;
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
case 'number':
|
|
99
|
+
case 'currency': {
|
|
100
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
101
|
+
return `${spec.label} must be a number`;
|
|
102
|
+
}
|
|
103
|
+
const min = numericBound(spec.min);
|
|
104
|
+
const max = numericBound(spec.max);
|
|
105
|
+
if (min !== undefined && value < min)
|
|
106
|
+
return `${spec.label} must be at least ${min}`;
|
|
107
|
+
if (max !== undefined && value > max)
|
|
108
|
+
return `${spec.label} must be at most ${max}`;
|
|
109
|
+
const precision = spec.type === 'currency'
|
|
110
|
+
? (spec.precision ?? 2)
|
|
111
|
+
: spec.precision;
|
|
112
|
+
if (precision !== undefined && decimalPlaces(value) > precision) {
|
|
113
|
+
return `${spec.label} must have at most ${precision} decimal place${precision === 1 ? '' : 's'}`;
|
|
114
|
+
}
|
|
115
|
+
if (spec.type === 'number' && spec.step !== undefined && !alignsToStep(value, spec.step)) {
|
|
116
|
+
return `${spec.label} must be a multiple of ${spec.step}`;
|
|
117
|
+
}
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
case 'select':
|
|
121
|
+
case 'searchSelect': {
|
|
122
|
+
if (typeof value !== 'string')
|
|
123
|
+
return `${spec.label} must be text`;
|
|
124
|
+
if (!spec.allowOther) {
|
|
125
|
+
const allowed = new Set((spec.choices ?? []).map(fieldChoiceValue));
|
|
126
|
+
if (!allowed.has(value))
|
|
127
|
+
return `${spec.label} contains an invalid choice`;
|
|
128
|
+
}
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
case 'text':
|
|
132
|
+
case 'textarea':
|
|
133
|
+
default: {
|
|
134
|
+
if (typeof value !== 'string')
|
|
135
|
+
return `${spec.label} must be text`;
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
function validateLineItemColumnValue(column, value) {
|
|
141
|
+
return validateScalarValue({
|
|
142
|
+
type: column.type,
|
|
143
|
+
label: column.label,
|
|
144
|
+
choices: column.choices,
|
|
145
|
+
allowOther: column.allowOther,
|
|
146
|
+
min: column.min,
|
|
147
|
+
max: column.max,
|
|
148
|
+
step: column.step,
|
|
149
|
+
precision: column.precision,
|
|
150
|
+
}, value);
|
|
151
|
+
}
|
|
152
|
+
function validateLineItemsValue(field, raw) {
|
|
153
|
+
if (!Array.isArray(raw))
|
|
154
|
+
return `${field.label} must be a list of rows`;
|
|
155
|
+
const columns = field.columns ?? [];
|
|
156
|
+
const minRows = field.minRows;
|
|
157
|
+
const maxRows = field.maxRows !== undefined
|
|
158
|
+
? Math.min(field.maxRows, exports.RUNTIME_CARD_LIMITS.lineItemRows)
|
|
159
|
+
: exports.RUNTIME_CARD_LIMITS.lineItemRows;
|
|
160
|
+
if (raw.length > maxRows)
|
|
161
|
+
return `${field.label} allows at most ${maxRows} rows`;
|
|
162
|
+
if (minRows !== undefined && raw.length < minRows) {
|
|
163
|
+
return `${field.label} requires at least ${minRows} row${minRows === 1 ? '' : 's'}`;
|
|
164
|
+
}
|
|
165
|
+
if (field.required && raw.length === 0)
|
|
166
|
+
return `${field.label} is required`;
|
|
167
|
+
const columnIds = new Set(columns.map((column) => column.id));
|
|
168
|
+
for (let index = 0; index < raw.length; index += 1) {
|
|
169
|
+
const row = raw[index];
|
|
170
|
+
if (!isRecord(row))
|
|
171
|
+
return `${field.label}: row ${index + 1} must be an object`;
|
|
172
|
+
for (const key of Object.keys(row)) {
|
|
173
|
+
if (!columnIds.has(key))
|
|
174
|
+
return `${field.label}: row ${index + 1} has unknown column "${key}"`;
|
|
175
|
+
}
|
|
176
|
+
for (const column of columns) {
|
|
177
|
+
const cell = row[column.id];
|
|
178
|
+
const missing = isMissingScalar(cell);
|
|
179
|
+
if (column.required && missing) {
|
|
180
|
+
return `${field.label}: ${column.label} is required in row ${index + 1}`;
|
|
181
|
+
}
|
|
182
|
+
if (missing)
|
|
183
|
+
continue;
|
|
184
|
+
const error = validateLineItemColumnValue(column, cell);
|
|
185
|
+
if (error)
|
|
186
|
+
return `${field.label}: row ${index + 1}: ${error}`;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
function validateRuntimeCardFieldValues(input) {
|
|
192
|
+
const fields = input.actionFields;
|
|
193
|
+
if (!input.actionId || !fields || fields.length === 0)
|
|
194
|
+
return null;
|
|
195
|
+
const values = input.values ?? {};
|
|
196
|
+
const allowedIds = new Set(fields.map((field) => field.id));
|
|
197
|
+
for (const key of Object.keys(values)) {
|
|
198
|
+
if (!allowedIds.has(key))
|
|
199
|
+
return `Unknown field: ${key}`;
|
|
200
|
+
}
|
|
201
|
+
for (const field of fields) {
|
|
202
|
+
const raw = values[field.id];
|
|
203
|
+
if (field.type === 'lineItems') {
|
|
204
|
+
if (raw === undefined || raw === null) {
|
|
205
|
+
if (field.required)
|
|
206
|
+
return `${field.label} is required`;
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
const error = validateLineItemsValue(field, raw);
|
|
210
|
+
if (error)
|
|
211
|
+
return error;
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
if (field.required && isMissingScalar(raw))
|
|
215
|
+
return `${field.label} is required`;
|
|
216
|
+
if (raw === undefined || raw === null || raw === '')
|
|
217
|
+
continue;
|
|
218
|
+
const error = validateScalarValue(field, raw);
|
|
219
|
+
if (error)
|
|
220
|
+
return error;
|
|
221
|
+
}
|
|
222
|
+
return null;
|
|
223
|
+
}
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
package/dist/media.js
CHANGED
|
@@ -51,6 +51,13 @@ export function getStoredFileExtension(input) {
|
|
|
51
51
|
}
|
|
52
52
|
return 'bin';
|
|
53
53
|
}
|
|
54
|
+
// Numeric attachment metadata flows straight into every client's duration and
|
|
55
|
+
// size formatting; NaN/Infinity/negatives are dropped rather than stored.
|
|
56
|
+
function normalizeAttachmentNumber(value) {
|
|
57
|
+
return typeof value === 'number' && Number.isFinite(value) && value >= 0
|
|
58
|
+
? value
|
|
59
|
+
: undefined;
|
|
60
|
+
}
|
|
54
61
|
export function normalizeStoredAttachments(value) {
|
|
55
62
|
if (!Array.isArray(value))
|
|
56
63
|
return null;
|
|
@@ -68,15 +75,19 @@ export function normalizeStoredAttachments(value) {
|
|
|
68
75
|
if (typeof candidate.url !== 'string' || candidate.url.length === 0) {
|
|
69
76
|
return null;
|
|
70
77
|
}
|
|
78
|
+
const sizeBytes = normalizeAttachmentNumber(candidate.sizeBytes);
|
|
79
|
+
const width = normalizeAttachmentNumber(candidate.width);
|
|
80
|
+
const height = normalizeAttachmentNumber(candidate.height);
|
|
81
|
+
const durationMs = normalizeAttachmentNumber(candidate.durationMs);
|
|
71
82
|
attachments.push({
|
|
72
83
|
kind: candidate.kind,
|
|
73
84
|
url: candidate.url,
|
|
74
85
|
...(typeof candidate.mimeType === 'string' && candidate.mimeType ? { mimeType: candidate.mimeType } : {}),
|
|
75
86
|
...(typeof candidate.fileName === 'string' && candidate.fileName ? { fileName: candidate.fileName } : {}),
|
|
76
|
-
...(
|
|
77
|
-
...(
|
|
78
|
-
...(
|
|
79
|
-
...(
|
|
87
|
+
...(sizeBytes !== undefined ? { sizeBytes } : {}),
|
|
88
|
+
...(width !== undefined ? { width } : {}),
|
|
89
|
+
...(height !== undefined ? { height } : {}),
|
|
90
|
+
...(durationMs !== undefined ? { durationMs } : {}),
|
|
80
91
|
});
|
|
81
92
|
}
|
|
82
93
|
return attachments.length > 0 ? attachments : [];
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
export type RuntimeCardFieldType = 'text' | 'textarea' | 'select' | 'multiSelect' | 'boolean' | 'date' | 'number' | 'currency' | 'searchSelect' | 'lineItems';
|
|
2
|
+
/**
|
|
3
|
+
* Caps shared across runtime-card normalizers, response validation, and
|
|
4
|
+
* authoring tools. Keep these backend-safe so Functions can import them.
|
|
5
|
+
*/
|
|
6
|
+
export declare const RUNTIME_CARD_LIMITS: {
|
|
7
|
+
readonly detailsRows: 32;
|
|
8
|
+
readonly lineItemRows: 50;
|
|
9
|
+
readonly lineItemColumns: 8;
|
|
10
|
+
readonly searchSelectChoices: 100;
|
|
11
|
+
};
|
|
12
|
+
export interface RuntimeCardFieldChoice {
|
|
13
|
+
label: string;
|
|
14
|
+
value?: string;
|
|
15
|
+
description?: string;
|
|
16
|
+
preview?: string;
|
|
17
|
+
}
|
|
18
|
+
export type RuntimeCardLineItemColumnType = 'text' | 'number' | 'currency' | 'date' | 'select';
|
|
19
|
+
export interface RuntimeCardLineItemColumn {
|
|
20
|
+
id: string;
|
|
21
|
+
label: string;
|
|
22
|
+
type: RuntimeCardLineItemColumnType;
|
|
23
|
+
required?: boolean;
|
|
24
|
+
choices?: RuntimeCardFieldChoice[];
|
|
25
|
+
min?: number;
|
|
26
|
+
max?: number;
|
|
27
|
+
step?: number;
|
|
28
|
+
precision?: number;
|
|
29
|
+
currencyCode?: string;
|
|
30
|
+
allowOther?: boolean;
|
|
31
|
+
}
|
|
32
|
+
export interface RuntimeCardField {
|
|
33
|
+
id: string;
|
|
34
|
+
label: string;
|
|
35
|
+
type: RuntimeCardFieldType;
|
|
36
|
+
required?: boolean;
|
|
37
|
+
placeholder?: string;
|
|
38
|
+
choices?: RuntimeCardFieldChoice[];
|
|
39
|
+
allowOther?: boolean;
|
|
40
|
+
sensitive?: boolean;
|
|
41
|
+
min?: number | string;
|
|
42
|
+
max?: number | string;
|
|
43
|
+
step?: number;
|
|
44
|
+
precision?: number;
|
|
45
|
+
currencyCode?: string;
|
|
46
|
+
columns?: RuntimeCardLineItemColumn[];
|
|
47
|
+
minRows?: number;
|
|
48
|
+
maxRows?: number;
|
|
49
|
+
}
|
|
50
|
+
export declare function validateRuntimeCardFieldValues(input: {
|
|
51
|
+
actionId: string | undefined;
|
|
52
|
+
actionFields: ReadonlyArray<RuntimeCardField> | undefined;
|
|
53
|
+
values: Record<string, unknown> | undefined;
|
|
54
|
+
}): string | null;
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Caps shared across runtime-card normalizers, response validation, and
|
|
3
|
+
* authoring tools. Keep these backend-safe so Functions can import them.
|
|
4
|
+
*/
|
|
5
|
+
export const RUNTIME_CARD_LIMITS = {
|
|
6
|
+
detailsRows: 32,
|
|
7
|
+
lineItemRows: 50,
|
|
8
|
+
lineItemColumns: 8,
|
|
9
|
+
searchSelectChoices: 100,
|
|
10
|
+
};
|
|
11
|
+
const ISO_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
|
12
|
+
function isRecord(value) {
|
|
13
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
14
|
+
}
|
|
15
|
+
function isValidCalendarDate(value) {
|
|
16
|
+
if (!ISO_DATE_PATTERN.test(value))
|
|
17
|
+
return false;
|
|
18
|
+
const [year, month, day] = value.split('-').map((part) => Number.parseInt(part, 10));
|
|
19
|
+
if (month < 1 || month > 12 || day < 1 || day > 31)
|
|
20
|
+
return false;
|
|
21
|
+
const date = new Date(Date.UTC(year, month - 1, day));
|
|
22
|
+
return date.getUTCFullYear() === year
|
|
23
|
+
&& date.getUTCMonth() === month - 1
|
|
24
|
+
&& date.getUTCDate() === day;
|
|
25
|
+
}
|
|
26
|
+
function fieldChoiceValue(choice) {
|
|
27
|
+
return choice.value ?? choice.label;
|
|
28
|
+
}
|
|
29
|
+
function isMissingScalar(value) {
|
|
30
|
+
if (value === undefined || value === null)
|
|
31
|
+
return true;
|
|
32
|
+
if (typeof value === 'string')
|
|
33
|
+
return value.trim().length === 0;
|
|
34
|
+
if (Array.isArray(value))
|
|
35
|
+
return value.length === 0;
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
function decimalPlaces(value) {
|
|
39
|
+
if (Number.isInteger(value))
|
|
40
|
+
return 0;
|
|
41
|
+
const text = Math.abs(value).toString();
|
|
42
|
+
if (text.includes('e') || text.includes('E')) {
|
|
43
|
+
const match = /e-?(\d+)/i.exec(text);
|
|
44
|
+
return match ? Number.parseInt(match[1], 10) : 0;
|
|
45
|
+
}
|
|
46
|
+
const dot = text.indexOf('.');
|
|
47
|
+
return dot < 0 ? 0 : text.length - dot - 1;
|
|
48
|
+
}
|
|
49
|
+
function alignsToStep(value, step) {
|
|
50
|
+
if (!(step > 0))
|
|
51
|
+
return true;
|
|
52
|
+
const quotient = value / step;
|
|
53
|
+
return Math.abs(quotient - Math.round(quotient)) < 1e-9;
|
|
54
|
+
}
|
|
55
|
+
function numericBound(value) {
|
|
56
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
|
57
|
+
}
|
|
58
|
+
function dateBound(value) {
|
|
59
|
+
return typeof value === 'string' && isValidCalendarDate(value) ? value : undefined;
|
|
60
|
+
}
|
|
61
|
+
function validateScalarValue(spec, value) {
|
|
62
|
+
switch (spec.type) {
|
|
63
|
+
case 'boolean': {
|
|
64
|
+
if (typeof value !== 'boolean')
|
|
65
|
+
return `${spec.label} must be true or false`;
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
case 'multiSelect': {
|
|
69
|
+
if (!Array.isArray(value) || !value.every((entry) => typeof entry === 'string' && entry.trim())) {
|
|
70
|
+
return `${spec.label} must be a list of choices`;
|
|
71
|
+
}
|
|
72
|
+
if (!spec.allowOther) {
|
|
73
|
+
const allowed = new Set((spec.choices ?? []).map(fieldChoiceValue));
|
|
74
|
+
for (const entry of value) {
|
|
75
|
+
if (!allowed.has(entry))
|
|
76
|
+
return `${spec.label} contains an invalid choice`;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
case 'date': {
|
|
82
|
+
if (typeof value !== 'string' || !isValidCalendarDate(value.trim())) {
|
|
83
|
+
return `${spec.label} must be a valid date (YYYY-MM-DD)`;
|
|
84
|
+
}
|
|
85
|
+
const date = value.trim();
|
|
86
|
+
const min = dateBound(spec.min);
|
|
87
|
+
const max = dateBound(spec.max);
|
|
88
|
+
if (min && date < min)
|
|
89
|
+
return `${spec.label} must be on or after ${min}`;
|
|
90
|
+
if (max && date > max)
|
|
91
|
+
return `${spec.label} must be on or before ${max}`;
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
case 'number':
|
|
95
|
+
case 'currency': {
|
|
96
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
97
|
+
return `${spec.label} must be a number`;
|
|
98
|
+
}
|
|
99
|
+
const min = numericBound(spec.min);
|
|
100
|
+
const max = numericBound(spec.max);
|
|
101
|
+
if (min !== undefined && value < min)
|
|
102
|
+
return `${spec.label} must be at least ${min}`;
|
|
103
|
+
if (max !== undefined && value > max)
|
|
104
|
+
return `${spec.label} must be at most ${max}`;
|
|
105
|
+
const precision = spec.type === 'currency'
|
|
106
|
+
? (spec.precision ?? 2)
|
|
107
|
+
: spec.precision;
|
|
108
|
+
if (precision !== undefined && decimalPlaces(value) > precision) {
|
|
109
|
+
return `${spec.label} must have at most ${precision} decimal place${precision === 1 ? '' : 's'}`;
|
|
110
|
+
}
|
|
111
|
+
if (spec.type === 'number' && spec.step !== undefined && !alignsToStep(value, spec.step)) {
|
|
112
|
+
return `${spec.label} must be a multiple of ${spec.step}`;
|
|
113
|
+
}
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
case 'select':
|
|
117
|
+
case 'searchSelect': {
|
|
118
|
+
if (typeof value !== 'string')
|
|
119
|
+
return `${spec.label} must be text`;
|
|
120
|
+
if (!spec.allowOther) {
|
|
121
|
+
const allowed = new Set((spec.choices ?? []).map(fieldChoiceValue));
|
|
122
|
+
if (!allowed.has(value))
|
|
123
|
+
return `${spec.label} contains an invalid choice`;
|
|
124
|
+
}
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
case 'text':
|
|
128
|
+
case 'textarea':
|
|
129
|
+
default: {
|
|
130
|
+
if (typeof value !== 'string')
|
|
131
|
+
return `${spec.label} must be text`;
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
function validateLineItemColumnValue(column, value) {
|
|
137
|
+
return validateScalarValue({
|
|
138
|
+
type: column.type,
|
|
139
|
+
label: column.label,
|
|
140
|
+
choices: column.choices,
|
|
141
|
+
allowOther: column.allowOther,
|
|
142
|
+
min: column.min,
|
|
143
|
+
max: column.max,
|
|
144
|
+
step: column.step,
|
|
145
|
+
precision: column.precision,
|
|
146
|
+
}, value);
|
|
147
|
+
}
|
|
148
|
+
function validateLineItemsValue(field, raw) {
|
|
149
|
+
if (!Array.isArray(raw))
|
|
150
|
+
return `${field.label} must be a list of rows`;
|
|
151
|
+
const columns = field.columns ?? [];
|
|
152
|
+
const minRows = field.minRows;
|
|
153
|
+
const maxRows = field.maxRows !== undefined
|
|
154
|
+
? Math.min(field.maxRows, RUNTIME_CARD_LIMITS.lineItemRows)
|
|
155
|
+
: RUNTIME_CARD_LIMITS.lineItemRows;
|
|
156
|
+
if (raw.length > maxRows)
|
|
157
|
+
return `${field.label} allows at most ${maxRows} rows`;
|
|
158
|
+
if (minRows !== undefined && raw.length < minRows) {
|
|
159
|
+
return `${field.label} requires at least ${minRows} row${minRows === 1 ? '' : 's'}`;
|
|
160
|
+
}
|
|
161
|
+
if (field.required && raw.length === 0)
|
|
162
|
+
return `${field.label} is required`;
|
|
163
|
+
const columnIds = new Set(columns.map((column) => column.id));
|
|
164
|
+
for (let index = 0; index < raw.length; index += 1) {
|
|
165
|
+
const row = raw[index];
|
|
166
|
+
if (!isRecord(row))
|
|
167
|
+
return `${field.label}: row ${index + 1} must be an object`;
|
|
168
|
+
for (const key of Object.keys(row)) {
|
|
169
|
+
if (!columnIds.has(key))
|
|
170
|
+
return `${field.label}: row ${index + 1} has unknown column "${key}"`;
|
|
171
|
+
}
|
|
172
|
+
for (const column of columns) {
|
|
173
|
+
const cell = row[column.id];
|
|
174
|
+
const missing = isMissingScalar(cell);
|
|
175
|
+
if (column.required && missing) {
|
|
176
|
+
return `${field.label}: ${column.label} is required in row ${index + 1}`;
|
|
177
|
+
}
|
|
178
|
+
if (missing)
|
|
179
|
+
continue;
|
|
180
|
+
const error = validateLineItemColumnValue(column, cell);
|
|
181
|
+
if (error)
|
|
182
|
+
return `${field.label}: row ${index + 1}: ${error}`;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
export function validateRuntimeCardFieldValues(input) {
|
|
188
|
+
const fields = input.actionFields;
|
|
189
|
+
if (!input.actionId || !fields || fields.length === 0)
|
|
190
|
+
return null;
|
|
191
|
+
const values = input.values ?? {};
|
|
192
|
+
const allowedIds = new Set(fields.map((field) => field.id));
|
|
193
|
+
for (const key of Object.keys(values)) {
|
|
194
|
+
if (!allowedIds.has(key))
|
|
195
|
+
return `Unknown field: ${key}`;
|
|
196
|
+
}
|
|
197
|
+
for (const field of fields) {
|
|
198
|
+
const raw = values[field.id];
|
|
199
|
+
if (field.type === 'lineItems') {
|
|
200
|
+
if (raw === undefined || raw === null) {
|
|
201
|
+
if (field.required)
|
|
202
|
+
return `${field.label} is required`;
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
const error = validateLineItemsValue(field, raw);
|
|
206
|
+
if (error)
|
|
207
|
+
return error;
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
if (field.required && isMissingScalar(raw))
|
|
211
|
+
return `${field.label} is required`;
|
|
212
|
+
if (raw === undefined || raw === null || raw === '')
|
|
213
|
+
continue;
|
|
214
|
+
const error = validateScalarValue(field, raw);
|
|
215
|
+
if (error)
|
|
216
|
+
return error;
|
|
217
|
+
}
|
|
218
|
+
return null;
|
|
219
|
+
}
|