Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
7e478c2
Make the profile the source of truth for author credit
maebeale Aug 4, 2026
9967ae6
Reconcile stored credit snapshots from one admin page
maebeale Aug 4, 2026
b112818
Use the shared badge partial for the reconciled indicator
maebeale Aug 6, 2026
f2f4871
Group credit divergences into resolvable sections with a person search
maebeale Aug 8, 2026
40942f3
Snapshot in-progress work on profile-visibility-preferences
maebeale Aug 8, 2026
19f73e5
Restyle divergences filter bar to match the app's filter conventions
maebeale Aug 8, 2026
3141e62
Open divergences person links in a new tab, like the content links
maebeale Aug 8, 2026
8e33573
Report each legacy author column separately, with a suggested person
maebeale Aug 8, 2026
65a4a92
Update credit divergences in place over Turbo, not a full reload
maebeale Aug 8, 2026
edf196f
Suppress stored-anonymous over legacy names; harden the divergences page
maebeale Aug 9, 2026
34551ae
Credit only the author the record actually names
maebeale Aug 10, 2026
7d505fe
Rename contributions_anonymous, and stop legacy credits resolving to …
maebeale Aug 10, 2026
e4c6167
Refresh AGENTS.md directory counts
maebeale Aug 15, 2026
ca50289
Cut the commentary back to what isn't obvious from the code
maebeale Aug 15, 2026
5ce9234
Credit facilitator spotlights and idea audit lines through the profile
maebeale Aug 15, 2026
c24c845
Keep a legacy credit clear of the person who entered it, and name the…
maebeale Aug 15, 2026
6647af4
Credit only the author, and name the org when there isn't one
maebeale Aug 16, 2026
165b126
Show the workshop idea submitter how they'll be credited
maebeale Aug 16, 2026
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
21 changes: 11 additions & 10 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,21 +48,21 @@ 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, `StoryImporter` for WordPress CSV import) | ~57 files |
| `app/jobs/` | SolidQueue background jobs | 5 files |
| `app/models/concerns/` | Shared model modules | 16 concerns |
| `app/models/` | ActiveRecord models | ~106 files |
| `app/services/` | Service objects and POROs (e.g. `MoneyFormatter` for currency display, `StoryImporter` for WordPress CSV import) | ~63 files |
| `app/jobs/` | SolidQueue background jobs | 6 files |
| `app/models/concerns/` | Shared model modules | 17 concerns |

### Presentation

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

Expand Down Expand Up @@ -134,7 +134,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 while set (either the profile or the record can make a credit anonymous, and neither strips the other's flag β€” only an admin clearing the record's snapshot on that page does) |
| `Featureable` | `featured`, `publicly_featured` scopes |
| `Mentioner` | ActionText @mention extraction and grouping |
| `NameFilterable` | Name-based filtering |
Expand Down Expand Up @@ -215,6 +215,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
94 changes: 94 additions & 0 deletions app/controllers/author_credit_divergences_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
class AuthorCreditDivergencesController < ApplicationController
before_action :authorize_page

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

def index
return unless turbo_frame_request?

@result = AuthorCreditDivergenceQuery.new(**filters.symbolize_keys).call
render :author_credit_divergences_results
end

# Stamped 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

# "anonymous" here suppresses this item's credit; 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])

# Clearing "anonymous" hands the item back to the profile, which may credit it β€”
# that's what this page exists to do.
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

# The fix for every section below the first: an author_id is the only credit path
# that follows a profile, links to it, and lists the record there.
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

# Re-render the frame over Turbo so a save doesn't flip the whole page.
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, :anonymous_contributions)
end

# The write actions name their 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
17 changes: 12 additions & 5 deletions app/controllers/people_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -52,24 +52,25 @@ 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) +
# The spotlight is a separate credit from authorship, so anonymity doesn't apply.
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 @@ -297,6 +298,13 @@ def check_duplicates

private

# Showing anonymous content to anyone but the person and admins would tie an
# "Anonymous" credit back to a name.
def visible_authored_content(scope)
return scope if allowed_to?(:manage?, Person) || current_user&.person_id == @person.id
return scope.none if @person.anonymous_contributions?
scope.credited_openly
end

def set_person
@person = Person.find(params[:id])
Expand Down Expand Up @@ -527,7 +535,6 @@ def person_params
:display_name_preference,
:anonymous_contributions,
: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 @@ -211,7 +211,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 @@ -146,7 +146,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 @@ -76,6 +76,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
8 changes: 8 additions & 0 deletions app/helpers/application_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,14 @@ def credited_author_link(record, **link_options)
end
end

# The person an author picker should show. Only the record's own author counts β€”
# falling back to the creator would present a person nobody chose as the selected
# author, and saving the form would silently promote them over a legacy credit.
# New records still default to the current user, which is the documented behavior.
def author_picker_person(record)
record.author || (record.new_record? ? current_user&.person : nil)
end

# Tags an admin may use in a form field name / group header that should
# render (rather than escape) on the public form. Block + inline formatting,
# links, line breaks, and font sizing/coloring (via <font> or inline style).
Expand Down
33 changes: 33 additions & 0 deletions app/helpers/author_credit_divergences_helper.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
module AuthorCreditDivergencesHelper
# The models label their content differently (title vs name).
def divergence_record_title(record)
record.try(:title).presence || record.try(:name).presence || "##{record.id}"
end

# Sections 3 and 4 have no name to guess from, so any suggestion is passed in.
def assignable_rows(records, suggested_author: nil)
records.map do |record|
AuthorCreditDivergenceQuery::AssignableRow.new(record: record, suggested_author: suggested_author)
end
end

# 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: 8 destinations, none carrying 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

# `anonymous` isn't a name format β€” it's the separate checkbox β€” so fall back.
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
11 changes: 4 additions & 7 deletions app/models/community_news.rb
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,6 @@ class CommunityNews < ApplicationRecord
validates :title, presence: true, length: { maximum: 150 }
validates :rhino_body, presence: true

# Unattributed community news is credited to the organization's staff.
def missing_author_label
"AWBW Staff"
end

# Nested attributes
accepts_nested_attributes_for :primary_asset, allow_destroy: true, reject_if: :all_blank
accepts_nested_attributes_for :gallery_assets, allow_destroy: true, reject_if: :all_blank
Expand All @@ -45,9 +40,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