Files
multica/server/pkg/db/queries/issue_read_cursor.sql
Claude 3ad79c8548 feat(issues): per-user read cursors with unread dots on the feed
Before: the feed could not tell which conversations had moved since the
reader last looked, so every card read the same.

After: opening an issue (page or peek) records a per-user read cursor after
1.5 s, posting a comment records it at once, and feed cards whose newest
counted comment is later than the cursor carry a small dot on the left: a
plain dot when the reader spoke last, a ringed one when someone else did
and the reply is theirs to make.

How: table issue_read_cursor (no primary key in the create, a CONCURRENTLY
built unique index on (user_id, issue_id) attached as the primary key in a
later migration, no foreign keys), queries UpsertIssueReadCursor (monotonic
GREATEST on the database clock), ListIssueReadCursors and the three
cleanup deletes wired into issue deletion, member removal and workspace
teardown. PUT /api/issues/{id}/read returns 204; thread-summaries gains
last_read_at for the caller. Core adds api.markIssueRead, useMarkIssueRead
and patchThreadSummariesLastRead; the comment-created patch keeps the
cursor. Views add getFeedReadState and the dot on FeedMessageCard, the
delayed mark in IssueDetail and the immediate mark on send in
useIssueTimeline.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_017deksZDSzZBA4QSqZZoBip
2026-10-21 05:07:17 +00:00

41 lines
1.8 KiB
SQL

-- name: UpsertIssueReadCursor :exec
-- Monotonic: GREATEST keeps the newest value, so a late or replayed request
-- never moves a cursor backwards and re-marking an issue as read is a no-op.
-- A NULL last_read_at means "now" on the database clock, the same clock that
-- stamps comment.created_at, so API/DB clock skew cannot leave a just-read
-- issue looking unread.
INSERT INTO issue_read_cursor (user_id, issue_id, workspace_id, last_read_at)
VALUES (
sqlc.arg('user_id')::uuid,
sqlc.arg('issue_id')::uuid,
sqlc.arg('workspace_id')::uuid,
COALESCE(sqlc.narg('last_read_at')::timestamptz, now())
)
ON CONFLICT (user_id, issue_id) DO UPDATE
SET last_read_at = GREATEST(issue_read_cursor.last_read_at, EXCLUDED.last_read_at);
-- name: ListIssueReadCursors :many
-- The caller's cursors for a batch of issues, scoped to the workspace. Issues
-- the user never opened have no row.
SELECT issue_id, last_read_at
FROM issue_read_cursor
WHERE user_id = sqlc.arg('user_id')::uuid
AND workspace_id = sqlc.arg('workspace_id')::uuid
AND issue_id = ANY(sqlc.arg('issue_ids')::uuid[]);
-- name: DeleteIssueReadCursorsByIssue :exec
-- No foreign keys by project rule, so issue deletion cleans up here.
DELETE FROM issue_read_cursor
WHERE issue_id = sqlc.arg('issue_id')::uuid
AND workspace_id = sqlc.arg('workspace_id')::uuid;
-- name: DeleteIssueReadCursorsByUserWorkspace :exec
-- A departing member's cursors, in the same tx as the member-row delete.
DELETE FROM issue_read_cursor
WHERE user_id = sqlc.arg('user_id')::uuid
AND workspace_id = sqlc.arg('workspace_id')::uuid;
-- name: DeleteIssueReadCursorsForWorkspace :exec
-- No foreign keys by project rule, so workspace teardown cleans up here.
DELETE FROM issue_read_cursor WHERE workspace_id = sqlc.arg('workspace_id')::uuid;