Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions app/controllers/form_submissions_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 4 additions & 0 deletions app/policies/form_submission_policy.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
138 changes: 138 additions & 0 deletions app/services/form_submission_changes.rb
Original file line number Diff line number Diff line change
@@ -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
15 changes: 15 additions & 0 deletions app/views/event_registrations/link_organization.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,21 @@
</li>
<% end %>
</ul>
<%# 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 %>
<i class="fa-solid fa-circle-info text-blue-500"></i>
What this registration's form changed
<span class="rounded-full bg-blue-200 px-1.5 text-2xs"><%= submission_changes.edited_count %></span>
<i class="fa-solid fa-arrow-right text-xs ml-auto text-blue-400 transition-transform group-hover:translate-x-0.5"></i>
<% end %>
<% end %>
<% elsif @form_submission %>
<p class="text-sm text-gray-500">
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" } %>.
Expand Down
13 changes: 13 additions & 0 deletions app/views/events/form_submissions/show.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,19 @@
<p class="text-xs text-gray-500">Submitted <%= submission.created_at.strftime("%B %d, %Y at %l:%M %P") %></p>
</div>

<%# 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 %>
<i class="fa-solid fa-circle-info text-blue-500"></i>
What this submission changed
<span class="rounded-full bg-blue-200 px-1.5 text-2xs"><%= submission_changes.edited_count %></span>
<span class="ml-1 rounded-full bg-white/70 text-blue-700 px-1.5 py-0.5 text-2xs font-normal border border-blue-200"><i class="fa-solid fa-lock text-2xs mr-0.5"></i>admin</span>
<i class="fa-solid fa-arrow-right text-xs ml-auto text-blue-400 transition-transform group-hover:translate-x-0.5"></i>
<% end %>
<% end %>

<% sections = [] %>
<% current = nil %>

Expand Down
81 changes: 81 additions & 0 deletions app/views/form_submissions/changes.html.erb
Original file line number Diff line number Diff line change
@@ -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"
}
%>
<div class="max-w-3xl mx-auto py-8 px-4">
<div class="mb-4">
<%= link_to back_link[:label], back_link[:path], class: "text-sm text-gray-500 hover:text-gray-700" %>
</div>

<div class="rounded-2xl overflow-hidden shadow-lg ring-1 ring-black/5 bg-white">
<div class="px-6 pt-5 pb-5 border-b border-gray-100">
<div class="flex items-center gap-3">
<div class="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-blue-900 text-white">
<i class="fa-solid fa-wand-magic-sparkles"></i>
</div>
<div class="min-w-0">
<div class="flex items-center gap-2">
<h1 class="text-2xl font-bold tracking-tight text-gray-900">What this form submission changed</h1>
<span class="inline-flex items-center gap-1 rounded-full bg-blue-100 text-blue-900 px-2 py-0.5 text-xs font-semibold uppercase tracking-wide"><i class="fa-solid fa-lock text-2xs"></i> Admin</span>
</div>
<p class="text-sm text-gray-500">
<%= @form_submission.person&.full_name %> Β· <%= @form_submission.form&.display_name || @form_submission.form&.name %> Β· <%= @form_submission.created_at.to_date.to_fs(:long) %>
</p>
</div>
</div>
</div>

<div class="px-6 py-6 space-y-5">
<% if @change_groups.any? %>
<p class="text-xs text-gray-500"><strong><%= pluralize(@changed_count, "value") %></strong> 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.)</p>

<% @change_groups.each do |group| %>
<section class="rounded-xl border border-gray-200 overflow-hidden">
<div class="flex items-center gap-2 px-5 py-3 bg-gray-50 border-b border-gray-200">
<i class="fa-solid <%= group_icon.fetch(group.record_type, "fa-database") %> text-gray-500"></i>
<h2 class="text-sm font-semibold text-gray-800"><%= group.record_type.underscore.humanize %><% if group.title.present? %> β€” <%= group.title %><% end %></h2>
</div>
<ul class="divide-y divide-gray-100">
<% group.changes.each do |change| %>
<li class="px-5 py-3 flex items-start justify-between gap-3 text-sm">
<div class="min-w-0">
<p class="text-gray-500"><%= change.label %></p>
<p class="text-gray-800">
<span class="font-medium"><%= change.value.presence || "β€”" %></span><%
%><% if change.outcome == "Replaced" && change.previous_value.present? %> <span class="text-rose-600">(replaced β€œ<%= change.previous_value %>”)</span><% end %>
</p>
</div>
<span class="shrink-0 inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-semibold <%= outcome_chip.fetch(change.outcome, "bg-gray-100 text-gray-700 border-gray-200") %>"><%= change.outcome %></span>
</li>
<% end %>
</ul>
</section>
<% end %>
<% else %>
<div class="rounded-xl border border-gray-200 bg-gray-50 px-5 py-8 text-center text-gray-500">
<i class="fa-solid fa-circle-check text-gray-400 text-xl mb-2"></i>
<p>This submission didn't change any existing records.</p>
</div>
<% end %>
</div>
</div>
</div>
13 changes: 13 additions & 0 deletions app/views/form_submissions/show.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,19 @@
<div class="absolute inset-x-0 bottom-0 h-1 bg-gradient-to-r from-accent via-amber-400 to-accent"></div>
</div>
<div class="px-6 sm:px-8 py-7">
<%# 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 %>
<i class="fa-solid fa-circle-info text-blue-500"></i>
What this submission changed
<span class="rounded-full bg-blue-200 px-1.5 text-2xs"><%= submission_changes.edited_count %></span>
<span class="ml-1 rounded-full bg-white/70 text-blue-700 px-1.5 py-0.5 text-2xs font-normal border border-blue-200"><i class="fa-solid fa-lock text-2xs mr-0.5"></i>admin</span>
<i class="fa-solid fa-arrow-right text-xs ml-auto text-blue-400 transition-transform group-hover:translate-x-0.5"></i>
<% end %>
<% end %>
<%= render "form_submissions/submission", submission: @form_submission %>

<% if event && @form_submission.role == "bulk_payment" %>
Expand Down
4 changes: 3 additions & 1 deletion config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading