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 +1 -1
- package/src/config/spec-helpers.js +1 -1
- package/src/lib/config-generator-engine.js +72 -1
- package/src/lib/crud-handlers.js +1 -0
- package/src/lib/field-registry.js +3 -1
- package/src/lib/validation/entity-validators.js +75 -1
- package/src/server/server.js +26 -0
- package/src/ui/entity-renderer.js +24 -1
- package/src/ui/page-handler-helpers.js +1 -1
- package/src/ui/page-handler.js +11 -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,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();
|
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') {
|
|
@@ -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
|
|
package/src/server/server.js
CHANGED
|
@@ -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
|
}
|
package/src/ui/page-handler.js
CHANGED
|
@@ -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
|
-
|
|
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
|