Skip to content
Merged
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: 7 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,8 @@ This codebase (Rails 8.1)

| Directory | Purpose | Count |
|---|---|---|
| `app/models/` | ActiveRecord models | ~90 files |
| `app/services/` | Service objects and POROs (e.g. `MoneyFormatter` for currency display, `StoryImporter` for the stories spreadsheet import) | ~73 files |
| `app/models/` | ActiveRecord models | ~144 files |
| `app/services/` | Service objects and POROs (e.g. `MoneyFormatter` for currency display, `StoryImporter` for the stories spreadsheet import) | ~118 files |
| `app/jobs/` | SolidQueue background jobs | 6 files |
| `app/models/concerns/` | Shared model modules | 23 concerns |

Expand Down Expand Up @@ -271,6 +271,8 @@ action, or `authorize! :workshop, to: :summary?`).
- `EventRegistrationServices::ProcessConfirmation` β€” Registration confirmation flow
- `RegistrantCeForm` — The CE-specific view over the inline form the **CE callout** carries (built on the generic callout→form mechanism: `RegistrationTicketCallout#forms` + `CalloutFormSubmission`; the CE callout carries a single form, read as `forms.first`). Adds the two things CE needs: the post-training form only surfaces once `EventRegistration#ce_signouts_complete?` (event ended + no open attendance entry), and completing it — read-time `complete?`, every required field answered — is a prerequisite for the CE certificate (`EventRegistration#ce_form_requirement_met?` gates `ContinuingEducationRegistration#certificate_available?`). Read by the CE callout page (which links to the generic `registration/:slug/forms/:callout_id` form page, gated so the CE callout's form opens only after sign-outs via `CalloutsController#ce_form_locked?`) and by `BuiltinCalloutCards#ce_form_pending?` for the CE card's "Complete your form" nudge
- `EventRegistrationServices::CalloutFormSubmission` β€” Records a registrant's answers to one of the forms a registration-ticket callout delivers inline (`call(registration:, callout:, form:, form_params:, clarity_params:)`, returns the service instance with `#submission`/`#profile_changes`). Find-or-creates one `FormSubmission` per `(registrant, form, event)` carrying the form's own role β€” a re-submit edits the answers in place via `FormSubmission#persist_answer`. That a callout collected it is recorded separately in `FormSubmission#metadata` (`collected_via: "callout"`, `collected_via_callout_id`) via `#record_callout_collection!` β€” the flag (`#collected_via_callout?`), not the role, tells a callout-collected submission apart. There is no survey-role flag β€” the extra side effects self-gate on the form's own fields, so they no-op on a form that doesn't ask those questions: a choice field with linked `FormFieldResource`s (`FormField#per_resource?`) fans out to one answer per resource (nil `form_field`, `FormField#per_resource_question` snapshotted in `question_name_when_answered` β€” subtitle-led, so two fan-out questions sharing a prompt can't collide on one answer row); fields carrying the `anonymous_contributions` / `display_name_preference` identifiers write through to the `Person` via `PersonServices::SyncSharingPreferences` (the same write-through the registration form runs), exposing `#profile_changes` so the controller Ahoy-tracks real edits; and submitting any form on the **`scholarship`** callout stamps a recipient's `post_survey_completed_at`. Drives `Events::CalloutsController#callout`/`#submit_callout` (the person-first `registration/:slug/forms/:callout_id` page and its `.../:form_id` submit)
- `TagTranslation` β€” Turns a legacy word for who someone serves into tags: `#resolve` answers with both the sector ids and the age-group ids a term means, since the vocabularies cross (`Elder Abuse` is a life experience that means the Elders *age range*). A tag's own name resolves only while it's **published** β€” a retired tag is unpublished, not deleted β€” but a recorded `TagAlias` may point at a retired one deliberately (FileMaker's `Poverty`). Splits a repeating field on line breaks and semicolons β€” **not commas**, which the archive's own wording contains β€” and drops a trailing parenthetical. Built once and reused (it reads the whole vocabulary up front); a term nothing answers to lands in `#unmatched` rather than a guess. Primacy is the caller's call, since it follows from the field a term sat in
- `TagAliasSeeder` β€” Records what the FileMaker archive, the older registration/scholarship forms, Story Share and the portal's earlier sector list each called a tag the portal now names its own way (**ADR-0007**) as `TagAlias` rows, so the translation is data an admin can extend and a record of what a tag used to be called. Terms AWBW decided are recorded as-is; semantic reads of the archive's hand-typed free text are marked **`proposed`** for a pass with staff (they still resolve, so no import waits on it), and text naming an organization or a facilitator's own circumstances is left alone. Idempotent on (term, tag) and never touches a term added by hand; a term whose tag isn't published is reported, not created. Shared with `data:seed_tag_aliases`
- `SurveyFormSeeder` β€” Creates the four standalone survey template forms from the form-builder presets (the Day 1 / Day 2 evaluations, the overall post-event survey, and the scholarship recipients survey), idempotent on form name and returning the names created. `#link_fanout_resources` then links each fan-out question to its training-topic `Resource`s by title (skipping any that aren't present). Shared by `db/seeds.rb` and the `data:seed_survey_forms` task so prod can create them without the full seed
- `CloseProgramFormSeeder` β€” Creates the standalone "Close Program" agreement form (role `close_program`, sections `person_identifier` + `close_program`) from the form-builder presets, idempotent on form name, returning the name created (or nil). Built as an unpublished template for staff to slug + publish. Shared by `db/seeds.rb` and the `data:seed_close_program_form` task
- `EventRegistrationServices::PublicRegistration` β€” Public registration handling. An affirmative `communication_consent` answer captures a News (mailing-list) `TopicSubscription` via `NewsSubscriptionCapture`, sourced to the event. A resubmission matched to an active registration sends the registrant and admins DUPLICATE emails (`event_registration_duplicate` / `event_registration_duplicate_fyi`)
Expand Down Expand Up @@ -561,7 +563,7 @@ RuboCop linting on PRs and pushes to main.

## Rake Tasks

Located in `lib/tasks/` (13 files):
Located in `lib/tasks/` (16 files):
- `dev.rake` β€” Development database seeding from XML/CSV
- `rhino_migrator.rake` β€” Rich text editor migration
- `attachment_report.rake` β€” Attachment reporting
Expand All @@ -573,6 +575,8 @@ Located in `lib/tasks/` (13 files):
- `migrate_workshop_logs.rake` β€” Workshop log migration
- `backfill_user_stamps.rake` β€” Backfill `created_by_id`/`updated_by_id` on legacy rows from the Ahoy trail (`data:backfill_user_stamps`)
- `backfill_notification_stamps.rake` β€” Backfill notifications' `created_by_id`/`updated_by_id` from `sender_id`, nil-only (`data:backfill_notification_stamps`)
- `fm_import.rake` β€” Load the FileMaker CSV exports into the `fm_*` archive tables (`fm:import`, reads each CSV from the `FM Archive` resources, batch `insert_all`, skips rows whose key is already stored)
- `resolve_resource_organizations.rake` β€” Link resources to organizations from the legacy `agency` free-text name (`data:resolve_resource_organizations`, match-or-create, idempotent, DRY_RUN)
- `seed_tag_aliases.rake` β€” Record the legacy sector and age-group terms from ADR-0007 as tag aliases (`data:seed_tag_aliases`, via `TagAliasSeeder`, idempotent; also part of `db:seed`)
- `seed_survey_forms.rake` β€” Create the Day 1 / Day 2 / recipients survey template forms without the full db:seed (`data:seed_survey_forms`, via `SurveyFormSeeder`, idempotent on form name; run before configuring the survey callouts)
- `seed_close_program_form.rake` β€” Create the Close Program agreement form without the full db:seed (`data:seed_close_program_form`, via `CloseProgramFormSeeder`, idempotent on form name; give it a slug and publish it in the form builder to use it)
46 changes: 46 additions & 0 deletions app/models/tag_alias.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# A word some other system used for a tag the portal now carries its own name for. The
# FileMaker archive, the older registration and scholarship forms, Story Share and the
# portal's own earlier sector list each had their own wording; ADR-0007 records what each
# term resolves to, and these rows are that mapping as data β€” so an admin can add a term
# as it turns up instead of waiting for a deploy, and the record of what a tag used to be
# called outlives the import that needed it.
#
# A term can name two tags ("C & T" is both Children and Teens), so it is read as a list.
# A term read out of free text rather than decided by AWBW is marked `proposed`, so the
# guesses can be reviewed as a list without holding up the import.
#
# It can also be recorded as naming nothing on purpose β€” a FileMaker "setting" like
# "Virtually" says where someone works, not who they serve β€” which is what keeps a decided
# non-translation out of the queue of terms still needing a decision.
class TagAlias < ApplicationRecord
SOURCES = %w[filemaker forms story_share portal].freeze

belongs_to :aliasable, polymorphic: true, optional: true
belongs_to :created_by, class_name: "User", optional: true
belongs_to :updated_by, class_name: "User", optional: true

normalizes :name, with: ->(value) { value.to_s.squish }

validates :name, presence: true, length: { maximum: 255 },
uniqueness: { scope: %i[aliasable_type aliasable_id], case_sensitive: false }
validates :source, inclusion: { in: SOURCES }
validate :names_a_tag_or_is_ignored

scope :ignored, -> { where(ignored: true) }
scope :resolving, -> { where(ignored: false) }
# Read from the archive's free text rather than decided by AWBW. They resolve, so an
# import doesn't lose them, and this is the list to walk with staff.
scope :proposed, -> { where(proposed: true) }
scope :decided, -> { where(proposed: false) }

scope :for_sectors, -> { where(aliasable_type: "Sector") }
scope :for_categories, -> { where(aliasable_type: "Category") }

private

def names_a_tag_or_is_ignored
return if aliasable.present? ^ ignored?

errors.add(:base, "A term either names a tag or is ignored, not both and not neither")
end
end
Loading
Loading