Add team-level lastSeenAt to conversation headers#158
Add team-level lastSeenAt to conversation headers#158seemayr wants to merge 1 commit intocossistantcom:mainfrom
Conversation
|
@seemayr is attempting to deploy a commit to the cossistant Team on Vercel. A member of the Team first needs to authorize it. |
Greptile SummaryThis PR adds The only notes are minor: both the loop in Confidence Score: 5/5Safe to merge — clean additive field with correct filtering logic and no changes to existing semantics. All findings are P2 style suggestions (redundant Date allocations on pre-sorted data). No logic bugs, schema mismatches, missing migrations, or broken contracts were found. No files require special attention. Important Files Changed
Sequence DiagramsequenceDiagram
participant Client as Dashboard Client
participant API as REST / tRPC API
participant Query as conversation.ts queries
participant DB as conversation_seen table
Client->>API: GET /inbox or tRPC header
API->>Query: listConversationsHeaders / getConversationHeader
Query->>DB: SELECT userId, lastSeenAt FROM conversation_seen
DB-->>Query: seenRows (all actor types)
Query->>Query: Filter rows where userId IS NOT NULL
Query->>Query: Compute max(lastSeenAt) → teamLastSeenAt
Query->>Query: Compute per-user lastSeenAt (unchanged)
Query-->>API: ConversationHeader { lastSeenAt, teamLastSeenAt, ... }
API-->>Client: { lastSeenAt, teamLastSeenAt }
Reviews (1): Last reviewed commit: "Add team-level last seen timestamp to co..." | Re-trigger Greptile |
| } else { | ||
| const currentDate = new Date(currentTeamLastSeen); | ||
| const candidateDate = new Date(seen.lastSeenAt); | ||
| if (candidateDate > currentDate) { | ||
| teamLastSeenMap.set(seen.conversationId, seen.lastSeenAt); | ||
| } | ||
| } |
There was a problem hiding this comment.
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.
| } 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 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); |
There was a problem hiding this comment.
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:
| 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); |
Summary
teamLastSeenAtto shared conversation header / inbox response typesconversation_seen.lastSeenAtacross all human teammateuserIdrowsWhy
Today the backend exposes:
lastSeenAt: current teammate-specific read statevisitor.lastSeenAt/visitorLastSeenAt: visitor read/presence stateseenData: full per-actor receipt rowsThat is enough for per-user unread state, but it makes it hard for dashboard clients to implement a lightweight "someone on the team already saw this" UI without either:
/seenfor many rows, orThis PR adds a dedicated aggregate field on conversation headers so clients can build a team-aware seen hint without extra per-conversation API calls.
What this changes
teamLastSeenAtis defined as:lastSeenAtacrossconversation_seenrows whereuserId IS NOT NULLThe existing
lastSeenAtbehavior stays unchanged:userIdis availablenullwhen there is no acting teammate contextAssumptions
This PR is only correct if these assumptions match the intended product semantics:
lastSeenAton conversation headers is teammate-specific, not visitor-specific.teamLastSeenAtshould include only human teammateuserIdreceipts.teamLastSeenAtshould not include visitor receipts.teamLastSeenAtshould not include AI agent receipts.lastSeenAtvalues while sharing the sameteamLastSeenAtaggregate.teamLastSeenAt = nullis still correct when no human teammate has seen the conversation.If any of those assumptions are wrong, this PR should be adjusted or rejected rather than merged as-is.
Scope boundaries
This PR intentionally does not:
POST /readorPOST /unreadworkIt only adds an aggregate field to the existing header shape.
Verification
bun test apps/api/src/rest/routers/conversation-auth.test.tsbun test apps/api/src/ws/router.test.tsNotes
This is designed to be a clean forward-compatible backend addition:
conversation_seenstorage model