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
9 changes: 9 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,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" typed on a form question, captured at submission time (registration, scholarship, bulk payment). Polymorphic `owner`: a **sector** "Other" is owned by the `Person` (promotable into a `Sector`, shown on their profile/edit chip); an **organization_type** "Other" is owned by the `Organization` (stored now, not promotable until `OrganizationType` is a model). `generic` questions aren't captured β€” that stays searchable in the form answers. `field_identifier` records the question; `kind` is derived. Curated at `/other_responses` (grouped by kind/question): `promote` (sectors only), `keep`, `dismiss`. Admins deep-link there from a person's chip. |
| `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 Expand Up @@ -212,6 +213,14 @@ end

- `AffiliationServices::CreateFromRegistration` β€” On registration / org linking, creates a "job affiliation" with the typed title (when present) plus a standing "Facilitator" affiliation, in one transaction. Skips the facilitator one only when the person already has an active-or-pending affiliation titled exactly "Facilitator" with that org (a current one or one dated to a future training); an ended facilitator affiliation gets a fresh second one. Dedupe is by title + org + dates, so a job title like "Lead Facilitator" still gets its own Facilitator affiliation. Accepts an optional `organization_address:` and sets it on every affiliation it creates (the registrant's typed agency address, upserted onto the org); when an affiliation already exists and is skipped, it backfills that address onto the existing one only if it has none (an admin-set address is never overwritten)

### Sectors

- `SectorTagging` β€” `.apply(person:, organizations:, primary_ids:, additional_ids:)` tags a person's sectors (primary + additional) and mirrors them onto the given organizations as additional-only (orgs aggregate members' sectors and have no primary). Shared by registration (person's selections onto the org they registered with) and "Other" sector-response promotion (additional only). Promotion passes `OtherResponse#registration_organizations`, which derives the org from the response's `source_form_answer` β†’ submission β†’ the person's registration for that event β€” so it tags exactly the org registration would have, without storing one on the response

### Other responses

- `OtherResponses::CaptureFromSubmission` β€” Materializes a form submission's **person-owned** "Other" answers (sectors) as `OtherResponse` records; org-type "Other" is captured separately in `PublicRegistration#sync_agency_type` (owned by the org). Uses `OtherOption.texts`, which keys strictly on the `Other:` prefix, so named specify options and the CE `Yes: N` box are ignored; de-dupes per owner + question. Shared by the registration, scholarship, and bulk-payment submission paths

### Organizations

- `OrganizationServices::UpsertAddress` β€” Find-or-create an organization's "work" address from a registrant's submitted agency fields (street/city/state/zip/country). Updates the matching city/state address in place, else adds a new one; never demotes the org's existing primary (a registrant's address becomes primary only when the org has none yet). Returns nil when no city is given. Shared by `PublicRegistration` and the admin org-linking actions so both build the org address identically before linking the affiliation to it
Expand Down
132 changes: 132 additions & 0 deletions app/controllers/other_responses_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
class OtherResponsesController < ApplicationController
before_action :set_other_response, only: :update

# Review page: the same free-text "Other" value typed across many people,
# grouped so a curator can decide what to do. Sector "Other"s are bucketed
# together (they all promote into the one Sector catalog); every other question
# is grouped on its own, since its "Other"s are unrelated auxiliary data.
def index
authorize!
@status_filter = params[:status].presence_in(OtherResponse::VISIBLE_STATUSES)
statuses = @status_filter ? [ @status_filter ] : OtherResponse::VISIBLE_STATUSES
responses = OtherResponse.where(status: statuses).includes(:owner, :source_form_answer)

@groups = responses
.group_by { |response| [ response.group_key, response.normalized_text ] }
.map { |_key, rows| build_group(rows) }
.sort_by { |group| [ group[:promotable] ? 0 : 1, group[:question_label].downcase, -group[:count], group[:display_text].downcase ] }

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

# Bulk keep/dismiss every visible person in a group, from the review queue.
# Keep leaves the value in place; dismiss hides it.
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!
status = params[:status]
unless %w[kept dismissed].include?(status)
return redirect_to other_responses_path, alert: "Choose keep or dismiss."
end

scope = group_scope.where(status: OtherResponse::VISIBLE_STATUSES)
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.owner), status: :see_other
else
redirect_to other_responses_path, status: :see_other
end
end

# Promote every non-dismissed person in a sector group 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. Only sector
# groups are promotable, enforced by the .sectors scope.
def promote
authorize!
sector = target_sector
return redirect_to other_responses_path, alert: "Pick or name a sector to promote to." unless sector

responses = group_scope.sectors.promotable_now
responses.includes(:owner).find_each do |response|
# Reuse registration's tagging so the sector lands on the person AND the
# org(s) they registered with, always as an additional tag (never primary).
SectorTagging.apply(person: response.owner, organizations: response.registration_organizations,

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: Update to my earlier note: promotion now tags the exact org from the registration (OtherResponse#registration_organizations walks source_form_answer β†’ submission β†’ the person's registration for that event), not their current affiliations β€” so no org needs storing on the response.

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

def build_group(rows)
first = rows.first
{
kind: first.kind,
field_identifier: first.field_identifier,
promotable: first.promotable?,
question_label: question_label_for(first),
display_text: first.text,
normalized_text: first.normalized_text,
anchor: first.review_anchor,
count: rows.size,
status_counts: rows.each_with_object(Hash.new(0)) { |r, h| h[r.status] += 1 }
}
end

def question_label_for(response)
case response.kind
when "sector" then "Sectors"
when "organization_type" then "Organization type"
else response.source_form_answer&.question_name_when_answered.presence || response.field_identifier.humanize
end
end

# The set of responses a curate/promote action targets, matching how the group
# was bucketed: captured kinds by kind, generic questions by field.
def group_scope
scope = OtherResponse.where(normalized_text: OtherResponse.normalize(params[:normalized_text]))
if params[:kind].present? && params[:kind] != "generic"
scope.where(kind: params[:kind])
else
scope.where(field_identifier: params[:field_identifier])
end
end

# The promote target: an existing sector by id, or find-or-create one from a
# typed name. Always published β€” a promoted sector is a real, public tag, even
# when the target already existed unpublished (a new name would default to
# unpublished, and an existing match keeps its state, so publish either way).
def target_sector
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)
end
return unless sector

sector.update!(published: true) unless sector.published?
sector
end
end
5 changes: 4 additions & 1 deletion app/controllers/people_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -296,7 +296,10 @@ def set_form_variables
end
@person.affiliations.build if @person.affiliations.empty?

@all_sectors = Sector.published.order(:name)
# "Other" is the free-text catch-all, not a real tag (see Sector), so it's
# never offered in the sector picker β€” a typed "Other" is captured as an
# OtherResponse, not saved as the literal "Other" sector.
@all_sectors = Sector.published.excluding_other.order(:name)
@sectors_collection = @all_sectors.pluck(:name, :id)
@current_sector_ids = @person.sectorable_items.map(&:sector_id)

Expand Down
5 changes: 4 additions & 1 deletion app/models/concerns/sectors_taggable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,10 @@ def demote_unselected_primary_sectors(primary_ids)
end

def upsert_sector_items(sector_ids, is_primary:)
Sector.where(id: sector_ids).find_each do |sector|
# "Other" is the free-text catch-all, never a real tag β€” a typed "Other" is
# captured as an OtherResponse, so it must not become a SectorableItem even
# if its id slips into the submitted set.
Sector.where(id: sector_ids).excluding_other.find_each do |sector|
item = sectorable_items.find_or_initialize_by(sector: sector)
item.is_primary = is_primary
item.save!
Expand Down
1 change: 1 addition & 0 deletions app/models/organization.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ class Organization < ApplicationRecord
belongs_to :windows_type, optional: true
has_many :addresses, as: :addressable, dependent: :destroy
has_many :bookmarks, as: :bookmarkable, dependent: :destroy
has_many :other_responses, as: :owner, dependent: :destroy
has_many :affiliations, dependent: :restrict_with_error, inverse_of: :organization
has_many :event_registration_organizations, dependent: :restrict_with_error
has_many :event_registrations, through: :event_registration_organizations
Expand Down
123 changes: 123 additions & 0 deletions app/models/other_response.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
class OtherResponse < ApplicationRecord
# A free-text "Other" someone typed on a form question whose "Other" can (or
# will) become a real tag. Captured at submission time so a curator can
# promote / keep / dismiss it. The `owner` is polymorphic because the answer's
# subject differs by question: a sector "Other" is about the person, an
# organization-type "Other" is about their organization.
#
# - `sector` β†’ owned by a Person; promotable into a `Sector`, shown
# on the person's profile beside the sector tags.
# - `organization_type` β†’ owned by an Organization; stored now, but not yet
# promotable (its promote button is hidden until
# `OrganizationType` is a model).
#
# Questions whose "Other" will never become a tag (`generic`) are not captured
# at all β€” that data stays searchable in the form answers.
KINDS = %w[sector organization_type generic].freeze

# Kinds we materialize as records (the rest stay in the form answers).
CAPTURED_KINDS = %w[sector organization_type].freeze

# Kinds captured against a Person, via their form submission.
PERSON_KINDS = %w[sector].freeze

# Kinds that can be promoted into a real tag today.
PROMOTABLE_KINDS = %w[sector].freeze

# The organization-type question. Its "Other" is org-owned (see
# PublicRegistration#sync_agency_type).
ORGANIZATION_TYPE_FIELD_IDENTIFIER = "agency_type"

# A response starts life as `pending` (awaiting a curator's decision) and is
# then either promoted into a real tag, kept as auxiliary data, or dismissed.
STATUSES = %w[pending kept promoted dismissed].freeze

# Statuses that still surface (as an "(other)" chip for sectors, or a live row
# in the review queue).
VISIBLE_STATUSES = %w[pending kept].freeze

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

before_validation :set_kind
before_validation :set_normalized_text

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

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

# The coarse category (and thus owner + promotability) for a question.
def self.kind_for(field_identifier)
identifier = field_identifier.to_s
if identifier.in?(FormField::SECTOR_FIELD_IDENTIFIERS)
"sector"
elsif identifier == ORGANIZATION_TYPE_FIELD_IDENTIFIER
"organization_type"
else
"generic"
end
end

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

# Stable DOM id for a review-page group, so a chip can deep-link to its row.
def self.review_anchor(bucket, normalized_text)
"other-#{bucket}-#{normalized_text}".parameterize
end

def promotable?
kind.in?(PROMOTABLE_KINDS)
end

# The organization(s) the person registered with when they typed this response,
# derived from the source form answer's submission + the person's registration
# for that event. Lets promotion mirror a sector onto exactly the orgs a
# registration would have β€” no need to store the org here. Empty when there's no
# registration context (no source answer, or a non-person owner).
def registration_organizations
event = source_form_answer&.form_submission&.event
return Organization.none unless event && owner.is_a?(Person)

owner.event_registrations.find_by(event: event)&.organizations || Organization.none
end

# How the review page buckets this response: captured kinds group by kind (all
# sector "Other"s together, all org-type together); generic groups by question.
def group_key
kind == "generic" ? field_identifier : kind
end

def review_anchor
self.class.review_anchor(group_key, normalized_text)
end

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

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

private

def set_kind
self.kind = self.class.kind_for(field_identifier) if field_identifier.present?
end

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, as: :owner, 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 @@ -305,10 +306,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
21 changes: 21 additions & 0 deletions app/policies/other_response_policy.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
class OtherResponsePolicy < ApplicationPolicy
# Reviewing and curating free-text "Other" responses (promote/keep/dismiss) is
# an admin-only task. Defined explicitly rather than leaning on the inherited
# manage? fallback, matching how the other policies spell out their rules.

def index?
admin?
end

def update?
admin?
end

def curate?
admin?
end

def promote?
admin?
end
end
Loading