feat: unread message tracking with blue dot indicator

Add ContactThreadRead model to track when users last viewed each contact thread.
Contacts with messages newer than the last read time show a blue dot in the sidebar.
Opening a thread automatically marks it as read via markThreadRead mutation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Ruslan Bakiev
2026-02-24 20:25:32 +07:00
parent 643d8d02ba
commit 5492e0d05c
10 changed files with 167 additions and 9 deletions

View File

@@ -455,7 +455,7 @@ async function getContacts(auth: AuthContext | null) {
const messageWhere = visibleMessageWhere(hiddenInboxIds);
const hiddenInboxIdSet = new Set(hiddenInboxIds);
const [contactsRaw, contactInboxesRaw, communicationsRaw] = await Promise.all([
const [contactsRaw, contactInboxesRaw, communicationsRaw, threadReadsRaw] = await Promise.all([
prisma.contact.findMany({
where: { teamId: ctx.teamId },
include: {
@@ -485,8 +485,14 @@ async function getContacts(auth: AuthContext | null) {
orderBy: { occurredAt: "asc" },
take: 2000,
}),
prisma.contactThreadRead.findMany({
where: { teamId: ctx.teamId, userId: ctx.userId },
select: { contactId: true, readAt: true },
}),
]);
const readAtByContactId = new Map(threadReadsRaw.map((r) => [r.contactId, r.readAt]));
const channelsByContactId = new Map<string, Set<string>>();
const totalInboxesByContactId = new Map<string, number>();
const visibleInboxesByContactId = new Map<string, number>();
@@ -518,6 +524,9 @@ async function getContacts(auth: AuthContext | null) {
lastContactAt: c.messages[0]?.occurredAt?.toISOString?.() ?? c.updatedAt.toISOString(),
lastMessageText: c.messages[0]?.content ?? "",
lastMessageChannel: c.messages[0]?.channel ? mapChannel(c.messages[0].channel) : "",
hasUnread: c.messages[0]?.occurredAt
? (!readAtByContactId.has(c.id) || c.messages[0].occurredAt > readAtByContactId.get(c.id)!)
: false,
description: c.note?.content ?? "",
}));
}
@@ -1513,6 +1522,23 @@ async function setContactInboxHidden(
return { ok: true };
}
async function markThreadRead(
auth: AuthContext | null,
input: { contactId: string },
) {
const ctx = requireAuth(auth);
const contactId = String(input?.contactId ?? "").trim();
if (!contactId) throw new Error("contactId is required");
await prisma.contactThreadRead.upsert({
where: { userId_contactId: { userId: ctx.userId, contactId } },
create: { teamId: ctx.teamId, userId: ctx.userId, contactId, readAt: new Date() },
update: { readAt: new Date() },
});
return { ok: true };
}
async function updateCommunicationTranscript(auth: AuthContext | null, id: string, transcript: string[]) {
const ctx = requireAuth(auth);
const messageId = String(id ?? "").trim();
@@ -1877,6 +1903,7 @@ export const crmGraphqlSchema = buildSchema(`
updateCommunicationTranscript(id: ID!, transcript: [String!]!): MutationWithIdResult!
updateFeedDecision(id: ID!, decision: String!, decisionNote: String): MutationWithIdResult!
setContactInboxHidden(inboxId: ID!, hidden: Boolean!): MutationResult!
markThreadRead(contactId: ID!): MutationResult!
}
type MutationResult {
@@ -2017,6 +2044,7 @@ export const crmGraphqlSchema = buildSchema(`
lastContactAt: String!
lastMessageText: String!
lastMessageChannel: String!
hasUnread: Boolean!
description: String!
}
@@ -2229,4 +2257,9 @@ export const crmGraphqlRoot = {
args: { inboxId: string; hidden: boolean },
context: GraphQLContext,
) => setContactInboxHidden(context.auth, args),
markThreadRead: async (
args: { contactId: string },
context: GraphQLContext,
) => markThreadRead(context.auth, args),
};