Files
BizGaze_Remote/server/routes.js
T
Sravan 529bc26cb5 perf(mobile): compositor 'render-then-arm' chat-open slide + robust load-older anchor; remove diagnostics
Slide (workflow-verified): the jump was the main thread building the message DOM + scroll writes DURING
the 300ms animation. Now: render off-screen first, then arm a transform-only slide on a promoted GPU
layer (translate3d/contain:layout paint) with all innerHTML/scrollTop work deferred to transitionend
(afterOpenSlide queue). armOpenSlide runs after the sync render; appendBubble defers live writes mid-slide.
Load-older jump: anchor on the specific oldest-loaded message element (not scrollHeight math, which broke
when prepended images loaded and shifted content) and re-anchor as those images load.
Removed all /api/dbg diagnostics (client reporter + server route + spike/diag probes).
Build marker -> 2026-07-18-batch121.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 23:49:38 +05:30

1718 lines
104 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 || '') };
}
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_FILE_BYTES = 25 * 1024 * 1024; // 25 MB per chat attachment
// 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 "<expiry>"
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 isnt 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:<userId>' or 'group:<groupId>'.
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();
const chunks = []; let total = 0, aborted = false;
req.on('data', (c) => { total += c.length; if (total > MAX_FILE_BYTES) { aborted = true; req.destroy(); return; } chunks.push(c); });
req.on('end', () => {
if (aborted) return json(res, 413, { error: 'file too large (max 25 MB)' });
if (!total) return json(res, 400, { error: 'empty file' });
const id = A.id();
try { fs.writeFileSync(path.join(UPLOADS_DIR, id), Buffer.concat(chunks)); }
catch (e) { return json(res, 500, { error: 'could not store file' }); }
R.attachments.create({ id, teamId: u.team_id, uploaderId: u.id, name, mime, size: total });
json(res, 200, { id, name, mime, size: total });
});
req.on('error', () => { try { res.end(); } catch (e) {} });
});
// API versioning: alias every /api/* route under /api/v1/* — a frozen contract for
// native desktop/mobile clients. The web app keeps using the unversioned paths, and
// both share the same handlers. (/sso is a browser redirect, intentionally unversioned.)
for (const key of Object.keys(routes)) {
const m = key.match(/^(\S+) \/api\/(.+)$/);
if (m) routes[`${m[1]} /api/v1/${m[2]}`] = routes[key];
}
module.exports = routes;