Files
multica/server/pkg/db/generated/issue_read_cursor.sql.go
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

132 lines
4.2 KiB
Go

// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: issue_read_cursor.sql
package db
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const deleteIssueReadCursorsByIssue = `-- name: DeleteIssueReadCursorsByIssue :exec
DELETE FROM issue_read_cursor
WHERE issue_id = $1::uuid
AND workspace_id = $2::uuid
`
type DeleteIssueReadCursorsByIssueParams struct {
IssueID pgtype.UUID `json:"issue_id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
}
// No foreign keys by project rule, so issue deletion cleans up here.
func (q *Queries) DeleteIssueReadCursorsByIssue(ctx context.Context, arg DeleteIssueReadCursorsByIssueParams) error {
_, err := q.db.Exec(ctx, deleteIssueReadCursorsByIssue, arg.IssueID, arg.WorkspaceID)
return err
}
const deleteIssueReadCursorsByUserWorkspace = `-- name: DeleteIssueReadCursorsByUserWorkspace :exec
DELETE FROM issue_read_cursor
WHERE user_id = $1::uuid
AND workspace_id = $2::uuid
`
type DeleteIssueReadCursorsByUserWorkspaceParams struct {
UserID pgtype.UUID `json:"user_id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
}
// A departing member's cursors, in the same tx as the member-row delete.
func (q *Queries) DeleteIssueReadCursorsByUserWorkspace(ctx context.Context, arg DeleteIssueReadCursorsByUserWorkspaceParams) error {
_, err := q.db.Exec(ctx, deleteIssueReadCursorsByUserWorkspace, arg.UserID, arg.WorkspaceID)
return err
}
const deleteIssueReadCursorsForWorkspace = `-- name: DeleteIssueReadCursorsForWorkspace :exec
DELETE FROM issue_read_cursor WHERE workspace_id = $1::uuid
`
// No foreign keys by project rule, so workspace teardown cleans up here.
func (q *Queries) DeleteIssueReadCursorsForWorkspace(ctx context.Context, workspaceID pgtype.UUID) error {
_, err := q.db.Exec(ctx, deleteIssueReadCursorsForWorkspace, workspaceID)
return err
}
const listIssueReadCursors = `-- name: ListIssueReadCursors :many
SELECT issue_id, last_read_at
FROM issue_read_cursor
WHERE user_id = $1::uuid
AND workspace_id = $2::uuid
AND issue_id = ANY($3::uuid[])
`
type ListIssueReadCursorsParams struct {
UserID pgtype.UUID `json:"user_id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
IssueIds []pgtype.UUID `json:"issue_ids"`
}
type ListIssueReadCursorsRow struct {
IssueID pgtype.UUID `json:"issue_id"`
LastReadAt pgtype.Timestamptz `json:"last_read_at"`
}
// The caller's cursors for a batch of issues, scoped to the workspace. Issues
// the user never opened have no row.
func (q *Queries) ListIssueReadCursors(ctx context.Context, arg ListIssueReadCursorsParams) ([]ListIssueReadCursorsRow, error) {
rows, err := q.db.Query(ctx, listIssueReadCursors, arg.UserID, arg.WorkspaceID, arg.IssueIds)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListIssueReadCursorsRow{}
for rows.Next() {
var i ListIssueReadCursorsRow
if err := rows.Scan(&i.IssueID, &i.LastReadAt); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const upsertIssueReadCursor = `-- name: UpsertIssueReadCursor :exec
INSERT INTO issue_read_cursor (user_id, issue_id, workspace_id, last_read_at)
VALUES (
$1::uuid,
$2::uuid,
$3::uuid,
COALESCE($4::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)
`
type UpsertIssueReadCursorParams struct {
UserID pgtype.UUID `json:"user_id"`
IssueID pgtype.UUID `json:"issue_id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
LastReadAt pgtype.Timestamptz `json:"last_read_at"`
}
// 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.
func (q *Queries) UpsertIssueReadCursor(ctx context.Context, arg UpsertIssueReadCursorParams) error {
_, err := q.db.Exec(ctx, upsertIssueReadCursor,
arg.UserID,
arg.IssueID,
arg.WorkspaceID,
arg.LastReadAt,
)
return err
}