30e354d58f
Thread: every group message I send now carries a receipt tick — sent (1 grey)
-> seen-by-some (2 grey) -> seen-by-all (2 yellow) — computed from the per-message
seenBy the server already returns and kept live by onGroupRead. Tap the tick to see
exactly who ('Seen by X, Y') with an 'N of M' tooltip. Replaces the old last-message-
only 'Seen by' line with a universal, tappable per-message receipt.
Sidebar: the group row tick now reflects real read state (read/delivered/sent) via
memberReads vs member count, instead of a hardcoded 'sent' — and refreshes live when
the open group is read by all.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1416 lines
85 KiB
JavaScript
1416 lines
85 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.deleted ? '' : m.body, created_at: m.created_at, read_at: m.read_at, delivered_at: m.delivered_at || null, reply_to: m.deleted ? null : (m.reply_to || null), mentions: parseMentions(m.mentions), evt: m.msg_type || null, deleted: !!m.deleted, system: m.sender_id === SYSTEM_SENDER || !!m.msg_type });
|
|
function namesFor(teamId){ const o = {}; for (const x of R.users.listByTenant(teamId)) o[x.id] = x.name || x.email; return o; }
|
|
// 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 bizId = bz.bizgazeUserId || null;
|
|
|
|
// Identity is keyed on the BizGaze person-id, NOT the typed identifier: signing in with a
|
|
// mobile number and with an email both return the same person-id, so both resolve to one
|
|
// Biz Connect account (#2 — no more duplicate contacts for the same person).
|
|
let existing = R.users.byBizgazeId(bizId);
|
|
// Legacy account created before the person-id was stored: fall back to the typed identifier,
|
|
// but only if it isn't already claimed by a different person, then stamp the id on below.
|
|
if (!existing) {
|
|
const byMail = R.users.byEmail(email);
|
|
if (byMail && (!byMail.bizgaze_user_id || byMail.bizgaze_user_id === bizId)) existing = byMail;
|
|
}
|
|
|
|
if (!existing) {
|
|
const team = R.teams.first() || R.teams.create('BizGaze');
|
|
const { hash, salt } = A.hashPassword(A.token());
|
|
const id = R.users.create({ tenantId: team.id, email, hash, salt, role, name: bz.name || null, mfaSecret: A.newMfaSecret() });
|
|
if (bizId) R.users.setBizgazeId(id, bizId);
|
|
if (bz.avatarUrl) R.users.setAvatar(id, bz.avatarUrl);
|
|
audit({ team_id: team.id, user_id: id, user_email: email, action: 'sso_user_created', detail: 'via BizGaze' });
|
|
return R.users.byId(id);
|
|
}
|
|
// Retroactive merge: if this same identifier already has its OWN legacy account (a separate
|
|
// row created before person-id keying — e.g. the person used email before and is now signing
|
|
// in with their mobile), fold that duplicate's history into the canonical account. BizGaze just
|
|
// proved this identifier belongs to this person, so the merge is safe.
|
|
if (bizId) {
|
|
const dup = R.users.byEmail(email);
|
|
if (dup && dup.id !== existing.id && (!dup.bizgaze_user_id || dup.bizgaze_user_id === bizId)) {
|
|
R.users.mergeInto(dup.id, existing.id);
|
|
audit({ team_id: existing.team_id, user_id: existing.id, user_email: email, action: 'account_merged', detail: 'folded duplicate ' + dup.id });
|
|
}
|
|
}
|
|
// BizGaze is the source of truth: keep the person-id + name + avatar + role in sync each login.
|
|
// Stamping the id links legacy rows so the person's other identifier converges here next time.
|
|
if (bizId && existing.bizgaze_user_id !== bizId) R.users.setBizgazeId(existing.id, bizId);
|
|
if (bz.name && bz.name !== existing.name) R.users.setName(existing.id, bz.name);
|
|
if (bz.avatarUrl && bz.avatarUrl !== existing.avatar_url) R.users.setAvatar(existing.id, bz.avatarUrl);
|
|
if (existing.role !== role) R.users.setRole(existing.id, role);
|
|
return R.users.byId(existing.id);
|
|
}
|
|
|
|
// Login: when BizGaze (BIZGAZE_LOGIN_URL) is configured it is the ONLY authority — the
|
|
// credentials are verified against BizGaze and the user is provisioned/synced locally
|
|
// (local passwords are not accepted). Without it (dev/tests) the local password is
|
|
// checked. Sets a session cookie.
|
|
route('POST', '/api/login', async (req, res) => {
|
|
const { email, password, remember } = await readBody(req);
|
|
if (!email || !password) return json(res, 400, { error: 'email and password required' });
|
|
const existing = R.users.byEmail(email);
|
|
if (existing && existing.active === 0) return json(res, 403, { error: 'This account has been deactivated' });
|
|
|
|
// Production: when BizGaze is the IdP, verify ONLY against BizGaze (no local-password
|
|
// fallback) so stale in-app accounts can't shadow a BizGaze login and everyone lands in
|
|
// the same tenant (admins then see all sessions). Local accounts stay usable for
|
|
// dev/testing via ALLOW_LOCAL_LOGIN=1.
|
|
const bizgazeOnly = BZ.isEnabled() && process.env.ALLOW_LOCAL_LOGIN !== '1';
|
|
let u = null, bzMsg = null;
|
|
if (bizgazeOnly) {
|
|
const bz = await BZ.validateLogin(email, password);
|
|
if (bz.error) return json(res, 503, { error: bz.error });
|
|
if (!bz.ok) return json(res, 401, { error: bz.message || 'Username or password do not match.' });
|
|
u = provisionFromBizgaze(email, bz);
|
|
if (u && u.active === 0) return json(res, 403, { error: 'This account has been deactivated' });
|
|
} else {
|
|
// Local/dev/tests, or ALLOW_LOCAL_LOGIN=1: verify the local password, then fall back
|
|
// to BizGaze if a local password isn't set/correct (so SSO users can still sign in).
|
|
u = (existing && A.verifyPassword(password, existing.pw_salt, existing.pw_hash)) ? existing : null;
|
|
if (!u) {
|
|
const bz = await BZ.validateLogin(email, password);
|
|
if (bz.ok) u = provisionFromBizgaze(email, bz);
|
|
else if (bz.error) return json(res, 503, { error: bz.error });
|
|
else bzMsg = bz.message || null; // BizGaze configured and rejected the credentials
|
|
}
|
|
if (!u) {
|
|
if (existing) return json(res, 401, { error: 'Incorrect password. Please try again.' });
|
|
if (bzMsg) return json(res, 401, { error: bzMsg });
|
|
return json(res, 404, { error: 'This email is not registered.' });
|
|
}
|
|
}
|
|
|
|
const tok = A.token();
|
|
const ttl = remember ? 1000 * 60 * 60 * 24 * 30 : SESSION_TTL; // 30 days if remembered, else 24h
|
|
R.authSessions.create({ token: tok, userId: u.id, mfaPassed: true, ttl });
|
|
res.setHeader('Set-Cookie', `sid=${tok}; HttpOnly; Path=/; Max-Age=${ttl / 1000}`);
|
|
audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'login' });
|
|
// Cookie for the web app; access token + refresh token in the body for native
|
|
// desktop/mobile clients (access via `Authorization: Bearer`, refresh via /api/v1/auth/refresh).
|
|
const refreshToken = issueRefreshToken(u.id);
|
|
json(res, 200, { ok: true, mfaRequired: false, token: tok, expiresAt: now() + ttl, refreshToken, refreshExpiresAt: now() + REFRESH_TTL });
|
|
});
|
|
|
|
// Exchange a refresh token for a fresh access token (with rotation). Native clients call
|
|
// this when their access token expires, so the user stays signed in without re-entering a password.
|
|
route('POST', '/api/auth/refresh', async (req, res) => {
|
|
const { refreshToken } = await readBody(req);
|
|
if (!refreshToken) return json(res, 400, { error: 'refreshToken required' });
|
|
const h = A.hashToken(refreshToken);
|
|
const row = R.refreshTokens.byHash(h);
|
|
if (!row || row.revoked || row.expires_at < now()) return json(res, 401, { error: 'invalid or expired refresh token' });
|
|
const u = R.users.byId(row.user_id);
|
|
if (!u || u.active === 0) return json(res, 401, { error: 'account unavailable' });
|
|
R.refreshTokens.revoke(h); // rotate: one-time use
|
|
const tok = A.token();
|
|
R.authSessions.create({ token: tok, userId: u.id, mfaPassed: true, ttl: SESSION_TTL });
|
|
const newRefresh = issueRefreshToken(u.id);
|
|
json(res, 200, { ok: true, token: tok, expiresAt: now() + SESSION_TTL, refreshToken: newRefresh, refreshExpiresAt: now() + REFRESH_TTL });
|
|
});
|
|
|
|
// Login step 2: TOTP code -> marks session mfa_passed
|
|
route('POST', '/api/login/mfa', async (req, res) => {
|
|
const { code } = await readBody(req);
|
|
const tok = parseCookies(req).sid;
|
|
const s = tok && R.authSessions.byToken(tok);
|
|
if (!s) return json(res, 401, { error: 'no session' });
|
|
const u = R.users.byId(s.user_id);
|
|
if (!A.verifyTotp(u.mfa_secret, code)) return json(res, 401, { error: 'invalid code' });
|
|
R.authSessions.markMfaPassed(tok);
|
|
audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'login' });
|
|
json(res, 200, { ok: true });
|
|
});
|
|
|
|
route('POST', '/api/logout', async (req, res) => {
|
|
const tok = tokenFromReq(req); // cookie (web) or Bearer (native)
|
|
if (tok) R.authSessions.deleteByToken(tok);
|
|
const { refreshToken } = await readBody(req);
|
|
if (refreshToken) R.refreshTokens.revoke(A.hashToken(refreshToken));
|
|
res.setHeader('Set-Cookie', 'sid=; HttpOnly; Path=/; Max-Age=0');
|
|
json(res, 200, { ok: true });
|
|
});
|
|
|
|
route('GET', '/api/setup-state', async (req, res) => {
|
|
const anyUser = R.users.anyExists();
|
|
json(res, 200, { registrationOpen: !anyUser || process.env.ALLOW_REGISTRATION === '1' });
|
|
});
|
|
|
|
// ICE servers for WebRTC. Always includes a public STUN; adds our TURN relay if
|
|
// configured. Two credential modes:
|
|
// - Shared secret (recommended, coturn `use-auth-secret`): set TURN_SECRET and we mint
|
|
// time-limited credentials per request (no permanent password is ever handed out, so
|
|
// outsiders can't reuse your relay). Optional TURN_TTL seconds (default 24h).
|
|
// - Static: set TURN_USERNAME + TURN_CREDENTIAL for a fixed long-term credential.
|
|
route('GET', '/api/ice', async (req, res) => {
|
|
const iceServers = [{ urls: 'stun:stun.l.google.com:19302' }];
|
|
if (process.env.TURN_URLS) {
|
|
const urls = process.env.TURN_URLS.split(',').map((u) => u.trim()).filter(Boolean);
|
|
let username = process.env.TURN_USERNAME || '';
|
|
let credential = process.env.TURN_CREDENTIAL || '';
|
|
if (process.env.TURN_SECRET) {
|
|
const ttl = parseInt(process.env.TURN_TTL || '86400', 10);
|
|
username = String(Math.floor(Date.now() / 1000) + ttl); // coturn expects "<expiry>"
|
|
credential = require('crypto').createHmac('sha1', process.env.TURN_SECRET).update(username).digest('base64');
|
|
}
|
|
iceServers.push({ urls, username, credential });
|
|
}
|
|
json(res, 200, { iceServers });
|
|
});
|
|
|
|
route('GET', '/api/me', async (req, res) => {
|
|
const u = currentUser(req);
|
|
if (!u) return json(res, 401, { error: 'unauthorized' });
|
|
json(res, 200, { id: u.id, email: u.email, role: u.role, teamId: u.team_id, name: u.name || null, avatarUrl: u.avatar_url || null, status: u.status || 'active' });
|
|
});
|
|
// Set my presence status: 'active' | 'away' | 'onleave' ('incall' is derived, not settable).
|
|
route('POST', '/api/me/status', async (req, res) => {
|
|
const u = currentUser(req);
|
|
if (!u) return json(res, 401, { error: 'unauthorized' });
|
|
const { status } = await readBody(req);
|
|
if (!['active', 'away', 'onleave'].includes(status)) return json(res, 400, { error: 'invalid status' });
|
|
try { R.users.setStatus(u.id, status); } catch (_) {}
|
|
try { CHAT.broadcastPresence(u.id); } catch (_) {} // push the new status to contacts live (no refresh)
|
|
json(res, 200, { ok: true, status });
|
|
});
|
|
|
|
// --- Web Push: background/closed-tab notifications (no-op unless VAPID is configured) ---
|
|
route('GET', '/api/push/vapid', async (req, res) => {
|
|
json(res, 200, { enabled: PUSH.isEnabled(), key: PUSH.publicKey() });
|
|
});
|
|
route('POST', '/api/push/subscribe', async (req, res) => {
|
|
const u = currentUser(req);
|
|
if (!u) return json(res, 401, { error: 'unauthorized' });
|
|
const sub = await readBody(req);
|
|
if (!sub || !sub.endpoint || !sub.keys || !sub.keys.p256dh || !sub.keys.auth) return json(res, 400, { error: 'invalid subscription' });
|
|
try { R.pushSubs.add({ id: A.id(), userId: u.id, endpoint: sub.endpoint, p256dh: sub.keys.p256dh, auth: sub.keys.auth }); } catch (_) {}
|
|
json(res, 200, { ok: true });
|
|
});
|
|
route('POST', '/api/push/unsubscribe', async (req, res) => {
|
|
const u = currentUser(req);
|
|
if (!u) return json(res, 401, { error: 'unauthorized' });
|
|
const { endpoint } = await readBody(req);
|
|
if (endpoint) { try { R.pushSubs.removeByEndpoint(endpoint); } catch (_) {} }
|
|
json(res, 200, { ok: true });
|
|
});
|
|
|
|
// --- Native device tokens (mobile app): FCM (Android) / APNs (iOS). Registration is always
|
|
// accepted and stored; delivery is a no-op until FCM/APNs creds are configured. ---
|
|
route('POST', '/api/devices', async (req, res) => {
|
|
const u = currentUser(req);
|
|
if (!u) return json(res, 401, { error: 'unauthorized' });
|
|
const { platform, token } = await readBody(req);
|
|
if (!token || typeof token !== 'string') return json(res, 400, { error: 'token required' });
|
|
if (platform !== 'ios' && platform !== 'android') return json(res, 400, { error: 'platform must be ios or android' });
|
|
try { R.deviceTokens.register({ id: A.id(), userId: u.id, tenantId: u.team_id, platform, token }); } catch (_) {}
|
|
json(res, 200, { ok: true });
|
|
});
|
|
route('POST', '/api/devices/remove', async (req, res) => {
|
|
const u = currentUser(req);
|
|
if (!u) return json(res, 401, { error: 'unauthorized' });
|
|
const { token } = await readBody(req);
|
|
if (token) { try { R.deviceTokens.removeByToken(token); } catch (_) {} }
|
|
json(res, 200, { ok: true });
|
|
});
|
|
|
|
// --- App install telemetry: records each install and, once the user signs in, who's using it. ---
|
|
route('POST', '/api/telemetry/install', async (req, res) => {
|
|
const { installId, platform, appVersion, os } = await readBody(req);
|
|
if (!installId || typeof installId !== 'string') return json(res, 400, { error: 'installId required' });
|
|
const u = currentUser(req); // may be null on a pre-login launch — still counted
|
|
try {
|
|
R.appInstalls.record({ id: A.id(), installId: installId.slice(0, 64), userId: u && u.id, userEmail: u && u.email, tenantId: u && u.team_id, platform: (platform || '').slice(0, 20), appVersion: (appVersion || '').slice(0, 20), os: (os || '').slice(0, 60) });
|
|
} catch (_) {}
|
|
json(res, 200, { ok: true });
|
|
});
|
|
// Admin: who installed the app (this tenant).
|
|
route('GET', '/api/admin/installs', async (req, res) => {
|
|
const u = currentUser(req);
|
|
if (!u) return json(res, 401, { error: 'unauthorized' });
|
|
if (u.role !== 'admin') return json(res, 403, { error: 'admin only' });
|
|
json(res, 200, R.appInstalls.listForTenant(u.team_id));
|
|
});
|
|
|
|
// ---------- BizGaze SSO: agent arrives already logged in ----------
|
|
route('GET', '/sso', async (req, res) => {
|
|
if (!process.env.SSO_SECRET) { res.writeHead(503); return res.end('SSO not configured'); }
|
|
const q = new URLSearchParams(req.url.split('?')[1] || '');
|
|
const token = q.get('token') || '';
|
|
const [payloadB64, sig] = token.split('.');
|
|
const fail = (msg) => { res.writeHead(403, { 'Content-Type': 'text/plain' }); res.end(msg); };
|
|
if (!payloadB64 || !sig) return fail('Invalid SSO token');
|
|
const crypto = require('crypto');
|
|
const expect = crypto.createHmac('sha256', process.env.SSO_SECRET).update(payloadB64).digest('base64url');
|
|
const sigBuf = Buffer.from(sig), expBuf = Buffer.from(expect);
|
|
if (sigBuf.length !== expBuf.length || !crypto.timingSafeEqual(sigBuf, expBuf)) return fail('Invalid SSO signature');
|
|
let p; try { p = JSON.parse(Buffer.from(payloadB64, 'base64url').toString()); } catch { return fail('Invalid SSO payload'); }
|
|
if (!p.email || !p.exp || p.exp < Math.floor(now() / 1000)) return fail('SSO token expired');
|
|
let u = R.users.byEmail(p.email);
|
|
if (!u) {
|
|
const team = R.teams.first();
|
|
if (!team) return fail('No team configured');
|
|
const { hash, salt } = A.hashPassword(A.token());
|
|
const role = (p.role === 'admin' || p.role === 'viewer') ? p.role : 'technician';
|
|
const userId = R.users.create({ tenantId: team.id, email: p.email, hash, salt, role, name: p.name || null, mfaSecret: A.newMfaSecret() });
|
|
u = R.users.byId(userId);
|
|
audit({ team_id: team.id, user_id: userId, user_email: p.email, action: 'sso_user_created', detail: p.name || '' });
|
|
} else if (p.name && p.name !== u.name) {
|
|
R.users.setName(u.id, p.name);
|
|
}
|
|
if (u.active === 0) return fail('Account deactivated');
|
|
const tok = A.token();
|
|
R.authSessions.create({ token: tok, userId: u.id, mfaPassed: true, ttl: SESSION_TTL });
|
|
audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'login', detail: 'via BizGaze SSO' });
|
|
const dest = '/connect' + (p.ticket ? ('?ticket=' + encodeURIComponent(p.ticket)) : '');
|
|
res.writeHead(302, { 'Set-Cookie': `sid=${tok}; HttpOnly; Path=/; Max-Age=${SESSION_TTL / 1000}`, Location: dest });
|
|
res.end();
|
|
});
|
|
|
|
// Admin adds an agent login to their team
|
|
route('POST', '/api/users', async (req, res) => {
|
|
const u = currentUser(req);
|
|
if (!u) return json(res, 401, { error: 'unauthorized' });
|
|
if (u.role !== 'admin') return json(res, 403, { error: 'only admins can add agents' });
|
|
// With BizGaze as the sole IdP, logins are created in BizGaze, not here (creating local
|
|
// accounts is what previously shadowed BizGaze and split tenants). Allowed in dev via
|
|
// ALLOW_LOCAL_LOGIN=1.
|
|
if (BZ.isEnabled() && process.env.ALLOW_LOCAL_LOGIN !== '1') return json(res, 400, { error: 'Logins are managed in BizGaze. Add the user there; they appear here on first sign-in.' });
|
|
const { email, password, name, role } = await readBody(req);
|
|
if (!email || !password) return json(res, 400, { error: 'email and temporary password required' });
|
|
if (R.users.emailExists(email))
|
|
return json(res, 409, { error: 'email already registered' });
|
|
const { hash, salt } = A.hashPassword(password);
|
|
const r = (role === 'admin' || role === 'viewer') ? role : 'technician';
|
|
const userId = R.users.create({ tenantId: u.team_id, email, hash, salt, role: r, name: name || null, mfaSecret: A.newMfaSecret() });
|
|
audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'agent_added', detail: email + ' (' + r + ')' });
|
|
json(res, 200, { ok: true, id: userId, email, role: r });
|
|
});
|
|
|
|
// List the team's agents
|
|
route('GET', '/api/users', async (req, res) => {
|
|
const u = currentUser(req);
|
|
if (!u) return json(res, 401, { error: 'unauthorized' });
|
|
const rows = R.users.listByTenant(u.team_id);
|
|
json(res, 200, rows);
|
|
});
|
|
|
|
// First-login MFA self-setup: a logged-in (password ok) user who hasn't enabled MFA yet
|
|
route('GET', '/api/mfa/setup', async (req, res) => {
|
|
const u = currentUser(req, { requireMfa: false });
|
|
if (!u) return json(res, 401, { error: 'unauthorized' });
|
|
if (u.mfa_enabled) return json(res, 400, { error: 'MFA already enabled' });
|
|
json(res, 200, { secret: u.mfa_secret, otpauthUrl: A.otpauthUrl(u.mfa_secret, u.email) });
|
|
});
|
|
|
|
// Admin manages an agent: reset password, rename, deactivate/activate, delete.
|
|
route('POST', '/api/users/manage', async (req, res) => {
|
|
const u = currentUser(req);
|
|
if (!u) return json(res, 401, { error: 'unauthorized' });
|
|
if (u.role !== 'admin') return json(res, 403, { error: 'only admins can manage agents' });
|
|
const { id, action, password, name } = await readBody(req);
|
|
const target = R.users.inTenant(id, u.team_id);
|
|
if (!target) return json(res, 404, { error: 'no such agent' });
|
|
switch (action) {
|
|
case 'reset-password': {
|
|
if (!password || String(password).length < 8) return json(res, 400, { error: 'new password must be at least 8 characters' });
|
|
const { hash, salt } = A.hashPassword(password);
|
|
R.users.setPassword(target.id, hash, salt);
|
|
R.authSessions.deleteByUser(target.id); // force re-login
|
|
R.refreshTokens.revokeByUser(target.id);
|
|
audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'agent_password_reset', detail: target.email });
|
|
return json(res, 200, { ok: true });
|
|
}
|
|
case 'rename': {
|
|
const clean = String(name || '').trim().slice(0, 60);
|
|
if (!clean) return json(res, 400, { error: 'name required' });
|
|
R.users.setName(target.id, clean);
|
|
audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'agent_renamed', detail: target.email + ' -> ' + clean });
|
|
return json(res, 200, { ok: true, name: clean });
|
|
}
|
|
case 'deactivate': {
|
|
if (target.id === u.id) return json(res, 400, { error: 'you cannot deactivate your own account' });
|
|
R.users.setActive(target.id, false);
|
|
R.authSessions.deleteByUser(target.id);
|
|
R.refreshTokens.revokeByUser(target.id);
|
|
audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'agent_deactivated', detail: target.email });
|
|
return json(res, 200, { ok: true });
|
|
}
|
|
case 'activate': {
|
|
R.users.setActive(target.id, true);
|
|
audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'agent_activated', detail: target.email });
|
|
return json(res, 200, { ok: true });
|
|
}
|
|
case 'delete': {
|
|
if (target.id === u.id) return json(res, 400, { error: 'you cannot delete your own account' });
|
|
R.authSessions.deleteByUser(target.id);
|
|
R.refreshTokens.revokeByUser(target.id);
|
|
R.users.remove(target.id);
|
|
audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'agent_deleted', detail: target.email });
|
|
return json(res, 200, { ok: true });
|
|
}
|
|
default: return json(res, 400, { error: 'unknown action' });
|
|
}
|
|
});
|
|
|
|
// ---------- API keys (admin-managed, for third-party / system integrations) ----------
|
|
route('POST', '/api/keys', async (req, res) => {
|
|
const u = currentUser(req);
|
|
if (!u) return json(res, 401, { error: 'unauthorized' });
|
|
if (u.role !== 'admin') return json(res, 403, { error: 'only admins can manage API keys' });
|
|
const { name, scopes } = await readBody(req);
|
|
const sc = (Array.isArray(scopes) ? scopes : ['report:read']).filter((s) => API_KEY_SCOPES.includes(s));
|
|
if (!sc.length) return json(res, 400, { error: 'at least one valid scope required (' + API_KEY_SCOPES.join(', ') + ')' });
|
|
const key = 'bzc_' + A.token(24); // shown once, never stored in plaintext
|
|
const id = A.id();
|
|
R.apiKeys.create({ id, tenantId: u.team_id, name: name || null, keyHash: A.hashToken(key), scopes: sc.join(','), createdBy: u.id });
|
|
audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'api_key_created', detail: (name || id) + ' [' + sc.join(',') + ']' });
|
|
json(res, 200, { id, name: name || null, scopes: sc, key });
|
|
});
|
|
|
|
route('GET', '/api/keys', async (req, res) => {
|
|
const u = currentUser(req);
|
|
if (!u) return json(res, 401, { error: 'unauthorized' });
|
|
if (u.role !== 'admin') return json(res, 403, { error: 'only admins can manage API keys' });
|
|
json(res, 200, R.apiKeys.listByTenant(u.team_id));
|
|
});
|
|
|
|
route('POST', '/api/keys/revoke', async (req, res) => {
|
|
const u = currentUser(req);
|
|
if (!u) return json(res, 401, { error: 'unauthorized' });
|
|
if (u.role !== 'admin') return json(res, 403, { error: 'only admins can manage API keys' });
|
|
const { id } = await readBody(req);
|
|
if (!id) return json(res, 400, { error: 'id required' });
|
|
R.apiKeys.revoke(id, u.team_id);
|
|
audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'api_key_revoked', detail: id });
|
|
json(res, 200, { ok: true });
|
|
});
|
|
|
|
// ---------- Webhook subscriptions (admin-managed, outbound event delivery) ----------
|
|
route('POST', '/api/webhooks', async (req, res) => {
|
|
const u = currentUser(req);
|
|
if (!u) return json(res, 401, { error: 'unauthorized' });
|
|
if (u.role !== 'admin') return json(res, 403, { error: 'only admins can manage webhooks' });
|
|
const { url, events, secret } = await readBody(req);
|
|
if (!url || !/^https?:\/\//i.test(url)) return json(res, 400, { error: 'a valid http(s) url is required' });
|
|
let ev = Array.isArray(events) ? events.filter((e) => e === '*' || W.EVENTS.includes(e)) : W.EVENTS.slice();
|
|
if (!ev.length) ev = W.EVENTS.slice();
|
|
const sec = (secret && String(secret).length >= 8) ? String(secret) : A.token(24);
|
|
const id = A.id();
|
|
R.webhooks.create({ id, tenantId: u.team_id, url, secret: sec, events: ev.join(','), createdBy: u.id });
|
|
audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'webhook_created', detail: url + ' [' + ev.join(',') + ']' });
|
|
// Secret returned so the receiver can verify the X-BizGaze-Signature header.
|
|
json(res, 200, { id, url, events: ev, secret: sec });
|
|
});
|
|
|
|
route('GET', '/api/webhooks', async (req, res) => {
|
|
const u = currentUser(req);
|
|
if (!u) return json(res, 401, { error: 'unauthorized' });
|
|
if (u.role !== 'admin') return json(res, 403, { error: 'only admins can manage webhooks' });
|
|
json(res, 200, R.webhooks.listByTenant(u.team_id));
|
|
});
|
|
|
|
route('POST', '/api/webhooks/delete', async (req, res) => {
|
|
const u = currentUser(req);
|
|
if (!u) return json(res, 401, { error: 'unauthorized' });
|
|
if (u.role !== 'admin') return json(res, 403, { error: 'only admins can manage webhooks' });
|
|
const { id } = await readBody(req);
|
|
if (!id) return json(res, 400, { error: 'id required' });
|
|
R.webhooks.remove(id, u.team_id);
|
|
audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'webhook_deleted', detail: id });
|
|
json(res, 200, { ok: true });
|
|
});
|
|
|
|
// Available webhook event types (for integrators / an admin UI).
|
|
route('GET', '/api/webhooks/events', async (req, res) => {
|
|
const u = currentUser(req);
|
|
if (!u) return json(res, 401, { error: 'unauthorized' });
|
|
json(res, 200, { events: W.EVENTS });
|
|
});
|
|
|
|
// Session report — readable by a logged-in user OR an API key with `report:read`.
|
|
route('GET', '/api/report', async (req, res) => {
|
|
const q = new URLSearchParams(req.url.split('?')[1] || '');
|
|
let tenantId, agentEmail;
|
|
const u = currentUser(req);
|
|
if (u) {
|
|
// Admins see the whole team (and may filter by agent); everyone else only their own.
|
|
tenantId = u.team_id;
|
|
agentEmail = u.role !== 'admin' ? u.email : (q.get('agent') || null);
|
|
} else {
|
|
const key = apiKeyFromReq(req);
|
|
if (!keyHasScope(key, 'report:read')) return json(res, 401, { error: 'unauthorized' });
|
|
R.apiKeys.touch(key.id);
|
|
tenantId = key.teamId; // a key sees its whole tenant
|
|
agentEmail = q.get('agent') || null;
|
|
}
|
|
const from = q.get('from') ? new Date(q.get('from') + 'T00:00:00').getTime() : null;
|
|
const to = q.get('to') ? new Date(q.get('to') + 'T23:59:59').getTime() : null;
|
|
json(res, 200, R.sessionsLog.report({ tenantId, agentEmail, from, to }));
|
|
});
|
|
|
|
// List machines for the team (with live online status from signaling layer)
|
|
route('GET', '/api/machines', async (req, res) => {
|
|
const u = currentUser(req);
|
|
if (!u) return json(res, 401, { error: 'unauthorized' });
|
|
const rows = R.machines.listByTenant(u.team_id);
|
|
json(res, 200, rows.map((m) => ({ ...m, online: onlineAgents.has(m.id) })));
|
|
});
|
|
|
|
// Create a machine enrollment token (admin/technician). Agent uses it to come online.
|
|
route('POST', '/api/machines', async (req, res) => {
|
|
const u = currentUser(req);
|
|
if (!u) return json(res, 401, { error: 'unauthorized' });
|
|
if (u.role === 'viewer') return json(res, 403, { error: 'forbidden' });
|
|
const { name, unattended } = await readBody(req);
|
|
const enroll = A.token();
|
|
const mId = R.machines.create({ tenantId: u.team_id, name: name || 'Unnamed PC', enrollToken: enroll, unattended: !!unattended });
|
|
audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, machine_id: mId, machine_name: name, action: 'machine_enrolled' });
|
|
json(res, 200, { id: mId, enrollToken: enroll });
|
|
});
|
|
|
|
route('GET', '/api/audit', async (req, res) => {
|
|
const u = currentUser(req);
|
|
let tenantId;
|
|
if (u) tenantId = u.team_id;
|
|
else {
|
|
const key = apiKeyFromReq(req);
|
|
if (!keyHasScope(key, 'audit:read')) return json(res, 401, { error: 'unauthorized' });
|
|
R.apiKeys.touch(key.id);
|
|
tenantId = key.teamId;
|
|
}
|
|
json(res, 200, R.audit.listByTenant(tenantId));
|
|
});
|
|
|
|
// ---------- session recording: upload (agent) ----------
|
|
const MAX_REC_BYTES = 500 * 1024 * 1024; // 500 MB safety cap
|
|
route('POST', '/api/recording', async (req, res) => {
|
|
const u = currentUser(req);
|
|
if (!u) return json(res, 401, { error: 'unauthorized' });
|
|
const params = new URLSearchParams(req.url.split('?')[1] || '');
|
|
const sid = params.get('sessionId');
|
|
const ext = params.get('ext') === 'mp4' ? 'mp4' : 'webm'; // container chosen by the recorder
|
|
if (!sid) return json(res, 400, { error: 'sessionId required' });
|
|
const row = R.sessionsLog.byIdInTenant(sid, u.team_id);
|
|
if (!row) return json(res, 404, { error: 'no such session' });
|
|
const chunks = []; let total = 0, aborted = false;
|
|
req.on('data', (c) => { total += c.length; if (total > MAX_REC_BYTES) { aborted = true; req.destroy(); return; } chunks.push(c); });
|
|
req.on('end', () => {
|
|
if (aborted) return json(res, 413, { error: 'recording too large' });
|
|
const fname = sid + '.' + ext;
|
|
try {
|
|
fs.writeFileSync(path.join(REC_DIR, fname), Buffer.concat(chunks));
|
|
R.sessionsLog.setRecording(sid, fname);
|
|
audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'recording_saved', detail: 'session ' + sid });
|
|
json(res, 200, { ok: true });
|
|
} catch (e) { json(res, 500, { error: 'could not save recording' }); }
|
|
});
|
|
req.on('error', () => { try { res.end(); } catch (e) {} });
|
|
});
|
|
|
|
route('POST', '/api/transcript', async (req, res) => {
|
|
const u = currentUser(req);
|
|
if (!u) return json(res, 401, { error: 'unauthorized' });
|
|
const sid = new URLSearchParams(req.url.split('?')[1] || '').get('sessionId');
|
|
if (!sid) return json(res, 400, { error: 'sessionId required' });
|
|
const row = R.sessionsLog.byIdInTenant(sid, u.team_id);
|
|
if (!row) return json(res, 404, { error: 'no such session' });
|
|
const chunks = []; let total = 0, aborted = false;
|
|
req.on('data', (c) => { total += c.length; if (total > 5 * 1024 * 1024) { aborted = true; req.destroy(); return; } chunks.push(c); });
|
|
req.on('end', () => {
|
|
if (aborted) return json(res, 413, { error: 'transcript too large' });
|
|
const fname = sid + '.txt';
|
|
try {
|
|
fs.writeFileSync(path.join(TRANS_DIR, fname), Buffer.concat(chunks));
|
|
R.sessionsLog.setTranscript(sid, fname);
|
|
json(res, 200, { ok: true });
|
|
} catch (e) { json(res, 500, { error: 'could not save transcript' }); }
|
|
});
|
|
req.on('error', () => { try { res.end(); } catch (e) {} });
|
|
});
|
|
|
|
// ---------- Chat (persistent 1:1 messaging between team members) ----------
|
|
// Contacts = other active users in the tenant (the people you can message).
|
|
route('GET', '/api/messages/contacts', async (req, res) => {
|
|
const u = currentUser(req);
|
|
if (!u) return json(res, 401, { error: 'unauthorized' });
|
|
const rows = R.users.listByTenant(u.team_id).filter((x) => x.id !== u.id && x.active !== 0);
|
|
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 = {};
|
|
const statuses = {};
|
|
for (const x of R.users.listByTenant(u.team_id)) { names[x.id] = x.name || x.email; avatars[x.id] = x.avatar_url || null; statuses[x.id] = x.status || 'active'; }
|
|
const favs = new Set(R.favorites.forUser(u.id));
|
|
const inCall = new Set();
|
|
for (const [, peers] of meetingRooms) { for (const [, p] of peers) { if (p.ws && p.ws._meetingUserId) inCall.add(p.ws._meetingUserId); } }
|
|
// DMs
|
|
const byOther = new Map();
|
|
for (const m of R.messages.recentFor(u.team_id, u.id)) {
|
|
const 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, favorite: favs.has('dm:' + c.other), status: inCall.has(c.other) ? 'incall' : (statuses[c.other] || 'active'),
|
|
last_body: c.last.body || (c.last.attachment_id ? '📎 Attachment' : ''), last_at: c.last.created_at, last_from_me: c.last.sender_id === u.id, unread: c.unread,
|
|
last_status: c.last.sender_id === u.id ? (c.last.read_at ? 'read' : (c.last.delivered_at ? 'delivered' : 'sent')) : null, // tick for my last message
|
|
}; });
|
|
// Groups
|
|
const groupItems = R.conversations.listForUser(u.team_id, u.id).map((g) => {
|
|
const last = R.messages.lastInConversation(g.id);
|
|
const since = R.conversations.lastReadAt(g.id, u.id);
|
|
const members = R.conversations.members(g.id);
|
|
// Group read tick for MY last message: read = every other member has read it, delivered = some
|
|
// have, else sent. Same three states as DMs, so the sidebar renders them identically.
|
|
let gStatus = null;
|
|
if (last && last.sender_id === u.id) {
|
|
const others = members.filter((id) => id !== u.id).length;
|
|
const seen = R.conversations.memberReads(g.id).filter((r) => r.user_id !== u.id && r.last_read_at >= last.created_at).length;
|
|
gStatus = (others > 0 && seen >= others) ? 'read' : (seen > 0 ? 'delivered' : 'sent');
|
|
}
|
|
return {
|
|
kind: 'group', id: g.id, name: g.name || 'Group', members: members.length, avatar: g.avatar_id ? ('/files/' + g.avatar_id) : null, favorite: favs.has('group:' + g.id),
|
|
callActive: groupCalls.has(g.id), callRoom: (groupCalls.get(g.id) || {}).room || null,
|
|
last_body: last ? (last.body || (last.attachment_id ? '📎 Attachment' : '')) : '', last_at: last ? last.created_at : g.created_at,
|
|
last_from_me: last ? last.sender_id === u.id : false, unread: last ? R.messages.unreadInConversation(g.id, u.id, since) : 0,
|
|
last_status: gStatus,
|
|
};
|
|
});
|
|
json(res, 200, [...dmItems, ...groupItems].sort((a, b) => b.last_at - a.last_at));
|
|
});
|
|
|
|
// Full thread: a DM (?with=userId) or a group (?group=conversationId). Marks it read.
|
|
route('GET', '/api/messages/thread', async (req, res) => {
|
|
const u = currentUser(req);
|
|
if (!u) return json(res, 401, { error: 'unauthorized' });
|
|
const q = new URLSearchParams(req.url.split('?')[1] || '');
|
|
const peek = !!q.get('peek'); // prefetch only — do NOT mark the conversation read
|
|
const 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 (_) {}
|
|
if (to !== u.id) try { CHAT.pushToUser(u.id, push); } catch (_) {} // sync the sender's other devices (skip for self-notes)
|
|
// Background/closed-tab push to the recipient (opens the DM). Not for a note-to-self.
|
|
if (to !== u.id) 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, icon: u.avatar_url || undefined });
|
|
json(res, 200, dto);
|
|
});
|
|
|
|
// Delete one of YOUR OWN messages for everyone (clears content, keeps the row as a placeholder).
|
|
route('POST', '/api/messages/delete', async (req, res) => {
|
|
const u = currentUser(req);
|
|
if (!u) return json(res, 401, { error: 'unauthorized' });
|
|
const { id } = await readBody(req);
|
|
if (!id) return json(res, 400, { error: 'id required' });
|
|
const m = R.messages.byId(id);
|
|
if (!m || m.team_id !== u.team_id) return json(res, 404, { error: 'not found' });
|
|
if (m.sender_id !== u.id) return json(res, 403, { error: 'you can only delete your own messages' });
|
|
R.messages.markDeleted(id);
|
|
const evt = { type: 'chat-deleted', id, conversation_id: m.conversation_id || null };
|
|
if (m.conversation_id) { for (const mid of R.conversations.members(m.conversation_id)) { try { CHAT.pushToUser(mid, evt); } catch (_) {} } }
|
|
else { try { CHAT.pushToUser(m.recipient_id, evt); } catch (_) {} try { CHAT.pushToUser(u.id, evt); } catch (_) {} }
|
|
json(res, 200, { ok: true });
|
|
});
|
|
// Edit a message (sender only, text only) — updates the body + marks it edited, and pushes the
|
|
// change live to the other side / other tabs (mirrors the delete broadcast).
|
|
route('POST', '/api/messages/edit', async (req, res) => {
|
|
const u = currentUser(req);
|
|
if (!u) return json(res, 401, { error: 'unauthorized' });
|
|
const { id, body } = await readBody(req);
|
|
if (!id || typeof body !== 'string') return json(res, 400, { error: 'id and body required' });
|
|
const text = body.trim();
|
|
if (!text) return json(res, 400, { error: 'message cannot be empty' });
|
|
const m = R.messages.byId(id);
|
|
if (!m || m.team_id !== u.team_id) return json(res, 404, { error: 'not found' });
|
|
if (m.sender_id !== u.id) return json(res, 403, { error: 'you can only edit your own messages' });
|
|
if (m.deleted) return json(res, 400, { error: 'cannot edit a deleted message' });
|
|
R.messages.editBody(id, text);
|
|
const edited = R.messages.byId(id);
|
|
const evt = { type: 'chat-edited', id, body: text, edited_at: edited.edited_at, conversation_id: m.conversation_id || null };
|
|
if (m.conversation_id) { for (const mid of R.conversations.members(m.conversation_id)) { try { CHAT.pushToUser(mid, evt); } catch (_) {} } }
|
|
else { try { CHAT.pushToUser(m.recipient_id, evt); } catch (_) {} try { CHAT.pushToUser(u.id, evt); } catch (_) {} }
|
|
json(res, 200, { ok: true, edited_at: edited.edited_at });
|
|
});
|
|
// Favourite/unfavourite a conversation (per user). target = 'dm:<userId>' or 'group:<groupId>'.
|
|
route('POST', '/api/favorites', async (req, res) => {
|
|
const u = currentUser(req);
|
|
if (!u) return json(res, 401, { error: 'unauthorized' });
|
|
const { kind, id, on } = await readBody(req);
|
|
if (!kind || !id) return json(res, 400, { error: 'kind and id required' });
|
|
try { R.favorites.set(u.id, kind + ':' + id, !!on); } catch (_) {}
|
|
json(res, 200, { ok: true, favorite: !!on });
|
|
});
|
|
// Shared media & files in a conversation (group) or DM — for the "Shared" Media/Files view.
|
|
route('GET', '/api/messages/media', async (req, res) => {
|
|
const u = currentUser(req);
|
|
if (!u) return json(res, 401, { error: 'unauthorized' });
|
|
const q = new URLSearchParams(req.url.split('?')[1] || '');
|
|
const group = q.get('group'); const other = q.get('with');
|
|
let rows = [], linkRows = [];
|
|
if (group) { if (!R.conversations.isMember(group, u.id)) return json(res, 403, { error: 'not a member' }); rows = R.messages.attachmentsForConversation(u.team_id, group); linkRows = R.messages.linksForConversation(u.team_id, group); }
|
|
else if (other) { rows = R.messages.attachmentsForDm(u.team_id, u.id, other); linkRows = R.messages.linksForDm(u.team_id, u.id, other); }
|
|
else return json(res, 400, { error: 'group or with required' });
|
|
const urlRe = /(https?:\/\/[^\s<>"']+)/gi;
|
|
const links = []; for (const m of linkRows) { const mm = (m.body || '').match(urlRe); if (mm) for (const url of mm) links.push({ url, at: m.created_at }); }
|
|
const att = rows.map((r) => ({ id: r.id, name: r.name, mime: r.mime, size: r.size, isImage: /^image\//.test(r.mime || ''), isAudio: /^audio\//.test(r.mime || ''), isVideo: /^video\//.test(r.mime || ''), at: r.created_at }));
|
|
const isMedia = (a) => a.isImage || a.isAudio || a.isVideo; // images, audio & video → "Media"; everything else → "Docs"
|
|
json(res, 200, { media: att.filter(isMedia), docs: att.filter((a) => !isMedia(a)), links });
|
|
});
|
|
route('POST', '/api/messages/read', async (req, res) => {
|
|
const u = currentUser(req);
|
|
if (!u) return json(res, 401, { error: 'unauthorized' });
|
|
const { with: 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;
|