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('|');
// 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' };
try {
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
// 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).
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 buf = transcriptBuffers.get(room) || [];
const ids = onlyUserId ? (subs.has(onlyUserId) ? [onlyUserId] : []) : [...subs];
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 body = ctx.title + ' — transcript\n' + new Date(buf[0].t).toLocaleString() + '\n\n' + lines.join('\n') + '\n';
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 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).
function postSystem(group, teamId, text) {
async function postSystem(group, teamId, text) {
const id = A.id();
await R.messages.send({ id, teamId, senderId: '__system__', recipientId: '', body: text, conversationId: group });
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 } });
}
function startGroupCall(group, teamId, user) {
async function startGroupCall(group, teamId, user) {
const existing = groupCalls.get(group);
if (existing) return { room: existing.room, active: true, already: true };
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.
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
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 (_) {}
broadcast(group, { type: 'group-call', group, active: true, room, by: user.id, startedByName: call.startedByName, groupName: gName });
return { room, active: true };
}
// 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);
if (!group) return;
const call = groupCalls.get(group);
roomToGroupCall.delete(room); groupCalls.delete(group); roomHost.delete(room);
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
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".
function startDmCall(me, otherId, teamId) {
async function startDmCall(me, otherId, teamId) {
const key = pairKey(me.id, otherId);
const existing = dmCalls.get(key);
if (existing) return { room: existing.room, active: true, already: true };
@@ -118,7 +118,7 @@ function startDmCall(me, otherId, teamId) {
return { room, active: true };
}
function endDmCallByRoom(room, silent) {
async function endDmCallByRoom(room, silent) {
const key = roomToDmCall.get(room); if (!key) return;
const call = dmCalls.get(key);
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; } }
}
// 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.
function declineDmCall(room, byUser) {
async function declineDmCall(room, byUser) {
const key = roomToDmCall.get(room); if (!key) 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
+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.
// Deliberately delayed and rate-limited by the same 2-at-a-time queue — boot must not stall on it.
function backfill() {
setTimeout(() => {
setTimeout(async () => {
let rows = [];
try { rows = require('./repos').attachments.allVideos(); } catch (e) { return; }
try { rows = await require('./repos').attachments.allVideos(); } catch (e) { return; }
let queued = 0;
for (const r of rows) {
if (hasWebRendition(r.id)) continue;
+2 -2
View File
@@ -3,7 +3,7 @@
const R = require('./repos');
const CHAT = require('./chat');
function tick() {
async function tick() {
try {
const now = Date.now();
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]);
let invited = []; try { invited = JSON.parse(s.participants || '[]'); } catch (_) {}
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 } };
recipients.forEach((uid) => { try { CHAT.pushToUser(uid, evt); } catch (_) {} });
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 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 });
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.
//
// 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
// 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.
function avatarsFor(teamId) {
async function avatarsFor(teamId) {
const users = await R.users.listByTenant(teamId);
const em = (x) => (x.email ? String(x.email).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'];
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.
function postSystemMessage(conversationId, teamId, text){
async function postSystemMessage(conversationId, teamId, text){
const id = A.id();
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 (_) {} }
return dto;
}
// 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();
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 (_) {} } }
@@ -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 })) : []);
// Full reaction DTO for ONE message, from `userId`'s perspective (mine/who).
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 }));
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 }));
return dtoReactions(groupReactions(rows, userId, names), messageId);
}
// 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 = []; }
const counts = opts.map(() => 0); const mine = opts.map(() => false); const voters = new Set();
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).
function buildMsgDTO(m, names, userId){
async function buildMsgDTO(m, names, userId){
const d = msgDTO(m);
if (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);
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]) || '';
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.
function issueRefreshToken(userId) {
async function issueRefreshToken(userId) {
const rtok = A.token(32);
await R.refreshTokens.create({ userId, tokenHash: A.hashToken(rtok), ttl: REFRESH_TTL });
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
// 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);
function provisionFromBizgaze(email, bz) {
async function provisionFromBizgaze(email, bz) {
const role = (bz.isAdmin || ADMIN_EMAILS.includes(String(email).toLowerCase())) ? 'admin' : 'technician';
const bizId = bz.bizgazeUserId || null;
@@ -257,7 +260,7 @@ route('POST', '/api/login', async (req, res) => {
const bz = await BZ.validateLogin(email, password);
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.' });
u = provisionFromBizgaze(email, bz);
u = await provisionFromBizgaze(email, bz);
if (u && u.active === 0) return json(res, 403, { error: 'This account has been deactivated' });
} else {
// 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;
if (!u) {
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 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' });
// 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).
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 });
});
@@ -300,7 +303,7 @@ route('POST', '/api/auth/refresh', async (req, res) => {
await R.refreshTokens.revoke(h); // rotate: one-time use
const tok = A.token();
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 });
});
@@ -702,7 +705,7 @@ route('POST', '/api/recording', async (req, res) => {
if (!row) return json(res, 404, { error: 'no such session' });
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('end', () => {
req.on('end', async () => {
if (aborted) return json(res, 413, { error: 'recording too large' });
const fname = sid + '.' + ext;
try {
@@ -724,7 +727,7 @@ route('POST', '/api/transcript', async (req, res) => {
if (!row) return json(res, 404, { error: 'no such session' });
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('end', () => {
req.on('end', async () => {
if (aborted) return json(res, 413, { error: 'transcript too large' });
const fname = sid + '.txt';
try {
@@ -741,8 +744,8 @@ route('POST', '/api/transcript', async (req, res) => {
route('GET', '/api/messages/contacts', async (req, res) => {
const u = await currentUser(req);
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 cAv = avatarsFor(u.team_id); // duplicate-row DP fallback
const rows = (await R.users.listByTenant(u.team_id)).filter((x) => x.id !== u.id && x.active !== 0);
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' })));
});
@@ -755,7 +758,7 @@ route('GET', '/api/directory/search', async (req, res) => {
if (q.length < 2) return json(res, 200, []);
const results = await require('./directory').search(q);
// 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 out = results.map((p) => {
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 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; }
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 inCall = new Set();
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 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).
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 (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
}; });
// 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 since = await R.conversations.lastReadAt(g.id, u.id);
const members = await R.conversations.members(g.id);
@@ -807,8 +810,8 @@ route('GET', '/api/messages/conversations', async (req, res) => {
let gStatus = null;
if (last && last.sender_id === u.id) {
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;
gStatus = (others > 0 && seen >= others) ? 'read' : (seen > 0 ? 'delivered' : 'sent');
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 && seenN >= others) ? 'read' : (seenN > 0 ? 'delivered' : 'sent');
}
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),
@@ -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_status: gStatus,
};
});
}));
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 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 names = namesFor(u.team_id);
const names = await namesFor(u.team_id);
const group = q.get('group');
if (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 reads = await R.conversations.memberReads(group); // ALL members' read times (#2: seen-by visible to everyone)
return json(res, 200, rows.map((m) => {
const d = buildMsgDTO(m, names, u.id); d.fromName = names[m.sender_id] || ''; d.reactions = dtoReactions(rxBy, m.id);
return json(res, 200, await Promise.all(rows.map(async (m) => {
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.
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;
}));
})));
}
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' });
@@ -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
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);
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,
@@ -886,7 +889,7 @@ route('POST', '/api/groups', async (req, res) => {
const { name, memberIds } = await readBody(req);
const nm = String(name || '').trim().slice(0, 80);
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();
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
@@ -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' });
const names = {}; const avatars = {};
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));
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).
@@ -918,7 +921,7 @@ route('GET', '/api/groups/info', async (req, res) => {
const tenantUsers = await R.users.listByTenant(u.team_id);
const names = {}; const avatars = {};
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));
json(res, 200, {
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,
createdByName: names[g.created_by] || 'Someone', createdAt: g.created_at,
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);
if (!nm) return json(res, 400, { error: 'group name required' });
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 });
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' });
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' });
const r = CALLS.startGroupCall(group, u.team_id, u);
const r = await CALLS.startGroupCall(group, u.team_id, u);
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' });
const { to } = await readBody(req);
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
@@ -971,7 +974,7 @@ route('POST', '/api/calls/invite', async (req, res) => {
if (!u) return json(res, 401, { error: 'unauthorized' });
const { room, userIds } = await readBody(req);
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 (_) {} }
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 —
// 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.
const sched = (() => {
const sched = await (async () => {
try {
const s = await R.scheduledMeetings.byCode(rm);
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' });
const { room } = await readBody(req);
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).
@@ -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 (!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);
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 });
});
@@ -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 (!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 (!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);
const names = 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'));
pushGroupUpdate(group);
const names = await namesFor(u.team_id);
await postSystemMessage(group, u.team_id, (u.name || u.email) + (value ? ' made ' + (names[userId] || 'someone') + ' an admin' : ' removed ' + (names[userId] || 'someone') + ' as admin'));
await pushGroupUpdate(group);
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 });
});
@@ -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' });
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' });
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);
if (ids.length) {
const names = namesFor(u.team_id);
postSystemMessage(group, u.team_id, (u.name || u.email) + ' added ' + ids.map((x) => names[x] || 'someone').join(', '));
const names = await namesFor(u.team_id);
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 });
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' }); }
const wasAdmin = await R.conversations.isAdmin(group, target);
// #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);
if (wasAdmin && others.length && await R.conversations.admins(group).filter((a) => a !== target).length === 0) {
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 (!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);
const names0 = namesFor(u.team_id);
postSystemMessage(group, u.team_id, (names0[newAdmin] || 'A member') + ' is now an admin');
const names0 = await namesFor(u.team_id);
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 (_) {}
}
// Post the activity BEFORE removing, so the removed person's tab also receives it.
if (target !== u.id && await R.conversations.isMember(group, target)) {
const names = namesFor(u.team_id);
postSystemMessage(group, u.team_id, (u.name || u.email) + ' removed ' + (names[target] || 'someone'));
const names = await namesFor(u.team_id);
await postSystemMessage(group, u.team_id, (u.name || u.email) + ' removed ' + (names[target] || 'someone'));
} 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);
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
if ((await R.conversations.members(group)).length === 0) { await R.conversations.remove(group); } // drop empty groups
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 });
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);
// 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.
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));
@@ -1204,7 +1207,7 @@ route('POST', '/api/meetings/schedule', async (req, res) => {
if (groupId) {
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 });
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 (_) {} }
}
// 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) => {
const u = await currentUser(req);
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 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 schedAt = s.scheduled_at;
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),
status, inCall: running ? live.size : 0, recordings: [],
};
});
}));
// 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
// 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 canSeeRec = (r) => {
const canSeeRec = async (r) => {
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.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 unsched = new Map();
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));
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); }
}
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];
return {
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,
invited: [], status: 'past', inCall: 0, recordings: list.map(recDTO),
};
});
}));
// #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
@@ -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.
route('POST', '/api/meetings/recording', (req, res) => {
route('POST', '/api/meetings/recording', async (req, res) => {
const u = await currentUser(req);
if (!u) return json(res, 401, { error: 'unauthorized' });
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 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('end', () => {
req.on('end', async () => {
if (aborted) return json(res, 413, { error: 'recording too large' });
if (!total) return json(res, 400, { error: 'empty recording' });
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 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 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);
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();
@@ -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.messages.setPoll(msgId, pollId);
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)) {
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.
@@ -1480,9 +1483,9 @@ route('POST', '/api/polls/vote', async (req, res) => {
if (!had) await R.pollVotes.add(p.id, u.id, idx);
}
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.
@@ -1496,9 +1499,9 @@ route('POST', '/api/polls/close', async (req, res) => {
await R.polls.close(p.id);
const fresh = await R.polls.byId(p.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).
@@ -1525,7 +1528,7 @@ route('POST', '/api/messages', async (req, res) => {
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 });
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;
const push = { type: 'chat-message', message: dto };
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 (!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 });
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 } };
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)
@@ -1568,7 +1571,7 @@ route('POST', '/api/messages/forward', async (req, res) => {
}
if (!srcs.length) return json(res, 400, { error: 'nothing to forward' });
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;
for (const t of (targets || []).slice(0, 20)) {
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
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 });
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 } };
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 (_) {} }
@@ -1683,20 +1686,20 @@ route('POST', '/api/messages/react', async (req, res) => {
if (!participant) return json(res, 404, { error: 'no such message' });
const e = String(emoji).slice(0, 16);
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
// 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 };
if (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 {
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(u.id, { type: 'chat-reaction', messageId, reactions: reactionsForMessage(messageId, u.id, 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: 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).
@@ -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 (!ws.write(c)) { req.pause(); ws.once('drain', () => { if (!aborted) req.resume(); }); } // respect backpressure
});
req.on('end', () => {
req.on('end', async () => {
if (aborted) return;
ws.end(() => {
ws.end(async () => {
if (!total) { try { fs.unlinkSync(tmp); } catch (_) {} return finish(400, { error: 'empty file' }); }
try { fs.renameSync(tmp, path.join(UPLOADS_DIR, id)); }
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 ----------
const server = http.createServer((req, res) => {
const key = `${req.method} ${req.url.split('?')[0]}`;
if (routes[key]) return routes[key](req, res);
if (req.method === 'GET') return handleGet(req, res); // downloads + static
json(res, 404, { error: 'not found' });
// Route/static handlers are async now (DB adapter). Catch any rejection so a handler error becomes a
// 500 instead of a hung request + unhandled promise rejection.
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 ----------
+20 -20
View File
@@ -26,7 +26,7 @@ function noteRoomStat(room, ws, size) {
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.
function persistCallHistory(room) {
async function persistCallHistory(room) {
const st = roomStats.get(room);
roomStats.delete(room);
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
// `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.
function meetingRoomRequiresApproval(room) {
async function meetingRoomRequiresApproval(room) {
if (roomLobby.has(room)) return !!roomLobby.get(room);
try { const s = await R.scheduledMeetings.byCode(room); if (s) return s.lobby !== 0; } catch (_) {}
return true;
@@ -77,12 +77,12 @@ function onConnection(ws, req) {
}, 25000);
ws.on('message', (raw) => {
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) {
// --- Logged-in user registers this socket for live chat delivery ---
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
// 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.
if (!ju && meetingRoomRequiresApproval(room)) {
if (!ju && await meetingRoomRequiresApproval(room)) {
let pend = lobbyPending.get(room); if (!pend) { pend = new Map(); lobbyPending.set(room, pend); }
pend.set(peerId, ws); ws._lobbyRoom = room;
ws.send(JSON.stringify({ type: 'meeting-lobby-wait' }));
@@ -259,7 +259,7 @@ function handle(ws, m, req) {
break;
}
case 'meeting-leave': {
leaveMeeting(ws);
await leaveMeeting(ws);
break;
}
// --- Agent comes online ---
@@ -370,13 +370,13 @@ function handle(ws, m, req) {
break;
}
case 'end-session': {
endSession(ws.sessionId, m.reason || null);
await endSession(ws.sessionId, m.reason || null);
break;
}
}
}
function endSession(sessionId, reason) {
async function endSession(sessionId, reason) {
const sess = liveSessions.get(sessionId);
if (!sess) return;
try { await R.sessionsLog.end(sessionId); } catch (e) {}
@@ -393,7 +393,7 @@ function endSession(sessionId, reason) {
liveSessions.delete(sessionId);
}
function leaveMeeting(ws) {
async function leaveMeeting(ws) {
const room = ws._meetingRoom;
if (!room) return;
const peers = meetingRooms.get(room);
@@ -401,34 +401,34 @@ function leaveMeeting(ws) {
const pid = ws._peerId;
const leaverId = ws._meetingUserId;
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);
// 1:1 call: when either party leaves, end it for everyone (a DM call has no "remaining" call).
if (roomToDmCall.has(room)) {
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; } }
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);
try { require('./calls').finalizeTranscript(room); } catch (_) {} // any remaining buffers (safety)
try { await require('./calls').finalizeTranscript(room); } catch (_) {} // any remaining buffers (safety)
roomHost.delete(room);
try { require('./calls').endCallByRoom(room); } catch (_) {}
try { await require('./calls').endCallByRoom(room); } catch (_) {}
if (leaverId) CHAT.broadcastPresence(leaverId);
others.forEach((uid) => CHAT.broadcastPresence(uid)); // both parties are now out of the call → live update
return;
}
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) {
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);
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);
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
}
function cleanup(ws) {
async function cleanup(ws) {
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.
if (ws._lobbyRoom) {
@@ -438,14 +438,14 @@ function cleanup(ws) {
ws._lobbyRoom = null;
}
CHAT.unregister(ws);
leaveMeeting(ws);
await leaveMeeting(ws);
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 === 'sharer' && ws.shareCode) pendingShares.delete(ws.shareCode);
if (ws.sessionId) {
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);
}
}
}
+18 -13
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
// 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);
if (!a || a.team_id !== u.team_id) return null;
const avatarGroup = await R.conversations.byAvatar(id);
const carriers = await R.messages.allByAttachment(id);
const ok = a.uploader_id === u.id
|| (avatarGroup && await R.conversations.isMember(avatarGroup.id, u.id))
|| carriers.some((msg) => msg.conversation_id
? await R.conversations.isMember(msg.conversation_id, u.id)
: (msg.sender_id === u.id || msg.recipient_id === u.id));
let ok = a.uploader_id === u.id || (avatarGroup && await R.conversations.isMember(avatarGroup.id, u.id));
if (!ok) {
// 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)
: (msg.sender_id === u.id || msg.recipient_id === u.id);
if (carried) { ok = true; break; }
}
}
return ok ? a : null;
}
// 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.
const _attAuth = new Map();
function authAttachment(id, u) {
async function authAttachment(id, u) {
const key = u.id + ':' + id, now = Date.now();
const hit = _attAuth.get(key);
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();
_attAuth.set(key, { a, exp: now + 60000 });
return a;
@@ -100,7 +105,7 @@ function serveStatic(req, res) {
}
// GET fallback: authenticated transcript/recording downloads, else static files.
function handleGet(req, res) {
async function handleGet(req, res) {
const pathOnly = req.url.split('?')[0];
// Stable "latest Windows installer" link (used by the site's Download button). Reads the
// electron-updater manifest and redirects to the current versioned .exe.
@@ -211,7 +216,7 @@ function handleGet(req, res) {
const u = await currentUser(req);
if (!u) return json(res, 401, { error: 'unauthorized' });
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 (!/^video\//.test(a.mime || '')) return json(res, 404, { error: 'not a video' });
const src = path.join(UPLOADS_DIR, id);
@@ -239,7 +244,7 @@ function handleGet(req, res) {
const u = await currentUser(req);
if (!u) return json(res, 401, { error: 'unauthorized' });
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' });
media.ensureWebRendition(id, a.mime); // idempotent — also backfills pre-existing uploads
const ready = media.hasWebRendition(id);
@@ -256,7 +261,7 @@ function handleGet(req, res) {
const u = await currentUser(req);
if (!u) return json(res, 401, { error: 'unauthorized' });
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' });
const fp = path.join(UPLOADS_DIR, id);
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();
}
function emit(event, tenantId, payload) {
async function emit(event, tenantId, payload) {
const body = JSON.stringify({ event, ...payload });
// Per-tenant subscriptions
try {
for (const h of await R.webhooks.activeForTenant(tenantId)) {
const subs = String(h.events || '').split(',').map((s) => s.trim());
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 (_) {}