From 9c722df440b0009760dd91452388b8a4434eefbe Mon Sep 17 00:00:00 2001 From: maebeale Date: Fri, 21 Aug 2026 11:35:49 -0400 Subject: [PATCH 1/5] Add admin "what this form submission changed" page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reconstruct everything a submission's smart-field answers changed across records — read back from the stamped Ahoy lifecycle events — grouped by record and labelled with what actually happened to each (added, removed, replaced, or filled a blank). Reachable from the linked-organizations page and any form-submission view, both admin-gated. The event stamping this reads is produced separately (PR #2301); the page and its read model are self-contained and tested against synthetic events. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../form_submissions_controller.rb | 8 ++ app/policies/form_submission_policy.rb | 4 + app/services/form_submission_changes.rb | 114 ++++++++++++++++++ .../link_organization.html.erb | 11 ++ app/views/form_submissions/changes.html.erb | 78 ++++++++++++ app/views/form_submissions/show.html.erb | 10 ++ config/routes.rb | 4 +- spec/requests/form_submissions_spec.rb | 45 +++++++ spec/services/form_submission_changes_spec.rb | 73 +++++++++++ spec/views/page_bg_class_alignment_spec.rb | 1 + 10 files changed, 347 insertions(+), 1 deletion(-) create mode 100644 app/services/form_submission_changes.rb create mode 100644 app/views/form_submissions/changes.html.erb create mode 100644 spec/services/form_submission_changes_spec.rb diff --git a/app/controllers/form_submissions_controller.rb b/app/controllers/form_submissions_controller.rb index 5218498e6c..4f412d7219 100644 --- a/app/controllers/form_submissions_controller.rb +++ b/app/controllers/form_submissions_controller.rb @@ -21,4 +21,12 @@ def show @form_submission = FormSubmission.find(params[:id]) authorize! @form_submission end + + # Admin-only audit of everything this submission's smart-field answers changed + # across records, read back from the stamped Ahoy lifecycle events. + def changes + @form_submission = FormSubmission.find(params[:id]) + authorize! @form_submission, to: :changes? + @change_groups = FormSubmissionChanges.new(@form_submission).groups + end end diff --git a/app/policies/form_submission_policy.rb b/app/policies/form_submission_policy.rb index c0b3c0ec72..77eedf349b 100644 --- a/app/policies/form_submission_policy.rb +++ b/app/policies/form_submission_policy.rb @@ -10,6 +10,10 @@ def show? admin? || (slug.present? && record.slug == slug) end + def changes? + admin? + end + def ticket? admin? || (slug.present? && record.slug == slug) end diff --git a/app/services/form_submission_changes.rb b/app/services/form_submission_changes.rb new file mode 100644 index 0000000000..27217c65f4 --- /dev/null +++ b/app/services/form_submission_changes.rb @@ -0,0 +1,114 @@ +# Reconstructs everything one form submission changed across records, read back +# from the Ahoy lifecycle events each write already emits (stamped with the +# submission id by the registration flow). Groups the changes by the record they +# happened to and labels each with what actually happened to it — added, removed, +# replaced, or filled (a blank) — for the admin "what this submission changed" page. +class FormSubmissionChanges + # The records whose changes are worth surfacing. Bookkeeping rows a submission + # also touches (the submission, its answers, the registration link) are noise here. + RELEVANT_TYPES = %w[Person Organization Address ContactMethod Affiliation SectorableItem CategorizableItem].freeze + GROUP_ORDER = %w[Person Organization Affiliation].freeze + IGNORED_ATTRIBUTES = %w[id created_at updated_at slug locality].freeze + + Change = Struct.new(:outcome, :label, :value, :previous_value, keyword_init: true) + Group = Struct.new(:record_type, :title, :changes, keyword_init: true) + + def initialize(form_submission) + @form_submission = form_submission + end + + def groups + relevant_events + .group_by { |event| owner_key(event) } + .filter_map { |(type, id), events| build_group(type, id, events) } + .reject { |group| group.changes.empty? } + .sort_by { |group| [ GROUP_ORDER.index(group.record_type) || GROUP_ORDER.size, group.title.to_s ] } + end + + def any? + groups.any? + end + + private + + def relevant_events + Ahoy::Event + .where("properties->>'$.form_submission_id' = ?", @form_submission.id.to_s) + .order(:time, :id) + .select { |event| event.properties["resource_type"].in?(RELEVANT_TYPES) } + end + + # A tag row belongs to the person/organization it tags, not to itself, so its + # changes group under that owner. Everything else owns its own changes. + def owner_key(event) + props = event.properties + case props["resource_type"] + when "SectorableItem" then [ props.dig("attributes", "sectorable_type"), props.dig("attributes", "sectorable_id") ] + when "CategorizableItem" then [ props.dig("attributes", "categorizable_type"), props.dig("attributes", "categorizable_id") ] + when "Address", "ContactMethod" then [ props.dig("attributes", "addressable_type") || props.dig("attributes", "contactable_type"), props.dig("attributes", "addressable_id") || props.dig("attributes", "contactable_id") ] + else [ props["resource_type"], props["resource_id"] ] + end + end + + def build_group(type, id, events) + changes = events.flat_map { |event| changes_for(event) }.compact + Group.new(record_type: type, title: owner_title(type, id), changes: changes) + end + + def changes_for(event) + action = event.name.split(".").first + props = event.properties + + return attribute_changes(props["changes"]) if props["changes"].present? + return [ tag_change(action, event) ] if props["resource_type"].in?(%w[SectorableItem CategorizableItem]) + return [ record_change(action, event) ] if action.in?(%w[create destroy]) + + [] + end + + def attribute_changes(changes) + changes.except(*IGNORED_ATTRIBUTES).filter_map do |attribute, before_after| + before, after = before_after.values_at("before", "after") + next if after.blank? && before.blank? + + Change.new( + outcome: before.present? ? "Replaced" : "Filled", + label: attribute.humanize, + value: display_value(after), + previous_value: display_value(before) + ) + end + end + + def tag_change(action, event) + props = event.properties + if props["resource_type"] == "SectorableItem" + name = Sector.find_by(id: props.dig("attributes", "sector_id"))&.name + kind = "sector" + else + name = Category.find_by(id: props.dig("attributes", "category_id"))&.name + kind = "age group" + end + primary = props.dig("attributes", "is_primary") ? " (primary)" : "" + Change.new(outcome: action == "destroy" ? "Removed" : "Added", label: kind.humanize, value: "#{name}#{primary}") + end + + def record_change(action, event) + Change.new( + outcome: action == "destroy" ? "Removed" : "Added", + label: event.properties["resource_type"].underscore.humanize, + value: event.properties["resource_title"] + ) + end + + def owner_title(type, id) + return type.to_s if id.blank? + + record = type.safe_constantize&.find_by(id: id) + record&.try(:full_name).presence || record&.try(:name).presence || "#{type} ##{id}" + end + + def display_value(value) + value.is_a?(Array) ? value.join(", ") : value + end +end diff --git a/app/views/event_registrations/link_organization.html.erb b/app/views/event_registrations/link_organization.html.erb index bf0dbf1f96..0858467ebd 100644 --- a/app/views/event_registrations/link_organization.html.erb +++ b/app/views/event_registrations/link_organization.html.erb @@ -68,6 +68,17 @@ <% end %> + <%# Admin-only jump to the audit of what these answers changed across records. %> + <% changes_submission = present_entries.first[:submission] %> + <% if changes_submission && allowed_to?(:changes?, changes_submission) %> + <%= link_to changes_form_submission_path(changes_submission, return_to: "link_organization", event_registration_id: @event_registration.id), + data: { turbo_frame: "_top" }, + class: "group mt-3 flex items-center gap-2 rounded-md border border-blue-200 bg-blue-50 px-3 py-2 text-sm font-semibold text-blue-900 hover:bg-blue-100" do %> + + What this registration's form changed + + <% end %> + <% end %> <% elsif @form_submission %>

