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 +1 -1
- package/src/lib/bulk-operations.js +30 -0
- package/src/lib/contract-expiry.js +13 -0
- package/src/lib/presence-tracker.js +52 -0
- package/src/lib/scheduler-engine.js +12 -1
- package/src/server/server.js +77 -0
- package/src/ui/entity-renderer.js +38 -1
- package/src/ui/page-handler.js +2 -3
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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: [] };
|
package/src/server/server.js
CHANGED
|
@@ -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 = []) {
|
package/src/ui/page-handler.js
CHANGED
|
@@ -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
|
|
159
|
-
|
|
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
|