From 25c00d2930e2c1973df30929a8fa2831e3d48133 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Wed, 19 Aug 2026 09:03:34 -0400 Subject: [PATCH 1/3] 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 | 3 +- .../backfill_affiliation_facilitator.rake | 11 +++++ spec/models/affiliation_spec.rb | 42 +++++++++++++++---- 9 files changed, 96 insertions(+), 30 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 641e23adaf..46286a0d2e 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 = ["affiliatedSince", "facilitatorSince", "affiliationsContainer", "programStatus"] @@ -68,11 +69,7 @@ export default class extends Controller { } } - // 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 6f780a6218..b530f19d1a 100644 --- a/app/models/affiliation.rb +++ b/app/models/affiliation.rb @@ -48,11 +48,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 @@ -77,6 +76,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. @@ -88,13 +88,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 @@ -168,6 +166,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 8674e05fdb..b5fa473533 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_08_17_115845) do +ActiveRecord::Schema[8.1].define(version: 2026_08_19_125941) do create_table "action_text_mentions", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| t.bigint "action_text_rich_text_id", null: false t.datetime "created_at", null: false @@ -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 afbe467f2445234bde9d2e19f85b0d6a29a3a76f Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Wed, 19 Aug 2026 09:54:14 -0400 Subject: [PATCH 2/3] 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 | 5 +- .../backfill_affiliation_facilitator.rake | 17 ++++--- spec/models/affiliation_spec.rb | 41 ++++++++++++---- 10 files changed, 116 insertions(+), 54 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 18e277690a..b771c6ef17 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 b530f19d1a..adad8ab057 100644 --- a/app/models/affiliation.rb +++ b/app/models/affiliation.rb @@ -48,10 +48,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 @@ -76,7 +76,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. @@ -87,12 +87,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 @@ -166,12 +182,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 b5fa473533..5a6a8fa71e 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_08_19_125941) do +ActiveRecord::Schema[8.1].define(version: 2026_08_19_134257) do create_table "action_text_mentions", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| t.bigint "action_text_rich_text_id", null: false t.datetime "created_at", null: false @@ -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 4307c8382818b279bda6ced99409eb1922e94135 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Wed, 19 Aug 2026 09:10:09 -0400 Subject: [PATCH 3/3] Show affiliation history as a merged timeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reading an affiliation's dates tells you what is true now but not how it got there. The Ahoy lifecycle events already record every edit, and the trainings and membership periods that explain those edits live in their own tables — this puts all three in one time-ordered view so an admin can see why a row looks the way it does without leaving the page. Trainings and memberships are read from their own tables rather than from Ahoy: Ahoy records *changes*, and only those made while a Current.user or Current.source was set, so imported and seeded rows have no events at all. Ahoy is used only for the affiliation's own columns, where nothing else records them. Ahoy events are matched on every STI name for the row's table, not just the record's current class. A row filed as Affiliation before the subtypes existed — or under the other subtype before a retitle re-typed it — would otherwise lose that history. Split out of #2195; stacked on #2259 for the STI subtypes. Co-Authored-By: Claude Opus 5 (1M context) --- AGENTS.md | 3 + app/controllers/affiliations_controller.rb | 2 + app/controllers/people_controller.rb | 12 +- app/decorators/affiliation_decorator.rb | 7 + app/policies/person_policy.rb | 4 + .../analytics/affiliation_timeline.rb | 101 ++++++++++++++ .../analytics/person_affiliation_timeline.rb | 79 +++++++++++ app/services/analytics/resource_history.rb | 117 ++++++++++++++++ app/views/affiliations/_timeline.html.erb | 94 +++++++++++++ .../_timeline_affiliation.html.erb | 21 +++ .../affiliations/_timeline_change.html.erb | 19 +++ .../_timeline_membership.html.erb | 10 ++ .../_timeline_person_training.html.erb | 27 ++++ .../_timeline_provenance.html.erb | 12 ++ .../affiliations/_timeline_training.html.erb | 26 ++++ app/views/affiliations/edit.html.erb | 2 + app/views/people/_form.html.erb | 14 +- app/views/people/affiliation_history.html.erb | 45 ++++++ config/routes.rb | 1 + spec/decorators/affiliation_decorator_spec.rb | 23 ++++ .../affiliations_edit_history_spec.rb | 65 +++++++++ .../people_affiliation_history_spec.rb | 47 +++++++ .../analytics/affiliation_timeline_spec.rb | 130 ++++++++++++++++++ .../person_affiliation_timeline_spec.rb | 77 +++++++++++ spec/views/page_bg_class_alignment_spec.rb | 1 + 25 files changed, 936 insertions(+), 3 deletions(-) create mode 100644 app/services/analytics/affiliation_timeline.rb create mode 100644 app/services/analytics/person_affiliation_timeline.rb create mode 100644 app/services/analytics/resource_history.rb create mode 100644 app/views/affiliations/_timeline.html.erb create mode 100644 app/views/affiliations/_timeline_affiliation.html.erb create mode 100644 app/views/affiliations/_timeline_change.html.erb create mode 100644 app/views/affiliations/_timeline_membership.html.erb create mode 100644 app/views/affiliations/_timeline_person_training.html.erb create mode 100644 app/views/affiliations/_timeline_provenance.html.erb create mode 100644 app/views/affiliations/_timeline_training.html.erb create mode 100644 app/views/people/affiliation_history.html.erb create mode 100644 spec/decorators/affiliation_decorator_spec.rb create mode 100644 spec/requests/affiliations_edit_history_spec.rb create mode 100644 spec/requests/people_affiliation_history_spec.rb create mode 100644 spec/services/analytics/affiliation_timeline_spec.rb create mode 100644 spec/services/analytics/person_affiliation_timeline_spec.rb diff --git a/AGENTS.md b/AGENTS.md index b771c6ef17..4e9af7861d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -198,6 +198,9 @@ action, or `authorize! :workshop, to: :summary?`). - `Analytics::EventBuilder` — Constructs analytics event payloads - `Analytics::AhoyTracker` — Coordinates ahoy event tracking - `Analytics::PersonActivityEvents` — Aggregates Ahoy events for a person, their user, and associated data (powers the person edit History card + `person_id` filter on the Ahoy activities index) +- `Analytics::ResourceHistory` — One record's own Ahoy lifecycle history, newest first, reading the `(resource_type, resource_id, time)` index. Normalizes each event into an entry (action, time, user, source) with its field changes turned into `label / before / after` triples, dates and booleans formatted for display. Generic — takes any record +- `Analytics::AffiliationTimeline` — Merges one affiliation's Ahoy edits with the person's facilitator-training registrations and membership invoice periods into one newest-first timeline (powers the affiliation edit History section). Only the edits come from Ahoy; trainings and memberships are read from their own tables because Ahoy only records changes made with a `Current.user`/`Current.source`. Flags the training that minted the affiliation, and the trainings linked to this affiliation's org; falls back to a `:provenance` entry when the minting registration is not a training (a job affiliation). See ADR-0002 D2a +- `Analytics::PersonAffiliationTimeline` — Person-level counterpart to `AffiliationTimeline`: merges all of a person's affiliations, their facilitator-training registrations, and their membership invoice periods into one newest-first timeline (powers the affiliation-history page reached from the gear on the person edit form's affiliations section). Read entirely from own tables (no Ahoy edit history — that stays on each affiliation's edit page); flags trainings that link to an org the person is affiliated with ### Business Logic diff --git a/app/controllers/affiliations_controller.rb b/app/controllers/affiliations_controller.rb index da1278bf2c..0cad016029 100644 --- a/app/controllers/affiliations_controller.rb +++ b/app/controllers/affiliations_controller.rb @@ -3,6 +3,7 @@ class AffiliationsController < ApplicationController def edit authorize! @affiliation + @timeline = Analytics::AffiliationTimeline.new(@affiliation) end def update @@ -14,6 +15,7 @@ def update if @affiliation.save redirect_to affiliation_return_path, notice: "Affiliation was successfully updated.", status: :see_other else + @timeline = Analytics::AffiliationTimeline.new(@affiliation) render :edit, status: :unprocessable_content end end diff --git a/app/controllers/people_controller.rb b/app/controllers/people_controller.rb index 030c9b2fc7..67f88a1c9c 100644 --- a/app/controllers/people_controller.rb +++ b/app/controllers/people_controller.rb @@ -1,6 +1,6 @@ class PeopleController < ApplicationController include AhoyTracking, TagAssignable - before_action :set_person, only: %i[ show edit update destroy workshop_logs checkout bio all_comments ] + before_action :set_person, only: %i[ show edit update destroy workshop_logs checkout bio all_comments affiliation_history ] def index authorize! @@ -97,6 +97,16 @@ def show # hang off them (registrations, scholarships, CE registrations, user account) — # in one newest-first feed you can add to and edit in place. Staff-only, since # comments are internal notes (CommentPolicy#manage? = admin). + # A person's affiliation history — their affiliations, facilitator trainings, + # and membership periods in one newest-first timeline. Reached from the gear on + # the affiliations section of the edit form; admin-only, like that section. + def affiliation_history + authorize! @person + @person = @person.decorate + @timeline = Analytics::PersonAffiliationTimeline.new(@person) + track_view("person_affiliation_history", { person_id: @person.id }) + end + def all_comments authorize! @person, to: :manage?, with: CommentPolicy @person = @person.decorate diff --git a/app/decorators/affiliation_decorator.rb b/app/decorators/affiliation_decorator.rb index 86f3f24dc7..46028193e0 100644 --- a/app/decorators/affiliation_decorator.rb +++ b/app/decorators/affiliation_decorator.rb @@ -2,4 +2,11 @@ class AffiliationDecorator < ApplicationDecorator def detail(length: nil) "#{person.full_name}: #{title.presence || position} - #{organization.name}" end + + # e.g. "Oct 13, 2026 – present" + def date_range + start = start_date ? start_date.strftime("%b %-d, %Y") : "no start date" + finish = end_date ? end_date.strftime("%b %-d, %Y") : "present" + "#{start} – #{finish}" + end end diff --git a/app/policies/person_policy.rb b/app/policies/person_policy.rb index f103336854..64ec826b1b 100644 --- a/app/policies/person_policy.rb +++ b/app/policies/person_policy.rb @@ -13,6 +13,10 @@ def workshop_logs? admin? || owner? end + def affiliation_history? + admin? + end + def own_membership? owner? && Membership.enabled? end diff --git a/app/services/analytics/affiliation_timeline.rb b/app/services/analytics/affiliation_timeline.rb new file mode 100644 index 0000000000..c71758b8a5 --- /dev/null +++ b/app/services/analytics/affiliation_timeline.rb @@ -0,0 +1,101 @@ +module Analytics + # One affiliation's story in time order: the edits made to the affiliation + # itself, the registration that minted it, the facilitator trainings the person + # registered for, and their membership periods — merged newest-first. + # + # Only the edits come from Ahoy. Ahoy records *changes*, and only those made + # while a `Current.user`/`Current.source` was set, so imported and seeded rows + # have no events at all. Everything else is read from its own table, which is + # the complete answer; Ahoy is used for the affiliation's own columns because + # nothing else records them. + class AffiliationTimeline + Entry = Data.define(:kind, :occurred_at, :record, :linked_here, :minted) do + def change? = kind == :change + def training? = kind == :training + def membership? = kind == :membership + def provenance? = kind == :provenance + end + + def initialize(affiliation, limit: ResourceHistory::DEFAULT_LIMIT) + @affiliation = affiliation + @limit = limit + end + + def entries + @entries ||= (change_entries + provenance_entries + training_entries + membership_entries) + .sort_by { |entry| entry.occurred_at || Time.at(0) } + .reverse + end + + def any? = entries.any? + def trainings? = training_entries.any? + def memberships? = membership_entries.any? + + private + + def person + @affiliation.person + end + + def minting_registration + @affiliation.event_registration + end + + # The timestamps arrive as a mix of Time and Date, which can't be sorted + # against each other. + def entry(kind:, occurred_at:, record:, linked_here: false, minted: false) + Entry.new(kind:, occurred_at: occurred_at&.to_time, record:, linked_here:, minted:) + end + + def change_entries + @change_entries ||= ResourceHistory.new(@affiliation, limit: @limit).entries.map do |change| + entry(kind: :change, occurred_at: change.time, record: change, linked_here: true) + end + end + + # The minting registration usually IS one of the trainings below, and gets + # marked there rather than duplicated. This covers the other case: a job + # affiliation minted by a registration to an event that isn't a training. + def provenance_entries + return [] unless minting_registration + return [] if training_entries.any? { |candidate| candidate.record.id == minting_registration.id } + + [ entry(kind: :provenance, occurred_at: registration_date(minting_registration), + record: minting_registration, minted: true) ] + end + + # Dated by the event itself rather than by when the row was written, so it + # sits alongside the affiliation dates it explains. + def training_entries + return @training_entries if defined?(@training_entries) + return @training_entries = [] unless person + + registrations = person.event_registrations + .joins(:event).where(events: { facilitator_training: true }) + .includes(:event, :organizations) + + @training_entries = registrations.map do |registration| + entry(kind: :training, occurred_at: registration_date(registration), record: registration, + linked_here: registration.organizations.any? { |org| org.id == @affiliation.organization_id }, + minted: registration.id == @affiliation.event_registration_id) + end + end + + def membership_entries + return @membership_entries if defined?(@membership_entries) + return @membership_entries = [] unless person && Membership.enabled? + + invoices = MembershipInvoice.joins(:membership) + .where(memberships: { person_id: person.id }) + .includes(:membership) + + @membership_entries = invoices.map do |invoice| + entry(kind: :membership, occurred_at: invoice.start_date, record: invoice) + end + end + + def registration_date(registration) + registration.event&.start_date || registration.created_at + end + end +end diff --git a/app/services/analytics/person_affiliation_timeline.rb b/app/services/analytics/person_affiliation_timeline.rb new file mode 100644 index 0000000000..79d151378e --- /dev/null +++ b/app/services/analytics/person_affiliation_timeline.rb @@ -0,0 +1,79 @@ +module Analytics + # A person's affiliation history as one time-ordered list: every affiliation + # they hold, the facilitator trainings they registered for (which can confer + # facilitator status), and their membership periods — merged newest-first. + # + # Everything is read from its own table, so this is the complete picture. Unlike + # AffiliationTimeline it carries no Ahoy edit history — that stays on each + # affiliation's own edit page, where a single record's audit trail belongs. + class PersonAffiliationTimeline + Entry = Data.define(:kind, :occurred_at, :record) do + def affiliation? = kind == :affiliation + def training? = kind == :training + def membership? = kind == :membership + end + + def initialize(person) + @person = person + end + + def entries + @entries ||= (affiliation_entries + training_entries + membership_entries) + .sort_by { |entry| entry.occurred_at || Time.at(0) } + .reverse + end + + def any? = entries.any? + def affiliations? = affiliation_entries.any? + def trainings? = training_entries.any? + def memberships? = membership_entries.any? + + # The organizations this person is affiliated with, so a training's linked + # organizations can be flagged as conferring status somewhere they belong. + def affiliated_organization_ids + @affiliated_organization_ids ||= affiliations.filter_map(&:organization_id).to_set + end + + private + + def affiliations + @affiliations ||= @person.affiliations.includes(organization: { logo_attachment: :blob }).to_a + end + + # Timestamps arrive as a mix of Date and Time, which can't be sorted against + # each other. + def entry(kind:, occurred_at:, record:) + Entry.new(kind:, occurred_at: occurred_at&.to_time, record:) + end + + def affiliation_entries + @affiliation_entries ||= affiliations.map do |affiliation| + entry(kind: :affiliation, occurred_at: affiliation.start_date || affiliation.created_at, + record: affiliation) + end + end + + # Dated by the event itself rather than by when the row was written, so it + # sits alongside the affiliation dates it explains. + def training_entries + @training_entries ||= @person.event_registrations + .joins(:event).where(events: { facilitator_training: true }) + .includes(:event, :organizations) + .map { |registration| entry(kind: :training, occurred_at: registration_date(registration), record: registration) } + end + + def membership_entries + return @membership_entries if defined?(@membership_entries) + return @membership_entries = [] unless Membership.enabled? + + @membership_entries = MembershipInvoice.joins(:membership) + .where(memberships: { person_id: @person.id }) + .includes(:membership) + .map { |invoice| entry(kind: :membership, occurred_at: invoice.start_date, record: invoice) } + end + + def registration_date(registration) + registration.event&.start_date || registration.created_at + end + end +end diff --git a/app/services/analytics/resource_history.rb b/app/services/analytics/resource_history.rb new file mode 100644 index 0000000000..1e04799c6b --- /dev/null +++ b/app/services/analytics/resource_history.rb @@ -0,0 +1,117 @@ +module Analytics + # One record's own Ahoy history, newest first: what changed, when, and who did + # it. Reads the (resource_type, resource_id, time) index, so it is cheap enough + # to render inline on an edit page. + # + # Takes every event filed against the record rather than just create/update, so + # a custom tracked event (`autochange.*` and the like) shows up here too — the + # action is the part of the event name before the dot. + class ResourceHistory + DEFAULT_LIMIT = 25 + + Change = Data.define(:label, :before, :after) + + Entry = Data.define(:action, :time, :user, :source, :changes, :association_summary) do + def detailed? = changes.any? || association_summary.present? + end + + def initialize(record, limit: DEFAULT_LIMIT) + @record = record + @limit = limit + end + + def entries + @entries ||= events.map { |event| entry_for(event) } + end + + def any? = entries.any? + + private + + def events + return Ahoy::Event.none unless @record&.persisted? + + Ahoy::Event + .where(resource_type: resource_types, resource_id: @record.id) + .includes(user: :person) + .order(time: :desc) + .limit(@limit) + end + + # STI subtypes share a table, so events for one row can be filed under the base + # class or any subtype — `Affiliation` before the subtypes existed, and a + # different subtype for anything recorded before a retitle re-typed the row. + # Matching only `@record.class.name` would silently drop that earlier history. + # + # `descendants` covers the retitle case only for subclasses that are loaded, + # which is always true under eager loading and may not be in a lazy-loading + # dev/test process. The base and current names are matched unconditionally. + def resource_types + klass = @record.class + base = klass.respond_to?(:base_class) ? klass.base_class : klass + + ([ klass.name, base.name ] + base.subclasses.map(&:name)).uniq + end + + def entry_for(event) + properties = event.properties || {} + + Entry.new( + action: event.name.to_s.split(".").first, + time: event.time, + user: event.user, + source: properties["source"].presence, + changes: changes_for(properties["changes"]), + association_summary: association_summary_for(properties["association_changes"]) + ) + end + + def changes_for(raw) + return [] unless raw.is_a?(Hash) + + raw.filter_map do |attribute, values| + next unless values.is_a?(Hash) + + Change.new(label: label_for(attribute), + before: format_value(values["before"]), + after: format_value(values["after"])) + end + end + + # e.g. "2 comments added, 1 updated" — enough to know something happened + # alongside the record's own columns without rebuilding the nested diff. + def association_summary_for(raw) + return nil unless raw.is_a?(Hash) + + parts = raw.flat_map do |association, entries| + next [] unless entries.is_a?(Array) + + entries.group_by { |entry| entry["action"] }.map do |action, group| + "#{group.size} #{association.to_s.humanize(capitalize: false).singularize.pluralize(group.size)} #{action}" + end + end + + parts.presence&.to_sentence + end + + def label_for(attribute) + @record.class.human_attribute_name(attribute) + end + + def format_value(value) + return "—" if value.nil? || value == "" + return "Yes" if value == true + return "No" if value == false + + as_date(value)&.strftime("%b %-d, %Y") || value.to_s + end + + def as_date(value) + return nil unless value.is_a?(String) && value.match?(/\A\d{4}-\d{2}-\d{2}/) + + Date.parse(value) + rescue Date::Error + nil + end + end +end diff --git a/app/views/affiliations/_timeline.html.erb b/app/views/affiliations/_timeline.html.erb new file mode 100644 index 0000000000..ce4c402c23 --- /dev/null +++ b/app/views/affiliations/_timeline.html.erb @@ -0,0 +1,94 @@ +<%# Locals: timeline (Analytics::AffiliationTimeline), affiliation. Admin-only — + the raw Ahoy records behind the edit entries are reachable from "Full log". + + One grid for the whole list so the four columns line up across every entry + kind: each
  • is `contents`, so its cells become grid items directly. The + row rule lives on the cells rather than the
  • for the same reason. %> +<% return unless allowed_to?(:index?, with: Admin::AhoyActivityPolicy) %> +<% cell = "border-t border-gray-100 py-2.5" %> +<% badge = { "create" => [ "Created", "bg-blue-50 text-blue-700" ], + "update" => [ "Update", "bg-gray-100 text-gray-700" ], + training: [ "Training", "bg-purple-50 text-purple-700" ], + membership: [ "Membership", "bg-amber-50 text-amber-800" ], + provenance: [ "Created", "bg-emerald-50 text-emerald-800" ] } %> + +
    +
    + + + + +
    +

    + History (<%= timeline.entries.size %>) +

    +

    Edits to this affiliation, this person's facilitator trainings, and their membership periods.

    +
    + <%= link_to admin_activities_events_path(resource_type: "Affiliation", resource_id: affiliation.id), + class: "ml-auto shrink-0 text-xs text-indigo-600 hover:underline", + title: "Open the full activity log for this affiliation" do %> + Full log + <% end %> +
    + + <% if timeline.any? %> +
      +
    1. + ChangeWhatWhenBy +
    2. + <% timeline.entries.each do |entry| %> + <% label, badge_class = badge[entry.change? ? entry.record.action : entry.kind] %> +
    3. + + <%= label %> + + +
      + <% if entry.change? %> + <%= render "affiliations/timeline_change", change: entry.record %> + <% elsif entry.training? %> + <%= render "affiliations/timeline_training", registration: entry.record, + linked_here: entry.linked_here, minted: entry.minted %> + <% elsif entry.provenance? %> + <%= render "affiliations/timeline_provenance", registration: entry.record %> + <% else %> + <%= render "affiliations/timeline_membership", invoice: entry.record %> + <% end %> +
      + + <%# Zone-converted, matching EventDecorator#date_range — the same event must + not read one date here and another on its own page. %> + + <% when_at = entry.occurred_at&.in_time_zone %> + <%= when_at&.strftime("%b %-d, %Y") %> + <% if entry.change? %> + <%= when_at&.strftime("%-l:%M %p") %> + <% end %> + + + + <% author = entry.change? ? entry.record.user : nil %> + <% if author&.person %> + <%= link_to author.full_name, person_path(author.person), class: "text-indigo-600 hover:underline" %> + <% elsif author %> + <%= author.full_name %> + <% else %> + + <% end %> + +
    4. + <% end %> +
    + <% else %> +

    + Nothing recorded yet — edits made from here on will show up, alongside any facilitator training this person registers for. +

    + <% end %> + + <% unless timeline.trainings? %> +

    + This person has no facilitator-training registrations on record. +

    + <% end %> +
    +
    diff --git a/app/views/affiliations/_timeline_affiliation.html.erb b/app/views/affiliations/_timeline_affiliation.html.erb new file mode 100644 index 0000000000..f376a337fe --- /dev/null +++ b/app/views/affiliations/_timeline_affiliation.html.erb @@ -0,0 +1,21 @@ +<% deco = affiliation.decorate %> +<% facilitator = affiliation.facilitator? %> +
    + "> + <%= affiliation.title.presence || "Facilitator" %> + + <%= deco.date_range %> + <% if affiliation.organization %> + <%= link_to affiliation.organization.name, organization_path(affiliation.organization), + class: "font-medium text-gray-800 hover:underline hover:text-blue-700" %> + <% end %> + <% unless affiliation.active? %> + Ended + <% end %> + <%= link_to edit_affiliation_path(affiliation, return_to: "person", origin_id: affiliation.person_id), + target: "_blank", rel: "noopener", + class: "ml-auto shrink-0 text-gray-300 hover:text-gray-500", + title: "Edit this affiliation (opens in a new tab)" do %> + + <% end %> +
    diff --git a/app/views/affiliations/_timeline_change.html.erb b/app/views/affiliations/_timeline_change.html.erb new file mode 100644 index 0000000000..40763da0ed --- /dev/null +++ b/app/views/affiliations/_timeline_change.html.erb @@ -0,0 +1,19 @@ +<%# The "What" cell for a recorded edit: one line per changed field. %> +<% if change.changes.any? %> + <% change.changes.each do |field| %> +
    + <%= field.label %> + <%= field.before %> + + <%= field.after %> +
    + <% end %> +<% else %> + Affiliation <%= change.action == "create" ? "created" : "saved" %> +<% end %> +<% if change.association_summary %> +

    <%= change.association_summary.upcase_first %>

    +<% end %> +<% if change.source %> +

    via <%= change.source %>

    +<% end %> diff --git a/app/views/affiliations/_timeline_membership.html.erb b/app/views/affiliations/_timeline_membership.html.erb new file mode 100644 index 0000000000..33e6795cc0 --- /dev/null +++ b/app/views/affiliations/_timeline_membership.html.erb @@ -0,0 +1,10 @@ +<%# The "What" cell for one paid membership period. %> +
    + + <%= invoice.start_date.strftime("%b %-d, %Y") %> – <%= invoice.end_date.strftime("%b %-d, %Y") %> + + <%= dollars_from_cents(invoice.cost_cents) %> + <% if invoice.membership.cancelled? %> + Cancelled + <% end %> +
    diff --git a/app/views/affiliations/_timeline_person_training.html.erb b/app/views/affiliations/_timeline_person_training.html.erb new file mode 100644 index 0000000000..fa6c48f798 --- /dev/null +++ b/app/views/affiliations/_timeline_person_training.html.erb @@ -0,0 +1,27 @@ +<%# Locals: registration, affiliated_org_ids (Set of org ids the person is + affiliated with). The person-page counterpart to _timeline_training: it flags + a training against every organization the person belongs to, not one. %> +<% deco = registration.decorate %> +
    + Training + <%= registration.event&.start_date&.to_date&.strftime("%b %Y") %> + <% if registration.event %> + <%= link_to registration.event.title, event_path(registration.event), + target: "_blank", rel: "noopener", class: "font-medium text-gray-800 hover:underline hover:text-blue-700" %> + <% end %> + + + <%= registration.attendance_status_label %> + +
    +

    + <% orgs = registration.organizations.to_a %> + <% linked = orgs.select { |org| affiliated_org_ids.include?(org.id) } %> + <% if linked.any? %> + Can confer facilitator status at <%= linked.map(&:name).to_sentence %> — an organization this person is affiliated with. + <% elsif orgs.any? %> + Linked to <%= orgs.map(&:name).to_sentence %>, not an organization this person is affiliated with. + <% else %> + No organization linked to this registration. + <% end %> +

    diff --git a/app/views/affiliations/_timeline_provenance.html.erb b/app/views/affiliations/_timeline_provenance.html.erb new file mode 100644 index 0000000000..7125284628 --- /dev/null +++ b/app/views/affiliations/_timeline_provenance.html.erb @@ -0,0 +1,12 @@ +<%# The "What" cell when the minting registration is not a facilitator training. %> +<% if registration.event %> + <%= link_to registration.event.title, event_path(registration.event), + target: "_blank", rel: "noopener", class: "font-medium text-gray-800 hover:underline hover:text-blue-700" %> + <%= registration.event.start_date&.in_time_zone&.strftime("%b %Y") %> +<% end %> +

    + Minted by + <%= link_to (registration.registrant&.full_name.presence || "a registration"), + edit_event_registration_path(registration), target: "_blank", rel: "noopener", class: "underline" %>, + which is not a facilitator training — so it confers no facilitator status. +

    diff --git a/app/views/affiliations/_timeline_training.html.erb b/app/views/affiliations/_timeline_training.html.erb new file mode 100644 index 0000000000..eb8361f8f1 --- /dev/null +++ b/app/views/affiliations/_timeline_training.html.erb @@ -0,0 +1,26 @@ +<%# The "What" cell for one of this person's facilitator trainings. %> +<% deco = registration.decorate %> +
    + + + <%= registration.attendance_status_label %> + + <% if registration.event %> + <%= link_to registration.event.title, event_path(registration.event), + target: "_blank", rel: "noopener", class: "font-medium text-gray-800 hover:underline hover:text-blue-700" %> + <%= registration.event.start_date&.in_time_zone&.strftime("%b %Y") %> + <% end %> + <% if minted %> + + Created this affiliation + + <% end %> +
    +

    + <% if linked_here %> + Linked to this organization — a training that can confer facilitator status here. + <% else %> + <% others = registration.organizations.map(&:name) %> + <%= others.any? ? "Linked to #{others.to_sentence}, not this organization." : "No organization linked to this registration." %> + <% end %> +

    diff --git a/app/views/affiliations/edit.html.erb b/app/views/affiliations/edit.html.erb index 54471cfef7..9e30009365 100644 --- a/app/views/affiliations/edit.html.erb +++ b/app/views/affiliations/edit.html.erb @@ -167,5 +167,7 @@ + <%= render "affiliations/timeline", timeline: @timeline, affiliation: @affiliation %> + <%= render "shared/audit_info", resource: @affiliation %> diff --git a/app/views/people/_form.html.erb b/app/views/people/_form.html.erb index edf690898f..a56df650a5 100644 --- a/app/views/people/_form.html.erb +++ b/app/views/people/_form.html.erb @@ -228,8 +228,18 @@ <% person = f.object.respond_to?(:object) ? f.object.object : f.object %> <% decorated = person.decorate %>
    -
    - Affiliations (only editable by admins) +
    +
    + Affiliations (only editable by admins) +
    + <% if person.persisted? %> + <%= link_to affiliation_history_person_path(person), target: "_blank", rel: "noopener", + class: "shrink-0 text-gray-400 hover:text-gray-600", + title: "Affiliation history — trainings and membership over time (opens in a new tab)" do %> + + Affiliation history + <% end %> + <% end %>
    diff --git a/app/views/people/affiliation_history.html.erb b/app/views/people/affiliation_history.html.erb new file mode 100644 index 0000000000..6804314759 --- /dev/null +++ b/app/views/people/affiliation_history.html.erb @@ -0,0 +1,45 @@ +<% content_for(:page_bg_class, "admin-only bg-blue-100") %> +<% affiliated_org_ids = @timeline.affiliated_organization_ids %> + +
    +
    + <%= link_to edit_person_path(@person, anchor: "affiliations"), class: "text-sm text-gray-500 hover:text-gray-700" do %> + + <%= @person.full_name %> + <% end %> + <%= link_to "Home", root_path, class: "text-sm text-gray-500 hover:text-gray-700" %> +
    + +

    Affiliation history

    +

    + <%= @person.full_name %>'s affiliations, facilitator trainings, and membership periods over time. +

    + +
    + <% if @timeline.any? %> +
      + <% @timeline.entries.each do |entry| %> +
    1. + <% if entry.affiliation? %> + <%= render "affiliations/timeline_affiliation", affiliation: entry.record %> + <% elsif entry.training? %> + <%= render "affiliations/timeline_person_training", registration: entry.record, affiliated_org_ids: affiliated_org_ids %> + <% else %> + <%= render "affiliations/timeline_membership", invoice: entry.record %> + <% end %> +
    2. + <% end %> +
    + <% else %> +

    + No affiliations, facilitator trainings, or membership periods on record for this person yet. +

    + <% end %> + + <% unless @timeline.trainings? %> +

    + This person has no facilitator-training registrations on record. +

    + <% end %> +
    +
    diff --git a/config/routes.rb b/config/routes.rb index 7ac4a1cf71..3a730b4f6a 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -226,6 +226,7 @@ get :checkout get :bio get :all_comments + get :affiliation_history end resources :comments, only: [ :create, :update ] resources :memberships, only: [ :index, :new, :create ] diff --git a/spec/decorators/affiliation_decorator_spec.rb b/spec/decorators/affiliation_decorator_spec.rb new file mode 100644 index 0000000000..bb67da60bd --- /dev/null +++ b/spec/decorators/affiliation_decorator_spec.rb @@ -0,0 +1,23 @@ +require "rails_helper" + +RSpec.describe AffiliationDecorator do + describe "#date_range" do + it "reads 'present' when there is no end date" do + affiliation = build(:affiliation, start_date: Date.new(2026, 10, 13), end_date: nil) + + expect(affiliation.decorate.date_range).to eq("Oct 13, 2026 – present") + end + + it "shows both dates when the affiliation has ended" do + affiliation = build(:affiliation, start_date: Date.new(2026, 10, 13), end_date: Date.new(2026, 10, 13)) + + expect(affiliation.decorate.date_range).to eq("Oct 13, 2026 – Oct 13, 2026") + end + + it "reads 'no start date' when the start date is unset" do + affiliation = build(:affiliation, start_date: nil, end_date: nil) + + expect(affiliation.decorate.date_range).to eq("no start date – present") + end + end +end diff --git a/spec/requests/affiliations_edit_history_spec.rb b/spec/requests/affiliations_edit_history_spec.rb new file mode 100644 index 0000000000..9274de6641 --- /dev/null +++ b/spec/requests/affiliations_edit_history_spec.rb @@ -0,0 +1,65 @@ +require "rails_helper" + +RSpec.describe "the affiliation edit History section", type: :request do + let(:admin) { create(:user, :admin) } + let(:organization) { create(:organization) } + let(:person) { create(:person) } + # Midday rather than midnight: an event stored at UTC midnight reads as the + # previous day for a Pacific viewer, and the controller sets the zone per user. + let(:event) do + create(:event, facilitator_training: true, title: "TOS205 Fresno", + start_date: Time.zone.parse("2026-03-04 12:00"), + end_date: Time.zone.parse("2026-03-05 17:00"), + registration_close_date: Time.zone.parse("2026-03-01 12:00")) + end + let(:registration) { create(:event_registration, event: event, registrant: person, status: "no_show") } + let(:affiliation) do + create(:affiliation, person: person, organization: organization, title: "Facilitator", + start_date: Date.new(2026, 3, 4), event_registration: registration) + end + + before do + sign_in admin + create(:event_registration_organization, event_registration: registration, organization: organization) + end + + it "shows the minting training with its event name and date, marked as the source" do + get edit_affiliation_path(affiliation) + + expect(response).to have_http_status(:ok) + expect(response.body).to include("TOS205 Fresno") + expect(response.body).to include("Mar 4, 2026") + expect(response.body).to include("Created this affiliation") + end + + it "lays the entries out in Change / What / When / By columns" do + get edit_affiliation_path(affiliation) + + headers = Nokogiri::HTML(response.body).css("li.contents").first.css("span").map(&:text).map(&:strip) + expect(headers).to eq(%w[Change What When By]) + end + + it "renders a recorded edit as before → after, with dates and booleans humanized" do + Ahoy::Event.create!(name: "update.affiliation", time: 2.days.ago, visit: create(:ahoy_visit), + resource_type: "Affiliation", resource_id: affiliation.id, user: admin, + properties: { "changes" => { + "inactive" => { "before" => false, "after" => true }, + "end_date" => { "before" => nil, "after" => "2026-03-04" } + } }) + + get edit_affiliation_path(affiliation) + + expect(response.body).to include("End date") + expect(response.body).to include("Mar 4, 2026") + expect(response.body).to include("Yes") + expect(response.body).to include(admin.full_name) + end + + it "does not show the section to a non-admin" do + sign_in create(:user) + + get edit_affiliation_path(affiliation) + + expect(response.body).not_to include("Created this affiliation") + end +end diff --git a/spec/requests/people_affiliation_history_spec.rb b/spec/requests/people_affiliation_history_spec.rb new file mode 100644 index 0000000000..0186769188 --- /dev/null +++ b/spec/requests/people_affiliation_history_spec.rb @@ -0,0 +1,47 @@ +require "rails_helper" + +RSpec.describe "People#affiliation_history", type: :request do + let(:admin) { create(:user, :admin) } + let(:person) { create(:person) } + let(:organization) { create(:organization, name: "Sunrise Center") } + + describe "GET /people/:id/affiliation_history" do + context "as an admin" do + before { sign_in admin } + + it "renders the merged affiliation history" do + create(:affiliation, person: person, organization: organization, + title: "Facilitator", start_date: 1.year.ago.to_date) + event = create(:event, facilitator_training: true, title: "Intro Training", + start_date: 2.years.ago, end_date: 2.years.ago + 1.day, + registration_close_date: 2.years.ago - 1.day) + registration = create(:event_registration, event: event, registrant: person, status: "attended") + create(:event_registration_organization, event_registration: registration, organization: organization) + + get affiliation_history_person_path(person) + + expect(response).to have_http_status(:ok) + expect(response.body).to include("Affiliation history") + expect(response.body).to include("Sunrise Center") + expect(response.body).to include("Intro Training") + end + + it "shows an empty state when there is nothing on record" do + get affiliation_history_person_path(person) + + expect(response).to have_http_status(:ok) + expect(response.body).to include("No affiliations, facilitator trainings, or membership periods") + end + end + + context "as a non-admin" do + it "is forbidden" do + sign_in create(:user) + + get affiliation_history_person_path(person) + + expect(response).not_to have_http_status(:ok) + end + end + end +end diff --git a/spec/services/analytics/affiliation_timeline_spec.rb b/spec/services/analytics/affiliation_timeline_spec.rb new file mode 100644 index 0000000000..d6c39cdf1e --- /dev/null +++ b/spec/services/analytics/affiliation_timeline_spec.rb @@ -0,0 +1,130 @@ +require "rails_helper" + +RSpec.describe Analytics::AffiliationTimeline do + let(:person) { create(:person) } + let(:organization) { create(:organization) } + let(:affiliation) do + create(:affiliation, person: person, organization: organization, + title: "Facilitator", start_date: 1.year.ago.to_date) + end + + def training(start_date:, status: "attended", link_org: organization, facilitator_training: true) + event = create(:event, facilitator_training: facilitator_training, + start_date: start_date, end_date: start_date + 1.day, + registration_close_date: start_date - 1.day) + registration = create(:event_registration, event: event, registrant: person, status: status) + create(:event_registration_organization, event_registration: registration, organization: link_org) if link_org + registration + end + + def ahoy_event(name:, time:, properties: {}) + Ahoy::Event.create!(name: name, time: time, visit: create(:ahoy_visit), + resource_type: "Affiliation", resource_id: affiliation.id, + properties: properties) + end + + describe "what it merges" do + it "puts edits, trainings and the minting registration in one newest-first list" do + old_training = training(start_date: 3.years.ago) + recent_training = training(start_date: 6.months.ago) + ahoy_event(name: "update.affiliation", time: 1.day.ago, + properties: { "changes" => { "end_date" => { "before" => nil, "after" => "2026-01-01" } } }) + + entries = described_class.new(affiliation.reload).entries + + expect(entries.map(&:kind)).to eq([ :change, :training, :training ]) + expect(entries.map(&:occurred_at)).to eq(entries.map(&:occurred_at).sort.reverse) + expect(entries.last.record).to eq(old_training) + expect(entries[1].record).to eq(recent_training) + end + + it "dates a training by the event, not by when the row was written" do + registration = training(start_date: 2.years.ago) + + entry = described_class.new(affiliation).entries.first + + expect(entry.occurred_at.to_date).to eq(registration.event.start_date.to_date) + end + + it "is empty for an affiliation with no history at all" do + expect(described_class.new(affiliation)).not_to be_any + end + end + + # STI: the row is a FacilitatorAffiliation, but its earlier events may be filed + # under the base class or the other subtype. + describe "history filed under another STI type" do + it "still finds events recorded as the base Affiliation class" do + ahoy_event(name: "update.affiliation", time: 1.day.ago, + properties: { "changes" => { "title" => { "before" => "Volunteer", "after" => "Facilitator" } } }) + + entries = described_class.new(affiliation.reload).entries + + expect(entries.map(&:kind)).to include(:change) + expect(entries.first.record.changes.map(&:label)).to eq([ "Title" ]) + end + + it "finds events recorded under the other subtype, from before a retitle" do + Ahoy::Event.create!(name: "update.affiliation", time: 2.days.ago, visit: create(:ahoy_visit), + resource_type: JobAffiliation.name, resource_id: affiliation.id, + properties: { "changes" => { "title" => { "before" => "Counselor", "after" => "Facilitator" } } }) + + expect(described_class.new(affiliation.reload).entries.map(&:kind)).to include(:change) + end + end + + describe "which trainings count as this organization's" do + it "flags a training linked to this affiliation's organization" do + training(start_date: 1.year.ago, link_org: organization) + + expect(described_class.new(affiliation).entries.first.linked_here).to be(true) + end + + it "still lists a training linked to a different organization, unflagged" do + training(start_date: 1.year.ago, link_org: create(:organization)) + + entry = described_class.new(affiliation).entries.first + + expect(entry).to be_training + expect(entry.linked_here).to be(false) + end + + it "ignores registrations to events that are not facilitator trainings" do + training(start_date: 1.year.ago, facilitator_training: false) + + expect(described_class.new(affiliation)).not_to be_trainings + end + end + + describe "the minting registration" do + it "marks the training that created this affiliation rather than repeating it" do + registration = training(start_date: 1.year.ago) + affiliation.update!(event_registration: registration) + + entries = described_class.new(affiliation.reload).entries + + expect(entries.map(&:kind)).to eq([ :training ]) + expect(entries.first.minted).to be(true) + end + + it "does not mark the person's other trainings" do + minting = training(start_date: 2.years.ago) + training(start_date: 1.year.ago) + affiliation.update!(event_registration: minting) + + entries = described_class.new(affiliation.reload).entries + + expect(entries.select(&:minted).map(&:record)).to eq([ minting ]) + end + + it "adds a provenance entry when the minting event is not a facilitator training" do + registration = training(start_date: 1.year.ago, facilitator_training: false) + affiliation.update!(event_registration: registration) + + entries = described_class.new(affiliation.reload).entries + + expect(entries.map(&:kind)).to eq([ :provenance ]) + expect(entries.first.record).to eq(registration) + end + end +end diff --git a/spec/services/analytics/person_affiliation_timeline_spec.rb b/spec/services/analytics/person_affiliation_timeline_spec.rb new file mode 100644 index 0000000000..23f74fff32 --- /dev/null +++ b/spec/services/analytics/person_affiliation_timeline_spec.rb @@ -0,0 +1,77 @@ +require "rails_helper" + +RSpec.describe Analytics::PersonAffiliationTimeline do + let(:person) { create(:person) } + let(:organization) { create(:organization) } + + def affiliation(start_date:, **attrs) + create(:affiliation, person: person, organization: organization, start_date: start_date, **attrs) + end + + def training(start_date:, status: "attended", link_org: organization, facilitator_training: true) + event = create(:event, facilitator_training: facilitator_training, + start_date: start_date, end_date: start_date + 1.day, + registration_close_date: start_date - 1.day) + registration = create(:event_registration, event: event, registrant: person, status: status) + create(:event_registration_organization, event_registration: registration, organization: link_org) if link_org + registration + end + + def membership_invoice(start_date:) + membership = create(:membership, person: person) + create(:membership_invoice, membership: membership, start_date: start_date, end_date: start_date + 1.year - 1.day) + end + + describe "what it merges" do + it "puts affiliations, trainings and memberships in one newest-first list" do + affiliation(start_date: 3.years.ago.to_date) + training(start_date: 2.years.ago) + membership_invoice(start_date: 6.months.ago.to_date) + + entries = described_class.new(person).entries + + expect(entries.map(&:kind)).to eq([ :membership, :training, :affiliation ]) + expect(entries.map(&:occurred_at)).to eq(entries.map(&:occurred_at).sort.reverse) + end + + it "dates a training by the event, not by when the row was written" do + registration = training(start_date: 2.years.ago) + + entry = described_class.new(person).entries.first + + expect(entry).to be_training + expect(entry.occurred_at.to_date).to eq(registration.event.start_date.to_date) + end + + it "is empty for a person with no affiliations, trainings or memberships" do + expect(described_class.new(person)).not_to be_any + end + + it "ignores registrations to events that are not facilitator trainings" do + training(start_date: 1.year.ago, facilitator_training: false) + + expect(described_class.new(person)).not_to be_trainings + end + end + + describe "#affiliated_organization_ids" do + it "collects the organizations the person is affiliated with" do + other = create(:organization) + affiliation(start_date: 1.year.ago.to_date) + create(:affiliation, person: person, organization: other, start_date: 1.year.ago.to_date) + + expect(described_class.new(person).affiliated_organization_ids).to contain_exactly(organization.id, other.id) + end + end + + describe "memberships" do + it "lists the person's membership periods" do + invoice = membership_invoice(start_date: 3.months.ago.to_date) + + timeline = described_class.new(person) + + expect(timeline).to be_memberships + expect(timeline.entries.map(&:record)).to include(invoice) + end + end +end diff --git a/spec/views/page_bg_class_alignment_spec.rb b/spec/views/page_bg_class_alignment_spec.rb index a38789da02..eb500059a4 100644 --- a/spec/views/page_bg_class_alignment_spec.rb +++ b/spec/views/page_bg_class_alignment_spec.rb @@ -111,6 +111,7 @@ "app/views/other_responses/index.html.erb" => "admin-only bg-blue-100", "app/views/banners/index.html.erb" => "admin-only bg-blue-100", "app/views/people/all_comments.html.erb" => "admin-only bg-white", + "app/views/people/affiliation_history.html.erb" => "admin-only bg-blue-100", "app/views/comments/index.html.erb" => "admin-only bg-white", "app/views/bookmarks/index.html.erb" => "admin-only bg-blue-100", "app/views/categories/index.html.erb" => "admin-only bg-blue-100",