mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-26 22:37:03 +00:00
423d312b77
* 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
645 lines
21 KiB
Go
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,
|
|
})
|
|
}
|