thatcher 1.0.70 → 1.0.71
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/package.json +1 -1
- package/src/config/spec-helpers.js +1 -1
- package/src/lib/config-generator-engine.js +38 -1
- package/src/lib/crud-handlers.js +1 -0
- package/src/lib/field-registry.js +3 -1
- package/src/lib/validation/entity-validators.js +50 -1
- package/src/server/server.js +13 -0
- package/src/ui/entity-renderer.js +9 -0
- package/src/ui/page-handler-helpers.js +1 -1
package/package.json
CHANGED
|
@@ -96,7 +96,7 @@ export function getInitialState(spec) {
|
|
|
96
96
|
state[key] = false;
|
|
97
97
|
} else if (field.type === 'int' || field.type === 'decimal' || field.type === 'currency') {
|
|
98
98
|
state[key] = 0;
|
|
99
|
-
} else if (field.type === 'json' || field.type === 'multiselect') {
|
|
99
|
+
} else if (field.type === 'json' || field.type === 'multiselect' || field.type === 'multiref') {
|
|
100
100
|
state[key] = [];
|
|
101
101
|
} else if (field.type === 'file' || field.type === 'attachment') {
|
|
102
102
|
state[key] = null;
|
|
@@ -160,10 +160,47 @@ function withCrmDefaults(masterConfig) {
|
|
|
160
160
|
return changed ? { ...masterConfig, entities, workflows } : masterConfig;
|
|
161
161
|
}
|
|
162
162
|
|
|
163
|
+
const PROJECT_ENTITY_DEFAULT = {
|
|
164
|
+
label: 'Project',
|
|
165
|
+
label_plural: 'Projects',
|
|
166
|
+
system_entity: true,
|
|
167
|
+
fields: {
|
|
168
|
+
name: { type: 'text', required: true, label: 'Name' },
|
|
169
|
+
description: { type: 'textarea', label: 'Description' },
|
|
170
|
+
owner_id: { type: 'ref', ref: 'user', label: 'Owner' },
|
|
171
|
+
status: { type: 'enum', options: ['planning', 'active', 'on_hold', 'completed', 'cancelled'], default: 'planning', label: 'Status' },
|
|
172
|
+
start_date: { type: 'date', label: 'Start Date' },
|
|
173
|
+
due_date: { type: 'date', label: 'Due Date' },
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
const TASK_ENTITY_DEFAULT = {
|
|
178
|
+
label: 'Task',
|
|
179
|
+
label_plural: 'Tasks',
|
|
180
|
+
system_entity: true,
|
|
181
|
+
fields: {
|
|
182
|
+
name: { type: 'text', required: true, label: 'Name' },
|
|
183
|
+
project_id: { type: 'ref', ref: 'project', label: 'Project' },
|
|
184
|
+
assignee_id: { type: 'ref', ref: 'user', label: 'Assignee' },
|
|
185
|
+
status: { type: 'enum', options: ['todo', 'in_progress', 'blocked', 'done'], default: 'todo', label: 'Status' },
|
|
186
|
+
priority: { type: 'enum', options: ['low', 'medium', 'high', 'urgent'], default: 'medium', label: 'Priority' },
|
|
187
|
+
due_date: { type: 'date', label: 'Due Date' },
|
|
188
|
+
depends_on: { type: 'multiref', ref: 'task', label: 'Depends On' },
|
|
189
|
+
},
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
function withProjectDefaults(masterConfig) {
|
|
193
|
+
const entities = { ...(masterConfig.entities || {}) };
|
|
194
|
+
let changed = false;
|
|
195
|
+
if (!entities.project) { entities.project = PROJECT_ENTITY_DEFAULT; changed = true; }
|
|
196
|
+
if (!entities.task) { entities.task = TASK_ENTITY_DEFAULT; changed = true; }
|
|
197
|
+
return changed ? { ...masterConfig, entities } : masterConfig;
|
|
198
|
+
}
|
|
199
|
+
|
|
163
200
|
export class ConfigGeneratorEngine {
|
|
164
201
|
constructor(masterConfig) {
|
|
165
202
|
if (!masterConfig) throw new Error('[ConfigGeneratorEngine] masterConfig is required');
|
|
166
|
-
this.masterConfig = deepFreeze(withCrmDefaults(withWebhookDefaults(withMultiTenancyDefaults(masterConfig))));
|
|
203
|
+
this.masterConfig = deepFreeze(withProjectDefaults(withCrmDefaults(withWebhookDefaults(withMultiTenancyDefaults(masterConfig)))));
|
|
167
204
|
this.specCache = new LRUCache(100);
|
|
168
205
|
this.debugMode = false;
|
|
169
206
|
this._plugins = new Map();
|
package/src/lib/crud-handlers.js
CHANGED
|
@@ -11,6 +11,7 @@ export function coerceFieldValue(value, type) {
|
|
|
11
11
|
return value === true || value === 'true' || value === 1;
|
|
12
12
|
case 'json':
|
|
13
13
|
case 'multiselect':
|
|
14
|
+
case 'multiref':
|
|
14
15
|
case 'file':
|
|
15
16
|
case 'attachment':
|
|
16
17
|
return typeof value === 'string' ? JSON.parse(value) : value;
|
|
@@ -36,12 +37,13 @@ export function deserializeField(value, type) {
|
|
|
36
37
|
switch (type) {
|
|
37
38
|
case 'json':
|
|
38
39
|
case 'multiselect':
|
|
40
|
+
case 'multiref':
|
|
39
41
|
case 'file':
|
|
40
42
|
case 'attachment':
|
|
41
43
|
try {
|
|
42
44
|
return typeof value === 'string' ? JSON.parse(value) : value;
|
|
43
45
|
} catch {
|
|
44
|
-
return type === 'multiselect' ? [] : {};
|
|
46
|
+
return (type === 'multiselect' || type === 'multiref') ? [] : {};
|
|
45
47
|
}
|
|
46
48
|
case 'bool':
|
|
47
49
|
return Boolean(value);
|
|
@@ -76,6 +76,27 @@ export async function validateField(fieldDef, value, options = {}) {
|
|
|
76
76
|
}
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
// Multi-ref: array-of-ids referencing another entity (e.g. task.depends_on
|
|
80
|
+
// referencing other task ids) -- every id must resolve to a real record,
|
|
81
|
+
// the same existence guarantee a single 'ref' field already gets.
|
|
82
|
+
if (fieldDef.type === 'multiref' && fieldDef.ref) {
|
|
83
|
+
const arr = Array.isArray(value) ? value : (typeof value === 'string' ? JSON.parse(value) : []);
|
|
84
|
+
try {
|
|
85
|
+
const { getBy } = await import('@/lib/busybase/store');
|
|
86
|
+
const refTable = fieldDef.ref === 'user' ? 'users' : fieldDef.ref;
|
|
87
|
+
for (const refId of arr) {
|
|
88
|
+
if (!(await getBy(refTable, 'id', refId))) {
|
|
89
|
+
return {
|
|
90
|
+
valid: false,
|
|
91
|
+
error: `${fieldDef.ref.charAt(0).toUpperCase() + fieldDef.ref.slice(1)} with id '${refId}' not found`,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
} catch {
|
|
96
|
+
// Reference table might not exist yet
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
79
100
|
return { valid: true };
|
|
80
101
|
}
|
|
81
102
|
|
|
@@ -94,7 +115,7 @@ function validateType(fieldDef, value, fieldName) {
|
|
|
94
115
|
if (typeof value !== 'boolean') return `Field '${fieldName}' must be a boolean`;
|
|
95
116
|
} else if (type === 'timestamp' || type === 'date') {
|
|
96
117
|
if (isNaN(Number(value))) return `Field '${fieldName}' must be a valid timestamp`;
|
|
97
|
-
} else if (type === 'multiselect') {
|
|
118
|
+
} else if (type === 'multiselect' || type === 'multiref') {
|
|
98
119
|
const arr = Array.isArray(value) ? value : (typeof value === 'string' ? (() => { try { return JSON.parse(value); } catch { return null; } })() : null);
|
|
99
120
|
if (!Array.isArray(arr)) return `Field '${fieldName}' must be an array`;
|
|
100
121
|
} else if (type === 'file' || type === 'attachment') {
|
|
@@ -176,6 +197,31 @@ async function checkUnique(fieldDef, value, { fieldName, entityName, existingRec
|
|
|
176
197
|
return null;
|
|
177
198
|
}
|
|
178
199
|
|
|
200
|
+
// Task completion cannot be a client-side/UI-only rule -- a raw API PATCH
|
|
201
|
+
// setting status=done must be rejected server-side the same as any other
|
|
202
|
+
// write, or dependency ordering is purely cosmetic. depends_on is checked
|
|
203
|
+
// against the CURRENT stored state of each referenced task (never trusting
|
|
204
|
+
// a value the caller might also be trying to change in the same payload).
|
|
205
|
+
async function checkTaskDependencies(entityName, changes, existingRecord) {
|
|
206
|
+
if (entityName !== 'task') return null;
|
|
207
|
+
if (changes.status !== 'done') return null;
|
|
208
|
+
|
|
209
|
+
const dependsOn = changes.depends_on !== undefined ? changes.depends_on : existingRecord?.depends_on;
|
|
210
|
+
const ids = Array.isArray(dependsOn) ? dependsOn : (typeof dependsOn === 'string' && dependsOn ? (() => { try { return JSON.parse(dependsOn); } catch { return []; } })() : []);
|
|
211
|
+
if (!ids.length) return null;
|
|
212
|
+
|
|
213
|
+
const { get } = await import('@/lib/busybase/store');
|
|
214
|
+
const incomplete = [];
|
|
215
|
+
for (const depId of ids) {
|
|
216
|
+
const dep = await get('task', depId);
|
|
217
|
+
if (!dep || dep.status !== 'done') incomplete.push(depId);
|
|
218
|
+
}
|
|
219
|
+
if (incomplete.length) {
|
|
220
|
+
return `Cannot mark done: depends on incomplete task(s) ${incomplete.join(', ')}`;
|
|
221
|
+
}
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
|
|
179
225
|
export async function validateUpdate(entityName, changes, existingRecord) {
|
|
180
226
|
const spec = getSpec(entityName);
|
|
181
227
|
const errors = {};
|
|
@@ -202,6 +248,9 @@ export async function validateUpdate(entityName, changes, existingRecord) {
|
|
|
202
248
|
if (uniqueErr && !errors[fieldName]) errors[fieldName] = uniqueErr;
|
|
203
249
|
}
|
|
204
250
|
|
|
251
|
+
const depErr = await checkTaskDependencies(entityName, changes, existingRecord);
|
|
252
|
+
if (depErr) errors.status = depErr;
|
|
253
|
+
|
|
205
254
|
return errors;
|
|
206
255
|
}
|
|
207
256
|
|
package/src/server/server.js
CHANGED
|
@@ -436,6 +436,19 @@ async function handleGenericCrud(req, res, entity, id, action, thatcher, configE
|
|
|
436
436
|
return;
|
|
437
437
|
}
|
|
438
438
|
permissionService.enforceEditPermissions(user, spec, body);
|
|
439
|
+
{
|
|
440
|
+
// validateUpdate is the authoritative server-side check for both
|
|
441
|
+
// generic field-type constraints AND entity-specific business
|
|
442
|
+
// rules (e.g. task dependency completion) -- this raw-CRUD path
|
|
443
|
+
// never called it at all, so those rules were UI-only until now.
|
|
444
|
+
const { validateUpdate, hasErrors } = await import('../lib/validation/entity-validators.js');
|
|
445
|
+
const updateErrors = await validateUpdate(entity, body, existingForUpdate);
|
|
446
|
+
if (hasErrors(updateErrors)) {
|
|
447
|
+
res.writeHead(400);
|
|
448
|
+
res.end(JSON.stringify({ error: 'Validation failed', errors: updateErrors }));
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
439
452
|
result = await thatcher.update(entity, id, body, user);
|
|
440
453
|
const { logAction: logUpdate } = await import('../lib/busybase/audit.js');
|
|
441
454
|
logUpdate(entity, id, 'update', user.id, existingForUpdate, result);
|
|
@@ -77,6 +77,10 @@ function formatFieldValue(k, v, entityName, f) {
|
|
|
77
77
|
const arr = Array.isArray(v) ? v : (typeof v === 'string' && v ? (() => { try { return JSON.parse(v) } catch { return [] } })() : [])
|
|
78
78
|
return arr.length ? arr.map(x => `<span class="pill pill-neutral" style="margin-right:4px">${esc(x)}</span>`).join('') : '-'
|
|
79
79
|
}
|
|
80
|
+
if (f?.type === 'multiref' && f.ref) {
|
|
81
|
+
const arr = Array.isArray(v) ? v : (typeof v === 'string' && v ? (() => { try { return JSON.parse(v) } catch { return [] } })() : [])
|
|
82
|
+
return arr.length ? arr.map(x => `<a href="/${esc(f.ref)}/${esc(x)}" class="pill pill-neutral" style="margin-right:4px;text-decoration:none">${esc(x)}</a>`).join('') : '-'
|
|
83
|
+
}
|
|
80
84
|
if (f?.type === 'file' || f?.type === 'attachment') {
|
|
81
85
|
const meta = typeof v === 'object' && v ? v : (typeof v === 'string' && v ? (() => { try { return JSON.parse(v) } catch { return null } })() : null)
|
|
82
86
|
return meta?.url ? `<a href="${esc(meta.url)}" class="text-primary hover:underline" target="_blank" rel="noopener">${esc(meta.filename || 'Download')}</a>` : '-'
|
|
@@ -213,6 +217,11 @@ export function renderEntityForm(entityName, item, spec, user, isNew = false, re
|
|
|
213
217
|
const opts = (Array.isArray(f.options) ? f.options : []).map(o => { const ov = typeof o === 'string' ? o : o.value; const ol = typeof o === 'string' ? o : o.label; return `<label style="display:flex;align-items:center;gap:6px;margin:2px 0"><input type="checkbox" name="${k}[]" value="${esc(ov)}" class="checkbox checkbox-primary" ${selected.has(ov)?'checked':''}/><span>${esc(ol)}</span></label>` }).join('')
|
|
214
218
|
return `<div class="form-field full">${lbl(k,f,f.required)}<div id="field-${k}" data-multiselect="${k}">${opts}</div></div>`
|
|
215
219
|
}
|
|
220
|
+
if (f.type === 'multiref' && refOptions[k]) {
|
|
221
|
+
const selected = new Set(Array.isArray(val) ? val : (typeof val === 'string' && val ? (() => { try { return JSON.parse(val) } catch { return [] } })() : []))
|
|
222
|
+
const opts = refOptions[k].filter(o => o.value !== item?.id).map(o => `<label style="display:flex;align-items:center;gap:6px;margin:2px 0"><input type="checkbox" name="${k}[]" value="${esc(o.value)}" class="checkbox checkbox-primary" ${selected.has(o.value)?'checked':''}/><span>${esc(o.label)}</span></label>`).join('') || '<p style="font-size:0.8rem;color:var(--color-text-muted)">No options available</p>'
|
|
223
|
+
return `<div class="form-field full">${lbl(k,f,f.required)}<div id="field-${k}" data-multiselect="${k}">${opts}</div></div>`
|
|
224
|
+
}
|
|
216
225
|
if (f.type === 'currency') {
|
|
217
226
|
const symbol = esc(f.currency_symbol || '$')
|
|
218
227
|
const decimalVal = typeof val === 'number' ? (val / 100).toFixed(2) : val
|
|
@@ -19,7 +19,7 @@ export function resolveEnumOptions(spec) {
|
|
|
19
19
|
|
|
20
20
|
export async function getRefOptions(spec) {
|
|
21
21
|
const refOptions = {};
|
|
22
|
-
for (const [fieldKey, field] of Object.entries(spec.fields || {}).filter(([, f]) => f.type === 'ref' && f.ref)) {
|
|
22
|
+
for (const [fieldKey, field] of Object.entries(spec.fields || {}).filter(([, f]) => (f.type === 'ref' || f.type === 'multiref') && f.ref)) {
|
|
23
23
|
try { refOptions[fieldKey] = (await list(field.ref, {})).map(r => ({ value: r.id, label: r.name || r.title || r.label || r.email || r.id })); }
|
|
24
24
|
catch { refOptions[fieldKey] = []; }
|
|
25
25
|
}
|