// 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 PUSH = require('./push'); const MSG_MAX = 4000; const parseMentions = (s) => { if (!s) return []; try { const a = JSON.parse(s); return Array.isArray(a) ? a : []; } catch { return []; } }; const SYSTEM_SENDER = '__system__'; const msgDTO = (m) => ({ id: m.id, from: m.sender_id, to: m.recipient_id, conversation_id: m.conversation_id || null, body: m.deleted ? '' : m.body, created_at: m.created_at, read_at: m.read_at, delivered_at: m.delivered_at || null, reply_to: m.deleted ? null : (m.reply_to || null), mentions: parseMentions(m.mentions), evt: m.msg_type || null, fwd_from: m.deleted ? null : (m.fwd_from || null), deleted: !!m.deleted, system: m.sender_id === SYSTEM_SENDER || !!m.msg_type }); function namesFor(teamId){ const o = {}; for (const x of R.users.listByTenant(teamId)) o[x.id] = x.name || x.email; return o; } // id -> profile photo, with a fallback across DUPLICATE rows for the same person. // // A person can end up with more than one row (signed in by email once and by mobile another time, before // the bizgaze_user_id merge landed). Only one of those rows carries the DP. Groups happened to reference // the row WITH the photo while a DM referenced the one without — so the same contact showed their picture // in a group but fell back to initials in the 1:1. Key each row by its stable person identity (BizGaze // person id, else email, else name) and let a photo-less row borrow its twin's photo. function avatarsFor(teamId) { const users = R.users.listByTenant(teamId); const em = (x) => (x.email ? String(x.email).toLowerCase() : ''); const nm = (x) => String(x.name || '').trim().toLowerCase(); // Index every KNOWN photo under all three identities, then let a photo-less row match on ANY of them — // a single composite key missed the common case where the twin rows have different emails. const byBz = {}, byEmail = {}, byName = {}; for (const x of users) { if (!x.avatar_url) continue; if (x.bizgaze_user_id && !byBz[x.bizgaze_user_id]) byBz[x.bizgaze_user_id] = x.avatar_url; if (em(x) && !byEmail[em(x)]) byEmail[em(x)] = x.avatar_url; if (nm(x) && !byName[nm(x)]) byName[nm(x)] = x.avatar_url; } const out = {}; for (const x of users) { out[x.id] = x.avatar_url || (x.bizgaze_user_id && byBz[x.bizgaze_user_id]) || (em(x) && byEmail[em(x)]) || (nm(x) && byName[nm(x)]) || null; } return out; } // 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, at: r.created_at, 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 || ''), isVideo: /^video\//.test(a.mime || ''), isAudio: /^audio\//.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, LIVEKIT_URL, LIVEKIT_API_KEY, LIVEKIT_API_SECRET, LIVEKIT_ENABLED, PUBLIC_BASE_URL, GIPHY_API_KEY } = require('./config'); const https = require('https'); // Small GET-JSON helper for the GIPHY proxy (keeps the key server-side). function fetchJSON(url) { return new Promise((resolve, reject) => { const req = https.get(url, (res) => { if (res.statusCode !== 200) { res.resume(); return reject(new Error('upstream ' + res.statusCode)); } let buf = ''; res.on('data', (c) => { buf += c; if (buf.length > 4 * 1024 * 1024) { req.destroy(); reject(new Error('too large')); } }); res.on('end', () => { try { resolve(JSON.parse(buf)); } catch (e) { reject(e); } }); }); req.on('error', reject); req.setTimeout(8000, () => { req.destroy(); reject(new Error('timeout')); }); }); } const mailer = require('./mailer'); // Basic email validation for external meeting invitees (#4). const isEmail = (s) => typeof s === 'string' && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s.trim()); const crypto = require('crypto'); const MAX_UPLOAD_MB = parseInt(process.env.MAX_UPLOAD_MB, 10) || 1024; // default 1 GB per chat attachment const MAX_FILE_BYTES = MAX_UPLOAD_MB * 1024 * 1024; // NOTE: also raise Nginx Proxy Manager's client_max_body_size to match (default is 1 MB) or large uploads are rejected at the proxy before reaching here. // Mint a LiveKit access token (HS256 JWT signed with the API secret) — same hand-rolled JWT // approach as push.js's FCM/APNs tokens, so no extra dependency. Grants the holder join+publish+ // subscribe on exactly one room, as one identity. Secret stays server-side. const _b64u = (buf) => Buffer.from(buf).toString('base64').replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_'); function livekitToken(identity, name, room, metadata) { const nowSec = Math.floor(Date.now() / 1000); const header = _b64u(JSON.stringify({ alg: 'HS256', typ: 'JWT' })); const payload = _b64u(JSON.stringify({ iss: LIVEKIT_API_KEY, sub: identity, name: name || identity, nbf: nowSec, exp: nowSec + 6 * 3600, // 6h — long enough for any meeting metadata: metadata || '', video: { room, roomJoin: true, canPublish: true, canSubscribe: true, canPublishData: true }, })); const sig = _b64u(crypto.createHmac('sha256', LIVEKIT_API_SECRET).update(header + '.' + payload).digest()); return header + '.' + payload + '.' + sig; } // 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 (safety net so an // admin can't be locked out of the report if BizGaze doesn't flag them isAdmin). Optional. const ADMIN_EMAILS = (process.env.ADMIN_EMAILS || '').split(',').map((s) => s.trim().toLowerCase()).filter(Boolean); function provisionFromBizgaze(email, bz) { const role = (bz.isAdmin || ADMIN_EMAILS.includes(String(email).toLowerCase())) ? 'admin' : 'technician'; const bizId = bz.bizgazeUserId || null; // Identity is keyed on the BizGaze person-id, NOT the typed identifier: signing in with a // mobile number and with an email both return the same person-id, so both resolve to one // Biz Connect account (#2 — no more duplicate contacts for the same person). let existing = R.users.byBizgazeId(bizId); // Legacy account created before the person-id was stored: fall back to the typed identifier, // but only if it isn't already claimed by a different person, then stamp the id on below. if (!existing) { const byMail = R.users.byEmail(email); if (byMail && (!byMail.bizgaze_user_id || byMail.bizgaze_user_id === bizId)) existing = byMail; } 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 (bizId) R.users.setBizgazeId(id, bizId); 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); } // Retroactive merge: if this same identifier already has its OWN legacy account (a separate // row created before person-id keying — e.g. the person used email before and is now signing // in with their mobile), fold that duplicate's history into the canonical account. BizGaze just // proved this identifier belongs to this person, so the merge is safe. if (bizId) { const dup = R.users.byEmail(email); if (dup && dup.id !== existing.id && (!dup.bizgaze_user_id || dup.bizgaze_user_id === bizId)) { R.users.mergeInto(dup.id, existing.id); audit({ team_id: existing.team_id, user_id: existing.id, user_email: email, action: 'account_merged', detail: 'folded duplicate ' + dup.id }); } } // BizGaze is the source of truth: keep the person-id + name + avatar + role in sync each login. // Stamping the id links legacy rows so the person's other identifier converges here next time. if (bizId && existing.bizgaze_user_id !== bizId) R.users.setBizgazeId(existing.id, bizId); 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: when BizGaze (BIZGAZE_LOGIN_URL) is configured it is the ONLY authority — the // credentials are verified against BizGaze and the user is provisioned/synced locally // (local passwords are not accepted). Without it (dev/tests) the local password is // checked. 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 (admins then see all sessions). 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 { // Local/dev/tests, or ALLOW_LOCAL_LOGIN=1: verify the local password, then fall back // to BizGaze if a local password isn't set/correct (so SSO users can still sign in). 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. Two credential modes: // - Shared secret (recommended, coturn `use-auth-secret`): set TURN_SECRET and we mint // time-limited credentials per request (no permanent password is ever handed out, so // outsiders can't reuse your relay). Optional TURN_TTL seconds (default 24h). // - Static: set TURN_USERNAME + TURN_CREDENTIAL for a fixed long-term 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); // coturn expects "" 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, status: u.status || 'active' }); }); // Set my presence status: 'active' | 'away' | 'onleave' ('incall' is derived, not settable). route('POST', '/api/me/status', async (req, res) => { const u = currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); const { status } = await readBody(req); if (!['active', 'away', 'onleave'].includes(status)) return json(res, 400, { error: 'invalid status' }); try { R.users.setStatus(u.id, status); } catch (_) {} try { CHAT.broadcastPresence(u.id); } catch (_) {} // push the new status to contacts live (no refresh) json(res, 200, { ok: true, status }); }); // --- Web Push: background/closed-tab notifications (no-op unless VAPID is configured) --- route('GET', '/api/push/vapid', async (req, res) => { json(res, 200, { enabled: PUSH.isEnabled(), key: PUSH.publicKey() }); }); route('POST', '/api/push/subscribe', async (req, res) => { const u = currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); const sub = await readBody(req); if (!sub || !sub.endpoint || !sub.keys || !sub.keys.p256dh || !sub.keys.auth) return json(res, 400, { error: 'invalid subscription' }); try { R.pushSubs.add({ id: A.id(), userId: u.id, endpoint: sub.endpoint, p256dh: sub.keys.p256dh, auth: sub.keys.auth }); } catch (_) {} json(res, 200, { ok: true }); }); route('POST', '/api/push/unsubscribe', async (req, res) => { const u = currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); const { endpoint } = await readBody(req); if (endpoint) { try { R.pushSubs.removeByEndpoint(endpoint); } catch (_) {} } json(res, 200, { ok: true }); }); // --- Native device tokens (mobile app): FCM (Android) / APNs (iOS). Registration is always // accepted and stored; delivery is a no-op until FCM/APNs creds are configured. --- route('POST', '/api/devices', async (req, res) => { const u = currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); const { platform, token } = await readBody(req); if (!token || typeof token !== 'string') return json(res, 400, { error: 'token required' }); if (platform !== 'ios' && platform !== 'android') return json(res, 400, { error: 'platform must be ios or android' }); try { R.deviceTokens.register({ id: A.id(), userId: u.id, tenantId: u.team_id, platform, token }); } catch (_) {} json(res, 200, { ok: true }); }); route('POST', '/api/devices/remove', async (req, res) => { const u = currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); const { token } = await readBody(req); if (token) { try { R.deviceTokens.removeByToken(token); } catch (_) {} } json(res, 200, { ok: true }); }); // --- App install telemetry: records each install and, once the user signs in, who's using it. --- route('POST', '/api/telemetry/install', async (req, res) => { const { installId, platform, appVersion, os } = await readBody(req); if (!installId || typeof installId !== 'string') return json(res, 400, { error: 'installId required' }); const u = currentUser(req); // may be null on a pre-login launch — still counted try { R.appInstalls.record({ id: A.id(), installId: installId.slice(0, 64), userId: u && u.id, userEmail: u && u.email, tenantId: u && u.team_id, platform: (platform || '').slice(0, 20), appVersion: (appVersion || '').slice(0, 20), os: (os || '').slice(0, 60) }); } catch (_) {} json(res, 200, { ok: true }); }); // Admin: who installed the app (this tenant). route('GET', '/api/admin/installs', async (req, res) => { const u = currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); if (u.role !== 'admin') return json(res, 403, { error: 'admin only' }); json(res, 200, R.appInstalls.listForTenant(u.team_id)); }); // ---------- 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 via // ALLOW_LOCAL_LOGIN=1. 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); const cAv = avatarsFor(u.team_id); // duplicate-row DP fallback json(res, 200, rows.map((x) => ({ id: x.id, name: x.name || x.email, email: x.email, online: CHAT.isOnline(x.id), avatar: cAv[x.id] || null, lastSeen: x.last_seen || null, status: x.status || 'active' }))); }); // 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 = {}; const statuses = {}; const seen = {}; for (const x of R.users.listByTenant(u.team_id)) { names[x.id] = x.name || x.email; statuses[x.id] = x.status || 'active'; seen[x.id] = x.last_seen || null; } Object.assign(avatars, avatarsFor(u.team_id)); // same person / two rows → borrow the DP (see avatarsFor) const favs = new Set(R.favorites.forUser(u.id)); const inCall = new Set(); for (const [, peers] of meetingRooms) { for (const [, p] of peers) { if (p.ws && p.ws._meetingUserId) inCall.add(p.ws._meetingUserId); } } // DMs const byOther = new Map(); for (const m of R.messages.recentFor(u.team_id, u.id)) { const raw = m.sender_id === u.id ? m.recipient_id : m.sender_id; if (!raw) continue; // If this counterparty was merged away, key the row by the SURVIVING account, so the thread carries // that account's name/photo/presence (and two half-threads for one person collapse into one row). const other = (() => { try { return R.users.resolve(raw) || raw; } catch (_) { return raw; } })(); if (!byOther.has(other)) byOther.set(other, { other, last: m, unread: 0 }); if (m.recipient_id === u.id && (m.sender_id === raw || m.sender_id === other) && !m.read_at) byOther.get(other).unread++; } 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, lastSeen: seen[c.other] || null, callActive: !!dc, callRoom: dc ? dc.room : null, favorite: favs.has('dm:' + c.other), status: inCall.has(c.other) ? 'incall' : (statuses[c.other] || 'active'), 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, last_status: c.last.sender_id === u.id ? (c.last.read_at ? 'read' : (c.last.delivered_at ? 'delivered' : 'sent')) : null, // tick for my last message }; }); // Groups const groupItems = 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); const members = R.conversations.members(g.id); // Group read tick for MY last message: read = every other member has read it, delivered = some // have, else sent. Same three states as DMs, so the sidebar renders them identically. let gStatus = null; if (last && last.sender_id === u.id) { const others = members.filter((id) => id !== u.id).length; const seen = R.conversations.memberReads(g.id).filter((r) => r.user_id !== u.id && r.last_read_at >= last.created_at).length; gStatus = (others > 0 && seen >= others) ? 'read' : (seen > 0 ? 'delivered' : 'sent'); } return { kind: 'group', id: g.id, name: g.name || 'Group', members: members.length, avatar: g.avatar_id ? ('/files/' + g.avatar_id) : null, favorite: favs.has('group:' + g.id), 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, last_status: gStatus, }; }); 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 before = parseInt(q.get('before') || '', 10) || null; // pagination cursor: fetch messages OLDER than this created_at 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, 40, before); // page size (latest 40 / older via ?before) — matches client PAGE for smooth open + lazy load if (!peek && !before) { 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 (_) {} } } try { CHAT.pushToUser(u.id, { type: 'notif-clear', kind: 'group', id: group }); } catch (_) {} // #13 } const rxBy = groupReactions(R.reactions.forConversation(group), u.id, names); const reads = R.conversations.memberReads(group); // ALL members' read times (#2: seen-by visible to everyone) return json(res, 200, rows.map((m) => { const d = buildMsgDTO(m, names, u.id); d.fromName = names[m.sender_id] || ''; d.reactions = dtoReactions(rxBy, m.id); // Who has read this message (excluding its sender) — shown to every member, not just the sender. d.seenBy = reads.filter((r) => r.user_id !== m.sender_id && r.last_read_at >= m.created_at).map((r) => names[r.user_id] || 'Someone'); return d; })); } const other = R.users.resolve(q.get('with')); // follow a merge redirect so a stale peer id still loads the thread if (!other) return json(res, 400, { error: 'with or group required' }); 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, 40, before); // page size (latest 40 / older via ?before) — matches client PAGE for smooth open + lazy load if (!peek && !before) { R.messages.markRead(u.team_id, u.id, other); try { CHAT.pushToUser(other, { type: 'chat-read', by: u.id }); } catch (_) {} try { CHAT.pushToUser(u.id, { type: 'notif-clear', kind: 'dm', id: other }); } catch (_) {} } // #13 const rxBy = groupReactions(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; })); }); // Search the ENTIRE thread (not just the loaded window). Returns matching message ids + timestamps, // oldest-first, so the client can jump to any hit and lazy-load the window around it. route('GET', '/api/messages/search', async (req, res) => { const u = currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); const q = new URLSearchParams(req.url.split('?')[1] || ''); const term = String(q.get('q') || '').trim(); if (term.length < 1) return json(res, 200, { hits: [] }); const like = '%' + term.replace(/[\\%_]/g, '\\$&') + '%'; // escape LIKE wildcards const group = q.get('group'); let rows; if (group) { if (!R.conversations.isMember(group, u.id)) return json(res, 403, { error: 'not a member of this group' }); rows = R.messages.searchConversation(group, like); } else { const other = R.users.resolve(q.get('with')); if (!other || !R.users.inTenant(other, u.team_id)) return json(res, 404, { error: 'no such contact' }); rows = R.messages.searchThread(u.team_id, u.id, other, like); } return json(res, 200, { hits: rows.map((m) => ({ id: m.id, at: m.created_at })) }); }); // 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; } Object.assign(avatars, avatarsFor(u.team_id)); 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; } Object.assign(avatars, avatarsFor(u.team_id)); 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 }); }); // Does this deployment use the LiveKit SFU for meeting media? The client asks on load; if sfu is // false it uses the built-in P2P mesh. url is the browser-facing signaling endpoint (wss://…). route('GET', '/api/meetings/config', (req, res) => { json(res, 200, { sfu: LIVEKIT_ENABLED, url: LIVEKIT_ENABLED ? LIVEKIT_URL : '' }); }); // #5 GIF search — server-side GIPHY proxy so the API key never reaches the browser. Empty q → trending. route('GET', '/api/gifs', async (req, res) => { const u = currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); if (!GIPHY_API_KEY) return json(res, 200, { enabled: false, gifs: [] }); const p = new URLSearchParams(req.url.split('?')[1] || ''); const q = (p.get('q') || '').trim(); const offset = Math.max(0, Math.min(200, Number(p.get('offset')) || 0)); const limit = 24; const base = 'https://api.giphy.com/v1/gifs/' + (q ? 'search' : 'trending'); const url = base + '?api_key=' + encodeURIComponent(GIPHY_API_KEY) + (q ? ('&q=' + encodeURIComponent(q)) : '') + '&limit=' + limit + '&offset=' + offset + '&rating=pg-13&bundle=messaging_non_clips'; try { const data = await fetchJSON(url); const gifs = (data && Array.isArray(data.data) ? data.data : []).map((g) => { const im = g.images || {}; const full = (im.downsized_medium || im.fixed_height || im.original || {}); const prev = (im.fixed_width_small || im.fixed_height_small || im.preview_gif || full || {}); return { id: g.id, url: full.url || '', preview: prev.url || full.url || '', w: +full.width || 0, h: +full.height || 0, title: g.title || 'GIF' }; }).filter((g) => g.url); json(res, 200, { enabled: true, gifs, offset: offset + limit }); } catch (e) { json(res, 502, { error: 'gif search failed' }); } }); // The web build currently on the server (home.html's __BUILD marker). Long-running clients poll this and // offer a Refresh when it changes. This matters because the desktop app now CLOSES TO TRAY — it can run // for weeks without ever reloading the page, so it would silently keep serving stale code after a deploy. let APP_BUILD = ''; try { const h = fs.readFileSync(path.join(require('./config').PUBLIC_DIR, 'home.html'), 'utf8'); const m = /__BUILD='([^']+)'/.exec(h); if (m) APP_BUILD = m[1]; } catch (_) {} route('GET', '/api/build', (req, res) => json(res, 200, { build: APP_BUILD })); // Mint a LiveKit join token for the signed-in user + a specific room (the 6-digit meeting code). // The room-membership/host authorization already happens over the meeting WebSocket; this only // hands the client a media-plane credential scoped to that room and its own identity. route('POST', '/api/meetings/token', async (req, res) => { const u = currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); if (!LIVEKIT_ENABLED) return json(res, 501, { error: 'sfu not configured' }); const { room } = await readBody(req); const rm = String(room || '').trim(); if (!/^[A-Za-z0-9._-]{4,64}$/.test(rm)) return json(res, 400, { error: 'invalid room' }); const metadata = JSON.stringify({ avatarUrl: u.avatar_url || '' }); const token = livekitToken(u.id, u.name || u.email, rm, metadata); json(res, 200, { token, url: LIVEKIT_URL, identity: u.id, name: u.name || u.email }); }); // GUEST (no-login) LiveKit token — lets an external person invited by link join a meeting without a // Connect account. Only minted for a room that is currently LIVE or a valid (not-ended) scheduled // meeting, so a token can't be created for an arbitrary/expired code. Identity is a throwaway guest id. route('POST', '/api/meetings/guest-token', async (req, res) => { if (!LIVEKIT_ENABLED) return json(res, 501, { error: 'sfu not configured' }); const { room, name, identity } = await readBody(req); 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; } })(); // #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')); const gname = String(name || 'Guest').slice(0, 60); const token = livekitToken(gid, gname, rm, JSON.stringify({ guest: true })); json(res, 200, { token, url: LIVEKIT_URL, identity: gid, name: gname }); }); // 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, 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); 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)))]; // External invitees by email (#4): people not on Connect — they get an emailed guest link. const guestEmails = [...new Set((Array.isArray(participantEmails) ? participantEmails : []).map((e) => String(e || '').trim().toLowerCase()).filter(isEmail))].slice(0, 100); let code; do { code = A.numericCode(6); } while (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, 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) { 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 (_) {} } // Email invites (#4): the guest join link goes to external invitees, plus any invited Connect users // who have an email on file. Fire-and-forget — a mail outage never fails scheduling. No-op if SMTP off. try { if (mailer.isEnabled() && (guestEmails.length || invited.length)) { const link = PUBLIC_BASE_URL + '/home?meet=' + code; const nameByEmail = {}; const emails = new Set(guestEmails); for (const x of R.users.listByTenant(u.team_id)) { if (x.email) nameByEmail[x.id] = x.email; } for (const pid of invited) { const em = nameByEmail[pid]; if (em && isEmail(em)) emails.add(em.toLowerCase()); } if (emails.size) { const tpl = mailer.meetingInviteEmail({ title: t, when: label, link, host: u.name || u.email, description: desc }); mailer.send({ to: [...emails], subject: tpl.subject, html: tpl.html, text: tpl.text }); } } } catch (e) { console.warn('[meetings] invite email failed:', e && e.message); } json(res, 200, { id, roomCode: code, title: t, description: desc, scheduledAt: when, groupId, participants: invited, guestEmails, link: PUBLIC_BASE_URL + '/home?meet=' + code }); }); // 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 (_) {} let guestEmails = []; try { guestEmails = JSON.parse(s.guest_emails || '[]'); } catch (_) {} return { id: s.id, roomCode: s.room_code, title: s.title, description: s.description || '', 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, lobby: s.lobby !== 0, 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), }; }); // #7: past CALLS from the call log. Rules the user asked for: // • a plain 1:1 direct call is NOT listed (it's a call, not a meeting) — UNLESS it produced a // recording/transcript, which the `synth` entries above already cover; // • a call that ever held MORE THAN 2 people IS listed (e.g. a 1:1 that a third person joined). // What belongs in Past meetings (the user's rules): // • real SCHEDULED meetings → always; // • INSTANT meetings (Start-a-meeting → meeting-create → logged in call_history as 'adhoc') → always; // • ad-hoc CALLS — a 1:1 "Direct Call", a group call, or a 1:1 that a 3rd person joined — are NOT // meetings, so they show ONLY if they produced a recording/transcript. // calls.js writes a 'Direct Call'/'Group call' scheduled row per call (that's the 136 cards); those are // the ad-hoc calls to gate. Instant meetings have no scheduled row and live only in call_history. const CALL_TITLES = new Set(['Direct Call', 'Group call']); const keptRows = rows.filter((m) => { if (!CALL_TITLES.has(m.title)) return true; // a real, user-scheduled meeting return !!(m.recordings && m.recordings.length); // ad-hoc call → only with a recording/transcript }); // Instant meetings from the call log (deduped against anything already listed via a scheduled row/recording). const usedRooms = new Set([...schedByRoom.keys(), ...[...unsched.values()].map((l) => l[0].room).filter(Boolean)]); const instantRows = []; for (const c of R.callHistory.forTeam(u.team_id)) { if (c.room && usedRooms.has(c.room)) continue; let uids = []; try { uids = JSON.parse(c.uids || '[]'); } catch (_) {} if (!uids.includes(u.id)) continue; // only meetings you were actually in let parts = []; try { parts = JSON.parse(c.participants || '[]'); } catch (_) {} instantRows.push({ id: 'call-' + c.id, roomCode: c.room || '', title: c.title || 'Meeting', description: '', scheduledAt: c.started_at, endedAt: c.ended_at, groupId: null, groupName: null, createdBy: null, createdByName: '', canManage: false, isHost: false, invited: [], participantCount: c.peak, durationMins: Math.max(1, Math.round((c.ended_at - c.started_at) / 60000)), status: 'past', inCall: 0, recordings: [], }); } // Date filter + pagination apply to PAST only (running/upcoming are small and always returned whole). const q = new URLSearchParams(req.url.split('?')[1] || ''); const from = Number(q.get('from')) || 0; const to = Number(q.get('to')) || 0; const page = Math.max(1, Number(q.get('page')) || 1); const pageSize = Math.min(50, Math.max(5, Number(q.get('pageSize')) || 10)); const all = keptRows.concat(synth, instantRows); const live2 = all.filter((m) => m.status !== 'past'); let past = all.filter((m) => m.status === 'past'); if (from) past = past.filter((m) => m.scheduledAt >= from); if (to) past = past.filter((m) => m.scheduledAt <= to); past.sort((a, b) => b.scheduledAt - a.scheduledAt); // newest first const pastTotal = past.length; const start = (page - 1) * pageSize; json(res, 200, { list: live2.concat(past.slice(start, start + pageSize)), pastTotal, page, pageSize }); }); // Host uploads an in-browser meeting recording (webm). Stored + indexed so it shows under Past meetings. 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, 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' }); 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)))]; 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, 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 { if (mailer.isEnabled() && guestEmails.length) { const link = PUBLIC_BASE_URL + '/home?meet=' + s.room_code; const tpl = mailer.meetingInviteEmail({ title: t, when: label, link, host: u.name || u.email, description: String(description || '').trim().slice(0, 1000) }); mailer.send({ to: guestEmails, subject: tpl.subject, html: tpl.html, text: tpl.text }); } } catch (_) {} 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' }); } try { R.users.touchSeen(u.id); } catch (_) {} // #2: keep "last seen" fresh on activity, not just on disconnect 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 }; const conv = R.conversations.byId(group); const gname = (conv && conv.name) || 'Group'; const pushBody = (u.name || u.email) + ': ' + (text ? (text.length > 80 ? text.slice(0, 80) + '…' : text) : '📎 Attachment'); for (const mid of R.conversations.members(group)) { try { CHAT.pushToUser(mid, push); } catch (_) {} // includes sender's other tabs if (mid !== u.id) PUSH.sendToUser(mid, { title: gname, body: pushBody, kind: 'group', id: group, tag: 'group:' + group }); } return json(res, 200, dto); } // Resolve a merged-away recipient id to the surviving account, so DMs to a merged contact don't // save against a deleted user (which made them silently vanish). const toId = R.users.resolve(to); if (!toId) return json(res, 400, { error: 'to or group required' }); if (!R.users.inTenant(toId, u.team_id)) return json(res, 404, { error: 'no such contact' }); R.messages.send({ id, teamId: u.team_id, senderId: u.id, recipientId: toId, 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(toId, push); } catch (_) {} if (toId !== u.id) try { CHAT.pushToUser(u.id, push); } catch (_) {} // sync the sender's other devices (skip for self-notes) // Background/closed-tab push to the recipient (opens the DM). Not for a note-to-self. if (toId !== u.id) PUSH.sendToUser(toId, { title: (u.name || u.email), body: (text ? (text.length > 80 ? text.slice(0, 80) + '…' : text) : '📎 Attachment'), kind: 'dm', id: u.id, tag: 'dm:' + u.id, icon: u.avatar_url || undefined }); json(res, 200, dto); }); // Forward one or more of my visible messages to existing conversations (DMs I'm in / groups I'm a // member of). Copies body + attachment (attachment stays viewable via the any-carrier /files auth). route('POST', '/api/messages/forward', async (req, res) => { const u = currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); const { messageIds, targets } = await readBody(req); if (!Array.isArray(messageIds) || !messageIds.length || !Array.isArray(targets) || !targets.length) return json(res, 400, { error: 'messageIds and targets required' }); // Gather source messages the user is allowed to see, oldest-first (preserve order). const srcs = []; for (const mid of messageIds.slice(0, 30)) { const m = R.messages.byId(mid); if (!m || m.team_id !== u.team_id || m.deleted || m.poll_id) continue; const ok = m.conversation_id ? R.conversations.isMember(m.conversation_id, u.id) : (m.sender_id === u.id || m.recipient_id === u.id); if (ok && (m.body || m.attachment_id)) srcs.push(m); } if (!srcs.length) return json(res, 400, { error: 'nothing to forward' }); srcs.sort((a, b) => a.created_at - b.created_at); const names = namesFor(u.team_id); let sent = 0; for (const t of (targets || []).slice(0, 20)) { let isGroup = t.kind === 'group', tid = t.id; if (isGroup) { if (!R.conversations.isMember(tid, u.id)) continue; } else { tid = R.users.resolve(tid); if (!tid || !R.users.inTenant(tid, u.team_id)) continue; } for (const m of srcs) { const nid = A.token(16); const origin = m.fwd_from || names[m.sender_id] || 'Unknown'; // preserve the true origin across re-forwards if (isGroup) R.messages.send({ id: nid, teamId: u.team_id, senderId: u.id, recipientId: '', body: m.body, attachmentId: m.attachment_id, conversationId: tid, fwdFrom: origin }); else R.messages.send({ id: nid, teamId: u.team_id, senderId: u.id, recipientId: tid, body: m.body, attachmentId: m.attachment_id, fwdFrom: origin }); const dto = buildMsgDTO(R.messages.byId(nid), names, u.id); const push = { type: 'chat-message', message: { ...dto, fromName: u.name || u.email } }; if (isGroup) { for (const mid of R.conversations.members(tid)) { try { CHAT.pushToUser(mid, push); } catch (_) {} } } else { try { CHAT.pushToUser(tid, push); } catch (_) {} if (tid !== u.id) try { CHAT.pushToUser(u.id, push); } catch (_) {} } sent++; } } json(res, 200, { ok: true, sent }); }); // Delete one of YOUR OWN messages for everyone (clears content, keeps the row as a placeholder). route('POST', '/api/messages/delete', async (req, res) => { const u = currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); const { id } = await readBody(req); if (!id) return json(res, 400, { error: 'id required' }); const m = R.messages.byId(id); if (!m || m.team_id !== u.team_id) return json(res, 404, { error: 'not found' }); if (m.sender_id !== u.id) return json(res, 403, { error: 'you can only delete your own messages' }); R.messages.markDeleted(id); const evt = { type: 'chat-deleted', id, conversation_id: m.conversation_id || null }; if (m.conversation_id) { for (const mid of R.conversations.members(m.conversation_id)) { try { CHAT.pushToUser(mid, evt); } catch (_) {} } } else { try { CHAT.pushToUser(m.recipient_id, evt); } catch (_) {} try { CHAT.pushToUser(u.id, evt); } catch (_) {} } json(res, 200, { ok: true }); }); // Edit a message (sender only, text only) — updates the body + marks it edited, and pushes the // change live to the other side / other tabs (mirrors the delete broadcast). route('POST', '/api/messages/edit', async (req, res) => { const u = currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); const { id, body } = await readBody(req); if (!id || typeof body !== 'string') return json(res, 400, { error: 'id and body required' }); const text = body.trim(); if (!text) return json(res, 400, { error: 'message cannot be empty' }); const m = R.messages.byId(id); if (!m || m.team_id !== u.team_id) return json(res, 404, { error: 'not found' }); if (m.sender_id !== u.id) return json(res, 403, { error: 'you can only edit your own messages' }); if (m.deleted) return json(res, 400, { error: 'cannot edit a deleted message' }); R.messages.editBody(id, text); const edited = R.messages.byId(id); const evt = { type: 'chat-edited', id, body: text, edited_at: edited.edited_at, conversation_id: m.conversation_id || null }; if (m.conversation_id) { for (const mid of R.conversations.members(m.conversation_id)) { try { CHAT.pushToUser(mid, evt); } catch (_) {} } } else { try { CHAT.pushToUser(m.recipient_id, evt); } catch (_) {} try { CHAT.pushToUser(u.id, evt); } catch (_) {} } json(res, 200, { ok: true, edited_at: edited.edited_at }); }); // Favourite/unfavourite a conversation (per user). target = 'dm:' or 'group:'. route('POST', '/api/favorites', async (req, res) => { const u = currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); const { kind, id, on } = await readBody(req); if (!kind || !id) return json(res, 400, { error: 'kind and id required' }); try { R.favorites.set(u.id, kind + ':' + id, !!on); } catch (_) {} json(res, 200, { ok: true, favorite: !!on }); }); // Shared media & files in a conversation (group) or DM — for the "Shared" Media/Files view. route('GET', '/api/messages/media', async (req, res) => { const u = currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); const q = new URLSearchParams(req.url.split('?')[1] || ''); const group = q.get('group'); const other = q.get('with'); let rows = [], linkRows = []; if (group) { if (!R.conversations.isMember(group, u.id)) return json(res, 403, { error: 'not a member' }); rows = R.messages.attachmentsForConversation(u.team_id, group); linkRows = R.messages.linksForConversation(u.team_id, group); } else if (other) { rows = R.messages.attachmentsForDm(u.team_id, u.id, other); linkRows = R.messages.linksForDm(u.team_id, u.id, other); } else return json(res, 400, { error: 'group or with required' }); const urlRe = /(https?:\/\/[^\s<>"']+)/gi; const links = []; for (const m of linkRows) { const mm = (m.body || '').match(urlRe); if (mm) for (const url of mm) links.push({ url, at: m.created_at }); } const att = rows.map((r) => ({ id: r.id, name: r.name, mime: r.mime, size: r.size, isImage: /^image\//.test(r.mime || ''), isAudio: /^audio\//.test(r.mime || ''), isVideo: /^video\//.test(r.mime || ''), at: r.created_at })); const isMedia = (a) => a.isImage || a.isAudio || a.isVideo; // images, audio & video → "Media"; everything else → "Docs" json(res, 200, { media: att.filter(isMedia), docs: att.filter((a) => !isMedia(a)), links }); }); route('POST', '/api/messages/read', async (req, res) => { const u = currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); const { with: rawOther, group } = await readBody(req); const other = R.users.resolve(rawOther); // follow a merge redirect 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 (_) {} } } try { CHAT.pushToUser(u.id, { type: 'notif-clear', kind: 'group', id: group }); } catch (_) {} // #13: clear this chat's notifications on my other devices } 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 (_) {} try { CHAT.pushToUser(u.id, { type: 'notif-clear', kind: 'dm', id: other }); } catch (_) {} // #13: multi-device dismissal 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(); // STREAM the body straight to disk (never buffer the whole file in memory — a 1 GB attachment would OOM the // container). Write to a .part temp file, then atomically rename on success. Backpressure-aware so a fast // uploader on a slow disk can't blow up memory either. const id = A.id(); const tmp = path.join(UPLOADS_DIR, id + '.part'); let ws, total = 0, aborted = false, done = false; try { ws = fs.createWriteStream(tmp); } catch (e) { return json(res, 500, { error: 'could not store file' }); } const cleanup = () => { try { ws.destroy(); } catch (_) {} try { fs.unlinkSync(tmp); } catch (_) {} }; const finish = (code, body) => { if (done) return; done = true; json(res, code, body); }; ws.on('error', () => { aborted = true; cleanup(); finish(500, { error: 'could not store file' }); }); req.on('data', (c) => { if (aborted) return; total += c.length; if (total > MAX_FILE_BYTES) { aborted = true; cleanup(); finish(413, { error: 'file too large (max ' + MAX_UPLOAD_MB + ' MB)' }); try { req.destroy(); } catch (_) {} return; } if (!ws.write(c)) { req.pause(); ws.once('drain', () => { if (!aborted) req.resume(); }); } // respect backpressure }); req.on('end', () => { if (aborted) return; ws.end(() => { if (!total) { try { fs.unlinkSync(tmp); } catch (_) {} return finish(400, { error: 'empty file' }); } try { fs.renameSync(tmp, path.join(UPLOADS_DIR, id)); } catch (e) { try { fs.unlinkSync(tmp); } catch (_) {} return finish(500, { error: 'could not store file' }); } R.attachments.create({ id, teamId: u.team_id, uploaderId: u.id, name, mime, size: total }); // Videos: build the capped/faststart streaming rendition in the background so it is ready before // anyone taps play. Never blocks the upload response, and playback falls back to the original. try { require('./media').ensureWebRendition(id, mime); } catch (e) {} finish(200, { id, name, mime, size: total }); }); }); req.on('error', () => { aborted = true; cleanup(); try { res.end(); } catch (e) {} }); req.on('aborted', () => { aborted = true; cleanup(); }); // client hung up mid-upload → drop the partial file }); // 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;