Skip to content
Draft
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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ This codebase (Rails 8.1)
| `Story` | Editorial content with facilitators, primary/gallery assets |
| `Resource` | Handouts, toolkits, templates with downloadable assets |
| `Person` | Organization affiliates with contacts, addresses, sectors |
| `OtherResponse` | A free-text "Other" a person typed on a tag-backed form question (`kind: "sector"` today), captured at registration so a curator can `promote` it into a real `Sector` tag, `keep` it as a chip, or `dismiss` it (hide from profile/edit). Reviewed at `/other_responses` |
| `Organization` | Groups with affiliations, addresses, logos via ActiveStorage |
| `Grant` | Donated funds (polymorphic `donor`: Organization or Person) with eligibility criteria, tasks, deadlines; parent of `Scholarship`. Scholarship totals cannot exceed the grant amount |
| `Scholarship` | Award to a `Person`; optionally drawn from a `Grant`, syncs to event registration `Allocation` |
Expand Down
96 changes: 96 additions & 0 deletions app/controllers/other_responses_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
class OtherResponsesController < ApplicationController
before_action :set_other_response, only: :update

# Review page: the same free-text "Other" sector value typed across many
# people, grouped with a count so a curator can decide what to promote.
def index
authorize!
@status_filter = params[:status].presence_in(OtherResponse::VISIBLE_STATUSES)
statuses = @status_filter ? [ @status_filter ] : OtherResponse::VISIBLE_STATUSES
responses = OtherResponse
.sectors.where(status: statuses)
.includes(:person)

@groups = responses.group_by(&:normalized_text).map do |_normalized, rows|
{
display_text: rows.first.text,
normalized_text: rows.first.normalized_text,
count: rows.size,
status_counts: rows.each_with_object(Hash.new(0)) { |r, h| h[r.status] += 1 }
}
end.sort_by { |group| [ -group[:count], group[:display_text].downcase ] }

@sectors = Sector.excluding_other.order(:name)
end

# Bulk keep/dismiss every visible person who typed this value, from the review
# queue. Keep leaves it as a free-text chip; dismiss hides it from profiles.
def curate

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸ€– From Claude: Bulk keep/dismiss acts on the whole grouped value (all VISIBLE_STATUSES rows for this normalized_text), mirroring how promote fans out across everyone who typed it. return_status round-trips the active filter so the queue stays put after the action.

authorize! to: :update?
status = params[:status]
unless %w[kept dismissed].include?(status)
return redirect_to other_responses_path, alert: "Choose keep or dismiss."
end

scope = OtherResponse.sectors.where(status: OtherResponse::VISIBLE_STATUSES)
.where(normalized_text: OtherResponse.normalize(params[:normalized_text]))
count = scope.count
scope.find_each { |response| response.update!(status: status) }

verb = status == "kept" ? "Kept" : "Dismissed"
redirect_to other_responses_path(status: params[:return_status].presence),
status: :see_other, notice: "#{verb} #{count} response(s)."
end

# Curate a single response β€” the profile-edit "Γ—" dismisses, and the review
# page can keep an individual person's response.
def update
authorize! @other_response
status = params.dig(:other_response, :status)
@other_response.update!(status: status) if OtherResponse::STATUSES.include?(status)

if params[:return_to] == "person_edit"
redirect_to edit_person_path(@other_response.person), status: :see_other
else
redirect_to other_responses_path, status: :see_other
end
end

# Promote every non-dismissed person who typed this value into a real Sector
# tag β€” mapping to an existing sector or minting a new (published) one β€” and
# mark those responses promoted so they stop showing as free-text chips.
def promote
authorize! to: :promote?
sector = target_sector
return redirect_to other_responses_path, alert: "Pick or name a sector to promote to." unless sector

responses = OtherResponse.sectors.promotable_now

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸ€– From Claude: promotable_now excludes dismissed β€” so "dismissed wins": promoting a value never re-tags someone who deliberately hid it.

.where(normalized_text: OtherResponse.normalize(params[:normalized_text]))

responses.includes(:person).find_each do |response|
response.person.tag_sectors(primary_ids: [], additional_ids: [ sector.id ])
response.update!(status: "promoted", promotable: sector)
end

redirect_to other_responses_path, status: :see_other,
notice: "Promoted #{responses.size} response(s) to β€œ#{sector.name}”."
end

private

def set_other_response
@other_response = OtherResponse.find(params[:id])
end

