feat(db): complete async call-site conversion — Phase 3 done, validated on SQLite
The full sync→async conversion is complete and green on the SQLite backend. Every DB call across the app now awaits the async adapter, so the identical code runs on Postgres at cutover. Converted (this commit finishes Phase 3): - session.js: currentUser/apiKeyFromReq async → 63 route awaits + WS + static. - routes.js: all ~250 R.* awaited; DTO helpers (namesFor, avatarsFor, buildMsgDTO, buildPollDTO, reactionsForMessage, postSystemMessage, pushGroupUpdate, issueRefreshToken, provisionFromBizgaze) made async; every `.map(x=>buildDTO(x))` restructured to `await Promise.all(...map(async...))` preserving order; `.filter` predicates that hit the DB moved to an `asyncFilter` helper; chained `R.x.y(...).length/.map/.filter` wrapped as `(await R.x.y(...)).method`; stream upload handlers (recording/transcript/attachment) made async. - calls.js / signaling.js: all call/meeting fns async; leaveMeeting AWAITS persistCallHistory + finalizeTranscript BEFORE endCallByRoom (ordering matters — fire-and-forget would race the map teardown); WS handle()/cleanup() async with .catch guards. - static.js: authAttachment(Raw) async (the .some carrier check became a loop), handleGet async; server.js dispatch catches handler rejections → 500 not a hang. - media.js backfill, push.js, reminders.js, webhooks.js await their repo calls. Validation on DB_BACKEND=sqlite: db-smoke 22/22; legacy e2e 80 checks pass with zero FAILs (throws only at a PRE-EXISTING WS lobby-drift assertion, unrelated). Every server file `node --check` clean. Still on the branch — master untouched. Next: Phase 5 (pg backend + ~7 dialect queries + data migration + Docker Postgres + cutover), then merge. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+94
-91
@@ -12,7 +12,10 @@ const MSG_MAX = 4000;
|
||||
const parseMentions = (s) => { if (!s) return []; try { const a = JSON.parse(s); return Array.isArray(a) ? a : []; } catch { return []; } };
|
||||
const SYSTEM_SENDER = '__system__';
|
||||
const msgDTO = (m) => ({ id: m.id, from: m.sender_id, to: m.recipient_id, conversation_id: m.conversation_id || null, body: m.deleted ? '' : m.body, created_at: m.created_at, read_at: m.read_at, delivered_at: m.delivered_at || null, reply_to: m.deleted ? null : (m.reply_to || null), mentions: parseMentions(m.mentions), evt: m.msg_type || null, fwd_from: m.deleted ? null : (m.fwd_from || null), deleted: !!m.deleted, system: m.sender_id === SYSTEM_SENDER || !!m.msg_type });
|
||||
function namesFor(teamId){ const o = {}; for (const x of await R.users.listByTenant(teamId)) o[x.id] = x.name || x.email; return o; }
|
||||
async function namesFor(teamId){ const o = {}; for (const x of await R.users.listByTenant(teamId)) o[x.id] = x.name || x.email; return o; }
|
||||
// Await-aware filter: keep items whose async predicate resolves truthy (these predicates hit the DB, and a
|
||||
// plain .filter() can't await). Sequential so per-item DB order is deterministic.
|
||||
async function asyncFilter(arr, pred){ const out = []; for (const x of arr) { if (await pred(x)) out.push(x); } return out; }
|
||||
// id -> profile photo, with a fallback across DUPLICATE rows for the same person.
|
||||
//
|
||||
// A person can end up with more than one row (signed in by email once and by mobile another time, before
|
||||
@@ -20,7 +23,7 @@ function namesFor(teamId){ const o = {}; for (const x of await R.users.listByTen
|
||||
// the row WITH the photo while a DM referenced the one without — so the same contact showed their picture
|
||||
// in a group but fell back to initials in the 1:1. Key each row by its stable person identity (BizGaze
|
||||
// person id, else email, else name) and let a photo-less row borrow its twin's photo.
|
||||
function avatarsFor(teamId) {
|
||||
async function avatarsFor(teamId) {
|
||||
const users = await R.users.listByTenant(teamId);
|
||||
const em = (x) => (x.email ? String(x.email).toLowerCase() : '');
|
||||
const nm = (x) => String(x.name || '').trim().toLowerCase();
|
||||
@@ -48,15 +51,15 @@ function nextOccurrence(baseTs, days, nowTs){ const b = new Date(baseTs); const
|
||||
const RDAY = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
function recurrenceLabel(days){ if (!days || !days.length) return ''; if (days.length === 7) return 'Every day'; return 'Every ' + days.slice().sort().map((d) => RDAY[d]).join(', '); }
|
||||
// Post a centered "activity" line into a group (member added/removed/renamed/left) and push it.
|
||||
function postSystemMessage(conversationId, teamId, text){
|
||||
async function postSystemMessage(conversationId, teamId, text){
|
||||
const id = A.id();
|
||||
await R.messages.send({ id, teamId, senderId: SYSTEM_SENDER, recipientId: '', body: text, conversationId });
|
||||
const dto = buildMsgDTO(await R.messages.byId(id), {}, '');
|
||||
const dto = await buildMsgDTO(await R.messages.byId(id), {}, '');
|
||||
for (const mid of await R.conversations.members(conversationId)) { try { CHAT.pushToUser(mid, { type: 'chat-message', message: dto }); } catch (_) {} }
|
||||
return dto;
|
||||
}
|
||||
// Tell clients a group's membership changed so they refresh the member count / sidebar immediately.
|
||||
function pushGroupUpdate(group, alsoUsers){
|
||||
async function pushGroupUpdate(group, alsoUsers){
|
||||
const seen = new Set();
|
||||
for (const mid of await R.conversations.members(group)) { seen.add(mid); try { CHAT.pushToUser(mid, { type: 'group-update', group }); } catch (_) {} }
|
||||
for (const mid of (alsoUsers || [])) { if (!seen.has(mid)) { try { CHAT.pushToUser(mid, { type: 'group-update', group, removed: true }); } catch (_) {} } }
|
||||
@@ -74,12 +77,12 @@ function groupReactions(list, userId, names){
|
||||
}
|
||||
const dtoReactions = (rxBy, id) => (rxBy[id] ? Object.entries(rxBy[id]).map(([emoji, v]) => ({ emoji, count: v.count, mine: v.mine, who: v.who })) : []);
|
||||
// Full reaction DTO for ONE message, from `userId`'s perspective (mine/who).
|
||||
function reactionsForMessage(messageId, userId, names){
|
||||
const rows = await R.reactions.forMessage(messageId).map((r) => ({ message_id: messageId, user_id: r.user_id, emoji: r.emoji }));
|
||||
async function reactionsForMessage(messageId, userId, names){
|
||||
const rows = (await R.reactions.forMessage(messageId)).map((r) => ({ message_id: messageId, user_id: r.user_id, emoji: r.emoji }));
|
||||
return dtoReactions(groupReactions(rows, userId, names), messageId);
|
||||
}
|
||||
// Poll tally for a given viewer ("mine" = this user voted that option).
|
||||
function buildPollDTO(poll, userId){
|
||||
async function buildPollDTO(poll, userId){
|
||||
let opts = []; try { opts = JSON.parse(poll.options); } catch { opts = []; }
|
||||
const counts = opts.map(() => 0); const mine = opts.map(() => false); const voters = new Set();
|
||||
for (const v of await R.pollVotes.forPoll(poll.id)) {
|
||||
@@ -93,7 +96,7 @@ function buildPollDTO(poll, userId){
|
||||
};
|
||||
}
|
||||
// DTO enriched with a small preview of the quoted message (if this is a reply).
|
||||
function buildMsgDTO(m, names, userId){
|
||||
async function buildMsgDTO(m, names, userId){
|
||||
const d = msgDTO(m);
|
||||
if (m.reply_to) {
|
||||
const r = await R.messages.byId(m.reply_to);
|
||||
@@ -103,7 +106,7 @@ function buildMsgDTO(m, names, userId){
|
||||
const a = await R.attachments.byId(m.attachment_id);
|
||||
if (a) d.attachment = { id: a.id, name: a.name, mime: a.mime, size: a.size, isImage: /^image\//.test(a.mime || ''), isVideo: /^video\//.test(a.mime || ''), isAudio: /^audio\//.test(a.mime || '') };
|
||||
}
|
||||
if (m.poll_id) { const p = await R.polls.byId(m.poll_id); if (p) d.poll = buildPollDTO(p, userId); }
|
||||
if (m.poll_id) { const p = await R.polls.byId(m.poll_id); if (p) d.poll = await buildPollDTO(p, userId); }
|
||||
if (m.msg_type) d.byName = (names && names[m.sender_id]) || '';
|
||||
return d;
|
||||
}
|
||||
@@ -152,7 +155,7 @@ function livekitToken(identity, name, room, metadata) {
|
||||
}
|
||||
|
||||
// Issue a refresh token (native clients), store only its hash, return the plaintext once.
|
||||
function issueRefreshToken(userId) {
|
||||
async function issueRefreshToken(userId) {
|
||||
const rtok = A.token(32);
|
||||
await R.refreshTokens.create({ userId, tokenHash: A.hashToken(rtok), ttl: REFRESH_TTL });
|
||||
return rtok;
|
||||
@@ -193,7 +196,7 @@ route('POST', '/api/mfa/enable', async (req, res) => {
|
||||
// Emails that must always be admins regardless of what BizGaze returns (safety net so an
|
||||
// admin can't be locked out of the report if BizGaze doesn't flag them isAdmin). Optional.
|
||||
const ADMIN_EMAILS = (process.env.ADMIN_EMAILS || '').split(',').map((s) => s.trim().toLowerCase()).filter(Boolean);
|
||||
function provisionFromBizgaze(email, bz) {
|
||||
async function provisionFromBizgaze(email, bz) {
|
||||
const role = (bz.isAdmin || ADMIN_EMAILS.includes(String(email).toLowerCase())) ? 'admin' : 'technician';
|
||||
const bizId = bz.bizgazeUserId || null;
|
||||
|
||||
@@ -257,7 +260,7 @@ route('POST', '/api/login', async (req, res) => {
|
||||
const bz = await BZ.validateLogin(email, password);
|
||||
if (bz.error) return json(res, 503, { error: bz.error });
|
||||
if (!bz.ok) return json(res, 401, { error: bz.message || 'Username or password do not match.' });
|
||||
u = provisionFromBizgaze(email, bz);
|
||||
u = await provisionFromBizgaze(email, bz);
|
||||
if (u && u.active === 0) return json(res, 403, { error: 'This account has been deactivated' });
|
||||
} else {
|
||||
// Local/dev/tests, or ALLOW_LOCAL_LOGIN=1: verify the local password, then fall back
|
||||
@@ -265,7 +268,7 @@ route('POST', '/api/login', async (req, res) => {
|
||||
u = (existing && A.verifyPassword(password, existing.pw_salt, existing.pw_hash)) ? existing : null;
|
||||
if (!u) {
|
||||
const bz = await BZ.validateLogin(email, password);
|
||||
if (bz.ok) u = provisionFromBizgaze(email, bz);
|
||||
if (bz.ok) u = await provisionFromBizgaze(email, bz);
|
||||
else if (bz.error) return json(res, 503, { error: bz.error });
|
||||
else bzMsg = bz.message || null; // BizGaze configured and rejected the credentials
|
||||
}
|
||||
@@ -283,7 +286,7 @@ route('POST', '/api/login', async (req, res) => {
|
||||
audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'login' });
|
||||
// Cookie for the web app; access token + refresh token in the body for native
|
||||
// desktop/mobile clients (access via `Authorization: Bearer`, refresh via /api/v1/auth/refresh).
|
||||
const refreshToken = issueRefreshToken(u.id);
|
||||
const refreshToken = await issueRefreshToken(u.id);
|
||||
json(res, 200, { ok: true, mfaRequired: false, token: tok, expiresAt: now() + ttl, refreshToken, refreshExpiresAt: now() + REFRESH_TTL });
|
||||
});
|
||||
|
||||
@@ -300,7 +303,7 @@ route('POST', '/api/auth/refresh', async (req, res) => {
|
||||
await R.refreshTokens.revoke(h); // rotate: one-time use
|
||||
const tok = A.token();
|
||||
await R.authSessions.create({ token: tok, userId: u.id, mfaPassed: true, ttl: SESSION_TTL });
|
||||
const newRefresh = issueRefreshToken(u.id);
|
||||
const newRefresh = await issueRefreshToken(u.id);
|
||||
json(res, 200, { ok: true, token: tok, expiresAt: now() + SESSION_TTL, refreshToken: newRefresh, refreshExpiresAt: now() + REFRESH_TTL });
|
||||
});
|
||||
|
||||
@@ -702,7 +705,7 @@ route('POST', '/api/recording', async (req, res) => {
|
||||
if (!row) return json(res, 404, { error: 'no such session' });
|
||||
const chunks = []; let total = 0, aborted = false;
|
||||
req.on('data', (c) => { total += c.length; if (total > MAX_REC_BYTES) { aborted = true; req.destroy(); return; } chunks.push(c); });
|
||||
req.on('end', () => {
|
||||
req.on('end', async () => {
|
||||
if (aborted) return json(res, 413, { error: 'recording too large' });
|
||||
const fname = sid + '.' + ext;
|
||||
try {
|
||||
@@ -724,7 +727,7 @@ route('POST', '/api/transcript', async (req, res) => {
|
||||
if (!row) return json(res, 404, { error: 'no such session' });
|
||||
const chunks = []; let total = 0, aborted = false;
|
||||
req.on('data', (c) => { total += c.length; if (total > 5 * 1024 * 1024) { aborted = true; req.destroy(); return; } chunks.push(c); });
|
||||
req.on('end', () => {
|
||||
req.on('end', async () => {
|
||||
if (aborted) return json(res, 413, { error: 'transcript too large' });
|
||||
const fname = sid + '.txt';
|
||||
try {
|
||||
@@ -741,8 +744,8 @@ route('POST', '/api/transcript', async (req, res) => {
|
||||
route('GET', '/api/messages/contacts', async (req, res) => {
|
||||
const u = await currentUser(req);
|
||||
if (!u) return json(res, 401, { error: 'unauthorized' });
|
||||
const rows = await R.users.listByTenant(u.team_id).filter((x) => x.id !== u.id && x.active !== 0);
|
||||
const cAv = avatarsFor(u.team_id); // duplicate-row DP fallback
|
||||
const rows = (await R.users.listByTenant(u.team_id)).filter((x) => x.id !== u.id && x.active !== 0);
|
||||
const cAv = await avatarsFor(u.team_id); // duplicate-row DP fallback
|
||||
json(res, 200, rows.map((x) => ({ id: x.id, name: x.name || x.email, email: x.email, online: CHAT.isOnline(x.id), avatar: cAv[x.id] || null, lastSeen: x.last_seen || null, status: x.status || 'active' })));
|
||||
});
|
||||
|
||||
@@ -755,7 +758,7 @@ route('GET', '/api/directory/search', async (req, res) => {
|
||||
if (q.length < 2) return json(res, 200, []);
|
||||
const results = await require('./directory').search(q);
|
||||
// Map directory people to existing Connect users in this tenant (by email) so they're chat-ready.
|
||||
const mine = await R.users.listByTenant(u.team_id).filter((x) => x.id !== u.id && x.active !== 0);
|
||||
const mine = (await R.users.listByTenant(u.team_id)).filter((x) => x.id !== u.id && x.active !== 0);
|
||||
const byEmail = new Map(mine.map((x) => [(x.email || '').toLowerCase(), x]));
|
||||
const out = results.map((p) => {
|
||||
const local = p.email ? byEmail.get(p.email.toLowerCase()) : null;
|
||||
@@ -774,7 +777,7 @@ route('GET', '/api/messages/conversations', async (req, res) => {
|
||||
const statuses = {};
|
||||
const seen = {};
|
||||
for (const x of await R.users.listByTenant(u.team_id)) { names[x.id] = x.name || x.email; statuses[x.id] = x.status || 'active'; seen[x.id] = x.last_seen || null; }
|
||||
Object.assign(avatars, avatarsFor(u.team_id)); // same person / two rows → borrow the DP (see avatarsFor)
|
||||
Object.assign(avatars, await avatarsFor(u.team_id)); // same person / two rows → borrow the DP (see avatarsFor)
|
||||
const favs = new Set(await R.favorites.forUser(u.id));
|
||||
const inCall = new Set();
|
||||
for (const [, peers] of meetingRooms) { for (const [, p] of peers) { if (p.ws && p.ws._meetingUserId) inCall.add(p.ws._meetingUserId); } }
|
||||
@@ -785,7 +788,7 @@ route('GET', '/api/messages/conversations', async (req, res) => {
|
||||
if (!raw) continue;
|
||||
// If this counterparty was merged away, key the row by the SURVIVING account, so the thread carries
|
||||
// that account's name/photo/presence (and two half-threads for one person collapse into one row).
|
||||
const other = (() => { try { return await R.users.resolve(raw) || raw; } catch (_) { return raw; } })();
|
||||
let other; try { other = await R.users.resolve(raw) || raw; } catch (_) { other = raw; }
|
||||
if (!byOther.has(other)) byOther.set(other, { other, last: m, unread: 0 });
|
||||
if (m.recipient_id === u.id && (m.sender_id === raw || m.sender_id === other) && !m.read_at) byOther.get(other).unread++;
|
||||
}
|
||||
@@ -798,7 +801,7 @@ route('GET', '/api/messages/conversations', async (req, res) => {
|
||||
last_status: c.last.sender_id === u.id ? (c.last.read_at ? 'read' : (c.last.delivered_at ? 'delivered' : 'sent')) : null, // tick for my last message
|
||||
}; });
|
||||
// Groups
|
||||
const groupItems = await R.conversations.listForUser(u.team_id, u.id).map((g) => {
|
||||
const groupItems = await Promise.all((await R.conversations.listForUser(u.team_id, u.id)).map(async (g) => {
|
||||
const last = await R.messages.lastInConversation(g.id);
|
||||
const since = await R.conversations.lastReadAt(g.id, u.id);
|
||||
const members = await R.conversations.members(g.id);
|
||||
@@ -807,8 +810,8 @@ route('GET', '/api/messages/conversations', async (req, res) => {
|
||||
let gStatus = null;
|
||||
if (last && last.sender_id === u.id) {
|
||||
const others = members.filter((id) => id !== u.id).length;
|
||||
const seen = await R.conversations.memberReads(g.id).filter((r) => r.user_id !== u.id && r.last_read_at >= last.created_at).length;
|
||||
gStatus = (others > 0 && seen >= others) ? 'read' : (seen > 0 ? 'delivered' : 'sent');
|
||||
const seenN = (await R.conversations.memberReads(g.id)).filter((r) => r.user_id !== u.id && r.last_read_at >= last.created_at).length;
|
||||
gStatus = (others > 0 && seenN >= others) ? 'read' : (seenN > 0 ? 'delivered' : 'sent');
|
||||
}
|
||||
return {
|
||||
kind: 'group', id: g.id, name: g.name || 'Group', members: members.length, avatar: g.avatar_id ? ('/files/' + g.avatar_id) : null, favorite: favs.has('group:' + g.id),
|
||||
@@ -817,7 +820,7 @@ route('GET', '/api/messages/conversations', async (req, res) => {
|
||||
last_from_me: last ? last.sender_id === u.id : false, unread: last ? await R.messages.unreadInConversation(g.id, u.id, since) : 0,
|
||||
last_status: gStatus,
|
||||
};
|
||||
});
|
||||
}));
|
||||
json(res, 200, [...dmItems, ...groupItems].sort((a, b) => b.last_at - a.last_at));
|
||||
});
|
||||
|
||||
@@ -828,7 +831,7 @@ route('GET', '/api/messages/thread', async (req, res) => {
|
||||
const q = new URLSearchParams(req.url.split('?')[1] || '');
|
||||
const peek = !!q.get('peek'); // prefetch only — do NOT mark the conversation read
|
||||
const before = parseInt(q.get('before') || '', 10) || null; // pagination cursor: fetch messages OLDER than this created_at
|
||||
const names = namesFor(u.team_id);
|
||||
const names = await namesFor(u.team_id);
|
||||
const group = q.get('group');
|
||||
if (group) {
|
||||
if (!await R.conversations.isMember(group, u.id)) return json(res, 403, { error: 'not a member of this group' });
|
||||
@@ -841,12 +844,12 @@ route('GET', '/api/messages/thread', async (req, res) => {
|
||||
}
|
||||
const rxBy = groupReactions(await R.reactions.forConversation(group), u.id, names);
|
||||
const reads = await R.conversations.memberReads(group); // ALL members' read times (#2: seen-by visible to everyone)
|
||||
return json(res, 200, rows.map((m) => {
|
||||
const d = buildMsgDTO(m, names, u.id); d.fromName = names[m.sender_id] || ''; d.reactions = dtoReactions(rxBy, m.id);
|
||||
return json(res, 200, await Promise.all(rows.map(async (m) => {
|
||||
const d = await buildMsgDTO(m, names, u.id); d.fromName = names[m.sender_id] || ''; d.reactions = dtoReactions(rxBy, m.id);
|
||||
// Who has read this message (excluding its sender) — shown to every member, not just the sender.
|
||||
d.seenBy = reads.filter((r) => r.user_id !== m.sender_id && r.last_read_at >= m.created_at).map((r) => names[r.user_id] || 'Someone');
|
||||
return d;
|
||||
}));
|
||||
})));
|
||||
}
|
||||
const other = await R.users.resolve(q.get('with')); // follow a merge redirect so a stale peer id still loads the thread
|
||||
if (!other) return json(res, 400, { error: 'with or group required' });
|
||||
@@ -854,7 +857,7 @@ route('GET', '/api/messages/thread', async (req, res) => {
|
||||
const rows = await R.messages.thread(u.team_id, u.id, other, 40, before); // page size (latest 40 / older via ?before) — matches client PAGE for smooth open + lazy load
|
||||
if (!peek && !before) { await R.messages.markRead(u.team_id, u.id, other); try { CHAT.pushToUser(other, { type: 'chat-read', by: u.id }); } catch (_) {} try { CHAT.pushToUser(u.id, { type: 'notif-clear', kind: 'dm', id: other }); } catch (_) {} } // #13
|
||||
const rxBy = groupReactions(await R.reactions.forPair(u.team_id, u.id, other), u.id, names);
|
||||
return json(res, 200, rows.map((m) => { const d = buildMsgDTO(m, names, u.id); d.reactions = dtoReactions(rxBy, m.id); return d; }));
|
||||
return json(res, 200, await Promise.all(rows.map(async (m) => { const d = await buildMsgDTO(m, names, u.id); d.reactions = dtoReactions(rxBy, m.id); return d; })));
|
||||
});
|
||||
|
||||
// Search the ENTIRE thread (not just the loaded window). Returns matching message ids + timestamps,
|
||||
@@ -886,7 +889,7 @@ route('POST', '/api/groups', async (req, res) => {
|
||||
const { name, memberIds } = await readBody(req);
|
||||
const nm = String(name || '').trim().slice(0, 80);
|
||||
if (!nm) return json(res, 400, { error: 'group name required' });
|
||||
const ids = (Array.isArray(memberIds) ? memberIds : []).filter((x) => typeof x === 'string' && x !== u.id && await R.users.inTenant(x, u.team_id));
|
||||
const ids = await asyncFilter((Array.isArray(memberIds) ? memberIds : []), async (x) => typeof x === 'string' && x !== u.id && await R.users.inTenant(x, u.team_id));
|
||||
const id = A.id();
|
||||
await R.conversations.create({ id, teamId: u.team_id, name: nm, createdBy: u.id });
|
||||
await R.conversations.addMember(id, u.id, true); // creator is the first admin
|
||||
@@ -903,9 +906,9 @@ route('GET', '/api/groups/members', async (req, res) => {
|
||||
if (!gid || !await R.conversations.isMember(gid, u.id)) return json(res, 403, { error: 'not a member' });
|
||||
const names = {}; const avatars = {};
|
||||
for (const x of await R.users.listByTenant(u.team_id)) { names[x.id] = x.name || x.email; }
|
||||
Object.assign(avatars, avatarsFor(u.team_id));
|
||||
Object.assign(avatars, await avatarsFor(u.team_id));
|
||||
const adminSet = new Set(await R.conversations.admins(gid));
|
||||
json(res, 200, await R.conversations.members(gid).map((mid) => ({ id: mid, name: names[mid] || 'Unknown', avatar: avatars[mid] || null, admin: adminSet.has(mid) })));
|
||||
json(res, 200, (await R.conversations.members(gid)).map((mid) => ({ id: mid, name: names[mid] || 'Unknown', avatar: avatars[mid] || null, admin: adminSet.has(mid) })));
|
||||
});
|
||||
|
||||
// Full group info: name, creator flag, members (with isMe).
|
||||
@@ -918,7 +921,7 @@ route('GET', '/api/groups/info', async (req, res) => {
|
||||
const tenantUsers = await R.users.listByTenant(u.team_id);
|
||||
const names = {}; const avatars = {};
|
||||
for (const x of tenantUsers) { names[x.id] = x.name || x.email; }
|
||||
Object.assign(avatars, avatarsFor(u.team_id));
|
||||
Object.assign(avatars, await avatarsFor(u.team_id));
|
||||
const adminSet = new Set(await R.conversations.admins(gid));
|
||||
json(res, 200, {
|
||||
id: gid, name: g.name || 'Group', createdBy: g.created_by, isCreator: g.created_by === u.id,
|
||||
@@ -926,7 +929,7 @@ route('GET', '/api/groups/info', async (req, res) => {
|
||||
adminOnly: !!g.admin_only, callActive: groupCalls.has(gid), callRoom: (groupCalls.get(gid) || {}).room || null,
|
||||
createdByName: names[g.created_by] || 'Someone', createdAt: g.created_at,
|
||||
avatar: g.avatar_id ? ('/files/' + g.avatar_id) : null,
|
||||
members: await R.conversations.members(gid).map((mid) => ({ id: mid, name: names[mid] || 'Unknown', avatar: avatars[mid] || null, isMe: mid === u.id, admin: adminSet.has(mid) })),
|
||||
members: (await R.conversations.members(gid)).map((mid) => ({ id: mid, name: names[mid] || 'Unknown', avatar: avatars[mid] || null, isMe: mid === u.id, admin: adminSet.has(mid) })),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -939,7 +942,7 @@ route('POST', '/api/groups/rename', async (req, res) => {
|
||||
const nm = String(name || '').trim().slice(0, 80);
|
||||
if (!nm) return json(res, 400, { error: 'group name required' });
|
||||
await R.conversations.rename(group, nm);
|
||||
postSystemMessage(group, u.team_id, (u.name || u.email) + ' renamed the group to “' + nm + '”');
|
||||
await postSystemMessage(group, u.team_id, (u.name || u.email) + ' renamed the group to “' + nm + '”');
|
||||
audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'group_renamed', detail: nm });
|
||||
json(res, 200, { ok: true, name: nm });
|
||||
});
|
||||
@@ -951,7 +954,7 @@ route('POST', '/api/groups/call/start', async (req, res) => {
|
||||
if (!u) return json(res, 401, { error: 'unauthorized' });
|
||||
const { group } = await readBody(req);
|
||||
if (!group || !await R.conversations.isMember(group, u.id)) return json(res, 403, { error: 'not a member of this group' });
|
||||
const r = CALLS.startGroupCall(group, u.team_id, u);
|
||||
const r = await CALLS.startGroupCall(group, u.team_id, u);
|
||||
json(res, 200, r);
|
||||
});
|
||||
|
||||
@@ -961,7 +964,7 @@ route('POST', '/api/calls/dm/start', async (req, res) => {
|
||||
if (!u) return json(res, 401, { error: 'unauthorized' });
|
||||
const { to } = await readBody(req);
|
||||
if (!to || !await R.users.inTenant(to, u.team_id)) return json(res, 404, { error: 'no such contact' });
|
||||
json(res, 200, CALLS.startDmCall(u, to, u.team_id));
|
||||
json(res, 200, await CALLS.startDmCall(u, to, u.team_id));
|
||||
});
|
||||
|
||||
// Invite more people into the call I'm in (turns a 1:1 into multi-party). Pushes them an
|
||||
@@ -971,7 +974,7 @@ route('POST', '/api/calls/invite', async (req, res) => {
|
||||
if (!u) return json(res, 401, { error: 'unauthorized' });
|
||||
const { room, userIds } = await readBody(req);
|
||||
if (!room || !meetingRooms.has(String(room))) return json(res, 404, { error: 'call not found' });
|
||||
const ids = (Array.isArray(userIds) ? userIds : []).filter((x) => typeof x === 'string' && x !== u.id && await R.users.inTenant(x, u.team_id));
|
||||
const ids = await asyncFilter((Array.isArray(userIds) ? userIds : []), async (x) => typeof x === 'string' && x !== u.id && await R.users.inTenant(x, u.team_id));
|
||||
for (const id of ids) { try { CHAT.pushToUser(id, { type: 'call-invite', room: String(room), byName: (u.name || u.email) }); } catch (_) {} }
|
||||
json(res, 200, { ok: true, invited: ids.length });
|
||||
});
|
||||
@@ -1045,7 +1048,7 @@ route('POST', '/api/meetings/guest-token', async (req, res) => {
|
||||
// #3 Link expiry: a scheduled meeting's guest link is only valid until ~2h after its scheduled end —
|
||||
// after that the link is dead (returns 404) even though the DB row lingers. Live rooms are valid while
|
||||
// anyone's in them (they vanish from meetingRooms when empty), which is its own natural expiry.
|
||||
const sched = (() => {
|
||||
const sched = await (async () => {
|
||||
try {
|
||||
const s = await R.scheduledMeetings.byCode(rm);
|
||||
if (!s || s.ended_at) return false;
|
||||
@@ -1068,7 +1071,7 @@ route('POST', '/api/calls/decline', async (req, res) => {
|
||||
if (!u) return json(res, 401, { error: 'unauthorized' });
|
||||
const { room } = await readBody(req);
|
||||
if (!room) return json(res, 400, { error: 'room required' });
|
||||
json(res, 200, CALLS.declineDmCall(String(room), u));
|
||||
json(res, 200, await CALLS.declineDmCall(String(room), u));
|
||||
});
|
||||
|
||||
// Toggle "only admins can add/remove members" (any admin).
|
||||
@@ -1080,7 +1083,7 @@ route('POST', '/api/groups/admin-only', async (req, res) => {
|
||||
if (!g || g.team_id !== u.team_id || !await R.conversations.isMember(group, u.id)) return json(res, 403, { error: 'not a member' });
|
||||
if (!await R.conversations.isAdmin(group, u.id)) return json(res, 403, { error: 'only a group admin can change this' });
|
||||
await R.conversations.setAdminOnly(group, !!value);
|
||||
postSystemMessage(group, u.team_id, (u.name || u.email) + (value ? ' restricted adding members to admins only' : ' allowed everyone to add members'));
|
||||
await postSystemMessage(group, u.team_id, (u.name || u.email) + (value ? ' restricted adding members to admins only' : ' allowed everyone to add members'));
|
||||
json(res, 200, { ok: true, adminOnly: !!value });
|
||||
});
|
||||
|
||||
@@ -1093,11 +1096,11 @@ route('POST', '/api/groups/admin', async (req, res) => {
|
||||
if (!g || g.team_id !== u.team_id || !await R.conversations.isMember(group, u.id)) return json(res, 403, { error: 'not a member' });
|
||||
if (!await R.conversations.isAdmin(group, u.id)) return json(res, 403, { error: 'only a group admin can change roles' });
|
||||
if (!userId || !await R.conversations.isMember(group, userId)) return json(res, 404, { error: 'not a member of this group' });
|
||||
if (!value && await R.conversations.admins(group).length <= 1 && await R.conversations.isAdmin(group, userId)) return json(res, 400, { error: 'a group must have at least one admin' });
|
||||
if (!value && (await R.conversations.admins(group)).length <= 1 && await R.conversations.isAdmin(group, userId)) return json(res, 400, { error: 'a group must have at least one admin' });
|
||||
await R.conversations.setMemberAdmin(group, userId, !!value);
|
||||
const names = namesFor(u.team_id);
|
||||
postSystemMessage(group, u.team_id, (u.name || u.email) + (value ? ' made ' + (names[userId] || 'someone') + ' an admin' : ' removed ' + (names[userId] || 'someone') + ' as admin'));
|
||||
pushGroupUpdate(group);
|
||||
const names = await namesFor(u.team_id);
|
||||
await postSystemMessage(group, u.team_id, (u.name || u.email) + (value ? ' made ' + (names[userId] || 'someone') + ' an admin' : ' removed ' + (names[userId] || 'someone') + ' as admin'));
|
||||
await pushGroupUpdate(group);
|
||||
try { CHAT.pushToUser(userId, { type: 'group-role', group, admin: !!value, by: u.name || u.email }); } catch (_) {} // notify the affected member
|
||||
json(res, 200, { ok: true });
|
||||
});
|
||||
@@ -1127,13 +1130,13 @@ route('POST', '/api/groups/add', async (req, res) => {
|
||||
if (!group || !await R.conversations.isMember(group, u.id)) return json(res, 403, { error: 'not a member' });
|
||||
const gA = await R.conversations.byId(group);
|
||||
if (gA && gA.admin_only && !await R.conversations.isAdmin(group, u.id)) return json(res, 403, { error: 'Only a group admin can add members' });
|
||||
const ids = (Array.isArray(memberIds) ? memberIds : []).filter((x) => typeof x === 'string' && await R.users.inTenant(x, u.team_id) && !await R.conversations.isMember(group, x));
|
||||
const ids = await asyncFilter((Array.isArray(memberIds) ? memberIds : []), async (x) => typeof x === 'string' && await R.users.inTenant(x, u.team_id) && !await R.conversations.isMember(group, x));
|
||||
for (const mid of ids) await R.conversations.addMember(group, mid);
|
||||
if (ids.length) {
|
||||
const names = namesFor(u.team_id);
|
||||
postSystemMessage(group, u.team_id, (u.name || u.email) + ' added ' + ids.map((x) => names[x] || 'someone').join(', '));
|
||||
const names = await namesFor(u.team_id);
|
||||
await postSystemMessage(group, u.team_id, (u.name || u.email) + ' added ' + ids.map((x) => names[x] || 'someone').join(', '));
|
||||
}
|
||||
if (ids.length) pushGroupUpdate(group); // live member-count refresh for everyone (incl. the new members)
|
||||
if (ids.length) await pushGroupUpdate(group); // live member-count refresh for everyone (incl. the new members)
|
||||
audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'group_members_added', detail: ids.length + ' to ' + group });
|
||||
json(res, 200, { ok: true, added: ids.length });
|
||||
});
|
||||
@@ -1150,24 +1153,24 @@ route('POST', '/api/groups/remove', async (req, res) => {
|
||||
if (!isSelf) { const gR = await R.conversations.byId(group); if (gR && gR.admin_only && !await R.conversations.isAdmin(group, u.id)) return json(res, 403, { error: 'Only a group admin can remove members' }); }
|
||||
const wasAdmin = await R.conversations.isAdmin(group, target);
|
||||
// #10: the last admin must hand off to a chosen successor before leaving (no auto-assign).
|
||||
const others = await R.conversations.members(group).filter((m) => m !== target);
|
||||
if (wasAdmin && others.length && await R.conversations.admins(group).filter((a) => a !== target).length === 0) {
|
||||
const others = (await R.conversations.members(group)).filter((m) => m !== target);
|
||||
if (wasAdmin && others.length && (await R.conversations.admins(group)).filter((a) => a !== target).length === 0) {
|
||||
if (!newAdmin || !await R.conversations.isMember(group, newAdmin) || newAdmin === target) return json(res, 400, { error: 'NEED_ADMIN', message: 'Choose a member to be the new admin before leaving.' });
|
||||
await R.conversations.setMemberAdmin(group, newAdmin, true);
|
||||
const names0 = namesFor(u.team_id);
|
||||
postSystemMessage(group, u.team_id, (names0[newAdmin] || 'A member') + ' is now an admin');
|
||||
const names0 = await namesFor(u.team_id);
|
||||
await postSystemMessage(group, u.team_id, (names0[newAdmin] || 'A member') + ' is now an admin');
|
||||
try { CHAT.pushToUser(newAdmin, { type: 'group-role', group, admin: true }); } catch (_) {}
|
||||
}
|
||||
// Post the activity BEFORE removing, so the removed person's tab also receives it.
|
||||
if (target !== u.id && await R.conversations.isMember(group, target)) {
|
||||
const names = namesFor(u.team_id);
|
||||
postSystemMessage(group, u.team_id, (u.name || u.email) + ' removed ' + (names[target] || 'someone'));
|
||||
const names = await namesFor(u.team_id);
|
||||
await postSystemMessage(group, u.team_id, (u.name || u.email) + ' removed ' + (names[target] || 'someone'));
|
||||
} else if (isSelf) {
|
||||
postSystemMessage(group, u.team_id, (u.name || u.email) + ' left the group');
|
||||
await postSystemMessage(group, u.team_id, (u.name || u.email) + ' left the group');
|
||||
}
|
||||
await R.conversations.removeMember(group, target);
|
||||
if (await R.conversations.members(group).length === 0) { await R.conversations.remove(group); } // drop empty groups
|
||||
else pushGroupUpdate(group, [target]); // live member-count refresh; the removed person drops the group
|
||||
if ((await R.conversations.members(group)).length === 0) { await R.conversations.remove(group); } // drop empty groups
|
||||
else await pushGroupUpdate(group, [target]); // live member-count refresh; the removed person drops the group
|
||||
audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: isSelf ? 'group_left' : 'group_member_removed', detail: group });
|
||||
json(res, 200, { ok: true, left: isSelf });
|
||||
});
|
||||
@@ -1193,7 +1196,7 @@ route('POST', '/api/meetings/schedule', async (req, res) => {
|
||||
}
|
||||
const desc = String(description || '').trim().slice(0, 1000);
|
||||
// Invited participants: tenant users, excluding the host (creator).
|
||||
const invited = [...new Set((Array.isArray(participants) ? participants : []).filter((x) => typeof x === 'string' && x !== u.id && await R.users.inTenant(x, u.team_id)))];
|
||||
const invited = [...new Set(await asyncFilter((Array.isArray(participants) ? participants : []), async (x) => typeof x === 'string' && x !== u.id && await R.users.inTenant(x, u.team_id)))];
|
||||
// External invitees by email (#4): people not on Connect — they get an emailed guest link.
|
||||
const guestEmails = [...new Set((Array.isArray(participantEmails) ? participantEmails : []).map((e) => String(e || '').trim().toLowerCase()).filter(isEmail))].slice(0, 100);
|
||||
let code; do { code = A.numericCode(6); } while (await R.scheduledMeetings.byCode(code) || meetingRooms.has(code));
|
||||
@@ -1204,7 +1207,7 @@ route('POST', '/api/meetings/schedule', async (req, res) => {
|
||||
if (groupId) {
|
||||
const mid = A.id();
|
||||
await R.messages.send({ id: mid, teamId: u.team_id, senderId: u.id, recipientId: '', body: '📅 Scheduled a call: ' + t + ' — ' + label, conversationId: groupId });
|
||||
const dto = buildMsgDTO(await R.messages.byId(mid), namesFor(u.team_id), u.id); dto.fromName = u.name || u.email;
|
||||
const dto = await buildMsgDTO(await R.messages.byId(mid), await namesFor(u.team_id), u.id); dto.fromName = u.name || u.email;
|
||||
for (const m of await R.conversations.members(groupId)) { try { CHAT.pushToUser(m, { type: 'chat-message', message: dto }); } catch (_) {} }
|
||||
}
|
||||
// Invitation notification to each invited participant.
|
||||
@@ -1231,9 +1234,9 @@ route('POST', '/api/meetings/schedule', async (req, res) => {
|
||||
route('GET', '/api/meetings', async (req, res) => {
|
||||
const u = await currentUser(req);
|
||||
if (!u) return json(res, 401, { error: 'unauthorized' });
|
||||
const names = namesFor(u.team_id);
|
||||
const names = await namesFor(u.team_id);
|
||||
const nowTs = Date.now();
|
||||
const rows = await R.scheduledMeetings.listForUser(u.team_id, u.id).map((s) => {
|
||||
const rows = await Promise.all((await R.scheduledMeetings.listForUser(u.team_id, u.id)).map(async (s) => {
|
||||
let recur = []; try { recur = JSON.parse(s.recurrence || '[]'); } catch (_) {}
|
||||
let schedAt = s.scheduled_at;
|
||||
const live = meetingRooms.get(s.room_code);
|
||||
@@ -1260,12 +1263,12 @@ route('GET', '/api/meetings', async (req, res) => {
|
||||
durationMins: s.duration_mins || null, recurrence: recur, recurrenceLabel: recurrenceLabel(recur),
|
||||
status, inCall: running ? live.size : 0, recordings: [],
|
||||
};
|
||||
});
|
||||
}));
|
||||
// Attach recordings/transcripts. A recording is visible to its creator, group members, or people
|
||||
// who can see the scheduled meeting it belongs to. Recordings not tied to a listed meeting become
|
||||
// their own "Past meeting" entry (group calls show the group name).
|
||||
const recDTO = (r) => ({ id: r.id, kind: r.kind, url: '/mrec/' + r.id, createdAt: r.created_at, durationMs: r.duration_ms, size: r.size, by: r.created_by_name });
|
||||
const canSeeRec = (r) => {
|
||||
const canSeeRec = async (r) => {
|
||||
if (r.kind === 'transcript') return r.created_by === u.id; // transcripts are private to their owner
|
||||
if (r.created_by === u.id) return true;
|
||||
if (r.group_id) return await R.conversations.isMember(r.group_id, u.id);
|
||||
@@ -1276,12 +1279,12 @@ route('GET', '/api/meetings', async (req, res) => {
|
||||
const schedByRoom = new Map(rows.map((m) => [m.roomCode, m]));
|
||||
const unsched = new Map();
|
||||
for (const r of await R.recordings.forTeam(u.team_id)) {
|
||||
if (!canSeeRec(r)) continue;
|
||||
if (!(await canSeeRec(r))) continue;
|
||||
const m = (r.meeting_id && schedById.get(r.meeting_id)) || (r.room && schedByRoom.get(r.room));
|
||||
if (m) { m.recordings.push(recDTO(r)); }
|
||||
else { const k = r.room || r.id; if (!unsched.has(k)) unsched.set(k, []); unsched.get(k).push(r); }
|
||||
}
|
||||
const synth = [...unsched.values()].map((list) => {
|
||||
const synth = await Promise.all([...unsched.values()].map(async (list) => {
|
||||
list.sort((a, b) => a.created_at - b.created_at); const f = list[0];
|
||||
return {
|
||||
id: 'rec-' + (f.room || f.id), roomCode: f.room || '', title: f.title || 'Meeting', description: '',
|
||||
@@ -1290,7 +1293,7 @@ route('GET', '/api/meetings', async (req, res) => {
|
||||
createdBy: f.created_by, createdByName: f.created_by_name || '', canManage: false, isHost: false,
|
||||
invited: [], status: 'past', inCall: 0, recordings: list.map(recDTO),
|
||||
};
|
||||
});
|
||||
}));
|
||||
|
||||
// #7: past CALLS from the call log. Rules the user asked for:
|
||||
// • a plain 1:1 direct call is NOT listed (it's a call, not a meeting) — UNLESS it produced a
|
||||
@@ -1343,7 +1346,7 @@ route('GET', '/api/meetings', async (req, res) => {
|
||||
});
|
||||
|
||||
// Host uploads an in-browser meeting recording (webm). Stored + indexed so it shows under Past meetings.
|
||||
route('POST', '/api/meetings/recording', (req, res) => {
|
||||
route('POST', '/api/meetings/recording', async (req, res) => {
|
||||
const u = await currentUser(req);
|
||||
if (!u) return json(res, 401, { error: 'unauthorized' });
|
||||
const params = new URLSearchParams(req.url.split('?')[1] || '');
|
||||
@@ -1352,7 +1355,7 @@ route('POST', '/api/meetings/recording', (req, res) => {
|
||||
const dur = parseInt(params.get('dur') || '0', 10) || null;
|
||||
const chunks = []; let total = 0, aborted = false;
|
||||
req.on('data', (c) => { total += c.length; if (total > MAX_REC_BYTES) { aborted = true; req.destroy(); return; } chunks.push(c); });
|
||||
req.on('end', () => {
|
||||
req.on('end', async () => {
|
||||
if (aborted) return json(res, 413, { error: 'recording too large' });
|
||||
if (!total) return json(res, 400, { error: 'empty recording' });
|
||||
const ctx = CALLS.meetingContext(room);
|
||||
@@ -1419,7 +1422,7 @@ route('POST', '/api/meetings/update', async (req, res) => {
|
||||
const when = Number(scheduledAt); if (!Number.isFinite(when) || when < Date.now()) return json(res, 400, { error: 'pick a valid future time' });
|
||||
const dur = [15, 30, 45, 60, 90, 120].includes(Number(durationMins)) ? Number(durationMins) : (s.duration_mins || 30);
|
||||
const recur = Array.isArray(recurrence) ? [...new Set(recurrence.map(Number).filter((d) => d >= 0 && d <= 6))] : [];
|
||||
const invited = [...new Set((Array.isArray(participants) ? participants : []).filter((x) => typeof x === 'string' && x !== u.id && await R.users.inTenant(x, u.team_id)))];
|
||||
const invited = [...new Set(await asyncFilter((Array.isArray(participants) ? participants : []), async (x) => typeof x === 'string' && x !== u.id && await R.users.inTenant(x, u.team_id)))];
|
||||
const guestEmails = [...new Set((Array.isArray(participantEmails) ? participantEmails : []).map((e) => String(e || '').trim().toLowerCase()).filter(isEmail))].slice(0, 100);
|
||||
await R.scheduledMeetings.update(id, u.team_id, { title: t, description: String(description || '').trim().slice(0, 1000), scheduledAt: when, durationMins: dur, participants: invited, recurrence: recur, guestEmails, lobby: lobby !== false });
|
||||
const label = new Date(when).toLocaleString();
|
||||
@@ -1453,11 +1456,11 @@ route('POST', '/api/polls', async (req, res) => {
|
||||
await R.polls.create({ id: pollId, teamId: u.team_id, conversationId: group, messageId: msgId, question: q, options: opts, multi: !!multi, createdBy: u.id });
|
||||
await R.messages.setPoll(msgId, pollId);
|
||||
audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'poll_created', detail: q });
|
||||
const names = namesFor(u.team_id);
|
||||
const names = await namesFor(u.team_id);
|
||||
for (const mid of await R.conversations.members(group)) {
|
||||
try { const dto = buildMsgDTO(await R.messages.byId(msgId), names, mid); dto.fromName = u.name || u.email; CHAT.pushToUser(mid, { type: 'chat-message', message: dto }); } catch (_) {}
|
||||
try { const dto = await buildMsgDTO(await R.messages.byId(msgId), names, mid); dto.fromName = u.name || u.email; CHAT.pushToUser(mid, { type: 'chat-message', message: dto }); } catch (_) {}
|
||||
}
|
||||
json(res, 200, buildPollDTO(await R.polls.byId(pollId), u.id));
|
||||
json(res, 200, await buildPollDTO(await R.polls.byId(pollId), u.id));
|
||||
});
|
||||
|
||||
// Vote on a poll option (toggle). Single-choice replaces the prior vote; multi toggles.
|
||||
@@ -1480,9 +1483,9 @@ route('POST', '/api/polls/vote', async (req, res) => {
|
||||
if (!had) await R.pollVotes.add(p.id, u.id, idx);
|
||||
}
|
||||
for (const mid of await R.conversations.members(p.conversation_id)) {
|
||||
try { CHAT.pushToUser(mid, { type: 'poll-update', poll: buildPollDTO(p, mid), messageId: p.message_id, conversationId: p.conversation_id }); } catch (_) {}
|
||||
try { CHAT.pushToUser(mid, { type: 'poll-update', poll: await buildPollDTO(p, mid), messageId: p.message_id, conversationId: p.conversation_id }); } catch (_) {}
|
||||
}
|
||||
json(res, 200, buildPollDTO(p, u.id));
|
||||
json(res, 200, await buildPollDTO(p, u.id));
|
||||
});
|
||||
|
||||
// Close a poll (creator only) — no more votes accepted.
|
||||
@@ -1496,9 +1499,9 @@ route('POST', '/api/polls/close', async (req, res) => {
|
||||
await R.polls.close(p.id);
|
||||
const fresh = await R.polls.byId(p.id);
|
||||
for (const mid of await R.conversations.members(p.conversation_id)) {
|
||||
try { CHAT.pushToUser(mid, { type: 'poll-update', poll: buildPollDTO(fresh, mid), messageId: p.message_id, conversationId: p.conversation_id }); } catch (_) {}
|
||||
try { CHAT.pushToUser(mid, { type: 'poll-update', poll: await buildPollDTO(fresh, mid), messageId: p.message_id, conversationId: p.conversation_id }); } catch (_) {}
|
||||
}
|
||||
json(res, 200, buildPollDTO(fresh, u.id));
|
||||
json(res, 200, await buildPollDTO(fresh, u.id));
|
||||
});
|
||||
|
||||
// Send a message (persists + live-pushes to the recipient and the sender's other tabs).
|
||||
@@ -1525,7 +1528,7 @@ route('POST', '/api/messages', async (req, res) => {
|
||||
mlist = [...new Set(mlist)];
|
||||
}
|
||||
await R.messages.send({ id, teamId: u.team_id, senderId: u.id, recipientId: '', body: text, replyTo: replyTo || null, attachmentId: attachmentId || null, conversationId: group, mentions: mlist });
|
||||
const dto = buildMsgDTO(await R.messages.byId(id), namesFor(u.team_id), u.id);
|
||||
const dto = await buildMsgDTO(await R.messages.byId(id), await namesFor(u.team_id), u.id);
|
||||
dto.fromName = u.name || u.email;
|
||||
const push = { type: 'chat-message', message: dto };
|
||||
const conv = await R.conversations.byId(group); const gname = (conv && conv.name) || 'Group';
|
||||
@@ -1542,7 +1545,7 @@ route('POST', '/api/messages', async (req, res) => {
|
||||
if (!toId) return json(res, 400, { error: 'to or group required' });
|
||||
if (!await R.users.inTenant(toId, u.team_id)) return json(res, 404, { error: 'no such contact' });
|
||||
await R.messages.send({ id, teamId: u.team_id, senderId: u.id, recipientId: toId, body: text, replyTo: replyTo || null, attachmentId: attachmentId || null });
|
||||
const dto = buildMsgDTO(await R.messages.byId(id), namesFor(u.team_id), u.id);
|
||||
const dto = await buildMsgDTO(await R.messages.byId(id), await namesFor(u.team_id), u.id);
|
||||
const push = { type: 'chat-message', message: { ...dto, fromName: u.name || u.email } };
|
||||
try { CHAT.pushToUser(toId, push); } catch (_) {}
|
||||
if (toId !== u.id) try { CHAT.pushToUser(u.id, push); } catch (_) {} // sync the sender's other devices (skip for self-notes)
|
||||
@@ -1568,7 +1571,7 @@ route('POST', '/api/messages/forward', async (req, res) => {
|
||||
}
|
||||
if (!srcs.length) return json(res, 400, { error: 'nothing to forward' });
|
||||
srcs.sort((a, b) => a.created_at - b.created_at);
|
||||
const names = namesFor(u.team_id);
|
||||
const names = await namesFor(u.team_id);
|
||||
let sent = 0;
|
||||
for (const t of (targets || []).slice(0, 20)) {
|
||||
let isGroup = t.kind === 'group', tid = t.id;
|
||||
@@ -1579,7 +1582,7 @@ route('POST', '/api/messages/forward', async (req, res) => {
|
||||
const origin = m.fwd_from || names[m.sender_id] || 'Unknown'; // preserve the true origin across re-forwards
|
||||
if (isGroup) await R.messages.send({ id: nid, teamId: u.team_id, senderId: u.id, recipientId: '', body: m.body, attachmentId: m.attachment_id, conversationId: tid, fwdFrom: origin });
|
||||
else await R.messages.send({ id: nid, teamId: u.team_id, senderId: u.id, recipientId: tid, body: m.body, attachmentId: m.attachment_id, fwdFrom: origin });
|
||||
const dto = buildMsgDTO(await R.messages.byId(nid), names, u.id);
|
||||
const dto = await buildMsgDTO(await R.messages.byId(nid), names, u.id);
|
||||
const push = { type: 'chat-message', message: { ...dto, fromName: u.name || u.email } };
|
||||
if (isGroup) { for (const mid of await R.conversations.members(tid)) { try { CHAT.pushToUser(mid, push); } catch (_) {} } }
|
||||
else { try { CHAT.pushToUser(tid, push); } catch (_) {} if (tid !== u.id) try { CHAT.pushToUser(u.id, push); } catch (_) {} }
|
||||
@@ -1683,20 +1686,20 @@ route('POST', '/api/messages/react', async (req, res) => {
|
||||
if (!participant) return json(res, 404, { error: 'no such message' });
|
||||
const e = String(emoji).slice(0, 16);
|
||||
const added = await R.reactions.toggle(messageId, u.id, e);
|
||||
const names = namesFor(u.team_id);
|
||||
const names = await namesFor(u.team_id);
|
||||
// Push the full, recomputed reaction set for this message (per-recipient perspective). Extra
|
||||
// fields (by/emoji/added/owner/convId) let the message owner show a "reacted to you" notification.
|
||||
const meta = { by: u.name || u.email, byId: u.id, emoji: e, added, owner: msg.sender_id, convId: msg.conversation_id || null };
|
||||
if (msg.conversation_id) {
|
||||
for (const mid of await R.conversations.members(msg.conversation_id)) {
|
||||
try { CHAT.pushToUser(mid, { type: 'chat-reaction', messageId, reactions: reactionsForMessage(messageId, mid, names), ...meta }); } catch (_) {}
|
||||
try { CHAT.pushToUser(mid, { type: 'chat-reaction', messageId, reactions: await reactionsForMessage(messageId, mid, names), ...meta }); } catch (_) {}
|
||||
}
|
||||
} else {
|
||||
const other = msg.sender_id === u.id ? msg.recipient_id : msg.sender_id;
|
||||
try { CHAT.pushToUser(other, { type: 'chat-reaction', messageId, reactions: reactionsForMessage(messageId, other, names), ...meta }); } catch (_) {}
|
||||
try { CHAT.pushToUser(u.id, { type: 'chat-reaction', messageId, reactions: reactionsForMessage(messageId, u.id, names), ...meta }); } catch (_) {}
|
||||
try { CHAT.pushToUser(other, { type: 'chat-reaction', messageId, reactions: await reactionsForMessage(messageId, other, names), ...meta }); } catch (_) {}
|
||||
try { CHAT.pushToUser(u.id, { type: 'chat-reaction', messageId, reactions: await reactionsForMessage(messageId, u.id, names), ...meta }); } catch (_) {}
|
||||
}
|
||||
json(res, 200, { ok: true, messageId, added, reactions: reactionsForMessage(messageId, u.id, names) });
|
||||
json(res, 200, { ok: true, messageId, added, reactions: await reactionsForMessage(messageId, u.id, names) });
|
||||
});
|
||||
|
||||
// Upload a chat attachment (raw body; filename in X-Filename, mime in Content-Type).
|
||||
@@ -1722,9 +1725,9 @@ route('POST', '/api/messages/upload', async (req, res) => {
|
||||
if (total > MAX_FILE_BYTES) { aborted = true; cleanup(); finish(413, { error: 'file too large (max ' + MAX_UPLOAD_MB + ' MB)' }); try { req.destroy(); } catch (_) {} return; }
|
||||
if (!ws.write(c)) { req.pause(); ws.once('drain', () => { if (!aborted) req.resume(); }); } // respect backpressure
|
||||
});
|
||||
req.on('end', () => {
|
||||
req.on('end', async () => {
|
||||
if (aborted) return;
|
||||
ws.end(() => {
|
||||
ws.end(async () => {
|
||||
if (!total) { try { fs.unlinkSync(tmp); } catch (_) {} return finish(400, { error: 'empty file' }); }
|
||||
try { fs.renameSync(tmp, path.join(UPLOADS_DIR, id)); }
|
||||
catch (e) { try { fs.unlinkSync(tmp); } catch (_) {} return finish(500, { error: 'could not store file' }); }
|
||||
|
||||
Reference in New Issue
Block a user