wip(db): async call-site conversion in progress (Phase 3) — DO NOT MERGE yet

On the db-migration branch only; master stays clean + deployable. Foundation
(adapter, pg schema, smoke harness) is already on master and safe.

Done:
- repos.js fully async (Phase 2, validated: node --check clean, no missed transforms).
- session.js currentUser/apiKeyFromReq async.
- Mechanical `await` prefix applied across routes/static/calls/signaling/reminders/
  webhooks/push.

Remaining (does NOT compile yet — deterministic to finish):
1. Async cascade: helper fns that now contain `await` must be marked async and their
   callers awaited. node --check points to each (namesFor, authAttachmentRaw/
   authAttachment in static, the WS handlers in calls/signaling, reminders/webhooks
   loops).
2. DTO builders are the real work: namesFor, avatarsFor, buildPollDTO, buildMsgDTO,
   recDTO all became async — every `.map(x => buildMsgDTO(...))` etc. must become
   `await Promise.all(arr.map(async x => ...))`.
3. Chained calls `R.x.y(...).map/.length/.includes` → `(await R.x.y(...)).method`.
4. Then: node --check all green → node test/db-smoke.js green → e2e → merge to master.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-24 21:26:10 +05:30
parent dbd209ac2b
commit 2460c0f9eb
9 changed files with 450 additions and 447 deletions
+19 -19
View File
@@ -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 (_) {}
+4 -4
View File
@@ -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 */ }
}
}
+3 -3
View File
@@ -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 */ }
}
+63 -61
View File
@@ -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 = {
+317 -317
View File
File diff suppressed because it is too large Load Diff
+6 -5
View File
@@ -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 };
}
+20 -20
View File
@@ -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 });
+16 -16
View File
@@ -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/<id>). 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 <video> just falls back to its own (black) poster.
if (pathOnly.startsWith('/thumbs/')) {
const u = currentUser(req);
const u = await currentUser(req);
if (!u) return json(res, 401, { error: 'unauthorized' });
const id = path.basename(decodeURIComponent(pathOnly));
const a = authAttachment(id, u);
@@ -236,7 +236,7 @@ function handleGet(req, res) {
// back to the original bytes while that is still transcoding, so a video is never unplayable. The
// download button keeps pointing at /files, which always serves the untouched original.
if (pathOnly.startsWith('/stream/')) {
const u = currentUser(req);
const u = await currentUser(req);
if (!u) return json(res, 401, { error: 'unauthorized' });
const id = path.basename(decodeURIComponent(pathOnly));
const a = authAttachment(id, u);
@@ -253,7 +253,7 @@ function handleGet(req, res) {
});
}
if (pathOnly.startsWith('/files/')) {
const u = currentUser(req);
const u = await currentUser(req);
if (!u) return json(res, 401, { error: 'unauthorized' });
const id = path.basename(decodeURIComponent(pathOnly));
const a = authAttachment(id, u);
+2 -2
View File
@@ -38,10 +38,10 @@ function emit(event, tenantId, payload) {
const body = JSON.stringify({ event, ...payload });
// Per-tenant subscriptions
try {
for (const h of R.webhooks.activeForTenant(tenantId)) {
for (const h of await R.webhooks.activeForTenant(tenantId)) {
const subs = String(h.events || '').split(',').map((s) => s.trim());
if (subs.includes('*') || subs.includes(event)) {
deliver(h.url, h.secret, body, (r) => { try { R.webhooks.setStatus(h.id, r.ok ? 1 : 0, r.err || ('HTTP ' + r.status)); } catch (_) {} });
deliver(h.url, h.secret, body, (r) => { try { await R.webhooks.setStatus(h.id, r.ok ? 1 : 0, r.err || ('HTTP ' + r.status)); } catch (_) {} });
}
}
} catch (_) {}