activeadmin_annotations 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +14 -0
  3. data/CODE_OF_CONDUCT.md +31 -0
  4. data/CONTRIBUTING.md +29 -0
  5. data/GOVERNANCE.md +29 -0
  6. data/LICENSE.md +21 -0
  7. data/README.md +321 -0
  8. data/SECURITY.md +27 -0
  9. data/activeadmin_annotations.gemspec +68 -0
  10. data/admin/annotation_reviews.rb +102 -0
  11. data/admin/annotations.rb +146 -0
  12. data/app/assets/controllers/activeadmin_annotations/annotator_controller.js +483 -0
  13. data/app/assets/controllers/activeadmin_annotations/clipboard_controller.js +73 -0
  14. data/app/assets/stylesheets/activeadmin_annotations.css +362 -0
  15. data/app/helpers/active_admin/annotations/panel_helper.rb +95 -0
  16. data/app/models/active_admin/annotations/annotation.rb +37 -0
  17. data/app/models/active_admin/annotations/application_record.rb +13 -0
  18. data/app/models/active_admin/annotations/review.rb +54 -0
  19. data/app/services/active_admin/annotations/review_service.rb +133 -0
  20. data/app/views/active_admin/annotations/_copy_details_action.html.erb +21 -0
  21. data/app/views/active_admin/annotations/_panel.html.erb +98 -0
  22. data/app/views/active_admin/annotations/_read_only_content.html.erb +3 -0
  23. data/config/polyrun_coverage.yml +8 -0
  24. data/db/migrate/20260617120000_create_activeadmin_annotations_tables.rb +41 -0
  25. data/db/migrate/20260706150000_add_metadata_json_gin_index_to_annotation_reviews.rb +9 -0
  26. data/db/migrate/20260710120000_add_panel_query_index_to_annotation_spans.rb +10 -0
  27. data/lib/activeadmin/annotations/annotatable.rb +15 -0
  28. data/lib/activeadmin/annotations/categories.rb +13 -0
  29. data/lib/activeadmin/annotations/content_revision.rb +101 -0
  30. data/lib/activeadmin/annotations/context.rb +11 -0
  31. data/lib/activeadmin/annotations/copy_text.rb +98 -0
  32. data/lib/activeadmin/annotations/engine.rb +49 -0
  33. data/lib/activeadmin/annotations/exporter.rb +99 -0
  34. data/lib/activeadmin/annotations/review_access.rb +29 -0
  35. data/lib/activeadmin/annotations/review_panel.rb +145 -0
  36. data/lib/activeadmin/annotations/version.rb +7 -0
  37. data/lib/activeadmin/annotations.rb +37 -0
  38. data/lib/activeadmin_annotations.rb +13 -0
  39. data/sig/activeadmin/annotations.rbs +5 -0
  40. metadata +393 -0
