1272b81cee
Page-level Notifications can't fire when a tab is frozen/closed (and never on mobile), which is why recipients on another tab/app got nothing. Adds a notification-only service worker (sw.js, no caching) + Web Push: - push.js: optional web-push wrapper (no-op unless web-push installed AND VAPID_PUBLIC_KEY/VAPID_PRIVATE_KEY set -> app unaffected if unconfigured). - push_subscriptions table + R.pushSubs repo (upsert by endpoint, prune dead). - /api/push/vapid|subscribe|unsubscribe; DM + group message routes also send a Web Push to recipients. - Client registers /sw.js, subscribes when permission granted; hidden-tab popups are left to push to avoid double-notifying (pushActive flag); SW suppresses the OS popup when a tab is visible. Removes the old code that unregistered SWs. Requires (prod, once): npm install + VAPID_PUBLIC_KEY/VAPID_PRIVATE_KEY/VAPID_SUBJECT env. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1268 lines
75 KiB
JavaScript
1268 lines
75 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 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.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 });
|
|
});
|
|
|
|
// --- 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 });
|
|
});
|
|
|
|
// ---------- 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 };
|
|
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);
|
|
}
|
|
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
|
|
// Background/closed-tab push to the recipient (opens the DM with this sender).
|
|
PUSH.sendToUser(to, { 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 });
|
|
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;
|