mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-08 01:16:45 +08:00
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
132 lines
4.2 KiB
Go
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
|
|
}
|