No organization was submitted on the <%= link_to "registration form submission", event_registrant_submissions_path(@event_registration.event, person_id: @person.id, form_submission_id: @form_submission.id, return_to: "link_organization", link_org_return_to: params[:return_to]), target: "_blank", class: "text-blue-600 hover:underline", data: { turbo_frame: "_top" } %>. diff --git a/app/views/form_submissions/changes.html.erb b/app/views/form_submissions/changes.html.erb new file mode 100644 index 0000000000..fb960cf8e4 --- /dev/null +++ b/app/views/form_submissions/changes.html.erb @@ -0,0 +1,78 @@ +<% content_for(:page_bg_class, "admin-only bg-blue-100") %> +<% + # Reachable from the linked-organizations page and from a submission view, so the + # eyebrow returns to whichever origin sent us. + back_link = case params[:return_to] + when "link_organization" + { label: "← Back to linked organizations", path: link_organization_event_registration_path(params[:event_registration_id]) } if params[:event_registration_id].present? + end + back_link ||= { label: "← Back to submission", path: form_submission_path(@form_submission) } + + outcome_chip = { + "Added" => "bg-emerald-100 text-emerald-800 border-emerald-200", + "Removed" => "bg-amber-100 text-amber-800 border-amber-200", + "Replaced" => "bg-rose-100 text-rose-800 border-rose-200", + "Filled" => "bg-slate-200 text-slate-700 border-slate-300" + } + group_icon = { + "Person" => "fa-user", "Organization" => "fa-building", "Affiliation" => "fa-link" + } +%> +

+
+ <%= link_to back_link[:label], back_link[:path], class: "text-sm text-gray-500 hover:text-gray-700" %> +
+ +
+
+
+
+ +
+
+
+

What this form submission changed

+ Admin +
+

+ <%= @form_submission.person&.full_name %> · <%= @form_submission.form&.display_name || @form_submission.form&.name %> · <%= @form_submission.created_at.to_date.to_fs(:long) %> +

+
+
+
+ +
+ <% if @change_groups.any? %> +

Each row shows what actually happened to the record — a value replaced or filled, or a tag added / removed.

+ + <% @change_groups.each do |group| %> +
+
+ text-gray-500"> +

<%= group.record_type.underscore.humanize %><% if group.title.present? %> — <%= group.title %><% end %>

+
+
    + <% group.changes.each do |change| %> +
  • +
    +

    <%= change.label %>

    +

    + <%= change.value.presence || "—" %><% + %><% if change.outcome == "Replaced" && change.previous_value.present? %> (replaced “<%= change.previous_value %>”)<% end %> +

    +
    + "><%= change.outcome %> +
  • + <% end %> +
+
+ <% end %> + <% else %> +
+ +

This submission didn't change any records.

