文件
BizGaze_Remote/server/routes.js
T

1240 行
73 KiB
JavaScript

// HTTP JSON API routes (auth, MFA, users, machines, report, audit, media uploads, SSO).
// Returns a { "METHOD /path": handler } map consumed by server.js.
const fs = require('fs');
const path = require('path');
const R = require('./repos');
const A = require('./auth');
const BZ = require('./bizgaze');
const W = require('./webhooks');
const CHAT = require('./chat');
const MSG_MAX = 4000;
const parseMentions = (s) => { if (!s) return []; try { const a = JSON.parse(s); return Array.isArray(a) ? a : []; } catch { return []; } };
const SYSTEM_SENDER = '__system__';
const msgDTO = (m) => ({ id: m.id, from: m.sender_id, to: m.recipient_id, conversation_id: m.conversation_id || null, body: m.body, created_at: m.created_at, read_at: m.read_at, delivered_at: m.delivered_at || null, reply_to: m.reply_to || null, mentions: parseMentions(m.mentions), evt: m.msg_type || null, system: m.sender_id === SYSTEM_SENDER || !!m.msg_type });
function namesFor(teamId){ const o = {}; for (const x of R.users.listByTenant(teamId)) o[x.id] = x.name || x.email; return o; }
// Next future occurrence (same time-of-day) of a weekly-recurring meeting; searches 14 days ahead.
function nextOccurrence(baseTs, days, nowTs){ const b = new Date(baseTs); const hh = b.getHours(), mm = b.getMinutes(); const s = new Date(nowTs); for (let i = 0; i <= 14; i++){ const d = new Date(s.getFullYear(), s.getMonth(), s.getDate() + i, hh, mm, 0, 0); if (days.indexOf(d.getDay()) >= 0 && d.getTime() > nowTs) return d.getTime(); } return baseTs; }
const RDAY = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
function recurrenceLabel(days){ if (!days || !days.length) return ''; if (days.length === 7) return 'Every day'; return 'Every ' + days.slice().sort().map((d) => RDAY[d]).join(', '); }
// Post a centered "activity" line into a group (member added/removed/renamed/left) and push it.
function postSystemMessage(conversationId, teamId, text){
const id = A.id();
R.messages.send({ id, teamId, senderId: SYSTEM_SENDER, recipientId: '', body: text, conversationId });
const dto = buildMsgDTO(R.messages.byId(id), {}, '');
for (const mid of R.conversations.members(conversationId)) { try { CHAT.pushToUser(mid, { type: 'chat-message', message: dto }); } catch (_) {} }
return dto;
}
// Tell clients a group's membership changed so they refresh the member count / sidebar immediately.
function pushGroupUpdate(group, alsoUsers){
const seen = new Set();
for (const mid of R.conversations.members(group)) { seen.add(mid); try { CHAT.pushToUser(mid, { type: 'group-update', group }); } catch (_) {} }
for (const mid of (alsoUsers || [])) { if (!seen.has(mid)) { try { CHAT.pushToUser(mid, { type: 'group-update', group, removed: true }); } catch (_) {} } }
}
// Group a flat reaction list into { messageId: [{emoji,count,mine,who}] } for the current user.
function groupReactions(list, userId, names){
const rxBy = {};
for (const r of list) {
const byEmoji = (rxBy[r.message_id] || (rxBy[r.message_id] = {}));
const e = (byEmoji[r.emoji] || (byEmoji[r.emoji] = { count: 0, mine: false, who: [] }));
e.count++; if (r.user_id === userId) e.mine = true;
e.who.push((names && names[r.user_id]) || 'Someone');
}
return rxBy;
}
const dtoReactions = (rxBy, id) => (rxBy[id] ? Object.entries(rxBy[id]).map(([emoji, v]) => ({ emoji, count: v.count, mine: v.mine, who: v.who })) : []);
// Full reaction DTO for ONE message, from `userId`'s perspective (mine/who).
function reactionsForMessage(messageId, userId, names){
const rows = R.reactions.forMessage(messageId).map((r) => ({ message_id: messageId, user_id: r.user_id, emoji: r.emoji }));
return dtoReactions(groupReactions(rows, userId, names), messageId);
}
// Poll tally for a given viewer ("mine" = this user voted that option).
function buildPollDTO(poll, userId){
let opts = []; try { opts = JSON.parse(poll.options); } catch { opts = []; }
const counts = opts.map(() => 0); const mine = opts.map(() => false); const voters = new Set();
for (const v of R.pollVotes.forPoll(poll.id)) {
if (v.option_idx >= 0 && v.option_idx < counts.length) { counts[v.option_idx]++; if (v.user_id === userId) mine[v.option_idx] = true; }
voters.add(v.user_id);
}
return {
id: poll.id, question: poll.question, multi: !!poll.multi, closed: !!poll.closed,
options: opts.map((t, i) => ({ text: t, votes: counts[i], mine: mine[i] })),
totalVotes: counts.reduce((a, b) => a + b, 0), voters: voters.size, isOwner: poll.created_by === userId,
};
}
// DTO enriched with a small preview of the quoted message (if this is a reply).
function buildMsgDTO(m, names, userId){
const d = msgDTO(m);
if (m.reply_to) {
const r = R.messages.byId(m.reply_to);
if (r) d.reply = { id: r.id, from: r.sender_id, fromName: (names && names[r.sender_id]) || '', body: r.body.length > 140 ? r.body.slice(0, 140) + '…' : r.body };
}
if (m.attachment_id) {
const a = R.attachments.byId(m.attachment_id);
if (a) d.attachment = { id: a.id, name: a.name, mime: a.mime, size: a.size, isImage: /^image\//.test(a.mime || '') };
}
if (m.poll_id) { const p = R.polls.byId(m.poll_id); if (p) d.poll = buildPollDTO(p, userId); }
if (m.msg_type) d.byName = (names && names[m.sender_id]) || '';
return d;
}
const { now, json, readBody, parseCookies } = require('./lib');
const { audit, currentUser, tokenFromReq, apiKeyFromReq, keyHasScope } = require('./session');
const API_KEY_SCOPES = ['report:read', 'audit:read'];
const { onlineAgents, meetingRooms, groupCalls, dmCalls } = require('./presence');
const CALLS = require('./calls');
require('./reminders'); // start the 10-minute meeting-reminder loop
const { REC_DIR, TRANS_DIR, UPLOADS_DIR, SESSION_TTL, REFRESH_TTL } = require('./config');
const MAX_FILE_BYTES = 25 * 1024 * 1024; // 25 MB per chat attachment
// Issue a refresh token (native clients), store only its hash, return the plaintext once.
function issueRefreshToken(userId) {
const rtok = A.token(32);
R.refreshTokens.create({ userId, tokenHash: A.hashToken(rtok), ttl: REFRESH_TTL });
return rtok;
}
const routes = {};
const route = (method, p, fn) => (routes[`${method} ${p}`] = fn);
// Register: creates a team + admin user. MFA must be set up before full access.
route('POST', '/api/register', async (req, res) => {
const anyUser = R.users.anyExists();
if (anyUser && process.env.ALLOW_REGISTRATION !== '1')
return json(res, 403, { error: 'Registration is closed. Contact your administrator.' });
const { email, password, teamName } = await readBody(req);
if (!email || !password) return json(res, 400, { error: 'email and password required' });
if (R.users.emailExists(email))
return json(res, 409, { error: 'email already registered' });
const { hash, salt } = A.hashPassword(password);
const team = R.teams.create(teamName || `${email}'s team`);
const userId = R.users.create({ tenantId: team.id, email, hash, salt, role: 'admin', name: null, mfaSecret: A.newMfaSecret() });
audit({ team_id: team.id, user_id: userId, user_email: email, action: 'user_registered' });
json(res, 200, { ok: true });
});
// Verify MFA enrollment (confirm the user scanned the QR / entered code)
route('POST', '/api/mfa/enable', async (req, res) => {
const { email, code } = await readBody(req);
const u = R.users.byEmail(email);
if (!u) return json(res, 404, { error: 'no such user' });
if (!A.verifyTotp(u.mfa_secret, code)) return json(res, 401, { error: 'invalid code' });
R.users.enableMfa(u.id);
json(res, 200, { ok: true });
});
// Provision (or refresh) a local user from a successful BizGaze identity check.
// The local row exists so sessions, audit, and team-scoped data work; BizGaze stays
// the source of truth for credentials (the local password is random + unused).
// Emails that must always be admins regardless of what BizGaze returns (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 existing = R.users.byEmail(email);
if (!existing) {
const team = R.teams.first() || R.teams.create('BizGaze');
const { hash, salt } = A.hashPassword(A.token());
const id = R.users.create({ tenantId: team.id, email, hash, salt, role, name: bz.name || null, mfaSecret: A.newMfaSecret() });
if (bz.avatarUrl) R.users.setAvatar(id, bz.avatarUrl);
audit({ team_id: team.id, user_id: id, user_email: email, action: 'sso_user_created', detail: 'via BizGaze' });
return R.users.byId(id);
}
// BizGaze is the source of truth: keep name + avatar + role in sync on each login.
if (bz.name && bz.name !== existing.name) R.users.setName(existing.id, bz.name);
if (bz.avatarUrl && bz.avatarUrl !== existing.avatar_url) R.users.setAvatar(existing.id, bz.avatarUrl);
if (existing.role !== role) R.users.setRole(existing.id, role);
return R.users.byId(existing.id);
}
// Login: 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 });
});
// ---------- 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);
json(res, 200, rows.map((x) => ({ id: x.id, name: x.name || x.email, email: x.email, online: CHAT.isOnline(x.id), avatar: x.avatar_url || null })));
});
// Cross-tenant people search via the BizGaze directory (token stays server-side). Results are
// tagged onConnect=true when the person already has a Connect account in this tenant (chat-ready).
route('GET', '/api/directory/search', async (req, res) => {
const u = currentUser(req);
if (!u) return json(res, 401, { error: 'unauthorized' });
const q = (new URLSearchParams(req.url.split('?')[1] || '').get('q') || '').trim();
if (q.length < 2) return json(res, 200, []);
const results = await require('./directory').search(q);
// Map directory people to existing Connect users in this tenant (by email) so they're chat-ready.
const mine = R.users.listByTenant(u.team_id).filter((x) => x.id !== u.id && x.active !== 0);
const byEmail = new Map(mine.map((x) => [(x.email || '').toLowerCase(), x]));
const out = results.map((p) => {
const local = p.email ? byEmail.get(p.email.toLowerCase()) : null;
return { name: p.name, email: p.email, phone: p.phone, org: p.org, avatar: p.avatar,
onConnect: !!local, connectId: local ? local.id : null };
});
json(res, 200, out);
});
// Conversation list: DMs (per counterparty) + group conversations, merged + sorted.
route('GET', '/api/messages/conversations', async (req, res) => {
const u = currentUser(req);
if (!u) return json(res, 401, { error: 'unauthorized' });
const names = {};
const avatars = {};
for (const x of R.users.listByTenant(u.team_id)) { names[x.id] = x.name || x.email; avatars[x.id] = x.avatar_url || null; }
// DMs
const byOther = new Map();
for (const m of R.messages.recentFor(u.team_id, u.id)) {
const other = m.sender_id === u.id ? m.recipient_id : m.sender_id;
if (!other) continue;
if (!byOther.has(other)) byOther.set(other, { other, last: m, unread: 0 });
if (m.recipient_id === u.id && m.sender_id === other && !m.read_at) byOther.get(other).unread++;
}
const dmItems = [...byOther.values()].map((c) => {
const dc = dmCalls.get(CALLS.pairKey(u.id, c.other));
return {
kind: 'dm', id: c.other, contactId: c.other, name: names[c.other] || 'Unknown', online: CHAT.isOnline(c.other), avatar: avatars[c.other] || null,
callActive: !!dc, callRoom: dc ? dc.room : null,
last_body: c.last.body || (c.last.attachment_id ? '📎 Attachment' : ''), last_at: c.last.created_at, last_from_me: c.last.sender_id === u.id, unread: c.unread,
}; });
// Groups
const groupItems = R.conversations.listForUser(u.team_id, u.id).map((g) => {
const last = R.messages.lastInConversation(g.id);
const since = R.conversations.lastReadAt(g.id, u.id);
return {
kind: 'group', id: g.id, name: g.name || 'Group', members: R.conversations.members(g.id).length, avatar: g.avatar_id ? ('/files/' + g.avatar_id) : null,
callActive: groupCalls.has(g.id), callRoom: (groupCalls.get(g.id) || {}).room || null,
last_body: last ? (last.body || (last.attachment_id ? '📎 Attachment' : '')) : '', last_at: last ? last.created_at : g.created_at,
last_from_me: last ? last.sender_id === u.id : false, unread: last ? R.messages.unreadInConversation(g.id, u.id, since) : 0,
};
});
json(res, 200, [...dmItems, ...groupItems].sort((a, b) => b.last_at - a.last_at));
});
// Full thread: a DM (?with=userId) or a group (?group=conversationId). Marks it read.
route('GET', '/api/messages/thread', async (req, res) => {
const u = currentUser(req);
if (!u) return json(res, 401, { error: 'unauthorized' });
const q = new URLSearchParams(req.url.split('?')[1] || '');
const peek = !!q.get('peek'); // prefetch only — do NOT mark the conversation read
const names = namesFor(u.team_id);
const group = q.get('group');
if (group) {
if (!R.conversations.isMember(group, u.id)) return json(res, 403, { error: 'not a member of this group' });
const rows = R.messages.threadByConversation(group);
if (!peek) {
R.conversations.markRead(group, u.id);
const evt = { type: 'group-read', group, by: u.id, byName: names[u.id] || u.email, at: now() };
for (const mid of R.conversations.members(group)) { if (mid !== u.id) { try { CHAT.pushToUser(mid, evt); } catch (_) {} } }
}
const rxBy = groupReactions(R.reactions.forConversation(group), u.id, names);
const reads = R.conversations.memberReads(group).filter((r) => r.user_id !== u.id); // others' read times
return json(res, 200, rows.map((m) => {
const d = buildMsgDTO(m, names, u.id); d.fromName = names[m.sender_id] || ''; d.reactions = dtoReactions(rxBy, m.id);
if (m.sender_id === u.id) d.seenBy = reads.filter((r) => r.last_read_at >= m.created_at).map((r) => names[r.user_id] || 'Someone');
return d;
}));
}
const other = q.get('with');
if (!other) return json(res, 400, { error: 'with or group required' });
if (!R.users.inTenant(other, u.team_id)) return json(res, 404, { error: 'no such contact' });
const rows = R.messages.thread(u.team_id, u.id, other);
if (!peek) { R.messages.markRead(u.team_id, u.id, other); try { CHAT.pushToUser(other, { type: 'chat-read', by: u.id }); } catch (_) {} }
const rxBy = groupReactions(R.reactions.forPair(u.team_id, u.id, other), u.id, names);
return json(res, 200, rows.map((m) => { const d = buildMsgDTO(m, names, u.id); d.reactions = dtoReactions(rxBy, m.id); return d; }));
});
// Create a group conversation with the given members (creator is always added).
route('POST', '/api/groups', async (req, res) => {
const u = currentUser(req);
if (!u) return json(res, 401, { error: 'unauthorized' });
const { name, memberIds } = await readBody(req);
const nm = String(name || '').trim().slice(0, 80);
if (!nm) return json(res, 400, { error: 'group name required' });
const ids = (Array.isArray(memberIds) ? memberIds : []).filter((x) => typeof x === 'string' && x !== u.id && R.users.inTenant(x, u.team_id));
const id = A.id();
R.conversations.create({ id, teamId: u.team_id, name: nm, createdBy: u.id });
R.conversations.addMember(id, u.id, true); // creator is the first admin
for (const mid of ids) R.conversations.addMember(id, mid);
audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'group_created', detail: nm + ' (' + (ids.length + 1) + ' members)' });
json(res, 200, { id, name: nm, members: ids.length + 1 });
});
// Members of a group (id + name), for the group header / member list.
route('GET', '/api/groups/members', async (req, res) => {
const u = currentUser(req);
if (!u) return json(res, 401, { error: 'unauthorized' });
const gid = new URLSearchParams(req.url.split('?')[1] || '').get('group');
if (!gid || !R.conversations.isMember(gid, u.id)) return json(res, 403, { error: 'not a member' });
const names = {}; const avatars = {};
for (const x of R.users.listByTenant(u.team_id)) { names[x.id] = x.name || x.email; avatars[x.id] = x.avatar_url || null; }
const adminSet = new Set(R.conversations.admins(gid));
json(res, 200, R.conversations.members(gid).map((mid) => ({ id: mid, name: names[mid] || 'Unknown', avatar: avatars[mid] || null, admin: adminSet.has(mid) })));
});
// Full group info: name, creator flag, members (with isMe).
route('GET', '/api/groups/info', async (req, res) => {
const u = currentUser(req);
if (!u) return json(res, 401, { error: 'unauthorized' });
const gid = new URLSearchParams(req.url.split('?')[1] || '').get('group');
if (!gid || !R.conversations.isMember(gid, u.id)) return json(res, 403, { error: 'not a member' });
const g = R.conversations.byId(gid);
const tenantUsers = R.users.listByTenant(u.team_id);
const names = {}; const avatars = {};
for (const x of tenantUsers) { names[x.id] = x.name || x.email; avatars[x.id] = x.avatar_url || null; }
const adminSet = new Set(R.conversations.admins(gid));
json(res, 200, {
id: gid, name: g.name || 'Group', createdBy: g.created_by, isCreator: g.created_by === u.id,
isAdmin: adminSet.has(u.id),
adminOnly: !!g.admin_only, callActive: groupCalls.has(gid), callRoom: (groupCalls.get(gid) || {}).room || null,
createdByName: names[g.created_by] || 'Someone', createdAt: g.created_at,
avatar: g.avatar_id ? ('/files/' + g.avatar_id) : null,
members: R.conversations.members(gid).map((mid) => ({ id: mid, name: names[mid] || 'Unknown', avatar: avatars[mid] || null, isMe: mid === u.id, admin: adminSet.has(mid) })),
});
});
// Rename a group (any member).
route('POST', '/api/groups/rename', async (req, res) => {
const u = currentUser(req);
if (!u) return json(res, 401, { error: 'unauthorized' });
const { group, name } = await readBody(req);
if (!group || !R.conversations.isMember(group, u.id)) return json(res, 403, { error: 'not a member' });
const nm = String(name || '').trim().slice(0, 80);
if (!nm) return json(res, 400, { error: 'group name required' });
R.conversations.rename(group, nm);
postSystemMessage(group, u.team_id, (u.name || u.email) + ' renamed the group to “' + nm + '”');
audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'group_renamed', detail: nm });
json(res, 200, { ok: true, name: nm });
});
// Start (or join) the group's shared call — returns the mesh room to connect to. No code:
// members see a Join button driven by the live call state.
route('POST', '/api/groups/call/start', async (req, res) => {
const u = currentUser(req);
if (!u) return json(res, 401, { error: 'unauthorized' });
const { group } = await readBody(req);
if (!group || !R.conversations.isMember(group, u.id)) return json(res, 403, { error: 'not a member of this group' });
const r = CALLS.startGroupCall(group, u.team_id, u);
json(res, 200, r);
});
// Start (or join) a 1:1 call with another user.
route('POST', '/api/calls/dm/start', async (req, res) => {
const u = currentUser(req);
if (!u) return json(res, 401, { error: 'unauthorized' });
const { to } = await readBody(req);
if (!to || !R.users.inTenant(to, u.team_id)) return json(res, 404, { error: 'no such contact' });
json(res, 200, CALLS.startDmCall(u, to, u.team_id));
});
// Invite more people into the call I'm in (turns a 1:1 into multi-party). Pushes them an
// incoming-call notification carrying the room to join.
route('POST', '/api/calls/invite', async (req, res) => {
const u = currentUser(req);
if (!u) return json(res, 401, { error: 'unauthorized' });
const { room, userIds } = await readBody(req);
if (!room || !meetingRooms.has(String(room))) return json(res, 404, { error: 'call not found' });
const ids = (Array.isArray(userIds) ? userIds : []).filter((x) => typeof x === 'string' && x !== u.id && R.users.inTenant(x, u.team_id));
for (const id of ids) { try { CHAT.pushToUser(id, { type: 'call-invite', room: String(room), byName: (u.name || u.email) }); } catch (_) {} }
json(res, 200, { ok: true, invited: ids.length });
});
// Decline an incoming 1:1 call: drops the caller, posts a "Call declined" line, clears the call.
route('POST', '/api/calls/decline', async (req, res) => {
const u = currentUser(req);
if (!u) return json(res, 401, { error: 'unauthorized' });
const { room } = await readBody(req);
if (!room) return json(res, 400, { error: 'room required' });
json(res, 200, CALLS.declineDmCall(String(room), u));
});
// Toggle "only admins can add/remove members" (any admin).
route('POST', '/api/groups/admin-only', async (req, res) => {
const u = currentUser(req);
if (!u) return json(res, 401, { error: 'unauthorized' });
const { group, value } = await readBody(req);
const g = group && R.conversations.byId(group);
if (!g || g.team_id !== u.team_id || !R.conversations.isMember(group, u.id)) return json(res, 403, { error: 'not a member' });
if (!R.conversations.isAdmin(group, u.id)) return json(res, 403, { error: 'only a group admin can change this' });
R.conversations.setAdminOnly(group, !!value);
postSystemMessage(group, u.team_id, (u.name || u.email) + (value ? ' restricted adding members to admins only' : ' allowed everyone to add members'));
json(res, 200, { ok: true, adminOnly: !!value });
});
// Promote/demote a member as admin (#9, multiple admins allowed). Only an admin can change roles.
route('POST', '/api/groups/admin', async (req, res) => {
const u = currentUser(req);
if (!u) return json(res, 401, { error: 'unauthorized' });
const { group, userId, value } = await readBody(req);
const g = group && R.conversations.byId(group);
if (!g || g.team_id !== u.team_id || !R.conversations.isMember(group, u.id)) return json(res, 403, { error: 'not a member' });
if (!R.conversations.isAdmin(group, u.id)) return json(res, 403, { error: 'only a group admin can change roles' });
if (!userId || !R.conversations.isMember(group, userId)) return json(res, 404, { error: 'not a member of this group' });
if (!value && R.conversations.admins(group).length <= 1 && R.conversations.isAdmin(group, userId)) return json(res, 400, { error: 'a group must have at least one admin' });
R.conversations.setMemberAdmin(group, userId, !!value);
const names = namesFor(u.team_id);
postSystemMessage(group, u.team_id, (u.name || u.email) + (value ? ' made ' + (names[userId] || 'someone') + ' an admin' : ' removed ' + (names[userId] || 'someone') + ' as admin'));
pushGroupUpdate(group);
try { CHAT.pushToUser(userId, { type: 'group-role', group, admin: !!value, by: u.name || u.email }); } catch (_) {} // notify the affected member
json(res, 200, { ok: true });
});
// Set a group's image. Pass an attachmentId from /api/messages/upload (must be an image
// the caller uploaded). Pass null/empty to clear it.
route('POST', '/api/groups/avatar', async (req, res) => {
const u = currentUser(req);
if (!u) return json(res, 401, { error: 'unauthorized' });
const { group, attachmentId } = await readBody(req);
if (!group || !R.conversations.isMember(group, u.id)) return json(res, 403, { error: 'not a member' });
if (attachmentId) {
const a = R.attachments.byId(attachmentId);
if (!a || a.team_id !== u.team_id || a.uploader_id !== u.id) return json(res, 400, { error: 'invalid attachment' });
if (!/^image\//.test(a.mime || '')) return json(res, 400, { error: 'group image must be an image file' });
}
R.conversations.setAvatar(group, attachmentId || null);
audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'group_avatar_set', detail: group });
json(res, 200, { ok: true, avatar: attachmentId ? ('/files/' + attachmentId) : null });
});
// Add members to a group (any member).
route('POST', '/api/groups/add', async (req, res) => {
const u = currentUser(req);
if (!u) return json(res, 401, { error: 'unauthorized' });
const { group, memberIds } = await readBody(req);
if (!group || !R.conversations.isMember(group, u.id)) return json(res, 403, { error: 'not a member' });
const gA = R.conversations.byId(group);
if (gA && gA.admin_only && !R.conversations.isAdmin(group, u.id)) return json(res, 403, { error: 'Only a group admin can add members' });
const ids = (Array.isArray(memberIds) ? memberIds : []).filter((x) => typeof x === 'string' && R.users.inTenant(x, u.team_id) && !R.conversations.isMember(group, x));
for (const mid of ids) R.conversations.addMember(group, mid);
if (ids.length) {
const names = namesFor(u.team_id);
postSystemMessage(group, u.team_id, (u.name || u.email) + ' added ' + ids.map((x) => names[x] || 'someone').join(', '));
}
if (ids.length) pushGroupUpdate(group); // live member-count refresh for everyone (incl. the new members)
audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'group_members_added', detail: ids.length + ' to ' + group });
json(res, 200, { ok: true, added: ids.length });
});
// Remove a member (creator removes others; anyone can remove themselves = leave).
route('POST', '/api/groups/remove', async (req, res) => {
const u = currentUser(req);
if (!u) return json(res, 401, { error: 'unauthorized' });
const { group, userId, newAdmin } = await readBody(req);
if (!group || !R.conversations.isMember(group, u.id)) return json(res, 403, { error: 'not a member' });
const target = userId || u.id;
const isSelf = target === u.id;
// Leaving (self) is always allowed; removing others requires admin when admin_only is on.
if (!isSelf) { const gR = R.conversations.byId(group); if (gR && gR.admin_only && !R.conversations.isAdmin(group, u.id)) return json(res, 403, { error: 'Only a group admin can remove members' }); }
const wasAdmin = R.conversations.isAdmin(group, target);
// #10: the last admin must hand off to a chosen successor before leaving (no auto-assign).
const others = R.conversations.members(group).filter((m) => m !== target);
if (wasAdmin && others.length && R.conversations.admins(group).filter((a) => a !== target).length === 0) {
if (!newAdmin || !R.conversations.isMember(group, newAdmin) || newAdmin === target) return json(res, 400, { error: 'NEED_ADMIN', message: 'Choose a member to be the new admin before leaving.' });
R.conversations.setMemberAdmin(group, newAdmin, true);
const names0 = namesFor(u.team_id);
postSystemMessage(group, u.team_id, (names0[newAdmin] || 'A member') + ' is now an admin');
try { CHAT.pushToUser(newAdmin, { type: 'group-role', group, admin: true }); } catch (_) {}
}
// Post the activity BEFORE removing, so the removed person's tab also receives it.
if (target !== u.id && R.conversations.isMember(group, target)) {
const names = namesFor(u.team_id);
postSystemMessage(group, u.team_id, (u.name || u.email) + ' removed ' + (names[target] || 'someone'));
} else if (isSelf) {
postSystemMessage(group, u.team_id, (u.name || u.email) + ' left the group');
}
R.conversations.removeMember(group, target);
if (R.conversations.members(group).length === 0) { R.conversations.remove(group); } // drop empty groups
else pushGroupUpdate(group, [target]); // live member-count refresh; the removed person drops the group
audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: isSelf ? 'group_left' : 'group_member_removed', detail: group });
json(res, 200, { ok: true, left: isSelf });
});
// ---------- Meetings (scheduled calls) ----------
// Schedule a call (optionally tied to a group). Gets a stable room code so it can be
// joined later; the live mesh room is created on first join. Announces in the group chat.
route('POST', '/api/meetings/schedule', async (req, res) => {
const u = currentUser(req);
if (!u) return json(res, 401, { error: 'unauthorized' });
const { group, title, description, scheduledAt, whenText, participants, durationMins, recurrence } = await readBody(req);
const t = String(title || '').trim().slice(0, 120);
if (!t) return json(res, 400, { error: 'title required' });
const when = Number(scheduledAt);
if (!Number.isFinite(when) || when <= 0) return json(res, 400, { error: 'valid scheduledAt (ms) required' });
if (when < Date.now()) return json(res, 400, { error: 'cannot schedule a meeting in the past' }); // #1
const dur = [15, 30, 45, 60, 90, 120].includes(Number(durationMins)) ? Number(durationMins) : 30;
const recur = Array.isArray(recurrence) ? [...new Set(recurrence.map(Number).filter((d) => d >= 0 && d <= 6))] : [];
let groupId = null;
if (group) {
if (!R.conversations.isMember(group, u.id)) return json(res, 403, { error: 'not a member of this group' });
groupId = group;
}
const desc = String(description || '').trim().slice(0, 1000);
// Invited participants: tenant users, excluding the host (creator).
const invited = [...new Set((Array.isArray(participants) ? participants : []).filter((x) => typeof x === 'string' && x !== u.id && R.users.inTenant(x, u.team_id)))];
let code; do { code = A.numericCode(6); } while (R.scheduledMeetings.byCode(code) || meetingRooms.has(code));
const id = A.id();
R.scheduledMeetings.create({ id, teamId: u.team_id, groupId, roomCode: code, title: t, description: desc, scheduledAt: when, createdBy: u.id, participants: invited, durationMins: dur, recurrence: recur });
audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'meeting_scheduled', detail: t });
const label = (typeof whenText === 'string' && whenText.trim()) ? whenText.trim() : new Date(when).toLocaleString();
if (groupId) {
const mid = A.id();
R.messages.send({ id: mid, teamId: u.team_id, senderId: u.id, recipientId: '', body: '📅 Scheduled a call: ' + t + ' — ' + label, conversationId: groupId });
const dto = buildMsgDTO(R.messages.byId(mid), namesFor(u.team_id), u.id); dto.fromName = u.name || u.email;
for (const m of R.conversations.members(groupId)) { try { CHAT.pushToUser(m, { type: 'chat-message', message: dto }); } catch (_) {} }
}
// Invitation notification to each invited participant.
const inviteEvt = { type: 'meeting-invite', meeting: { id, title: t, scheduledAt: when, whenText: label, room: code, by: u.name || u.email } };
for (const pid of invited) { try { CHAT.pushToUser(pid, inviteEvt); } catch (_) {} }
json(res, 200, { id, roomCode: code, title: t, description: desc, scheduledAt: when, groupId, participants: invited });
});
// List the meetings this user can see, bucketed into running / upcoming / past.
route('GET', '/api/meetings', async (req, res) => {
const u = currentUser(req);
if (!u) return json(res, 401, { error: 'unauthorized' });
const names = namesFor(u.team_id);
const nowTs = Date.now();
const rows = R.scheduledMeetings.listForUser(u.team_id, u.id).map((s) => {
let recur = []; try { recur = JSON.parse(s.recurrence || '[]'); } catch (_) {}
let schedAt = s.scheduled_at;
const live = meetingRooms.get(s.room_code);
const running = !!(live && live.size > 0);
// Recurring + its window has passed (and not live/cancelled) → roll forward to the next occurrence.
if (recur.length && !running && !s.cancelled && !s.ended_at && nowTs > schedAt + ((s.duration_mins || 60) * 60000)) {
const nxt = nextOccurrence(schedAt, recur, nowTs);
if (nxt !== schedAt) { try { R.scheduledMeetings.reschedule(s.id, u.team_id, nxt); } catch (_) {} schedAt = nxt; }
}
const endTime = schedAt + ((s.duration_mins || 60) * 60000); // can't be started past this (#3)
let status = 'upcoming';
if (s.cancelled) status = 'cancelled';
else if (running) status = 'running';
else if (s.ended_at) status = 'past';
else if (nowTs > endTime) status = 'past'; // its scheduled window has fully passed
let invited = []; try { invited = JSON.parse(s.participants || '[]'); } catch (_) {}
return {
id: s.id, roomCode: s.room_code, title: s.title, description: s.description || '',
scheduledAt: schedAt, groupId: s.group_id,
groupName: s.group_id ? ((R.conversations.byId(s.group_id) || {}).name || 'Group') : null,
createdBy: s.created_by, createdByName: names[s.created_by] || '', canManage: s.created_by === u.id, isHost: s.created_by === u.id,
invited: invited.map((pid) => names[pid] || 'Someone'), invitedIds: invited,
durationMins: s.duration_mins || null, recurrence: recur, recurrenceLabel: recurrenceLabel(recur),
status, inCall: running ? live.size : 0, recordings: [],
};
});
// Attach recordings/transcripts. A recording is visible to its creator, group members, or people
// who can see the scheduled meeting it belongs to. Recordings not tied to a listed meeting become
// their own "Past meeting" entry (group calls show the group name).
const recDTO = (r) => ({ id: r.id, kind: r.kind, url: '/mrec/' + r.id, createdAt: r.created_at, durationMs: r.duration_ms, size: r.size, by: r.created_by_name });
const canSeeRec = (r) => {
if (r.kind === 'transcript') return r.created_by === u.id; // transcripts are private to their owner
if (r.created_by === u.id) return true;
if (r.group_id) return R.conversations.isMember(r.group_id, u.id);
if (r.meeting_id) { const s = R.scheduledMeetings.byId(r.meeting_id); if (s) return s.created_by === u.id || (s.participants && s.participants.includes('"' + u.id + '"')); }
return false;
};
const schedById = new Map(rows.map((m) => [m.id, m]));
const schedByRoom = new Map(rows.map((m) => [m.roomCode, m]));
const unsched = new Map();
for (const r of R.recordings.forTeam(u.team_id)) {
if (!canSeeRec(r)) continue;
const m = (r.meeting_id && schedById.get(r.meeting_id)) || (r.room && schedByRoom.get(r.room));
if (m) { m.recordings.push(recDTO(r)); }
else { const k = r.room || r.id; if (!unsched.has(k)) unsched.set(k, []); unsched.get(k).push(r); }
}
const synth = [...unsched.values()].map((list) => {
list.sort((a, b) => a.created_at - b.created_at); const f = list[0];
return {
id: 'rec-' + (f.room || f.id), roomCode: f.room || '', title: f.title || 'Meeting', description: '',
scheduledAt: f.created_at, groupId: f.group_id || null,
groupName: f.group_id ? ((R.conversations.byId(f.group_id) || {}).name || 'Group') : null,
createdBy: f.created_by, createdByName: f.created_by_name || '', canManage: false, isHost: false,
invited: [], status: 'past', inCall: 0, recordings: list.map(recDTO),
};
});
json(res, 200, rows.concat(synth));
});
// Host uploads an in-browser meeting recording (webm). Stored + indexed so it shows under Past meetings.
route('POST', '/api/meetings/recording', (req, res) => {
const u = currentUser(req);
if (!u) return json(res, 401, { error: 'unauthorized' });
const params = new URLSearchParams(req.url.split('?')[1] || '');
const room = params.get('room') || '';
const groupHint = params.get('group') || '';
const dur = parseInt(params.get('dur') || '0', 10) || null;
const chunks = []; let total = 0, aborted = false;
req.on('data', (c) => { total += c.length; if (total > MAX_REC_BYTES) { aborted = true; req.destroy(); return; } chunks.push(c); });
req.on('end', () => {
if (aborted) return json(res, 413, { error: 'recording too large' });
if (!total) return json(res, 400, { error: 'empty recording' });
const ctx = CALLS.meetingContext(room);
const groupId = ctx.groupId || (groupHint && R.conversations.isMember(groupHint, u.id) ? groupHint : null);
let title = ctx.title; if ((!title || title === 'Meeting') && groupId) { const g = R.conversations.byId(groupId); if (g) title = g.name || 'Group'; }
const id = A.id(); const file = 'm_' + id + '.webm';
try {
fs.writeFileSync(path.join(REC_DIR, file), Buffer.concat(chunks));
R.recordings.create({ id, teamId: u.team_id, room, groupId, meetingId: ctx.meetingId, title, kind: 'video', file, mime: 'video/webm', size: total, durationMs: dur, createdBy: u.id, createdByName: u.name || u.email });
audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'meeting_recording_saved', detail: 'room ' + room });
json(res, 200, { ok: true, id });
} catch (e) { json(res, 500, { error: 'could not save recording' }); }
});
req.on('error', () => { try { res.end(); } catch (e) {} });
});
// Cancel a scheduled meeting (organizer only).
route('POST', '/api/meetings/cancel', async (req, res) => {
const u = currentUser(req);
if (!u) return json(res, 401, { error: 'unauthorized' });
const { id, scope } = await readBody(req);
const s = id && R.scheduledMeetings.byId(id);
if (!s || s.team_id !== u.team_id) return json(res, 404, { error: 'not found' });
if (s.created_by !== u.id) return json(res, 403, { error: 'only the organizer can cancel' });
if (s.cancelled || s.ended_at) return json(res, 400, { error: 'this meeting can no longer be cancelled' });
if (s.scheduled_at <= Date.now()) return json(res, 400, { error: 'the meeting time has passed — it can no longer be cancelled' }); // #13
let recur = []; try { recur = JSON.parse(s.recurrence || '[]'); } catch (_) {}
const recips = new Set(); try { JSON.parse(s.participants || '[]').forEach((x) => recips.add(x)); } catch (_) {}
if (s.group_id) for (const mid of R.conversations.members(s.group_id)) recips.add(mid);
if (recur.length && scope === 'one') {
const occ = s.scheduled_at;
const whenLabel = new Date(occ).toLocaleString([], { weekday: 'short', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
// Snapshot this cancelled occurrence (own non-recurring row) so it appears under Past meetings.
try {
let sc; do { sc = A.numericCode(6); } while (R.scheduledMeetings.byCode(sc));
let parts = []; try { parts = JSON.parse(s.participants || '[]'); } catch (_) {}
const sid = A.id();
R.scheduledMeetings.create({ id: sid, teamId: u.team_id, groupId: s.group_id, roomCode: sc, title: s.title, description: s.description, scheduledAt: occ, createdBy: s.created_by, participants: parts, durationMins: s.duration_mins, recurrence: [] });
R.scheduledMeetings.cancel(sid, u.team_id);
} catch (_) {}
// Roll the recurring series forward to its next occurrence.
const nxt = nextOccurrence(occ, recur, occ);
if (nxt !== occ) R.scheduledMeetings.reschedule(id, u.team_id, nxt);
const cevt = { type: 'meeting-cancelled', meeting: { id: s.id, title: s.title, by: u.name || u.email, when: whenLabel } };
recips.forEach((rid) => { if (rid !== u.id) { try { CHAT.pushToUser(rid, cevt); } catch (_) {} } });
return json(res, 200, { ok: true, skipped: true });
}
R.scheduledMeetings.cancel(id, u.team_id); // keep it (marked cancelled), don't delete — #12
const cevt = { type: 'meeting-cancelled', meeting: { id: s.id, title: s.title, by: u.name || u.email } };
recips.forEach((rid) => { if (rid !== u.id) { try { CHAT.pushToUser(rid, cevt); } catch (_) {} } });
json(res, 200, { ok: true });
});
// Edit a scheduled meeting (organizer only, while still upcoming).
route('POST', '/api/meetings/update', async (req, res) => {
const u = currentUser(req);
if (!u) return json(res, 401, { error: 'unauthorized' });
const { id, title, description, scheduledAt, durationMins, participants, recurrence } = await readBody(req);
const s = id && R.scheduledMeetings.byId(id);
if (!s || s.team_id !== u.team_id) return json(res, 404, { error: 'not found' });
if (s.created_by !== u.id) return json(res, 403, { error: 'only the organizer can edit' });
if (s.cancelled || s.ended_at) return json(res, 400, { error: 'this meeting can no longer be edited' });
const t = String(title || '').trim().slice(0, 120); if (!t) return json(res, 400, { error: 'title required' });
const when = Number(scheduledAt); if (!Number.isFinite(when) || when < Date.now()) return json(res, 400, { error: 'pick a valid future time' });
const dur = [15, 30, 45, 60, 90, 120].includes(Number(durationMins)) ? Number(durationMins) : (s.duration_mins || 30);
const recur = Array.isArray(recurrence) ? [...new Set(recurrence.map(Number).filter((d) => d >= 0 && d <= 6))] : [];
const invited = [...new Set((Array.isArray(participants) ? participants : []).filter((x) => typeof x === 'string' && x !== u.id && R.users.inTenant(x, u.team_id)))];
R.scheduledMeetings.update(id, u.team_id, { title: t, description: String(description || '').trim().slice(0, 1000), scheduledAt: when, durationMins: dur, participants: invited, recurrence: recur });
const label = new Date(when).toLocaleString();
const evt = { type: 'meeting-invite', meeting: { id, title: t, scheduledAt: when, whenText: label, room: s.room_code, by: u.name || u.email, updated: true } };
const recips = new Set(invited); if (s.group_id) for (const mid of R.conversations.members(s.group_id)) recips.add(mid);
recips.forEach((rid) => { if (rid !== u.id) { try { CHAT.pushToUser(rid, evt); } catch (_) {} } });
json(res, 200, { ok: true });
});
// ---------- Polls (within a group conversation) ----------
// Create a poll: stores it + a message (body = question) and pushes the message to members.
route('POST', '/api/polls', async (req, res) => {
const u = currentUser(req);
if (!u) return json(res, 401, { error: 'unauthorized' });
const { group, question, options, multi } = await readBody(req);
if (!group || !R.conversations.isMember(group, u.id)) return json(res, 403, { error: 'not a member of this group' });
const q = String(question || '').trim().slice(0, 300);
const opts = (Array.isArray(options) ? options : []).map((s) => String(s || '').trim()).filter(Boolean).slice(0, 10);
if (!q) return json(res, 400, { error: 'question required' });
if (opts.length < 2) return json(res, 400, { error: 'at least two options required' });
const pollId = A.id(); const msgId = A.id();
R.messages.send({ id: msgId, teamId: u.team_id, senderId: u.id, recipientId: '', body: q, conversationId: group });
R.polls.create({ id: pollId, teamId: u.team_id, conversationId: group, messageId: msgId, question: q, options: opts, multi: !!multi, createdBy: u.id });
R.messages.setPoll(msgId, pollId);
audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'poll_created', detail: q });
const names = namesFor(u.team_id);
for (const mid of R.conversations.members(group)) {
try { const dto = buildMsgDTO(R.messages.byId(msgId), names, mid); dto.fromName = u.name || u.email; CHAT.pushToUser(mid, { type: 'chat-message', message: dto }); } catch (_) {}
}
json(res, 200, buildPollDTO(R.polls.byId(pollId), u.id));
});
// Vote on a poll option (toggle). Single-choice replaces the prior vote; multi toggles.
route('POST', '/api/polls/vote', async (req, res) => {
const u = currentUser(req);
if (!u) return json(res, 401, { error: 'unauthorized' });
const { pollId, optionIdx } = await readBody(req);
const p = pollId && R.polls.byId(pollId);
if (!p || p.team_id !== u.team_id) return json(res, 404, { error: 'poll not found' });
if (!R.conversations.isMember(p.conversation_id, u.id)) return json(res, 403, { error: 'not a member' });
if (p.closed) return json(res, 400, { error: 'poll is closed' });
let opts = []; try { opts = JSON.parse(p.options); } catch {}
const idx = Number(optionIdx);
if (!Number.isInteger(idx) || idx < 0 || idx >= opts.length) return json(res, 400, { error: 'invalid option' });
if (p.multi) {
if (R.pollVotes.hasVoted(p.id, u.id, idx)) R.pollVotes.remove(p.id, u.id, idx); else R.pollVotes.add(p.id, u.id, idx);
} else {
const had = R.pollVotes.hasVoted(p.id, u.id, idx);
R.pollVotes.clearUser(p.id, u.id);
if (!had) R.pollVotes.add(p.id, u.id, idx);
}
for (const mid of R.conversations.members(p.conversation_id)) {
try { CHAT.pushToUser(mid, { type: 'poll-update', poll: buildPollDTO(p, mid), messageId: p.message_id, conversationId: p.conversation_id }); } catch (_) {}
}
json(res, 200, buildPollDTO(p, u.id));
});
// Close a poll (creator only) — no more votes accepted.
route('POST', '/api/polls/close', async (req, res) => {
const u = currentUser(req);
if (!u) return json(res, 401, { error: 'unauthorized' });
const { pollId } = await readBody(req);
const p = pollId && R.polls.byId(pollId);
if (!p || p.team_id !== u.team_id) return json(res, 404, { error: 'poll not found' });
if (p.created_by !== u.id) return json(res, 403, { error: 'only the poll creator can close it' });
R.polls.close(p.id);
const fresh = R.polls.byId(p.id);
for (const mid of R.conversations.members(p.conversation_id)) {
try { CHAT.pushToUser(mid, { type: 'poll-update', poll: buildPollDTO(fresh, mid), messageId: p.message_id, conversationId: p.conversation_id }); } catch (_) {}
}
json(res, 200, buildPollDTO(fresh, u.id));
});
// Send a message (persists + live-pushes to the recipient and the sender's other tabs).
route('POST', '/api/messages', async (req, res) => {
const u = currentUser(req);
if (!u) return json(res, 401, { error: 'unauthorized' });
const { to, group, body, replyTo, attachmentId, mentions } = await readBody(req);
const text = String(body || '').trim();
if (!text && !attachmentId) return json(res, 400, { error: 'message or attachment required' });
if (text.length > MSG_MAX) return json(res, 400, { error: 'message too long' });
if (attachmentId) {
const a = R.attachments.byId(attachmentId);
if (!a || a.team_id !== u.team_id || a.uploader_id !== u.id) return json(res, 400, { error: 'invalid attachment' });
}
const id = A.id();
if (group) {
if (!R.conversations.isMember(group, u.id)) return json(res, 403, { error: 'not a member of this group' });
// Validate mentions: keep only the literal "everyone" and ids that are actual members.
let mlist = [];
if (Array.isArray(mentions)) {
const memberSet = new Set(R.conversations.members(group));
mlist = mentions.filter((x) => x === 'everyone' || memberSet.has(x));
mlist = [...new Set(mlist)];
}
R.messages.send({ id, teamId: u.team_id, senderId: u.id, recipientId: '', body: text, replyTo: replyTo || null, attachmentId: attachmentId || null, conversationId: group, mentions: mlist });
const dto = buildMsgDTO(R.messages.byId(id), namesFor(u.team_id), u.id);
dto.fromName = u.name || u.email;
const push = { type: 'chat-message', message: dto };
for (const mid of R.conversations.members(group)) { try { CHAT.pushToUser(mid, push); } catch (_) {} } // includes sender's other tabs
return json(res, 200, dto);
}
if (!to) return json(res, 400, { error: 'to or group required' });
if (!R.users.inTenant(to, u.team_id)) return json(res, 404, { error: 'no such contact' });
R.messages.send({ id, teamId: u.team_id, senderId: u.id, recipientId: to, body: text, replyTo: replyTo || null, attachmentId: attachmentId || null });
const dto = buildMsgDTO(R.messages.byId(id), namesFor(u.team_id), u.id);
const push = { type: 'chat-message', message: { ...dto, fromName: u.name || u.email } };
try { CHAT.pushToUser(to, push); } catch (_) {}
try { CHAT.pushToUser(u.id, push); } catch (_) {} // sync the sender's other devices
json(res, 200, dto);
});
route('POST', '/api/messages/read', async (req, res) => {
const u = currentUser(req);
if (!u) return json(res, 401, { error: 'unauthorized' });
const { with: other, group } = await readBody(req);
if (group) {
if (R.conversations.isMember(group, u.id)) {
R.conversations.markRead(group, u.id);
const evt = { type: 'group-read', group, by: u.id, byName: (u.name || u.email), at: now() };
for (const mid of R.conversations.members(group)) { if (mid !== u.id) { try { CHAT.pushToUser(mid, evt); } catch (_) {} } }
}
return json(res, 200, { ok: true });
}
if (!other) return json(res, 400, { error: 'with or group required' });
R.messages.markRead(u.team_id, u.id, other);
try { CHAT.pushToUser(other, { type: 'chat-read', by: u.id }); } catch (_) {}
json(res, 200, { ok: true });
});
// Toggle an emoji reaction on a message (live-pushed to the other party).
route('POST', '/api/messages/react', async (req, res) => {
const u = currentUser(req);
if (!u) return json(res, 401, { error: 'unauthorized' });
const { messageId, emoji } = await readBody(req);
if (!messageId || !emoji) return json(res, 400, { error: 'messageId and emoji required' });
const msg = R.messages.byId(messageId);
const participant = msg && msg.team_id === u.team_id && (
msg.conversation_id ? R.conversations.isMember(msg.conversation_id, u.id)
: (msg.sender_id === u.id || msg.recipient_id === u.id));
if (!participant) return json(res, 404, { error: 'no such message' });
const e = String(emoji).slice(0, 16);
const added = R.reactions.toggle(messageId, u.id, e);
const names = namesFor(u.team_id);
// Push the full, recomputed reaction set for this message (per-recipient perspective). Extra
// fields (by/emoji/added/owner/convId) let the message owner show a "reacted to you" notification.
const meta = { by: u.name || u.email, byId: u.id, emoji: e, added, owner: msg.sender_id, convId: msg.conversation_id || null };
if (msg.conversation_id) {
for (const mid of R.conversations.members(msg.conversation_id)) {
try { CHAT.pushToUser(mid, { type: 'chat-reaction', messageId, reactions: reactionsForMessage(messageId, mid, names), ...meta }); } catch (_) {}
}
} else {
const other = msg.sender_id === u.id ? msg.recipient_id : msg.sender_id;
try { CHAT.pushToUser(other, { type: 'chat-reaction', messageId, reactions: reactionsForMessage(messageId, other, names), ...meta }); } catch (_) {}
try { CHAT.pushToUser(u.id, { type: 'chat-reaction', messageId, reactions: reactionsForMessage(messageId, u.id, names), ...meta }); } catch (_) {}
}
json(res, 200, { ok: true, messageId, added, reactions: reactionsForMessage(messageId, u.id, names) });
});
// Upload a chat attachment (raw body; filename in X-Filename, mime in Content-Type).
// Returns the attachment id to attach to a subsequent /api/messages send.
route('POST', '/api/messages/upload', async (req, res) => {
const u = currentUser(req);
if (!u) return json(res, 401, { error: 'unauthorized' });
const name = decodeURIComponent(req.headers['x-filename'] || 'file').slice(0, 200);
const mime = (req.headers['content-type'] || 'application/octet-stream').split(';')[0].trim();
const chunks = []; let total = 0, aborted = false;
req.on('data', (c) => { total += c.length; if (total > MAX_FILE_BYTES) { aborted = true; req.destroy(); return; } chunks.push(c); });
req.on('end', () => {
if (aborted) return json(res, 413, { error: 'file too large (max 25 MB)' });
if (!total) return json(res, 400, { error: 'empty file' });
const id = A.id();
try { fs.writeFileSync(path.join(UPLOADS_DIR, id), Buffer.concat(chunks)); }
catch (e) { return json(res, 500, { error: 'could not store file' }); }
R.attachments.create({ id, teamId: u.team_id, uploaderId: u.id, name, mime, size: total });
json(res, 200, { id, name, mime, size: total });
});
req.on('error', () => { try { res.end(); } catch (e) {} });
});
// API versioning: alias every /api/* route under /api/v1/* — a frozen contract for
// native desktop/mobile clients. The web app keeps using the unversioned paths, and
// both share the same handlers. (/sso is a browser redirect, intentionally unversioned.)
for (const key of Object.keys(routes)) {
const m = key.match(/^(\S+) \/api\/(.+)$/);
if (m) routes[`${m[1]} /api/v1/${m[2]}`] = routes[key];
}
module.exports = routes;