backend: per-conversation unread counts

Count messages from others newer than the caller's read pointer alongside the
existing last-message lookup, expose it as unreadCount on
ConversationSummary, and derive the unread boolean from it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Khalid Abdi
2026-06-11 21:04:12 +03:00
parent 09745b8fdd
commit f550f4cbd7
2 changed files with 22 additions and 9 deletions
+20 -9
View File
@@ -1,4 +1,4 @@
import { and, asc, desc, eq, inArray } from "drizzle-orm"; import { and, asc, count, desc, eq, gt, inArray, ne } from "drizzle-orm";
import { db } from "../db/index.js"; import { db } from "../db/index.js";
import { member, user } from "../db/schema/auth.js"; import { member, user } from "../db/schema/auth.js";
@@ -96,8 +96,9 @@ async function buildSummaries(
} }
const lastByConv = new Map<string, ConversationMessage | null>(); const lastByConv = new Map<string, ConversationMessage | null>();
const unreadByConv = new Map<string, number>();
await Promise.all( await Promise.all(
convIds.map(async (convId) => { base.map(async (b) => {
const [row] = await db const [row] = await db
.select({ .select({
id: messages.id, id: messages.id,
@@ -109,13 +110,25 @@ async function buildSummaries(
}) })
.from(messages) .from(messages)
.innerJoin(user, eq(user.id, messages.senderId)) .innerJoin(user, eq(user.id, messages.senderId))
.where(eq(messages.conversationId, convId)) .where(eq(messages.conversationId, b.convId))
.orderBy(desc(messages.createdAt)) .orderBy(desc(messages.createdAt))
.limit(1); .limit(1);
lastByConv.set( lastByConv.set(
convId, b.convId,
row ? { ...row, createdAt: row.createdAt.toISOString() } : null, row ? { ...row, createdAt: row.createdAt.toISOString() } : null,
); );
// Messages from others newer than the caller's read pointer.
const [cnt] = await db
.select({ value: count() })
.from(messages)
.where(
and(
eq(messages.conversationId, b.convId),
ne(messages.senderId, userId),
b.lastReadAt ? gt(messages.createdAt, b.lastReadAt) : undefined,
),
);
unreadByConv.set(b.convId, cnt?.value ?? 0);
}), }),
); );
@@ -128,17 +141,15 @@ async function buildSummaries(
? others.map((p) => p.name).join(", ") || "Group" ? others.map((p) => p.name).join(", ") || "Group"
: (others[0]?.name ?? "Conversation")); : (others[0]?.name ?? "Conversation"));
const lastMessage = lastByConv.get(b.convId) ?? null; const lastMessage = lastByConv.get(b.convId) ?? null;
const unread = const unreadCount = unreadByConv.get(b.convId) ?? 0;
!!lastMessage &&
lastMessage.senderId !== userId &&
(!b.lastReadAt || new Date(lastMessage.createdAt) > b.lastReadAt);
return { return {
id: b.convId, id: b.convId,
name: displayName, name: displayName,
isGroup: b.isGroup, isGroup: b.isGroup,
participants, participants,
lastMessage, lastMessage,
unread, unread: unreadCount > 0,
unreadCount,
updatedAt: b.updatedAt.toISOString(), updatedAt: b.updatedAt.toISOString(),
}; };
}); });
+2
View File
@@ -21,5 +21,7 @@ export type ConversationSummary = {
participants: Participant[]; participants: Participant[];
lastMessage: ConversationMessage | null; lastMessage: ConversationMessage | null;
unread: boolean; unread: boolean;
// Messages from others newer than the caller's read pointer.
unreadCount: number;
updatedAt: string; updatedAt: string;
}; };