+
+ <% end %> +
+
+
diff --git a/app/views/form_submissions/show.html.erb b/app/views/form_submissions/show.html.erb index 7e92fe3e8d..36ade6d837 100644 --- a/app/views/form_submissions/show.html.erb +++ b/app/views/form_submissions/show.html.erb @@ -32,6 +32,16 @@
+ <%# Admin-only jump to the audit of what this submission's answers changed. %> + <% if allowed_to?(:changes?, @form_submission) %> + <%= link_to changes_form_submission_path(@form_submission), + class: "group mb-5 flex items-center gap-2 rounded-md border border-blue-200 bg-blue-50 px-3 py-2 text-sm font-semibold text-blue-900 hover:bg-blue-100" do %> + + What this submission changed + admin + + <% end %> + <% end %> <%= render "form_submissions/submission", submission: @form_submission %> <% if event && @form_submission.role == "bulk_payment" %> diff --git a/config/routes.rb b/config/routes.rb index 758afa847a..bfb129f6ba 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -150,7 +150,9 @@ patch :update_sections end end - resources :form_submissions, only: [ :index, :show ] + resources :form_submissions, only: [ :index, :show ] do + member { get :changes } + end resources :grants resources :scholarships, only: [ :index, :new, :create, :show, :edit, :update, :destroy ] do member do diff --git a/spec/requests/form_submissions_spec.rb b/spec/requests/form_submissions_spec.rb index ff6f46afd5..50c72a92f2 100644 --- a/spec/requests/form_submissions_spec.rb +++ b/spec/requests/form_submissions_spec.rb @@ -200,4 +200,49 @@ end end end + + describe "GET /form_submissions/:id/changes" do + def stamp(name, resource_type:, resource_id: 0, properties: {}) + create(:ahoy_event, name: name, properties: { + "resource_type" => resource_type, "resource_id" => resource_id, + "form_submission_id" => submission.id + }.merge(properties)) + end + + context "as an admin" do + before { sign_in admin } + + it "renders what the submission changed, grouped by record" do + org = create(:organization, name: "Riverside Community Arts") + stamp("update.organization", resource_type: "Organization", resource_id: org.id, + properties: { "resource_title" => org.name, + "changes" => { "website_url" => { "before" => "old.com", "after" => "new.com" } } }) + + get changes_form_submission_path(submission) + + expect(response).to have_http_status(:ok) + expect(response.body).to include("What this form submission changed") + expect(response.body).to include("Riverside Community Arts") + expect(response.body).to include("Replaced") + expect(response.body).to include("new.com") + end + + it "shows an empty state when nothing changed" do + get changes_form_submission_path(submission) + + expect(response).to have_http_status(:ok) + expect(response.body).to include("didn't change any records") + end + end + + context "as a non-admin" do + before { sign_in create(:user) } + + it "redirects away" do + get changes_form_submission_path(submission) + + expect(response).to redirect_to(root_path) + end + end + end end diff --git a/spec/services/form_submission_changes_spec.rb b/spec/services/form_submission_changes_spec.rb new file mode 100644 index 0000000000..a29db3cf44 --- /dev/null +++ b/spec/services/form_submission_changes_spec.rb @@ -0,0 +1,73 @@ +require "rails_helper" + +RSpec.describe FormSubmissionChanges do + let(:submission) { create(:form_submission) } + + def stamp(name, resource_type:, resource_id: 0, properties: {}) + create(:ahoy_event, name: name, properties: { + "resource_type" => resource_type, + "resource_id" => resource_id, + "form_submission_id" => submission.id + }.merge(properties)) + end + + it "groups an organization profile change under the org and labels it replaced" do + org = create(:organization, name: "Riverside Community Arts") + stamp("update.organization", resource_type: "Organization", resource_id: org.id, + properties: { "resource_title" => org.name, + "changes" => { "website_url" => { "before" => "old.com", "after" => "new.com" } } }) + + group = described_class.new(submission).groups.find { |g| g.record_type == "Organization" } + expect(group.title).to eq("Riverside Community Arts") + change = group.changes.first + expect(change).to have_attributes(outcome: "Replaced", label: "Website url", value: "new.com", previous_value: "old.com") + end + + it "labels a change from blank as filled" do + person = create(:person) + stamp("update.person", resource_type: "Person", resource_id: person.id, + properties: { "changes" => { "racial_ethnic_identity" => { "before" => nil, "after" => "Prefer not to say" } } }) + + change = described_class.new(submission).groups.first.changes.first + expect(change).to have_attributes(outcome: "Filled", value: "Prefer not to say") + end + + it "attributes a sector tag to its owner and resolves the sector name" do + org = create(:organization, name: "Riverside") + sector = create(:sector, :published, name: "Healthcare") + stamp("create.sectorable_item", resource_type: "SectorableItem", + properties: { "attributes" => { "sector_id" => sector.id, "sectorable_type" => "Organization", + "sectorable_id" => org.id, "is_primary" => true } }) + + change = described_class.new(submission).groups.find { |g| g.record_type == "Organization" }.changes.first + expect(change).to have_attributes(outcome: "Added", label: "Sector", value: "Healthcare (primary)") + end + + it "resolves an age group tag name" do + person = create(:person) + category = create(:category, :published, name: "Adolescents (13-17)") + stamp("create.categorizable_item", resource_type: "CategorizableItem", + properties: { "attributes" => { "category_id" => category.id, "categorizable_type" => "Person", + "categorizable_id" => person.id, "is_primary" => false } }) + + change = described_class.new(submission).groups.find { |g| g.record_type == "Person" }.changes.first + expect(change).to have_attributes(outcome: "Added", label: "Age group", value: "Adolescents (13-17)") + end + + it "ignores bookkeeping records like form answers and the submission itself" do + stamp("create.form_answer", resource_type: "FormAnswer", resource_id: 1) + stamp("create.form_submission", resource_type: "FormSubmission", resource_id: submission.id) + + expect(described_class.new(submission).groups).to be_empty + end + + it "only reads events stamped with this submission" do + other = create(:form_submission) + create(:ahoy_event, name: "update.person", properties: { + "resource_type" => "Person", "resource_id" => 1, "form_submission_id" => other.id, + "changes" => { "first_name" => { "before" => "A", "after" => "B" } } + }) + + expect(described_class.new(submission).groups).to be_empty + end +end diff --git a/spec/views/page_bg_class_alignment_spec.rb b/spec/views/page_bg_class_alignment_spec.rb index f0c5c1e278..ae350be3ed 100644 --- a/spec/views/page_bg_class_alignment_spec.rb +++ b/spec/views/page_bg_class_alignment_spec.rb @@ -106,6 +106,7 @@ "app/views/story_share_admin/show.html.erb" => "admin-only bg-blue-100", "app/views/story_imports/new.html.erb" => "admin-only bg-blue-100", "app/views/story_imports/create.html.erb" => "admin-only bg-blue-100", + "app/views/form_submissions/changes.html.erb" => "admin-only bg-blue-100", # index "app/views/allocations/index.html.erb" => "admin-only bg-blue-100", "app/views/other_responses/index.html.erb" => "admin-only bg-blue-100", From 0bc971aace53b27a37f6d4ca375a9498ce9904c9 Mon Sep 17 00:00:00 2001 From: maebeale Date: Fri, 21 Aug 2026 11:50:15 -0400 Subject: [PATCH 2/5] Link the changes page from the event registrant submissions view too The per-event registrant submissions view lists a person's submissions; add the same admin-gated "what this submission changed" jump to each, and teach the changes page's eyebrow to return there. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/views/events/form_submissions/show.html.erb | 11 +++++++++++ app/views/form_submissions/changes.html.erb | 3 +++ spec/requests/events/form_submissions_spec.rb | 7 +++++++ spec/requests/form_submissions_spec.rb | 9 +++++++++ 4 files changed, 30 insertions(+) diff --git a/app/views/events/form_submissions/show.html.erb b/app/views/events/form_submissions/show.html.erb index 363b7df0e9..fae4048a34 100644 --- a/app/views/events/form_submissions/show.html.erb +++ b/app/views/events/form_submissions/show.html.erb @@ -19,6 +19,17 @@

