Files
multica/server/internal/handler/issue_read_cursor.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

41 lines
1.1 KiB
Go

package handler
import (
"log/slog"
"net/http"
"github.com/go-chi/chi/v5"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
// MarkIssueRead sets the caller's read cursor on an issue to the current
// database time. The cursor only ever moves forward (see UpsertIssueReadCursor),
// so a retried or out-of-order request is harmless. It carries no body and no
// payload: the cursor is private to the caller, so nothing is broadcast.
func (h *Handler) MarkIssueRead(w http.ResponseWriter, r *http.Request) {
issue, ok := h.loadIssueForUser(w, r, chi.URLParam(r, "id"))
if !ok {
return
}
userID, ok := requireUserID(w, r)
if !ok {
return
}
userUUID, ok := parseUUIDOrBadRequest(w, userID, "user_id")
if !ok {
return
}
if err := h.Queries.UpsertIssueReadCursor(r.Context(), db.UpsertIssueReadCursorParams{
UserID: userUUID,
IssueID: issue.ID,
WorkspaceID: issue.WorkspaceID,
}); err != nil {
slog.Warn("mark issue read failed", "error", err, "issue_id", uuidToString(issue.ID))
writeError(w, http.StatusInternalServerError, "failed to mark issue read")
return
}
w.WriteHeader(http.StatusNoContent)
}