thatcher 1.0.74 → 1.0.76

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.74",
3
+ "version": "1.0.76",
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",
@@ -56,6 +56,33 @@ async function bulkTransitionOne(entityName, id, workflowName, toState, user) {
56
56
  return transition(entityName, id, workflowName, toState, user, 'bulk operation');
57
57
  }
58
58
 
59
+ async function bulkNotifyOne(entityName, spec, id, user, message) {
60
+ // Same permission/row-access gate every other bulk action already applies
61
+ // -- 'notify' reads the record to build the notification, so it must not
62
+ // bypass the checks a plain view of that record would require.
63
+ await requirePermission(user, spec, 'view');
64
+ const existing = await get(entityName, id, { user });
65
+ if (!existing) throw new Error('Not found');
66
+ if (!permissionService.checkRowAccess(user, spec, existing)) throw new Error('Access denied');
67
+
68
+ const recipientId = existing.owner_id;
69
+ if (!recipientId) throw new Error('Record has no owner_id to notify');
70
+
71
+ const { createNotification } = await import('../services/notification-engine.js');
72
+ // createNotification stores user_id as the sole addressee -- the existing
73
+ // /notifications route already filters list('notification',{user_id:...})
74
+ // scoped by {user}, so a notification is only ever visible to the user it
75
+ // names, the same row-access guarantee every other entity gets.
76
+ return createNotification({
77
+ user_id: recipientId,
78
+ title: message || `${spec.label || entityName} update`,
79
+ message: message || `${spec.label || entityName} "${existing.name || id}" requires attention`,
80
+ entity_type: entityName,
81
+ entity_id: id,
82
+ created_by: user?.id || 'system',
83
+ });
84
+ }
85
+
59
86
  export async function runBulkOperation(entityName, spec, ids, action, user) {
60
87
  if (!Array.isArray(ids) || !ids.length) return { ok: false, error: 'ids array required and must be non-empty' };
61
88
  if (ids.length > MAX_BULK_IDS) return { ok: false, error: `Cannot process more than ${MAX_BULK_IDS} ids in one bulk operation` };
@@ -77,6 +104,9 @@ export async function runBulkOperation(entityName, spec, ids, action, user) {
77
104
  if (!action.workflow || !action.toState) throw new Error('action.workflow and action.toState required');
78
105
  await bulkTransitionOne(entityName, id, action.workflow, action.toState, user);
79
106
  results.push({ id, success: true });
107
+ } else if (action.type === 'notify') {
108
+ await bulkNotifyOne(entityName, spec, id, user, action.message);
109
+ results.push({ id, success: true });
80
110
  } else {
81
111
  results.push({ id, success: false, error: `Unknown action type "${action.type}"` });
82
112
  }
@@ -253,10 +253,49 @@ function withTimeTrackingDefaults(masterConfig) {
253
253
  return changed ? { ...masterConfig, entities } : masterConfig;
254
254
  }
255
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
+
256
295
  export class ConfigGeneratorEngine {
257
296
  constructor(masterConfig) {
258
297
  if (!masterConfig) throw new Error('[ConfigGeneratorEngine] masterConfig is required');
259
- this.masterConfig = deepFreeze(withTimeTrackingDefaults(withInventoryDefaults(withProjectDefaults(withCrmDefaults(withWebhookDefaults(withMultiTenancyDefaults(masterConfig)))))));
298
+ this.masterConfig = deepFreeze(withContractDefaults(withTimeTrackingDefaults(withInventoryDefaults(withProjectDefaults(withCrmDefaults(withWebhookDefaults(withMultiTenancyDefaults(masterConfig))))))));
260
299
  this.specCache = new LRUCache(100);
261
300
  this.debugMode = false;
262
301
  this._plugins = new Map();
@@ -0,0 +1,13 @@
1
+ // Single source of truth for contract expiry math, shared between
2
+ // page-handler.js's detail-view computation and scheduler-engine.js's
3
+ // expiry-notification pre-filter -- kept in one place so the two never drift
4
+ // against each other the way a duplicated formula could.
5
+ export function daysUntilExpiry(endDate, nowSeconds = Math.floor(Date.now() / 1000)) {
6
+ return Math.floor((Number(endDate) - nowSeconds) / 86400);
7
+ }
8
+
9
+ export function isWithinNoticeWindow(contract, nowSeconds = Math.floor(Date.now() / 1000)) {
10
+ if (contract.status !== 'active' || contract.end_date == null) return false;
11
+ const days = daysUntilExpiry(contract.end_date, nowSeconds);
12
+ return days <= (contract.notice_period_days ?? 30);
13
+ }
@@ -35,7 +35,18 @@ async function runOneJob(job, nowTs) {
35
35
  // scoping crud-handlers.js's read path enforces for an interactive
36
36
  // request, so a job cannot reach records outside its owner's org just
37
37
  // because it runs unattended.
38
- const targets = await list(job.entity, filter, { user: owner });
38
+ let targets = await list(job.entity, filter, { user: owner });
39
+
40
+ // job.filter can only express stored-field equality (e.g. status:active);
41
+ // "expiring within notice_period_days" is a per-record computed
42
+ // comparison, not a filterable field, so it's checked here in JS against
43
+ // the SAME shared math the contract detail view uses -- never a second,
44
+ // divergent formula.
45
+ if (job.entity === 'contract' && action?.type === 'notify') {
46
+ const { isWithinNoticeWindow } = await import('./contract-expiry.js');
47
+ targets = targets.filter(c => isWithinNoticeWindow(c, nowTs));
48
+ }
49
+
39
50
  const ids = targets.map(r => r.id);
40
51
 
41
52
  let result = { ok: true, total: 0, succeeded: 0, failed: 0, results: [] };
@@ -190,6 +190,21 @@ async function checkStockBalance(entityName, data) {
190
190
  return null;
191
191
  }
192
192
 
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
+
193
208
  export async function validateEntity(entityName, data, existingRecord = null, options = {}) {
194
209
  const spec = getSpec(entityName);
195
210
  const errors = {};
@@ -220,6 +235,9 @@ export async function validateEntity(entityName, data, existingRecord = null, op
220
235
  const ownershipErr = checkTimeEntryOwnership(entityName, data, options);
221
236
  if (ownershipErr) errors.user_id = ownershipErr;
222
237
 
238
+ const dateOrderErr = checkContractDateOrder(entityName, data, existingRecord);
239
+ if (dateOrderErr) errors.end_date = dateOrderErr;
240
+
223
241
  return errors;
224
242
  }
225
243
 
@@ -295,6 +313,9 @@ export async function validateUpdate(entityName, changes, existingRecord) {
295
313
  const depErr = await checkTaskDependencies(entityName, changes, existingRecord);
296
314
  if (depErr) errors.status = depErr;
297
315
 
316
+ const dateOrderErr = checkContractDateOrder(entityName, changes, existingRecord);
317
+ if (dateOrderErr) errors.end_date = dateOrderErr;
318
+
298
319
  return errors;
299
320
  }
300
321
 
@@ -146,9 +146,23 @@ export function renderEntityDetail(entityName, item, spec, user, history = []) {
146
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
147
  : ''
148
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
+
149
163
  const visibleFields = Object.entries(fields).filter(([k]) => k !== 'id' && !HIDDEN_FIELDS.has(k) && item[k] !== undefined)
150
164
 
151
- const fieldRows = stockRow + timeTrackingRows + visibleFields.map(([k, f]) =>
165
+ const fieldRows = stockRow + timeTrackingRows + expiryRow + visibleFields.map(([k, f]) =>
152
166
  `<div class="detail-row">
153
167
  <span class="detail-row-label">${esc(f.label || k)}</span>
154
168
  <span class="detail-row-value">${formatFieldValue(k, item[k], entityName, f)}</span>
@@ -149,6 +149,15 @@ async function handleGenericEntityView(user, entityName, id, req) {
149
149
  resolvedItem = { ...resolvedItem, total_hours: totalHours, billable_amount: billableAmount };
150
150
  } catch { resolvedItem = { ...resolvedItem, total_hours: 0, billable_amount: 0 }; }
151
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 { daysUntilExpiry } = await import('@/lib/contract-expiry.js');
159
+ resolvedItem = { ...resolvedItem, days_until_expiry: daysUntilExpiry(resolvedItem.end_date) };
160
+ }
152
161
  // get(...,{user}) above already enforced row/org access for this exact
153
162
  // record (a denied/absent record returns null before this point), so
154
163
  // fetching its audit trail here is scoped by construction -- there is no