thatcher 1.0.75 → 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.75",
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
  }
@@ -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: [] };
@@ -155,9 +155,8 @@ async function handleGenericEntityView(user, entityName, id, req) {
155
155
  // contract before this line is ever reached), never a separate query
156
156
  // that could leak the value to a caller who never proved they can view
157
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 };
158
+ const { daysUntilExpiry } = await import('@/lib/contract-expiry.js');
159
+ resolvedItem = { ...resolvedItem, days_until_expiry: daysUntilExpiry(resolvedItem.end_date) };
161
160
  }
162
161
  // get(...,{user}) above already enforced row/org access for this exact
163
162
  // record (a denied/absent record returns null before this point), so