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__' ;
2026-07-07 23:11:02 +05:30
const msgDTO = ( m ) => ({ id : m . id , from : m . sender_id , to : m . recipient_id , conversation_id : m . conversation_id || null , body : m . deleted ? '' : m . body , created_at : m . created_at , read_at : m . read_at , delivered_at : m . delivered_at || null , reply_to : m . deleted ? null : ( m . reply_to || null ), mentions : parseMentions ( m . mentions ), evt : m . msg_type || null , fwd_from : m . deleted ? null : ( m . fwd_from || null ), deleted : !! m . deleted , system : m . sender_id === SYSTEM_SENDER || !! m . msg_type });
2026-07-24 22:06:27 +05:30
async function namesFor ( teamId ){ const o = {}; for ( const x of await R . users . listByTenant ( teamId )) o [ x . id ] = x . name || x . email ; return o ; }
// Await-aware filter: keep items whose async predicate resolves truthy (these predicates hit the DB, and a
// plain .filter() can't await). Sequential so per-item DB order is deterministic.
async function asyncFilter ( arr , pred ){ const out = []; for ( const x of arr ) { if ( await pred ( x )) out . push ( x ); } return out ; }
2026-07-14 15:34:35 +05:30
// id -> profile photo, with a fallback across DUPLICATE rows for the same person.
//
// A person can end up with more than one row (signed in by email once and by mobile another time, before
// the bizgaze_user_id merge landed). Only one of those rows carries the DP. Groups happened to reference
// the row WITH the photo while a DM referenced the one without — so the same contact showed their picture
// in a group but fell back to initials in the 1:1. Key each row by its stable person identity (BizGaze
// person id, else email, else name) and let a photo-less row borrow its twin's photo.
2026-07-24 22:06:27 +05:30
async function avatarsFor ( teamId ) {
2026-07-24 21:26:10 +05:30
const users = await R . users . listByTenant ( teamId );
2026-07-14 16:06:28 +05:30
const em = ( x ) => ( x . email ? String ( x . email ). toLowerCase () : '' );
const nm = ( x ) => String ( x . name || '' ). trim (). toLowerCase ();
// Index every KNOWN photo under all three identities, then let a photo-less row match on ANY of them —
// a single composite key missed the common case where the twin rows have different emails.
const byBz = {}, byEmail = {}, byName = {};
for ( const x of users ) {
if ( ! x . avatar_url ) continue ;
if ( x . bizgaze_user_id && ! byBz [ x . bizgaze_user_id ]) byBz [ x . bizgaze_user_id ] = x . avatar_url ;
if ( em ( x ) && ! byEmail [ em ( x )]) byEmail [ em ( x )] = x . avatar_url ;
if ( nm ( x ) && ! byName [ nm ( x )]) byName [ nm ( x )] = x . avatar_url ;
}
2026-07-14 15:34:35 +05:30
const out = {};
2026-07-14 16:06:28 +05:30
for ( const x of users ) {
out [ x . id ] = x . avatar_url
|| ( x . bizgaze_user_id && byBz [ x . bizgaze_user_id ])
|| ( em ( x ) && byEmail [ em ( x )])
|| ( nm ( x ) && byName [ nm ( x )])
|| null ;
}
2026-07-14 15:34:35 +05:30
return out ;
}
2026-06-23 16:15:29 +05:30
// 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.
2026-07-24 22:06:27 +05:30
async function postSystemMessage ( conversationId , teamId , text ){
2026-06-23 16:15:29 +05:30
const id = A . id ();
2026-07-24 21:26:10 +05:30
await R . messages . send ({ id , teamId , senderId : SYSTEM_SENDER , recipientId : '' , body : text , conversationId });
2026-07-24 22:06:27 +05:30
const dto = await buildMsgDTO ( await R . messages . byId ( id ), {}, '' );
2026-07-24 21:26:10 +05:30
for ( const mid of await R . conversations . members ( conversationId )) { try { CHAT . pushToUser ( mid , { type : 'chat-message' , message : dto }); } catch ( _ ) {} }
2026-06-23 16:15:29 +05:30
return dto ;
}
// Tell clients a group's membership changed so they refresh the member count / sidebar immediately.
2026-07-24 22:06:27 +05:30
async function pushGroupUpdate ( group , alsoUsers ){
2026-06-23 16:15:29 +05:30
const seen = new Set ();
2026-07-24 21:26:10 +05:30
for ( const mid of await R . conversations . members ( group )) { seen . add ( mid ); try { CHAT . pushToUser ( mid , { type : 'group-update' , group }); } catch ( _ ) {} }
2026-06-23 16:15:29 +05:30
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).
2026-07-24 22:06:27 +05:30
async function reactionsForMessage ( messageId , userId , names ){
const rows = ( await R . reactions . forMessage ( messageId )). map (( r ) => ({ message_id : messageId , user_id : r . user_id , emoji : r . emoji }));
2026-06-23 16:15:29 +05:30
return dtoReactions ( groupReactions ( rows , userId , names ), messageId );
}
// Poll tally for a given viewer ("mine" = this user voted that option).
2026-07-24 22:06:27 +05:30
async function buildPollDTO ( poll , userId ){
2026-06-23 16:15:29 +05:30
let opts = []; try { opts = JSON . parse ( poll . options ); } catch { opts = []; }
const counts = opts . map (() => 0 ); const mine = opts . map (() => false ); const voters = new Set ();
2026-07-24 21:26:10 +05:30
for ( const v of await R . pollVotes . forPoll ( poll . id )) {
2026-06-23 16:15:29 +05:30
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).
2026-07-24 22:06:27 +05:30
async function buildMsgDTO ( m , names , userId ){
2026-06-23 16:15:29 +05:30
const d = msgDTO ( m );
if ( m . reply_to ) {
2026-07-24 21:26:10 +05:30
const r = await R . messages . byId ( m . reply_to );
2026-07-07 16:02:15 +05:30
if ( r ) d . reply = { id : r . id , at : r . created_at , from : r . sender_id , fromName : ( names && names [ r . sender_id ]) || '' , body : r . body . length > 140 ? r . body . slice ( 0 , 140 ) + '…' : r . body };
2026-06-23 16:15:29 +05:30
}
if ( m . attachment_id ) {
2026-07-24 21:26:10 +05:30
const a = await R . attachments . byId ( m . attachment_id );
2026-07-22 21:48:09 +05:30
if ( a ) d . attachment = { id : a . id , name : a . name , mime : a . mime , size : a . size , isImage : /^image\// . test ( a . mime || '' ), isVideo : /^video\// . test ( a . mime || '' ), isAudio : /^audio\// . test ( a . mime || '' ) };
2026-06-23 16:15:29 +05:30
}
2026-07-24 22:06:27 +05:30
if ( m . poll_id ) { const p = await R . polls . byId ( m . poll_id ); if ( p ) d . poll = await buildPollDTO ( p , userId ); }
2026-06-23 16:15:29 +05:30
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
2026-07-29 17:54:21 +05:30
const { REC_DIR , TRANS_DIR , UPLOADS_DIR , SESSION_TTL , REFRESH_TTL , LIVEKIT_URL , LIVEKIT_API_KEY , LIVEKIT_API_SECRET , LIVEKIT_ENABLED , PUBLIC_BASE_URL , GIPHY_API_KEY , CALLKIT_ENABLED } = require ( './config' );
2026-07-15 15:30:44 +05:30
const https = require ( 'https' );
// Small GET-JSON helper for the GIPHY proxy (keeps the key server-side).
function fetchJSON ( url ) {
return new Promise (( resolve , reject ) => {
const req = https . get ( url , ( res ) => {
if ( res . statusCode !== 200 ) { res . resume (); return reject ( new Error ( 'upstream ' + res . statusCode )); }
let buf = '' ; res . on ( 'data' , ( c ) => { buf += c ; if ( buf . length > 4 * 1024 * 1024 ) { req . destroy (); reject ( new Error ( 'too large' )); } });
res . on ( 'end' , () => { try { resolve ( JSON . parse ( buf )); } catch ( e ) { reject ( e ); } });
});
req . on ( 'error' , reject );
req . setTimeout ( 8000 , () => { req . destroy (); reject ( new Error ( 'timeout' )); });
});
}
2026-07-10 16:02:45 +05:30
const mailer = require ( './mailer' );
// Basic email validation for external meeting invitees (#4).
const isEmail = ( s ) => typeof s === 'string' && /^[^\s@]+@[^\s@]+\.[^\s@]+$/ . test ( s . trim ());
2026-07-06 13:11:37 +05:30
const crypto = require ( 'crypto' );
2026-07-20 16:06:19 +05:30
const MAX_UPLOAD_MB = parseInt ( process . env . MAX_UPLOAD_MB , 10 ) || 1024 ; // default 1 GB per chat attachment
const MAX_FILE_BYTES = MAX_UPLOAD_MB * 1024 * 1024 ; // NOTE: also raise Nginx Proxy Manager's client_max_body_size to match (default is 1 MB) or large uploads are rejected at the proxy before reaching here.
2026-06-23 16:15:29 +05:30
2026-07-06 13:11:37 +05:30
// Mint a LiveKit access token (HS256 JWT signed with the API secret) — same hand-rolled JWT
// approach as push.js's FCM/APNs tokens, so no extra dependency. Grants the holder join+publish+
// subscribe on exactly one room, as one identity. Secret stays server-side.
const _b64u = ( buf ) => Buffer . from ( buf ). toString ( 'base64' ). replace ( /=/g , '' ). replace ( /\+/g , '-' ). replace ( /\//g , '_' );
function livekitToken ( identity , name , room , metadata ) {
const nowSec = Math . floor ( Date . now () / 1000 );
const header = _b64u ( JSON . stringify ({ alg : 'HS256' , typ : 'JWT' }));
const payload = _b64u ( JSON . stringify ({
iss : LIVEKIT_API_KEY , sub : identity , name : name || identity ,
nbf : nowSec , exp : nowSec + 6 * 3600 , // 6h — long enough for any meeting
metadata : metadata || '' ,
video : { room , roomJoin : true , canPublish : true , canSubscribe : true , canPublishData : true },
}));
const sig = _b64u ( crypto . createHmac ( 'sha256' , LIVEKIT_API_SECRET ). update ( header + '.' + payload ). digest ());
return header + '.' + payload + '.' + sig ;
}
2026-06-23 16:15:29 +05:30
// Issue a refresh token (native clients), store only its hash, return the plaintext once.
2026-07-24 22:06:27 +05:30
async function issueRefreshToken ( userId ) {
2026-06-23 16:15:29 +05:30
const rtok = A . token ( 32 );
2026-07-24 21:26:10 +05:30
await R . refreshTokens . create ({ userId , tokenHash : A . hashToken ( rtok ), ttl : REFRESH_TTL });
2026-06-23 16:15:29 +05:30
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 ) => {
2026-07-24 21:26:10 +05:30
const anyUser = await R . users . anyExists ();
2026-06-12 00:40:07 +05:30
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' });
2026-07-24 21:26:10 +05:30
if ( await R . users . emailExists ( email ))
2026-06-12 00:40:07 +05:30
return json ( res , 409 , { error : 'email already registered' });
const { hash , salt } = A . hashPassword ( password );
2026-07-24 21:26:10 +05:30
const team = await R . teams . create ( teamName || ` ${ email } 's team` );
const userId = await R . users . create ({ tenantId : team . id , email , hash , salt , role : 'admin' , name : null , mfaSecret : A . newMfaSecret () });
2026-06-12 00:40:07 +05:30
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 );
2026-07-24 21:26:10 +05:30
const u = await R . users . byEmail ( email );
2026-06-12 00:40:07 +05:30
if ( ! u ) return json ( res , 404 , { error : 'no such user' });
if ( ! A . verifyTotp ( u . mfa_secret , code )) return json ( res , 401 , { error : 'invalid code' });
2026-07-24 21:26:10 +05:30
await R . users . enableMfa ( u . id );
2026-06-12 00:40:07 +05:30
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-07-24 22:06:27 +05:30
async 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-07-02 12:35:29 +05:30
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).
2026-07-24 21:26:10 +05:30
let existing = await R . users . byBizgazeId ( bizId );
2026-07-02 12:35:29 +05:30
// 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 ) {
2026-07-24 21:26:10 +05:30
const byMail = await R . users . byEmail ( email );
2026-07-02 12:35:29 +05:30
if ( byMail && ( ! byMail . bizgaze_user_id || byMail . bizgaze_user_id === bizId )) existing = byMail ;
}
2026-06-12 00:40:07 +05:30
if ( ! existing ) {
2026-07-24 21:26:10 +05:30
const team = await R . teams . first () || await R . teams . create ( 'BizGaze' );
2026-06-12 00:40:07 +05:30
const { hash , salt } = A . hashPassword ( A . token ());
2026-07-24 21:26:10 +05:30
const id = await R . users . create ({ tenantId : team . id , email , hash , salt , role , name : bz . name || null , mfaSecret : A . newMfaSecret () });
if ( bizId ) await R . users . setBizgazeId ( id , bizId );
if ( bz . avatarUrl ) await 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' });
2026-07-24 21:26:10 +05:30
return await R . users . byId ( id );
2026-06-12 00:40:07 +05:30
}
2026-07-02 12:35:29 +05:30
// 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 ) {
2026-07-24 21:26:10 +05:30
const dup = await R . users . byEmail ( email );
2026-07-02 12:35:29 +05:30
if ( dup && dup . id !== existing . id && ( ! dup . bizgaze_user_id || dup . bizgaze_user_id === bizId )) {
2026-07-24 21:26:10 +05:30
await R . users . mergeInto ( dup . id , existing . id );
2026-07-02 12:35:29 +05:30
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.
2026-07-24 21:26:10 +05:30
if ( bizId && existing . bizgaze_user_id !== bizId ) await R . users . setBizgazeId ( existing . id , bizId );
if ( bz . name && bz . name !== existing . name ) await R . users . setName ( existing . id , bz . name );
if ( bz . avatarUrl && bz . avatarUrl !== existing . avatar_url ) await R . users . setAvatar ( existing . id , bz . avatarUrl );
if ( existing . role !== role ) await R . users . setRole ( existing . id , role );
return await R . users . byId ( existing . id );
2026-06-12 00:40:07 +05:30
}
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' });
2026-07-24 21:26:10 +05:30
const existing = await R . users . byEmail ( email );
2026-06-12 00:40:07 +05:30
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.' });
2026-07-24 22:06:27 +05:30
u = await provisionFromBizgaze ( email , bz );
2026-06-23 16:15:29 +05:30
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 );
2026-07-24 22:06:27 +05:30
if ( bz . ok ) u = await provisionFromBizgaze ( email , bz );
2026-06-23 16:15:29 +05:30
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
2026-07-24 21:26:10 +05:30
await R . authSessions . create ({ token : tok , userId : u . id , mfaPassed : true , ttl });
2026-06-12 00:40:07 +05:30
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).
2026-07-24 22:06:27 +05:30
const refreshToken = await issueRefreshToken ( u . id );
2026-06-23 16:15:29 +05:30
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 );
2026-07-24 21:26:10 +05:30
const row = await R . refreshTokens . byHash ( h );
2026-06-23 16:15:29 +05:30
if ( ! row || row . revoked || row . expires_at < now ()) return json ( res , 401 , { error : 'invalid or expired refresh token' });
2026-07-24 21:26:10 +05:30
const u = await R . users . byId ( row . user_id );
2026-06-23 16:15:29 +05:30
if ( ! u || u . active === 0 ) return json ( res , 401 , { error : 'account unavailable' });
2026-07-24 21:26:10 +05:30
await R . refreshTokens . revoke ( h ); // rotate: one-time use
2026-06-23 16:15:29 +05:30
const tok = A . token ();
2026-07-24 21:26:10 +05:30
await R . authSessions . create ({ token : tok , userId : u . id , mfaPassed : true , ttl : SESSION_TTL });
2026-07-24 22:06:27 +05:30
const newRefresh = await issueRefreshToken ( u . id );
2026-06-23 16:15:29 +05:30
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
});
2026-07-24 16:33:23 +05:30
// Mint a bearer token for the iOS Share Extension. The extension is a separate process that can't see the
// web app's HttpOnly `sid` cookie, so the logged-in web app calls this on boot and hands the token to the
// extension via the App Group. The extension then talks to the API directly (list chats, upload, send) —
// exactly like the native client, so no app-open is needed to share. Short-ish TTL, refreshed each boot.
route ( 'GET' , '/api/share/token' , async ( req , res ) => {
2026-07-24 21:26:10 +05:30
const u = await currentUser ( req );
2026-07-24 16:33:23 +05:30
if ( ! u ) return json ( res , 401 , { error : 'unauthorized' });
const tok = A . token ();
const ttl = 1000 * 60 * 60 * 24 * 30 ; // 30 days; the app re-mints on every launch anyway
2026-07-24 21:26:10 +05:30
await R . authSessions . create ({ token : tok , userId : u . id , mfaPassed : true , ttl });
2026-07-24 16:33:23 +05:30
json ( res , 200 , { token : tok , expiresAt : now () + 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 ;
2026-07-24 21:26:10 +05:30
const s = tok && await R . authSessions . byToken ( tok );
2026-06-12 00:40:07 +05:30
if ( ! s ) return json ( res , 401 , { error : 'no session' });
2026-07-24 21:26:10 +05:30
const u = await R . users . byId ( s . user_id );
2026-06-12 00:40:07 +05:30
if ( ! A . verifyTotp ( u . mfa_secret , code )) return json ( res , 401 , { error : 'invalid code' });
2026-07-24 21:26:10 +05:30
await R . authSessions . markMfaPassed ( tok );
2026-06-12 00:40:07 +05:30
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-07-24 21:26:10 +05:30
if ( tok ) await R . authSessions . deleteByToken ( tok );
2026-06-23 16:15:29 +05:30
const { refreshToken } = await readBody ( req );
2026-07-24 21:26:10 +05:30
if ( refreshToken ) await 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 ) => {
2026-07-24 21:26:10 +05:30
const anyUser = await R . users . anyExists ();
2026-06-12 00:40:07 +05:30
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 ) => {
2026-07-24 21:26:10 +05:30
const u = await currentUser ( req );
2026-06-12 00:40:07 +05:30
if ( ! u ) return json ( res , 401 , { error : 'unauthorized' });
2026-06-30 17:01:15 +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 , status : u . status || 'active' });
});
// Set my presence status: 'active' | 'away' | 'onleave' ('incall' is derived, not settable).
route ( 'POST' , '/api/me/status' , async ( req , res ) => {
2026-07-24 21:26:10 +05:30
const u = await currentUser ( req );
2026-06-30 17:01:15 +05:30
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' });
2026-07-24 21:26:10 +05:30
try { await R . users . setStatus ( u . id , status ); } catch ( _ ) {}
2026-07-02 15:43:02 +05:30
try { CHAT . broadcastPresence ( u . id ); } catch ( _ ) {} // push the new status to contacts live (no refresh)
2026-06-30 17:01:15 +05:30
json ( res , 200 , { ok : true , status });
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 ) => {
2026-07-24 21:26:10 +05:30
const u = await currentUser ( req );
2026-06-23 21:58:49 +05:30
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' });
2026-07-24 21:26:10 +05:30
try { await R . pushSubs . add ({ id : A . id (), userId : u . id , endpoint : sub . endpoint , p256dh : sub . keys . p256dh , auth : sub . keys . auth }); } catch ( _ ) {}
2026-06-23 21:58:49 +05:30
json ( res , 200 , { ok : true });
});
route ( 'POST' , '/api/push/unsubscribe' , async ( req , res ) => {
2026-07-24 21:26:10 +05:30
const u = await currentUser ( req );
2026-06-23 21:58:49 +05:30
if ( ! u ) return json ( res , 401 , { error : 'unauthorized' });
const { endpoint } = await readBody ( req );
2026-07-24 21:26:10 +05:30
if ( endpoint ) { try { await R . pushSubs . removeByEndpoint ( endpoint ); } catch ( _ ) {} }
2026-06-23 21:58:49 +05:30
json ( res , 200 , { ok : true });
});
2026-06-30 18:23:10 +05:30
// --- 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 ) => {
2026-07-24 21:26:10 +05:30
const u = await currentUser ( req );
2026-06-30 18:23:10 +05:30
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' });
2026-07-28 20:57:56 +05:30
// 'ios-voip' = a PushKit VoIP token (CallKit wake), stored alongside the normal alert token.
if ( platform !== 'ios' && platform !== 'android' && platform !== 'ios-voip' ) return json ( res , 400 , { error : 'platform must be ios, android or ios-voip' });
2026-07-24 21:26:10 +05:30
try { await R . deviceTokens . register ({ id : A . id (), userId : u . id , tenantId : u . team_id , platform , token }); } catch ( _ ) {}
2026-06-30 18:23:10 +05:30
json ( res , 200 , { ok : true });
});
route ( 'POST' , '/api/devices/remove' , async ( req , res ) => {
2026-07-24 21:26:10 +05:30
const u = await currentUser ( req );
2026-06-30 18:23:10 +05:30
if ( ! u ) return json ( res , 401 , { error : 'unauthorized' });
const { token } = await readBody ( req );
2026-07-24 21:26:10 +05:30
if ( token ) { try { await R . deviceTokens . removeByToken ( token ); } catch ( _ ) {} }
2026-06-30 18:23:10 +05:30
json ( res , 200 , { ok : true });
});
2026-07-27 21:20:03 +05:30
// --- Push diagnostics (temporary): the native app reports each step of push setup here so we can see
// WHERE iOS registration fails without a Mac/device console. Best-effort; logs and returns 200. ---
route ( 'POST' , '/api/push-debug' , async ( req , res ) => {
try {
const b = await readBody ( req );
let uid = 'anon' ; try { const u = await currentUser ( req ); if ( u ) uid = u . id ; } catch ( _ ) {}
const line = ( typeof b === 'object' ? JSON . stringify ( b ) : String ( b )). slice ( 0 , 800 );
console . log ( '[push-debug] user=' + uid + ' ' + line );
} catch ( _ ) {}
json ( res , 200 , { ok : true });
});
2026-07-01 21:28:53 +05:30
// --- 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' });
2026-07-24 21:26:10 +05:30
const u = await currentUser ( req ); // may be null on a pre-login launch — still counted
2026-07-01 21:28:53 +05:30
try {
2026-07-24 21:26:10 +05:30
await 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 ) });
2026-07-01 21:28:53 +05:30
} catch ( _ ) {}
json ( res , 200 , { ok : true });
});
// Admin: who installed the app (this tenant).
route ( 'GET' , '/api/admin/installs' , async ( req , res ) => {
2026-07-24 21:26:10 +05:30
const u = await currentUser ( req );
2026-07-01 21:28:53 +05:30
if ( ! u ) return json ( res , 401 , { error : 'unauthorized' });
if ( u . role !== 'admin' ) return json ( res , 403 , { error : 'admin only' });
2026-07-24 21:26:10 +05:30
json ( res , 200 , await R . appInstalls . listForTenant ( u . team_id ));
2026-07-01 21:28:53 +05:30
});
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' );
2026-07-24 21:26:10 +05:30
let u = await R . users . byEmail ( p . email );
2026-06-12 00:40:07 +05:30
if ( ! u ) {
2026-07-24 21:26:10 +05:30
const team = await R . teams . first ();
2026-06-12 00:40:07 +05:30
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' ;
2026-07-24 21:26:10 +05:30
const userId = await R . users . create ({ tenantId : team . id , email : p . email , hash , salt , role , name : p . name || null , mfaSecret : A . newMfaSecret () });
u = await R . users . byId ( userId );
2026-06-12 00:40:07 +05:30
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 ) {
2026-07-24 21:26:10 +05:30
await R . users . setName ( u . id , p . name );
2026-06-12 00:40:07 +05:30
}
if ( u . active === 0 ) return fail ( 'Account deactivated' );
const tok = A . token ();
2026-07-24 21:26:10 +05:30
await R . authSessions . create ({ token : tok , userId : u . id , mfaPassed : true , ttl : SESSION_TTL });
2026-06-12 00:40:07 +05:30
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 ) => {
2026-07-24 21:26:10 +05:30
const u = await currentUser ( req );
2026-06-12 00:40:07 +05:30
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' });
2026-07-24 21:26:10 +05:30
if ( await R . users . emailExists ( email ))
2026-06-12 00:40:07 +05:30
return json ( res , 409 , { error : 'email already registered' });
const { hash , salt } = A . hashPassword ( password );
const r = ( role === 'admin' || role === 'viewer' ) ? role : 'technician' ;
2026-07-24 21:26:10 +05:30
const userId = await R . users . create ({ tenantId : u . team_id , email , hash , salt , role : r , name : name || null , mfaSecret : A . newMfaSecret () });
2026-06-12 00:40:07 +05:30
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 ) => {
2026-07-24 21:26:10 +05:30
const u = await currentUser ( req );
2026-06-12 00:40:07 +05:30
if ( ! u ) return json ( res , 401 , { error : 'unauthorized' });
2026-07-24 21:26:10 +05:30
const rows = await R . users . listByTenant ( u . team_id );
2026-06-12 00:40:07 +05:30
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 ) => {
2026-07-24 21:26:10 +05:30
const u = await currentUser ( req , { requireMfa : false });
2026-06-12 00:40:07 +05:30
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 ) => {
2026-07-24 21:26:10 +05:30
const u = await currentUser ( req );
2026-06-12 00:40:07 +05:30
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 );
2026-07-24 21:26:10 +05:30
const target = await R . users . inTenant ( id , u . team_id );
2026-06-12 00:40:07 +05:30
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 );
2026-07-24 21:26:10 +05:30
await R . users . setPassword ( target . id , hash , salt );
await R . authSessions . deleteByUser ( target . id ); // force re-login
await 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' });
2026-07-24 21:26:10 +05:30
await R . users . setName ( target . id , clean );
2026-06-12 00:40:07 +05:30
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' });
2026-07-24 21:26:10 +05:30
await R . users . setActive ( target . id , false );
await R . authSessions . deleteByUser ( target . id );
await 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' : {
2026-07-24 21:26:10 +05:30
await R . users . setActive ( target . id , true );
2026-06-12 00:40:07 +05:30
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' });
2026-07-24 21:26:10 +05:30
await R . authSessions . deleteByUser ( target . id );
await R . refreshTokens . revokeByUser ( target . id );
await R . users . remove ( 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_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 ) => {
2026-07-24 21:26:10 +05:30
const u = await currentUser ( req );
2026-06-23 16:15:29 +05:30
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 ();
2026-07-24 21:26:10 +05:30
await R . apiKeys . create ({ id , tenantId : u . team_id , name : name || null , keyHash : A . hashToken ( key ), scopes : sc . join ( ',' ), createdBy : u . id });
2026-06-23 16:15:29 +05:30
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-07-24 21:26:10 +05:30
const u = await currentUser ( req );
2026-06-12 00:40:07 +05:30
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' });
2026-07-24 21:26:10 +05:30
json ( res , 200 , await R . apiKeys . listByTenant ( u . team_id ));
2026-06-23 16:15:29 +05:30
});
route ( 'POST' , '/api/keys/revoke' , async ( req , res ) => {
2026-07-24 21:26:10 +05:30
const u = await currentUser ( req );
2026-06-23 16:15:29 +05:30
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' });
2026-07-24 21:26:10 +05:30
await R . apiKeys . revoke ( id , u . team_id );
2026-06-23 16:15:29 +05:30
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 ) => {
2026-07-24 21:26:10 +05:30
const u = await currentUser ( req );
2026-06-23 16:15:29 +05:30
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 ();
2026-07-24 21:26:10 +05:30
await R . webhooks . create ({ id , tenantId : u . team_id , url , secret : sec , events : ev . join ( ',' ), createdBy : u . id });
2026-06-23 16:15:29 +05:30
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 ) => {
2026-07-24 21:26:10 +05:30
const u = await currentUser ( req );
2026-06-23 16:15:29 +05:30
if ( ! u ) return json ( res , 401 , { error : 'unauthorized' });
if ( u . role !== 'admin' ) return json ( res , 403 , { error : 'only admins can manage webhooks' });
2026-07-24 21:26:10 +05:30
json ( res , 200 , await R . webhooks . listByTenant ( u . team_id ));
2026-06-23 16:15:29 +05:30
});
route ( 'POST' , '/api/webhooks/delete' , async ( req , res ) => {
2026-07-24 21:26:10 +05:30
const u = await currentUser ( req );
2026-06-23 16:15:29 +05:30
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' });
2026-07-24 21:26:10 +05:30
await R . webhooks . remove ( id , u . team_id );
2026-06-23 16:15:29 +05:30
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 ) => {
2026-07-24 21:26:10 +05:30
const u = await currentUser ( req );
2026-06-23 16:15:29 +05:30
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 ;
2026-07-24 21:26:10 +05:30
const u = await currentUser ( req );
2026-06-23 16:15:29 +05:30
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 {
2026-07-24 21:26:10 +05:30
const key = await apiKeyFromReq ( req );
2026-06-23 16:15:29 +05:30
if ( ! keyHasScope ( key , 'report:read' )) return json ( res , 401 , { error : 'unauthorized' });
2026-07-24 21:26:10 +05:30
await R . apiKeys . touch ( key . id );
2026-06-23 16:15:29 +05:30
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-07-24 21:26:10 +05:30
json ( res , 200 , await 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 ) => {
2026-07-24 21:26:10 +05:30
const u = await currentUser ( req );
2026-06-12 00:40:07 +05:30
if ( ! u ) return json ( res , 401 , { error : 'unauthorized' });
2026-07-24 21:26:10 +05:30
const rows = await R . machines . listByTenant ( u . team_id );
2026-06-12 00:40:07 +05:30
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 ) => {
2026-07-24 21:26:10 +05:30
const u = await currentUser ( req );
2026-06-12 00:40:07 +05:30
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 ();
2026-07-24 21:26:10 +05:30
const mId = await R . machines . create ({ tenantId : u . team_id , name : name || 'Unnamed PC' , enrollToken : enroll , unattended : !! unattended });
2026-06-12 00:40:07 +05:30
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 ) => {
2026-07-24 21:26:10 +05:30
const u = await currentUser ( req );
2026-06-23 16:15:29 +05:30
let tenantId ;
if ( u ) tenantId = u . team_id ;
else {
2026-07-24 21:26:10 +05:30
const key = await apiKeyFromReq ( req );
2026-06-23 16:15:29 +05:30
if ( ! keyHasScope ( key , 'audit:read' )) return json ( res , 401 , { error : 'unauthorized' });
2026-07-24 21:26:10 +05:30
await R . apiKeys . touch ( key . id );
2026-06-23 16:15:29 +05:30
tenantId = key . teamId ;
}
2026-07-24 21:26:10 +05:30
json ( res , 200 , await 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 ) => {
2026-07-24 21:26:10 +05:30
const u = await currentUser ( req );
2026-06-12 00:40:07 +05:30
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' });
2026-07-24 21:26:10 +05:30
const row = await R . sessionsLog . byIdInTenant ( sid , u . team_id );
2026-06-12 00:40:07 +05:30
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 ); });
2026-07-24 22:06:27 +05:30
req . on ( 'end' , async () => {
2026-06-12 00:40:07 +05:30
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 ));
2026-07-24 21:26:10 +05:30
await R . sessionsLog . setRecording ( sid , fname );
2026-06-12 00:40:07 +05:30
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 ) => {
2026-07-24 21:26:10 +05:30
const u = await currentUser ( req );
2026-06-12 00:40:07 +05:30
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' });
2026-07-24 21:26:10 +05:30
const row = await R . sessionsLog . byIdInTenant ( sid , u . team_id );
2026-06-12 00:40:07 +05:30
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 ); });
2026-07-24 22:06:27 +05:30
req . on ( 'end' , async () => {
2026-06-12 00:40:07 +05:30
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 ));
2026-07-24 21:26:10 +05:30
await R . sessionsLog . setTranscript ( sid , fname );
2026-06-12 00:40:07 +05:30
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 ) => {
2026-07-24 21:26:10 +05:30
const u = await currentUser ( req );
2026-06-23 16:15:29 +05:30
if ( ! u ) return json ( res , 401 , { error : 'unauthorized' });
2026-07-24 22:06:27 +05:30
const rows = ( await R . users . listByTenant ( u . team_id )). filter (( x ) => x . id !== u . id && x . active !== 0 );
const cAv = await avatarsFor ( u . team_id ); // duplicate-row DP fallback
2026-07-14 23:46:52 +05:30
json ( res , 200 , rows . map (( x ) => ({ id : x . id , name : x . name || x . email , email : x . email , online : CHAT . isOnline ( x . id ), avatar : cAv [ x . id ] || null , lastSeen : x . last_seen || null , status : x . status || 'active' })));
2026-06-23 16:15:29 +05:30
});
// 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 ) => {
2026-07-24 21:26:10 +05:30
const u = await currentUser ( req );
2026-06-23 16:15:29 +05:30
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.
2026-07-24 22:06:27 +05:30
const mine = ( await R . users . listByTenant ( u . team_id )). filter (( x ) => x . id !== u . id && x . active !== 0 );
2026-06-23 16:15:29 +05:30
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 ) => {
2026-07-24 21:26:10 +05:30
const u = await currentUser ( req );
2026-06-23 16:15:29 +05:30
if ( ! u ) return json ( res , 401 , { error : 'unauthorized' });
const names = {};
const avatars = {};
2026-06-30 17:01:15 +05:30
const statuses = {};
2026-07-14 23:46:52 +05:30
const seen = {};
2026-07-24 21:26:10 +05:30
for ( const x of await R . users . listByTenant ( u . team_id )) { names [ x . id ] = x . name || x . email ; statuses [ x . id ] = x . status || 'active' ; seen [ x . id ] = x . last_seen || null ; }
2026-07-24 22:06:27 +05:30
Object . assign ( avatars , await avatarsFor ( u . team_id )); // same person / two rows → borrow the DP (see avatarsFor)
2026-07-24 21:26:10 +05:30
const favs = new Set ( await R . favorites . forUser ( u . id ));
2026-06-30 17:01:15 +05:30
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 ); } }
2026-06-23 16:15:29 +05:30
// DMs
const byOther = new Map ();
2026-07-24 21:26:10 +05:30
for ( const m of await R . messages . recentFor ( u . team_id , u . id )) {
2026-07-14 15:34:35 +05:30
const raw = m . sender_id === u . id ? m . recipient_id : m . sender_id ;
if ( ! raw ) continue ;
// If this counterparty was merged away, key the row by the SURVIVING account, so the thread carries
// that account's name/photo/presence (and two half-threads for one person collapse into one row).
2026-07-24 22:06:27 +05:30
let other ; try { other = await R . users . resolve ( raw ) || raw ; } catch ( _ ) { other = raw ; }
2026-06-23 16:15:29 +05:30
if ( ! byOther . has ( other )) byOther . set ( other , { other , last : m , unread : 0 });
2026-07-14 15:34:35 +05:30
if ( m . recipient_id === u . id && ( m . sender_id === raw || m . sender_id === other ) && ! m . read_at ) byOther . get ( other ). unread ++ ;
2026-06-23 16:15:29 +05:30
}
const dmItems = [... byOther . values ()]. map (( c ) => {
const dc = dmCalls . get ( CALLS . pairKey ( u . id , c . other ));
return {
2026-07-14 23:46:52 +05:30
kind : 'dm' , id : c . other , contactId : c . other , name : names [ c . other ] || 'Unknown' , online : CHAT . isOnline ( c . other ), avatar : avatars [ c . other ] || null , lastSeen : seen [ c . other ] || null ,
2026-06-30 17:01:15 +05:30
callActive : !! dc , callRoom : dc ? dc . room : null , favorite : favs . has ( 'dm:' + c . other ), status : inCall . has ( c . other ) ? 'incall' : ( statuses [ c . other ] || 'active' ),
2026-06-23 16:15:29 +05:30
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 ,
2026-07-02 11:59:53 +05:30
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
2026-06-23 16:15:29 +05:30
}; });
// Groups
2026-07-24 22:06:27 +05:30
const groupItems = await Promise . all (( await R . conversations . listForUser ( u . team_id , u . id )). map ( async ( g ) => {
2026-07-24 21:26:10 +05:30
const last = await R . messages . lastInConversation ( g . id );
const since = await R . conversations . lastReadAt ( g . id , u . id );
const members = await R . conversations . members ( g . id );
2026-07-06 10:57:08 +05:30
// 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 ;
2026-07-24 22:06:27 +05:30
const seenN = ( await R . conversations . memberReads ( g . id )). filter (( r ) => r . user_id !== u . id && r . last_read_at >= last . created_at ). length ;
gStatus = ( others > 0 && seenN >= others ) ? 'read' : ( seenN > 0 ? 'delivered' : 'sent' );
2026-07-06 10:57:08 +05:30
}
2026-06-23 16:15:29 +05:30
return {
2026-07-06 10:57:08 +05:30
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 ),
2026-06-23 16:15:29 +05:30
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 ,
2026-07-24 21:26:10 +05:30
last_from_me : last ? last . sender_id === u . id : false , unread : last ? await R . messages . unreadInConversation ( g . id , u . id , since ) : 0 ,
2026-07-06 10:57:08 +05:30
last_status : gStatus ,
2026-06-23 16:15:29 +05:30
};
2026-07-24 22:06:27 +05:30
}));
2026-06-23 16:15:29 +05:30
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 ) => {
2026-07-24 21:26:10 +05:30
const u = await currentUser ( req );
2026-06-23 16:15:29 +05:30
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
2026-07-07 15:21:14 +05:30
const before = parseInt ( q . get ( 'before' ) || '' , 10 ) || null ; // pagination cursor: fetch messages OLDER than this created_at
2026-07-24 22:06:27 +05:30
const names = await namesFor ( u . team_id );
2026-06-23 16:15:29 +05:30
const group = q . get ( 'group' );
if ( group ) {
2026-07-24 21:26:10 +05:30
if ( ! await R . conversations . isMember ( group , u . id )) return json ( res , 403 , { error : 'not a member of this group' });
const rows = await R . messages . threadByConversation ( group , 40 , before ); // page size (latest 40 / older via ?before) — matches client PAGE for smooth open + lazy load
2026-07-07 15:21:14 +05:30
if ( ! peek && ! before ) {
2026-07-24 21:26:10 +05:30
await R . conversations . markRead ( group , u . id );
2026-06-23 16:15:29 +05:30
const evt = { type : 'group-read' , group , by : u . id , byName : names [ u . id ] || u . email , at : now () };
2026-07-24 21:26:10 +05:30
for ( const mid of await R . conversations . members ( group )) { if ( mid !== u . id ) { try { CHAT . pushToUser ( mid , evt ); } catch ( _ ) {} } }
2026-07-07 16:20:18 +05:30
try { CHAT . pushToUser ( u . id , { type : 'notif-clear' , kind : 'group' , id : group }); } catch ( _ ) {} // #13
2026-06-23 16:15:29 +05:30
}
2026-07-24 21:26:10 +05:30
const rxBy = groupReactions ( await R . reactions . forConversation ( group ), u . id , names );
const reads = await R . conversations . memberReads ( group ); // ALL members' read times (#2: seen-by visible to everyone)
2026-07-24 22:06:27 +05:30
return json ( res , 200 , await Promise . all ( rows . map ( async ( m ) => {
const d = await buildMsgDTO ( m , names , u . id ); d . fromName = names [ m . sender_id ] || '' ; d . reactions = dtoReactions ( rxBy , m . id );
2026-07-08 15:58:09 +05:30
// Who has read this message (excluding its sender) — shown to every member, not just the sender.
d . seenBy = reads . filter (( r ) => r . user_id !== m . sender_id && r . last_read_at >= m . created_at ). map (( r ) => names [ r . user_id ] || 'Someone' );
2026-06-23 16:15:29 +05:30
return d ;
2026-07-24 22:06:27 +05:30
})));
2026-06-23 16:15:29 +05:30
}
2026-07-24 21:26:10 +05:30
const other = await R . users . resolve ( q . get ( 'with' )); // follow a merge redirect so a stale peer id still loads the thread
2026-06-23 16:15:29 +05:30
if ( ! other ) return json ( res , 400 , { error : 'with or group required' });
2026-07-24 21:26:10 +05:30
if ( ! await R . users . inTenant ( other , u . team_id )) return json ( res , 404 , { error : 'no such contact' });
const rows = await R . messages . thread ( u . team_id , u . id , other , 40 , before ); // page size (latest 40 / older via ?before) — matches client PAGE for smooth open + lazy load
if ( ! peek && ! before ) { await R . messages . markRead ( u . team_id , u . id , other ); try { CHAT . pushToUser ( other , { type : 'chat-read' , by : u . id }); } catch ( _ ) {} try { CHAT . pushToUser ( u . id , { type : 'notif-clear' , kind : 'dm' , id : other }); } catch ( _ ) {} } // #13
const rxBy = groupReactions ( await R . reactions . forPair ( u . team_id , u . id , other ), u . id , names );
2026-07-24 22:06:27 +05:30
return json ( res , 200 , await Promise . all ( rows . map ( async ( m ) => { const d = await buildMsgDTO ( m , names , u . id ); d . reactions = dtoReactions ( rxBy , m . id ); return d ; })));
2026-06-23 16:15:29 +05:30
});
2026-07-07 15:21:14 +05:30
// Search the ENTIRE thread (not just the loaded window). Returns matching message ids + timestamps,
// oldest-first, so the client can jump to any hit and lazy-load the window around it.
route ( 'GET' , '/api/messages/search' , async ( req , res ) => {
2026-07-24 21:26:10 +05:30
const u = await currentUser ( req );
2026-07-07 15:21:14 +05:30
if ( ! u ) return json ( res , 401 , { error : 'unauthorized' });
const q = new URLSearchParams ( req . url . split ( '?' )[ 1 ] || '' );
const term = String ( q . get ( 'q' ) || '' ). trim ();
if ( term . length < 1 ) return json ( res , 200 , { hits : [] });
const like = '%' + term . replace ( /[\\%_]/g , '\\$&' ) + '%' ; // escape LIKE wildcards
const group = q . get ( 'group' );
let rows ;
if ( group ) {
2026-07-24 21:26:10 +05:30
if ( ! await R . conversations . isMember ( group , u . id )) return json ( res , 403 , { error : 'not a member of this group' });
rows = await R . messages . searchConversation ( group , like );
2026-07-07 15:21:14 +05:30
} else {
2026-07-24 21:26:10 +05:30
const other = await R . users . resolve ( q . get ( 'with' ));
if ( ! other || ! await R . users . inTenant ( other , u . team_id )) return json ( res , 404 , { error : 'no such contact' });
rows = await R . messages . searchThread ( u . team_id , u . id , other , like );
2026-07-07 15:21:14 +05:30
}
return json ( res , 200 , { hits : rows . map (( m ) => ({ id : m . id , at : m . created_at })) });
});
2026-06-23 16:15:29 +05:30
// Create a group conversation with the given members (creator is always added).
route ( 'POST' , '/api/groups' , async ( req , res ) => {
2026-07-24 21:26:10 +05:30
const u = await currentUser ( req );
2026-06-23 16:15:29 +05:30
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' });
2026-07-24 22:06:27 +05:30
const ids = await asyncFilter (( Array . isArray ( memberIds ) ? memberIds : []), async ( x ) => typeof x === 'string' && x !== u . id && await R . users . inTenant ( x , u . team_id ));
2026-06-23 16:15:29 +05:30
const id = A . id ();
2026-07-24 21:26:10 +05:30
await R . conversations . create ({ id , teamId : u . team_id , name : nm , createdBy : u . id });
await R . conversations . addMember ( id , u . id , true ); // creator is the first admin
for ( const mid of ids ) await R . conversations . addMember ( id , mid );
2026-06-23 16:15:29 +05:30
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 ) => {
2026-07-24 21:26:10 +05:30
const u = await currentUser ( req );
2026-06-23 16:15:29 +05:30
if ( ! u ) return json ( res , 401 , { error : 'unauthorized' });
const gid = new URLSearchParams ( req . url . split ( '?' )[ 1 ] || '' ). get ( 'group' );
2026-07-24 21:26:10 +05:30
if ( ! gid || ! await R . conversations . isMember ( gid , u . id )) return json ( res , 403 , { error : 'not a member' });
2026-06-23 16:15:29 +05:30
const names = {}; const avatars = {};
2026-07-24 21:26:10 +05:30
for ( const x of await R . users . listByTenant ( u . team_id )) { names [ x . id ] = x . name || x . email ; }
2026-07-24 22:06:27 +05:30
Object . assign ( avatars , await avatarsFor ( u . team_id ));
2026-07-24 21:26:10 +05:30
const adminSet = new Set ( await R . conversations . admins ( gid ));
2026-07-24 22:06:27 +05:30
json ( res , 200 , ( await R . conversations . members ( gid )). map (( mid ) => ({ id : mid , name : names [ mid ] || 'Unknown' , avatar : avatars [ mid ] || null , admin : adminSet . has ( mid ) })));
2026-06-23 16:15:29 +05:30
});
// Full group info: name, creator flag, members (with isMe).
route ( 'GET' , '/api/groups/info' , async ( req , res ) => {
2026-07-24 21:26:10 +05:30
const u = await currentUser ( req );
2026-06-23 16:15:29 +05:30
if ( ! u ) return json ( res , 401 , { error : 'unauthorized' });
const gid = new URLSearchParams ( req . url . split ( '?' )[ 1 ] || '' ). get ( 'group' );
2026-07-24 21:26:10 +05:30
if ( ! gid || ! await R . conversations . isMember ( gid , u . id )) return json ( res , 403 , { error : 'not a member' });
const g = await R . conversations . byId ( gid );
const tenantUsers = await R . users . listByTenant ( u . team_id );
2026-06-23 16:15:29 +05:30
const names = {}; const avatars = {};
2026-07-14 15:34:35 +05:30
for ( const x of tenantUsers ) { names [ x . id ] = x . name || x . email ; }
2026-07-24 22:06:27 +05:30
Object . assign ( avatars , await avatarsFor ( u . team_id ));
2026-07-24 21:26:10 +05:30
const adminSet = new Set ( await R . conversations . admins ( gid ));
2026-06-23 16:15:29 +05:30
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 ,
2026-07-24 22:06:27 +05:30
members : ( await R . conversations . members ( gid )). map (( mid ) => ({ id : mid , name : names [ mid ] || 'Unknown' , avatar : avatars [ mid ] || null , isMe : mid === u . id , admin : adminSet . has ( mid ) })),
2026-06-23 16:15:29 +05:30
});
});
// Rename a group (any member).
route ( 'POST' , '/api/groups/rename' , async ( req , res ) => {
2026-07-24 21:26:10 +05:30
const u = await currentUser ( req );
2026-06-23 16:15:29 +05:30
if ( ! u ) return json ( res , 401 , { error : 'unauthorized' });
const { group , name } = await readBody ( req );
2026-07-24 21:26:10 +05:30
if ( ! group || ! await R . conversations . isMember ( group , u . id )) return json ( res , 403 , { error : 'not a member' });
2026-06-23 16:15:29 +05:30
const nm = String ( name || '' ). trim (). slice ( 0 , 80 );
if ( ! nm ) return json ( res , 400 , { error : 'group name required' });
2026-07-24 21:26:10 +05:30
await R . conversations . rename ( group , nm );
2026-07-24 22:06:27 +05:30
await postSystemMessage ( group , u . team_id , ( u . name || u . email ) + ' renamed the group to “' + nm + '”' );
2026-06-23 16:15:29 +05:30
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 ) => {
2026-07-24 21:26:10 +05:30
const u = await currentUser ( req );
2026-06-23 16:15:29 +05:30
if ( ! u ) return json ( res , 401 , { error : 'unauthorized' });
const { group } = await readBody ( req );
2026-07-24 21:26:10 +05:30
if ( ! group || ! await R . conversations . isMember ( group , u . id )) return json ( res , 403 , { error : 'not a member of this group' });
2026-07-24 22:06:27 +05:30
const r = await CALLS . startGroupCall ( group , u . team_id , u );
2026-06-23 16:15:29 +05:30
json ( res , 200 , r );
});
// Start (or join) a 1:1 call with another user.
route ( 'POST' , '/api/calls/dm/start' , async ( req , res ) => {
2026-07-24 21:26:10 +05:30
const u = await currentUser ( req );
2026-06-23 16:15:29 +05:30
if ( ! u ) return json ( res , 401 , { error : 'unauthorized' });
const { to } = await readBody ( req );
2026-07-24 21:26:10 +05:30
if ( ! to || ! await R . users . inTenant ( to , u . team_id )) return json ( res , 404 , { error : 'no such contact' });
2026-07-24 22:06:27 +05:30
json ( res , 200 , await CALLS . startDmCall ( u , to , u . team_id ));
2026-06-23 16:15:29 +05:30
});
// 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 ) => {
2026-07-24 21:26:10 +05:30
const u = await currentUser ( req );
2026-06-23 16:15:29 +05:30
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' });
2026-07-24 22:06:27 +05:30
const ids = await asyncFilter (( Array . isArray ( userIds ) ? userIds : []), async ( x ) => typeof x === 'string' && x !== u . id && await R . users . inTenant ( x , u . team_id ));
2026-06-23 16:15:29 +05:30
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 });
});
2026-07-06 13:11:37 +05:30
// Does this deployment use the LiveKit SFU for meeting media? The client asks on load; if sfu is
// false it uses the built-in P2P mesh. url is the browser-facing signaling endpoint (wss://…).
route ( 'GET' , '/api/meetings/config' , ( req , res ) => {
2026-07-29 17:54:21 +05:30
json ( res , 200 , { sfu : LIVEKIT_ENABLED , url : LIVEKIT_ENABLED ? LIVEKIT_URL : '' , callkit : CALLKIT_ENABLED });
2026-07-06 13:11:37 +05:30
});
2026-07-15 15:30:44 +05:30
// #5 GIF search — server-side GIPHY proxy so the API key never reaches the browser. Empty q → trending.
route ( 'GET' , '/api/gifs' , async ( req , res ) => {
2026-07-24 21:26:10 +05:30
const u = await currentUser ( req );
2026-07-15 15:30:44 +05:30
if ( ! u ) return json ( res , 401 , { error : 'unauthorized' });
if ( ! GIPHY_API_KEY ) return json ( res , 200 , { enabled : false , gifs : [] });
const p = new URLSearchParams ( req . url . split ( '?' )[ 1 ] || '' );
const q = ( p . get ( 'q' ) || '' ). trim ();
const offset = Math . max ( 0 , Math . min ( 200 , Number ( p . get ( 'offset' )) || 0 ));
const limit = 24 ;
const base = 'https://api.giphy.com/v1/gifs/' + ( q ? 'search' : 'trending' );
const url = base + '?api_key=' + encodeURIComponent ( GIPHY_API_KEY )
+ ( q ? ( '&q=' + encodeURIComponent ( q )) : '' )
+ '&limit=' + limit + '&offset=' + offset + '&rating=pg-13&bundle=messaging_non_clips' ;
try {
const data = await fetchJSON ( url );
const gifs = ( data && Array . isArray ( data . data ) ? data . data : []). map (( g ) => {
const im = g . images || {};
const full = ( im . downsized_medium || im . fixed_height || im . original || {});
const prev = ( im . fixed_width_small || im . fixed_height_small || im . preview_gif || full || {});
return { id : g . id , url : full . url || '' , preview : prev . url || full . url || '' , w : + full . width || 0 , h : + full . height || 0 , title : g . title || 'GIF' };
}). filter (( g ) => g . url );
json ( res , 200 , { enabled : true , gifs , offset : offset + limit });
} catch ( e ) { json ( res , 502 , { error : 'gif search failed' }); }
});
2026-07-14 13:38:55 +05:30
// The web build currently on the server (home.html's __BUILD marker). Long-running clients poll this and
// offer a Refresh when it changes. This matters because the desktop app now CLOSES TO TRAY — it can run
// for weeks without ever reloading the page, so it would silently keep serving stale code after a deploy.
let APP_BUILD = '' ;
try {
const h = fs . readFileSync ( path . join ( require ( './config' ). PUBLIC_DIR , 'home.html' ), 'utf8' );
const m = /__BUILD='([^']+)'/ . exec ( h );
if ( m ) APP_BUILD = m [ 1 ];
} catch ( _ ) {}
route ( 'GET' , '/api/build' , ( req , res ) => json ( res , 200 , { build : APP_BUILD }));
2026-07-06 13:11:37 +05:30
// Mint a LiveKit join token for the signed-in user + a specific room (the 6-digit meeting code).
// The room-membership/host authorization already happens over the meeting WebSocket; this only
// hands the client a media-plane credential scoped to that room and its own identity.
route ( 'POST' , '/api/meetings/token' , async ( req , res ) => {
2026-07-24 21:26:10 +05:30
const u = await currentUser ( req );
2026-07-06 13:11:37 +05:30
if ( ! u ) return json ( res , 401 , { error : 'unauthorized' });
if ( ! LIVEKIT_ENABLED ) return json ( res , 501 , { error : 'sfu not configured' });
const { room } = await readBody ( req );
const rm = String ( room || '' ). trim ();
if ( ! /^[A-Za-z0-9._-]{4,64}$/ . test ( rm )) return json ( res , 400 , { error : 'invalid room' });
const metadata = JSON . stringify ({ avatarUrl : u . avatar_url || '' });
const token = livekitToken ( u . id , u . name || u . email , rm , metadata );
json ( res , 200 , { token , url : LIVEKIT_URL , identity : u . id , name : u . name || u . email });
});
2026-07-10 15:44:28 +05:30
// GUEST (no-login) LiveKit token — lets an external person invited by link join a meeting without a
// Connect account. Only minted for a room that is currently LIVE or a valid (not-ended) scheduled
// meeting, so a token can't be created for an arbitrary/expired code. Identity is a throwaway guest id.
route ( 'POST' , '/api/meetings/guest-token' , async ( req , res ) => {
if ( ! LIVEKIT_ENABLED ) return json ( res , 501 , { error : 'sfu not configured' });
2026-07-11 14:28:37 +05:30
const { room , name , identity } = await readBody ( req );
2026-07-10 15:44:28 +05:30
const rm = String ( room || '' ). trim ();
if ( ! /^\d{6}$/ . test ( rm )) return json ( res , 400 , { error : 'invalid room' });
const live = (() => { try { return require ( './presence' ). meetingRooms . has ( rm ); } catch ( _ ) { return false ; } })();
2026-07-11 14:44:58 +05:30
// #3 Link expiry: a scheduled meeting's guest link is only valid until ~2h after its scheduled end —
// after that the link is dead (returns 404) even though the DB row lingers. Live rooms are valid while
// anyone's in them (they vanish from meetingRooms when empty), which is its own natural expiry.
2026-07-24 22:06:27 +05:30
const sched = await ( async () => {
2026-07-11 14:44:58 +05:30
try {
2026-07-24 21:26:10 +05:30
const s = await R . scheduledMeetings . byCode ( rm );
2026-07-11 14:44:58 +05:30
if ( ! s || s . ended_at ) return false ;
const endBy = s . scheduled_at + (( s . duration_mins || 60 ) * 60000 ) + ( 2 * 3600000 );
return Date . now () <= endBy ;
} catch ( _ ) { return false ; }
})();
if ( ! live && ! sched ) return json ( res , 410 , { error : 'This meeting link has expired or the meeting isn’ t active.' });
2026-07-11 14:28:37 +05:30
// Reuse the guest's client id as the LiveKit identity so it matches the id they announced over
// signaling (meeting-join guestId) — that mapping is how their media attaches to their tile.
const gid = ( typeof identity === 'string' && /^guest-[a-z0-9]+$/i . test ( identity )) ? identity . slice ( 0 , 64 ) : ( 'guest-' + crypto . randomBytes ( 8 ). toString ( 'hex' ));
2026-07-10 15:44:28 +05:30
const gname = String ( name || 'Guest' ). slice ( 0 , 60 );
const token = livekitToken ( gid , gname , rm , JSON . stringify ({ guest : true }));
json ( res , 200 , { token , url : LIVEKIT_URL , identity : gid , name : gname });
});
2026-06-23 16:15:29 +05:30
// 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 ) => {
2026-07-24 21:26:10 +05:30
const u = await currentUser ( req );
2026-06-23 16:15:29 +05:30
if ( ! u ) return json ( res , 401 , { error : 'unauthorized' });
const { room } = await readBody ( req );
if ( ! room ) return json ( res , 400 , { error : 'room required' });
2026-07-24 22:06:27 +05:30
json ( res , 200 , await CALLS . declineDmCall ( String ( room ), u ));
2026-06-23 16:15:29 +05:30
});
// Toggle "only admins can add/remove members" (any admin).
route ( 'POST' , '/api/groups/admin-only' , async ( req , res ) => {
2026-07-24 21:26:10 +05:30
const u = await currentUser ( req );
2026-06-23 16:15:29 +05:30
if ( ! u ) return json ( res , 401 , { error : 'unauthorized' });
const { group , value } = await readBody ( req );
2026-07-24 21:26:10 +05:30
const g = group && await R . conversations . byId ( group );
if ( ! g || g . team_id !== u . team_id || ! await R . conversations . isMember ( group , u . id )) return json ( res , 403 , { error : 'not a member' });
if ( ! await R . conversations . isAdmin ( group , u . id )) return json ( res , 403 , { error : 'only a group admin can change this' });
await R . conversations . setAdminOnly ( group , !! value );
2026-07-24 22:06:27 +05:30
await postSystemMessage ( group , u . team_id , ( u . name || u . email ) + ( value ? ' restricted adding members to admins only' : ' allowed everyone to add members' ));
2026-06-23 16:15:29 +05:30
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 ) => {
2026-07-24 21:26:10 +05:30
const u = await currentUser ( req );
2026-06-23 16:15:29 +05:30
if ( ! u ) return json ( res , 401 , { error : 'unauthorized' });
const { group , userId , value } = await readBody ( req );
2026-07-24 21:26:10 +05:30
const g = group && await R . conversations . byId ( group );
if ( ! g || g . team_id !== u . team_id || ! await R . conversations . isMember ( group , u . id )) return json ( res , 403 , { error : 'not a member' });
if ( ! await R . conversations . isAdmin ( group , u . id )) return json ( res , 403 , { error : 'only a group admin can change roles' });
if ( ! userId || ! await R . conversations . isMember ( group , userId )) return json ( res , 404 , { error : 'not a member of this group' });
2026-07-24 22:06:27 +05:30
if ( ! value && ( await R . conversations . admins ( group )). length <= 1 && await R . conversations . isAdmin ( group , userId )) return json ( res , 400 , { error : 'a group must have at least one admin' });
2026-07-24 21:26:10 +05:30
await R . conversations . setMemberAdmin ( group , userId , !! value );
2026-07-24 22:06:27 +05:30
const names = await namesFor ( u . team_id );
await postSystemMessage ( group , u . team_id , ( u . name || u . email ) + ( value ? ' made ' + ( names [ userId ] || 'someone' ) + ' an admin' : ' removed ' + ( names [ userId ] || 'someone' ) + ' as admin' ));
await pushGroupUpdate ( group );
2026-06-23 16:15:29 +05:30
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 ) => {
2026-07-24 21:26:10 +05:30
const u = await currentUser ( req );
2026-06-23 16:15:29 +05:30
if ( ! u ) return json ( res , 401 , { error : 'unauthorized' });
const { group , attachmentId } = await readBody ( req );
2026-07-24 21:26:10 +05:30
if ( ! group || ! await R . conversations . isMember ( group , u . id )) return json ( res , 403 , { error : 'not a member' });
2026-06-23 16:15:29 +05:30
if ( attachmentId ) {
2026-07-24 21:26:10 +05:30
const a = await R . attachments . byId ( attachmentId );
2026-06-23 16:15:29 +05:30
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' });
}
2026-07-24 21:26:10 +05:30
await R . conversations . setAvatar ( group , attachmentId || null );
2026-06-23 16:15:29 +05:30
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 ) => {
2026-07-24 21:26:10 +05:30
const u = await currentUser ( req );
2026-06-23 16:15:29 +05:30
if ( ! u ) return json ( res , 401 , { error : 'unauthorized' });
const { group , memberIds } = await readBody ( req );
2026-07-24 21:26:10 +05:30
if ( ! group || ! await R . conversations . isMember ( group , u . id )) return json ( res , 403 , { error : 'not a member' });
const gA = await R . conversations . byId ( group );
if ( gA && gA . admin_only && ! await R . conversations . isAdmin ( group , u . id )) return json ( res , 403 , { error : 'Only a group admin can add members' });
2026-07-24 22:06:27 +05:30
const ids = await asyncFilter (( Array . isArray ( memberIds ) ? memberIds : []), async ( x ) => typeof x === 'string' && await R . users . inTenant ( x , u . team_id ) && ! await R . conversations . isMember ( group , x ));
2026-07-24 21:26:10 +05:30
for ( const mid of ids ) await R . conversations . addMember ( group , mid );
2026-06-23 16:15:29 +05:30
if ( ids . length ) {
2026-07-24 22:06:27 +05:30
const names = await namesFor ( u . team_id );
await postSystemMessage ( group , u . team_id , ( u . name || u . email ) + ' added ' + ids . map (( x ) => names [ x ] || 'someone' ). join ( ', ' ));
2026-06-23 16:15:29 +05:30
}
2026-07-24 22:06:27 +05:30
if ( ids . length ) await pushGroupUpdate ( group ); // live member-count refresh for everyone (incl. the new members)
2026-06-23 16:15:29 +05:30
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 ) => {
2026-07-24 21:26:10 +05:30
const u = await currentUser ( req );
2026-06-23 16:15:29 +05:30
if ( ! u ) return json ( res , 401 , { error : 'unauthorized' });
const { group , userId , newAdmin } = await readBody ( req );
2026-07-24 21:26:10 +05:30
if ( ! group || ! await R . conversations . isMember ( group , u . id )) return json ( res , 403 , { error : 'not a member' });
2026-06-23 16:15:29 +05:30
const target = userId || u . id ;
const isSelf = target === u . id ;
// Leaving (self) is always allowed; removing others requires admin when admin_only is on.
2026-07-24 21:26:10 +05:30
if ( ! isSelf ) { const gR = await R . conversations . byId ( group ); if ( gR && gR . admin_only && ! await R . conversations . isAdmin ( group , u . id )) return json ( res , 403 , { error : 'Only a group admin can remove members' }); }
const wasAdmin = await R . conversations . isAdmin ( group , target );
2026-06-23 16:15:29 +05:30
// #10: the last admin must hand off to a chosen successor before leaving (no auto-assign).
2026-07-24 22:06:27 +05:30
const others = ( await R . conversations . members ( group )). filter (( m ) => m !== target );
if ( wasAdmin && others . length && ( await R . conversations . admins ( group )). filter (( a ) => a !== target ). length === 0 ) {
2026-07-24 21:26:10 +05:30
if ( ! newAdmin || ! await 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.' });
await R . conversations . setMemberAdmin ( group , newAdmin , true );
2026-07-24 22:06:27 +05:30
const names0 = await namesFor ( u . team_id );
await postSystemMessage ( group , u . team_id , ( names0 [ newAdmin ] || 'A member' ) + ' is now an admin' );
2026-06-23 16:15:29 +05:30
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.
2026-07-24 21:26:10 +05:30
if ( target !== u . id && await R . conversations . isMember ( group , target )) {
2026-07-24 22:06:27 +05:30
const names = await namesFor ( u . team_id );
await postSystemMessage ( group , u . team_id , ( u . name || u . email ) + ' removed ' + ( names [ target ] || 'someone' ));
2026-06-23 16:15:29 +05:30
} else if ( isSelf ) {
2026-07-24 22:06:27 +05:30
await postSystemMessage ( group , u . team_id , ( u . name || u . email ) + ' left the group' );
2026-06-23 16:15:29 +05:30
}
2026-07-24 21:26:10 +05:30
await R . conversations . removeMember ( group , target );
2026-07-24 22:06:27 +05:30
if (( await R . conversations . members ( group )). length === 0 ) { await R . conversations . remove ( group ); } // drop empty groups
else await pushGroupUpdate ( group , [ target ]); // live member-count refresh; the removed person drops the group
2026-06-23 16:15:29 +05:30
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 ) => {
2026-07-24 21:26:10 +05:30
const u = await currentUser ( req );
2026-06-23 16:15:29 +05:30
if ( ! u ) return json ( res , 401 , { error : 'unauthorized' });
2026-07-11 14:44:58 +05:30
const { group , title , description , scheduledAt , whenText , participants , participantEmails , durationMins , recurrence , lobby } = await readBody ( req );
2026-06-23 16:15:29 +05:30
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 ) {
2026-07-24 21:26:10 +05:30
if ( ! await R . conversations . isMember ( group , u . id )) return json ( res , 403 , { error : 'not a member of this group' });
2026-06-23 16:15:29 +05:30
groupId = group ;
}
const desc = String ( description || '' ). trim (). slice ( 0 , 1000 );
// Invited participants: tenant users, excluding the host (creator).
2026-07-24 22:06:27 +05:30
const invited = [... new Set ( await asyncFilter (( Array . isArray ( participants ) ? participants : []), async ( x ) => typeof x === 'string' && x !== u . id && await R . users . inTenant ( x , u . team_id )))];
2026-07-10 16:02:45 +05:30
// External invitees by email (#4): people not on Connect — they get an emailed guest link.
const guestEmails = [... new Set (( Array . isArray ( participantEmails ) ? participantEmails : []). map (( e ) => String ( e || '' ). trim (). toLowerCase ()). filter ( isEmail ))]. slice ( 0 , 100 );
2026-07-24 21:26:10 +05:30
let code ; do { code = A . numericCode ( 6 ); } while ( await R . scheduledMeetings . byCode ( code ) || meetingRooms . has ( code ));
2026-06-23 16:15:29 +05:30
const id = A . id ();
2026-07-24 21:26:10 +05:30
await R . scheduledMeetings . create ({ id , teamId : u . team_id , groupId , roomCode : code , title : t , description : desc , scheduledAt : when , createdBy : u . id , participants : invited , durationMins : dur , recurrence : recur , guestEmails , lobby : lobby !== false });
2026-06-23 16:15:29 +05:30
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 ();
2026-07-24 21:26:10 +05:30
await R . messages . send ({ id : mid , teamId : u . team_id , senderId : u . id , recipientId : '' , body : '📅 Scheduled a call: ' + t + ' — ' + label , conversationId : groupId });
2026-07-24 22:06:27 +05:30
const dto = await buildMsgDTO ( await R . messages . byId ( mid ), await namesFor ( u . team_id ), u . id ); dto . fromName = u . name || u . email ;
2026-07-24 21:26:10 +05:30
for ( const m of await R . conversations . members ( groupId )) { try { CHAT . pushToUser ( m , { type : 'chat-message' , message : dto }); } catch ( _ ) {} }
2026-06-23 16:15:29 +05:30
}
// 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 ( _ ) {} }
2026-07-10 16:02:45 +05:30
// Email invites (#4): the guest join link goes to external invitees, plus any invited Connect users
// who have an email on file. Fire-and-forget — a mail outage never fails scheduling. No-op if SMTP off.
try {
if ( mailer . isEnabled () && ( guestEmails . length || invited . length )) {
const link = PUBLIC_BASE_URL + '/home?meet=' + code ;
const nameByEmail = {}; const emails = new Set ( guestEmails );
2026-07-24 21:26:10 +05:30
for ( const x of await R . users . listByTenant ( u . team_id )) { if ( x . email ) nameByEmail [ x . id ] = x . email ; }
2026-07-10 16:02:45 +05:30
for ( const pid of invited ) { const em = nameByEmail [ pid ]; if ( em && isEmail ( em )) emails . add ( em . toLowerCase ()); }
if ( emails . size ) {
const tpl = mailer . meetingInviteEmail ({ title : t , when : label , link , host : u . name || u . email , description : desc });
mailer . send ({ to : [... emails ], subject : tpl . subject , html : tpl . html , text : tpl . text });
}
}
} catch ( e ) { console . warn ( '[meetings] invite email failed:' , e && e . message ); }
json ( res , 200 , { id , roomCode : code , title : t , description : desc , scheduledAt : when , groupId , participants : invited , guestEmails , link : PUBLIC_BASE_URL + '/home?meet=' + code });
2026-06-23 16:15:29 +05:30
});
// List the meetings this user can see, bucketed into running / upcoming / past.
route ( 'GET' , '/api/meetings' , async ( req , res ) => {
2026-07-24 21:26:10 +05:30
const u = await currentUser ( req );
2026-06-23 16:15:29 +05:30
if ( ! u ) return json ( res , 401 , { error : 'unauthorized' });
2026-07-24 22:06:27 +05:30
const names = await namesFor ( u . team_id );
2026-06-23 16:15:29 +05:30
const nowTs = Date . now ();
2026-07-24 22:06:27 +05:30
const rows = await Promise . all (( await R . scheduledMeetings . listForUser ( u . team_id , u . id )). map ( async ( s ) => {
2026-06-23 16:15:29 +05:30
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 );
2026-07-24 21:26:10 +05:30
if ( nxt !== schedAt ) { try { await R . scheduledMeetings . reschedule ( s . id , u . team_id , nxt ); } catch ( _ ) {} schedAt = nxt ; }
2026-06-23 16:15:29 +05:30
}
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 ( _ ) {}
2026-07-10 16:02:45 +05:30
let guestEmails = []; try { guestEmails = JSON . parse ( s . guest_emails || '[]' ); } catch ( _ ) {}
2026-06-23 16:15:29 +05:30
return {
id : s . id , roomCode : s . room_code , title : s . title , description : s . description || '' ,
2026-07-10 16:02:45 +05:30
scheduledAt : schedAt , groupId : s . group_id , link : PUBLIC_BASE_URL + '/home?meet=' + s . room_code ,
2026-07-24 21:26:10 +05:30
groupName : s . group_id ? (( await R . conversations . byId ( s . group_id ) || {}). name || 'Group' ) : null ,
2026-06-23 16:15:29 +05:30
createdBy : s . created_by , createdByName : names [ s . created_by ] || '' , canManage : s . created_by === u . id , isHost : s . created_by === u . id ,
2026-07-11 14:44:58 +05:30
invited : invited . map (( pid ) => names [ pid ] || 'Someone' ), invitedIds : invited , guestEmails , lobby : s . lobby !== 0 ,
2026-06-23 16:15:29 +05:30
durationMins : s . duration_mins || null , recurrence : recur , recurrenceLabel : recurrenceLabel ( recur ),
status , inCall : running ? live . size : 0 , recordings : [],
};
2026-07-24 22:06:27 +05:30
}));
2026-06-23 16:15:29 +05:30
// 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 });
2026-07-24 22:06:27 +05:30
const canSeeRec = async ( r ) => {
2026-06-23 16:15:29 +05:30
if ( r . kind === 'transcript' ) return r . created_by === u . id ; // transcripts are private to their owner
if ( r . created_by === u . id ) return true ;
2026-07-24 21:26:10 +05:30
if ( r . group_id ) return await R . conversations . isMember ( r . group_id , u . id );
if ( r . meeting_id ) { const s = await R . scheduledMeetings . byId ( r . meeting_id ); if ( s ) return s . created_by === u . id || ( s . participants && s . participants . includes ( '"' + u . id + '"' )); }
2026-06-23 16:15:29 +05:30
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 ();
2026-07-24 21:26:10 +05:30
for ( const r of await R . recordings . forTeam ( u . team_id )) {
2026-07-24 22:06:27 +05:30
if ( ! ( await canSeeRec ( r ))) continue ;
2026-06-23 16:15:29 +05:30
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 ); }
}
2026-07-24 22:06:27 +05:30
const synth = await Promise . all ([... unsched . values ()]. map ( async ( list ) => {
2026-06-23 16:15:29 +05:30
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 ,
2026-07-24 21:26:10 +05:30
groupName : f . group_id ? (( await R . conversations . byId ( f . group_id ) || {}). name || 'Group' ) : null ,
2026-06-23 16:15:29 +05:30
createdBy : f . created_by , createdByName : f . created_by_name || '' , canManage : false , isHost : false ,
invited : [], status : 'past' , inCall : 0 , recordings : list . map ( recDTO ),
};
2026-07-24 22:06:27 +05:30
}));
2026-07-15 00:42:53 +05:30
// #7: past CALLS from the call log. Rules the user asked for:
// • a plain 1:1 direct call is NOT listed (it's a call, not a meeting) — UNLESS it produced a
// recording/transcript, which the `synth` entries above already cover;
// • a call that ever held MORE THAN 2 people IS listed (e.g. a 1:1 that a third person joined).
2026-07-15 14:58:48 +05:30
// What belongs in Past meetings (the user's rules):
// • real SCHEDULED meetings → always;
// • INSTANT meetings (Start-a-meeting → meeting-create → logged in call_history as 'adhoc') → always;
// • ad-hoc CALLS — a 1:1 "Direct Call", a group call, or a 1:1 that a 3rd person joined — are NOT
// meetings, so they show ONLY if they produced a recording/transcript.
// calls.js writes a 'Direct Call'/'Group call' scheduled row per call (that's the 136 cards); those are
// the ad-hoc calls to gate. Instant meetings have no scheduled row and live only in call_history.
2026-07-15 13:19:12 +05:30
const CALL_TITLES = new Set ([ 'Direct Call' , 'Group call' ]);
const keptRows = rows . filter (( m ) => {
2026-07-15 14:58:48 +05:30
if ( ! CALL_TITLES . has ( m . title )) return true ; // a real, user-scheduled meeting
return !! ( m . recordings && m . recordings . length ); // ad-hoc call → only with a recording/transcript
2026-07-15 13:19:12 +05:30
});
2026-07-15 14:58:48 +05:30
// Instant meetings from the call log (deduped against anything already listed via a scheduled row/recording).
const usedRooms = new Set ([... schedByRoom . keys (), ...[... unsched . values ()]. map (( l ) => l [ 0 ]. room ). filter ( Boolean )]);
const instantRows = [];
2026-07-24 21:26:10 +05:30
for ( const c of await R . callHistory . forTeam ( u . team_id )) {
2026-07-15 14:58:48 +05:30
if ( c . room && usedRooms . has ( c . room )) continue ;
let uids = []; try { uids = JSON . parse ( c . uids || '[]' ); } catch ( _ ) {}
if ( ! uids . includes ( u . id )) continue ; // only meetings you were actually in
let parts = []; try { parts = JSON . parse ( c . participants || '[]' ); } catch ( _ ) {}
instantRows . push ({
id : 'call-' + c . id , roomCode : c . room || '' , title : c . title || 'Meeting' , description : '' ,
scheduledAt : c . started_at , endedAt : c . ended_at , groupId : null , groupName : null ,
createdBy : null , createdByName : '' , canManage : false , isHost : false , invited : [],
participantCount : c . peak , durationMins : Math . max ( 1 , Math . round (( c . ended_at - c . started_at ) / 60000 )),
status : 'past' , inCall : 0 , recordings : [],
});
}
2026-07-15 00:42:53 +05:30
// Date filter + pagination apply to PAST only (running/upcoming are small and always returned whole).
const q = new URLSearchParams ( req . url . split ( '?' )[ 1 ] || '' );
const from = Number ( q . get ( 'from' )) || 0 ;
const to = Number ( q . get ( 'to' )) || 0 ;
const page = Math . max ( 1 , Number ( q . get ( 'page' )) || 1 );
const pageSize = Math . min ( 50 , Math . max ( 5 , Number ( q . get ( 'pageSize' )) || 10 ));
2026-07-15 14:58:48 +05:30
const all = keptRows . concat ( synth , instantRows );
2026-07-15 00:42:53 +05:30
const live2 = all . filter (( m ) => m . status !== 'past' );
let past = all . filter (( m ) => m . status === 'past' );
if ( from ) past = past . filter (( m ) => m . scheduledAt >= from );
if ( to ) past = past . filter (( m ) => m . scheduledAt <= to );
past . sort (( a , b ) => b . scheduledAt - a . scheduledAt ); // newest first
const pastTotal = past . length ;
const start = ( page - 1 ) * pageSize ;
json ( res , 200 , { list : live2 . concat ( past . slice ( start , start + pageSize )), pastTotal , page , pageSize });
2026-06-23 16:15:29 +05:30
});
// Host uploads an in-browser meeting recording (webm). Stored + indexed so it shows under Past meetings.
2026-07-24 22:06:27 +05:30
route ( 'POST' , '/api/meetings/recording' , async ( req , res ) => {
2026-07-24 21:26:10 +05:30
const u = await currentUser ( req );
2026-06-23 16:15:29 +05:30
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 ); });
2026-07-24 22:06:27 +05:30
req . on ( 'end' , async () => {
2026-06-23 16:15:29 +05:30
if ( aborted ) return json ( res , 413 , { error : 'recording too large' });
if ( ! total ) return json ( res , 400 , { error : 'empty recording' });
const ctx = CALLS . meetingContext ( room );
2026-07-24 21:26:10 +05:30
const groupId = ctx . groupId || ( groupHint && await R . conversations . isMember ( groupHint , u . id ) ? groupHint : null );
let title = ctx . title ; if (( ! title || title === 'Meeting' ) && groupId ) { const g = await R . conversations . byId ( groupId ); if ( g ) title = g . name || 'Group' ; }
2026-06-23 16:15:29 +05:30
const id = A . id (); const file = 'm_' + id + '.webm' ;
try {
fs . writeFileSync ( path . join ( REC_DIR , file ), Buffer . concat ( chunks ));
2026-07-24 21:26:10 +05:30
await 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 });
2026-06-23 16:15:29 +05:30
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 ) => {
2026-07-24 21:26:10 +05:30
const u = await currentUser ( req );
2026-06-23 16:15:29 +05:30
if ( ! u ) return json ( res , 401 , { error : 'unauthorized' });
const { id , scope } = await readBody ( req );
2026-07-24 21:26:10 +05:30
const s = id && await R . scheduledMeetings . byId ( id );
2026-06-23 16:15:29 +05:30
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 ( _ ) {}
2026-07-24 21:26:10 +05:30
if ( s . group_id ) for ( const mid of await R . conversations . members ( s . group_id )) recips . add ( mid );
2026-06-23 16:15:29 +05:30
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 {
2026-07-24 21:26:10 +05:30
let sc ; do { sc = A . numericCode ( 6 ); } while ( await R . scheduledMeetings . byCode ( sc ));
2026-06-23 16:15:29 +05:30
let parts = []; try { parts = JSON . parse ( s . participants || '[]' ); } catch ( _ ) {}
const sid = A . id ();
2026-07-24 21:26:10 +05:30
await 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 : [] });
await R . scheduledMeetings . cancel ( sid , u . team_id );
2026-06-23 16:15:29 +05:30
} catch ( _ ) {}
// Roll the recurring series forward to its next occurrence.
const nxt = nextOccurrence ( occ , recur , occ );
2026-07-24 21:26:10 +05:30
if ( nxt !== occ ) await R . scheduledMeetings . reschedule ( id , u . team_id , nxt );
2026-06-23 16:15:29 +05:30
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 });
}
2026-07-24 21:26:10 +05:30
await R . scheduledMeetings . cancel ( id , u . team_id ); // keep it (marked cancelled), don't delete — #12
2026-06-23 16:15:29 +05:30
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 ) => {
2026-07-24 21:26:10 +05:30
const u = await currentUser ( req );
2026-06-23 16:15:29 +05:30
if ( ! u ) return json ( res , 401 , { error : 'unauthorized' });
2026-07-11 14:44:58 +05:30
const { id , title , description , scheduledAt , durationMins , participants , participantEmails , recurrence , lobby } = await readBody ( req );
2026-07-24 21:26:10 +05:30
const s = id && await R . scheduledMeetings . byId ( id );
2026-06-23 16:15:29 +05:30
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 ))] : [];
2026-07-24 22:06:27 +05:30
const invited = [... new Set ( await asyncFilter (( Array . isArray ( participants ) ? participants : []), async ( x ) => typeof x === 'string' && x !== u . id && await R . users . inTenant ( x , u . team_id )))];
2026-07-10 16:02:45 +05:30
const guestEmails = [... new Set (( Array . isArray ( participantEmails ) ? participantEmails : []). map (( e ) => String ( e || '' ). trim (). toLowerCase ()). filter ( isEmail ))]. slice ( 0 , 100 );
2026-07-24 21:26:10 +05:30
await R . scheduledMeetings . update ( id , u . team_id , { title : t , description : String ( description || '' ). trim (). slice ( 0 , 1000 ), scheduledAt : when , durationMins : dur , participants : invited , recurrence : recur , guestEmails , lobby : lobby !== false });
2026-06-23 16:15:29 +05:30
const label = new Date ( when ). toLocaleString ();
2026-07-10 16:02:45 +05:30
// Email the updated details to external invitees (new + existing) so their link/time stays current.
try {
if ( mailer . isEnabled () && guestEmails . length ) {
const link = PUBLIC_BASE_URL + '/home?meet=' + s . room_code ;
const tpl = mailer . meetingInviteEmail ({ title : t , when : label , link , host : u . name || u . email , description : String ( description || '' ). trim (). slice ( 0 , 1000 ) });
mailer . send ({ to : guestEmails , subject : tpl . subject , html : tpl . html , text : tpl . text });
}
} catch ( _ ) {}
2026-06-23 16:15:29 +05:30
const evt = { type : 'meeting-invite' , meeting : { id , title : t , scheduledAt : when , whenText : label , room : s . room_code , by : u . name || u . email , updated : true } };
2026-07-24 21:26:10 +05:30
const recips = new Set ( invited ); if ( s . group_id ) for ( const mid of await R . conversations . members ( s . group_id )) recips . add ( mid );
2026-06-23 16:15:29 +05:30
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 ) => {
2026-07-24 21:26:10 +05:30
const u = await currentUser ( req );
2026-06-23 16:15:29 +05:30
if ( ! u ) return json ( res , 401 , { error : 'unauthorized' });
const { group , question , options , multi } = await readBody ( req );
2026-07-24 21:26:10 +05:30
if ( ! group || ! await R . conversations . isMember ( group , u . id )) return json ( res , 403 , { error : 'not a member of this group' });
2026-06-23 16:15:29 +05:30
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 ();
2026-07-24 21:26:10 +05:30
await R . messages . send ({ id : msgId , teamId : u . team_id , senderId : u . id , recipientId : '' , body : q , conversationId : group });
await R . polls . create ({ id : pollId , teamId : u . team_id , conversationId : group , messageId : msgId , question : q , options : opts , multi : !! multi , createdBy : u . id });
await R . messages . setPoll ( msgId , pollId );
2026-06-23 16:15:29 +05:30
audit ({ team_id : u . team_id , user_id : u . id , user_email : u . email , action : 'poll_created' , detail : q });
2026-07-24 22:06:27 +05:30
const names = await namesFor ( u . team_id );
2026-07-24 21:26:10 +05:30
for ( const mid of await R . conversations . members ( group )) {
2026-07-24 22:06:27 +05:30
try { const dto = await buildMsgDTO ( await R . messages . byId ( msgId ), names , mid ); dto . fromName = u . name || u . email ; CHAT . pushToUser ( mid , { type : 'chat-message' , message : dto }); } catch ( _ ) {}
2026-06-23 16:15:29 +05:30
}
2026-07-24 22:06:27 +05:30
json ( res , 200 , await buildPollDTO ( await R . polls . byId ( pollId ), u . id ));
2026-06-23 16:15:29 +05:30
});
// Vote on a poll option (toggle). Single-choice replaces the prior vote; multi toggles.
route ( 'POST' , '/api/polls/vote' , async ( req , res ) => {
2026-07-24 21:26:10 +05:30
const u = await currentUser ( req );
2026-06-23 16:15:29 +05:30
if ( ! u ) return json ( res , 401 , { error : 'unauthorized' });
const { pollId , optionIdx } = await readBody ( req );
2026-07-24 21:26:10 +05:30
const p = pollId && await R . polls . byId ( pollId );
2026-06-23 16:15:29 +05:30
if ( ! p || p . team_id !== u . team_id ) return json ( res , 404 , { error : 'poll not found' });
2026-07-24 21:26:10 +05:30
if ( ! await R . conversations . isMember ( p . conversation_id , u . id )) return json ( res , 403 , { error : 'not a member' });
2026-06-23 16:15:29 +05:30
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 ) {
2026-07-24 21:26:10 +05:30
if ( await R . pollVotes . hasVoted ( p . id , u . id , idx )) await R . pollVotes . remove ( p . id , u . id , idx ); else await R . pollVotes . add ( p . id , u . id , idx );
2026-06-23 16:15:29 +05:30
} else {
2026-07-24 21:26:10 +05:30
const had = await R . pollVotes . hasVoted ( p . id , u . id , idx );
await R . pollVotes . clearUser ( p . id , u . id );
if ( ! had ) await R . pollVotes . add ( p . id , u . id , idx );
2026-06-23 16:15:29 +05:30
}
2026-07-24 21:26:10 +05:30
for ( const mid of await R . conversations . members ( p . conversation_id )) {
2026-07-24 22:06:27 +05:30
try { CHAT . pushToUser ( mid , { type : 'poll-update' , poll : await buildPollDTO ( p , mid ), messageId : p . message_id , conversationId : p . conversation_id }); } catch ( _ ) {}
2026-06-23 16:15:29 +05:30
}
2026-07-24 22:06:27 +05:30
json ( res , 200 , await buildPollDTO ( p , u . id ));
2026-06-23 16:15:29 +05:30
});
// Close a poll (creator only) — no more votes accepted.
route ( 'POST' , '/api/polls/close' , async ( req , res ) => {
2026-07-24 21:26:10 +05:30
const u = await currentUser ( req );
2026-06-23 16:15:29 +05:30
if ( ! u ) return json ( res , 401 , { error : 'unauthorized' });
const { pollId } = await readBody ( req );
2026-07-24 21:26:10 +05:30
const p = pollId && await R . polls . byId ( pollId );
2026-06-23 16:15:29 +05:30
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' });
2026-07-24 21:26:10 +05:30
await R . polls . close ( p . id );
const fresh = await R . polls . byId ( p . id );
for ( const mid of await R . conversations . members ( p . conversation_id )) {
2026-07-24 22:06:27 +05:30
try { CHAT . pushToUser ( mid , { type : 'poll-update' , poll : await buildPollDTO ( fresh , mid ), messageId : p . message_id , conversationId : p . conversation_id }); } catch ( _ ) {}
2026-06-23 16:15:29 +05:30
}
2026-07-24 22:06:27 +05:30
json ( res , 200 , await buildPollDTO ( fresh , u . id ));
2026-06-23 16:15:29 +05:30
});
// Send a message (persists + live-pushes to the recipient and the sender's other tabs).
route ( 'POST' , '/api/messages' , async ( req , res ) => {
2026-07-24 21:26:10 +05:30
const u = await currentUser ( req );
2026-06-23 16:15:29 +05:30
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 ) {
2026-07-24 21:26:10 +05:30
const a = await R . attachments . byId ( attachmentId );
2026-06-23 16:15:29 +05:30
if ( ! a || a . team_id !== u . team_id || a . uploader_id !== u . id ) return json ( res , 400 , { error : 'invalid attachment' });
}
2026-07-24 21:26:10 +05:30
try { await R . users . touchSeen ( u . id ); } catch ( _ ) {} // #2: keep "last seen" fresh on activity, not just on disconnect
2026-06-23 16:15:29 +05:30
const id = A . id ();
if ( group ) {
2026-07-24 21:26:10 +05:30
if ( ! await R . conversations . isMember ( group , u . id )) return json ( res , 403 , { error : 'not a member of this group' });
2026-06-23 16:15:29 +05:30
// Validate mentions: keep only the literal "everyone" and ids that are actual members.
let mlist = [];
if ( Array . isArray ( mentions )) {
2026-07-24 21:26:10 +05:30
const memberSet = new Set ( await R . conversations . members ( group ));
2026-06-23 16:15:29 +05:30
mlist = mentions . filter (( x ) => x === 'everyone' || memberSet . has ( x ));
mlist = [... new Set ( mlist )];
}
2026-07-24 21:26:10 +05:30
await R . messages . send ({ id , teamId : u . team_id , senderId : u . id , recipientId : '' , body : text , replyTo : replyTo || null , attachmentId : attachmentId || null , conversationId : group , mentions : mlist });
2026-07-24 22:06:27 +05:30
const dto = await buildMsgDTO ( await R . messages . byId ( id ), await namesFor ( u . team_id ), u . id );
2026-06-23 16:15:29 +05:30
dto . fromName = u . name || u . email ;
const push = { type : 'chat-message' , message : dto };
2026-07-24 21:26:10 +05:30
const conv = await R . conversations . byId ( group ); const gname = ( conv && conv . name ) || 'Group' ;
2026-06-23 21:58:49 +05:30
const pushBody = ( u . name || u . email ) + ': ' + ( text ? ( text . length > 80 ? text . slice ( 0 , 80 ) + '…' : text ) : '📎 Attachment' );
2026-07-24 21:26:10 +05:30
for ( const mid of await R . conversations . members ( group )) {
2026-06-23 21:58:49 +05:30
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 );
}
2026-07-07 13:23:49 +05:30
// Resolve a merged-away recipient id to the surviving account, so DMs to a merged contact don't
// save against a deleted user (which made them silently vanish).
2026-07-24 21:26:10 +05:30
const toId = await R . users . resolve ( to );
2026-07-07 13:23:49 +05:30
if ( ! toId ) return json ( res , 400 , { error : 'to or group required' });
2026-07-24 21:26:10 +05:30
if ( ! await R . users . inTenant ( toId , u . team_id )) return json ( res , 404 , { error : 'no such contact' });
await R . messages . send ({ id , teamId : u . team_id , senderId : u . id , recipientId : toId , body : text , replyTo : replyTo || null , attachmentId : attachmentId || null });
2026-07-24 22:06:27 +05:30
const dto = await buildMsgDTO ( await R . messages . byId ( id ), await namesFor ( u . team_id ), u . id );
2026-06-23 16:15:29 +05:30
const push = { type : 'chat-message' , message : { ... dto , fromName : u . name || u . email } };
2026-07-07 13:23:49 +05:30
try { CHAT . pushToUser ( toId , push ); } catch ( _ ) {}
if ( toId !== u . id ) try { CHAT . pushToUser ( u . id , push ); } catch ( _ ) {} // sync the sender's other devices (skip for self-notes)
2026-07-02 12:05:32 +05:30
// Background/closed-tab push to the recipient (opens the DM). Not for a note-to-self.
2026-07-07 13:23:49 +05:30
if ( toId !== u . id ) PUSH . sendToUser ( toId , { title : ( u . name || u . email ), body : ( text ? ( text . length > 80 ? text . slice ( 0 , 80 ) + '…' : text ) : '📎 Attachment' ), kind : 'dm' , id : u . id , tag : 'dm:' + u . id , icon : u . avatar_url || undefined });
2026-06-23 16:15:29 +05:30
json ( res , 200 , dto );
});
2026-07-07 16:09:02 +05:30
// Forward one or more of my visible messages to existing conversations (DMs I'm in / groups I'm a
// member of). Copies body + attachment (attachment stays viewable via the any-carrier /files auth).
route ( 'POST' , '/api/messages/forward' , async ( req , res ) => {
2026-07-24 21:26:10 +05:30
const u = await currentUser ( req );
2026-07-07 16:09:02 +05:30
if ( ! u ) return json ( res , 401 , { error : 'unauthorized' });
const { messageIds , targets } = await readBody ( req );
if ( ! Array . isArray ( messageIds ) || ! messageIds . length || ! Array . isArray ( targets ) || ! targets . length ) return json ( res , 400 , { error : 'messageIds and targets required' });
// Gather source messages the user is allowed to see, oldest-first (preserve order).
const srcs = [];
for ( const mid of messageIds . slice ( 0 , 30 )) {
2026-07-24 21:26:10 +05:30
const m = await R . messages . byId ( mid );
2026-07-07 16:09:02 +05:30
if ( ! m || m . team_id !== u . team_id || m . deleted || m . poll_id ) continue ;
2026-07-24 21:26:10 +05:30
const ok = m . conversation_id ? await R . conversations . isMember ( m . conversation_id , u . id ) : ( m . sender_id === u . id || m . recipient_id === u . id );
2026-07-07 16:09:02 +05:30
if ( ok && ( m . body || m . attachment_id )) srcs . push ( m );
}
if ( ! srcs . length ) return json ( res , 400 , { error : 'nothing to forward' });
srcs . sort (( a , b ) => a . created_at - b . created_at );
2026-07-24 22:06:27 +05:30
const names = await namesFor ( u . team_id );
2026-07-07 16:09:02 +05:30
let sent = 0 ;
for ( const t of ( targets || []). slice ( 0 , 20 )) {
let isGroup = t . kind === 'group' , tid = t . id ;
2026-07-24 21:26:10 +05:30
if ( isGroup ) { if ( ! await R . conversations . isMember ( tid , u . id )) continue ; }
else { tid = await R . users . resolve ( tid ); if ( ! tid || ! await R . users . inTenant ( tid , u . team_id )) continue ; }
2026-07-07 16:09:02 +05:30
for ( const m of srcs ) {
const nid = A . token ( 16 );
2026-07-07 23:11:02 +05:30
const origin = m . fwd_from || names [ m . sender_id ] || 'Unknown' ; // preserve the true origin across re-forwards
2026-07-24 21:26:10 +05:30
if ( isGroup ) await R . messages . send ({ id : nid , teamId : u . team_id , senderId : u . id , recipientId : '' , body : m . body , attachmentId : m . attachment_id , conversationId : tid , fwdFrom : origin });
else await R . messages . send ({ id : nid , teamId : u . team_id , senderId : u . id , recipientId : tid , body : m . body , attachmentId : m . attachment_id , fwdFrom : origin });
2026-07-24 22:06:27 +05:30
const dto = await buildMsgDTO ( await R . messages . byId ( nid ), names , u . id );
2026-07-07 16:09:02 +05:30
const push = { type : 'chat-message' , message : { ... dto , fromName : u . name || u . email } };
2026-07-24 21:26:10 +05:30
if ( isGroup ) { for ( const mid of await R . conversations . members ( tid )) { try { CHAT . pushToUser ( mid , push ); } catch ( _ ) {} } }
2026-07-07 16:09:02 +05:30
else { try { CHAT . pushToUser ( tid , push ); } catch ( _ ) {} if ( tid !== u . id ) try { CHAT . pushToUser ( u . id , push ); } catch ( _ ) {} }
sent ++ ;
}
}
json ( res , 200 , { ok : true , sent });
});
2026-06-30 17:01:15 +05:30
// Delete one of YOUR OWN messages for everyone (clears content, keeps the row as a placeholder).
route ( 'POST' , '/api/messages/delete' , async ( req , res ) => {
2026-07-24 21:26:10 +05:30
const u = await currentUser ( req );
2026-06-30 17:01:15 +05:30
if ( ! u ) return json ( res , 401 , { error : 'unauthorized' });
const { id } = await readBody ( req );
if ( ! id ) return json ( res , 400 , { error : 'id required' });
2026-07-24 21:26:10 +05:30
const m = await R . messages . byId ( id );
2026-06-30 17:01:15 +05:30
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' });
2026-07-24 21:26:10 +05:30
await R . messages . markDeleted ( id );
2026-06-30 17:01:15 +05:30
const evt = { type : 'chat-deleted' , id , conversation_id : m . conversation_id || null };
2026-07-24 21:26:10 +05:30
if ( m . conversation_id ) { for ( const mid of await R . conversations . members ( m . conversation_id )) { try { CHAT . pushToUser ( mid , evt ); } catch ( _ ) {} } }
2026-06-30 17:01:15 +05:30
else { try { CHAT . pushToUser ( m . recipient_id , evt ); } catch ( _ ) {} try { CHAT . pushToUser ( u . id , evt ); } catch ( _ ) {} }
json ( res , 200 , { ok : true });
});
2026-07-02 17:45:20 +05:30
// 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 ) => {
2026-07-24 21:26:10 +05:30
const u = await currentUser ( req );
2026-07-02 17:45:20 +05:30
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' });
2026-07-24 21:26:10 +05:30
const m = await R . messages . byId ( id );
2026-07-02 17:45:20 +05:30
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' });
2026-07-24 21:26:10 +05:30
await R . messages . editBody ( id , text );
const edited = await R . messages . byId ( id );
2026-07-02 17:45:20 +05:30
const evt = { type : 'chat-edited' , id , body : text , edited_at : edited . edited_at , conversation_id : m . conversation_id || null };
2026-07-24 21:26:10 +05:30
if ( m . conversation_id ) { for ( const mid of await R . conversations . members ( m . conversation_id )) { try { CHAT . pushToUser ( mid , evt ); } catch ( _ ) {} } }
2026-07-02 17:45:20 +05:30
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 });
});
2026-06-30 17:01:15 +05:30
// Favourite/unfavourite a conversation (per user). target = 'dm:<userId>' or 'group:<groupId>'.
route ( 'POST' , '/api/favorites' , async ( req , res ) => {
2026-07-24 21:26:10 +05:30
const u = await currentUser ( req );
2026-06-30 17:01:15 +05:30
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' });
2026-07-24 21:26:10 +05:30
try { await R . favorites . set ( u . id , kind + ':' + id , !! on ); } catch ( _ ) {}
2026-06-30 17:01:15 +05:30
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 ) => {
2026-07-24 21:26:10 +05:30
const u = await currentUser ( req );
2026-06-30 17:01:15 +05:30
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 = [];
2026-07-24 21:26:10 +05:30
if ( group ) { if ( ! await R . conversations . isMember ( group , u . id )) return json ( res , 403 , { error : 'not a member' }); rows = await R . messages . attachmentsForConversation ( u . team_id , group ); linkRows = await R . messages . linksForConversation ( u . team_id , group ); }
else if ( other ) { rows = await R . messages . attachmentsForDm ( u . team_id , u . id , other ); linkRows = await R . messages . linksForDm ( u . team_id , u . id , other ); }
2026-06-30 17:01:15 +05:30
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 });
});
2026-06-23 16:15:29 +05:30
route ( 'POST' , '/api/messages/read' , async ( req , res ) => {
2026-07-24 21:26:10 +05:30
const u = await currentUser ( req );
2026-06-23 16:15:29 +05:30
if ( ! u ) return json ( res , 401 , { error : 'unauthorized' });
2026-07-07 13:23:49 +05:30
const { with : rawOther , group } = await readBody ( req );
2026-07-24 21:26:10 +05:30
const other = await R . users . resolve ( rawOther ); // follow a merge redirect
2026-06-23 16:15:29 +05:30
if ( group ) {
2026-07-24 21:26:10 +05:30
if ( await R . conversations . isMember ( group , u . id )) {
await R . conversations . markRead ( group , u . id );
2026-06-23 16:15:29 +05:30
const evt = { type : 'group-read' , group , by : u . id , byName : ( u . name || u . email ), at : now () };
2026-07-24 21:26:10 +05:30
for ( const mid of await R . conversations . members ( group )) { if ( mid !== u . id ) { try { CHAT . pushToUser ( mid , evt ); } catch ( _ ) {} } }
2026-07-07 16:20:18 +05:30
try { CHAT . pushToUser ( u . id , { type : 'notif-clear' , kind : 'group' , id : group }); } catch ( _ ) {} // #13: clear this chat's notifications on my other devices
2026-06-23 16:15:29 +05:30
}
return json ( res , 200 , { ok : true });
}
if ( ! other ) return json ( res , 400 , { error : 'with or group required' });
2026-07-24 21:26:10 +05:30
await R . messages . markRead ( u . team_id , u . id , other );
2026-06-23 16:15:29 +05:30
try { CHAT . pushToUser ( other , { type : 'chat-read' , by : u . id }); } catch ( _ ) {}
2026-07-07 16:20:18 +05:30
try { CHAT . pushToUser ( u . id , { type : 'notif-clear' , kind : 'dm' , id : other }); } catch ( _ ) {} // #13: multi-device dismissal
2026-06-23 16:15:29 +05:30
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 ) => {
2026-07-24 21:26:10 +05:30
const u = await currentUser ( req );
2026-06-23 16:15:29 +05:30
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' });
2026-07-24 21:26:10 +05:30
const msg = await R . messages . byId ( messageId );
2026-06-23 16:15:29 +05:30
const participant = msg && msg . team_id === u . team_id && (
2026-07-24 21:26:10 +05:30
msg . conversation_id ? await R . conversations . isMember ( msg . conversation_id , u . id )
2026-06-23 16:15:29 +05:30
: ( 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 );
2026-07-24 21:26:10 +05:30
const added = await R . reactions . toggle ( messageId , u . id , e );
2026-07-24 22:06:27 +05:30
const names = await namesFor ( u . team_id );
2026-06-23 16:15:29 +05:30
// 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 ) {
2026-07-24 21:26:10 +05:30
for ( const mid of await R . conversations . members ( msg . conversation_id )) {
2026-07-24 22:06:27 +05:30
try { CHAT . pushToUser ( mid , { type : 'chat-reaction' , messageId , reactions : await reactionsForMessage ( messageId , mid , names ), ... meta }); } catch ( _ ) {}
2026-06-23 16:15:29 +05:30
}
} else {
const other = msg . sender_id === u . id ? msg . recipient_id : msg . sender_id ;
2026-07-24 22:06:27 +05:30
try { CHAT . pushToUser ( other , { type : 'chat-reaction' , messageId , reactions : await reactionsForMessage ( messageId , other , names ), ... meta }); } catch ( _ ) {}
try { CHAT . pushToUser ( u . id , { type : 'chat-reaction' , messageId , reactions : await reactionsForMessage ( messageId , u . id , names ), ... meta }); } catch ( _ ) {}
2026-06-23 16:15:29 +05:30
}
2026-07-24 22:06:27 +05:30
json ( res , 200 , { ok : true , messageId , added , reactions : await reactionsForMessage ( messageId , u . id , names ) });
2026-06-23 16:15:29 +05:30
});
// 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 ) => {
2026-07-24 21:26:10 +05:30
const u = await currentUser ( req );
2026-06-23 16:15:29 +05:30
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 ();
2026-07-20 16:06:19 +05:30
// STREAM the body straight to disk (never buffer the whole file in memory — a 1 GB attachment would OOM the
// container). Write to a .part temp file, then atomically rename on success. Backpressure-aware so a fast
// uploader on a slow disk can't blow up memory either.
const id = A . id ();
const tmp = path . join ( UPLOADS_DIR , id + '.part' );
let ws , total = 0 , aborted = false , done = false ;
try { ws = fs . createWriteStream ( tmp ); } catch ( e ) { return json ( res , 500 , { error : 'could not store file' }); }
const cleanup = () => { try { ws . destroy (); } catch ( _ ) {} try { fs . unlinkSync ( tmp ); } catch ( _ ) {} };
const finish = ( code , body ) => { if ( done ) return ; done = true ; json ( res , code , body ); };
ws . on ( 'error' , () => { aborted = true ; cleanup (); finish ( 500 , { error : 'could not store file' }); });
req . on ( 'data' , ( c ) => {
if ( aborted ) return ;
total += c . length ;
if ( total > MAX_FILE_BYTES ) { aborted = true ; cleanup (); finish ( 413 , { error : 'file too large (max ' + MAX_UPLOAD_MB + ' MB)' }); try { req . destroy (); } catch ( _ ) {} return ; }
if ( ! ws . write ( c )) { req . pause (); ws . once ( 'drain' , () => { if ( ! aborted ) req . resume (); }); } // respect backpressure
});
2026-07-24 22:06:27 +05:30
req . on ( 'end' , async () => {
2026-07-20 16:06:19 +05:30
if ( aborted ) return ;
2026-07-24 22:06:27 +05:30
ws . end ( async () => {
2026-07-20 16:06:19 +05:30
if ( ! total ) { try { fs . unlinkSync ( tmp ); } catch ( _ ) {} return finish ( 400 , { error : 'empty file' }); }
try { fs . renameSync ( tmp , path . join ( UPLOADS_DIR , id )); }
catch ( e ) { try { fs . unlinkSync ( tmp ); } catch ( _ ) {} return finish ( 500 , { error : 'could not store file' }); }
2026-07-24 21:26:10 +05:30
await R . attachments . create ({ id , teamId : u . team_id , uploaderId : u . id , name , mime , size : total });
2026-07-23 10:49:04 +05:30
// Videos: build the capped/faststart streaming rendition in the background so it is ready before
// anyone taps play. Never blocks the upload response, and playback falls back to the original.
try { require ( './media' ). ensureWebRendition ( id , mime ); } catch ( e ) {}
2026-07-20 16:06:19 +05:30
finish ( 200 , { id , name , mime , size : total });
});
2026-06-23 16:15:29 +05:30
});
2026-07-20 16:06:19 +05:30
req . on ( 'error' , () => { aborted = true ; cleanup (); try { res . end (); } catch ( e ) {} });
req . on ( 'aborted' , () => { aborted = true ; cleanup (); }); // client hung up mid-upload → drop the partial file
2026-06-23 16:15:29 +05:30
});
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 ;