diff --git a/apps/api/src/db/queries/conversation.ts b/apps/api/src/db/queries/conversation.ts index 6a700efc..764da105 100644 --- a/apps/api/src/db/queries/conversation.ts +++ b/apps/api/src/db/queries/conversation.ts @@ -589,6 +589,7 @@ export async function listConversationsHeaders( const seenDataMap = new Map(); const userLastSeenMap = new Map(); + const teamLastSeenMap = new Map(); for (const seen of seenRows) { const collection = seenDataMap.get(seen.conversationId) ?? []; @@ -611,6 +612,19 @@ export async function listConversationsHeaders( } } } + + if (seen.userId && seen.lastSeenAt) { + const currentTeamLastSeen = teamLastSeenMap.get(seen.conversationId); + if (!currentTeamLastSeen) { + teamLastSeenMap.set(seen.conversationId, seen.lastSeenAt); + } else { + const currentDate = new Date(currentTeamLastSeen); + const candidateDate = new Date(seen.lastSeenAt); + if (candidateDate > currentDate) { + teamLastSeenMap.set(seen.conversationId, seen.lastSeenAt); + } + } + } } const conversationsWithDetails = items.map((row) => { @@ -652,6 +666,7 @@ export async function listConversationsHeaders( viewIds: viewIdsMap.get(conversationId) ?? [], lastMessageAt, lastSeenAt: userLastSeenMap.get(conversationId) ?? null, + teamLastSeenAt: teamLastSeenMap.get(conversationId) ?? null, lastMessageTimelineItem, lastTimelineItem, activeClarification: @@ -808,6 +823,18 @@ export async function getConversationHeader( }, null) : null; + const teamLastSeenAt = seenRows.reduce((acc, seen) => { + if (!seen.userId || !seen.lastSeenAt) { + return acc; + } + if (!acc) { + return seen.lastSeenAt; + } + return new Date(seen.lastSeenAt) > new Date(acc) + ? seen.lastSeenAt + : acc; + }, null); + return { ...row.conversation, visitor: { @@ -828,6 +855,7 @@ export async function getConversationHeader( viewIds, lastMessageAt, lastSeenAt: userLastSeenAt ?? null, + teamLastSeenAt: teamLastSeenAt ?? null, lastMessageTimelineItem, lastTimelineItem, activeClarification: diff --git a/apps/api/src/rest/routers/conversation-auth.test.ts b/apps/api/src/rest/routers/conversation-auth.test.ts index 782c4b82..5aa9e1a5 100644 --- a/apps/api/src/rest/routers/conversation-auth.test.ts +++ b/apps/api/src/rest/routers/conversation-auth.test.ts @@ -259,6 +259,7 @@ function createInboxItem(overrides: Partial> = {}) { deletedAt: null, lastMessageAt: "2026-04-07T11:00:00.000Z", lastSeenAt: null, + teamLastSeenAt: null, lastMessageTimelineItem: null, lastTimelineItem: null, activeClarification: null, @@ -401,6 +402,47 @@ describe("conversation auth and inbox routes", () => { }); }); + it("includes teamLastSeenAt in private inbox responses", async () => { + listConversationsHeadersMock.mockResolvedValue({ + items: [ + createInboxItem({ + lastSeenAt: "2026-04-07T11:30:00.000Z", + teamLastSeenAt: "2026-04-07T12:00:00.000Z", + }), + ], + nextCursor: null, + }); + safelyExtractRequestQueryMock.mockResolvedValue({ + db: {}, + apiKey: { keyType: APIKeyType.PRIVATE }, + organization: { id: "org-1" }, + website: { id: "site-1", organizationId: "org-1", teamId: "team-1" }, + query: { + limit: 20, + cursor: null, + }, + }); + + const { conversationRouter } = await conversationRouterModulePromise; + const response = await conversationRouter.request( + new Request("http://localhost/inbox?limit=20", { + method: "GET", + }) + ); + const payload = (await response.json()) as { + items: Array<{ + lastSeenAt: string | null; + teamLastSeenAt: string | null; + }>; + }; + + expect(response.status).toBe(200); + expect(payload.items[0]?.lastSeenAt).toBe("2026-04-07T11:30:00.000Z"); + expect(payload.items[0]?.teamLastSeenAt).toBe( + "2026-04-07T12:00:00.000Z" + ); + }); + it("allows private API keys to read a conversation without a visitor header", async () => { safelyExtractRequestDataMock.mockResolvedValue({ db: {}, diff --git a/apps/api/src/ws/router.test.ts b/apps/api/src/ws/router.test.ts index 3c97149d..e8cce5a9 100644 --- a/apps/api/src/ws/router.test.ts +++ b/apps/api/src/ws/router.test.ts @@ -525,6 +525,7 @@ describe("conversationCreated handler", () => { deletedAt: null, lastMessageAt: null, lastSeenAt: null, + teamLastSeenAt: null, visitorRating: null, visitorRatingAt: null, lastTimelineItem: null, diff --git a/packages/types/src/api/conversation.ts b/packages/types/src/api/conversation.ts index 36e603fe..9e7e5a6f 100644 --- a/packages/types/src/api/conversation.ts +++ b/packages/types/src/api/conversation.ts @@ -280,6 +280,10 @@ export const conversationInboxItemSchema = z description: "User-specific last-seen timestamp when available, otherwise null.", }), + teamLastSeenAt: nullableApiTimestampSchema.openapi({ + description: + "Most recent last-seen timestamp across all human teammates who have seen the conversation, otherwise null.", + }), lastMessageTimelineItem: timelineItemSchema.nullable().openapi({ description: "Latest message timeline item for the conversation, if any.", }), diff --git a/packages/types/src/trpc/conversation.ts b/packages/types/src/trpc/conversation.ts index 3342d36e..c1f20826 100644 --- a/packages/types/src/trpc/conversation.ts +++ b/packages/types/src/trpc/conversation.ts @@ -110,6 +110,7 @@ export const conversationHeaderSchema = z.object({ deletedAt: z.string().nullable(), lastMessageAt: z.string().nullable(), lastSeenAt: z.string().nullable(), + teamLastSeenAt: z.string().nullable(), lastMessageTimelineItem: timelineItemSchema.nullable(), lastTimelineItem: timelineItemSchema.nullable(), activeClarification: conversationClarificationSummarySchema.nullable(),