Persist issue read state and clear unread on open

This commit is contained in:
Dotta
2026-03-06 08:34:19 -06:00
parent 86bd26ee8a
commit 38d3d5fa59
11 changed files with 6062 additions and 44 deletions

View File

@@ -23,6 +23,7 @@ describe("deriveIssueUserContext", () => {
"user-1",
{
myLastCommentAt: new Date("2026-03-06T12:00:00.000Z"),
myLastReadAt: null,
lastExternalCommentAt: new Date("2026-03-06T13:00:00.000Z"),
},
);
@@ -38,6 +39,7 @@ describe("deriveIssueUserContext", () => {
"user-1",
{
myLastCommentAt: new Date("2026-03-06T14:00:00.000Z"),
myLastReadAt: null,
lastExternalCommentAt: new Date("2026-03-06T13:00:00.000Z"),
},
);
@@ -51,6 +53,7 @@ describe("deriveIssueUserContext", () => {
"user-1",
{
myLastCommentAt: null,
myLastReadAt: null,
lastExternalCommentAt: new Date("2026-03-06T10:00:00.000Z"),
},
);
@@ -65,6 +68,7 @@ describe("deriveIssueUserContext", () => {
"user-1",
{
myLastCommentAt: null,
myLastReadAt: null,
lastExternalCommentAt: new Date("2026-03-06T14:59:00.000Z"),
},
);
@@ -72,4 +76,19 @@ describe("deriveIssueUserContext", () => {
expect(context.myLastTouchAt?.toISOString()).toBe("2026-03-06T15:00:00.000Z");
expect(context.isUnreadForMe).toBe(false);
});
it("uses latest read timestamp to clear unread without requiring a comment", () => {
const context = deriveIssueUserContext(
makeIssue({ createdByUserId: "user-1", createdAt: new Date("2026-03-06T09:00:00.000Z") }),
"user-1",
{
myLastCommentAt: null,
myLastReadAt: new Date("2026-03-06T11:30:00.000Z"),
lastExternalCommentAt: new Date("2026-03-06T11:00:00.000Z"),
},
);
expect(context.myLastTouchAt?.toISOString()).toBe("2026-03-06T11:30:00.000Z");
expect(context.isUnreadForMe).toBe(false);
});
});

View File

@@ -303,6 +303,38 @@ export function issueRoutes(db: Db, storage: StorageService) {
res.json({ ...issue, ancestors, project: project ?? null, goal: goal ?? null, mentionedProjects });
});
router.post("/issues/:id/read", async (req, res) => {
const id = req.params.id as string;
const issue = await svc.getById(id);
if (!issue) {
res.status(404).json({ error: "Issue not found" });
return;
}
assertCompanyAccess(req, issue.companyId);
if (req.actor.type !== "board") {
res.status(403).json({ error: "Board authentication required" });
return;
}
if (!req.actor.userId) {
res.status(403).json({ error: "Board user context required" });
return;
}
const readState = await svc.markRead(issue.companyId, issue.id, req.actor.userId, new Date());
const actor = getActorInfo(req);
await logActivity(db, {
companyId: issue.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "issue.read_marked",
entityType: "issue",
entityId: issue.id,
details: { userId: req.actor.userId, lastReadAt: readState.lastReadAt },
});
res.json(readState);
});
router.get("/issues/:id/approvals", async (req, res) => {
const id = req.params.id as string;
const issue = await svc.getById(id);

View File

@@ -10,6 +10,7 @@ import {
issueAttachments,
issueLabels,
issueComments,
issueReadStates,
issues,
labels,
projectWorkspaces,
@@ -98,6 +99,13 @@ function touchedByUserCondition(companyId: string, userId: string) {
(
${issues.createdByUserId} = ${userId}
OR ${issues.assigneeUserId} = ${userId}
OR EXISTS (
SELECT 1
FROM ${issueReadStates}
WHERE ${issueReadStates.issueId} = ${issues.id}
AND ${issueReadStates.companyId} = ${companyId}
AND ${issueReadStates.userId} = ${userId}
)
OR EXISTS (
SELECT 1
FROM ${issueComments}
@@ -121,13 +129,27 @@ function myLastCommentAtExpr(companyId: string, userId: string) {
`;
}
function myLastReadAtExpr(companyId: string, userId: string) {
return sql<Date | null>`
(
SELECT MAX(${issueReadStates.lastReadAt})
FROM ${issueReadStates}
WHERE ${issueReadStates.issueId} = ${issues.id}
AND ${issueReadStates.companyId} = ${companyId}
AND ${issueReadStates.userId} = ${userId}
)
`;
}
function myLastTouchAtExpr(companyId: string, userId: string) {
const myLastCommentAt = myLastCommentAtExpr(companyId, userId);
const myLastReadAt = myLastReadAtExpr(companyId, userId);
return sql<Date | null>`
COALESCE(
${myLastCommentAt},
CASE WHEN ${issues.createdByUserId} = ${userId} THEN ${issues.createdAt} ELSE NULL END,
CASE WHEN ${issues.assigneeUserId} = ${userId} THEN ${issues.updatedAt} ELSE NULL END
GREATEST(
COALESCE(${myLastCommentAt}, to_timestamp(0)),
COALESCE(${myLastReadAt}, to_timestamp(0)),
COALESCE(CASE WHEN ${issues.createdByUserId} = ${userId} THEN ${issues.createdAt} ELSE NULL END, to_timestamp(0)),
COALESCE(CASE WHEN ${issues.assigneeUserId} = ${userId} THEN ${issues.updatedAt} ELSE NULL END, to_timestamp(0))
)
`;
}
@@ -156,13 +178,18 @@ function unreadForUserCondition(companyId: string, userId: string) {
export function deriveIssueUserContext(
issue: IssueUserContextInput,
userId: string,
stats: { myLastCommentAt: Date | null; lastExternalCommentAt: Date | null } | null | undefined,
stats:
| { myLastCommentAt: Date | null; myLastReadAt: Date | null; lastExternalCommentAt: Date | null }
| null
| undefined,
) {
const myLastCommentAt = stats?.myLastCommentAt ?? null;
const myLastTouchAt =
myLastCommentAt ??
(issue.createdByUserId === userId ? issue.createdAt : null) ??
(issue.assigneeUserId === userId ? issue.updatedAt : null);
const myLastReadAt = stats?.myLastReadAt ?? null;
const createdTouchAt = issue.createdByUserId === userId ? issue.createdAt : null;
const assignedTouchAt = issue.assigneeUserId === userId ? issue.updatedAt : null;
const myLastTouchAt = [myLastCommentAt, myLastReadAt, createdTouchAt, assignedTouchAt]
.filter((value): value is Date => value instanceof Date)
.sort((a, b) => b.getTime() - a.getTime())[0] ?? null;
const lastExternalCommentAt = stats?.lastExternalCommentAt ?? null;
const isUnreadForMe = Boolean(
myLastTouchAt &&
@@ -488,11 +515,29 @@ export function issueService(db: Db) {
),
)
.groupBy(issueComments.issueId);
const readRows = await db
.select({
issueId: issueReadStates.issueId,
myLastReadAt: issueReadStates.lastReadAt,
})
.from(issueReadStates)
.where(
and(
eq(issueReadStates.companyId, companyId),
eq(issueReadStates.userId, contextUserId),
inArray(issueReadStates.issueId, issueIds),
),
);
const statsByIssueId = new Map(statsRows.map((row) => [row.issueId, row]));
const readByIssueId = new Map(readRows.map((row) => [row.issueId, row.myLastReadAt]));
return withRuns.map((row) => ({
...row,
...deriveIssueUserContext(row, contextUserId, statsByIssueId.get(row.id)),
...deriveIssueUserContext(row, contextUserId, {
myLastCommentAt: statsByIssueId.get(row.id)?.myLastCommentAt ?? null,
myLastReadAt: readByIssueId.get(row.id) ?? null,
lastExternalCommentAt: statsByIssueId.get(row.id)?.lastExternalCommentAt ?? null,
}),
}));
},
@@ -517,6 +562,28 @@ export function issueService(db: Db) {
return Number(row?.count ?? 0);
},
markRead: async (companyId: string, issueId: string, userId: string, readAt: Date = new Date()) => {
const now = new Date();
const [row] = await db
.insert(issueReadStates)
.values({
companyId,
issueId,
userId,
lastReadAt: readAt,
updatedAt: now,
})
.onConflictDoUpdate({
target: [issueReadStates.companyId, issueReadStates.issueId, issueReadStates.userId],
set: {
lastReadAt: readAt,
updatedAt: now,
},
})
.returning();
return row;
},
getById: async (id: string) => {
const row = await db
.select()