thatcher 1.0.75 → 1.0.77

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.77",
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
+ }
@@ -0,0 +1,52 @@
1
+ // In-memory presence: who is currently viewing entity+id. No persistence --
2
+ // presence is inherently ephemeral, and a restart clearing it is correct
3
+ // behavior, not data loss. Swept on a timer the same way scheduler-engine.js
4
+ // sweeps due jobs, so a closed tab's viewer entry disappears on its own
5
+ // without requiring an explicit "leaving" signal the client might never send.
6
+ const STALE_AFTER_MS = 30 * 1000;
7
+ const SWEEP_INTERVAL_MS = 15 * 1000;
8
+ const presence = new Map();
9
+
10
+ let sweepHandle = null;
11
+ function ensureSweep() {
12
+ if (sweepHandle) return;
13
+ sweepHandle = setInterval(() => {
14
+ const now = Date.now();
15
+ for (const [key, viewers] of presence) {
16
+ for (const [userId, entry] of viewers) {
17
+ if (now - entry.lastSeenAt > STALE_AFTER_MS) viewers.delete(userId);
18
+ }
19
+ if (viewers.size === 0) presence.delete(key);
20
+ }
21
+ }, SWEEP_INTERVAL_MS);
22
+ if (sweepHandle.unref) sweepHandle.unref();
23
+ }
24
+
25
+ function keyFor(entity, id) {
26
+ return `${entity}:${id}`;
27
+ }
28
+
29
+ export function heartbeat(entity, id, userId, userName) {
30
+ ensureSweep();
31
+ const key = keyFor(entity, id);
32
+ let viewers = presence.get(key);
33
+ if (!viewers) { viewers = new Map(); presence.set(key, viewers); }
34
+ viewers.set(userId, { userId, userName, lastSeenAt: Date.now() });
35
+ }
36
+
37
+ // Excludes the requester so a solo viewer never sees themselves listed as
38
+ // "someone else is viewing this record" -- the indicator is meaningless (and
39
+ // mildly alarming) if it counts the person reading it.
40
+ export function getViewers(entity, id, excludeUserId) {
41
+ const key = keyFor(entity, id);
42
+ const viewers = presence.get(key);
43
+ if (!viewers) return [];
44
+ const now = Date.now();
45
+ return [...viewers.values()]
46
+ .filter(v => v.userId !== excludeUserId && now - v.lastSeenAt <= STALE_AFTER_MS)
47
+ .map(v => ({ userId: v.userId, userName: v.userName }));
48
+ }
49
+
50
+ export function resetPresence() {
51
+ presence.clear();
52
+ }
@@ -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: [] };
@@ -119,6 +119,22 @@ export function createServer(options) {
119
119
  const id = parts[1] || null;
120
120
  const action = parts[2] || null;
121
121
 
122
+ // presence/changes are meta-routes over an (entity,id) pair, not
123
+ // themselves entities -- parts[1]/parts[2] here are the TARGET
124
+ // entity name and record id, distinct from the entity/id/action
125
+ // parsed above for the generic CRUD routes.
126
+ if (req.method === 'POST' && entity === 'presence' && parts[1] && parts[2] && parts[3] === 'heartbeat') {
127
+ return await handlePresenceHeartbeat(req, res, parts[1], parts[2]);
128
+ }
129
+
130
+ if (req.method === 'GET' && entity === 'presence' && parts[1] && parts[2] && !parts[3]) {
131
+ return await handlePresenceGet(req, res, parts[1], parts[2]);
132
+ }
133
+
134
+ if (req.method === 'GET' && entity === 'changes' && parts[1] && parts[2] && parts[3] === 'since' && parts[4]) {
135
+ return await handleChangesSince(req, res, parts[1], parts[2], parts[4]);
136
+ }
137
+
122
138
  if (req.method === 'GET' && entity === 'auth' && id === 'google' && !action) {
123
139
  return await handleOAuthGoogleStart(req, res);
124
140
  }
@@ -1427,6 +1443,67 @@ async function readMultipartFile(req) {
1427
1443
  throw new Error('No file field found in upload');
1428
1444
  }
1429
1445
 
1446
+ async function verifyRecordAccess(req, res, entityName, id) {
1447
+ const user = await resolveRequestUser(req);
1448
+ if (!user) {
1449
+ res.writeHead(401, { 'Content-Type': 'application/json' });
1450
+ res.end(JSON.stringify({ error: 'Authentication required' }));
1451
+ return null;
1452
+ }
1453
+ const { get } = await import('../lib/busybase/store.js');
1454
+ let record;
1455
+ try {
1456
+ record = await get(entityName, id, { user });
1457
+ } catch {
1458
+ res.writeHead(404, { 'Content-Type': 'application/json' });
1459
+ res.end(JSON.stringify({ error: 'Not found' }));
1460
+ return null;
1461
+ }
1462
+ if (!record) {
1463
+ // A record the caller cannot access and a record that doesn't exist
1464
+ // resolve identically here on purpose -- distinguishing them would leak
1465
+ // that a specific id exists to someone who isn't allowed to see it.
1466
+ res.writeHead(404, { 'Content-Type': 'application/json' });
1467
+ res.end(JSON.stringify({ error: 'Not found' }));
1468
+ return null;
1469
+ }
1470
+ return user;
1471
+ }
1472
+
1473
+ async function handlePresenceHeartbeat(req, res, entityName, id) {
1474
+ const user = await verifyRecordAccess(req, res, entityName, id);
1475
+ if (!user) return;
1476
+ const { heartbeat } = await import('../lib/presence-tracker.js');
1477
+ heartbeat(entityName, id, user.id, user.name || user.email || user.id);
1478
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1479
+ res.end(JSON.stringify({ ok: true }));
1480
+ }
1481
+
1482
+ async function handlePresenceGet(req, res, entityName, id) {
1483
+ const user = await verifyRecordAccess(req, res, entityName, id);
1484
+ if (!user) return;
1485
+ const { getViewers } = await import('../lib/presence-tracker.js');
1486
+ const viewers = getViewers(entityName, id, user.id);
1487
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1488
+ res.end(JSON.stringify({ viewers }));
1489
+ }
1490
+
1491
+ async function handleChangesSince(req, res, entityName, id, timestampStr) {
1492
+ const user = await verifyRecordAccess(req, res, entityName, id);
1493
+ if (!user) return;
1494
+ const since = Number(timestampStr);
1495
+ if (!Number.isFinite(since)) {
1496
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1497
+ res.end(JSON.stringify({ error: 'Invalid timestamp' }));
1498
+ return;
1499
+ }
1500
+ const { getEntityAuditTrail } = await import('../lib/busybase/audit-reads.js');
1501
+ const trail = await getEntityAuditTrail(entityName, id);
1502
+ const changed = trail.some(entry => (entry.createdAt || 0) > since);
1503
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1504
+ res.end(JSON.stringify({ changed }));
1505
+ }
1506
+
1430
1507
  async function handleFileUpload(req, res, thatcher, configEngineArg) {
1431
1508
  const user = await resolveRequestUser(req);
1432
1509
  if (!user) {
@@ -199,6 +199,13 @@ export function renderEntityDetail(entityName, item, spec, user, history = []) {
199
199
  <div style="flex:1">${headerExtra}</div>
200
200
  <div style="display:flex;gap:0.5rem;margin-left:1rem">${editBtn}${cloneBtn}${delBtn}</div>
201
201
  </div>
202
+ <div id="stale-data-banner" style="display:none;margin-bottom:12px" class="card-clean">
203
+ <div class="card-clean-body" style="padding:10px 16px;display:flex;align-items:center;justify-content:space-between">
204
+ <span style="font-size:13px">This record was updated by someone else -- refresh to see changes.</span>
205
+ <button type="button" class="btn-ghost-clean" onclick="window.location.reload()">Refresh</button>
206
+ </div>
207
+ </div>
208
+ <div id="presence-indicator" style="display:none;margin-bottom:12px;font-size:13px;color:var(--color-text-muted)"></div>
202
209
  <div class="card-clean">
203
210
  <div class="card-clean-body"><div class="detail-grid">${fieldRows || '<p style="color:var(--color-text-muted);font-size:0.875rem;grid-column:1/-1">No details available</p>'}</div></div>
204
211
  </div>
@@ -207,8 +214,38 @@ export function renderEntityDetail(entityName, item, spec, user, history = []) {
207
214
 
208
215
  // Canonical gmConfirm (session-13): showDeleteConfirm runs the styled confirm then DELETEs; no bespoke dialog markup/show-hide.
209
216
  const script = `${TOAST_SCRIPT}window.showDeleteConfirm=async()=>{const ok=await window.gmConfirm({title:'Delete ${entityName}',message:'Delete this ${entityName}? This cannot be undone.',confirmLabel:'Delete',danger:true});if(!ok)return;try{const res=await fetch('/api/${entityName}/${item.id}',{method:'DELETE'});if(res.ok){showToast('Deleted successfully','success');setTimeout(()=>{window.location='/${entityName}'},500)}else{const d=await res.json().catch(()=>({}));showToast(d.message||d.error||'Delete failed','error')}}catch(err){showToast('Error: '+err.message,'error')}}`
217
+ const collabScript = `(function(){
218
+ var entity='${entityName}',id='${esc(String(item.id))}';
219
+ var openedAt=Math.floor(Date.now()/1000);
220
+ function heartbeat(){fetch('/api/presence/'+entity+'/'+id+'/heartbeat',{method:'POST'}).catch(function(){})}
221
+ function pollPresence(){
222
+ fetch('/api/presence/'+entity+'/'+id).then(function(r){return r.json()}).then(function(d){
223
+ var el=document.getElementById('presence-indicator');
224
+ if(!el)return;
225
+ var viewers=d.viewers||[];
226
+ if(viewers.length){
227
+ el.style.display='block';
228
+ el.textContent=(viewers.length===1?'1 other person is':viewers.length+' other people are')+' currently viewing this: '+viewers.map(function(v){return v.userName}).join(', ');
229
+ } else {
230
+ el.style.display='none';
231
+ }
232
+ }).catch(function(){})
233
+ }
234
+ function pollChanges(){
235
+ fetch('/api/changes/'+entity+'/'+id+'/since/'+openedAt).then(function(r){return r.json()}).then(function(d){
236
+ if(d.changed){
237
+ var el=document.getElementById('stale-data-banner');
238
+ if(el)el.style.display='block';
239
+ }
240
+ }).catch(function(){})
241
+ }
242
+ heartbeat();pollPresence();pollChanges();
243
+ setInterval(heartbeat,10000);
244
+ setInterval(pollPresence,10000);
245
+ setInterval(pollChanges,8000);
246
+ })();`
210
247
  const bc = [{ href: '/', label: 'Dashboard' }, { href: `/${entityName}`, label: spec?.labelPlural || label }, { label: item.name || item.title || `#${item.id}` }]
211
- return page(user, `${label} Detail`, bc, content, [script])
248
+ return page(user, `${label} Detail`, bc, content, [script, collabScript])
212
249
  }
213
250
 
214
251
  export function renderEntityForm(entityName, item, spec, user, isNew = false, refOptions = {}, templates = []) {
@@ -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