Compare commits

...

3 Commits

Author SHA1 Message Date
Sravan e67a783bdc feat(db): Postgres backend + dialect-portable queries + data migration (Phase 5)
- db/pg.js: the pg backend (prepare/exec/tx/init) — ?→$N translation, BIGINT parsed
  as Number (matches sqlite; else expires_at<Date.now() compares string<number),
  transactions on one pooled client, init() applies schema.pg.sql. Same interface as
  db/sqlite.js, so repos are unchanged.
- repos.js: the ~7 SQLite-only queries rewritten to run on BOTH engines —
  audit.add @named→positional; email lookups COLLATE NOCASE→LOWER()=LOWER();
  INSERT OR IGNORE→ON CONFLICT DO NOTHING (addMember/poll vote/favorite);
  mergeInto's UPDATE OR IGNORE→UPDATE…WHERE NOT EXISTS/NOT IN and INSERT OR
  REPLACE→ON CONFLICT DO UPDATE. Re-validated on sqlite: db-smoke still 22/22.
- server.js: boot now `await db.init()` before listening (pg creates tables; sqlite
  no-op), so the first request can't hit a missing table.
- db/migrate-sqlite-to-pg.js: one-shot row copy in FK order (bulk insert, TRUNCATE
  first so re-runnable). audit_log id left to PG's identity.
- package.json: add pg ^8.13.1.

Next: validate DB_BACKEND=pg smoke against a real Postgres on the server, then merge.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 22:28:04 +05:30
Sravan 3250530596 feat(db): complete async call-site conversion — Phase 3 done, validated on SQLite
The full sync→async conversion is complete and green on the SQLite backend. Every
DB call across the app now awaits the async adapter, so the identical code runs on
Postgres at cutover.

Converted (this commit finishes Phase 3):
- session.js: currentUser/apiKeyFromReq async → 63 route awaits + WS + static.
- routes.js: all ~250 R.* awaited; DTO helpers (namesFor, avatarsFor, buildMsgDTO,
  buildPollDTO, reactionsForMessage, postSystemMessage, pushGroupUpdate,
  issueRefreshToken, provisionFromBizgaze) made async; every `.map(x=>buildDTO(x))`
  restructured to `await Promise.all(...map(async...))` preserving order; `.filter`
  predicates that hit the DB moved to an `asyncFilter` helper; chained
  `R.x.y(...).length/.map/.filter` wrapped as `(await R.x.y(...)).method`; stream
  upload handlers (recording/transcript/attachment) made async.
- calls.js / signaling.js: all call/meeting fns async; leaveMeeting AWAITS
  persistCallHistory + finalizeTranscript BEFORE endCallByRoom (ordering matters —
  fire-and-forget would race the map teardown); WS handle()/cleanup() async with
  .catch guards.
- static.js: authAttachment(Raw) async (the .some carrier check became a loop),
  handleGet async; server.js dispatch catches handler rejections → 500 not a hang.
- media.js backfill, push.js, reminders.js, webhooks.js await their repo calls.

Validation on DB_BACKEND=sqlite: db-smoke 22/22; legacy e2e 80 checks pass with zero
FAILs (throws only at a PRE-EXISTING WS lobby-drift assertion, unrelated). Every
server file `node --check` clean.

Still on the branch — master untouched. Next: Phase 5 (pg backend + ~7 dialect
queries + data migration + Docker Postgres + cutover), then merge.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 22:06:27 +05:30
Sravan 2460c0f9eb wip(db): async call-site conversion in progress (Phase 3) — DO NOT MERGE yet
On the db-migration branch only; master stays clean + deployable. Foundation
(adapter, pg schema, smoke harness) is already on master and safe.

Done:
- repos.js fully async (Phase 2, validated: node --check clean, no missed transforms).
- session.js currentUser/apiKeyFromReq async.
- Mechanical `await` prefix applied across routes/static/calls/signaling/reminders/
  webhooks/push.

Remaining (does NOT compile yet — deterministic to finish):
1. Async cascade: helper fns that now contain `await` must be marked async and their
   callers awaited. node --check points to each (namesFor, authAttachmentRaw/
   authAttachment in static, the WS handlers in calls/signaling, reminders/webhooks
   loops).
2. DTO builders are the real work: namesFor, avatarsFor, buildPollDTO, buildMsgDTO,
   recDTO all became async — every `.map(x => buildMsgDTO(...))` etc. must become
   `await Promise.all(arr.map(async x => ...))`.