# The promote target: an existing sector by id, or a newly minted published one
# from a typed name. Returns nil when neither was supplied.
def target_sector
if params[:sector_id].present?
Sector.find_by(id: params[:sector_id])
elsif params[:new_sector_name].present?
Sector.find_or_create_by!(name: params[:new_sector_name].strip) do |sector|
sector.published = true
end
end
end
end
50 changes: 50 additions & 0 deletions app/models/other_response.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
class OtherResponse < ApplicationRecord
# The free-text "Other" a person typed on a tag-backed form question. Only
# sectors are wired up today; the `kind` column leaves room for the identical
# workshop-setting responses to move here later.
KINDS = %w[sector].freeze

# A response starts life as `pending` (awaiting a curator's decision) and is
# then either promoted into a real tag, kept as a free-text chip, or dismissed
# (hidden from the person's profile and edit form).
STATUSES = %w[pending kept promoted dismissed].freeze

# Statuses that still surface as an "(other)" chip on the person's pages.
VISIBLE_STATUSES = %w[pending kept].freeze

belongs_to :person
belongs_to :promotable, polymorphic: true, optional: true
belongs_to :source_form_answer, class_name: "FormAnswer", optional: true

before_validation :set_normalized_text

validates :text, presence: true
validates :kind, inclusion: { in: KINDS }
validates :status, inclusion: { in: STATUSES }
validates :normalized_text, uniqueness: { scope: [ :person_id, :kind ] }

scope :sectors, -> { where(kind: "sector") }
scope :visible, -> { where(status: VISIBLE_STATUSES) }
scope :pending, -> { where(status: "pending") }
scope :promotable_now, -> { where.not(status: "dismissed") }

# Case/whitespace-insensitive key used both for the unique index and for
# grouping the same typed value across many people on the review page.
def self.normalize(value)
value.to_s.strip.downcase
end

def dismiss!
update!(status: "dismissed")
end

def keep!
update!(status: "kept")
end

private

def set_normalized_text
self.normalized_text = self.class.normalize(text)
end
end
9 changes: 6 additions & 3 deletions app/models/person.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ class Person < ApplicationRecord
has_many :categorizable_items, inverse_of: :categorizable, as: :categorizable, dependent: :destroy
has_many :notifications, as: :noticeable, dependent: :destroy
has_many :sectorable_items, as: :sectorable, dependent: :destroy
has_many :other_responses, dependent: :destroy
has_many :stories_as_spotlighted_facilitator, inverse_of: :spotlighted_facilitator, class_name: "Story",
dependent: :restrict_with_error
has_many :stories_as_author, inverse_of: :author, class_name: "Story", foreign_key: :author_id,
Expand Down Expand Up @@ -285,10 +286,12 @@ def remote_search_label
# profile fields shown on the edit page.
OTHER_WORKSHOP_SETTING_IDENTIFIERS = %w[primary_age_group additional_age_group].freeze

# Free-text "Other" sectors the person typed on registration forms.
# They can't be Sector records, so they're surfaced beside the sector tags.
# Free-text "Other" sectors the person typed on registration forms, captured
# as OtherResponse records (see EventRegistrationServices::PublicRegistration).
# They can't be Sector records, so they're surfaced beside the sector tags β€”
# only while pending or explicitly kept (dismissed/promoted ones drop off).
def other_sector_responses
other_form_responses(FormField::SECTOR_FIELD_IDENTIFIERS)
other_responses.sectors.visible.order(:text)
end

# Free-text "Other" workshop settings (category-backed fields) from forms.
Expand Down
8 changes: 8 additions & 0 deletions app/policies/other_response_policy.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
class OtherResponsePolicy < ApplicationPolicy
# Curating free-text "Other" responses (reviewing, promoting, keeping,
# dismissing) is an admin-only task. index?/update? fall back to manage?.

def promote?
admin?
end
end
22 changes: 22 additions & 0 deletions app/services/event_registration_services/public_registration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -422,6 +422,7 @@ def invoice_requested?
def create_form_submission(person)
submission = FormSubmission.create!(person: person, form: @form, event: @event)
save_form_answers(submission)
capture_other_sector_responses(submission)
submission
end

Expand All @@ -430,9 +431,30 @@ def update_form_submission(person)
record.event = @event
end
save_form_answers(submission)
capture_other_sector_responses(submission)
submission
end

# Materialize the free-text "Other" sector answers as OtherResponse records so
# they can be curated (promoted/kept/dismissed). Reuses OtherOption.texts, the
# same extraction used to display them, and de-dupes on the person's normalized
# value so a repeat registration of the same text doesn't create a second row.
def capture_other_sector_responses(submission)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸ€– From Claude: Capture reuses OtherOption.texts (the same extraction the display used) and find_or_create_by on the person + normalized value, so a repeat registration of the same "Other" text doesn't create a second row or trip the unique index.

submission.form_answers
.joins(:form_field)
.where(form_fields: { field_identifier: FormField::SECTOR_FIELD_IDENTIFIERS })
.each do |answer|
OtherOption.texts(answer.submitted_answer).each do |text|
submission.person.other_responses.find_or_create_by!(
kind: "sector", normalized_text: OtherResponse.normalize(text)
) do |response|
response.text = text
response.source_form_answer = answer
end
end
end
end

def save_form_answers(submission)
@form_params.each do |field_id, raw_value|
field = @form.form_fields.find_by(id: field_id)
Expand Down
79 changes: 79 additions & 0 deletions app/views/other_responses/index.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
<% content_for(:page_bg_class, "admin-only bg-blue-100") %>
<%= link_to sectors_path, class: "inline-flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-700 px-2 py-1 mb-2" do %>
<i class="fa-solid fa-arrow-left text-xs"></i> Sectors
<% end %>
<div class="<%= DomainTheme.bg_class_for(:sectors) %> border border-gray-200 rounded-xl shadow p-6">
<div class="space-y-6">
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-2xl font-semibold text-gray-900">Review β€œOther” sectors</h1>
<p class="text-sm text-gray-600 mt-1">
Free-text sectors people typed on registration forms, grouped by value.
Promote a recurring one into a real sector tag for everyone who typed it.
</p>
</div>
</div>

<!-- Status filter -->
<div class="flex items-center gap-2 text-sm">
<% [ [ "All", nil ], [ "Pending", "pending" ], [ "Kept", "kept" ] ].each do |label, value| %>
<% active = @status_filter == value %>
<%= link_to label, other_responses_path(status: value),
class: "px-3 py-1 rounded-full #{active ? "bg-gray-800 text-white" : "bg-white text-gray-600 border border-gray-300 hover:bg-gray-100"}" %>
<% end %>
</div>

<div class="rounded-xl bg-white p-6">
<% if @groups.any? %>
<table class="w-full border-collapse border border-gray-200">
<thead class="bg-gray-100">
<tr>
<th class="px-4 py-2 text-left text-sm font-semibold text-gray-700">Response</th>
<th class="px-4 py-2 text-center text-sm font-semibold text-gray-700 w-24">People</th>
<th class="px-4 py-2 text-left text-sm font-semibold text-gray-700 w-1/2">Promote to a sector</th>
</tr>
</thead>
<tbody>
<% @groups.each do |group| %>
<tr class="border-t border-gray-200 align-top">
<td class="px-4 py-3 text-sm text-gray-800">
<span class="font-medium"><%= group[:display_text] %></span>
<% if group[:status_counts]["kept"].to_i.positive? %>
<span class="ml-1 text-xs text-gray-400">(<%= group[:status_counts]["kept"] %> kept)</span>
<% end %>
</td>
<td class="px-4 py-3 text-center text-sm text-gray-700"><%= group[:count] %></td>
<td class="px-4 py-3">
<%= form_with url: promote_other_responses_path, method: :post,
class: "flex flex-wrap items-center gap-2" do %>
<%= hidden_field_tag :normalized_text, group[:normalized_text] %>
<%= select_tag :sector_id,
options_from_collection_for_select(@sectors, :id, :name),
include_blank: "Existing sector…",
class: "rounded-md border-gray-300 text-sm" %>
<span class="text-xs text-gray-400">or</span>
<%= text_field_tag :new_sector_name, group[:display_text],
placeholder: "New sector name",
class: "rounded-md border-gray-300 text-sm" %>
<%= submit_tag "Promote", class: "btn btn-primary-outline text-sm",
data: { turbo_confirm: "Promote β€œ#{group[:display_text]}” for all #{group[:count]} people?" } %>
<% end %>
<div class="flex items-center gap-3 mt-2 text-sm">
<%= button_to "Keep all", curate_other_responses_path(normalized_text: group[:normalized_text], status: "kept", return_status: @status_filter),
class: "text-gray-500 hover:text-gray-700" %>
<%= button_to "Dismiss all", curate_other_responses_path(normalized_text: group[:normalized_text], status: "dismissed", return_status: @status_filter),
class: "text-gray-500 hover:text-red-600",
form: { data: { turbo_confirm: "Hide β€œ#{group[:display_text]}” from all #{group[:count]} profiles?" } } %>
</div>
</td>
</tr>
<% end %>
</tbody>
</table>
<% else %>
<p class="text-gray-500 italic">No β€œOther” sector responses to review.</p>
<% end %>
</div>
</div>
</div>
2 changes: 1 addition & 1 deletion app/views/people/_form.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@
.reject { |_, id| (@current_sector_ids || []).include?(id) },
show_admin_flags: true } },
class: "btn btn-secondary-outline" %>
<%= render "people/other_responses", responses: @person.other_sector_responses %>
<%= render "people/other_sector_responses", responses: @person.other_sector_responses, dismissable: true %>
</div>
</div>