@@ -0,0 +1,98 @@
1
+ <% annotations_payload = annotations.map do |annotation|
2
+ {
3
+ id: annotation.id,
4
+ field_name: annotation.field_name,
5
+ selected_text: annotation.selected_text,
6
+ start_offset: annotation.start_offset,
7
+ end_offset: annotation.end_offset,
8
+ comment: annotation.comment,
9
+ category: annotation.category
10
+ }
11
+ end %>
12
+ <% category_options = categories.to_h %>
13
+ <% panel_id = "aa-annotation-#{review.id}" %>
14
+
15
+ <%= stylesheet_link_tag "activeadmin_annotations", "data-turbo-track": "reload" %>
16
+
17
+ <div class="aa-annotations-panel">
18
+ <% if content_stale && stale_message.present? %>
19
+ <div class="aa-annotations-stale-banner" role="status">
20
+ <p><%= stale_message %></p>
21
+ <% if advance_review_url.present? %>
22
+ <%= button_to "Review latest version",
23
+ advance_review_url,
24
+ method: :post,
25
+ class: "action-item-button" %>
26
+ <% end %>
27
+ </div>
28
+ <% end %>
29
+
30
+ <div
31
+ class="aa-annotations-layout"
32
+ data-controller="activeadmin-annotations--annotator"
33
+ data-activeadmin-annotations--annotator-review-id-value="<%= review.id %>"
34
+ data-activeadmin-annotations--annotator-field-value="<%= field %>"
35
+ data-activeadmin-annotations--annotator-category-label-value="<%= category_label %>"
36
+ data-activeadmin-annotations--annotator-categories-value="<%= ERB::Util.html_escape(category_options.to_json) %>"
37
+ data-activeadmin-annotations--annotator-create-url-value="<%= admin_annotation_spans_path(format: :json) %>"
38
+ data-activeadmin-annotations--annotator-update-url-template-value="<%= admin_annotation_span_path(":id", format: :json) %>"
39
+ data-activeadmin-annotations--annotator-annotations-value="<%= ERB::Util.html_escape(annotations_payload.to_json) %>">
40
+ <div class="aa-annotations-main">
41
+ <div class="aa-annotations-output">
42
+ <div
43
+ class="aa-annotations-annotatable prose"
44
+ data-activeadmin-annotations--annotator-target="annotatable">
45
+ <%= content %>
46
+ </div>
47
+ </div>
48
+
49
+ <% if context_panels.any? %>
50
+ <div class="aa-annotations-context">
51
+ <% context_panels.each do |panel| %>
52
+ <details class="aa-annotations-context-panel">
53
+ <summary><%= panel.fetch(:title) %></summary>
54
+ <pre><%= panel.fetch(:content) %></pre>
55
+ </details>
56
+ <% end %>
57
+ </div>
58
+ <% end %>
59
+ </div>
60
+
61
+ <aside class="aa-annotations-sidebar" data-activeadmin-annotations--annotator-target="sidebar">
62
+ <h4>Annotations</h4>
63
+ <p class="aa-annotations-hint">Select text in the block, then add a comment.</p>
64
+ <% if content_revision_enabled && content_revision_label.present? %>
65
+ <p class="aa-annotations-version"><%= content_revision_label %></p>
66
+ <% end %>
67
+
68
+ <div class="aa-annotations-composer aa-annotations-composer-hidden" data-activeadmin-annotations--annotator-target="composer">
69
+ <h4 data-activeadmin-annotations--annotator-target="composerHeading">New annotation</h4>
70
+ <p class="aa-annotations-selected-preview" data-activeadmin-annotations--annotator-target="selectedPreview"></p>
71
+ <label for="<%= panel_id %>-comment">Comment</label>
72
+ <textarea
73
+ id="<%= panel_id %>-comment"
74
+ rows="4"
75
+ data-activeadmin-annotations--annotator-target="commentInput"
76
+ data-action="keydown.meta+enter->activeadmin-annotations--annotator#submitComposer keydown.ctrl+enter->activeadmin-annotations--annotator#submitComposer"></textarea>
77
+ <% if category_options.any? %>
78
+ <label for="<%= panel_id %>-category"><%= category_label %></label>
79
+ <select id="<%= panel_id %>-category" data-activeadmin-annotations--annotator-target="categoryInput">
80
+ <option value="">None</option>
81
+ <% category_options.each do |value, label| %>
82
+ <option value="<%= value %>"><%= label %></option>
83
+ <% end %>
84
+ </select>
85
+ <% else %>
86
+ <input type="hidden" value="" data-activeadmin-annotations--annotator-target="categoryInput">
87
+ <% end %>
88
+ <div class="aa-annotations-composer-actions">
89
+ <button type="button" class="action-item-button aa-annotations-save-button" data-action="activeadmin-annotations--annotator#submitComposer">Save annotation</button>
90
+ <button type="button" class="action-item-button" data-action="activeadmin-annotations--annotator#cancelComposer">Cancel</button>
91
+ </div>
92
+ <p class="aa-annotations-error aa-annotations-hidden" data-activeadmin-annotations--annotator-target="errorMessage"></p>
93
+ </div>
94
+
95
+ <div class="aa-annotations-list" data-activeadmin-annotations--annotator-target="annotationList"></div>
96
+ </aside>
97
+ </div>
98
+ </div>
@@ -0,0 +1,3 @@
1
+ <div class="aa-annotations-panel aa-annotations-panel--read-only">
2
+ <%= content %>
3
+ </div>
@@ -0,0 +1,8 @@
1
+ track_under:
2
+ - lib
3
+ - app
4
+ minimum_line_percent: 50
5
+ report_formats:
6
+ - html
7
+ - cobertura
8
+ - console
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateActiveadminAnnotationsTables < ActiveRecord::Migration[8.1]
4
+ def change
5
+ create_table :annotation_reviews, id: :uuid do |t|
6
+ t.references :subject, polymorphic: true, null: false, type: :uuid, index: true
7
+ t.uuid :reviewer_id, null: false
8
+ t.string :review_status, null: false, default: "pending"
9
+ t.text :notes
10
+ t.jsonb :context_json, null: false, default: {}
11
+ t.string :context_digest
12
+ t.integer :content_revision_version, null: false, default: 0
13
+ t.jsonb :metadata_json, null: false, default: {}
14
+
15
+ t.timestamps
16
+ end
17
+
18
+ add_index :annotation_reviews, %i[subject_type subject_id reviewer_id], unique: true, name: "index_annotation_reviews_on_subject_and_reviewer"
19
+ add_index :annotation_reviews, :review_status
20
+ add_index :annotation_reviews, :context_digest
21
+ add_index :annotation_reviews, :reviewer_id
22
+
23
+ create_table :annotation_spans, id: :uuid do |t|
24
+ t.references :annotation_review, null: false, type: :uuid, foreign_key: {to_table: :annotation_reviews}, index: true
25
+ t.string :field_name, null: false
26
+ t.text :selected_text, null: false
27
+ t.integer :start_offset, null: false
28
+ t.integer :end_offset, null: false
29
+ t.text :comment, null: false
30
+ t.string :category
31
+ t.integer :content_revision_version, null: false, default: 0
32
+ t.jsonb :context_paths_json, null: false, default: []
33
+ t.jsonb :metadata_json, null: false, default: {}
34
+
35
+ t.timestamps
36
+ end
37
+
38
+ add_index :annotation_spans, :category
39
+ add_index :annotation_spans, :field_name
40
+ end
41
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AddMetadataJsonGinIndexToAnnotationReviews < ActiveRecord::Migration[8.1]
4
+ def change
5
+ return unless connection.adapter_name.match?(/postgres/i)
6
+
7
+ add_index :annotation_reviews, :metadata_json, using: :gin, if_not_exists: true
8
+ end
9
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AddPanelQueryIndexToAnnotationSpans < ActiveRecord::Migration[8.1]
4
+ def change
5
+ add_index :annotation_spans,
6
+ %i[annotation_review_id field_name content_revision_version],
7
+ name: "index_annotation_spans_on_review_field_and_revision",
8
+ if_not_exists: true
9
+ end
10
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveAdmin::Annotations
4
+ module Annotatable
5
+ extend ::ActiveSupport::Concern
6
+
7
+ included do
8
+ has_many :annotation_reviews,
9
+ as: :subject,
10
+ class_name: "ActiveAdmin::Annotations::Review",
11
+ dependent: :destroy,
12
+ inverse_of: :subject
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveAdmin::Annotations
4
+ module Categories
5
+ def self.all
6
+ ActiveAdmin::Annotations.categories
7
+ end
8
+
9
+ def self.allowed?(value)
10
+ value.blank? || all.key?(value)
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveAdmin::Annotations
4
+ module ContentRevision
5
+ class << self
6
+ def enabled_for?(subject)
7
+ case ActiveAdmin::Annotations.content_revision_strategy
8
+ when false, :none
9
+ false
10
+ when true, :active_version
11
+ active_version_subject?(subject) || custom_version_resolver?
12
+ else
13
+ custom_version_resolver? || active_version_subject?(subject)
14
+ end
15
+ end
16
+
17
+ def current_version_for(subject)
18
+ if custom_current_version
19
+ return custom_current_version.call(subject).to_i
20
+ end
21
+
22
+ return subject.current_version.to_i if active_version_subject?(subject)
23
+
24
+ 0
25
+ end
26
+
27
+ def content_subject_for(subject, version)
28
+ if custom_content_subject
29
+ return custom_content_subject.call(subject, version.to_i)
30
+ end
31
+
32
+ if active_version_subject?(subject)
33
+ return active_version_content_subject(subject, version.to_i)
34
+ end
35
+
36
+ subject
37
+ end
38
+
39
+ def stale_review_message(review:, pinned_version:, latest_version:)
40
+ message = ActiveAdmin::Annotations.stale_content_review_message
41
+ return if message.blank?
42
+
43
+ if message.respond_to?(:call)
44
+ return message.call(review, pinned_version:, latest_version:)
45
+ end
46
+
47
+ format(message.to_s, pinned_version:, latest_version:)
48
+ end
49
+
50
+ def stale_context_message(review:)
51
+ message = ActiveAdmin::Annotations.stale_context_message
52
+ return if message.blank?
53
+
54
+ return message.call(review) if message.respond_to?(:call)
55
+
56
+ message.to_s
57
+ end
58
+
59
+ def version_label(version)
60
+ label = ActiveAdmin::Annotations.content_revision_label
61
+ return if label == false
62
+
63
+ if label.respond_to?(:call)
64
+ return label.call(version.to_i)
65
+ end
66
+
67
+ format((label || "Content version %{version}").to_s, version: version.to_i)
68
+ end
69
+
70
+ private
71
+
72
+ def custom_version_resolver?
73
+ custom_current_version.present? || custom_content_subject.present?
74
+ end
75
+
76
+ def custom_current_version
77
+ ActiveAdmin::Annotations.current_content_revision_version
78
+ end
79
+
80
+ def custom_content_subject
81
+ ActiveAdmin::Annotations.content_subject_for_revision
82
+ end
83
+
84
+ def active_version_subject?(subject)
85
+ subject.class.respond_to?(:has_revisions?) &&
86
+ subject.class.has_revisions? &&
87
+ subject.respond_to?(:current_version)
88
+ end
89
+
90
+ def active_version_content_subject(subject, requested_version)
91
+ head_version = subject.current_version.to_i
92
+ return subject if requested_version == head_version
93
+
94
+ snapshot_version = requested_version + 1
95
+ return subject.at_version(snapshot_version) if subject.respond_to?(:at_version) && subject.at_version(snapshot_version)
96
+
97
+ subject
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module ActiveAdmin::Annotations
6
+ module Context
7
+ def self.digest_for(context)
8
+ Digest::SHA256.hexdigest(context.to_json)
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module ActiveAdmin::Annotations
6
+ class CopyText
7
+ def self.for(annotation)
8
+ new(annotation).to_s
9
+ end
10
+
11
+ def initialize(annotation)
12
+ @annotation = annotation
13
+ @review = annotation.annotation_review
14
+ end
15
+
16
+ def to_s
17
+ sections = []
18
+ instructions = copy_instructions
19
+ sections << instructions if instructions.present?
20
+ sections << review_section
21
+ sections << context_section
22
+ sections << annotation_section
23
+ sections.join("\n\n")
24
+ end
25
+
26
+ private
27
+
28
+ attr_reader :annotation, :review
29
+
30
+ def copy_instructions
31
+ instructions = ActiveAdmin::Annotations.copy_instructions
32
+ return if instructions.blank?
33
+ return instructions.call(annotation, review) if instructions.respond_to?(:call)
34
+
35
+ instructions.to_s
36
+ end
37
+
38
+ def review_section
39
+ lines = [
40
+ "Review",
41
+ "Subject: #{review.subject_type} ##{review.subject_id}",
42
+ "Status: #{review.review_status}",
43
+ "Reviewer ID: #{review.reviewer_id}",
44
+ "Review ID: #{review.id}"
45
+ ]
46
+ if content_revision_enabled?
47
+ lines.insert(2, "Content revision version: #{review.content_revision_version}")
48
+ end
49
+ lines << "Notes: #{review.notes}" if review.notes.present?
50
+ lines << "Context digest: #{review.context_digest}" if review.context_digest.present?
51
+ lines.join("\n")
52
+ end
53
+
54
+ def context_section
55
+ "Context:\n#{JSON.pretty_generate(review.context_json.to_h)}"
56
+ end
57
+
58
+ def annotation_section
59
+ category_label = ActiveAdmin::Annotations.category_label
60
+ category_value = category_label_for(annotation.category)
61
+ context_paths = Array(annotation.context_paths_json)
62
+
63
+ lines = [
64
+ "Annotation",
65
+ "Annotation ID: #{annotation.id}",
66
+ "Field: #{annotation.field_name}",
67
+ "Selected text:\n#{annotation.selected_text}",
68
+ "Comment:\n#{annotation.comment}",
69
+ "#{category_label}: #{category_value}",
70
+ "Span: #{annotation.start_offset}-#{annotation.end_offset}"
71
+ ]
72
+ lines << if context_paths.any?
73
+ "Context paths: #{context_paths.join(", ")}"
74
+ else
75
+ "Context paths: (none)"
76
+ end
77
+ lines << "Created: #{annotation.created_at&.iso8601}" if annotation.created_at
78
+ lines << "Updated: #{annotation.updated_at&.iso8601}" if annotation.updated_at
79
+ if content_revision_enabled?
80
+ lines.insert(2, "Content revision version: #{annotation.content_revision_version}")
81
+ end
82
+ lines.join("\n")
83
+ end
84
+
85
+ def content_revision_enabled?
86
+ subject = review.subject
87
+ return false if subject.blank?
88
+
89
+ ContentRevision.enabled_for?(subject)
90
+ end
91
+
92
+ def category_label_for(value)
93
+ return "(none)" if value.blank?
94
+
95
+ ActiveAdmin::Annotations.categories[value] || value
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveAdmin
4
+ module Annotations
5
+ class Engine < ::Rails::Engine
6
+ engine_name "activeadmin_annotations"
7
+
8
+ isolate_namespace ActiveAdmin::Annotations
9
+
10
+ config.generators do |generator|
11
+ generator.test_framework :rspec
12
+ end
13
+
14
+ initializer "activeadmin_annotations.active_admin", after: :load_config_initializers do
15
+ next unless defined?(ActiveAdmin) && ActiveAdmin.respond_to?(:application)
16
+
17
+ ActiveAdmin.application.load_paths << ActiveAdmin::Annotations::Engine.root.join("admin").to_s
18
+ end
19
+
20
+ initializer "activeadmin_annotations.importmap", after: :load_config_initializers do
21
+ next unless Rails.application.respond_to?(:importmap)
22
+
23
+ pin_controller = proc do |importmap|
24
+ importmap.pin "controllers/activeadmin_annotations/annotator_controller",
25
+ to: "activeadmin_annotations/annotator_controller.js"
26
+ importmap.pin "controllers/activeadmin_annotations/clipboard_controller",
27
+ to: "activeadmin_annotations/clipboard_controller.js"
28
+ end
29
+
30
+ Rails.application.importmap.draw(&pin_controller)
31
+ ActiveAdmin.importmap.draw(&pin_controller) if defined?(ActiveAdmin)
32
+ end
33
+
34
+ initializer "activeadmin_annotations.assets" do |app|
35
+ assets_path = root.join("app/assets")
36
+ app.config.importmap.cache_sweepers << assets_path.join("controllers") if app.config.respond_to?(:importmap)
37
+
38
+ assets = app.config.assets if app.config.respond_to?(:assets)
39
+ assets.precompile << "activeadmin_annotations.css" if assets&.respond_to?(:precompile)
40
+ end
41
+
42
+ initializer "activeadmin_annotations.view_helpers" do
43
+ ActiveSupport.on_load(:action_view) do
44
+ include ActiveAdmin::Annotations::PanelHelper
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module ActiveAdmin::Annotations
6
+ class Exporter
7
+ BATCH_SIZE = 100
8
+
9
+ def initialize(reviews:)
10
+ @reviews = reviews
11
+ end
12
+
13
+ def to_jsonl
14
+ lines = []
15
+ each_jsonl_row { |row| lines << JSON.generate(row) }
16
+ return "" if lines.empty?
17
+
18
+ "#{lines.join("\n")}\n"
19
+ end
20
+
21
+ def each_jsonl_row
22
+ return enum_for(:each_jsonl_row) unless block_given?
23
+
24
+ if reviews.is_a?(ActiveRecord::Relation)
25
+ export_relation_in_batches { |row| yield row }
26
+ else
27
+ Array(reviews).each do |review|
28
+ rows_for(review).each { |row| yield row }
29
+ end
30
+ end
31
+ end
32
+
33
+ private
34
+
35
+ attr_reader :reviews
36
+
37
+ def export_relation_in_batches
38
+ reviews.find_in_batches(batch_size: BATCH_SIZE) do |batch|
39
+ ActiveRecord::Associations::Preloader.new(records: batch, associations: :annotations).call
40
+ batch.each do |review|
41
+ rows_for(review).each { |row| yield row }
42
+ end
43
+ end
44
+ end
45
+
46
+ def rows_for(review)
47
+ context = review.context_json.to_h
48
+ revision_enabled = ContentRevision.enabled_for?(review.subject)
49
+ annotations_for(review).map do |annotation|
50
+ {
51
+ review_id: review.id,
52
+ annotation_id: annotation.id,
53
+ subject: {
54
+ type: review.subject_type,
55
+ id: review.subject_id
56
+ },
57
+ field: annotation.field_name,
58
+ context: context,
59
+ annotation: annotation_payload(annotation, revision_enabled: revision_enabled),
60
+ review: review_payload(review, revision_enabled: revision_enabled)
61
+ }
62
+ end
63
+ end
64
+
65
+ def annotation_payload(annotation, revision_enabled:)
66
+ payload = {
67
+ selected_text: annotation.selected_text,
68
+ comment: annotation.comment,
69
+ category: annotation.category,
70
+ start_offset: annotation.start_offset,
71
+ end_offset: annotation.end_offset,
72
+ context_paths: Array(annotation.context_paths_json)
73
+ }
74
+ payload[:content_revision_version] = annotation.content_revision_version if revision_enabled
75
+ payload
76
+ end
77
+
78
+ def annotations_for(review)
79
+ association = review.association(:annotations)
80
+ if association.loaded?
81
+ association.target.sort_by(&:created_at)
82
+ else
83
+ review.annotations.order(:created_at).to_a
84
+ end
85
+ end
86
+
87
+ def review_payload(review, revision_enabled:)
88
+ payload = {
89
+ reviewer_id: review.reviewer_id,
90
+ review_status: review.review_status,
91
+ notes: review.notes,
92
+ context_digest: review.context_digest,
93
+ reviewed_at: review.updated_at&.iso8601
94
+ }
95
+ payload[:content_revision_version] = review.content_revision_version if revision_enabled
96
+ payload
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveAdmin::Annotations
4
+ module ReviewAccess
5
+ class Error < StandardError; end
6
+
7
+ class NotFound < Error; end
8
+
9
+ class Forbidden < Error; end
10
+
11
+ def self.review_for_span_create!(review_id:, reviewer:)
12
+ raise NotFound, "Review not found" if review_id.blank?
13
+
14
+ review = Review.find_by(id: review_id)
15
+ raise NotFound, "Review not found" unless review
16
+ raise Forbidden, "Review not found" unless reviewer.present? && review.reviewer_id == reviewer.id
17
+
18
+ review
19
+ end
20
+
21
+ def self.authorize_annotation!(annotation:, reviewer:)
22
+ raise Forbidden, "Annotation not found" unless annotation
23
+ raise Forbidden, "Annotation not found" unless reviewer.present? &&
24
+ annotation.annotation_review.reviewer_id == reviewer.id
25
+
26
+ annotation
27
+ end
28
+ end
29
+ end