package store import ( "database/sql" "fmt" "time" "github.com/PerpetualSoftware/pad/internal/models" ) // CreateComment adds a new comment to an item. userID is the authenticated // user authoring the comment (empty for agent/system comments); it's stored // as the canonical author identity for the comment-edit permission check — // the caller passes it explicitly rather than via the request body so it // can't be spoofed. func (s *Store) CreateComment(workspaceID, itemID, userID string, input models.CommentCreate) (*models.Comment, error) { id := newID() ts := now() createdBy := input.CreatedBy if createdBy == "" { createdBy = "user" } source := input.Source if source == "" { source = "web" } author := input.Author if author == "" { author = createdBy } _, err := s.db.Exec(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) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`), id, itemID, workspaceID, author, nilIfEmpty(userID), input.Body, createdBy, source, nilIfEmpty(input.ActivityID), nilIfEmpty(input.ParentID), ts, ts, ) if err != nil { return nil, fmt.Errorf("insert comment: %w", err) } return s.GetComment(id) } // UpdateComment replaces a comment's body and bumps updated_at. The // comments_fts_update trigger re-indexes the new body. Returns // sql.ErrNoRows when no live comment matches. Permission (author or // admin) is enforced by the handler, not here. func (s *Store) UpdateComment(id, body string) (*models.Comment, error) { ts := now() res, err := s.db.Exec(s.q(`UPDATE comments SET body = ?, updated_at = ? WHERE id = ?`), body, ts, id) if err != nil { return nil, fmt.Errorf("update comment: %w", err) } n, _ := res.RowsAffected() if n == 0 { return nil, sql.ErrNoRows } return s.GetComment(id) } // GetComment returns a single comment by ID. func (s *Store) GetComment(id string) (*models.Comment, error) { row := s.db.QueryRow(s.q(` SELECT c.id, c.item_id, c.workspace_id, c.author, COALESCE(c.user_id, ''), c.body, c.created_by, c.source, COALESCE(c.activity_id, ''), COALESCE(c.parent_id, ''), c.created_at, c.updated_at, i.title, i.slug FROM comments c JOIN items i ON i.id = c.item_id WHERE c.id = ?`), id) var c models.Comment var createdAt, updatedAt string err := row.Scan( &c.ID, &c.ItemID, &c.WorkspaceID, &c.Author, &c.UserID, &c.Body, &c.CreatedBy, &c.Source, &c.ActivityID, &c.ParentID, &createdAt, &updatedAt, &c.ItemTitle, &c.ItemSlug, ) if err == sql.ErrNoRows { return nil, nil } if err != nil { return nil, fmt.Errorf("get comment: %w", err) } c.CreatedAt = parseTime(createdAt) c.UpdatedAt = parseTime(updatedAt) return &c, nil } // ListComments returns all comments for an item, ordered chronologically. func (s *Store) ListComments(itemID string) ([]models.Comment, error) { rows, err := s.db.Query(s.q(` SELECT c.id, c.item_id, c.workspace_id, c.author, COALESCE(c.user_id, ''), c.body, c.created_by, c.source, COALESCE(c.activity_id, ''), COALESCE(c.parent_id, ''), c.created_at, c.updated_at FROM comments c WHERE c.item_id = ? ORDER BY c.created_at ASC`), itemID) if err != nil { return nil, fmt.Errorf("list comments: %w", err) } defer rows.Close() var comments []models.Comment for rows.Next() { var c models.Comment var createdAt, updatedAt string if err := rows.Scan( &c.ID, &c.ItemID, &c.WorkspaceID, &c.Author, &c.UserID, &c.Body, &c.CreatedBy, &c.Source, &c.ActivityID, &c.ParentID, &createdAt, &updatedAt, ); err != nil { return nil, fmt.Errorf("scan comment: %w", err) } c.CreatedAt = parseTime(createdAt) c.UpdatedAt = parseTime(updatedAt) comments = append(comments, c) } return comments, rows.Err() } // ListCommentsBeforeTime returns comments for an item created before the given time, // ordered newest-first, limited to `limit` results. Used for cursor-based timeline pagination. // // When beforeID is empty (first page / no cursor), the secondary id tie-breaker // is omitted. Earlier code passed a "\xff" sentinel intended to sort after any // UUID, but Postgres rejects that as an invalid UTF-8 byte sequence in a TEXT // bind parameter (SQLSTATE 22021). See BUG-1086. func (s *Store) ListCommentsBeforeTime(itemID string, before time.Time, beforeID string, limit int) ([]models.Comment, error) { ts := before.Format(time.RFC3339) const selectCols = `c.id, c.item_id, c.workspace_id, c.author, COALESCE(c.user_id, ''), c.body, c.created_by, c.source, COALESCE(c.activity_id, ''), COALESCE(c.parent_id, ''), c.created_at, c.updated_at` const orderLimit = `ORDER BY c.created_at DESC, c.id DESC LIMIT ?` var rows *sql.Rows var err error if beforeID == "" { rows, err = s.db.Query(s.q(` SELECT `+selectCols+` FROM comments c WHERE c.item_id = ? AND c.created_at < ? `+orderLimit), itemID, ts, limit) } else { rows, err = s.db.Query(s.q(` SELECT `+selectCols+` FROM comments c WHERE c.item_id = ? AND (c.created_at < ? OR (c.created_at = ? AND c.id < ?)) `+orderLimit), itemID, ts, ts, beforeID, limit) } if err != nil { return nil, fmt.Errorf("list comments before time: %w", err) } defer rows.Close() var comments []models.Comment for rows.Next() { var c models.Comment var createdAt, updatedAt string if err := rows.Scan( &c.ID, &c.ItemID, &c.WorkspaceID, &c.Author, &c.UserID, &c.Body, &c.CreatedBy, &c.Source, &c.ActivityID, &c.ParentID, &createdAt, &updatedAt, ); err != nil { return nil, fmt.Errorf("scan comment: %w", err) } c.CreatedAt = parseTime(createdAt) c.UpdatedAt = parseTime(updatedAt) comments = append(comments, c) } return comments, rows.Err() } // DeleteComment removes a comment by ID. func (s *Store) DeleteComment(id string) error { result, err := s.db.Exec(s.q("DELETE FROM comments WHERE id = ?"), id) if err != nil { return fmt.Errorf("delete comment: %w", err) } n, _ := result.RowsAffected() if n == 0 { return sql.ErrNoRows } return nil } // CountComments returns the number of comments for an item. func (s *Store) CountComments(itemID string) (int, error) { var count int err := s.db.QueryRow(s.q("SELECT COUNT(*) FROM comments WHERE item_id = ?"), itemID).Scan(&count) return count, err }