thatcher 1.0.73 → 1.0.75

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.73",
3
+ "version": "1.0.75",
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",
@@ -231,10 +231,71 @@ function withInventoryDefaults(masterConfig) {
231
231
  return changed ? { ...masterConfig, entities } : masterConfig;
232
232
  }
233
233
 
234
+ const TIME_ENTRY_ENTITY_DEFAULT = {
235
+ label: 'Time Entry',
236
+ label_plural: 'Time Entries',
237
+ system_entity: true,
238
+ fields: {
239
+ task_id: { type: 'ref', ref: 'task', required: true, label: 'Task' },
240
+ user_id: { type: 'ref', ref: 'user', required: true, label: 'User' },
241
+ date: { type: 'date', required: true, label: 'Date' },
242
+ hours: { type: 'number', required: true, min: 0, max: 24, label: 'Hours' },
243
+ billable: { type: 'bool', default: true, label: 'Billable' },
244
+ rate: { type: 'currency', label: 'Rate' },
245
+ notes: { type: 'textarea', label: 'Notes' },
246
+ },
247
+ };
248
+
249
+ function withTimeTrackingDefaults(masterConfig) {
250
+ const entities = { ...(masterConfig.entities || {}) };
251
+ let changed = false;
252
+ if (!entities.time_entry) { entities.time_entry = TIME_ENTRY_ENTITY_DEFAULT; changed = true; }
253
+ return changed ? { ...masterConfig, entities } : masterConfig;
254
+ }
255
+
256
+ const CONTRACT_LIFECYCLE_WORKFLOW = {
257
+ state_field: 'status',
258
+ stages: [
259
+ { name: 'draft', label: 'Draft', forward: ['active'], backward: [] },
260
+ { name: 'active', label: 'Active', forward: ['expired', 'renewed', 'terminated'], backward: [] },
261
+ { name: 'expired', label: 'Expired', forward: ['renewed'], backward: [] },
262
+ { name: 'terminated', label: 'Terminated', forward: [], backward: [] },
263
+ { name: 'renewed', label: 'Renewed', forward: [], backward: [] },
264
+ ],
265
+ };
266
+
267
+ const CONTRACT_ENTITY_DEFAULT = {
268
+ label: 'Contract',
269
+ label_plural: 'Contracts',
270
+ system_entity: true,
271
+ workflow: 'contract_lifecycle',
272
+ fields: {
273
+ name: { type: 'text', required: true, label: 'Name' },
274
+ organization_id: { type: 'ref', ref: 'organization', label: 'Organization' },
275
+ opportunity_id: { type: 'ref', ref: 'opportunity', label: 'Opportunity' },
276
+ value: { type: 'currency', label: 'Value' },
277
+ start_date: { type: 'date', required: true, label: 'Start Date' },
278
+ end_date: { type: 'date', required: true, label: 'End Date' },
279
+ status: { type: 'enum', options: ['draft', 'active', 'expired', 'terminated', 'renewed'], default: 'draft', label: 'Status' },
280
+ auto_renew: { type: 'bool', default: false, label: 'Auto Renew' },
281
+ notice_period_days: { type: 'number', min: 0, default: 30, label: 'Notice Period (days)' },
282
+ owner_id: { type: 'ref', ref: 'user', label: 'Owner' },
283
+ },
284
+ };
285
+
286
+ function withContractDefaults(masterConfig) {
287
+ const entities = { ...(masterConfig.entities || {}) };
288
+ const workflows = { ...(masterConfig.workflows || {}) };
289
+ let changed = false;
290
+ if (!entities.contract) { entities.contract = CONTRACT_ENTITY_DEFAULT; changed = true; }
291
+ if (!workflows.contract_lifecycle) { workflows.contract_lifecycle = CONTRACT_LIFECYCLE_WORKFLOW; changed = true; }
292
+ return changed ? { ...masterConfig, entities, workflows } : masterConfig;
293
+ }
294
+
234
295
  export class ConfigGeneratorEngine {
235
296
  constructor(masterConfig) {
236
297
  if (!masterConfig) throw new Error('[ConfigGeneratorEngine] masterConfig is required');
237
- this.masterConfig = deepFreeze(withInventoryDefaults(withProjectDefaults(withCrmDefaults(withWebhookDefaults(withMultiTenancyDefaults(masterConfig))))));
298
+ this.masterConfig = deepFreeze(withContractDefaults(withTimeTrackingDefaults(withInventoryDefaults(withProjectDefaults(withCrmDefaults(withWebhookDefaults(withMultiTenancyDefaults(masterConfig))))))));
238
299
  this.specCache = new LRUCache(100);
239
300
  this.debugMode = false;
240
301
  this._plugins = new Map();
@@ -152,6 +152,22 @@ function resolveEnumOptions(fieldDef, entityName) {
152
152
  return [];
153
153
  }
154
154
 
155
+ // A non-privileged user may only log time against their own user_id -- a
156
+ // clerk POSTing another user's id must be rejected the same way it would be
157
+ // UI-side, but server-side is the authoritative check per this session's SEC
158
+ // pattern. Privileged roles (partner/manager) may log time for anyone, e.g.
159
+ // entering a team member's hours on their behalf.
160
+ function checkTimeEntryOwnership(entityName, data, options) {
161
+ if (entityName !== 'time_entry') return null;
162
+ const actingUser = options?.actingUser;
163
+ if (!actingUser) return null;
164
+ if (['partner', 'admin', 'manager'].includes(actingUser.role)) return null;
165
+ if (data.user_id !== undefined && data.user_id !== actingUser.id) {
166
+ return `Cannot log time for another user`;
167
+ }
168
+ return null;
169
+ }
170
+
155
171
  // Stock movements are immutable history (checked at creation only, never on
156
172
  // edit -- there is no update path for them). An outbound movement (negative
157
173
  // quantity) that would drive the running balance below zero is invalid: the
@@ -174,7 +190,22 @@ async function checkStockBalance(entityName, data) {
174
190
  return null;
175
191
  }
176
192
 
177
- export async function validateEntity(entityName, data, existingRecord = null) {
193
+ // A contract whose end_date is not strictly after its start_date is invalid
194
+ // regardless of what the form happened to allow -- checked wherever either
195
+ // date is present in the payload so it also catches a create that only sets
196
+ // one of the two dates against an existing record's other date on update.
197
+ function checkContractDateOrder(entityName, data, existingRecord) {
198
+ if (entityName !== 'contract') return null;
199
+ const startDate = data.start_date !== undefined ? data.start_date : existingRecord?.start_date;
200
+ const endDate = data.end_date !== undefined ? data.end_date : existingRecord?.end_date;
201
+ if (startDate == null || endDate == null) return null;
202
+ if (Number(endDate) <= Number(startDate)) {
203
+ return `end_date must be after start_date`;
204
+ }
205
+ return null;
206
+ }
207
+
208
+ export async function validateEntity(entityName, data, existingRecord = null, options = {}) {
178
209
  const spec = getSpec(entityName);
179
210
  const errors = {};
180
211
 
@@ -201,6 +232,12 @@ export async function validateEntity(entityName, data, existingRecord = null) {
201
232
  const stockErr = await checkStockBalance(entityName, data);
202
233
  if (stockErr) errors.quantity = stockErr;
203
234
 
235
+ const ownershipErr = checkTimeEntryOwnership(entityName, data, options);
236
+ if (ownershipErr) errors.user_id = ownershipErr;
237
+
238
+ const dateOrderErr = checkContractDateOrder(entityName, data, existingRecord);
239
+ if (dateOrderErr) errors.end_date = dateOrderErr;
240
+
204
241
  return errors;
205
242
  }
206
243
 
@@ -276,6 +313,9 @@ export async function validateUpdate(entityName, changes, existingRecord) {
276
313
  const depErr = await checkTaskDependencies(entityName, changes, existingRecord);
277
314
  if (depErr) errors.status = depErr;
278
315
 
316
+ const dateOrderErr = checkContractDateOrder(entityName, changes, existingRecord);
317
+ if (dateOrderErr) errors.end_date = dateOrderErr;
318
+
279
319
  return errors;
280
320
  }
281
321
 
@@ -417,7 +417,7 @@ async function handleGenericCrud(req, res, entity, id, action, thatcher, configE
417
417
  // existence and entity-specific rules (e.g. stock-movement balance
418
418
  // enforcement below) were silently skipped for a hand-crafted create.
419
419
  const { validateEntity, hasErrors } = await import('../lib/validation/entity-validators.js');
420
- const createErrors = await validateEntity(entity, body, null);
420
+ const createErrors = await validateEntity(entity, body, null, { actingUser: user });
421
421
  if (hasErrors(createErrors)) {
422
422
  res.writeHead(400);
423
423
  res.end(JSON.stringify({ error: 'Validation failed', errors: createErrors }));
@@ -138,9 +138,31 @@ export function renderEntityDetail(entityName, item, spec, user, history = []) {
138
138
  })()
139
139
  : ''
140
140
 
141
+ // total_hours/billable_amount are computed (page-handler.js sums time_entry
142
+ // rows, directly for a task or joined through task for a project), same
143
+ // not-a-spec-field treatment as current_stock above.
144
+ const timeTrackingRows = (entityName === 'task' || entityName === 'project') && typeof item.total_hours === 'number'
145
+ ? `<div class="detail-row"><span class="detail-row-label">Total Hours</span><span class="detail-row-value">${esc(String(item.total_hours))}</span></div>
146
+ <div class="detail-row"><span class="detail-row-label">Billable Amount</span><span class="detail-row-value">${esc('$' + (item.billable_amount / 100).toFixed(2))}</span></div>`
147
+ : ''
148
+
149
+ // days_until_expiry is computed (page-handler.js diffs end_date against
150
+ // now), never a spec.fields entry, same treatment as current_stock above.
151
+ const expiryRow = entityName === 'contract' && typeof item.days_until_expiry === 'number'
152
+ ? (() => {
153
+ const warn = item.status === 'active' && item.days_until_expiry <= (item.notice_period_days ?? 30)
154
+ const pillCls = item.days_until_expiry < 0 ? 'pill-danger' : (warn ? 'pill-warning' : 'pill-success')
155
+ const label = item.days_until_expiry < 0 ? `Expired ${Math.abs(item.days_until_expiry)} days ago` : `${item.days_until_expiry} days`
156
+ return `<div class="detail-row">
157
+ <span class="detail-row-label">Days Until Expiry</span>
158
+ <span class="detail-row-value"><span class="pill ${pillCls}">${esc(label)}${warn ? ' (Renewal Notice)' : ''}</span></span>
159
+ </div>`
160
+ })()
161
+ : ''
162
+
141
163
  const visibleFields = Object.entries(fields).filter(([k]) => k !== 'id' && !HIDDEN_FIELDS.has(k) && item[k] !== undefined)
142
164
 
143
- const fieldRows = stockRow + visibleFields.map(([k, f]) =>
165
+ const fieldRows = stockRow + timeTrackingRows + expiryRow + visibleFields.map(([k, f]) =>
144
166
  `<div class="detail-row">
145
167
  <span class="detail-row-label">${esc(f.label || k)}</span>
146
168
  <span class="detail-row-value">${formatFieldValue(k, item[k], entityName, f)}</span>
@@ -125,6 +125,40 @@ async function handleGenericEntityView(user, entityName, id, req) {
125
125
  resolvedItem = { ...resolvedItem, current_stock: currentStock };
126
126
  } catch { resolvedItem = { ...resolvedItem, current_stock: 0 }; }
127
127
  }
128
+ if (entityName === 'task') {
129
+ // total_hours/billable_amount are derived from time_entry history, same
130
+ // never-a-stored-counter discipline as product.current_stock above.
131
+ try {
132
+ const entries = await list('time_entry', { task_id: id });
133
+ const totalHours = entries.reduce((sum, e) => sum + (Number(e.hours) || 0), 0);
134
+ const billableAmount = entries.filter(e => e.billable).reduce((sum, e) => sum + (Number(e.hours) || 0) * (Number(e.rate) || 0), 0);
135
+ resolvedItem = { ...resolvedItem, total_hours: totalHours, billable_amount: billableAmount };
136
+ } catch { resolvedItem = { ...resolvedItem, total_hours: 0, billable_amount: 0 }; }
137
+ }
138
+ if (entityName === 'project') {
139
+ // Project-level rollup joins through task the same way the rollup report
140
+ // added last pass joins entity A through a ref field to entity B -- no
141
+ // new join logic, just the same id-lookup-then-aggregate shape.
142
+ try {
143
+ const tasks = await list('task', { project_id: id });
144
+ const taskIds = new Set(tasks.map(t => t.id));
145
+ const allEntries = await list('time_entry', {});
146
+ const projectEntries = allEntries.filter(e => taskIds.has(e.task_id));
147
+ const totalHours = projectEntries.reduce((sum, e) => sum + (Number(e.hours) || 0), 0);
148
+ const billableAmount = projectEntries.filter(e => e.billable).reduce((sum, e) => sum + (Number(e.hours) || 0) * (Number(e.rate) || 0), 0);
149
+ resolvedItem = { ...resolvedItem, total_hours: totalHours, billable_amount: billableAmount };
150
+ } catch { resolvedItem = { ...resolvedItem, total_hours: 0, billable_amount: 0 }; }
151
+ }
152
+ if (entityName === 'contract' && resolvedItem.end_date != null) {
153
+ // days_until_expiry is computed from the already-access-checked record
154
+ // (get(...,{user}) above already returned null for a denied/absent
155
+ // contract before this line is ever reached), never a separate query
156
+ // that could leak the value to a caller who never proved they can view
157
+ // this specific contract.
158
+ const nowSeconds = Math.floor(Date.now() / 1000);
159
+ const daysUntilExpiry = Math.floor((Number(resolvedItem.end_date) - nowSeconds) / 86400);
160
+ resolvedItem = { ...resolvedItem, days_until_expiry: daysUntilExpiry };
161
+ }
128
162
  // get(...,{user}) above already enforced row/org access for this exact
129
163
  // record (a denied/absent record returns null before this point), so
130
164
  // fetching its audit trail here is scoped by construction -- there is no