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:
+19
-19
@@ -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
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+6
-5
@@ -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
@@ -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
@@ -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
@@ -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 (_) {}
|
||||
|
||||
Reference in New Issue
Block a user