diff --git a/server/calls.js b/server/calls.js index 20b88c0..9341a89 100644 --- a/server/calls.js +++ b/server/calls.js @@ -14,11 +14,11 @@ const pairKey = (a, b) => [a, b].sort().join('|'); function meetingContext(room) { const ctx = { groupId: null, meetingId: null, title: 'Meeting' }; try { - const sched = R.scheduledMeetings.byCode(room); + const sched = await R.scheduledMeetings.byCode(room); if (sched) { ctx.meetingId = sched.id; ctx.groupId = sched.group_id || null; ctx.title = sched.title || 'Meeting'; } } catch (_) {} if (!ctx.groupId) { const gid = roomToGroupCall.get(room); if (gid) ctx.groupId = gid; } - if (ctx.groupId && ctx.title === 'Meeting') { try { const g = R.conversations.byId(ctx.groupId); if (g) ctx.title = g.name || 'Group'; } catch (_) {} } + if (ctx.groupId && ctx.title === 'Meeting') { try { const g = await R.conversations.byId(ctx.groupId); if (g) ctx.title = g.name || 'Group'; } catch (_) {} } if (!ctx.groupId && !ctx.meetingId && roomToDmCall.has(room)) ctx.title = 'Direct Call'; return ctx; } @@ -35,12 +35,12 @@ function finalizeTranscript(room, onlyUserId) { const lines = buf.map((s) => { const ts = new Date(s.t); const hh = String(ts.getHours()).padStart(2, '0'), mm = String(ts.getMinutes()).padStart(2, '0'); return '[' + hh + ':' + mm + '] ' + s.speaker + ': ' + s.text; }); const body = ctx.title + ' β€” transcript\n' + new Date(buf[0].t).toLocaleString() + '\n\n' + lines.join('\n') + '\n'; for (const uid of ids) { - let user = null; try { user = R.users.byId(uid); } catch (_) {} + let user = null; try { user = await R.users.byId(uid); } catch (_) {} if (!user) { subs.delete(uid); continue; } const id = A.id(); const file = 'm_' + id + '.txt'; try { fs.writeFileSync(path.join(TRANS_DIR, file), body); } catch (e) { continue; } // groupId null β†’ private to its creator (see canSeeRec / /mrec auth). - R.recordings.create({ id, teamId: user.team_id, room, groupId: null, meetingId: ctx.meetingId, title: ctx.title, kind: 'transcript', file, mime: 'text/plain', size: null, durationMs: null, createdBy: uid, createdByName: user.name || user.email }); + await R.recordings.create({ id, teamId: user.team_id, room, groupId: null, meetingId: ctx.meetingId, title: ctx.title, kind: 'transcript', file, mime: 'text/plain', size: null, durationMs: null, createdBy: uid, createdByName: user.name || user.email }); subs.delete(uid); } } else { ids.forEach((uid) => subs.delete(uid)); } @@ -49,13 +49,13 @@ function finalizeTranscript(room, onlyUserId) { function fmtDur(ms) { const s = Math.max(0, Math.round(ms / 1000)); const m = Math.floor(s / 60); return m ? (m + 'm ' + (s % 60) + 's') : (s + 's'); } -function broadcast(group, evt) { try { for (const mid of R.conversations.members(group)) CHAT.pushToUser(mid, evt); } catch (_) {} } +function broadcast(group, evt) { try { for (const mid of await R.conversations.members(group)) CHAT.pushToUser(mid, evt); } catch (_) {} } // Post a centered activity line into the group (system sender β†’ no ping on clients). function postSystem(group, teamId, text) { const id = A.id(); - R.messages.send({ id, teamId, senderId: '__system__', recipientId: '', body: text, conversationId: group }); - const m = R.messages.byId(id); + await R.messages.send({ id, teamId, senderId: '__system__', recipientId: '', body: text, conversationId: group }); + const m = await R.messages.byId(id); broadcast(group, { type: 'chat-message', message: { id: m.id, from: '__system__', conversation_id: group, body: m.body, created_at: m.created_at, system: true } }); } @@ -66,10 +66,10 @@ function startGroupCall(group, teamId, user) { meetingRooms.set(room, new Map()); const call = { room, startedAt: now(), startedBy: user.id, startedByName: user.name || user.email }; // Log the call as a meeting so it appears under Past meetings (history) with the group name. - try { const hid = A.id(); R.scheduledMeetings.create({ id: hid, teamId, groupId: group, roomCode: room, title: 'Group call', description: null, scheduledAt: now(), createdBy: user.id }); call.historyId = hid; call.teamId = teamId; } catch (_) {} + try { const hid = A.id(); await R.scheduledMeetings.create({ id: hid, teamId, groupId: group, roomCode: room, title: 'Group call', description: null, scheduledAt: now(), createdBy: user.id }); call.historyId = hid; call.teamId = teamId; } catch (_) {} groupCalls.set(group, call); roomToGroupCall.set(room, group); roomHost.set(room, user.id); // creator = host postSystem(group, teamId, 'πŸ“ž ' + call.startedByName + ' started a group call'); - let gName = 'Group'; try { const g = R.conversations.byId(group); if (g) gName = g.name || 'Group'; } catch (_) {} + let gName = 'Group'; try { const g = await R.conversations.byId(group); if (g) gName = g.name || 'Group'; } catch (_) {} broadcast(group, { type: 'group-call', group, active: true, room, by: user.id, startedByName: call.startedByName, groupName: gName }); return { room, active: true }; } @@ -81,8 +81,8 @@ function endGroupCallByRoom(room) { const call = groupCalls.get(group); roomToGroupCall.delete(room); groupCalls.delete(group); roomHost.delete(room); if (call) { - let teamId = call.teamId; try { const g = R.conversations.byId(group); if (g) { teamId = g.team_id; postSystem(group, g.team_id, 'πŸ“ž Group call ended Β· ' + fmtDur(now() - call.startedAt)); } } catch (_) {} - if (call.historyId && teamId) { try { R.scheduledMeetings.end(call.historyId, teamId); } catch (_) {} } // mark the history row past + let teamId = call.teamId; try { const g = await R.conversations.byId(group); if (g) { teamId = g.team_id; postSystem(group, g.team_id, 'πŸ“ž Group call ended Β· ' + fmtDur(now() - call.startedAt)); } } catch (_) {} + if (call.historyId && teamId) { try { await R.scheduledMeetings.end(call.historyId, teamId); } catch (_) {} } // mark the history row past broadcast(group, { type: 'group-call', group, active: false, room }); } } @@ -97,7 +97,7 @@ function startDmCall(me, otherId, teamId) { const byName = me.name || me.email; const call = { room, startedAt: now(), startedBy: me.id, startedByName: byName, users: [me.id, otherId], teamId, answered: false }; // Log to history (both participants) so the call shows under Past meetings with its transcript. - try { const hid = A.id(); R.scheduledMeetings.create({ id: hid, teamId, groupId: null, roomCode: room, title: 'Direct Call', description: null, scheduledAt: now(), createdBy: me.id, participants: [me.id, otherId] }); call.historyId = hid; } catch (_) {} + try { const hid = A.id(); await R.scheduledMeetings.create({ id: hid, teamId, groupId: null, roomCode: room, title: 'Direct Call', description: null, scheduledAt: now(), createdBy: me.id, participants: [me.id, otherId] }); call.historyId = hid; } catch (_) {} dmCalls.set(key, call); roomToDmCall.set(room, key); roomHost.set(room, me.id); // caller = host // #9 (unanswered): if the callee never joins within the ring window, auto-end and mark it missed β€” // so the caller isn't stuck "ringing" forever. @@ -109,8 +109,8 @@ function startDmCall(me, otherId, teamId) { }, 40000); // A viewer-relative activity line: the caller sees "You started a call", the callee sees the name. const mid = A.id(); - R.messages.send({ id: mid, teamId, senderId: me.id, recipientId: otherId, body: 'πŸ“ž Started a call', msgType: 'call-start' }); - const m = R.messages.byId(mid); const dto = { id: m.id, from: me.id, to: otherId, conversation_id: null, body: m.body, created_at: m.created_at, system: true, evt: 'call-start', byName }; + await R.messages.send({ id: mid, teamId, senderId: me.id, recipientId: otherId, body: 'πŸ“ž Started a call', msgType: 'call-start' }); + const m = await R.messages.byId(mid); const dto = { id: m.id, from: me.id, to: otherId, conversation_id: null, body: m.body, created_at: m.created_at, system: true, evt: 'call-start', byName }; try { CHAT.pushToUser(otherId, { type: 'chat-message', message: dto }); } catch (_) {} try { CHAT.pushToUser(me.id, { type: 'chat-message', message: dto }); } catch (_) {} try { CHAT.pushToUser(otherId, { type: 'dm-call', active: true, room, with: me.id, by: me.id, byName }); } catch (_) {} @@ -124,12 +124,12 @@ function endDmCallByRoom(room, silent) { roomToDmCall.delete(room); dmCalls.delete(key); roomHost.delete(room); if (!call) return; if (call.ringTimer) { try { clearTimeout(call.ringTimer); } catch (_) {} } - if (call.historyId && call.teamId) { try { R.scheduledMeetings.end(call.historyId, call.teamId); } catch (_) {} } // mark history past + if (call.historyId && call.teamId) { try { await R.scheduledMeetings.end(call.historyId, call.teamId); } catch (_) {} } // mark history past // Activity line: a duration ONLY if the call was answered; otherwise "Missed call" (#7/#9). if (!silent) try { const mid = A.id(); const body = call.answered ? ('πŸ“ž Call ended Β· ' + fmtDur(now() - (call.answeredAt || call.startedAt))) : 'πŸ“ž Missed call'; - R.messages.send({ id: mid, teamId: call.teamId, senderId: call.startedBy, recipientId: call.users.find((u) => u !== call.startedBy) || '', body, msgType: 'call-end' }); - const m = R.messages.byId(mid); const dto = { id: m.id, from: call.startedBy, to: m.recipient_id, conversation_id: null, body, created_at: m.created_at, system: true, evt: 'call-end' }; + await R.messages.send({ id: mid, teamId: call.teamId, senderId: call.startedBy, recipientId: call.users.find((u) => u !== call.startedBy) || '', body, msgType: 'call-end' }); + const m = await R.messages.byId(mid); const dto = { id: m.id, from: call.startedBy, to: m.recipient_id, conversation_id: null, body, created_at: m.created_at, system: true, evt: 'call-end' }; call.users.forEach((uid) => { try { CHAT.pushToUser(uid, { type: 'chat-message', message: dto }); } catch (_) {} }); } catch (_) {} call.users.forEach((uid, i) => { try { CHAT.pushToUser(uid, { type: 'dm-call', active: false, with: call.users[1 - i], room }); } catch (_) {} }); @@ -156,8 +156,8 @@ function declineDmCall(room, byUser) { const callerId = call.users.find((id) => id !== byUser.id) || call.startedBy; try { const mid = A.id(); - R.messages.send({ id: mid, teamId: byUser.team_id, senderId: byUser.id, recipientId: callerId, body: 'πŸ“ž Call declined', msgType: 'call-end' }); - const mm = R.messages.byId(mid); const dto = { id: mm.id, from: byUser.id, to: callerId, conversation_id: null, body: mm.body, created_at: mm.created_at, system: true, evt: 'call-end' }; + await R.messages.send({ id: mid, teamId: byUser.team_id, senderId: byUser.id, recipientId: callerId, body: 'πŸ“ž Call declined', msgType: 'call-end' }); + const mm = await R.messages.byId(mid); const dto = { id: mm.id, from: byUser.id, to: callerId, conversation_id: null, body: mm.body, created_at: mm.created_at, system: true, evt: 'call-end' }; CHAT.pushToUser(callerId, { type: 'chat-message', message: dto }); CHAT.pushToUser(byUser.id, { type: 'chat-message', message: dto }); } catch (_) {} diff --git a/server/push.js b/server/push.js index 2e695b4..48d3e45 100644 --- a/server/push.js +++ b/server/push.js @@ -114,21 +114,21 @@ async function sendToUser(userId, payload) { const data = JSON.stringify(payload || {}); if (webReady) { let subs = []; - try { subs = R.pushSubs.byUser(userId); } catch (_) { subs = []; } + try { subs = await R.pushSubs.byUser(userId); } catch (_) { subs = []; } for (const s of subs) { try { await webpush.sendNotification({ endpoint: s.endpoint, keys: { p256dh: s.p256dh, auth: s.auth } }, data, { TTL: 600 }); } - catch (err) { const c = err && err.statusCode; if (c === 404 || c === 410) { try { R.pushSubs.removeByEndpoint(s.endpoint); } catch (_) {} } } + catch (err) { const c = err && err.statusCode; if (c === 404 || c === 410) { try { await R.pushSubs.removeByEndpoint(s.endpoint); } catch (_) {} } } } } if (nativeReady) { let toks = []; - try { toks = R.deviceTokens.byUser(userId); } catch (_) { toks = []; } + try { toks = await R.deviceTokens.byUser(userId); } catch (_) { toks = []; } for (const t of toks) { try { let r = null; if (t.platform === 'android' && fcmSA) r = await sendFcm(t.token, payload); else if (t.platform === 'ios' && apnsCfg) r = await sendApns(t.token, payload); - if (r && r.dead) { try { R.deviceTokens.removeByToken(t.token); } catch (_) {} } + if (r && r.dead) { try { await R.deviceTokens.removeByToken(t.token); } catch (_) {} } } catch (_) { /* best-effort */ } } } diff --git a/server/reminders.js b/server/reminders.js index 0b605db..2c378d7 100644 --- a/server/reminders.js +++ b/server/reminders.js @@ -6,15 +6,15 @@ const CHAT = require('./chat'); function tick() { try { const now = Date.now(); - const due = R.scheduledMeetings.dueForReminder(now, now + 10 * 60 * 1000); // starting within 10 min + const due = await R.scheduledMeetings.dueForReminder(now, now + 10 * 60 * 1000); // starting within 10 min for (const s of due) { const recipients = new Set([s.created_by]); let invited = []; try { invited = JSON.parse(s.participants || '[]'); } catch (_) {} invited.forEach((id) => recipients.add(id)); - if (s.group_id) { try { R.conversations.members(s.group_id).forEach((m) => recipients.add(m)); } catch (_) {} } + if (s.group_id) { try { await R.conversations.members(s.group_id).forEach((m) => recipients.add(m)); } catch (_) {} } const evt = { type: 'meeting-reminder', meeting: { id: s.id, title: s.title, scheduledAt: s.scheduled_at, room: s.room_code } }; recipients.forEach((uid) => { try { CHAT.pushToUser(uid, evt); } catch (_) {} }); - R.scheduledMeetings.markReminded(s.id); + await R.scheduledMeetings.markReminded(s.id); } } catch (_) { /* never let the timer die */ } } diff --git a/server/repos.js b/server/repos.js index f21e737..aae4d29 100644 --- a/server/repos.js +++ b/server/repos.js @@ -5,28 +5,34 @@ // TENANT ABSTRACTION: a "tenant" currently maps 1:1 to a team (column `team_id`). // Repo signatures take `tenantId` so that when the tenant is later elevated to a // first-class Organization (Phase 3), callers and the API/auth built on top stay unchanged. -const db = require('./db'); +// +// ASYNC: queries go through the db adapter (dbx.js), so every method returns a Promise β€” the same repo +// code runs on synchronous SQLite and asynchronous Postgres. Callers MUST await. Methods that only +// `return db.prepare(...).get/all/run(...)` already yield the adapter's Promise; methods that transform a +// result (.map, !!, .c, or run multiple statements) are written async/await so they don't operate on a +// bare Promise. +const db = require('./dbx'); const A = require('./auth'); const now = () => Date.now(); const teams = { first: () => db.prepare('SELECT * FROM teams LIMIT 1').get(), byId: (id) => db.prepare('SELECT * FROM teams WHERE id=?').get(id), - create: (name) => { + create: async (name) => { const id = A.id(); - db.prepare('INSERT INTO teams (id,name,created_at) VALUES (?,?,?)').run(id, name, now()); + await db.prepare('INSERT INTO teams (id,name,created_at) VALUES (?,?,?)').run(id, name, now()); return db.prepare('SELECT * FROM teams WHERE id=?').get(id); }, }; const users = { - anyExists: () => !!db.prepare('SELECT 1 FROM users LIMIT 1').get(), + anyExists: async () => !!(await db.prepare('SELECT 1 FROM users LIMIT 1').get()), byId: (id) => db.prepare('SELECT * FROM users WHERE id=?').get(id), // Follow a merge redirect: a merged-away id resolves to the surviving account, else returns id // unchanged. Use for any user id that arrived from the client (DM recipient, thread peer). - resolve: (id) => { if (!id) return id; const a = db.prepare('SELECT user_id FROM user_aliases WHERE old_id=?').get(id); return a ? a.user_id : id; }, + resolve: async (id) => { if (!id) return id; const a = await db.prepare('SELECT user_id FROM user_aliases WHERE old_id=?').get(id); return a ? a.user_id : id; }, byEmail: (email) => db.prepare('SELECT * FROM users WHERE email=? COLLATE NOCASE').get(email), - emailExists: (email) => !!db.prepare('SELECT 1 FROM users WHERE email=? COLLATE NOCASE').get(email), + emailExists: async (email) => !!(await db.prepare('SELECT 1 FROM users WHERE email=? COLLATE NOCASE').get(email)), // Match on the stable BizGaze person-id so email- and mobile-logins resolve to one account (#2). byBizgazeId: (bizId) => (bizId ? db.prepare('SELECT * FROM users WHERE bizgaze_user_id=?').get(String(bizId)) : undefined), setBizgazeId: (id, bizId) => db.prepare('UPDATE users SET bizgaze_user_id=? WHERE id=?').run(bizId != null ? String(bizId) : null, id), @@ -34,9 +40,9 @@ const users = { db.prepare('SELECT id,email,name,role,active,avatar_url,created_at FROM users WHERE team_id=?').all(tenantId), inTenant: (id, tenantId) => db.prepare('SELECT * FROM users WHERE id=? AND team_id=?').get(id, tenantId), - create: ({ tenantId, email, hash, salt, role, name, mfaSecret }) => { + create: async ({ tenantId, email, hash, salt, role, name, mfaSecret }) => { const id = A.id(); - db.prepare(`INSERT INTO users (id,team_id,email,pw_hash,pw_salt,role,name,mfa_secret,mfa_enabled,created_at) + await db.prepare(`INSERT INTO users (id,team_id,email,pw_hash,pw_salt,role,name,mfa_secret,mfa_enabled,created_at) VALUES (?,?,?,?,?,?,?,?,0,?)`) .run(id, tenantId, email, hash, salt, role, name || null, mfaSecret, now()); return id; @@ -57,47 +63,43 @@ const users = { // BizGaze person (#2). Runs in a single transaction so a failure leaves the data untouched. // For composite-key tables we UPDATE OR IGNORE (move what won't collide) then DELETE the rest // (the survivor already has that membership/reaction/vote). - mergeInto: (fromId, intoId) => { + mergeInto: async (fromId, intoId) => { if (!fromId || !intoId || fromId === intoId) return; - const run = (sql, ...a) => db.prepare(sql).run(...a); - db.exec('BEGIN'); - try { - run('UPDATE messages SET sender_id=? WHERE sender_id=?', intoId, fromId); - run('UPDATE messages SET recipient_id=? WHERE recipient_id=?', intoId, fromId); - run('UPDATE OR IGNORE message_reactions SET user_id=? WHERE user_id=?', intoId, fromId); - run('DELETE FROM message_reactions WHERE user_id=?', fromId); - run('UPDATE OR IGNORE conversation_members SET user_id=? WHERE user_id=?', intoId, fromId); - run('DELETE FROM conversation_members WHERE user_id=?', fromId); - run('UPDATE OR IGNORE poll_votes SET user_id=? WHERE user_id=?', intoId, fromId); - run('DELETE FROM poll_votes WHERE user_id=?', fromId); - run('UPDATE OR IGNORE favorites SET user_id=? WHERE user_id=?', intoId, fromId); - run('DELETE FROM favorites WHERE user_id=?', fromId); + // db.tx() gives one atomic unit on both engines (sqlite single-connection; pg one pooled client). + return db.tx(async (t) => { + const run = (sql, ...a) => t.run(sql, ...a); + await run('UPDATE messages SET sender_id=? WHERE sender_id=?', intoId, fromId); + await run('UPDATE messages SET recipient_id=? WHERE recipient_id=?', intoId, fromId); + await run('UPDATE OR IGNORE message_reactions SET user_id=? WHERE user_id=?', intoId, fromId); + await run('DELETE FROM message_reactions WHERE user_id=?', fromId); + await run('UPDATE OR IGNORE conversation_members SET user_id=? WHERE user_id=?', intoId, fromId); + await run('DELETE FROM conversation_members WHERE user_id=?', fromId); + await run('UPDATE OR IGNORE poll_votes SET user_id=? WHERE user_id=?', intoId, fromId); + await run('DELETE FROM poll_votes WHERE user_id=?', fromId); + await run('UPDATE OR IGNORE favorites SET user_id=? WHERE user_id=?', intoId, fromId); + await run('DELETE FROM favorites WHERE user_id=?', fromId); // A DM favourite pointing AT the merged-away user should now point at the survivor. - run("UPDATE OR IGNORE favorites SET target='dm:'||? WHERE target='dm:'||?", intoId, fromId); - run("DELETE FROM favorites WHERE target='dm:'||?", fromId); - run('UPDATE conversations SET created_by=? WHERE created_by=?', intoId, fromId); - run('UPDATE polls SET created_by=? WHERE created_by=?', intoId, fromId); - run('UPDATE scheduled_meetings SET created_by=? WHERE created_by=?', intoId, fromId); - run('UPDATE recordings SET created_by=? WHERE created_by=?', intoId, fromId); - run('UPDATE attachments SET uploader_id=? WHERE uploader_id=?', intoId, fromId); - run('UPDATE push_subscriptions SET user_id=? WHERE user_id=?', intoId, fromId); - run('UPDATE OR IGNORE device_tokens SET user_id=? WHERE user_id=?', intoId, fromId); - run('DELETE FROM device_tokens WHERE user_id=?', fromId); - run('UPDATE app_installs SET user_id=? WHERE user_id=?', intoId, fromId); + await run("UPDATE OR IGNORE favorites SET target='dm:'||? WHERE target='dm:'||?", intoId, fromId); + await run("DELETE FROM favorites WHERE target='dm:'||?", fromId); + await run('UPDATE conversations SET created_by=? WHERE created_by=?', intoId, fromId); + await run('UPDATE polls SET created_by=? WHERE created_by=?', intoId, fromId); + await run('UPDATE scheduled_meetings SET created_by=? WHERE created_by=?', intoId, fromId); + await run('UPDATE recordings SET created_by=? WHERE created_by=?', intoId, fromId); + await run('UPDATE attachments SET uploader_id=? WHERE uploader_id=?', intoId, fromId); + await run('UPDATE push_subscriptions SET user_id=? WHERE user_id=?', intoId, fromId); + await run('UPDATE OR IGNORE device_tokens SET user_id=? WHERE user_id=?', intoId, fromId); + await run('DELETE FROM device_tokens WHERE user_id=?', fromId); + await run('UPDATE app_installs SET user_id=? WHERE user_id=?', intoId, fromId); // Drop the merged-away account's auth so a stale token can't resurrect the empty row. - run('DELETE FROM sessions_auth WHERE user_id=?', fromId); - run('DELETE FROM refresh_tokens WHERE user_id=?', fromId); + await run('DELETE FROM sessions_auth WHERE user_id=?', fromId); + await run('DELETE FROM refresh_tokens WHERE user_id=?', fromId); // Record old_id -> survivor so lingering references (cached contacts, in-flight DMs) resolve // instead of hitting the deleted row (which made messages to merged contacts vanish). - const _iu = db.prepare('SELECT team_id FROM users WHERE id=?').get(intoId); - run('INSERT OR REPLACE INTO user_aliases (old_id,user_id,team_id,created_at) VALUES (?,?,?,?)', fromId, intoId, (_iu && _iu.team_id) || null, now()); - run('UPDATE user_aliases SET user_id=? WHERE user_id=?', intoId, fromId); // re-chain earlier aliases to the new survivor - run('DELETE FROM users WHERE id=?', fromId); - db.exec('COMMIT'); - } catch (e) { - db.exec('ROLLBACK'); - throw e; - } + const _iu = await t.get('SELECT team_id FROM users WHERE id=?', intoId); + await run('INSERT OR REPLACE INTO user_aliases (old_id,user_id,team_id,created_at) VALUES (?,?,?,?)', fromId, intoId, (_iu && _iu.team_id) || null, now()); + await run('UPDATE user_aliases SET user_id=? WHERE user_id=?', intoId, fromId); // re-chain earlier aliases to the new survivor + await run('DELETE FROM users WHERE id=?', fromId); + }); }, }; @@ -116,9 +118,9 @@ const machines = { inTenant: (id, tenantId) => db.prepare('SELECT * FROM machines WHERE id=? AND team_id=?').get(id, tenantId), listByTenant: (tenantId) => db.prepare('SELECT id,name,unattended,last_seen FROM machines WHERE team_id=?').all(tenantId), - create: ({ tenantId, name, enrollToken, unattended }) => { + create: async ({ tenantId, name, enrollToken, unattended }) => { const id = A.id(); - db.prepare('INSERT INTO machines (id,team_id,name,enroll_token,unattended,created_at) VALUES (?,?,?,?,?,?)') + await db.prepare('INSERT INTO machines (id,team_id,name,enroll_token,unattended,created_at) VALUES (?,?,?,?,?,?)') .run(id, tenantId, name, enrollToken, unattended ? 1 : 0, now()); return id; }, @@ -239,18 +241,18 @@ const messages = { AND body LIKE ? ESCAPE '\\' ORDER BY created_at ASC LIMIT ?`).all(conversationId, like, limit), lastInConversation: (conversationId) => db.prepare('SELECT * FROM messages WHERE conversation_id=? ORDER BY created_at DESC LIMIT 1').get(conversationId), - unreadInConversation: (conversationId, userId, since) => - db.prepare('SELECT COUNT(*) AS c FROM messages WHERE conversation_id=? AND sender_id<>? AND created_at>?').get(conversationId, userId, since).c, + unreadInConversation: async (conversationId, userId, since) => + (await db.prepare('SELECT COUNT(*) AS c FROM messages WHERE conversation_id=? AND sender_id<>? AND created_at>?').get(conversationId, userId, since)).c, }; const reactions = { // Toggle with ONE reaction per user per message: picking an emoji replaces any prior // reaction by that user; picking the same one again removes it. Returns true if added. - toggle: (messageId, userId, emoji) => { - const had = db.prepare('SELECT 1 FROM message_reactions WHERE message_id=? AND user_id=? AND emoji=?').get(messageId, userId, emoji); - db.prepare('DELETE FROM message_reactions WHERE message_id=? AND user_id=?').run(messageId, userId); + toggle: async (messageId, userId, emoji) => { + const had = await db.prepare('SELECT 1 FROM message_reactions WHERE message_id=? AND user_id=? AND emoji=?').get(messageId, userId, emoji); + await db.prepare('DELETE FROM message_reactions WHERE message_id=? AND user_id=?').run(messageId, userId); if (had) return false; - db.prepare('INSERT INTO message_reactions (message_id,user_id,emoji,created_at) VALUES (?,?,?,?)').run(messageId, userId, emoji, now()); + await db.prepare('INSERT INTO message_reactions (message_id,user_id,emoji,created_at) VALUES (?,?,?,?)').run(messageId, userId, emoji, now()); return true; }, forMessage: (messageId) => db.prepare('SELECT user_id, emoji FROM message_reactions WHERE message_id=? ORDER BY created_at ASC').all(messageId), @@ -271,16 +273,16 @@ const conversations = { byId: (id) => db.prepare('SELECT * FROM conversations WHERE id=?').get(id), addMember: (conversationId, userId, admin) => db.prepare('INSERT OR IGNORE INTO conversation_members (conversation_id,user_id,last_read_at,joined_at,admin) VALUES (?,?,?,?,?)').run(conversationId, userId, 0, now(), admin ? 1 : 0), - members: (conversationId) => db.prepare('SELECT user_id FROM conversation_members WHERE conversation_id=? ORDER BY joined_at ASC').all(conversationId).map((r) => r.user_id), - isMember: (conversationId, userId) => !!db.prepare('SELECT 1 FROM conversation_members WHERE conversation_id=? AND user_id=?').get(conversationId, userId), + members: async (conversationId) => (await db.prepare('SELECT user_id FROM conversation_members WHERE conversation_id=? ORDER BY joined_at ASC').all(conversationId)).map((r) => r.user_id), + isMember: async (conversationId, userId) => !!(await db.prepare('SELECT 1 FROM conversation_members WHERE conversation_id=? AND user_id=?').get(conversationId, userId)), // ---- Group admins (multiple allowed) ---- - isAdmin: (conversationId, userId) => !!db.prepare('SELECT 1 FROM conversation_members WHERE conversation_id=? AND user_id=? AND admin=1').get(conversationId, userId), - admins: (conversationId) => db.prepare('SELECT user_id FROM conversation_members WHERE conversation_id=? AND admin=1').all(conversationId).map((r) => r.user_id), + isAdmin: async (conversationId, userId) => !!(await db.prepare('SELECT 1 FROM conversation_members WHERE conversation_id=? AND user_id=? AND admin=1').get(conversationId, userId)), + admins: async (conversationId) => (await db.prepare('SELECT user_id FROM conversation_members WHERE conversation_id=? AND admin=1').all(conversationId)).map((r) => r.user_id), setMemberAdmin: (conversationId, userId, v) => db.prepare('UPDATE conversation_members SET admin=? WHERE conversation_id=? AND user_id=?').run(v ? 1 : 0, conversationId, userId), - oldestMember: (conversationId) => { const r = db.prepare('SELECT user_id FROM conversation_members WHERE conversation_id=? ORDER BY joined_at ASC LIMIT 1').get(conversationId); return r ? r.user_id : null; }, + oldestMember: async (conversationId) => { const r = await db.prepare('SELECT user_id FROM conversation_members WHERE conversation_id=? ORDER BY joined_at ASC LIMIT 1').get(conversationId); return r ? r.user_id : null; }, listForUser: (teamId, userId) => db.prepare('SELECT c.* FROM conversations c JOIN conversation_members m ON m.conversation_id=c.id WHERE c.team_id=? AND m.user_id=?').all(teamId, userId), - lastReadAt: (conversationId, userId) => { const r = db.prepare('SELECT last_read_at FROM conversation_members WHERE conversation_id=? AND user_id=?').get(conversationId, userId); return r ? r.last_read_at : 0; }, + lastReadAt: async (conversationId, userId) => { const r = await db.prepare('SELECT last_read_at FROM conversation_members WHERE conversation_id=? AND user_id=?').get(conversationId, userId); return r ? r.last_read_at : 0; }, memberReads: (conversationId) => db.prepare('SELECT user_id, last_read_at FROM conversation_members WHERE conversation_id=?').all(conversationId), setAdminOnly: (id, v) => db.prepare('UPDATE conversations SET admin_only=? WHERE id=?').run(v ? 1 : 0, id), markRead: (conversationId, userId) => db.prepare('UPDATE conversation_members SET last_read_at=? WHERE conversation_id=? AND user_id=?').run(now(), conversationId, userId), @@ -288,7 +290,7 @@ const conversations = { setAvatar: (id, attachmentId) => db.prepare('UPDATE conversations SET avatar_id=? WHERE id=?').run(attachmentId || null, id), byAvatar: (attachmentId) => db.prepare('SELECT * FROM conversations WHERE avatar_id=? LIMIT 1').get(attachmentId), removeMember: (conversationId, userId) => db.prepare('DELETE FROM conversation_members WHERE conversation_id=? AND user_id=?').run(conversationId, userId), - remove: (id) => { db.prepare('DELETE FROM conversation_members WHERE conversation_id=?').run(id); db.prepare('DELETE FROM conversations WHERE id=?').run(id); }, + remove: async (id) => { await db.prepare('DELETE FROM conversation_members WHERE conversation_id=?').run(id); await db.prepare('DELETE FROM conversations WHERE id=?').run(id); }, }; const attachments = { @@ -352,7 +354,7 @@ const polls = { const pollVotes = { forPoll: (pollId) => db.prepare('SELECT user_id, option_idx FROM poll_votes WHERE poll_id=?').all(pollId), - hasVoted: (pollId, userId, idx) => !!db.prepare('SELECT 1 FROM poll_votes WHERE poll_id=? AND user_id=? AND option_idx=?').get(pollId, userId, idx), + hasVoted: async (pollId, userId, idx) => !!(await db.prepare('SELECT 1 FROM poll_votes WHERE poll_id=? AND user_id=? AND option_idx=?').get(pollId, userId, idx)), add: (pollId, userId, idx) => db.prepare('INSERT OR IGNORE INTO poll_votes (poll_id,user_id,option_idx,created_at) VALUES (?,?,?,?)').run(pollId, userId, idx, now()), remove: (pollId, userId, idx) => db.prepare('DELETE FROM poll_votes WHERE poll_id=? AND user_id=? AND option_idx=?').run(pollId, userId, idx), clearUser: (pollId, userId) => db.prepare('DELETE FROM poll_votes WHERE poll_id=? AND user_id=?').run(pollId, userId), @@ -371,7 +373,7 @@ const favorites = { set: (userId, target, on) => on ? db.prepare('INSERT OR IGNORE INTO favorites (user_id,target,created_at) VALUES (?,?,?)').run(userId, target, now()) : db.prepare('DELETE FROM favorites WHERE user_id=? AND target=?').run(userId, target), - forUser: (userId) => db.prepare('SELECT target FROM favorites WHERE user_id=?').all(userId).map((r) => r.target), + forUser: async (userId) => (await db.prepare('SELECT target FROM favorites WHERE user_id=?').all(userId)).map((r) => r.target), }; const deviceTokens = { diff --git a/server/routes.js b/server/routes.js index fb40a63..71a789a 100644 --- a/server/routes.js +++ b/server/routes.js @@ -12,7 +12,7 @@ const MSG_MAX = 4000; const parseMentions = (s) => { if (!s) return []; try { const a = JSON.parse(s); return Array.isArray(a) ? a : []; } catch { return []; } }; const SYSTEM_SENDER = '__system__'; const msgDTO = (m) => ({ id: m.id, from: m.sender_id, to: m.recipient_id, conversation_id: m.conversation_id || null, body: m.deleted ? '' : m.body, created_at: m.created_at, read_at: m.read_at, delivered_at: m.delivered_at || null, reply_to: m.deleted ? null : (m.reply_to || null), mentions: parseMentions(m.mentions), evt: m.msg_type || null, fwd_from: m.deleted ? null : (m.fwd_from || null), deleted: !!m.deleted, system: m.sender_id === SYSTEM_SENDER || !!m.msg_type }); -function namesFor(teamId){ const o = {}; for (const x of R.users.listByTenant(teamId)) o[x.id] = x.name || x.email; return o; } +function namesFor(teamId){ const o = {}; for (const x of await R.users.listByTenant(teamId)) o[x.id] = x.name || x.email; return o; } // 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 @@ -21,7 +21,7 @@ function namesFor(teamId){ const o = {}; for (const x of R.users.listByTenant(te // 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. function avatarsFor(teamId) { - const users = R.users.listByTenant(teamId); + const users = await R.users.listByTenant(teamId); 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 β€” @@ -50,15 +50,15 @@ function recurrenceLabel(days){ if (!days || !days.length) return ''; if (days.l // Post a centered "activity" line into a group (member added/removed/renamed/left) and push it. function postSystemMessage(conversationId, teamId, text){ const id = A.id(); - R.messages.send({ id, teamId, senderId: SYSTEM_SENDER, recipientId: '', body: text, conversationId }); - const dto = buildMsgDTO(R.messages.byId(id), {}, ''); - for (const mid of R.conversations.members(conversationId)) { try { CHAT.pushToUser(mid, { type: 'chat-message', message: dto }); } catch (_) {} } + await R.messages.send({ id, teamId, senderId: SYSTEM_SENDER, recipientId: '', body: text, conversationId }); + const dto = buildMsgDTO(await R.messages.byId(id), {}, ''); + for (const mid of await R.conversations.members(conversationId)) { try { CHAT.pushToUser(mid, { type: 'chat-message', message: dto }); } catch (_) {} } return dto; } // Tell clients a group's membership changed so they refresh the member count / sidebar immediately. function pushGroupUpdate(group, alsoUsers){ const seen = new Set(); - for (const mid of R.conversations.members(group)) { seen.add(mid); try { CHAT.pushToUser(mid, { type: 'group-update', group }); } catch (_) {} } + for (const mid of await R.conversations.members(group)) { seen.add(mid); try { CHAT.pushToUser(mid, { type: 'group-update', group }); } catch (_) {} } for (const mid of (alsoUsers || [])) { if (!seen.has(mid)) { try { CHAT.pushToUser(mid, { type: 'group-update', group, removed: true }); } catch (_) {} } } } // Group a flat reaction list into { messageId: [{emoji,count,mine,who}] } for the current user. @@ -75,14 +75,14 @@ function groupReactions(list, userId, names){ const dtoReactions = (rxBy, id) => (rxBy[id] ? Object.entries(rxBy[id]).map(([emoji, v]) => ({ emoji, count: v.count, mine: v.mine, who: v.who })) : []); // Full reaction DTO for ONE message, from `userId`'s perspective (mine/who). function reactionsForMessage(messageId, userId, names){ - const rows = R.reactions.forMessage(messageId).map((r) => ({ message_id: messageId, user_id: r.user_id, emoji: r.emoji })); + const rows = await R.reactions.forMessage(messageId).map((r) => ({ message_id: messageId, user_id: r.user_id, emoji: r.emoji })); return dtoReactions(groupReactions(rows, userId, names), messageId); } // Poll tally for a given viewer ("mine" = this user voted that option). function buildPollDTO(poll, userId){ let opts = []; try { opts = JSON.parse(poll.options); } catch { opts = []; } const counts = opts.map(() => 0); const mine = opts.map(() => false); const voters = new Set(); - for (const v of R.pollVotes.forPoll(poll.id)) { + for (const v of await R.pollVotes.forPoll(poll.id)) { if (v.option_idx >= 0 && v.option_idx < counts.length) { counts[v.option_idx]++; if (v.user_id === userId) mine[v.option_idx] = true; } voters.add(v.user_id); } @@ -96,14 +96,14 @@ function buildPollDTO(poll, userId){ function buildMsgDTO(m, names, userId){ const d = msgDTO(m); if (m.reply_to) { - const r = R.messages.byId(m.reply_to); + const r = await R.messages.byId(m.reply_to); 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 }; } if (m.attachment_id) { - const a = R.attachments.byId(m.attachment_id); + const a = await R.attachments.byId(m.attachment_id); if (a) d.attachment = { id: a.id, name: a.name, mime: a.mime, size: a.size, isImage: /^image\//.test(a.mime || ''), isVideo: /^video\//.test(a.mime || ''), isAudio: /^audio\//.test(a.mime || '') }; } - if (m.poll_id) { const p = R.polls.byId(m.poll_id); if (p) d.poll = buildPollDTO(p, userId); } + if (m.poll_id) { const p = await R.polls.byId(m.poll_id); if (p) d.poll = buildPollDTO(p, userId); } if (m.msg_type) d.byName = (names && names[m.sender_id]) || ''; return d; } @@ -154,7 +154,7 @@ function livekitToken(identity, name, room, metadata) { // Issue a refresh token (native clients), store only its hash, return the plaintext once. function issueRefreshToken(userId) { const rtok = A.token(32); - R.refreshTokens.create({ userId, tokenHash: A.hashToken(rtok), ttl: REFRESH_TTL }); + await R.refreshTokens.create({ userId, tokenHash: A.hashToken(rtok), ttl: REFRESH_TTL }); return rtok; } @@ -163,16 +163,16 @@ const route = (method, p, fn) => (routes[`${method} ${p}`] = fn); // Register: creates a team + admin user. MFA must be set up before full access. route('POST', '/api/register', async (req, res) => { - const anyUser = R.users.anyExists(); + const anyUser = await R.users.anyExists(); if (anyUser && process.env.ALLOW_REGISTRATION !== '1') return json(res, 403, { error: 'Registration is closed. Contact your administrator.' }); const { email, password, teamName } = await readBody(req); if (!email || !password) return json(res, 400, { error: 'email and password required' }); - if (R.users.emailExists(email)) + if (await R.users.emailExists(email)) return json(res, 409, { error: 'email already registered' }); const { hash, salt } = A.hashPassword(password); - const team = R.teams.create(teamName || `${email}'s team`); - const userId = R.users.create({ tenantId: team.id, email, hash, salt, role: 'admin', name: null, mfaSecret: A.newMfaSecret() }); + 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() }); audit({ team_id: team.id, user_id: userId, user_email: email, action: 'user_registered' }); json(res, 200, { ok: true }); }); @@ -180,10 +180,10 @@ route('POST', '/api/register', async (req, res) => { // Verify MFA enrollment (confirm the user scanned the QR / entered code) route('POST', '/api/mfa/enable', async (req, res) => { const { email, code } = await readBody(req); - const u = R.users.byEmail(email); + const u = await R.users.byEmail(email); if (!u) return json(res, 404, { error: 'no such user' }); if (!A.verifyTotp(u.mfa_secret, code)) return json(res, 401, { error: 'invalid code' }); - R.users.enableMfa(u.id); + await R.users.enableMfa(u.id); json(res, 200, { ok: true }); }); @@ -200,41 +200,41 @@ function provisionFromBizgaze(email, bz) { // Identity is keyed on the BizGaze person-id, NOT the typed identifier: signing in with a // mobile number and with an email both return the same person-id, so both resolve to one // Biz Connect account (#2 β€” no more duplicate contacts for the same person). - let existing = R.users.byBizgazeId(bizId); + let existing = await R.users.byBizgazeId(bizId); // Legacy account created before the person-id was stored: fall back to the typed identifier, // but only if it isn't already claimed by a different person, then stamp the id on below. if (!existing) { - const byMail = R.users.byEmail(email); + const byMail = await R.users.byEmail(email); if (byMail && (!byMail.bizgaze_user_id || byMail.bizgaze_user_id === bizId)) existing = byMail; } if (!existing) { - const team = R.teams.first() || R.teams.create('BizGaze'); + const team = await R.teams.first() || await R.teams.create('BizGaze'); const { hash, salt } = A.hashPassword(A.token()); - const id = R.users.create({ tenantId: team.id, email, hash, salt, role, name: bz.name || null, mfaSecret: A.newMfaSecret() }); - if (bizId) R.users.setBizgazeId(id, bizId); - if (bz.avatarUrl) R.users.setAvatar(id, bz.avatarUrl); + 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); audit({ team_id: team.id, user_id: id, user_email: email, action: 'sso_user_created', detail: 'via BizGaze' }); - return R.users.byId(id); + return await R.users.byId(id); } // Retroactive merge: if this same identifier already has its OWN legacy account (a separate // row created before person-id keying β€” e.g. the person used email before and is now signing // in with their mobile), fold that duplicate's history into the canonical account. BizGaze just // proved this identifier belongs to this person, so the merge is safe. if (bizId) { - const dup = R.users.byEmail(email); + const dup = await R.users.byEmail(email); if (dup && dup.id !== existing.id && (!dup.bizgaze_user_id || dup.bizgaze_user_id === bizId)) { - R.users.mergeInto(dup.id, existing.id); + await R.users.mergeInto(dup.id, existing.id); audit({ team_id: existing.team_id, user_id: existing.id, user_email: email, action: 'account_merged', detail: 'folded duplicate ' + dup.id }); } } // BizGaze is the source of truth: keep the person-id + name + avatar + role in sync each login. // Stamping the id links legacy rows so the person's other identifier converges here next time. - if (bizId && existing.bizgaze_user_id !== bizId) R.users.setBizgazeId(existing.id, bizId); - if (bz.name && bz.name !== existing.name) R.users.setName(existing.id, bz.name); - if (bz.avatarUrl && bz.avatarUrl !== existing.avatar_url) R.users.setAvatar(existing.id, bz.avatarUrl); - if (existing.role !== role) R.users.setRole(existing.id, role); - return R.users.byId(existing.id); + 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); } // Login: when BizGaze (BIZGAZE_LOGIN_URL) is configured it is the ONLY authority β€” the @@ -244,7 +244,7 @@ function provisionFromBizgaze(email, bz) { route('POST', '/api/login', async (req, res) => { const { email, password, remember } = await readBody(req); if (!email || !password) return json(res, 400, { error: 'email and password required' }); - const existing = R.users.byEmail(email); + const existing = await R.users.byEmail(email); if (existing && existing.active === 0) return json(res, 403, { error: 'This account has been deactivated' }); // Production: when BizGaze is the IdP, verify ONLY against BizGaze (no local-password @@ -278,7 +278,7 @@ route('POST', '/api/login', async (req, res) => { const tok = A.token(); const ttl = remember ? 1000 * 60 * 60 * 24 * 30 : SESSION_TTL; // 30 days if remembered, else 24h - R.authSessions.create({ token: tok, userId: u.id, mfaPassed: true, ttl }); + await R.authSessions.create({ token: tok, userId: u.id, mfaPassed: true, ttl }); res.setHeader('Set-Cookie', `sid=${tok}; HttpOnly; Path=/; Max-Age=${ttl / 1000}`); audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'login' }); // Cookie for the web app; access token + refresh token in the body for native @@ -293,13 +293,13 @@ route('POST', '/api/auth/refresh', async (req, res) => { const { refreshToken } = await readBody(req); if (!refreshToken) return json(res, 400, { error: 'refreshToken required' }); const h = A.hashToken(refreshToken); - const row = R.refreshTokens.byHash(h); + const row = await R.refreshTokens.byHash(h); if (!row || row.revoked || row.expires_at < now()) return json(res, 401, { error: 'invalid or expired refresh token' }); - const u = R.users.byId(row.user_id); + const u = await R.users.byId(row.user_id); if (!u || u.active === 0) return json(res, 401, { error: 'account unavailable' }); - R.refreshTokens.revoke(h); // rotate: one-time use + await R.refreshTokens.revoke(h); // rotate: one-time use const tok = A.token(); - R.authSessions.create({ token: tok, userId: u.id, mfaPassed: true, ttl: SESSION_TTL }); + await R.authSessions.create({ token: tok, userId: u.id, mfaPassed: true, ttl: SESSION_TTL }); const newRefresh = issueRefreshToken(u.id); json(res, 200, { ok: true, token: tok, expiresAt: now() + SESSION_TTL, refreshToken: newRefresh, refreshExpiresAt: now() + REFRESH_TTL }); }); @@ -309,11 +309,11 @@ route('POST', '/api/auth/refresh', async (req, res) => { // 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) => { - const u = currentUser(req); + const u = await currentUser(req); 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 - R.authSessions.create({ token: tok, userId: u.id, mfaPassed: true, ttl }); + await R.authSessions.create({ token: tok, userId: u.id, mfaPassed: true, ttl }); json(res, 200, { token: tok, expiresAt: now() + ttl }); }); @@ -321,26 +321,26 @@ route('GET', '/api/share/token', async (req, res) => { route('POST', '/api/login/mfa', async (req, res) => { const { code } = await readBody(req); const tok = parseCookies(req).sid; - const s = tok && R.authSessions.byToken(tok); + const s = tok && await R.authSessions.byToken(tok); if (!s) return json(res, 401, { error: 'no session' }); - const u = R.users.byId(s.user_id); + const u = await R.users.byId(s.user_id); if (!A.verifyTotp(u.mfa_secret, code)) return json(res, 401, { error: 'invalid code' }); - R.authSessions.markMfaPassed(tok); + await R.authSessions.markMfaPassed(tok); audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'login' }); json(res, 200, { ok: true }); }); route('POST', '/api/logout', async (req, res) => { const tok = tokenFromReq(req); // cookie (web) or Bearer (native) - if (tok) R.authSessions.deleteByToken(tok); + if (tok) await R.authSessions.deleteByToken(tok); const { refreshToken } = await readBody(req); - if (refreshToken) R.refreshTokens.revoke(A.hashToken(refreshToken)); + if (refreshToken) await R.refreshTokens.revoke(A.hashToken(refreshToken)); res.setHeader('Set-Cookie', 'sid=; HttpOnly; Path=/; Max-Age=0'); json(res, 200, { ok: true }); }); route('GET', '/api/setup-state', async (req, res) => { - const anyUser = R.users.anyExists(); + const anyUser = await R.users.anyExists(); json(res, 200, { registrationOpen: !anyUser || process.env.ALLOW_REGISTRATION === '1' }); }); @@ -367,17 +367,17 @@ route('GET', '/api/ice', async (req, res) => { }); route('GET', '/api/me', async (req, res) => { - const u = currentUser(req); + const u = await currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); json(res, 200, { id: u.id, email: u.email, role: u.role, teamId: u.team_id, name: u.name || null, avatarUrl: u.avatar_url || null, status: u.status || 'active' }); }); // Set my presence status: 'active' | 'away' | 'onleave' ('incall' is derived, not settable). route('POST', '/api/me/status', async (req, res) => { - const u = currentUser(req); + const u = await currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); const { status } = await readBody(req); if (!['active', 'away', 'onleave'].includes(status)) return json(res, 400, { error: 'invalid status' }); - try { R.users.setStatus(u.id, status); } catch (_) {} + try { await R.users.setStatus(u.id, status); } catch (_) {} try { CHAT.broadcastPresence(u.id); } catch (_) {} // push the new status to contacts live (no refresh) json(res, 200, { ok: true, status }); }); @@ -387,37 +387,37 @@ route('GET', '/api/push/vapid', async (req, res) => { json(res, 200, { enabled: PUSH.isEnabled(), key: PUSH.publicKey() }); }); route('POST', '/api/push/subscribe', async (req, res) => { - const u = currentUser(req); + const u = await currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); const sub = await readBody(req); if (!sub || !sub.endpoint || !sub.keys || !sub.keys.p256dh || !sub.keys.auth) return json(res, 400, { error: 'invalid subscription' }); - try { R.pushSubs.add({ id: A.id(), userId: u.id, endpoint: sub.endpoint, p256dh: sub.keys.p256dh, auth: sub.keys.auth }); } catch (_) {} + try { await R.pushSubs.add({ id: A.id(), userId: u.id, endpoint: sub.endpoint, p256dh: sub.keys.p256dh, auth: sub.keys.auth }); } catch (_) {} json(res, 200, { ok: true }); }); route('POST', '/api/push/unsubscribe', async (req, res) => { - const u = currentUser(req); + const u = await currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); const { endpoint } = await readBody(req); - if (endpoint) { try { R.pushSubs.removeByEndpoint(endpoint); } catch (_) {} } + if (endpoint) { try { await R.pushSubs.removeByEndpoint(endpoint); } catch (_) {} } json(res, 200, { ok: true }); }); // --- Native device tokens (mobile app): FCM (Android) / APNs (iOS). Registration is always // accepted and stored; delivery is a no-op until FCM/APNs creds are configured. --- route('POST', '/api/devices', async (req, res) => { - const u = currentUser(req); + const u = await currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); const { platform, token } = await readBody(req); if (!token || typeof token !== 'string') return json(res, 400, { error: 'token required' }); if (platform !== 'ios' && platform !== 'android') return json(res, 400, { error: 'platform must be ios or android' }); - try { R.deviceTokens.register({ id: A.id(), userId: u.id, tenantId: u.team_id, platform, token }); } catch (_) {} + try { await R.deviceTokens.register({ id: A.id(), userId: u.id, tenantId: u.team_id, platform, token }); } catch (_) {} json(res, 200, { ok: true }); }); route('POST', '/api/devices/remove', async (req, res) => { - const u = currentUser(req); + const u = await currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); const { token } = await readBody(req); - if (token) { try { R.deviceTokens.removeByToken(token); } catch (_) {} } + if (token) { try { await R.deviceTokens.removeByToken(token); } catch (_) {} } json(res, 200, { ok: true }); }); @@ -425,18 +425,18 @@ route('POST', '/api/devices/remove', async (req, res) => { route('POST', '/api/telemetry/install', async (req, res) => { const { installId, platform, appVersion, os } = await readBody(req); if (!installId || typeof installId !== 'string') return json(res, 400, { error: 'installId required' }); - const u = currentUser(req); // may be null on a pre-login launch β€” still counted + const u = await currentUser(req); // may be null on a pre-login launch β€” still counted try { - R.appInstalls.record({ id: A.id(), installId: installId.slice(0, 64), userId: u && u.id, userEmail: u && u.email, tenantId: u && u.team_id, platform: (platform || '').slice(0, 20), appVersion: (appVersion || '').slice(0, 20), os: (os || '').slice(0, 60) }); + 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) }); } catch (_) {} json(res, 200, { ok: true }); }); // Admin: who installed the app (this tenant). route('GET', '/api/admin/installs', async (req, res) => { - const u = currentUser(req); + const u = await currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); if (u.role !== 'admin') return json(res, 403, { error: 'admin only' }); - json(res, 200, R.appInstalls.listForTenant(u.team_id)); + json(res, 200, await R.appInstalls.listForTenant(u.team_id)); }); // ---------- BizGaze SSO: agent arrives already logged in ---------- @@ -453,21 +453,21 @@ route('GET', '/sso', async (req, res) => { if (sigBuf.length !== expBuf.length || !crypto.timingSafeEqual(sigBuf, expBuf)) return fail('Invalid SSO signature'); let p; try { p = JSON.parse(Buffer.from(payloadB64, 'base64url').toString()); } catch { return fail('Invalid SSO payload'); } if (!p.email || !p.exp || p.exp < Math.floor(now() / 1000)) return fail('SSO token expired'); - let u = R.users.byEmail(p.email); + let u = await R.users.byEmail(p.email); if (!u) { - const team = R.teams.first(); + const team = await R.teams.first(); if (!team) return fail('No team configured'); const { hash, salt } = A.hashPassword(A.token()); const role = (p.role === 'admin' || p.role === 'viewer') ? p.role : 'technician'; - const userId = R.users.create({ tenantId: team.id, email: p.email, hash, salt, role, name: p.name || null, mfaSecret: A.newMfaSecret() }); - u = R.users.byId(userId); + 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); audit({ team_id: team.id, user_id: userId, user_email: p.email, action: 'sso_user_created', detail: p.name || '' }); } else if (p.name && p.name !== u.name) { - R.users.setName(u.id, p.name); + await R.users.setName(u.id, p.name); } if (u.active === 0) return fail('Account deactivated'); const tok = A.token(); - R.authSessions.create({ token: tok, userId: u.id, mfaPassed: true, ttl: SESSION_TTL }); + await R.authSessions.create({ token: tok, userId: u.id, mfaPassed: true, ttl: SESSION_TTL }); audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'login', detail: 'via BizGaze SSO' }); const dest = '/connect' + (p.ticket ? ('?ticket=' + encodeURIComponent(p.ticket)) : ''); res.writeHead(302, { 'Set-Cookie': `sid=${tok}; HttpOnly; Path=/; Max-Age=${SESSION_TTL / 1000}`, Location: dest }); @@ -476,7 +476,7 @@ route('GET', '/sso', async (req, res) => { // Admin adds an agent login to their team route('POST', '/api/users', async (req, res) => { - const u = currentUser(req); + const u = await currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); if (u.role !== 'admin') return json(res, 403, { error: 'only admins can add agents' }); // With BizGaze as the sole IdP, logins are created in BizGaze, not here (creating local @@ -485,26 +485,26 @@ route('POST', '/api/users', async (req, res) => { if (BZ.isEnabled() && process.env.ALLOW_LOCAL_LOGIN !== '1') return json(res, 400, { error: 'Logins are managed in BizGaze. Add the user there; they appear here on first sign-in.' }); const { email, password, name, role } = await readBody(req); if (!email || !password) return json(res, 400, { error: 'email and temporary password required' }); - if (R.users.emailExists(email)) + if (await R.users.emailExists(email)) return json(res, 409, { error: 'email already registered' }); const { hash, salt } = A.hashPassword(password); const r = (role === 'admin' || role === 'viewer') ? role : 'technician'; - const userId = R.users.create({ tenantId: u.team_id, email, hash, salt, role: r, name: name || null, mfaSecret: A.newMfaSecret() }); + const userId = await R.users.create({ tenantId: u.team_id, email, hash, salt, role: r, name: name || null, mfaSecret: A.newMfaSecret() }); audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'agent_added', detail: email + ' (' + r + ')' }); json(res, 200, { ok: true, id: userId, email, role: r }); }); // List the team's agents route('GET', '/api/users', async (req, res) => { - const u = currentUser(req); + const u = await currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); - const rows = R.users.listByTenant(u.team_id); + const rows = await R.users.listByTenant(u.team_id); json(res, 200, rows); }); // First-login MFA self-setup: a logged-in (password ok) user who hasn't enabled MFA yet route('GET', '/api/mfa/setup', async (req, res) => { - const u = currentUser(req, { requireMfa: false }); + const u = await currentUser(req, { requireMfa: false }); if (!u) return json(res, 401, { error: 'unauthorized' }); if (u.mfa_enabled) return json(res, 400, { error: 'MFA already enabled' }); json(res, 200, { secret: u.mfa_secret, otpauthUrl: A.otpauthUrl(u.mfa_secret, u.email) }); @@ -512,47 +512,47 @@ route('GET', '/api/mfa/setup', async (req, res) => { // Admin manages an agent: reset password, rename, deactivate/activate, delete. route('POST', '/api/users/manage', async (req, res) => { - const u = currentUser(req); + const u = await currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); if (u.role !== 'admin') return json(res, 403, { error: 'only admins can manage agents' }); const { id, action, password, name } = await readBody(req); - const target = R.users.inTenant(id, u.team_id); + const target = await R.users.inTenant(id, u.team_id); if (!target) return json(res, 404, { error: 'no such agent' }); switch (action) { case 'reset-password': { if (!password || String(password).length < 8) return json(res, 400, { error: 'new password must be at least 8 characters' }); const { hash, salt } = A.hashPassword(password); - R.users.setPassword(target.id, hash, salt); - R.authSessions.deleteByUser(target.id); // force re-login - R.refreshTokens.revokeByUser(target.id); + await R.users.setPassword(target.id, hash, salt); + await R.authSessions.deleteByUser(target.id); // force re-login + await R.refreshTokens.revokeByUser(target.id); audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'agent_password_reset', detail: target.email }); return json(res, 200, { ok: true }); } case 'rename': { const clean = String(name || '').trim().slice(0, 60); if (!clean) return json(res, 400, { error: 'name required' }); - R.users.setName(target.id, clean); + await R.users.setName(target.id, clean); audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'agent_renamed', detail: target.email + ' -> ' + clean }); return json(res, 200, { ok: true, name: clean }); } case 'deactivate': { if (target.id === u.id) return json(res, 400, { error: 'you cannot deactivate your own account' }); - R.users.setActive(target.id, false); - R.authSessions.deleteByUser(target.id); - R.refreshTokens.revokeByUser(target.id); + await R.users.setActive(target.id, false); + await R.authSessions.deleteByUser(target.id); + await R.refreshTokens.revokeByUser(target.id); audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'agent_deactivated', detail: target.email }); return json(res, 200, { ok: true }); } case 'activate': { - R.users.setActive(target.id, true); + await R.users.setActive(target.id, true); audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'agent_activated', detail: target.email }); return json(res, 200, { ok: true }); } case 'delete': { if (target.id === u.id) return json(res, 400, { error: 'you cannot delete your own account' }); - R.authSessions.deleteByUser(target.id); - R.refreshTokens.revokeByUser(target.id); - R.users.remove(target.id); + await R.authSessions.deleteByUser(target.id); + await R.refreshTokens.revokeByUser(target.id); + await R.users.remove(target.id); audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'agent_deleted', detail: target.email }); return json(res, 200, { ok: true }); } @@ -562,7 +562,7 @@ route('POST', '/api/users/manage', async (req, res) => { // ---------- API keys (admin-managed, for third-party / system integrations) ---------- route('POST', '/api/keys', async (req, res) => { - const u = currentUser(req); + const u = await currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); if (u.role !== 'admin') return json(res, 403, { error: 'only admins can manage API keys' }); const { name, scopes } = await readBody(req); @@ -570,32 +570,32 @@ route('POST', '/api/keys', async (req, res) => { if (!sc.length) return json(res, 400, { error: 'at least one valid scope required (' + API_KEY_SCOPES.join(', ') + ')' }); const key = 'bzc_' + A.token(24); // shown once, never stored in plaintext const id = A.id(); - R.apiKeys.create({ id, tenantId: u.team_id, name: name || null, keyHash: A.hashToken(key), scopes: sc.join(','), createdBy: u.id }); + await R.apiKeys.create({ id, tenantId: u.team_id, name: name || null, keyHash: A.hashToken(key), scopes: sc.join(','), createdBy: u.id }); audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'api_key_created', detail: (name || id) + ' [' + sc.join(',') + ']' }); json(res, 200, { id, name: name || null, scopes: sc, key }); }); route('GET', '/api/keys', async (req, res) => { - const u = currentUser(req); + const u = await currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); if (u.role !== 'admin') return json(res, 403, { error: 'only admins can manage API keys' }); - json(res, 200, R.apiKeys.listByTenant(u.team_id)); + json(res, 200, await R.apiKeys.listByTenant(u.team_id)); }); route('POST', '/api/keys/revoke', async (req, res) => { - const u = currentUser(req); + const u = await currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); if (u.role !== 'admin') return json(res, 403, { error: 'only admins can manage API keys' }); const { id } = await readBody(req); if (!id) return json(res, 400, { error: 'id required' }); - R.apiKeys.revoke(id, u.team_id); + await R.apiKeys.revoke(id, u.team_id); audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'api_key_revoked', detail: id }); json(res, 200, { ok: true }); }); // ---------- Webhook subscriptions (admin-managed, outbound event delivery) ---------- route('POST', '/api/webhooks', async (req, res) => { - const u = currentUser(req); + const u = await currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); if (u.role !== 'admin') return json(res, 403, { error: 'only admins can manage webhooks' }); const { url, events, secret } = await readBody(req); @@ -604,33 +604,33 @@ route('POST', '/api/webhooks', async (req, res) => { if (!ev.length) ev = W.EVENTS.slice(); const sec = (secret && String(secret).length >= 8) ? String(secret) : A.token(24); const id = A.id(); - R.webhooks.create({ id, tenantId: u.team_id, url, secret: sec, events: ev.join(','), createdBy: u.id }); + await R.webhooks.create({ id, tenantId: u.team_id, url, secret: sec, events: ev.join(','), createdBy: u.id }); audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'webhook_created', detail: url + ' [' + ev.join(',') + ']' }); // Secret returned so the receiver can verify the X-BizGaze-Signature header. json(res, 200, { id, url, events: ev, secret: sec }); }); route('GET', '/api/webhooks', async (req, res) => { - const u = currentUser(req); + const u = await currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); if (u.role !== 'admin') return json(res, 403, { error: 'only admins can manage webhooks' }); - json(res, 200, R.webhooks.listByTenant(u.team_id)); + json(res, 200, await R.webhooks.listByTenant(u.team_id)); }); route('POST', '/api/webhooks/delete', async (req, res) => { - const u = currentUser(req); + const u = await currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); if (u.role !== 'admin') return json(res, 403, { error: 'only admins can manage webhooks' }); const { id } = await readBody(req); if (!id) return json(res, 400, { error: 'id required' }); - R.webhooks.remove(id, u.team_id); + await R.webhooks.remove(id, u.team_id); audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'webhook_deleted', detail: id }); json(res, 200, { ok: true }); }); // Available webhook event types (for integrators / an admin UI). route('GET', '/api/webhooks/events', async (req, res) => { - const u = currentUser(req); + const u = await currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); json(res, 200, { events: W.EVENTS }); }); @@ -639,66 +639,66 @@ route('GET', '/api/webhooks/events', async (req, res) => { route('GET', '/api/report', async (req, res) => { const q = new URLSearchParams(req.url.split('?')[1] || ''); let tenantId, agentEmail; - const u = currentUser(req); + const u = await currentUser(req); if (u) { // Admins see the whole team (and may filter by agent); everyone else only their own. tenantId = u.team_id; agentEmail = u.role !== 'admin' ? u.email : (q.get('agent') || null); } else { - const key = apiKeyFromReq(req); + const key = await apiKeyFromReq(req); if (!keyHasScope(key, 'report:read')) return json(res, 401, { error: 'unauthorized' }); - R.apiKeys.touch(key.id); + await R.apiKeys.touch(key.id); tenantId = key.teamId; // a key sees its whole tenant agentEmail = q.get('agent') || null; } const from = q.get('from') ? new Date(q.get('from') + 'T00:00:00').getTime() : null; const to = q.get('to') ? new Date(q.get('to') + 'T23:59:59').getTime() : null; - json(res, 200, R.sessionsLog.report({ tenantId, agentEmail, from, to })); + json(res, 200, await R.sessionsLog.report({ tenantId, agentEmail, from, to })); }); // List machines for the team (with live online status from signaling layer) route('GET', '/api/machines', async (req, res) => { - const u = currentUser(req); + const u = await currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); - const rows = R.machines.listByTenant(u.team_id); + const rows = await R.machines.listByTenant(u.team_id); json(res, 200, rows.map((m) => ({ ...m, online: onlineAgents.has(m.id) }))); }); // Create a machine enrollment token (admin/technician). Agent uses it to come online. route('POST', '/api/machines', async (req, res) => { - const u = currentUser(req); + const u = await currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); if (u.role === 'viewer') return json(res, 403, { error: 'forbidden' }); const { name, unattended } = await readBody(req); const enroll = A.token(); - const mId = R.machines.create({ tenantId: u.team_id, name: name || 'Unnamed PC', enrollToken: enroll, unattended: !!unattended }); + const mId = await R.machines.create({ tenantId: u.team_id, name: name || 'Unnamed PC', enrollToken: enroll, unattended: !!unattended }); audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, machine_id: mId, machine_name: name, action: 'machine_enrolled' }); json(res, 200, { id: mId, enrollToken: enroll }); }); route('GET', '/api/audit', async (req, res) => { - const u = currentUser(req); + const u = await currentUser(req); let tenantId; if (u) tenantId = u.team_id; else { - const key = apiKeyFromReq(req); + const key = await apiKeyFromReq(req); if (!keyHasScope(key, 'audit:read')) return json(res, 401, { error: 'unauthorized' }); - R.apiKeys.touch(key.id); + await R.apiKeys.touch(key.id); tenantId = key.teamId; } - json(res, 200, R.audit.listByTenant(tenantId)); + json(res, 200, await R.audit.listByTenant(tenantId)); }); // ---------- session recording: upload (agent) ---------- const MAX_REC_BYTES = 500 * 1024 * 1024; // 500 MB safety cap route('POST', '/api/recording', async (req, res) => { - const u = currentUser(req); + const u = await currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); const params = new URLSearchParams(req.url.split('?')[1] || ''); const sid = params.get('sessionId'); const ext = params.get('ext') === 'mp4' ? 'mp4' : 'webm'; // container chosen by the recorder if (!sid) return json(res, 400, { error: 'sessionId required' }); - const row = R.sessionsLog.byIdInTenant(sid, u.team_id); + const row = await R.sessionsLog.byIdInTenant(sid, u.team_id); if (!row) return json(res, 404, { error: 'no such session' }); const chunks = []; let total = 0, aborted = false; req.on('data', (c) => { total += c.length; if (total > MAX_REC_BYTES) { aborted = true; req.destroy(); return; } chunks.push(c); }); @@ -707,7 +707,7 @@ route('POST', '/api/recording', async (req, res) => { const fname = sid + '.' + ext; try { fs.writeFileSync(path.join(REC_DIR, fname), Buffer.concat(chunks)); - R.sessionsLog.setRecording(sid, fname); + await R.sessionsLog.setRecording(sid, fname); audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'recording_saved', detail: 'session ' + sid }); json(res, 200, { ok: true }); } catch (e) { json(res, 500, { error: 'could not save recording' }); } @@ -716,11 +716,11 @@ route('POST', '/api/recording', async (req, res) => { }); route('POST', '/api/transcript', async (req, res) => { - const u = currentUser(req); + const u = await currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); const sid = new URLSearchParams(req.url.split('?')[1] || '').get('sessionId'); if (!sid) return json(res, 400, { error: 'sessionId required' }); - const row = R.sessionsLog.byIdInTenant(sid, u.team_id); + const row = await R.sessionsLog.byIdInTenant(sid, u.team_id); if (!row) return json(res, 404, { error: 'no such session' }); const chunks = []; let total = 0, aborted = false; req.on('data', (c) => { total += c.length; if (total > 5 * 1024 * 1024) { aborted = true; req.destroy(); return; } chunks.push(c); }); @@ -729,7 +729,7 @@ route('POST', '/api/transcript', async (req, res) => { const fname = sid + '.txt'; try { fs.writeFileSync(path.join(TRANS_DIR, fname), Buffer.concat(chunks)); - R.sessionsLog.setTranscript(sid, fname); + await R.sessionsLog.setTranscript(sid, fname); json(res, 200, { ok: true }); } catch (e) { json(res, 500, { error: 'could not save transcript' }); } }); @@ -739,9 +739,9 @@ route('POST', '/api/transcript', async (req, res) => { // ---------- Chat (persistent 1:1 messaging between team members) ---------- // Contacts = other active users in the tenant (the people you can message). route('GET', '/api/messages/contacts', async (req, res) => { - const u = currentUser(req); + const u = await currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); - const rows = R.users.listByTenant(u.team_id).filter((x) => x.id !== u.id && x.active !== 0); + const rows = await R.users.listByTenant(u.team_id).filter((x) => x.id !== u.id && x.active !== 0); const cAv = avatarsFor(u.team_id); // duplicate-row DP fallback 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' }))); }); @@ -749,13 +749,13 @@ route('GET', '/api/messages/contacts', async (req, res) => { // Cross-tenant people search via the BizGaze directory (token stays server-side). Results are // tagged onConnect=true when the person already has a Connect account in this tenant (chat-ready). route('GET', '/api/directory/search', async (req, res) => { - const u = currentUser(req); + const u = await currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); const q = (new URLSearchParams(req.url.split('?')[1] || '').get('q') || '').trim(); if (q.length < 2) return json(res, 200, []); const results = await require('./directory').search(q); // Map directory people to existing Connect users in this tenant (by email) so they're chat-ready. - const mine = R.users.listByTenant(u.team_id).filter((x) => x.id !== u.id && x.active !== 0); + const mine = await R.users.listByTenant(u.team_id).filter((x) => x.id !== u.id && x.active !== 0); const byEmail = new Map(mine.map((x) => [(x.email || '').toLowerCase(), x])); const out = results.map((p) => { const local = p.email ? byEmail.get(p.email.toLowerCase()) : null; @@ -767,25 +767,25 @@ route('GET', '/api/directory/search', async (req, res) => { // Conversation list: DMs (per counterparty) + group conversations, merged + sorted. route('GET', '/api/messages/conversations', async (req, res) => { - const u = currentUser(req); + const u = await currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); const names = {}; const avatars = {}; const statuses = {}; const seen = {}; - for (const x of 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; } + 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; } Object.assign(avatars, avatarsFor(u.team_id)); // same person / two rows β†’ borrow the DP (see avatarsFor) - const favs = new Set(R.favorites.forUser(u.id)); + const favs = new Set(await R.favorites.forUser(u.id)); const inCall = new Set(); for (const [, peers] of meetingRooms) { for (const [, p] of peers) { if (p.ws && p.ws._meetingUserId) inCall.add(p.ws._meetingUserId); } } // DMs const byOther = new Map(); - for (const m of R.messages.recentFor(u.team_id, u.id)) { + for (const m of await R.messages.recentFor(u.team_id, u.id)) { 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). - const other = (() => { try { return R.users.resolve(raw) || raw; } catch (_) { return raw; } })(); + const other = (() => { try { return await R.users.resolve(raw) || raw; } catch (_) { return raw; } })(); if (!byOther.has(other)) byOther.set(other, { other, last: m, unread: 0 }); if (m.recipient_id === u.id && (m.sender_id === raw || m.sender_id === other) && !m.read_at) byOther.get(other).unread++; } @@ -798,23 +798,23 @@ route('GET', '/api/messages/conversations', async (req, res) => { last_status: c.last.sender_id === u.id ? (c.last.read_at ? 'read' : (c.last.delivered_at ? 'delivered' : 'sent')) : null, // tick for my last message }; }); // Groups - const groupItems = R.conversations.listForUser(u.team_id, u.id).map((g) => { - const last = R.messages.lastInConversation(g.id); - const since = R.conversations.lastReadAt(g.id, u.id); - const members = R.conversations.members(g.id); + const groupItems = await R.conversations.listForUser(u.team_id, u.id).map((g) => { + 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); // Group read tick for MY last message: read = every other member has read it, delivered = some // have, else sent. Same three states as DMs, so the sidebar renders them identically. let gStatus = null; if (last && last.sender_id === u.id) { const others = members.filter((id) => id !== u.id).length; - const seen = R.conversations.memberReads(g.id).filter((r) => r.user_id !== u.id && r.last_read_at >= last.created_at).length; + const seen = await R.conversations.memberReads(g.id).filter((r) => r.user_id !== u.id && r.last_read_at >= last.created_at).length; gStatus = (others > 0 && seen >= others) ? 'read' : (seen > 0 ? 'delivered' : 'sent'); } return { kind: 'group', id: g.id, name: g.name || 'Group', members: members.length, avatar: g.avatar_id ? ('/files/' + g.avatar_id) : null, favorite: favs.has('group:' + g.id), callActive: groupCalls.has(g.id), callRoom: (groupCalls.get(g.id) || {}).room || null, last_body: last ? (last.body || (last.attachment_id ? 'πŸ“Ž Attachment' : '')) : '', last_at: last ? last.created_at : g.created_at, - last_from_me: last ? last.sender_id === u.id : false, unread: last ? R.messages.unreadInConversation(g.id, u.id, since) : 0, + last_from_me: last ? last.sender_id === u.id : false, unread: last ? await R.messages.unreadInConversation(g.id, u.id, since) : 0, last_status: gStatus, }; }); @@ -823,7 +823,7 @@ route('GET', '/api/messages/conversations', async (req, res) => { // Full thread: a DM (?with=userId) or a group (?group=conversationId). Marks it read. route('GET', '/api/messages/thread', async (req, res) => { - const u = currentUser(req); + const u = await currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); const q = new URLSearchParams(req.url.split('?')[1] || ''); const peek = !!q.get('peek'); // prefetch only β€” do NOT mark the conversation read @@ -831,16 +831,16 @@ route('GET', '/api/messages/thread', async (req, res) => { const names = namesFor(u.team_id); const group = q.get('group'); if (group) { - if (!R.conversations.isMember(group, u.id)) return json(res, 403, { error: 'not a member of this group' }); - const rows = R.messages.threadByConversation(group, 40, before); // page size (latest 40 / older via ?before) β€” matches client PAGE for smooth open + lazy load + 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 if (!peek && !before) { - R.conversations.markRead(group, u.id); + await R.conversations.markRead(group, u.id); const evt = { type: 'group-read', group, by: u.id, byName: names[u.id] || u.email, at: now() }; - for (const mid of R.conversations.members(group)) { if (mid !== u.id) { try { CHAT.pushToUser(mid, evt); } catch (_) {} } } + for (const mid of await R.conversations.members(group)) { if (mid !== u.id) { try { CHAT.pushToUser(mid, evt); } catch (_) {} } } try { CHAT.pushToUser(u.id, { type: 'notif-clear', kind: 'group', id: group }); } catch (_) {} // #13 } - const rxBy = groupReactions(R.reactions.forConversation(group), u.id, names); - const reads = R.conversations.memberReads(group); // ALL members' read times (#2: seen-by visible to everyone) + 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) return json(res, 200, rows.map((m) => { const d = buildMsgDTO(m, names, u.id); d.fromName = names[m.sender_id] || ''; d.reactions = dtoReactions(rxBy, m.id); // Who has read this message (excluding its sender) β€” shown to every member, not just the sender. @@ -848,19 +848,19 @@ route('GET', '/api/messages/thread', async (req, res) => { return d; })); } - const other = R.users.resolve(q.get('with')); // follow a merge redirect so a stale peer id still loads the thread + const other = await R.users.resolve(q.get('with')); // follow a merge redirect so a stale peer id still loads the thread if (!other) return json(res, 400, { error: 'with or group required' }); - if (!R.users.inTenant(other, u.team_id)) return json(res, 404, { error: 'no such contact' }); - const rows = R.messages.thread(u.team_id, u.id, other, 40, before); // page size (latest 40 / older via ?before) β€” matches client PAGE for smooth open + lazy load - if (!peek && !before) { 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(R.reactions.forPair(u.team_id, u.id, other), u.id, names); + 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); return json(res, 200, rows.map((m) => { const d = buildMsgDTO(m, names, u.id); d.reactions = dtoReactions(rxBy, m.id); return d; })); }); // 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) => { - const u = currentUser(req); + const u = await currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); const q = new URLSearchParams(req.url.split('?')[1] || ''); const term = String(q.get('q') || '').trim(); @@ -869,76 +869,76 @@ route('GET', '/api/messages/search', async (req, res) => { const group = q.get('group'); let rows; if (group) { - if (!R.conversations.isMember(group, u.id)) return json(res, 403, { error: 'not a member of this group' }); - rows = R.messages.searchConversation(group, like); + 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); } else { - const other = R.users.resolve(q.get('with')); - if (!other || !R.users.inTenant(other, u.team_id)) return json(res, 404, { error: 'no such contact' }); - rows = R.messages.searchThread(u.team_id, u.id, other, like); + 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); } return json(res, 200, { hits: rows.map((m) => ({ id: m.id, at: m.created_at })) }); }); // Create a group conversation with the given members (creator is always added). route('POST', '/api/groups', async (req, res) => { - const u = currentUser(req); + const u = await currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); const { name, memberIds } = await readBody(req); const nm = String(name || '').trim().slice(0, 80); if (!nm) return json(res, 400, { error: 'group name required' }); - const ids = (Array.isArray(memberIds) ? memberIds : []).filter((x) => typeof x === 'string' && x !== u.id && R.users.inTenant(x, u.team_id)); + const ids = (Array.isArray(memberIds) ? memberIds : []).filter((x) => typeof x === 'string' && x !== u.id && await R.users.inTenant(x, u.team_id)); const id = A.id(); - R.conversations.create({ id, teamId: u.team_id, name: nm, createdBy: u.id }); - R.conversations.addMember(id, u.id, true); // creator is the first admin - for (const mid of ids) R.conversations.addMember(id, mid); + 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); audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'group_created', detail: nm + ' (' + (ids.length + 1) + ' members)' }); json(res, 200, { id, name: nm, members: ids.length + 1 }); }); // Members of a group (id + name), for the group header / member list. route('GET', '/api/groups/members', async (req, res) => { - const u = currentUser(req); + const u = await currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); const gid = new URLSearchParams(req.url.split('?')[1] || '').get('group'); - if (!gid || !R.conversations.isMember(gid, u.id)) return json(res, 403, { error: 'not a member' }); + if (!gid || !await R.conversations.isMember(gid, u.id)) return json(res, 403, { error: 'not a member' }); const names = {}; const avatars = {}; - for (const x of R.users.listByTenant(u.team_id)) { names[x.id] = x.name || x.email; } + for (const x of await R.users.listByTenant(u.team_id)) { names[x.id] = x.name || x.email; } Object.assign(avatars, avatarsFor(u.team_id)); - const adminSet = new Set(R.conversations.admins(gid)); - json(res, 200, R.conversations.members(gid).map((mid) => ({ id: mid, name: names[mid] || 'Unknown', avatar: avatars[mid] || null, admin: adminSet.has(mid) }))); + const adminSet = new Set(await R.conversations.admins(gid)); + json(res, 200, await R.conversations.members(gid).map((mid) => ({ id: mid, name: names[mid] || 'Unknown', avatar: avatars[mid] || null, admin: adminSet.has(mid) }))); }); // Full group info: name, creator flag, members (with isMe). route('GET', '/api/groups/info', async (req, res) => { - const u = currentUser(req); + const u = await currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); const gid = new URLSearchParams(req.url.split('?')[1] || '').get('group'); - if (!gid || !R.conversations.isMember(gid, u.id)) return json(res, 403, { error: 'not a member' }); - const g = R.conversations.byId(gid); - const tenantUsers = R.users.listByTenant(u.team_id); + 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); const names = {}; const avatars = {}; for (const x of tenantUsers) { names[x.id] = x.name || x.email; } Object.assign(avatars, avatarsFor(u.team_id)); - const adminSet = new Set(R.conversations.admins(gid)); + const adminSet = new Set(await R.conversations.admins(gid)); json(res, 200, { id: gid, name: g.name || 'Group', createdBy: g.created_by, isCreator: g.created_by === u.id, isAdmin: adminSet.has(u.id), adminOnly: !!g.admin_only, callActive: groupCalls.has(gid), callRoom: (groupCalls.get(gid) || {}).room || null, createdByName: names[g.created_by] || 'Someone', createdAt: g.created_at, avatar: g.avatar_id ? ('/files/' + g.avatar_id) : null, - members: R.conversations.members(gid).map((mid) => ({ id: mid, name: names[mid] || 'Unknown', avatar: avatars[mid] || null, isMe: mid === u.id, admin: adminSet.has(mid) })), + 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) })), }); }); // Rename a group (any member). route('POST', '/api/groups/rename', async (req, res) => { - const u = currentUser(req); + const u = await currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); const { group, name } = await readBody(req); - if (!group || !R.conversations.isMember(group, u.id)) return json(res, 403, { error: 'not a member' }); + if (!group || !await R.conversations.isMember(group, u.id)) return json(res, 403, { error: 'not a member' }); const nm = String(name || '').trim().slice(0, 80); if (!nm) return json(res, 400, { error: 'group name required' }); - R.conversations.rename(group, nm); + await R.conversations.rename(group, nm); postSystemMessage(group, u.team_id, (u.name || u.email) + ' renamed the group to β€œ' + nm + '”'); audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'group_renamed', detail: nm }); json(res, 200, { ok: true, name: nm }); @@ -947,31 +947,31 @@ route('POST', '/api/groups/rename', async (req, res) => { // Start (or join) the group's shared call β€” returns the mesh room to connect to. No code: // members see a Join button driven by the live call state. route('POST', '/api/groups/call/start', async (req, res) => { - const u = currentUser(req); + const u = await currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); const { group } = await readBody(req); - if (!group || !R.conversations.isMember(group, u.id)) return json(res, 403, { error: 'not a member of this group' }); + if (!group || !await R.conversations.isMember(group, u.id)) return json(res, 403, { error: 'not a member of this group' }); const r = CALLS.startGroupCall(group, u.team_id, u); json(res, 200, r); }); // Start (or join) a 1:1 call with another user. route('POST', '/api/calls/dm/start', async (req, res) => { - const u = currentUser(req); + const u = await currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); const { to } = await readBody(req); - if (!to || !R.users.inTenant(to, u.team_id)) return json(res, 404, { error: 'no such contact' }); + if (!to || !await R.users.inTenant(to, u.team_id)) return json(res, 404, { error: 'no such contact' }); json(res, 200, CALLS.startDmCall(u, to, u.team_id)); }); // Invite more people into the call I'm in (turns a 1:1 into multi-party). Pushes them an // incoming-call notification carrying the room to join. route('POST', '/api/calls/invite', async (req, res) => { - const u = currentUser(req); + const u = await currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); const { room, userIds } = await readBody(req); if (!room || !meetingRooms.has(String(room))) return json(res, 404, { error: 'call not found' }); - const ids = (Array.isArray(userIds) ? userIds : []).filter((x) => typeof x === 'string' && x !== u.id && R.users.inTenant(x, u.team_id)); + const ids = (Array.isArray(userIds) ? userIds : []).filter((x) => typeof x === 'string' && x !== u.id && await R.users.inTenant(x, u.team_id)); for (const id of ids) { try { CHAT.pushToUser(id, { type: 'call-invite', room: String(room), byName: (u.name || u.email) }); } catch (_) {} } json(res, 200, { ok: true, invited: ids.length }); }); @@ -984,7 +984,7 @@ route('GET', '/api/meetings/config', (req, res) => { // #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) => { - const u = currentUser(req); + const u = await currentUser(req); 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] || ''); @@ -1022,7 +1022,7 @@ route('GET', '/api/build', (req, res) => json(res, 200, { build: APP_BUILD })); // 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) => { - const u = currentUser(req); + const u = await currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); if (!LIVEKIT_ENABLED) return json(res, 501, { error: 'sfu not configured' }); const { room } = await readBody(req); @@ -1047,7 +1047,7 @@ route('POST', '/api/meetings/guest-token', async (req, res) => { // anyone's in them (they vanish from meetingRooms when empty), which is its own natural expiry. const sched = (() => { try { - const s = R.scheduledMeetings.byCode(rm); + const s = await R.scheduledMeetings.byCode(rm); if (!s || s.ended_at) return false; const endBy = s.scheduled_at + ((s.duration_mins || 60) * 60000) + (2 * 3600000); return Date.now() <= endBy; @@ -1064,7 +1064,7 @@ route('POST', '/api/meetings/guest-token', async (req, res) => { // Decline an incoming 1:1 call: drops the caller, posts a "Call declined" line, clears the call. route('POST', '/api/calls/decline', async (req, res) => { - const u = currentUser(req); + const u = await currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); const { room } = await readBody(req); if (!room) return json(res, 400, { error: 'room required' }); @@ -1073,28 +1073,28 @@ route('POST', '/api/calls/decline', async (req, res) => { // Toggle "only admins can add/remove members" (any admin). route('POST', '/api/groups/admin-only', async (req, res) => { - const u = currentUser(req); + const u = await currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); const { group, value } = await readBody(req); - const g = group && R.conversations.byId(group); - if (!g || g.team_id !== u.team_id || !R.conversations.isMember(group, u.id)) return json(res, 403, { error: 'not a member' }); - if (!R.conversations.isAdmin(group, u.id)) return json(res, 403, { error: 'only a group admin can change this' }); - R.conversations.setAdminOnly(group, !!value); + 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); postSystemMessage(group, u.team_id, (u.name || u.email) + (value ? ' restricted adding members to admins only' : ' allowed everyone to add members')); json(res, 200, { ok: true, adminOnly: !!value }); }); // Promote/demote a member as admin (#9, multiple admins allowed). Only an admin can change roles. route('POST', '/api/groups/admin', async (req, res) => { - const u = currentUser(req); + const u = await currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); const { group, userId, value } = await readBody(req); - const g = group && R.conversations.byId(group); - if (!g || g.team_id !== u.team_id || !R.conversations.isMember(group, u.id)) return json(res, 403, { error: 'not a member' }); - if (!R.conversations.isAdmin(group, u.id)) return json(res, 403, { error: 'only a group admin can change roles' }); - if (!userId || !R.conversations.isMember(group, userId)) return json(res, 404, { error: 'not a member of this group' }); - if (!value && R.conversations.admins(group).length <= 1 && R.conversations.isAdmin(group, userId)) return json(res, 400, { error: 'a group must have at least one admin' }); - R.conversations.setMemberAdmin(group, userId, !!value); + const 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' }); + 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' }); + await R.conversations.setMemberAdmin(group, userId, !!value); const names = namesFor(u.team_id); postSystemMessage(group, u.team_id, (u.name || u.email) + (value ? ' made ' + (names[userId] || 'someone') + ' an admin' : ' removed ' + (names[userId] || 'someone') + ' as admin')); pushGroupUpdate(group); @@ -1105,30 +1105,30 @@ route('POST', '/api/groups/admin', async (req, res) => { // Set a group's image. Pass an attachmentId from /api/messages/upload (must be an image // the caller uploaded). Pass null/empty to clear it. route('POST', '/api/groups/avatar', async (req, res) => { - const u = currentUser(req); + const u = await currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); const { group, attachmentId } = await readBody(req); - if (!group || !R.conversations.isMember(group, u.id)) return json(res, 403, { error: 'not a member' }); + if (!group || !await R.conversations.isMember(group, u.id)) return json(res, 403, { error: 'not a member' }); if (attachmentId) { - const a = R.attachments.byId(attachmentId); + const a = await R.attachments.byId(attachmentId); if (!a || a.team_id !== u.team_id || a.uploader_id !== u.id) return json(res, 400, { error: 'invalid attachment' }); if (!/^image\//.test(a.mime || '')) return json(res, 400, { error: 'group image must be an image file' }); } - R.conversations.setAvatar(group, attachmentId || null); + await R.conversations.setAvatar(group, attachmentId || null); audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'group_avatar_set', detail: group }); json(res, 200, { ok: true, avatar: attachmentId ? ('/files/' + attachmentId) : null }); }); // Add members to a group (any member). route('POST', '/api/groups/add', async (req, res) => { - const u = currentUser(req); + const u = await currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); const { group, memberIds } = await readBody(req); - if (!group || !R.conversations.isMember(group, u.id)) return json(res, 403, { error: 'not a member' }); - const gA = R.conversations.byId(group); - if (gA && gA.admin_only && !R.conversations.isAdmin(group, u.id)) return json(res, 403, { error: 'Only a group admin can add members' }); - const ids = (Array.isArray(memberIds) ? memberIds : []).filter((x) => typeof x === 'string' && R.users.inTenant(x, u.team_id) && !R.conversations.isMember(group, x)); - for (const mid of ids) R.conversations.addMember(group, mid); + if (!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' }); + const ids = (Array.isArray(memberIds) ? memberIds : []).filter((x) => typeof x === 'string' && await R.users.inTenant(x, u.team_id) && !await R.conversations.isMember(group, x)); + for (const mid of ids) await R.conversations.addMember(group, mid); if (ids.length) { const names = namesFor(u.team_id); postSystemMessage(group, u.team_id, (u.name || u.email) + ' added ' + ids.map((x) => names[x] || 'someone').join(', ')); @@ -1140,33 +1140,33 @@ route('POST', '/api/groups/add', async (req, res) => { // Remove a member (creator removes others; anyone can remove themselves = leave). route('POST', '/api/groups/remove', async (req, res) => { - const u = currentUser(req); + const u = await currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); const { group, userId, newAdmin } = await readBody(req); - if (!group || !R.conversations.isMember(group, u.id)) return json(res, 403, { error: 'not a member' }); + if (!group || !await R.conversations.isMember(group, u.id)) return json(res, 403, { error: 'not a member' }); const target = userId || u.id; const isSelf = target === u.id; // Leaving (self) is always allowed; removing others requires admin when admin_only is on. - if (!isSelf) { const gR = R.conversations.byId(group); if (gR && gR.admin_only && !R.conversations.isAdmin(group, u.id)) return json(res, 403, { error: 'Only a group admin can remove members' }); } - const wasAdmin = R.conversations.isAdmin(group, target); + 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); // #10: the last admin must hand off to a chosen successor before leaving (no auto-assign). - const others = R.conversations.members(group).filter((m) => m !== target); - if (wasAdmin && others.length && R.conversations.admins(group).filter((a) => a !== target).length === 0) { - if (!newAdmin || !R.conversations.isMember(group, newAdmin) || newAdmin === target) return json(res, 400, { error: 'NEED_ADMIN', message: 'Choose a member to be the new admin before leaving.' }); - R.conversations.setMemberAdmin(group, newAdmin, true); + const 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) { + 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); const names0 = namesFor(u.team_id); postSystemMessage(group, u.team_id, (names0[newAdmin] || 'A member') + ' is now an admin'); try { CHAT.pushToUser(newAdmin, { type: 'group-role', group, admin: true }); } catch (_) {} } // Post the activity BEFORE removing, so the removed person's tab also receives it. - if (target !== u.id && R.conversations.isMember(group, target)) { + if (target !== u.id && await R.conversations.isMember(group, target)) { const names = namesFor(u.team_id); postSystemMessage(group, u.team_id, (u.name || u.email) + ' removed ' + (names[target] || 'someone')); } else if (isSelf) { postSystemMessage(group, u.team_id, (u.name || u.email) + ' left the group'); } - R.conversations.removeMember(group, target); - if (R.conversations.members(group).length === 0) { R.conversations.remove(group); } // drop empty groups + await R.conversations.removeMember(group, target); + if (await R.conversations.members(group).length === 0) { await R.conversations.remove(group); } // drop empty groups else pushGroupUpdate(group, [target]); // live member-count refresh; the removed person drops the group audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: isSelf ? 'group_left' : 'group_member_removed', detail: group }); json(res, 200, { ok: true, left: isSelf }); @@ -1176,7 +1176,7 @@ route('POST', '/api/groups/remove', async (req, res) => { // Schedule a call (optionally tied to a group). Gets a stable room code so it can be // joined later; the live mesh room is created on first join. Announces in the group chat. route('POST', '/api/meetings/schedule', async (req, res) => { - const u = currentUser(req); + const u = await currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); const { group, title, description, scheduledAt, whenText, participants, participantEmails, durationMins, recurrence, lobby } = await readBody(req); const t = String(title || '').trim().slice(0, 120); @@ -1188,24 +1188,24 @@ route('POST', '/api/meetings/schedule', async (req, res) => { const recur = Array.isArray(recurrence) ? [...new Set(recurrence.map(Number).filter((d) => d >= 0 && d <= 6))] : []; let groupId = null; if (group) { - if (!R.conversations.isMember(group, u.id)) return json(res, 403, { error: 'not a member of this group' }); + if (!await R.conversations.isMember(group, u.id)) return json(res, 403, { error: 'not a member of this group' }); groupId = group; } const desc = String(description || '').trim().slice(0, 1000); // Invited participants: tenant users, excluding the host (creator). - const invited = [...new Set((Array.isArray(participants) ? participants : []).filter((x) => typeof x === 'string' && x !== u.id && R.users.inTenant(x, u.team_id)))]; + const invited = [...new Set((Array.isArray(participants) ? participants : []).filter((x) => typeof x === 'string' && x !== u.id && await R.users.inTenant(x, u.team_id)))]; // 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); - let code; do { code = A.numericCode(6); } while (R.scheduledMeetings.byCode(code) || meetingRooms.has(code)); + let code; do { code = A.numericCode(6); } while (await R.scheduledMeetings.byCode(code) || meetingRooms.has(code)); const id = A.id(); - R.scheduledMeetings.create({ id, teamId: u.team_id, groupId, roomCode: code, title: t, description: desc, scheduledAt: when, createdBy: u.id, participants: invited, durationMins: dur, recurrence: recur, guestEmails, lobby: lobby !== false }); + 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 }); audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'meeting_scheduled', detail: t }); const label = (typeof whenText === 'string' && whenText.trim()) ? whenText.trim() : new Date(when).toLocaleString(); if (groupId) { const mid = A.id(); - R.messages.send({ id: mid, teamId: u.team_id, senderId: u.id, recipientId: '', body: 'πŸ“… Scheduled a call: ' + t + ' β€” ' + label, conversationId: groupId }); - const dto = buildMsgDTO(R.messages.byId(mid), namesFor(u.team_id), u.id); dto.fromName = u.name || u.email; - for (const m of R.conversations.members(groupId)) { try { CHAT.pushToUser(m, { type: 'chat-message', message: dto }); } catch (_) {} } + await R.messages.send({ id: mid, teamId: u.team_id, senderId: u.id, recipientId: '', body: 'πŸ“… Scheduled a call: ' + t + ' β€” ' + label, conversationId: groupId }); + const dto = buildMsgDTO(await R.messages.byId(mid), namesFor(u.team_id), u.id); dto.fromName = u.name || u.email; + for (const m of await R.conversations.members(groupId)) { try { CHAT.pushToUser(m, { type: 'chat-message', message: dto }); } catch (_) {} } } // Invitation notification to each invited participant. const inviteEvt = { type: 'meeting-invite', meeting: { id, title: t, scheduledAt: when, whenText: label, room: code, by: u.name || u.email } }; @@ -1216,7 +1216,7 @@ route('POST', '/api/meetings/schedule', async (req, res) => { if (mailer.isEnabled() && (guestEmails.length || invited.length)) { const link = PUBLIC_BASE_URL + '/home?meet=' + code; const nameByEmail = {}; const emails = new Set(guestEmails); - for (const x of R.users.listByTenant(u.team_id)) { if (x.email) nameByEmail[x.id] = x.email; } + for (const x of await R.users.listByTenant(u.team_id)) { if (x.email) nameByEmail[x.id] = x.email; } 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 }); @@ -1229,11 +1229,11 @@ route('POST', '/api/meetings/schedule', async (req, res) => { // List the meetings this user can see, bucketed into running / upcoming / past. route('GET', '/api/meetings', async (req, res) => { - const u = currentUser(req); + const u = await currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); const names = namesFor(u.team_id); const nowTs = Date.now(); - const rows = R.scheduledMeetings.listForUser(u.team_id, u.id).map((s) => { + const rows = await R.scheduledMeetings.listForUser(u.team_id, u.id).map((s) => { let recur = []; try { recur = JSON.parse(s.recurrence || '[]'); } catch (_) {} let schedAt = s.scheduled_at; const live = meetingRooms.get(s.room_code); @@ -1241,7 +1241,7 @@ route('GET', '/api/meetings', async (req, res) => { // Recurring + its window has passed (and not live/cancelled) β†’ roll forward to the next occurrence. if (recur.length && !running && !s.cancelled && !s.ended_at && nowTs > schedAt + ((s.duration_mins || 60) * 60000)) { const nxt = nextOccurrence(schedAt, recur, nowTs); - if (nxt !== schedAt) { try { R.scheduledMeetings.reschedule(s.id, u.team_id, nxt); } catch (_) {} schedAt = nxt; } + if (nxt !== schedAt) { try { await R.scheduledMeetings.reschedule(s.id, u.team_id, nxt); } catch (_) {} schedAt = nxt; } } const endTime = schedAt + ((s.duration_mins || 60) * 60000); // can't be started past this (#3) let status = 'upcoming'; @@ -1254,7 +1254,7 @@ route('GET', '/api/meetings', async (req, res) => { return { id: s.id, roomCode: s.room_code, title: s.title, description: s.description || '', scheduledAt: schedAt, groupId: s.group_id, link: PUBLIC_BASE_URL + '/home?meet=' + s.room_code, - groupName: s.group_id ? ((R.conversations.byId(s.group_id) || {}).name || 'Group') : null, + groupName: s.group_id ? ((await R.conversations.byId(s.group_id) || {}).name || 'Group') : null, createdBy: s.created_by, createdByName: names[s.created_by] || '', canManage: s.created_by === u.id, isHost: s.created_by === u.id, invited: invited.map((pid) => names[pid] || 'Someone'), invitedIds: invited, guestEmails, lobby: s.lobby !== 0, durationMins: s.duration_mins || null, recurrence: recur, recurrenceLabel: recurrenceLabel(recur), @@ -1268,14 +1268,14 @@ route('GET', '/api/meetings', async (req, res) => { const canSeeRec = (r) => { if (r.kind === 'transcript') return r.created_by === u.id; // transcripts are private to their owner if (r.created_by === u.id) return true; - if (r.group_id) return R.conversations.isMember(r.group_id, u.id); - if (r.meeting_id) { const s = R.scheduledMeetings.byId(r.meeting_id); if (s) return s.created_by === u.id || (s.participants && s.participants.includes('"' + u.id + '"')); } + 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 + '"')); } return false; }; const schedById = new Map(rows.map((m) => [m.id, m])); const schedByRoom = new Map(rows.map((m) => [m.roomCode, m])); const unsched = new Map(); - for (const r of R.recordings.forTeam(u.team_id)) { + for (const r of await R.recordings.forTeam(u.team_id)) { if (!canSeeRec(r)) continue; const m = (r.meeting_id && schedById.get(r.meeting_id)) || (r.room && schedByRoom.get(r.room)); if (m) { m.recordings.push(recDTO(r)); } @@ -1286,7 +1286,7 @@ route('GET', '/api/meetings', async (req, res) => { return { id: 'rec-' + (f.room || f.id), roomCode: f.room || '', title: f.title || 'Meeting', description: '', scheduledAt: f.created_at, groupId: f.group_id || null, - groupName: f.group_id ? ((R.conversations.byId(f.group_id) || {}).name || 'Group') : null, + groupName: f.group_id ? ((await R.conversations.byId(f.group_id) || {}).name || 'Group') : null, createdBy: f.created_by, createdByName: f.created_by_name || '', canManage: false, isHost: false, invited: [], status: 'past', inCall: 0, recordings: list.map(recDTO), }; @@ -1311,7 +1311,7 @@ route('GET', '/api/meetings', async (req, res) => { // 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 = []; - for (const c of R.callHistory.forTeam(u.team_id)) { + for (const c of await R.callHistory.forTeam(u.team_id)) { 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 @@ -1344,7 +1344,7 @@ route('GET', '/api/meetings', async (req, res) => { // Host uploads an in-browser meeting recording (webm). Stored + indexed so it shows under Past meetings. route('POST', '/api/meetings/recording', (req, res) => { - const u = currentUser(req); + const u = await currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); const params = new URLSearchParams(req.url.split('?')[1] || ''); const room = params.get('room') || ''; @@ -1356,12 +1356,12 @@ route('POST', '/api/meetings/recording', (req, res) => { if (aborted) return json(res, 413, { error: 'recording too large' }); if (!total) return json(res, 400, { error: 'empty recording' }); const ctx = CALLS.meetingContext(room); - const groupId = ctx.groupId || (groupHint && R.conversations.isMember(groupHint, u.id) ? groupHint : null); - let title = ctx.title; if ((!title || title === 'Meeting') && groupId) { const g = R.conversations.byId(groupId); if (g) title = g.name || 'Group'; } + const 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'; } const id = A.id(); const file = 'm_' + id + '.webm'; try { fs.writeFileSync(path.join(REC_DIR, file), Buffer.concat(chunks)); - R.recordings.create({ id, teamId: u.team_id, room, groupId, meetingId: ctx.meetingId, title, kind: 'video', file, mime: 'video/webm', size: total, durationMs: dur, createdBy: u.id, createdByName: u.name || u.email }); + 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 }); 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' }); } @@ -1371,36 +1371,36 @@ route('POST', '/api/meetings/recording', (req, res) => { // Cancel a scheduled meeting (organizer only). route('POST', '/api/meetings/cancel', async (req, res) => { - const u = currentUser(req); + const u = await currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); const { id, scope } = await readBody(req); - const s = id && R.scheduledMeetings.byId(id); + const s = id && await R.scheduledMeetings.byId(id); if (!s || s.team_id !== u.team_id) return json(res, 404, { error: 'not found' }); if (s.created_by !== u.id) return json(res, 403, { error: 'only the organizer can cancel' }); if (s.cancelled || s.ended_at) return json(res, 400, { error: 'this meeting can no longer be cancelled' }); if (s.scheduled_at <= Date.now()) return json(res, 400, { error: 'the meeting time has passed β€” it can no longer be cancelled' }); // #13 let recur = []; try { recur = JSON.parse(s.recurrence || '[]'); } catch (_) {} const recips = new Set(); try { JSON.parse(s.participants || '[]').forEach((x) => recips.add(x)); } catch (_) {} - if (s.group_id) for (const mid of R.conversations.members(s.group_id)) recips.add(mid); + if (s.group_id) for (const mid of await R.conversations.members(s.group_id)) recips.add(mid); if (recur.length && scope === 'one') { const occ = s.scheduled_at; const whenLabel = new Date(occ).toLocaleString([], { weekday: 'short', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }); // Snapshot this cancelled occurrence (own non-recurring row) so it appears under Past meetings. try { - let sc; do { sc = A.numericCode(6); } while (R.scheduledMeetings.byCode(sc)); + let sc; do { sc = A.numericCode(6); } while (await R.scheduledMeetings.byCode(sc)); let parts = []; try { parts = JSON.parse(s.participants || '[]'); } catch (_) {} const sid = A.id(); - R.scheduledMeetings.create({ id: sid, teamId: u.team_id, groupId: s.group_id, roomCode: sc, title: s.title, description: s.description, scheduledAt: occ, createdBy: s.created_by, participants: parts, durationMins: s.duration_mins, recurrence: [] }); - R.scheduledMeetings.cancel(sid, u.team_id); + 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); } catch (_) {} // Roll the recurring series forward to its next occurrence. const nxt = nextOccurrence(occ, recur, occ); - if (nxt !== occ) R.scheduledMeetings.reschedule(id, u.team_id, nxt); + if (nxt !== occ) await R.scheduledMeetings.reschedule(id, u.team_id, nxt); const cevt = { type: 'meeting-cancelled', meeting: { id: s.id, title: s.title, by: u.name || u.email, when: whenLabel } }; recips.forEach((rid) => { if (rid !== u.id) { try { CHAT.pushToUser(rid, cevt); } catch (_) {} } }); return json(res, 200, { ok: true, skipped: true }); } - R.scheduledMeetings.cancel(id, u.team_id); // keep it (marked cancelled), don't delete β€” #12 + await R.scheduledMeetings.cancel(id, u.team_id); // keep it (marked cancelled), don't delete β€” #12 const cevt = { type: 'meeting-cancelled', meeting: { id: s.id, title: s.title, by: u.name || u.email } }; recips.forEach((rid) => { if (rid !== u.id) { try { CHAT.pushToUser(rid, cevt); } catch (_) {} } }); json(res, 200, { ok: true }); @@ -1408,10 +1408,10 @@ route('POST', '/api/meetings/cancel', async (req, res) => { // Edit a scheduled meeting (organizer only, while still upcoming). route('POST', '/api/meetings/update', async (req, res) => { - const u = currentUser(req); + const u = await currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); const { id, title, description, scheduledAt, durationMins, participants, participantEmails, recurrence, lobby } = await readBody(req); - const s = id && R.scheduledMeetings.byId(id); + const s = id && await R.scheduledMeetings.byId(id); if (!s || s.team_id !== u.team_id) return json(res, 404, { error: 'not found' }); if (s.created_by !== u.id) return json(res, 403, { error: 'only the organizer can edit' }); if (s.cancelled || s.ended_at) return json(res, 400, { error: 'this meeting can no longer be edited' }); @@ -1419,9 +1419,9 @@ route('POST', '/api/meetings/update', async (req, res) => { const when = Number(scheduledAt); if (!Number.isFinite(when) || when < Date.now()) return json(res, 400, { error: 'pick a valid future time' }); const dur = [15, 30, 45, 60, 90, 120].includes(Number(durationMins)) ? Number(durationMins) : (s.duration_mins || 30); const recur = Array.isArray(recurrence) ? [...new Set(recurrence.map(Number).filter((d) => d >= 0 && d <= 6))] : []; - const invited = [...new Set((Array.isArray(participants) ? participants : []).filter((x) => typeof x === 'string' && x !== u.id && R.users.inTenant(x, u.team_id)))]; + const invited = [...new Set((Array.isArray(participants) ? participants : []).filter((x) => typeof x === 'string' && x !== u.id && await R.users.inTenant(x, u.team_id)))]; const guestEmails = [...new Set((Array.isArray(participantEmails) ? participantEmails : []).map((e) => String(e || '').trim().toLowerCase()).filter(isEmail))].slice(0, 100); - 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 }); + 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 }); const label = new Date(when).toLocaleString(); // Email the updated details to external invitees (new + existing) so their link/time stays current. try { @@ -1432,7 +1432,7 @@ route('POST', '/api/meetings/update', async (req, res) => { } } catch (_) {} const evt = { type: 'meeting-invite', meeting: { id, title: t, scheduledAt: when, whenText: label, room: s.room_code, by: u.name || u.email, updated: true } }; - const recips = new Set(invited); if (s.group_id) for (const mid of R.conversations.members(s.group_id)) recips.add(mid); + const recips = new Set(invited); if (s.group_id) for (const mid of await R.conversations.members(s.group_id)) recips.add(mid); recips.forEach((rid) => { if (rid !== u.id) { try { CHAT.pushToUser(rid, evt); } catch (_) {} } }); json(res, 200, { ok: true }); }); @@ -1440,46 +1440,46 @@ route('POST', '/api/meetings/update', async (req, res) => { // ---------- Polls (within a group conversation) ---------- // Create a poll: stores it + a message (body = question) and pushes the message to members. route('POST', '/api/polls', async (req, res) => { - const u = currentUser(req); + const u = await currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); const { group, question, options, multi } = await readBody(req); - if (!group || !R.conversations.isMember(group, u.id)) return json(res, 403, { error: 'not a member of this group' }); + if (!group || !await R.conversations.isMember(group, u.id)) return json(res, 403, { error: 'not a member of this group' }); const q = String(question || '').trim().slice(0, 300); const opts = (Array.isArray(options) ? options : []).map((s) => String(s || '').trim()).filter(Boolean).slice(0, 10); if (!q) return json(res, 400, { error: 'question required' }); if (opts.length < 2) return json(res, 400, { error: 'at least two options required' }); const pollId = A.id(); const msgId = A.id(); - R.messages.send({ id: msgId, teamId: u.team_id, senderId: u.id, recipientId: '', body: q, conversationId: group }); - R.polls.create({ id: pollId, teamId: u.team_id, conversationId: group, messageId: msgId, question: q, options: opts, multi: !!multi, createdBy: u.id }); - R.messages.setPoll(msgId, pollId); + 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); audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'poll_created', detail: q }); const names = namesFor(u.team_id); - for (const mid of R.conversations.members(group)) { - try { const dto = buildMsgDTO(R.messages.byId(msgId), names, mid); dto.fromName = u.name || u.email; CHAT.pushToUser(mid, { type: 'chat-message', message: dto }); } catch (_) {} + for (const mid of await R.conversations.members(group)) { + try { const dto = buildMsgDTO(await R.messages.byId(msgId), names, mid); dto.fromName = u.name || u.email; CHAT.pushToUser(mid, { type: 'chat-message', message: dto }); } catch (_) {} } - json(res, 200, buildPollDTO(R.polls.byId(pollId), u.id)); + json(res, 200, buildPollDTO(await R.polls.byId(pollId), u.id)); }); // Vote on a poll option (toggle). Single-choice replaces the prior vote; multi toggles. route('POST', '/api/polls/vote', async (req, res) => { - const u = currentUser(req); + const u = await currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); const { pollId, optionIdx } = await readBody(req); - const p = pollId && R.polls.byId(pollId); + const p = pollId && await R.polls.byId(pollId); if (!p || p.team_id !== u.team_id) return json(res, 404, { error: 'poll not found' }); - if (!R.conversations.isMember(p.conversation_id, u.id)) return json(res, 403, { error: 'not a member' }); + if (!await R.conversations.isMember(p.conversation_id, u.id)) return json(res, 403, { error: 'not a member' }); if (p.closed) return json(res, 400, { error: 'poll is closed' }); let opts = []; try { opts = JSON.parse(p.options); } catch {} const idx = Number(optionIdx); if (!Number.isInteger(idx) || idx < 0 || idx >= opts.length) return json(res, 400, { error: 'invalid option' }); if (p.multi) { - if (R.pollVotes.hasVoted(p.id, u.id, idx)) R.pollVotes.remove(p.id, u.id, idx); else R.pollVotes.add(p.id, u.id, idx); + 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); } else { - const had = R.pollVotes.hasVoted(p.id, u.id, idx); - R.pollVotes.clearUser(p.id, u.id); - if (!had) R.pollVotes.add(p.id, u.id, idx); + 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); } - for (const mid of R.conversations.members(p.conversation_id)) { + for (const mid of await R.conversations.members(p.conversation_id)) { try { CHAT.pushToUser(mid, { type: 'poll-update', poll: buildPollDTO(p, mid), messageId: p.message_id, conversationId: p.conversation_id }); } catch (_) {} } json(res, 200, buildPollDTO(p, u.id)); @@ -1487,15 +1487,15 @@ route('POST', '/api/polls/vote', async (req, res) => { // Close a poll (creator only) β€” no more votes accepted. route('POST', '/api/polls/close', async (req, res) => { - const u = currentUser(req); + const u = await currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); const { pollId } = await readBody(req); - const p = pollId && R.polls.byId(pollId); + const p = pollId && await R.polls.byId(pollId); if (!p || p.team_id !== u.team_id) return json(res, 404, { error: 'poll not found' }); if (p.created_by !== u.id) return json(res, 403, { error: 'only the poll creator can close it' }); - R.polls.close(p.id); - const fresh = R.polls.byId(p.id); - for (const mid of R.conversations.members(p.conversation_id)) { + 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)) { try { CHAT.pushToUser(mid, { type: 'poll-update', poll: buildPollDTO(fresh, mid), messageId: p.message_id, conversationId: p.conversation_id }); } catch (_) {} } json(res, 200, buildPollDTO(fresh, u.id)); @@ -1503,34 +1503,34 @@ route('POST', '/api/polls/close', async (req, res) => { // Send a message (persists + live-pushes to the recipient and the sender's other tabs). route('POST', '/api/messages', async (req, res) => { - const u = currentUser(req); + const u = await currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); const { to, group, body, replyTo, attachmentId, mentions } = await readBody(req); const text = String(body || '').trim(); if (!text && !attachmentId) return json(res, 400, { error: 'message or attachment required' }); if (text.length > MSG_MAX) return json(res, 400, { error: 'message too long' }); if (attachmentId) { - const a = R.attachments.byId(attachmentId); + const a = await R.attachments.byId(attachmentId); if (!a || a.team_id !== u.team_id || a.uploader_id !== u.id) return json(res, 400, { error: 'invalid attachment' }); } - try { R.users.touchSeen(u.id); } catch (_) {} // #2: keep "last seen" fresh on activity, not just on disconnect + try { await R.users.touchSeen(u.id); } catch (_) {} // #2: keep "last seen" fresh on activity, not just on disconnect const id = A.id(); if (group) { - if (!R.conversations.isMember(group, u.id)) return json(res, 403, { error: 'not a member of this group' }); + if (!await R.conversations.isMember(group, u.id)) return json(res, 403, { error: 'not a member of this group' }); // Validate mentions: keep only the literal "everyone" and ids that are actual members. let mlist = []; if (Array.isArray(mentions)) { - const memberSet = new Set(R.conversations.members(group)); + const memberSet = new Set(await R.conversations.members(group)); mlist = mentions.filter((x) => x === 'everyone' || memberSet.has(x)); mlist = [...new Set(mlist)]; } - R.messages.send({ id, teamId: u.team_id, senderId: u.id, recipientId: '', body: text, replyTo: replyTo || null, attachmentId: attachmentId || null, conversationId: group, mentions: mlist }); - const dto = buildMsgDTO(R.messages.byId(id), namesFor(u.team_id), u.id); + 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 }); + const dto = buildMsgDTO(await R.messages.byId(id), namesFor(u.team_id), u.id); dto.fromName = u.name || u.email; const push = { type: 'chat-message', message: dto }; - const conv = R.conversations.byId(group); const gname = (conv && conv.name) || 'Group'; + const conv = await R.conversations.byId(group); const gname = (conv && conv.name) || 'Group'; const pushBody = (u.name || u.email) + ': ' + (text ? (text.length > 80 ? text.slice(0, 80) + '…' : text) : 'πŸ“Ž Attachment'); - for (const mid of R.conversations.members(group)) { + for (const mid of await R.conversations.members(group)) { try { CHAT.pushToUser(mid, push); } catch (_) {} // includes sender's other tabs if (mid !== u.id) PUSH.sendToUser(mid, { title: gname, body: pushBody, kind: 'group', id: group, tag: 'group:' + group }); } @@ -1538,11 +1538,11 @@ route('POST', '/api/messages', async (req, res) => { } // 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). - const toId = R.users.resolve(to); + const toId = await R.users.resolve(to); if (!toId) return json(res, 400, { error: 'to or group required' }); - if (!R.users.inTenant(toId, u.team_id)) return json(res, 404, { error: 'no such contact' }); - R.messages.send({ id, teamId: u.team_id, senderId: u.id, recipientId: toId, body: text, replyTo: replyTo || null, attachmentId: attachmentId || null }); - const dto = buildMsgDTO(R.messages.byId(id), namesFor(u.team_id), u.id); + 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 }); + const dto = buildMsgDTO(await R.messages.byId(id), namesFor(u.team_id), u.id); const push = { type: 'chat-message', message: { ...dto, fromName: u.name || u.email } }; try { CHAT.pushToUser(toId, push); } catch (_) {} if (toId !== u.id) try { CHAT.pushToUser(u.id, push); } catch (_) {} // sync the sender's other devices (skip for self-notes) @@ -1554,16 +1554,16 @@ route('POST', '/api/messages', async (req, res) => { // 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) => { - const u = currentUser(req); + const u = await currentUser(req); 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)) { - const m = R.messages.byId(mid); + const m = await R.messages.byId(mid); if (!m || m.team_id !== u.team_id || m.deleted || m.poll_id) continue; - const ok = m.conversation_id ? R.conversations.isMember(m.conversation_id, u.id) : (m.sender_id === u.id || m.recipient_id === u.id); + const ok = m.conversation_id ? await R.conversations.isMember(m.conversation_id, u.id) : (m.sender_id === u.id || m.recipient_id === u.id); if (ok && (m.body || m.attachment_id)) srcs.push(m); } if (!srcs.length) return json(res, 400, { error: 'nothing to forward' }); @@ -1572,16 +1572,16 @@ route('POST', '/api/messages/forward', async (req, res) => { let sent = 0; for (const t of (targets || []).slice(0, 20)) { let isGroup = t.kind === 'group', tid = t.id; - if (isGroup) { if (!R.conversations.isMember(tid, u.id)) continue; } - else { tid = R.users.resolve(tid); if (!tid || !R.users.inTenant(tid, u.team_id)) continue; } + 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; } for (const m of srcs) { const nid = A.token(16); const origin = m.fwd_from || names[m.sender_id] || 'Unknown'; // preserve the true origin across re-forwards - if (isGroup) 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 R.messages.send({ id: nid, teamId: u.team_id, senderId: u.id, recipientId: tid, body: m.body, attachmentId: m.attachment_id, fwdFrom: origin }); - const dto = buildMsgDTO(R.messages.byId(nid), names, u.id); + 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 }); + const dto = buildMsgDTO(await R.messages.byId(nid), names, u.id); const push = { type: 'chat-message', message: { ...dto, fromName: u.name || u.email } }; - if (isGroup) { for (const mid of R.conversations.members(tid)) { try { CHAT.pushToUser(mid, push); } catch (_) {} } } + if (isGroup) { for (const mid of await R.conversations.members(tid)) { try { CHAT.pushToUser(mid, push); } catch (_) {} } } else { try { CHAT.pushToUser(tid, push); } catch (_) {} if (tid !== u.id) try { CHAT.pushToUser(u.id, push); } catch (_) {} } sent++; } @@ -1591,57 +1591,57 @@ route('POST', '/api/messages/forward', async (req, res) => { // Delete one of YOUR OWN messages for everyone (clears content, keeps the row as a placeholder). route('POST', '/api/messages/delete', async (req, res) => { - const u = currentUser(req); + const u = await currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); const { id } = await readBody(req); if (!id) return json(res, 400, { error: 'id required' }); - const m = R.messages.byId(id); + const m = await R.messages.byId(id); if (!m || m.team_id !== u.team_id) return json(res, 404, { error: 'not found' }); if (m.sender_id !== u.id) return json(res, 403, { error: 'you can only delete your own messages' }); - R.messages.markDeleted(id); + await R.messages.markDeleted(id); const evt = { type: 'chat-deleted', id, conversation_id: m.conversation_id || null }; - if (m.conversation_id) { for (const mid of R.conversations.members(m.conversation_id)) { try { CHAT.pushToUser(mid, evt); } catch (_) {} } } + if (m.conversation_id) { for (const mid of await R.conversations.members(m.conversation_id)) { try { CHAT.pushToUser(mid, evt); } catch (_) {} } } else { try { CHAT.pushToUser(m.recipient_id, evt); } catch (_) {} try { CHAT.pushToUser(u.id, evt); } catch (_) {} } json(res, 200, { ok: true }); }); // Edit a message (sender only, text only) β€” updates the body + marks it edited, and pushes the // change live to the other side / other tabs (mirrors the delete broadcast). route('POST', '/api/messages/edit', async (req, res) => { - const u = currentUser(req); + const u = await currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); const { id, body } = await readBody(req); if (!id || typeof body !== 'string') return json(res, 400, { error: 'id and body required' }); const text = body.trim(); if (!text) return json(res, 400, { error: 'message cannot be empty' }); - const m = R.messages.byId(id); + const m = await R.messages.byId(id); if (!m || m.team_id !== u.team_id) return json(res, 404, { error: 'not found' }); if (m.sender_id !== u.id) return json(res, 403, { error: 'you can only edit your own messages' }); if (m.deleted) return json(res, 400, { error: 'cannot edit a deleted message' }); - R.messages.editBody(id, text); - const edited = R.messages.byId(id); + await R.messages.editBody(id, text); + const edited = await R.messages.byId(id); const evt = { type: 'chat-edited', id, body: text, edited_at: edited.edited_at, conversation_id: m.conversation_id || null }; - if (m.conversation_id) { for (const mid of R.conversations.members(m.conversation_id)) { try { CHAT.pushToUser(mid, evt); } catch (_) {} } } + if (m.conversation_id) { for (const mid of await R.conversations.members(m.conversation_id)) { try { CHAT.pushToUser(mid, evt); } catch (_) {} } } else { try { CHAT.pushToUser(m.recipient_id, evt); } catch (_) {} try { CHAT.pushToUser(u.id, evt); } catch (_) {} } json(res, 200, { ok: true, edited_at: edited.edited_at }); }); // Favourite/unfavourite a conversation (per user). target = 'dm:' or 'group:'. route('POST', '/api/favorites', async (req, res) => { - const u = currentUser(req); + const u = await currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); const { kind, id, on } = await readBody(req); if (!kind || !id) return json(res, 400, { error: 'kind and id required' }); - try { R.favorites.set(u.id, kind + ':' + id, !!on); } catch (_) {} + try { await R.favorites.set(u.id, kind + ':' + id, !!on); } catch (_) {} json(res, 200, { ok: true, favorite: !!on }); }); // Shared media & files in a conversation (group) or DM β€” for the "Shared" Media/Files view. route('GET', '/api/messages/media', async (req, res) => { - const u = currentUser(req); + const u = await currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); const q = new URLSearchParams(req.url.split('?')[1] || ''); const group = q.get('group'); const other = q.get('with'); let rows = [], linkRows = []; - if (group) { if (!R.conversations.isMember(group, u.id)) return json(res, 403, { error: 'not a member' }); rows = R.messages.attachmentsForConversation(u.team_id, group); linkRows = R.messages.linksForConversation(u.team_id, group); } - else if (other) { rows = R.messages.attachmentsForDm(u.team_id, u.id, other); linkRows = R.messages.linksForDm(u.team_id, u.id, other); } + 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); } 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 }); } @@ -1650,21 +1650,21 @@ route('GET', '/api/messages/media', async (req, res) => { json(res, 200, { media: att.filter(isMedia), docs: att.filter((a) => !isMedia(a)), links }); }); route('POST', '/api/messages/read', async (req, res) => { - const u = currentUser(req); + const u = await currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); const { with: rawOther, group } = await readBody(req); - const other = R.users.resolve(rawOther); // follow a merge redirect + const other = await R.users.resolve(rawOther); // follow a merge redirect if (group) { - if (R.conversations.isMember(group, u.id)) { - R.conversations.markRead(group, u.id); + if (await R.conversations.isMember(group, u.id)) { + await R.conversations.markRead(group, u.id); const evt = { type: 'group-read', group, by: u.id, byName: (u.name || u.email), at: now() }; - for (const mid of R.conversations.members(group)) { if (mid !== u.id) { try { CHAT.pushToUser(mid, evt); } catch (_) {} } } + for (const mid of await R.conversations.members(group)) { if (mid !== u.id) { try { CHAT.pushToUser(mid, evt); } catch (_) {} } } try { CHAT.pushToUser(u.id, { type: 'notif-clear', kind: 'group', id: group }); } catch (_) {} // #13: clear this chat's notifications on my other devices } return json(res, 200, { ok: true }); } if (!other) return json(res, 400, { error: 'with or group required' }); - R.messages.markRead(u.team_id, u.id, other); + 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: multi-device dismissal json(res, 200, { ok: true }); @@ -1672,23 +1672,23 @@ route('POST', '/api/messages/read', async (req, res) => { // Toggle an emoji reaction on a message (live-pushed to the other party). route('POST', '/api/messages/react', async (req, res) => { - const u = currentUser(req); + const u = await currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); const { messageId, emoji } = await readBody(req); if (!messageId || !emoji) return json(res, 400, { error: 'messageId and emoji required' }); - const msg = R.messages.byId(messageId); + const msg = await R.messages.byId(messageId); const participant = msg && msg.team_id === u.team_id && ( - msg.conversation_id ? R.conversations.isMember(msg.conversation_id, u.id) + msg.conversation_id ? await R.conversations.isMember(msg.conversation_id, u.id) : (msg.sender_id === u.id || msg.recipient_id === u.id)); if (!participant) return json(res, 404, { error: 'no such message' }); const e = String(emoji).slice(0, 16); - const added = R.reactions.toggle(messageId, u.id, e); + const added = await R.reactions.toggle(messageId, u.id, e); const names = namesFor(u.team_id); // Push the full, recomputed reaction set for this message (per-recipient perspective). Extra // fields (by/emoji/added/owner/convId) let the message owner show a "reacted to you" notification. const meta = { by: u.name || u.email, byId: u.id, emoji: e, added, owner: msg.sender_id, convId: msg.conversation_id || null }; if (msg.conversation_id) { - for (const mid of R.conversations.members(msg.conversation_id)) { + for (const mid of await R.conversations.members(msg.conversation_id)) { try { CHAT.pushToUser(mid, { type: 'chat-reaction', messageId, reactions: reactionsForMessage(messageId, mid, names), ...meta }); } catch (_) {} } } else { @@ -1702,7 +1702,7 @@ route('POST', '/api/messages/react', async (req, res) => { // Upload a chat attachment (raw body; filename in X-Filename, mime in Content-Type). // Returns the attachment id to attach to a subsequent /api/messages send. route('POST', '/api/messages/upload', async (req, res) => { - const u = currentUser(req); + const u = await currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); const name = decodeURIComponent(req.headers['x-filename'] || 'file').slice(0, 200); const mime = (req.headers['content-type'] || 'application/octet-stream').split(';')[0].trim(); @@ -1728,7 +1728,7 @@ route('POST', '/api/messages/upload', async (req, res) => { 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' }); } - R.attachments.create({ id, teamId: u.team_id, uploaderId: u.id, name, mime, size: total }); + await R.attachments.create({ id, teamId: u.team_id, uploaderId: u.id, name, mime, size: total }); // 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) {} diff --git a/server/session.js b/server/session.js index 8103373..747464a 100644 --- a/server/session.js +++ b/server/session.js @@ -25,27 +25,28 @@ function tokenFromReq(req) { } // Resolve the logged-in user from the request. Returns user row (with mfa state) or null. -function currentUser(req, { requireMfa = true } = {}) { +// Async: the repos it reads go through the DB adapter, so every caller must `await currentUser(...)`. +async function currentUser(req, { requireMfa = true } = {}) { const tok = tokenFromReq(req); if (!tok) return null; - const s = R.authSessions.byToken(tok); + const s = await R.authSessions.byToken(tok); if (!s || s.expires_at < now()) return null; if (requireMfa && !s.mfa_passed) return null; - const u = R.users.byId(s.user_id); + const u = await R.users.byId(s.user_id); if (!u || u.active === 0) return null; return { ...u, _session: s }; } // Resolve a third-party API key from `X-API-Key` or `Authorization: Bearer bzc_...`. // Returns { id, teamId, scopes:[], name } or null. Keys are prefixed `bzc_` and stored hashed. -function apiKeyFromReq(req) { +async function apiKeyFromReq(req) { let raw = req.headers && req.headers['x-api-key']; if (!raw) { const h = req.headers && (req.headers.authorization || req.headers.Authorization); if (h && /^Bearer\s+bzc_/i.test(h)) raw = h.replace(/^Bearer\s+/i, '').trim(); } if (!raw || !/^bzc_/.test(raw)) return null; - const row = R.apiKeys.byHash(A.hashToken(raw)); + const row = await R.apiKeys.byHash(A.hashToken(raw)); if (!row || row.revoked) return null; return { id: row.id, teamId: row.team_id, scopes: String(row.scopes || '').split(',').map((s) => s.trim()).filter(Boolean), name: row.name }; } diff --git a/server/signaling.js b/server/signaling.js index 3b5484a..1dcf3aa 100644 --- a/server/signaling.js +++ b/server/signaling.js @@ -31,10 +31,10 @@ function persistCallHistory(room) { roomStats.delete(room); if (!st || !st.teamId || st.peak < 1) return; try { - if (R.scheduledMeetings.byCode(room)) return; // the scheduled meeting row already represents this + if (await R.scheduledMeetings.byCode(room)) return; // the scheduled meeting row already represents this const gid = roomToGroupCall.get(room) || null; const isDm = roomToDmCall.has(room); - R.callHistory.create({ + await R.callHistory.create({ id: A.id(), teamId: st.teamId, room, groupId: gid, kind: isDm ? 'dm' : (gid ? 'group' : 'adhoc'), title: isDm ? 'Direct call' : (gid ? null : 'Meeting'), @@ -48,7 +48,7 @@ function persistCallHistory(room) { // organizer chose "join directly". Logged-in tenant users are never held β€” only guests. function meetingRoomRequiresApproval(room) { if (roomLobby.has(room)) return !!roomLobby.get(room); - try { const s = R.scheduledMeetings.byCode(room); if (s) return s.lobby !== 0; } catch (_) {} + try { const s = await R.scheduledMeetings.byCode(room); if (s) return s.lobby !== 0; } catch (_) {} return true; } // Actually add a newcomer to the room: tell them who's here, tell others they arrived, and (if they're @@ -86,7 +86,7 @@ function handle(ws, m, req) { switch (m.type) { // --- Logged-in user registers this socket for live chat delivery --- case 'chat-hello': { - const u = currentUser(req); // identity from the cookie/Bearer on the WS upgrade + const u = await currentUser(req); // identity from the cookie/Bearer on the WS upgrade if (!u) return ws.send(JSON.stringify({ type: 'error', message: 'unauthorized' })); ws._chatUserId = u.id; ws._chatTeamId = u.team_id; CHAT.register(u.id, ws); @@ -97,10 +97,10 @@ function handle(ws, m, req) { // Recipient's client acknowledges a DM was delivered β†’ mark it + tell the sender. case 'chat-delivered': { if (!ws._chatUserId || !m.id) break; - const msg = R.messages.byId(m.id); + const msg = await R.messages.byId(m.id); if (!msg || msg.conversation_id || msg.team_id !== ws._chatTeamId) break; // DMs only if (msg.recipient_id !== ws._chatUserId) break; // only the recipient can ack - if (!msg.delivered_at) { R.messages.markDelivered(m.id); try { CHAT.pushToUser(msg.sender_id, { type: 'chat-delivered', id: m.id, with: msg.recipient_id }); } catch (_) {} } + if (!msg.delivered_at) { await R.messages.markDelivered(m.id); try { CHAT.pushToUser(msg.sender_id, { type: 'chat-delivered', id: m.id, with: msg.recipient_id }); } catch (_) {} } break; } // Live "is typing…" β€” ephemeral, never persisted. Relay to the DM peer, or fan out to @@ -108,9 +108,9 @@ function handle(ws, m, req) { case 'chat-typing': { const uid = ws._chatUserId; if (!uid) break; const on = !!m.on; - let name = ''; try { const u = R.users.byId(uid); name = (u && u.name) || ''; } catch (_) {} + let name = ''; try { const u = await R.users.byId(uid); name = (u && u.name) || ''; } catch (_) {} if (m.group) { - let members; try { if (!R.conversations.isMember(m.group, uid)) break; members = R.conversations.members(m.group); } catch (_) { break; } + let members; try { if (!await R.conversations.isMember(m.group, uid)) break; members = await R.conversations.members(m.group); } catch (_) { break; } for (const mid of members) { if (mid !== uid) { try { CHAT.pushToUser(mid, { type: 'chat-typing', group: m.group, from: uid, name, on }); } catch (_) {} } } } else if (m.to) { try { CHAT.pushToUser(m.to, { type: 'chat-typing', from: uid, name, on }); } catch (_) {} @@ -121,7 +121,7 @@ function handle(ws, m, req) { case 'meeting-create': { let code; do { code = A.numericCode(6); } while (meetingRooms.has(code)); meetingRooms.set(code, new Map()); - const cu = currentUser(req); if (cu) roomHost.set(code, cu.id); // ad-hoc meeting: creator = host + const cu = await currentUser(req); if (cu) roomHost.set(code, cu.id); // ad-hoc meeting: creator = host // Lobby preference for guests joining this ad-hoc room by link (default: require approval). roomLobby.set(code, m.lobby === false ? false : true); ws.send(JSON.stringify({ type: 'meeting-created', room: code })); @@ -132,7 +132,7 @@ function handle(ws, m, req) { let peers = meetingRooms.get(room); // A scheduled meeting's room is created lazily on first join (its code lives in the DB). if (!peers) { - const sched = R.scheduledMeetings.byCode(room); + const sched = await R.scheduledMeetings.byCode(room); if (sched && !sched.ended_at) { peers = new Map(); meetingRooms.set(room, peers); } } if (!peers) return ws.send(JSON.stringify({ type: 'error', message: 'Meeting not found' })); @@ -141,8 +141,8 @@ function handle(ws, m, req) { ws.kind = 'meeting'; ws._meetingRoom = room; ws._peerId = peerId; ws._peerName = name; // Host = the meeting's creator. roomHost is set on call/meeting creation; scheduled meetings fall back to created_by. let hostUserId = roomHost.get(room); - if (hostUserId === undefined) { try { const s = R.scheduledMeetings.byCode(room); if (s) { hostUserId = s.created_by; roomHost.set(room, hostUserId); } } catch (_) {} } - const ju = currentUser(req); + if (hostUserId === undefined) { try { const s = await R.scheduledMeetings.byCode(room); if (s) { hostUserId = s.created_by; roomHost.set(room, hostUserId); } } catch (_) {} } + const ju = await currentUser(req); // Identity used to map LiveKit media β†’ this tile (peerIdForUid). Logged-in users use their user id // (their LiveKit token identity is the same). GUESTS have no session, so they pass a stable client // guest id here that ALSO becomes their LiveKit token identity β€” otherwise their media never maps @@ -264,20 +264,20 @@ function handle(ws, m, req) { } // --- Agent comes online --- case 'agent-hello': { - const machine = R.machines.byEnrollToken(m.enrollToken); + const machine = await R.machines.byEnrollToken(m.enrollToken); if (!machine) return ws.send(JSON.stringify({ type: 'error', message: 'invalid enroll token' })); ws.kind = 'agent'; ws.machineId = machine.id; onlineAgents.set(machine.id, { ws, machine }); - R.machines.touch(machine.id); + await R.machines.touch(machine.id); ws.send(JSON.stringify({ type: 'agent-registered', machineId: machine.id, name: machine.name })); break; } // --- Technician requests control of a machine --- case 'viewer-connect': { - const u = currentUser(req); // cookie sent on WS upgrade + const u = await currentUser(req); // cookie sent on WS upgrade if (!u) return ws.send(JSON.stringify({ type: 'error', message: 'unauthorized' })); const agent = onlineAgents.get(m.machineId); - const machine = R.machines.inTenant(m.machineId, u.team_id); + const machine = await R.machines.inTenant(m.machineId, u.team_id); if (!machine) return ws.send(JSON.stringify({ type: 'error', message: 'no such machine' })); if (!agent) return ws.send(JSON.stringify({ type: 'error', message: 'machine offline' })); if (u.role === 'viewer' && false) {} // view-only still allowed to watch; control gated agent-side @@ -301,7 +301,7 @@ function handle(ws, m, req) { if (m.granted) { audit({ team_id: sess.machine.team_id, user_id: sess.user.id, user_email: sess.user.email, machine_id: sess.machine.id, machine_name: sess.machine.name, action: 'consent_granted', detail: sess.ticket ? 'Ticket ' + sess.ticket : (sess.machine.id ? null : 'Direct session') }); try { - R.sessionsLog.create({ id: m.sessionId, tenantId: sess.machine.team_id, agentEmail: sess.user.email, agentName: sess.agentName || sess.user.email, ticket: sess.ticket || null }); + await R.sessionsLog.create({ id: m.sessionId, tenantId: sess.machine.team_id, agentEmail: sess.user.email, agentName: sess.agentName || sess.user.email, ticket: sess.ticket || null }); } catch (e) { /* duplicate consent */ } try { W.emit('session.started', sess.machine.team_id, { sessionId: m.sessionId, agent_email: sess.user.email, agent_name: sess.agentName || sess.user.email, ticket: sess.ticket || null, started_at: Date.now() }); } catch (_) {} sess.viewerWs.send(JSON.stringify({ type: 'session-ready', sessionId: m.sessionId })); @@ -325,7 +325,7 @@ function handle(ws, m, req) { } // --- Logged-in agent enters the code (+ ticket) to connect --- case 'code-connect': { - const agent = currentUser(req); // identity from the agent's authenticated session + const agent = await currentUser(req); // identity from the agent's authenticated session if (!agent) { return ws.send(JSON.stringify({ type: 'error', message: 'Please sign in as an agent first' })); } @@ -379,9 +379,9 @@ function handle(ws, m, req) { function endSession(sessionId, reason) { const sess = liveSessions.get(sessionId); if (!sess) return; - try { R.sessionsLog.end(sessionId); } catch (e) {} + try { await R.sessionsLog.end(sessionId); } catch (e) {} try { - const row = R.sessionsLog.byId(sessionId); + const row = await R.sessionsLog.byId(sessionId); if (row) W.emit('session.ended', sess.machine.team_id, { sessionId: row.id, agent_email: row.agent_email, agent_name: row.agent_name, ticket: row.ticket, started_at: row.started_at, ended_at: row.ended_at, duration_ms: row.ended_at ? row.ended_at - row.started_at : null }); diff --git a/server/static.js b/server/static.js index d9ed2c2..e2a3777 100644 --- a/server/static.js +++ b/server/static.js @@ -13,14 +13,14 @@ const MIME = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css // Authorize an attachment id: the uploader, a member of the group using it as an avatar, or a participant of // ANY message carrying it (the "any" covers forwarded attachments, which reuse the same id). Returns the row. function authAttachmentRaw(id, u) { - const a = R.attachments.byId(id); + const a = await R.attachments.byId(id); if (!a || a.team_id !== u.team_id) return null; - const avatarGroup = R.conversations.byAvatar(id); - const carriers = R.messages.allByAttachment(id); + const avatarGroup = await R.conversations.byAvatar(id); + const carriers = await R.messages.allByAttachment(id); const ok = a.uploader_id === u.id - || (avatarGroup && R.conversations.isMember(avatarGroup.id, u.id)) + || (avatarGroup && await R.conversations.isMember(avatarGroup.id, u.id)) || carriers.some((msg) => msg.conversation_id - ? R.conversations.isMember(msg.conversation_id, u.id) + ? await R.conversations.isMember(msg.conversation_id, u.id) : (msg.sender_id === u.id || msg.recipient_id === u.id)); return ok ? a : null; } @@ -141,11 +141,11 @@ function handleGet(req, res) { }); } if (pathOnly.startsWith('/transcripts/')) { - const u = currentUser(req); + const u = await currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); const name = path.basename(decodeURIComponent(pathOnly)); const sid = name.replace(/\.txt$/i, ''); - const row = R.sessionsLog.byIdInTenant(sid, u.team_id); + const row = await R.sessionsLog.byIdInTenant(sid, u.team_id); if (!row || !row.transcript) return json(res, 404, { error: 'not found' }); const fp = path.join(TRANS_DIR, row.transcript); if (!fp.startsWith(TRANS_DIR)) return json(res, 403, { error: 'forbidden' }); @@ -158,11 +158,11 @@ function handleGet(req, res) { }); } if (pathOnly.startsWith('/recordings/')) { - const u = currentUser(req); + const u = await currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); const name = path.basename(decodeURIComponent(pathOnly)); const sid = name.replace(/\.(webm|mp4)$/i, ''); - const row = R.sessionsLog.byIdInTenant(sid, u.team_id); + const row = await R.sessionsLog.byIdInTenant(sid, u.team_id); if (!row || !row.recording) return json(res, 404, { error: 'not found' }); const fp = path.join(REC_DIR, row.recording); if (!fp.startsWith(REC_DIR)) return json(res, 403, { error: 'forbidden' }); @@ -179,16 +179,16 @@ function handleGet(req, res) { // Meeting recordings & transcripts (/mrec/). Visible to the creator, group members, or those // who can see the scheduled meeting it belongs to. if (pathOnly.startsWith('/mrec/')) { - const u = currentUser(req); + const u = await currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); const id = path.basename(decodeURIComponent(pathOnly)); - const r = R.recordings.byId(id); + const r = await R.recordings.byId(id); if (!r || r.team_id !== u.team_id || !r.file) return json(res, 404, { error: 'not found' }); let allowed = r.created_by === u.id; if (r.kind === 'transcript') allowed = r.created_by === u.id; // transcripts are private to their owner else { - if (!allowed && r.group_id) allowed = R.conversations.isMember(r.group_id, u.id); - if (!allowed && r.meeting_id) { const s = R.scheduledMeetings.byId(r.meeting_id); if (s) allowed = s.created_by === u.id || (s.participants && s.participants.includes('"' + u.id + '"')); } + if (!allowed && r.group_id) allowed = await R.conversations.isMember(r.group_id, u.id); + if (!allowed && r.meeting_id) { const s = await R.scheduledMeetings.byId(r.meeting_id); if (s) allowed = s.created_by === u.id || (s.participants && s.participants.includes('"' + u.id + '"')); } } if (!allowed) return json(res, 403, { error: 'forbidden' }); const isVideo = r.kind === 'video'; @@ -208,7 +208,7 @@ function handleGet(req, res) { // Video POSTER thumbnail β€” first frame extracted with ffmpeg, cached next to the file. Cosmetic: if ffmpeg // is missing or fails we 404 and the