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 } %>
-
+
<% if delete_items.any? %> -

Tagged Items

+

<%= tagged_items_label %>

    <% delete_items.sort_by { |item| [item.public_send(item_type_col), (item.public_send(assoc_name).try(:name) || item.public_send(assoc_name).try(:title) || "").to_s.downcase] }.each do |item| %>
  • @@ -139,9 +196,9 @@
<% end %>
-
+
<% if keep_items.any? %> -

Tagged Items

+

<%= tagged_items_label %>

    <% keep_items.sort_by { |item| [item.public_send(item_type_col), (item.public_send(assoc_name).try(:name) || item.public_send(assoc_name).try(:title) || "").to_s.downcase] }.each do |item| %>
  • @@ -152,4 +209,49 @@
<% end %>
+ + + <% 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