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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thatcher",
3
- "version": "1.0.70",
3
+ "version": "1.0.71",
4
4
  "description": "A config-driven application framework for building data-intensive web apps without code.",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -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();
@@ -221,6 +221,7 @@ function coerceFieldValue(value, type) {
221
221
  return Boolean(value);
222
222
  case 'json':
223
223
  case 'multiselect':
224
+ case 'multiref':
224
225
  case 'file':
225
226
  case 'attachment':
226
227
  return typeof value === 'string' ? JSON.parse(value) : value;
@@ -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
 
@@ -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
  }