thatcher 1.0.71 → 1.0.73

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.71",
3
+ "version": "1.0.73",
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",
@@ -197,10 +197,44 @@ function withProjectDefaults(masterConfig) {
197
197
  return changed ? { ...masterConfig, entities } : masterConfig;
198
198
  }
199
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
+
200
234
  export class ConfigGeneratorEngine {
201
235
  constructor(masterConfig) {
202
236
  if (!masterConfig) throw new Error('[ConfigGeneratorEngine] masterConfig is required');
203
- this.masterConfig = deepFreeze(withProjectDefaults(withCrmDefaults(withWebhookDefaults(withMultiTenancyDefaults(masterConfig)))));
237
+ this.masterConfig = deepFreeze(withInventoryDefaults(withProjectDefaults(withCrmDefaults(withWebhookDefaults(withMultiTenancyDefaults(masterConfig))))));
204
238
  this.specCache = new LRUCache(100);
205
239
  this.debugMode = false;
206
240
  this._plugins = new Map();
@@ -152,6 +152,28 @@ function resolveEnumOptions(fieldDef, entityName) {
152
152
  return [];
153
153
  }
154
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
+
155
177
  export async function validateEntity(entityName, data, existingRecord = null) {
156
178
  const spec = getSpec(entityName);
157
179
  const errors = {};
@@ -176,6 +198,9 @@ export async function validateEntity(entityName, data, existingRecord = null) {
176
198
  if (uniqueErr && !errors[fieldName]) errors[fieldName] = uniqueErr;
177
199
  }
178
200
 
201
+ const stockErr = await checkStockBalance(entityName, data);
202
+ if (stockErr) errors.quantity = stockErr;
203
+
179
204
  return errors;
180
205
  }
181
206
 
@@ -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);
@@ -124,9 +124,23 @@ export function renderEntityDetail(entityName, item, spec, user, history = []) {
124
124
  const userCanEdit = canEdit(user, entityName)
125
125
  const userCanDelete = canDelete(user, entityName)
126
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
+
127
141
  const visibleFields = Object.entries(fields).filter(([k]) => k !== 'id' && !HIDDEN_FIELDS.has(k) && item[k] !== undefined)
128
142
 
129
- const fieldRows = visibleFields.map(([k, f]) =>
143
+ const fieldRows = stockRow + visibleFields.map(([k, f]) =>
130
144
  `<div class="detail-row">
131
145
  <span class="detail-row-label">${esc(f.label || k)}</span>
132
146
  <span class="detail-row-value">${formatFieldValue(k, item[k], entityName, f)}</span>
@@ -9,7 +9,7 @@ import { renderEngagementGrid } from '@/ui/engagement-grid-renderer.js';
9
9
  import { renderBoardView } from '@/ui/board-view-renderer.js';
10
10
  import { renderGridView } from '@/ui/grid-view-renderer.js';
11
11
  import { renderCalendarView, renderTimelineView } from '@/ui/calendar-view-renderer.js';
12
- import { renderCountByFieldReport, renderCountOverTimeReport } from '@/ui/report-renderer.js';
12
+ import { renderCountByFieldReport, renderCountOverTimeReport, renderSumByFieldReport, renderRollupReport } from '@/ui/report-renderer.js';
13
13
  import { renderClientProgress } from '@/ui/client-progress-renderer.js';
14
14
  import { renderLetterWorkflow } from '@/ui/letter-workflow-renderer.js';
15
15
  import { renderAdvancedSearch } from '@/ui/advanced-search-renderer.js';
@@ -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
@@ -293,6 +303,27 @@ export async function handlePage(pathname, req, res) {
293
303
  const granularity = params.get('granularity') || 'month';
294
304
  return renderCountOverTimeReport(user, entityName, spec, items, dateField, granularity);
295
305
  }
306
+ if (report === 'sum-by-field') {
307
+ const field = params.get('field') || '';
308
+ const groupBy = params.get('group_by') || '';
309
+ return renderSumByFieldReport(user, entityName, spec, items, field, groupBy);
310
+ }
311
+ if (report === 'rollup') {
312
+ const refField = params.get('ref_field') || '';
313
+ const rollupField = params.get('rollup_field') || '';
314
+ // The rollup groups by a field on the RELATED entity (refField.ref), so
315
+ // that entity's list access must be checked the same way viewing it
316
+ // directly would be -- a cross-entity report must not become a side
317
+ // channel for reading data the user couldn't otherwise view.
318
+ const refFieldDef = spec.fields?.[refField];
319
+ const relatedEntity = refFieldDef?.ref;
320
+ if (!relatedEntity || !canList(user, relatedEntity)) {
321
+ return renderAccessDenied(user, relatedEntity || entityName, 'view');
322
+ }
323
+ const relatedSpec = getSpec(relatedEntity);
324
+ const relatedRecords = await list(relatedEntity, {}, { user });
325
+ return renderRollupReport(user, entityName, spec, items, refField, relatedEntity, relatedSpec, relatedRecords, rollupField);
326
+ }
296
327
  const view = params.get('view');
297
328
  if (view === 'board') return renderBoardView(user, entityName, spec, items);
298
329
  if (view === 'grid') return renderGridView(user, entityName, spec, items);
@@ -89,6 +89,101 @@ export function renderCountByFieldReport(user, entityName, spec, records, field)
89
89
  return page(user, `${label} Report | Thatcher`, null, content);
90
90
  }
91
91
 
92
+ export function sumByField(records, spec, field, groupBy) {
93
+ const sums = new Map();
94
+ for (const r of records) {
95
+ const raw = r[field];
96
+ const num = typeof raw === 'string' ? Number(raw) : raw;
97
+ if (typeof num !== 'number' || isNaN(num)) continue;
98
+ const label = bucketLabel(spec, groupBy, r[groupBy]);
99
+ sums.set(label, (sums.get(label) || 0) + num);
100
+ }
101
+ const sorted = [...sums.entries()].sort((a, b) => b[1] - a[1]);
102
+ if (sorted.length <= MAX_BUCKETS) return sorted;
103
+ const top = sorted.slice(0, MAX_BUCKETS - 1);
104
+ const otherSum = sorted.slice(MAX_BUCKETS - 1).reduce((sum, [, v]) => sum + v, 0);
105
+ return [...top, ['(other)', otherSum]];
106
+ }
107
+
108
+ function formatSumValue(value, fieldDef) {
109
+ if (fieldDef?.type === 'currency') return (fieldDef.currency_symbol || '$') + (value / 100).toFixed(2);
110
+ return String(Math.round(value * 100) / 100);
111
+ }
112
+
113
+ function sumBarChart(buckets, fieldDef) {
114
+ if (!buckets.length) return emptyState('No data to report on', 'bar-chart');
115
+ const max = Math.max(...buckets.map(([, v]) => Math.abs(v)), 1);
116
+ const rows = buckets.map(([label, value]) => {
117
+ const pct = Math.round((Math.abs(value) / max) * 100);
118
+ return `<div class="report-bar-row" style="display:flex;align-items:center;gap:12px;margin-bottom:8px">
119
+ <div style="min-width:140px;max-width:220px;font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(label)}">${esc(label)}</div>
120
+ <div style="flex:1;height:20px;background:var(--color-border,#eee);border-radius:4px;overflow:hidden">
121
+ <div style="height:100%;width:${pct}%;background:var(--color-primary,#3b82f6);border-radius:4px"></div>
122
+ </div>
123
+ <div style="min-width:80px;text-align:right;font-size:13px;font-weight:600">${esc(formatSumValue(value, fieldDef))}</div>
124
+ </div>`;
125
+ }).join('');
126
+ return `<div class="report-bar-chart">${rows}</div>`;
127
+ }
128
+
129
+ export function renderSumByFieldReport(user, entityName, spec, records, field, groupBy) {
130
+ const label = getEntityLabel(spec, true) || entityName;
131
+ const fieldDef = spec.fields?.[field];
132
+ const groupByDef = spec.fields?.[groupBy];
133
+ if (!fieldDef || !groupByDef) {
134
+ return page(user, `${label} | Report`, null,
135
+ `<div class="page-header"><h1 class="page-title">${esc(label)}</h1></div>
136
+ <div class="report-empty-state">Unknown field "${esc(!fieldDef ? field : groupBy)}" for this entity.</div>`);
137
+ }
138
+ const buckets = sumByField(records, spec, field, groupBy);
139
+ const content = `<div class="page-header">
140
+ <div><h1 class="page-title">${esc(label)}: ${esc(fieldDef.label || field)} by ${esc(groupByDef.label || groupBy)}</h1><p class="page-subtitle">${records.length} total records</p></div>
141
+ </div>
142
+ ${sumBarChart(buckets, fieldDef)}`;
143
+ return page(user, `${label} Report | Thatcher`, null, content);
144
+ }
145
+
146
+ // Cross-entity rollup: entity A's records are grouped by a field on related
147
+ // entity B, joined through A's ref field. relatedRecords must already be the
148
+ // CALLER's row/org-scoped list() result for B -- this function only joins
149
+ // and counts, it enforces no access itself (the access decision -- can this
150
+ // user list B at all -- is made by the caller before relatedRecords exists).
151
+ export function rollupByRelatedField(records, refField, relatedRecords, relatedSpec, rollupField) {
152
+ const relatedById = new Map(relatedRecords.map(r => [String(r.id), r]));
153
+ const counts = new Map();
154
+ let unmatched = 0;
155
+ for (const r of records) {
156
+ const refId = r[refField];
157
+ const related = refId != null ? relatedById.get(String(refId)) : null;
158
+ if (!related) { unmatched++; continue; }
159
+ const label = bucketLabel(relatedSpec, rollupField, related[rollupField]);
160
+ counts.set(label, (counts.get(label) || 0) + 1);
161
+ }
162
+ const sorted = [...counts.entries()].sort((a, b) => b[1] - a[1]);
163
+ return { buckets: sorted.length > MAX_BUCKETS ? sorted.slice(0, MAX_BUCKETS) : sorted, unmatched };
164
+ }
165
+
166
+ export function renderRollupReport(user, entityName, spec, records, refField, relatedEntity, relatedSpec, relatedRecords, rollupField) {
167
+ const label = getEntityLabel(spec, true) || entityName;
168
+ const relatedLabel = getEntityLabel(relatedSpec, true) || relatedEntity;
169
+ const rollupFieldDef = relatedSpec?.fields?.[rollupField];
170
+ if (!spec.fields?.[refField] || !rollupFieldDef) {
171
+ return page(user, `${label} | Report`, null,
172
+ `<div class="page-header"><h1 class="page-title">${esc(label)}</h1></div>
173
+ <div class="report-empty-state">Unknown field for this rollup.</div>`);
174
+ }
175
+ const { buckets, unmatched } = rollupByRelatedField(records, refField, relatedRecords, relatedSpec, rollupField);
176
+ const notice = unmatched
177
+ ? `<div class="report-notice">${unmatched} record${unmatched === 1 ? '' : 's'} without a resolvable ${esc(relatedLabel)} excluded</div>`
178
+ : '';
179
+ const content = `<div class="page-header">
180
+ <div><h1 class="page-title">${esc(label)} by ${esc(relatedLabel)}.${esc(rollupFieldDef.label || rollupField)}</h1><p class="page-subtitle">${records.length} total records</p></div>
181
+ </div>
182
+ ${notice}
183
+ ${barChart(buckets)}`;
184
+ return page(user, `${label} Report | Thatcher`, null, content);
185
+ }
186
+
92
187
  export function renderCountOverTimeReport(user, entityName, spec, records, dateField, granularity) {
93
188
  const label = getEntityLabel(spec, true) || entityName;
94
189
  const fieldDef = spec.fields?.[dateField];