fix(chat): resolve merged-away user ids so DMs to merged contacts don't vanish
Root cause of 'messages to some contacts disappear (gone after reopen)': the #2 account merge deletes the merged-away user row. Any lingering reference to that old id — a cached contact, an in-flight DM — then saved against a dead recipient / 404'd on thread fetch, so messages silently vanished. - New user_aliases table records old_id -> survivor on every merge (mergeInto). - users.resolve(id) follows the redirect. - DM send (recipient), thread fetch (with), and read now resolve() the peer id, so a stale id transparently routes to the surviving account. Fixes future merges fully. Contacts merged BEFORE this (no alias recorded) may need a one-off data check. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -225,6 +225,16 @@ try { db.exec("ALTER TABLE users ADD COLUMN status TEXT NOT NULL DEFAULT 'active
|
|||||||
// Provisioning matches on it to keep one Biz Connect account per person (#2 account merge).
|
// Provisioning matches on it to keep one Biz Connect account per person (#2 account merge).
|
||||||
try { db.exec('ALTER TABLE users ADD COLUMN bizgaze_user_id TEXT'); } catch (e) { /* exists */ }
|
try { db.exec('ALTER TABLE users ADD COLUMN bizgaze_user_id TEXT'); } catch (e) { /* exists */ }
|
||||||
try { db.exec('CREATE INDEX IF NOT EXISTS idx_users_bizgaze_user_id ON users(bizgaze_user_id)'); } catch (e) { /* exists */ }
|
try { db.exec('CREATE INDEX IF NOT EXISTS idx_users_bizgaze_user_id ON users(bizgaze_user_id)'); } catch (e) { /* exists */ }
|
||||||
|
// When two accounts merge (#2), the merged-away row is deleted. This records old_id -> survivor so
|
||||||
|
// any lingering reference to the old id (a cached contact, an in-flight DM) resolves to the survivor
|
||||||
|
// instead of hitting a deleted user (which made messages to merged contacts silently vanish).
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS user_aliases (
|
||||||
|
old_id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
team_id TEXT,
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
)`);
|
||||||
db.exec(`
|
db.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS polls (
|
CREATE TABLE IF NOT EXISTS polls (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ const teams = {
|
|||||||
const users = {
|
const users = {
|
||||||
anyExists: () => !!db.prepare('SELECT 1 FROM users LIMIT 1').get(),
|
anyExists: () => !!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
|
||||||
|
// 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; },
|
||||||
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: (email) => !!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).
|
||||||
@@ -82,6 +85,11 @@ const users = {
|
|||||||
// 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);
|
run('DELETE FROM sessions_auth WHERE user_id=?', fromId);
|
||||||
run('DELETE FROM refresh_tokens WHERE user_id=?', fromId);
|
run('DELETE FROM refresh_tokens WHERE user_id=?', fromId);
|
||||||
|
// Record old_id -> survivor so lingering references (cached contacts, in-flight DMs) resolve
|
||||||
|
// instead of hitting the deleted row (which made messages to merged contacts vanish).
|
||||||
|
const _iu = db.prepare('SELECT team_id FROM users WHERE id=?').get(intoId);
|
||||||
|
run('INSERT OR REPLACE INTO user_aliases (old_id,user_id,team_id,created_at) VALUES (?,?,?,?)', fromId, intoId, (_iu && _iu.team_id) || null, now());
|
||||||
|
run('UPDATE user_aliases SET user_id=? WHERE user_id=?', intoId, fromId); // re-chain earlier aliases to the new survivor
|
||||||
run('DELETE FROM users WHERE id=?', fromId);
|
run('DELETE FROM users WHERE id=?', fromId);
|
||||||
db.exec('COMMIT');
|
db.exec('COMMIT');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
+12
-8
@@ -779,7 +779,7 @@ route('GET', '/api/messages/thread', async (req, res) => {
|
|||||||
return d;
|
return d;
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
const other = q.get('with');
|
const other = R.users.resolve(q.get('with')); // follow a merge redirect so a stale peer id still loads the thread
|
||||||
if (!other) return json(res, 400, { error: 'with or group required' });
|
if (!other) return json(res, 400, { error: 'with or group required' });
|
||||||
if (!R.users.inTenant(other, u.team_id)) return json(res, 404, { error: 'no such contact' });
|
if (!R.users.inTenant(other, u.team_id)) return json(res, 404, { error: 'no such contact' });
|
||||||
const rows = R.messages.thread(u.team_id, u.id, other);
|
const rows = R.messages.thread(u.team_id, u.id, other);
|
||||||
@@ -1303,15 +1303,18 @@ route('POST', '/api/messages', async (req, res) => {
|
|||||||
}
|
}
|
||||||
return json(res, 200, dto);
|
return json(res, 200, dto);
|
||||||
}
|
}
|
||||||
if (!to) return json(res, 400, { error: 'to or group required' });
|
// Resolve a merged-away recipient id to the surviving account, so DMs to a merged contact don't
|
||||||
if (!R.users.inTenant(to, u.team_id)) return json(res, 404, { error: 'no such contact' });
|
// save against a deleted user (which made them silently vanish).
|
||||||
R.messages.send({ id, teamId: u.team_id, senderId: u.id, recipientId: to, body: text, replyTo: replyTo || null, attachmentId: attachmentId || null });
|
const toId = R.users.resolve(to);
|
||||||
|
if (!toId) return json(res, 400, { error: 'to or group required' });
|
||||||
|
if (!R.users.inTenant(toId, u.team_id)) return json(res, 404, { error: 'no such contact' });
|
||||||
|
R.messages.send({ id, teamId: u.team_id, senderId: u.id, recipientId: toId, body: text, replyTo: replyTo || null, attachmentId: attachmentId || null });
|
||||||
const dto = buildMsgDTO(R.messages.byId(id), namesFor(u.team_id), u.id);
|
const dto = buildMsgDTO(R.messages.byId(id), namesFor(u.team_id), u.id);
|
||||||
const push = { type: 'chat-message', message: { ...dto, fromName: u.name || u.email } };
|
const push = { type: 'chat-message', message: { ...dto, fromName: u.name || u.email } };
|
||||||
try { CHAT.pushToUser(to, push); } catch (_) {}
|
try { CHAT.pushToUser(toId, push); } catch (_) {}
|
||||||
if (to !== u.id) try { CHAT.pushToUser(u.id, push); } catch (_) {} // sync the sender's other devices (skip for self-notes)
|
if (toId !== u.id) try { CHAT.pushToUser(u.id, push); } catch (_) {} // sync the sender's other devices (skip for self-notes)
|
||||||
// Background/closed-tab push to the recipient (opens the DM). Not for a note-to-self.
|
// Background/closed-tab push to the recipient (opens the DM). Not for a note-to-self.
|
||||||
if (to !== u.id) PUSH.sendToUser(to, { title: (u.name || u.email), body: (text ? (text.length > 80 ? text.slice(0, 80) + '…' : text) : '📎 Attachment'), kind: 'dm', id: u.id, tag: 'dm:' + u.id, icon: u.avatar_url || undefined });
|
if (toId !== u.id) PUSH.sendToUser(toId, { title: (u.name || u.email), body: (text ? (text.length > 80 ? text.slice(0, 80) + '…' : text) : '📎 Attachment'), kind: 'dm', id: u.id, tag: 'dm:' + u.id, icon: u.avatar_url || undefined });
|
||||||
json(res, 200, dto);
|
json(res, 200, dto);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1378,7 +1381,8 @@ route('GET', '/api/messages/media', async (req, res) => {
|
|||||||
route('POST', '/api/messages/read', async (req, res) => {
|
route('POST', '/api/messages/read', async (req, res) => {
|
||||||
const u = currentUser(req);
|
const u = currentUser(req);
|
||||||
if (!u) return json(res, 401, { error: 'unauthorized' });
|
if (!u) return json(res, 401, { error: 'unauthorized' });
|
||||||
const { with: other, group } = await readBody(req);
|
const { with: rawOther, group } = await readBody(req);
|
||||||
|
const other = R.users.resolve(rawOther); // follow a merge redirect
|
||||||
if (group) {
|
if (group) {
|
||||||
if (R.conversations.isMember(group, u.id)) {
|
if (R.conversations.isMember(group, u.id)) {
|
||||||
R.conversations.markRead(group, u.id);
|
R.conversations.markRead(group, u.id);
|
||||||
|
|||||||
Reference in New Issue
Block a user