Skip to content
Open
83 changes: 83 additions & 0 deletions app/models/comment.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# frozen_string_literal: true

# Mirrors Commontator::Comment. Belongs to a thread rather than directly to the
# commentable, so that thread level state (open/closed, subscriptions) has a
# home and the existing API shape is preserved.
class Comment < ApplicationRecord
BODY_MAX_LENGTH = 10_000

belongs_to :comment_thread, inverse_of: :comments
belongs_to :user
belongs_to :editor, class_name: "User", optional: true
belongs_to :parent, class_name: "Comment", optional: true

has_many :replies, class_name: "Comment",
foreign_key: :parent_id,
inverse_of: :parent,
dependent: :destroy

validates :body, presence: true, length: { maximum: BODY_MAX_LENGTH }
validate :parent_shares_thread
validate :parent_is_not_cyclic

scope :kept, -> { where(deleted_at: nil) }
scope :roots, -> { where(parent_id: nil) }
scope :chronological, -> { order(created_at: :asc) }

def deleted?
deleted_at.present?
end

def edited?
editor_id.present?
end

def soft_delete!
update!(deleted_at: Time.current)
end

def restore!
update!(deleted_at: nil)
end

private

# A reply must live in the same thread as its parent, otherwise a comment
# could be threaded onto a discussion it does not belong to.
def parent_shares_thread
return if parent.nil?
return if parent.comment_thread_id == comment_thread_id

errors.add(:parent, "must belong to the same thread")
end

# Walk up the ancestor chain so a comment cannot be its own parent, nor
# part of a reply cycle. Without this, `replies` recurses forever during
# dependent: :destroy.
#
# NOTE: application-level guard only. It does not run for update_column,
# insert_all, or raw SQL, and does not protect against concurrent writes.
# The invariant must be re-checked wherever rows are written outside the
# model, in particular the Commontator data migration.
def parent_is_not_cyclic
return if parent.nil?

if parent == self
errors.add(:parent, "cannot be the comment itself")
return
end

# A new record has no id, so nothing can point back at it yet.
return if id.nil?

ancestor = parent
while ancestor
if ancestor.parent_id == id
errors.add(:parent, "cannot create a cycle")
return
end

ancestor = ancestor.parent
end
end
end
22 changes: 22 additions & 0 deletions app/models/comment_thread.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# frozen_string_literal: true

# Mirrors Commontator::Thread. One thread per commentable, holding the comments
# and the open/closed state.
class CommentThread < ApplicationRecord
belongs_to :commentable, polymorphic: true
belongs_to :closer, class_name: "User", optional: true

has_many :comments, dependent: :destroy

def closed?
closed_at.present?
end

def close!(user)
update!(closed_at: Time.current, closer: user)
end

def reopen!
update!(closed_at: nil, closer: nil)
end
end
1 change: 1 addition & 0 deletions app/models/project.rb
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ class Project < ApplicationRecord
has_many :notifications, as: :notifiable
has_one :contest_winner, dependent: :destroy
has_many :submissions, dependent: :destroy
has_one :comment_thread, as: :commentable, dependent: :destroy

scope :public_and_not_forked,
-> { where(project_access_type: "Public", forked_project_id: nil) }
Expand Down
85 changes: 85 additions & 0 deletions app/policies/comment_policy.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# frozen_string_literal: true

# Mirrors the comment permissions in commontator-7.0.1, as configured in
# config/initializers/commontator.rb:
#
# comment_editing = :a -> authors may always edit their own comments
# comment_deletion = :a -> authors may always delete their own comments
# moderator_permissions = :d -> moderators may delete and close, not edit
# comment_voting = :ld -> likes and dislikes are both enabled
#
# Intentionally does not call super, for the same reason as
# CommentThreadPolicy: comments on public projects are readable anonymously.
class CommentPolicy < ApplicationPolicy
attr_reader :user, :comment

delegate :show?, to: :thread_policy

def initialize(user, comment)
@user = user
@comment = comment
end

def create?
thread_policy.create_comment?
end

# Commontator#can_be_edited_by?: moderators are excluded here because
# moderator_permissions is :d rather than :e.
def update?
author? &&
!comment.deleted? &&
!thread_closed? &&
(comment.editor_id.nil? || comment.editor_id == user.id) &&
show?
end

# Commontator#can_be_deleted_by?: the moderator branch returns before the
# closed-thread check, so moderators may still delete in a closed thread.
# An author may undelete only a comment they deleted themselves.
def destroy?
return true if thread_policy.moderator?

author? &&
!thread_closed? &&
(!comment.deleted? || comment.editor_id == user.id) &&
show?
end

# Commontator routes undelete through can_be_deleted_by?, so moderators may
# restore any comment, while an author may only restore one they deleted
# themselves.
def restore?
return true if thread_policy.moderator?
Comment thread
coderabbitai[bot] marked this conversation as resolved.

author? &&
comment.deleted? &&
comment.editor_id == user.id &&
!thread_closed? &&
show?
end

# Commontator#can_be_voted_on_by?: a user may not vote on their own comment,
# and voting closes along with the thread.
def vote?
user.present? &&
!author? &&
!comment.deleted? &&
!thread_closed? &&
show?
end

