// HTTP JSON API routes (auth, MFA, users, machines, report, audit, media uploads, SSO). // Returns a { "METHOD /path": handler } map consumed by server.js. const fs = require('fs'); const path = require('path'); const R = require('./repos'); const A = require('./auth'); const BZ = require('./bizgaze'); const W = require('./webhooks'); const CHAT = require('./chat'); const MSG_MAX = 4000; const parseMentions = (s) => { if (!s) return []; try { const a = JSON.parse(s); return Array.isArray(a) ? a : []; } catch { return []; } }; const SYSTEM_SENDER = '__system__'; const msgDTO = (m) => ({ id: m.id, from: m.sender_id, to: m.recipient_id, conversation_id: m.conversation_id || null, body: m.body, created_at: m.created_at, read_at: m.read_at, delivered_at: m.delivered_at || null, reply_to: m.reply_to || null, mentions: parseMentions(m.mentions), evt: m.msg_type || null, system: m.sender_id === SYSTEM_SENDER || !!m.msg_type }); function namesFor(teamId){ const o = {}; for (const x of R.users.listByTenant(teamId)) o[x.id] = x.name || x.email; return o; } // Next future occurrence (same time-of-day) of a weekly-recurring meeting; searches 14 days ahead. function nextOccurrence(baseTs, days, nowTs){ const b = new Date(baseTs); const hh = b.getHours(), mm = b.getMinutes(); const s = new Date(nowTs); for (let i = 0; i <= 14; i++){ const d = new Date(s.getFullYear(), s.getMonth(), s.getDate() + i, hh, mm, 0, 0); if (days.indexOf(d.getDay()) >= 0 && d.getTime() > nowTs) return d.getTime(); } return baseTs; } const RDAY = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; function recurrenceLabel(days){ if (!days || !days.length) return ''; if (days.length === 7) return 'Every day'; return 'Every ' + days.slice().sort().map((d) => RDAY[d]).join(', '); } // Post a centered "activity" line into a group (member added/removed/renamed/left) and push it. function postSystemMessage(conversationId, teamId, text){ const id = A.id(); R.messages.send({ id, teamId, senderId: SYSTEM_SENDER, recipientId: '', body: text, conversationId }); const dto = buildMsgDTO(R.messages.byId(id), {}, ''); for (const mid of R.conversations.members(conversationId)) { try { CHAT.pushToUser(mid, { type: 'chat-message', message: dto }); } catch (_) {} } return dto; } // Tell clients a group's membership changed so they refresh the member count / sidebar immediately. function pushGroupUpdate(group, alsoUsers){ const seen = new Set(); for (const mid of R.conversations.members(group)) { seen.add(mid); try { CHAT.pushToUser(mid, { type: 'group-update', group }); } catch (_) {} } for (const mid of (alsoUsers || [])) { if (!seen.has(mid)) { try { CHAT.pushToUser(mid, { type: 'group-update', group, removed: true }); } catch (_) {} } } } // Group a flat reaction list into { messageId: [{emoji,count,mine,who}] } for the current user. function groupReactions(list, userId, names){ const rxBy = {}; for (const r of list) { const byEmoji = (rxBy[r.message_id] || (rxBy[r.message_id] = {})); const e = (byEmoji[r.emoji] || (byEmoji[r.emoji] = { count: 0, mine: false, who: [] })); e.count++; if (r.user_id === userId) e.mine = true; e.who.push((names && names[r.user_id]) || 'Someone'); } return rxBy; } const dtoReactions = (rxBy, id) => (rxBy[id] ? Object.entries(rxBy[id]).map(([emoji, v]) => ({ emoji, count: v.count, mine: v.mine, who: v.who })) : []); // Full reaction DTO for ONE message, from `userId`'s perspective (mine/who). function reactionsForMessage(messageId, userId, names){ const rows = R.reactions.forMessage(messageId).map((r) => ({ message_id: messageId, user_id: r.user_id, emoji: r.emoji })); return dtoReactions(groupReactions(rows, userId, names), messageId); } // Poll tally for a given viewer ("mine" = this user voted that option). function buildPollDTO(poll, userId){ let opts = []; try { opts = JSON.parse(poll.options); } catch { opts = []; } const counts = opts.map(() => 0); const mine = opts.map(() => false); const voters = new Set(); for (const v of R.pollVotes.forPoll(poll.id)) { if (v.option_idx >= 0 && v.option_idx < counts.length) { counts[v.option_idx]++; if (v.user_id === userId) mine[v.option_idx] = true; } voters.add(v.user_id); } return { id: poll.id, question: poll.question, multi: !!poll.multi, closed: !!poll.closed, options: opts.map((t, i) => ({ text: t, votes: counts[i], mine: mine[i] })), totalVotes: counts.reduce((a, b) => a + b, 0), voters: voters.size, isOwner: poll.created_by === userId, }; } // DTO enriched with a small preview of the quoted message (if this is a reply). function buildMsgDTO(m, names, userId){ const d = msgDTO(m); if (m.reply_to) { const r = R.messages.byId(m.reply_to); if (r) d.reply = { id: r.id, from: r.sender_id, fromName: (names && names[r.sender_id]) || '', body: r.body.length > 140 ? r.body.slice(0, 140) + '…' : r.body }; } if (m.attachment_id) { const a = R.attachments.byId(m.attachment_id); if (a) d.attachment = { id: a.id, name: a.name, mime: a.mime, size: a.size, isImage: /^image\//.test(a.mime || '') }; } if (m.poll_id) { const p = R.polls.byId(m.poll_id); if (p) d.poll = buildPollDTO(p, userId); } if (m.msg_type) d.byName = (names && names[m.sender_id]) || ''; return d; } const { now, json, readBody, parseCookies } = require('./lib'); const { audit, currentUser, tokenFromReq, apiKeyFromReq, keyHasScope } = require('./session'); const API_KEY_SCOPES = ['report:read', 'audit:read']; const { onlineAgents, meetingRooms, groupCalls, dmCalls } = require('./presence'); const CALLS = require('./calls'); require('./reminders'); // start the 10-minute meeting-reminder loop const { REC_DIR, TRANS_DIR, UPLOADS_DIR, SESSION_TTL, REFRESH_TTL } = require('./config'); const MAX_FILE_BYTES = 25 * 1024 * 1024; // 25 MB per chat attachment // Issue a refresh token (native clients), store only its hash, return the plaintext once. function issueRefreshToken(userId) { const rtok = A.token(32); R.refreshTokens.create({ userId, tokenHash: A.hashToken(rtok), ttl: REFRESH_TTL }); return rtok; } const routes = {}; const route = (method, p, fn) => (routes[`${method} ${p}`] = fn); // Register: creates a team + admin user. MFA must be set up before full access. route('POST', '/api/register', async (req, res) => { const anyUser = R.users.anyExists(); if (anyUser && process.env.ALLOW_REGISTRATION !== '1') return json(res, 403, { error: 'Registration is closed. Contact your administrator.' }); const { email, password, teamName } = await readBody(req); if (!email || !password) return json(res, 400, { error: 'email and password required' }); if (R.users.emailExists(email)) return json(res, 409, { error: 'email already registered' }); const { hash, salt } = A.hashPassword(password); const team = R.teams.create(teamName || `${email}'s team`); const userId = R.users.create({ tenantId: team.id, email, hash, salt, role: 'admin', name: null, mfaSecret: A.newMfaSecret() }); audit({ team_id: team.id, user_id: userId, user_email: email, action: 'user_registered' }); json(res, 200, { ok: true }); }); // Verify MFA enrollment (confirm the user scanned the QR / entered code) route('POST', '/api/mfa/enable', async (req, res) => { const { email, code } = await readBody(req); const u = R.users.byEmail(email); if (!u) return json(res, 404, { error: 'no such user' }); if (!A.verifyTotp(u.mfa_secret, code)) return json(res, 401, { error: 'invalid code' }); R.users.enableMfa(u.id); json(res, 200, { ok: true }); }); // Provision (or refresh) a local user from a successful BizGaze identity check. // The local row exists so sessions, audit, and team-scoped data work; BizGaze stays // the source of truth for credentials (the local password is random + unused). // Emails that must always be admins regardless of what BizGaze returns (lockout safety net). const ADMIN_EMAILS = (process.env.ADMIN_EMAILS || '').split(',').map((s) => s.trim().toLowerCase()).filter(Boolean); function provisionFromBizgaze(email, bz) { const role = (bz.isAdmin || ADMIN_EMAILS.includes(String(email).toLowerCase())) ? 'admin' : 'technician'; const existing = R.users.byEmail(email); if (!existing) { const team = R.teams.first() || R.teams.create('BizGaze'); const { hash, salt } = A.hashPassword(A.token()); const id = R.users.create({ tenantId: team.id, email, hash, salt, role, name: bz.name || null, mfaSecret: A.newMfaSecret() }); if (bz.avatarUrl) R.users.setAvatar(id, bz.avatarUrl); audit({ team_id: team.id, user_id: id, user_email: email, action: 'sso_user_created', detail: 'via BizGaze' }); return R.users.byId(id); } // BizGaze is the source of truth: keep name + avatar + role in sync on each login. if (bz.name && bz.name !== existing.name) R.users.setName(existing.id, bz.name); if (bz.avatarUrl && bz.avatarUrl !== existing.avatar_url) R.users.setAvatar(existing.id, bz.avatarUrl); if (existing.role !== role) R.users.setRole(existing.id, role); return R.users.byId(existing.id); } // Login: validates locally first (seeded/bootstrap accounts), then against BizGaze // (the identity provider) when BIZGAZE_LOGIN_URL is configured. Sets a session cookie. route('POST', '/api/login', async (req, res) => { const { email, password, remember } = await readBody(req); if (!email || !password) return json(res, 400, { error: 'email and password required' }); const existing = R.users.byEmail(email); if (existing && existing.active === 0) return json(res, 403, { error: 'This account has been deactivated' }); // Production: when BizGaze is the IdP, verify ONLY against BizGaze (no local-password // fallback) so stale in-app accounts can't shadow a BizGaze login and everyone lands in // the same tenant. Local accounts stay usable for dev/testing via ALLOW_LOCAL_LOGIN=1. const bizgazeOnly = BZ.isEnabled() && process.env.ALLOW_LOCAL_LOGIN !== '1'; let u = null, bzMsg = null; if (bizgazeOnly) { const bz = await BZ.validateLogin(email, password); if (bz.error) return json(res, 503, { error: bz.error }); if (!bz.ok) return json(res, 401, { error: bz.message || 'Username or password do not match.' }); u = provisionFromBizgaze(email, bz); if (u && u.active === 0) return json(res, 403, { error: 'This account has been deactivated' }); } else { u = (existing && A.verifyPassword(password, existing.pw_salt, existing.pw_hash)) ? existing : null; if (!u) { const bz = await BZ.validateLogin(email, password); if (bz.ok) u = provisionFromBizgaze(email, bz); else if (bz.error) return json(res, 503, { error: bz.error }); else bzMsg = bz.message || null; // BizGaze configured and rejected the credentials } if (!u) { if (existing) return json(res, 401, { error: 'Incorrect password. Please try again.' }); if (bzMsg) return json(res, 401, { error: bzMsg }); return json(res, 404, { error: 'This email is not registered.' }); } } const tok = A.token(); const ttl = remember ? 1000 * 60 * 60 * 24 * 30 : SESSION_TTL; // 30 days if remembered, else 24h R.authSessions.create({ token: tok, userId: u.id, mfaPassed: true, ttl }); res.setHeader('Set-Cookie', `sid=${tok}; HttpOnly; Path=/; Max-Age=${ttl / 1000}`); audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'login' }); // Cookie for the web app; access token + refresh token in the body for native // desktop/mobile clients (access via `Authorization: Bearer`, refresh via /api/v1/auth/refresh). const refreshToken = issueRefreshToken(u.id); json(res, 200, { ok: true, mfaRequired: false, token: tok, expiresAt: now() + ttl, refreshToken, refreshExpiresAt: now() + REFRESH_TTL }); }); // Exchange a refresh token for a fresh access token (with rotation). Native clients call // this when their access token expires, so the user stays signed in without re-entering a password. route('POST', '/api/auth/refresh', async (req, res) => { const { refreshToken } = await readBody(req); if (!refreshToken) return json(res, 400, { error: 'refreshToken required' }); const h = A.hashToken(refreshToken); const row = R.refreshTokens.byHash(h); if (!row || row.revoked || row.expires_at < now()) return json(res, 401, { error: 'invalid or expired refresh token' }); const u = R.users.byId(row.user_id); if (!u || u.active === 0) return json(res, 401, { error: 'account unavailable' }); R.refreshTokens.revoke(h); // rotate: one-time use const tok = A.token(); R.authSessions.create({ token: tok, userId: u.id, mfaPassed: true, ttl: SESSION_TTL }); const newRefresh = issueRefreshToken(u.id); json(res, 200, { ok: true, token: tok, expiresAt: now() + SESSION_TTL, refreshToken: newRefresh, refreshExpiresAt: now() + REFRESH_TTL }); }); // Login step 2: TOTP code -> marks session mfa_passed route('POST', '/api/login/mfa', async (req, res) => { const { code } = await readBody(req); const tok = parseCookies(req).sid; const s = tok && R.authSessions.byToken(tok); if (!s) return json(res, 401, { error: 'no session' }); const u = R.users.byId(s.user_id); if (!A.verifyTotp(u.mfa_secret, code)) return json(res, 401, { error: 'invalid code' }); R.authSessions.markMfaPassed(tok); audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'login' }); json(res, 200, { ok: true }); }); route('POST', '/api/logout', async (req, res) => { const tok = tokenFromReq(req); // cookie (web) or Bearer (native) if (tok) R.authSessions.deleteByToken(tok); const { refreshToken } = await readBody(req); if (refreshToken) R.refreshTokens.revoke(A.hashToken(refreshToken)); res.setHeader('Set-Cookie', 'sid=; HttpOnly; Path=/; Max-Age=0'); json(res, 200, { ok: true }); }); route('GET', '/api/setup-state', async (req, res) => { const anyUser = R.users.anyExists(); json(res, 200, { registrationOpen: !anyUser || process.env.ALLOW_REGISTRATION === '1' }); }); // ICE servers for WebRTC. Always includes a public STUN; adds our TURN relay if // configured. TURN_SECRET (coturn use-auth-secret) -> time-limited HMAC credentials // (no permanent password exposed); otherwise static TURN_USERNAME + TURN_CREDENTIAL. route('GET', '/api/ice', async (req, res) => { const iceServers = [{ urls: 'stun:stun.l.google.com:19302' }]; if (process.env.TURN_URLS) { const urls = process.env.TURN_URLS.split(',').map((u) => u.trim()).filter(Boolean); let username = process.env.TURN_USERNAME || ''; let credential = process.env.TURN_CREDENTIAL || ''; if (process.env.TURN_SECRET) { const ttl = parseInt(process.env.TURN_TTL || '86400', 10); username = String(Math.floor(Date.now() / 1000) + ttl); credential = require('crypto').createHmac('sha1', process.env.TURN_SECRET).update(username).digest('base64'); } iceServers.push({ urls, username, credential }); } json(res, 200, { iceServers }); }); route('GET', '/api/me', async (req, res) => { const u = currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); json(res, 200, { id: u.id, email: u.email, role: u.role, teamId: u.team_id, name: u.name || null, avatarUrl: u.avatar_url || null }); }); // ---------- BizGaze SSO: agent arrives already logged in ---------- route('GET', '/sso', async (req, res) => { if (!process.env.SSO_SECRET) { res.writeHead(503); return res.end('SSO not configured'); } const q = new URLSearchParams(req.url.split('?')[1] || ''); const token = q.get('token') || ''; const [payloadB64, sig] = token.split('.'); const fail = (msg) => { res.writeHead(403, { 'Content-Type': 'text/plain' }); res.end(msg); }; if (!payloadB64 || !sig) return fail('Invalid SSO token'); const crypto = require('crypto'); const expect = crypto.createHmac('sha256', process.env.SSO_SECRET).update(payloadB64).digest('base64url'); const sigBuf = Buffer.from(sig), expBuf = Buffer.from(expect); if (sigBuf.length !== expBuf.length || !crypto.timingSafeEqual(sigBuf, expBuf)) return fail('Invalid SSO signature'); let p; try { p = JSON.parse(Buffer.from(payloadB64, 'base64url').toString()); } catch { return fail('Invalid SSO payload'); } if (!p.email || !p.exp || p.exp < Math.floor(now() / 1000)) return fail('SSO token expired'); let u = R.users.byEmail(p.email); if (!u) { const team = R.teams.first(); if (!team) return fail('No team configured'); const { hash, salt } = A.hashPassword(A.token()); const role = (p.role === 'admin' || p.role === 'viewer') ? p.role : 'technician'; const userId = R.users.create({ tenantId: team.id, email: p.email, hash, salt, role, name: p.name || null, mfaSecret: A.newMfaSecret() }); u = R.users.byId(userId); audit({ team_id: team.id, user_id: userId, user_email: p.email, action: 'sso_user_created', detail: p.name || '' }); } else if (p.name && p.name !== u.name) { R.users.setName(u.id, p.name); } if (u.active === 0) return fail('Account deactivated'); const tok = A.token(); R.authSessions.create({ token: tok, userId: u.id, mfaPassed: true, ttl: SESSION_TTL }); audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'login', detail: 'via BizGaze SSO' }); const dest = '/connect' + (p.ticket ? ('?ticket=' + encodeURIComponent(p.ticket)) : ''); res.writeHead(302, { 'Set-Cookie': `sid=${tok}; HttpOnly; Path=/; Max-Age=${SESSION_TTL / 1000}`, Location: dest }); res.end(); }); // Admin adds an agent login to their team route('POST', '/api/users', async (req, res) => { const u = currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); if (u.role !== 'admin') return json(res, 403, { error: 'only admins can add agents' }); // With BizGaze as the sole IdP, logins are created in BizGaze, not here (creating local // accounts is what previously shadowed BizGaze and split tenants). Allowed in dev. if (BZ.isEnabled() && process.env.ALLOW_LOCAL_LOGIN !== '1') return json(res, 400, { error: 'Logins are managed in BizGaze. Add the user there; they appear here on first sign-in.' }); const { email, password, name, role } = await readBody(req); if (!email || !password) return json(res, 400, { error: 'email and temporary password required' }); if (R.users.emailExists(email)) return json(res, 409, { error: 'email already registered' }); const { hash, salt } = A.hashPassword(password); const r = (role === 'admin' || role === 'viewer') ? role : 'technician'; const userId = R.users.create({ tenantId: u.team_id, email, hash, salt, role: r, name: name || null, mfaSecret: A.newMfaSecret() }); audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'agent_added', detail: email + ' (' + r + ')' }); json(res, 200, { ok: true, id: userId, email, role: r }); }); // List the team's agents route('GET', '/api/users', async (req, res) => { const u = currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); const rows = R.users.listByTenant(u.team_id); json(res, 200, rows); }); // First-login MFA self-setup: a logged-in (password ok) user who hasn't enabled MFA yet route('GET', '/api/mfa/setup', async (req, res) => { const u = currentUser(req, { requireMfa: false }); if (!u) return json(res, 401, { error: 'unauthorized' }); if (u.mfa_enabled) return json(res, 400, { error: 'MFA already enabled' }); json(res, 200, { secret: u.mfa_secret, otpauthUrl: A.otpauthUrl(u.mfa_secret, u.email) }); }); // Admin manages an agent: reset password, rename, deactivate/activate, delete. route('POST', '/api/users/manage', async (req, res) => { const u = currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); if (u.role !== 'admin') return json(res, 403, { error: 'only admins can manage agents' }); const { id, action, password, name } = await readBody(req); const target = R.users.inTenant(id, u.team_id); if (!target) return json(res, 404, { error: 'no such agent' }); switch (action) { case 'reset-password': { if (!password || String(password).length < 8) return json(res, 400, { error: 'new password must be at least 8 characters' }); const { hash, salt } = A.hashPassword(password); R.users.setPassword(target.id, hash, salt); R.authSessions.deleteByUser(target.id); // force re-login R.refreshTokens.revokeByUser(target.id); audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'agent_password_reset', detail: target.email }); return json(res, 200, { ok: true }); } case 'rename': { const clean = String(name || '').trim().slice(0, 60); if (!clean) return json(res, 400, { error: 'name required' }); R.users.setName(target.id, clean); audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'agent_renamed', detail: target.email + ' -> ' + clean }); return json(res, 200, { ok: true, name: clean }); } case 'deactivate': { if (target.id === u.id) return json(res, 400, { error: 'you cannot deactivate your own account' }); R.users.setActive(target.id, false); R.authSessions.deleteByUser(target.id); R.refreshTokens.revokeByUser(target.id); audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'agent_deactivated', detail: target.email }); return json(res, 200, { ok: true }); } case 'activate': { R.users.setActive(target.id, true); audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'agent_activated', detail: target.email }); return json(res, 200, { ok: true }); } case 'delete': { if (target.id === u.id) return json(res, 400, { error: 'you cannot delete your own account' }); R.authSessions.deleteByUser(target.id); R.refreshTokens.revokeByUser(target.id); R.users.remove(target.id); audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'agent_deleted', detail: target.email }); return json(res, 200, { ok: true }); } default: return json(res, 400, { error: 'unknown action' }); } }); // ---------- API keys (admin-managed, for third-party / system integrations) ---------- route('POST', '/api/keys', async (req, res) => { const u = currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); if (u.role !== 'admin') return json(res, 403, { error: 'only admins can manage API keys' }); const { name, scopes } = await readBody(req); const sc = (Array.isArray(scopes) ? scopes : ['report:read']).filter((s) => API_KEY_SCOPES.includes(s)); if (!sc.length) return json(res, 400, { error: 'at least one valid scope required (' + API_KEY_SCOPES.join(', ') + ')' }); const key = 'bzc_' + A.token(24); // shown once, never stored in plaintext const id = A.id(); R.apiKeys.create({ id, tenantId: u.team_id, name: name || null, keyHash: A.hashToken(key), scopes: sc.join(','), createdBy: u.id }); audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'api_key_created', detail: (name || id) + ' [' + sc.join(',') + ']' }); json(res, 200, { id, name: name || null, scopes: sc, key }); }); route('GET', '/api/keys', async (req, res) => { const u = currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); if (u.role !== 'admin') return json(res, 403, { error: 'only admins can manage API keys' }); json(res, 200, R.apiKeys.listByTenant(u.team_id)); }); route('POST', '/api/keys/revoke', async (req, res) => { const u = currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); if (u.role !== 'admin') return json(res, 403, { error: 'only admins can manage API keys' }); const { id } = await readBody(req); if (!id) return json(res, 400, { error: 'id required' }); R.apiKeys.revoke(id, u.team_id); audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'api_key_revoked', detail: id }); json(res, 200, { ok: true }); }); // ---------- Webhook subscriptions (admin-managed, outbound event delivery) ---------- route('POST', '/api/webhooks', async (req, res) => { const u = currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); if (u.role !== 'admin') return json(res, 403, { error: 'only admins can manage webhooks' }); const { url, events, secret } = await readBody(req); if (!url || !/^https?:\/\//i.test(url)) return json(res, 400, { error: 'a valid http(s) url is required' }); let ev = Array.isArray(events) ? events.filter((e) => e === '*' || W.EVENTS.includes(e)) : W.EVENTS.slice(); if (!ev.length) ev = W.EVENTS.slice(); const sec = (secret && String(secret).length >= 8) ? String(secret) : A.token(24); const id = A.id(); R.webhooks.create({ id, tenantId: u.team_id, url, secret: sec, events: ev.join(','), createdBy: u.id }); audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'webhook_created', detail: url + ' [' + ev.join(',') + ']' }); // Secret returned so the receiver can verify the X-BizGaze-Signature header. json(res, 200, { id, url, events: ev, secret: sec }); }); route('GET', '/api/webhooks', async (req, res) => { const u = currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); if (u.role !== 'admin') return json(res, 403, { error: 'only admins can manage webhooks' }); json(res, 200, R.webhooks.listByTenant(u.team_id)); }); route('POST', '/api/webhooks/delete', async (req, res) => { const u = currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); if (u.role !== 'admin') return json(res, 403, { error: 'only admins can manage webhooks' }); const { id } = await readBody(req); if (!id) return json(res, 400, { error: 'id required' }); R.webhooks.remove(id, u.team_id); audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'webhook_deleted', detail: id }); json(res, 200, { ok: true }); }); // Available webhook event types (for integrators / an admin UI). route('GET', '/api/webhooks/events', async (req, res) => { const u = currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); json(res, 200, { events: W.EVENTS }); }); // Session report — readable by a logged-in user OR an API key with `report:read`. route('GET', '/api/report', async (req, res) => { const q = new URLSearchParams(req.url.split('?')[1] || ''); let tenantId, agentEmail; const u = currentUser(req); if (u) { // Admins see the whole team (and may filter by agent); everyone else only their own. tenantId = u.team_id; agentEmail = u.role !== 'admin' ? u.email : (q.get('agent') || null); } else { const key = apiKeyFromReq(req); if (!keyHasScope(key, 'report:read')) return json(res, 401, { error: 'unauthorized' }); R.apiKeys.touch(key.id); tenantId = key.teamId; // a key sees its whole tenant agentEmail = q.get('agent') || null; } const from = q.get('from') ? new Date(q.get('from') + 'T00:00:00').getTime() : null; const to = q.get('to') ? new Date(q.get('to') + 'T23:59:59').getTime() : null; json(res, 200, R.sessionsLog.report({ tenantId, agentEmail, from, to })); }); // List machines for the team (with live online status from signaling layer) route('GET', '/api/machines', async (req, res) => { const u = currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); const rows = R.machines.listByTenant(u.team_id); json(res, 200, rows.map((m) => ({ ...m, online: onlineAgents.has(m.id) }))); }); // Create a machine enrollment token (admin/technician). Agent uses it to come online. route('POST', '/api/machines', async (req, res) => { const u = currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); if (u.role === 'viewer') return json(res, 403, { error: 'forbidden' }); const { name, unattended } = await readBody(req); const enroll = A.token(); const mId = R.machines.create({ tenantId: u.team_id, name: name || 'Unnamed PC', enrollToken: enroll, unattended: !!unattended }); audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, machine_id: mId, machine_name: name, action: 'machine_enrolled' }); json(res, 200, { id: mId, enrollToken: enroll }); }); route('GET', '/api/audit', async (req, res) => { const u = currentUser(req); let tenantId; if (u) tenantId = u.team_id; else { const key = apiKeyFromReq(req); if (!keyHasScope(key, 'audit:read')) return json(res, 401, { error: 'unauthorized' }); R.apiKeys.touch(key.id); tenantId = key.teamId; } json(res, 200, R.audit.listByTenant(tenantId)); }); // ---------- session recording: upload (agent) ---------- const MAX_REC_BYTES = 500 * 1024 * 1024; // 500 MB safety cap route('POST', '/api/recording', async (req, res) => { const u = currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); const params = new URLSearchParams(req.url.split('?')[1] || ''); const sid = params.get('sessionId'); const ext = params.get('ext') === 'mp4' ? 'mp4' : 'webm'; // container chosen by the recorder if (!sid) return json(res, 400, { error: 'sessionId required' }); const row = R.sessionsLog.byIdInTenant(sid, u.team_id); if (!row) return json(res, 404, { error: 'no such session' }); const chunks = []; let total = 0, aborted = false; req.on('data', (c) => { total += c.length; if (total > MAX_REC_BYTES) { aborted = true; req.destroy(); return; } chunks.push(c); }); req.on('end', () => { if (aborted) return json(res, 413, { error: 'recording too large' }); const fname = sid + '.' + ext; try { fs.writeFileSync(path.join(REC_DIR, fname), Buffer.concat(chunks)); R.sessionsLog.setRecording(sid, fname); audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'recording_saved', detail: 'session ' + sid }); json(res, 200, { ok: true }); } catch (e) { json(res, 500, { error: 'could not save recording' }); } }); req.on('error', () => { try { res.end(); } catch (e) {} }); }); route('POST', '/api/transcript', async (req, res) => { const u = currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); const sid = new URLSearchParams(req.url.split('?')[1] || '').get('sessionId'); if (!sid) return json(res, 400, { error: 'sessionId required' }); const row = R.sessionsLog.byIdInTenant(sid, u.team_id); if (!row) return json(res, 404, { error: 'no such session' }); const chunks = []; let total = 0, aborted = false; req.on('data', (c) => { total += c.length; if (total > 5 * 1024 * 1024) { aborted = true; req.destroy(); return; } chunks.push(c); }); req.on('end', () => { if (aborted) return json(res, 413, { error: 'transcript too large' }); const fname = sid + '.txt'; try { fs.writeFileSync(path.join(TRANS_DIR, fname), Buffer.concat(chunks)); R.sessionsLog.setTranscript(sid, fname); json(res, 200, { ok: true }); } catch (e) { json(res, 500, { error: 'could not save transcript' }); } }); req.on('error', () => { try { res.end(); } catch (e) {} }); }); // ---------- Chat (persistent 1:1 messaging between team members) ---------- // Contacts = other active users in the tenant (the people you can message). route('GET', '/api/messages/contacts', async (req, res) => { const u = currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); const rows = R.users.listByTenant(u.team_id).filter((x) => x.id !== u.id && x.active !== 0); json(res, 200, rows.map((x) => ({ id: x.id, name: x.name || x.email, email: x.email, online: CHAT.isOnline(x.id), avatar: x.avatar_url || null }))); }); // Cross-tenant people search via the BizGaze directory (token stays server-side). Results are // tagged onConnect=true when the person already has a Connect account in this tenant (chat-ready). route('GET', '/api/directory/search', async (req, res) => { const u = currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); const q = (new URLSearchParams(req.url.split('?')[1] || '').get('q') || '').trim(); if (q.length < 2) return json(res, 200, []); const results = await require('./directory').search(q); // Map directory people to existing Connect users in this tenant (by email) so they're chat-ready. const mine = R.users.listByTenant(u.team_id).filter((x) => x.id !== u.id && x.active !== 0); const byEmail = new Map(mine.map((x) => [(x.email || '').toLowerCase(), x])); const out = results.map((p) => { const local = p.email ? byEmail.get(p.email.toLowerCase()) : null; return { name: p.name, email: p.email, phone: p.phone, org: p.org, avatar: p.avatar, onConnect: !!local, connectId: local ? local.id : null }; }); json(res, 200, out); }); // Conversation list: DMs (per counterparty) + group conversations, merged + sorted. route('GET', '/api/messages/conversations', async (req, res) => { const u = currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); const names = {}; const avatars = {}; for (const x of R.users.listByTenant(u.team_id)) { names[x.id] = x.name || x.email; avatars[x.id] = x.avatar_url || null; } // DMs const byOther = new Map(); for (const m of R.messages.recentFor(u.team_id, u.id)) { const other = m.sender_id === u.id ? m.recipient_id : m.sender_id; if (!other) continue; if (!byOther.has(other)) byOther.set(other, { other, last: m, unread: 0 }); if (m.recipient_id === u.id && m.sender_id === other && !m.read_at) byOther.get(other).unread++; } const dmItems = [...byOther.values()].map((c) => { const dc = dmCalls.get(CALLS.pairKey(u.id, c.other)); return { kind: 'dm', id: c.other, contactId: c.other, name: names[c.other] || 'Unknown', online: CHAT.isOnline(c.other), avatar: avatars[c.other] || null, callActive: !!dc, callRoom: dc ? dc.room : null, last_body: c.last.body || (c.last.attachment_id ? '📎 Attachment' : ''), last_at: c.last.created_at, last_from_me: c.last.sender_id === u.id, unread: c.unread, }; }); // Groups const groupItems = R.conversations.listForUser(u.team_id, u.id).map((g) => { const last = R.messages.lastInConversation(g.id); const since = R.conversations.lastReadAt(g.id, u.id); return { kind: 'group', id: g.id, name: g.name || 'Group', members: R.conversations.members(g.id).length, avatar: g.avatar_id ? ('/files/' + g.avatar_id) : null, callActive: groupCalls.has(g.id), callRoom: (groupCalls.get(g.id) || {}).room || null, last_body: last ? (last.body || (last.attachment_id ? '📎 Attachment' : '')) : '', last_at: last ? last.created_at : g.created_at, last_from_me: last ? last.sender_id === u.id : false, unread: last ? R.messages.unreadInConversation(g.id, u.id, since) : 0, }; }); json(res, 200, [...dmItems, ...groupItems].sort((a, b) => b.last_at - a.last_at)); }); // Full thread: a DM (?with=userId) or a group (?group=conversationId). Marks it read. route('GET', '/api/messages/thread', async (req, res) => { const u = currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); const q = new URLSearchParams(req.url.split('?')[1] || ''); const peek = !!q.get('peek'); // prefetch only — do NOT mark the conversation read const names = namesFor(u.team_id); const group = q.get('group'); if (group) { if (!R.conversations.isMember(group, u.id)) return json(res, 403, { error: 'not a member of this group' }); const rows = R.messages.threadByConversation(group); if (!peek) { R.conversations.markRead(group, u.id); const evt = { type: 'group-read', group, by: u.id, byName: names[u.id] || u.email, at: now() }; for (const mid of R.conversations.members(group)) { if (mid !== u.id) { try { CHAT.pushToUser(mid, evt); } catch (_) {} } } } const rxBy = groupReactions(R.reactions.forConversation(group), u.id, names); const reads = R.conversations.memberReads(group).filter((r) => r.user_id !== u.id); // others' read times return json(res, 200, rows.map((m) => { const d = buildMsgDTO(m, names, u.id); d.fromName = names[m.sender_id] || ''; d.reactions = dtoReactions(rxBy, m.id); if (m.sender_id === u.id) d.seenBy = reads.filter((r) => r.last_read_at >= m.created_at).map((r) => names[r.user_id] || 'Someone'); return d; })); } const other = q.get('with'); if (!other) return json(res, 400, { error: 'with or group required' }); if (!R.users.inTenant(other, u.team_id)) return json(res, 404, { error: 'no such contact' }); const rows = R.messages.thread(u.team_id, u.id, other); if (!peek) { R.messages.markRead(u.team_id, u.id, other); try { CHAT.pushToUser(other, { type: 'chat-read', by: u.id }); } catch (_) {} } const rxBy = groupReactions(R.reactions.forPair(u.team_id, u.id, other), u.id, names); return json(res, 200, rows.map((m) => { const d = buildMsgDTO(m, names, u.id); d.reactions = dtoReactions(rxBy, m.id); return d; })); }); // Create a group conversation with the given members (creator is always added). route('POST', '/api/groups', async (req, res) => { const u = currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); const { name, memberIds } = await readBody(req); const nm = String(name || '').trim().slice(0, 80); if (!nm) return json(res, 400, { error: 'group name required' }); const ids = (Array.isArray(memberIds) ? memberIds : []).filter((x) => typeof x === 'string' && x !== u.id && R.users.inTenant(x, u.team_id)); const id = A.id(); R.conversations.create({ id, teamId: u.team_id, name: nm, createdBy: u.id }); R.conversations.addMember(id, u.id, true); // creator is the first admin for (const mid of ids) R.conversations.addMember(id, mid); audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'group_created', detail: nm + ' (' + (ids.length + 1) + ' members)' }); json(res, 200, { id, name: nm, members: ids.length + 1 }); }); // Members of a group (id + name), for the group header / member list. route('GET', '/api/groups/members', async (req, res) => { const u = currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); const gid = new URLSearchParams(req.url.split('?')[1] || '').get('group'); if (!gid || !R.conversations.isMember(gid, u.id)) return json(res, 403, { error: 'not a member' }); const names = {}; const avatars = {}; for (const x of R.users.listByTenant(u.team_id)) { names[x.id] = x.name || x.email; avatars[x.id] = x.avatar_url || null; } const adminSet = new Set(R.conversations.admins(gid)); json(res, 200, R.conversations.members(gid).map((mid) => ({ id: mid, name: names[mid] || 'Unknown', avatar: avatars[mid] || null, admin: adminSet.has(mid) }))); }); // Full group info: name, creator flag, members (with isMe). route('GET', '/api/groups/info', async (req, res) => { const u = currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); const gid = new URLSearchParams(req.url.split('?')[1] || '').get('group'); if (!gid || !R.conversations.isMember(gid, u.id)) return json(res, 403, { error: 'not a member' }); const g = R.conversations.byId(gid); const tenantUsers = R.users.listByTenant(u.team_id); const names = {}; const avatars = {}; for (const x of tenantUsers) { names[x.id] = x.name || x.email; avatars[x.id] = x.avatar_url || null; } const adminSet = new Set(R.conversations.admins(gid)); json(res, 200, { id: gid, name: g.name || 'Group', createdBy: g.created_by, isCreator: g.created_by === u.id, isAdmin: adminSet.has(u.id), adminOnly: !!g.admin_only, callActive: groupCalls.has(gid), callRoom: (groupCalls.get(gid) || {}).room || null, createdByName: names[g.created_by] || 'Someone', createdAt: g.created_at, avatar: g.avatar_id ? ('/files/' + g.avatar_id) : null, members: R.conversations.members(gid).map((mid) => ({ id: mid, name: names[mid] || 'Unknown', avatar: avatars[mid] || null, isMe: mid === u.id, admin: adminSet.has(mid) })), }); }); // Rename a group (any member). route('POST', '/api/groups/rename', async (req, res) => { const u = currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); const { group, name } = await readBody(req); if (!group || !R.conversations.isMember(group, u.id)) return json(res, 403, { error: 'not a member' }); const nm = String(name || '').trim().slice(0, 80); if (!nm) return json(res, 400, { error: 'group name required' }); R.conversations.rename(group, nm); postSystemMessage(group, u.team_id, (u.name || u.email) + ' renamed the group to “' + nm + '”'); audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'group_renamed', detail: nm }); json(res, 200, { ok: true, name: nm }); }); // Start (or join) the group's shared call — returns the mesh room to connect to. No code: // members see a Join button driven by the live call state. route('POST', '/api/groups/call/start', async (req, res) => { const u = currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); const { group } = await readBody(req); if (!group || !R.conversations.isMember(group, u.id)) return json(res, 403, { error: 'not a member of this group' }); const r = CALLS.startGroupCall(group, u.team_id, u); json(res, 200, r); }); // Start (or join) a 1:1 call with another user. route('POST', '/api/calls/dm/start', async (req, res) => { const u = currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); const { to } = await readBody(req); if (!to || !R.users.inTenant(to, u.team_id)) return json(res, 404, { error: 'no such contact' }); json(res, 200, CALLS.startDmCall(u, to, u.team_id)); }); // Invite more people into the call I'm in (turns a 1:1 into multi-party). Pushes them an // incoming-call notification carrying the room to join. route('POST', '/api/calls/invite', async (req, res) => { const u = currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); const { room, userIds } = await readBody(req); if (!room || !meetingRooms.has(String(room))) return json(res, 404, { error: 'call not found' }); const ids = (Array.isArray(userIds) ? userIds : []).filter((x) => typeof x === 'string' && x !== u.id && R.users.inTenant(x, u.team_id)); for (const id of ids) { try { CHAT.pushToUser(id, { type: 'call-invite', room: String(room), byName: (u.name || u.email) }); } catch (_) {} } json(res, 200, { ok: true, invited: ids.length }); }); // Decline an incoming 1:1 call: drops the caller, posts a "Call declined" line, clears the call. route('POST', '/api/calls/decline', async (req, res) => { const u = currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); const { room } = await readBody(req); if (!room) return json(res, 400, { error: 'room required' }); json(res, 200, CALLS.declineDmCall(String(room), u)); }); // Toggle "only admins can add/remove members" (any admin). route('POST', '/api/groups/admin-only', async (req, res) => { const u = currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); const { group, value } = await readBody(req); const g = group && R.conversations.byId(group); if (!g || g.team_id !== u.team_id || !R.conversations.isMember(group, u.id)) return json(res, 403, { error: 'not a member' }); if (!R.conversations.isAdmin(group, u.id)) return json(res, 403, { error: 'only a group admin can change this' }); R.conversations.setAdminOnly(group, !!value); postSystemMessage(group, u.team_id, (u.name || u.email) + (value ? ' restricted adding members to admins only' : ' allowed everyone to add members')); json(res, 200, { ok: true, adminOnly: !!value }); }); // Promote/demote a member as admin (#9, multiple admins allowed). Only an admin can change roles. route('POST', '/api/groups/admin', async (req, res) => { const u = currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); const { group, userId, value } = await readBody(req); const g = group && R.conversations.byId(group); if (!g || g.team_id !== u.team_id || !R.conversations.isMember(group, u.id)) return json(res, 403, { error: 'not a member' }); if (!R.conversations.isAdmin(group, u.id)) return json(res, 403, { error: 'only a group admin can change roles' }); if (!userId || !R.conversations.isMember(group, userId)) return json(res, 404, { error: 'not a member of this group' }); if (!value && R.conversations.admins(group).length <= 1 && R.conversations.isAdmin(group, userId)) return json(res, 400, { error: 'a group must have at least one admin' }); R.conversations.setMemberAdmin(group, userId, !!value); const names = namesFor(u.team_id); postSystemMessage(group, u.team_id, (u.name || u.email) + (value ? ' made ' + (names[userId] || 'someone') + ' an admin' : ' removed ' + (names[userId] || 'someone') + ' as admin')); pushGroupUpdate(group); try { CHAT.pushToUser(userId, { type: 'group-role', group, admin: !!value, by: u.name || u.email }); } catch (_) {} // notify the affected member json(res, 200, { ok: true }); }); // Set a group's image. Pass an attachmentId from /api/messages/upload (must be an image // the caller uploaded). Pass null/empty to clear it. route('POST', '/api/groups/avatar', async (req, res) => { const u = currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); const { group, attachmentId } = await readBody(req); if (!group || !R.conversations.isMember(group, u.id)) return json(res, 403, { error: 'not a member' }); if (attachmentId) { const a = R.attachments.byId(attachmentId); if (!a || a.team_id !== u.team_id || a.uploader_id !== u.id) return json(res, 400, { error: 'invalid attachment' }); if (!/^image\//.test(a.mime || '')) return json(res, 400, { error: 'group image must be an image file' }); } R.conversations.setAvatar(group, attachmentId || null); audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'group_avatar_set', detail: group }); json(res, 200, { ok: true, avatar: attachmentId ? ('/files/' + attachmentId) : null }); }); // Add members to a group (any member). route('POST', '/api/groups/add', async (req, res) => { const u = currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); const { group, memberIds } = await readBody(req); if (!group || !R.conversations.isMember(group, u.id)) return json(res, 403, { error: 'not a member' }); const gA = R.conversations.byId(group); if (gA && gA.admin_only && !R.conversations.isAdmin(group, u.id)) return json(res, 403, { error: 'Only a group admin can add members' }); const ids = (Array.isArray(memberIds) ? memberIds : []).filter((x) => typeof x === 'string' && R.users.inTenant(x, u.team_id) && !R.conversations.isMember(group, x)); for (const mid of ids) R.conversations.addMember(group, mid); if (ids.length) { const names = namesFor(u.team_id); postSystemMessage(group, u.team_id, (u.name || u.email) + ' added ' + ids.map((x) => names[x] || 'someone').join(', ')); } if (ids.length) pushGroupUpdate(group); // live member-count refresh for everyone (incl. the new members) audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'group_members_added', detail: ids.length + ' to ' + group }); json(res, 200, { ok: true, added: ids.length }); }); // Remove a member (creator removes others; anyone can remove themselves = leave). route('POST', '/api/groups/remove', async (req, res) => { const u = currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); const { group, userId, newAdmin } = await readBody(req); if (!group || !R.conversations.isMember(group, u.id)) return json(res, 403, { error: 'not a member' }); const target = userId || u.id; const isSelf = target === u.id; // Leaving (self) is always allowed; removing others requires admin when admin_only is on. if (!isSelf) { const gR = R.conversations.byId(group); if (gR && gR.admin_only && !R.conversations.isAdmin(group, u.id)) return json(res, 403, { error: 'Only a group admin can remove members' }); } const wasAdmin = R.conversations.isAdmin(group, target); // #10: the last admin must hand off to a chosen successor before leaving (no auto-assign). const others = R.conversations.members(group).filter((m) => m !== target); if (wasAdmin && others.length && R.conversations.admins(group).filter((a) => a !== target).length === 0) { if (!newAdmin || !R.conversations.isMember(group, newAdmin) || newAdmin === target) return json(res, 400, { error: 'NEED_ADMIN', message: 'Choose a member to be the new admin before leaving.' }); R.conversations.setMemberAdmin(group, newAdmin, true); const names0 = namesFor(u.team_id); postSystemMessage(group, u.team_id, (names0[newAdmin] || 'A member') + ' is now an admin'); try { CHAT.pushToUser(newAdmin, { type: 'group-role', group, admin: true }); } catch (_) {} } // Post the activity BEFORE removing, so the removed person's tab also receives it. if (target !== u.id && R.conversations.isMember(group, target)) { const names = namesFor(u.team_id); postSystemMessage(group, u.team_id, (u.name || u.email) + ' removed ' + (names[target] || 'someone')); } else if (isSelf) { postSystemMessage(group, u.team_id, (u.name || u.email) + ' left the group'); } R.conversations.removeMember(group, target); if (R.conversations.members(group).length === 0) { R.conversations.remove(group); } // drop empty groups else pushGroupUpdate(group, [target]); // live member-count refresh; the removed person drops the group audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: isSelf ? 'group_left' : 'group_member_removed', detail: group }); json(res, 200, { ok: true, left: isSelf }); }); // ---------- Meetings (scheduled calls) ---------- // Schedule a call (optionally tied to a group). Gets a stable room code so it can be // joined later; the live mesh room is created on first join. Announces in the group chat. 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, durationMins, recurrence } = await readBody(req); const t = String(title || '').trim().slice(0, 120); if (!t) return json(res, 400, { error: 'title required' }); const when = Number(scheduledAt); if (!Number.isFinite(when) || when <= 0) return json(res, 400, { error: 'valid scheduledAt (ms) required' }); if (when < Date.now()) return json(res, 400, { error: 'cannot schedule a meeting in the past' }); // #1 const dur = [15, 30, 45, 60, 90, 120].includes(Number(durationMins)) ? Number(durationMins) : 30; const recur = Array.isArray(recurrence) ? [...new Set(recurrence.map(Number).filter((d) => d >= 0 && d <= 6))] : []; let groupId = null; if (group) { if (!R.conversations.isMember(group, u.id)) return json(res, 403, { error: 'not a member of this group' }); groupId = group; } const desc = String(description || '').trim().slice(0, 1000); // Invited participants: tenant users, excluding the host (creator). const invited = [...new Set((Array.isArray(participants) ? participants : []).filter((x) => typeof x === 'string' && x !== u.id && R.users.inTenant(x, u.team_id)))]; 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 }); 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) { const mid = A.id(); R.messages.send({ id: mid, teamId: u.team_id, senderId: u.id, recipientId: '', body: '📅 Scheduled a call: ' + t + ' — ' + label, conversationId: groupId }); const dto = buildMsgDTO(R.messages.byId(mid), namesFor(u.team_id), u.id); dto.fromName = u.name || u.email; for (const m of R.conversations.members(groupId)) { try { CHAT.pushToUser(m, { type: 'chat-message', message: dto }); } catch (_) {} } } // Invitation notification to each invited participant. const inviteEvt = { type: 'meeting-invite', meeting: { id, title: t, scheduledAt: when, whenText: label, room: code, by: u.name || u.email } }; for (const pid of invited) { try { CHAT.pushToUser(pid, inviteEvt); } catch (_) {} } json(res, 200, { id, roomCode: code, title: t, description: desc, scheduledAt: when, groupId, participants: invited }); }); // List the meetings this user can see, bucketed into running / upcoming / past. route('GET', '/api/meetings', async (req, res) => { const u = currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); const names = namesFor(u.team_id); const nowTs = Date.now(); const rows = R.scheduledMeetings.listForUser(u.team_id, u.id).map((s) => { let recur = []; try { recur = JSON.parse(s.recurrence || '[]'); } catch (_) {} let schedAt = s.scheduled_at; const live = meetingRooms.get(s.room_code); const running = !!(live && live.size > 0); // Recurring + its window has passed (and not live/cancelled) → roll forward to the next occurrence. if (recur.length && !running && !s.cancelled && !s.ended_at && nowTs > schedAt + ((s.duration_mins || 60) * 60000)) { const nxt = nextOccurrence(schedAt, recur, nowTs); if (nxt !== schedAt) { try { R.scheduledMeetings.reschedule(s.id, u.team_id, nxt); } catch (_) {} schedAt = nxt; } } const endTime = schedAt + ((s.duration_mins || 60) * 60000); // can't be started past this (#3) let status = 'upcoming'; if (s.cancelled) status = 'cancelled'; else if (running) status = 'running'; else if (s.ended_at) status = 'past'; else if (nowTs > endTime) status = 'past'; // its scheduled window has fully passed let invited = []; try { invited = JSON.parse(s.participants || '[]'); } catch (_) {} return { id: s.id, roomCode: s.room_code, title: s.title, description: s.description || '', scheduledAt: schedAt, groupId: s.group_id, 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, durationMins: s.duration_mins || null, recurrence: recur, recurrenceLabel: recurrenceLabel(recur), status, inCall: running ? live.size : 0, recordings: [], }; }); // Attach recordings/transcripts. A recording is visible to its creator, group members, or people // who can see the scheduled meeting it belongs to. Recordings not tied to a listed meeting become // their own "Past meeting" entry (group calls show the group name). const recDTO = (r) => ({ id: r.id, kind: r.kind, url: '/mrec/' + r.id, createdAt: r.created_at, durationMs: r.duration_ms, size: r.size, by: r.created_by_name }); const canSeeRec = (r) => { if (r.kind === 'transcript') return r.created_by === u.id; // transcripts are private to their owner if (r.created_by === u.id) return true; if (r.group_id) return R.conversations.isMember(r.group_id, u.id); if (r.meeting_id) { const s = R.scheduledMeetings.byId(r.meeting_id); if (s) return s.created_by === u.id || (s.participants && s.participants.includes('"' + u.id + '"')); } return false; }; const schedById = new Map(rows.map((m) => [m.id, m])); const schedByRoom = new Map(rows.map((m) => [m.roomCode, m])); const unsched = new Map(); for (const r of R.recordings.forTeam(u.team_id)) { if (!canSeeRec(r)) continue; const m = (r.meeting_id && schedById.get(r.meeting_id)) || (r.room && schedByRoom.get(r.room)); if (m) { m.recordings.push(recDTO(r)); } else { const k = r.room || r.id; if (!unsched.has(k)) unsched.set(k, []); unsched.get(k).push(r); } } const synth = [...unsched.values()].map((list) => { list.sort((a, b) => a.created_at - b.created_at); const f = list[0]; return { id: 'rec-' + (f.room || f.id), roomCode: f.room || '', title: f.title || 'Meeting', description: '', scheduledAt: f.created_at, groupId: f.group_id || null, groupName: f.group_id ? ((R.conversations.byId(f.group_id) || {}).name || 'Group') : null, createdBy: f.created_by, createdByName: f.created_by_name || '', canManage: false, isHost: false, invited: [], status: 'past', inCall: 0, recordings: list.map(recDTO), }; }); json(res, 200, rows.concat(synth)); }); // Host uploads an in-browser meeting recording (webm). Stored + indexed so it shows under Past meetings. route('POST', '/api/meetings/recording', (req, res) => { const u = currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); const params = new URLSearchParams(req.url.split('?')[1] || ''); const room = params.get('room') || ''; const groupHint = params.get('group') || ''; const dur = parseInt(params.get('dur') || '0', 10) || null; const chunks = []; let total = 0, aborted = false; req.on('data', (c) => { total += c.length; if (total > MAX_REC_BYTES) { aborted = true; req.destroy(); return; } chunks.push(c); }); req.on('end', () => { if (aborted) return json(res, 413, { error: 'recording too large' }); if (!total) return json(res, 400, { error: 'empty recording' }); const ctx = CALLS.meetingContext(room); const groupId = ctx.groupId || (groupHint && R.conversations.isMember(groupHint, u.id) ? groupHint : null); let title = ctx.title; if ((!title || title === 'Meeting') && groupId) { const g = R.conversations.byId(groupId); if (g) title = g.name || 'Group'; } const id = A.id(); const file = 'm_' + id + '.webm'; try { fs.writeFileSync(path.join(REC_DIR, file), Buffer.concat(chunks)); R.recordings.create({ id, teamId: u.team_id, room, groupId, meetingId: ctx.meetingId, title, kind: 'video', file, mime: 'video/webm', size: total, durationMs: dur, createdBy: u.id, createdByName: u.name || u.email }); audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'meeting_recording_saved', detail: 'room ' + room }); json(res, 200, { ok: true, id }); } catch (e) { json(res, 500, { error: 'could not save recording' }); } }); req.on('error', () => { try { res.end(); } catch (e) {} }); }); // Cancel a scheduled meeting (organizer only). route('POST', '/api/meetings/cancel', async (req, res) => { const u = currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); const { id, scope } = 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 cancel' }); if (s.cancelled || s.ended_at) return json(res, 400, { error: 'this meeting can no longer be cancelled' }); if (s.scheduled_at <= Date.now()) return json(res, 400, { error: 'the meeting time has passed — it can no longer be cancelled' }); // #13 let recur = []; try { recur = JSON.parse(s.recurrence || '[]'); } catch (_) {} const recips = new Set(); try { JSON.parse(s.participants || '[]').forEach((x) => recips.add(x)); } catch (_) {} if (s.group_id) for (const mid of R.conversations.members(s.group_id)) recips.add(mid); if (recur.length && scope === 'one') { const occ = s.scheduled_at; const whenLabel = new Date(occ).toLocaleString([], { weekday: 'short', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }); // Snapshot this cancelled occurrence (own non-recurring row) so it appears under Past meetings. try { let sc; do { sc = A.numericCode(6); } while (R.scheduledMeetings.byCode(sc)); let parts = []; try { parts = JSON.parse(s.participants || '[]'); } catch (_) {} const sid = A.id(); R.scheduledMeetings.create({ id: sid, teamId: u.team_id, groupId: s.group_id, roomCode: sc, title: s.title, description: s.description, scheduledAt: occ, createdBy: s.created_by, participants: parts, durationMins: s.duration_mins, recurrence: [] }); R.scheduledMeetings.cancel(sid, u.team_id); } catch (_) {} // Roll the recurring series forward to its next occurrence. const nxt = nextOccurrence(occ, recur, occ); if (nxt !== occ) R.scheduledMeetings.reschedule(id, u.team_id, nxt); const cevt = { type: 'meeting-cancelled', meeting: { id: s.id, title: s.title, by: u.name || u.email, when: whenLabel } }; recips.forEach((rid) => { if (rid !== u.id) { try { CHAT.pushToUser(rid, cevt); } catch (_) {} } }); return json(res, 200, { ok: true, skipped: true }); } R.scheduledMeetings.cancel(id, u.team_id); // keep it (marked cancelled), don't delete — #12 const cevt = { type: 'meeting-cancelled', meeting: { id: s.id, title: s.title, by: u.name || u.email } }; recips.forEach((rid) => { if (rid !== u.id) { try { CHAT.pushToUser(rid, cevt); } catch (_) {} } }); json(res, 200, { ok: true }); }); // Edit a scheduled meeting (organizer only, while still upcoming). 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, recurrence } = 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' }); if (s.cancelled || s.ended_at) return json(res, 400, { error: 'this meeting can no longer be edited' }); const t = String(title || '').trim().slice(0, 120); if (!t) return json(res, 400, { error: 'title required' }); const when = Number(scheduledAt); if (!Number.isFinite(when) || when < Date.now()) return json(res, 400, { error: 'pick a valid future time' }); const dur = [15, 30, 45, 60, 90, 120].includes(Number(durationMins)) ? Number(durationMins) : (s.duration_mins || 30); const recur = Array.isArray(recurrence) ? [...new Set(recurrence.map(Number).filter((d) => d >= 0 && d <= 6))] : []; const invited = [...new Set((Array.isArray(participants) ? participants : []).filter((x) => typeof x === 'string' && x !== u.id && R.users.inTenant(x, u.team_id)))]; R.scheduledMeetings.update(id, u.team_id, { title: t, description: String(description || '').trim().slice(0, 1000), scheduledAt: when, durationMins: dur, participants: invited, recurrence: recur }); const label = new Date(when).toLocaleString(); const evt = { type: 'meeting-invite', meeting: { id, title: t, scheduledAt: when, whenText: label, room: s.room_code, by: u.name || u.email, updated: true } }; const recips = new Set(invited); if (s.group_id) for (const mid of R.conversations.members(s.group_id)) recips.add(mid); recips.forEach((rid) => { if (rid !== u.id) { try { CHAT.pushToUser(rid, evt); } catch (_) {} } }); json(res, 200, { ok: true }); }); // ---------- Polls (within a group conversation) ---------- // Create a poll: stores it + a message (body = question) and pushes the message to members. route('POST', '/api/polls', async (req, res) => { const u = currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); const { group, question, options, multi } = await readBody(req); if (!group || !R.conversations.isMember(group, u.id)) return json(res, 403, { error: 'not a member of this group' }); const q = String(question || '').trim().slice(0, 300); const opts = (Array.isArray(options) ? options : []).map((s) => String(s || '').trim()).filter(Boolean).slice(0, 10); if (!q) return json(res, 400, { error: 'question required' }); if (opts.length < 2) return json(res, 400, { error: 'at least two options required' }); const pollId = A.id(); const msgId = A.id(); R.messages.send({ id: msgId, teamId: u.team_id, senderId: u.id, recipientId: '', body: q, conversationId: group }); R.polls.create({ id: pollId, teamId: u.team_id, conversationId: group, messageId: msgId, question: q, options: opts, multi: !!multi, createdBy: u.id }); R.messages.setPoll(msgId, pollId); audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'poll_created', detail: q }); const names = namesFor(u.team_id); for (const mid of R.conversations.members(group)) { try { const dto = buildMsgDTO(R.messages.byId(msgId), names, mid); dto.fromName = u.name || u.email; CHAT.pushToUser(mid, { type: 'chat-message', message: dto }); } catch (_) {} } json(res, 200, buildPollDTO(R.polls.byId(pollId), u.id)); }); // Vote on a poll option (toggle). Single-choice replaces the prior vote; multi toggles. route('POST', '/api/polls/vote', async (req, res) => { const u = currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); const { pollId, optionIdx } = await readBody(req); const p = pollId && R.polls.byId(pollId); if (!p || p.team_id !== u.team_id) return json(res, 404, { error: 'poll not found' }); if (!R.conversations.isMember(p.conversation_id, u.id)) return json(res, 403, { error: 'not a member' }); if (p.closed) return json(res, 400, { error: 'poll is closed' }); let opts = []; try { opts = JSON.parse(p.options); } catch {} const idx = Number(optionIdx); if (!Number.isInteger(idx) || idx < 0 || idx >= opts.length) return json(res, 400, { error: 'invalid option' }); if (p.multi) { if (R.pollVotes.hasVoted(p.id, u.id, idx)) R.pollVotes.remove(p.id, u.id, idx); else R.pollVotes.add(p.id, u.id, idx); } else { const had = R.pollVotes.hasVoted(p.id, u.id, idx); R.pollVotes.clearUser(p.id, u.id); if (!had) R.pollVotes.add(p.id, u.id, idx); } for (const mid of R.conversations.members(p.conversation_id)) { try { CHAT.pushToUser(mid, { type: 'poll-update', poll: buildPollDTO(p, mid), messageId: p.message_id, conversationId: p.conversation_id }); } catch (_) {} } json(res, 200, buildPollDTO(p, u.id)); }); // Close a poll (creator only) — no more votes accepted. route('POST', '/api/polls/close', async (req, res) => { const u = currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); const { pollId } = await readBody(req); const p = pollId && R.polls.byId(pollId); if (!p || p.team_id !== u.team_id) return json(res, 404, { error: 'poll not found' }); if (p.created_by !== u.id) return json(res, 403, { error: 'only the poll creator can close it' }); R.polls.close(p.id); const fresh = R.polls.byId(p.id); for (const mid of R.conversations.members(p.conversation_id)) { try { CHAT.pushToUser(mid, { type: 'poll-update', poll: buildPollDTO(fresh, mid), messageId: p.message_id, conversationId: p.conversation_id }); } catch (_) {} } json(res, 200, buildPollDTO(fresh, u.id)); }); // Send a message (persists + live-pushes to the recipient and the sender's other tabs). route('POST', '/api/messages', async (req, res) => { const u = currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); const { to, group, body, replyTo, attachmentId, mentions } = await readBody(req); const text = String(body || '').trim(); if (!text && !attachmentId) return json(res, 400, { error: 'message or attachment required' }); if (text.length > MSG_MAX) return json(res, 400, { error: 'message too long' }); if (attachmentId) { const a = R.attachments.byId(attachmentId); if (!a || a.team_id !== u.team_id || a.uploader_id !== u.id) return json(res, 400, { error: 'invalid attachment' }); } const id = A.id(); if (group) { if (!R.conversations.isMember(group, u.id)) return json(res, 403, { error: 'not a member of this group' }); // Validate mentions: keep only the literal "everyone" and ids that are actual members. let mlist = []; if (Array.isArray(mentions)) { const memberSet = new Set(R.conversations.members(group)); mlist = mentions.filter((x) => x === 'everyone' || memberSet.has(x)); mlist = [...new Set(mlist)]; } R.messages.send({ id, teamId: u.team_id, senderId: u.id, recipientId: '', body: text, replyTo: replyTo || null, attachmentId: attachmentId || null, conversationId: group, mentions: mlist }); const dto = buildMsgDTO(R.messages.byId(id), namesFor(u.team_id), u.id); dto.fromName = u.name || u.email; const push = { type: 'chat-message', message: dto }; for (const mid of R.conversations.members(group)) { try { CHAT.pushToUser(mid, push); } catch (_) {} } // includes sender's other tabs return json(res, 200, dto); } if (!to) return json(res, 400, { error: 'to or group required' }); if (!R.users.inTenant(to, u.team_id)) return json(res, 404, { error: 'no such contact' }); R.messages.send({ id, teamId: u.team_id, senderId: u.id, recipientId: to, body: text, replyTo: replyTo || null, attachmentId: attachmentId || null }); const dto = buildMsgDTO(R.messages.byId(id), namesFor(u.team_id), u.id); const push = { type: 'chat-message', message: { ...dto, fromName: u.name || u.email } }; try { CHAT.pushToUser(to, push); } catch (_) {} try { CHAT.pushToUser(u.id, push); } catch (_) {} // sync the sender's other devices json(res, 200, dto); }); route('POST', '/api/messages/read', async (req, res) => { const u = currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); const { with: other, group } = await readBody(req); if (group) { if (R.conversations.isMember(group, u.id)) { R.conversations.markRead(group, u.id); const evt = { type: 'group-read', group, by: u.id, byName: (u.name || u.email), at: now() }; for (const mid of R.conversations.members(group)) { if (mid !== u.id) { try { CHAT.pushToUser(mid, evt); } catch (_) {} } } } return json(res, 200, { ok: true }); } if (!other) return json(res, 400, { error: 'with or group required' }); R.messages.markRead(u.team_id, u.id, other); try { CHAT.pushToUser(other, { type: 'chat-read', by: u.id }); } catch (_) {} json(res, 200, { ok: true }); }); // Toggle an emoji reaction on a message (live-pushed to the other party). route('POST', '/api/messages/react', async (req, res) => { const u = currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); const { messageId, emoji } = await readBody(req); if (!messageId || !emoji) return json(res, 400, { error: 'messageId and emoji required' }); const msg = R.messages.byId(messageId); const participant = msg && msg.team_id === u.team_id && ( msg.conversation_id ? R.conversations.isMember(msg.conversation_id, u.id) : (msg.sender_id === u.id || msg.recipient_id === u.id)); if (!participant) return json(res, 404, { error: 'no such message' }); const e = String(emoji).slice(0, 16); const added = R.reactions.toggle(messageId, u.id, e); const names = namesFor(u.team_id); // Push the full, recomputed reaction set for this message (per-recipient perspective). Extra // fields (by/emoji/added/owner/convId) let the message owner show a "reacted to you" notification. const meta = { by: u.name || u.email, byId: u.id, emoji: e, added, owner: msg.sender_id, convId: msg.conversation_id || null }; if (msg.conversation_id) { for (const mid of R.conversations.members(msg.conversation_id)) { try { CHAT.pushToUser(mid, { type: 'chat-reaction', messageId, reactions: reactionsForMessage(messageId, mid, names), ...meta }); } catch (_) {} } } else { const other = msg.sender_id === u.id ? msg.recipient_id : msg.sender_id; try { CHAT.pushToUser(other, { type: 'chat-reaction', messageId, reactions: reactionsForMessage(messageId, other, names), ...meta }); } catch (_) {} try { CHAT.pushToUser(u.id, { type: 'chat-reaction', messageId, reactions: reactionsForMessage(messageId, u.id, names), ...meta }); } catch (_) {} } json(res, 200, { ok: true, messageId, added, reactions: reactionsForMessage(messageId, u.id, names) }); }); // Upload a chat attachment (raw body; filename in X-Filename, mime in Content-Type). // Returns the attachment id to attach to a subsequent /api/messages send. route('POST', '/api/messages/upload', async (req, res) => { const u = currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); const name = decodeURIComponent(req.headers['x-filename'] || 'file').slice(0, 200); const mime = (req.headers['content-type'] || 'application/octet-stream').split(';')[0].trim(); const chunks = []; let total = 0, aborted = false; req.on('data', (c) => { total += c.length; if (total > MAX_FILE_BYTES) { aborted = true; req.destroy(); return; } chunks.push(c); }); req.on('end', () => { if (aborted) return json(res, 413, { error: 'file too large (max 25 MB)' }); if (!total) return json(res, 400, { error: 'empty file' }); const id = A.id(); try { fs.writeFileSync(path.join(UPLOADS_DIR, id), Buffer.concat(chunks)); } catch (e) { return json(res, 500, { error: 'could not store file' }); } R.attachments.create({ id, teamId: u.team_id, uploaderId: u.id, name, mime, size: total }); json(res, 200, { id, name, mime, size: total }); }); req.on('error', () => { try { res.end(); } catch (e) {} }); }); // API versioning: alias every /api/* route under /api/v1/* — a frozen contract for // native desktop/mobile clients. The web app keeps using the unversioned paths, and // both share the same handlers. (/sso is a browser redirect, intentionally unversioned.) for (const key of Object.keys(routes)) { const m = key.match(/^(\S+) \/api\/(.+)$/); if (m) routes[`${m[1]} /api/v1/${m[2]}`] = routes[key]; } module.exports = routes;