feat(meetings): call log, past pagination + date filter; last-seen exact time (batch83)

#7 The past list was hard-capped at 12 (a .slice(0,12) in the client) and there was NO record
   of how many people were ever in a finished call — so the "only calls with >2 people" rule
   was impossible to apply. Added a call_history table: signaling tracks the HIGH-WATER
   participant count per room and logs the call when the room tears down (scheduled meetings
   are skipped — they already have their own row).
   Past meetings now follow the rules asked for:
     • a plain 1:1 direct call is NOT listed — unless it produced a recording/transcript
       (those already surface as recording entries);
     • a call that ever held MORE than 2 people IS listed (e.g. a 1:1 a third person joined),
       showing its participant count and duration;
     • entries are visible only to people who were actually in the call (or the group).
   Server-side pagination (10/page) + a from/to date filter; nothing is double-listed.

#2 Last seen now shows the exact time/date, WhatsApp-style — "last seen today at 1:36 PM",
   "last seen yesterday at 10:15 AM", "last seen 14/07/2026 at 9:00 AM" — instead of "10
   minutes ago".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-15 00:42:53 +05:30
parent 860a7bd6cf
commit 6fa261b68f
5 changed files with 163 additions and 21 deletions
+18
View File
@@ -238,6 +238,24 @@ try { db.exec('CREATE INDEX IF NOT EXISTS idx_users_bizgaze_user_id ON users(biz
// When two accounts merge (#2), the merged-away row is deleted. This records old_id -> survivor so // When two accounts merge (#2), the merged-away row is deleted. This records old_id -> survivor so
// any lingering reference to the old id (a cached contact, an in-flight DM) resolves to the survivor // any lingering reference to the old id (a cached contact, an in-flight DM) resolves to the survivor
// instead of hitting a deleted user (which made messages to merged contacts silently vanish). // instead of hitting a deleted user (which made messages to merged contacts silently vanish).
// #7: a log of finished CALLS (ad-hoc / group / 1:1). Scheduled meetings already have their own row, so
// they're not duplicated here. `peak` is the most people who were in the room at once — that's what lets
// "Past meetings" show a call that grew past 2 people while hiding plain 1:1s.
db.exec(`
CREATE TABLE IF NOT EXISTS call_history (
id TEXT PRIMARY KEY,
team_id TEXT NOT NULL,
room TEXT NOT NULL,
group_id TEXT,
kind TEXT,
title TEXT,
peak INTEGER NOT NULL DEFAULT 0,
participants TEXT,
uids TEXT,
started_at INTEGER NOT NULL,
ended_at INTEGER NOT NULL
)`);
try { db.exec('CREATE INDEX IF NOT EXISTS idx_call_history_team ON call_history(team_id, ended_at)'); } catch (e) {}
db.exec(` db.exec(`
CREATE TABLE IF NOT EXISTS user_aliases ( CREATE TABLE IF NOT EXISTS user_aliases (
old_id TEXT PRIMARY KEY, old_id TEXT PRIMARY KEY,
+60 -18
View File
@@ -820,7 +820,18 @@
.gi-open{cursor:pointer;} .gi-open{cursor:pointer;}
.sched-wrap{width:100%;margin:1.2rem 0 0;text-align:left;} .sched-wrap{width:100%;margin:1.2rem 0 0;text-align:left;}
.sched-sec{margin-bottom:1.4rem;} .sched-sec{margin-bottom:1.4rem;}
.sched-h{font-size:.74rem;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);margin:0 0 .55rem;font-weight:700;} .sched-h{display:flex;align-items:center;gap:.6rem;font-size:.74rem;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);margin:0 0 .55rem;font-weight:700;}
/* #7: past-meetings date filter + pager */
.mtg-filter{display:inline-flex;align-items:center;gap:.35rem;margin-left:auto;text-transform:none;letter-spacing:0;}
.mtg-filter svg{color:var(--blue);flex:0 0 auto;}
.mtg-filter input[type=date]{border:1px solid var(--line);border-radius:8px;padding:.25rem .4rem;font:inherit;font-size:.72rem;font-weight:600;background:#fbfcfe;color:var(--ink);}
.mtg-filter input[type=date]:focus{outline:none;border-color:var(--blue);}
.mtg-clear{border:none;background:transparent;color:var(--muted);cursor:pointer;display:grid;place-items:center;padding:.15rem;border-radius:50%;}
.mtg-clear:hover{background:#fee2e2;color:var(--red);}
.mtg-pager{display:flex;align-items:center;justify-content:center;gap:.7rem;margin-top:.7rem;font-size:.76rem;color:var(--muted);}
.mtg-pg{border:1px solid var(--line);background:var(--card);color:var(--blue);border-radius:8px;width:30px;height:28px;display:grid;place-items:center;cursor:pointer;}
.mtg-pg:hover:not(:disabled){background:var(--blue-soft);}
.mtg-pg:disabled{opacity:.4;cursor:default;}
.sched-list{display:grid;grid-template-columns:repeat(auto-fill,minmax(330px,1fr));gap:.7rem;} .sched-list{display:grid;grid-template-columns:repeat(auto-fill,minmax(330px,1fr));gap:.7rem;}
.sched-item{display:flex;align-items:flex-start;gap:.6rem;background:var(--card);border:1px solid var(--line);border-radius:12px;padding:.8rem .9rem;} .sched-item{display:flex;align-items:flex-start;gap:.6rem;background:var(--card);border:1px solid var(--line);border-radius:12px;padding:.8rem .9rem;}
.sched-item.live{border-color:#34d399;background:#f0fdf4;} .sched-item.live{border-color:#34d399;background:#f0fdf4;}
@@ -1037,7 +1048,7 @@
</head> </head>
<body> <body>
<script src="/icons.js?v=6"></script> <script src="/icons.js?v=6"></script>
<script>window.__BUILD='2026-07-14-batch82';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD); <script>window.__BUILD='2026-07-15-batch83';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD);
// Emoji are rendered with the OS's own (colour) emoji font — instant, zero network. // Emoji are rendered with the OS's own (colour) emoji font — instant, zero network.
// //
// We used to run Twemoji over every emoji, which swapped each one for an <img> pulled INDIVIDUALLY from // We used to run Twemoji over every emoji, which swapped each one for an <img> pulled INDIVIDUALLY from
@@ -1564,21 +1575,18 @@ function fmtTime(ts){
// Presence/status helpers (#7): 'incall' (auto) overrides; offline if not connected. // Presence/status helpers (#7): 'incall' (auto) overrides; offline if not connected.
function statusCls(it){ const s=it&&it.status; if(s==='incall') return 'incall'; if(!it||!it.online) return 'offline'; if(s==='away') return 'away'; if(s==='onleave') return 'onleave'; return 'active'; } function statusCls(it){ const s=it&&it.status; if(s==='incall') return 'incall'; if(!it||!it.online) return 'offline'; if(s==='away') return 'away'; if(s==='onleave') return 'onleave'; return 'active'; }
function statusLabel(it){ const c=statusCls(it); return c==='incall'?'In a call':c==='away'?'Away':c==='onleave'?'On leave':c==='active'?'Available':lastSeenLabel(it); } function statusLabel(it){ const c=statusCls(it); return c==='incall'?'In a call':c==='away'?'Away':c==='onleave'?'On leave':c==='active'?'Available':lastSeenLabel(it); }
// #2: when someone is offline, "Offline" alone is unhelpful — say when they were last around. // #2: when someone is offline, say exactly WHEN they were last around — WhatsApp style, i.e. an actual
// time/date ("last seen today at 1:36 PM"), not a vague "10 minutes ago".
function lastSeenLabel(it){ function lastSeenLabel(it){
const ts=it&&it.lastSeen; if(!ts) return 'Offline'; const ts=it&&it.lastSeen; if(!ts) return 'Offline';
const d=Date.now()-ts;
if(d<60*1000) return 'Last seen just now';
const mins=Math.floor(d/60000);
if(mins<60) return 'Last seen '+mins+(mins===1?' minute ago':' minutes ago');
const hrs=Math.floor(mins/60);
if(hrs<24) return 'Last seen '+hrs+(hrs===1?' hour ago':' hours ago');
const t=new Date(ts), now=new Date(); const t=new Date(ts), now=new Date();
const days=Math.floor((new Date(now.getFullYear(),now.getMonth(),now.getDate())-new Date(t.getFullYear(),t.getMonth(),t.getDate()))/86400000); const clock=t.toLocaleTimeString([],{hour:'numeric',minute:'2-digit'});
const clock=t.toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'}); const dayOf=(d)=>new Date(d.getFullYear(),d.getMonth(),d.getDate()).getTime();
if(days===1) return 'Last seen yesterday at '+clock; const days=Math.round((dayOf(now)-dayOf(t))/86400000);
if(days<7) return 'Last seen '+t.toLocaleDateString([],{weekday:'long'})+' at '+clock; if(days<=0) return 'last seen today at '+clock;
return 'Last seen '+t.toLocaleDateString([],{month:'short',day:'numeric'})+' at '+clock; if(days===1) return 'last seen yesterday at '+clock;
if(days<7) return 'last seen '+t.toLocaleDateString([],{weekday:'long'})+' at '+clock;
return 'last seen '+t.toLocaleDateString([],{day:'2-digit',month:'2-digit',year:'numeric'})+' at '+clock;
} }
function avatarHTML(it, big){ function avatarHTML(it, big){
const isG=it.kind==='group'; const isG=it.kind==='group';
@@ -3602,17 +3610,27 @@ function renderMeetingLobby(){
loadScheduledMeetings(); loadScheduledMeetings();
} }
// Fetch + render scheduled meetings, bucketed into Running / Upcoming / Past. // Fetch + render scheduled meetings, bucketed into Running / Upcoming / Past.
// #7: Past meetings are paged + date-filterable on the SERVER (the list used to be hard-capped at 12).
let _mtgPage=1, _mtgFrom='', _mtgTo='', _mtgTotal=0, _mtgPageSize=10;
async function loadScheduledMeetings(){ async function loadScheduledMeetings(){
const wrap=document.getElementById('schedWrap'); if(!wrap) return; const wrap=document.getElementById('schedWrap'); if(!wrap) return;
let list; try{ list=await fetch('/api/meetings').then(r=>r.json()); }catch(_){ return; } const qs=new URLSearchParams();
if(!Array.isArray(list)||!list.length){ wrap.innerHTML='<div class="sched-empty">No meetings yet. Start one now or schedule it for later.</div>'; return; } qs.set('page', String(_mtgPage)); qs.set('pageSize', String(_mtgPageSize));
if(_mtgFrom){ const t=new Date(_mtgFrom); t.setHours(0,0,0,0); qs.set('from', String(t.getTime())); }
if(_mtgTo){ const t=new Date(_mtgTo); t.setHours(23,59,59,999); qs.set('to', String(t.getTime())); }
let res; try{ res=await fetch('/api/meetings?'+qs.toString()).then(r=>r.json()); }catch(_){ return; }
const list=Array.isArray(res)?res:(res&&Array.isArray(res.list)?res.list:[]); // tolerate the old array shape
_mtgTotal=(res&&res.pastTotal)||0; _mtgPageSize=(res&&res.pageSize)||_mtgPageSize;
const filtering=!!(_mtgFrom||_mtgTo);
if(!list.length && !filtering && !_mtgTotal){ wrap.innerHTML='<div class="sched-empty">No meetings yet. Start one now or schedule it for later.</div>'; return; }
const running=list.filter(m=>m.status==='running'); const running=list.filter(m=>m.status==='running');
const upcoming=list.filter(m=>m.status==='upcoming').sort((a,b)=>a.scheduledAt-b.scheduledAt); const upcoming=list.filter(m=>m.status==='upcoming').sort((a,b)=>a.scheduledAt-b.scheduledAt);
const past=list.filter(m=>m.status==='past'||m.status==='cancelled').sort((a,b)=>b.scheduledAt-a.scheduledAt).slice(0,12); const past=list.filter(m=>m.status==='past'||m.status==='cancelled'); // server already sorted+paged
const fmt=ts=>new Date(ts).toLocaleString([],{weekday:'short',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}); const fmt=ts=>new Date(ts).toLocaleString([],{weekday:'short',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'});
const card=m=>{ const card=m=>{
const cancelled=m.status==='cancelled'; const cancelled=m.status==='cancelled';
const meta=[]; if(m.groupName) meta.push(pEsc(m.groupName)); meta.push(fmt(m.scheduledAt)); const meta=[]; if(m.groupName) meta.push(pEsc(m.groupName)); meta.push(fmt(m.scheduledAt));
if(m.participantCount) meta.push(m.participantCount+' participants'); // #7: from the call log
if(m.durationMins) meta.push(m.durationMins+' min'); if(m.durationMins) meta.push(m.durationMins+' min');
if(m.recurrenceLabel) meta.push('🔁 '+pEsc(m.recurrenceLabel)); if(m.recurrenceLabel) meta.push('🔁 '+pEsc(m.recurrenceLabel));
if(m.status==='running') meta.push(m.inCall+' in call'); if(m.status==='running') meta.push(m.inCall+' in call');
@@ -3637,7 +3655,31 @@ async function loadScheduledMeetings(){
}; };
const byId={}; list.forEach(m=>byId[m.id]=m); const byId={}; list.forEach(m=>byId[m.id]=m);
const sec=(title,arr)=>arr.length?('<div class="sched-sec"><div class="sched-h">'+title+'</div><div class="sched-list">'+arr.map(card).join('')+'</div></div>'):''; const sec=(title,arr)=>arr.length?('<div class="sched-sec"><div class="sched-h">'+title+'</div><div class="sched-list">'+arr.map(card).join('')+'</div></div>'):'';
wrap.innerHTML=sec('Ongoing now',running)+sec('Upcoming meetings',upcoming)+sec('Past meetings',past); // Past section: date range + pager. Always rendered (even with 0 results) so the filter stays reachable.
const pages=Math.max(1, Math.ceil(_mtgTotal/_mtgPageSize));
const pastSec='<div class="sched-sec"><div class="sched-h">Past meetings'
+'<span class="mtg-filter">'+ic('calendar',13)
+'<input type="date" id="mtgFrom" value="'+pEsc(_mtgFrom)+'" title="From">'
+'<span></span><input type="date" id="mtgTo" value="'+pEsc(_mtgTo)+'" title="To">'
+((_mtgFrom||_mtgTo)?'<button class="mtg-clear" id="mtgClear" title="Clear filter">'+ic('x',13)+'</button>':'')
+'</span></div>'
+(past.length?('<div class="sched-list">'+past.map(card).join('')+'</div>')
:('<div class="sched-empty" style="margin:0">'+((_mtgFrom||_mtgTo)?'No meetings in that date range.':'No past meetings yet.')+'</div>'))
+(_mtgTotal>_mtgPageSize?('<div class="mtg-pager">'
+'<button class="mtg-pg" id="mtgPrev"'+(_mtgPage<=1?' disabled':'')+'>'+ic('chevronLeft',14)+'</button>'
+'<span>Page '+_mtgPage+' of '+pages+' · '+_mtgTotal+' total</span>'
+'<button class="mtg-pg" id="mtgNext"'+(_mtgPage>=pages?' disabled':'')+'>'+ic('chevronRight',14)+'</button>'
+'</div>'):'')
+'</div>';
wrap.innerHTML=sec('Ongoing now',running)+sec('Upcoming meetings',upcoming)+pastSec;
{ const f=wrap.querySelector('#mtgFrom'), t=wrap.querySelector('#mtgTo'), c=wrap.querySelector('#mtgClear');
if(f) f.onchange=()=>{ _mtgFrom=f.value; _mtgPage=1; loadScheduledMeetings(); };
if(t) t.onchange=()=>{ _mtgTo=t.value; _mtgPage=1; loadScheduledMeetings(); };
if(c) c.onclick=()=>{ _mtgFrom=''; _mtgTo=''; _mtgPage=1; loadScheduledMeetings(); };
const pv=wrap.querySelector('#mtgPrev'), nx=wrap.querySelector('#mtgNext');
if(pv) pv.onclick=()=>{ if(_mtgPage>1){ _mtgPage--; loadScheduledMeetings(); } };
if(nx) nx.onclick=()=>{ if(_mtgPage<pages){ _mtgPage++; loadScheduledMeetings(); } };
}
wrap.querySelectorAll('[data-code]').forEach(b=>b.onclick=()=>enterMeeting(b.dataset.code)); wrap.querySelectorAll('[data-code]').forEach(b=>b.onclick=()=>enterMeeting(b.dataset.code));
wrap.querySelectorAll('[data-link]').forEach(b=>b.onclick=()=>{ const u=b.dataset.link; if(!u){ toast('No link'); return; } const done=()=>toast('Invite link copied'); try{ if(navigator.clipboard&&navigator.clipboard.writeText) return void navigator.clipboard.writeText(u).then(done).catch(()=>fallbackCopy(u,done)); }catch(_){} fallbackCopy(u,done); }); wrap.querySelectorAll('[data-link]').forEach(b=>b.onclick=()=>{ const u=b.dataset.link; if(!u){ toast('No link'); return; } const done=()=>toast('Invite link copied'); try{ if(navigator.clipboard&&navigator.clipboard.writeText) return void navigator.clipboard.writeText(u).then(done).catch(()=>fallbackCopy(u,done)); }catch(_){} fallbackCopy(u,done); });
wrap.querySelectorAll('[data-edit]').forEach(b=>b.onclick=()=>{ const m=byId[b.dataset.edit]; if(m) openScheduleModal(m.groupId||null, m); }); wrap.querySelectorAll('[data-edit]').forEach(b=>b.onclick=()=>{ const m=byId[b.dataset.edit]; if(m) openScheduleModal(m.groupId||null, m); });
+10 -1
View File
@@ -323,6 +323,15 @@ const scheduledMeetings = {
remove: (id, teamId) => db.prepare('DELETE FROM scheduled_meetings WHERE id=? AND team_id=?').run(id, teamId), remove: (id, teamId) => db.prepare('DELETE FROM scheduled_meetings WHERE id=? AND team_id=?').run(id, teamId),
}; };
// #7: finished calls (not scheduled meetings — those have their own table).
const callHistory = {
create: ({ id, teamId, room, groupId, kind, title, peak, participants, uids, startedAt, endedAt }) =>
db.prepare('INSERT INTO call_history (id,team_id,room,group_id,kind,title,peak,participants,uids,started_at,ended_at) VALUES (?,?,?,?,?,?,?,?,?,?,?)')
.run(id, teamId, room, groupId || null, kind || null, title || null, peak || 0,
JSON.stringify(participants || []), JSON.stringify(uids || []), startedAt, endedAt),
forTeam: (teamId) => db.prepare('SELECT * FROM call_history WHERE team_id=? ORDER BY ended_at DESC').all(teamId),
};
const recordings = { const recordings = {
create: ({ id, teamId, room, groupId, meetingId, title, kind, file, mime, size, durationMs, createdBy, createdByName }) => create: ({ id, teamId, room, groupId, meetingId, title, kind, file, mime, size, durationMs, createdBy, createdByName }) =>
db.prepare('INSERT INTO recordings (id,team_id,room,group_id,meeting_id,title,kind,file,mime,size,duration_ms,created_by,created_by_name,created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)') db.prepare('INSERT INTO recordings (id,team_id,room,group_id,meeting_id,title,kind,file,mime,size,duration_ms,created_by,created_by_name,created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)')
@@ -388,4 +397,4 @@ const appInstalls = {
listForTenant: (tenantId) => db.prepare('SELECT * FROM app_installs WHERE tenant_id=? ORDER BY last_seen DESC').all(tenantId), listForTenant: (tenantId) => db.prepare('SELECT * FROM app_installs WHERE tenant_id=? ORDER BY last_seen DESC').all(tenantId),
}; };
module.exports = { teams, users, authSessions, machines, audit, sessionsLog, refreshTokens, apiKeys, webhooks, messages, reactions, attachments, conversations, scheduledMeetings, recordings, polls, pollVotes, pushSubs, favorites, deviceTokens, appInstalls }; module.exports = { teams, users, authSessions, machines, audit, sessionsLog, refreshTokens, apiKeys, webhooks, messages, reactions, attachments, conversations, scheduledMeetings, callHistory, recordings, polls, pollVotes, pushSubs, favorites, deviceTokens, appInstalls };
+41 -1
View File
@@ -1239,7 +1239,47 @@ route('GET', '/api/meetings', async (req, res) => {
invited: [], status: 'past', inCall: 0, recordings: list.map(recDTO), invited: [], status: 'past', inCall: 0, recordings: list.map(recDTO),
}; };
}); });
json(res, 200, rows.concat(synth));
// #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
// recording/transcript, which the `synth` entries above already cover;
// • a call that ever held MORE THAN 2 people IS listed (e.g. a 1:1 that a third person joined).
// Rooms already represented by a scheduled meeting or a recording entry are skipped, so nothing doubles.
const takenRooms = new Set([...schedByRoom.keys(), ...[...unsched.values()].map((l) => l[0].room).filter(Boolean)]);
const callRows = [];
for (const c of R.callHistory.forTeam(u.team_id)) {
if (c.peak <= 2) continue; // 1:1 (or nobody) → not a meeting
if (c.room && takenRooms.has(c.room)) continue; // already listed above
let uids = []; try { uids = JSON.parse(c.uids || '[]'); } catch (_) {}
const canSee = uids.includes(u.id) || (c.group_id && R.conversations.isMember(c.group_id, u.id));
if (!canSee) continue; // only people who were actually in it
let parts = []; try { parts = JSON.parse(c.participants || '[]'); } catch (_) {}
callRows.push({
id: 'call-' + c.id, roomCode: c.room || '', title: c.title || (c.group_id ? 'Group call' : 'Meeting'),
description: '', scheduledAt: c.started_at, endedAt: c.ended_at, groupId: c.group_id || null,
groupName: c.group_id ? ((R.conversations.byId(c.group_id) || {}).name || 'Group') : null,
createdBy: null, createdByName: '', canManage: false, isHost: false,
invited: parts, participantCount: c.peak,
durationMins: Math.max(1, Math.round((c.ended_at - c.started_at) / 60000)),
status: 'past', inCall: 0, recordings: [],
});
}
// Date filter + pagination apply to PAST only (running/upcoming are small and always returned whole).
const q = new URLSearchParams(req.url.split('?')[1] || '');
const from = Number(q.get('from')) || 0;
const to = Number(q.get('to')) || 0;
const page = Math.max(1, Number(q.get('page')) || 1);
const pageSize = Math.min(50, Math.max(5, Number(q.get('pageSize')) || 10));
const all = rows.concat(synth, callRows);
const live2 = all.filter((m) => m.status !== 'past');
let past = all.filter((m) => m.status === 'past');
if (from) past = past.filter((m) => m.scheduledAt >= from);
if (to) past = past.filter((m) => m.scheduledAt <= to);
past.sort((a, b) => b.scheduledAt - a.scheduledAt); // newest first
const pastTotal = past.length;
const start = (page - 1) * pageSize;
json(res, 200, { list: live2.concat(past.slice(start, start + pageSize)), pastTotal, page, pageSize });
}); });
// Host uploads an in-browser meeting recording (webm). Stored + indexed so it shows under Past meetings. // Host uploads an in-browser meeting recording (webm). Stored + indexed so it shows under Past meetings.
+34 -1
View File
@@ -5,7 +5,7 @@
const R = require('./repos'); const R = require('./repos');
const A = require('./auth'); const A = require('./auth');
const { currentUser, audit } = require('./session'); const { currentUser, audit } = require('./session');
const { onlineAgents, liveSessions, pendingShares, meetingRooms, roomToDmCall, roomHost, transcriptBuffers, transcriptSubs } = require('./presence'); const { onlineAgents, liveSessions, pendingShares, meetingRooms, roomToDmCall, roomToGroupCall, roomHost, transcriptBuffers, transcriptSubs } = require('./presence');
const W = require('./webhooks'); const W = require('./webhooks');
const CHAT = require('./chat'); const CHAT = require('./chat');
@@ -14,6 +14,35 @@ const CHAT = require('./chat');
// lobbyPending: room code -> Map(peerId -> guest ws) awaiting admission. // lobbyPending: room code -> Map(peerId -> guest ws) awaiting admission.
const roomLobby = new Map(); const roomLobby = new Map();
const lobbyPending = 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.
function persistCallHistory(room) {
const st = roomStats.get(room);
roomStats.delete(room);
if (!st || !st.teamId || st.peak < 1) return;
try {
if (R.scheduledMeetings.byCode(room)) return; // the scheduled meeting row already represents this
const gid = roomToGroupCall.get(room) || null;
const isDm = roomToDmCall.has(room);
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 // A room requires host approval for GUESTS when explicitly set (ad-hoc) or by the scheduled meeting's
// `lobby` flag. Default: require approval (the "people with the link join directly" concern) unless the // `lobby` flag. Default: require approval (the "people with the link join directly" concern) unless the
// organizer chose "join directly". Logged-in tenant users are never held — only guests. // organizer chose "join directly". Logged-in tenant users are never held — only guests.
@@ -32,6 +61,7 @@ function finishMeetingJoin(ws, room, peers) {
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 })) })); 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 })); } 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 }); 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); } 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 })); 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' })); } } 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' })); } }
@@ -121,6 +151,7 @@ function handle(ws, m, req) {
if (!mUid && typeof m.guestId === 'string' && /^guest-[a-z0-9]+$/i.test(m.guestId)) mUid = m.guestId.slice(0, 64); 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._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 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. // LOBBY (#4): a GUEST (no session) waits for the host to admit them when the room requires approval.
// Logged-in tenant members always join directly. // Logged-in tenant members always join directly.
if (!ju && meetingRoomRequiresApproval(room)) { if (!ju && meetingRoomRequiresApproval(room)) {
@@ -376,6 +407,7 @@ function leaveMeeting(ws) {
if (roomToDmCall.has(room)) { if (roomToDmCall.has(room)) {
const others = [...peers.values()].map((p) => p.ws && p.ws._meetingUserId).filter(Boolean); const others = [...peers.values()].map((p) => p.ws && p.ws._meetingUserId).filter(Boolean);
for (const [, p] of peers) { if (p.ws.readyState === 1) { try { p.ws.send(JSON.stringify({ type: 'meeting-ended' })); } catch (_) {} p.ws._meetingRoom = null; } } for (const [, p] of peers) { if (p.ws.readyState === 1) { try { p.ws.send(JSON.stringify({ type: 'meeting-ended' })); } catch (_) {} p.ws._meetingRoom = null; } }
persistCallHistory(room); // #7: log the call BEFORE endCallByRoom clears roomToDmCall/roomToGroupCall
meetingRooms.delete(room); meetingRooms.delete(room);
try { require('./calls').finalizeTranscript(room); } catch (_) {} // any remaining buffers (safety) try { require('./calls').finalizeTranscript(room); } catch (_) {} // any remaining buffers (safety)
roomHost.delete(room); roomHost.delete(room);
@@ -386,6 +418,7 @@ function leaveMeeting(ws) {
} }
for (const [, p] of peers) { if (p.ws.readyState === 1) p.ws.send(JSON.stringify({ type: 'meeting-peer-left', peerId: pid })); } for (const [, p] of peers) { if (p.ws.readyState === 1) p.ws.send(JSON.stringify({ type: 'meeting-peer-left', peerId: pid })); }
if (peers.size === 0) { if (peers.size === 0) {
persistCallHistory(room); // #7: log the finished call (before the room maps are cleared)
meetingRooms.delete(room); meetingRooms.delete(room);
lobbyPending.delete(room); roomLobby.delete(room); // room gone → drop its lobby state lobbyPending.delete(room); roomLobby.delete(room); // room gone → drop its lobby state
try { require('./calls').finalizeTranscript(room); } catch (_) {} // before endCallByRoom clears the maps try { require('./calls').finalizeTranscript(room); } catch (_) {} // before endCallByRoom clears the maps