private

def author?
user.present? && comment.user_id == user.id
end

def thread_closed?
comment.comment_thread&.closed? || false
end

def thread_policy
@thread_policy ||= CommentThreadPolicy.new(user, comment.comment_thread)
end
end
58 changes: 58 additions & 0 deletions app/policies/comment_thread_policy.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# frozen_string_literal: true

# Mirrors the thread permissions Commontator is currently configured with:
#
# thread_read_proc -> public commentable, or ProjectPolicy view access
# thread_moderator_proc -> user.admin?
#
# Intentionally does not call super: threads on public projects must stay
# readable by anonymous users, matching the current API behaviour where
# unauthenticated requests can list a public project's comments.
class CommentThreadPolicy < ApplicationPolicy
attr_reader :user, :comment_thread

def initialize(user, comment_thread)
@user = user
@comment_thread = comment_thread
end

def show?
readable?
end

def create_comment?
user.present? && !comment_thread.closed? && readable?
end

def subscribe?
user.present? && readable?
end

def unsubscribe?
subscribe?
end

def close?
moderator?
end

def reopen?
moderator?
end

# moderator_permissions is :d, so moderators may delete comments and close
# threads, but may not edit other people's comments.
def moderator?
user.present? && user.admin?
end

private

def readable?
commentable = comment_thread.commentable
return false if commentable.nil?
return true if commentable.public?

ProjectPolicy.new(user, commentable).check_view_access?
end
end
40 changes: 40 additions & 0 deletions db/migrate/20260901000000_create_comment_tables.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# frozen_string_literal: true

class CreateCommentTables < ActiveRecord::Migration[8.1]
def change
create_comment_threads
create_comments
end

private

def create_comment_threads
create_table :comment_threads do |t|
t.references :commentable, polymorphic: true, null: false, index: false
t.datetime :closed_at
t.references :closer, null: true, foreign_key: { to_table: :users }

t.timestamps
end

add_index :comment_threads, %i[commentable_type commentable_id],
unique: true,
name: "index_comment_threads_on_commentable"
end

def create_comments
create_table :comments do |t|
t.references :comment_thread, null: false, foreign_key: true, index: false
t.references :user, null: false, foreign_key: true
t.references :editor, null: true, foreign_key: { to_table: :users }
t.references :parent, null: true, foreign_key: { to_table: :comments }
t.text :body, null: false
t.datetime :deleted_at

t.timestamps
end

add_index :comments, %i[comment_thread_id created_at]
add_index :comments, :deleted_at
end
end
34 changes: 33 additions & 1 deletion db/schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.

ActiveRecord::Schema[8.1].define(version: 2026_08_21_000000) do
ActiveRecord::Schema[8.1].define(version: 2026_09_01_000000) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_catalog.plpgsql"

Expand Down Expand Up @@ -116,6 +116,33 @@
t.index ["user_id"], name: "index_collaborations_on_user_id"
end

create_table "comment_threads", force: :cascade do |t|
t.datetime "closed_at"
t.bigint "closer_id"
t.bigint "commentable_id", null: false
t.string "commentable_type", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["closer_id"], name: "index_comment_threads_on_closer_id"
t.index ["commentable_type", "commentable_id"], name: "index_comment_threads_on_commentable", unique: true
end

create_table "comments", force: :cascade do |t|
t.text "body", null: false
t.bigint "comment_thread_id", null: false
t.datetime "created_at", null: false
t.datetime "deleted_at"
t.bigint "editor_id"
t.bigint "parent_id"
t.datetime "updated_at", null: false
t.bigint "user_id", null: false
t.index ["comment_thread_id", "created_at"], name: "index_comments_on_comment_thread_id_and_created_at"
t.index ["deleted_at"], name: "index_comments_on_deleted_at"
t.index ["editor_id"], name: "index_comments_on_editor_id"
t.index ["parent_id"], name: "index_comments_on_parent_id"
t.index ["user_id"], name: "index_comments_on_user_id"
end

create_table "commontator_comments", id: :serial, force: :cascade do |t|
t.string "creator_type"
t.integer "creator_id"
Expand Down Expand Up @@ -594,6 +621,11 @@
add_foreign_key "assignments", "groups"
add_foreign_key "collaborations", "projects"
add_foreign_key "collaborations", "users"
add_foreign_key "comment_threads", "users", column: "closer_id"
add_foreign_key "comments", "comment_threads"
add_foreign_key "comments", "comments", column: "parent_id"
add_foreign_key "comments", "users"
add_foreign_key "comments", "users", column: "editor_id"
add_foreign_key "commontator_comments", "commontator_comments", column: "parent_id", on_update: :restrict, on_delete: :cascade
add_foreign_key "contest_winners", "contests"
add_foreign_key "contest_winners", "projects"
Expand Down
22 changes: 22 additions & 0 deletions spec/factories/comment.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# frozen_string_literal: true

FactoryBot.define do
factory :comment_thread do
association :commentable, factory: :project

trait :closed do
closed_at { Time.current }
association :closer, factory: :user
end
end

factory :comment do
association :comment_thread
association :user
body { "A comment body" }

trait :deleted do
deleted_at { Time.current }
end
end
end
Loading
Loading