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) {
|
function meetingContext(room) {
|
||||||
const ctx = { groupId: null, meetingId: null, title: 'Meeting' };
|
const ctx = { groupId: null, meetingId: null, title: 'Meeting' };
|
||||||
try {
|
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'; }
|
if (sched) { ctx.meetingId = sched.id; ctx.groupId = sched.group_id || null; ctx.title = sched.title || 'Meeting'; }
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
if (!ctx.groupId) { const gid = roomToGroupCall.get(room); if (gid) ctx.groupId = gid; }
|
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';
|
if (!ctx.groupId && !ctx.meetingId && roomToDmCall.has(room)) ctx.title = 'Direct Call';
|
||||||
return ctx;
|
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 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';
|
const body = ctx.title + ' — transcript\n' + new Date(buf[0].t).toLocaleString() + '\n\n' + lines.join('\n') + '\n';
|
||||||
for (const uid of ids) {
|
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; }
|
if (!user) { subs.delete(uid); continue; }
|
||||||
const id = A.id(); const file = 'm_' + id + '.txt';
|
const id = A.id(); const file = 'm_' + id + '.txt';
|
||||||
try { fs.writeFileSync(path.join(TRANS_DIR, file), body); } catch (e) { continue; }
|
try { fs.writeFileSync(path.join(TRANS_DIR, file), body); } catch (e) { continue; }
|
||||||
// groupId null → private to its creator (see canSeeRec / /mrec auth).
|
// 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);
|
subs.delete(uid);
|
||||||
}
|
}
|
||||||
} else { ids.forEach((uid) => 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 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).
|
// Post a centered activity line into the group (system sender → no ping on clients).
|
||||||
function postSystem(group, teamId, text) {
|
function postSystem(group, teamId, text) {
|
||||||
const id = A.id();
|
const id = A.id();
|
||||||
R.messages.send({ id, teamId, senderId: '__system__', recipientId: '', body: text, conversationId: group });
|
await R.messages.send({ id, teamId, senderId: '__system__', recipientId: '', body: text, conversationId: group });
|
||||||
const m = R.messages.byId(id);
|
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 } });
|
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());
|
meetingRooms.set(room, new Map());
|
||||||
const call = { room, startedAt: now(), startedBy: user.id, startedByName: user.name || user.email };
|
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.
|
// 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
|
groupCalls.set(group, call); roomToGroupCall.set(room, group); roomHost.set(room, user.id); // creator = host
|
||||||
postSystem(group, teamId, '📞 ' + call.startedByName + ' started a group call');
|
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 });
|
broadcast(group, { type: 'group-call', group, active: true, room, by: user.id, startedByName: call.startedByName, groupName: gName });
|
||||||
return { room, active: true };
|
return { room, active: true };
|
||||||
}
|
}
|
||||||
@@ -81,8 +81,8 @@ function endGroupCallByRoom(room) {
|
|||||||
const call = groupCalls.get(group);
|
const call = groupCalls.get(group);
|
||||||
roomToGroupCall.delete(room); groupCalls.delete(group); roomHost.delete(room);
|
roomToGroupCall.delete(room); groupCalls.delete(group); roomHost.delete(room);
|
||||||
if (call) {
|
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 (_) {}
|
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 { R.scheduledMeetings.end(call.historyId, teamId); } catch (_) {} } // mark the history row past
|
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 });
|
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 byName = me.name || me.email;
|
||||||
const call = { room, startedAt: now(), startedBy: me.id, startedByName: byName, users: [me.id, otherId], teamId, answered: false };
|
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.
|
// 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
|
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 —
|
// #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.
|
// so the caller isn't stuck "ringing" forever.
|
||||||
@@ -109,8 +109,8 @@ function startDmCall(me, otherId, teamId) {
|
|||||||
}, 40000);
|
}, 40000);
|
||||||
// A viewer-relative activity line: the caller sees "You started a call", the callee sees the name.
|
// A viewer-relative activity line: the caller sees "You started a call", the callee sees the name.
|
||||||
const mid = A.id();
|
const mid = A.id();
|
||||||
R.messages.send({ id: mid, teamId, senderId: me.id, recipientId: otherId, body: '📞 Started a call', msgType: 'call-start' });
|
await 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 };
|
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(otherId, { type: 'chat-message', message: dto }); } catch (_) {}
|
||||||
try { CHAT.pushToUser(me.id, { 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 (_) {}
|
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);
|
roomToDmCall.delete(room); dmCalls.delete(key); roomHost.delete(room);
|
||||||
if (!call) return;
|
if (!call) return;
|
||||||
if (call.ringTimer) { try { clearTimeout(call.ringTimer); } catch (_) {} }
|
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).
|
// Activity line: a duration ONLY if the call was answered; otherwise "Missed call" (#7/#9).
|
||||||
if (!silent) try {
|
if (!silent) try {
|
||||||
const mid = A.id(); const body = call.answered ? ('📞 Call ended · ' + fmtDur(now() - (call.answeredAt || call.startedAt))) : '📞 Missed call';
|
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' });
|
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 = 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' };
|
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 (_) {} });
|
call.users.forEach((uid) => { try { CHAT.pushToUser(uid, { type: 'chat-message', message: dto }); } catch (_) {} });
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
call.users.forEach((uid, i) => { try { CHAT.pushToUser(uid, { type: 'dm-call', active: false, with: call.users[1 - i], room }); } 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;
|
const callerId = call.users.find((id) => id !== byUser.id) || call.startedBy;
|
||||||
try {
|
try {
|
||||||
const mid = A.id();
|
const mid = A.id();
|
||||||
R.messages.send({ id: mid, teamId: byUser.team_id, senderId: byUser.id, recipientId: callerId, body: '📞 Call declined', msgType: '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 = 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' };
|
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(callerId, { type: 'chat-message', message: dto });
|
||||||
CHAT.pushToUser(byUser.id, { type: 'chat-message', message: dto });
|
CHAT.pushToUser(byUser.id, { type: 'chat-message', message: dto });
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
|
|||||||
+4
-4
@@ -114,21 +114,21 @@ async function sendToUser(userId, payload) {
|
|||||||
const data = JSON.stringify(payload || {});
|
const data = JSON.stringify(payload || {});
|
||||||
if (webReady) {
|
if (webReady) {
|
||||||
let subs = [];
|
let subs = [];
|
||||||
try { subs = R.pushSubs.byUser(userId); } catch (_) { subs = []; }
|
try { subs = await R.pushSubs.byUser(userId); } catch (_) { subs = []; }
|
||||||
for (const s of subs) {
|
for (const s of subs) {
|
||||||
try { await webpush.sendNotification({ endpoint: s.endpoint, keys: { p256dh: s.p256dh, auth: s.auth } }, data, { TTL: 600 }); }
|
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) {
|
if (nativeReady) {
|
||||||
let toks = [];
|
let toks = [];
|
||||||
try { toks = R.deviceTokens.byUser(userId); } catch (_) { toks = []; }
|
try { toks = await R.deviceTokens.byUser(userId); } catch (_) { toks = []; }
|
||||||
for (const t of toks) {
|
for (const t of toks) {
|
||||||
try {
|
try {
|
||||||
let r = null;
|
let r = null;
|
||||||
if (t.platform === 'android' && fcmSA) r = await sendFcm(t.token, payload);
|
if (t.platform === 'android' && fcmSA) r = await sendFcm(t.token, payload);
|
||||||
else if (t.platform === 'ios' && apnsCfg) r = await sendApns(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 */ }
|
} catch (_) { /* best-effort */ }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-3
@@ -6,15 +6,15 @@ const CHAT = require('./chat');
|
|||||||
function tick() {
|
function tick() {
|
||||||
try {
|
try {
|
||||||
const now = Date.now();
|
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) {
|
for (const s of due) {
|
||||||
const recipients = new Set([s.created_by]);
|
const recipients = new Set([s.created_by]);
|
||||||
let invited = []; try { invited = JSON.parse(s.participants || '[]'); } catch (_) {}
|
let invited = []; try { invited = JSON.parse(s.participants || '[]'); } catch (_) {}
|
||||||
invited.forEach((id) => recipients.add(id));
|
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 } };
|
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 (_) {} });
|
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 */ }
|
} 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`).
|
// 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
|
// 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.
|
// 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 A = require('./auth');
|
||||||
const now = () => Date.now();
|
const now = () => Date.now();
|
||||||
|
|
||||||
const teams = {
|
const teams = {
|
||||||
first: () => db.prepare('SELECT * FROM teams LIMIT 1').get(),
|
first: () => db.prepare('SELECT * FROM teams LIMIT 1').get(),
|
||||||
byId: (id) => db.prepare('SELECT * FROM teams WHERE id=?').get(id),
|
byId: (id) => db.prepare('SELECT * FROM teams WHERE id=?').get(id),
|
||||||
create: (name) => {
|
create: async (name) => {
|
||||||
const id = A.id();
|
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);
|
return db.prepare('SELECT * FROM teams WHERE id=?').get(id);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const users = {
|
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),
|
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
|
// 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).
|
// 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),
|
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).
|
// 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),
|
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),
|
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),
|
db.prepare('SELECT id,email,name,role,active,avatar_url,created_at FROM users WHERE team_id=?').all(tenantId),
|
||||||
inTenant: (id, tenantId) =>
|
inTenant: (id, tenantId) =>
|
||||||
db.prepare('SELECT * FROM users WHERE id=? AND team_id=?').get(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();
|
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,?)`)
|
VALUES (?,?,?,?,?,?,?,?,0,?)`)
|
||||||
.run(id, tenantId, email, hash, salt, role, name || null, mfaSecret, now());
|
.run(id, tenantId, email, hash, salt, role, name || null, mfaSecret, now());
|
||||||
return id;
|
return id;
|
||||||
@@ -57,47 +63,43 @@ const users = {
|
|||||||
// BizGaze person (#2). Runs in a single transaction so a failure leaves the data untouched.
|
// 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
|
// 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).
|
// (the survivor already has that membership/reaction/vote).
|
||||||
mergeInto: (fromId, intoId) => {
|
mergeInto: async (fromId, intoId) => {
|
||||||
if (!fromId || !intoId || fromId === intoId) return;
|
if (!fromId || !intoId || fromId === intoId) return;
|
||||||
const run = (sql, ...a) => db.prepare(sql).run(...a);
|
// db.tx() gives one atomic unit on both engines (sqlite single-connection; pg one pooled client).
|
||||||
db.exec('BEGIN');
|
return db.tx(async (t) => {
|
||||||
try {
|
const run = (sql, ...a) => t.run(sql, ...a);
|
||||||
run('UPDATE messages SET sender_id=? WHERE sender_id=?', intoId, fromId);
|
await run('UPDATE messages SET sender_id=? WHERE sender_id=?', intoId, fromId);
|
||||||
run('UPDATE messages SET recipient_id=? WHERE recipient_id=?', intoId, fromId);
|
await 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);
|
await run('UPDATE OR IGNORE message_reactions SET user_id=? WHERE user_id=?', intoId, fromId);
|
||||||
run('DELETE FROM message_reactions WHERE user_id=?', fromId);
|
await run('DELETE FROM message_reactions WHERE user_id=?', fromId);
|
||||||
run('UPDATE OR IGNORE conversation_members SET user_id=? WHERE user_id=?', intoId, fromId);
|
await run('UPDATE OR IGNORE conversation_members SET user_id=? WHERE user_id=?', intoId, fromId);
|
||||||
run('DELETE FROM conversation_members WHERE user_id=?', fromId);
|
await run('DELETE FROM conversation_members WHERE user_id=?', fromId);
|
||||||
run('UPDATE OR IGNORE poll_votes SET user_id=? WHERE user_id=?', intoId, fromId);
|
await run('UPDATE OR IGNORE poll_votes SET user_id=? WHERE user_id=?', intoId, fromId);
|
||||||
run('DELETE FROM poll_votes WHERE user_id=?', fromId);
|
await run('DELETE FROM poll_votes WHERE user_id=?', fromId);
|
||||||
run('UPDATE OR IGNORE favorites SET user_id=? WHERE user_id=?', intoId, fromId);
|
await run('UPDATE OR IGNORE favorites SET user_id=? WHERE user_id=?', intoId, fromId);
|
||||||
run('DELETE FROM favorites WHERE user_id=?', 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.
|
// 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);
|
await run("UPDATE OR IGNORE favorites SET target='dm:'||? WHERE target='dm:'||?", intoId, fromId);
|
||||||
run("DELETE FROM favorites WHERE target='dm:'||?", fromId);
|
await run("DELETE FROM favorites WHERE target='dm:'||?", fromId);
|
||||||
run('UPDATE conversations SET created_by=? WHERE created_by=?', intoId, fromId);
|
await run('UPDATE conversations SET created_by=? WHERE created_by=?', intoId, fromId);
|
||||||
run('UPDATE polls SET created_by=? WHERE created_by=?', intoId, fromId);
|
await run('UPDATE polls SET created_by=? WHERE created_by=?', intoId, fromId);
|
||||||
run('UPDATE scheduled_meetings SET created_by=? WHERE created_by=?', intoId, fromId);
|
await run('UPDATE scheduled_meetings SET created_by=? WHERE created_by=?', intoId, fromId);
|
||||||
run('UPDATE recordings SET created_by=? WHERE created_by=?', intoId, fromId);
|
await run('UPDATE recordings SET created_by=? WHERE created_by=?', intoId, fromId);
|
||||||
run('UPDATE attachments SET uploader_id=? WHERE uploader_id=?', intoId, fromId);
|
await run('UPDATE attachments SET uploader_id=? WHERE uploader_id=?', intoId, fromId);
|
||||||
run('UPDATE push_subscriptions SET user_id=? WHERE user_id=?', intoId, fromId);
|
await 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);
|
await run('UPDATE OR IGNORE device_tokens SET user_id=? WHERE user_id=?', intoId, fromId);
|
||||||
run('DELETE FROM device_tokens WHERE user_id=?', fromId);
|
await run('DELETE FROM device_tokens WHERE user_id=?', fromId);
|
||||||
run('UPDATE app_installs SET user_id=? WHERE user_id=?', intoId, 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.
|
// 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);
|
await run('DELETE FROM sessions_auth WHERE user_id=?', fromId);
|
||||||
run('DELETE FROM refresh_tokens 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
|
// 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).
|
// 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);
|
const _iu = await t.get('SELECT team_id FROM users WHERE id=?', 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());
|
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());
|
||||||
run('UPDATE user_aliases SET user_id=? WHERE user_id=?', intoId, fromId); // re-chain earlier aliases to the new survivor
|
await 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);
|
await run('DELETE FROM users WHERE id=?', fromId);
|
||||||
db.exec('COMMIT');
|
});
|
||||||
} catch (e) {
|
|
||||||
db.exec('ROLLBACK');
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -116,9 +118,9 @@ const machines = {
|
|||||||
inTenant: (id, tenantId) => db.prepare('SELECT * FROM machines WHERE id=? AND team_id=?').get(id, tenantId),
|
inTenant: (id, tenantId) => db.prepare('SELECT * FROM machines WHERE id=? AND team_id=?').get(id, tenantId),
|
||||||
listByTenant: (tenantId) =>
|
listByTenant: (tenantId) =>
|
||||||
db.prepare('SELECT id,name,unattended,last_seen FROM machines WHERE team_id=?').all(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();
|
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());
|
.run(id, tenantId, name, enrollToken, unattended ? 1 : 0, now());
|
||||||
return id;
|
return id;
|
||||||
},
|
},
|
||||||
@@ -239,18 +241,18 @@ const messages = {
|
|||||||
AND body LIKE ? ESCAPE '\\' ORDER BY created_at ASC LIMIT ?`).all(conversationId, like, limit),
|
AND body LIKE ? ESCAPE '\\' ORDER BY created_at ASC LIMIT ?`).all(conversationId, like, limit),
|
||||||
lastInConversation: (conversationId) =>
|
lastInConversation: (conversationId) =>
|
||||||
db.prepare('SELECT * FROM messages WHERE conversation_id=? ORDER BY created_at DESC LIMIT 1').get(conversationId),
|
db.prepare('SELECT * FROM messages WHERE conversation_id=? ORDER BY created_at DESC LIMIT 1').get(conversationId),
|
||||||
unreadInConversation: (conversationId, userId, since) =>
|
unreadInConversation: async (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,
|
(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 = {
|
const reactions = {
|
||||||
// Toggle with ONE reaction per user per message: picking an emoji replaces any prior
|
// 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.
|
// reaction by that user; picking the same one again removes it. Returns true if added.
|
||||||
toggle: (messageId, userId, emoji) => {
|
toggle: async (messageId, userId, emoji) => {
|
||||||
const had = db.prepare('SELECT 1 FROM message_reactions WHERE message_id=? AND user_id=? AND emoji=?').get(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);
|
||||||
db.prepare('DELETE FROM message_reactions WHERE message_id=? AND user_id=?').run(messageId, userId);
|
await db.prepare('DELETE FROM message_reactions WHERE message_id=? AND user_id=?').run(messageId, userId);
|
||||||
if (had) return false;
|
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;
|
return true;
|
||||||
},
|
},
|
||||||
forMessage: (messageId) => db.prepare('SELECT user_id, emoji FROM message_reactions WHERE message_id=? ORDER BY created_at ASC').all(messageId),
|
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),
|
byId: (id) => db.prepare('SELECT * FROM conversations WHERE id=?').get(id),
|
||||||
addMember: (conversationId, userId, admin) =>
|
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),
|
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),
|
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: (conversationId, userId) => !!db.prepare('SELECT 1 FROM conversation_members WHERE conversation_id=? AND user_id=?').get(conversationId, userId),
|
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) ----
|
// ---- 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),
|
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: (conversationId) => db.prepare('SELECT user_id FROM conversation_members WHERE conversation_id=? AND admin=1').all(conversationId).map((r) => r.user_id),
|
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),
|
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) =>
|
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),
|
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),
|
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),
|
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),
|
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),
|
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),
|
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),
|
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 = {
|
const attachments = {
|
||||||
@@ -352,7 +354,7 @@ const polls = {
|
|||||||
|
|
||||||
const pollVotes = {
|
const pollVotes = {
|
||||||
forPoll: (pollId) => db.prepare('SELECT user_id, option_idx FROM poll_votes WHERE poll_id=?').all(pollId),
|
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()),
|
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),
|
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),
|
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
|
set: (userId, target, on) => on
|
||||||
? db.prepare('INSERT OR IGNORE INTO favorites (user_id,target,created_at) VALUES (?,?,?)').run(userId, target, now())
|
? 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),
|
: 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 = {
|
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.
|
// 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);
|
const tok = tokenFromReq(req);
|
||||||
if (!tok) return null;
|
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 (!s || s.expires_at < now()) return null;
|
||||||
if (requireMfa && !s.mfa_passed) 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;
|
if (!u || u.active === 0) return null;
|
||||||
return { ...u, _session: s };
|
return { ...u, _session: s };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve a third-party API key from `X-API-Key` or `Authorization: Bearer bzc_...`.
|
// 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.
|
// 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'];
|
let raw = req.headers && req.headers['x-api-key'];
|
||||||
if (!raw) {
|
if (!raw) {
|
||||||
const h = req.headers && (req.headers.authorization || req.headers.Authorization);
|
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 (h && /^Bearer\s+bzc_/i.test(h)) raw = h.replace(/^Bearer\s+/i, '').trim();
|
||||||
}
|
}
|
||||||
if (!raw || !/^bzc_/.test(raw)) return null;
|
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;
|
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 };
|
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);
|
roomStats.delete(room);
|
||||||
if (!st || !st.teamId || st.peak < 1) return;
|
if (!st || !st.teamId || st.peak < 1) return;
|
||||||
try {
|
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 gid = roomToGroupCall.get(room) || null;
|
||||||
const isDm = roomToDmCall.has(room);
|
const isDm = roomToDmCall.has(room);
|
||||||
R.callHistory.create({
|
await R.callHistory.create({
|
||||||
id: A.id(), teamId: st.teamId, room, groupId: gid,
|
id: A.id(), teamId: st.teamId, room, groupId: gid,
|
||||||
kind: isDm ? 'dm' : (gid ? 'group' : 'adhoc'),
|
kind: isDm ? 'dm' : (gid ? 'group' : 'adhoc'),
|
||||||
title: isDm ? 'Direct call' : (gid ? null : 'Meeting'),
|
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.
|
// organizer chose "join directly". Logged-in tenant users are never held — only guests.
|
||||||
function meetingRoomRequiresApproval(room) {
|
function meetingRoomRequiresApproval(room) {
|
||||||
if (roomLobby.has(room)) return !!roomLobby.get(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;
|
return true;
|
||||||
}
|
}
|
||||||
// Actually add a newcomer to the room: tell them who's here, tell others they arrived, and (if they're
|
// 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) {
|
switch (m.type) {
|
||||||
// --- Logged-in user registers this socket for live chat delivery ---
|
// --- Logged-in user registers this socket for live chat delivery ---
|
||||||
case 'chat-hello': {
|
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' }));
|
if (!u) return ws.send(JSON.stringify({ type: 'error', message: 'unauthorized' }));
|
||||||
ws._chatUserId = u.id; ws._chatTeamId = u.team_id;
|
ws._chatUserId = u.id; ws._chatTeamId = u.team_id;
|
||||||
CHAT.register(u.id, ws);
|
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.
|
// Recipient's client acknowledges a DM was delivered → mark it + tell the sender.
|
||||||
case 'chat-delivered': {
|
case 'chat-delivered': {
|
||||||
if (!ws._chatUserId || !m.id) break;
|
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 || 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.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;
|
break;
|
||||||
}
|
}
|
||||||
// Live "is typing…" — ephemeral, never persisted. Relay to the DM peer, or fan out to
|
// 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': {
|
case 'chat-typing': {
|
||||||
const uid = ws._chatUserId; if (!uid) break;
|
const uid = ws._chatUserId; if (!uid) break;
|
||||||
const on = !!m.on;
|
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) {
|
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 (_) {} } }
|
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) {
|
} else if (m.to) {
|
||||||
try { CHAT.pushToUser(m.to, { type: 'chat-typing', from: uid, name, on }); } catch (_) {}
|
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': {
|
case 'meeting-create': {
|
||||||
let code; do { code = A.numericCode(6); } while (meetingRooms.has(code));
|
let code; do { code = A.numericCode(6); } while (meetingRooms.has(code));
|
||||||
meetingRooms.set(code, new Map());
|
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).
|
// Lobby preference for guests joining this ad-hoc room by link (default: require approval).
|
||||||
roomLobby.set(code, m.lobby === false ? false : true);
|
roomLobby.set(code, m.lobby === false ? false : true);
|
||||||
ws.send(JSON.stringify({ type: 'meeting-created', room: code }));
|
ws.send(JSON.stringify({ type: 'meeting-created', room: code }));
|
||||||
@@ -132,7 +132,7 @@ function handle(ws, m, req) {
|
|||||||
let peers = meetingRooms.get(room);
|
let peers = meetingRooms.get(room);
|
||||||
// A scheduled meeting's room is created lazily on first join (its code lives in the DB).
|
// A scheduled meeting's room is created lazily on first join (its code lives in the DB).
|
||||||
if (!peers) {
|
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 (sched && !sched.ended_at) { peers = new Map(); meetingRooms.set(room, peers); }
|
||||||
}
|
}
|
||||||
if (!peers) return ws.send(JSON.stringify({ type: 'error', message: 'Meeting not found' }));
|
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;
|
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.
|
// Host = the meeting's creator. roomHost is set on call/meeting creation; scheduled meetings fall back to created_by.
|
||||||
let hostUserId = roomHost.get(room);
|
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 (_) {} }
|
if (hostUserId === undefined) { try { const s = await R.scheduledMeetings.byCode(room); if (s) { hostUserId = s.created_by; roomHost.set(room, hostUserId); } } catch (_) {} }
|
||||||
const ju = currentUser(req);
|
const ju = await currentUser(req);
|
||||||
// Identity used to map LiveKit media → this tile (peerIdForUid). Logged-in users use their user id
|
// 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
|
// (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
|
// 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 ---
|
// --- Agent comes online ---
|
||||||
case 'agent-hello': {
|
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' }));
|
if (!machine) return ws.send(JSON.stringify({ type: 'error', message: 'invalid enroll token' }));
|
||||||
ws.kind = 'agent'; ws.machineId = machine.id;
|
ws.kind = 'agent'; ws.machineId = machine.id;
|
||||||
onlineAgents.set(machine.id, { ws, machine });
|
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 }));
|
ws.send(JSON.stringify({ type: 'agent-registered', machineId: machine.id, name: machine.name }));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
// --- Technician requests control of a machine ---
|
// --- Technician requests control of a machine ---
|
||||||
case 'viewer-connect': {
|
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' }));
|
if (!u) return ws.send(JSON.stringify({ type: 'error', message: 'unauthorized' }));
|
||||||
const agent = onlineAgents.get(m.machineId);
|
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 (!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 (!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
|
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) {
|
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') });
|
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 {
|
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 */ }
|
} 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 (_) {}
|
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 }));
|
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 ---
|
// --- Logged-in agent enters the code (+ ticket) to connect ---
|
||||||
case 'code-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) {
|
if (!agent) {
|
||||||
return ws.send(JSON.stringify({ type: 'error', message: 'Please sign in as an agent first' }));
|
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) {
|
function endSession(sessionId, reason) {
|
||||||
const sess = liveSessions.get(sessionId);
|
const sess = liveSessions.get(sessionId);
|
||||||
if (!sess) return;
|
if (!sess) return;
|
||||||
try { R.sessionsLog.end(sessionId); } catch (e) {}
|
try { await R.sessionsLog.end(sessionId); } catch (e) {}
|
||||||
try {
|
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,
|
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,
|
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 });
|
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
|
// 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.
|
// ANY message carrying it (the "any" covers forwarded attachments, which reuse the same id). Returns the row.
|
||||||
function authAttachmentRaw(id, u) {
|
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;
|
if (!a || a.team_id !== u.team_id) return null;
|
||||||
const avatarGroup = R.conversations.byAvatar(id);
|
const avatarGroup = await R.conversations.byAvatar(id);
|
||||||
const carriers = R.messages.allByAttachment(id);
|
const carriers = await R.messages.allByAttachment(id);
|
||||||
const ok = a.uploader_id === u.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
|
|| 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));
|
: (msg.sender_id === u.id || msg.recipient_id === u.id));
|
||||||
return ok ? a : null;
|
return ok ? a : null;
|
||||||
}
|
}
|
||||||
@@ -141,11 +141,11 @@ function handleGet(req, res) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (pathOnly.startsWith('/transcripts/')) {
|
if (pathOnly.startsWith('/transcripts/')) {
|
||||||
const u = currentUser(req);
|
const u = await currentUser(req);
|
||||||
if (!u) return json(res, 401, { error: 'unauthorized' });
|
if (!u) return json(res, 401, { error: 'unauthorized' });
|
||||||
const name = path.basename(decodeURIComponent(pathOnly));
|
const name = path.basename(decodeURIComponent(pathOnly));
|
||||||
const sid = name.replace(/\.txt$/i, '');
|
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' });
|
if (!row || !row.transcript) return json(res, 404, { error: 'not found' });
|
||||||
const fp = path.join(TRANS_DIR, row.transcript);
|
const fp = path.join(TRANS_DIR, row.transcript);
|
||||||
if (!fp.startsWith(TRANS_DIR)) return json(res, 403, { error: 'forbidden' });
|
if (!fp.startsWith(TRANS_DIR)) return json(res, 403, { error: 'forbidden' });
|
||||||
@@ -158,11 +158,11 @@ function handleGet(req, res) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (pathOnly.startsWith('/recordings/')) {
|
if (pathOnly.startsWith('/recordings/')) {
|
||||||
const u = currentUser(req);
|
const u = await currentUser(req);
|
||||||
if (!u) return json(res, 401, { error: 'unauthorized' });
|
if (!u) return json(res, 401, { error: 'unauthorized' });
|
||||||
const name = path.basename(decodeURIComponent(pathOnly));
|
const name = path.basename(decodeURIComponent(pathOnly));
|
||||||
const sid = name.replace(/\.(webm|mp4)$/i, '');
|
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' });
|
if (!row || !row.recording) return json(res, 404, { error: 'not found' });
|
||||||
const fp = path.join(REC_DIR, row.recording);
|
const fp = path.join(REC_DIR, row.recording);
|
||||||
if (!fp.startsWith(REC_DIR)) return json(res, 403, { error: 'forbidden' });
|
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
|
// Meeting recordings & transcripts (/mrec/<id>). Visible to the creator, group members, or those
|
||||||
// who can see the scheduled meeting it belongs to.
|
// who can see the scheduled meeting it belongs to.
|
||||||
if (pathOnly.startsWith('/mrec/')) {
|
if (pathOnly.startsWith('/mrec/')) {
|
||||||
const u = currentUser(req);
|
const u = await currentUser(req);
|
||||||
if (!u) return json(res, 401, { error: 'unauthorized' });
|
if (!u) return json(res, 401, { error: 'unauthorized' });
|
||||||
const id = path.basename(decodeURIComponent(pathOnly));
|
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' });
|
if (!r || r.team_id !== u.team_id || !r.file) return json(res, 404, { error: 'not found' });
|
||||||
let allowed = r.created_by === u.id;
|
let allowed = r.created_by === u.id;
|
||||||
if (r.kind === 'transcript') allowed = r.created_by === u.id; // transcripts are private to their owner
|
if (r.kind === 'transcript') allowed = r.created_by === u.id; // transcripts are private to their owner
|
||||||
else {
|
else {
|
||||||
if (!allowed && r.group_id) allowed = R.conversations.isMember(r.group_id, u.id);
|
if (!allowed && r.group_id) allowed = await 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.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' });
|
if (!allowed) return json(res, 403, { error: 'forbidden' });
|
||||||
const isVideo = r.kind === 'video';
|
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
|
// 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.
|
// is missing or fails we 404 and the <video> just falls back to its own (black) poster.
|
||||||
if (pathOnly.startsWith('/thumbs/')) {
|
if (pathOnly.startsWith('/thumbs/')) {
|
||||||
const u = currentUser(req);
|
const u = await currentUser(req);
|
||||||
if (!u) return json(res, 401, { error: 'unauthorized' });
|
if (!u) return json(res, 401, { error: 'unauthorized' });
|
||||||
const id = path.basename(decodeURIComponent(pathOnly));
|
const id = path.basename(decodeURIComponent(pathOnly));
|
||||||
const a = authAttachment(id, u);
|
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
|
// 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.
|
// download button keeps pointing at /files, which always serves the untouched original.
|
||||||
if (pathOnly.startsWith('/stream/')) {
|
if (pathOnly.startsWith('/stream/')) {
|
||||||
const u = currentUser(req);
|
const u = await currentUser(req);
|
||||||
if (!u) return json(res, 401, { error: 'unauthorized' });
|
if (!u) return json(res, 401, { error: 'unauthorized' });
|
||||||
const id = path.basename(decodeURIComponent(pathOnly));
|
const id = path.basename(decodeURIComponent(pathOnly));
|
||||||
const a = authAttachment(id, u);
|
const a = authAttachment(id, u);
|
||||||
@@ -253,7 +253,7 @@ function handleGet(req, res) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (pathOnly.startsWith('/files/')) {
|
if (pathOnly.startsWith('/files/')) {
|
||||||
const u = currentUser(req);
|
const u = await currentUser(req);
|
||||||
if (!u) return json(res, 401, { error: 'unauthorized' });
|
if (!u) return json(res, 401, { error: 'unauthorized' });
|
||||||
const id = path.basename(decodeURIComponent(pathOnly));
|
const id = path.basename(decodeURIComponent(pathOnly));
|
||||||
const a = authAttachment(id, u);
|
const a = authAttachment(id, u);
|
||||||
|
|||||||
+2
-2
@@ -38,10 +38,10 @@ function emit(event, tenantId, payload) {
|
|||||||
const body = JSON.stringify({ event, ...payload });
|
const body = JSON.stringify({ event, ...payload });
|
||||||
// Per-tenant subscriptions
|
// Per-tenant subscriptions
|
||||||
try {
|
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());
|
const subs = String(h.events || '').split(',').map((s) => s.trim());
|
||||||
if (subs.includes('*') || subs.includes(event)) {
|
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 (_) {}
|
} catch (_) {}
|
||||||
|
|||||||
Reference in New Issue
Block a user