Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions apps/api/src/db/queries/conversation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -589,6 +589,7 @@ export async function listConversationsHeaders(

const seenDataMap = new Map<string, ConversationSeen[]>();
const userLastSeenMap = new Map<string, string | null>();
const teamLastSeenMap = new Map<string, string | null>();

for (const seen of seenRows) {
const collection = seenDataMap.get(seen.conversationId) ?? [];
Expand All @@ -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);
}
}
Comment on lines +620 to +626
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Repeated Date allocation in hot loop

The else branch allocates two new Date(...) objects per candidate, same pattern used for userLastSeenMap above. Since seenRows for the bulk query is already ordered desc(lastSeenAt), the first userId row encountered for each conversation is always the maximum, so this branch will never trigger in practice. If the ordering guarantee is ever relaxed, consider caching the parsed date alongside the string to avoid repeated parsing.

Suggested change
} else {
const currentDate = new Date(currentTeamLastSeen);
const candidateDate = new Date(seen.lastSeenAt);
if (candidateDate > currentDate) {
teamLastSeenMap.set(seen.conversationId, seen.lastSeenAt);
}
}
} else {
if (seen.lastSeenAt > currentTeamLastSeen) {
teamLastSeenMap.set(seen.conversationId, seen.lastSeenAt);
}
}

ISO 8601 strings with the same timezone offset compare correctly as plain strings, so > avoids the Date allocation entirely.

}
}

const conversationsWithDetails = items.map((row) => {
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -808,6 +823,18 @@ export async function getConversationHeader(
}, null)
: null;

const teamLastSeenAt = seenRows.reduce<string | null>((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);
Comment on lines +826 to +836
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Repeated Date allocation in reduce

Same repeated new Date(...) construction on every comparison. Because the DB query orders by desc(lastSeenAt), the accumulator will always win once set, so the allocation is wasted every iteration. The same plain-string comparison applies here:

Suggested change
const teamLastSeenAt = seenRows.reduce<string | null>((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);
const teamLastSeenAt = seenRows.reduce<string | null>((acc, seen) => {
if (!seen.userId || !seen.lastSeenAt) {
return acc;
}
if (!acc) {
return seen.lastSeenAt;
}
return seen.lastSeenAt > acc ? seen.lastSeenAt : acc;
}, null);


return {
...row.conversation,
visitor: {
Expand All @@ -828,6 +855,7 @@ export async function getConversationHeader(
viewIds,
lastMessageAt,
lastSeenAt: userLastSeenAt ?? null,
teamLastSeenAt: teamLastSeenAt ?? null,
lastMessageTimelineItem,
lastTimelineItem,
activeClarification:
Expand Down
42 changes: 42 additions & 0 deletions apps/api/src/rest/routers/conversation-auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,7 @@ function createInboxItem(overrides: Partial<Record<string, unknown>> = {}) {
deletedAt: null,
lastMessageAt: "2026-04-07T11:00:00.000Z",
lastSeenAt: null,
teamLastSeenAt: null,
lastMessageTimelineItem: null,
lastTimelineItem: null,
activeClarification: null,
Expand Down Expand Up @@ -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: {},
Expand Down
1 change: 1 addition & 0 deletions apps/api/src/ws/router.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -525,6 +525,7 @@ describe("conversationCreated handler", () => {
deletedAt: null,
lastMessageAt: null,
lastSeenAt: null,
teamLastSeenAt: null,
visitorRating: null,
visitorRatingAt: null,
lastTimelineItem: null,
Expand Down
4 changes: 4 additions & 0 deletions packages/types/src/api/conversation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
}),
Expand Down
1 change: 1 addition & 0 deletions packages/types/src/trpc/conversation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down