Skip to content

Add team-level lastSeenAt to conversation headers#158

Open
seemayr wants to merge 1 commit intocossistantcom:mainfrom
seemayr:feat/team-last-seen-header
Open

Add team-level lastSeenAt to conversation headers#158
seemayr wants to merge 1 commit intocossistantcom:mainfrom
seemayr:feat/team-last-seen-header

Conversation

@seemayr
Copy link
Copy Markdown
Contributor

@seemayr seemayr commented Apr 11, 2026

Summary

  • add teamLastSeenAt to shared conversation header / inbox response types
  • compute it from the most recent conversation_seen.lastSeenAt across all human teammate userId rows
  • expose it through the existing header query layer used by REST inbox, tRPC dashboard headers, and header-based realtime payloads
  • add REST auth coverage for the new field and update the websocket header fixture

Why

Today the backend exposes:

  • lastSeenAt: current teammate-specific read state
  • visitor.lastSeenAt / visitorLastSeenAt: visitor read/presence state
  • seenData: full per-actor receipt rows

That 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:

  • fetching /seen for many rows, or
  • inferring from message authorship

This 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

teamLastSeenAt is defined as:

  • the max lastSeenAt across conversation_seen rows where userId IS NOT NULL
  • ignoring visitor and AI seen rows

The existing lastSeenAt behavior stays unchanged:

  • it remains scoped to the current dashboard teammate when a userId is available
  • it remains null when there is no acting teammate context

Assumptions

This PR is only correct if these assumptions match the intended product semantics:

  1. lastSeenAt on conversation headers is teammate-specific, not visitor-specific.
  2. A separate team-level aggregate is useful and should be exposed explicitly rather than inferred client-side.
  3. teamLastSeenAt should include only human teammate userId receipts.
  4. teamLastSeenAt should not include visitor receipts.
  5. teamLastSeenAt should not include AI agent receipts.
  6. Multiple teammates can have different personal lastSeenAt values while sharing the same teamLastSeenAt aggregate.
  7. This is intended as an additive signal for dashboard clients and should not change existing per-user unread semantics.
  8. Returning teamLastSeenAt = null is 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:

  • change how POST /read or POST /unread work
  • change visitor/widget seen semantics
  • change unread logic in the web dashboard
  • add any new endpoints

It only adds an aggregate field to the existing header shape.

Verification

  • bun test apps/api/src/rest/routers/conversation-auth.test.ts
  • bun test apps/api/src/ws/router.test.ts

Notes

This is designed to be a clean forward-compatible backend addition:

  • existing clients can ignore the field
  • new clients can adopt it without extra API load
  • it stays aligned with the current per-actor conversation_seen storage model

@vercel
Copy link
Copy Markdown

vercel bot commented Apr 11, 2026

@seemayr is attempting to deploy a commit to the cossistant Team on Vercel.

A member of the Team first needs to authorize it.

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Apr 11, 2026

Greptile Summary

This PR adds teamLastSeenAt — the maximum lastSeenAt across all human-teammate conversation_seen rows — to both the REST inbox and tRPC conversation header shapes. The field is computed correctly in listConversationsHeaders (running-max map) and getConversationHeader (reduce), correctly filtering out visitor and AI-agent rows by checking seen.userId. Type schemas, fixture updates, and a new REST auth test are all consistent.

The only notes are minor: both the loop in listConversationsHeaders and the reduce in getConversationHeader allocate new Date() objects for string timestamps that are already pre-sorted descending by the DB, making the comparisons redundant. ISO 8601 strings with a consistent timezone offset can be compared directly as strings, which avoids the allocations.

Confidence Score: 5/5

Safe 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

Filename Overview
apps/api/src/db/queries/conversation.ts Adds teamLastSeenAt computation in both listConversationsHeaders (map-based running max) and getConversationHeader (reduce); correctly filters to userId-only rows, excluding visitor and AI agent rows.
packages/types/src/api/conversation.ts Adds teamLastSeenAt to conversationInboxItemSchema with correct nullable timestamp type and clear OpenAPI description.
packages/types/src/trpc/conversation.ts Adds teamLastSeenAt: z.string().nullable() to conversationHeaderSchema, consistent with lastSeenAt and other timestamp fields.
apps/api/src/rest/routers/conversation-auth.test.ts Adds teamLastSeenAt to the createInboxItem fixture and a new test verifying the field is correctly surfaced in private inbox REST responses.
apps/api/src/ws/router.test.ts Adds teamLastSeenAt: null to the conversationCreated handler fixture to keep it in sync with the updated ConversationHeader shape.

Sequence Diagram

sequenceDiagram
    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 }
Loading

Reviews (1): Last reviewed commit: "Add team-level last seen timestamp to co..." | Re-trigger Greptile

Comment on lines +620 to +626
} else {
const currentDate = new Date(currentTeamLastSeen);
const candidateDate = new Date(seen.lastSeenAt);
if (candidateDate > currentDate) {
teamLastSeenMap.set(seen.conversationId, seen.lastSeenAt);
}
}
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.

Comment on lines +826 to +836
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);
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);

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant