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),
};