Submitted <%= submission.created_at.strftime("%B %d, %Y at %l:%M %P") %>

+ <%# Admin-only jump to the audit of what this submission's answers changed. %> + <% if allowed_to?(:changes?, submission) %> + <%= link_to changes_form_submission_path(submission, return_to: "event_registrant_submissions"), + class: "group mb-5 flex items-center gap-2 rounded-md border border-blue-200 bg-blue-50 px-3 py-2 text-sm font-semibold text-blue-900 hover:bg-blue-100" do %> + + What this submission changed + admin + + <% end %> + <% end %> + <% sections = [] %> <% current = nil %> diff --git a/app/views/form_submissions/changes.html.erb b/app/views/form_submissions/changes.html.erb index fb960cf8e4..f7c2b6a5ec 100644 --- a/app/views/form_submissions/changes.html.erb +++ b/app/views/form_submissions/changes.html.erb @@ -5,6 +5,9 @@ back_link = case params[:return_to] when "link_organization" { label: "← Back to linked organizations", path: link_organization_event_registration_path(params[:event_registration_id]) } if params[:event_registration_id].present? + when "event_registrant_submissions" + event = @form_submission.resolved_event + { label: "← Back to submissions", path: event_registrant_submissions_path(event, person_id: @form_submission.person_id) } if event end back_link ||= { label: "← Back to submission", path: form_submission_path(@form_submission) } diff --git a/spec/requests/events/form_submissions_spec.rb b/spec/requests/events/form_submissions_spec.rb index 82edf508b9..7346f15379 100644 --- a/spec/requests/events/form_submissions_spec.rb +++ b/spec/requests/events/form_submissions_spec.rb @@ -20,6 +20,13 @@ expect(response.body).to include(form.name) end + it "links each submission to its admin changes audit" do + get event_registrant_submissions_path(event, person_id: person.id) + + expect(response.body).to include(changes_form_submission_path(submission)) + expect(response.body).to include("What this submission changed") + end + it "returns 404 when person does not exist" do get event_registrant_submissions_path(event, person_id: 999999) expect(response).to have_http_status(:not_found) diff --git a/spec/requests/form_submissions_spec.rb b/spec/requests/form_submissions_spec.rb index 50c72a92f2..0de0856816 100644 --- a/spec/requests/form_submissions_spec.rb +++ b/spec/requests/form_submissions_spec.rb @@ -233,6 +233,15 @@ def stamp(name, resource_type:, resource_id: 0, properties: {}) expect(response).to have_http_status(:ok) expect(response.body).to include("didn't change any records") end + + it "returns to the event registrant submissions when arriving from there" do + submission = create(:form_submission, :with_event) + + get changes_form_submission_path(submission, return_to: "event_registrant_submissions") + + expect(response.body).to include("Back to submissions") + expect(response.body).to include(event_registrant_submissions_path(submission.resolved_event, person_id: submission.person_id)) + end end context "as a non-admin" do From 2b60c363b6497b3acd275a2acf04e9770a2fa585 Mon Sep 17 00:00:00 2001 From: maebeale Date: Fri, 21 Aug 2026 11:58:08 -0400 Subject: [PATCH 3/5] Scope the changes bar to overwritten values, with a count MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Changed" now means a submission overwrote a value that already existed — the risky case worth reviewing. Creating records, adding tags, and filling blanks are a new submission's own data, not edits, so they no longer count. The link only appears when there's at least one overwrite and shows how many. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../form_submissions_controller.rb | 4 ++- app/services/form_submission_changes.rb | 24 ++++++++++++-- .../link_organization.html.erb | 8 +++-- .../events/form_submissions/show.html.erb | 6 ++-- app/views/form_submissions/changes.html.erb | 4 +-- app/views/form_submissions/show.html.erb | 7 +++-- spec/requests/events/form_submissions_spec.rb | 15 ++++++++- spec/requests/form_submissions_spec.rb | 4 +-- spec/services/form_submission_changes_spec.rb | 31 +++++++++++++++++++ 9 files changed, 89 insertions(+), 14 deletions(-) diff --git a/app/controllers/form_submissions_controller.rb b/app/controllers/form_submissions_controller.rb index 4f412d7219..4d44ee4b8d 100644 --- a/app/controllers/form_submissions_controller.rb +++ b/app/controllers/form_submissions_controller.rb @@ -27,6 +27,8 @@ def show def changes @form_submission = FormSubmission.find(params[:id]) authorize! @form_submission, to: :changes? - @change_groups = FormSubmissionChanges.new(@form_submission).groups + changes = FormSubmissionChanges.new(@form_submission) + @change_groups = changes.edited_groups + @changed_count = changes.edited_count end end diff --git a/app/services/form_submission_changes.rb b/app/services/form_submission_changes.rb index 27217c65f4..95a8838137 100644 --- a/app/services/form_submission_changes.rb +++ b/app/services/form_submission_changes.rb @@ -25,8 +25,22 @@ def groups .sort_by { |group| [ GROUP_ORDER.index(group.record_type) || GROUP_ORDER.size, group.title.to_s ] } end - def any? - groups.any? + # A submission "changed" a value only when it overwrote one that was already + # there. Creating records, adding tags, and filling blanks are new data from a + # new submission — not edits — so they don't count here. + def edited_groups + groups.filter_map do |group| + edits = group.changes.select { |change| change.outcome == "Replaced" } + Group.new(record_type: group.record_type, title: group.title, changes: edits) if edits.any? + end + end + + def edited_count + relevant_events.sum { |event| replaced_changes(event.properties["changes"]).size } + end + + def edited? + edited_count.positive? end private @@ -66,6 +80,12 @@ def changes_for(event) [] end + def replaced_changes(changes) + return [] if changes.blank? + + attribute_changes(changes).select { |change| change.outcome == "Replaced" } + end + def attribute_changes(changes) changes.except(*IGNORED_ATTRIBUTES).filter_map do |attribute, before_after| before, after = before_after.values_at("before", "after") diff --git a/app/views/event_registrations/link_organization.html.erb b/app/views/event_registrations/link_organization.html.erb index 0858467ebd..36c585a264 100644 --- a/app/views/event_registrations/link_organization.html.erb +++ b/app/views/event_registrations/link_organization.html.erb @@ -68,14 +68,18 @@ <% end %> - <%# Admin-only jump to the audit of what these answers changed across records. %> + <%# Admin-only jump to the audit of the values these answers overwrote — + shown only once the submission has overwritten something (e.g. after + linking an org that wasn't a clean match writes onto it). %> <% changes_submission = present_entries.first[:submission] %> - <% if changes_submission && allowed_to?(:changes?, changes_submission) %> + <% submission_changes = changes_submission && FormSubmissionChanges.new(changes_submission) %> + <% if submission_changes&.edited? && allowed_to?(:changes?, changes_submission) %> <%= link_to changes_form_submission_path(changes_submission, return_to: "link_organization", event_registration_id: @event_registration.id), data: { turbo_frame: "_top" }, class: "group mt-3 flex items-center gap-2 rounded-md border border-blue-200 bg-blue-50 px-3 py-2 text-sm font-semibold text-blue-900 hover:bg-blue-100" do %> What this registration's form changed + <%= submission_changes.edited_count %> <% end %> <% end %> diff --git a/app/views/events/form_submissions/show.html.erb b/app/views/events/form_submissions/show.html.erb index fae4048a34..b6776cd4cb 100644 --- a/app/views/events/form_submissions/show.html.erb +++ b/app/views/events/form_submissions/show.html.erb @@ -19,12 +19,14 @@

