Skip to content
Draft
11 changes: 6 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,20 +49,20 @@ This codebase (Rails 8.1)
| Directory | Purpose | Count |
|---|---|---|
| `app/models/` | ActiveRecord models | ~80 files |
| `app/services/` | Service objects and POROs (e.g. `MoneyFormatter` for currency display) | ~40 files |
| `app/services/` | Service objects and POROs (e.g. `MoneyFormatter` for currency display) | ~46 files |
| `app/jobs/` | SolidQueue background jobs | 4 files |
| `app/models/concerns/` | Shared model modules | 16 concerns |

### Presentation

| Directory | Purpose | Count |
|---|---|---|
| `app/controllers/` | Rails controllers (admin/, events/) | ~78 files |
| `app/controllers/` | Rails controllers (admin/, events/) | ~86 files |
| `app/views/` | ERB templates | ~632 files |
| `app/decorators/` | Draper decorators for view logic | ~40 files |
| `app/policies/` | ActionPolicy authorization rules | ~55 files |
| `app/policies/` | ActionPolicy authorization rules | ~61 files |
| `app/presenters/` | Presentation objects | 6 files |
| `app/helpers/` | View helpers | ~31 files |
| `app/helpers/` | View helpers | ~32 files |
| `app/mailers/` | ActionMailer classes | 5 files |
| `app/inputs/` | Custom SimpleForm inputs | 1 file |

Expand Down Expand Up @@ -133,7 +133,7 @@ This codebase (Rails 8.1)
|---|---|
| `AgeGroupTaggable` | Splits AgeRange category taggings into primary/additional via `categorizable_items.is_primary` (Person, Organization) |
| `AhoyTrackable` | Event tracking integration |
| `AuthorCreditable` | Author attribution |
| `AuthorCreditable` | Author attribution. Credits are formatted by the credited **person's profile** (`Person#display_name_preference`), not by the record. The record's `author_credit_preference` is the consent snapshot taken at create time and is human-editable only on the author credit divergences page β€” it no longer drives display, except `"anonymous"`, which is always honored (anonymity is a one-way latch: profile or record can set it, neither can strip it) |
| `Featureable` | `featured`, `publicly_featured` scopes |
| `Mentioner` | ActionText @mention extraction and grouping |
| `NameFilterable` | Name-based filtering |
Expand Down Expand Up @@ -203,6 +203,7 @@ action, or `authorize! :workshop, to: :summary?`).
- `WorkshopSearchService` β€” Complex filtering, sorting, pagination with ActionPolicy
- `WorkshopFromIdeaService` β€” Converts WorkshopIdea to Workshop with asset migration
- `WorkshopVariationFromIdeaService` β€” Variation creation from ideas
- `AuthorCreditDivergenceQuery` β€” Backs the admin author credit divergences page. Returns four sections: `preference` (stored snapshot no longer matches the profile, grouped by person), `legacy` (credited by a free-text column β€” `workshops.full_name`, `resources.legacy_author_name`), `creator` (no `author_id`, so the credit falls back to the creating user's person β€” idea models excluded, since that's their only credit path), and `unattributed` (nothing to credit, renders `missing_author_label`). The last three all resolve by assigning an `author_id`, the only credit path that follows a profile and links to it. `MODEL_NAMES` doubles as the allowlist for the `type` param (never constantize a raw param)
- `TaggingSearchService` β€” Search and filter tagging data
- `PersonFromUserService` β€” Create Person from User account
- `PersonCommentAggregator` β€” Unifies every comment connected to a person (their profile, event registrations, scholarships, CE registrations, topic subscriptions, and user account) into one newest-first `Comment` relation for the aggregated `/people/:id/all_comments` page
Expand Down
105 changes: 105 additions & 0 deletions app/controllers/author_credit_divergences_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
class AuthorCreditDivergencesController < ApplicationController
before_action :authorize_page

FILTER_KEYS = %i[person_id type preference include_reconciled].freeze

def index
@result = AuthorCreditDivergenceQuery.new(**filters.symbolize_keys).call

return unless turbo_frame_request?
render :author_credit_divergences_results
end

# Resolve a whole person: point their profile at one preference and stamp them
# reconciled so a deliberate divergence stops reappearing on the worklist.
def update_person
person = Person.find(params[:id])
person.assign_attributes(person_params)
person.author_credit_reconciled_at = Time.current
person.updated_by = current_user

if person.save
render_divergence_change("Updated credit preferences for #{person.full_name}.", :notice)
else
render_divergence_change(person.errors.full_messages.to_sentence, :alert)
end
end

# Resolve a single item by rewriting its stored consent snapshot. Setting
# "anonymous" here is a live override that suppresses that item's credit alone;
# any other value only re-records history (see AuthorCreditable).
def update_item
model = AuthorCreditDivergenceQuery.model_for(params[:record_type])
return render_divergence_change("Unknown record type.", :alert) unless model

