Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -196,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

Expand Down
2 changes: 2 additions & 0 deletions app/controllers/affiliations_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ class AffiliationsController < ApplicationController

def edit
authorize! @affiliation
@timeline = Analytics::AffiliationTimeline.new(@affiliation)
end

def update
Expand All @@ -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
Expand Down
12 changes: 11 additions & 1 deletion app/controllers/people_controller.rb
Original file line number Diff line number Diff line change
@@ -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!
Expand Down Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions app/decorators/affiliation_decorator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Original file line number Diff line number Diff line change
@@ -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"]
Expand Down Expand Up @@ -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))))
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Controller } from "@hotwired/stimulus";
import { isFacilitatorTitle } from "../lib/affiliation";

// Connects to data-controller="affiliation-facilitator-warning"
//
Expand Down Expand Up @@ -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),
};
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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);
}
}
9 changes: 9 additions & 0 deletions app/frontend/javascript/lib/affiliation.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
// 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.
export const facilitatorTitle = "Facilitator"

export const isFacilitatorTitle = (title) => (title ?? "").trim() === facilitatorTitle
43 changes: 33 additions & 10 deletions app/models/affiliation.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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") }
# 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
Expand All @@ -77,6 +76,7 @@ class Affiliation < ApplicationRecord
end
}

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.
Expand All @@ -87,13 +87,27 @@ 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
# 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.
# 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?
title.to_s.strip == "Facilitator"
type == FacilitatorAffiliation.name
end

# Current: not flagged inactive and not past its end date. Mirrors the `active`
Expand Down Expand Up @@ -168,6 +182,15 @@ 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.
def set_type_from_title
self.type = title.to_s.strip == FACILITATOR_TITLE ? FacilitatorAffiliation.name : JobAffiliation.name
end

def sync_organization_affiliation_dates
org = organization
affiliations = org.affiliations.where.not(id: destroyed_by_association ? id : nil)
Expand Down
6 changes: 6 additions & 0 deletions app/models/facilitator_affiliation.rb
Original file line number Diff line number Diff line change
@@ -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
5 changes: 5 additions & 0 deletions app/models/job_affiliation.rb
Original file line number Diff line number Diff line change
@@ -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
4 changes: 4 additions & 0 deletions app/policies/person_policy.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ def workshop_logs?
admin? || owner?
end

def affiliation_history?
admin?
end

def own_membership?
owner? && Membership.enabled?
end
Expand Down
101 changes: 101 additions & 0 deletions app/services/analytics/affiliation_timeline.rb
Original file line number Diff line number Diff line change
@@ -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
Loading