Submitted <%= submission.created_at.strftime("%B %d, %Y at %l:%M %P") %>

- <%# Admin-only jump to the audit of what this submission's answers changed. %> - <% if allowed_to?(:changes?, submission) %> + <%# Admin-only jump to the audit of the values this submission overwrote. %> + <% submission_changes = FormSubmissionChanges.new(submission) %> + <% if allowed_to?(:changes?, submission) && submission_changes.edited? %> <%= link_to changes_form_submission_path(submission, return_to: "event_registrant_submissions"), class: "group mb-5 flex items-center gap-2 rounded-md border border-blue-200 bg-blue-50 px-3 py-2 text-sm font-semibold text-blue-900 hover:bg-blue-100" do %> What this submission changed + <%= submission_changes.edited_count %> admin <% end %> diff --git a/app/views/form_submissions/changes.html.erb b/app/views/form_submissions/changes.html.erb index f7c2b6a5ec..0549042435 100644 --- a/app/views/form_submissions/changes.html.erb +++ b/app/views/form_submissions/changes.html.erb @@ -46,7 +46,7 @@
<% if @change_groups.any? %> -

Each row shows what actually happened to the record — a value replaced or filled, or a tag added / removed.

+

<%= pluralize(@changed_count, "value") %> this submission overwrote on records that already existed. The previous value is kept so it can be put back. (New records, added tags, and filled-in blanks aren't shown — they're new data, not changes.)

<% @change_groups.each do |group| %>
@@ -73,7 +73,7 @@ <% else %>
-

This submission didn't change any records.

+

This submission didn't overwrite any existing values.

