Skip to content
Open
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
7 changes: 4 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,9 @@ This codebase (Rails 8.1)
| `app/controllers/` | Rails controllers (admin/, events/) | ~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/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 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 @@ -208,6 +208,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
103 changes: 103 additions & 0 deletions app/controllers/author_credit_divergences_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
class AuthorCreditDivergencesController < ApplicationController
before_action :authorize_page

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

# The full page renders only the header, filters, and an empty results frame;
# the frame's src request builds the divergences.
def index
return unless turbo_frame_request?

@result = AuthorCreditDivergenceQuery.new(**filters.symbolize_keys).call
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])

# Clearing an "anonymous" snapshot hands the item back to the profile, which may
# well credit it. That's the point: a per-item anonymous flag is the legacy state
# this page exists to drain, and the person's profile is the source of truth.
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, :anonymous_contributions)
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 @@ -52,24 +52,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 @@ -297,6 +299,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. `anonymous_contributions` 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.anonymous_contributions?
scope.credited_openly
end

def set_person
@person = Person.find(params[:id])
Expand Down Expand Up @@ -525,8 +536,8 @@ def person_params
:mailing_list_consented,
:bio, :shoutout_text, :notes,
: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
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