diff --git a/desktop/main.js b/desktop/main.js index 72b4f4e..394c635 100644 --- a/desktop/main.js +++ b/desktop/main.js @@ -8,7 +8,7 @@ // - external links open in the user's browser, not inside the app // // Server origin is configurable so the same build works against prod or a dev server. -const { app, BrowserWindow, session, desktopCapturer, shell, Menu, MenuItem, ipcMain, nativeImage, Notification } = require('electron'); +const { app, BrowserWindow, session, desktopCapturer, shell, Menu, MenuItem, Tray, ipcMain, nativeImage, Notification } = require('electron'); const path = require('path'); const fs = require('fs'); const os = require('os'); @@ -190,6 +190,30 @@ const SERVER_URL = (process.env.SERVER_URL || (app.isPackaged ? 'https://remote. let win; let splash; +let tray = null; +let isQuitting = false; // true only during a real Quit (tray menu / before-quit) — otherwise close = hide to tray + +// Close-to-tray: closing the window HIDES it instead of quitting, so the app keeps running in the +// background with its chat WebSocket alive. That's what lets call/message notifications still fire when +// the window is "closed" (General #1/#2) — a fully-quit Electron app gets no push. The tray icon + menu +// bring it back or quit for real. +function createTray() { + if (tray) return; + try { + let img = nativeImage.createFromPath(path.join(__dirname, 'tray.ico')); + if (img.isEmpty()) img = nativeImage.createFromPath(path.join(process.resourcesPath || __dirname, 'tray.ico')); + tray = new Tray(img.isEmpty() ? nativeImage.createEmpty() : img); + tray.setToolTip('Biz Connect'); + const showApp = () => { if (!win) return createWindow(); if (win.isMinimized()) win.restore(); win.show(); win.focus(); }; + tray.setContextMenu(Menu.buildFromTemplate([ + { label: 'Open Biz Connect', click: showApp }, + { type: 'separator' }, + { label: 'Quit', click: () => { isQuitting = true; app.quit(); } }, + ])); + tray.on('click', showApp); // single-click (Windows) + tray.on('double-click', showApp); + } catch (_) { tray = null; } +} // A tiny brand-blue splash (splash.html) shown while the web UI loads, so launch feels instant // and on-brand instead of a blank window. Closed as soon as the main window is ready to show. @@ -233,6 +257,18 @@ function createWindow() { win.once('ready-to-show', reveal); setTimeout(reveal, 12000); + // Close = hide to tray (keep running for notifications). First time, tell the user where it went. + let toldTray = false; + win.on('close', (e) => { + if (isQuitting) return; // real quit → let it close + e.preventDefault(); + win.hide(); + if (!toldTray && Notification.isSupported()) { + toldTray = true; + try { const n = new Notification({ title: 'Biz Connect is still running', body: 'It stays in the system tray so you keep getting calls & messages. Quit from the tray icon.' }); n.show(); } catch (_) {} + } + }); + // Open the landing page (same entry as the website): the "before login" screen with the // no-login "Share my screen" option + sign-in. It redirects logged-in users straight to /home. win.loadURL(SERVER_URL + '/'); @@ -376,12 +412,22 @@ function configureSession() { } catch (_) {} } +// Single-instance: a tray app must not spawn a second copy. If another launch happens, focus the +// existing window (restoring it from the tray) instead. +if (!app.requestSingleInstanceLock()) { + app.quit(); +} else { + app.on('second-instance', () => { if (win) { if (win.isMinimized()) win.restore(); win.show(); win.focus(); } }); +} +app.on('before-quit', () => { isQuitting = true; }); + app.whenReady().then(() => { configureSession(); createSplash(); createWindow(); + createTray(); // keep the app reachable while its window is hidden to tray Menu.setApplicationMenu(null); // hide the default menu bar; the web UI is the chrome - app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) createWindow(); }); + app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) createWindow(); else if (win) { win.show(); win.focus(); } }); // Check for shell updates on launch, then every 6 hours. Only in packaged builds. if (app.isPackaged && autoUpdater) { // #3: surface update progress to the web UI so the user can SEE an update is downloading / @@ -405,4 +451,6 @@ app.whenReady().then(() => { } }); -app.on('window-all-closed', () => { if (process.platform !== 'darwin') app.quit(); }); +// With close-to-tray the window is HIDDEN, not destroyed, so this normally won't fire while the app is +// meant to keep running. Only quit here if we're actually quitting (belt-and-braces). +app.on('window-all-closed', () => { if (isQuitting && process.platform !== 'darwin') app.quit(); }); diff --git a/desktop/package.json b/desktop/package.json index 8791d25..f04de2b 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,6 +1,6 @@ { "name": "biz-connect-desktop", - "version": "0.1.13", + "version": "0.1.14", "description": "Biz Connect technician desktop client — loads the Connect web UI with native screen capture", "author": { "name": "BizGaze", diff --git a/desktop/tray.ico b/desktop/tray.ico new file mode 100644 index 0000000..61ab853 Binary files /dev/null and b/desktop/tray.ico differ diff --git a/server/db.js b/server/db.js index 48961c5..96a1ac5 100644 --- a/server/db.js +++ b/server/db.js @@ -228,6 +228,9 @@ try { db.exec('ALTER TABLE users ADD COLUMN bizgaze_user_id TEXT'); } catch (e) // External (non-Connect) invitee emails on a scheduled meeting (#4) — JSON array. They get an emailed // guest join link instead of an in-app invite. try { db.exec('ALTER TABLE scheduled_meetings ADD COLUMN guest_emails TEXT'); } catch (e) { /* exists */ } +// Lobby (#4): 1 = guests joining by link must be admitted by the host; 0 = they join directly. NULL is +// treated as "require approval" (safe default) by the signaling layer. +try { db.exec('ALTER TABLE scheduled_meetings ADD COLUMN lobby INTEGER'); } catch (e) { /* exists */ } try { db.exec('CREATE INDEX IF NOT EXISTS idx_users_bizgaze_user_id ON users(bizgaze_user_id)'); } catch (e) { /* exists */ } // 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 diff --git a/server/public/connect.html b/server/public/connect.html index 34805d1..a69a3d4 100644 --- a/server/public/connect.html +++ b/server/public/connect.html @@ -409,6 +409,11 @@ video.addEventListener('contextmenu',e=>e.preventDefault()); function rcTyping(){ const a=document.activeElement; return a && (a.tagName==='INPUT'||a.tagName==='TEXTAREA'); } document.addEventListener('keydown',e=>{ if(!video||video.style.display!=='block'||rcTyping()) return; e.preventDefault(); send({kind:'keydown',key:e.key,code:e.code}); }); document.addEventListener('keyup',e=>{ if(!video||video.style.display!=='block'||rcTyping()) return; e.preventDefault(); send({kind:'keyup',key:e.key,code:e.code}); }); +// Mobile viewer (#4): map touch → mouse so a phone/tablet can control too. Tap = move+click; drag = move. +const relT=(t)=>{ const c=contentRect(); return {x:Math.max(0,Math.min(1,(t.clientX-c.left)/c.width)), y:Math.max(0,Math.min(1,(t.clientY-c.top)/c.height))}; }; +video.addEventListener('touchstart',e=>{ if(!e.touches.length) return; e.preventDefault(); const p=relT(e.touches[0]); send({kind:'mousemove',...p}); send({kind:'mousedown',button:0,...p}); },{passive:false}); +video.addEventListener('touchmove',e=>{ if(!e.touches.length) return; e.preventDefault(); const t=performance.now(); if(t-lm<16) return; lm=t; send({kind:'mousemove',...relT(e.touches[0])}); },{passive:false}); +video.addEventListener('touchend',e=>{ e.preventDefault(); const t=e.changedTouches&&e.changedTouches[0]; const p=t?relT(t):null; if(p) send({kind:'mousemove',...p}); send({kind:'mouseup',button:0,...(p||{})}); },{passive:false}); document.getElementById('endBtn').onclick=()=>{ws.send(JSON.stringify({type:'end-session',sessionId,reason:'agent-ended'}));}; function esc(s){return String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));} diff --git a/server/public/home.html b/server/public/home.html index 46903ae..f85529d 100644 --- a/server/public/home.html +++ b/server/public/home.html @@ -627,6 +627,16 @@ .convo-call span{font-size:.84rem;} .call-on{display:inline-flex;align-items:center;gap:.3rem;color:#15803d;font-weight:600;} .call-invite{position:fixed;right:18px;bottom:18px;z-index:6000;display:flex;align-items:center;gap:.7rem;background:#fff;border:1px solid var(--line);border-left:4px solid #15803d;border-radius:14px;padding:.7rem .9rem;box-shadow:0 12px 30px rgba(20,30,60,.25);max-width:340px;} + /* #4 lobby request stacks above call invites, tinted blue; multiple stack upward */ + .call-invite.lobby-req{border-left-color:var(--blue);bottom:auto;top:18px;} + .call-invite.lobby-req .ci-ico{background:var(--blue-soft);color:var(--blue);} + /* #5 speaker output menu */ + .spk-menu{position:fixed;z-index:9700;background:#fff;border:1px solid var(--line);border-radius:12px;box-shadow:0 14px 34px rgba(20,30,60,.28);padding:.35rem;min-width:220px;max-width:300px;} + .spk-menu .spk-h{display:flex;align-items:center;gap:.35rem;font-size:.72rem;font-weight:700;text-transform:uppercase;letter-spacing:.03em;color:var(--muted);padding:.35rem .5rem;} + .spk-menu .spk-opt{display:block;width:100%;text-align:left;border:none;background:transparent;font:inherit;font-size:.86rem;color:var(--ink);padding:.5rem .6rem;border-radius:8px;cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;} + .spk-menu .spk-opt:hover{background:var(--blue-soft);} + .spk-menu .spk-opt.on{color:var(--blue);font-weight:600;} + .spk-menu .spk-empty{font-size:.8rem;color:var(--muted);padding:.5rem .6rem;line-height:1.4;} .call-invite .ci-ico{width:38px;height:38px;border-radius:50%;background:#dcfce7;color:#15803d;display:grid;place-items:center;flex:0 0 auto;} .call-invite .ci-txt{font-size:.88rem;color:var(--ink);line-height:1.25;} .call-invite .ci-join{border:none;background:#15803d;color:#fff;border-radius:9px;padding:.45rem .7rem;font-weight:700;cursor:pointer;display:inline-flex;align-items:center;gap:.3rem;flex:0 0 auto;} @@ -884,7 +894,7 @@ - @@ -3176,13 +3186,14 @@ function openScheduleModal(gid, editMtg){ +'' +'
' +'
' + +'' +'' +'
'; document.body.appendChild(ov); ov.onclick=e=>{ if(e.target===ov) ov.remove(); }; document.getElementById('schClose').onclick=()=>ov.remove(); const $=id=>document.getElementById(id); - if(editing){ $('schTitle').value=editMtg.title||''; $('schDesc').value=editMtg.description||''; if(editMtg.durationMins) $('schDur').value=String(editMtg.durationMins); } + if(editing){ $('schTitle').value=editMtg.title||''; $('schDesc').value=editMtg.description||''; if(editMtg.durationMins) $('schDur').value=String(editMtg.durationMins); { const lb=$('schLobby'); if(lb) lb.checked=(editMtg.lobby!==false); } } else $('schDur').value='30'; const err=$('schErr'); const dateBtn=$('schDateBtn'), timeBtn=$('schTimeBtn'), cal=$('schCal'), timePop=$('schTimePop'); @@ -3243,8 +3254,9 @@ function openScheduleModal(gid, editMtg){ let recurrence=[]; if(repeat.checked){ recurrence=[...daysWrap.querySelectorAll('.day-chip.on')].map(b=>+b.dataset.d); if(!recurrence.length) recurrence=[new Date(ts).getDay()]; } const whenText=new Date(ts).toLocaleString([],{weekday:'short',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}); try{ - if(editing){ await postJSON('/api/meetings/update',{ id:editMtg.id, title, description:desc, scheduledAt:ts, durationMins, participants, participantEmails:emailList, recurrence }); toast('Meeting updated'); } - else { const invn=participants.length+emailList.length; await postJSON('/api/meetings/schedule',{ group:gid||undefined, title, description:desc, scheduledAt:ts, whenText, participants, participantEmails:emailList, durationMins, recurrence }); toast('Meeting scheduled'+(invn?' · '+invn+' invited':'')); } + const lobby=!!($('schLobby')&&$('schLobby').checked); + if(editing){ await postJSON('/api/meetings/update',{ id:editMtg.id, title, description:desc, scheduledAt:ts, durationMins, participants, participantEmails:emailList, recurrence, lobby }); toast('Meeting updated'); } + else { const invn=participants.length+emailList.length; await postJSON('/api/meetings/schedule',{ group:gid||undefined, title, description:desc, scheduledAt:ts, whenText, participants, participantEmails:emailList, durationMins, recurrence, lobby }); toast('Meeting scheduled'+(invn?' · '+invn+' invited':'')); } ov.remove(); switchTab('meeting'); loadScheduledMeetings(); }catch(e){ err.textContent=e.message||'Could not save'; } }; @@ -3255,6 +3267,24 @@ function renderCallConnecting(){ const el=document.getElementById('meetingPanel'); if(!el) return; el.innerHTML='
Connecting to call…
'; } +// #4 Lobby — guest side: waiting for the host to admit them. +function renderLobbyWait(){ + const el=document.getElementById('meetingPanel'); if(!el) return; + el.innerHTML='
Waiting for the host to let you in…
You’ll join automatically once they admit you.
'; +} +// #4 Lobby — host side: a guest is asking to join. Stacks like the call-invite banners. +function showLobbyRequest(peerId, name){ + if(!peerId || document.getElementById('lob-'+peerId)) return; + try{ playPing(); }catch(_){} + const el=document.createElement('div'); el.className='call-invite lobby-req'; el.id='lob-'+peerId; + el.innerHTML=''+ic('users',18)+''+pEsc(name||'A guest')+'
wants to join the meeting
' + +'' + +''; + document.body.appendChild(el); + el.querySelector('.ci-join').onclick=()=>{ meetSend({type:'meeting-admit', peerId}); el.remove(); }; + el.querySelector('.ci-decline').onclick=()=>{ meetSend({type:'meeting-reject', peerId}); el.remove(); }; +} +function dismissLobbyRequest(peerId){ const el=document.getElementById('lob-'+peerId); if(el){ try{ el.remove(); }catch(_){} } } function renderCall(){ const el=document.getElementById('meetingPanel'); if(!el) return; el.innerHTML='
' @@ -3264,6 +3294,7 @@ function renderCall(){ + '' + '' + ((ME&&ME.guest)?'':'') // #12: transcript is a signed-in feature (guests can't download it) + + '' + '' + '
'; document.getElementById('meetMicBtn').onclick=toggleMic; @@ -3272,6 +3303,7 @@ function renderCall(){ document.getElementById('meetRecBtn').onclick=toggleRecord; { const tb=document.getElementById('meetTransBtn'); if(tb) tb.onclick=toggleTranscribe; } document.getElementById('meetPplBtn').onclick=toggleMeetPanel; + { const sb=document.getElementById('meetSpkBtn'); if(sb) sb.onclick=(e)=>{ e.stopPropagation(); openSpeakerMenu(sb); }; } document.getElementById('meetLeaveBtn').onclick=leaveMeeting; updateHostControls(); // Click another shared screen (in the side column) to bring it onto the stage. @@ -3279,13 +3311,30 @@ function renderCall(){ addTile('__local', meetLocalStream, (ME&&ME.name)?ME.name:'You', true); setTileMute('__local', !meetMic); } +// #5 Speaker / headphone output selection. Applies the chosen audiooutput device (setSinkId) to every +// meeting media element, and remembers it for new tiles. No-op where setSinkId isn't supported. +let meetSinkId=(()=>{ try{ return localStorage.getItem('bzc_sink')||''; }catch(_){ return ''; } })(); +function applySink(el){ try{ if(el && meetSinkId && typeof el.setSinkId==='function') el.setSinkId(meetSinkId).catch(()=>{}); }catch(_){} } +function applySinkAll(){ document.querySelectorAll('#meetGrid video').forEach(applySink); } +async function openSpeakerMenu(anchor){ + document.querySelectorAll('.spk-menu').forEach(x=>x.remove()); + let devs=[]; try{ devs=(await navigator.mediaDevices.enumerateDevices()).filter(d=>d.kind==='audiooutput'); }catch(_){} + const menu=document.createElement('div'); menu.className='spk-menu'; + if(!devs.length){ menu.innerHTML='
Your browser can’t switch audio output here. Set it in the OS sound settings.
'; } + else menu.innerHTML='
'+ic('headphones',13)+' Speaker
'+devs.map((d,i)=>'').join(''); + document.body.appendChild(menu); + const r=anchor.getBoundingClientRect(); menu.style.left=Math.max(8,Math.min(r.left, window.innerWidth-menu.offsetWidth-8))+'px'; menu.style.top=(r.top-menu.offsetHeight-8)+'px'; + menu.querySelectorAll('.spk-opt').forEach(b=>b.onclick=()=>{ meetSinkId=b.dataset.id; try{ localStorage.setItem('bzc_sink', meetSinkId); }catch(_){} applySinkAll(); menu.remove(); toast('Speaker set'); }); + const close=(e)=>{ if(!menu.contains(e.target) && e.target!==anchor){ menu.remove(); document.removeEventListener('mousedown',close); } }; + setTimeout(()=>document.addEventListener('mousedown',close),0); +} function addTile(id, stream, label, muted){ const grid=document.getElementById('meetGrid'); if(!grid) return; let tile=document.getElementById('meet-tile-'+id); if(!tile){ tile=document.createElement('div'); tile.className='meet-tile'; tile.id='meet-tile-'+id; const av=(id==='__local')?((ME&&ME.avatarUrl)||null):(meetAvatars.get(id)||null); // profile pic on the tile tile.innerHTML='
'+pEsc(initials(label||'?'))+(av?'':'')+'
'+pEsc(label||'')+''; grid.appendChild(tile); } - const v=tile.querySelector('video'); if(v && stream && v.srcObject!==stream) v.srcObject=stream; + const v=tile.querySelector('video'); if(v && stream && v.srcObject!==stream){ v.srcObject=stream; applySink(v); } // #5: route audio to the chosen speaker const hasVid=!!(stream && stream.getVideoTracks && stream.getVideoTracks().some(t=>t.enabled && t.readyState!=='ended')); tile.classList.toggle('novid', !hasVid || (meetCamOff.get(id)===true && !meetSharers.has(id))); // camOff → avatar, UNLESS they're sharing a screen (#9: screen must show even with camera off) if(meetMuted.has(id)) setTileMute(id, meetMuted.get(id)); // apply any known mute state @@ -3501,12 +3550,16 @@ async function onMeetMsg(e){ if(_dmCallWaiting && !(m.peers&&m.peers.length)) addWaitingTile(_dmCallWaiting.name, _dmCallWaiting.avatar); // #7: show who we're calling while it rings // SFU: connect to LiveKit for media once (peer uid→peerId map is populated above). Mic/cam are // off at join, so nothing publishes yet — toggleMic/toggleCam publish on demand. - if(SFU.on){ try{ await sfuConnect(); }catch(err){ const e2=document.getElementById('meetErr'); if(e2) e2.textContent='Could not connect meeting media'; } } + if(SFU.on){ try{ await sfuConnect(); }catch(err){ if(ME&&ME.guest){ toast((err&&err.message)||'This meeting link has expired or isn’t active.'); leaveMeeting(true); return; } const e2=document.getElementById('meetErr'); if(e2) e2.textContent='Could not connect meeting media'; } } meetSend({type:'meeting-state', muted:!meetMic, camOff:!meetCam}); // tell existing peers my state if(meetIsHost) meetSend({type:'meeting-host', to:meetMyId}); // announce host so others know refreshMeetPanel(); updateHostControls(); return; } + if(m.type==='meeting-lobby-wait'){ renderLobbyWait(); return; } // #4: guest waits for host to admit + if(m.type==='meeting-rejected'){ toast('The host didn’t let you in.'); leaveMeeting(true); return; } + if(m.type==='meeting-lobby-request'){ showLobbyRequest(m.peerId, m.name); return; } // host: someone wants in + if(m.type==='meeting-lobby-cancel'){ dismissLobbyRequest(m.peerId); return; } // that guest left the lobby if(m.type==='meeting-ended'){ toast(m.reason==='unanswered'?'No answer':'Call ended'); leaveMeeting(true); return; } // 1:1 hangup / host ended / unanswered if(m.type==='meeting-peer-joined'){ stopRingback(); removeWaitingTile(); _dmCallWaiting=null; // #17/#7: they answered → stop ring + drop the ringing tile diff --git a/server/repos.js b/server/repos.js index ec819c0..e650641 100644 --- a/server/repos.js +++ b/server/repos.js @@ -297,9 +297,9 @@ const attachments = { }; const scheduledMeetings = { - create: ({ id, teamId, groupId, roomCode, title, description, scheduledAt, createdBy, participants, durationMins, recurrence, guestEmails }) => - db.prepare('INSERT INTO scheduled_meetings (id,team_id,group_id,room_code,title,description,scheduled_at,created_by,created_at,participants,duration_mins,recurrence,guest_emails) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)') - .run(id, teamId, groupId || null, roomCode, title, description || null, scheduledAt, createdBy, now(), (participants && participants.length) ? JSON.stringify(participants) : null, durationMins || null, (recurrence && recurrence.length) ? JSON.stringify(recurrence) : null, (guestEmails && guestEmails.length) ? JSON.stringify(guestEmails) : null), + create: ({ id, teamId, groupId, roomCode, title, description, scheduledAt, createdBy, participants, durationMins, recurrence, guestEmails, lobby }) => + db.prepare('INSERT INTO scheduled_meetings (id,team_id,group_id,room_code,title,description,scheduled_at,created_by,created_at,participants,duration_mins,recurrence,guest_emails,lobby) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)') + .run(id, teamId, groupId || null, roomCode, title, description || null, scheduledAt, createdBy, now(), (participants && participants.length) ? JSON.stringify(participants) : null, durationMins || null, (recurrence && recurrence.length) ? JSON.stringify(recurrence) : null, (guestEmails && guestEmails.length) ? JSON.stringify(guestEmails) : null, (lobby === false ? 0 : 1)), byId: (id) => db.prepare('SELECT * FROM scheduled_meetings WHERE id=?').get(id), byCode: (code) => db.prepare('SELECT * FROM scheduled_meetings WHERE room_code=? ORDER BY created_at DESC LIMIT 1').get(code), // Meetings a user can see: created by them, a member of the group, or an invited participant. @@ -315,9 +315,9 @@ const scheduledMeetings = { end: (id, teamId) => db.prepare('UPDATE scheduled_meetings SET ended_at=? WHERE id=? AND team_id=?').run(now(), id, teamId), cancel: (id, teamId) => db.prepare('UPDATE scheduled_meetings SET cancelled=1, ended_at=? WHERE id=? AND team_id=?').run(now(), id, teamId), reschedule: (id, teamId, ts) => db.prepare('UPDATE scheduled_meetings SET scheduled_at=?, reminded=0 WHERE id=? AND team_id=?').run(ts, id, teamId), // recurrence: roll to next occurrence - update: (id, teamId, { title, description, scheduledAt, durationMins, participants, recurrence, guestEmails }) => - db.prepare('UPDATE scheduled_meetings SET title=?, description=?, scheduled_at=?, duration_mins=?, participants=?, recurrence=?, guest_emails=?, reminded=0 WHERE id=? AND team_id=?') - .run(title, description || null, scheduledAt, durationMins || null, (participants && participants.length) ? JSON.stringify(participants) : null, (recurrence && recurrence.length) ? JSON.stringify(recurrence) : null, (guestEmails && guestEmails.length) ? JSON.stringify(guestEmails) : null, id, teamId), + update: (id, teamId, { title, description, scheduledAt, durationMins, participants, recurrence, guestEmails, lobby }) => + db.prepare('UPDATE scheduled_meetings SET title=?, description=?, scheduled_at=?, duration_mins=?, participants=?, recurrence=?, guest_emails=?, lobby=?, reminded=0 WHERE id=? AND team_id=?') + .run(title, description || null, scheduledAt, durationMins || null, (participants && participants.length) ? JSON.stringify(participants) : null, (recurrence && recurrence.length) ? JSON.stringify(recurrence) : null, (guestEmails && guestEmails.length) ? JSON.stringify(guestEmails) : null, (lobby === false ? 0 : 1), id, teamId), remove: (id, teamId) => db.prepare('DELETE FROM scheduled_meetings WHERE id=? AND team_id=?').run(id, teamId), }; diff --git a/server/routes.js b/server/routes.js index 35a3a12..4517fa4 100644 --- a/server/routes.js +++ b/server/routes.js @@ -941,8 +941,18 @@ route('POST', '/api/meetings/guest-token', async (req, res) => { const rm = String(room || '').trim(); if (!/^\d{6}$/.test(rm)) return json(res, 400, { error: 'invalid room' }); const live = (() => { try { return require('./presence').meetingRooms.has(rm); } catch (_) { return false; } })(); - const sched = (() => { try { const s = R.scheduledMeetings.byCode(rm); return !!(s && !s.ended_at); } catch (_) { return false; } })(); - if (!live && !sched) return json(res, 404, { error: 'meeting not found or not active' }); + // #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 = (() => { + try { + const s = R.scheduledMeetings.byCode(rm); + if (!s || s.ended_at) return false; + const endBy = s.scheduled_at + ((s.duration_mins || 60) * 60000) + (2 * 3600000); + return Date.now() <= endBy; + } catch (_) { return false; } + })(); + if (!live && !sched) return json(res, 410, { error: 'This meeting link has expired or the meeting isn’t active.' }); // Reuse the guest's client id as the LiveKit identity so it matches the id they announced over // signaling (meeting-join guestId) — that mapping is how their media attaches to their tile. const gid = (typeof identity === 'string' && /^guest-[a-z0-9]+$/i.test(identity)) ? identity.slice(0, 64) : ('guest-' + crypto.randomBytes(8).toString('hex')); @@ -1067,7 +1077,7 @@ route('POST', '/api/groups/remove', async (req, res) => { route('POST', '/api/meetings/schedule', async (req, res) => { const u = currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); - const { group, title, description, scheduledAt, whenText, participants, participantEmails, durationMins, recurrence } = await readBody(req); + const { group, title, description, scheduledAt, whenText, participants, participantEmails, durationMins, recurrence, lobby } = await readBody(req); const t = String(title || '').trim().slice(0, 120); if (!t) return json(res, 400, { error: 'title required' }); const when = Number(scheduledAt); @@ -1087,7 +1097,7 @@ route('POST', '/api/meetings/schedule', async (req, res) => { 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 (R.scheduledMeetings.byCode(code) || meetingRooms.has(code)); const id = A.id(); - R.scheduledMeetings.create({ id, teamId: u.team_id, groupId, roomCode: code, title: t, description: desc, scheduledAt: when, createdBy: u.id, participants: invited, durationMins: dur, recurrence: recur, guestEmails }); + R.scheduledMeetings.create({ id, teamId: u.team_id, groupId, roomCode: code, title: t, description: desc, scheduledAt: when, createdBy: u.id, participants: invited, durationMins: dur, recurrence: recur, guestEmails, lobby: lobby !== false }); audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'meeting_scheduled', detail: t }); const label = (typeof whenText === 'string' && whenText.trim()) ? whenText.trim() : new Date(when).toLocaleString(); if (groupId) { @@ -1145,7 +1155,7 @@ route('GET', '/api/meetings', async (req, res) => { scheduledAt: schedAt, groupId: s.group_id, link: PUBLIC_BASE_URL + '/home?meet=' + s.room_code, groupName: s.group_id ? ((R.conversations.byId(s.group_id) || {}).name || 'Group') : null, createdBy: s.created_by, createdByName: names[s.created_by] || '', canManage: s.created_by === u.id, isHost: s.created_by === u.id, - invited: invited.map((pid) => names[pid] || 'Someone'), invitedIds: invited, guestEmails, + invited: invited.map((pid) => names[pid] || 'Someone'), invitedIds: invited, guestEmails, lobby: s.lobby !== 0, durationMins: s.duration_mins || null, recurrence: recur, recurrenceLabel: recurrenceLabel(recur), status, inCall: running ? live.size : 0, recordings: [], }; @@ -1251,7 +1261,7 @@ route('POST', '/api/meetings/cancel', async (req, res) => { route('POST', '/api/meetings/update', async (req, res) => { const u = currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); - const { id, title, description, scheduledAt, durationMins, participants, participantEmails, recurrence } = await readBody(req); + const { id, title, description, scheduledAt, durationMins, participants, participantEmails, recurrence, lobby } = await readBody(req); const s = id && R.scheduledMeetings.byId(id); if (!s || s.team_id !== u.team_id) return json(res, 404, { error: 'not found' }); if (s.created_by !== u.id) return json(res, 403, { error: 'only the organizer can edit' }); @@ -1262,7 +1272,7 @@ route('POST', '/api/meetings/update', async (req, res) => { 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 && 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); - 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 }); + 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(); // Email the updated details to external invitees (new + existing) so their link/time stays current. try { diff --git a/server/signaling.js b/server/signaling.js index df1aec3..d46a6a6 100644 --- a/server/signaling.js +++ b/server/signaling.js @@ -9,6 +9,38 @@ const { onlineAgents, liveSessions, pendingShares, meetingRooms, roomToDmCall, r 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(); +// 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) { + if (roomLobby.has(room)) return !!roomLobby.get(room); + try { const s = 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 }); + 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); } @@ -60,6 +92,8 @@ function handle(ws, m, req) { let code; do { code = A.numericCode(6); } while (meetingRooms.has(code)); meetingRooms.set(code, new Map()); const cu = 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; } @@ -86,15 +120,30 @@ function handle(ws, m, req) { 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 - const avatar = (ju && ju.avatar_url) ? ju.avatar_url : null; // for participant-tile profile pics - const isHost = !!(ju && hostUserId && ju.id === hostUserId); - // Tell the newcomer who's already here (they initiate offers to existing 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 })) })); - // …and tell existing peers a newcomer arrived. - 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 }); - if (ws._meetingUserId) { try { require('./calls').markDmAnswered(room, ws._meetingUserId); } catch (_) {} CHAT.broadcastPresence(ws._meetingUserId); } // #9: callee joined → mark 1:1 answered - const tsubs = transcriptSubs.get(room); if (tsubs && tsubs.size > 0) ws.send(JSON.stringify({ type: 'meeting-transcribe-state', active: true })); // catch up: already transcribing + ws._meetingAvatar = (ju && ju.avatar_url) ? ju.avatar_url : null; // for participant-tile profile pics + // 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)) { + 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': { @@ -327,6 +376,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 })); } if (peers.size === 0) { 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 roomHost.delete(room); try { require('./calls').endCallByRoom(room); } catch (_) {} @@ -336,6 +386,13 @@ function leaveMeeting(ws) { 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); leaveMeeting(ws); if (goneUserId) CHAT.broadcastPresence(goneUserId); // now reflects offline / no-longer-in-call