record = model.find(params[:record_id])

# Anonymity is a one-way latch: clearing the snapshot of an item submitted
# anonymously would silently de-anonymize it. A deliberate re-credit still works
# by picking an explicit preference.
if params[:author_credit_preference].blank? && record.author_credit_preference == AuthorCreditable::ANONYMOUS
return render_divergence_change("Can't clear the consent for an item submitted anonymously β€” pick an explicit preference instead.", :alert)
end

record.author_credit_preference = params[:author_credit_preference]
record.updated_by = current_user if record.respond_to?(:updated_by=)

if record.save
render_divergence_change("Updated credit for #{model.name.underscore.humanize.downcase} ##{record.id}.", :notice)
else
render_divergence_change(record.errors.full_messages.to_sentence, :alert)
end
end

# Point a record at a real person. This is the fix for every section below the
# first: an author_id is the only credit path that follows the person's profile,
# links to it, and lists the record there. Once set, any legacy free-text name on
# the record stops being used.
def assign_author
model = AuthorCreditDivergenceQuery.model_for(params[:record_type])
return render_divergence_change("Unknown record type.", :alert) unless model

record = model.find(params[:record_id])
person = Person.find_by(id: params[:author_id])
return render_divergence_change("Choose a person to credit.", :alert) unless person

record.author_id = person.id
record.updated_by = current_user if record.respond_to?(:updated_by=)

if record.save
render_divergence_change("Credited #{model.name.underscore.humanize.downcase} ##{record.id} to #{person.full_name}.", :notice)
else
render_divergence_change(record.errors.full_messages.to_sentence, :alert)
end
end

private

# Update in place: re-render the results frame and flash over Turbo so a save
# doesn't flip the whole page. Falls back to a redirect for non-Turbo requests.
def render_divergence_change(message, type)
respond_to do |format|
format.turbo_stream do
flash.now[type] = message
@result = AuthorCreditDivergenceQuery.new(**filters.symbolize_keys).call
render :divergence_change
end
format.html { redirect_to author_credit_divergences_path(filters), flash: { type => message } }
end
end

def authorize_page
authorize! :author_credit_divergence, to: :"#{action_name}?", with: AuthorCreditDivergencePolicy
end

def person_params
params.require(:person).permit(:display_name_preference, :contributions_anonymous)
end

# Carried through every redirect so the admin lands back on the same filtered list.
# The write actions deliberately name their own params `id` / `record_type` /
# `record_id` so a record identifier can never be mistaken for a filter.
def filters
params.permit(*FILTER_KEYS).to_h.compact_blank
end
end
2 changes: 1 addition & 1 deletion app/controllers/community_news_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ def community_news_params
:title, :rhino_body, :published, :featured, :publicly_visible, :publicly_featured,
:reference_url, :youtube_url,
:organization_id,
:author_id, :author_credit_preference, :created_by_id, :updated_by_id,
:author_id, :created_by_id, :updated_by_id,
category_ids: [],
sector_ids: [],
primary_asset_attributes: [ :id, :file, :_destroy ],
Expand Down
21 changes: 16 additions & 5 deletions app/controllers/people_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -47,24 +47,26 @@ def show
when "workshops"
# Credit the person for workshops they authored β€” not ones their user
# merely created (created_by is a pure audit trail).
@workshops = @person.workshops_as_author.order(created_at: :desc).paginate(page: params[:page], per_page: per_page)
@workshops = visible_authored_content(@person.workshops_as_author).order(created_at: :desc).paginate(page: params[:page], per_page: per_page)
render partial: "people/sections/workshops", locals: { person: @person, workshops: @workshops }
when "workshop_variations"
# Credit the person for variations they authored β€” not ones their user
# merely entered (created_by is a pure audit trail).
@workshop_variations = @person.workshop_variations_as_author.order(created_at: :desc).paginate(page: params[:page], per_page: per_page)
@workshop_variations = visible_authored_content(@person.workshop_variations_as_author).order(created_at: :desc).paginate(page: params[:page], per_page: per_page)
render partial: "people/sections/workshop_variations", locals: { person: @person, workshop_variations: @workshop_variations }
when "stories"
# Credit the person for stories they authored or were spotlighted in β€”
# not ones their user merely entered (created_by is a pure audit trail).
story_ids = @person.stories_as_author.pluck(:id) +
# Spotlighted stories are always listed: the spotlight is a separate credit
# from authorship, so the anonymity flag doesn't apply to it.
story_ids = visible_authored_content(@person.stories_as_author).pluck(:id) +
@person.stories_as_spotlighted_facilitator.pluck(:id)
@stories = Story.where(id: story_ids).order(created_at: :desc).paginate(page: params[:page], per_page: per_page)
render partial: "people/sections/stories", locals: { person: @person, stories: @stories }
when "resources"
# Credit the person for resources they authored β€” not ones their user
# merely entered (created_by is a pure audit trail).
@resources = @person.resources_as_author.order(created_at: :desc).paginate(page: params[:page], per_page: per_page)
@resources = visible_authored_content(@person.resources_as_author).order(created_at: :desc).paginate(page: params[:page], per_page: per_page)
render partial: "people/sections/resources", locals: { person: @person, resources: @resources }
when "events"
@event_registrations = @person.event_registrations.active.includes(:event).order("events.start_date DESC").references(:events).paginate(page: params[:page], per_page: per_page)
Expand Down Expand Up @@ -292,6 +294,15 @@ def check_duplicates

private

# Anonymously-credited content is listed on the profile only for the person
# themselves and admins β€” showing it to anyone else would tie an "Anonymous"
# credit back to a name. `contributions_anonymous` anonymizes every item at once;
# otherwise only the items whose stored consent is "anonymous" are hidden.
def visible_authored_content(scope)
return scope if allowed_to?(:manage?, Person) || current_user&.person_id == @person.id
return scope.none if @person.contributions_anonymous?
scope.where.not(author_credit_preference: AuthorCreditable::ANONYMOUS)
end

def set_person
@person = Person.find(params[:id])
Expand Down Expand Up @@ -520,8 +531,8 @@ def person_params
:mailing_list_consented,
:bio, :shoutout_text, :notes,
:display_name_preference,
:contributions_anonymous,
:pronouns,
:profile_show_name_preference,
:profile_is_searchable,
:profile_show_pronouns,
:profile_show_credentials,
Expand Down
2 changes: 1 addition & 1 deletion app/controllers/resources_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@ def resource_params
params.require(:resource).permit(
:rhino_body, :kind, :male, :female, :title, :featured, :published, :publicly_visible, :publicly_featured,
:hidden_from_search,
:agency, :author_id, :author_credit_preference, :filemaker_code, :windows_type_id, :position,
:agency, :author_id, :filemaker_code, :windows_type_id, :position,
primary_asset_attributes: [ :id, :file, :_destroy ],
downloadable_asset_attributes: [ :id, :file, :_destroy ],
gallery_assets_attributes: [ :id, :file, :_destroy ],
Expand Down
2 changes: 1 addition & 1 deletion app/controllers/stories_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,7 @@ def story_params
params.require(:story).permit(
:title, :rhino_body, :featured, :published, :publicly_visible, :publicly_featured, :youtube_url, :website_url,
:windows_type_id, :organization_id, :workshop_id, :external_workshop_title,
:author_id, :updated_by_id, :story_idea_id, :spotlighted_facilitator_id, :author_credit_preference,
:author_id, :updated_by_id, :story_idea_id, :spotlighted_facilitator_id,
category_ids: [],
sector_ids: [],
primary_asset_attributes: [ :id, :file, :_destroy ],
Expand Down
2 changes: 1 addition & 1 deletion app/controllers/story_ideas_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ def set_story_idea
def story_idea_params
params.require(:story_idea).permit(
:title, :rhino_body, :youtube_url,
:permission_given, :author_credit_preference, :promoted_to_story,
:permission_given,
:windows_type_id, :organization_id, :workshop_id, :external_workshop_title,
:created_by_id, :updated_by_id,
category_ids: [],
Expand Down
3 changes: 1 addition & 2 deletions app/controllers/workshop_ideas_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -111,8 +111,7 @@ def set_workshop_idea
# Strong parameters
def workshop_idea_params
params.require(:workshop_idea).permit(
:title, :staff_notes, :author_credit_preference,
:created_by_id, :updated_by_id, :windows_type_id,
:title, :staff_notes, :created_by_id, :updated_by_id, :windows_type_id,
:time_closing, :time_creation, :time_demonstration,
:time_hours, :time_intro, :time_minutes,
:time_opening, :time_opening_circle, :time_warm_up,
Expand Down
3 changes: 1 addition & 2 deletions app/controllers/workshop_variation_ideas_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -117,8 +117,7 @@ def set_form_variables
def workshop_variation_idea_params
params.require(:workshop_variation_idea).permit(
:name, :rhino_body, :youtube_url,
:permission_given, :author_credit_preference,
:organization_id, :windows_type_id, :workshop_id, :created_by_id, :updated_by_id,
:permission_given, :organization_id, :windows_type_id, :workshop_id, :created_by_id, :updated_by_id,
primary_asset_attributes: [ :id, :file, :_destroy ],
gallery_assets_attributes: [ :id, :file, :_destroy ]
)
Expand Down
3 changes: 1 addition & 2 deletions app/controllers/workshop_variations_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -132,8 +132,7 @@ def set_form_variables
def workshop_variation_params
params.require(:workshop_variation).permit(
[ :name, :rhino_body, :published, :publicly_visible, :position, :youtube_url, :author_id,
:organization_id, :workshop_id, :workshop_variation_idea_id, :author_credit_preference,
:windows_type_id,
:organization_id, :workshop_id, :workshop_variation_idea_id, :windows_type_id,
primary_asset_attributes: [ :id, :file, :_destroy ],
gallery_assets_attributes: [ :id, :file, :_destroy ]
]
Expand Down
3 changes: 1 addition & 2 deletions app/controllers/workshops_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -226,8 +226,7 @@ def log_workshop_error(action, error)
def workshop_params
params.require(:workshop).permit(
:title, :featured, :published,
:full_name, :author_id, :windows_type_id, :workshop_idea_id, :author_credit_preference,
:month, :year,
:full_name, :author_id, :windows_type_id, :workshop_idea_id, :month, :year,
:publicly_visible,
:publicly_featured,

Expand Down
8 changes: 0 additions & 8 deletions app/decorators/resource_decorator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,6 @@ def kind_display
kind == "Scholarship" ? "Scholar-ship" : (kind.present? ? kind.titleize : "Resource")
end

def truncated_author
h.truncate author_credit, length: 20
end

def truncated_title
h.truncate title, length: 25
end
Expand All @@ -36,10 +32,6 @@ def breadcrumbs
"#{type_link} >> #{title}".html_safe
end

def author_full_name
author_credit
end

def display_date
created_at.strftime("%B %Y")
end
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ export default class extends Controller {
z-index: 1;
}
.remote-select-container .ts-control {
padding-left: 1.5rem !important; /* Make room for the search icon */
padding-left: 2rem !important; /* Clear the search icon so it never overlaps the placeholder or value */
}
.ts-control {
border: none !important;
Expand Down
1 change: 1 addition & 0 deletions app/helpers/admin_cards_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ def deprecated_data_cards
def additional_data_cards
[
custom_card("Allocations", allocations_path, icon: "πŸ“€", color: :sky, intensity: 100),
custom_card("Author credit divergences", author_credit_divergences_path, icon: "✍️", color: :sky, intensity: 100),
disabled_card("Bulk payments", icon: "πŸ’³"),
custom_card("Event registrations", event_registrations_path, icon: "🎟️", color: :sky, intensity: 100),
custom_card("Forms", forms_path, icon: "πŸ“‹", color: :sky, intensity: 100),
Expand Down
39 changes: 39 additions & 0 deletions app/helpers/author_credit_divergences_helper.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
module AuthorCreditDivergencesHelper
# The 8 AuthorCreditable models label their content differently (title vs name).
def divergence_record_title(record)
record.try(:title).presence || record.try(:name).presence || "##{record.id}"
end

# Wraps plain records for the shared assign-a-person table. Sections 3 and 4 have
# no free-text name to guess from, so the suggestion is passed in (the creator) or
# omitted entirely.
def assignable_rows(records, suggested_author: nil)
records.map do |record|
AuthorCreditDivergenceQuery::AssignableRow.new(record: record, suggested_author: suggested_author)
end
end

# An empty page means "nothing left to reconcile" only when nothing is filtered
# out β€” otherwise the congratulations would be reporting on the filter.
def divergence_filters_applied?
AuthorCreditDivergencesController::FILTER_KEYS.any? { |key| params[key].present? }
end

# New tab rather than an eyebrow: these rows link to 8 different destinations,
# none of which carries a return_to today.
def divergence_record_link(record)
link_to divergence_record_title(record), polymorphic_path(record),
target: "_blank", rel: "noopener",
title: "Opens in a new tab",
class: "text-blue-700 hover:underline"
end

# The suggestion is the most restrictive preference across the person's content,
# but `anonymous` isn't a name format β€” it's the separate checkbox β€” so fall back
# to the profile's current format when that's what was suggested.
def suggested_display_name_preference(group)
suggested = group.suggested_preference
return suggested if Person::DISPLAY_NAME_PREFERENCES.include?(suggested)
group.person.display_name_preference.presence || "full_name"
end
end
6 changes: 4 additions & 2 deletions app/models/community_news.rb
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,11 @@ def missing_author_label
# SearchCop
include SearchCop
search_scope :search do
attributes :title, :published, person_first: "people.first_name", person_last: "people.last_name"
attributes :title, :published

scope { join_rich_texts.left_joins(:author) }
# Author names are deliberately not indexed here β€” see Story. Person-name
# search goes through `by_credited_person_name`, which honors the preference.
scope { join_rich_texts }
attributes action_text_body: "action_text_rich_texts.plain_text_body"
end

Expand Down
Loading