3. Chained calls `R.x.y(...).map/.length/.includes` → `(await R.x.y(...)).method`.
4. Then: node --check all green → node test/db-smoke.js green → e2e → merge to master.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 21:26:10 +05:30
14 changed files with 736 additions and 596 deletions
+30 -30
View File
@@ -11,14 +11,14 @@ const now = () => Date.now();
const pairKey = (a, b) => [a, b].sort().join('|'); const pairKey = (a, b) => [a, b].sort().join('|');
// Resolve a room's meeting context (group / scheduled meeting / title) for labelling recordings. // Resolve a room's meeting context (group / scheduled meeting / title) for labelling recordings.
function meetingContext(room) { async function meetingContext(room) {
const ctx = { groupId: null, meetingId: null, title: 'Meeting' }; const ctx = { groupId: null, meetingId: null, title: 'Meeting' };
try { try {
const sched = R.scheduledMeetings.byCode(room); const sched = await R.scheduledMeetings.byCode(room);
if (sched) { ctx.meetingId = sched.id; ctx.groupId = sched.group_id || null; ctx.title = sched.title || 'Meeting'; } if (sched) { ctx.meetingId = sched.id; ctx.groupId = sched.group_id || null; ctx.title = sched.title || 'Meeting'; }
} catch (_) {} } catch (_) {}
if (!ctx.groupId) { const gid = roomToGroupCall.get(room); if (gid) ctx.groupId = gid; } if (!ctx.groupId) { const gid = roomToGroupCall.get(room); if (gid) ctx.groupId = gid; }
if (ctx.groupId && ctx.title === 'Meeting') { try { const g = R.conversations.byId(ctx.groupId); if (g) ctx.title = g.name || 'Group'; } catch (_) {} } if (ctx.groupId && ctx.title === 'Meeting') { try { const g = await R.conversations.byId(ctx.groupId); if (g) ctx.title = g.name || 'Group'; } catch (_) {} }
if (!ctx.groupId && !ctx.meetingId && roomToDmCall.has(room)) ctx.title = 'Direct Call'; if (!ctx.groupId && !ctx.meetingId && roomToDmCall.has(room)) ctx.title = 'Direct Call';
return ctx; return ctx;
} }
@@ -26,21 +26,21 @@ function meetingContext(room) {
// Save the FULL shared conversation transcript as a PRIVATE copy for each subscriber. onlyUserId // Save the FULL shared conversation transcript as a PRIVATE copy for each subscriber. onlyUserId
// finalizes just that subscriber (on their leave / opt-out); omit to flush all remaining (room end). // finalizes just that subscriber (on their leave / opt-out); omit to flush all remaining (room end).
// Must run BEFORE endCallByRoom (which clears the room→meeting maps meetingContext relies on). // Must run BEFORE endCallByRoom (which clears the room→meeting maps meetingContext relies on).
function finalizeTranscript(room, onlyUserId) { async function finalizeTranscript(room, onlyUserId) {
const subs = transcriptSubs.get(room); if (!subs || !subs.size) { if (!onlyUserId) { transcriptBuffers.delete(room); transcriptSubs.delete(room); } return; } const subs = transcriptSubs.get(room); if (!subs || !subs.size) { if (!onlyUserId) { transcriptBuffers.delete(room); transcriptSubs.delete(room); } return; }
const buf = transcriptBuffers.get(room) || []; const buf = transcriptBuffers.get(room) || [];
const ids = onlyUserId ? (subs.has(onlyUserId) ? [onlyUserId] : []) : [...subs]; const ids = onlyUserId ? (subs.has(onlyUserId) ? [onlyUserId] : []) : [...subs];
if (ids.length && buf.length) { if (ids.length && buf.length) {
const ctx = meetingContext(room); const ctx = await meetingContext(room);
const lines = buf.map((s) => { const ts = new Date(s.t); const hh = String(ts.getHours()).padStart(2, '0'), mm = String(ts.getMinutes()).padStart(2, '0'); return '[' + hh + ':' + mm + '] ' + s.speaker + ': ' + s.text; }); const lines = buf.map((s) => { const ts = new Date(s.t); const hh = String(ts.getHours()).padStart(2, '0'), mm = String(ts.getMinutes()).padStart(2, '0'); return '[' + hh + ':' + mm + '] ' + s.speaker + ': ' + s.text; });
const body = ctx.title + ' — transcript\n' + new Date(buf[0].t).toLocaleString() + '\n\n' + lines.join('\n') + '\n'; const body = ctx.title + ' — transcript\n' + new Date(buf[0].t).toLocaleString() + '\n\n' + lines.join('\n') + '\n';
for (const uid of ids) { for (const uid of ids) {
let user = null; try { user = R.users.byId(uid); } catch (_) {} let user = null; try { user = await R.users.byId(uid); } catch (_) {}
if (!user) { subs.delete(uid); continue; } if (!user) { subs.delete(uid); continue; }
const id = A.id(); const file = 'm_' + id + '.txt'; const id = A.id(); const file = 'm_' + id + '.txt';
try { fs.writeFileSync(path.join(TRANS_DIR, file), body); } catch (e) { continue; } try { fs.writeFileSync(path.join(TRANS_DIR, file), body); } catch (e) { continue; }
// groupId null → private to its creator (see canSeeRec / /mrec auth). // groupId null → private to its creator (see canSeeRec / /mrec auth).
R.recordings.create({ id, teamId: user.team_id, room, groupId: null, meetingId: ctx.meetingId, title: ctx.title, kind: 'transcript', file, mime: 'text/plain', size: null, durationMs: null, createdBy: uid, createdByName: user.name || user.email }); await R.recordings.create({ id, teamId: user.team_id, room, groupId: null, meetingId: ctx.meetingId, title: ctx.title, kind: 'transcript', file, mime: 'text/plain', size: null, durationMs: null, createdBy: uid, createdByName: user.name || user.email });
subs.delete(uid); subs.delete(uid);
} }
} else { ids.forEach((uid) => subs.delete(uid)); } } else { ids.forEach((uid) => subs.delete(uid)); }
@@ -49,46 +49,46 @@ function finalizeTranscript(room, onlyUserId) {
function fmtDur(ms) { const s = Math.max(0, Math.round(ms / 1000)); const m = Math.floor(s / 60); return m ? (m + 'm ' + (s % 60) + 's') : (s + 's'); } function fmtDur(ms) { const s = Math.max(0, Math.round(ms / 1000)); const m = Math.floor(s / 60); return m ? (m + 'm ' + (s % 60) + 's') : (s + 's'); }
function broadcast(group, evt) { try { for (const mid of R.conversations.members(group)) CHAT.pushToUser(mid, evt); } catch (_) {} } async function broadcast(group, evt) { try { for (const mid of await R.conversations.members(group)) CHAT.pushToUser(mid, evt); } catch (_) {} }
// Post a centered activity line into the group (system sender → no ping on clients). // Post a centered activity line into the group (system sender → no ping on clients).
function postSystem(group, teamId, text) { async function postSystem(group, teamId, text) {
const id = A.id(); const id = A.id();
R.messages.send({ id, teamId, senderId: '__system__', recipientId: '', body: text, conversationId: group }); await R.messages.send({ id, teamId, senderId: '__system__', recipientId: '', body: text, conversationId: group });
const m = R.messages.byId(id); const m = await R.messages.byId(id);
broadcast(group, { type: 'chat-message', message: { id: m.id, from: '__system__', conversation_id: group, body: m.body, created_at: m.created_at, system: true } }); broadcast(group, { type: 'chat-message', message: { id: m.id, from: '__system__', conversation_id: group, body: m.body, created_at: m.created_at, system: true } });
} }
function startGroupCall(group, teamId, user) { async function startGroupCall(group, teamId, user) {
const existing = groupCalls.get(group); const existing = groupCalls.get(group);
if (existing) return { room: existing.room, active: true, already: true }; if (existing) return { room: existing.room, active: true, already: true };
let room; do { room = A.numericCode(6); } while (meetingRooms.has(room)); let room; do { room = A.numericCode(6); } while (meetingRooms.has(room));
meetingRooms.set(room, new Map()); meetingRooms.set(room, new Map());
const call = { room, startedAt: now(), startedBy: user.id, startedByName: user.name || user.email }; const call = { room, startedAt: now(), startedBy: user.id, startedByName: user.name || user.email };
// Log the call as a meeting so it appears under Past meetings (history) with the group name. // Log the call as a meeting so it appears under Past meetings (history) with the group name.
try { const hid = A.id(); R.scheduledMeetings.create({ id: hid, teamId, groupId: group, roomCode: room, title: 'Group call', description: null, scheduledAt: now(), createdBy: user.id }); call.historyId = hid; call.teamId = teamId; } catch (_) {} try { const hid = A.id(); await R.scheduledMeetings.create({ id: hid, teamId, groupId: group, roomCode: room, title: 'Group call', description: null, scheduledAt: now(), createdBy: user.id }); call.historyId = hid; call.teamId = teamId; } catch (_) {}
groupCalls.set(group, call); roomToGroupCall.set(room, group); roomHost.set(room, user.id); // creator = host groupCalls.set(group, call); roomToGroupCall.set(room, group); roomHost.set(room, user.id); // creator = host
postSystem(group, teamId, '📞 ' + call.startedByName + ' started a group call'); postSystem(group, teamId, '📞 ' + call.startedByName + ' started a group call').catch(() => {});
let gName = 'Group'; try { const g = R.conversations.byId(group); if (g) gName = g.name || 'Group'; } catch (_) {} let gName = 'Group'; try { const g = await R.conversations.byId(group); if (g) gName = g.name || 'Group'; } catch (_) {}
broadcast(group, { type: 'group-call', group, active: true, room, by: user.id, startedByName: call.startedByName, groupName: gName }); broadcast(group, { type: 'group-call', group, active: true, room, by: user.id, startedByName: call.startedByName, groupName: gName });
return { room, active: true }; return { room, active: true };
} }
// Called from signaling when a mesh room empties — ends the group call if this room was one. // Called from signaling when a mesh room empties — ends the group call if this room was one.
function endGroupCallByRoom(room) { async function endGroupCallByRoom(room) {
const group = roomToGroupCall.get(room); const group = roomToGroupCall.get(room);
if (!group) return; if (!group) return;
const call = groupCalls.get(group); const call = groupCalls.get(group);
roomToGroupCall.delete(room); groupCalls.delete(group); roomHost.delete(room); roomToGroupCall.delete(room); groupCalls.delete(group); roomHost.delete(room);
if (call) { if (call) {
let teamId = call.teamId; try { const g = R.conversations.byId(group); if (g) { teamId = g.team_id; postSystem(group, g.team_id, '📞 Group call ended · ' + fmtDur(now() - call.startedAt)); } } catch (_) {} let teamId = call.teamId; try { const g = await R.conversations.byId(group); if (g) { teamId = g.team_id; postSystem(group, g.team_id, '📞 Group call ended · ' + fmtDur(now() - call.startedAt)).catch(() => {}); } } catch (_) {}
if (call.historyId && teamId) { try { R.scheduledMeetings.end(call.historyId, teamId); } catch (_) {} } // mark the history row past if (call.historyId && teamId) { try { await R.scheduledMeetings.end(call.historyId, teamId); } catch (_) {} } // mark the history row past
broadcast(group, { type: 'group-call', group, active: false, room }); broadcast(group, { type: 'group-call', group, active: false, room });
} }
} }
// 1:1 (DM) call. Notifies both parties (state + a chat line) so the callee sees "Join". // 1:1 (DM) call. Notifies both parties (state + a chat line) so the callee sees "Join".
function startDmCall(me, otherId, teamId) { async function startDmCall(me, otherId, teamId) {
const key = pairKey(me.id, otherId); const key = pairKey(me.id, otherId);
const existing = dmCalls.get(key); const existing = dmCalls.get(key);
if (existing) return { room: existing.room, active: true, already: true }; if (existing) return { room: existing.room, active: true, already: true };
@@ -97,7 +97,7 @@ function startDmCall(me, otherId, teamId) {
const byName = me.name || me.email; const byName = me.name || me.email;
const call = { room, startedAt: now(), startedBy: me.id, startedByName: byName, users: [me.id, otherId], teamId, answered: false }; const call = { room, startedAt: now(), startedBy: me.id, startedByName: byName, users: [me.id, otherId], teamId, answered: false };
// Log to history (both participants) so the call shows under Past meetings with its transcript. // Log to history (both participants) so the call shows under Past meetings with its transcript.
try { const hid = A.id(); R.scheduledMeetings.create({ id: hid, teamId, groupId: null, roomCode: room, title: 'Direct Call', description: null, scheduledAt: now(), createdBy: me.id, participants: [me.id, otherId] }); call.historyId = hid; } catch (_) {} try { const hid = A.id(); await R.scheduledMeetings.create({ id: hid, teamId, groupId: null, roomCode: room, title: 'Direct Call', description: null, scheduledAt: now(), createdBy: me.id, participants: [me.id, otherId] }); call.historyId = hid; } catch (_) {}
dmCalls.set(key, call); roomToDmCall.set(room, key); roomHost.set(room, me.id); // caller = host dmCalls.set(key, call); roomToDmCall.set(room, key); roomHost.set(room, me.id); // caller = host
// #9 (unanswered): if the callee never joins within the ring window, auto-end and mark it missed — // #9 (unanswered): if the callee never joins within the ring window, auto-end and mark it missed —
// so the caller isn't stuck "ringing" forever. // so the caller isn't stuck "ringing" forever.
@@ -109,8 +109,8 @@ function startDmCall(me, otherId, teamId) {
}, 40000); }, 40000);
// A viewer-relative activity line: the caller sees "You started a call", the callee sees the name. // A viewer-relative activity line: the caller sees "You started a call", the callee sees the name.
const mid = A.id(); const mid = A.id();
R.messages.send({ id: mid, teamId, senderId: me.id, recipientId: otherId, body: '📞 Started a call', msgType: 'call-start' }); await R.messages.send({ id: mid, teamId, senderId: me.id, recipientId: otherId, body: '📞 Started a call', msgType: 'call-start' });
const m = R.messages.byId(mid); const dto = { id: m.id, from: me.id, to: otherId, conversation_id: null, body: m.body, created_at: m.created_at, system: true, evt: 'call-start', byName }; const m = await R.messages.byId(mid); const dto = { id: m.id, from: me.id, to: otherId, conversation_id: null, body: m.body, created_at: m.created_at, system: true, evt: 'call-start', byName };
try { CHAT.pushToUser(otherId, { type: 'chat-message', message: dto }); } catch (_) {} try { CHAT.pushToUser(otherId, { type: 'chat-message', message: dto }); } catch (_) {}
try { CHAT.pushToUser(me.id, { type: 'chat-message', message: dto }); } catch (_) {} try { CHAT.pushToUser(me.id, { type: 'chat-message', message: dto }); } catch (_) {}
try { CHAT.pushToUser(otherId, { type: 'dm-call', active: true, room, with: me.id, by: me.id, byName }); } catch (_) {} try { CHAT.pushToUser(otherId, { type: 'dm-call', active: true, room, with: me.id, by: me.id, byName }); } catch (_) {}
@@ -118,18 +118,18 @@ function startDmCall(me, otherId, teamId) {
return { room, active: true }; return { room, active: true };
} }
function endDmCallByRoom(room, silent) { async function endDmCallByRoom(room, silent) {
const key = roomToDmCall.get(room); if (!key) return; const key = roomToDmCall.get(room); if (!key) return;
const call = dmCalls.get(key); const call = dmCalls.get(key);
roomToDmCall.delete(room); dmCalls.delete(key); roomHost.delete(room); roomToDmCall.delete(room); dmCalls.delete(key); roomHost.delete(room);
if (!call) return; if (!call) return;
if (call.ringTimer) { try { clearTimeout(call.ringTimer); } catch (_) {} } if (call.ringTimer) { try { clearTimeout(call.ringTimer); } catch (_) {} }
if (call.historyId && call.teamId) { try { R.scheduledMeetings.end(call.historyId, call.teamId); } catch (_) {} } // mark history past if (call.historyId && call.teamId) { try { await R.scheduledMeetings.end(call.historyId, call.teamId); } catch (_) {} } // mark history past
// Activity line: a duration ONLY if the call was answered; otherwise "Missed call" (#7/#9). // Activity line: a duration ONLY if the call was answered; otherwise "Missed call" (#7/#9).
if (!silent) try { if (!silent) try {
const mid = A.id(); const body = call.answered ? ('📞 Call ended · ' + fmtDur(now() - (call.answeredAt || call.startedAt))) : '📞 Missed call'; const mid = A.id(); const body = call.answered ? ('📞 Call ended · ' + fmtDur(now() - (call.answeredAt || call.startedAt))) : '📞 Missed call';
R.messages.send({ id: mid, teamId: call.teamId, senderId: call.startedBy, recipientId: call.users.find((u) => u !== call.startedBy) || '', body, msgType: 'call-end' }); await R.messages.send({ id: mid, teamId: call.teamId, senderId: call.startedBy, recipientId: call.users.find((u) => u !== call.startedBy) || '', body, msgType: 'call-end' });
const m = R.messages.byId(mid); const dto = { id: m.id, from: call.startedBy, to: m.recipient_id, conversation_id: null, body, created_at: m.created_at, system: true, evt: 'call-end' }; const m = await R.messages.byId(mid); const dto = { id: m.id, from: call.startedBy, to: m.recipient_id, conversation_id: null, body, created_at: m.created_at, system: true, evt: 'call-end' };
call.users.forEach((uid) => { try { CHAT.pushToUser(uid, { type: 'chat-message', message: dto }); } catch (_) {} }); call.users.forEach((uid) => { try { CHAT.pushToUser(uid, { type: 'chat-message', message: dto }); } catch (_) {} });
} catch (_) {} } catch (_) {}
call.users.forEach((uid, i) => { try { CHAT.pushToUser(uid, { type: 'dm-call', active: false, with: call.users[1 - i], room }); } catch (_) {} }); call.users.forEach((uid, i) => { try { CHAT.pushToUser(uid, { type: 'dm-call', active: false, with: call.users[1 - i], room }); } catch (_) {} });
@@ -143,10 +143,10 @@ function markDmAnswered(room, userId) {
if (userId && userId !== call.startedBy && !call.answered) { call.answered = true; call.answeredAt = now(); if (call.ringTimer) { try { clearTimeout(call.ringTimer); } catch (_) {} call.ringTimer = null; } } if (userId && userId !== call.startedBy && !call.answered) { call.answered = true; call.answeredAt = now(); if (call.ringTimer) { try { clearTimeout(call.ringTimer); } catch (_) {} call.ringTimer = null; } }
} }
// Called from signaling when any mesh room empties. // Called from signaling when any mesh room empties.
function endCallByRoom(room) { endGroupCallByRoom(room); endDmCallByRoom(room); } async function endCallByRoom(room) { await endGroupCallByRoom(room); await endDmCallByRoom(room); }
// Callee declines a 1:1 call: post "Call declined" into the DM, drop the waiting caller, end it. // Callee declines a 1:1 call: post "Call declined" into the DM, drop the waiting caller, end it.
function declineDmCall(room, byUser) { async function declineDmCall(room, byUser) {
const key = roomToDmCall.get(room); if (!key) return { ok: false }; const key = roomToDmCall.get(room); if (!key) return { ok: false };
const call = dmCalls.get(key); if (!call) return { ok: false }; const call = dmCalls.get(key); if (!call) return { ok: false };
// #8: if this user has ALREADY accepted on another device (they're in the room), this is just the // #8: if this user has ALREADY accepted on another device (they're in the room), this is just the
@@ -156,8 +156,8 @@ function declineDmCall(room, byUser) {
const callerId = call.users.find((id) => id !== byUser.id) || call.startedBy; const callerId = call.users.find((id) => id !== byUser.id) || call.startedBy;
try { try {
const mid = A.id(); const mid = A.id();
R.messages.send({ id: mid, teamId: byUser.team_id, senderId: byUser.id, recipientId: callerId, body: '📞 Call declined', msgType: 'call-end' }); await R.messages.send({ id: mid, teamId: byUser.team_id, senderId: byUser.id, recipientId: callerId, body: '📞 Call declined', msgType: 'call-end' });
const mm = R.messages.byId(mid); const dto = { id: mm.id, from: byUser.id, to: callerId, conversation_id: null, body: mm.body, created_at: mm.created_at, system: true, evt: 'call-end' }; const mm = await R.messages.byId(mid); const dto = { id: mm.id, from: byUser.id, to: callerId, conversation_id: null, body: mm.body, created_at: mm.created_at, system: true, evt: 'call-end' };
CHAT.pushToUser(callerId, { type: 'chat-message', message: dto }); CHAT.pushToUser(callerId, { type: 'chat-message', message: dto });
CHAT.pushToUser(byUser.id, { type: 'chat-message', message: dto }); CHAT.pushToUser(byUser.id, { type: 'chat-message', message: dto });
} catch (_) {} } catch (_) {}
+56
View File
@@ -0,0 +1,56 @@
// One-shot data migration: copy every row from the SQLite data.db into Postgres. Run ONCE at cutover,
// with the app stopped, BEFORE switching DB_BACKEND to pg.
//
// DB_PATH=/data/data.db DATABASE_URL=postgres://user:pass@host/db node db/migrate-sqlite-to-pg.js
//
// It applies the Postgres schema first, TRUNCATEs the target tables (so a re-run re-copies cleanly), then
// bulk-inserts in FK-dependency order. audit_log.id is a GENERATED identity, so its id is not copied (PG
// assigns fresh ones — nothing references audit_log.id). Timestamps/flags are plain integers on both sides.
const fs = require('fs');
const path = require('path');
const { DatabaseSync } = require('node:sqlite');
const { Pool } = require('pg');
const SQLITE = process.env.DB_PATH || path.join(__dirname, '..', 'data.db');
if (!process.env.DATABASE_URL) { console.error('DATABASE_URL is required'); process.exit(1); }
const src = new DatabaseSync(SQLITE);
const pool = new Pool({ connectionString: process.env.DATABASE_URL, max: 4 });
// Parents before children (users→teams, sessions_auth→users, machines→teams); the rest have no FKs.
const ORDER = [
'teams', 'users', 'machines', 'sessions_auth', 'audit_log', 'sessions_log', 'refresh_tokens',
'api_keys', 'webhooks', 'messages', 'message_reactions', 'attachments', 'conversations',
'conversation_members', 'call_history', 'user_aliases', 'polls', 'poll_votes', 'scheduled_meetings',
'recordings', 'push_subscriptions', 'device_tokens', 'app_installs', 'favorites',
];
async function main() {
await pool.query(fs.readFileSync(path.join(__dirname, 'schema.pg.sql'), 'utf8')); // ensure schema exists
await pool.query('TRUNCATE ' + ORDER.map((t) => '"' + t + '"').join(', ') + ' RESTART IDENTITY CASCADE');
const totals = {};
for (const table of ORDER) {
let rows = [];
try { rows = src.prepare('SELECT * FROM ' + table).all(); } catch (e) { totals[table] = 'skip(' + e.message + ')'; continue; }
if (!rows.length) { totals[table] = 0; continue; }
let cols = Object.keys(rows[0]);
if (table === 'audit_log') cols = cols.filter((c) => c !== 'id'); // GENERATED — let PG assign
const colList = cols.map((c) => '"' + c + '"').join(',');
const CHUNK = 400; // keep param count well under Postgres' 65535 limit even for wide tables
for (let i = 0; i < rows.length; i += CHUNK) {
const batch = rows.slice(i, i + CHUNK);
const values = []; const params = [];
batch.forEach((r, ri) => {
values.push('(' + cols.map((c, ci) => '$' + (ri * cols.length + ci + 1)).join(',') + ')');
cols.forEach((c) => params.push(r[c] === undefined ? null : r[c]));
});
await pool.query('INSERT INTO "' + table + '" (' + colList + ') VALUES ' + values.join(','), params);
}
totals[table] = rows.length;
}
console.log('MIGRATED rows:', JSON.stringify(totals, null, 0));
await pool.end();
}
main().catch((e) => { console.error('MIGRATION FAILED:', e && e.message); process.exit(1); });
+59
View File
@@ -0,0 +1,59 @@
// PostgreSQL backend for the async DB adapter. Same interface as db/sqlite.js — prepare(sql).{get,all,run},
// exec(sql), tx(fn), init() — so repos and app code are engine-agnostic. Selected by DB_BACKEND=pg;
// connection string from DATABASE_URL.
const { Pool, types } = require('pg');
const fs = require('fs');
const path = require('path');
// BIGINT (int8, OID 20) defaults to STRING in node-postgres to avoid precision loss. Every BIGINT here is
// an epoch-ms timestamp or a byte size — all far below Number.MAX_SAFE_INTEGER — so parse them as numbers
// to match the SQLite backend. Otherwise `expires_at < Date.now()` would compare a string to a number.
types.setTypeParser(20, (v) => (v === null ? null : parseInt(v, 10)));
const pool = new Pool({ connectionString: process.env.DATABASE_URL, max: 10 });
// Repos use '?' placeholders (SQLite style); Postgres wants $1,$2,… — replace positionally. Safe because
// no literal '?' appears inside any SQL string literal in this codebase.
function toPg(sql) { let i = 0; return sql.replace(/\?/g, () => '$' + (++i)); }
function prepare(sql) {
const q = toPg(sql);
return {
get: (...p) => pool.query(q, p).then((r) => r.rows[0]),
all: (...p) => pool.query(q, p).then((r) => r.rows),
run: (...p) => pool.query(q, p).then((r) => ({ changes: r.rowCount, lastInsertRowid: undefined })),
};
}
function exec(sql) { return pool.query(sql).then(() => {}); }
// Transaction on ONE pooled client (a pool would scatter BEGIN/COMMIT across connections). Same runner
// shape the sqlite backend's tx() exposes, so repos.mergeInto is identical on both engines.
async function tx(fn) {
const client = await pool.connect();
try {
await client.query('BEGIN');
const t = {
run: (sql, ...p) => client.query(toPg(sql), p).then((r) => ({ changes: r.rowCount })),
get: (sql, ...p) => client.query(toPg(sql), p).then((r) => r.rows[0]),
all: (sql, ...p) => client.query(toPg(sql), p).then((r) => r.rows),
};
const out = await fn(t);
await client.query('COMMIT');
return out;
} catch (e) {
try { await client.query('ROLLBACK'); } catch (_) {}
throw e;
} finally {
client.release();
}
}
// Apply the schema (all CREATE ... IF NOT EXISTS — idempotent). Multi-statement, no params, so it runs via
// the simple-query protocol in one call. MUST be awaited before serving (server.js boot).
async function init() {
const sql = fs.readFileSync(path.join(__dirname, 'schema.pg.sql'), 'utf8');
await pool.query(sql);
}
module.exports = { prepare, exec, tx, init, name: 'pg', _pool: pool };
+2 -2
View File
@@ -193,9 +193,9 @@ function dropDerived(id) {
// persist on the data volume, so on a normal restart this finds nothing to do and costs one query. // persist on the data volume, so on a normal restart this finds nothing to do and costs one query.
// Deliberately delayed and rate-limited by the same 2-at-a-time queue — boot must not stall on it. // Deliberately delayed and rate-limited by the same 2-at-a-time queue — boot must not stall on it.
function backfill() { function backfill() {
setTimeout(() => { setTimeout(async () => {
let rows = []; let rows = [];
try { rows = require('./repos').attachments.allVideos(); } catch (e) { return; } try { rows = await require('./repos').attachments.allVideos(); } catch (e) { return; }
let queued = 0; let queued = 0;
for (const r of rows) { for (const r of rows) {
if (hasWebRendition(r.id)) continue; if (hasWebRendition(r.id)) continue;
+1
View File
@@ -10,6 +10,7 @@
"node": ">=22.5.0" "node": ">=22.5.0"
}, },
"dependencies": { "dependencies": {
"pg": "^8.13.1",
"web-push": "^3.6.7", "web-push": "^3.6.7",
"ws": "^8.18.0" "ws": "^8.18.0"
}, },
+4 -4
View File
@@ -114,21 +114,21 @@ async function sendToUser(userId, payload) {
const data = JSON.stringify(payload || {}); const data = JSON.stringify(payload || {});
if (webReady) { if (webReady) {
let subs = []; let subs = [];
try { subs = R.pushSubs.byUser(userId); } catch (_) { subs = []; } try { subs = await R.pushSubs.byUser(userId); } catch (_) { subs = []; }
for (const s of subs) { for (const s of subs) {
try { await webpush.sendNotification({ endpoint: s.endpoint, keys: { p256dh: s.p256dh, auth: s.auth } }, data, { TTL: 600 }); } try { await webpush.sendNotification({ endpoint: s.endpoint, keys: { p256dh: s.p256dh, auth: s.auth } }, data, { TTL: 600 }); }
catch (err) { const c = err && err.statusCode; if (c === 404 || c === 410) { try { R.pushSubs.removeByEndpoint(s.endpoint); } catch (_) {} } } catch (err) { const c = err && err.statusCode; if (c === 404 || c === 410) { try { await R.pushSubs.removeByEndpoint(s.endpoint); } catch (_) {} } }
} }
} }
if (nativeReady) { if (nativeReady) {
let toks = []; let toks = [];
try { toks = R.deviceTokens.byUser(userId); } catch (_) { toks = []; } try { toks = await R.deviceTokens.byUser(userId); } catch (_) { toks = []; }
for (const t of toks) { for (const t of toks) {
try { try {
let r = null; let r = null;
if (t.platform === 'android' && fcmSA) r = await sendFcm(t.token, payload); if (t.platform === 'android' && fcmSA) r = await sendFcm(t.token, payload);
else if (t.platform === 'ios' && apnsCfg) r = await sendApns(t.token, payload); else if (t.platform === 'ios' && apnsCfg) r = await sendApns(t.token, payload);
if (r && r.dead) { try { R.deviceTokens.removeByToken(t.token); } catch (_) {} } if (r && r.dead) { try { await R.deviceTokens.removeByToken(t.token); } catch (_) {} }
} catch (_) { /* best-effort */ } } catch (_) { /* best-effort */ }
} }
} }
+4 -4
View File
@@ -3,18 +3,18 @@
const R = require('./repos'); const R = require('./repos');
const CHAT = require('./chat'); const CHAT = require('./chat');
function tick() { async function tick() {
try { try {
const now = Date.now(); const now = Date.now();
const due = R.scheduledMeetings.dueForReminder(now, now + 10 * 60 * 1000); // starting within 10 min const due = await R.scheduledMeetings.dueForReminder(now, now + 10 * 60 * 1000); // starting within 10 min
for (const s of due) { for (const s of due) {
const recipients = new Set([s.created_by]); const recipients = new Set([s.created_by]);
let invited = []; try { invited = JSON.parse(s.participants || '[]'); } catch (_) {} let invited = []; try { invited = JSON.parse(s.participants || '[]'); } catch (_) {}
invited.forEach((id) => recipients.add(id)); invited.forEach((id) => recipients.add(id));
if (s.group_id) { try { R.conversations.members(s.group_id).forEach((m) => recipients.add(m)); } catch (_) {} } if (s.group_id) { try { (await R.conversations.members(s.group_id)).forEach((m) => recipients.add(m)); } catch (_) {} }
const evt = { type: 'meeting-reminder', meeting: { id: s.id, title: s.title, scheduledAt: s.scheduled_at, room: s.room_code } }; const evt = { type: 'meeting-reminder', meeting: { id: s.id, title: s.title, scheduledAt: s.scheduled_at, room: s.room_code } };
recipients.forEach((uid) => { try { CHAT.pushToUser(uid, evt); } catch (_) {} }); recipients.forEach((uid) => { try { CHAT.pushToUser(uid, evt); } catch (_) {} });
R.scheduledMeetings.markReminded(s.id); await R.scheduledMeetings.markReminded(s.id);
} }
} catch (_) { /* never let the timer die */ } } catch (_) { /* never let the timer die */ }
} }
+74 -71
View File
@@ -5,28 +5,35 @@
// TENANT ABSTRACTION: a "tenant" currently maps 1:1 to a team (column `team_id`). // TENANT ABSTRACTION: a "tenant" currently maps 1:1 to a team (column `team_id`).
// Repo signatures take `tenantId` so that when the tenant is later elevated to a // Repo signatures take `tenantId` so that when the tenant is later elevated to a
// first-class Organization (Phase 3), callers and the API/auth built on top stay unchanged. // first-class Organization (Phase 3), callers and the API/auth built on top stay unchanged.
const db = require('./db'); //
// ASYNC: queries go through the db adapter (dbx.js), so every method returns a Promise — the same repo
// code runs on synchronous SQLite and asynchronous Postgres. Callers MUST await. Methods that only
// `return db.prepare(...).get/all/run(...)` already yield the adapter's Promise; methods that transform a
// result (.map, !!, .c, or run multiple statements) are written async/await so they don't operate on a
// bare Promise.
const db = require('./dbx');
const A = require('./auth'); const A = require('./auth');
const now = () => Date.now(); const now = () => Date.now();
const teams = { const teams = {
first: () => db.prepare('SELECT * FROM teams LIMIT 1').get(), first: () => db.prepare('SELECT * FROM teams LIMIT 1').get(),
byId: (id) => db.prepare('SELECT * FROM teams WHERE id=?').get(id), byId: (id) => db.prepare('SELECT * FROM teams WHERE id=?').get(id),
create: (name) => { create: async (name) => {
const id = A.id(); const id = A.id();
db.prepare('INSERT INTO teams (id,name,created_at) VALUES (?,?,?)').run(id, name, now()); await db.prepare('INSERT INTO teams (id,name,created_at) VALUES (?,?,?)').run(id, name, now());
return db.prepare('SELECT * FROM teams WHERE id=?').get(id); return db.prepare('SELECT * FROM teams WHERE id=?').get(id);
}, },
}; };
const users = { const users = {
anyExists: () => !!db.prepare('SELECT 1 FROM users LIMIT 1').get(), anyExists: async () => !!(await db.prepare('SELECT 1 FROM users LIMIT 1').get()),
byId: (id) => db.prepare('SELECT * FROM users WHERE id=?').get(id), byId: (id) => db.prepare('SELECT * FROM users WHERE id=?').get(id),
// Follow a merge redirect: a merged-away id resolves to the surviving account, else returns id // Follow a merge redirect: a merged-away id resolves to the surviving account, else returns id
// unchanged. Use for any user id that arrived from the client (DM recipient, thread peer). // unchanged. Use for any user id that arrived from the client (DM recipient, thread peer).
resolve: (id) => { if (!id) return id; const a = db.prepare('SELECT user_id FROM user_aliases WHERE old_id=?').get(id); return a ? a.user_id : id; }, resolve: async (id) => { if (!id) return id; const a = await db.prepare('SELECT user_id FROM user_aliases WHERE old_id=?').get(id); return a ? a.user_id : id; },
byEmail: (email) => db.prepare('SELECT * FROM users WHERE email=? COLLATE NOCASE').get(email), // LOWER()=LOWER() is case-insensitive on both engines (SQLite has no portable COLLATE NOCASE in Postgres).
emailExists: (email) => !!db.prepare('SELECT 1 FROM users WHERE email=? COLLATE NOCASE').get(email), byEmail: (email) => db.prepare('SELECT * FROM users WHERE LOWER(email)=LOWER(?)').get(email),
emailExists: async (email) => !!(await db.prepare('SELECT 1 FROM users WHERE LOWER(email)=LOWER(?)').get(email)),
// Match on the stable BizGaze person-id so email- and mobile-logins resolve to one account (#2). // Match on the stable BizGaze person-id so email- and mobile-logins resolve to one account (#2).
byBizgazeId: (bizId) => (bizId ? db.prepare('SELECT * FROM users WHERE bizgaze_user_id=?').get(String(bizId)) : undefined), byBizgazeId: (bizId) => (bizId ? db.prepare('SELECT * FROM users WHERE bizgaze_user_id=?').get(String(bizId)) : undefined),
setBizgazeId: (id, bizId) => db.prepare('UPDATE users SET bizgaze_user_id=? WHERE id=?').run(bizId != null ? String(bizId) : null, id), setBizgazeId: (id, bizId) => db.prepare('UPDATE users SET bizgaze_user_id=? WHERE id=?').run(bizId != null ? String(bizId) : null, id),
@@ -34,9 +41,9 @@ const users = {
db.prepare('SELECT id,email,name,role,active,avatar_url,created_at FROM users WHERE team_id=?').all(tenantId), db.prepare('SELECT id,email,name,role,active,avatar_url,created_at FROM users WHERE team_id=?').all(tenantId),
inTenant: (id, tenantId) => inTenant: (id, tenantId) =>
db.prepare('SELECT * FROM users WHERE id=? AND team_id=?').get(id, tenantId), db.prepare('SELECT * FROM users WHERE id=? AND team_id=?').get(id, tenantId),
create: ({ tenantId, email, hash, salt, role, name, mfaSecret }) => { create: async ({ tenantId, email, hash, salt, role, name, mfaSecret }) => {
const id = A.id(); const id = A.id();
db.prepare(`INSERT INTO users (id,team_id,email,pw_hash,pw_salt,role,name,mfa_secret,mfa_enabled,created_at) await db.prepare(`INSERT INTO users (id,team_id,email,pw_hash,pw_salt,role,name,mfa_secret,mfa_enabled,created_at)
VALUES (?,?,?,?,?,?,?,?,0,?)`) VALUES (?,?,?,?,?,?,?,?,0,?)`)
.run(id, tenantId, email, hash, salt, role, name || null, mfaSecret, now()); .run(id, tenantId, email, hash, salt, role, name || null, mfaSecret, now());
return id; return id;
@@ -57,47 +64,46 @@ const users = {
// BizGaze person (#2). Runs in a single transaction so a failure leaves the data untouched. // BizGaze person (#2). Runs in a single transaction so a failure leaves the data untouched.
// For composite-key tables we UPDATE OR IGNORE (move what won't collide) then DELETE the rest // For composite-key tables we UPDATE OR IGNORE (move what won't collide) then DELETE the rest
// (the survivor already has that membership/reaction/vote). // (the survivor already has that membership/reaction/vote).
mergeInto: (fromId, intoId) => { mergeInto: async (fromId, intoId) => {
if (!fromId || !intoId || fromId === intoId) return; if (!fromId || !intoId || fromId === intoId) return;
const run = (sql, ...a) => db.prepare(sql).run(...a); // db.tx() gives one atomic unit on both engines (sqlite single-connection; pg one pooled client).
db.exec('BEGIN'); return db.tx(async (t) => {
try { const run = (sql, ...a) => t.run(sql, ...a);
run('UPDATE messages SET sender_id=? WHERE sender_id=?', intoId, fromId); await run('UPDATE messages SET sender_id=? WHERE sender_id=?', intoId, fromId);
run('UPDATE messages SET recipient_id=? WHERE recipient_id=?', intoId, fromId); await run('UPDATE messages SET recipient_id=? WHERE recipient_id=?', intoId, fromId);
run('UPDATE OR IGNORE message_reactions SET user_id=? WHERE user_id=?', intoId, fromId); // Composite-key tables: MOVE only the rows that won't collide with the survivor's existing rows
run('DELETE FROM message_reactions WHERE user_id=?', fromId); // (NOT EXISTS / NOT IN — portable across SQLite & Postgres, replacing SQLite-only UPDATE OR IGNORE),
run('UPDATE OR IGNORE conversation_members SET user_id=? WHERE user_id=?', intoId, fromId); // then DELETE whatever remains (the survivor already had that membership/reaction/vote).
run('DELETE FROM conversation_members WHERE user_id=?', fromId); await run('UPDATE message_reactions SET user_id=? WHERE user_id=? AND NOT EXISTS (SELECT 1 FROM message_reactions x WHERE x.message_id=message_reactions.message_id AND x.emoji=message_reactions.emoji AND x.user_id=?)', intoId, fromId, intoId);
run('UPDATE OR IGNORE poll_votes SET user_id=? WHERE user_id=?', intoId, fromId); await run('DELETE FROM message_reactions WHERE user_id=?', fromId);
run('DELETE FROM poll_votes WHERE user_id=?', fromId); await run('UPDATE conversation_members SET user_id=? WHERE user_id=? AND conversation_id NOT IN (SELECT conversation_id FROM conversation_members WHERE user_id=?)', intoId, fromId, intoId);
run('UPDATE OR IGNORE favorites SET user_id=? WHERE user_id=?', intoId, fromId); await run('DELETE FROM conversation_members WHERE user_id=?', fromId);
run('DELETE FROM favorites WHERE user_id=?', fromId); await run('UPDATE poll_votes SET user_id=? WHERE user_id=? AND NOT EXISTS (SELECT 1 FROM poll_votes x WHERE x.poll_id=poll_votes.poll_id AND x.option_idx=poll_votes.option_idx AND x.user_id=?)', intoId, fromId, intoId);
await run('DELETE FROM poll_votes WHERE user_id=?', fromId);
await run('UPDATE favorites SET user_id=? WHERE user_id=? AND target NOT IN (SELECT target FROM favorites WHERE user_id=?)', intoId, fromId, intoId);
await run('DELETE FROM favorites WHERE user_id=?', fromId);
// A DM favourite pointing AT the merged-away user should now point at the survivor. // A DM favourite pointing AT the merged-away user should now point at the survivor.
run("UPDATE OR IGNORE favorites SET target='dm:'||? WHERE target='dm:'||?", intoId, fromId); await run("UPDATE favorites SET target='dm:'||? WHERE target='dm:'||? AND NOT EXISTS (SELECT 1 FROM favorites x WHERE x.user_id=favorites.user_id AND x.target='dm:'||?)", intoId, fromId, intoId);
run("DELETE FROM favorites WHERE target='dm:'||?", fromId); await run("DELETE FROM favorites WHERE target='dm:'||?", fromId);
run('UPDATE conversations SET created_by=? WHERE created_by=?', intoId, fromId); await run('UPDATE conversations SET created_by=? WHERE created_by=?', intoId, fromId);
run('UPDATE polls SET created_by=? WHERE created_by=?', intoId, fromId); await run('UPDATE polls SET created_by=? WHERE created_by=?', intoId, fromId);
run('UPDATE scheduled_meetings SET created_by=? WHERE created_by=?', intoId, fromId); await run('UPDATE scheduled_meetings SET created_by=? WHERE created_by=?', intoId, fromId);
run('UPDATE recordings SET created_by=? WHERE created_by=?', intoId, fromId); await run('UPDATE recordings SET created_by=? WHERE created_by=?', intoId, fromId);
run('UPDATE attachments SET uploader_id=? WHERE uploader_id=?', intoId, fromId); await run('UPDATE attachments SET uploader_id=? WHERE uploader_id=?', intoId, fromId);
run('UPDATE push_subscriptions SET user_id=? WHERE user_id=?', intoId, fromId); await run('UPDATE push_subscriptions SET user_id=? WHERE user_id=?', intoId, fromId);
run('UPDATE OR IGNORE device_tokens SET user_id=? WHERE user_id=?', intoId, fromId); // device_tokens: changing user_id can't violate its PK (id) or UNIQUE (token), so a plain UPDATE.
run('DELETE FROM device_tokens WHERE user_id=?', fromId); await run('UPDATE device_tokens SET user_id=? WHERE user_id=?', intoId, fromId);
run('UPDATE app_installs SET user_id=? WHERE user_id=?', intoId, fromId); await run('UPDATE app_installs SET user_id=? WHERE user_id=?', intoId, fromId);
// Drop the merged-away account's auth so a stale token can't resurrect the empty row. // Drop the merged-away account's auth so a stale token can't resurrect the empty row.
run('DELETE FROM sessions_auth WHERE user_id=?', fromId); await run('DELETE FROM sessions_auth WHERE user_id=?', fromId);
run('DELETE FROM refresh_tokens WHERE user_id=?', fromId); await run('DELETE FROM refresh_tokens WHERE user_id=?', fromId);
// Record old_id -> survivor so lingering references (cached contacts, in-flight DMs) resolve // Record old_id -> survivor so lingering references (cached contacts, in-flight DMs) resolve
// instead of hitting the deleted row (which made messages to merged contacts vanish). // instead of hitting the deleted row (which made messages to merged contacts vanish).
const _iu = db.prepare('SELECT team_id FROM users WHERE id=?').get(intoId); const _iu = await t.get('SELECT team_id FROM users WHERE id=?', intoId);
run('INSERT OR REPLACE INTO user_aliases (old_id,user_id,team_id,created_at) VALUES (?,?,?,?)', fromId, intoId, (_iu && _iu.team_id) || null, now()); await run('INSERT INTO user_aliases (old_id,user_id,team_id,created_at) VALUES (?,?,?,?) ON CONFLICT(old_id) DO UPDATE SET user_id=excluded.user_id, team_id=excluded.team_id, created_at=excluded.created_at', fromId, intoId, (_iu && _iu.team_id) || null, now());
run('UPDATE user_aliases SET user_id=? WHERE user_id=?', intoId, fromId); // re-chain earlier aliases to the new survivor await run('UPDATE user_aliases SET user_id=? WHERE user_id=?', intoId, fromId); // re-chain earlier aliases to the new survivor
run('DELETE FROM users WHERE id=?', fromId); await run('DELETE FROM users WHERE id=?', fromId);
db.exec('COMMIT'); });
} catch (e) {
db.exec('ROLLBACK');
throw e;
}
}, },
}; };
@@ -116,9 +122,9 @@ const machines = {
inTenant: (id, tenantId) => db.prepare('SELECT * FROM machines WHERE id=? AND team_id=?').get(id, tenantId), inTenant: (id, tenantId) => db.prepare('SELECT * FROM machines WHERE id=? AND team_id=?').get(id, tenantId),
listByTenant: (tenantId) => listByTenant: (tenantId) =>
db.prepare('SELECT id,name,unattended,last_seen FROM machines WHERE team_id=?').all(tenantId), db.prepare('SELECT id,name,unattended,last_seen FROM machines WHERE team_id=?').all(tenantId),
create: ({ tenantId, name, enrollToken, unattended }) => { create: async ({ tenantId, name, enrollToken, unattended }) => {
const id = A.id(); const id = A.id();
db.prepare('INSERT INTO machines (id,team_id,name,enroll_token,unattended,created_at) VALUES (?,?,?,?,?,?)') await db.prepare('INSERT INTO machines (id,team_id,name,enroll_token,unattended,created_at) VALUES (?,?,?,?,?,?)')
.run(id, tenantId, name, enrollToken, unattended ? 1 : 0, now()); .run(id, tenantId, name, enrollToken, unattended ? 1 : 0, now());
return id; return id;
}, },
@@ -126,14 +132,11 @@ const machines = {
}; };
const audit = { const audit = {
// Positional params (not @named) so the same SQL runs on SQLite and Postgres.
add: (e) => add: (e) =>
db.prepare(`INSERT INTO audit_log (team_id,user_id,user_email,machine_id,machine_name,action,detail,at) db.prepare(`INSERT INTO audit_log (team_id,user_id,user_email,machine_id,machine_name,action,detail,at)
VALUES (@team_id,@user_id,@user_email,@machine_id,@machine_name,@action,@detail,@at)`) VALUES (?,?,?,?,?,?,?,?)`)
.run({ .run(e.team_id, e.user_id || null, e.user_email || null, e.machine_id || null, e.machine_name || null, e.action, e.detail || null, now()),
team_id: e.team_id, user_id: e.user_id || null, user_email: e.user_email || null,
machine_id: e.machine_id || null, machine_name: e.machine_name || null,
action: e.action, detail: e.detail || null, at: now(),
}),
listByTenant: (tenantId) => listByTenant: (tenantId) =>
db.prepare("SELECT * FROM audit_log WHERE team_id=? OR team_id='adhoc' ORDER BY at DESC LIMIT 200").all(tenantId), db.prepare("SELECT * FROM audit_log WHERE team_id=? OR team_id='adhoc' ORDER BY at DESC LIMIT 200").all(tenantId),
}; };
@@ -239,18 +242,18 @@ const messages = {
AND body LIKE ? ESCAPE '\\' ORDER BY created_at ASC LIMIT ?`).all(conversationId, like, limit), AND body LIKE ? ESCAPE '\\' ORDER BY created_at ASC LIMIT ?`).all(conversationId, like, limit),
lastInConversation: (conversationId) => lastInConversation: (conversationId) =>
db.prepare('SELECT * FROM messages WHERE conversation_id=? ORDER BY created_at DESC LIMIT 1').get(conversationId), db.prepare('SELECT * FROM messages WHERE conversation_id=? ORDER BY created_at DESC LIMIT 1').get(conversationId),
unreadInConversation: (conversationId, userId, since) => unreadInConversation: async (conversationId, userId, since) =>
db.prepare('SELECT COUNT(*) AS c FROM messages WHERE conversation_id=? AND sender_id<>? AND created_at>?').get(conversationId, userId, since).c, (await db.prepare('SELECT COUNT(*) AS c FROM messages WHERE conversation_id=? AND sender_id<>? AND created_at>?').get(conversationId, userId, since)).c,
}; };
const reactions = { const reactions = {
// Toggle with ONE reaction per user per message: picking an emoji replaces any prior // Toggle with ONE reaction per user per message: picking an emoji replaces any prior
// reaction by that user; picking the same one again removes it. Returns true if added. // reaction by that user; picking the same one again removes it. Returns true if added.
toggle: (messageId, userId, emoji) => { toggle: async (messageId, userId, emoji) => {
const had = db.prepare('SELECT 1 FROM message_reactions WHERE message_id=? AND user_id=? AND emoji=?').get(messageId, userId, emoji); const had = await db.prepare('SELECT 1 FROM message_reactions WHERE message_id=? AND user_id=? AND emoji=?').get(messageId, userId, emoji);
db.prepare('DELETE FROM message_reactions WHERE message_id=? AND user_id=?').run(messageId, userId); await db.prepare('DELETE FROM message_reactions WHERE message_id=? AND user_id=?').run(messageId, userId);
if (had) return false; if (had) return false;
db.prepare('INSERT INTO message_reactions (message_id,user_id,emoji,created_at) VALUES (?,?,?,?)').run(messageId, userId, emoji, now()); await db.prepare('INSERT INTO message_reactions (message_id,user_id,emoji,created_at) VALUES (?,?,?,?)').run(messageId, userId, emoji, now());
return true; return true;
}, },
forMessage: (messageId) => db.prepare('SELECT user_id, emoji FROM message_reactions WHERE message_id=? ORDER BY created_at ASC').all(messageId), forMessage: (messageId) => db.prepare('SELECT user_id, emoji FROM message_reactions WHERE message_id=? ORDER BY created_at ASC').all(messageId),
@@ -270,17 +273,17 @@ const conversations = {
db.prepare('INSERT INTO conversations (id,team_id,type,name,created_by,created_at) VALUES (?,?,?,?,?,?)').run(id, teamId, 'group', name || null, createdBy || null, now()), db.prepare('INSERT INTO conversations (id,team_id,type,name,created_by,created_at) VALUES (?,?,?,?,?,?)').run(id, teamId, 'group', name || null, createdBy || null, now()),
byId: (id) => db.prepare('SELECT * FROM conversations WHERE id=?').get(id), byId: (id) => db.prepare('SELECT * FROM conversations WHERE id=?').get(id),
addMember: (conversationId, userId, admin) => addMember: (conversationId, userId, admin) =>
db.prepare('INSERT OR IGNORE INTO conversation_members (conversation_id,user_id,last_read_at,joined_at,admin) VALUES (?,?,?,?,?)').run(conversationId, userId, 0, now(), admin ? 1 : 0), db.prepare('INSERT INTO conversation_members (conversation_id,user_id,last_read_at,joined_at,admin) VALUES (?,?,?,?,?) ON CONFLICT(conversation_id,user_id) DO NOTHING').run(conversationId, userId, 0, now(), admin ? 1 : 0),
members: (conversationId) => db.prepare('SELECT user_id FROM conversation_members WHERE conversation_id=? ORDER BY joined_at ASC').all(conversationId).map((r) => r.user_id), members: async (conversationId) => (await db.prepare('SELECT user_id FROM conversation_members WHERE conversation_id=? ORDER BY joined_at ASC').all(conversationId)).map((r) => r.user_id),
isMember: (conversationId, userId) => !!db.prepare('SELECT 1 FROM conversation_members WHERE conversation_id=? AND user_id=?').get(conversationId, userId), isMember: async (conversationId, userId) => !!(await db.prepare('SELECT 1 FROM conversation_members WHERE conversation_id=? AND user_id=?').get(conversationId, userId)),
// ---- Group admins (multiple allowed) ---- // ---- Group admins (multiple allowed) ----
isAdmin: (conversationId, userId) => !!db.prepare('SELECT 1 FROM conversation_members WHERE conversation_id=? AND user_id=? AND admin=1').get(conversationId, userId), isAdmin: async (conversationId, userId) => !!(await db.prepare('SELECT 1 FROM conversation_members WHERE conversation_id=? AND user_id=? AND admin=1').get(conversationId, userId)),
admins: (conversationId) => db.prepare('SELECT user_id FROM conversation_members WHERE conversation_id=? AND admin=1').all(conversationId).map((r) => r.user_id), admins: async (conversationId) => (await db.prepare('SELECT user_id FROM conversation_members WHERE conversation_id=? AND admin=1').all(conversationId)).map((r) => r.user_id),
setMemberAdmin: (conversationId, userId, v) => db.prepare('UPDATE conversation_members SET admin=? WHERE conversation_id=? AND user_id=?').run(v ? 1 : 0, conversationId, userId), setMemberAdmin: (conversationId, userId, v) => db.prepare('UPDATE conversation_members SET admin=? WHERE conversation_id=? AND user_id=?').run(v ? 1 : 0, conversationId, userId),
oldestMember: (conversationId) => { const r = db.prepare('SELECT user_id FROM conversation_members WHERE conversation_id=? ORDER BY joined_at ASC LIMIT 1').get(conversationId); return r ? r.user_id : null; }, oldestMember: async (conversationId) => { const r = await db.prepare('SELECT user_id FROM conversation_members WHERE conversation_id=? ORDER BY joined_at ASC LIMIT 1').get(conversationId); return r ? r.user_id : null; },
listForUser: (teamId, userId) => listForUser: (teamId, userId) =>
db.prepare('SELECT c.* FROM conversations c JOIN conversation_members m ON m.conversation_id=c.id WHERE c.team_id=? AND m.user_id=?').all(teamId, userId), db.prepare('SELECT c.* FROM conversations c JOIN conversation_members m ON m.conversation_id=c.id WHERE c.team_id=? AND m.user_id=?').all(teamId, userId),
lastReadAt: (conversationId, userId) => { const r = db.prepare('SELECT last_read_at FROM conversation_members WHERE conversation_id=? AND user_id=?').get(conversationId, userId); return r ? r.last_read_at : 0; }, lastReadAt: async (conversationId, userId) => { const r = await db.prepare('SELECT last_read_at FROM conversation_members WHERE conversation_id=? AND user_id=?').get(conversationId, userId); return r ? r.last_read_at : 0; },
memberReads: (conversationId) => db.prepare('SELECT user_id, last_read_at FROM conversation_members WHERE conversation_id=?').all(conversationId), memberReads: (conversationId) => db.prepare('SELECT user_id, last_read_at FROM conversation_members WHERE conversation_id=?').all(conversationId),
setAdminOnly: (id, v) => db.prepare('UPDATE conversations SET admin_only=? WHERE id=?').run(v ? 1 : 0, id), setAdminOnly: (id, v) => db.prepare('UPDATE conversations SET admin_only=? WHERE id=?').run(v ? 1 : 0, id),
markRead: (conversationId, userId) => db.prepare('UPDATE conversation_members SET last_read_at=? WHERE conversation_id=? AND user_id=?').run(now(), conversationId, userId), markRead: (conversationId, userId) => db.prepare('UPDATE conversation_members SET last_read_at=? WHERE conversation_id=? AND user_id=?').run(now(), conversationId, userId),
@@ -288,7 +291,7 @@ const conversations = {
setAvatar: (id, attachmentId) => db.prepare('UPDATE conversations SET avatar_id=? WHERE id=?').run(attachmentId || null, id), setAvatar: (id, attachmentId) => db.prepare('UPDATE conversations SET avatar_id=? WHERE id=?').run(attachmentId || null, id),
byAvatar: (attachmentId) => db.prepare('SELECT * FROM conversations WHERE avatar_id=? LIMIT 1').get(attachmentId), byAvatar: (attachmentId) => db.prepare('SELECT * FROM conversations WHERE avatar_id=? LIMIT 1').get(attachmentId),
removeMember: (conversationId, userId) => db.prepare('DELETE FROM conversation_members WHERE conversation_id=? AND user_id=?').run(conversationId, userId), removeMember: (conversationId, userId) => db.prepare('DELETE FROM conversation_members WHERE conversation_id=? AND user_id=?').run(conversationId, userId),
remove: (id) => { db.prepare('DELETE FROM conversation_members WHERE conversation_id=?').run(id); db.prepare('DELETE FROM conversations WHERE id=?').run(id); }, remove: async (id) => { await db.prepare('DELETE FROM conversation_members WHERE conversation_id=?').run(id); await db.prepare('DELETE FROM conversations WHERE id=?').run(id); },
}; };
const attachments = { const attachments = {
@@ -352,8 +355,8 @@ const polls = {
const pollVotes = { const pollVotes = {
forPoll: (pollId) => db.prepare('SELECT user_id, option_idx FROM poll_votes WHERE poll_id=?').all(pollId), forPoll: (pollId) => db.prepare('SELECT user_id, option_idx FROM poll_votes WHERE poll_id=?').all(pollId),
hasVoted: (pollId, userId, idx) => !!db.prepare('SELECT 1 FROM poll_votes WHERE poll_id=? AND user_id=? AND option_idx=?').get(pollId, userId, idx), hasVoted: async (pollId, userId, idx) => !!(await db.prepare('SELECT 1 FROM poll_votes WHERE poll_id=? AND user_id=? AND option_idx=?').get(pollId, userId, idx)),
add: (pollId, userId, idx) => db.prepare('INSERT OR IGNORE INTO poll_votes (poll_id,user_id,option_idx,created_at) VALUES (?,?,?,?)').run(pollId, userId, idx, now()), add: (pollId, userId, idx) => db.prepare('INSERT INTO poll_votes (poll_id,user_id,option_idx,created_at) VALUES (?,?,?,?) ON CONFLICT(poll_id,user_id,option_idx) DO NOTHING').run(pollId, userId, idx, now()),
remove: (pollId, userId, idx) => db.prepare('DELETE FROM poll_votes WHERE poll_id=? AND user_id=? AND option_idx=?').run(pollId, userId, idx), remove: (pollId, userId, idx) => db.prepare('DELETE FROM poll_votes WHERE poll_id=? AND user_id=? AND option_idx=?').run(pollId, userId, idx),
clearUser: (pollId, userId) => db.prepare('DELETE FROM poll_votes WHERE poll_id=? AND user_id=?').run(pollId, userId), clearUser: (pollId, userId) => db.prepare('DELETE FROM poll_votes WHERE poll_id=? AND user_id=?').run(pollId, userId),
}; };
@@ -369,9 +372,9 @@ const pushSubs = {
const favorites = { const favorites = {
set: (userId, target, on) => on set: (userId, target, on) => on
? db.prepare('INSERT OR IGNORE INTO favorites (user_id,target,created_at) VALUES (?,?,?)').run(userId, target, now()) ? db.prepare('INSERT INTO favorites (user_id,target,created_at) VALUES (?,?,?) ON CONFLICT(user_id,target) DO NOTHING').run(userId, target, now())
: db.prepare('DELETE FROM favorites WHERE user_id=? AND target=?').run(userId, target), : db.prepare('DELETE FROM favorites WHERE user_id=? AND target=?').run(userId, target),
forUser: (userId) => db.prepare('SELECT target FROM favorites WHERE user_id=?').all(userId).map((r) => r.target), forUser: async (userId) => (await db.prepare('SELECT target FROM favorites WHERE user_id=?').all(userId)).map((r) => r.target),
}; };
const deviceTokens = { const deviceTokens = {
+384 -381
View File
File diff suppressed because it is too large Load Diff
+41 -29
View File
@@ -23,43 +23,55 @@ const { onConnection } = require('./signaling');
// ---------- HTTP request dispatch ---------- // ---------- HTTP request dispatch ----------
const server = http.createServer((req, res) => { const server = http.createServer((req, res) => {
const key = `${req.method} ${req.url.split('?')[0]}`; const key = `${req.method} ${req.url.split('?')[0]}`;
if (routes[key]) return routes[key](req, res); // Route/static handlers are async now (DB adapter). Catch any rejection so a handler error becomes a
if (req.method === 'GET') return handleGet(req, res); // downloads + static // 500 instead of a hung request + unhandled promise rejection.
json(res, 404, { error: 'not found' }); let p;
if (routes[key]) p = routes[key](req, res);
else if (req.method === 'GET') p = handleGet(req, res); // downloads + static
else return json(res, 404, { error: 'not found' });
if (p && typeof p.catch === 'function') p.catch((e) => { try { console.error('handler error', key, e && e.message); json(res, 500, { error: 'server error' }); } catch (_) {} });
}); });
// ---------- WebSocket signaling ---------- // ---------- WebSocket signaling ----------
const wss = new WebSocketServer({ server, path: '/ws' }); const wss = new WebSocketServer({ server, path: '/ws' });
wss.on('connection', onConnection); wss.on('connection', onConnection);
server.listen(PORT, () => { // Apply the DB schema BEFORE serving. For Postgres this creates the tables (async); for SQLite it's a
console.log(`HTTP on http://localhost:${PORT}`); // no-op (the schema is already applied synchronously when db.js is required). Serving only starts once the
try { require('./media').backfill(); } catch (e) {} // streaming renditions for older video uploads // store is ready, so the first request can never hit a missing table.
}); const db = require('./dbx');
// HTTPS — required so other devices can share their screen (browsers block function startListening() {
// screen capture on non-secure origins). Uses cert.pem/key.pem if present. server.listen(PORT, () => {
let httpsServer = null; console.log(`HTTP on http://localhost:${PORT} (db=${db.name})`);
try { try { require('./media').backfill(); } catch (e) {} // streaming renditions for older video uploads
const certPath = path.join(__dirname, 'cert.pem'); });
const keyPath = path.join(__dirname, 'key.pem');
if (fs.existsSync(certPath) && fs.existsSync(keyPath)) { // HTTPS — required so other devices can share their screen (browsers block
httpsServer = https.createServer( // screen capture on non-secure origins). Uses cert.pem/key.pem if present.
{ cert: fs.readFileSync(certPath), key: fs.readFileSync(keyPath) }, try {
(req, res) => server.emit('request', req, res) const certPath = path.join(__dirname, 'cert.pem');
); const keyPath = path.join(__dirname, 'key.pem');
const wssSecure = new WebSocketServer({ server: httpsServer, path: '/ws' }); if (fs.existsSync(certPath) && fs.existsSync(keyPath)) {
wssSecure.on('connection', onConnection); const httpsServer = https.createServer(
httpsServer.listen(HTTPS_PORT, () => { { cert: fs.readFileSync(certPath), key: fs.readFileSync(keyPath) },
console.log(`HTTPS on https://localhost:${HTTPS_PORT} (use this address from other devices)`); (req, res) => server.emit('request', req, res)
console.log(` End user shares screen: https://<this-pc-ip>:${HTTPS_PORT}/share`); );
console.log(` Technician connects: https://<this-pc-ip>:${HTTPS_PORT}/connect`); const wssSecure = new WebSocketServer({ server: httpsServer, path: '/ws' });
}); wssSecure.on('connection', onConnection);
} else { httpsServer.listen(HTTPS_PORT, () => {
console.log('(No cert.pem/key.pem found — HTTPS disabled. Other devices can view but not share their screen.)'); console.log(`HTTPS on https://localhost:${HTTPS_PORT} (use this address from other devices)`);
console.log(` End user shares screen: https://<this-pc-ip>:${HTTPS_PORT}/share`);
console.log(` Technician connects: https://<this-pc-ip>:${HTTPS_PORT}/connect`);
});
} else {
console.log('(No cert.pem/key.pem found — HTTPS disabled. Other devices can view but not share their screen.)');
}
} catch (e) {
console.log('HTTPS failed to start:', e.message);
} }
} catch (e) {
console.log('HTTPS failed to start:', e.message);
} }
db.init().then(startListening).catch((e) => { console.error('DB init failed:', (e && e.message) || e); process.exit(1); });
module.exports = { server }; module.exports = { server };
+6 -5
View File
@@ -25,27 +25,28 @@ function tokenFromReq(req) {
} }
// Resolve the logged-in user from the request. Returns user row (with mfa state) or null. // Resolve the logged-in user from the request. Returns user row (with mfa state) or null.
function currentUser(req, { requireMfa = true } = {}) { // Async: the repos it reads go through the DB adapter, so every caller must `await currentUser(...)`.
async function currentUser(req, { requireMfa = true } = {}) {
const tok = tokenFromReq(req); const tok = tokenFromReq(req);
if (!tok) return null; if (!tok) return null;
const s = R.authSessions.byToken(tok); const s = await R.authSessions.byToken(tok);
if (!s || s.expires_at < now()) return null; if (!s || s.expires_at < now()) return null;
if (requireMfa && !s.mfa_passed) return null; if (requireMfa && !s.mfa_passed) return null;
const u = R.users.byId(s.user_id); const u = await R.users.byId(s.user_id);
if (!u || u.active === 0) return null; if (!u || u.active === 0) return null;
return { ...u, _session: s }; return { ...u, _session: s };
} }
// Resolve a third-party API key from `X-API-Key` or `Authorization: Bearer bzc_...`. // Resolve a third-party API key from `X-API-Key` or `Authorization: Bearer bzc_...`.
// Returns { id, teamId, scopes:[], name } or null. Keys are prefixed `bzc_` and stored hashed. // Returns { id, teamId, scopes:[], name } or null. Keys are prefixed `bzc_` and stored hashed.
function apiKeyFromReq(req) { async function apiKeyFromReq(req) {
let raw = req.headers && req.headers['x-api-key']; let raw = req.headers && req.headers['x-api-key'];
if (!raw) { if (!raw) {
const h = req.headers && (req.headers.authorization || req.headers.Authorization); const h = req.headers && (req.headers.authorization || req.headers.Authorization);
if (h && /^Bearer\s+bzc_/i.test(h)) raw = h.replace(/^Bearer\s+/i, '').trim(); if (h && /^Bearer\s+bzc_/i.test(h)) raw = h.replace(/^Bearer\s+/i, '').trim();
} }
if (!raw || !/^bzc_/.test(raw)) return null; if (!raw || !/^bzc_/.test(raw)) return null;
const row = R.apiKeys.byHash(A.hashToken(raw)); const row = await R.apiKeys.byHash(A.hashToken(raw));
if (!row || row.revoked) return null; if (!row || row.revoked) return null;
return { id: row.id, teamId: row.team_id, scopes: String(row.scopes || '').split(',').map((s) => s.trim()).filter(Boolean), name: row.name }; return { id: row.id, teamId: row.team_id, scopes: String(row.scopes || '').split(',').map((s) => s.trim()).filter(Boolean), name: row.name };
} }
+40 -40
View File
@@ -26,15 +26,15 @@ function noteRoomStat(room, ws, size) {
if (ws._meetingUserId) st.uids.add(ws._meetingUserId); if (ws._meetingUserId) st.uids.add(ws._meetingUserId);
} }
// Called as a room is torn down. Scheduled meetings already have their own row, so they're skipped. // Called as a room is torn down. Scheduled meetings already have their own row, so they're skipped.
function persistCallHistory(room) { async function persistCallHistory(room) {
const st = roomStats.get(room); const st = roomStats.get(room);
roomStats.delete(room); roomStats.delete(room);
if (!st || !st.teamId || st.peak < 1) return; if (!st || !st.teamId || st.peak < 1) return;
try { try {
if (R.scheduledMeetings.byCode(room)) return; // the scheduled meeting row already represents this if (await R.scheduledMeetings.byCode(room)) return; // the scheduled meeting row already represents this
const gid = roomToGroupCall.get(room) || null; const gid = roomToGroupCall.get(room) || null;
const isDm = roomToDmCall.has(room); const isDm = roomToDmCall.has(room);
R.callHistory.create({ await R.callHistory.create({
id: A.id(), teamId: st.teamId, room, groupId: gid, id: A.id(), teamId: st.teamId, room, groupId: gid,
kind: isDm ? 'dm' : (gid ? 'group' : 'adhoc'), kind: isDm ? 'dm' : (gid ? 'group' : 'adhoc'),
title: isDm ? 'Direct call' : (gid ? null : 'Meeting'), title: isDm ? 'Direct call' : (gid ? null : 'Meeting'),
@@ -46,9 +46,9 @@ function persistCallHistory(room) {
// A room requires host approval for GUESTS when explicitly set (ad-hoc) or by the scheduled meeting's // A room requires host approval for GUESTS when explicitly set (ad-hoc) or by the scheduled meeting's
// `lobby` flag. Default: require approval (the "people with the link join directly" concern) unless the // `lobby` flag. Default: require approval (the "people with the link join directly" concern) unless the
// organizer chose "join directly". Logged-in tenant users are never held — only guests. // organizer chose "join directly". Logged-in tenant users are never held — only guests.
function meetingRoomRequiresApproval(room) { async function meetingRoomRequiresApproval(room) {
if (roomLobby.has(room)) return !!roomLobby.get(room); if (roomLobby.has(room)) return !!roomLobby.get(room);
try { const s = R.scheduledMeetings.byCode(room); if (s) return s.lobby !== 0; } catch (_) {} try { const s = await R.scheduledMeetings.byCode(room); if (s) return s.lobby !== 0; } catch (_) {}
return true; return true;
} }
// Actually add a newcomer to the room: tell them who's here, tell others they arrived, and (if they're // Actually add a newcomer to the room: tell them who's here, tell others they arrived, and (if they're
@@ -77,16 +77,16 @@ function onConnection(ws, req) {
}, 25000); }, 25000);
ws.on('message', (raw) => { ws.on('message', (raw) => {
let m; try { m = JSON.parse(raw); } catch { return; } let m; try { m = JSON.parse(raw); } catch { return; }
handle(ws, m, req); handle(ws, m, req).catch(() => {}); // handle is async now; never let a rejection go unhandled
}); });
ws.on('close', () => { clearInterval(hb); cleanup(ws); }); ws.on('close', () => { clearInterval(hb); cleanup(ws).catch(() => {}); });
} }
function handle(ws, m, req) { async function handle(ws, m, req) {
switch (m.type) { switch (m.type) {
// --- Logged-in user registers this socket for live chat delivery --- // --- Logged-in user registers this socket for live chat delivery ---
case 'chat-hello': { case 'chat-hello': {
const u = currentUser(req); // identity from the cookie/Bearer on the WS upgrade const u = await currentUser(req); // identity from the cookie/Bearer on the WS upgrade
if (!u) return ws.send(JSON.stringify({ type: 'error', message: 'unauthorized' })); if (!u) return ws.send(JSON.stringify({ type: 'error', message: 'unauthorized' }));
ws._chatUserId = u.id; ws._chatTeamId = u.team_id; ws._chatUserId = u.id; ws._chatTeamId = u.team_id;
CHAT.register(u.id, ws); CHAT.register(u.id, ws);
@@ -97,10 +97,10 @@ function handle(ws, m, req) {
// Recipient's client acknowledges a DM was delivered → mark it + tell the sender. // Recipient's client acknowledges a DM was delivered → mark it + tell the sender.
case 'chat-delivered': { case 'chat-delivered': {
if (!ws._chatUserId || !m.id) break; if (!ws._chatUserId || !m.id) break;
const msg = R.messages.byId(m.id); const msg = await R.messages.byId(m.id);
if (!msg || msg.conversation_id || msg.team_id !== ws._chatTeamId) break; // DMs only if (!msg || msg.conversation_id || msg.team_id !== ws._chatTeamId) break; // DMs only
if (msg.recipient_id !== ws._chatUserId) break; // only the recipient can ack if (msg.recipient_id !== ws._chatUserId) break; // only the recipient can ack
if (!msg.delivered_at) { R.messages.markDelivered(m.id); try { CHAT.pushToUser(msg.sender_id, { type: 'chat-delivered', id: m.id, with: msg.recipient_id }); } catch (_) {} } if (!msg.delivered_at) { await R.messages.markDelivered(m.id); try { CHAT.pushToUser(msg.sender_id, { type: 'chat-delivered', id: m.id, with: msg.recipient_id }); } catch (_) {} }
break; break;
} }
// Live "is typing…" — ephemeral, never persisted. Relay to the DM peer, or fan out to // Live "is typing…" — ephemeral, never persisted. Relay to the DM peer, or fan out to
@@ -108,9 +108,9 @@ function handle(ws, m, req) {
case 'chat-typing': { case 'chat-typing': {
const uid = ws._chatUserId; if (!uid) break; const uid = ws._chatUserId; if (!uid) break;
const on = !!m.on; const on = !!m.on;
let name = ''; try { const u = R.users.byId(uid); name = (u && u.name) || ''; } catch (_) {} let name = ''; try { const u = await R.users.byId(uid); name = (u && u.name) || ''; } catch (_) {}
if (m.group) { if (m.group) {
let members; try { if (!R.conversations.isMember(m.group, uid)) break; members = R.conversations.members(m.group); } catch (_) { break; } let members; try { if (!await R.conversations.isMember(m.group, uid)) break; members = await R.conversations.members(m.group); } catch (_) { break; }
for (const mid of members) { if (mid !== uid) { try { CHAT.pushToUser(mid, { type: 'chat-typing', group: m.group, from: uid, name, on }); } catch (_) {} } } for (const mid of members) { if (mid !== uid) { try { CHAT.pushToUser(mid, { type: 'chat-typing', group: m.group, from: uid, name, on }); } catch (_) {} } }
} else if (m.to) { } else if (m.to) {
try { CHAT.pushToUser(m.to, { type: 'chat-typing', from: uid, name, on }); } catch (_) {} try { CHAT.pushToUser(m.to, { type: 'chat-typing', from: uid, name, on }); } catch (_) {}
@@ -121,7 +121,7 @@ function handle(ws, m, req) {
case 'meeting-create': { case 'meeting-create': {
let code; do { code = A.numericCode(6); } while (meetingRooms.has(code)); let code; do { code = A.numericCode(6); } while (meetingRooms.has(code));
meetingRooms.set(code, new Map()); meetingRooms.set(code, new Map());
const cu = currentUser(req); if (cu) roomHost.set(code, cu.id); // ad-hoc meeting: creator = host const cu = await currentUser(req); if (cu) roomHost.set(code, cu.id); // ad-hoc meeting: creator = host
// Lobby preference for guests joining this ad-hoc room by link (default: require approval). // Lobby preference for guests joining this ad-hoc room by link (default: require approval).
roomLobby.set(code, m.lobby === false ? false : true); roomLobby.set(code, m.lobby === false ? false : true);
ws.send(JSON.stringify({ type: 'meeting-created', room: code })); ws.send(JSON.stringify({ type: 'meeting-created', room: code }));
@@ -132,7 +132,7 @@ function handle(ws, m, req) {
let peers = meetingRooms.get(room); let peers = meetingRooms.get(room);
// A scheduled meeting's room is created lazily on first join (its code lives in the DB). // A scheduled meeting's room is created lazily on first join (its code lives in the DB).
if (!peers) { if (!peers) {
const sched = R.scheduledMeetings.byCode(room); const sched = await R.scheduledMeetings.byCode(room);
if (sched && !sched.ended_at) { peers = new Map(); meetingRooms.set(room, peers); } if (sched && !sched.ended_at) { peers = new Map(); meetingRooms.set(room, peers); }
} }
if (!peers) return ws.send(JSON.stringify({ type: 'error', message: 'Meeting not found' })); if (!peers) return ws.send(JSON.stringify({ type: 'error', message: 'Meeting not found' }));
@@ -141,8 +141,8 @@ function handle(ws, m, req) {
ws.kind = 'meeting'; ws._meetingRoom = room; ws._peerId = peerId; ws._peerName = name; ws.kind = 'meeting'; ws._meetingRoom = room; ws._peerId = peerId; ws._peerName = name;
// Host = the meeting's creator. roomHost is set on call/meeting creation; scheduled meetings fall back to created_by. // Host = the meeting's creator. roomHost is set on call/meeting creation; scheduled meetings fall back to created_by.
let hostUserId = roomHost.get(room); let hostUserId = roomHost.get(room);
if (hostUserId === undefined) { try { const s = R.scheduledMeetings.byCode(room); if (s) { hostUserId = s.created_by; roomHost.set(room, hostUserId); } } catch (_) {} } if (hostUserId === undefined) { try { const s = await R.scheduledMeetings.byCode(room); if (s) { hostUserId = s.created_by; roomHost.set(room, hostUserId); } } catch (_) {} }
const ju = currentUser(req); const ju = await currentUser(req);
// Identity used to map LiveKit media → this tile (peerIdForUid). Logged-in users use their user id // Identity used to map LiveKit media → this tile (peerIdForUid). Logged-in users use their user id
// (their LiveKit token identity is the same). GUESTS have no session, so they pass a stable client // (their LiveKit token identity is the same). GUESTS have no session, so they pass a stable client
// guest id here that ALSO becomes their LiveKit token identity — otherwise their media never maps // guest id here that ALSO becomes their LiveKit token identity — otherwise their media never maps
@@ -154,7 +154,7 @@ function handle(ws, m, req) {
if (ju) ws._meetingTeamId = ju.team_id; // #7: which tenant owns this call log if (ju) ws._meetingTeamId = ju.team_id; // #7: which tenant owns this call log
// LOBBY (#4): a GUEST (no session) waits for the host to admit them when the room requires approval. // LOBBY (#4): a GUEST (no session) waits for the host to admit them when the room requires approval.
// Logged-in tenant members always join directly. // Logged-in tenant members always join directly.
if (!ju && meetingRoomRequiresApproval(room)) { if (!ju && await meetingRoomRequiresApproval(room)) {
let pend = lobbyPending.get(room); if (!pend) { pend = new Map(); lobbyPending.set(room, pend); } let pend = lobbyPending.get(room); if (!pend) { pend = new Map(); lobbyPending.set(room, pend); }
pend.set(peerId, ws); ws._lobbyRoom = room; pend.set(peerId, ws); ws._lobbyRoom = room;
ws.send(JSON.stringify({ type: 'meeting-lobby-wait' })); ws.send(JSON.stringify({ type: 'meeting-lobby-wait' }));
@@ -259,25 +259,25 @@ function handle(ws, m, req) {
break; break;
} }
case 'meeting-leave': { case 'meeting-leave': {
leaveMeeting(ws); await leaveMeeting(ws);
break; break;
} }
// --- Agent comes online --- // --- Agent comes online ---
case 'agent-hello': { case 'agent-hello': {
const machine = R.machines.byEnrollToken(m.enrollToken); const machine = await R.machines.byEnrollToken(m.enrollToken);
if (!machine) return ws.send(JSON.stringify({ type: 'error', message: 'invalid enroll token' })); if (!machine) return ws.send(JSON.stringify({ type: 'error', message: 'invalid enroll token' }));
ws.kind = 'agent'; ws.machineId = machine.id; ws.kind = 'agent'; ws.machineId = machine.id;
onlineAgents.set(machine.id, { ws, machine }); onlineAgents.set(machine.id, { ws, machine });
R.machines.touch(machine.id); await R.machines.touch(machine.id);
ws.send(JSON.stringify({ type: 'agent-registered', machineId: machine.id, name: machine.name })); ws.send(JSON.stringify({ type: 'agent-registered', machineId: machine.id, name: machine.name }));
break; break;
} }
// --- Technician requests control of a machine --- // --- Technician requests control of a machine ---
case 'viewer-connect': { case 'viewer-connect': {
const u = currentUser(req); // cookie sent on WS upgrade const u = await currentUser(req); // cookie sent on WS upgrade
if (!u) return ws.send(JSON.stringify({ type: 'error', message: 'unauthorized' })); if (!u) return ws.send(JSON.stringify({ type: 'error', message: 'unauthorized' }));
const agent = onlineAgents.get(m.machineId); const agent = onlineAgents.get(m.machineId);
const machine = R.machines.inTenant(m.machineId, u.team_id); const machine = await R.machines.inTenant(m.machineId, u.team_id);
if (!machine) return ws.send(JSON.stringify({ type: 'error', message: 'no such machine' })); if (!machine) return ws.send(JSON.stringify({ type: 'error', message: 'no such machine' }));
if (!agent) return ws.send(JSON.stringify({ type: 'error', message: 'machine offline' })); if (!agent) return ws.send(JSON.stringify({ type: 'error', message: 'machine offline' }));
if (u.role === 'viewer' && false) {} // view-only still allowed to watch; control gated agent-side if (u.role === 'viewer' && false) {} // view-only still allowed to watch; control gated agent-side
@@ -301,7 +301,7 @@ function handle(ws, m, req) {
if (m.granted) { if (m.granted) {
audit({ team_id: sess.machine.team_id, user_id: sess.user.id, user_email: sess.user.email, machine_id: sess.machine.id, machine_name: sess.machine.name, action: 'consent_granted', detail: sess.ticket ? 'Ticket ' + sess.ticket : (sess.machine.id ? null : 'Direct session') }); audit({ team_id: sess.machine.team_id, user_id: sess.user.id, user_email: sess.user.email, machine_id: sess.machine.id, machine_name: sess.machine.name, action: 'consent_granted', detail: sess.ticket ? 'Ticket ' + sess.ticket : (sess.machine.id ? null : 'Direct session') });
try { try {
R.sessionsLog.create({ id: m.sessionId, tenantId: sess.machine.team_id, agentEmail: sess.user.email, agentName: sess.agentName || sess.user.email, ticket: sess.ticket || null }); await R.sessionsLog.create({ id: m.sessionId, tenantId: sess.machine.team_id, agentEmail: sess.user.email, agentName: sess.agentName || sess.user.email, ticket: sess.ticket || null });
} catch (e) { /* duplicate consent */ } } catch (e) { /* duplicate consent */ }
try { W.emit('session.started', sess.machine.team_id, { sessionId: m.sessionId, agent_email: sess.user.email, agent_name: sess.agentName || sess.user.email, ticket: sess.ticket || null, started_at: Date.now() }); } catch (_) {} try { W.emit('session.started', sess.machine.team_id, { sessionId: m.sessionId, agent_email: sess.user.email, agent_name: sess.agentName || sess.user.email, ticket: sess.ticket || null, started_at: Date.now() }); } catch (_) {}
sess.viewerWs.send(JSON.stringify({ type: 'session-ready', sessionId: m.sessionId })); sess.viewerWs.send(JSON.stringify({ type: 'session-ready', sessionId: m.sessionId }));
@@ -325,7 +325,7 @@ function handle(ws, m, req) {
} }
// --- Logged-in agent enters the code (+ ticket) to connect --- // --- Logged-in agent enters the code (+ ticket) to connect ---
case 'code-connect': { case 'code-connect': {
const agent = currentUser(req); // identity from the agent's authenticated session const agent = await currentUser(req); // identity from the agent's authenticated session
if (!agent) { if (!agent) {
return ws.send(JSON.stringify({ type: 'error', message: 'Please sign in as an agent first' })); return ws.send(JSON.stringify({ type: 'error', message: 'Please sign in as an agent first' }));
} }
@@ -370,18 +370,18 @@ function handle(ws, m, req) {
break; break;
} }
case 'end-session': { case 'end-session': {
endSession(ws.sessionId, m.reason || null); await endSession(ws.sessionId, m.reason || null);
break; break;
} }
} }
} }
function endSession(sessionId, reason) { async function endSession(sessionId, reason) {
const sess = liveSessions.get(sessionId); const sess = liveSessions.get(sessionId);
if (!sess) return; if (!sess) return;
try { R.sessionsLog.end(sessionId); } catch (e) {} try { await R.sessionsLog.end(sessionId); } catch (e) {}
try { try {
const row = R.sessionsLog.byId(sessionId); const row = await R.sessionsLog.byId(sessionId);
if (row) W.emit('session.ended', sess.machine.team_id, { sessionId: row.id, agent_email: row.agent_email, if (row) W.emit('session.ended', sess.machine.team_id, { sessionId: row.id, agent_email: row.agent_email,
agent_name: row.agent_name, ticket: row.ticket, started_at: row.started_at, ended_at: row.ended_at, agent_name: row.agent_name, ticket: row.ticket, started_at: row.started_at, ended_at: row.ended_at,
duration_ms: row.ended_at ? row.ended_at - row.started_at : null }); duration_ms: row.ended_at ? row.ended_at - row.started_at : null });
@@ -393,7 +393,7 @@ function endSession(sessionId, reason) {
liveSessions.delete(sessionId); liveSessions.delete(sessionId);
} }
function leaveMeeting(ws) { async function leaveMeeting(ws) {
const room = ws._meetingRoom; const room = ws._meetingRoom;
if (!room) return; if (!room) return;
const peers = meetingRooms.get(room); const peers = meetingRooms.get(room);
@@ -401,34 +401,34 @@ function leaveMeeting(ws) {
const pid = ws._peerId; const pid = ws._peerId;
const leaverId = ws._meetingUserId; const leaverId = ws._meetingUserId;
if (!peers) { if (leaverId) CHAT.broadcastPresence(leaverId); return; } if (!peers) { if (leaverId) CHAT.broadcastPresence(leaverId); return; }
try { require('./calls').finalizeTranscript(room, ws._meetingUserId); } catch (_) {} // save THIS user's transcript try { await require('./calls').finalizeTranscript(room, ws._meetingUserId); } catch (_) {} // save THIS user's transcript
peers.delete(pid); peers.delete(pid);
// 1:1 call: when either party leaves, end it for everyone (a DM call has no "remaining" call). // 1:1 call: when either party leaves, end it for everyone (a DM call has no "remaining" call).
if (roomToDmCall.has(room)) { if (roomToDmCall.has(room)) {
const others = [...peers.values()].map((p) => p.ws && p.ws._meetingUserId).filter(Boolean); const others = [...peers.values()].map((p) => p.ws && p.ws._meetingUserId).filter(Boolean);
for (const [, p] of peers) { if (p.ws.readyState === 1) { try { p.ws.send(JSON.stringify({ type: 'meeting-ended' })); } catch (_) {} p.ws._meetingRoom = null; } } for (const [, p] of peers) { if (p.ws.readyState === 1) { try { p.ws.send(JSON.stringify({ type: 'meeting-ended' })); } catch (_) {} p.ws._meetingRoom = null; } }
persistCallHistory(room); // #7: log the call BEFORE endCallByRoom clears roomToDmCall/roomToGroupCall await persistCallHistory(room); // #7: log the call BEFORE endCallByRoom clears roomToDmCall/roomToGroupCall
meetingRooms.delete(room); meetingRooms.delete(room);
try { require('./calls').finalizeTranscript(room); } catch (_) {} // any remaining buffers (safety) try { await require('./calls').finalizeTranscript(room); } catch (_) {} // any remaining buffers (safety)
roomHost.delete(room); roomHost.delete(room);
try { require('./calls').endCallByRoom(room); } catch (_) {} try { await require('./calls').endCallByRoom(room); } catch (_) {}
if (leaverId) CHAT.broadcastPresence(leaverId); if (leaverId) CHAT.broadcastPresence(leaverId);
others.forEach((uid) => CHAT.broadcastPresence(uid)); // both parties are now out of the call → live update others.forEach((uid) => CHAT.broadcastPresence(uid)); // both parties are now out of the call → live update
return; return;
} }
for (const [, p] of peers) { if (p.ws.readyState === 1) p.ws.send(JSON.stringify({ type: 'meeting-peer-left', peerId: pid })); } for (const [, p] of peers) { if (p.ws.readyState === 1) p.ws.send(JSON.stringify({ type: 'meeting-peer-left', peerId: pid })); }
if (peers.size === 0) { if (peers.size === 0) {
persistCallHistory(room); // #7: log the finished call (before the room maps are cleared) await persistCallHistory(room); // #7: log the finished call (before the room maps are cleared)
meetingRooms.delete(room); meetingRooms.delete(room);
lobbyPending.delete(room); roomLobby.delete(room); // room gone → drop its lobby state lobbyPending.delete(room); roomLobby.delete(room); // room gone → drop its lobby state
try { require('./calls').finalizeTranscript(room); } catch (_) {} // before endCallByRoom clears the maps try { await require('./calls').finalizeTranscript(room); } catch (_) {} // before endCallByRoom clears the maps
roomHost.delete(room); roomHost.delete(room);
try { require('./calls').endCallByRoom(room); } catch (_) {} try { await require('./calls').endCallByRoom(room); } catch (_) {}
} }
if (leaverId) CHAT.broadcastPresence(leaverId); // this user left the call → update contacts live if (leaverId) CHAT.broadcastPresence(leaverId); // this user left the call → update contacts live
} }
function cleanup(ws) { async function cleanup(ws) {
const goneUserId = ws._chatUserId; // capture before unregister so we can announce the change const goneUserId = ws._chatUserId; // capture before unregister so we can announce the change
// A guest waiting in the lobby dropped → remove their pending request and tell the host to clear it. // A guest waiting in the lobby dropped → remove their pending request and tell the host to clear it.
if (ws._lobbyRoom) { if (ws._lobbyRoom) {
@@ -438,14 +438,14 @@ function cleanup(ws) {
ws._lobbyRoom = null; ws._lobbyRoom = null;
} }
CHAT.unregister(ws); CHAT.unregister(ws);
leaveMeeting(ws); await leaveMeeting(ws);
if (goneUserId) CHAT.broadcastPresence(goneUserId); // now reflects offline / no-longer-in-call if (goneUserId) CHAT.broadcastPresence(goneUserId); // now reflects offline / no-longer-in-call
if (ws.kind === 'agent' && ws.machineId) onlineAgents.delete(ws.machineId); if (ws.kind === 'agent' && ws.machineId) onlineAgents.delete(ws.machineId);
if (ws.kind === 'sharer' && ws.shareCode) pendingShares.delete(ws.shareCode); if (ws.kind === 'sharer' && ws.shareCode) pendingShares.delete(ws.shareCode);
if (ws.sessionId) { if (ws.sessionId) {
for (const [sid, sess] of liveSessions) { for (const [sid, sess] of liveSessions) {
if (sess.agentWs === ws || sess.viewerWs === ws) endSession(sid); if (sess.agentWs === ws || sess.viewerWs === ws) await endSession(sid);
} }
} }
} }
+32 -27
View File
@@ -12,27 +12,32 @@ const MIME = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css
// Authorize an attachment id: the uploader, a member of the group using it as an avatar, or a participant of // Authorize an attachment id: the uploader, a member of the group using it as an avatar, or a participant of
// ANY message carrying it (the "any" covers forwarded attachments, which reuse the same id). Returns the row. // ANY message carrying it (the "any" covers forwarded attachments, which reuse the same id). Returns the row.
function authAttachmentRaw(id, u) { async function authAttachmentRaw(id, u) {
const a = R.attachments.byId(id); const a = await R.attachments.byId(id);
if (!a || a.team_id !== u.team_id) return null; if (!a || a.team_id !== u.team_id) return null;
const avatarGroup = R.conversations.byAvatar(id); const avatarGroup = await R.conversations.byAvatar(id);
const carriers = R.messages.allByAttachment(id); const carriers = await R.messages.allByAttachment(id);
const ok = a.uploader_id === u.id let ok = a.uploader_id === u.id || (avatarGroup && await R.conversations.isMember(avatarGroup.id, u.id));
|| (avatarGroup && R.conversations.isMember(avatarGroup.id, u.id)) if (!ok) {
|| carriers.some((msg) => msg.conversation_id // A .some() predicate can't await, so walk the carriers explicitly.
? R.conversations.isMember(msg.conversation_id, u.id) for (const msg of carriers) {
: (msg.sender_id === u.id || msg.recipient_id === u.id)); const carried = msg.conversation_id
? await R.conversations.isMember(msg.conversation_id, u.id)
: (msg.sender_id === u.id || msg.recipient_id === u.id);
if (carried) { ok = true; break; }
}
}
return ok ? a : null; return ok ? a : null;
} }
// Video playback fires MANY /files Range requests, and authAttachmentRaw scans messages by attachment_id // Video playback fires MANY /files Range requests, and authAttachmentRaw scans messages by attachment_id
// (un-indexed) each time — that per-chunk scan is what made playback stutter ("buffers and plays…"). Cache the // each time — that per-chunk scan is what made playback stutter ("buffers and plays…"). Cache the resolved
// decision per user+attachment for 60s so range requests after the first are ~free. Bounded to keep memory flat. // decision per user+attachment for 60s so range requests after the first are ~free. Bounded to keep memory flat.
const _attAuth = new Map(); const _attAuth = new Map();
function authAttachment(id, u) { async function authAttachment(id, u) {
const key = u.id + ':' + id, now = Date.now(); const key = u.id + ':' + id, now = Date.now();
const hit = _attAuth.get(key); const hit = _attAuth.get(key);
if (hit && hit.exp > now) return hit.a; if (hit && hit.exp > now) return hit.a;
const a = authAttachmentRaw(id, u); const a = await authAttachmentRaw(id, u);
if (_attAuth.size > 4000) _attAuth.clear(); if (_attAuth.size > 4000) _attAuth.clear();
_attAuth.set(key, { a, exp: now + 60000 }); _attAuth.set(key, { a, exp: now + 60000 });
return a; return a;
@@ -100,7 +105,7 @@ function serveStatic(req, res) {
} }
// GET fallback: authenticated transcript/recording downloads, else static files. // GET fallback: authenticated transcript/recording downloads, else static files.
function handleGet(req, res) { async function handleGet(req, res) {
const pathOnly = req.url.split('?')[0]; const pathOnly = req.url.split('?')[0];
// Stable "latest Windows installer" link (used by the site's Download button). Reads the // Stable "latest Windows installer" link (used by the site's Download button). Reads the
// electron-updater manifest and redirects to the current versioned .exe. // electron-updater manifest and redirects to the current versioned .exe.
@@ -141,11 +146,11 @@ function handleGet(req, res) {
}); });
} }
if (pathOnly.startsWith('/transcripts/')) { if (pathOnly.startsWith('/transcripts/')) {
const u = currentUser(req); const u = await currentUser(req);
if (!u) return json(res, 401, { error: 'unauthorized' }); if (!u) return json(res, 401, { error: 'unauthorized' });
const name = path.basename(decodeURIComponent(pathOnly)); const name = path.basename(decodeURIComponent(pathOnly));
const sid = name.replace(/\.txt$/i, ''); const sid = name.replace(/\.txt$/i, '');
const row = R.sessionsLog.byIdInTenant(sid, u.team_id); const row = await R.sessionsLog.byIdInTenant(sid, u.team_id);
if (!row || !row.transcript) return json(res, 404, { error: 'not found' }); if (!row || !row.transcript) return json(res, 404, { error: 'not found' });
const fp = path.join(TRANS_DIR, row.transcript); const fp = path.join(TRANS_DIR, row.transcript);
if (!fp.startsWith(TRANS_DIR)) return json(res, 403, { error: 'forbidden' }); if (!fp.startsWith(TRANS_DIR)) return json(res, 403, { error: 'forbidden' });
@@ -158,11 +163,11 @@ function handleGet(req, res) {
}); });
} }
if (pathOnly.startsWith('/recordings/')) { if (pathOnly.startsWith('/recordings/')) {
const u = currentUser(req); const u = await currentUser(req);
if (!u) return json(res, 401, { error: 'unauthorized' }); if (!u) return json(res, 401, { error: 'unauthorized' });
const name = path.basename(decodeURIComponent(pathOnly)); const name = path.basename(decodeURIComponent(pathOnly));
const sid = name.replace(/\.(webm|mp4)$/i, ''); const sid = name.replace(/\.(webm|mp4)$/i, '');
const row = R.sessionsLog.byIdInTenant(sid, u.team_id); const row = await R.sessionsLog.byIdInTenant(sid, u.team_id);
if (!row || !row.recording) return json(res, 404, { error: 'not found' }); if (!row || !row.recording) return json(res, 404, { error: 'not found' });
const fp = path.join(REC_DIR, row.recording); const fp = path.join(REC_DIR, row.recording);
if (!fp.startsWith(REC_DIR)) return json(res, 403, { error: 'forbidden' }); if (!fp.startsWith(REC_DIR)) return json(res, 403, { error: 'forbidden' });
@@ -179,16 +184,16 @@ function handleGet(req, res) {
// Meeting recordings & transcripts (/mrec/<id>). Visible to the creator, group members, or those // Meeting recordings & transcripts (/mrec/<id>). Visible to the creator, group members, or those
// who can see the scheduled meeting it belongs to. // who can see the scheduled meeting it belongs to.
if (pathOnly.startsWith('/mrec/')) { if (pathOnly.startsWith('/mrec/')) {
const u = currentUser(req); const u = await currentUser(req);
if (!u) return json(res, 401, { error: 'unauthorized' }); if (!u) return json(res, 401, { error: 'unauthorized' });
const id = path.basename(decodeURIComponent(pathOnly)); const id = path.basename(decodeURIComponent(pathOnly));
const r = R.recordings.byId(id); const r = await R.recordings.byId(id);
if (!r || r.team_id !== u.team_id || !r.file) return json(res, 404, { error: 'not found' }); if (!r || r.team_id !== u.team_id || !r.file) return json(res, 404, { error: 'not found' });
let allowed = r.created_by === u.id; let allowed = r.created_by === u.id;
if (r.kind === 'transcript') allowed = r.created_by === u.id; // transcripts are private to their owner if (r.kind === 'transcript') allowed = r.created_by === u.id; // transcripts are private to their owner
else { else {
if (!allowed && r.group_id) allowed = R.conversations.isMember(r.group_id, u.id); if (!allowed && r.group_id) allowed = await R.conversations.isMember(r.group_id, u.id);
if (!allowed && r.meeting_id) { const s = R.scheduledMeetings.byId(r.meeting_id); if (s) allowed = s.created_by === u.id || (s.participants && s.participants.includes('"' + u.id + '"')); } if (!allowed && r.meeting_id) { const s = await R.scheduledMeetings.byId(r.meeting_id); if (s) allowed = s.created_by === u.id || (s.participants && s.participants.includes('"' + u.id + '"')); }
} }
if (!allowed) return json(res, 403, { error: 'forbidden' }); if (!allowed) return json(res, 403, { error: 'forbidden' });
const isVideo = r.kind === 'video'; const isVideo = r.kind === 'video';
@@ -208,10 +213,10 @@ function handleGet(req, res) {
// Video POSTER thumbnail — first frame extracted with ffmpeg, cached next to the file. Cosmetic: if ffmpeg // Video POSTER thumbnail — first frame extracted with ffmpeg, cached next to the file. Cosmetic: if ffmpeg
// is missing or fails we 404 and the <video> just falls back to its own (black) poster. // is missing or fails we 404 and the <video> just falls back to its own (black) poster.
if (pathOnly.startsWith('/thumbs/')) { if (pathOnly.startsWith('/thumbs/')) {
const u = currentUser(req); const u = await currentUser(req);
if (!u) return json(res, 401, { error: 'unauthorized' }); if (!u) return json(res, 401, { error: 'unauthorized' });
const id = path.basename(decodeURIComponent(pathOnly)); const id = path.basename(decodeURIComponent(pathOnly));
const a = authAttachment(id, u); const a = await authAttachment(id, u);
if (!a) return json(res, 404, { error: 'not found' }); if (!a) return json(res, 404, { error: 'not found' });
if (!/^video\//.test(a.mime || '')) return json(res, 404, { error: 'not a video' }); if (!/^video\//.test(a.mime || '')) return json(res, 404, { error: 'not a video' });
const src = path.join(UPLOADS_DIR, id); const src = path.join(UPLOADS_DIR, id);
@@ -236,10 +241,10 @@ function handleGet(req, res) {
// back to the original bytes while that is still transcoding, so a video is never unplayable. The // back to the original bytes while that is still transcoding, so a video is never unplayable. The
// download button keeps pointing at /files, which always serves the untouched original. // download button keeps pointing at /files, which always serves the untouched original.
if (pathOnly.startsWith('/stream/')) { if (pathOnly.startsWith('/stream/')) {
const u = currentUser(req); const u = await currentUser(req);
if (!u) return json(res, 401, { error: 'unauthorized' }); if (!u) return json(res, 401, { error: 'unauthorized' });
const id = path.basename(decodeURIComponent(pathOnly)); const id = path.basename(decodeURIComponent(pathOnly));
const a = authAttachment(id, u); const a = await authAttachment(id, u);
if (!a) return json(res, 404, { error: 'not found' }); if (!a) return json(res, 404, { error: 'not found' });
media.ensureWebRendition(id, a.mime); // idempotent — also backfills pre-existing uploads media.ensureWebRendition(id, a.mime); // idempotent — also backfills pre-existing uploads
const ready = media.hasWebRendition(id); const ready = media.hasWebRendition(id);
@@ -253,10 +258,10 @@ function handleGet(req, res) {
}); });
} }
if (pathOnly.startsWith('/files/')) { if (pathOnly.startsWith('/files/')) {
const u = currentUser(req); const u = await currentUser(req);
if (!u) return json(res, 401, { error: 'unauthorized' }); if (!u) return json(res, 401, { error: 'unauthorized' });
const id = path.basename(decodeURIComponent(pathOnly)); const id = path.basename(decodeURIComponent(pathOnly));
const a = authAttachment(id, u); const a = await authAttachment(id, u);
if (!a) return json(res, 404, { error: 'not found' }); if (!a) return json(res, 404, { error: 'not found' });
const fp = path.join(UPLOADS_DIR, id); const fp = path.join(UPLOADS_DIR, id);
if (!fp.startsWith(UPLOADS_DIR)) return json(res, 403, { error: 'forbidden' }); if (!fp.startsWith(UPLOADS_DIR)) return json(res, 403, { error: 'forbidden' });
+3 -3
View File
@@ -34,14 +34,14 @@ function deliver(url, secret, body, onDone) {
go(); go();
} }
function emit(event, tenantId, payload) { async function emit(event, tenantId, payload) {
const body = JSON.stringify({ event, ...payload }); const body = JSON.stringify({ event, ...payload });
// Per-tenant subscriptions // Per-tenant subscriptions
try { try {
for (const h of R.webhooks.activeForTenant(tenantId)) { for (const h of await R.webhooks.activeForTenant(tenantId)) {
const subs = String(h.events || '').split(',').map((s) => s.trim()); const subs = String(h.events || '').split(',').map((s) => s.trim());
if (subs.includes('*') || subs.includes(event)) { if (subs.includes('*') || subs.includes(event)) {
deliver(h.url, h.secret, body, (r) => { try { R.webhooks.setStatus(h.id, r.ok ? 1 : 0, r.err || ('HTTP ' + r.status)); } catch (_) {} }); deliver(h.url, h.secret, body, async (r) => { try { await R.webhooks.setStatus(h.id, r.ok ? 1 : 0, r.err || ('HTTP ' + r.status)); } catch (_) {} });
} }
} }
} catch (_) {} } catch (_) {}