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
383 lines
15 KiB
Go
383 lines
15 KiB
Go
package store
|
|
|
|
import (
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"github.com/PerpetualSoftware/pad/internal/kernelevents"
|
|
"github.com/PerpetualSoftware/pad/internal/models"
|
|
)
|
|
|
|
// App comment writes inside a FencedTx (SPEC-6 U2b, DOC-3371 §4; TASK-3391).
|
|
//
|
|
// Compared with the human comment writes:
|
|
// - the item must be a live companion item, and a comment addressed through
|
|
// an item must BE on that item, or it is answered as missing;
|
|
// - edit and delete are author-only with no admin bypass: the comment's
|
|
// user AND the install that wrote it must both be the caller's;
|
|
// - a pad-attachment: reference must name a live attachment already bound
|
|
// to the comment's own item (checkItemAttachmentRefs). Nothing is
|
|
// stamped; the human stamp (stampAttachmentRefsTx) is never reached;
|
|
// - a delete refuses a thread whose ancestor chain leaves the item (a
|
|
// legacy cross-item parent), because the tombstone reap walks that chain.
|
|
//
|
|
// LOCK ORDER. Each method takes the workspace seq lock FIRST, as the U2a item
|
|
// writes do. Every human path that moves, deletes or restores an item takes
|
|
// it first too, so the companion check cannot be invalidated before commit
|
|
// (codex round 3: an unlocked check let a concurrent move into a private
|
|
// collection land under an app edit). Then the comment rows (root first on a
|
|
// delete). No attachment row is locked.
|
|
|
|
// FencedCommentCreate is an app comment, already validated by appstore.
|
|
type FencedCommentCreate struct {
|
|
ItemID string
|
|
ParentID string // optional: a live comment on the same item
|
|
Body string
|
|
Author string // display name; the actor kind when empty, as on the human path
|
|
Actor FencedActor
|
|
}
|
|
|
|
var (
|
|
// ErrAppCommentNotFound: no live comment of that id on that item. A
|
|
// comment on another item, or a missing parent, answers the same.
|
|
ErrAppCommentNotFound = errors.New("app write: comment not found")
|
|
// ErrAppNotCommentAuthor: the comment exists on the item but this install,
|
|
// acting as this user, did not write it.
|
|
ErrAppNotCommentAuthor = errors.New("app write: not the comment's author")
|
|
// ErrAppAttachmentUnavailable: a pad-attachment: reference names no live
|
|
// attachment bound to the comment's item. Unknown, foreign and unbound
|
|
// ids answer the same, so the refusal is not an existence oracle.
|
|
ErrAppAttachmentUnavailable = errors.New("app write: attachment unavailable")
|
|
// ErrAppCrossItemThread: the comment's ancestor chain leaves its item, so
|
|
// a delete could reap a comment on another item.
|
|
ErrAppCrossItemThread = errors.New("app write: comment thread crosses items")
|
|
)
|
|
|
|
// appAttachmentRefRE finds references the way the human matcher does
|
|
// (attachmentRefRE), case-insensitively: every spelling the human path would
|
|
// stamp is checked here, and a spelling it would not is checked too.
|
|
var appAttachmentRefRE = regexp.MustCompile(`(?i)pad-attachment:([0-9A-Za-z][0-9A-Za-z_-]*)`)
|
|
|
|
// CreateComment writes an app comment and its "commented" activity, linked as
|
|
// CreateCommentWithActivity links them.
|
|
func (f *FencedTx) CreateComment(in FencedCommentCreate) (*models.Comment, error) {
|
|
if !in.Actor.valid() {
|
|
return nil, ErrAppBadActor
|
|
}
|
|
if err := f.LockWorkspaceSeq(); err != nil {
|
|
return nil, err
|
|
}
|
|
if _, err := f.requireCompanionItem(in.ItemID); err != nil {
|
|
return nil, err
|
|
}
|
|
if in.ParentID != "" {
|
|
// The human parent check, verbatim: same item, same workspace, live;
|
|
// FOR KEY SHARE on Postgres so a concurrent tombstone is seen.
|
|
parentQ := `SELECT CASE WHEN deleted_at IS NULL THEN 0 ELSE 1 END FROM comments WHERE id = ? AND workspace_id = ? AND item_id = ?`
|
|
if f.s.dialect.Driver() == DriverPostgres {
|
|
parentQ += ` FOR KEY SHARE`
|
|
}
|
|
var parentDeleted int
|
|
switch err := f.tx.QueryRow(f.s.q(parentQ), in.ParentID, f.workspaceID, in.ItemID).Scan(&parentDeleted); {
|
|
case errors.Is(err, sql.ErrNoRows):
|
|
return nil, ErrAppCommentNotFound
|
|
case err != nil:
|
|
return nil, fmt.Errorf("fenced comment: read parent: %w", err)
|
|
case parentDeleted == 1:
|
|
return nil, ErrCommentDeleted
|
|
}
|
|
}
|
|
if err := f.checkItemAttachmentRefs(in.ItemID, in.Body); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
activityID, err := f.s.createActivityQ(f.tx, models.Activity{
|
|
WorkspaceID: f.workspaceID, DocumentID: in.ItemID, Action: "commented",
|
|
Actor: in.Actor.Kind, Source: appSource, Metadata: agentMeta(in.Actor.AgentName),
|
|
UserID: in.Actor.UserID, ViaApp: f.installID,
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("fenced comment activity: %w", err)
|
|
}
|
|
author := in.Author
|
|
if author == "" {
|
|
author = in.Actor.Kind
|
|
}
|
|
id := newID()
|
|
ts := now()
|
|
if _, err := f.tx.Exec(f.s.q(`
|
|
INSERT INTO comments (id, item_id, workspace_id, author, user_id, body, created_by, source, activity_id, parent_id, created_at, updated_at, via_app)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`),
|
|
id, in.ItemID, f.workspaceID, author, in.Actor.UserID, in.Body, in.Actor.Kind, appSource,
|
|
activityID, nilIfEmpty(in.ParentID), ts, ts, f.installID,
|
|
); err != nil {
|
|
return nil, fmt.Errorf("fenced comment insert: %w", err)
|
|
}
|
|
mentioned, err := f.s.reindexCommentMentionsTx(f.tx, id, f.workspaceID, in.ItemID, in.Body, true, ts)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
created, err := f.s.getCommentQ(f.tx, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := f.s.emitCommentEventAsInstallTx(f.tx, kernelevents.CommentCreated, created, f.installID); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := f.s.enqueueDecisionJobsForItemTx(f.tx, in.ItemID); err != nil {
|
|
return nil, err
|
|
}
|
|
// TASK-3937: at the tail, after every other in-tx step.
|
|
if err := f.s.enqueueCommentNotificationsTx(f.tx, created, mentioned, in.Actor.UserID, in.Actor.Kind == "agent"); err != nil {
|
|
return nil, err
|
|
}
|
|
return created, nil
|
|
}
|
|
|
|
// lockOwnComment reads the comment the caller addresses through itemID, row
|
|
// locked on Postgres as the human edit locks it, and refuses unless it is on
|
|
// that item and was written by this install as this user. The item binding is
|
|
// checked BEFORE authorship, so a comment on another item is "not found",
|
|
// never "not yours".
|
|
func (f *FencedTx) lockOwnComment(itemID, commentID string, actor FencedActor) (body string, deleted, imported bool, err error) {
|
|
q := `SELECT body, COALESCE(user_id, ''), COALESCE(via_app, ''), CASE WHEN deleted_at IS NULL THEN 0 ELSE 1 END, imported
|
|
FROM comments WHERE id = ? AND item_id = ? AND workspace_id = ?`
|
|
if f.s.dialect.Driver() == DriverPostgres {
|
|
q += ` FOR NO KEY UPDATE`
|
|
}
|
|
var userID, viaApp string
|
|
var del, imp int
|
|
switch err := f.tx.QueryRow(f.s.q(q), commentID, itemID, f.workspaceID).Scan(&body, &userID, &viaApp, &del, &imp); {
|
|
case errors.Is(err, sql.ErrNoRows):
|
|
return "", false, false, ErrAppCommentNotFound
|
|
case err != nil:
|
|
return "", false, false, fmt.Errorf("fenced comment: read: %w", err)
|
|
}
|
|
if userID != actor.UserID || viaApp != f.installID {
|
|
return "", false, false, ErrAppNotCommentAuthor
|
|
}
|
|
return body, del == 1, imp == 1, nil
|
|
}
|
|
|
|
// UpdateComment replaces the body of a comment this install wrote as this
|
|
// user. A tombstone is refused. The event and the decision job follow only a
|
|
// changed body, as on the human path.
|
|
func (f *FencedTx) UpdateComment(itemID, commentID, body string, actor FencedActor) (*models.Comment, error) {
|
|
if !actor.valid() {
|
|
return nil, ErrAppBadActor
|
|
}
|
|
if err := f.LockWorkspaceSeq(); err != nil {
|
|
return nil, err
|
|
}
|
|
if _, err := f.requireCompanionItem(itemID); err != nil {
|
|
return nil, err
|
|
}
|
|
before, deleted, imported, err := f.lockOwnComment(itemID, commentID, actor)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if deleted {
|
|
return nil, ErrCommentDeleted
|
|
}
|
|
if err := f.checkItemAttachmentRefs(itemID, body); err != nil {
|
|
return nil, err
|
|
}
|
|
editTS := now()
|
|
if _, err := f.tx.Exec(f.s.q(`UPDATE comments SET body = ?, updated_at = ? WHERE id = ?`), body, editTS, commentID); err != nil {
|
|
return nil, fmt.Errorf("fenced comment update: %w", err)
|
|
}
|
|
// Imported comments are never indexed (TASK-3917 lead ruling bb0c9fc6).
|
|
// This skip is unreachable today: lockOwnComment requires user_id and
|
|
// via_app to match the actor, and imported rows carry neither, so an
|
|
// imported comment never gets here (TASK-3917 r2 F6, no test by design).
|
|
// A change that gives imported rows a user_id or via_app reopens this path
|
|
// and owes it a test.
|
|
// Accepted cost (TASK-3917 lead ruling, day 20): a mention a local user ADDS while editing an imported comment is not indexed either.
|
|
var mentioned []string
|
|
if !imported {
|
|
if mentioned, err = f.s.reindexCommentMentionsTx(f.tx, commentID, f.workspaceID, itemID, body, true, editTS); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
updated, err := f.s.getCommentQ(f.tx, commentID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// TASK-3946: users the edit newly mentioned.
|
|
if err := f.s.enqueueMentionNotificationsTx(f.tx, updated, mentioned, actor.UserID, actor.Kind == "agent"); err != nil {
|
|
return nil, err
|
|
}
|
|
if body != before {
|
|
if err := f.s.emitCommentEventAsInstallTx(f.tx, kernelevents.CommentUpdated, updated, f.installID); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := f.s.enqueueDecisionJobsForItemTx(f.tx, itemID); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return updated, nil
|
|
}
|
|
|
|
// DeleteComment deletes a comment this install wrote as this user, exactly as
|
|
// the human DeleteComment does: the ancestor chain locked root first, a
|
|
// tombstone when replies remain, otherwise a delete and a reap of emptied
|
|
// tombstones above it, the app projection built before either. It refuses a
|
|
// chain that leaves the item, since the reap walks it.
|
|
func (f *FencedTx) DeleteComment(itemID, commentID string, actor FencedActor) error {
|
|
if !actor.valid() {
|
|
return ErrAppBadActor
|
|
}
|
|
if err := f.LockWorkspaceSeq(); err != nil {
|
|
return err
|
|
}
|
|
if _, err := f.requireCompanionItem(itemID); err != nil {
|
|
return err
|
|
}
|
|
// An unlocked binding check first, so a comment on another item is
|
|
// refused before its chain is locked. It cannot be a row lock: the chain
|
|
// lock below must take the root first (lockCommentForDeleteTx). The
|
|
// binding is re-checked under that lock.
|
|
var onItem int
|
|
if err := f.tx.QueryRow(f.s.q(`SELECT COUNT(*) FROM comments WHERE id = ? AND item_id = ? AND workspace_id = ?`),
|
|
commentID, itemID, f.workspaceID).Scan(&onItem); err != nil {
|
|
return fmt.Errorf("fenced comment delete: read: %w", err)
|
|
}
|
|
if onItem == 0 {
|
|
return ErrAppCommentNotFound
|
|
}
|
|
target, err := f.s.lockCommentForDeleteTx(f.tx, commentID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if target == nil || target.itemID != itemID || target.workspaceID != f.workspaceID {
|
|
return ErrAppCommentNotFound
|
|
}
|
|
if _, _, _, err := f.lockOwnComment(itemID, commentID, actor); err != nil {
|
|
return err
|
|
}
|
|
if target.deleted {
|
|
return ErrAppCommentNotFound
|
|
}
|
|
if err := f.requireThreadOnItem(itemID, target.parentID); err != nil {
|
|
return err
|
|
}
|
|
|
|
var authorID sql.NullString
|
|
var author, createdBy string
|
|
if err := f.tx.QueryRow(f.s.q(`SELECT user_id, COALESCE(author, ''), created_by FROM comments WHERE id = ?`), commentID).Scan(&authorID, &author, &createdBy); err != nil {
|
|
return fmt.Errorf("fenced comment delete: read author: %w", err)
|
|
}
|
|
proj, err := f.s.buildCommentAppProjectionTx(f.tx, commentID, itemID, authorID.String, author, createdBy, target.parentID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if proj != nil {
|
|
proj.ActorViaApp = f.installID // TASK-3411
|
|
}
|
|
|
|
var replies int
|
|
if err := f.tx.QueryRow(f.s.q(`SELECT COUNT(*) FROM comments WHERE parent_id = ?`), commentID).Scan(&replies); err != nil {
|
|
return fmt.Errorf("fenced comment delete: count replies: %w", err)
|
|
}
|
|
if replies > 0 {
|
|
if _, err := f.tx.Exec(f.s.q(`DELETE FROM comment_reactions WHERE comment_id = ?`), commentID); err != nil {
|
|
return fmt.Errorf("fenced comment delete: drop reactions: %w", err)
|
|
}
|
|
if _, err := f.tx.Exec(f.s.q(`UPDATE comments SET body = '', deleted_at = ? WHERE id = ?`), now(), commentID); err != nil {
|
|
return fmt.Errorf("fenced comment delete: tombstone: %w", err)
|
|
}
|
|
if _, err := f.s.reindexCommentMentionsTx(f.tx, commentID, f.workspaceID, itemID, "", false, ""); err != nil {
|
|
return err
|
|
}
|
|
} else {
|
|
if _, err := f.tx.Exec(f.s.q(`DELETE FROM comments WHERE id = ?`), commentID); err != nil {
|
|
return fmt.Errorf("fenced comment delete: %w", err)
|
|
}
|
|
if err := f.s.reapTombstonesTx(f.tx, target.parentID); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if err := f.s.emitRefOnlyDeletionWithProjectionTx(f.tx, kernelevents.CommentDeleted, f.workspaceID, commentID, itemID, target.parentID, proj); err != nil {
|
|
return err
|
|
}
|
|
return f.s.enqueueDecisionJobsForItemTx(f.tx, itemID)
|
|
}
|
|
|
|
// requireThreadOnItem walks the ancestors from parentID up and refuses if any
|
|
// of them is on another item. lockCommentForDeleteTx has already locked the
|
|
// chain, so the walk reads what the reap will walk.
|
|
func (f *FencedTx) requireThreadOnItem(itemID, parentID string) error {
|
|
seen := map[string]bool{}
|
|
for cur := parentID; cur != "" && !seen[cur]; {
|
|
seen[cur] = true
|
|
var onItem, next string
|
|
err := f.tx.QueryRow(f.s.q(`SELECT item_id, COALESCE(parent_id, '') FROM comments WHERE id = ?`), cur).Scan(&onItem, &next)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("fenced comment delete: read ancestor: %w", err)
|
|
}
|
|
if onItem != itemID {
|
|
return ErrAppCrossItemThread
|
|
}
|
|
cur = next
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// checkItemAttachmentRefs refuses a comment body unless every
|
|
// pad-attachment: reference in it names a live attachment bound to itemID.
|
|
// Unknown, foreign, unbound and deleted ids all answer
|
|
// ErrAppAttachmentUnavailable, so the refusal is not an existence oracle.
|
|
//
|
|
// It is a CHECK and writes nothing: there is no stamp. The human path stamps
|
|
// last_referenced_at (stampAttachmentRefsTx) so that the orphan GC's
|
|
// never-attached claim cannot reap a row a body has just started to
|
|
// reference. That claim (ClaimNeverAttachedAttachment) requires
|
|
// item_id IS NULL, and no store path ever sets a bound attachment's item_id
|
|
// back to NULL (the only writers go NULL -> item). Every row this check
|
|
// admits is bound to the item, so a stamp on it would protect nothing. A
|
|
// stamp would also be the only reason to lock attachment rows here, and
|
|
// those locks deadlock against the human stamp, which locks originals and
|
|
// variants in no fixed order (codex round 4; ruling revised on DOC-3371).
|
|
//
|
|
// Unlocked: an attachment deleted just after this read leaves the committed
|
|
// body with a dangling reference, the same outcome as a delete a moment after
|
|
// commit, and the residual the human stamp already accepts.
|
|
func (f *FencedTx) checkItemAttachmentRefs(itemID, body string) error {
|
|
if !strings.Contains(strings.ToLower(body), attachmentRefPrefix) {
|
|
return nil
|
|
}
|
|
seen := map[string]bool{}
|
|
var ids []string
|
|
for _, m := range appAttachmentRefRE.FindAllStringSubmatch(body, -1) {
|
|
if !seen[m[1]] {
|
|
seen[m[1]] = true
|
|
ids = append(ids, m[1])
|
|
}
|
|
}
|
|
// Chunked like the human stamp, to stay under the bind-parameter limit.
|
|
const chunkSize = 400
|
|
for start := 0; start < len(ids); start += chunkSize {
|
|
part := ids[start:min(start+chunkSize, len(ids))]
|
|
args := []any{f.workspaceID, itemID}
|
|
for _, id := range part {
|
|
args = append(args, id)
|
|
}
|
|
var found int
|
|
if err := f.tx.QueryRow(f.s.q(`SELECT COUNT(*) FROM attachments
|
|
WHERE workspace_id = ? AND item_id = ? AND deleted_at IS NULL AND id IN (`+
|
|
strings.TrimSuffix(strings.Repeat("?,", len(part)), ",")+`)`), args...).Scan(&found); err != nil {
|
|
return fmt.Errorf("fenced attachment check: %w", err)
|
|
}
|
|
if found != len(part) {
|
|
return ErrAppAttachmentUnavailable
|
|
}
|
|
}
|
|
return nil
|
|
}
|