<% end %>
diff --git a/app/views/form_submissions/show.html.erb b/app/views/form_submissions/show.html.erb index 36ade6d837..3ed703e04c 100644 --- a/app/views/form_submissions/show.html.erb +++ b/app/views/form_submissions/show.html.erb @@ -32,12 +32,15 @@
- <%# Admin-only jump to the audit of what this submission's answers changed. %> - <% if allowed_to?(:changes?, @form_submission) %> + <%# Admin-only jump to the audit of the values this submission overwrote — + shown only when it actually overwrote something. %> + <% submission_changes = FormSubmissionChanges.new(@form_submission) %> + <% if allowed_to?(:changes?, @form_submission) && submission_changes.edited? %> <%= link_to changes_form_submission_path(@form_submission), class: "group mb-5 flex items-center gap-2 rounded-md border border-blue-200 bg-blue-50 px-3 py-2 text-sm font-semibold text-blue-900 hover:bg-blue-100" do %> What this submission changed + <%= submission_changes.edited_count %> admin <% end %> diff --git a/spec/requests/events/form_submissions_spec.rb b/spec/requests/events/form_submissions_spec.rb index 7346f15379..74b8b25eed 100644 --- a/spec/requests/events/form_submissions_spec.rb +++ b/spec/requests/events/form_submissions_spec.rb @@ -20,13 +20,26 @@ expect(response.body).to include(form.name) end - it "links each submission to its admin changes audit" do + it "links a submission to its changes audit once it has overwritten a value" do + org = create(:organization) + create(:ahoy_event, name: "update.organization", properties: { + "resource_type" => "Organization", "resource_id" => org.id, + "form_submission_id" => submission.id, + "changes" => { "website_url" => { "before" => "old.com", "after" => "new.com" } } + }) + get event_registrant_submissions_path(event, person_id: person.id) expect(response.body).to include(changes_form_submission_path(submission)) expect(response.body).to include("What this submission changed") end + it "omits the changes link for a submission that only created new data" do + get event_registrant_submissions_path(event, person_id: person.id) + + expect(response.body).not_to include(changes_form_submission_path(submission)) + end + it "returns 404 when person does not exist" do get event_registrant_submissions_path(event, person_id: 999999) expect(response).to have_http_status(:not_found) diff --git a/spec/requests/form_submissions_spec.rb b/spec/requests/form_submissions_spec.rb index 0de0856816..4630a6bf82 100644 --- a/spec/requests/form_submissions_spec.rb +++ b/spec/requests/form_submissions_spec.rb @@ -227,11 +227,11 @@ def stamp(name, resource_type:, resource_id: 0, properties: {}) expect(response.body).to include("new.com") end - it "shows an empty state when nothing changed" do + it "shows an empty state when nothing was overwritten" do get changes_form_submission_path(submission) expect(response).to have_http_status(:ok) - expect(response.body).to include("didn't change any records") + expect(response.body).to include("didn't overwrite any existing values") end it "returns to the event registrant submissions when arriving from there" do diff --git a/spec/services/form_submission_changes_spec.rb b/spec/services/form_submission_changes_spec.rb index a29db3cf44..152421d68d 100644 --- a/spec/services/form_submission_changes_spec.rb +++ b/spec/services/form_submission_changes_spec.rb @@ -61,6 +61,37 @@ def stamp(name, resource_type:, resource_id: 0, properties: {}) expect(described_class.new(submission).groups).to be_empty end + describe "edited (overwritten) values" do + it "counts and groups only values that overwrote an existing one" do + org = create(:organization, name: "Riverside") + stamp("update.organization", resource_type: "Organization", resource_id: org.id, + properties: { "resource_title" => org.name, "changes" => { + "website_url" => { "before" => "old.com", "after" => "new.com" }, + "agency_type" => { "before" => nil, "after" => "Hospital" } + } }) + + changes = described_class.new(submission) + expect(changes.edited?).to be(true) + expect(changes.edited_count).to eq(1) + expect(changes.edited_groups.sum { |group| group.changes.size }).to eq(1) + expect(changes.edited_groups.first.changes.first).to have_attributes(outcome: "Replaced", value: "new.com") + end + + it "does not count a fresh submission that only creates, adds, and fills blanks" do + person = create(:person) + sector = create(:sector, :published) + stamp("update.person", resource_type: "Person", resource_id: person.id, + properties: { "changes" => { "pronouns" => { "before" => nil, "after" => "she/her" } } }) + stamp("create.sectorable_item", resource_type: "SectorableItem", + properties: { "attributes" => { "sector_id" => sector.id, "sectorable_type" => "Person", "sectorable_id" => person.id } }) + + changes = described_class.new(submission) + expect(changes.edited?).to be(false) + expect(changes.edited_count).to eq(0) + expect(changes.edited_groups).to be_empty + end + end + it "only reads events stamped with this submission" do other = create(:form_submission) create(:ahoy_event, name: "update.person", properties: { From 03bef07c54b4e75cd9da8fadae2c2cc85cdf0149 Mon Sep 17 00:00:00 2001 From: maebeale Date: Fri, 21 Aug 2026 12:02:01 -0400 Subject: [PATCH 4/5] Count edits to existing records, not just overwrites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Changed" means a submission edited a record that already existed — a value replaced or a blank filled (both from an update event). New records and added tags are still excluded as new data. This lets linking an org that wasn't a clean match raise the count, since the fill lands on the pre-existing org. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/services/form_submission_changes.rb | 20 +++++++++---------- app/views/form_submissions/changes.html.erb | 4 ++-- spec/requests/form_submissions_spec.rb | 4 ++-- spec/services/form_submission_changes_spec.rb | 16 +++++++-------- 4 files changed, 21 insertions(+), 23 deletions(-) diff --git a/app/services/form_submission_changes.rb b/app/services/form_submission_changes.rb index 95a8838137..85890e785b 100644 --- a/app/services/form_submission_changes.rb +++ b/app/services/form_submission_changes.rb @@ -25,18 +25,22 @@ def groups .sort_by { |group| [ GROUP_ORDER.index(group.record_type) || GROUP_ORDER.size, group.title.to_s ] } end - # A submission "changed" a value only when it overwrote one that was already - # there. Creating records, adding tags, and filling blanks are new data from a - # new submission — not edits — so they don't count here. + # A submission "changed" a value only when it edited a record that already + # existed — a value replaced, or a blank filled, on that record (both come from + # an update event). Creating new records and adding tags are a new submission's + # own data, not edits, so they don't count. (This is why linking an org that + # wasn't a clean match can raise the count: the fill lands on the existing org.) + EDIT_OUTCOMES = %w[Replaced Filled].freeze + def edited_groups groups.filter_map do |group| - edits = group.changes.select { |change| change.outcome == "Replaced" } + edits = group.changes.select { |change| EDIT_OUTCOMES.include?(change.outcome) } Group.new(record_type: group.record_type, title: group.title, changes: edits) if edits.any? end end def edited_count - relevant_events.sum { |event| replaced_changes(event.properties["changes"]).size } + relevant_events.sum { |event| attribute_changes(event.properties["changes"] || {}).size } end def edited? @@ -80,12 +84,6 @@ def changes_for(event) [] end - def replaced_changes(changes) - return [] if changes.blank? - - attribute_changes(changes).select { |change| change.outcome == "Replaced" } - end - def attribute_changes(changes) changes.except(*IGNORED_ATTRIBUTES).filter_map do |attribute, before_after| before, after = before_after.values_at("before", "after") diff --git a/app/views/form_submissions/changes.html.erb b/app/views/form_submissions/changes.html.erb index 0549042435..c2c7eca963 100644 --- a/app/views/form_submissions/changes.html.erb +++ b/app/views/form_submissions/changes.html.erb @@ -46,7 +46,7 @@
<% if @change_groups.any? %> -

<%= pluralize(@changed_count, "value") %> this submission overwrote on records that already existed. The previous value is kept so it can be put back. (New records, added tags, and filled-in blanks aren't shown — they're new data, not changes.)

+

<%= pluralize(@changed_count, "value") %> this submission changed on records that already existed — a value replaced or a blank filled. A replaced value keeps its previous entry so it can be put back. (Brand-new records and added tags aren't shown — they're new data, not changes.)

<% @change_groups.each do |group| %>
@@ -73,7 +73,7 @@ <% else %>
-

This submission didn't overwrite any existing values.

+

This submission didn't change any existing records.

