diff --git a/AGENTS.md b/AGENTS.md index 18e277690a..4e9af7861d 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 @@ -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 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/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..5bf12fbe26 --- /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 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 6f780a6218..adad8ab057 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") } + # STI: facilitator affiliations are the FacilitatorAffiliation subtype, which + # #set_type_from_title assigns whenever the title is exactly "Facilitator" + # (trimmed, case-sensitive). An executable agreement spec locks type to that rule. + scope :facilitators, -> { where(type: FacilitatorAffiliation.name) } # Affiliations whose #status_on(date) equals the given status, expressed in SQL # so it composes as a subquery (e.g. person-id narrowing). Kept in lock-step with @@ -77,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. @@ -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` @@ -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) 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/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
Edits to this affiliation, this person's facilitator trainings, and their membership periods.
++ 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 %> +<%= 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. %> ++ <% 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 %> ++ <% 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 %>+ <%= @person.full_name %>'s affiliations, facilitator trainings, and membership periods over time. +
+ ++ 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 %> +