rails-markup 1.2.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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +304 -0
- data/app/assets/javascripts/rails_markup/toolbar.js +2225 -0
- data/app/controllers/rails_markup/annotations_controller.rb +244 -0
- data/app/controllers/rails_markup/application_controller.rb +7 -0
- data/app/controllers/rails_markup/dashboard_controller.rb +230 -0
- data/app/controllers/rails_markup/external/annotations_controller.rb +68 -0
- data/app/models/rails_markup/annotation.rb +176 -0
- data/app/views/layouts/rails_markup/application.html.erb +158 -0
- data/app/views/rails_markup/dashboard/_annotation_card.html.erb +25 -0
- data/app/views/rails_markup/dashboard/_annotation_list.html.erb +57 -0
- data/app/views/rails_markup/dashboard/_annotation_page.html.erb +13 -0
- data/app/views/rails_markup/dashboard/_filters.html.erb +75 -0
- data/app/views/rails_markup/dashboard/_stats.html.erb +22 -0
- data/app/views/rails_markup/dashboard/_styles.html.erb +115 -0
- data/app/views/rails_markup/dashboard/board.html.erb +135 -0
- data/app/views/rails_markup/dashboard/index.html.erb +38 -0
- data/app/views/rails_markup/dashboard/show.html.erb +129 -0
- data/app/views/rails_markup/shared/_toolbar.html.erb +28 -0
- data/bin/rails-markup +5 -0
- data/config/routes.rb +45 -0
- data/db/migrate/20260720000000_add_client_uuid_to_rails_markup_annotations.rb +13 -0
- data/db/migrate/20260721000000_backfill_rails_markup_client_uuids.rb +17 -0
- data/lib/generators/rails_markup/install/templates/auth_controller.rb.erb +17 -0
- data/lib/generators/rails_markup/install/templates/bin_markup.erb +5 -0
- data/lib/generators/rails_markup/install/templates/create_rails_markup_annotations.rb.erb +27 -0
- data/lib/generators/rails_markup/install/templates/initializer.rb.erb +54 -0
- data/lib/generators/rails_markup/install_generator.rb +167 -0
- data/lib/generators/rails_markup/uninstall_generator.rb +110 -0
- data/lib/rails_markup/cli/base.rb +8 -0
- data/lib/rails_markup/cli/initializer_writer.rb +54 -0
- data/lib/rails_markup/cli/setup_wizard.rb +229 -0
- data/lib/rails_markup/cli.rb +831 -0
- data/lib/rails_markup/client_uuid_maintenance.rb +174 -0
- data/lib/rails_markup/configuration.rb +160 -0
- data/lib/rails_markup/engine.rb +18 -0
- data/lib/rails_markup/http_server.rb +226 -0
- data/lib/rails_markup/http_store_proxy.rb +122 -0
- data/lib/rails_markup/mcp_config.rb +258 -0
- data/lib/rails_markup/mcp_server.rb +639 -0
- data/lib/rails_markup/server.rb +73 -0
- data/lib/rails_markup/store.rb +219 -0
- data/lib/rails_markup/version.rb +5 -0
- data/lib/rails_markup.rb +11 -0
- data/lib/tasks/rails_markup_client_uuids.rake +19 -0
- metadata +198 -0
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsMarkup
|
|
4
|
+
class AnnotationsController < RailsMarkup.config.base_controller_class.constantize
|
|
5
|
+
protect_from_forgery with: :exception
|
|
6
|
+
|
|
7
|
+
before_action :set_annotation, only: %i[acknowledge resolve dismiss reply]
|
|
8
|
+
|
|
9
|
+
rescue_from ActiveRecord::RecordNotFound do
|
|
10
|
+
render json: { error: "not found" }, status: :not_found
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# POST /feedback/api/sessions
|
|
14
|
+
def create_session
|
|
15
|
+
render json: { id: "rm-#{SecureRandom.hex(8)}", url: params[:url] }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# POST /feedback/api/sessions/:session_id/annotations
|
|
19
|
+
def create
|
|
20
|
+
return render_invalid_metadata if client_supplied_author?
|
|
21
|
+
|
|
22
|
+
attributes = annotation_params
|
|
23
|
+
return render_invalid_legacy_client_id unless attributes
|
|
24
|
+
|
|
25
|
+
annotation = Annotation.new(attributes)
|
|
26
|
+
assign_current_user(annotation)
|
|
27
|
+
|
|
28
|
+
if annotation.client_uuid.present? && (existing = Annotation.find_by(client_uuid: annotation.client_uuid))
|
|
29
|
+
return render_duplicate(existing, annotation)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
if annotation.save
|
|
33
|
+
fire_create_callback(annotation)
|
|
34
|
+
render json: annotation.as_api_json, status: :created
|
|
35
|
+
else
|
|
36
|
+
render json: { errors: annotation.errors.full_messages }, status: :unprocessable_entity
|
|
37
|
+
end
|
|
38
|
+
rescue ActiveRecord::RecordNotUnique
|
|
39
|
+
raise if annotation.client_uuid.blank?
|
|
40
|
+
|
|
41
|
+
render_duplicate(Annotation.find_by!(client_uuid: annotation.client_uuid), annotation)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# GET /feedback/api/annotations?page_url=/current?page=variant
|
|
45
|
+
def index
|
|
46
|
+
annotations = Annotation.for_page(params[:page_url]).recent.to_a
|
|
47
|
+
unless annotations.all? { |annotation| Annotation.valid_client_uuid?(annotation.client_uuid) }
|
|
48
|
+
return render json: { error: "annotation identities require repair" }, status: :service_unavailable
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
render json: annotations.map(&:as_api_json)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# PUT /feedback/api/annotations/:client_uuid
|
|
55
|
+
def upsert
|
|
56
|
+
client_uuid = normalized_route_uuid
|
|
57
|
+
return render_invalid_uuid unless client_uuid
|
|
58
|
+
return render_invalid_metadata if client_supplied_author?
|
|
59
|
+
|
|
60
|
+
dirty_fields = normalized_dirty_fields
|
|
61
|
+
return render_invalid_dirty_fields unless dirty_fields
|
|
62
|
+
|
|
63
|
+
attributes = browser_attributes
|
|
64
|
+
return render_invalid_status if dirty_fields.include?("status") && !Annotation::STATUSES.include?(attributes["status"])
|
|
65
|
+
|
|
66
|
+
annotation = Annotation.find_or_initialize_by(client_uuid: client_uuid)
|
|
67
|
+
created = annotation.new_record?
|
|
68
|
+
apply_desired_state(annotation, attributes, dirty_fields)
|
|
69
|
+
annotation.save!
|
|
70
|
+
fire_create_callback(annotation) if created
|
|
71
|
+
render json: annotation.as_api_json
|
|
72
|
+
rescue ActiveRecord::RecordInvalid => error
|
|
73
|
+
render json: { errors: error.record.errors.full_messages }, status: :unprocessable_entity
|
|
74
|
+
rescue ActiveRecord::RecordNotUnique
|
|
75
|
+
annotation = Annotation.find_by!(client_uuid: client_uuid)
|
|
76
|
+
apply_desired_state(annotation, attributes, dirty_fields)
|
|
77
|
+
annotation.save!
|
|
78
|
+
render json: annotation.as_api_json
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# DELETE /feedback/api/annotations/:client_uuid
|
|
82
|
+
def destroy
|
|
83
|
+
client_uuid = normalized_route_uuid
|
|
84
|
+
return render_invalid_uuid unless client_uuid
|
|
85
|
+
|
|
86
|
+
Annotation.find_by(client_uuid: client_uuid)&.destroy!
|
|
87
|
+
head :no_content
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# GET /feedback/api/health
|
|
91
|
+
def health
|
|
92
|
+
render json: { ok: true }
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# POST /feedback/api/annotations/:id/acknowledge
|
|
96
|
+
def acknowledge
|
|
97
|
+
@annotation.acknowledge!
|
|
98
|
+
render json: @annotation.as_api_json
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# POST /feedback/api/annotations/:id/resolve
|
|
102
|
+
def resolve
|
|
103
|
+
@annotation.resolve!(summary: params[:summary])
|
|
104
|
+
render json: @annotation.as_api_json
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# POST /feedback/api/annotations/:id/dismiss
|
|
108
|
+
def dismiss
|
|
109
|
+
@annotation.dismiss!(reason: params[:reason])
|
|
110
|
+
render json: @annotation.as_api_json
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# POST /feedback/api/annotations/:id/reply
|
|
114
|
+
def reply
|
|
115
|
+
return render json: { error: "message is required" }, status: :unprocessable_entity if params[:message].blank?
|
|
116
|
+
|
|
117
|
+
@annotation.add_reply!(message: params[:message], role: "agent")
|
|
118
|
+
render json: @annotation.as_api_json
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
private
|
|
122
|
+
|
|
123
|
+
def set_annotation
|
|
124
|
+
@annotation = Annotation.find(params[:id])
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
DEDUP_ATTRIBUTES = %w[user_id page_url content intent severity selected_text target metadata].freeze
|
|
128
|
+
|
|
129
|
+
def assign_current_user(annotation)
|
|
130
|
+
return unless respond_to?(:current_user, true) && current_user
|
|
131
|
+
|
|
132
|
+
annotation.user_id = current_user.id
|
|
133
|
+
author = RailsMarkup.config.resolve_author_name(current_user)
|
|
134
|
+
annotation.metadata = (annotation.metadata || {}).merge("author" => author) if author
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def render_duplicate(existing, candidate)
|
|
138
|
+
if existing.attributes.slice(*DEDUP_ATTRIBUTES) == candidate.attributes.slice(*DEDUP_ATTRIBUTES)
|
|
139
|
+
render json: existing.as_api_json, status: :ok
|
|
140
|
+
else
|
|
141
|
+
render json: { error: "client id already used for a different annotation" }, status: :conflict
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
ALLOWED_TARGET_KEYS = %w[selector cssPath nearbyText boundingBox].freeze
|
|
146
|
+
ALLOWED_METADATA_KEYS = Annotation::BROWSER_METADATA_KEYS.freeze
|
|
147
|
+
ALLOWED_DIRTY_FIELDS = (Annotation::BROWSER_ATTRIBUTES + %w[metadata status]).freeze
|
|
148
|
+
DIRTY_FIELD_ALIASES = { "selectedText" => "selected_text" }.freeze
|
|
149
|
+
|
|
150
|
+
def fire_create_callback(annotation)
|
|
151
|
+
callback = RailsMarkup.config.on_create_callback
|
|
152
|
+
return unless callback.respond_to?(:call)
|
|
153
|
+
|
|
154
|
+
callback.call(annotation)
|
|
155
|
+
rescue => e
|
|
156
|
+
Rails.logger.error("[rails-markup] on_create_callback error: #{e.message}")
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def annotation_params
|
|
160
|
+
permitted = params.permit(:page_url, :content, :intent, :severity, :selected_text, :selectedText, :clientId, target: {}, metadata: {})
|
|
161
|
+
permitted[:selected_text] ||= permitted.delete(:selectedText)
|
|
162
|
+
requested_client_uuid = permitted.delete(:clientId).to_s.strip
|
|
163
|
+
if (normalized_client_uuid = Annotation.normalize_client_uuid(requested_client_uuid))
|
|
164
|
+
permitted[:client_uuid] = normalized_client_uuid
|
|
165
|
+
elsif requested_client_uuid.present?
|
|
166
|
+
permitted[:client_uuid] = Annotation.legacy_client_uuid(
|
|
167
|
+
session_id: params[:session_id], legacy_client_id: requested_client_uuid
|
|
168
|
+
)
|
|
169
|
+
end
|
|
170
|
+
permitted[:page_url] ||= request.referer || "/"
|
|
171
|
+
permitted[:target] = normalize_target(params[:target]) if params[:target].present?
|
|
172
|
+
permitted[:metadata] = normalize_hash(params[:metadata], ALLOWED_METADATA_KEYS) if params[:metadata].present?
|
|
173
|
+
permitted
|
|
174
|
+
rescue ArgumentError
|
|
175
|
+
nil
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def browser_attributes
|
|
179
|
+
permitted = params.permit(:page_url, :content, :intent, :severity, :status, :selected_text, :selectedText, target: {}, metadata: {})
|
|
180
|
+
permitted[:selected_text] ||= permitted.delete(:selectedText)
|
|
181
|
+
permitted[:target] = normalize_target(params[:target]) if params.key?(:target)
|
|
182
|
+
permitted[:metadata] = normalize_hash(params[:metadata], ALLOWED_METADATA_KEYS) if params.key?(:metadata)
|
|
183
|
+
permitted.to_h.stringify_keys
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def apply_desired_state(annotation, attributes, dirty_fields)
|
|
187
|
+
assign_current_user(annotation) if annotation.new_record?
|
|
188
|
+
annotation.apply_browser_state(attributes, dirty_fields: dirty_fields)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def normalized_route_uuid
|
|
192
|
+
uuid = params[:client_uuid].to_s.strip
|
|
193
|
+
Annotation.normalize_client_uuid(uuid)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def normalized_dirty_fields
|
|
197
|
+
fields = params[:dirtyFields] || []
|
|
198
|
+
return unless fields.is_a?(Array)
|
|
199
|
+
|
|
200
|
+
fields = fields.map { |field| DIRTY_FIELD_ALIASES.fetch(field.to_s, field.to_s) }
|
|
201
|
+
fields if (fields - ALLOWED_DIRTY_FIELDS).empty?
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def client_supplied_author?
|
|
205
|
+
metadata = params[:metadata]
|
|
206
|
+
metadata.respond_to?(:key?) && (metadata.key?(:author) || metadata.key?("author"))
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def render_invalid_uuid
|
|
210
|
+
render json: { error: "client uuid must be a canonical UUID" }, status: :unprocessable_entity
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def render_invalid_legacy_client_id
|
|
214
|
+
render json: { error: "legacy client id requires a valid session identity" }, status: :unprocessable_entity
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def render_invalid_metadata
|
|
218
|
+
render json: { error: "author metadata is server owned" }, status: :unprocessable_entity
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def render_invalid_dirty_fields
|
|
222
|
+
render json: { error: "invalid dirty fields" }, status: :unprocessable_entity
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def render_invalid_status
|
|
226
|
+
render json: { error: "invalid status" }, status: :unprocessable_entity
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def normalize_target(target)
|
|
230
|
+
case target
|
|
231
|
+
when String then { "selector" => target }
|
|
232
|
+
when ActionController::Parameters
|
|
233
|
+
target.permit(*ALLOWED_TARGET_KEYS, boundingBox: %i[x y top left width height]).to_h
|
|
234
|
+
else {}
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def normalize_hash(value, allowed_keys)
|
|
239
|
+
return {} unless value.is_a?(ActionController::Parameters)
|
|
240
|
+
|
|
241
|
+
value.permit(*allowed_keys).to_h
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
end
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "csv"
|
|
4
|
+
|
|
5
|
+
module RailsMarkup
|
|
6
|
+
class DashboardController < RailsMarkup.config.base_controller_class.constantize
|
|
7
|
+
layout -> { RailsMarkup.config.dashboard_layout }
|
|
8
|
+
|
|
9
|
+
# When using a host app layout, expose host route helpers and all
|
|
10
|
+
# view helpers (icon, super_admin?, etc.) so the layout renders correctly.
|
|
11
|
+
if RailsMarkup.config.dashboard_layout != "rails_markup/application"
|
|
12
|
+
base = RailsMarkup.config.base_controller_class.constantize
|
|
13
|
+
helper base._helpers
|
|
14
|
+
|
|
15
|
+
# Delegate unknown *_path/*_url helpers to main_app so host routes
|
|
16
|
+
# don't get the engine mount prefix (/admin/annotations) prepended.
|
|
17
|
+
host_routes = Module.new do
|
|
18
|
+
def method_missing(method, *args, **kwargs, &block)
|
|
19
|
+
if method.to_s.match?(/_(path|url)\z/) && main_app.respond_to?(method)
|
|
20
|
+
main_app.public_send(method, *args, **kwargs, &block)
|
|
21
|
+
else
|
|
22
|
+
super
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def respond_to_missing?(method, include_private = false)
|
|
27
|
+
(method.to_s.match?(/_(path|url)\z/) && main_app.respond_to?(method)) || super
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
helper host_routes
|
|
31
|
+
|
|
32
|
+
# Include helpers that gems add directly to ActionView::Base
|
|
33
|
+
ActionView::Base.included_modules.each do |mod|
|
|
34
|
+
next unless mod.is_a?(Module) && mod.name&.include?("Helper")
|
|
35
|
+
next if mod.name.start_with?("RailsMarkup")
|
|
36
|
+
helper mod rescue nil
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
ALLOWED_STATUSES = %w[all pending acknowledged resolved dismissed].freeze
|
|
41
|
+
ALLOWED_ROLES = %w[agent user].freeze
|
|
42
|
+
|
|
43
|
+
before_action :set_annotation, only: %i[show update]
|
|
44
|
+
|
|
45
|
+
# GET /feedback
|
|
46
|
+
def index
|
|
47
|
+
@current_status = ALLOWED_STATUSES.include?(params[:status]) ? params[:status] : "pending"
|
|
48
|
+
base_scope = build_base_scope
|
|
49
|
+
|
|
50
|
+
# Single grouped count query instead of 6 separate queries
|
|
51
|
+
counts = base_scope.group(:status).count
|
|
52
|
+
@total_count = counts.values.sum
|
|
53
|
+
@pending_count = counts["pending"] || 0
|
|
54
|
+
@acknowledged_count = counts["acknowledged"] || 0
|
|
55
|
+
@resolved_count = counts["resolved"] || 0
|
|
56
|
+
@dismissed_count = counts["dismissed"] || 0
|
|
57
|
+
|
|
58
|
+
paginate(filtered_scope)
|
|
59
|
+
|
|
60
|
+
@page_urls = Annotation.distinct.pluck(:page_url).sort
|
|
61
|
+
@current_page_url = params[:page_url]
|
|
62
|
+
@authors = Annotation.distinct_authors
|
|
63
|
+
@current_author = params[:author]
|
|
64
|
+
@current_query = params[:q]
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# GET /feedback/annotations/:id
|
|
68
|
+
def show
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# GET /feedback/load_more
|
|
72
|
+
def load_more
|
|
73
|
+
@current_status = ALLOWED_STATUSES.include?(params[:status]) ? params[:status] : "pending"
|
|
74
|
+
@current_page_url = params[:page_url]
|
|
75
|
+
@current_author = params[:author]
|
|
76
|
+
@current_query = params[:q]
|
|
77
|
+
|
|
78
|
+
# load_more only ever fetches the NEXT page. A missing/invalid cursor
|
|
79
|
+
# (stale ?page= link, malformed cursor) must not silently re-serve page
|
|
80
|
+
# one, which would append duplicate cards. Return an empty page instead.
|
|
81
|
+
if valid_cursor?
|
|
82
|
+
paginate(filtered_scope)
|
|
83
|
+
else
|
|
84
|
+
@annotations = []
|
|
85
|
+
@next_page = false
|
|
86
|
+
@remaining = 0
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
render partial: "annotation_page", layout: false
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# GET /feedback/board
|
|
93
|
+
def board
|
|
94
|
+
@columns = {
|
|
95
|
+
pending: Annotation.pending.recent.limit(50),
|
|
96
|
+
acknowledged: Annotation.acknowledged.recent.limit(50),
|
|
97
|
+
resolved: Annotation.resolved.recent.limit(20),
|
|
98
|
+
dismissed: Annotation.dismissed.recent.limit(20)
|
|
99
|
+
}
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# POST /feedback/dismiss_all
|
|
103
|
+
def dismiss_all
|
|
104
|
+
status = params[:status]
|
|
105
|
+
unless status.in?(%w[pending acknowledged])
|
|
106
|
+
return redirect_to root_path, alert: "Invalid status for bulk dismiss."
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
count = Annotation.where(status: status).update_all(status: "dismissed")
|
|
110
|
+
redirect_to root_path(status: "dismissed"), notice: "#{count} annotations dismissed."
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# PATCH /feedback/annotations/:id
|
|
114
|
+
def update
|
|
115
|
+
case params[:action_type]
|
|
116
|
+
when "acknowledge" then @annotation.acknowledge!
|
|
117
|
+
when "resolve" then @annotation.resolve!(summary: params[:summary].presence)
|
|
118
|
+
when "dismiss" then @annotation.dismiss!(reason: params[:reason].presence)
|
|
119
|
+
when "reply"
|
|
120
|
+
role = ALLOWED_ROLES.include?(params[:role]) ? params[:role] : "agent"
|
|
121
|
+
@annotation.add_reply!(message: params[:message], role: role)
|
|
122
|
+
when "transition"
|
|
123
|
+
new_status = params[:status]
|
|
124
|
+
if Annotation::STATUSES.include?(new_status)
|
|
125
|
+
@annotation.update!(status: new_status)
|
|
126
|
+
return head :ok
|
|
127
|
+
else
|
|
128
|
+
return render json: { error: "invalid status" }, status: :unprocessable_entity
|
|
129
|
+
end
|
|
130
|
+
else
|
|
131
|
+
return redirect_to annotation_path(@annotation), alert: "Unknown action."
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
redirect_to annotation_path(@annotation), notice: "Annotation updated."
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# GET /feedback/export.csv
|
|
138
|
+
def export_csv
|
|
139
|
+
scope = build_export_scope
|
|
140
|
+
csv_data = generate_csv(scope)
|
|
141
|
+
send_data csv_data, filename: "annotations-#{Date.current}.csv", type: "text/csv"
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# GET /feedback/export.json
|
|
145
|
+
def export_json
|
|
146
|
+
scope = build_export_scope
|
|
147
|
+
json_data = scope.map(&:as_api_json).to_json
|
|
148
|
+
send_data json_data, filename: "annotations-#{Date.current}.json", type: "application/json"
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
private
|
|
152
|
+
|
|
153
|
+
def set_annotation
|
|
154
|
+
@annotation = Annotation.find(params[:id])
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def per_page
|
|
158
|
+
RailsMarkup.config.per_page
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def build_base_scope
|
|
162
|
+
params[:page_url].present? ? Annotation.for_page(params[:page_url]) : Annotation.all
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Shared filtered, ordered scope for index + load_more. Requires @current_status.
|
|
166
|
+
def filtered_scope
|
|
167
|
+
scope = build_base_scope.recent
|
|
168
|
+
scope = scope.where(status: @current_status) unless @current_status == "all"
|
|
169
|
+
scope = scope.search(params[:q]) if params[:q].present?
|
|
170
|
+
scope = scope.by_author(params[:author]) if params[:author].present?
|
|
171
|
+
scope
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Keyset pagination: fetch one extra row to detect "more", and expose a
|
|
175
|
+
# (created_at, id) cursor for the next page instead of a page/offset — so a
|
|
176
|
+
# row inserted between requests can't push a boundary row onto two pages.
|
|
177
|
+
def paginate(scope)
|
|
178
|
+
cursor = scope
|
|
179
|
+
before_time = parse_cursor_time(params[:before_time])
|
|
180
|
+
if before_time && params[:before_id].present?
|
|
181
|
+
cursor = cursor.before_cursor(before_time, params[:before_id].to_i)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
rows = cursor.limit(per_page + 1).to_a
|
|
185
|
+
@next_page = rows.size > per_page
|
|
186
|
+
@annotations = rows.first(per_page)
|
|
187
|
+
|
|
188
|
+
if @next_page && @annotations.any?
|
|
189
|
+
last = @annotations.last
|
|
190
|
+
@next_time = last.created_at.iso8601(6)
|
|
191
|
+
@next_id = last.id
|
|
192
|
+
@remaining = scope.before_cursor(last.created_at, last.id).count
|
|
193
|
+
else
|
|
194
|
+
@next_page = false
|
|
195
|
+
@next_time = @next_id = nil
|
|
196
|
+
@remaining = 0
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def parse_cursor_time(value)
|
|
201
|
+
return nil if value.blank?
|
|
202
|
+
|
|
203
|
+
Time.zone.parse(value.to_s)
|
|
204
|
+
rescue ArgumentError
|
|
205
|
+
nil
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def valid_cursor?
|
|
209
|
+
params[:before_id].present? && parse_cursor_time(params[:before_time]).present?
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def build_export_scope
|
|
213
|
+
scope = build_base_scope.recent
|
|
214
|
+
scope = scope.where(status: params[:status]) if params[:status].present? && params[:status] != "all"
|
|
215
|
+
scope = scope.search(params[:q]) if params[:q].present?
|
|
216
|
+
scope = scope.by_author(params[:author]) if params[:author].present?
|
|
217
|
+
scope
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def generate_csv(scope)
|
|
221
|
+
CSV.generate(headers: true) do |csv|
|
|
222
|
+
csv << %w[id status intent severity content page_url author selected_text created_at updated_at]
|
|
223
|
+
scope.find_each do |ann|
|
|
224
|
+
csv << [ann.id, ann.status, ann.intent, ann.severity, ann.content, ann.page_url,
|
|
225
|
+
ann.author_name, ann.selected_text, ann.created_at.iso8601, ann.updated_at.iso8601]
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsMarkup
|
|
4
|
+
module External
|
|
5
|
+
class AnnotationsController < ActionController::API
|
|
6
|
+
before_action :authenticate_token!
|
|
7
|
+
before_action :set_annotation, only: %i[show acknowledge resolve dismiss reply]
|
|
8
|
+
|
|
9
|
+
rescue_from ActiveRecord::RecordNotFound do
|
|
10
|
+
render json: { error: "not found" }, status: :not_found
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# GET /external/pending
|
|
14
|
+
def pending
|
|
15
|
+
annotations = Annotation.pending.recent.limit(50)
|
|
16
|
+
render json: { annotations: annotations.map(&:as_api_json) }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# GET /external/:id
|
|
20
|
+
def show
|
|
21
|
+
render json: @annotation.as_api_json
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# PATCH /external/:id/acknowledge
|
|
25
|
+
def acknowledge
|
|
26
|
+
@annotation.acknowledge!
|
|
27
|
+
render json: @annotation.as_api_json
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# PATCH /external/:id/resolve
|
|
31
|
+
def resolve
|
|
32
|
+
@annotation.resolve!(summary: params[:summary])
|
|
33
|
+
render json: @annotation.as_api_json
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# PATCH /external/:id/dismiss
|
|
37
|
+
def dismiss
|
|
38
|
+
@annotation.dismiss!(reason: params[:reason])
|
|
39
|
+
render json: @annotation.as_api_json
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# PATCH /external/:id/reply
|
|
43
|
+
def reply
|
|
44
|
+
return render json: { error: "message is required" }, status: :unprocessable_entity if params[:message].blank?
|
|
45
|
+
|
|
46
|
+
@annotation.add_reply!(message: params[:message], role: "agent")
|
|
47
|
+
render json: @annotation.as_api_json
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def set_annotation
|
|
53
|
+
@annotation = Annotation.find(params[:id])
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def authenticate_token!
|
|
57
|
+
# Development — allow all requests without token (dev server may bind to LAN IP)
|
|
58
|
+
return if Rails.env.development?
|
|
59
|
+
|
|
60
|
+
token = RailsMarkup.config.api_token
|
|
61
|
+
return head(:not_found) if token.nil?
|
|
62
|
+
|
|
63
|
+
provided = request.headers["Authorization"]&.delete_prefix("Bearer ")
|
|
64
|
+
head(:unauthorized) unless ActiveSupport::SecurityUtils.secure_compare(provided.to_s, token)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|