Files
BizGaze_Remote/server/signaling.js
T
Sravan 3250530596 feat(db): complete async call-site conversion — Phase 3 done, validated on SQLite
The full sync→async conversion is complete and green on the SQLite backend. Every
DB call across the app now awaits the async adapter, so the identical code runs on
Postgres at cutover.

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 22:06:27 +05:30

454 lines
27 KiB
JavaScript

// WebSocket signaling. Two kinds of WS clients:
// agent -> authenticates with machine enroll_token, waits for session requests
// viewer -> authenticated technician, requests a session to a machine
// The server brokers consent and relays SDP/ICE. Media never traverses the server.
const R = require('./repos');
const A = require('./auth');
const { currentUser, audit } = require('./session');
const { onlineAgents, liveSessions, pendingShares, meetingRooms, roomToDmCall, roomToGroupCall, roomHost, transcriptBuffers, transcriptSubs } = require('./presence');
const W = require('./webhooks');
const CHAT = require('./chat');
// ---- Meeting lobby (#4): guests joining by link can be held until the host admits them ----
// roomLobby: ad-hoc room code -> whether guests need approval (set on meeting-create).
// lobbyPending: room code -> Map(peerId -> guest ws) awaiting admission.
const roomLobby = new Map();
const lobbyPending = new Map();
// #7: live stats per room so a finished call can be logged with the MOST people it ever held. Without
// this there's no way to tell a plain 1:1 from a call that grew to 3+ once everyone has left.
const roomStats = new Map(); // room -> { teamId, startedAt, peak, names:Set, uids:Set }
function noteRoomStat(room, ws, size) {
let st = roomStats.get(room);
if (!st) { st = { teamId: null, startedAt: Date.now(), peak: 0, names: new Set(), uids: new Set() }; roomStats.set(room, st); }
if (!st.teamId && ws._meetingTeamId) st.teamId = ws._meetingTeamId;
if (size > st.peak) st.peak = size;
if (ws._peerName) st.names.add(ws._peerName);
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.
async function persistCallHistory(room) {
const st = roomStats.get(room);
roomStats.delete(room);
if (!st || !st.teamId || st.peak < 1) return;
try {
if (await R.scheduledMeetings.byCode(room)) return; // the scheduled meeting row already represents this
const gid = roomToGroupCall.get(room) || null;
const isDm = roomToDmCall.has(room);
await R.callHistory.create({
id: A.id(), teamId: st.teamId, room, groupId: gid,
kind: isDm ? 'dm' : (gid ? 'group' : 'adhoc'),
title: isDm ? 'Direct call' : (gid ? null : 'Meeting'),
peak: st.peak, participants: [...st.names], uids: [...st.uids],
startedAt: st.startedAt, endedAt: Date.now(),
});
} catch (_) {}
}
// 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.
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;
}
// Actually add a newcomer to the room: tell them who's here, tell others they arrived, and (if they're
// the host) hand them any guests already waiting in the lobby. Shared by direct joins and admissions.
function finishMeetingJoin(ws, room, peers) {
const peerId = ws._peerId, name = ws._peerName;
const hostUserId = roomHost.get(room);
const avatar = ws._meetingAvatar || null;
const isHost = !!(ws._meetingUserId && hostUserId && ws._meetingUserId === hostUserId);
ws.send(JSON.stringify({ type: 'meeting-joined', room, peerId, isHost, peers: [...peers.entries()].map(([id, p]) => ({ peerId: id, name: p.name, avatar: p.avatar || null, uid: p.uid || null })) }));
for (const [, p] of peers) { if (p.ws.readyState === 1) p.ws.send(JSON.stringify({ type: 'meeting-peer-joined', peerId, name, avatar, uid: ws._meetingUserId || null })); }
peers.set(peerId, { ws, name, avatar, uid: ws._meetingUserId || null });
noteRoomStat(room, ws, peers.size); // #7: track the high-water participant count for the call log
if (ws._meetingUserId) { try { require('./calls').markDmAnswered(room, ws._meetingUserId); } catch (_) {} CHAT.broadcastPresence(ws._meetingUserId); }
const tsubs = transcriptSubs.get(room); if (tsubs && tsubs.size > 0) ws.send(JSON.stringify({ type: 'meeting-transcribe-state', active: true }));
if (isHost) { const pend = lobbyPending.get(room); if (pend) for (const [ppid, pws] of pend) { if (pws.readyState === 1) ws.send(JSON.stringify({ type: 'meeting-lobby-request', peerId: ppid, name: pws._peerName || 'Guest' })); } }
}
// Send a lobby request to whoever is hosting the room right now (if anyone).
function notifyHostsLobby(room, peers, hostUserId, peerId, name) {
for (const [, p] of peers) { if (p.uid && hostUserId && p.uid === hostUserId && p.ws.readyState === 1) p.ws.send(JSON.stringify({ type: 'meeting-lobby-request', peerId, name })); }
}
function onConnection(ws, req) {
const hb = setInterval(() => {
if (ws.readyState === 1) { try { ws.ping(); } catch {} } else { clearInterval(hb); }
}, 25000);
ws.on('message', (raw) => {
let m; try { m = JSON.parse(raw); } catch { return; }
handle(ws, m, req).catch(() => {}); // handle is async now; never let a rejection go unhandled
});
ws.on('close', () => { clearInterval(hb); cleanup(ws).catch(() => {}); });
}
async function handle(ws, m, req) {
switch (m.type) {
// --- Logged-in user registers this socket for live chat delivery ---
case 'chat-hello': {
const u = await currentUser(req); // identity from the cookie/Bearer on the WS upgrade
if (!u) return ws.send(JSON.stringify({ type: 'error', message: 'unauthorized' }));
ws._chatUserId = u.id; ws._chatTeamId = u.team_id;
CHAT.register(u.id, ws);
ws.send(JSON.stringify({ type: 'chat-ready' }));
CHAT.broadcastPresence(u.id); // tell contacts this user just came online
break;
}
// Recipient's client acknowledges a DM was delivered → mark it + tell the sender.
case 'chat-delivered': {
if (!ws._chatUserId || !m.id) break;
const msg = await R.messages.byId(m.id);
if (!msg || msg.conversation_id || msg.team_id !== ws._chatTeamId) break; // DMs only
if (msg.recipient_id !== ws._chatUserId) break; // only the recipient can ack
if (!msg.delivered_at) { await R.messages.markDelivered(m.id); try { CHAT.pushToUser(msg.sender_id, { type: 'chat-delivered', id: m.id, with: msg.recipient_id }); } catch (_) {} }
break;
}
// Live "is typing…" — ephemeral, never persisted. Relay to the DM peer, or fan out to
// group members (membership-checked). The receiver auto-expires it, so a lost 'off' is harmless.
case 'chat-typing': {
const uid = ws._chatUserId; if (!uid) break;
const on = !!m.on;
let name = ''; try { const u = await R.users.byId(uid); name = (u && u.name) || ''; } catch (_) {}
if (m.group) {
let members; try { if (!await R.conversations.isMember(m.group, uid)) break; members = await R.conversations.members(m.group); } catch (_) { break; }
for (const mid of members) { if (mid !== uid) { try { CHAT.pushToUser(mid, { type: 'chat-typing', group: m.group, from: uid, name, on }); } catch (_) {} } }
} else if (m.to) {
try { CHAT.pushToUser(m.to, { type: 'chat-typing', from: uid, name, on }); } catch (_) {}
}
break;
}
// --- Meetings (mesh): create a room, join by code, relay SDP/ICE peer-to-peer ---
case 'meeting-create': {
let code; do { code = A.numericCode(6); } while (meetingRooms.has(code));
meetingRooms.set(code, new Map());
const cu = await currentUser(req); if (cu) roomHost.set(code, cu.id); // ad-hoc meeting: creator = host
// Lobby preference for guests joining this ad-hoc room by link (default: require approval).
roomLobby.set(code, m.lobby === false ? false : true);
ws.send(JSON.stringify({ type: 'meeting-created', room: code }));
break;
}
case 'meeting-join': {
const room = String(m.room || '').trim();
let peers = meetingRooms.get(room);
// A scheduled meeting's room is created lazily on first join (its code lives in the DB).
if (!peers) {
const sched = await R.scheduledMeetings.byCode(room);
if (sched && !sched.ended_at) { peers = new Map(); meetingRooms.set(room, peers); }
}
if (!peers) return ws.send(JSON.stringify({ type: 'error', message: 'Meeting not found' }));
const peerId = A.token(6);
const name = String(m.name || 'Guest').slice(0, 60);
ws.kind = 'meeting'; ws._meetingRoom = room; ws._peerId = peerId; ws._peerName = name;
// Host = the meeting's creator. roomHost is set on call/meeting creation; scheduled meetings fall back to created_by.
let hostUserId = roomHost.get(room);
if (hostUserId === undefined) { try { const s = await R.scheduledMeetings.byCode(room); if (s) { hostUserId = s.created_by; roomHost.set(room, hostUserId); } } catch (_) {} }
const ju = await currentUser(req);
// Identity used to map LiveKit media → this tile (peerIdForUid). Logged-in users use their user id
// (their LiveKit token identity is the same). GUESTS have no session, so they pass a stable client
// guest id here that ALSO becomes their LiveKit token identity — otherwise their media never maps
// to a tile and they're invisible/inaudible to others (and vice-versa for their screen share).
let mUid = ju ? ju.id : null;
if (!mUid && typeof m.guestId === 'string' && /^guest-[a-z0-9]+$/i.test(m.guestId)) mUid = m.guestId.slice(0, 64);
ws._meetingUserId = mUid; // for per-user transcript ownership + SFU media mapping
ws._meetingAvatar = (ju && ju.avatar_url) ? ju.avatar_url : null; // for participant-tile profile pics
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 && 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' }));
notifyHostsLobby(room, peers, hostUserId, peerId, name);
break;
}
finishMeetingJoin(ws, room, peers);
break;
}
// Host admits / rejects a guest waiting in the lobby.
case 'meeting-admit':
case 'meeting-reject': {
const room = ws._meetingRoom; const peers = room && meetingRooms.get(room); if (!peers) return;
const hostUserId = roomHost.get(room);
if (!(ws._meetingUserId && hostUserId && ws._meetingUserId === hostUserId)) return; // host only
const pend = lobbyPending.get(room); const gws = pend && pend.get(m.peerId); if (!gws) return;
pend.delete(m.peerId); if (gws) gws._lobbyRoom = null;
if (gws.readyState !== 1) return;
if (m.type === 'meeting-admit') finishMeetingJoin(gws, room, peers);
else gws.send(JSON.stringify({ type: 'meeting-rejected' }));
break;
}
case 'meeting-signal': {
const peers = ws._meetingRoom && meetingRooms.get(ws._meetingRoom);
if (!peers) return;
const target = peers.get(m.to);
if (target && target.ws.readyState === 1) target.ws.send(JSON.stringify({ type: 'meeting-signal', from: ws._peerId, data: m.data }));
break;
}
// In-meeting chat: relay a message to EVERYONE (m.to empty) or DIRECT to one peer (m.to = peerId).
// Ephemeral (not persisted) — it lives only for the duration of the call.
case 'meeting-chat': {
const peers = ws._meetingRoom && meetingRooms.get(ws._meetingRoom);
if (!peers) return;
const text = String(m.text || '').slice(0, 2000); if (!text) return;
const base = { type: 'meeting-chat', from: ws._peerId, name: ws._peerName || 'Guest', text, at: Date.now() };
if (m.to) { const target = peers.get(m.to); if (target && target.ws.readyState === 1) target.ws.send(JSON.stringify({ ...base, to: m.to, direct: true })); }
else { for (const [id, p] of peers) { if (id !== ws._peerId && p.ws.readyState === 1) p.ws.send(JSON.stringify(base)); } }
break;
}
// Relay a peer's mic/cam state to everyone else in the room (for the tile mute icon).
case 'meeting-state': {
const peers = ws._meetingRoom && meetingRooms.get(ws._meetingRoom);
if (!peers) return;
for (const [id, p] of peers) { if (id !== ws._peerId && p.ws.readyState === 1) p.ws.send(JSON.stringify({ type: 'meeting-peer-state', peerId: ws._peerId, muted: !!m.muted, camOff: !!m.camOff })); }
break;
}
// Relay a peer's screen-share on/off to everyone else (for the tile badge + single-share rule).
case 'meeting-screen': {
const peers = ws._meetingRoom && meetingRooms.get(ws._meetingRoom);
if (!peers) return;
for (const [id, p] of peers) { if (id !== ws._peerId && p.ws.readyState === 1) p.ws.send(JSON.stringify({ type: 'meeting-peer-screen', from: ws._peerId, on: !!m.on })); }
break;
}
// Host: set whether multiple people may share their screen at once.
case 'meeting-sharemode': {
const peers = ws._meetingRoom && meetingRooms.get(ws._meetingRoom);
if (!peers) return;
for (const [id, p] of peers) { if (id !== ws._peerId && p.ws.readyState === 1) p.ws.send(JSON.stringify({ type: 'meeting-sharemode', multi: !!m.multi })); }
break;
}
// Host starts/stops recording → tell everyone so they see (and hear) the "being recorded" notice.
case 'meeting-recording': {
const peers = ws._meetingRoom && meetingRooms.get(ws._meetingRoom);
if (!peers) return;
for (const [id, p] of peers) { if (id !== ws._peerId && p.ws.readyState === 1) p.ws.send(JSON.stringify({ type: 'meeting-recording', on: !!m.on, by: ws._peerName || 'The host' })); }
break;
}
// A participant subscribes/unsubscribes to a transcript copy. While ≥1 subscriber, EVERY client
// transcribes its own mic (full conversation); each subscriber gets their own private copy.
// Unsubscribing only drops YOUR copy — it never stops anyone else's.
case 'meeting-transcribe': {
const room = ws._meetingRoom; const peers = room && meetingRooms.get(room);
if (!peers) return; const uid = ws._meetingUserId; if (!uid) return;
let subs = transcriptSubs.get(room); if (!subs) { subs = new Set(); transcriptSubs.set(room, subs); }
if (m.on) subs.add(uid); else { try { require('./calls').finalizeTranscript(room, uid); } catch (_) {} } // finalize writes + removes the sub
const active = subs.size > 0;
for (const [, p] of peers) { if (p.ws.readyState === 1) p.ws.send(JSON.stringify({ type: 'meeting-transcribe-state', active })); }
break;
}
// A participant's recognized speech segment → appended to the room's shared transcript buffer.
case 'meeting-transcript': {
const room = ws._meetingRoom; if (!room || !meetingRooms.get(room)) return;
const text = String(m.text || '').slice(0, 1000).trim(); if (!text) return;
let buf = transcriptBuffers.get(room); if (!buf) { buf = []; transcriptBuffers.set(room, buf); }
buf.push({ t: Date.now(), speaker: ws._peerName || 'Guest', text });
if (buf.length > 8000) buf.shift();
break;
}
// Host: mute everyone else in the room.
case 'meeting-muteall': {
const peers = ws._meetingRoom && meetingRooms.get(ws._meetingRoom);
if (!peers) return;
for (const [id, p] of peers) { if (id !== ws._peerId && p.ws.readyState === 1) p.ws.send(JSON.stringify({ type: 'meeting-muteall', by: ws._peerId })); }
break;
}
// Host: transfer host to another peer (broadcast the new host to the room).
case 'meeting-host': {
const peers = ws._meetingRoom && meetingRooms.get(ws._meetingRoom);
if (!peers || !m.to) return;
for (const [, p] of peers) { if (p.ws.readyState === 1) p.ws.send(JSON.stringify({ type: 'meeting-host', hostPeerId: m.to })); }
break;
}
case 'meeting-leave': {
await leaveMeeting(ws);
break;
}
// --- Agent comes online ---
case 'agent-hello': {
const machine = await R.machines.byEnrollToken(m.enrollToken);
if (!machine) return ws.send(JSON.stringify({ type: 'error', message: 'invalid enroll token' }));
ws.kind = 'agent'; ws.machineId = machine.id;
onlineAgents.set(machine.id, { ws, machine });
await R.machines.touch(machine.id);
ws.send(JSON.stringify({ type: 'agent-registered', machineId: machine.id, name: machine.name }));
break;
}
// --- Technician requests control of a machine ---
case 'viewer-connect': {
const u = await currentUser(req); // cookie sent on WS upgrade
if (!u) return ws.send(JSON.stringify({ type: 'error', message: 'unauthorized' }));
const agent = onlineAgents.get(m.machineId);
const machine = await R.machines.inTenant(m.machineId, u.team_id);
if (!machine) return ws.send(JSON.stringify({ type: 'error', message: 'no such machine' }));
if (!agent) return ws.send(JSON.stringify({ type: 'error', message: 'machine offline' }));
if (u.role === 'viewer' && false) {} // view-only still allowed to watch; control gated agent-side
const sessionId = A.token(8);
ws.kind = 'viewer'; ws.sessionId = sessionId;
liveSessions.set(sessionId, { agentWs: agent.ws, viewerWs: ws, machine, user: u });
audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, machine_id: machine.id, machine_name: machine.name, action: 'session_requested' });
// Ask the agent for consent (or auto-grant if unattended policy is on)
agent.ws.sessionId = sessionId;
agent.ws.send(JSON.stringify({
type: 'session-request', sessionId,
technician: u.email, unattended: !!machine.unattended,
}));
ws.send(JSON.stringify({ type: 'session-pending', sessionId, machineName: machine.name }));
break;
}
// --- Agent grants/denies consent ---
case 'consent': {
const sess = liveSessions.get(m.sessionId);
if (!sess) return;
if (m.granted) {
audit({ team_id: sess.machine.team_id, user_id: sess.user.id, user_email: sess.user.email, machine_id: sess.machine.id, machine_name: sess.machine.name, action: 'consent_granted', detail: sess.ticket ? 'Ticket ' + sess.ticket : (sess.machine.id ? null : 'Direct session') });
try {
await R.sessionsLog.create({ id: m.sessionId, tenantId: sess.machine.team_id, agentEmail: sess.user.email, agentName: sess.agentName || sess.user.email, ticket: sess.ticket || null });
} catch (e) { /* duplicate consent */ }
try { W.emit('session.started', sess.machine.team_id, { sessionId: m.sessionId, agent_email: sess.user.email, agent_name: sess.agentName || sess.user.email, ticket: sess.ticket || null, started_at: Date.now() }); } catch (_) {}
sess.viewerWs.send(JSON.stringify({ type: 'session-ready', sessionId: m.sessionId }));
sess.agentWs.send(JSON.stringify({ type: 'start-stream', sessionId: m.sessionId }));
} else {
audit({ team_id: sess.machine.team_id, user_id: sess.user.id, user_email: sess.user.email, machine_id: sess.machine.id, machine_name: sess.machine.name, action: 'consent_denied', detail: sess.ticket ? 'Ticket ' + sess.ticket : (sess.machine.id ? null : 'Direct session') });
sess.viewerWs.send(JSON.stringify({ type: 'session-denied', sessionId: m.sessionId }));
liveSessions.delete(m.sessionId);
}
break;
}
// --- No-install: end user opens /share, gets a one-time code ---
case 'share-create': {
let code;
do { code = A.numericCode(6); } while (pendingShares.has(code));
const sessionId = A.token(8);
ws.kind = 'sharer'; ws.shareCode = code; ws.sessionId = sessionId;
pendingShares.set(code, { sharerWs: ws, sessionId });
ws.send(JSON.stringify({ type: 'share-code', code }));
break;
}
// --- Logged-in agent enters the code (+ ticket) to connect ---
case 'code-connect': {
const agent = await currentUser(req); // identity from the agent's authenticated session
if (!agent) {
return ws.send(JSON.stringify({ type: 'error', message: 'Please sign in as an agent first' }));
}
const ticket = String(m.ticket || '').trim() || null; // optional: direct sessions have no ticket
const pend = pendingShares.get(String(m.code || '').trim());
if (!pend || pend.sharerWs.readyState !== 1) {
return ws.send(JSON.stringify({ type: 'error', message: 'Invalid or expired code' }));
}
pendingShares.delete(pend.sharerWs.shareCode);
const sessionId = pend.sessionId;
ws.kind = 'viewer'; ws.sessionId = sessionId;
const agentName = agent.name || agent.email;
const machine = { id: null, name: 'Support session ' + pend.sharerWs.shareCode, team_id: agent.team_id };
const user = { id: agent.id, email: agent.email, team_id: agent.team_id };
liveSessions.set(sessionId, { agentWs: pend.sharerWs, viewerWs: ws, machine, user, ticket, agentName });
pend.sharerWs.sessionId = sessionId;
audit({ team_id: agent.team_id, user_id: agent.id, user_email: agent.email, machine_name: machine.name, action: 'code_session_requested', detail: (ticket ? 'Ticket ' + ticket : 'Direct session (no ticket)') + ' · agent ' + agentName });
pend.sharerWs.send(JSON.stringify({ type: 'share-request', sessionId, technician: agentName, ticket }));
ws.send(JSON.stringify({ type: 'code-pending', sessionId }));
break;
}
// --- Relay WebRTC signaling between the two peers ---
case 'offer': case 'answer': case 'ice-candidate': {
const sess = liveSessions.get(m.sessionId || ws.sessionId);
if (!sess) return;
const peer = ws === sess.agentWs ? sess.viewerWs : sess.agentWs;
if (peer && peer.readyState === 1) peer.send(JSON.stringify(m));
break;
}
case 'transcript': {
const sess = liveSessions.get(m.sessionId || ws.sessionId);
if (!sess) return;
const peer = ws === sess.agentWs ? sess.viewerWs : sess.agentWs;
if (peer && peer.readyState === 1) peer.send(JSON.stringify(m));
break;
}
case 'recording': {
const sess = liveSessions.get(m.sessionId || ws.sessionId);
if (!sess) return;
const peer = ws === sess.agentWs ? sess.viewerWs : sess.agentWs;
if (peer && peer.readyState === 1) peer.send(JSON.stringify(m));
break;
}
case 'end-session': {
await endSession(ws.sessionId, m.reason || null);
break;
}
}
}
async function endSession(sessionId, reason) {
const sess = liveSessions.get(sessionId);
if (!sess) return;
try { await R.sessionsLog.end(sessionId); } catch (e) {}
try {
const row = await R.sessionsLog.byId(sessionId);
if (row) W.emit('session.ended', sess.machine.team_id, { sessionId: row.id, agent_email: row.agent_email,
agent_name: row.agent_name, ticket: row.ticket, started_at: row.started_at, ended_at: row.ended_at,
duration_ms: row.ended_at ? row.ended_at - row.started_at : null });
} catch (e) {}
audit({ team_id: sess.machine.team_id, user_id: sess.user.id, user_email: sess.user.email, machine_id: sess.machine.id, machine_name: sess.machine.name, action: 'session_ended', detail: sess.ticket ? 'Ticket ' + sess.ticket : (sess.machine.id ? null : 'Direct session') });
[sess.agentWs, sess.viewerWs].forEach((p) => {
if (p && p.readyState === 1) p.send(JSON.stringify({ type: 'session-ended', sessionId, reason: reason || null }));
});
liveSessions.delete(sessionId);
}
async function leaveMeeting(ws) {
const room = ws._meetingRoom;
if (!room) return;
const peers = meetingRooms.get(room);
ws._meetingRoom = null;
const pid = ws._peerId;
const leaverId = ws._meetingUserId;
if (!peers) { if (leaverId) CHAT.broadcastPresence(leaverId); return; }
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; } }
await persistCallHistory(room); // #7: log the call BEFORE endCallByRoom clears roomToDmCall/roomToGroupCall
meetingRooms.delete(room);
try { await require('./calls').finalizeTranscript(room); } catch (_) {} // any remaining buffers (safety)
roomHost.delete(room);
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) {
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 { await require('./calls').finalizeTranscript(room); } catch (_) {} // before endCallByRoom clears the maps
roomHost.delete(room);
try { await require('./calls').endCallByRoom(room); } catch (_) {}
}
if (leaverId) CHAT.broadcastPresence(leaverId); // this user left the call → update contacts live
}
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) {
const room = ws._lobbyRoom; const pend = lobbyPending.get(room); if (pend) pend.delete(ws._peerId);
const peers = meetingRooms.get(room); const hostUserId = roomHost.get(room);
if (peers) for (const [, p] of peers) { if (p.uid && hostUserId && p.uid === hostUserId && p.ws.readyState === 1) p.ws.send(JSON.stringify({ type: 'meeting-lobby-cancel', peerId: ws._peerId })); }
ws._lobbyRoom = null;
}
CHAT.unregister(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) await endSession(sid);
}
}
}
module.exports = { onConnection };