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:
@@ -108,6 +108,7 @@ const {
|
|||||||
avatarSrcForThread,
|
avatarSrcForThread,
|
||||||
markAvatarBroken,
|
markAvatarBroken,
|
||||||
contactInitials,
|
contactInitials,
|
||||||
|
markContactRead,
|
||||||
refetchContacts,
|
refetchContacts,
|
||||||
} = useContacts({ apolloAuthReady });
|
} = useContacts({ apolloAuthReady });
|
||||||
|
|
||||||
@@ -484,6 +485,7 @@ const commThreads = computed(() => {
|
|||||||
channels,
|
channels,
|
||||||
lastAt: c.lastContactAt,
|
lastAt: c.lastContactAt,
|
||||||
lastText: c.lastMessageText || "No messages yet",
|
lastText: c.lastMessageText || "No messages yet",
|
||||||
|
hasUnread: c.hasUnread,
|
||||||
items: [] as CommItem[],
|
items: [] as CommItem[],
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
@@ -660,11 +662,12 @@ const { crmRealtimeState, startCrmRealtime, stopCrmRealtime } = useCrmRealtime({
|
|||||||
await Promise.all([refetchAllCrmQueries(), loadTelegramConnectStatus()]);
|
await Promise.all([refetchAllCrmQueries(), loadTelegramConnectStatus()]);
|
||||||
},
|
},
|
||||||
onNewMessage: (msg) => {
|
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) {
|
if (msg.contactId === selectedCommThreadId.value) {
|
||||||
void refreshSelectedClientTimeline(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();
|
void refetchContacts();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -1160,6 +1163,7 @@ watch(selectedCommThreadId, () => {
|
|||||||
clientTimelineItems.value = [];
|
clientTimelineItems.value = [];
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
markContactRead(selectedCommThreadId.value);
|
||||||
void refreshSelectedClientTimeline(selectedCommThreadId.value).catch(() => undefined);
|
void refreshSelectedClientTimeline(selectedCommThreadId.value).catch(() => undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -142,10 +142,13 @@ function onSearchInput(event: Event) {
|
|||||||
|
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<div class="flex items-start justify-between gap-2">
|
<div class="flex items-start justify-between gap-2">
|
||||||
<p class="min-w-0 flex-1 truncate text-xs font-semibold">{{ thread.contact }}</p>
|
<div class="flex min-w-0 flex-1 items-center gap-1">
|
||||||
<span class="shrink-0 text-[10px] text-base-content/55">{{ formatThreadTime(thread.lastAt) }}</span>
|
<span v-if="thread.hasUnread" class="h-2 w-2 shrink-0 rounded-full bg-primary" />
|
||||||
|
<p class="min-w-0 flex-1 truncate text-xs" :class="thread.hasUnread ? 'font-bold' : 'font-semibold'">{{ thread.contact }}</p>
|
||||||
|
</div>
|
||||||
|
<span class="shrink-0 text-[10px]" :class="thread.hasUnread ? 'font-semibold text-primary' : 'text-base-content/55'">{{ formatThreadTime(thread.lastAt) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-0.5 min-w-0 truncate text-[11px] text-base-content/75">
|
<p class="mt-0.5 min-w-0 truncate text-[11px]" :class="thread.hasUnread ? 'font-semibold text-base-content' : 'text-base-content/75'">
|
||||||
{{ thread.lastText || threadChannelLabel(thread) }}
|
{{ thread.lastText || threadChannelLabel(thread) }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { ref, computed, watch, watchEffect, type ComputedRef } from "vue";
|
import { ref, computed, watch, watchEffect, type ComputedRef } from "vue";
|
||||||
import { useQuery } from "@vue/apollo-composable";
|
import { useQuery, useMutation } from "@vue/apollo-composable";
|
||||||
import { ContactsQueryDocument } from "~~/graphql/generated";
|
import { ContactsQueryDocument, MarkThreadReadDocument } from "~~/graphql/generated";
|
||||||
|
|
||||||
export type Contact = {
|
export type Contact = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -10,6 +10,7 @@ export type Contact = {
|
|||||||
lastContactAt: string;
|
lastContactAt: string;
|
||||||
lastMessageText: string;
|
lastMessageText: string;
|
||||||
lastMessageChannel: string;
|
lastMessageChannel: string;
|
||||||
|
hasUnread: boolean;
|
||||||
description: string;
|
description: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -117,6 +118,19 @@ export function useContacts(opts: { apolloAuthReady: ComputedRef<boolean> }) {
|
|||||||
|
|
||||||
const selectedContact = computed(() => contacts.value.find((item) => item.id === selectedContactId.value));
|
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<Record<string, boolean>>({});
|
const brokenAvatarByContactId = ref<Record<string, boolean>>({});
|
||||||
|
|
||||||
function contactInitials(name: string) {
|
function contactInitials(name: string) {
|
||||||
@@ -159,6 +173,7 @@ export function useContacts(opts: { apolloAuthReady: ComputedRef<boolean> }) {
|
|||||||
avatarSrcForThread,
|
avatarSrcForThread,
|
||||||
markAvatarBroken,
|
markAvatarBroken,
|
||||||
contactInitials,
|
contactInitials,
|
||||||
|
markContactRead,
|
||||||
refetchContacts,
|
refetchContacts,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,8 +87,11 @@ export type Contact = {
|
|||||||
avatar: Scalars['String']['output'];
|
avatar: Scalars['String']['output'];
|
||||||
channels: Array<Scalars['String']['output']>;
|
channels: Array<Scalars['String']['output']>;
|
||||||
description: Scalars['String']['output'];
|
description: Scalars['String']['output'];
|
||||||
|
hasUnread: Scalars['Boolean']['output'];
|
||||||
id: Scalars['ID']['output'];
|
id: Scalars['ID']['output'];
|
||||||
lastContactAt: Scalars['String']['output'];
|
lastContactAt: Scalars['String']['output'];
|
||||||
|
lastMessageChannel: Scalars['String']['output'];
|
||||||
|
lastMessageText: Scalars['String']['output'];
|
||||||
name: Scalars['String']['output'];
|
name: Scalars['String']['output'];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -220,6 +223,7 @@ export type Mutation = {
|
|||||||
logPilotNote: MutationResult;
|
logPilotNote: MutationResult;
|
||||||
login: MutationResult;
|
login: MutationResult;
|
||||||
logout: MutationResult;
|
logout: MutationResult;
|
||||||
|
markThreadRead: MutationResult;
|
||||||
rollbackChangeSetItems: MutationResult;
|
rollbackChangeSetItems: MutationResult;
|
||||||
rollbackLatestChangeSet: MutationResult;
|
rollbackLatestChangeSet: MutationResult;
|
||||||
selectChatConversation: MutationResult;
|
selectChatConversation: MutationResult;
|
||||||
@@ -277,6 +281,11 @@ export type MutationloginArgs = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export type MutationmarkThreadReadArgs = {
|
||||||
|
contactId: Scalars['ID']['input'];
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
export type MutationrollbackChangeSetItemsArgs = {
|
export type MutationrollbackChangeSetItemsArgs = {
|
||||||
changeSetId: Scalars['ID']['input'];
|
changeSetId: Scalars['ID']['input'];
|
||||||
itemIds: Array<Scalars['ID']['input']>;
|
itemIds: Array<Scalars['ID']['input']>;
|
||||||
@@ -464,7 +473,7 @@ export type ContactInboxesQueryQuery = { __typename?: 'Query', contactInboxes: A
|
|||||||
export type ContactsQueryQueryVariables = Exact<{ [key: string]: never; }>;
|
export type ContactsQueryQueryVariables = Exact<{ [key: string]: never; }>;
|
||||||
|
|
||||||
|
|
||||||
export type ContactsQueryQuery = { __typename?: 'Query', contacts: Array<{ __typename?: 'Contact', id: string, name: string, avatar: string, channels: Array<string>, lastContactAt: string, description: string }> };
|
export type ContactsQueryQuery = { __typename?: 'Query', contacts: Array<{ __typename?: 'Contact', id: string, name: string, avatar: string, channels: Array<string>, lastContactAt: string, lastMessageText: string, lastMessageChannel: string, hasUnread: boolean, description: string }> };
|
||||||
|
|
||||||
export type CreateCalendarEventMutationMutationVariables = Exact<{
|
export type CreateCalendarEventMutationMutationVariables = Exact<{
|
||||||
input: CreateCalendarEventInput;
|
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 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; }>;
|
export type MeQueryQueryVariables = Exact<{ [key: string]: never; }>;
|
||||||
|
|
||||||
|
|
||||||
@@ -921,6 +937,9 @@ export const ContactsQueryDocument = gql`
|
|||||||
avatar
|
avatar
|
||||||
channels
|
channels
|
||||||
lastContactAt
|
lastContactAt
|
||||||
|
lastMessageText
|
||||||
|
lastMessageChannel
|
||||||
|
hasUnread
|
||||||
description
|
description
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1399,6 +1418,35 @@ export function useLogoutMutationMutation(options: VueApolloComposable.UseMutati
|
|||||||
return VueApolloComposable.useMutation<LogoutMutationMutation, LogoutMutationMutationVariables>(LogoutMutationDocument, options);
|
return VueApolloComposable.useMutation<LogoutMutationMutation, LogoutMutationMutationVariables>(LogoutMutationDocument, options);
|
||||||
}
|
}
|
||||||
export type LogoutMutationMutationCompositionFunctionResult = VueApolloComposable.UseMutationReturn<LogoutMutationMutation, LogoutMutationMutationVariables>;
|
export type LogoutMutationMutationCompositionFunctionResult = VueApolloComposable.UseMutationReturn<LogoutMutationMutation, LogoutMutationMutationVariables>;
|
||||||
|
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<MarkThreadReadMutation, MarkThreadReadMutationVariables> | ReactiveFunction<VueApolloComposable.UseMutationOptions<MarkThreadReadMutation, MarkThreadReadMutationVariables>> = {}) {
|
||||||
|
return VueApolloComposable.useMutation<MarkThreadReadMutation, MarkThreadReadMutationVariables>(MarkThreadReadDocument, options);
|
||||||
|
}
|
||||||
|
export type MarkThreadReadMutationCompositionFunctionResult = VueApolloComposable.UseMutationReturn<MarkThreadReadMutation, MarkThreadReadMutationVariables>;
|
||||||
export const MeQueryDocument = gql`
|
export const MeQueryDocument = gql`
|
||||||
query MeQuery {
|
query MeQuery {
|
||||||
me {
|
me {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ query ContactsQuery {
|
|||||||
lastContactAt
|
lastContactAt
|
||||||
lastMessageText
|
lastMessageText
|
||||||
lastMessageChannel
|
lastMessageChannel
|
||||||
|
hasUnread
|
||||||
description
|
description
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
5
frontend/graphql/operations/mark-thread-read.graphql
Normal file
5
frontend/graphql/operations/mark-thread-read.graphql
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
mutation MarkThreadRead($contactId: ID!) {
|
||||||
|
markThreadRead(contactId: $contactId) {
|
||||||
|
ok
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,6 +33,7 @@ type Mutation {
|
|||||||
updateCommunicationTranscript(id: ID!, transcript: [String!]!): MutationWithIdResult!
|
updateCommunicationTranscript(id: ID!, transcript: [String!]!): MutationWithIdResult!
|
||||||
updateFeedDecision(id: ID!, decision: String!, decisionNote: String): MutationWithIdResult!
|
updateFeedDecision(id: ID!, decision: String!, decisionNote: String): MutationWithIdResult!
|
||||||
setContactInboxHidden(inboxId: ID!, hidden: Boolean!): MutationResult!
|
setContactInboxHidden(inboxId: ID!, hidden: Boolean!): MutationResult!
|
||||||
|
markThreadRead(contactId: ID!): MutationResult!
|
||||||
}
|
}
|
||||||
|
|
||||||
type MutationResult {
|
type MutationResult {
|
||||||
@@ -173,6 +174,7 @@ type Contact {
|
|||||||
lastContactAt: String!
|
lastContactAt: String!
|
||||||
lastMessageText: String!
|
lastMessageText: String!
|
||||||
lastMessageChannel: String!
|
lastMessageChannel: String!
|
||||||
|
hasUnread: Boolean!
|
||||||
description: String!
|
description: String!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -88,6 +88,7 @@ model Team {
|
|||||||
clientTimelineEntries ClientTimelineEntry[]
|
clientTimelineEntries ClientTimelineEntry[]
|
||||||
contactInboxes ContactInbox[]
|
contactInboxes ContactInbox[]
|
||||||
contactInboxPreferences ContactInboxPreference[]
|
contactInboxPreferences ContactInboxPreference[]
|
||||||
|
contactThreadReads ContactThreadRead[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
@@ -103,6 +104,7 @@ model User {
|
|||||||
aiConversations AiConversation[] @relation("ConversationCreator")
|
aiConversations AiConversation[] @relation("ConversationCreator")
|
||||||
aiMessages AiMessage[] @relation("ChatAuthor")
|
aiMessages AiMessage[] @relation("ChatAuthor")
|
||||||
contactInboxPreferences ContactInboxPreference[]
|
contactInboxPreferences ContactInboxPreference[]
|
||||||
|
contactThreadReads ContactThreadRead[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model TeamMember {
|
model TeamMember {
|
||||||
@@ -142,10 +144,28 @@ model Contact {
|
|||||||
omniIdentities OmniContactIdentity[]
|
omniIdentities OmniContactIdentity[]
|
||||||
contactInboxes ContactInbox[]
|
contactInboxes ContactInbox[]
|
||||||
clientTimelineEntries ClientTimelineEntry[]
|
clientTimelineEntries ClientTimelineEntry[]
|
||||||
|
contactThreadReads ContactThreadRead[]
|
||||||
|
|
||||||
@@index([teamId, updatedAt])
|
@@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 {
|
model ContactNote {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
contactId String @unique
|
contactId String @unique
|
||||||
|
|||||||
@@ -455,7 +455,7 @@ async function getContacts(auth: AuthContext | null) {
|
|||||||
const messageWhere = visibleMessageWhere(hiddenInboxIds);
|
const messageWhere = visibleMessageWhere(hiddenInboxIds);
|
||||||
const hiddenInboxIdSet = new Set(hiddenInboxIds);
|
const hiddenInboxIdSet = new Set(hiddenInboxIds);
|
||||||
|
|
||||||
const [contactsRaw, contactInboxesRaw, communicationsRaw] = await Promise.all([
|
const [contactsRaw, contactInboxesRaw, communicationsRaw, threadReadsRaw] = await Promise.all([
|
||||||
prisma.contact.findMany({
|
prisma.contact.findMany({
|
||||||
where: { teamId: ctx.teamId },
|
where: { teamId: ctx.teamId },
|
||||||
include: {
|
include: {
|
||||||
@@ -485,8 +485,14 @@ async function getContacts(auth: AuthContext | null) {
|
|||||||
orderBy: { occurredAt: "asc" },
|
orderBy: { occurredAt: "asc" },
|
||||||
take: 2000,
|
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 channelsByContactId = new Map<string, Set<string>>();
|
||||||
const totalInboxesByContactId = new Map<string, number>();
|
const totalInboxesByContactId = new Map<string, number>();
|
||||||
const visibleInboxesByContactId = 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(),
|
lastContactAt: c.messages[0]?.occurredAt?.toISOString?.() ?? c.updatedAt.toISOString(),
|
||||||
lastMessageText: c.messages[0]?.content ?? "",
|
lastMessageText: c.messages[0]?.content ?? "",
|
||||||
lastMessageChannel: c.messages[0]?.channel ? mapChannel(c.messages[0].channel) : "",
|
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 ?? "",
|
description: c.note?.content ?? "",
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -1513,6 +1522,23 @@ async function setContactInboxHidden(
|
|||||||
return { ok: true };
|
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[]) {
|
async function updateCommunicationTranscript(auth: AuthContext | null, id: string, transcript: string[]) {
|
||||||
const ctx = requireAuth(auth);
|
const ctx = requireAuth(auth);
|
||||||
const messageId = String(id ?? "").trim();
|
const messageId = String(id ?? "").trim();
|
||||||
@@ -1877,6 +1903,7 @@ export const crmGraphqlSchema = buildSchema(`
|
|||||||
updateCommunicationTranscript(id: ID!, transcript: [String!]!): MutationWithIdResult!
|
updateCommunicationTranscript(id: ID!, transcript: [String!]!): MutationWithIdResult!
|
||||||
updateFeedDecision(id: ID!, decision: String!, decisionNote: String): MutationWithIdResult!
|
updateFeedDecision(id: ID!, decision: String!, decisionNote: String): MutationWithIdResult!
|
||||||
setContactInboxHidden(inboxId: ID!, hidden: Boolean!): MutationResult!
|
setContactInboxHidden(inboxId: ID!, hidden: Boolean!): MutationResult!
|
||||||
|
markThreadRead(contactId: ID!): MutationResult!
|
||||||
}
|
}
|
||||||
|
|
||||||
type MutationResult {
|
type MutationResult {
|
||||||
@@ -2017,6 +2044,7 @@ export const crmGraphqlSchema = buildSchema(`
|
|||||||
lastContactAt: String!
|
lastContactAt: String!
|
||||||
lastMessageText: String!
|
lastMessageText: String!
|
||||||
lastMessageChannel: String!
|
lastMessageChannel: String!
|
||||||
|
hasUnread: Boolean!
|
||||||
description: String!
|
description: String!
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2229,4 +2257,9 @@ export const crmGraphqlRoot = {
|
|||||||
args: { inboxId: string; hidden: boolean },
|
args: { inboxId: string; hidden: boolean },
|
||||||
context: GraphQLContext,
|
context: GraphQLContext,
|
||||||
) => setContactInboxHidden(context.auth, args),
|
) => setContactInboxHidden(context.auth, args),
|
||||||
|
|
||||||
|
markThreadRead: async (
|
||||||
|
args: { contactId: string },
|
||||||
|
context: GraphQLContext,
|
||||||
|
) => markThreadRead(context.auth, args),
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user