2026-06-12 00:40:07 +05:30
// 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' );
2026-06-23 16:15:29 +05:30
const W = require ( './webhooks' );
const CHAT = require ( './chat' );
2026-06-23 21:58:49 +05:30
const PUSH = require ( './push' );
2026-06-23 16:15:29 +05:30
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 ;
}
2026-06-12 00:40:07 +05:30
const { now , json , readBody , parseCookies } = require ( './lib' );
2026-06-23 16:15:29 +05:30
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 ;
}
2026-06-12 00:40:07 +05:30
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).
2026-06-15 19:02:08 +05:30
// 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.
2026-06-23 16:15:29 +05:30
const ADMIN_EMAILS = ( process . env . ADMIN_EMAILS || '' ). split ( ',' ). map (( s ) => s . trim (). toLowerCase ()). filter ( Boolean );
2026-06-12 00:40:07 +05:30
function provisionFromBizgaze ( email , bz ) {
2026-06-23 16:15:29 +05:30
const role = ( bz . isAdmin || ADMIN_EMAILS . includes ( String ( email ). toLowerCase ())) ? 'admin' : 'technician' ;
2026-06-12 00:40:07 +05:30
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 () });
2026-06-23 16:15:29 +05:30
if ( bz . avatarUrl ) R . users . setAvatar ( id , bz . avatarUrl );
2026-06-12 00:40:07 +05:30
audit ({ team_id : team . id , user_id : id , user_email : email , action : 'sso_user_created' , detail : 'via BizGaze' });
return R . users . byId ( id );
}
2026-06-23 16:15:29 +05:30
// BizGaze is the source of truth: keep name + avatar + role in sync on each login.
2026-06-12 00:40:07 +05:30
if ( bz . name && bz . name !== existing . name ) R . users . setName ( existing . id , bz . name );
2026-06-23 16:15:29 +05:30
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 );
2026-06-12 00:40:07 +05:30
return R . users . byId ( existing . id );
}
2026-06-15 19:02:08 +05:30
// 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.
2026-06-12 00:40:07 +05:30
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' });
2026-06-23 16:15:29 +05:30
// 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
2026-06-23 16:27:59 +05:30
// the same tenant (admins then see all sessions). Local accounts stay usable for
// dev/testing via ALLOW_LOCAL_LOGIN=1.
2026-06-23 16:15:29 +05:30
const bizgazeOnly = BZ . isEnabled () && process . env . ALLOW_LOCAL_LOGIN !== '1' ;
let u = null , bzMsg = null ;
if ( bizgazeOnly ) {
2026-06-12 00:40:07 +05:30
const bz = await BZ . validateLogin ( email , password );
2026-06-23 16:15:29 +05:30
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 {
2026-06-23 16:27:59 +05:30
// 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).
2026-06-23 16:15:29 +05:30
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.' });
}
2026-06-12 00:40:07 +05:30
}
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' });
2026-06-23 16:15:29 +05:30
// 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 });
2026-06-12 00:40:07 +05:30
});
// 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 ) => {
2026-06-23 16:15:29 +05:30
const tok = tokenFromReq ( req ); // cookie (web) or Bearer (native)
2026-06-12 00:40:07 +05:30
if ( tok ) R . authSessions . deleteByToken ( tok );
2026-06-23 16:15:29 +05:30
const { refreshToken } = await readBody ( req );
if ( refreshToken ) R . refreshTokens . revoke ( A . hashToken ( refreshToken ));
2026-06-12 00:40:07 +05:30
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' });
});
2026-06-23 16:15:29 +05:30
// ICE servers for WebRTC. Always includes a public STUN; adds our TURN relay if
2026-06-16 14:36:05 +05:30
// 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.
2026-06-12 00:40:07 +05:30
route ( 'GET' , '/api/ice' , async ( req , res ) => {
const iceServers = [{ urls : 'stun:stun.l.google.com:19302' }];
if ( process . env . TURN_URLS ) {
2026-06-23 16:15:29 +05:30
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 );
2026-06-16 14:36:05 +05:30
username = String ( Math . floor ( Date . now () / 1000 ) + ttl ); // coturn expects "<expiry>"
2026-06-23 16:15:29 +05:30
credential = require ( 'crypto' ). createHmac ( 'sha1' , process . env . TURN_SECRET ). update ( username ). digest ( 'base64' );
}
iceServers . push ({ urls , username , credential });
2026-06-12 00:40:07 +05:30
}
json ( res , 200 , { iceServers });
});
route ( 'GET' , '/api/me' , async ( req , res ) => {
const u = currentUser ( req );
if ( ! u ) return json ( res , 401 , { error : 'unauthorized' });
2026-06-23 16:15:29 +05:30
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 });
2026-06-12 00:40:07 +05:30
});
2026-06-23 21:58:49 +05:30
// --- 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 });
});
2026-06-12 00:40:07 +05:30
// ---------- 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' });
2026-06-23 16:15:29 +05:30
// With BizGaze as the sole IdP, logins are created in BizGaze, not here (creating local
2026-06-23 16:27:59 +05:30
// accounts is what previously shadowed BizGaze and split tenants). Allowed in dev via
// ALLOW_LOCAL_LOGIN=1.
2026-06-23 16:15:29 +05:30
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.' });
2026-06-12 00:40:07 +05:30
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
2026-06-23 16:15:29 +05:30
R . refreshTokens . revokeByUser ( target . id );
2026-06-12 00:40:07 +05:30
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 );
2026-06-23 16:15:29 +05:30
R . refreshTokens . revokeByUser ( target . id );
2026-06-12 00:40:07 +05:30
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 );
2026-06-23 16:15:29 +05:30
R . refreshTokens . revokeByUser ( target . id );
2026-06-12 00:40:07 +05:30
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' });
}
});
2026-06-23 16:15:29 +05:30
// ---------- 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 ) => {
2026-06-12 00:40:07 +05:30
const u = currentUser ( req );
if ( ! u ) return json ( res , 401 , { error : 'unauthorized' });
2026-06-23 16:15:29 +05:30
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 ) => {
2026-06-12 00:40:07 +05:30
const q = new URLSearchParams ( req . url . split ( '?' )[ 1 ] || '' );
2026-06-23 16:15:29 +05:30
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 ;
}
2026-06-12 00:40:07 +05:30
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 ;
2026-06-23 16:15:29 +05:30
json ( res , 200 , R . sessionsLog . report ({ tenantId , agentEmail , from , to }));
2026-06-12 00:40:07 +05:30
});
// 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 );
2026-06-23 16:15:29 +05:30
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 ));
2026-06-12 00:40:07 +05:30
});
// ---------- 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 ) {} });
});
2026-06-23 16:15:29 +05:30
// ---------- 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 };
2026-06-23 21:58:49 +05:30
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 });
}
2026-06-23 16:15:29 +05:30
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
2026-06-23 21:58:49 +05:30
// 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 });
2026-06-23 16:15:29 +05:30
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 ) {} });
});
2026-06-12 00:40:07 +05:30
// 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 ;