From 5492e0d05c2cd64d1ba4301b3328589dc951f68a Mon Sep 17 00:00:00 2001 From: Ruslan Bakiev <572431+veikab@users.noreply.github.com> Date: Tue, 24 Feb 2026 20:25:32 +0700 Subject: [PATCH] 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 --- .../components/workspace/CrmWorkspaceApp.vue | 8 ++- .../CrmCommunicationsListSidebar.vue | 9 ++-- frontend/app/composables/useContacts.ts | 19 ++++++- frontend/graphql/generated.ts | 50 ++++++++++++++++++- frontend/graphql/operations/contacts.graphql | 1 + .../operations/mark-thread-read.graphql | 5 ++ frontend/graphql/schema.graphql | 2 + .../migration.sql | 27 ++++++++++ frontend/prisma/schema.prisma | 20 ++++++++ frontend/server/graphql/schema.ts | 35 ++++++++++++- 10 files changed, 167 insertions(+), 9 deletions(-) create mode 100644 frontend/graphql/operations/mark-thread-read.graphql create mode 100644 frontend/prisma/migrations/5_contact_thread_read_tracking/migration.sql diff --git a/frontend/app/components/workspace/CrmWorkspaceApp.vue b/frontend/app/components/workspace/CrmWorkspaceApp.vue index 4e6ea18..5c95de0 100644 --- a/frontend/app/components/workspace/CrmWorkspaceApp.vue +++ b/frontend/app/components/workspace/CrmWorkspaceApp.vue @@ -108,6 +108,7 @@ const { avatarSrcForThread, markAvatarBroken, contactInitials, + markContactRead, refetchContacts, } = useContacts({ apolloAuthReady }); @@ -484,6 +485,7 @@ const commThreads = computed(() => { channels, lastAt: c.lastContactAt, lastText: c.lastMessageText || "No messages yet", + hasUnread: c.hasUnread, items: [] as CommItem[], }; }) @@ -660,11 +662,12 @@ const { crmRealtimeState, startCrmRealtime, stopCrmRealtime } = useCrmRealtime({ await Promise.all([refetchAllCrmQueries(), loadTelegramConnectStatus()]); }, onNewMessage: (msg) => { - // If the message is for the currently open thread → refresh its timeline + // If the message is for the currently open thread → refresh its timeline + mark read if (msg.contactId === selectedCommThreadId.value) { void refreshSelectedClientTimeline(selectedCommThreadId.value); + markContactRead(msg.contactId); } - // Refresh contacts to update sidebar preview (lastMessageText, lastAt) + // Refresh contacts to update sidebar preview (lastMessageText, lastAt, hasUnread) void refetchContacts(); }, }); @@ -1160,6 +1163,7 @@ watch(selectedCommThreadId, () => { clientTimelineItems.value = []; return; } + markContactRead(selectedCommThreadId.value); void refreshSelectedClientTimeline(selectedCommThreadId.value).catch(() => undefined); }); diff --git a/frontend/app/components/workspace/communications/CrmCommunicationsListSidebar.vue b/frontend/app/components/workspace/communications/CrmCommunicationsListSidebar.vue index 52120c9..5cd9f50 100644 --- a/frontend/app/components/workspace/communications/CrmCommunicationsListSidebar.vue +++ b/frontend/app/components/workspace/communications/CrmCommunicationsListSidebar.vue @@ -142,10 +142,13 @@ function onSearchInput(event: Event) {
-

{{ thread.contact }}

- {{ formatThreadTime(thread.lastAt) }} +
+ +

{{ thread.contact }}

+
+ {{ formatThreadTime(thread.lastAt) }}
-

+

{{ thread.lastText || threadChannelLabel(thread) }}

diff --git a/frontend/app/composables/useContacts.ts b/frontend/app/composables/useContacts.ts index 344c253..f6808e6 100644 --- a/frontend/app/composables/useContacts.ts +++ b/frontend/app/composables/useContacts.ts @@ -1,6 +1,6 @@ import { ref, computed, watch, watchEffect, type ComputedRef } from "vue"; -import { useQuery } from "@vue/apollo-composable"; -import { ContactsQueryDocument } from "~~/graphql/generated"; +import { useQuery, useMutation } from "@vue/apollo-composable"; +import { ContactsQueryDocument, MarkThreadReadDocument } from "~~/graphql/generated"; export type Contact = { id: string; @@ -10,6 +10,7 @@ export type Contact = { lastContactAt: string; lastMessageText: string; lastMessageChannel: string; + hasUnread: boolean; description: string; }; @@ -117,6 +118,19 @@ export function useContacts(opts: { apolloAuthReady: ComputedRef }) { const selectedContact = computed(() => contacts.value.find((item) => item.id === selectedContactId.value)); + const { mutate: doMarkThreadRead } = useMutation(MarkThreadReadDocument); + + function markContactRead(contactId: string) { + if (!contactId) return; + // Optimistically update local state + const idx = contacts.value.findIndex((c) => c.id === contactId); + if (idx >= 0 && contacts.value[idx]!.hasUnread) { + contacts.value[idx] = { ...contacts.value[idx]!, hasUnread: false }; + } + // Fire-and-forget backend call + void doMarkThreadRead({ contactId }).catch(() => undefined); + } + const brokenAvatarByContactId = ref>({}); function contactInitials(name: string) { @@ -159,6 +173,7 @@ export function useContacts(opts: { apolloAuthReady: ComputedRef }) { avatarSrcForThread, markAvatarBroken, contactInitials, + markContactRead, refetchContacts, }; } diff --git a/frontend/graphql/generated.ts b/frontend/graphql/generated.ts index 8a78eb3..1f2039f 100644 --- a/frontend/graphql/generated.ts +++ b/frontend/graphql/generated.ts @@ -87,8 +87,11 @@ export type Contact = { avatar: Scalars['String']['output']; channels: Array; description: Scalars['String']['output']; + hasUnread: Scalars['Boolean']['output']; id: Scalars['ID']['output']; lastContactAt: Scalars['String']['output']; + lastMessageChannel: Scalars['String']['output']; + lastMessageText: Scalars['String']['output']; name: Scalars['String']['output']; }; @@ -220,6 +223,7 @@ export type Mutation = { logPilotNote: MutationResult; login: MutationResult; logout: MutationResult; + markThreadRead: MutationResult; rollbackChangeSetItems: MutationResult; rollbackLatestChangeSet: MutationResult; selectChatConversation: MutationResult; @@ -277,6 +281,11 @@ export type MutationloginArgs = { }; +export type MutationmarkThreadReadArgs = { + contactId: Scalars['ID']['input']; +}; + + export type MutationrollbackChangeSetItemsArgs = { changeSetId: Scalars['ID']['input']; itemIds: Array; @@ -464,7 +473,7 @@ export type ContactInboxesQueryQuery = { __typename?: 'Query', contactInboxes: A export type ContactsQueryQueryVariables = Exact<{ [key: string]: never; }>; -export type ContactsQueryQuery = { __typename?: 'Query', contacts: Array<{ __typename?: 'Contact', id: string, name: string, avatar: string, channels: Array, lastContactAt: string, description: string }> }; +export type ContactsQueryQuery = { __typename?: 'Query', contacts: Array<{ __typename?: 'Contact', id: string, name: string, avatar: string, channels: Array, lastContactAt: string, lastMessageText: string, lastMessageChannel: string, hasUnread: boolean, description: string }> }; export type CreateCalendarEventMutationMutationVariables = Exact<{ input: CreateCalendarEventInput; @@ -544,6 +553,13 @@ export type LogoutMutationMutationVariables = Exact<{ [key: string]: never; }>; export type LogoutMutationMutation = { __typename?: 'Mutation', logout: { __typename?: 'MutationResult', ok: boolean } }; +export type MarkThreadReadMutationVariables = Exact<{ + contactId: Scalars['ID']['input']; +}>; + + +export type MarkThreadReadMutation = { __typename?: 'Mutation', markThreadRead: { __typename?: 'MutationResult', ok: boolean } }; + export type MeQueryQueryVariables = Exact<{ [key: string]: never; }>; @@ -921,6 +937,9 @@ export const ContactsQueryDocument = gql` avatar channels lastContactAt + lastMessageText + lastMessageChannel + hasUnread description } } @@ -1399,6 +1418,35 @@ export function useLogoutMutationMutation(options: VueApolloComposable.UseMutati return VueApolloComposable.useMutation(LogoutMutationDocument, options); } export type LogoutMutationMutationCompositionFunctionResult = VueApolloComposable.UseMutationReturn; +export const MarkThreadReadDocument = gql` + mutation MarkThreadRead($contactId: ID!) { + markThreadRead(contactId: $contactId) { + ok + } +} + `; + +/** + * __useMarkThreadReadMutation__ + * + * To run a mutation, you first call `useMarkThreadReadMutation` within a Vue component and pass it any options that fit your needs. + * When your component renders, `useMarkThreadReadMutation` returns an object that includes: + * - A mutate function that you can call at any time to execute the mutation + * - Several other properties: https://v4.apollo.vuejs.org/api/use-mutation.html#return + * + * @param options that will be passed into the mutation, supported options are listed on: https://v4.apollo.vuejs.org/guide-composable/mutation.html#options; + * + * @example + * const { mutate, loading, error, onDone } = useMarkThreadReadMutation({ + * variables: { + * contactId: // value for 'contactId' + * }, + * }); + */ +export function useMarkThreadReadMutation(options: VueApolloComposable.UseMutationOptions | ReactiveFunction> = {}) { + return VueApolloComposable.useMutation(MarkThreadReadDocument, options); +} +export type MarkThreadReadMutationCompositionFunctionResult = VueApolloComposable.UseMutationReturn; export const MeQueryDocument = gql` query MeQuery { me { diff --git a/frontend/graphql/operations/contacts.graphql b/frontend/graphql/operations/contacts.graphql index 86ff019..3d1e3ca 100644 --- a/frontend/graphql/operations/contacts.graphql +++ b/frontend/graphql/operations/contacts.graphql @@ -7,6 +7,7 @@ query ContactsQuery { lastContactAt lastMessageText lastMessageChannel + hasUnread description } } diff --git a/frontend/graphql/operations/mark-thread-read.graphql b/frontend/graphql/operations/mark-thread-read.graphql new file mode 100644 index 0000000..71f04fa --- /dev/null +++ b/frontend/graphql/operations/mark-thread-read.graphql @@ -0,0 +1,5 @@ +mutation MarkThreadRead($contactId: ID!) { + markThreadRead(contactId: $contactId) { + ok + } +} diff --git a/frontend/graphql/schema.graphql b/frontend/graphql/schema.graphql index 0e842b7..27bb8f2 100644 --- a/frontend/graphql/schema.graphql +++ b/frontend/graphql/schema.graphql @@ -33,6 +33,7 @@ type Mutation { 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 { @@ -173,6 +174,7 @@ type Contact { lastContactAt: String! lastMessageText: String! lastMessageChannel: String! + hasUnread: Boolean! description: String! } diff --git a/frontend/prisma/migrations/5_contact_thread_read_tracking/migration.sql b/frontend/prisma/migrations/5_contact_thread_read_tracking/migration.sql new file mode 100644 index 0000000..99f267b --- /dev/null +++ b/frontend/prisma/migrations/5_contact_thread_read_tracking/migration.sql @@ -0,0 +1,27 @@ +-- CreateTable +CREATE TABLE "ContactThreadRead" ( + "id" TEXT NOT NULL, + "teamId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "contactId" TEXT NOT NULL, + "readAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "ContactThreadRead_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "ContactThreadRead_userId_contactId_key" ON "ContactThreadRead"("userId", "contactId"); + +-- CreateIndex +CREATE INDEX "ContactThreadRead_teamId_userId_idx" ON "ContactThreadRead"("teamId", "userId"); + +-- AddForeignKey +ALTER TABLE "ContactThreadRead" ADD CONSTRAINT "ContactThreadRead_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ContactThreadRead" ADD CONSTRAINT "ContactThreadRead_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ContactThreadRead" ADD CONSTRAINT "ContactThreadRead_contactId_fkey" FOREIGN KEY ("contactId") REFERENCES "Contact"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/frontend/prisma/schema.prisma b/frontend/prisma/schema.prisma index 7e54e06..84de961 100644 --- a/frontend/prisma/schema.prisma +++ b/frontend/prisma/schema.prisma @@ -88,6 +88,7 @@ model Team { clientTimelineEntries ClientTimelineEntry[] contactInboxes ContactInbox[] contactInboxPreferences ContactInboxPreference[] + contactThreadReads ContactThreadRead[] } model User { @@ -103,6 +104,7 @@ model User { aiConversations AiConversation[] @relation("ConversationCreator") aiMessages AiMessage[] @relation("ChatAuthor") contactInboxPreferences ContactInboxPreference[] + contactThreadReads ContactThreadRead[] } model TeamMember { @@ -142,10 +144,28 @@ model Contact { omniIdentities OmniContactIdentity[] contactInboxes ContactInbox[] clientTimelineEntries ClientTimelineEntry[] + contactThreadReads ContactThreadRead[] @@index([teamId, updatedAt]) } +model ContactThreadRead { + id String @id @default(cuid()) + teamId String + userId String + contactId String + readAt DateTime @default(now()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade) + + @@unique([userId, contactId]) + @@index([teamId, userId]) +} + model ContactNote { id String @id @default(cuid()) contactId String @unique diff --git a/frontend/server/graphql/schema.ts b/frontend/server/graphql/schema.ts index fec1c3e..347633f 100644 --- a/frontend/server/graphql/schema.ts +++ b/frontend/server/graphql/schema.ts @@ -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>(); const totalInboxesByContactId = new Map(); const visibleInboxesByContactId = new Map(); @@ -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), };