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:
2026-07-07 13:23:49 +05:30
parent 1128f9811a
commit 12be747609
3 changed files with 30 additions and 8 deletions
+12 -8
View File
@@ -779,7 +779,7 @@ route('GET', '/api/messages/thread', async (req, res) => {
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 (!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);
@@ -1303,15 +1303,18 @@ route('POST', '/api/messages', async (req, res) => {
}
return json(res, 200, dto);
}
if (!to) return json(res, 400, { error: 'to or group required' });
if (!R.users.inTenant(to, u.team_id)) return json(res, 404, { error: 'no such contact' });
R.messages.send({ id, teamId: u.team_id, senderId: u.id, recipientId: to, body: text, replyTo: replyTo || null, attachmentId: attachmentId || null });
// Resolve a merged-away recipient id to the surviving account, so DMs to a merged contact don't
// save against a deleted user (which made them silently vanish).
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 push = { type: 'chat-message', message: { ...dto, fromName: u.name || u.email } };
try { CHAT.pushToUser(to, push); } catch (_) {}
if (to !== u.id) try { CHAT.pushToUser(u.id, push); } catch (_) {} // sync the sender's other devices (skip for self-notes)
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)
// 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);
});
@@ -1378,7 +1381,8 @@ route('GET', '/api/messages/media', async (req, res) => {
route('POST', '/api/messages/read', async (req, res) => {
const u = currentUser(req);
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 (R.conversations.isMember(group, u.id)) {
R.conversations.markRead(group, u.id);