|
| 1 | +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * Record Validator |
| 5 | + * |
| 6 | + * Validates an incoming insert/update payload against the canonical |
| 7 | + * `Field` metadata of an `ObjectSchema`. Implements ROADMAP §M10.4 — |
| 8 | + * "Zod-at-rest" — but does not require constructing a Zod schema: |
| 9 | + * we walk the field map directly, which is both faster and lets us |
| 10 | + * produce per-field error envelopes shaped for REST consumption. |
| 11 | + * |
| 12 | + * Rules applied (in order, stop at first error per field): |
| 13 | + * |
| 14 | + * - `required` missing/null/empty-string is rejected (insert only; |
| 15 | + * PATCH validates only fields actually supplied) |
| 16 | + * - `maxLength` / `minLength` (text/textarea/email/url/phone/password) |
| 17 | + * - `min` / `max` (number/currency/percent/rating/slider) |
| 18 | + * - format email / url / phone (lightweight RFC-aware regex) |
| 19 | + * - select / multiselect: value must appear in `options` |
| 20 | + * - boolean / toggle: must coerce to boolean |
| 21 | + * - date / datetime: must be ISO-parsable |
| 22 | + * |
| 23 | + * System-injected fields (`id`, `created_at`, `created_by`, |
| 24 | + * `updated_at`, `updated_by`, `organization_id`) are never validated |
| 25 | + * here — the engine and the audit plugin manage them. |
| 26 | + * |
| 27 | + * On failure, a `ValidationError` is thrown with `.fields[]` holding |
| 28 | + * one entry per offending field. REST translates this into a |
| 29 | + * `400 { code: 'VALIDATION_FAILED', message, fields }` envelope so |
| 30 | + * the UI can highlight the specific input. |
| 31 | + */ |
| 32 | + |
| 33 | +const SKIP_FIELDS = new Set<string>([ |
| 34 | + 'id', 'created_at', 'created_by', 'updated_at', 'updated_by', |
| 35 | + 'organization_id', 'tenant_id', |
| 36 | +]); |
| 37 | + |
| 38 | +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; |
| 39 | +const URL_RE = /^https?:\/\/[^\s]+$/i; |
| 40 | +const PHONE_RE = /^[+()\-\s\d.]{5,}$/; |
| 41 | + |
| 42 | +export interface FieldValidationError { |
| 43 | + field: string; |
| 44 | + code: |
| 45 | + | 'required' |
| 46 | + | 'min_length' |
| 47 | + | 'max_length' |
| 48 | + | 'min_value' |
| 49 | + | 'max_value' |
| 50 | + | 'invalid_email' |
| 51 | + | 'invalid_url' |
| 52 | + | 'invalid_phone' |
| 53 | + | 'invalid_number' |
| 54 | + | 'invalid_boolean' |
| 55 | + | 'invalid_date' |
| 56 | + | 'invalid_option'; |
| 57 | + message: string; |
| 58 | + /** Allowed values for select/multiselect, when applicable. */ |
| 59 | + options?: string[]; |
| 60 | +} |
| 61 | + |
| 62 | +export class ValidationError extends Error { |
| 63 | + readonly code = 'VALIDATION_FAILED'; |
| 64 | + readonly fields: FieldValidationError[]; |
| 65 | + constructor(fields: FieldValidationError[]) { |
| 66 | + super( |
| 67 | + `Validation failed for ${fields.length} field(s): ` + |
| 68 | + fields.map((f) => `${f.field} (${f.code})`).join(', '), |
| 69 | + ); |
| 70 | + this.name = 'ValidationError'; |
| 71 | + this.fields = fields; |
| 72 | + } |
| 73 | +} |
| 74 | + |
| 75 | +type Mode = 'insert' | 'update'; |
| 76 | + |
| 77 | +interface FieldDef { |
| 78 | + name?: string; |
| 79 | + type: string; |
| 80 | + required?: boolean; |
| 81 | + readonly?: boolean; |
| 82 | + system?: boolean; |
| 83 | + multiple?: boolean; |
| 84 | + maxLength?: number; |
| 85 | + minLength?: number; |
| 86 | + min?: number; |
| 87 | + max?: number; |
| 88 | + options?: Array<{ value: string | number; label?: string } | string | number>; |
| 89 | +} |
| 90 | + |
| 91 | +function isMissing(v: unknown): boolean { |
| 92 | + return v === undefined || v === null || (typeof v === 'string' && v.trim() === ''); |
| 93 | +} |
| 94 | + |
| 95 | +function optionValues(options: FieldDef['options']): string[] { |
| 96 | + if (!Array.isArray(options)) return []; |
| 97 | + return options.map((o) => |
| 98 | + typeof o === 'object' && o !== null ? String((o as any).value) : String(o), |
| 99 | + ); |
| 100 | +} |
| 101 | + |
| 102 | +function validateOne(name: string, def: FieldDef, value: unknown): FieldValidationError | null { |
| 103 | + // ── required ──────────────────────────────────────────────────── |
| 104 | + if (def.required && isMissing(value)) { |
| 105 | + return { field: name, code: 'required', message: `${name} is required` }; |
| 106 | + } |
| 107 | + if (isMissing(value)) return null; // nothing else to check |
| 108 | + |
| 109 | + const t = def.type; |
| 110 | + |
| 111 | + // ── string types ──────────────────────────────────────────────── |
| 112 | + if (t === 'text' || t === 'textarea' || t === 'email' || t === 'url' || t === 'phone' || t === 'password' || t === 'markdown' || t === 'html' || t === 'richtext' || t === 'code') { |
| 113 | + const s = typeof value === 'string' ? value : String(value); |
| 114 | + if (def.maxLength !== undefined && s.length > def.maxLength) { |
| 115 | + return { field: name, code: 'max_length', message: `${name} must be ≤ ${def.maxLength} characters (got ${s.length})` }; |
| 116 | + } |
| 117 | + if (def.minLength !== undefined && s.length < def.minLength) { |
| 118 | + return { field: name, code: 'min_length', message: `${name} must be ≥ ${def.minLength} characters (got ${s.length})` }; |
| 119 | + } |
| 120 | + if (t === 'email' && !EMAIL_RE.test(s)) { |
| 121 | + return { field: name, code: 'invalid_email', message: `${name} must be a valid email address` }; |
| 122 | + } |
| 123 | + if (t === 'url' && !URL_RE.test(s)) { |
| 124 | + return { field: name, code: 'invalid_url', message: `${name} must be a valid URL (http:// or https://)` }; |
| 125 | + } |
| 126 | + if (t === 'phone' && !PHONE_RE.test(s)) { |
| 127 | + return { field: name, code: 'invalid_phone', message: `${name} must be a valid phone number` }; |
| 128 | + } |
| 129 | + return null; |
| 130 | + } |
| 131 | + |
| 132 | + // ── number types ──────────────────────────────────────────────── |
| 133 | + if (t === 'number' || t === 'currency' || t === 'percent' || t === 'rating' || t === 'slider') { |
| 134 | + const n = typeof value === 'number' ? value : Number(value); |
| 135 | + if (!Number.isFinite(n)) { |
| 136 | + return { field: name, code: 'invalid_number', message: `${name} must be a number` }; |
| 137 | + } |
| 138 | + if (def.min !== undefined && n < def.min) { |
| 139 | + return { field: name, code: 'min_value', message: `${name} must be ≥ ${def.min}` }; |
| 140 | + } |
| 141 | + if (def.max !== undefined && n > def.max) { |
| 142 | + return { field: name, code: 'max_value', message: `${name} must be ≤ ${def.max}` }; |
| 143 | + } |
| 144 | + return null; |
| 145 | + } |
| 146 | + |
| 147 | + // ── boolean ──────────────────────────────────────────────────── |
| 148 | + if (t === 'boolean' || t === 'toggle') { |
| 149 | + if (typeof value === 'boolean') return null; |
| 150 | + if (value === 0 || value === 1 || value === '0' || value === '1' || value === 'true' || value === 'false') return null; |
| 151 | + return { field: name, code: 'invalid_boolean', message: `${name} must be true or false` }; |
| 152 | + } |
| 153 | + |
| 154 | + // ── date/datetime ─────────────────────────────────────────────── |
| 155 | + if (t === 'date' || t === 'datetime' || t === 'time') { |
| 156 | + if (value instanceof Date) return null; |
| 157 | + if (typeof value === 'string' && !Number.isNaN(Date.parse(value))) return null; |
| 158 | + return { field: name, code: 'invalid_date', message: `${name} must be a valid ${t} (ISO-8601)` }; |
| 159 | + } |
| 160 | + |
| 161 | + // ── select / multiselect / radio ──────────────────────────────── |
| 162 | + if (t === 'select' || t === 'radio') { |
| 163 | + const allowed = optionValues(def.options); |
| 164 | + if (allowed.length > 0 && !allowed.includes(String(value))) { |
| 165 | + return { field: name, code: 'invalid_option', message: `${name} must be one of: ${allowed.join(', ')}`, options: allowed }; |
| 166 | + } |
| 167 | + return null; |
| 168 | + } |
| 169 | + if (t === 'multiselect' || t === 'checkboxes' || t === 'tags') { |
| 170 | + const allowed = optionValues(def.options); |
| 171 | + if (allowed.length === 0) return null; |
| 172 | + const arr = Array.isArray(value) ? value : [value]; |
| 173 | + for (const v of arr) { |
| 174 | + if (!allowed.includes(String(v))) { |
| 175 | + return { field: name, code: 'invalid_option', message: `${name}: "${v}" is not one of: ${allowed.join(', ')}`, options: allowed }; |
| 176 | + } |
| 177 | + } |
| 178 | + return null; |
| 179 | + } |
| 180 | + |
| 181 | + // Other types (lookup, file, formula, json, location, etc.) — no |
| 182 | + // strict shape check at this layer; reference integrity is handled |
| 183 | + // elsewhere (lookup) and the rest are opaque payloads. |
| 184 | + return null; |
| 185 | +} |
| 186 | + |
| 187 | +/** |
| 188 | + * Validate a payload against a list of declared fields. `objectSchema` |
| 189 | + * comes from `ObjectQL.getRegistry().getObject(name)` and exposes a |
| 190 | + * `fields` map of `{ [fieldName]: FieldDef }`. |
| 191 | + * |
| 192 | + * Returns void on success; throws `ValidationError` on failure. |
| 193 | + */ |
| 194 | +export function validateRecord( |
| 195 | + objectSchema: { fields?: Record<string, FieldDef> } | undefined | null, |
| 196 | + data: Record<string, unknown> | undefined | null, |
| 197 | + mode: Mode, |
| 198 | +): void { |
| 199 | + if (!objectSchema?.fields || !data) return; |
| 200 | + |
| 201 | + const errors: FieldValidationError[] = []; |
| 202 | + const fields = objectSchema.fields; |
| 203 | + |
| 204 | + if (mode === 'insert') { |
| 205 | + // Walk all declared fields — required check applies even when |
| 206 | + // the caller didn't supply the field at all. |
| 207 | + for (const [name, def] of Object.entries(fields)) { |
| 208 | + if (SKIP_FIELDS.has(name)) continue; |
| 209 | + if (def.system || def.readonly) continue; |
| 210 | + const err = validateOne(name, def, data[name]); |
| 211 | + if (err) errors.push(err); |
| 212 | + } |
| 213 | + } else { |
| 214 | + // Update — validate only supplied fields, skip required check. |
| 215 | + for (const [name, value] of Object.entries(data)) { |
| 216 | + if (SKIP_FIELDS.has(name)) continue; |
| 217 | + const def = fields[name]; |
| 218 | + if (!def) continue; |
| 219 | + if (def.system || def.readonly) continue; |
| 220 | + // Clone def with required=false so PATCH-omitted-fields don't 400. |
| 221 | + const err = validateOne(name, { ...def, required: false }, value); |
| 222 | + if (err) errors.push(err); |
| 223 | + } |
| 224 | + } |
| 225 | + |
| 226 | + if (errors.length > 0) throw new ValidationError(errors); |
| 227 | +} |
0 commit comments