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",