diff --git a/app/controllers/form_submissions_controller.rb b/app/controllers/form_submissions_controller.rb
index 5218498e6..4d44ee4b8 100644
--- a/app/controllers/form_submissions_controller.rb
+++ b/app/controllers/form_submissions_controller.rb
@@ -21,4 +21,14 @@ 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?
+ changes = FormSubmissionChanges.new(@form_submission)
+ @change_groups = changes.edited_groups
+ @changed_count = changes.edited_count
+ end
end
diff --git a/app/policies/form_submission_policy.rb b/app/policies/form_submission_policy.rb
index c0b3c0ec7..77eedf349 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 000000000..1925d56c9
--- /dev/null
+++ b/app/services/form_submission_changes.rb
@@ -0,0 +1,138 @@
+# 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
+ # 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)
+
+ 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
+
+ # 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| 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| attribute_changes(event.properties["changes"] || {}).size }
+ end
+
+ def edited?
+ edited_count.positive?
+ 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_LABELS[attribute] || 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 bf0dbf1f9..36c585a26 100644
--- a/app/views/event_registrations/link_organization.html.erb
+++ b/app/views/event_registrations/link_organization.html.erb
@@ -68,6 +68,21 @@
<% end %>
+ <%# 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] %>
+ <% 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 %>
<% 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/events/form_submissions/show.html.erb b/app/views/events/form_submissions/show.html.erb
index 363b7df0e..b6776cd4c 100644
--- a/app/views/events/form_submissions/show.html.erb
+++ b/app/views/events/form_submissions/show.html.erb
@@ -19,6 +19,19 @@
Submitted <%= submission.created_at.strftime("%B %d, %Y at %l:%M %P") %>
+ <%# 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 %>
+ <% end %>
+
<% sections = [] %>
<% current = nil %>
diff --git a/app/views/form_submissions/changes.html.erb b/app/views/form_submissions/changes.html.erb
new file mode 100644
index 000000000..c2c7eca96
--- /dev/null
+++ b/app/views/form_submissions/changes.html.erb
@@ -0,0 +1,81 @@
+<% 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?
+ 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) }
+
+ 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? %>
+
<%= 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| %>
+
+
+ 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 existing records.
+
+ <% end %>
+
+
+
diff --git a/app/views/form_submissions/show.html.erb b/app/views/form_submissions/show.html.erb
index 7e92fe3e8..3ed703e04 100644
--- a/app/views/form_submissions/show.html.erb
+++ b/app/views/form_submissions/show.html.erb
@@ -32,6 +32,19 @@
+ <%# 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 %>
+ <% 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 758afa847..bfb129f6b 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/db/seeds/dev/form_submission_changes.rb b/db/seeds/dev/form_submission_changes.rb
new file mode 100644
index 000000000..daad9b3fd
--- /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 3da230837..cef92b45c 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/requests/events/form_submissions_spec.rb b/spec/requests/events/form_submissions_spec.rb
index 82edf508b..74b8b25ee 100644
--- a/spec/requests/events/form_submissions_spec.rb
+++ b/spec/requests/events/form_submissions_spec.rb
@@ -20,6 +20,26 @@
expect(response.body).to include(form.name)
end
+ 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 ff6f46afd..ccd212fdb 100644
--- a/spec/requests/form_submissions_spec.rb
+++ b/spec/requests/form_submissions_spec.rb
@@ -200,4 +200,58 @@
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 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 change any existing 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
+ 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/seeds/form_submission_changes_seed_spec.rb b/spec/seeds/form_submission_changes_seed_spec.rb
new file mode 100644
index 000000000..38c7a176f
--- /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
new file mode 100644
index 000000000..917d18387
--- /dev/null
+++ b/spec/services/form_submission_changes_spec.rb
@@ -0,0 +1,104 @@
+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", 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
+
+ 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" => {
+ "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(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 records and adds tags" do
+ person = create(:person)
+ sector = create(:sector, :published)
+ 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 } })
+
+ 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: {
+ "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 f0c5c1e27..ae350be3e 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",