diff --git a/app/controllers/concerns/dedupable.rb b/app/controllers/concerns/dedupable.rb
index acb7dfffd..c82b2f36d 100644
--- a/app/controllers/concerns/dedupable.rb
+++ b/app/controllers/concerns/dedupable.rb
@@ -7,10 +7,22 @@ def dedupe_index
authorize!
config = dedupe_config
mc = config[:model_class]
+ name_col = config[:name_column] || :name
- groups = mc.all.group_by { |r| r.name.to_s.strip.downcase }
+ # Eager-load belongs_to associations used by record_extras
+ eager = mc.reflect_on_all_associations(:belongs_to).map(&:name)
+ all_records = mc.includes(eager).to_a
+
+ groups = all_records.group_by { |r| r.public_send(name_col).to_s.strip.downcase }
@possible_duplicates = groups.select { |_name, records| records.size > 1 }
- @records_for_select = mc.order(:name).map { |r| [ r.name, r.id ] }
+ @records_for_select = all_records.sort_by { |r| r.public_send(name_col).to_s.downcase }.map { |r| [ r.public_send(name_col), r.id ] }
+
+ # Pre-compute tagging counts in a single query
+ join_assoc, _join_incl = dedupe_primary_join(mc)
+ assoc_reflection = mc.reflect_on_association(join_assoc)
+ fk = assoc_reflection.foreign_key
+ @tagging_counts = assoc_reflection.klass.group(fk).count
+
@dedupe = build_dedupe_vars(config)
render "dedupes/index"
@@ -43,6 +55,14 @@ def dedupe_preview
@keep_items = @record_to_keep.public_send(join_assoc).includes(join_incl)
@dedupe = build_dedupe_vars(config)
+ @extra_association_data = (config[:extra_associations] || []).map do |ea|
+ {
+ label: ea[:label],
+ delete_items: @record_to_delete.public_send(ea[:name]),
+ keep_items: @record_to_keep.public_send(ea[:name])
+ }
+ end
+
render "dedupes/preview"
end
@@ -56,8 +76,9 @@ def dedupe_update_keep
keep_param_key = "#{mn}_to_keep"
if params[keep_param_key].present?
- editable = mc.column_names - %w[id created_at updated_at legacy_id]
- record.update!(params.require(keep_param_key).permit(editable))
+ editable = mc.column_names - %w[id created_at updated_at legacy_id] + rich_text_attribute_names(mc)
+ non_blank = params.require(keep_param_key).permit(editable).to_h.reject { |_k, v| v.nil? || v == "" }
+ record.update!(non_blank) if non_blank.any?
end
head :ok
@@ -72,32 +93,48 @@ def dedupe_execute
config = dedupe_config
mc = config[:model_class]
mn = mc.model_name.singular
+ name_col = config[:name_column] || :name
record_to_delete = mc.find(params["#{mn}_to_delete_id"])
record_to_keep = mc.find(params["#{mn}_to_keep_id"])
- keep_param_key = "#{mn}_to_keep"
- if params[keep_param_key].present?
- editable = mc.column_names - %w[id created_at updated_at legacy_id]
- record_to_keep.update!(params.require(keep_param_key).permit(editable))
- end
+ delete_name = record_to_delete.public_send(name_col)
+ keep_name = record_to_keep.public_send(name_col)
- if respond_to?(:track_event, true)
- track_event("dedupe.#{mn}", {
- resource_type: mc.name,
- resource_id: record_to_keep.id,
- deleted_record: record_to_delete.attributes,
- kept_record: { id: record_to_keep.id, name: record_to_keep.name },
- associations_moved: record_to_delete.public_send(dedupe_primary_join(mc).first).count
- })
- end
+ ActiveRecord::Base.transaction do
+ keep_param_key = "#{mn}_to_keep"
+ if params[keep_param_key].present?
+ editable = mc.column_names - %w[id created_at updated_at legacy_id] + rich_text_attribute_names(mc)
+ non_blank = params.require(keep_param_key).permit(editable).to_h.reject { |_k, v| v.nil? || v == "" }
+ record_to_keep.update!(non_blank) if non_blank.any?
+ end
- deduper = ModelDeduper.new(model_class: mc, logger: Rails.logger, dry_run: false, min_usage: 0)
- deduper.merge(record_to_keep, record_to_delete)
+ if respond_to?(:track_event, true)
+ track_event("dedupe.#{mn}", {
+ resource_type: mc.name,
+ resource_id: record_to_keep.id,
+ deleted_record: record_to_delete.attributes,
+ kept_record: { id: record_to_keep.id, name: keep_name },
+ associations_moved: record_to_delete.public_send(dedupe_primary_join(mc).first).count
+ })
+ end
+
+ if (extra_assocs = config[:extra_associations])
+ extra_assocs.reject { |ea| ea[:display_only] }.each do |ea|
+ reassign_direct_association(record_to_delete, record_to_keep, ea[:name])
+ end
+ # Clear stale association caches so dependent: :destroy
+ # doesn't cascade-delete already-reassigned records.
+ record_to_delete.reload
+ end
+
+ deduper = ModelDeduper.new(model_class: mc, logger: Rails.logger, dry_run: false, min_usage: 0)
+ deduper.merge(record_to_keep, record_to_delete)
+ end
label = mc.model_name.human.pluralize
redirect_to url_for(action: :index),
- notice: "#{label} merged successfully. '#{record_to_delete.name}' was merged into '#{record_to_keep.name}'."
+ notice: "#{label} merged successfully. '#{delete_name}' was merged into '#{keep_name}'."
rescue ActionPolicy::Unauthorized
raise
rescue StandardError => e
@@ -133,14 +170,67 @@ def dedupe_primary_join(mc)
[ assoc.name, poly.name ]
end
+ # Reassign a direct FK has_many association from one record to another.
+ # Detects duplicates using other FK columns and destroys them instead of moving.
+ # Also catches DB-level uniqueness violations as a fallback.
+ def reassign_direct_association(from_record, to_record, assoc_name)
+ assoc = from_record.class.reflect_on_association(assoc_name)
+ fk = assoc.foreign_key.to_s
+ join_class = assoc.klass
+
+ # Collect other belongs_to FK columns for duplicate detection,
+ # filtering to only columns that actually exist in the table.
+ db_columns = join_class.column_names.to_set
+ other_fk_cols = join_class.reflect_on_all_associations(:belongs_to).flat_map do |bt|
+ next [] if bt.foreign_key.to_s == fk
+ cols =
+ if bt.polymorphic?
+ [ bt.foreign_type.to_s, bt.foreign_key.to_s ]
+ else
+ [ bt.foreign_key.to_s ]
+ end
+ cols.select { |c| db_columns.include?(c) }
+ end
+
+ items = from_record.public_send(assoc_name).to_a
+
+ items.each do |item|
+ if other_fk_cols.any?
+ dedup_attrs = other_fk_cols.each_with_object({}) { |col, h| h[col] = item.public_send(col) }
+ if to_record.public_send(assoc_name).where(dedup_attrs).exists?
+ item.destroy!
+ next
+ end
+ end
+
+ begin
+ item.update!(fk => to_record.id)
+ rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotUnique
+ # Model-level validation (e.g., name uniqueness) prevents the move;
+ # destroy the unmovable record since the keeper has a conflict.
+ item.destroy!
+ end
+ end
+ end
+
+ # Returns attribute names for has_rich_text fields (e.g. ["rhino_objective", "rhino_materials"]).
+ # Empty array for models without ActionText.
+ def rich_text_attribute_names(mc)
+ mc.reflect_on_all_associations(:has_one)
+ .select { |a| a.class_name == "ActionText::RichText" }
+ .map { |a| a.name.to_s.sub(/^rich_text_/, "") }
+ end
+
def build_dedupe_vars(config)
mc = config[:model_class]
mn = mc.model_name.singular
join_assoc, join_incl = dedupe_primary_join(mc)
opts = config[:belongs_to_options]
+ name_col = config[:name_column] || :name
{
domain: config[:domain] || mc.model_name.plural.to_sym,
+ name_column: name_col,
model_label: mc.model_name.human,
model_label_plural: mc.model_name.human.pluralize,
model_name: mn,
diff --git a/app/controllers/workshops_controller.rb b/app/controllers/workshops_controller.rb
index d9c650886..91c18c879 100644
--- a/app/controllers/workshops_controller.rb
+++ b/app/controllers/workshops_controller.rb
@@ -1,5 +1,5 @@
class WorkshopsController < ApplicationController
- include AhoyTracking
+ include AhoyTracking, Dedupable
skip_before_action :authenticate_user!, only: [ :index, :show ]
def index
@@ -237,6 +237,29 @@ def assign_associations(workshop)
workshop.save!
end
+ def dedupe_config
+ {
+ model_class: Workshop,
+ domain: :workshops,
+ name_column: :title,
+ belongs_to_options: -> { { "windows_type_id" => WindowsType.order(:name) } },
+ record_extras: ->(record) { "Type: #{record.windows_type&.name || 'None'}" },
+ extra_associations: [
+ # Polymorphic joins (handled by ModelDeduper during merge, shown here for preview)
+ { name: :sectorable_items, label: "Sector Tags", display_only: true },
+ { name: :quotable_item_quotes, label: "Quote Tags", display_only: true },
+ { name: :bookmarks, label: "Bookmarks", display_only: true },
+ { name: :workshop_logs, label: "Workshop Logs", display_only: true },
+ # Direct FK associations (handled by reassign_direct_association)
+ { name: :associated_resources, label: "Associated Resources" },
+ { name: :workshop_resources, label: "Workshop Resources" },
+ { name: :workshop_series_children, label: "Series Children" },
+ { name: :workshop_series_parents, label: "Series Parents" },
+ { name: :workshop_variations, label: "Workshop Variations" }
+ ]
+ }
+ end
+
def log_workshop_error(action, error)
Rails.logger.error "Workshop #{action} failed: #{error.class} - #{error.message}\n#{error.backtrace.join("\n")}"
end
diff --git a/app/services/model_deduper.rb b/app/services/model_deduper.rb
index dda226325..52d6eb4b8 100644
--- a/app/services/model_deduper.rb
+++ b/app/services/model_deduper.rb
@@ -119,23 +119,50 @@ def merge_join(primary, dupe, join)
type_col = join[:polymorphic_type_column]
id_col = join[:polymorphic_id_column]
- existing_taggings = jc
- .where(fk => primary.id)
- .pluck(type_col, id_col)
- .map { |type, id| "#{type}_#{id}" }
- .to_set
-
- items_to_move = jc.where(fk => dupe.id)
-
- items_to_move.find_each do |item|
- tagging_key = "#{item.public_send(type_col)}_#{item.public_send(id_col)}"
-
- if existing_taggings.include?(tagging_key)
- item.destroy!
- logger.info " deleted duplicate #{jc.name} #{item.id} (primary already has it)"
+ if fk == id_col
+ # Deduplicating the polymorphic side (e.g. Workshop via categorizable_items).
+ # Use non-polymorphic FK columns for duplicate detection.
+ other_fk_cols = jc.reflect_on_all_associations(:belongs_to)
+ .reject(&:polymorphic?)
+ .map { |a| a.foreign_key.to_s }
+
+ existing_keys = if other_fk_cols.any?
+ jc.where(fk => primary.id)
+ .pluck(*other_fk_cols)
+ .map { |vals| Array(vals).join("_") }
+ .to_set
else
- item.update!(fk => primary.id)
- logger.info " moved #{jc.name} #{item.id} to primary"
+ Set.new
+ end
+
+ jc.where(fk => dupe.id).find_each do |item|
+ key = other_fk_cols.map { |c| item.public_send(c) }.join("_")
+ if other_fk_cols.any? && existing_keys.include?(key)
+ item.destroy!
+ logger.info " deleted duplicate #{jc.name} #{item.id} (primary already has it)"
+ else
+ item.update!(fk => primary.id)
+ logger.info " moved #{jc.name} #{item.id} to primary"
+ end
+ end
+ else
+ # Normal case: deduplicating the non-polymorphic side (e.g. Category via categorizable_items).
+ existing_taggings = jc
+ .where(fk => primary.id)
+ .pluck(type_col, id_col)
+ .map { |type, id| "#{type}_#{id}" }
+ .to_set
+
+ jc.where(fk => dupe.id).find_each do |item|
+ tagging_key = "#{item.public_send(type_col)}_#{item.public_send(id_col)}"
+
+ if existing_taggings.include?(tagging_key)
+ item.destroy!
+ logger.info " deleted duplicate #{jc.name} #{item.id} (primary already has it)"
+ else
+ item.update!(fk => primary.id)
+ logger.info " moved #{jc.name} #{item.id} to primary"
+ end
end
end
diff --git a/app/views/dedupes/_preview.html.erb b/app/views/dedupes/_preview.html.erb
index 3e9a4a20b..f298b9d52 100644
--- a/app/views/dedupes/_preview.html.erb
+++ b/app/views/dedupes/_preview.html.erb
@@ -1,6 +1,6 @@
<%# Shared dedupe preview partial
Required locals:
- domain: Symbol (:categories or :sectors)
+ domain: Symbol (:categories, :sectors, or :workshops)
record_to_delete: The record that will be deleted
record_to_keep: The record that will be kept
delete_items: Associated items of the record to delete
@@ -11,11 +11,15 @@
keep_param_key: Param key for keep fields (e.g. :category_to_keep)
form_builder: The parent form builder (f)
belongs_to_options: Hash of { column_name => collection } for select fields
+ name_column: Column used as the display name (default: :name)
+ extra_association_data: Array of { label:, delete_items:, keep_items: } (optional)
%>
<%
color = DomainTheme.color_for(domain)
model_class = record_to_delete.class
+ name_col = (defined?(name_column) && name_column) || :name
+ extra_assocs = (defined?(extra_association_data) && extra_association_data) || []
skip_columns = %w[id created_at updated_at legacy_id]
columns = model_class.columns.reject { |c| skip_columns.include?(c.name) }
@@ -23,6 +27,7 @@
bt_associations = model_class.reflect_on_all_associations(:belongs_to).index_by { |a| a.foreign_key.to_s }
belongs_to_options ||= {}
assoc_name = item_type_col.to_s.sub(/_type$/, '').to_sym
+ tagged_items_label = delete_items.klass.model_name.human.pluralize
%>
@@ -35,15 +40,15 @@
- <%= model_label %> to delete: <%= record_to_delete.name %>
+ <%= model_label %> to delete: <%= record_to_delete.public_send(name_col) %>
- <%= model_label %> to keep: <%= record_to_keep.name %>
+ <%= model_label %> to keep: <%= record_to_keep.public_send(name_col) %>
-
+
ID
<%= record_to_delete.id %>
@@ -76,11 +81,17 @@
%>
-
-
<%= label %>
-
<%= display_val %>
- <% if is_different %>
-
DIFFERENT
+
+
+ <%= label %>
+ <% if is_different %>
+ DIFFERENT
+ <% end %>
+
+ <% if col.type == :text && display_val.present? %>
+
<%= display_val %>
+ <% else %>
+
<%= display_val %>
<% end %>
@@ -97,16 +108,62 @@
<% elsif col.type == :integer %>
<%= keep_f.number_field col.name.to_sym, class: "ml-2 w-20 px-2 py-1 border border-gray-200 rounded text-gray-900 focus:ring-2 focus:ring-#{color}-500 focus:border-transparent" %>
+ <% elsif col.type == :text %>
+
+
+ <%= keep_f.text_area col.name.to_sym,
+ rows: 1,
+ class: "mt-1 w-full px-2 py-1 border border-gray-200 rounded text-gray-900 resize-y focus:ring-2 focus:ring-#{color}-500 focus:border-transparent",
+ data: { autosize: true } %>
+
<% else %>
- <%= keep_f.text_field col.name.to_sym, class: "ml-2 px-2 py-1 border border-gray-200 rounded text-gray-900 #{col.name == 'name' ? 'font-semibold' : ''} focus:ring-2 focus:ring-#{color}-500 focus:border-transparent" %>
+ <%= keep_f.text_field col.name.to_sym, class: "ml-2 px-2 py-1 border border-gray-200 rounded text-gray-900 #{col.name == name_col.to_s ? 'font-semibold' : ''} focus:ring-2 focus:ring-#{color}-500 focus:border-transparent" %>
<% end %>
<% end %>
+
+
+ <% rt_attrs = model_class.reflect_on_all_associations(:has_one)
+ .select { |a| a.class_name == "ActionText::RichText" }
+ .map { |a| a.name.to_s.sub(/^rich_text_/, "") } %>
+ <% rt_attrs.each do |rt_attr| %>
+ <% delete_rt = record_to_delete.public_send(rt_attr) %>
+ <% keep_rt = record_to_keep.public_send(rt_attr) %>
+ <% next if delete_rt.blank? && keep_rt.blank? %>
+ <% delete_body = delete_rt&.body&.to_s.presence %>
+ <% keep_body = keep_rt&.body&.to_s.presence %>
+ <% is_different = delete_body != keep_body %>
+
+
+
+
+ <%= rt_attr.titleize %>
+ (rich text)
+ <% if is_different %>
+ DIFFERENT
+ <% end %>
+
+ <% if delete_rt.present? %>
+
<%= delete_rt %>
+ <% end %>
+
+
+
+
+
+
+
+
+
+ <% end %>
<% end %>
-
+
Created
<%= record_to_delete.created_at&.strftime("%Y-%m-%d %H:%M") || 'N/A' %>
@@ -116,19 +173,19 @@
-
+
Associated Records
- <%= delete_items.count %>
+ <%= delete_items.count + extra_assocs.sum { |ea| ea[:delete_items].count } %>
Associated Records
- <%= keep_items.count %>
+ <%= keep_items.count + extra_assocs.sum { |ea| ea[:keep_items].count } %>
-
+
-
+
+
+
+ <% extra_assocs.each_with_index do |ea, idx| %>
+ <% is_last = idx == extra_assocs.size - 1 %>
+
+
<%= ea[:label] %>
+
<%= ea[:delete_items].count %>
+ <% if ea[:delete_items].any? %>
+
+ <% ea[:delete_items].each do |item| %>
+ -
+ • <%= item.try(:name) || item.try(:title) || "##{item.id}" %>
+
+ <% end %>
+
+ <% end %>
+
+
+
<%= ea[:label] %>
+
<%= ea[:keep_items].count %>
+ <% if ea[:keep_items].any? %>
+
+ <% ea[:keep_items].each do |item| %>
+ -
+ • <%= item.try(:name) || item.try(:title) || "##{item.id}" %>
+
+ <% end %>
+
+ <% end %>
+
+ <% end %>
+
+
diff --git a/app/views/dedupes/index.html.erb b/app/views/dedupes/index.html.erb
index 4606366aa..24dc8f857 100644
--- a/app/views/dedupes/index.html.erb
+++ b/app/views/dedupes/index.html.erb
@@ -71,19 +71,25 @@
<% @possible_duplicates.each do |normalized_name, records| %>
+ <% first_two = records.first(2) %>
-
- "<%= normalized_name %>" (<%= records.count %> <%= @dedupe[:model_label_plural].downcase %>)
+
+ <%= link_to url_for(action: :dedupe_preview,
+ @dedupe[:delete_id_param] => first_two.first.id,
+ @dedupe[:keep_id_param] => first_two.last.id),
+ class: "text-blue-700 hover:text-blue-900 hover:underline" do %>
+ "<%= normalized_name %>" (<%= records.count %> <%= @dedupe[:model_label_plural].downcase %>)
+ <% end %>
<% records.each do |record| %>
-
- <%= record.name %>
+ <%= record.public_send(@dedupe[:name_column]) %>
(ID: <%= record.id %>,
<% if @dedupe[:record_extras] %><%= @dedupe[:record_extras].call(record) %>, <% end %>
<%= record.published? ? 'Published' : 'Unpublished' %>,
- <%= record.public_send(@dedupe[:join_association]).count %> taggings)
+ <%= @tagging_counts[record.id] || 0 %> taggings)
<% end %>
diff --git a/app/views/dedupes/preview.html.erb b/app/views/dedupes/preview.html.erb
index c62f06dd6..51a2bc22b 100644
--- a/app/views/dedupes/preview.html.erb
+++ b/app/views/dedupes/preview.html.erb
@@ -48,7 +48,9 @@
model_label: @dedupe[:model_label],
keep_param_key: @dedupe[:keep_param_key],
form_builder: f,
- belongs_to_options: @dedupe[:belongs_to_options] %>
+ belongs_to_options: @dedupe[:belongs_to_options],
+ name_column: @dedupe[:name_column],
+ extra_association_data: @extra_association_data || [] %>
@@ -56,9 +58,9 @@
Danger: This action cannot be undone.
- "<%= @record_to_delete.name %>" (ID: <%= @record_to_delete.id %>) will be permanently deleted
- and its <%= @delete_items.count %> associations moved to
- "<%= @record_to_keep.name %>" (ID: <%= @record_to_keep.id %>).
+ "<%= @record_to_delete.public_send(@dedupe[:name_column]) %>" (ID: <%= @record_to_delete.id %>) will be permanently deleted
+ and its <%= @delete_items.count + (@extra_association_data || []).sum { |ea| ea[:delete_items].count } %> associations moved to
+ "<%= @record_to_keep.public_send(@dedupe[:name_column]) %>" (ID: <%= @record_to_keep.id %>).
diff --git a/config/routes.rb b/config/routes.rb
index 2c19c6d22..21d05e6e5 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -148,6 +148,10 @@
resources :workshops do
collection do
post :search
+ get :dedupe_index
+ get :dedupe_preview
+ post :dedupe_execute
+ patch :dedupe_update_keep
end
end
diff --git a/spec/requests/dedupable_spec.rb b/spec/requests/dedupable_spec.rb
index 41d21aa24..8122b2d6b 100644
--- a/spec/requests/dedupable_spec.rb
+++ b/spec/requests/dedupable_spec.rb
@@ -22,6 +22,12 @@ def create_sector(name:, **attrs)
record
end
+ def create_workshop(title:, **attrs)
+ record = build(:workshop, title: title, **attrs)
+ record.save!(validate: false)
+ record
+ end
+
# ============================================================
# CATEGORIES — full coverage of all 4 dedupe actions
# ============================================================
@@ -151,12 +157,12 @@ def create_sector(name:, **attrs)
expect(keep.reload.published).to be true
end
- it "returns 422 on failure" do
+ it "ignores blank values and does not overwrite existing data" do
patch dedupe_update_keep_categories_path,
params: { id: keep.id, category_to_keep: { name: "" } }
- expect(response).to have_http_status(:unprocessable_content)
- expect(response.parsed_body).to have_key("error")
+ expect(response).to have_http_status(:ok)
+ expect(keep.reload.name).to eq("Original")
end
end
@@ -329,4 +335,280 @@ def create_sector(name:, **attrs)
end
end
end
+
+ # ============================================================
+ # WORKSHOPS — verify concern works with name_column: :title
+ # ============================================================
+
+ describe "Workshops" do
+ describe "GET dedupe_index" do
+ before { sign_in admin }
+
+ it "renders successfully" do
+ create_workshop(title: "Dup Workshop")
+ create_workshop(title: "dup workshop")
+
+ get dedupe_index_workshops_path
+ expect(response).to have_http_status(:ok)
+ expect(response.body).to include("Dup Workshop")
+ end
+ end
+
+ describe "GET dedupe_preview" do
+ before { sign_in admin }
+
+ let!(:keep) { create(:workshop, title: "Keep Workshop", published: true) }
+ let!(:delete_rec) { create_workshop(title: "Delete Workshop", published: false) }
+
+ it "renders the preview page" do
+ get dedupe_preview_workshops_path(
+ workshop_to_keep_id: keep.id,
+ workshop_to_delete_id: delete_rec.id
+ )
+ expect(response).to have_http_status(:ok)
+ expect(response.body).to include("Keep Workshop")
+ expect(response.body).to include("Delete Workshop")
+ end
+
+ it "redirects when both IDs are the same" do
+ get dedupe_preview_workshops_path(
+ workshop_to_keep_id: keep.id,
+ workshop_to_delete_id: keep.id
+ )
+ expect(response).to redirect_to(dedupe_index_workshops_path)
+ follow_redirect!
+ expect(response.body).to include("two different")
+ end
+ end
+
+ describe "PATCH dedupe_update_keep" do
+ before { sign_in admin }
+
+ let!(:keep) { create(:workshop, title: "Original Workshop", published: false) }
+
+ it "updates the record" do
+ patch dedupe_update_keep_workshops_path,
+ params: { id: keep.id, workshop_to_keep: { title: "Updated Workshop" } }
+
+ expect(response).to have_http_status(:ok)
+ expect(keep.reload.title).to eq("Updated Workshop")
+ end
+
+ it "updates rich text fields" do
+ patch dedupe_update_keep_workshops_path,
+ params: { id: keep.id, workshop_to_keep: { rhino_objective: "Updated rich text" } }
+
+ expect(response).to have_http_status(:ok)
+ expect(keep.reload.rhino_objective.to_plain_text).to eq("Updated rich text")
+ end
+ end
+
+ describe "POST dedupe_execute" do
+ before { sign_in admin }
+
+ let!(:keep) { create(:workshop, title: "Keep Workshop", published: true, objective: "Keep objective") }
+ let!(:delete_rec) { create_workshop(title: "Dup Workshop", published: false, objective: "Delete objective") }
+
+ it "merges and redirects with success notice" do
+ expect {
+ post dedupe_execute_workshops_path, params: {
+ workshop_to_delete_id: delete_rec.id,
+ workshop_to_keep_id: keep.id
+ }
+ }.to change(Workshop, :count).by(-1)
+
+ expect(response).to redirect_to(workshops_path)
+ follow_redirect!
+ expect(response.body).to include("merged successfully")
+ end
+
+ context "record fields" do
+ it "updates keeper fields when keep params provided" do
+ post dedupe_execute_workshops_path, params: {
+ workshop_to_delete_id: delete_rec.id,
+ workshop_to_keep_id: keep.id,
+ workshop_to_keep: { title: "Merged Title", objective: "Merged objective" }
+ }
+
+ keep.reload
+ expect(keep.title).to eq("Merged Title")
+ expect(keep.objective).to eq("Merged objective")
+ end
+
+ it "preserves keeper fields when no keep params provided" do
+ post dedupe_execute_workshops_path, params: {
+ workshop_to_delete_id: delete_rec.id,
+ workshop_to_keep_id: keep.id
+ }
+
+ keep.reload
+ expect(keep.title).to eq("Keep Workshop")
+ expect(keep.objective).to eq("Keep objective")
+ end
+ end
+
+ context "rich text (ActionText) fields" do
+ it "updates keeper rich text when keep params provided" do
+ keep.update!(rhino_objective: "Original rich text")
+
+ post dedupe_execute_workshops_path, params: {
+ workshop_to_delete_id: delete_rec.id,
+ workshop_to_keep_id: keep.id,
+ workshop_to_keep: { rhino_objective: "Merged rich text" }
+ }
+
+ expect(keep.reload.rhino_objective.to_plain_text).to eq("Merged rich text")
+ end
+
+ it "preserves keeper rich text when no keep params provided" do
+ keep.update!(rhino_objective: "Keep rich text")
+ delete_rec.update!(rhino_objective: "Delete rich text")
+
+ post dedupe_execute_workshops_path, params: {
+ workshop_to_delete_id: delete_rec.id,
+ workshop_to_keep_id: keep.id
+ }
+
+ expect(keep.reload.rhino_objective.to_plain_text).to eq("Keep rich text")
+ end
+
+ it "destroys deleted record's rich text on merge" do
+ delete_rec.update!(rhino_objective: "Will be lost")
+
+ post dedupe_execute_workshops_path, params: {
+ workshop_to_delete_id: delete_rec.id,
+ workshop_to_keep_id: keep.id
+ }
+
+ expect(ActionText::RichText.where(record_type: "Workshop", record_id: delete_rec.id)).not_to exist
+ end
+ end
+
+ context "polymorphic join associations" do
+ it "moves categorizable_items to the keeper" do
+ category = create(:category, category_type: category_type)
+ create(:categorizable_item, category: category, categorizable: delete_rec)
+
+ post dedupe_execute_workshops_path, params: {
+ workshop_to_delete_id: delete_rec.id,
+ workshop_to_keep_id: keep.id
+ }
+
+ expect(CategorizableItem.where(categorizable: keep).count).to eq(1)
+ expect(CategorizableItem.where(categorizable: delete_rec).count).to eq(0)
+ end
+
+ it "moves sectorable_items to the keeper" do
+ sector = create(:sector)
+ create(:sectorable_item, sector: sector, sectorable: delete_rec)
+
+ post dedupe_execute_workshops_path, params: {
+ workshop_to_delete_id: delete_rec.id,
+ workshop_to_keep_id: keep.id
+ }
+
+ expect(SectorableItem.where(sectorable: keep).count).to eq(1)
+ expect(SectorableItem.where(sectorable: delete_rec).count).to eq(0)
+ end
+
+ it "moves categorizable_items even when keeper already has a different category" do
+ cat1 = create(:category, category_type: category_type)
+ cat2 = create(:category, category_type: category_type)
+ create(:categorizable_item, category: cat1, categorizable: keep)
+ create(:categorizable_item, category: cat2, categorizable: delete_rec)
+
+ post dedupe_execute_workshops_path, params: {
+ workshop_to_delete_id: delete_rec.id,
+ workshop_to_keep_id: keep.id
+ }
+
+ expect(CategorizableItem.where(categorizable: keep).count).to eq(2)
+ expect(CategorizableItem.where(categorizable: delete_rec).count).to eq(0)
+ end
+ end
+
+ context "extra associations (direct FK)" do
+ it "reassigns workshop_variations to the keeper" do
+ variation = create(:workshop_variation, workshop: delete_rec)
+
+ post dedupe_execute_workshops_path, params: {
+ workshop_to_delete_id: delete_rec.id,
+ workshop_to_keep_id: keep.id
+ }
+
+ expect(variation.reload.workshop_id).to eq(keep.id)
+ end
+
+ it "reassigns workshop_resources to the keeper" do
+ resource = create(:resource)
+ wr = WorkshopResource.create!(workshop: delete_rec, resource: resource)
+
+ post dedupe_execute_workshops_path, params: {
+ workshop_to_delete_id: delete_rec.id,
+ workshop_to_keep_id: keep.id
+ }
+
+ expect(wr.reload.workshop_id).to eq(keep.id)
+ end
+
+ it "reassigns associated_resources to the keeper" do
+ resource = create(:resource, workshop: delete_rec)
+
+ post dedupe_execute_workshops_path, params: {
+ workshop_to_delete_id: delete_rec.id,
+ workshop_to_keep_id: keep.id
+ }
+
+ expect(resource.reload.workshop_id).to eq(keep.id)
+ end
+
+ it "reassigns workshop_series_children to the keeper" do
+ child_workshop = create(:workshop)
+ membership = WorkshopSeriesMembership.create!(
+ workshop_parent: delete_rec,
+ workshop_child: child_workshop,
+ position: 1
+ )
+
+ post dedupe_execute_workshops_path, params: {
+ workshop_to_delete_id: delete_rec.id,
+ workshop_to_keep_id: keep.id
+ }
+
+ expect(membership.reload.workshop_parent_id).to eq(keep.id)
+ end
+
+ it "reassigns workshop_series_parents to the keeper" do
+ parent_workshop = create(:workshop)
+ membership = WorkshopSeriesMembership.create!(
+ workshop_parent: parent_workshop,
+ workshop_child: delete_rec,
+ position: 1
+ )
+
+ post dedupe_execute_workshops_path, params: {
+ workshop_to_delete_id: delete_rec.id,
+ workshop_to_keep_id: keep.id
+ }
+
+ expect(membership.reload.workshop_child_id).to eq(keep.id)
+ end
+
+ it "reassigns multiple workshop_resources to the keeper" do
+ resource1 = create(:resource)
+ resource2 = create(:resource)
+ wr1 = WorkshopResource.create!(workshop: delete_rec, resource: resource1)
+ wr2 = WorkshopResource.create!(workshop: delete_rec, resource: resource2)
+
+ post dedupe_execute_workshops_path, params: {
+ workshop_to_delete_id: delete_rec.id,
+ workshop_to_keep_id: keep.id
+ }
+
+ expect(wr1.reload.workshop_id).to eq(keep.id)
+ expect(wr2.reload.workshop_id).to eq(keep.id)
+ end
+ end
+ end
+ end
end