Expand Down
20 changes: 20 additions & 0 deletions app/views/people/_other_sector_responses.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<%# Chips for a person's free-text "Other" sector responses (OtherResponse
records). Read-only on the profile; on the edit form (dismissable: true)
each chip carries an Γ— that dismisses it (hides it from the profile/edit).
Renders bare chips β€” the caller supplies the surrounding flex container. %>
<% responses ||= [] %>
<% dismissable = local_assigns.fetch(:dismissable, false) %>
<% responses.each do |response| %>
<span class="inline-flex items-center rounded-md border border-gray-300 bg-white text-gray-600 px-3 py-1 text-sm font-medium"
title="Other response entered during registration">
<%= response.text %>
<span class="ml-1 text-xs text-gray-400">(other)</span>
<% if dismissable %>
<%= link_to "Γ—",

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸ€– From Claude: Dismiss is a link_to with turbo_method: :patch, not button_to β€” this chip lives inside the person edit <form>, and a nested <form> (what button_to emits) is invalid HTML.

other_response_path(response, other_response: { status: "dismissed" }, return_to: "person_edit"),
data: { turbo_method: :patch },
class: "ml-2 -mr-1 px-1 leading-none text-gray-400 hover:text-red-600",
title: "Hide this response from the profile" %>
<% end %>
</span>
<% end %>
2 changes: 1 addition & 1 deletion app/views/people/show.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@
display_leader: true,
is_leader: si.is_leader %>
<% end %>
<%= render "people/other_responses", responses: other_sectors %>
<%= render "people/other_sector_responses", responses: other_sectors %>
</div>
<% else %>
<p class="text-gray-500 italic">None selected.</p>
Expand Down
3 changes: 3 additions & 0 deletions app/views/sectors/index.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@
Sectors (<%= @count_display %>)
</h1>
<div class="flex gap-2">
<%= link_to "Review β€œOther”",
other_responses_path,
class: "text-sm text-gray-500 hover:text-gray-700 px-2 py-1" %>
<%= link_to "Dedupe",
dedupe_index_sectors_path,
class: "text-sm text-gray-500 hover:text-gray-700 px-2 py-1" %>
Expand Down
6 changes: 6 additions & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,12 @@
resources :comments, only: [ :index, :create, :update ]
end
resources :faqs
resources :other_responses, only: [ :index, :update ] do
collection do
post :promote
post :curate
end
end
resources :notifications, only: [ :index, :show, :update ] do
member do
post :resend
Expand Down
28 changes: 28 additions & 0 deletions db/migrate/20260705014639_create_other_responses.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
class CreateOtherResponses < ActiveRecord::Migration[8.1]
# Materializes the free-text "Other" answers people type on tag-backed form
# questions (sectors today; workshop settings could follow via `kind`). These
# can't be Sector/Category records, so they were previously derived on the fly
# from form answers. Capturing them as records lets a curator promote a
# recurring value into a real tag, keep it as a free-text chip, or dismiss it.
def up
return if table_exists?(:other_responses)

create_table :other_responses do |t|
t.references :person, null: false, foreign_key: true
t.string :kind, null: false
t.string :text, null: false
t.string :normalized_text, null: false
t.string :status, null: false, default: "pending"
t.references :promotable, polymorphic: true, null: true
t.references :source_form_answer, null: true, foreign_key: { to_table: :form_answers }
t.timestamps
end

add_index :other_responses, [ :person_id, :kind, :normalized_text ],
unique: true, name: "index_other_responses_on_person_kind_text"
end

def down
drop_table :other_responses, if_exists: true
end
end
Loading