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>
This commit is contained in:
2026-07-24 22:06:27 +05:30
parent 2460c0f9eb
commit 3250530596
8 changed files with 158 additions and 146 deletions
+13 -13
View File
@@ -11,7 +11,7 @@ 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 = await R.scheduledMeetings.byCode(room); const sched = await R.scheduledMeetings.byCode(room);
@@ -26,12 +26,12 @@ 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) {
@@ -49,17 +49,17 @@ 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 await 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();
await 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 = await 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));
@@ -68,27 +68,27 @@ function startGroupCall(group, teamId, user) {
// 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(); 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 (_) {} 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 = await 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 = await 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 { await 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 };
@@ -118,7 +118,7 @@ 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);
@@ -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
+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;
+2 -2
View File
@@ -3,7 +3,7 @@
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 = await 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
@@ -11,7 +11,7 @@ function tick() {
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 { await 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 (_) {} });
await R.scheduledMeetings.markReminded(s.id); await R.scheduledMeetings.markReminded(s.id);
+94 -91
View File
@@ -12,7 +12,10 @@ const MSG_MAX = 4000;
const parseMentions = (s) => { if (!s) return []; try { const a = JSON.parse(s); return Array.isArray(a) ? a : []; } catch { return []; } }; const parseMentions = (s) => { if (!s) return []; try { const a = JSON.parse(s); return Array.isArray(a) ? a : []; } catch { return []; } };
const SYSTEM_SENDER = '__system__'; const SYSTEM_SENDER = '__system__';
const msgDTO = (m) => ({ id: m.id, from: m.sender_id, to: m.recipient_id, conversation_id: m.conversation_id || null, body: m.deleted ? '' : m.body, created_at: m.created_at, read_at: m.read_at, delivered_at: m.delivered_at || null, reply_to: m.deleted ? null : (m.reply_to || null), mentions: parseMentions(m.mentions), evt: m.msg_type || null, fwd_from: m.deleted ? null : (m.fwd_from || null), deleted: !!m.deleted, system: m.sender_id === SYSTEM_SENDER || !!m.msg_type }); const msgDTO = (m) => ({ id: m.id, from: m.sender_id, to: m.recipient_id, conversation_id: m.conversation_id || null, body: m.deleted ? '' : m.body, created_at: m.created_at, read_at: m.read_at, delivered_at: m.delivered_at || null, reply_to: m.deleted ? null : (m.reply_to || null), mentions: parseMentions(m.mentions), evt: m.msg_type || null, fwd_from: m.deleted ? null : (m.fwd_from || null), deleted: !!m.deleted, system: m.sender_id === SYSTEM_SENDER || !!m.msg_type });
function namesFor(teamId){ const o = {}; for (const x of await R.users.listByTenant(teamId)) o[x.id] = x.name || x.email; return o; } async function namesFor(teamId){ const o = {}; for (const x of await R.users.listByTenant(teamId)) o[x.id] = x.name || x.email; return o; }
// Await-aware filter: keep items whose async predicate resolves truthy (these predicates hit the DB, and a
// plain .filter() can't await). Sequential so per-item DB order is deterministic.
async function asyncFilter(arr, pred){ const out = []; for (const x of arr) { if (await pred(x)) out.push(x); } return out; }
// id -> profile photo, with a fallback across DUPLICATE rows for the same person. // id -> profile photo, with a fallback across DUPLICATE rows for the same person.
// //
// A person can end up with more than one row (signed in by email once and by mobile another time, before // A person can end up with more than one row (signed in by email once and by mobile another time, before
@@ -20,7 +23,7 @@ function namesFor(teamId){ const o = {}; for (const x of await R.users.listByTen
// the row WITH the photo while a DM referenced the one without — so the same contact showed their picture // the row WITH the photo while a DM referenced the one without — so the same contact showed their picture
// in a group but fell back to initials in the 1:1. Key each row by its stable person identity (BizGaze // in a group but fell back to initials in the 1:1. Key each row by its stable person identity (BizGaze
// person id, else email, else name) and let a photo-less row borrow its twin's photo. // person id, else email, else name) and let a photo-less row borrow its twin's photo.
function avatarsFor(teamId) { async function avatarsFor(teamId) {
const users = await R.users.listByTenant(teamId); const users = await R.users.listByTenant(teamId);
const em = (x) => (x.email ? String(x.email).toLowerCase() : ''); const em = (x) => (x.email ? String(x.email).toLowerCase() : '');
const nm = (x) => String(x.name || '').trim().toLowerCase(); const nm = (x) => String(x.name || '').trim().toLowerCase();
@@ -48,15 +51,15 @@ function nextOccurrence(baseTs, days, nowTs){ const b = new Date(baseTs); const
const RDAY = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; const RDAY = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
function recurrenceLabel(days){ if (!days || !days.length) return ''; if (days.length === 7) return 'Every day'; return 'Every ' + days.slice().sort().map((d) => RDAY[d]).join(', '); } function recurrenceLabel(days){ if (!days || !days.length) return ''; if (days.length === 7) return 'Every day'; return 'Every ' + days.slice().sort().map((d) => RDAY[d]).join(', '); }
// Post a centered "activity" line into a group (member added/removed/renamed/left) and push it. // Post a centered "activity" line into a group (member added/removed/renamed/left) and push it.
function postSystemMessage(conversationId, teamId, text){ async function postSystemMessage(conversationId, teamId, text){
const id = A.id(); const id = A.id();
await R.messages.send({ id, teamId, senderId: SYSTEM_SENDER, recipientId: '', body: text, conversationId }); await R.messages.send({ id, teamId, senderId: SYSTEM_SENDER, recipientId: '', body: text, conversationId });
const dto = buildMsgDTO(await R.messages.byId(id), {}, ''); const dto = await buildMsgDTO(await R.messages.byId(id), {}, '');
for (const mid of await R.conversations.members(conversationId)) { try { CHAT.pushToUser(mid, { type: 'chat-message', message: dto }); } catch (_) {} } for (const mid of await R.conversations.members(conversationId)) { try { CHAT.pushToUser(mid, { type: 'chat-message', message: dto }); } catch (_) {} }
return dto; return dto;
} }
// Tell clients a group's membership changed so they refresh the member count / sidebar immediately. // Tell clients a group's membership changed so they refresh the member count / sidebar immediately.
function pushGroupUpdate(group, alsoUsers){ async function pushGroupUpdate(group, alsoUsers){
const seen = new Set(); const seen = new Set();
for (const mid of await R.conversations.members(group)) { seen.add(mid); try { CHAT.pushToUser(mid, { type: 'group-update', group }); } catch (_) {} } for (const mid of await R.conversations.members(group)) { seen.add(mid); try { CHAT.pushToUser(mid, { type: 'group-update', group }); } catch (_) {} }
for (const mid of (alsoUsers || [])) { if (!seen.has(mid)) { try { CHAT.pushToUser(mid, { type: 'group-update', group, removed: true }); } catch (_) {} } } for (const mid of (alsoUsers || [])) { if (!seen.has(mid)) { try { CHAT.pushToUser(mid, { type: 'group-update', group, removed: true }); } catch (_) {} } }
@@ -74,12 +77,12 @@ function groupReactions(list, userId, names){
} }
const dtoReactions = (rxBy, id) => (rxBy[id] ? Object.entries(rxBy[id]).map(([emoji, v]) => ({ emoji, count: v.count, mine: v.mine, who: v.who })) : []); const dtoReactions = (rxBy, id) => (rxBy[id] ? Object.entries(rxBy[id]).map(([emoji, v]) => ({ emoji, count: v.count, mine: v.mine, who: v.who })) : []);
// Full reaction DTO for ONE message, from `userId`'s perspective (mine/who). // Full reaction DTO for ONE message, from `userId`'s perspective (mine/who).
function reactionsForMessage(messageId, userId, names){ async function reactionsForMessage(messageId, userId, names){
const rows = await R.reactions.forMessage(messageId).map((r) => ({ message_id: messageId, user_id: r.user_id, emoji: r.emoji })); const rows = (await R.reactions.forMessage(messageId)).map((r) => ({ message_id: messageId, user_id: r.user_id, emoji: r.emoji }));
return dtoReactions(groupReactions(rows, userId, names), messageId); return dtoReactions(groupReactions(rows, userId, names), messageId);
} }
// Poll tally for a given viewer ("mine" = this user voted that option). // Poll tally for a given viewer ("mine" = this user voted that option).
function buildPollDTO(poll, userId){ async function buildPollDTO(poll, userId){
let opts = []; try { opts = JSON.parse(poll.options); } catch { opts = []; } let opts = []; try { opts = JSON.parse(poll.options); } catch { opts = []; }
const counts = opts.map(() => 0); const mine = opts.map(() => false); const voters = new Set(); const counts = opts.map(() => 0); const mine = opts.map(() => false); const voters = new Set();
for (const v of await R.pollVotes.forPoll(poll.id)) { for (const v of await R.pollVotes.forPoll(poll.id)) {
@@ -93,7 +96,7 @@ function buildPollDTO(poll, userId){
}; };
} }
// DTO enriched with a small preview of the quoted message (if this is a reply). // DTO enriched with a small preview of the quoted message (if this is a reply).
function buildMsgDTO(m, names, userId){ async function buildMsgDTO(m, names, userId){
const d = msgDTO(m); const d = msgDTO(m);
if (m.reply_to) { if (m.reply_to) {
const r = await R.messages.byId(m.reply_to); const r = await R.messages.byId(m.reply_to);
@@ -103,7 +106,7 @@ function buildMsgDTO(m, names, userId){
const a = await R.attachments.byId(m.attachment_id); const a = await R.attachments.byId(m.attachment_id);
if (a) d.attachment = { id: a.id, name: a.name, mime: a.mime, size: a.size, isImage: /^image\//.test(a.mime || ''), isVideo: /^video\//.test(a.mime || ''), isAudio: /^audio\//.test(a.mime || '') }; if (a) d.attachment = { id: a.id, name: a.name, mime: a.mime, size: a.size, isImage: /^image\//.test(a.mime || ''), isVideo: /^video\//.test(a.mime || ''), isAudio: /^audio\//.test(a.mime || '') };
} }
if (m.poll_id) { const p = await R.polls.byId(m.poll_id); if (p) d.poll = buildPollDTO(p, userId); } if (m.poll_id) { const p = await R.polls.byId(m.poll_id); if (p) d.poll = await buildPollDTO(p, userId); }
if (m.msg_type) d.byName = (names && names[m.sender_id]) || ''; if (m.msg_type) d.byName = (names && names[m.sender_id]) || '';
return d; return d;
} }
@@ -152,7 +155,7 @@ function livekitToken(identity, name, room, metadata) {
} }
// Issue a refresh token (native clients), store only its hash, return the plaintext once. // Issue a refresh token (native clients), store only its hash, return the plaintext once.
function issueRefreshToken(userId) { async function issueRefreshToken(userId) {
const rtok = A.token(32); const rtok = A.token(32);
await R.refreshTokens.create({ userId, tokenHash: A.hashToken(rtok), ttl: REFRESH_TTL }); await R.refreshTokens.create({ userId, tokenHash: A.hashToken(rtok), ttl: REFRESH_TTL });
return rtok; return rtok;
@@ -193,7 +196,7 @@ route('POST', '/api/mfa/enable', async (req, res) => {
// Emails that must always be admins regardless of what BizGaze returns (safety net so an // Emails that must always be admins regardless of what BizGaze returns (safety net so an
// admin can't be locked out of the report if BizGaze doesn't flag them isAdmin). Optional. // admin can't be locked out of the report if BizGaze doesn't flag them isAdmin). Optional.
const ADMIN_EMAILS = (process.env.ADMIN_EMAILS || '').split(',').map((s) => s.trim().toLowerCase()).filter(Boolean); const ADMIN_EMAILS = (process.env.ADMIN_EMAILS || '').split(',').map((s) => s.trim().toLowerCase()).filter(Boolean);
function provisionFromBizgaze(email, bz) { async function provisionFromBizgaze(email, bz) {
const role = (bz.isAdmin || ADMIN_EMAILS.includes(String(email).toLowerCase())) ? 'admin' : 'technician'; const role = (bz.isAdmin || ADMIN_EMAILS.includes(String(email).toLowerCase())) ? 'admin' : 'technician';
const bizId = bz.bizgazeUserId || null; const bizId = bz.bizgazeUserId || null;
@@ -257,7 +260,7 @@ route('POST', '/api/login', async (req, res) => {
const bz = await BZ.validateLogin(email, password); const bz = await BZ.validateLogin(email, password);
if (bz.error) return json(res, 503, { error: bz.error }); if (bz.error) return json(res, 503, { error: bz.error });
if (!bz.ok) return json(res, 401, { error: bz.message || 'Username or password do not match.' }); if (!bz.ok) return json(res, 401, { error: bz.message || 'Username or password do not match.' });
u = provisionFromBizgaze(email, bz); u = await provisionFromBizgaze(email, bz);
if (u && u.active === 0) return json(res, 403, { error: 'This account has been deactivated' }); if (u && u.active === 0) return json(res, 403, { error: 'This account has been deactivated' });
} else { } else {
// Local/dev/tests, or ALLOW_LOCAL_LOGIN=1: verify the local password, then fall back // Local/dev/tests, or ALLOW_LOCAL_LOGIN=1: verify the local password, then fall back
@@ -265,7 +268,7 @@ route('POST', '/api/login', async (req, res) => {
u = (existing && A.verifyPassword(password, existing.pw_salt, existing.pw_hash)) ? existing : null; u = (existing && A.verifyPassword(password, existing.pw_salt, existing.pw_hash)) ? existing : null;
if (!u) { if (!u) {
const bz = await BZ.validateLogin(email, password); const bz = await BZ.validateLogin(email, password);
if (bz.ok) u = provisionFromBizgaze(email, bz); if (bz.ok) u = await provisionFromBizgaze(email, bz);
else if (bz.error) return json(res, 503, { error: bz.error }); else if (bz.error) return json(res, 503, { error: bz.error });
else bzMsg = bz.message || null; // BizGaze configured and rejected the credentials else bzMsg = bz.message || null; // BizGaze configured and rejected the credentials
} }
@@ -283,7 +286,7 @@ route('POST', '/api/login', async (req, res) => {
audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'login' }); audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'login' });
// Cookie for the web app; access token + refresh token in the body for native // Cookie for the web app; access token + refresh token in the body for native
// desktop/mobile clients (access via `Authorization: Bearer`, refresh via /api/v1/auth/refresh). // desktop/mobile clients (access via `Authorization: Bearer`, refresh via /api/v1/auth/refresh).
const refreshToken = issueRefreshToken(u.id); const refreshToken = await issueRefreshToken(u.id);
json(res, 200, { ok: true, mfaRequired: false, token: tok, expiresAt: now() + ttl, refreshToken, refreshExpiresAt: now() + REFRESH_TTL }); json(res, 200, { ok: true, mfaRequired: false, token: tok, expiresAt: now() + ttl, refreshToken, refreshExpiresAt: now() + REFRESH_TTL });
}); });
@@ -300,7 +303,7 @@ route('POST', '/api/auth/refresh', async (req, res) => {
await R.refreshTokens.revoke(h); // rotate: one-time use await R.refreshTokens.revoke(h); // rotate: one-time use
const tok = A.token(); const tok = A.token();
await R.authSessions.create({ token: tok, userId: u.id, mfaPassed: true, ttl: SESSION_TTL }); await R.authSessions.create({ token: tok, userId: u.id, mfaPassed: true, ttl: SESSION_TTL });
const newRefresh = issueRefreshToken(u.id); const newRefresh = await issueRefreshToken(u.id);
json(res, 200, { ok: true, token: tok, expiresAt: now() + SESSION_TTL, refreshToken: newRefresh, refreshExpiresAt: now() + REFRESH_TTL }); json(res, 200, { ok: true, token: tok, expiresAt: now() + SESSION_TTL, refreshToken: newRefresh, refreshExpiresAt: now() + REFRESH_TTL });
}); });
@@ -702,7 +705,7 @@ route('POST', '/api/recording', async (req, res) => {
if (!row) return json(res, 404, { error: 'no such session' }); if (!row) return json(res, 404, { error: 'no such session' });
const chunks = []; let total = 0, aborted = false; const chunks = []; let total = 0, aborted = false;
req.on('data', (c) => { total += c.length; if (total > MAX_REC_BYTES) { aborted = true; req.destroy(); return; } chunks.push(c); }); req.on('data', (c) => { total += c.length; if (total > MAX_REC_BYTES) { aborted = true; req.destroy(); return; } chunks.push(c); });
req.on('end', () => { req.on('end', async () => {
if (aborted) return json(res, 413, { error: 'recording too large' }); if (aborted) return json(res, 413, { error: 'recording too large' });
const fname = sid + '.' + ext; const fname = sid + '.' + ext;
try { try {
@@ -724,7 +727,7 @@ route('POST', '/api/transcript', async (req, res) => {
if (!row) return json(res, 404, { error: 'no such session' }); if (!row) return json(res, 404, { error: 'no such session' });
const chunks = []; let total = 0, aborted = false; const chunks = []; let total = 0, aborted = false;
req.on('data', (c) => { total += c.length; if (total > 5 * 1024 * 1024) { aborted = true; req.destroy(); return; } chunks.push(c); }); req.on('data', (c) => { total += c.length; if (total > 5 * 1024 * 1024) { aborted = true; req.destroy(); return; } chunks.push(c); });
req.on('end', () => { req.on('end', async () => {
if (aborted) return json(res, 413, { error: 'transcript too large' }); if (aborted) return json(res, 413, { error: 'transcript too large' });
const fname = sid + '.txt'; const fname = sid + '.txt';
try { try {
@@ -741,8 +744,8 @@ route('POST', '/api/transcript', async (req, res) => {
route('GET', '/api/messages/contacts', async (req, res) => { route('GET', '/api/messages/contacts', async (req, res) => {
const u = await currentUser(req); const u = await currentUser(req);
if (!u) return json(res, 401, { error: 'unauthorized' }); if (!u) return json(res, 401, { error: 'unauthorized' });
const rows = await R.users.listByTenant(u.team_id).filter((x) => x.id !== u.id && x.active !== 0); const rows = (await R.users.listByTenant(u.team_id)).filter((x) => x.id !== u.id && x.active !== 0);
const cAv = avatarsFor(u.team_id); // duplicate-row DP fallback const cAv = await avatarsFor(u.team_id); // duplicate-row DP fallback
json(res, 200, rows.map((x) => ({ id: x.id, name: x.name || x.email, email: x.email, online: CHAT.isOnline(x.id), avatar: cAv[x.id] || null, lastSeen: x.last_seen || null, status: x.status || 'active' }))); json(res, 200, rows.map((x) => ({ id: x.id, name: x.name || x.email, email: x.email, online: CHAT.isOnline(x.id), avatar: cAv[x.id] || null, lastSeen: x.last_seen || null, status: x.status || 'active' })));
}); });
@@ -755,7 +758,7 @@ route('GET', '/api/directory/search', async (req, res) => {
if (q.length < 2) return json(res, 200, []); if (q.length < 2) return json(res, 200, []);
const results = await require('./directory').search(q); const results = await require('./directory').search(q);
// Map directory people to existing Connect users in this tenant (by email) so they're chat-ready. // Map directory people to existing Connect users in this tenant (by email) so they're chat-ready.
const mine = await R.users.listByTenant(u.team_id).filter((x) => x.id !== u.id && x.active !== 0); const mine = (await R.users.listByTenant(u.team_id)).filter((x) => x.id !== u.id && x.active !== 0);
const byEmail = new Map(mine.map((x) => [(x.email || '').toLowerCase(), x])); const byEmail = new Map(mine.map((x) => [(x.email || '').toLowerCase(), x]));
const out = results.map((p) => { const out = results.map((p) => {
const local = p.email ? byEmail.get(p.email.toLowerCase()) : null; const local = p.email ? byEmail.get(p.email.toLowerCase()) : null;
@@ -774,7 +777,7 @@ route('GET', '/api/messages/conversations', async (req, res) => {
const statuses = {}; const statuses = {};
const seen = {}; const seen = {};
for (const x of await R.users.listByTenant(u.team_id)) { names[x.id] = x.name || x.email; statuses[x.id] = x.status || 'active'; seen[x.id] = x.last_seen || null; } for (const x of await R.users.listByTenant(u.team_id)) { names[x.id] = x.name || x.email; statuses[x.id] = x.status || 'active'; seen[x.id] = x.last_seen || null; }
Object.assign(avatars, avatarsFor(u.team_id)); // same person / two rows → borrow the DP (see avatarsFor) Object.assign(avatars, await avatarsFor(u.team_id)); // same person / two rows → borrow the DP (see avatarsFor)
const favs = new Set(await R.favorites.forUser(u.id)); const favs = new Set(await R.favorites.forUser(u.id));
const inCall = new Set(); const inCall = new Set();
for (const [, peers] of meetingRooms) { for (const [, p] of peers) { if (p.ws && p.ws._meetingUserId) inCall.add(p.ws._meetingUserId); } } for (const [, peers] of meetingRooms) { for (const [, p] of peers) { if (p.ws && p.ws._meetingUserId) inCall.add(p.ws._meetingUserId); } }
@@ -785,7 +788,7 @@ route('GET', '/api/messages/conversations', async (req, res) => {
if (!raw) continue; if (!raw) continue;
// If this counterparty was merged away, key the row by the SURVIVING account, so the thread carries // If this counterparty was merged away, key the row by the SURVIVING account, so the thread carries
// that account's name/photo/presence (and two half-threads for one person collapse into one row). // that account's name/photo/presence (and two half-threads for one person collapse into one row).
const other = (() => { try { return await R.users.resolve(raw) || raw; } catch (_) { return raw; } })(); let other; try { other = await R.users.resolve(raw) || raw; } catch (_) { other = raw; }
if (!byOther.has(other)) byOther.set(other, { other, last: m, unread: 0 }); if (!byOther.has(other)) byOther.set(other, { other, last: m, unread: 0 });
if (m.recipient_id === u.id && (m.sender_id === raw || m.sender_id === other) && !m.read_at) byOther.get(other).unread++; if (m.recipient_id === u.id && (m.sender_id === raw || m.sender_id === other) && !m.read_at) byOther.get(other).unread++;
} }
@@ -798,7 +801,7 @@ route('GET', '/api/messages/conversations', async (req, res) => {
last_status: c.last.sender_id === u.id ? (c.last.read_at ? 'read' : (c.last.delivered_at ? 'delivered' : 'sent')) : null, // tick for my last message last_status: c.last.sender_id === u.id ? (c.last.read_at ? 'read' : (c.last.delivered_at ? 'delivered' : 'sent')) : null, // tick for my last message
}; }); }; });
// Groups // Groups
const groupItems = await R.conversations.listForUser(u.team_id, u.id).map((g) => { const groupItems = await Promise.all((await R.conversations.listForUser(u.team_id, u.id)).map(async (g) => {
const last = await R.messages.lastInConversation(g.id); const last = await R.messages.lastInConversation(g.id);
const since = await R.conversations.lastReadAt(g.id, u.id); const since = await R.conversations.lastReadAt(g.id, u.id);
const members = await R.conversations.members(g.id); const members = await R.conversations.members(g.id);
@@ -807,8 +810,8 @@ route('GET', '/api/messages/conversations', async (req, res) => {
let gStatus = null; let gStatus = null;
if (last && last.sender_id === u.id) { if (last && last.sender_id === u.id) {
const others = members.filter((id) => id !== u.id).length; const others = members.filter((id) => id !== u.id).length;
const seen = await R.conversations.memberReads(g.id).filter((r) => r.user_id !== u.id && r.last_read_at >= last.created_at).length; const seenN = (await R.conversations.memberReads(g.id)).filter((r) => r.user_id !== u.id && r.last_read_at >= last.created_at).length;
gStatus = (others > 0 && seen >= others) ? 'read' : (seen > 0 ? 'delivered' : 'sent'); gStatus = (others > 0 && seenN >= others) ? 'read' : (seenN > 0 ? 'delivered' : 'sent');
} }
return { return {
kind: 'group', id: g.id, name: g.name || 'Group', members: members.length, avatar: g.avatar_id ? ('/files/' + g.avatar_id) : null, favorite: favs.has('group:' + g.id), kind: 'group', id: g.id, name: g.name || 'Group', members: members.length, avatar: g.avatar_id ? ('/files/' + g.avatar_id) : null, favorite: favs.has('group:' + g.id),
@@ -817,7 +820,7 @@ route('GET', '/api/messages/conversations', async (req, res) => {
last_from_me: last ? last.sender_id === u.id : false, unread: last ? await R.messages.unreadInConversation(g.id, u.id, since) : 0, last_from_me: last ? last.sender_id === u.id : false, unread: last ? await R.messages.unreadInConversation(g.id, u.id, since) : 0,
last_status: gStatus, last_status: gStatus,
}; };
}); }));
json(res, 200, [...dmItems, ...groupItems].sort((a, b) => b.last_at - a.last_at)); json(res, 200, [...dmItems, ...groupItems].sort((a, b) => b.last_at - a.last_at));
}); });
@@ -828,7 +831,7 @@ route('GET', '/api/messages/thread', async (req, res) => {
const q = new URLSearchParams(req.url.split('?')[1] || ''); const q = new URLSearchParams(req.url.split('?')[1] || '');
const peek = !!q.get('peek'); // prefetch only — do NOT mark the conversation read const peek = !!q.get('peek'); // prefetch only — do NOT mark the conversation read
const before = parseInt(q.get('before') || '', 10) || null; // pagination cursor: fetch messages OLDER than this created_at const before = parseInt(q.get('before') || '', 10) || null; // pagination cursor: fetch messages OLDER than this created_at
const names = namesFor(u.team_id); const names = await namesFor(u.team_id);
const group = q.get('group'); const group = q.get('group');
if (group) { if (group) {
if (!await R.conversations.isMember(group, u.id)) return json(res, 403, { error: 'not a member of this group' }); if (!await R.conversations.isMember(group, u.id)) return json(res, 403, { error: 'not a member of this group' });
@@ -841,12 +844,12 @@ route('GET', '/api/messages/thread', async (req, res) => {
} }
const rxBy = groupReactions(await R.reactions.forConversation(group), u.id, names); const rxBy = groupReactions(await R.reactions.forConversation(group), u.id, names);
const reads = await R.conversations.memberReads(group); // ALL members' read times (#2: seen-by visible to everyone) const reads = await R.conversations.memberReads(group); // ALL members' read times (#2: seen-by visible to everyone)
return json(res, 200, rows.map((m) => { return json(res, 200, await Promise.all(rows.map(async (m) => {
const d = buildMsgDTO(m, names, u.id); d.fromName = names[m.sender_id] || ''; d.reactions = dtoReactions(rxBy, m.id); const d = await buildMsgDTO(m, names, u.id); d.fromName = names[m.sender_id] || ''; d.reactions = dtoReactions(rxBy, m.id);
// Who has read this message (excluding its sender) — shown to every member, not just the sender. // Who has read this message (excluding its sender) — shown to every member, not just the sender.
d.seenBy = reads.filter((r) => r.user_id !== m.sender_id && r.last_read_at >= m.created_at).map((r) => names[r.user_id] || 'Someone'); d.seenBy = reads.filter((r) => r.user_id !== m.sender_id && r.last_read_at >= m.created_at).map((r) => names[r.user_id] || 'Someone');
return d; return d;
})); })));
} }
const other = await R.users.resolve(q.get('with')); // follow a merge redirect so a stale peer id still loads the thread const other = await R.users.resolve(q.get('with')); // follow a merge redirect so a stale peer id still loads the thread
if (!other) return json(res, 400, { error: 'with or group required' }); if (!other) return json(res, 400, { error: 'with or group required' });
@@ -854,7 +857,7 @@ route('GET', '/api/messages/thread', async (req, res) => {
const rows = await R.messages.thread(u.team_id, u.id, other, 40, before); // page size (latest 40 / older via ?before) — matches client PAGE for smooth open + lazy load const rows = await R.messages.thread(u.team_id, u.id, other, 40, before); // page size (latest 40 / older via ?before) — matches client PAGE for smooth open + lazy load
if (!peek && !before) { await R.messages.markRead(u.team_id, u.id, other); try { CHAT.pushToUser(other, { type: 'chat-read', by: u.id }); } catch (_) {} try { CHAT.pushToUser(u.id, { type: 'notif-clear', kind: 'dm', id: other }); } catch (_) {} } // #13 if (!peek && !before) { await R.messages.markRead(u.team_id, u.id, other); try { CHAT.pushToUser(other, { type: 'chat-read', by: u.id }); } catch (_) {} try { CHAT.pushToUser(u.id, { type: 'notif-clear', kind: 'dm', id: other }); } catch (_) {} } // #13
const rxBy = groupReactions(await R.reactions.forPair(u.team_id, u.id, other), u.id, names); const rxBy = groupReactions(await R.reactions.forPair(u.team_id, u.id, other), u.id, names);
return json(res, 200, rows.map((m) => { const d = buildMsgDTO(m, names, u.id); d.reactions = dtoReactions(rxBy, m.id); return d; })); return json(res, 200, await Promise.all(rows.map(async (m) => { const d = await buildMsgDTO(m, names, u.id); d.reactions = dtoReactions(rxBy, m.id); return d; })));
}); });
// Search the ENTIRE thread (not just the loaded window). Returns matching message ids + timestamps, // Search the ENTIRE thread (not just the loaded window). Returns matching message ids + timestamps,
@@ -886,7 +889,7 @@ route('POST', '/api/groups', async (req, res) => {
const { name, memberIds } = await readBody(req); const { name, memberIds } = await readBody(req);
const nm = String(name || '').trim().slice(0, 80); const nm = String(name || '').trim().slice(0, 80);
if (!nm) return json(res, 400, { error: 'group name required' }); if (!nm) return json(res, 400, { error: 'group name required' });
const ids = (Array.isArray(memberIds) ? memberIds : []).filter((x) => typeof x === 'string' && x !== u.id && await R.users.inTenant(x, u.team_id)); const ids = await asyncFilter((Array.isArray(memberIds) ? memberIds : []), async (x) => typeof x === 'string' && x !== u.id && await R.users.inTenant(x, u.team_id));
const id = A.id(); const id = A.id();
await R.conversations.create({ id, teamId: u.team_id, name: nm, createdBy: u.id }); await R.conversations.create({ id, teamId: u.team_id, name: nm, createdBy: u.id });
await R.conversations.addMember(id, u.id, true); // creator is the first admin await R.conversations.addMember(id, u.id, true); // creator is the first admin
@@ -903,9 +906,9 @@ route('GET', '/api/groups/members', async (req, res) => {
if (!gid || !await R.conversations.isMember(gid, u.id)) return json(res, 403, { error: 'not a member' }); if (!gid || !await R.conversations.isMember(gid, u.id)) return json(res, 403, { error: 'not a member' });
const names = {}; const avatars = {}; const names = {}; const avatars = {};
for (const x of await R.users.listByTenant(u.team_id)) { names[x.id] = x.name || x.email; } for (const x of await R.users.listByTenant(u.team_id)) { names[x.id] = x.name || x.email; }
Object.assign(avatars, avatarsFor(u.team_id)); Object.assign(avatars, await avatarsFor(u.team_id));
const adminSet = new Set(await R.conversations.admins(gid)); const adminSet = new Set(await R.conversations.admins(gid));
json(res, 200, await R.conversations.members(gid).map((mid) => ({ id: mid, name: names[mid] || 'Unknown', avatar: avatars[mid] || null, admin: adminSet.has(mid) }))); json(res, 200, (await R.conversations.members(gid)).map((mid) => ({ id: mid, name: names[mid] || 'Unknown', avatar: avatars[mid] || null, admin: adminSet.has(mid) })));
}); });
// Full group info: name, creator flag, members (with isMe). // Full group info: name, creator flag, members (with isMe).
@@ -918,7 +921,7 @@ route('GET', '/api/groups/info', async (req, res) => {
const tenantUsers = await R.users.listByTenant(u.team_id); const tenantUsers = await R.users.listByTenant(u.team_id);
const names = {}; const avatars = {}; const names = {}; const avatars = {};
for (const x of tenantUsers) { names[x.id] = x.name || x.email; } for (const x of tenantUsers) { names[x.id] = x.name || x.email; }
Object.assign(avatars, avatarsFor(u.team_id)); Object.assign(avatars, await avatarsFor(u.team_id));
const adminSet = new Set(await R.conversations.admins(gid)); const adminSet = new Set(await R.conversations.admins(gid));
json(res, 200, { json(res, 200, {
id: gid, name: g.name || 'Group', createdBy: g.created_by, isCreator: g.created_by === u.id, id: gid, name: g.name || 'Group', createdBy: g.created_by, isCreator: g.created_by === u.id,
@@ -926,7 +929,7 @@ route('GET', '/api/groups/info', async (req, res) => {
adminOnly: !!g.admin_only, callActive: groupCalls.has(gid), callRoom: (groupCalls.get(gid) || {}).room || null, adminOnly: !!g.admin_only, callActive: groupCalls.has(gid), callRoom: (groupCalls.get(gid) || {}).room || null,
createdByName: names[g.created_by] || 'Someone', createdAt: g.created_at, createdByName: names[g.created_by] || 'Someone', createdAt: g.created_at,
avatar: g.avatar_id ? ('/files/' + g.avatar_id) : null, avatar: g.avatar_id ? ('/files/' + g.avatar_id) : null,
members: await R.conversations.members(gid).map((mid) => ({ id: mid, name: names[mid] || 'Unknown', avatar: avatars[mid] || null, isMe: mid === u.id, admin: adminSet.has(mid) })), members: (await R.conversations.members(gid)).map((mid) => ({ id: mid, name: names[mid] || 'Unknown', avatar: avatars[mid] || null, isMe: mid === u.id, admin: adminSet.has(mid) })),
}); });
}); });
@@ -939,7 +942,7 @@ route('POST', '/api/groups/rename', async (req, res) => {
const nm = String(name || '').trim().slice(0, 80); const nm = String(name || '').trim().slice(0, 80);
if (!nm) return json(res, 400, { error: 'group name required' }); if (!nm) return json(res, 400, { error: 'group name required' });
await R.conversations.rename(group, nm); await R.conversations.rename(group, nm);
postSystemMessage(group, u.team_id, (u.name || u.email) + ' renamed the group to “' + nm + '”'); await postSystemMessage(group, u.team_id, (u.name || u.email) + ' renamed the group to “' + nm + '”');
audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'group_renamed', detail: nm }); audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'group_renamed', detail: nm });
json(res, 200, { ok: true, name: nm }); json(res, 200, { ok: true, name: nm });
}); });
@@ -951,7 +954,7 @@ route('POST', '/api/groups/call/start', async (req, res) => {
if (!u) return json(res, 401, { error: 'unauthorized' }); if (!u) return json(res, 401, { error: 'unauthorized' });
const { group } = await readBody(req); const { group } = await readBody(req);
if (!group || !await R.conversations.isMember(group, u.id)) return json(res, 403, { error: 'not a member of this group' }); if (!group || !await R.conversations.isMember(group, u.id)) return json(res, 403, { error: 'not a member of this group' });
const r = CALLS.startGroupCall(group, u.team_id, u); const r = await CALLS.startGroupCall(group, u.team_id, u);
json(res, 200, r); json(res, 200, r);
}); });
@@ -961,7 +964,7 @@ route('POST', '/api/calls/dm/start', async (req, res) => {
if (!u) return json(res, 401, { error: 'unauthorized' }); if (!u) return json(res, 401, { error: 'unauthorized' });
const { to } = await readBody(req); const { to } = await readBody(req);
if (!to || !await R.users.inTenant(to, u.team_id)) return json(res, 404, { error: 'no such contact' }); if (!to || !await R.users.inTenant(to, u.team_id)) return json(res, 404, { error: 'no such contact' });
json(res, 200, CALLS.startDmCall(u, to, u.team_id)); json(res, 200, await CALLS.startDmCall(u, to, u.team_id));
}); });
// Invite more people into the call I'm in (turns a 1:1 into multi-party). Pushes them an // Invite more people into the call I'm in (turns a 1:1 into multi-party). Pushes them an
@@ -971,7 +974,7 @@ route('POST', '/api/calls/invite', async (req, res) => {
if (!u) return json(res, 401, { error: 'unauthorized' }); if (!u) return json(res, 401, { error: 'unauthorized' });
const { room, userIds } = await readBody(req); const { room, userIds } = await readBody(req);
if (!room || !meetingRooms.has(String(room))) return json(res, 404, { error: 'call not found' }); if (!room || !meetingRooms.has(String(room))) return json(res, 404, { error: 'call not found' });
const ids = (Array.isArray(userIds) ? userIds : []).filter((x) => typeof x === 'string' && x !== u.id && await R.users.inTenant(x, u.team_id)); const ids = await asyncFilter((Array.isArray(userIds) ? userIds : []), async (x) => typeof x === 'string' && x !== u.id && await R.users.inTenant(x, u.team_id));
for (const id of ids) { try { CHAT.pushToUser(id, { type: 'call-invite', room: String(room), byName: (u.name || u.email) }); } catch (_) {} } for (const id of ids) { try { CHAT.pushToUser(id, { type: 'call-invite', room: String(room), byName: (u.name || u.email) }); } catch (_) {} }
json(res, 200, { ok: true, invited: ids.length }); json(res, 200, { ok: true, invited: ids.length });
}); });
@@ -1045,7 +1048,7 @@ route('POST', '/api/meetings/guest-token', async (req, res) => {
// #3 Link expiry: a scheduled meeting's guest link is only valid until ~2h after its scheduled end — // #3 Link expiry: a scheduled meeting's guest link is only valid until ~2h after its scheduled end —
// after that the link is dead (returns 404) even though the DB row lingers. Live rooms are valid while // after that the link is dead (returns 404) even though the DB row lingers. Live rooms are valid while
// anyone's in them (they vanish from meetingRooms when empty), which is its own natural expiry. // anyone's in them (they vanish from meetingRooms when empty), which is its own natural expiry.
const sched = (() => { const sched = await (async () => {
try { try {
const s = await R.scheduledMeetings.byCode(rm); const s = await R.scheduledMeetings.byCode(rm);
if (!s || s.ended_at) return false; if (!s || s.ended_at) return false;
@@ -1068,7 +1071,7 @@ route('POST', '/api/calls/decline', async (req, res) => {
if (!u) return json(res, 401, { error: 'unauthorized' }); if (!u) return json(res, 401, { error: 'unauthorized' });
const { room } = await readBody(req); const { room } = await readBody(req);
if (!room) return json(res, 400, { error: 'room required' }); if (!room) return json(res, 400, { error: 'room required' });
json(res, 200, CALLS.declineDmCall(String(room), u)); json(res, 200, await CALLS.declineDmCall(String(room), u));
}); });
// Toggle "only admins can add/remove members" (any admin). // Toggle "only admins can add/remove members" (any admin).
@@ -1080,7 +1083,7 @@ route('POST', '/api/groups/admin-only', async (req, res) => {
if (!g || g.team_id !== u.team_id || !await R.conversations.isMember(group, u.id)) return json(res, 403, { error: 'not a member' }); if (!g || g.team_id !== u.team_id || !await R.conversations.isMember(group, u.id)) return json(res, 403, { error: 'not a member' });
if (!await R.conversations.isAdmin(group, u.id)) return json(res, 403, { error: 'only a group admin can change this' }); if (!await R.conversations.isAdmin(group, u.id)) return json(res, 403, { error: 'only a group admin can change this' });
await R.conversations.setAdminOnly(group, !!value); await R.conversations.setAdminOnly(group, !!value);
postSystemMessage(group, u.team_id, (u.name || u.email) + (value ? ' restricted adding members to admins only' : ' allowed everyone to add members')); await postSystemMessage(group, u.team_id, (u.name || u.email) + (value ? ' restricted adding members to admins only' : ' allowed everyone to add members'));
json(res, 200, { ok: true, adminOnly: !!value }); json(res, 200, { ok: true, adminOnly: !!value });
}); });
@@ -1093,11 +1096,11 @@ route('POST', '/api/groups/admin', async (req, res) => {
if (!g || g.team_id !== u.team_id || !await R.conversations.isMember(group, u.id)) return json(res, 403, { error: 'not a member' }); if (!g || g.team_id !== u.team_id || !await R.conversations.isMember(group, u.id)) return json(res, 403, { error: 'not a member' });
if (!await R.conversations.isAdmin(group, u.id)) return json(res, 403, { error: 'only a group admin can change roles' }); if (!await R.conversations.isAdmin(group, u.id)) return json(res, 403, { error: 'only a group admin can change roles' });
if (!userId || !await R.conversations.isMember(group, userId)) return json(res, 404, { error: 'not a member of this group' }); if (!userId || !await R.conversations.isMember(group, userId)) return json(res, 404, { error: 'not a member of this group' });
if (!value && await R.conversations.admins(group).length <= 1 && await R.conversations.isAdmin(group, userId)) return json(res, 400, { error: 'a group must have at least one admin' }); if (!value && (await R.conversations.admins(group)).length <= 1 && await R.conversations.isAdmin(group, userId)) return json(res, 400, { error: 'a group must have at least one admin' });
await R.conversations.setMemberAdmin(group, userId, !!value); await R.conversations.setMemberAdmin(group, userId, !!value);
const names = namesFor(u.team_id); const names = await namesFor(u.team_id);
postSystemMessage(group, u.team_id, (u.name || u.email) + (value ? ' made ' + (names[userId] || 'someone') + ' an admin' : ' removed ' + (names[userId] || 'someone') + ' as admin')); await postSystemMessage(group, u.team_id, (u.name || u.email) + (value ? ' made ' + (names[userId] || 'someone') + ' an admin' : ' removed ' + (names[userId] || 'someone') + ' as admin'));
pushGroupUpdate(group); await pushGroupUpdate(group);
try { CHAT.pushToUser(userId, { type: 'group-role', group, admin: !!value, by: u.name || u.email }); } catch (_) {} // notify the affected member try { CHAT.pushToUser(userId, { type: 'group-role', group, admin: !!value, by: u.name || u.email }); } catch (_) {} // notify the affected member
json(res, 200, { ok: true }); json(res, 200, { ok: true });
}); });
@@ -1127,13 +1130,13 @@ route('POST', '/api/groups/add', async (req, res) => {
if (!group || !await R.conversations.isMember(group, u.id)) return json(res, 403, { error: 'not a member' }); if (!group || !await R.conversations.isMember(group, u.id)) return json(res, 403, { error: 'not a member' });
const gA = await R.conversations.byId(group); const gA = await R.conversations.byId(group);
if (gA && gA.admin_only && !await R.conversations.isAdmin(group, u.id)) return json(res, 403, { error: 'Only a group admin can add members' }); if (gA && gA.admin_only && !await R.conversations.isAdmin(group, u.id)) return json(res, 403, { error: 'Only a group admin can add members' });
const ids = (Array.isArray(memberIds) ? memberIds : []).filter((x) => typeof x === 'string' && await R.users.inTenant(x, u.team_id) && !await R.conversations.isMember(group, x)); const ids = await asyncFilter((Array.isArray(memberIds) ? memberIds : []), async (x) => typeof x === 'string' && await R.users.inTenant(x, u.team_id) && !await R.conversations.isMember(group, x));
for (const mid of ids) await R.conversations.addMember(group, mid); for (const mid of ids) await R.conversations.addMember(group, mid);
if (ids.length) { if (ids.length) {
const names = namesFor(u.team_id); const names = await namesFor(u.team_id);
postSystemMessage(group, u.team_id, (u.name || u.email) + ' added ' + ids.map((x) => names[x] || 'someone').join(', ')); await postSystemMessage(group, u.team_id, (u.name || u.email) + ' added ' + ids.map((x) => names[x] || 'someone').join(', '));
} }
if (ids.length) pushGroupUpdate(group); // live member-count refresh for everyone (incl. the new members) if (ids.length) await pushGroupUpdate(group); // live member-count refresh for everyone (incl. the new members)
audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'group_members_added', detail: ids.length + ' to ' + group }); audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'group_members_added', detail: ids.length + ' to ' + group });
json(res, 200, { ok: true, added: ids.length }); json(res, 200, { ok: true, added: ids.length });
}); });
@@ -1150,24 +1153,24 @@ route('POST', '/api/groups/remove', async (req, res) => {
if (!isSelf) { const gR = await R.conversations.byId(group); if (gR && gR.admin_only && !await R.conversations.isAdmin(group, u.id)) return json(res, 403, { error: 'Only a group admin can remove members' }); } if (!isSelf) { const gR = await R.conversations.byId(group); if (gR && gR.admin_only && !await R.conversations.isAdmin(group, u.id)) return json(res, 403, { error: 'Only a group admin can remove members' }); }
const wasAdmin = await R.conversations.isAdmin(group, target); const wasAdmin = await R.conversations.isAdmin(group, target);
// #10: the last admin must hand off to a chosen successor before leaving (no auto-assign). // #10: the last admin must hand off to a chosen successor before leaving (no auto-assign).
const others = await R.conversations.members(group).filter((m) => m !== target); const others = (await R.conversations.members(group)).filter((m) => m !== target);
if (wasAdmin && others.length && await R.conversations.admins(group).filter((a) => a !== target).length === 0) { if (wasAdmin && others.length && (await R.conversations.admins(group)).filter((a) => a !== target).length === 0) {
if (!newAdmin || !await R.conversations.isMember(group, newAdmin) || newAdmin === target) return json(res, 400, { error: 'NEED_ADMIN', message: 'Choose a member to be the new admin before leaving.' }); if (!newAdmin || !await R.conversations.isMember(group, newAdmin) || newAdmin === target) return json(res, 400, { error: 'NEED_ADMIN', message: 'Choose a member to be the new admin before leaving.' });
await R.conversations.setMemberAdmin(group, newAdmin, true); await R.conversations.setMemberAdmin(group, newAdmin, true);
const names0 = namesFor(u.team_id); const names0 = await namesFor(u.team_id);
postSystemMessage(group, u.team_id, (names0[newAdmin] || 'A member') + ' is now an admin'); await postSystemMessage(group, u.team_id, (names0[newAdmin] || 'A member') + ' is now an admin');
try { CHAT.pushToUser(newAdmin, { type: 'group-role', group, admin: true }); } catch (_) {} try { CHAT.pushToUser(newAdmin, { type: 'group-role', group, admin: true }); } catch (_) {}
} }
// Post the activity BEFORE removing, so the removed person's tab also receives it. // Post the activity BEFORE removing, so the removed person's tab also receives it.
if (target !== u.id && await R.conversations.isMember(group, target)) { if (target !== u.id && await R.conversations.isMember(group, target)) {
const names = namesFor(u.team_id); const names = await namesFor(u.team_id);
postSystemMessage(group, u.team_id, (u.name || u.email) + ' removed ' + (names[target] || 'someone')); await postSystemMessage(group, u.team_id, (u.name || u.email) + ' removed ' + (names[target] || 'someone'));
} else if (isSelf) { } else if (isSelf) {
postSystemMessage(group, u.team_id, (u.name || u.email) + ' left the group'); await postSystemMessage(group, u.team_id, (u.name || u.email) + ' left the group');
} }
await R.conversations.removeMember(group, target); await R.conversations.removeMember(group, target);
if (await R.conversations.members(group).length === 0) { await R.conversations.remove(group); } // drop empty groups if ((await R.conversations.members(group)).length === 0) { await R.conversations.remove(group); } // drop empty groups
else pushGroupUpdate(group, [target]); // live member-count refresh; the removed person drops the group else await pushGroupUpdate(group, [target]); // live member-count refresh; the removed person drops the group
audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: isSelf ? 'group_left' : 'group_member_removed', detail: group }); audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: isSelf ? 'group_left' : 'group_member_removed', detail: group });
json(res, 200, { ok: true, left: isSelf }); json(res, 200, { ok: true, left: isSelf });
}); });
@@ -1193,7 +1196,7 @@ route('POST', '/api/meetings/schedule', async (req, res) => {
} }
const desc = String(description || '').trim().slice(0, 1000); const desc = String(description || '').trim().slice(0, 1000);
// Invited participants: tenant users, excluding the host (creator). // Invited participants: tenant users, excluding the host (creator).
const invited = [...new Set((Array.isArray(participants) ? participants : []).filter((x) => typeof x === 'string' && x !== u.id && await R.users.inTenant(x, u.team_id)))]; const invited = [...new Set(await asyncFilter((Array.isArray(participants) ? participants : []), async (x) => typeof x === 'string' && x !== u.id && await R.users.inTenant(x, u.team_id)))];
// External invitees by email (#4): people not on Connect — they get an emailed guest link. // External invitees by email (#4): people not on Connect — they get an emailed guest link.
const guestEmails = [...new Set((Array.isArray(participantEmails) ? participantEmails : []).map((e) => String(e || '').trim().toLowerCase()).filter(isEmail))].slice(0, 100); const guestEmails = [...new Set((Array.isArray(participantEmails) ? participantEmails : []).map((e) => String(e || '').trim().toLowerCase()).filter(isEmail))].slice(0, 100);
let code; do { code = A.numericCode(6); } while (await R.scheduledMeetings.byCode(code) || meetingRooms.has(code)); let code; do { code = A.numericCode(6); } while (await R.scheduledMeetings.byCode(code) || meetingRooms.has(code));
@@ -1204,7 +1207,7 @@ route('POST', '/api/meetings/schedule', async (req, res) => {
if (groupId) { if (groupId) {
const mid = A.id(); const mid = A.id();
await R.messages.send({ id: mid, teamId: u.team_id, senderId: u.id, recipientId: '', body: '📅 Scheduled a call: ' + t + ' — ' + label, conversationId: groupId }); await R.messages.send({ id: mid, teamId: u.team_id, senderId: u.id, recipientId: '', body: '📅 Scheduled a call: ' + t + ' — ' + label, conversationId: groupId });
const dto = buildMsgDTO(await R.messages.byId(mid), namesFor(u.team_id), u.id); dto.fromName = u.name || u.email; const dto = await buildMsgDTO(await R.messages.byId(mid), await namesFor(u.team_id), u.id); dto.fromName = u.name || u.email;
for (const m of await R.conversations.members(groupId)) { try { CHAT.pushToUser(m, { type: 'chat-message', message: dto }); } catch (_) {} } for (const m of await R.conversations.members(groupId)) { try { CHAT.pushToUser(m, { type: 'chat-message', message: dto }); } catch (_) {} }
} }
// Invitation notification to each invited participant. // Invitation notification to each invited participant.
@@ -1231,9 +1234,9 @@ route('POST', '/api/meetings/schedule', async (req, res) => {
route('GET', '/api/meetings', async (req, res) => { route('GET', '/api/meetings', async (req, res) => {
const u = await currentUser(req); const u = await currentUser(req);
if (!u) return json(res, 401, { error: 'unauthorized' }); if (!u) return json(res, 401, { error: 'unauthorized' });
const names = namesFor(u.team_id); const names = await namesFor(u.team_id);
const nowTs = Date.now(); const nowTs = Date.now();
const rows = await R.scheduledMeetings.listForUser(u.team_id, u.id).map((s) => { const rows = await Promise.all((await R.scheduledMeetings.listForUser(u.team_id, u.id)).map(async (s) => {
let recur = []; try { recur = JSON.parse(s.recurrence || '[]'); } catch (_) {} let recur = []; try { recur = JSON.parse(s.recurrence || '[]'); } catch (_) {}
let schedAt = s.scheduled_at; let schedAt = s.scheduled_at;
const live = meetingRooms.get(s.room_code); const live = meetingRooms.get(s.room_code);
@@ -1260,12 +1263,12 @@ route('GET', '/api/meetings', async (req, res) => {
durationMins: s.duration_mins || null, recurrence: recur, recurrenceLabel: recurrenceLabel(recur), durationMins: s.duration_mins || null, recurrence: recur, recurrenceLabel: recurrenceLabel(recur),
status, inCall: running ? live.size : 0, recordings: [], status, inCall: running ? live.size : 0, recordings: [],
}; };
}); }));
// Attach recordings/transcripts. A recording is visible to its creator, group members, or people // Attach recordings/transcripts. A recording is visible to its creator, group members, or people
// who can see the scheduled meeting it belongs to. Recordings not tied to a listed meeting become // who can see the scheduled meeting it belongs to. Recordings not tied to a listed meeting become
// their own "Past meeting" entry (group calls show the group name). // their own "Past meeting" entry (group calls show the group name).
const recDTO = (r) => ({ id: r.id, kind: r.kind, url: '/mrec/' + r.id, createdAt: r.created_at, durationMs: r.duration_ms, size: r.size, by: r.created_by_name }); const recDTO = (r) => ({ id: r.id, kind: r.kind, url: '/mrec/' + r.id, createdAt: r.created_at, durationMs: r.duration_ms, size: r.size, by: r.created_by_name });
const canSeeRec = (r) => { const canSeeRec = async (r) => {
if (r.kind === 'transcript') return r.created_by === u.id; // transcripts are private to their owner if (r.kind === 'transcript') return r.created_by === u.id; // transcripts are private to their owner
if (r.created_by === u.id) return true; if (r.created_by === u.id) return true;
if (r.group_id) return await R.conversations.isMember(r.group_id, u.id); if (r.group_id) return await R.conversations.isMember(r.group_id, u.id);
@@ -1276,12 +1279,12 @@ route('GET', '/api/meetings', async (req, res) => {
const schedByRoom = new Map(rows.map((m) => [m.roomCode, m])); const schedByRoom = new Map(rows.map((m) => [m.roomCode, m]));
const unsched = new Map(); const unsched = new Map();
for (const r of await R.recordings.forTeam(u.team_id)) { for (const r of await R.recordings.forTeam(u.team_id)) {
if (!canSeeRec(r)) continue; if (!(await canSeeRec(r))) continue;
const m = (r.meeting_id && schedById.get(r.meeting_id)) || (r.room && schedByRoom.get(r.room)); const m = (r.meeting_id && schedById.get(r.meeting_id)) || (r.room && schedByRoom.get(r.room));
if (m) { m.recordings.push(recDTO(r)); } if (m) { m.recordings.push(recDTO(r)); }
else { const k = r.room || r.id; if (!unsched.has(k)) unsched.set(k, []); unsched.get(k).push(r); } else { const k = r.room || r.id; if (!unsched.has(k)) unsched.set(k, []); unsched.get(k).push(r); }
} }
const synth = [...unsched.values()].map((list) => { const synth = await Promise.all([...unsched.values()].map(async (list) => {
list.sort((a, b) => a.created_at - b.created_at); const f = list[0]; list.sort((a, b) => a.created_at - b.created_at); const f = list[0];
return { return {
id: 'rec-' + (f.room || f.id), roomCode: f.room || '', title: f.title || 'Meeting', description: '', id: 'rec-' + (f.room || f.id), roomCode: f.room || '', title: f.title || 'Meeting', description: '',
@@ -1290,7 +1293,7 @@ route('GET', '/api/meetings', async (req, res) => {
createdBy: f.created_by, createdByName: f.created_by_name || '', canManage: false, isHost: false, createdBy: f.created_by, createdByName: f.created_by_name || '', canManage: false, isHost: false,
invited: [], status: 'past', inCall: 0, recordings: list.map(recDTO), invited: [], status: 'past', inCall: 0, recordings: list.map(recDTO),
}; };
}); }));
// #7: past CALLS from the call log. Rules the user asked for: // #7: past CALLS from the call log. Rules the user asked for:
// • a plain 1:1 direct call is NOT listed (it's a call, not a meeting) — UNLESS it produced a // • a plain 1:1 direct call is NOT listed (it's a call, not a meeting) — UNLESS it produced a
@@ -1343,7 +1346,7 @@ route('GET', '/api/meetings', async (req, res) => {
}); });
// Host uploads an in-browser meeting recording (webm). Stored + indexed so it shows under Past meetings. // Host uploads an in-browser meeting recording (webm). Stored + indexed so it shows under Past meetings.
route('POST', '/api/meetings/recording', (req, res) => { route('POST', '/api/meetings/recording', async (req, res) => {
const u = await currentUser(req); const u = await currentUser(req);
if (!u) return json(res, 401, { error: 'unauthorized' }); if (!u) return json(res, 401, { error: 'unauthorized' });
const params = new URLSearchParams(req.url.split('?')[1] || ''); const params = new URLSearchParams(req.url.split('?')[1] || '');
@@ -1352,7 +1355,7 @@ route('POST', '/api/meetings/recording', (req, res) => {
const dur = parseInt(params.get('dur') || '0', 10) || null; const dur = parseInt(params.get('dur') || '0', 10) || null;
const chunks = []; let total = 0, aborted = false; const chunks = []; let total = 0, aborted = false;
req.on('data', (c) => { total += c.length; if (total > MAX_REC_BYTES) { aborted = true; req.destroy(); return; } chunks.push(c); }); req.on('data', (c) => { total += c.length; if (total > MAX_REC_BYTES) { aborted = true; req.destroy(); return; } chunks.push(c); });
req.on('end', () => { req.on('end', async () => {
if (aborted) return json(res, 413, { error: 'recording too large' }); if (aborted) return json(res, 413, { error: 'recording too large' });
if (!total) return json(res, 400, { error: 'empty recording' }); if (!total) return json(res, 400, { error: 'empty recording' });
const ctx = CALLS.meetingContext(room); const ctx = CALLS.meetingContext(room);
@@ -1419,7 +1422,7 @@ route('POST', '/api/meetings/update', async (req, res) => {
const when = Number(scheduledAt); if (!Number.isFinite(when) || when < Date.now()) return json(res, 400, { error: 'pick a valid future time' }); const when = Number(scheduledAt); if (!Number.isFinite(when) || when < Date.now()) return json(res, 400, { error: 'pick a valid future time' });
const dur = [15, 30, 45, 60, 90, 120].includes(Number(durationMins)) ? Number(durationMins) : (s.duration_mins || 30); const dur = [15, 30, 45, 60, 90, 120].includes(Number(durationMins)) ? Number(durationMins) : (s.duration_mins || 30);
const recur = Array.isArray(recurrence) ? [...new Set(recurrence.map(Number).filter((d) => d >= 0 && d <= 6))] : []; const recur = Array.isArray(recurrence) ? [...new Set(recurrence.map(Number).filter((d) => d >= 0 && d <= 6))] : [];
const invited = [...new Set((Array.isArray(participants) ? participants : []).filter((x) => typeof x === 'string' && x !== u.id && await R.users.inTenant(x, u.team_id)))]; const invited = [...new Set(await asyncFilter((Array.isArray(participants) ? participants : []), async (x) => typeof x === 'string' && x !== u.id && await R.users.inTenant(x, u.team_id)))];
const guestEmails = [...new Set((Array.isArray(participantEmails) ? participantEmails : []).map((e) => String(e || '').trim().toLowerCase()).filter(isEmail))].slice(0, 100); const guestEmails = [...new Set((Array.isArray(participantEmails) ? participantEmails : []).map((e) => String(e || '').trim().toLowerCase()).filter(isEmail))].slice(0, 100);
await R.scheduledMeetings.update(id, u.team_id, { title: t, description: String(description || '').trim().slice(0, 1000), scheduledAt: when, durationMins: dur, participants: invited, recurrence: recur, guestEmails, lobby: lobby !== false }); await R.scheduledMeetings.update(id, u.team_id, { title: t, description: String(description || '').trim().slice(0, 1000), scheduledAt: when, durationMins: dur, participants: invited, recurrence: recur, guestEmails, lobby: lobby !== false });
const label = new Date(when).toLocaleString(); const label = new Date(when).toLocaleString();
@@ -1453,11 +1456,11 @@ route('POST', '/api/polls', async (req, res) => {
await R.polls.create({ id: pollId, teamId: u.team_id, conversationId: group, messageId: msgId, question: q, options: opts, multi: !!multi, createdBy: u.id }); await R.polls.create({ id: pollId, teamId: u.team_id, conversationId: group, messageId: msgId, question: q, options: opts, multi: !!multi, createdBy: u.id });
await R.messages.setPoll(msgId, pollId); await R.messages.setPoll(msgId, pollId);
audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'poll_created', detail: q }); audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'poll_created', detail: q });
const names = namesFor(u.team_id); const names = await namesFor(u.team_id);
for (const mid of await R.conversations.members(group)) { for (const mid of await R.conversations.members(group)) {
try { const dto = buildMsgDTO(await R.messages.byId(msgId), names, mid); dto.fromName = u.name || u.email; CHAT.pushToUser(mid, { type: 'chat-message', message: dto }); } catch (_) {} try { const dto = await buildMsgDTO(await R.messages.byId(msgId), names, mid); dto.fromName = u.name || u.email; CHAT.pushToUser(mid, { type: 'chat-message', message: dto }); } catch (_) {}
} }
json(res, 200, buildPollDTO(await R.polls.byId(pollId), u.id)); json(res, 200, await buildPollDTO(await R.polls.byId(pollId), u.id));
}); });
// Vote on a poll option (toggle). Single-choice replaces the prior vote; multi toggles. // Vote on a poll option (toggle). Single-choice replaces the prior vote; multi toggles.
@@ -1480,9 +1483,9 @@ route('POST', '/api/polls/vote', async (req, res) => {
if (!had) await R.pollVotes.add(p.id, u.id, idx); if (!had) await R.pollVotes.add(p.id, u.id, idx);
} }
for (const mid of await R.conversations.members(p.conversation_id)) { for (const mid of await R.conversations.members(p.conversation_id)) {
try { CHAT.pushToUser(mid, { type: 'poll-update', poll: buildPollDTO(p, mid), messageId: p.message_id, conversationId: p.conversation_id }); } catch (_) {} try { CHAT.pushToUser(mid, { type: 'poll-update', poll: await buildPollDTO(p, mid), messageId: p.message_id, conversationId: p.conversation_id }); } catch (_) {}
} }
json(res, 200, buildPollDTO(p, u.id)); json(res, 200, await buildPollDTO(p, u.id));
}); });
// Close a poll (creator only) — no more votes accepted. // Close a poll (creator only) — no more votes accepted.
@@ -1496,9 +1499,9 @@ route('POST', '/api/polls/close', async (req, res) => {
await R.polls.close(p.id); await R.polls.close(p.id);
const fresh = await R.polls.byId(p.id); const fresh = await R.polls.byId(p.id);
for (const mid of await R.conversations.members(p.conversation_id)) { for (const mid of await R.conversations.members(p.conversation_id)) {
try { CHAT.pushToUser(mid, { type: 'poll-update', poll: buildPollDTO(fresh, mid), messageId: p.message_id, conversationId: p.conversation_id }); } catch (_) {} try { CHAT.pushToUser(mid, { type: 'poll-update', poll: await buildPollDTO(fresh, mid), messageId: p.message_id, conversationId: p.conversation_id }); } catch (_) {}
} }
json(res, 200, buildPollDTO(fresh, u.id)); json(res, 200, await buildPollDTO(fresh, u.id));
}); });
// Send a message (persists + live-pushes to the recipient and the sender's other tabs). // Send a message (persists + live-pushes to the recipient and the sender's other tabs).
@@ -1525,7 +1528,7 @@ route('POST', '/api/messages', async (req, res) => {
mlist = [...new Set(mlist)]; mlist = [...new Set(mlist)];
} }
await R.messages.send({ id, teamId: u.team_id, senderId: u.id, recipientId: '', body: text, replyTo: replyTo || null, attachmentId: attachmentId || null, conversationId: group, mentions: mlist }); await R.messages.send({ id, teamId: u.team_id, senderId: u.id, recipientId: '', body: text, replyTo: replyTo || null, attachmentId: attachmentId || null, conversationId: group, mentions: mlist });
const dto = buildMsgDTO(await R.messages.byId(id), namesFor(u.team_id), u.id); const dto = await buildMsgDTO(await R.messages.byId(id), await namesFor(u.team_id), u.id);
dto.fromName = u.name || u.email; dto.fromName = u.name || u.email;
const push = { type: 'chat-message', message: dto }; const push = { type: 'chat-message', message: dto };
const conv = await R.conversations.byId(group); const gname = (conv && conv.name) || 'Group'; const conv = await R.conversations.byId(group); const gname = (conv && conv.name) || 'Group';
@@ -1542,7 +1545,7 @@ route('POST', '/api/messages', async (req, res) => {
if (!toId) return json(res, 400, { error: 'to or group required' }); if (!toId) return json(res, 400, { error: 'to or group required' });
if (!await R.users.inTenant(toId, u.team_id)) return json(res, 404, { error: 'no such contact' }); if (!await R.users.inTenant(toId, u.team_id)) return json(res, 404, { error: 'no such contact' });
await R.messages.send({ id, teamId: u.team_id, senderId: u.id, recipientId: toId, body: text, replyTo: replyTo || null, attachmentId: attachmentId || null }); await R.messages.send({ id, teamId: u.team_id, senderId: u.id, recipientId: toId, body: text, replyTo: replyTo || null, attachmentId: attachmentId || null });
const dto = buildMsgDTO(await R.messages.byId(id), namesFor(u.team_id), u.id); const dto = await buildMsgDTO(await R.messages.byId(id), await namesFor(u.team_id), u.id);
const push = { type: 'chat-message', message: { ...dto, fromName: u.name || u.email } }; const push = { type: 'chat-message', message: { ...dto, fromName: u.name || u.email } };
try { CHAT.pushToUser(toId, push); } catch (_) {} try { CHAT.pushToUser(toId, push); } catch (_) {}
if (toId !== u.id) try { CHAT.pushToUser(u.id, push); } catch (_) {} // sync the sender's other devices (skip for self-notes) if (toId !== u.id) try { CHAT.pushToUser(u.id, push); } catch (_) {} // sync the sender's other devices (skip for self-notes)
@@ -1568,7 +1571,7 @@ route('POST', '/api/messages/forward', async (req, res) => {
} }
if (!srcs.length) return json(res, 400, { error: 'nothing to forward' }); if (!srcs.length) return json(res, 400, { error: 'nothing to forward' });
srcs.sort((a, b) => a.created_at - b.created_at); srcs.sort((a, b) => a.created_at - b.created_at);
const names = namesFor(u.team_id); const names = await namesFor(u.team_id);
let sent = 0; let sent = 0;
for (const t of (targets || []).slice(0, 20)) { for (const t of (targets || []).slice(0, 20)) {
let isGroup = t.kind === 'group', tid = t.id; let isGroup = t.kind === 'group', tid = t.id;
@@ -1579,7 +1582,7 @@ route('POST', '/api/messages/forward', async (req, res) => {
const origin = m.fwd_from || names[m.sender_id] || 'Unknown'; // preserve the true origin across re-forwards const origin = m.fwd_from || names[m.sender_id] || 'Unknown'; // preserve the true origin across re-forwards
if (isGroup) await R.messages.send({ id: nid, teamId: u.team_id, senderId: u.id, recipientId: '', body: m.body, attachmentId: m.attachment_id, conversationId: tid, fwdFrom: origin }); if (isGroup) await R.messages.send({ id: nid, teamId: u.team_id, senderId: u.id, recipientId: '', body: m.body, attachmentId: m.attachment_id, conversationId: tid, fwdFrom: origin });
else await R.messages.send({ id: nid, teamId: u.team_id, senderId: u.id, recipientId: tid, body: m.body, attachmentId: m.attachment_id, fwdFrom: origin }); else await R.messages.send({ id: nid, teamId: u.team_id, senderId: u.id, recipientId: tid, body: m.body, attachmentId: m.attachment_id, fwdFrom: origin });
const dto = buildMsgDTO(await R.messages.byId(nid), names, u.id); const dto = await buildMsgDTO(await R.messages.byId(nid), names, u.id);
const push = { type: 'chat-message', message: { ...dto, fromName: u.name || u.email } }; const push = { type: 'chat-message', message: { ...dto, fromName: u.name || u.email } };
if (isGroup) { for (const mid of await R.conversations.members(tid)) { try { CHAT.pushToUser(mid, push); } catch (_) {} } } if (isGroup) { for (const mid of await R.conversations.members(tid)) { try { CHAT.pushToUser(mid, push); } catch (_) {} } }
else { try { CHAT.pushToUser(tid, push); } catch (_) {} if (tid !== u.id) try { CHAT.pushToUser(u.id, push); } catch (_) {} } else { try { CHAT.pushToUser(tid, push); } catch (_) {} if (tid !== u.id) try { CHAT.pushToUser(u.id, push); } catch (_) {} }
@@ -1683,20 +1686,20 @@ route('POST', '/api/messages/react', async (req, res) => {
if (!participant) return json(res, 404, { error: 'no such message' }); if (!participant) return json(res, 404, { error: 'no such message' });
const e = String(emoji).slice(0, 16); const e = String(emoji).slice(0, 16);
const added = await R.reactions.toggle(messageId, u.id, e); const added = await R.reactions.toggle(messageId, u.id, e);
const names = namesFor(u.team_id); const names = await namesFor(u.team_id);
// Push the full, recomputed reaction set for this message (per-recipient perspective). Extra // Push the full, recomputed reaction set for this message (per-recipient perspective). Extra
// fields (by/emoji/added/owner/convId) let the message owner show a "reacted to you" notification. // fields (by/emoji/added/owner/convId) let the message owner show a "reacted to you" notification.
const meta = { by: u.name || u.email, byId: u.id, emoji: e, added, owner: msg.sender_id, convId: msg.conversation_id || null }; const meta = { by: u.name || u.email, byId: u.id, emoji: e, added, owner: msg.sender_id, convId: msg.conversation_id || null };
if (msg.conversation_id) { if (msg.conversation_id) {
for (const mid of await R.conversations.members(msg.conversation_id)) { for (const mid of await R.conversations.members(msg.conversation_id)) {
try { CHAT.pushToUser(mid, { type: 'chat-reaction', messageId, reactions: reactionsForMessage(messageId, mid, names), ...meta }); } catch (_) {} try { CHAT.pushToUser(mid, { type: 'chat-reaction', messageId, reactions: await reactionsForMessage(messageId, mid, names), ...meta }); } catch (_) {}
} }
} else { } else {
const other = msg.sender_id === u.id ? msg.recipient_id : msg.sender_id; const other = msg.sender_id === u.id ? msg.recipient_id : msg.sender_id;
try { CHAT.pushToUser(other, { type: 'chat-reaction', messageId, reactions: reactionsForMessage(messageId, other, names), ...meta }); } catch (_) {} try { CHAT.pushToUser(other, { type: 'chat-reaction', messageId, reactions: await reactionsForMessage(messageId, other, names), ...meta }); } catch (_) {}
try { CHAT.pushToUser(u.id, { type: 'chat-reaction', messageId, reactions: reactionsForMessage(messageId, u.id, names), ...meta }); } catch (_) {} try { CHAT.pushToUser(u.id, { type: 'chat-reaction', messageId, reactions: await reactionsForMessage(messageId, u.id, names), ...meta }); } catch (_) {}
} }
json(res, 200, { ok: true, messageId, added, reactions: reactionsForMessage(messageId, u.id, names) }); json(res, 200, { ok: true, messageId, added, reactions: await reactionsForMessage(messageId, u.id, names) });
}); });
// Upload a chat attachment (raw body; filename in X-Filename, mime in Content-Type). // Upload a chat attachment (raw body; filename in X-Filename, mime in Content-Type).
@@ -1722,9 +1725,9 @@ route('POST', '/api/messages/upload', async (req, res) => {
if (total > MAX_FILE_BYTES) { aborted = true; cleanup(); finish(413, { error: 'file too large (max ' + MAX_UPLOAD_MB + ' MB)' }); try { req.destroy(); } catch (_) {} return; } if (total > MAX_FILE_BYTES) { aborted = true; cleanup(); finish(413, { error: 'file too large (max ' + MAX_UPLOAD_MB + ' MB)' }); try { req.destroy(); } catch (_) {} return; }
if (!ws.write(c)) { req.pause(); ws.once('drain', () => { if (!aborted) req.resume(); }); } // respect backpressure if (!ws.write(c)) { req.pause(); ws.once('drain', () => { if (!aborted) req.resume(); }); } // respect backpressure
}); });
req.on('end', () => { req.on('end', async () => {
if (aborted) return; if (aborted) return;
ws.end(() => { ws.end(async () => {
if (!total) { try { fs.unlinkSync(tmp); } catch (_) {} return finish(400, { error: 'empty file' }); } if (!total) { try { fs.unlinkSync(tmp); } catch (_) {} return finish(400, { error: 'empty file' }); }
try { fs.renameSync(tmp, path.join(UPLOADS_DIR, id)); } try { fs.renameSync(tmp, path.join(UPLOADS_DIR, id)); }
catch (e) { try { fs.unlinkSync(tmp); } catch (_) {} return finish(500, { error: 'could not store file' }); } catch (e) { try { fs.unlinkSync(tmp); } catch (_) {} return finish(500, { error: 'could not store file' }); }
+7 -3
View File
@@ -23,9 +23,13 @@ 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 ----------
+20 -20
View File
@@ -26,7 +26,7 @@ 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;
@@ -46,7 +46,7 @@ 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 = await 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;
@@ -77,12 +77,12 @@ 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': {
@@ -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,7 +259,7 @@ 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 ---
@@ -370,13 +370,13 @@ 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 { await R.sessionsLog.end(sessionId); } catch (e) {} try { await R.sessionsLog.end(sessionId); } catch (e) {}
@@ -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);
} }
} }
} }
+17 -12
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 = await 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 = await R.conversations.byAvatar(id); const avatarGroup = await R.conversations.byAvatar(id);
const carriers = await 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 && await 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.
for (const msg of carriers) {
const carried = msg.conversation_id
? await R.conversations.isMember(msg.conversation_id, u.id) ? await R.conversations.isMember(msg.conversation_id, u.id)
: (msg.sender_id === u.id || msg.recipient_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.
@@ -211,7 +216,7 @@ function handleGet(req, res) {
const u = await 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);
@@ -239,7 +244,7 @@ function handleGet(req, res) {
const u = await 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);
@@ -256,7 +261,7 @@ function handleGet(req, res) {
const u = await 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' });
+2 -2
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 await 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 { await 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 (_) {}