thatcher 1.0.76 → 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/presence-tracker.js +52 -0
- package/src/server/server.js +77 -0
- package/src/ui/entity-renderer.js +38 -1
package/package.json
CHANGED
|
@@ -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
|
+
}
|
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 = []) {
|