diff --git a/server/calls.js b/server/calls.js index 9341a89..f6b709c 100644 --- a/server/calls.js +++ b/server/calls.js @@ -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 diff --git a/server/media.js b/server/media.js index b1ccde3..6d45a8c 100644 --- a/server/media.js +++ b/server/media.js @@ -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; diff --git a/server/reminders.js b/server/reminders.js index 2c378d7..07a6f88 100644 --- a/server/reminders.js +++ b/server/reminders.js @@ -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); diff --git a/server/routes.js b/server/routes.js index 71a789a..17037e0 100644 --- a/server/routes.js +++ b/server/routes.js @@ -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' }); } diff --git a/server/server.js b/server/server.js index a77fd55..aa2e3c6 100644 --- a/server/server.js +++ b/server/server.js @@ -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 ---------- diff --git a/server/signaling.js b/server/signaling.js index 1dcf3aa..3959387 100644 --- a/server/signaling.js +++ b/server/signaling.js @@ -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); } } } diff --git a/server/static.js b/server/static.js index e2a3777..5e27b07 100644 --- a/server/static.js +++ b/server/static.js @@ -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' }); diff --git a/server/webhooks.js b/server/webhooks.js index a09db84..afef086 100644 --- a/server/webhooks.js +++ b/server/webhooks.js @@ -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 (_) {}