Files
pad/internal/server/handlers_comments.go
T
xarmian 423d312b77 feat(store): @mentions enqueue a push notification (TASK-3946) (#2272)
* feat(store): @mentions enqueue a push notification (TASK-3946)

A comment create or edit enqueues a 'mention' notification for each user
the comment_mentions index newly records, keyed mention:<comment>:<user>.
Never on a tombstone, import, cross-workspace copy or the backfill: the
reindex returns the new users and only the create and edit sites enqueue.

The author is never told. A mentioned user who cannot see the item
(non-member, no-grant guest, restricted member outside the collection)
gets nothing (binding rule 645fab20). On create a mention supersedes the
user's comment/reply row; on edit a written mention deletes the user's
still-pending comment/reply row for the comment, and a row already sent,
skipped or expired stays (ruling f0fc5933).

Claude-Session: https://claude.ai/code/session_019KLyQQPbv9eCe1d6PT2wHP

* fix(server): an edit's mentions are from the editor (TASK-3946 r1)

The edit handler passes the request's actor to a new UpdateCommentAs, so a
mention a platform admin adds to another user's comment is from the admin,
the admin's own @-mention is skipped, and the author is told when mentioned
(ruling e6a86278). UpdateComment(id, body) stays for tests and attributes
to the author.

Claude-Session: https://claude.ai/code/session_019KLyQQPbv9eCe1d6PT2wHP

* test(appstore,store): review the mention helpers into the fence door (TASK-3946)

The fenced comment create and edit now reach the mention enqueue and its
visibility read. Each newly reachable helper joins
allowedStoreMethodsInsideDoor with what it writes, confirmed by reading
it: only enqueueNotificationsCountTx (the existing INSERT) and
enqueueMentionNotificationsTx (the pending comment/reply supersede DELETE)
write, on the fence's tx. The mention site guard follows the
UpdateComment -> updateComment rename.

Claude-Session: https://claude.ai/code/session_019KLyQQPbv9eCe1d6PT2wHP

* docs(store): qualify the reply-wins note; GetUserQ decrypts (TASK-3946 r4)

The one-"reply" rule on enqueueCommentNotificationsTx yields to a mention,
and the fence door's GetUserQ entry names the in-memory TOTP decrypt
(ruling 864f5506).

Claude-Session: https://claude.ai/code/session_019KLyQQPbv9eCe1d6PT2wHP
2026-10-21 10:12:15 -04:00

645 lines
21 KiB
Go

package server
import (
"database/sql"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
"github.com/PerpetualSoftware/pad/internal/events"
"github.com/PerpetualSoftware/pad/internal/models"
"github.com/PerpetualSoftware/pad/internal/store"
"github.com/PerpetualSoftware/pad/internal/watchevents"
)
// handleListComments returns all comments for an item.
func (s *Server) handleListComments(w http.ResponseWriter, r *http.Request) {
workspaceID, ok := s.getWorkspaceID(w, r)
if !ok {
return
}
itemSlug := chi.URLParam(r, "itemSlug")
item, err := s.store.ResolveItemIncludeDeleted(workspaceID, itemSlug)
if err != nil {
writeInternalError(w, err)
return
}
if item == nil {
writeError(w, http.StatusNotFound, "not_found", "Item not found")
return
}
if !s.requireItemVisible(w, r, workspaceID, item) {
return
}
comments, err := s.store.ListComments(item.ID)
if err != nil {
writeInternalError(w, err)
return
}
if comments == nil {
comments = []models.Comment{}
}
s.hydrateCommentsAppAttribution(comments) // SPEC-6 U9c
// Bulk-load reactions for all comments.
if len(comments) > 0 {
commentIDs := make([]string, len(comments))
for i, c := range comments {
commentIDs[i] = c.ID
}
reactionsMap, err := s.store.ListReactionsByComments(commentIDs)
if err == nil && reactionsMap != nil {
for i := range comments {
if reactions, ok := reactionsMap[comments[i].ID]; ok {
comments[i].Reactions = reactions
}
}
}
}
writeJSON(w, http.StatusOK, comments)
}
// handleCreateComment adds a new comment to an item.
func (s *Server) handleCreateComment(w http.ResponseWriter, r *http.Request) {
workspaceID, ok := s.getWorkspaceID(w, r)
if !ok {
return
}
itemSlug := chi.URLParam(r, "itemSlug")
item, err := s.store.ResolveItem(workspaceID, itemSlug)
if err != nil {
writeInternalError(w, err)
return
}
if item == nil {
s.writeItemResolveError(w, r, workspaceID, itemSlug)
return
}
if !s.requireItemVisible(w, r, workspaceID, item) {
return
}
// Check edit permission (grant-aware for guests)
if !s.requireEditPermission(w, r, workspaceID, item.ID, item.CollectionID) {
return
}
var input models.CommentCreate
if err := decodeJSON(r, &input); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
if input.Body == "" {
writeError(w, http.StatusBadRequest, "bad_request", "body is required")
return
}
// TASK-3585: an idempotency key, claimed before the write. A replay
// answers with the comment the first request made, as it stands now.
key, kerr := idempotencyKey(r, input.OpKey)
if kerr != nil {
writeError(w, http.StatusBadRequest, "bad_request", kerr.Error())
return
}
canon := input
canon.OpKey = ""
hold, ok := s.claimKey(w, r, workspaceID, idempotencyRouteComment, item.ID, canon, key, func(status int, stored string) {
var a commentedAnswer
if json.Unmarshal([]byte(stored), &a) != nil {
writeInternalError(w, fmt.Errorf("stored idempotent answer does not decode"))
return
}
c, gerr := s.store.GetComment(a.CommentID)
if gerr != nil || c == nil || c.Deleted {
writeError2(w, http.StatusGone, "idempotency_target_gone", "the comment this key created no longer exists", map[string]interface{}{"comment_id": a.CommentID})
return
}
writeJSON(w, status, c)
})
if !ok {
return
}
defer s.release(hold)
if hold != nil {
input.AfterWriteTx = func(tx *sql.Tx, created *models.Comment) error {
return s.completeTx(hold, http.StatusCreated, commentedAnswer{CommentID: created.ID})(tx)
}
}
stampCommentAttribution(r, &input)
actor, source := input.CreatedBy, input.Source
// The "commented" activity and the comment that links to it commit in ONE
// transaction (BUG-2716). The order inside is still forced — the comment
// carries the activity's id, so the activity row is written first — but
// a failed comment now takes the activity with it instead of leaving an
// orphan "commented" entry on the timeline. The activity is the carrier
// of the writing agent's name and the timeline's dedupe key (one entry
// for the comment, not two), which is why it is written at all.
comment, err := s.store.CreateCommentWithActivity(workspaceID, item.ID, currentUserID(r),
s.activityForRequest(workspaceID, item.ID, "commented", r, ""), input)
if err != nil {
// A parent_id in the body reaches the same parent check a reply does.
if errors.Is(err, store.ErrCommentDeleted) {
writeCommentDeleted(w, input.ParentID)
return
}
if errors.Is(err, sql.ErrNoRows) {
writeError(w, http.StatusNotFound, "not_found", "Parent comment not found")
return
}
writeInternalError(w, err)
return
}
// A committed comment is a user write (PLAN-1542 / TASK-1543). Done after
// the commit rather than inside the activity helper, so a failed comment
// is not counted as one.
s.touchUserWrite(r, writeKindComment)
// Publish SSE event
s.publishCommentEvent(sseCommentCreated, workspaceID, item.ID, comment.ID, item.Title, item.CollectionSlug, actor, source)
s.publishWatchNotification(watchevents.Notification{
WorkspaceID: workspaceID,
ItemID: item.ID,
CollectionID: item.CollectionID,
ItemRef: item.Ref,
Kind: watchevents.KindComment,
Actor: actor,
ActorName: actorNameFromRequest(r),
Summary: truncateForSummary(comment.Body, 120),
})
writeJSON(w, http.StatusCreated, comment)
}
// truncateForSummary shortens a comment body (or any free-text field) to
// a single-line notification summary. Collapses newlines to spaces first
// so a multi-paragraph comment doesn't produce a multi-line CLI nudge
// line — `pad watch --stream --for-session`'s contract is exactly one
// stdout line per event.
func truncateForSummary(body string, maxLen int) string {
body = strings.Join(strings.Fields(body), " ")
r := []rune(body)
if len(r) <= maxLen {
return body
}
return string(r[:maxLen]) + "…"
}
// handleDeleteComment removes a comment.
func (s *Server) handleDeleteComment(w http.ResponseWriter, r *http.Request) {
workspaceID, ok := s.getWorkspaceID(w, r)
if !ok {
return
}
commentID := chi.URLParam(r, "commentID")
// Verify the comment belongs to this workspace.
comment, cerr := s.store.GetComment(commentID)
if cerr != nil || comment == nil || comment.WorkspaceID != workspaceID {
writeError(w, http.StatusNotFound, "not_found", "Comment not found")
return
}
if !s.requireCommentVisible(w, r, workspaceID, comment) {
return
}
// Check edit permission on the comment's item (grant-aware for guests)
if commentItem, ierr := s.store.GetItem(comment.ItemID); ierr == nil && commentItem != nil {
if !s.requireEditPermission(w, r, workspaceID, commentItem.ID, commentItem.CollectionID) {
return
}
} else if !requireMinRole(w, r, "editor") {
return
}
// A comment with replies is left as a tombstone rather than removed
// (BUG-3252); the answer is the same 204 either way. Deleting a
// tombstone answers 404, as it is already deleted.
if err := s.store.DeleteComment(commentID); err != nil {
if err == sql.ErrNoRows {
writeError(w, http.StatusNotFound, "not_found", "Comment not found")
return
}
writeInternalError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// writeCommentDeleted refuses a write addressed to a tombstone (BUG-3252):
// an edit, a reply or a reaction. 409 rather than 404, because the comment is
// there and still holds its replies; there is simply nothing left to change.
func writeCommentDeleted(w http.ResponseWriter, commentID string) {
writeJSON(w, http.StatusConflict, map[string]any{
"error": map[string]any{
"code": "comment_deleted",
"message": "this comment was deleted; it cannot be edited, replied to or reacted to",
"details": map[string]any{"comment_id": commentID},
},
})
}
// handleUpdateComment edits a comment's body. Editing is an authorship
// operation — distinct from delete, which any item editor may do — so only
// the comment author (matching user_id) or a platform admin may edit.
// Comments with no recorded user_id (created before TASK-1663, or
// agent/system comments) are admin-only. (PLAN-1662.)
func (s *Server) handleUpdateComment(w http.ResponseWriter, r *http.Request) {
workspaceID, ok := s.getWorkspaceID(w, r)
if !ok {
return
}
commentID := chi.URLParam(r, "commentID")
comment, cerr := s.store.GetComment(commentID)
if cerr != nil || comment == nil || comment.WorkspaceID != workspaceID {
writeError(w, http.StatusNotFound, "not_found", "Comment not found")
return
}
if !s.requireCommentVisible(w, r, workspaceID, comment) {
return
}
if !s.canEditComment(r, comment) {
writeError(w, http.StatusForbidden, "forbidden", "Only the comment author or an admin can edit this comment")
return
}
var input struct {
Body string `json:"body"`
}
if err := decodeJSON(r, &input); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
body := strings.TrimSpace(input.Body)
if body == "" {
writeError(w, http.StatusBadRequest, "bad_request", "body is required (use delete to remove a comment)")
return
}
// The editor, not the author, is who a mention the edit adds is from
// (TASK-3946): an admin may edit another user's comment.
editActor, _ := actorFromRequest(r)
updated, err := s.store.UpdateCommentAs(commentID, body, store.CommentEditor{
UserID: currentUserID(r), IsAgent: editActor == "agent",
})
if err != nil {
if errors.Is(err, store.ErrCommentDeleted) {
writeCommentDeleted(w, commentID)
return
}
if err == sql.ErrNoRows {
writeError(w, http.StatusNotFound, "not_found", "Comment not found")
return
}
writeInternalError(w, err)
return
}
actor, source := actorFromRequest(r)
var title, collSlug string
if item, ierr := s.store.GetItemIncludeDeleted(updated.ItemID); ierr == nil && item != nil {
title = item.Title
collSlug = item.CollectionSlug
}
s.publishCommentEvent(sseCommentUpdated, workspaceID, updated.ItemID, updated.ID, title, collSlug, actor, source)
one := []models.Comment{*updated}
s.hydrateCommentsAppAttribution(one) // SPEC-6 U9c
updated.ViaApp, updated.ViaAppName = one[0].ViaApp, one[0].ViaAppName
writeJSON(w, http.StatusOK, updated)
}
// itemScopedComment adapts a /comments/{commentID} handler to the item-scoped
// route /items/{itemSlug}/comments/{commentID} (TASK-2695). It resolves the
// item and answers 404 unless the comment is on it, then runs next unchanged,
// so the ACL, validation and events stay the workspace-scoped handler's own.
//
// The item-scoped form exists for the agent doors (CLI, both MCP transports),
// which name a comment by item ref plus id: checking the pairing here, rather
// than in each client, is what makes the three doors land on one check. The
// remote MCP dispatcher maps an action to a single HTTP call, so a
// client-side check could not reach it.
//
// A comment that exists on another item answers the same 404 as one that
// does not exist, so the route says nothing about comments elsewhere.
func (s *Server) itemScopedComment(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
workspaceID, ok := s.getWorkspaceID(w, r)
if !ok {
return
}
item, err := s.store.ResolveItemIncludeDeleted(workspaceID, chi.URLParam(r, "itemSlug"))
if err != nil {
writeInternalError(w, err)
return
}
if item == nil {
writeError(w, http.StatusNotFound, "not_found", "Item not found")
return
}
if !s.requireItemVisible(w, r, workspaceID, item) {
return
}
comment, cerr := s.store.GetComment(chi.URLParam(r, "commentID"))
if cerr != nil || comment == nil || comment.WorkspaceID != workspaceID || comment.ItemID != item.ID {
writeError(w, http.StatusNotFound, "not_found", "Comment not found")
return
}
next(w, r)
}
}
// canEditComment reports whether the requester may edit the given comment:
// the authenticated author (matching user_id) or a platform admin. A comment
// with an empty user_id has no provable author, so only admins can edit it.
//
// The admin bypass is cookie-session only (BUG-1616/BUG-1919): a
// bearer-borne admin (PAT/CLI/MCP) falls through to the author check like
// any other member, so a bearer admin can no longer edit another user's
// comment, including empty-user_id ones. Mirrors the gate in
// handlers_collab.go's authorizeCollabAccess.
func (s *Server) canEditComment(r *http.Request, comment *models.Comment) bool {
u := currentUser(r)
if u == nil {
return false
}
if u.Role == "admin" && !isBearerAuth(r) {
return true
}
return comment.UserID != "" && comment.UserID == u.ID
}
// handleCreateReply creates a reply to an existing comment.
func (s *Server) handleCreateReply(w http.ResponseWriter, r *http.Request) {
workspaceID, ok := s.getWorkspaceID(w, r)
if !ok {
return
}
commentID := chi.URLParam(r, "commentID")
parentComment, err := s.store.GetComment(commentID)
if err != nil || parentComment == nil {
writeError(w, http.StatusNotFound, "not_found", "Parent comment not found")
return
}
if parentComment.WorkspaceID != workspaceID {
writeError(w, http.StatusNotFound, "not_found", "Parent comment not found")
return
}
if !s.requireCommentVisible(w, r, workspaceID, parentComment) {
return
}
// Check edit permission on the parent comment's item (grant-aware for guests)
if commentItem, ierr := s.store.GetItem(parentComment.ItemID); ierr == nil && commentItem != nil {
if !s.requireEditPermission(w, r, workspaceID, commentItem.ID, commentItem.CollectionID) {
return
}
} else if !requireMinRole(w, r, "editor") {
return
}
var input models.CommentCreate
if err := decodeJSON(r, &input); err != nil {
writeError(w, http.StatusBadRequest, "invalid_json", err.Error())
return
}
if strings.TrimSpace(input.Body) == "" {
writeError(w, http.StatusBadRequest, "validation_error", "body is required")
return
}
stampCommentAttribution(r, &input)
actor, source := input.CreatedBy, input.Source
input.ParentID = commentID
// The `commented` activity and the reply commit in ONE transaction,
// exactly as handleCreateComment (BUG-2716). A reply used to be logged
// without an activity row, which was harmless while the row was only a
// feed entry — but the activity is the ONLY carrier of the writing
// agent's name (TASK-2760), so a reply without one rendered under a
// generic "Agent" chip. The timeline suppresses every activity a fetched
// comment links to, replies included, so this adds no duplicate entry.
comment, err := s.store.CreateCommentWithActivity(workspaceID, parentComment.ItemID, currentUserID(r),
s.activityForRequest(workspaceID, parentComment.ItemID, "commented", r, ""), input)
if err != nil {
if errors.Is(err, store.ErrCommentDeleted) {
writeCommentDeleted(w, commentID)
return
}
if errors.Is(err, sql.ErrNoRows) {
// The parent was deleted between the read above and the insert.
writeError(w, http.StatusNotFound, "not_found", "Parent comment not found")
return
}
writeInternalError(w, err)
return
}
s.touchUserWrite(r, writeKindComment)
// Resolve the item's collection slug for SSE filtering
replyCollSlug := ""
replyItemRef := ""
replyCollID := ""
if replyItem, err := s.store.GetItemIncludeDeleted(parentComment.ItemID); err == nil && replyItem != nil {
replyCollSlug = replyItem.CollectionSlug
replyItemRef = replyItem.Ref
replyCollID = replyItem.CollectionID
}
s.publishCommentEvent(sseCommentCreated, workspaceID, parentComment.ItemID, comment.ID, parentComment.ItemTitle, replyCollSlug, actor, source)
// TASK-2533 (codex round 1, finding 2): a reply is a SEPARATE code
// path from handleCreateComment — it calls store.CreateComment
// directly here, not via POST .../comments — and was missing this
// hook entirely. Same kind=comment notification as a top-level
// comment; a watcher shouldn't lose replies just because they landed
// one level deeper in the thread.
s.publishWatchNotification(watchevents.Notification{
WorkspaceID: workspaceID,
ItemID: parentComment.ItemID,
CollectionID: replyCollID,
ItemRef: replyItemRef,
Kind: watchevents.KindComment,
Actor: actor,
ActorName: actorNameFromRequest(r),
Summary: truncateForSummary(comment.Body, 120),
})
writeJSON(w, http.StatusCreated, comment)
}
// handleAddReaction adds an emoji reaction to a comment.
func (s *Server) handleAddReaction(w http.ResponseWriter, r *http.Request) {
workspaceID, ok := s.getWorkspaceID(w, r)
if !ok {
return
}
commentID := chi.URLParam(r, "commentID")
// Verify the comment belongs to this workspace.
comment, err := s.store.GetComment(commentID)
if err != nil || comment == nil || comment.WorkspaceID != workspaceID {
writeError(w, http.StatusNotFound, "not_found", "Comment not found")
return
}
if !s.requireCommentVisible(w, r, workspaceID, comment) {
return
}
// Check edit permission on the comment's item (grant-aware for guests)
if commentItem, ierr := s.store.GetItem(comment.ItemID); ierr == nil && commentItem != nil {
if !s.requireEditPermission(w, r, workspaceID, commentItem.ID, commentItem.CollectionID) {
return
}
} else if !requireMinRole(w, r, "editor") {
return
}
var input struct {
Emoji string `json:"emoji"`
}
if err := decodeJSON(r, &input); err != nil {
writeError(w, http.StatusBadRequest, "invalid_json", err.Error())
return
}
if strings.TrimSpace(input.Emoji) == "" {
writeError(w, http.StatusBadRequest, "validation_error", "emoji is required")
return
}
actor, _ := actorFromRequest(r)
userID := currentUserID(r)
reaction, err := s.store.AddReaction(commentID, userID, actor, input.Emoji)
if err != nil {
if errors.Is(err, store.ErrCommentDeleted) {
writeCommentDeleted(w, commentID)
return
}
writeInternalError(w, err)
return
}
// Fire SSE event for the reaction.
if parentComment, cerr := s.store.GetComment(commentID); cerr == nil {
s.publishReactionEvent(events.ReactionAdded, parentComment)
}
writeJSON(w, http.StatusCreated, reaction)
}
// handleRemoveReaction removes an emoji reaction from a comment.
func (s *Server) handleRemoveReaction(w http.ResponseWriter, r *http.Request) {
workspaceID, ok := s.getWorkspaceID(w, r)
if !ok {
return
}
commentID := chi.URLParam(r, "commentID")
emoji := chi.URLParam(r, "emoji")
// Verify the comment belongs to this workspace.
commentObj, cerr := s.store.GetComment(commentID)
if cerr != nil || commentObj == nil || commentObj.WorkspaceID != workspaceID {
writeError(w, http.StatusNotFound, "not_found", "Comment not found")
return
}
if !s.requireCommentVisible(w, r, workspaceID, commentObj) {
return
}
// Check edit permission on the comment's item (grant-aware for guests)
if commentItem, ierr := s.store.GetItem(commentObj.ItemID); ierr == nil && commentItem != nil {
if !s.requireEditPermission(w, r, workspaceID, commentItem.ID, commentItem.CollectionID) {
return
}
} else if !requireMinRole(w, r, "editor") {
return
}
if commentObj.Deleted {
// Its reactions went with the delete (BUG-3252); say why, rather
// than "Reaction not found".
writeCommentDeleted(w, commentID)
return
}
userID := currentUserID(r)
if err := s.store.RemoveReaction(commentID, userID, emoji); err != nil {
// A delete that tombstoned the comment after the read above took
// its reactions with it; re-read so that answers the same 409.
if again, gerr := s.store.GetComment(commentID); gerr == nil && again != nil && again.Deleted {
writeCommentDeleted(w, commentID)
return
}
writeError(w, http.StatusNotFound, "not_found", "Reaction not found")
return
}
// Fire SSE event for the reaction removal.
if parentComment, cerr := s.store.GetComment(commentID); cerr == nil {
s.publishReactionEvent(events.ReactionRemoved, parentComment)
}
w.WriteHeader(http.StatusNoContent)
}
// requireCommentVisible checks that a comment's underlying item is in a visible
// collection. Writes a 404 and returns false if not.
func (s *Server) requireCommentVisible(w http.ResponseWriter, r *http.Request, workspaceID string, comment *models.Comment) bool {
item, err := s.store.GetItem(comment.ItemID)
if err != nil || item == nil {
writeError(w, http.StatusNotFound, "not_found", "Comment not found")
return false
}
return s.requireItemVisible(w, r, workspaceID, item)
}
// publishCommentEvent publishes a real-time event for comment changes.
func (s *Server) publishCommentEvent(eventType, workspaceID, itemID, commentID, title, collection, actor, source string) {
if s.events == nil {
return
}
s.publishActivityEvent(events.Event{
Type: eventType,
WorkspaceID: workspaceID,
ItemID: itemID,
Collection: collection,
Title: title,
Actor: actor,
Source: source,
})
}
// publishReactionEvent publishes a real-time event for reaction changes.
func (s *Server) publishReactionEvent(eventType string, comment *models.Comment) {
if s.events == nil || comment == nil {
return
}
// Resolve the item's collection slug so SSE filtering can scope this
// event. Include archived items: this runs after the write, and a
// concurrent archive must not blank the collection (BUG-3343).
collSlug := ""
if item, err := s.store.GetItemIncludeDeleted(comment.ItemID); err == nil && item != nil {
collSlug = item.CollectionSlug
}
s.publishActivityEvent(events.Event{
Type: eventType,
WorkspaceID: comment.WorkspaceID,
ItemID: comment.ItemID,
Collection: collSlug,
})
}