thatcher 1.0.70 → 1.0.72

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.72",
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,81 @@ 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
+
200
+ const PRODUCT_ENTITY_DEFAULT = {
201
+ label: 'Product',
202
+ label_plural: 'Products',
203
+ system_entity: true,
204
+ fields: {
205
+ name: { type: 'text', required: true, label: 'Name' },
206
+ sku: { type: 'text', required: true, unique: true, label: 'SKU' },
207
+ description: { type: 'textarea', label: 'Description' },
208
+ unit_price: { type: 'currency', label: 'Unit Price' },
209
+ reorder_threshold: { type: 'number', min: 0, label: 'Reorder Threshold' },
210
+ },
211
+ };
212
+
213
+ const STOCK_MOVEMENT_ENTITY_DEFAULT = {
214
+ label: 'Stock Movement',
215
+ label_plural: 'Stock Movements',
216
+ system_entity: true,
217
+ fields: {
218
+ product_id: { type: 'ref', ref: 'product', required: true, label: 'Product' },
219
+ quantity: { type: 'number', required: true, label: 'Quantity' },
220
+ movement_type: { type: 'enum', options: ['receipt', 'sale', 'adjustment', 'return'], required: true, label: 'Movement Type' },
221
+ reference: { type: 'text', label: 'Reference' },
222
+ notes: { type: 'textarea', label: 'Notes' },
223
+ },
224
+ };
225
+
226
+ function withInventoryDefaults(masterConfig) {
227
+ const entities = { ...(masterConfig.entities || {}) };
228
+ let changed = false;
229
+ if (!entities.product) { entities.product = PRODUCT_ENTITY_DEFAULT; changed = true; }
230
+ if (!entities.stock_movement) { entities.stock_movement = STOCK_MOVEMENT_ENTITY_DEFAULT; changed = true; }
231
+ return changed ? { ...masterConfig, entities } : masterConfig;
232
+ }
233
+
163
234
  export class ConfigGeneratorEngine {
164
235
  constructor(masterConfig) {
165
236
  if (!masterConfig) throw new Error('[ConfigGeneratorEngine] masterConfig is required');
166
- this.masterConfig = deepFreeze(withCrmDefaults(withWebhookDefaults(withMultiTenancyDefaults(masterConfig))));
237
+ this.masterConfig = deepFreeze(withInventoryDefaults(withProjectDefaults(withCrmDefaults(withWebhookDefaults(withMultiTenancyDefaults(masterConfig))))));
167
238
  this.specCache = new LRUCache(100);
168
239
  this.debugMode = false;
169
240
  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') {
@@ -131,6 +152,28 @@ function resolveEnumOptions(fieldDef, entityName) {
131
152
  return [];
132
153
  }
133
154
 
155
+ // Stock movements are immutable history (checked at creation only, never on
156
+ // edit -- there is no update path for them). An outbound movement (negative
157
+ // quantity) that would drive the running balance below zero is invalid: the
158
+ // balance is the sum of every OTHER movement for this product plus the new
159
+ // one, computed fresh from stored data, never trusting a client-supplied
160
+ // "current stock" value that doesn't exist as an editable field.
161
+ async function checkStockBalance(entityName, data) {
162
+ if (entityName !== 'stock_movement') return null;
163
+ const quantity = Number(data.quantity);
164
+ if (!Number.isFinite(quantity) || quantity >= 0) return null;
165
+ if (!data.product_id) return null;
166
+
167
+ const { list } = await import('@/lib/busybase/store');
168
+ const existingMovements = await list('stock_movement', { product_id: data.product_id });
169
+ const currentBalance = existingMovements.reduce((sum, m) => sum + (Number(m.quantity) || 0), 0);
170
+ const resultingBalance = currentBalance + quantity;
171
+ if (resultingBalance < 0) {
172
+ return `Insufficient stock: current balance is ${currentBalance}, this movement would result in ${resultingBalance}`;
173
+ }
174
+ return null;
175
+ }
176
+
134
177
  export async function validateEntity(entityName, data, existingRecord = null) {
135
178
  const spec = getSpec(entityName);
136
179
  const errors = {};
@@ -155,6 +198,9 @@ export async function validateEntity(entityName, data, existingRecord = null) {
155
198
  if (uniqueErr && !errors[fieldName]) errors[fieldName] = uniqueErr;
156
199
  }
157
200
 
201
+ const stockErr = await checkStockBalance(entityName, data);
202
+ if (stockErr) errors.quantity = stockErr;
203
+
158
204
  return errors;
159
205
  }
160
206
 
@@ -176,6 +222,31 @@ async function checkUnique(fieldDef, value, { fieldName, entityName, existingRec
176
222
  return null;
177
223
  }
178
224
 
225
+ // Task completion cannot be a client-side/UI-only rule -- a raw API PATCH
226
+ // setting status=done must be rejected server-side the same as any other
227
+ // write, or dependency ordering is purely cosmetic. depends_on is checked
228
+ // against the CURRENT stored state of each referenced task (never trusting
229
+ // a value the caller might also be trying to change in the same payload).
230
+ async function checkTaskDependencies(entityName, changes, existingRecord) {
231
+ if (entityName !== 'task') return null;
232
+ if (changes.status !== 'done') return null;
233
+
234
+ const dependsOn = changes.depends_on !== undefined ? changes.depends_on : existingRecord?.depends_on;
235
+ const ids = Array.isArray(dependsOn) ? dependsOn : (typeof dependsOn === 'string' && dependsOn ? (() => { try { return JSON.parse(dependsOn); } catch { return []; } })() : []);
236
+ if (!ids.length) return null;
237
+
238
+ const { get } = await import('@/lib/busybase/store');
239
+ const incomplete = [];
240
+ for (const depId of ids) {
241
+ const dep = await get('task', depId);
242
+ if (!dep || dep.status !== 'done') incomplete.push(depId);
243
+ }
244
+ if (incomplete.length) {
245
+ return `Cannot mark done: depends on incomplete task(s) ${incomplete.join(', ')}`;
246
+ }
247
+ return null;
248
+ }
249
+
179
250
  export async function validateUpdate(entityName, changes, existingRecord) {
180
251
  const spec = getSpec(entityName);
181
252
  const errors = {};
@@ -202,6 +273,9 @@ export async function validateUpdate(entityName, changes, existingRecord) {
202
273
  if (uniqueErr && !errors[fieldName]) errors[fieldName] = uniqueErr;
203
274
  }
204
275
 
276
+ const depErr = await checkTaskDependencies(entityName, changes, existingRecord);
277
+ if (depErr) errors.status = depErr;
278
+
205
279
  return errors;
206
280
  }
207
281
 
@@ -411,6 +411,19 @@ async function handleGenericCrud(req, res, entity, id, action, thatcher, configE
411
411
  // editable_by, so a hand-crafted request bypassing the rendered form
412
412
  // cannot smuggle a restricted field in.
413
413
  permissionService.enforceEditPermissions(user, spec, body);
414
+ {
415
+ // Same gap PUT/PATCH had (fixed last pass): this raw-CRUD POST
416
+ // never called any field validation, so required/min-max/enum/ref-
417
+ // existence and entity-specific rules (e.g. stock-movement balance
418
+ // enforcement below) were silently skipped for a hand-crafted create.
419
+ const { validateEntity, hasErrors } = await import('../lib/validation/entity-validators.js');
420
+ const createErrors = await validateEntity(entity, body, null);
421
+ if (hasErrors(createErrors)) {
422
+ res.writeHead(400);
423
+ res.end(JSON.stringify({ error: 'Validation failed', errors: createErrors }));
424
+ return;
425
+ }
426
+ }
414
427
  result = await thatcher.create(entity, body, user);
415
428
  const { logAction } = await import('../lib/busybase/audit.js');
416
429
  logAction(entity, result.id, 'create', user.id, null, result);
@@ -436,6 +449,19 @@ async function handleGenericCrud(req, res, entity, id, action, thatcher, configE
436
449
  return;
437
450
  }
438
451
  permissionService.enforceEditPermissions(user, spec, body);
452
+ {
453
+ // validateUpdate is the authoritative server-side check for both
454
+ // generic field-type constraints AND entity-specific business
455
+ // rules (e.g. task dependency completion) -- this raw-CRUD path
456
+ // never called it at all, so those rules were UI-only until now.
457
+ const { validateUpdate, hasErrors } = await import('../lib/validation/entity-validators.js');
458
+ const updateErrors = await validateUpdate(entity, body, existingForUpdate);
459
+ if (hasErrors(updateErrors)) {
460
+ res.writeHead(400);
461
+ res.end(JSON.stringify({ error: 'Validation failed', errors: updateErrors }));
462
+ return;
463
+ }
464
+ }
439
465
  result = await thatcher.update(entity, id, body, user);
440
466
  const { logAction: logUpdate } = await import('../lib/busybase/audit.js');
441
467
  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>` : '-'
@@ -120,9 +124,23 @@ export function renderEntityDetail(entityName, item, spec, user, history = []) {
120
124
  const userCanEdit = canEdit(user, entityName)
121
125
  const userCanDelete = canDelete(user, entityName)
122
126
 
127
+ // current_stock is computed (page-handler.js sums stock_movement rows),
128
+ // never a spec.fields entry -- rendered as its own row rather than folded
129
+ // into the generic field loop below, which only knows about spec fields.
130
+ const stockRow = entityName === 'product' && typeof item.current_stock === 'number'
131
+ ? (() => {
132
+ const low = item.reorder_threshold != null && item.current_stock <= item.reorder_threshold
133
+ const pillCls = low ? 'pill-danger' : 'pill-success'
134
+ return `<div class="detail-row">
135
+ <span class="detail-row-label">Current Stock</span>
136
+ <span class="detail-row-value"><span class="pill ${pillCls}">${esc(String(item.current_stock))}${low ? ' (Low Stock)' : ''}</span></span>
137
+ </div>`
138
+ })()
139
+ : ''
140
+
123
141
  const visibleFields = Object.entries(fields).filter(([k]) => k !== 'id' && !HIDDEN_FIELDS.has(k) && item[k] !== undefined)
124
142
 
125
- const fieldRows = visibleFields.map(([k, f]) =>
143
+ const fieldRows = stockRow + visibleFields.map(([k, f]) =>
126
144
  `<div class="detail-row">
127
145
  <span class="detail-row-label">${esc(f.label || k)}</span>
128
146
  <span class="detail-row-value">${formatFieldValue(k, item[k], entityName, f)}</span>
@@ -213,6 +231,11 @@ export function renderEntityForm(entityName, item, spec, user, isNew = false, re
213
231
  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
232
  return `<div class="form-field full">${lbl(k,f,f.required)}<div id="field-${k}" data-multiselect="${k}">${opts}</div></div>`
215
233
  }
234
+ if (f.type === 'multiref' && refOptions[k]) {
235
+ const selected = new Set(Array.isArray(val) ? val : (typeof val === 'string' && val ? (() => { try { return JSON.parse(val) } catch { return [] } })() : []))
236
+ 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>'
237
+ return `<div class="form-field full">${lbl(k,f,f.required)}<div id="field-${k}" data-multiselect="${k}">${opts}</div></div>`
238
+ }
216
239
  if (f.type === 'currency') {
217
240
  const symbol = esc(f.currency_symbol || '$')
218
241
  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
  }
@@ -114,7 +114,17 @@ async function handleGenericEntityView(user, entityName, id, req) {
114
114
  const item = await get(entityName, id, { user }); if (!item) return null;
115
115
  if (item.team_id && user.team_id && item.team_id !== user.team_id && !isPartner(user)) return renderAccessDenied(user, entityName, 'view');
116
116
  if (isClientUser(user) && user.client_id && item.client_id && item.client_id !== user.client_id) return renderAccessDenied(user, entityName, 'view');
117
- const [resolvedItem] = resolveRefFields([item], spec);
117
+ let [resolvedItem] = resolveRefFields([item], spec);
118
+ if (entityName === 'product') {
119
+ // current_stock is derived, never a stored/editable field -- summed fresh
120
+ // from stock_movement history so it can never drift from the ledger the
121
+ // way a separately-writable counter could.
122
+ try {
123
+ const movements = await list('stock_movement', { product_id: id });
124
+ const currentStock = movements.reduce((sum, m) => sum + (Number(m.quantity) || 0), 0);
125
+ resolvedItem = { ...resolvedItem, current_stock: currentStock };
126
+ } catch { resolvedItem = { ...resolvedItem, current_stock: 0 }; }
127
+ }
118
128
  // get(...,{user}) above already enforced row/org access for this exact
119
129
  // record (a denied/absent record returns null before this point), so
120
130
  // fetching its audit trail here is scoped by construction -- there is no