From 15372019428855630a88275ab830f6b0e112b51a Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Wed, 19 Aug 2026 09:03:34 -0400 Subject: [PATCH 1/7] Add facilitator boolean to affiliations, synced from title The "is this a facilitator affiliation?" fact was derived from a raw, collation-sensitive BINARY TRIM(title) = 'Facilitator' scope re-encoded in Ruby and three JS controllers. Denormalize it to a boolean column kept in sync from the title, so the SQL scope reads a plain flag instead of raw SQL. Title stays the input; the two-row (job + Facilitator) model is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../affiliation_dates_controller.js | 7 +--- ...iliation_facilitator_warning_controller.js | 4 +- .../controllers/inactive_toggle_controller.js | 5 +-- app/frontend/javascript/lib/affiliation.js | 9 ++++ app/models/affiliation.rb | 30 +++++++------ ...9125941_add_facilitator_to_affiliations.rb | 15 +++++++ db/schema.rb | 1 + .../backfill_affiliation_facilitator.rake | 11 +++++ spec/models/affiliation_spec.rb | 42 +++++++++++++++---- 9 files changed, 95 insertions(+), 29 deletions(-) create mode 100644 app/frontend/javascript/lib/affiliation.js create mode 100644 db/migrate/20260819125941_add_facilitator_to_affiliations.rb create mode 100644 lib/tasks/backfill_affiliation_facilitator.rake diff --git a/app/frontend/javascript/controllers/affiliation_dates_controller.js b/app/frontend/javascript/controllers/affiliation_dates_controller.js index 88013eed64..7c4731f5f6 100644 --- a/app/frontend/javascript/controllers/affiliation_dates_controller.js +++ b/app/frontend/javascript/controllers/affiliation_dates_controller.js @@ -1,4 +1,5 @@ import { Controller } from "@hotwired/stimulus" +import { isFacilitatorTitle } from "../lib/affiliation" export default class extends Controller { static targets = ["facilitatorSince", "affiliatedNote", "affiliatedNoteText", "memberSinceFlag", "affiliationsContainer", "programStatus"] @@ -51,11 +52,7 @@ export default class extends Controller { const now = new Date() const today = new Date(Date.UTC(now.getFullYear(), now.getMonth(), now.getDate())) - // Mirrors Affiliation#facilitator?: exact, case-sensitive, trimmed — so the - // live figure matches the server render. - const facilitatorAffiliations = affiliations.filter(a => - a.title.trim() === "Facilitator" - ) + const facilitatorAffiliations = affiliations.filter(a => isFacilitatorTitle(a.title)) const facStartDates = facilitatorAffiliations.map(a => a.startDate).filter(Boolean) const facilitatorSince = facStartDates.length ? new Date(Math.min(...facStartDates.map(d => new Date(d)))) diff --git a/app/frontend/javascript/controllers/affiliation_facilitator_warning_controller.js b/app/frontend/javascript/controllers/affiliation_facilitator_warning_controller.js index 702edbf6a8..4260502dd9 100644 --- a/app/frontend/javascript/controllers/affiliation_facilitator_warning_controller.js +++ b/app/frontend/javascript/controllers/affiliation_facilitator_warning_controller.js @@ -1,4 +1,5 @@ import { Controller } from "@hotwired/stimulus"; +import { isFacilitatorTitle } from "../lib/affiliation"; // Connects to data-controller="affiliation-facilitator-warning" // @@ -65,8 +66,7 @@ export default class extends Controller { startDate, endDate, destroyed, - // Mirror Affiliation#facilitator?: exact, case-sensitive "Facilitator" (trimmed). - facilitator: title.trim() === "Facilitator", + facilitator: isFacilitatorTitle(title), }; } diff --git a/app/frontend/javascript/controllers/inactive_toggle_controller.js b/app/frontend/javascript/controllers/inactive_toggle_controller.js index 5dad1705e6..3083aeff31 100644 --- a/app/frontend/javascript/controllers/inactive_toggle_controller.js +++ b/app/frontend/javascript/controllers/inactive_toggle_controller.js @@ -1,4 +1,5 @@ import { Controller } from "@hotwired/stimulus"; +import { isFacilitatorTitle } from "../lib/affiliation"; // Live styling for the affiliation editor row as you edit, before saving. Four // states by colour: role is the hue (facilitator = purple, else blue) and status @@ -92,9 +93,7 @@ export default class extends Controller { return this.expiredValue; } - // Mirror Affiliation#facilitator? — an exact, case-sensitive match on - // "Facilitator" (trimmed), so the live styling matches what the server renders. isFacilitator() { - return this.hasTitleTarget && this.titleTarget.value.trim() === "Facilitator"; + return this.hasTitleTarget && isFacilitatorTitle(this.titleTarget.value); } } diff --git a/app/frontend/javascript/lib/affiliation.js b/app/frontend/javascript/lib/affiliation.js new file mode 100644 index 0000000000..32fb8aa5af --- /dev/null +++ b/app/frontend/javascript/lib/affiliation.js @@ -0,0 +1,9 @@ +// The single JS source of truth for "is this the standing Facilitator +// affiliation?", mirroring Ruby's Affiliation#facilitator? / .facilitators: the +// title must be *exactly* "Facilitator" (trimmed, case-sensitive). Variants like +// "Lead Facilitator" or "facilitator" are deliberately excluded. The affiliation +// editors drive their live preview off the typed title, so they compare the input +// value through this helper rather than the persisted boolean. +export const facilitatorTitle = "Facilitator" + +export const isFacilitatorTitle = (title) => (title ?? "").trim() === facilitatorTitle diff --git a/app/models/affiliation.rb b/app/models/affiliation.rb index 94f9fd650c..3a4e5c16d2 100644 --- a/app/models/affiliation.rb +++ b/app/models/affiliation.rb @@ -49,11 +49,10 @@ class Affiliation < ApplicationRecord .where("affiliations.end_date IS NULL OR affiliations.end_date >= ?", date) } - # Only the exact, case-sensitive title "Facilitator" counts — variants like - # "Lead Facilitator" or "facilitator" are deliberately excluded. BINARY forces - # a case-sensitive comparison under MySQL's default case-insensitive collation; - # TRIM mirrors the in-memory #facilitator? strip so stray whitespace still matches. - scope :facilitators, -> { where("BINARY TRIM(title) = ?", "Facilitator") } + # Reads the denormalized `facilitator` flag, which #sync_facilitator_from_title + # keeps in lock-step with the title rule (exactly "Facilitator", trimmed, + # case-sensitive). An executable agreement spec locks the column to that rule. + scope :facilitators, -> { where(facilitator: true) } # Affiliations whose #status_on(date) equals the given status, expressed in SQL # so it composes as a subquery (e.g. person-id narrowing). Kept in lock-step with @@ -78,6 +77,7 @@ class Affiliation < ApplicationRecord end } + before_validation :sync_facilitator_from_title before_validation :skip_if_duplicate # Runs before validation so a reassigned org drops its stale organization_address_id # before organization_address_belongs_to_organization would reject it. @@ -89,13 +89,11 @@ class Affiliation < ApplicationRecord after_destroy :sync_organization_affiliation_dates # Methods - # A facilitator affiliation is one whose title is *exactly* "Facilitator" - # (trimmed, case-sensitive). Variants like "Lead Facilitator" or "facilitator" - # are deliberately excluded. Mirrors the .facilitators scope so in-memory and - # SQL checks agree. - def facilitator? - title.to_s.strip == "Facilitator" - end + # `facilitator?` is the boolean column's auto-generated reader. A facilitator + # affiliation is one whose title is *exactly* "Facilitator" (trimmed, + # case-sensitive); #sync_facilitator_from_title keeps the column in step with + # that rule on every save, so #facilitator? and the .facilitators scope agree. + # Variants like "Lead Facilitator" or "facilitator" are deliberately excluded. # Current: not flagged inactive and not past its end date. Mirrors the `active` # scope so already-loaded affiliations can be filtered in Ruby without another @@ -169,6 +167,14 @@ def set_inactive_from_dates self.inactive = end_date.present? && end_date < Date.current end + # Keep the denormalized flag in step with the title rule (exactly "Facilitator", + # trimmed, case-sensitive) on every save, so the .facilitators scope and + # #facilitator? agree. Invariant: never write `title` via update_columns / + # update_all — that skips this callback and lets the flag drift. + def sync_facilitator_from_title + self.facilitator = title.to_s.strip == FACILITATOR_TITLE + end + def sync_organization_affiliation_dates org = organization affiliations = org.affiliations.where.not(id: destroyed_by_association ? id : nil) diff --git a/db/migrate/20260819125941_add_facilitator_to_affiliations.rb b/db/migrate/20260819125941_add_facilitator_to_affiliations.rb new file mode 100644 index 0000000000..3dc80927e7 --- /dev/null +++ b/db/migrate/20260819125941_add_facilitator_to_affiliations.rb @@ -0,0 +1,15 @@ +class AddFacilitatorToAffiliations < ActiveRecord::Migration[8.1] + # Denormalized cache of "is this the standing Facilitator affiliation?", kept in + # sync from the title by Affiliation. Replaces the raw BINARY TRIM(title) scope. + # Schema only — existing rows are backfilled by the affiliations:backfill_facilitator + # rake task after deploy (see lib/tasks). + def up + return if column_exists?(:affiliations, :facilitator) + + add_column :affiliations, :facilitator, :boolean, null: false, default: false + end + + def down + remove_column :affiliations, :facilitator, if_exists: true + end +end diff --git a/db/schema.rb b/db/schema.rb index 1581f5f647..2e7223bb04 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -108,6 +108,7 @@ t.datetime "created_at", precision: nil, null: false t.date "end_date" t.bigint "event_registration_id" + t.boolean "facilitator", default: false, null: false t.string "filemaker_code" t.boolean "inactive", default: false, null: false t.bigint "organization_address_id" diff --git a/lib/tasks/backfill_affiliation_facilitator.rake b/lib/tasks/backfill_affiliation_facilitator.rake new file mode 100644 index 0000000000..490ca9b1b1 --- /dev/null +++ b/lib/tasks/backfill_affiliation_facilitator.rake @@ -0,0 +1,11 @@ +namespace :affiliations do + desc "Backfill the affiliations.facilitator flag from the title (one-off, post-deploy)" + task backfill_facilitator: :environment do + # Same rule as the retired .facilitators SQL scope: exactly "Facilitator", + # trimmed, case-sensitive. update_all is deliberate — the value is computed + # inline, so no per-row callback is needed and this stays a single bulk write. + scope = Affiliation.where("BINARY TRIM(title) = ?", "Facilitator") + count = scope.update_all(facilitator: true) + puts "Backfilled facilitator: true on #{count} affiliation(s)." + end +end diff --git a/spec/models/affiliation_spec.rb b/spec/models/affiliation_spec.rb index fa1ff6a1a2..3915524bfc 100644 --- a/spec/models/affiliation_spec.rb +++ b/spec/models/affiliation_spec.rb @@ -118,26 +118,43 @@ end end - describe '#facilitator?' do + describe '#facilitator? (synced from title on validation)' do + # #facilitator? reads the denormalized column, which sync_facilitator_from_title + # sets in before_validation — so validate before reading it. + def facilitator_flag(title) + build(:affiliation, title: title).tap(&:validate).facilitator? + end + it 'is true for the exact title "Facilitator"' do - expect(build(:affiliation, title: "Facilitator").facilitator?).to be true + expect(facilitator_flag("Facilitator")).to be true end it 'ignores surrounding whitespace' do - expect(build(:affiliation, title: " Facilitator ").facilitator?).to be true + expect(facilitator_flag(" Facilitator ")).to be true end it 'is false for title variants like "Lead Facilitator"' do - expect(build(:affiliation, title: "Lead Facilitator").facilitator?).to be false + expect(facilitator_flag("Lead Facilitator")).to be false end it 'is case-sensitive' do - expect(build(:affiliation, title: "facilitator").facilitator?).to be false - expect(build(:affiliation, title: "FACILITATOR").facilitator?).to be false + expect(facilitator_flag("facilitator")).to be false + expect(facilitator_flag("FACILITATOR")).to be false end it 'is false when the title is blank' do - expect(build(:affiliation, title: nil).facilitator?).to be false + expect(facilitator_flag(nil)).to be false + end + + it 'flips the column when a row is retitled to or from "Facilitator"' do + affiliation = create(:affiliation, title: "Facilitator") + expect(affiliation.facilitator?).to be true + + affiliation.update!(title: "Lead Facilitator") + expect(affiliation.reload.facilitator?).to be false + + affiliation.update!(title: "Facilitator") + expect(affiliation.reload.facilitator?).to be true end end @@ -150,6 +167,17 @@ it 'includes only the exact, case-sensitive title "Facilitator" (whitespace-trimmed)' do expect(described_class.facilitators).to contain_exactly(exact, whitespace) end + + it 'returns exactly the rows whose title matches the rule (column ↔ scope agree)' do + expected = described_class.all.select { |a| a.title.to_s.strip == "Facilitator" }.map(&:id).sort + expect(described_class.facilitators.ids.sort).to eq(expected) + end + + it 'keeps the persisted facilitator column in step with the title rule' do + described_class.find_each do |affiliation| + expect(affiliation.facilitator).to eq(affiliation.title.to_s.strip == "Facilitator") + end + end end describe '#sync_organization_status_with_affiliations' do From da0d5e7e4cea8d8f0ff662d4ec23ac89504498c7 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Wed, 19 Aug 2026 09:54:14 -0400 Subject: [PATCH 2/7] Model facilitator vs job as STI subtypes instead of a boolean MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the facilitator boolean with STI: FacilitatorAffiliation and JobAffiliation (default). The subtype is derived from the title in a before_validation, so title stays the single source of truth and a retitle re-types the row. Server-authoritative — no form-submitted type needed. Key STI accommodations: the type column has no default (a default subclass name makes Affiliation.new build that subclass and break reload after the callback re-types); #facilitator?/.facilitators read the type column; subtypes share Affiliation's routes/param-key/dom_id via self.model_name and authorize through AffiliationPolicy via self.policy_class. Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 2 + app/frontend/javascript/lib/affiliation.js | 10 ++-- app/models/affiliation.rb | 49 +++++++++++++------ app/models/facilitator_affiliation.rb | 6 +++ app/models/job_affiliation.rb | 5 ++ ...9125941_add_facilitator_to_affiliations.rb | 15 ------ ...20260819134257_add_type_to_affiliations.rb | 20 ++++++++ db/schema.rb | 3 +- .../backfill_affiliation_facilitator.rake | 17 ++++--- spec/models/affiliation_spec.rb | 41 ++++++++++++---- 10 files changed, 115 insertions(+), 53 deletions(-) create mode 100644 app/models/facilitator_affiliation.rb create mode 100644 app/models/job_affiliation.rb delete mode 100644 db/migrate/20260819125941_add_facilitator_to_affiliations.rb create mode 100644 db/migrate/20260819134257_add_type_to_affiliations.rb diff --git a/AGENTS.md b/AGENTS.md index 375ebb393d..bb117d0ceb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -121,6 +121,8 @@ This codebase (Rails 8.1) - **Asset** (inheritance column: `type`): PrimaryAsset, GalleryAsset, RichTextAsset, DownloadableAsset, ThumbnailAsset, FormUploadAsset - The `type` column defaults to `"PrimaryAsset"`, which narrows `ACCEPTED_CONTENT_TYPES` to five image types — always name the type when building an Asset, or documents will fail validation. `FormUploadAsset` backs a respondent's file-upload form answer and takes Asset's full accepted-type list. - **Report**: MonthlyReport +- **Affiliation** (inheritance column: `type`, nullable, no default): FacilitatorAffiliation, JobAffiliation + - The type is *derived from the title* — `set_type_from_title` (a `before_validation`) sets `FacilitatorAffiliation` when the title is exactly `"Facilitator"` (trimmed, case-sensitive), else `JobAffiliation` (the default). Title is the single source of truth, so a retitle re-types the row; never write `title` via `update_all`/`update_columns` (skips the callback). The column has **no default** on purpose — a default subclass name would make `Affiliation.new` build that subclass and then break `reload` after the callback re-types. `#facilitator?` and the `.facilitators` scope read the `type` column. Both subtypes share `Affiliation`'s routes/param-key/`dom_id` (`self.model_name`) and authorize through the one `AffiliationPolicy` (`self.policy_class`). ### Polymorphic Associations diff --git a/app/frontend/javascript/lib/affiliation.js b/app/frontend/javascript/lib/affiliation.js index 32fb8aa5af..5bf12fbe26 100644 --- a/app/frontend/javascript/lib/affiliation.js +++ b/app/frontend/javascript/lib/affiliation.js @@ -1,9 +1,9 @@ // The single JS source of truth for "is this the standing Facilitator -// affiliation?", mirroring Ruby's Affiliation#facilitator? / .facilitators: the -// title must be *exactly* "Facilitator" (trimmed, case-sensitive). Variants like -// "Lead Facilitator" or "facilitator" are deliberately excluded. The affiliation -// editors drive their live preview off the typed title, so they compare the input -// value through this helper rather than the persisted boolean. +// affiliation?", mirroring the server's title rule (Affiliation derives its STI +// type from this): the title must be *exactly* "Facilitator" (trimmed, +// case-sensitive). Variants like "Lead Facilitator" or "facilitator" are +// deliberately excluded. The affiliation editors drive their live preview off the +// typed title, so they compare the input value through this helper. export const facilitatorTitle = "Facilitator" export const isFacilitatorTitle = (title) => (title ?? "").trim() === facilitatorTitle diff --git a/app/models/affiliation.rb b/app/models/affiliation.rb index 3a4e5c16d2..02ccba4197 100644 --- a/app/models/affiliation.rb +++ b/app/models/affiliation.rb @@ -49,10 +49,10 @@ class Affiliation < ApplicationRecord .where("affiliations.end_date IS NULL OR affiliations.end_date >= ?", date) } - # Reads the denormalized `facilitator` flag, which #sync_facilitator_from_title - # keeps in lock-step with the title rule (exactly "Facilitator", trimmed, - # case-sensitive). An executable agreement spec locks the column to that rule. - scope :facilitators, -> { where(facilitator: true) } + # STI: facilitator affiliations are the FacilitatorAffiliation subtype, which + # #set_type_from_title assigns whenever the title is exactly "Facilitator" + # (trimmed, case-sensitive). An executable agreement spec locks type to that rule. + scope :facilitators, -> { where(type: FacilitatorAffiliation.name) } # Affiliations whose #status_on(date) equals the given status, expressed in SQL # so it composes as a subquery (e.g. person-id narrowing). Kept in lock-step with @@ -77,7 +77,7 @@ class Affiliation < ApplicationRecord end } - before_validation :sync_facilitator_from_title + before_validation :set_type_from_title before_validation :skip_if_duplicate # Runs before validation so a reassigned org drops its stale organization_address_id # before organization_address_belongs_to_organization would reject it. @@ -88,12 +88,28 @@ class Affiliation < ApplicationRecord after_destroy :sync_organization_status_with_affiliations after_destroy :sync_organization_affiliation_dates + # Both STI subtypes authorize through the one AffiliationPolicy — ActionPolicy's + # class-policy_class resolver picks this up before it would fail to infer a + # FacilitatorAffiliationPolicy / JobAffiliationPolicy. + def self.policy_class + AffiliationPolicy + end + + # STI subtypes share Affiliation's routes, form param key, and dom_ids — the app + # treats them uniformly as "affiliation" (AffiliationsController#params.require(:affiliation), + # dom_id anchors like affiliation_123, affiliation_path). Without this, url_for / + # dom_id would derive facilitator_affiliation_* and break those. + def self.model_name + @_affiliation_model_name ||= ActiveModel::Name.new(Affiliation) + end + # Methods - # `facilitator?` is the boolean column's auto-generated reader. A facilitator - # affiliation is one whose title is *exactly* "Facilitator" (trimmed, - # case-sensitive); #sync_facilitator_from_title keeps the column in step with - # that rule on every save, so #facilitator? and the .facilitators scope agree. - # Variants like "Lead Facilitator" or "facilitator" are deliberately excluded. + # True for the FacilitatorAffiliation subtype. Reads the STI type column rather + # than #is_a? so it's correct even on a base-built instance whose type was just + # assigned by #set_type_from_title but not yet reloaded into its subclass. + def facilitator? + type == FacilitatorAffiliation.name + end # Current: not flagged inactive and not past its end date. Mirrors the `active` # scope so already-loaded affiliations can be filtered in Ruby without another @@ -167,12 +183,13 @@ def set_inactive_from_dates self.inactive = end_date.present? && end_date < Date.current end - # Keep the denormalized flag in step with the title rule (exactly "Facilitator", - # trimmed, case-sensitive) on every save, so the .facilitators scope and - # #facilitator? agree. Invariant: never write `title` via update_columns / - # update_all — that skips this callback and lets the flag drift. - def sync_facilitator_from_title - self.facilitator = title.to_s.strip == FACILITATOR_TITLE + # The title is the single source of truth for the STI subtype: exactly + # "Facilitator" (trimmed, case-sensitive) is a FacilitatorAffiliation, anything + # else (including blank) is a JobAffiliation, the default. Runs on every save so + # a retitle re-types the row. Invariant: never write `title` via update_columns / + # update_all — that skips this callback and lets type drift from the title. + def set_type_from_title + self.type = title.to_s.strip == FACILITATOR_TITLE ? FacilitatorAffiliation.name : JobAffiliation.name end def sync_organization_affiliation_dates diff --git a/app/models/facilitator_affiliation.rb b/app/models/facilitator_affiliation.rb new file mode 100644 index 0000000000..7514c98861 --- /dev/null +++ b/app/models/facilitator_affiliation.rb @@ -0,0 +1,6 @@ +# STI subclass for the standing "Facilitator" affiliation — the one that confers +# AWBW Art Program status on an organization. Affiliation assigns this type from +# the title (exactly "Facilitator") in a before_validation, so the type always +# tracks the title; see Affiliation#set_type_from_title. +class FacilitatorAffiliation < Affiliation +end diff --git a/app/models/job_affiliation.rb b/app/models/job_affiliation.rb new file mode 100644 index 0000000000..5c9cd1607f --- /dev/null +++ b/app/models/job_affiliation.rb @@ -0,0 +1,5 @@ +# STI subclass for a person's role/job at an organization (any title other than +# exactly "Facilitator"). This is the default affiliation type; Affiliation +# assigns it from the title in a before_validation. See Affiliation#set_type_from_title. +class JobAffiliation < Affiliation +end diff --git a/db/migrate/20260819125941_add_facilitator_to_affiliations.rb b/db/migrate/20260819125941_add_facilitator_to_affiliations.rb deleted file mode 100644 index 3dc80927e7..0000000000 --- a/db/migrate/20260819125941_add_facilitator_to_affiliations.rb +++ /dev/null @@ -1,15 +0,0 @@ -class AddFacilitatorToAffiliations < ActiveRecord::Migration[8.1] - # Denormalized cache of "is this the standing Facilitator affiliation?", kept in - # sync from the title by Affiliation. Replaces the raw BINARY TRIM(title) scope. - # Schema only — existing rows are backfilled by the affiliations:backfill_facilitator - # rake task after deploy (see lib/tasks). - def up - return if column_exists?(:affiliations, :facilitator) - - add_column :affiliations, :facilitator, :boolean, null: false, default: false - end - - def down - remove_column :affiliations, :facilitator, if_exists: true - end -end diff --git a/db/migrate/20260819134257_add_type_to_affiliations.rb b/db/migrate/20260819134257_add_type_to_affiliations.rb new file mode 100644 index 0000000000..93ff591abe --- /dev/null +++ b/db/migrate/20260819134257_add_type_to_affiliations.rb @@ -0,0 +1,20 @@ +class AddTypeToAffiliations < ActiveRecord::Migration[8.1] + # STI discriminator: FacilitatorAffiliation vs JobAffiliation. Affiliation derives + # the type from the title on save (JobAffiliation is the default for any non- + # "Facilitator" title). Intentionally nullable with NO column default: a default + # subclass name would make Affiliation.new instantiate that subclass, so a row + # the callback then re-types would raise RecordNotFound on reload. Schema only — + # existing rows are typed by the affiliations:backfill_facilitator rake task + # after deploy (see lib/tasks); app-created rows always get a type via the callback. + def up + return if column_exists?(:affiliations, :type) + + add_column :affiliations, :type, :string + add_index :affiliations, :type + end + + def down + remove_index :affiliations, :type, if_exists: true + remove_column :affiliations, :type, if_exists: true + end +end diff --git a/db/schema.rb b/db/schema.rb index 2e7223bb04..4713689a45 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -108,7 +108,6 @@ t.datetime "created_at", precision: nil, null: false t.date "end_date" t.bigint "event_registration_id" - t.boolean "facilitator", default: false, null: false t.string "filemaker_code" t.boolean "inactive", default: false, null: false t.bigint "organization_address_id" @@ -119,6 +118,7 @@ t.boolean "primary_contact", default: false, null: false t.date "start_date" t.string "title" + t.string "type" t.datetime "updated_at", precision: nil, null: false t.integer "user_id" t.index ["event_registration_id"], name: "index_affiliations_on_event_registration_id" @@ -126,6 +126,7 @@ t.index ["organization_agency_id"], name: "index_affiliations_on_organization_agency_id" t.index ["organization_id"], name: "index_affiliations_on_organization_id" t.index ["person_id"], name: "index_affiliations_on_person_id" + t.index ["type"], name: "index_affiliations_on_type" t.index ["user_id"], name: "index_affiliations_on_user_id" end diff --git a/lib/tasks/backfill_affiliation_facilitator.rake b/lib/tasks/backfill_affiliation_facilitator.rake index 490ca9b1b1..708adc435c 100644 --- a/lib/tasks/backfill_affiliation_facilitator.rake +++ b/lib/tasks/backfill_affiliation_facilitator.rake @@ -1,11 +1,14 @@ namespace :affiliations do - desc "Backfill the affiliations.facilitator flag from the title (one-off, post-deploy)" + desc "Backfill affiliation STI type from the title (one-off, post-deploy)" task backfill_facilitator: :environment do - # Same rule as the retired .facilitators SQL scope: exactly "Facilitator", - # trimmed, case-sensitive. update_all is deliberate — the value is computed - # inline, so no per-row callback is needed and this stays a single bulk write. - scope = Affiliation.where("BINARY TRIM(title) = ?", "Facilitator") - count = scope.update_all(facilitator: true) - puts "Backfilled facilitator: true on #{count} affiliation(s)." + # Type every existing row from its title, matching Affiliation#set_type_from_title: + # exactly "Facilitator" (trimmed, case-sensitive) is a FacilitatorAffiliation, + # everything else a JobAffiliation. update_all is deliberate — the value is + # computed inline, so no per-row callback is needed. + facilitators = Affiliation.where("BINARY TRIM(title) = ?", "Facilitator") + .update_all(type: "FacilitatorAffiliation") + jobs = Affiliation.where("BINARY TRIM(title) <> ? OR title IS NULL", "Facilitator") + .update_all(type: "JobAffiliation") + puts "Typed #{facilitators} FacilitatorAffiliation(s) and #{jobs} JobAffiliation(s)." end end diff --git a/spec/models/affiliation_spec.rb b/spec/models/affiliation_spec.rb index 3915524bfc..1f90f48721 100644 --- a/spec/models/affiliation_spec.rb +++ b/spec/models/affiliation_spec.rb @@ -118,9 +118,9 @@ end end - describe '#facilitator? (synced from title on validation)' do - # #facilitator? reads the denormalized column, which sync_facilitator_from_title - # sets in before_validation — so validate before reading it. + describe '#facilitator? (STI type derived from title on validation)' do + # #facilitator? reads the STI type column, which set_type_from_title assigns in + # before_validation — so validate before reading it. def facilitator_flag(title) build(:affiliation, title: title).tap(&:validate).facilitator? end @@ -146,18 +146,40 @@ def facilitator_flag(title) expect(facilitator_flag(nil)).to be false end - it 'flips the column when a row is retitled to or from "Facilitator"' do + it 're-types the row when retitled to or from "Facilitator"' do affiliation = create(:affiliation, title: "Facilitator") - expect(affiliation.facilitator?).to be true + expect(described_class.find(affiliation.id)).to be_a(FacilitatorAffiliation) affiliation.update!(title: "Lead Facilitator") + expect(described_class.find(affiliation.id)).to be_a(JobAffiliation) expect(affiliation.reload.facilitator?).to be false affiliation.update!(title: "Facilitator") + expect(described_class.find(affiliation.id)).to be_a(FacilitatorAffiliation) expect(affiliation.reload.facilitator?).to be true end end + describe 'STI subtypes' do + it 'loads a "Facilitator"-titled row as FacilitatorAffiliation and others as JobAffiliation' do + facilitator = create(:affiliation, title: "Facilitator") + job = create(:affiliation, title: "Volunteer") + + expect(described_class.find(facilitator.id)).to be_a(FacilitatorAffiliation) + expect(described_class.find(job.id)).to be_a(JobAffiliation) + end + + it 'defaults an untitled row to JobAffiliation' do + affiliation = create(:affiliation, title: nil) + expect(described_class.find(affiliation.id)).to be_a(JobAffiliation) + end + + it 'authorizes both subtypes through AffiliationPolicy' do + expect(FacilitatorAffiliation.policy_class).to eq(AffiliationPolicy) + expect(JobAffiliation.policy_class).to eq(AffiliationPolicy) + end + end + describe '.facilitators' do let!(:exact) { create(:affiliation, title: "Facilitator") } let!(:whitespace) { create(:affiliation, title: " Facilitator ") } @@ -165,17 +187,18 @@ def facilitator_flag(title) let!(:lowercase) { create(:affiliation, title: "facilitator") } it 'includes only the exact, case-sensitive title "Facilitator" (whitespace-trimmed)' do - expect(described_class.facilitators).to contain_exactly(exact, whitespace) + expect(described_class.facilitators.ids).to contain_exactly(exact.id, whitespace.id) end - it 'returns exactly the rows whose title matches the rule (column ↔ scope agree)' do + it 'returns exactly the rows whose title matches the rule (type ↔ scope agree)' do expected = described_class.all.select { |a| a.title.to_s.strip == "Facilitator" }.map(&:id).sort expect(described_class.facilitators.ids.sort).to eq(expected) end - it 'keeps the persisted facilitator column in step with the title rule' do + it 'keeps the persisted STI type in step with the title rule' do described_class.find_each do |affiliation| - expect(affiliation.facilitator).to eq(affiliation.title.to_s.strip == "Facilitator") + expected_type = affiliation.title.to_s.strip == "Facilitator" ? "FacilitatorAffiliation" : "JobAffiliation" + expect(affiliation.type).to eq(expected_type) end end end From 9b36e71d67fea78a49da90a2710dd6684b5a654c Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Wed, 19 Aug 2026 23:02:56 -0400 Subject: [PATCH 3/7] Trim STI comments to the non-obvious gotchas per CLAUDE.md --- app/frontend/javascript/lib/affiliation.js | 8 ++---- app/models/affiliation.rb | 25 ++++++------------- app/models/facilitator_affiliation.rb | 6 ++--- app/models/job_affiliation.rb | 5 ++-- ...20260819134257_add_type_to_affiliations.rb | 11 +++----- .../backfill_affiliation_facilitator.rake | 5 +--- 6 files changed, 18 insertions(+), 42 deletions(-) diff --git a/app/frontend/javascript/lib/affiliation.js b/app/frontend/javascript/lib/affiliation.js index 5bf12fbe26..aa9dfb9787 100644 --- a/app/frontend/javascript/lib/affiliation.js +++ b/app/frontend/javascript/lib/affiliation.js @@ -1,9 +1,5 @@ -// The single JS source of truth for "is this the standing Facilitator -// affiliation?", mirroring the server's title rule (Affiliation derives its STI -// type from this): the title must be *exactly* "Facilitator" (trimmed, -// case-sensitive). Variants like "Lead Facilitator" or "facilitator" are -// deliberately excluded. The affiliation editors drive their live preview off the -// typed title, so they compare the input value through this helper. +// Mirrors the server's facilitator rule (exactly "Facilitator", trimmed, +// case-sensitive) so the editors' live preview matches the persisted STI type. export const facilitatorTitle = "Facilitator" export const isFacilitatorTitle = (title) => (title ?? "").trim() === facilitatorTitle diff --git a/app/models/affiliation.rb b/app/models/affiliation.rb index 02ccba4197..a4c6832176 100644 --- a/app/models/affiliation.rb +++ b/app/models/affiliation.rb @@ -49,9 +49,6 @@ class Affiliation < ApplicationRecord .where("affiliations.end_date IS NULL OR affiliations.end_date >= ?", date) } - # STI: facilitator affiliations are the FacilitatorAffiliation subtype, which - # #set_type_from_title assigns whenever the title is exactly "Facilitator" - # (trimmed, case-sensitive). An executable agreement spec locks type to that rule. scope :facilitators, -> { where(type: FacilitatorAffiliation.name) } # Affiliations whose #status_on(date) equals the given status, expressed in SQL @@ -88,25 +85,20 @@ class Affiliation < ApplicationRecord after_destroy :sync_organization_status_with_affiliations after_destroy :sync_organization_affiliation_dates - # Both STI subtypes authorize through the one AffiliationPolicy — ActionPolicy's - # class-policy_class resolver picks this up before it would fail to infer a - # FacilitatorAffiliationPolicy / JobAffiliationPolicy. + # Both STI subtypes authorize through the one AffiliationPolicy. def self.policy_class AffiliationPolicy end - # STI subtypes share Affiliation's routes, form param key, and dom_ids — the app - # treats them uniformly as "affiliation" (AffiliationsController#params.require(:affiliation), - # dom_id anchors like affiliation_123, affiliation_path). Without this, url_for / - # dom_id would derive facilitator_affiliation_* and break those. + # Both subtypes share Affiliation's routes, param key, and dom_ids — without this, + # url_for/dom_id would derive facilitator_affiliation_* and break the controller. def self.model_name @_affiliation_model_name ||= ActiveModel::Name.new(Affiliation) end # Methods - # True for the FacilitatorAffiliation subtype. Reads the STI type column rather - # than #is_a? so it's correct even on a base-built instance whose type was just - # assigned by #set_type_from_title but not yet reloaded into its subclass. + # Reads the type column, not #is_a?, so it holds on a base-built instance whose + # type was just assigned by #set_type_from_title but not yet reloaded. def facilitator? type == FacilitatorAffiliation.name end @@ -183,11 +175,8 @@ def set_inactive_from_dates self.inactive = end_date.present? && end_date < Date.current end - # The title is the single source of truth for the STI subtype: exactly - # "Facilitator" (trimmed, case-sensitive) is a FacilitatorAffiliation, anything - # else (including blank) is a JobAffiliation, the default. Runs on every save so - # a retitle re-types the row. Invariant: never write `title` via update_columns / - # update_all — that skips this callback and lets type drift from the title. + # Title is the source of truth for the subtype, re-derived on every save. Never + # write `title` via update_columns/update_all — that skips this and drifts the type. def set_type_from_title self.type = title.to_s.strip == FACILITATOR_TITLE ? FacilitatorAffiliation.name : JobAffiliation.name end diff --git a/app/models/facilitator_affiliation.rb b/app/models/facilitator_affiliation.rb index 7514c98861..ef5ed94011 100644 --- a/app/models/facilitator_affiliation.rb +++ b/app/models/facilitator_affiliation.rb @@ -1,6 +1,4 @@ -# STI subclass for the standing "Facilitator" affiliation — the one that confers -# AWBW Art Program status on an organization. Affiliation assigns this type from -# the title (exactly "Facilitator") in a before_validation, so the type always -# tracks the title; see Affiliation#set_type_from_title. +# STI subtype for the standing "Facilitator" affiliation (confers org Art Program +# status). Affiliation#set_type_from_title assigns this type from the title. class FacilitatorAffiliation < Affiliation end diff --git a/app/models/job_affiliation.rb b/app/models/job_affiliation.rb index 5c9cd1607f..a4e146b54d 100644 --- a/app/models/job_affiliation.rb +++ b/app/models/job_affiliation.rb @@ -1,5 +1,4 @@ -# STI subclass for a person's role/job at an organization (any title other than -# exactly "Facilitator"). This is the default affiliation type; Affiliation -# assigns it from the title in a before_validation. See Affiliation#set_type_from_title. +# STI subtype for a person's role/job at an org (any non-"Facilitator" title), the +# default. Affiliation#set_type_from_title assigns this type from the title. class JobAffiliation < Affiliation end diff --git a/db/migrate/20260819134257_add_type_to_affiliations.rb b/db/migrate/20260819134257_add_type_to_affiliations.rb index 93ff591abe..9a4515ed88 100644 --- a/db/migrate/20260819134257_add_type_to_affiliations.rb +++ b/db/migrate/20260819134257_add_type_to_affiliations.rb @@ -1,11 +1,8 @@ class AddTypeToAffiliations < ActiveRecord::Migration[8.1] - # STI discriminator: FacilitatorAffiliation vs JobAffiliation. Affiliation derives - # the type from the title on save (JobAffiliation is the default for any non- - # "Facilitator" title). Intentionally nullable with NO column default: a default - # subclass name would make Affiliation.new instantiate that subclass, so a row - # the callback then re-types would raise RecordNotFound on reload. Schema only — - # existing rows are typed by the affiliations:backfill_facilitator rake task - # after deploy (see lib/tasks); app-created rows always get a type via the callback. + # STI discriminator. Nullable with NO default on purpose: a default subclass name + # makes Affiliation.new build that subclass, so a row the callback re-types raises + # RecordNotFound on reload. Existing rows are typed post-deploy by + # affiliations:backfill_facilitator (see lib/tasks); new rows via the model callback. def up return if column_exists?(:affiliations, :type) diff --git a/lib/tasks/backfill_affiliation_facilitator.rake b/lib/tasks/backfill_affiliation_facilitator.rake index 708adc435c..5899c94da9 100644 --- a/lib/tasks/backfill_affiliation_facilitator.rake +++ b/lib/tasks/backfill_affiliation_facilitator.rake @@ -1,10 +1,7 @@ namespace :affiliations do desc "Backfill affiliation STI type from the title (one-off, post-deploy)" task backfill_facilitator: :environment do - # Type every existing row from its title, matching Affiliation#set_type_from_title: - # exactly "Facilitator" (trimmed, case-sensitive) is a FacilitatorAffiliation, - # everything else a JobAffiliation. update_all is deliberate — the value is - # computed inline, so no per-row callback is needed. + # Type every existing row from its title, matching Affiliation#set_type_from_title. facilitators = Affiliation.where("BINARY TRIM(title) = ?", "Facilitator") .update_all(type: "FacilitatorAffiliation") jobs = Affiliation.where("BINARY TRIM(title) <> ? OR title IS NULL", "Facilitator") From d9c41252c444490d0dda4befef8d1416c0cba85d Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Wed, 19 Aug 2026 23:45:37 -0400 Subject: [PATCH 4/7] Read affiliation Ahoy events under every STI class name Ahoy stamps resource_type from the saving instance's class, so splitting Affiliation into subtypes scattered a single row's history across three names: "Affiliation" for everything recorded before the split (and for freshly built rows), plus the two subtypes once a row is loaded back. Both readers assumed one name, so the person History card silently dropped affiliation edits and the affiliation edit page lost its created-by/updated-by attribution. Models now declare which names their events can carry, so the next STI split doesn't have to rediscover this. Co-Authored-By: Claude Opus 5 (1M context) --- AGENTS.md | 2 +- app/models/affiliation.rb | 9 +++++++++ app/models/concerns/ahoy_trackable.rb | 10 ++++++++++ app/services/analytics/person_activity_events.rb | 7 ++++--- app/views/shared/_audit_info.html.erb | 9 ++++++--- spec/requests/affiliations_spec.rb | 10 ++++++++++ spec/services/analytics/person_activity_events_spec.rb | 9 +++++++++ 7 files changed, 49 insertions(+), 7 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index bb117d0ceb..b5b8e08e57 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -122,7 +122,7 @@ This codebase (Rails 8.1) - The `type` column defaults to `"PrimaryAsset"`, which narrows `ACCEPTED_CONTENT_TYPES` to five image types — always name the type when building an Asset, or documents will fail validation. `FormUploadAsset` backs a respondent's file-upload form answer and takes Asset's full accepted-type list. - **Report**: MonthlyReport - **Affiliation** (inheritance column: `type`, nullable, no default): FacilitatorAffiliation, JobAffiliation - - The type is *derived from the title* — `set_type_from_title` (a `before_validation`) sets `FacilitatorAffiliation` when the title is exactly `"Facilitator"` (trimmed, case-sensitive), else `JobAffiliation` (the default). Title is the single source of truth, so a retitle re-types the row; never write `title` via `update_all`/`update_columns` (skips the callback). The column has **no default** on purpose — a default subclass name would make `Affiliation.new` build that subclass and then break `reload` after the callback re-types. `#facilitator?` and the `.facilitators` scope read the `type` column. Both subtypes share `Affiliation`'s routes/param-key/`dom_id` (`self.model_name`) and authorize through the one `AffiliationPolicy` (`self.policy_class`). + - The type is *derived from the title* — `set_type_from_title` (a `before_validation`) sets `FacilitatorAffiliation` when the title is exactly `"Facilitator"` (trimmed, case-sensitive), else `JobAffiliation` (the default). Title is the single source of truth, so a retitle re-types the row; never write `title` via `update_all`/`update_columns` (skips the callback). The column has **no default** on purpose — a default subclass name would make `Affiliation.new` build that subclass and then break `reload` after the callback re-types. `#facilitator?` and the `.facilitators` scope read the `type` column. Both subtypes share `Affiliation`'s routes/param-key/`dom_id` (`self.model_name`) and authorize through the one `AffiliationPolicy` (`self.policy_class`). Ahoy events, though, are stamped with the *instance's* class name, so affiliation events exist under all three names — read them back through `Affiliation.event_resource_types` (the `AhoyTrackable` hook), never a literal `"Affiliation"`. ### Polymorphic Associations diff --git a/app/models/affiliation.rb b/app/models/affiliation.rb index a4c6832176..5e8dfc006a 100644 --- a/app/models/affiliation.rb +++ b/app/models/affiliation.rb @@ -96,6 +96,15 @@ def self.model_name @_affiliation_model_name ||= ActiveModel::Name.new(Affiliation) end + # Ahoy stamps resource_type from the saving instance's class, which is the base + # class for a freshly built row and the subtype once it's loaded back — plus + # "Affiliation" on everything recorded before the STI split. Readers must match + # all three (Analytics::PersonActivityEvents, shared/_audit_info). Listed lazily + # so the subclasses aren't autoloaded while this class body is still evaluating. + def self.event_resource_types + [ Affiliation, FacilitatorAffiliation, JobAffiliation ].map(&:name) + end + # Methods # Reads the type column, not #is_a?, so it holds on a base-built instance whose # type was just assigned by #set_type_from_title but not yet reloaded. diff --git a/app/models/concerns/ahoy_trackable.rb b/app/models/concerns/ahoy_trackable.rb index 4c8116740f..705d709709 100644 --- a/app/models/concerns/ahoy_trackable.rb +++ b/app/models/concerns/ahoy_trackable.rb @@ -9,6 +9,16 @@ module AhoyTrackable before_destroy :capture_destroy_snapshot end + class_methods do + # Every value this model's events can carry in `resource_type`, for code that + # reads events back. Analytics::EventBuilder stamps the instance's own class + # name, so an STI model must list its subtypes here (and the base name, for + # rows written before the subtypes existed) — see Affiliation. + def event_resource_types + [ name ] + end + end + private def devise_only_changes?(changes) diff --git a/app/services/analytics/person_activity_events.rb b/app/services/analytics/person_activity_events.rb index c879e6c2dd..7494d3e383 100644 --- a/app/services/analytics/person_activity_events.rb +++ b/app/services/analytics/person_activity_events.rb @@ -28,12 +28,13 @@ def resource_scopes end end - # resource_type => ids (arrays or id-only subqueries). Mirrors the records in - # people/_associated_records plus the person's own nested records. + # resource_type => ids (arrays or id-only subqueries). A key may be a list of + # type names when one model records events under several (STI). Mirrors the + # records in people/_associated_records plus the person's own nested records. def resource_ids_by_type map = { "Person" => [ @person.id ], - "Affiliation" => @person.affiliations.select(:id), + Affiliation.event_resource_types => @person.affiliations.select(:id), "ProfessionalLicense" => @person.professional_licenses.select(:id), "Membership" => @person.memberships.select(:id), "Address" => @person.addresses.select(:id), diff --git a/app/views/shared/_audit_info.html.erb b/app/views/shared/_audit_info.html.erb index c5baf18ecd..174d9e882e 100644 --- a/app/views/shared/_audit_info.html.erb +++ b/app/views/shared/_audit_info.html.erb @@ -4,7 +4,10 @@ return unless resource&.persisted? model = resource.respond_to?(:object) ? resource.object : resource - ahoy_scope = Ahoy::Event.where(resource_type: model.class.name, resource_id: model.id) + # An STI model's events are spread across its class names, so ask the model + # which ones to look under rather than assuming its own (see AhoyTrackable). + resource_types = model.class.event_resource_types + ahoy_scope = Ahoy::Event.where(resource_type: resource_types, resource_id: model.id) created_by_user = resource.try(:created_by) || resource.try(:user) || ahoy_scope.where("name LIKE 'create.%'").order(time: :asc).first&.user @@ -23,7 +26,7 @@ by <%= link_to created_by_user.full_name, person_path(created_by_user.person), class: "text-indigo-600 hover:underline" %> <% end %> <% if allowed_to?(:index?, with: Admin::AhoyActivityPolicy) %> - <%= link_to admin_activities_events_path(resource_type: model.class.name, resource_id: resource.id), + <%= link_to admin_activities_events_path(resource_type: resource_types, resource_id: resource.id), class: "ml-1 text-indigo-600 hover:underline", title: "View creation event in Ahoy" do %> @@ -40,7 +43,7 @@ by <%= link_to updated_by_user.full_name, person_path(updated_by_user.person), class: "text-indigo-600 hover:underline" %> <% end %> <% if allowed_to?(:index?, with: Admin::AhoyActivityPolicy) %> - <%= link_to admin_activities_events_path(resource_type: model.class.name, resource_id: resource.id), + <%= link_to admin_activities_events_path(resource_type: resource_types, resource_id: resource.id), class: "ml-1 text-indigo-600 hover:underline", title: "View update events in Ahoy" do %> diff --git a/spec/requests/affiliations_spec.rb b/spec/requests/affiliations_spec.rb index 1cf65b6dcd..2c751c7768 100644 --- a/spec/requests/affiliations_spec.rb +++ b/spec/requests/affiliations_spec.rb @@ -18,6 +18,16 @@ expect(response).to be_successful end + it "credits the creator from a lifecycle event recorded under the base class name" do + author = create(:person).user + create(:ahoy_event, name: "create.affiliation", resource_type: "Affiliation", + resource_id: affiliation.id, user: author, time: 1.day.ago) + + get edit_affiliation_path(affiliation) + + expect(response.body).to include(author.full_name) + end + it "surfaces a linked registration with the org-linking warning" do registration = create(:event_registration) affiliation.update_column(:event_registration_id, registration.id) diff --git a/spec/services/analytics/person_activity_events_spec.rb b/spec/services/analytics/person_activity_events_spec.rb index 6866483745..47243f929c 100644 --- a/spec/services/analytics/person_activity_events_spec.rb +++ b/spec/services/analytics/person_activity_events_spec.rb @@ -44,6 +44,15 @@ def event(resource_type:, resource_id:, name: "update.record", properties: {}) expect(described_class.new(person).relation).to include(target) end + it "includes affiliation events under every STI class name Ahoy may have recorded" do + affiliation = create(:affiliation, person: person, title: "Facilitator") + pre_split = event(resource_type: "Affiliation", resource_id: affiliation.id, name: "create.affiliation") + facilitator = event(resource_type: "FacilitatorAffiliation", resource_id: affiliation.id, name: "update.affiliation") + job = event(resource_type: "JobAffiliation", resource_id: affiliation.id, name: "update.affiliation") + + expect(described_class.new(person).relation).to include(pre_split, facilitator, job) + end + it "includes events about the person's continuing education registrations" do registration = create(:event_registration, registrant: person) ce = create(:continuing_education_registration, event_registration: registration) From 75e1fb5867ec2d4d63917bcf170295dfab8a788d Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Wed, 19 Aug 2026 23:50:23 -0400 Subject: [PATCH 5/7] Trim the Ahoy event-type comments to the non-obvious part Co-Authored-By: Claude Opus 5 (1M context) --- app/models/affiliation.rb | 7 ++----- app/models/concerns/ahoy_trackable.rb | 6 ++---- app/services/analytics/person_activity_events.rb | 5 ++--- app/views/shared/_audit_info.html.erb | 2 -- 4 files changed, 6 insertions(+), 14 deletions(-) diff --git a/app/models/affiliation.rb b/app/models/affiliation.rb index 5e8dfc006a..218aed281a 100644 --- a/app/models/affiliation.rb +++ b/app/models/affiliation.rb @@ -96,11 +96,8 @@ def self.model_name @_affiliation_model_name ||= ActiveModel::Name.new(Affiliation) end - # Ahoy stamps resource_type from the saving instance's class, which is the base - # class for a freshly built row and the subtype once it's loaded back — plus - # "Affiliation" on everything recorded before the STI split. Readers must match - # all three (Analytics::PersonActivityEvents, shared/_audit_info). Listed lazily - # so the subclasses aren't autoloaded while this class body is still evaluating. + # All three: "Affiliation" for rows built as the base class or saved before the + # STI split, each subtype for rows saved after being loaded back. def self.event_resource_types [ Affiliation, FacilitatorAffiliation, JobAffiliation ].map(&:name) end diff --git a/app/models/concerns/ahoy_trackable.rb b/app/models/concerns/ahoy_trackable.rb index 705d709709..5d028b92fa 100644 --- a/app/models/concerns/ahoy_trackable.rb +++ b/app/models/concerns/ahoy_trackable.rb @@ -10,10 +10,8 @@ module AhoyTrackable end class_methods do - # Every value this model's events can carry in `resource_type`, for code that - # reads events back. Analytics::EventBuilder stamps the instance's own class - # name, so an STI model must list its subtypes here (and the base name, for - # rows written before the subtypes existed) — see Affiliation. + # Read events back through this: EventBuilder stamps the instance's own class + # name, so an STI model must override it with every name it has written. def event_resource_types [ name ] end diff --git a/app/services/analytics/person_activity_events.rb b/app/services/analytics/person_activity_events.rb index 7494d3e383..2330a36ff9 100644 --- a/app/services/analytics/person_activity_events.rb +++ b/app/services/analytics/person_activity_events.rb @@ -28,9 +28,8 @@ def resource_scopes end end - # resource_type => ids (arrays or id-only subqueries). A key may be a list of - # type names when one model records events under several (STI). Mirrors the - # records in people/_associated_records plus the person's own nested records. + # resource_type => ids (arrays or id-only subqueries). Mirrors the records in + # people/_associated_records plus the person's own nested records. def resource_ids_by_type map = { "Person" => [ @person.id ], diff --git a/app/views/shared/_audit_info.html.erb b/app/views/shared/_audit_info.html.erb index 174d9e882e..a80620c9c9 100644 --- a/app/views/shared/_audit_info.html.erb +++ b/app/views/shared/_audit_info.html.erb @@ -4,8 +4,6 @@ return unless resource&.persisted? model = resource.respond_to?(:object) ? resource.object : resource - # An STI model's events are spread across its class names, so ask the model - # which ones to look under rather than assuming its own (see AhoyTrackable). resource_types = model.class.event_resource_types ahoy_scope = Ahoy::Event.where(resource_type: resource_types, resource_id: model.id) From 0605ed74d47c99fd05add9178af808105f9dd9cb Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Sat, 22 Aug 2026 04:53:46 -0400 Subject: [PATCH 6/7] Revert the STI split, keeping the facilitator boolean The STI subtypes bought a typed model but cost a second source of truth for every name the record answers to: model_name, policy_class and an event_resource_types indirection for Ahoy all existed only to make two subclasses keep behaving like one Affiliation. The boolean says the same thing about the same rows without splitting the class. Reverts da0d5e7e4..75e1fb586, restoring the tree from 153720194. Co-Authored-By: Claude Opus 5 (1M context) --- AGENTS.md | 2 - app/frontend/javascript/lib/affiliation.js | 8 +++- app/models/affiliation.rb | 44 +++++++------------ app/models/concerns/ahoy_trackable.rb | 8 ---- app/models/facilitator_affiliation.rb | 4 -- app/models/job_affiliation.rb | 4 -- .../analytics/person_activity_events.rb | 2 +- app/views/shared/_audit_info.html.erb | 7 ++- ...9125941_add_facilitator_to_affiliations.rb | 15 +++++++ ...20260819134257_add_type_to_affiliations.rb | 17 ------- db/schema.rb | 3 +- .../backfill_affiliation_facilitator.rake | 14 +++--- spec/models/affiliation_spec.rb | 41 ++++------------- spec/requests/affiliations_spec.rb | 10 ----- .../analytics/person_activity_events_spec.rb | 9 ---- 15 files changed, 58 insertions(+), 130 deletions(-) delete mode 100644 app/models/facilitator_affiliation.rb delete mode 100644 app/models/job_affiliation.rb create mode 100644 db/migrate/20260819125941_add_facilitator_to_affiliations.rb delete mode 100644 db/migrate/20260819134257_add_type_to_affiliations.rb diff --git a/AGENTS.md b/AGENTS.md index b5b8e08e57..375ebb393d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -121,8 +121,6 @@ This codebase (Rails 8.1) - **Asset** (inheritance column: `type`): PrimaryAsset, GalleryAsset, RichTextAsset, DownloadableAsset, ThumbnailAsset, FormUploadAsset - The `type` column defaults to `"PrimaryAsset"`, which narrows `ACCEPTED_CONTENT_TYPES` to five image types — always name the type when building an Asset, or documents will fail validation. `FormUploadAsset` backs a respondent's file-upload form answer and takes Asset's full accepted-type list. - **Report**: MonthlyReport -- **Affiliation** (inheritance column: `type`, nullable, no default): FacilitatorAffiliation, JobAffiliation - - The type is *derived from the title* — `set_type_from_title` (a `before_validation`) sets `FacilitatorAffiliation` when the title is exactly `"Facilitator"` (trimmed, case-sensitive), else `JobAffiliation` (the default). Title is the single source of truth, so a retitle re-types the row; never write `title` via `update_all`/`update_columns` (skips the callback). The column has **no default** on purpose — a default subclass name would make `Affiliation.new` build that subclass and then break `reload` after the callback re-types. `#facilitator?` and the `.facilitators` scope read the `type` column. Both subtypes share `Affiliation`'s routes/param-key/`dom_id` (`self.model_name`) and authorize through the one `AffiliationPolicy` (`self.policy_class`). Ahoy events, though, are stamped with the *instance's* class name, so affiliation events exist under all three names — read them back through `Affiliation.event_resource_types` (the `AhoyTrackable` hook), never a literal `"Affiliation"`. ### Polymorphic Associations diff --git a/app/frontend/javascript/lib/affiliation.js b/app/frontend/javascript/lib/affiliation.js index aa9dfb9787..32fb8aa5af 100644 --- a/app/frontend/javascript/lib/affiliation.js +++ b/app/frontend/javascript/lib/affiliation.js @@ -1,5 +1,9 @@ -// Mirrors the server's facilitator rule (exactly "Facilitator", trimmed, -// case-sensitive) so the editors' live preview matches the persisted STI type. +// The single JS source of truth for "is this the standing Facilitator +// affiliation?", mirroring Ruby's Affiliation#facilitator? / .facilitators: the +// title must be *exactly* "Facilitator" (trimmed, case-sensitive). Variants like +// "Lead Facilitator" or "facilitator" are deliberately excluded. The affiliation +// editors drive their live preview off the typed title, so they compare the input +// value through this helper rather than the persisted boolean. export const facilitatorTitle = "Facilitator" export const isFacilitatorTitle = (title) => (title ?? "").trim() === facilitatorTitle diff --git a/app/models/affiliation.rb b/app/models/affiliation.rb index 218aed281a..3a4e5c16d2 100644 --- a/app/models/affiliation.rb +++ b/app/models/affiliation.rb @@ -49,7 +49,10 @@ class Affiliation < ApplicationRecord .where("affiliations.end_date IS NULL OR affiliations.end_date >= ?", date) } - scope :facilitators, -> { where(type: FacilitatorAffiliation.name) } + # Reads the denormalized `facilitator` flag, which #sync_facilitator_from_title + # keeps in lock-step with the title rule (exactly "Facilitator", trimmed, + # case-sensitive). An executable agreement spec locks the column to that rule. + scope :facilitators, -> { where(facilitator: true) } # Affiliations whose #status_on(date) equals the given status, expressed in SQL # so it composes as a subquery (e.g. person-id narrowing). Kept in lock-step with @@ -74,7 +77,7 @@ class Affiliation < ApplicationRecord end } - before_validation :set_type_from_title + before_validation :sync_facilitator_from_title before_validation :skip_if_duplicate # Runs before validation so a reassigned org drops its stale organization_address_id # before organization_address_belongs_to_organization would reject it. @@ -85,29 +88,12 @@ class Affiliation < ApplicationRecord after_destroy :sync_organization_status_with_affiliations after_destroy :sync_organization_affiliation_dates - # Both STI subtypes authorize through the one AffiliationPolicy. - def self.policy_class - AffiliationPolicy - end - - # Both subtypes share Affiliation's routes, param key, and dom_ids — without this, - # url_for/dom_id would derive facilitator_affiliation_* and break the controller. - def self.model_name - @_affiliation_model_name ||= ActiveModel::Name.new(Affiliation) - end - - # All three: "Affiliation" for rows built as the base class or saved before the - # STI split, each subtype for rows saved after being loaded back. - def self.event_resource_types - [ Affiliation, FacilitatorAffiliation, JobAffiliation ].map(&:name) - end - # Methods - # Reads the type column, not #is_a?, so it holds on a base-built instance whose - # type was just assigned by #set_type_from_title but not yet reloaded. - def facilitator? - type == FacilitatorAffiliation.name - end + # `facilitator?` is the boolean column's auto-generated reader. A facilitator + # affiliation is one whose title is *exactly* "Facilitator" (trimmed, + # case-sensitive); #sync_facilitator_from_title keeps the column in step with + # that rule on every save, so #facilitator? and the .facilitators scope agree. + # Variants like "Lead Facilitator" or "facilitator" are deliberately excluded. # Current: not flagged inactive and not past its end date. Mirrors the `active` # scope so already-loaded affiliations can be filtered in Ruby without another @@ -181,10 +167,12 @@ def set_inactive_from_dates self.inactive = end_date.present? && end_date < Date.current end - # Title is the source of truth for the subtype, re-derived on every save. Never - # write `title` via update_columns/update_all — that skips this and drifts the type. - def set_type_from_title - self.type = title.to_s.strip == FACILITATOR_TITLE ? FacilitatorAffiliation.name : JobAffiliation.name + # Keep the denormalized flag in step with the title rule (exactly "Facilitator", + # trimmed, case-sensitive) on every save, so the .facilitators scope and + # #facilitator? agree. Invariant: never write `title` via update_columns / + # update_all — that skips this callback and lets the flag drift. + def sync_facilitator_from_title + self.facilitator = title.to_s.strip == FACILITATOR_TITLE end def sync_organization_affiliation_dates diff --git a/app/models/concerns/ahoy_trackable.rb b/app/models/concerns/ahoy_trackable.rb index 5d028b92fa..4c8116740f 100644 --- a/app/models/concerns/ahoy_trackable.rb +++ b/app/models/concerns/ahoy_trackable.rb @@ -9,14 +9,6 @@ module AhoyTrackable before_destroy :capture_destroy_snapshot end - class_methods do - # Read events back through this: EventBuilder stamps the instance's own class - # name, so an STI model must override it with every name it has written. - def event_resource_types - [ name ] - end - end - private def devise_only_changes?(changes) diff --git a/app/models/facilitator_affiliation.rb b/app/models/facilitator_affiliation.rb deleted file mode 100644 index ef5ed94011..0000000000 --- a/app/models/facilitator_affiliation.rb +++ /dev/null @@ -1,4 +0,0 @@ -# STI subtype for the standing "Facilitator" affiliation (confers org Art Program -# status). Affiliation#set_type_from_title assigns this type from the title. -class FacilitatorAffiliation < Affiliation -end diff --git a/app/models/job_affiliation.rb b/app/models/job_affiliation.rb deleted file mode 100644 index a4e146b54d..0000000000 --- a/app/models/job_affiliation.rb +++ /dev/null @@ -1,4 +0,0 @@ -# STI subtype for a person's role/job at an org (any non-"Facilitator" title), the -# default. Affiliation#set_type_from_title assigns this type from the title. -class JobAffiliation < Affiliation -end diff --git a/app/services/analytics/person_activity_events.rb b/app/services/analytics/person_activity_events.rb index 2330a36ff9..c879e6c2dd 100644 --- a/app/services/analytics/person_activity_events.rb +++ b/app/services/analytics/person_activity_events.rb @@ -33,7 +33,7 @@ def resource_scopes def resource_ids_by_type map = { "Person" => [ @person.id ], - Affiliation.event_resource_types => @person.affiliations.select(:id), + "Affiliation" => @person.affiliations.select(:id), "ProfessionalLicense" => @person.professional_licenses.select(:id), "Membership" => @person.memberships.select(:id), "Address" => @person.addresses.select(:id), diff --git a/app/views/shared/_audit_info.html.erb b/app/views/shared/_audit_info.html.erb index a80620c9c9..c5baf18ecd 100644 --- a/app/views/shared/_audit_info.html.erb +++ b/app/views/shared/_audit_info.html.erb @@ -4,8 +4,7 @@ return unless resource&.persisted? model = resource.respond_to?(:object) ? resource.object : resource - resource_types = model.class.event_resource_types - ahoy_scope = Ahoy::Event.where(resource_type: resource_types, resource_id: model.id) + ahoy_scope = Ahoy::Event.where(resource_type: model.class.name, resource_id: model.id) created_by_user = resource.try(:created_by) || resource.try(:user) || ahoy_scope.where("name LIKE 'create.%'").order(time: :asc).first&.user @@ -24,7 +23,7 @@ by <%= link_to created_by_user.full_name, person_path(created_by_user.person), class: "text-indigo-600 hover:underline" %> <% end %> <% if allowed_to?(:index?, with: Admin::AhoyActivityPolicy) %> - <%= link_to admin_activities_events_path(resource_type: resource_types, resource_id: resource.id), + <%= link_to admin_activities_events_path(resource_type: model.class.name, resource_id: resource.id), class: "ml-1 text-indigo-600 hover:underline", title: "View creation event in Ahoy" do %> @@ -41,7 +40,7 @@ by <%= link_to updated_by_user.full_name, person_path(updated_by_user.person), class: "text-indigo-600 hover:underline" %> <% end %> <% if allowed_to?(:index?, with: Admin::AhoyActivityPolicy) %> - <%= link_to admin_activities_events_path(resource_type: resource_types, resource_id: resource.id), + <%= link_to admin_activities_events_path(resource_type: model.class.name, resource_id: resource.id), class: "ml-1 text-indigo-600 hover:underline", title: "View update events in Ahoy" do %> diff --git a/db/migrate/20260819125941_add_facilitator_to_affiliations.rb b/db/migrate/20260819125941_add_facilitator_to_affiliations.rb new file mode 100644 index 0000000000..3dc80927e7 --- /dev/null +++ b/db/migrate/20260819125941_add_facilitator_to_affiliations.rb @@ -0,0 +1,15 @@ +class AddFacilitatorToAffiliations < ActiveRecord::Migration[8.1] + # Denormalized cache of "is this the standing Facilitator affiliation?", kept in + # sync from the title by Affiliation. Replaces the raw BINARY TRIM(title) scope. + # Schema only — existing rows are backfilled by the affiliations:backfill_facilitator + # rake task after deploy (see lib/tasks). + def up + return if column_exists?(:affiliations, :facilitator) + + add_column :affiliations, :facilitator, :boolean, null: false, default: false + end + + def down + remove_column :affiliations, :facilitator, if_exists: true + end +end diff --git a/db/migrate/20260819134257_add_type_to_affiliations.rb b/db/migrate/20260819134257_add_type_to_affiliations.rb deleted file mode 100644 index 9a4515ed88..0000000000 --- a/db/migrate/20260819134257_add_type_to_affiliations.rb +++ /dev/null @@ -1,17 +0,0 @@ -class AddTypeToAffiliations < ActiveRecord::Migration[8.1] - # STI discriminator. Nullable with NO default on purpose: a default subclass name - # makes Affiliation.new build that subclass, so a row the callback re-types raises - # RecordNotFound on reload. Existing rows are typed post-deploy by - # affiliations:backfill_facilitator (see lib/tasks); new rows via the model callback. - def up - return if column_exists?(:affiliations, :type) - - add_column :affiliations, :type, :string - add_index :affiliations, :type - end - - def down - remove_index :affiliations, :type, if_exists: true - remove_column :affiliations, :type, if_exists: true - end -end diff --git a/db/schema.rb b/db/schema.rb index 4713689a45..2e7223bb04 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -108,6 +108,7 @@ t.datetime "created_at", precision: nil, null: false t.date "end_date" t.bigint "event_registration_id" + t.boolean "facilitator", default: false, null: false t.string "filemaker_code" t.boolean "inactive", default: false, null: false t.bigint "organization_address_id" @@ -118,7 +119,6 @@ t.boolean "primary_contact", default: false, null: false t.date "start_date" t.string "title" - t.string "type" t.datetime "updated_at", precision: nil, null: false t.integer "user_id" t.index ["event_registration_id"], name: "index_affiliations_on_event_registration_id" @@ -126,7 +126,6 @@ t.index ["organization_agency_id"], name: "index_affiliations_on_organization_agency_id" t.index ["organization_id"], name: "index_affiliations_on_organization_id" t.index ["person_id"], name: "index_affiliations_on_person_id" - t.index ["type"], name: "index_affiliations_on_type" t.index ["user_id"], name: "index_affiliations_on_user_id" end diff --git a/lib/tasks/backfill_affiliation_facilitator.rake b/lib/tasks/backfill_affiliation_facilitator.rake index 5899c94da9..490ca9b1b1 100644 --- a/lib/tasks/backfill_affiliation_facilitator.rake +++ b/lib/tasks/backfill_affiliation_facilitator.rake @@ -1,11 +1,11 @@ namespace :affiliations do - desc "Backfill affiliation STI type from the title (one-off, post-deploy)" + desc "Backfill the affiliations.facilitator flag from the title (one-off, post-deploy)" task backfill_facilitator: :environment do - # Type every existing row from its title, matching Affiliation#set_type_from_title. - facilitators = Affiliation.where("BINARY TRIM(title) = ?", "Facilitator") - .update_all(type: "FacilitatorAffiliation") - jobs = Affiliation.where("BINARY TRIM(title) <> ? OR title IS NULL", "Facilitator") - .update_all(type: "JobAffiliation") - puts "Typed #{facilitators} FacilitatorAffiliation(s) and #{jobs} JobAffiliation(s)." + # Same rule as the retired .facilitators SQL scope: exactly "Facilitator", + # trimmed, case-sensitive. update_all is deliberate — the value is computed + # inline, so no per-row callback is needed and this stays a single bulk write. + scope = Affiliation.where("BINARY TRIM(title) = ?", "Facilitator") + count = scope.update_all(facilitator: true) + puts "Backfilled facilitator: true on #{count} affiliation(s)." end end diff --git a/spec/models/affiliation_spec.rb b/spec/models/affiliation_spec.rb index 1f90f48721..3915524bfc 100644 --- a/spec/models/affiliation_spec.rb +++ b/spec/models/affiliation_spec.rb @@ -118,9 +118,9 @@ end end - describe '#facilitator? (STI type derived from title on validation)' do - # #facilitator? reads the STI type column, which set_type_from_title assigns in - # before_validation — so validate before reading it. + describe '#facilitator? (synced from title on validation)' do + # #facilitator? reads the denormalized column, which sync_facilitator_from_title + # sets in before_validation — so validate before reading it. def facilitator_flag(title) build(:affiliation, title: title).tap(&:validate).facilitator? end @@ -146,40 +146,18 @@ def facilitator_flag(title) expect(facilitator_flag(nil)).to be false end - it 're-types the row when retitled to or from "Facilitator"' do + it 'flips the column when a row is retitled to or from "Facilitator"' do affiliation = create(:affiliation, title: "Facilitator") - expect(described_class.find(affiliation.id)).to be_a(FacilitatorAffiliation) + expect(affiliation.facilitator?).to be true affiliation.update!(title: "Lead Facilitator") - expect(described_class.find(affiliation.id)).to be_a(JobAffiliation) expect(affiliation.reload.facilitator?).to be false affiliation.update!(title: "Facilitator") - expect(described_class.find(affiliation.id)).to be_a(FacilitatorAffiliation) expect(affiliation.reload.facilitator?).to be true end end - describe 'STI subtypes' do - it 'loads a "Facilitator"-titled row as FacilitatorAffiliation and others as JobAffiliation' do - facilitator = create(:affiliation, title: "Facilitator") - job = create(:affiliation, title: "Volunteer") - - expect(described_class.find(facilitator.id)).to be_a(FacilitatorAffiliation) - expect(described_class.find(job.id)).to be_a(JobAffiliation) - end - - it 'defaults an untitled row to JobAffiliation' do - affiliation = create(:affiliation, title: nil) - expect(described_class.find(affiliation.id)).to be_a(JobAffiliation) - end - - it 'authorizes both subtypes through AffiliationPolicy' do - expect(FacilitatorAffiliation.policy_class).to eq(AffiliationPolicy) - expect(JobAffiliation.policy_class).to eq(AffiliationPolicy) - end - end - describe '.facilitators' do let!(:exact) { create(:affiliation, title: "Facilitator") } let!(:whitespace) { create(:affiliation, title: " Facilitator ") } @@ -187,18 +165,17 @@ def facilitator_flag(title) let!(:lowercase) { create(:affiliation, title: "facilitator") } it 'includes only the exact, case-sensitive title "Facilitator" (whitespace-trimmed)' do - expect(described_class.facilitators.ids).to contain_exactly(exact.id, whitespace.id) + expect(described_class.facilitators).to contain_exactly(exact, whitespace) end - it 'returns exactly the rows whose title matches the rule (type ↔ scope agree)' do + it 'returns exactly the rows whose title matches the rule (column ↔ scope agree)' do expected = described_class.all.select { |a| a.title.to_s.strip == "Facilitator" }.map(&:id).sort expect(described_class.facilitators.ids.sort).to eq(expected) end - it 'keeps the persisted STI type in step with the title rule' do + it 'keeps the persisted facilitator column in step with the title rule' do described_class.find_each do |affiliation| - expected_type = affiliation.title.to_s.strip == "Facilitator" ? "FacilitatorAffiliation" : "JobAffiliation" - expect(affiliation.type).to eq(expected_type) + expect(affiliation.facilitator).to eq(affiliation.title.to_s.strip == "Facilitator") end end end diff --git a/spec/requests/affiliations_spec.rb b/spec/requests/affiliations_spec.rb index 2c751c7768..1cf65b6dcd 100644 --- a/spec/requests/affiliations_spec.rb +++ b/spec/requests/affiliations_spec.rb @@ -18,16 +18,6 @@ expect(response).to be_successful end - it "credits the creator from a lifecycle event recorded under the base class name" do - author = create(:person).user - create(:ahoy_event, name: "create.affiliation", resource_type: "Affiliation", - resource_id: affiliation.id, user: author, time: 1.day.ago) - - get edit_affiliation_path(affiliation) - - expect(response.body).to include(author.full_name) - end - it "surfaces a linked registration with the org-linking warning" do registration = create(:event_registration) affiliation.update_column(:event_registration_id, registration.id) diff --git a/spec/services/analytics/person_activity_events_spec.rb b/spec/services/analytics/person_activity_events_spec.rb index 47243f929c..6866483745 100644 --- a/spec/services/analytics/person_activity_events_spec.rb +++ b/spec/services/analytics/person_activity_events_spec.rb @@ -44,15 +44,6 @@ def event(resource_type:, resource_id:, name: "update.record", properties: {}) expect(described_class.new(person).relation).to include(target) end - it "includes affiliation events under every STI class name Ahoy may have recorded" do - affiliation = create(:affiliation, person: person, title: "Facilitator") - pre_split = event(resource_type: "Affiliation", resource_id: affiliation.id, name: "create.affiliation") - facilitator = event(resource_type: "FacilitatorAffiliation", resource_id: affiliation.id, name: "update.affiliation") - job = event(resource_type: "JobAffiliation", resource_id: affiliation.id, name: "update.affiliation") - - expect(described_class.new(person).relation).to include(pre_split, facilitator, job) - end - it "includes events about the person's continuing education registrations" do registration = create(:event_registration, registrant: person) ce = create(:continuing_education_registration, event_registration: registration) From 16f4226e4d934d7950c414e6b32d310a21335f1b Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Sat, 22 Aug 2026 04:54:30 -0400 Subject: [PATCH 7/7] List the backfill rake task in AGENTS.md CLAUDE.md requires AGENTS.md to track added rake tasks. Also drops the duplicate migrate_sectors.rake line, which made the count read right by accident. Co-Authored-By: Claude Opus 5 (1M context) --- AGENTS.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 375ebb393d..e03d3a5af4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -510,7 +510,7 @@ RuboCop linting on PRs and pushes to main. ## Rake Tasks -Located in `lib/tasks/` (9 files): +Located in `lib/tasks/` (10 files): - `dev.rake` — Development database seeding from XML/CSV - `rhino_migrator.rake` — Rich text editor migration - `attachment_report.rake` — Attachment reporting @@ -520,4 +520,4 @@ Located in `lib/tasks/` (9 files): - `migrate_sectors.rake` — Sector data migration - `import_stories.rake` — Imports stories from a WordPress Posts Export CSV (`StoryImporter`) - `migrate_workshop_logs.rake` — Workshop log migration -- `migrate_sectors.rake` — Sector data migration +- `backfill_affiliation_facilitator.rake` — One-off post-deploy backfill of `affiliations.facilitator` from the title