<% end %>
diff --git a/spec/requests/form_submissions_spec.rb b/spec/requests/form_submissions_spec.rb index 4630a6bf82..ccd212fdbc 100644 --- a/spec/requests/form_submissions_spec.rb +++ b/spec/requests/form_submissions_spec.rb @@ -227,11 +227,11 @@ def stamp(name, resource_type:, resource_id: 0, properties: {}) expect(response.body).to include("new.com") end - it "shows an empty state when nothing was overwritten" do + it "shows an empty state when no existing record was changed" do get changes_form_submission_path(submission) expect(response).to have_http_status(:ok) - expect(response.body).to include("didn't overwrite any existing values") + expect(response.body).to include("didn't change any existing records") end it "returns to the event registrant submissions when arriving from there" do diff --git a/spec/services/form_submission_changes_spec.rb b/spec/services/form_submission_changes_spec.rb index 152421d68d..06966b2b31 100644 --- a/spec/services/form_submission_changes_spec.rb +++ b/spec/services/form_submission_changes_spec.rb @@ -61,8 +61,8 @@ def stamp(name, resource_type:, resource_id: 0, properties: {}) expect(described_class.new(submission).groups).to be_empty end - describe "edited (overwritten) values" do - it "counts and groups only values that overwrote an existing one" do + describe "edited values (changes to records that already existed)" do + it "counts both replaced and filled values on an existing record" do org = create(:organization, name: "Riverside") stamp("update.organization", resource_type: "Organization", resource_id: org.id, properties: { "resource_title" => org.name, "changes" => { @@ -72,16 +72,16 @@ def stamp(name, resource_type:, resource_id: 0, properties: {}) changes = described_class.new(submission) expect(changes.edited?).to be(true) - expect(changes.edited_count).to eq(1) - expect(changes.edited_groups.sum { |group| group.changes.size }).to eq(1) - expect(changes.edited_groups.first.changes.first).to have_attributes(outcome: "Replaced", value: "new.com") + expect(changes.edited_count).to eq(2) + expect(changes.edited_groups.sum { |group| group.changes.size }).to eq(2) + expect(changes.edited_groups.first.changes.map(&:outcome)).to contain_exactly("Replaced", "Filled") end - it "does not count a fresh submission that only creates, adds, and fills blanks" do + it "does not count a fresh submission that only creates records and adds tags" do person = create(:person) sector = create(:sector, :published) - stamp("update.person", resource_type: "Person", resource_id: person.id, - properties: { "changes" => { "pronouns" => { "before" => nil, "after" => "she/her" } } }) + stamp("create.person", resource_type: "Person", resource_id: person.id, + properties: { "resource_title" => person.full_name, "attributes" => { "first_name" => "Dana" } }) stamp("create.sectorable_item", resource_type: "SectorableItem", properties: { "attributes" => { "sector_id" => sector.id, "sectorable_type" => "Person", "sectorable_id" => person.id } }) From 786984bc6c5704d40519cce45ffe0c3eb2f30608 Mon Sep 17 00:00:00 2001 From: maebeale Date: Fri, 21 Aug 2026 12:29:39 -0400 Subject: [PATCH 5/5] Seed a known person whose later registration edited their data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Give Maria Johnson (an existing dev-seed person) a registration that arrived after she was on record and edited six details already there — profile, address, phone, and her org's website/type — recorded as stamped Ahoy events so the "what this submission changed" page has a realistic worked example. Also give a few common columns friendlier labels on that page. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/services/form_submission_changes.rb | 8 +- db/seeds/dev/form_submission_changes.rb | 74 +++++++++++++++++++ lib/tasks/dev.rake | 6 ++ .../form_submission_changes_seed_spec.rb | 37 ++++++++++ spec/services/form_submission_changes_spec.rb | 2 +- 5 files changed, 125 insertions(+), 2 deletions(-) create mode 100644 db/seeds/dev/form_submission_changes.rb create mode 100644 spec/seeds/form_submission_changes_seed_spec.rb diff --git a/app/services/form_submission_changes.rb b/app/services/form_submission_changes.rb index 85890e785b..1925d56c9c 100644 --- a/app/services/form_submission_changes.rb +++ b/app/services/form_submission_changes.rb @@ -9,6 +9,12 @@ class FormSubmissionChanges RELEVANT_TYPES = %w[Person Organization Address ContactMethod Affiliation SectorableItem CategorizableItem].freeze GROUP_ORDER = %w[Person Organization Affiliation].freeze IGNORED_ATTRIBUTES = %w[id created_at updated_at slug locality].freeze + # Friendlier than humanizing the raw column (e.g. "value" on a phone contact). + ATTRIBUTE_LABELS = { + "website_url" => "Website", "agency_type" => "Type", "value" => "Phone", + "racial_ethnic_identity" => "Racial / ethnic identity", "zip_code" => "ZIP", + "street_address" => "Street address" + }.freeze Change = Struct.new(:outcome, :label, :value, :previous_value, keyword_init: true) Group = Struct.new(:record_type, :title, :changes, keyword_init: true) @@ -91,7 +97,7 @@ def attribute_changes(changes) Change.new( outcome: before.present? ? "Replaced" : "Filled", - label: attribute.humanize, + label: ATTRIBUTE_LABELS[attribute] || attribute.humanize, value: display_value(after), previous_value: display_value(before) ) diff --git a/db/seeds/dev/form_submission_changes.rb b/db/seeds/dev/form_submission_changes.rb new file mode 100644 index 0000000000..daad9b3fd5 --- /dev/null +++ b/db/seeds/dev/form_submission_changes.rb @@ -0,0 +1,74 @@ +# Dev-only: give one known-named person (Maria Johnson) a registration that +# arrived AFTER she was already in the database and edited several details that +# were already on record — so the admin "what this form submission changed" page +# has a rich, realistic example. The edits are stored as stamped Ahoy lifecycle +# events (the same shape the live registration flow produces), keyed to her +# registration's form submission. + +event = Event.joins(:event_forms).where(event_forms: { role: "registration" }).first +registration_form = event&.registration_form +maria = Person.find_by("LOWER(first_name) = ? AND LOWER(last_name) = ?", "maria", "johnson") + +if maria.nil? || registration_form.nil? + puts " Skipping form-submission-changes seed (missing Maria or a registration form/event)." +else + org = maria.organizations.first || Organization.first + + # Move Maria and her org into their post-registration ("after") state; the + # events below carry the previous values so the page can show what changed. + maria.update!(racial_ethnic_identity: "Latina") + address = maria.addresses.order(:id).first || + maria.addresses.create!(street_address: "250 New Ave", city: "Los Angeles", state: "CA", zip_code: "90012", locality: "LA City", address_type: "mailing", primary: true) + address.update!(street_address: "250 New Ave", zip_code: "90012") + phone = maria.contact_methods.where(kind: :phone).order(:id).first || + maria.contact_methods.create!(kind: :phone, value: "(310) 555-0199", contact_type: "personal", primary: true) + phone.update!(value: "(310) 555-0199") + org&.update!(website_url: "newsite.org", agency_type: "Hospital") + + submission = FormSubmission.find_or_create_by!(person: maria, form: registration_form, event: event, role: "registration") do |record| + record.created_at = 2.days.ago + end + + # Surface it on the linked-organizations and registrant-submission pages too. + begin + if org + registration = EventRegistration.find_or_create_by!(registrant: maria, event: event) + registration.event_registration_organizations.find_or_create_by!(organization: org).record_form_submission(submission) + end + rescue ActiveRecord::RecordInvalid => e + puts " (Couldn't attach a registration for the linking-page demo: #{e.message})" + end + + if Ahoy::Event.where("properties->>'$.form_submission_id' = ?", submission.id.to_s).exists? + puts " Form-submission-changes seed already present for #{maria.full_name}." + else + visit = Ahoy::Visit.create!(visit_token: SecureRandom.uuid, visitor_token: SecureRandom.uuid, started_at: 2.days.ago) + + edits = [ + { name: "update.person", type: "Person", id: maria.id, title: maria.full_name, + changes: { "racial_ethnic_identity" => { "before" => "Prefer not to say", "after" => "Latina" } } }, + { name: "update.address", type: "Address", id: address.id, title: maria.full_name, + attributes: { "addressable_type" => "Person", "addressable_id" => maria.id }, + changes: { "street_address" => { "before" => "100 Old St", "after" => "250 New Ave" }, + "zip_code" => { "before" => "90001", "after" => "90012" } } }, + { name: "update.contact_method", type: "ContactMethod", id: phone.id, title: maria.full_name, + attributes: { "contactable_type" => "Person", "contactable_id" => maria.id }, + changes: { "value" => { "before" => "(310) 555-0001", "after" => "(310) 555-0199" } } } + ] + if org + edits << { name: "update.organization", type: "Organization", id: org.id, title: org.name, + changes: { "website_url" => { "before" => "oldsite.org", "after" => "newsite.org" }, + "agency_type" => { "before" => "Nonprofit", "after" => "Hospital" } } } + end + + edits.each do |edit| + properties = { "resource_type" => edit[:type], "resource_id" => edit[:id], "resource_title" => edit[:title], + "form_submission_id" => submission.id, "changes" => edit[:changes] } + properties["attributes"] = edit[:attributes] if edit[:attributes] + Ahoy::Event.create!(visit: visit, name: edit[:name], resource_type: edit[:type], resource_id: edit[:id], + properties: properties, time: 2.days.ago) + end + + puts " Seeded a post-registration edit trail for #{maria.full_name} (#{edits.sum { |edit| edit[:changes].size }} changed values)." + end +end diff --git a/lib/tasks/dev.rake b/lib/tasks/dev.rake index 3da230837f..cef92b45c6 100644 --- a/lib/tasks/dev.rake +++ b/lib/tasks/dev.rake @@ -13,6 +13,7 @@ namespace :db do workshop_logs monthly_reports events_management + form_submission_changes resources faqs video_recordings @@ -80,6 +81,11 @@ namespace :db do load Rails.root.join("db/seeds/dev/resources.rb") end + desc "Seed a post-registration edit trail for a known person (dev only)" + task form_submission_changes: :environment do + load Rails.root.join("db/seeds/dev/form_submission_changes.rb") + end + desc "Seed dev FAQs" task faqs: :environment do load Rails.root.join("db/seeds/dev/faqs.rb") diff --git a/spec/seeds/form_submission_changes_seed_spec.rb b/spec/seeds/form_submission_changes_seed_spec.rb new file mode 100644 index 0000000000..38c7a176f9 --- /dev/null +++ b/spec/seeds/form_submission_changes_seed_spec.rb @@ -0,0 +1,37 @@ +require "rails_helper" + +RSpec.describe "dev seed: form_submission_changes" do + it "gives Maria Johnson a stamped post-registration edit trail on the changes page" do + maria = create(:person, first_name: "Maria", last_name: "Johnson") + form = create(:form) + event = create(:event) + create(:event_form, :registration, event: event, form: form) + org = create(:organization, name: "Helping Hands") + create(:affiliation, person: maria, organization: org) + + load Rails.root.join("db/seeds/dev/form_submission_changes.rb") + + submission = FormSubmission.find_by!(person: maria, role: "registration") + changes = FormSubmissionChanges.new(submission) + + expect(changes.edited?).to be(true) + expect(changes.edited_count).to eq(6) + expect(changes.edited_groups.map(&:record_type)).to contain_exactly("Person", "Organization") + end + + it "is idempotent — re-running adds no duplicate events" do + create(:person, first_name: "Maria", last_name: "Johnson") + event = create(:event) + create(:event_form, :registration, event: event, form: create(:form)) + create(:organization) + + load Rails.root.join("db/seeds/dev/form_submission_changes.rb") + submission = FormSubmission.find_by!(role: "registration") + first_count = Ahoy::Event.where("properties->>'$.form_submission_id' = ?", submission.id.to_s).count + + load Rails.root.join("db/seeds/dev/form_submission_changes.rb") + second_count = Ahoy::Event.where("properties->>'$.form_submission_id' = ?", submission.id.to_s).count + + expect(second_count).to eq(first_count) + end +end diff --git a/spec/services/form_submission_changes_spec.rb b/spec/services/form_submission_changes_spec.rb index 06966b2b31..917d183875 100644 --- a/spec/services/form_submission_changes_spec.rb +++ b/spec/services/form_submission_changes_spec.rb @@ -20,7 +20,7 @@ def stamp(name, resource_type:, resource_id: 0, properties: {}) group = described_class.new(submission).groups.find { |g| g.record_type == "Organization" } expect(group.title).to eq("Riverside Community Arts") change = group.changes.first - expect(change).to have_attributes(outcome: "Replaced", label: "Website url", value: "new.com", previous_value: "old.com") + expect(change).to have_attributes(outcome: "Replaced", label: "Website", value: "new.com", previous_value: "old.com") end it "labels a change from blank as filled" do