diff --git a/AGENTS.md b/AGENTS.md index 9bda67752b..bd10e7a8b4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -49,7 +49,7 @@ 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 | @@ -57,12 +57,12 @@ This codebase (Rails 8.1) | 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 | @@ -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 | @@ -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 diff --git a/app/controllers/author_credit_divergences_controller.rb b/app/controllers/author_credit_divergences_controller.rb new file mode 100644 index 0000000000..cb2bf62b78 --- /dev/null +++ b/app/controllers/author_credit_divergences_controller.rb @@ -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 diff --git a/app/controllers/community_news_controller.rb b/app/controllers/community_news_controller.rb index 10ce42cfe0..3e35d01edb 100644 --- a/app/controllers/community_news_controller.rb +++ b/app/controllers/community_news_controller.rb @@ -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 ], diff --git a/app/controllers/people_controller.rb b/app/controllers/people_controller.rb index 383e81389e..429645d680 100644 --- a/app/controllers/people_controller.rb +++ b/app/controllers/people_controller.rb @@ -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) @@ -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]) @@ -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, diff --git a/app/controllers/resources_controller.rb b/app/controllers/resources_controller.rb index deb644e4d7..37cd5e4b4b 100644 --- a/app/controllers/resources_controller.rb +++ b/app/controllers/resources_controller.rb @@ -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 ], diff --git a/app/controllers/stories_controller.rb b/app/controllers/stories_controller.rb index 34b618fabf..f7f5a6a4f4 100644 --- a/app/controllers/stories_controller.rb +++ b/app/controllers/stories_controller.rb @@ -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 ], diff --git a/app/controllers/story_ideas_controller.rb b/app/controllers/story_ideas_controller.rb index fae4b9508c..32e99ab3fe 100644 --- a/app/controllers/story_ideas_controller.rb +++ b/app/controllers/story_ideas_controller.rb @@ -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: [], diff --git a/app/controllers/workshop_ideas_controller.rb b/app/controllers/workshop_ideas_controller.rb index 11d8058246..5cd8ecdb0f 100644 --- a/app/controllers/workshop_ideas_controller.rb +++ b/app/controllers/workshop_ideas_controller.rb @@ -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, diff --git a/app/controllers/workshop_variation_ideas_controller.rb b/app/controllers/workshop_variation_ideas_controller.rb index 3ade7ecfd7..9d9e6b16f0 100644 --- a/app/controllers/workshop_variation_ideas_controller.rb +++ b/app/controllers/workshop_variation_ideas_controller.rb @@ -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 ] ) diff --git a/app/controllers/workshop_variations_controller.rb b/app/controllers/workshop_variations_controller.rb index 9f42ac9ffe..964ba8fa4b 100644 --- a/app/controllers/workshop_variations_controller.rb +++ b/app/controllers/workshop_variations_controller.rb @@ -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 ] ] diff --git a/app/controllers/workshops_controller.rb b/app/controllers/workshops_controller.rb index 0650ef5286..e4fe284207 100644 --- a/app/controllers/workshops_controller.rb +++ b/app/controllers/workshops_controller.rb @@ -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, diff --git a/app/decorators/resource_decorator.rb b/app/decorators/resource_decorator.rb index 172ac2fa97..f156774631 100644 --- a/app/decorators/resource_decorator.rb +++ b/app/decorators/resource_decorator.rb @@ -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 @@ -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 diff --git a/app/frontend/javascript/controllers/remote_select_controller.js b/app/frontend/javascript/controllers/remote_select_controller.js index e759e55596..ce69d403e7 100644 --- a/app/frontend/javascript/controllers/remote_select_controller.js +++ b/app/frontend/javascript/controllers/remote_select_controller.js @@ -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; diff --git a/app/helpers/admin_cards_helper.rb b/app/helpers/admin_cards_helper.rb index c8de982e6f..85606d75d0 100644 --- a/app/helpers/admin_cards_helper.rb +++ b/app/helpers/admin_cards_helper.rb @@ -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), diff --git a/app/helpers/author_credit_divergences_helper.rb b/app/helpers/author_credit_divergences_helper.rb new file mode 100644 index 0000000000..009614c7a4 --- /dev/null +++ b/app/helpers/author_credit_divergences_helper.rb @@ -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 diff --git a/app/models/community_news.rb b/app/models/community_news.rb index b75a120b8a..3f00786c39 100644 --- a/app/models/community_news.rb +++ b/app/models/community_news.rb @@ -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 diff --git a/app/models/concerns/author_creditable.rb b/app/models/concerns/author_creditable.rb index 1585dddba1..ebf27e86f7 100644 --- a/app/models/concerns/author_creditable.rb +++ b/app/models/concerns/author_creditable.rb @@ -1,15 +1,16 @@ module AuthorCreditable extend ActiveSupport::Concern + # How a credit renders comes from the credited person's profile, not from the record. + # `author_credit_preference` is retained as the record of what the submitter consented + # to at submission time, and is human-editable only on the author credit divergences + # page. It no longer drives display — with one exception: a stored "anonymous" is + # always honored, because anonymity is inherently per-item (a person may want four + # stories credited and the fifth not) and because nothing should be able to + # de-anonymize an item that was submitted anonymously. AUTHOR_CREDIT_PREFERENCES = %w[full_name first_name_last_initial first_name_only last_name_only anonymous].freeze - IDEA_FORM_OPTIONS = { - "I would like my full name published with the story" => "full_name", - "I would like my first name and last initial published" => "first_name_last_initial", - "I would like only my first name published" => "first_name_only", - "I would like only my last name published" => "last_name_only", - "I do not want my name published with my story" => "anonymous" - }.freeze + ANONYMOUS = "anonymous" ADMIN_FORM_OPTIONS = { "Full name" => "full_name", @@ -20,11 +21,12 @@ module AuthorCreditable }.freeze included do - # Admin-created records default a blank preference to full_name (in the UI and - # on save) — no data backfill. Public-submission models call - # `require_author_credit_preference` instead, to force a conscious choice. - attribute :author_credit_preference, :string, default: "full_name" - before_validation :apply_default_author_credit_preference + # Snapshot the credited person's profile preference on create, so the column keeps + # recording consent-at-submission without a human ever picking it. + before_create :snapshot_author_credit_preference + # A blank preference means "no per-item override, just follow the profile" — store + # it as nil so the divergence worklist (which excludes nil) never re-flags it. + normalizes :author_credit_preference, with: ->(value) { value.presence } validates :author_credit_preference, inclusion: { in: AUTHOR_CREDIT_PREFERENCES }, allow_blank: true # Filter to content explicitly authored by a person (belongs_to :author); @@ -57,16 +59,15 @@ def legacy_author_name_text nil end - # Display string for the credited author, honoring the credit preference. - # Precedence: an explicit "anonymous" preference always renders "Anonymous"; - # then the primary author person, then the legacy free-text name, then the + # Display string for the credited author, formatted by that person's profile. + # Precedence: the primary author person, then the legacy free-text name, then the # creating user's person, then `missing_author_label`. def author_credit - return "Anonymous" if author_credit_preference == "anonymous" person = primary_author_person - return format_person_credit(person) if person + return credit_for(person) if person return legacy_author_name_text if legacy_author_name_text.present? - format_person_credit(created_by&.person) + creator = created_by&.person + creator ? credit_for(creator) : missing_author_label end # The person the credit should link to, or nil when the credit must not resolve @@ -75,8 +76,23 @@ def author_credit # never declared authorship (and the record isn't listed on their profile # either). Anonymous never links. def author_credit_person - return nil if author_credit_preference == "anonymous" - primary_author_person + person = primary_author_person + person && !credit_anonymous?(person) ? person : nil + end + + # Anonymity is a one-way latch: the profile can set it, the record can set it, + # and neither can strip it from the other. + def credit_anonymous?(person) + person.contributions_anonymous? || author_credit_preference == ANONYMOUS + end + + # True when the stored consent snapshot no longer agrees with the credited + # person's current profile — surfaced as a warning on the record's form and as a + # row on the author credit divergences page. + def author_credit_diverged? + return false if author_credit_preference.blank? + person = author_person + person.present? && author_credit_preference != person.effective_author_credit_preference end # Shown when there is no credited person or legacy name. Overridable per model @@ -85,44 +101,19 @@ def missing_author_label "Anonymous" end - # Default an unset preference to "full_name" (so legacy rows normalize on save, - # no backfill) — unless the model requires an explicit choice. - def apply_default_author_credit_preference - return if self.class.require_author_credit_preference? - self.author_credit_preference = "full_name" if author_credit_preference.blank? + def snapshot_author_credit_preference + # Promotion services copy the originating idea's snapshot forward — keep it. + return if author_credit_preference.present? + person = primary_author_person || created_by&.person + self.author_credit_preference = person.effective_author_credit_preference if person end - # Formats a person's name per the credit preference, falling back to - # `missing_author_label` when the person or the requested name part is missing. - private def format_person_credit(person) - case author_credit_preference - when "first_name_last_initial" - first = person&.first_name - first.present? ? "#{first} #{person.last_name&.first}." : missing_author_label - when "first_name_only" - person&.first_name.presence || missing_author_label - when "last_name_only" - person&.last_name.presence || missing_author_label - else # full_name — the default, and the fallback for any unknown value - person&.full_name.presence || missing_author_label - end + private def credit_for(person) + return "Anonymous" if credit_anonymous?(person) + person.name.presence || missing_author_label end class_methods do - # Require an explicit credit choice rather than defaulting to full_name — for - # public submissions where the preference is a privacy decision (the submitter - # must not be silently opted into publishing their full name). Used by the - # *_idea models. - def require_author_credit_preference - @require_author_credit_preference = true - attribute :author_credit_preference, :string, default: nil - validates :author_credit_preference, presence: true - end - - def require_author_credit_preference? - @require_author_credit_preference == true - end - # Legacy free-text columns (fully qualified, e.g. "resources.legacy_author_name") # that also hold an author's name. Overridden per model that has one. def legacy_author_name_columns @@ -138,8 +129,8 @@ def by_credited_person_name(query) sanitized = query.to_s.strip.gsub(/\s+/, "") return none if sanitized.blank? - clauses = credited_person_aliases.flat_map { |a| person_name_match_clauses(a) } - clauses += legacy_author_name_columns.map { |col| "LOWER(REPLACE(#{col}, ' ', '')) LIKE :name" } + clauses = credited_person_aliases.map { |a| credited_person_match_sql(a) } + clauses += legacy_author_name_columns.map { |col| legacy_author_name_match_sql(col) } joins(credited_person_join_sql).where(clauses.join(" OR "), name: "%#{sanitized}%") end @@ -189,13 +180,40 @@ def coalesced_author_arel(field, ascending) ascending ? node.asc : node.desc end - def person_name_match_clauses(sql_alias) - [ - "LOWER(REPLACE(CONCAT(#{sql_alias}.first_name, #{sql_alias}.last_name), ' ', '')) LIKE :name", - "LOWER(REPLACE(CONCAT(#{sql_alias}.last_name, #{sql_alias}.first_name), ' ', '')) LIKE :name", - "LOWER(REPLACE(#{sql_alias}.first_name, ' ', '')) LIKE :name", - "LOWER(REPLACE(#{sql_alias}.last_name, ' ', '')) LIKE :name" - ] + # Match only on the name parts the credit actually displays, so search can't + # surface what the credit hides: an anonymous credit matches nothing, a + # "first name only" credit isn't findable by last name, and a "first name, + # last initial" credit matches the initial rather than the whole last name. + def credited_person_match_sql(sql_alias) + first = "#{sql_alias}.first_name" + last = "#{sql_alias}.last_name" + preference = "COALESCE(#{sql_alias}.display_name_preference, 'full_name')" + + by_preference = { + "full_name" => [ "CONCAT(#{first}, #{last})", "CONCAT(#{last}, #{first})", first, last ], + "first_name_last_initial" => [ "CONCAT(#{first}, LEFT(#{last}, 1))", first ], + "first_name_only" => [ first ], + "last_name_only" => [ last ] + }.map do |value, expressions| + "(#{preference} = '#{value}' AND (#{expressions.map { |e| name_like(e) }.join(' OR ')}))" + end + + "(#{sql_alias}.contributions_anonymous = FALSE AND #{not_anonymous_sql} AND (#{by_preference.join(' OR ')}))" + end + + # Legacy free-text author names have no person, so only the record's own + # anonymity applies. + def legacy_author_name_match_sql(column) + "(#{not_anonymous_sql} AND #{name_like(column)})" + end + + def not_anonymous_sql + "(#{table_name}.author_credit_preference IS NULL OR " \ + "#{table_name}.author_credit_preference <> '#{AuthorCreditable::ANONYMOUS}')" + end + + def name_like(expression) + "LOWER(REPLACE(#{expression}, ' ', '')) LIKE :name" end end end diff --git a/app/models/person.rb b/app/models/person.rb index 20554904ca..5def838f24 100644 --- a/app/models/person.rb +++ b/app/models/person.rb @@ -71,6 +71,20 @@ class Person < ApplicationRecord CONTACT_TYPES = [ "work", "personal" ].freeze validates :email_type, inclusion: { in: %w[work personal] }, allow_blank: true validates :email_2_type, inclusion: { in: %w[work personal] }, allow_blank: true + + # How this person's name is formatted wherever it appears. Anonymity is not one of + # these — it's the separate `contributions_anonymous` flag, because a person still + # has to be listed *somehow* on the people index. + DISPLAY_NAME_PREFERENCES = %w[full_name first_name_last_initial first_name_only last_name_only].freeze + + DISPLAY_NAME_PREFERENCE_LABELS = { + "full_name" => "First and last name", + "first_name_last_initial" => "First name and last initial", + "first_name_only" => "First name only", + "last_name_only" => "Last name only" + }.freeze + + validates :display_name_preference, inclusion: { in: DISPLAY_NAME_PREFERENCES }, allow_blank: true # Mirrors SectorsTaggable's single-primary rule for age ranges — the chip # editor's single-star JS is the first line of defense, this guards imports, # the console, and bad form posts. Person-only: organizations aggregate @@ -206,21 +220,30 @@ def mailing_list_consented=(value) end end + # The single name formatter for this person — drives the people index, the profile + # header, and every author credit on content they've shared. def name case display_name_preference - when "full_name" - full_name when "first_name_last_initial" - "#{first_name} #{last_name.first}" + initial = last_name&.first + initial.present? ? "#{first_name} #{initial}." : first_name.to_s when "first_name_only" first_name when "last_name_only" last_name - else + else # full_name — the default, and the fallback for any unknown value full_name end end + # How this person is credited on content they've shared. Anonymity is a separate + # axis from the name format: it suppresses author credits without affecting how + # they're listed on the people index. See AuthorCreditable. + def effective_author_credit_preference + return "anonymous" if contributions_anonymous? + display_name_preference.presence || "full_name" + end + def full_name "#{first_name} #{last_name}" end diff --git a/app/models/story.rb b/app/models/story.rb index 565ea7eb81..46511ece5b 100644 --- a/app/models/story.rb +++ b/app/models/story.rb @@ -46,10 +46,13 @@ class Story < ApplicationRecord search_scope :search do attributes all: [ :title, :published ] attributes :title, :published - attributes person_first: "people.first_name", person_last: "people.last_name" options :all, type: :text, default: true, default_operator: :or - scope { join_rich_texts.left_joins(created_by: :person) } + # Author names are deliberately not indexed here. `by_credited_person_name` is + # the only person-name search path, because it honors the credit preference — + # indexing people.first_name/last_name would let a full-text query surface a + # credit that renders "Anonymous". + scope { join_rich_texts } attributes action_text_body: "action_text_rich_texts.plain_text_body" options :action_text_body, type: :text, default: true, default_operator: :or end diff --git a/app/models/story_idea.rb b/app/models/story_idea.rb index aa169956d6..a17dddd99e 100644 --- a/app/models/story_idea.rb +++ b/app/models/story_idea.rb @@ -1,7 +1,5 @@ class StoryIdea < ApplicationRecord include AuthorCreditable - # Public submission: the submitter must choose how they're credited. - require_author_credit_preference include SearchCop search_scope :search do attributes :title, :body diff --git a/app/models/workshop.rb b/app/models/workshop.rb index 7d6028788c..5e0e7a7bf3 100644 --- a/app/models/workshop.rb +++ b/app/models/workshop.rb @@ -200,10 +200,6 @@ def missing_author_label "AWBW Facilitator" end - def author_name - author_person&.full_name.presence || full_name.presence - end - def date if month.present? && year.present? Date.new(year.to_i, month.to_i).strftime("%B %Y") diff --git a/app/models/workshop_idea.rb b/app/models/workshop_idea.rb index 243e3083b3..dbb6b53be2 100644 --- a/app/models/workshop_idea.rb +++ b/app/models/workshop_idea.rb @@ -1,7 +1,5 @@ class WorkshopIdea < ApplicationRecord include AuthorCreditable - # Public submission: the submitter must choose how they're credited. - require_author_credit_preference belongs_to :created_by, class_name: "User" belongs_to :updated_by, class_name: "User" @@ -75,9 +73,12 @@ class WorkshopIdea < ApplicationRecord # Scopes scope :title, ->(title) { where("workshop_ideas.title like ?", "%#{ title }%") } - scope :author_name, ->(author_name) { joins(:created_by). - where("users.first_name like ? or users.last_name like ? or users.email like ?", - "%#{author_name}%", "%#{author_name}%", "%#{author_name}%") } + # Goes through by_credited_person_name so the filter honors the credit preference — + # matching users.first_name/last_name/email directly would surface ideas whose + # credit renders "Anonymous". + scope :author_name, ->(author_name) { + where(id: by_credited_person_name(author_name).select("workshop_ideas.id")) + } def self.search(params) results = is_a?(ActiveRecord::Relation) ? self : all diff --git a/app/models/workshop_variation_idea.rb b/app/models/workshop_variation_idea.rb index 993b498dcc..9cedd1e280 100644 --- a/app/models/workshop_variation_idea.rb +++ b/app/models/workshop_variation_idea.rb @@ -1,7 +1,5 @@ class WorkshopVariationIdea < ApplicationRecord include AuthorCreditable - # Public submission: the submitter must choose how they're credited. - require_author_credit_preference include SearchCop search_scope :search do attributes :name, :body diff --git a/app/policies/author_credit_divergence_policy.rb b/app/policies/author_credit_divergence_policy.rb new file mode 100644 index 0000000000..52089688b6 --- /dev/null +++ b/app/policies/author_credit_divergence_policy.rb @@ -0,0 +1,17 @@ +class AuthorCreditDivergencePolicy < ApplicationPolicy + def index? + admin? + end + + def update_person? + admin? + end + + def update_item? + admin? + end + + def assign_author? + admin? + end +end diff --git a/app/services/author_credit_divergence_query.rb b/app/services/author_credit_divergence_query.rb new file mode 100644 index 0000000000..9f522cbdb8 --- /dev/null +++ b/app/services/author_credit_divergence_query.rb @@ -0,0 +1,211 @@ +# Everything on the author credit divergences page: content whose credit doesn't +# resolve cleanly through the credited person's profile. +# +# Four sections, in the order an admin should work them. The first is about a +# preference that drifted; the other three are all forms of "this credit isn't +# coming from an author_id", which is the only path that links to a profile and +# lists the record on it. +# +# preference — stored consent snapshot no longer matches the profile +# legacy — credited by a free-text name column, no person at all +# creator — author_id is blank, so the credit falls back to the creator +# unattributed — nothing to credit; renders the model's missing_author_label +# +# Comparisons run in Ruby because they walk the author fallback chain. The +# candidate sets are small: the preference column was added with no default and +# no backfill, and the legacy columns only hold pre-person data. +class AuthorCreditDivergenceQuery + # Every AuthorCreditable model. Doubles as the allowlist for the `type` param — + # never constantize a raw param. + MODEL_NAMES = %w[ + Story + StoryIdea + Workshop + WorkshopIdea + WorkshopVariation + WorkshopVariationIdea + Resource + CommunityNews + ].freeze + + SECTIONS = %w[preference legacy creator unattributed].freeze + + # Which preference reveals the least, for suggesting a profile value that + # satisfies all of a person's content. + RESTRICTIVENESS = { + "anonymous" => 4, + "last_name_only" => 3, + "first_name_only" => 3, + "first_name_last_initial" => 2, + "full_name" => 1 + }.freeze + + PersonGroup = Struct.new(:person, :records, :suggested_preference, keyword_init: true) + + # One per legacy column, kept even when empty so the page can congratulate each + # field separately — clearing a column is what makes it safe to drop. + LegacyGroup = Struct.new(:model, :column, :entries, keyword_init: true) do + def empty? = entries.empty? + def field = column.split(".").last + end + # A row an admin can resolve by picking a person, optionally pre-guessed. + AssignableRow = Struct.new(:record, :suggested_author, keyword_init: true) + + Result = Struct.new(:preference, :legacy, :creator, :unattributed, keyword_init: true) do + def legacy_empty? = legacy.all?(&:empty?) + + def empty? + preference.empty? && legacy_empty? && creator.empty? && unattributed.empty? + end + end + + def self.model_for(type) + MODEL_NAMES.include?(type.to_s) ? type.to_s.constantize : nil + end + + def initialize(person_id: nil, type: nil, preference: nil, include_reconciled: false) + @person_id = person_id.presence + @type = type.presence + @preference = preference.presence + @include_reconciled = ActiveModel::Type::Boolean.new.cast(include_reconciled) + end + + def call + Result.new( + preference: preference_groups, + legacy: legacy_groups, + creator: creator_groups, + unattributed: unattributed_records + ) + end + + private + + attr_reader :person_id, :type, :preference, :include_reconciled + + def models + @models ||= type ? [ self.class.model_for(type) ].compact : MODEL_NAMES.map(&:constantize) + end + + # Only models that actually have an author_id can be "missing" one — the idea + # models have no such column, so crediting through the creator is their normal + # and correct behavior, not something to resolve. + def authorable_models + models.select { |model| model.column_names.include?("author_id") } + end + + def scoped(model) + model.includes(includes_for(model)) + end + + def includes_for(model) + includes = [ { created_by: :person } ] + includes << :author if model.column_names.include?("author_id") + includes + end + + # ── Section 1: the stored snapshot drifted from the profile ──────────────── + def preference_groups + records = models.flat_map do |model| + scope = scoped(model).where.not(author_credit_preference: nil) + scope = scope.where(author_credit_preference: preference) if preference + scope.select(&:author_credit_diverged?) + end + + group_by_person(records) + end + + # ── Section 2: credited by a free-text name, with no person behind it ────── + # One group per legacy column, so each field can be reported (and retired) + # on its own. The person filter can't apply here: these records have no + # credited person, which is the whole problem with them. + def legacy_groups + groups = authorable_models.flat_map do |model| + model.legacy_author_name_columns.map { |column| [ model, column ] } + end + + records = preference || person_id ? [] : legacy_candidates + suggestions = suggested_authors_for(records) + + groups.map do |model, column| + entries = records.select { |record| record.is_a?(model) }.map do |record| + AssignableRow.new(record: record, suggested_author: suggestions[normalized(record.legacy_author_name_text)]) + end + LegacyGroup.new(model: model, column: column, entries: entries) + end + end + + def legacy_candidates + authorable_models.flat_map do |model| + next [] if model.legacy_author_name_columns.empty? + sorted(scoped(model).where(author_id: nil).select { |record| record.legacy_author_name_text.present? }) + end + end + + # Guess who each free-text name refers to, so an admin can confirm rather than + # look every one up. Matches on the whole name, or on first + last token, in one + # query for the whole page rather than one per row. + def suggested_authors_for(records) + names = records.filter_map { |record| record.legacy_author_name_text.presence }.uniq + return {} if names.empty? + + candidates = Person.where(last_name: names.flat_map { |name| name.split(/\s+/) }.uniq) + by_normalized_full_name = candidates.index_by { |person| normalized(person.full_name) } + + names.index_with { |name| by_normalized_full_name[normalized(name)] } + .transform_keys { |name| normalized(name) } + end + + def normalized(value) + value.to_s.downcase.gsub(/\s+/, "") + end + + # ── Section 3: author_id blank, so the credit falls back to the creator ──── + def creator_groups + records = authorable_models.flat_map do |model| + scoped(model) + .where(author_id: nil) + .select { |record| record.legacy_author_name_text.blank? && record.created_by&.person.present? } + end + + group_by_person(records) + end + + # ── Section 4: nothing to credit at all ──────────────────────────────────── + def unattributed_records + return [] if preference || person_id + + authorable_models.flat_map do |model| + sorted(scoped(model) + .where(author_id: nil) + .select { |record| record.legacy_author_name_text.blank? && record.created_by&.person.blank? }) + end + end + + def group_by_person(records) + records + .group_by(&:author_person) + .filter_map { |person, grouped| build_group(person, grouped) } + .sort_by { |group| [ group.person.first_name.to_s.downcase, group.person.last_name.to_s.downcase ] } + end + + def build_group(person, records) + return nil if person.blank? + return nil if person_id.present? && person.id != person_id.to_i + return nil if person.author_credit_reconciled_at.present? && !include_reconciled + + PersonGroup.new( + person: person, + records: sorted(records), + suggested_preference: most_restrictive(records) + ) + end + + def sorted(records) + records.sort_by { |record| [ record.class.name, record.id ] } + end + + def most_restrictive(records) + records.map(&:author_credit_preference).compact.max_by { |value| RESTRICTIVENESS.fetch(value, 0) } + end +end diff --git a/app/views/author_credit_divergences/_assign_author_form.html.erb b/app/views/author_credit_divergences/_assign_author_form.html.erb new file mode 100644 index 0000000000..46ad7eed93 --- /dev/null +++ b/app/views/author_credit_divergences/_assign_author_form.html.erb @@ -0,0 +1,17 @@ +<%# Point one record at a real person. Locals: record (required), suggested (optional + Person to preselect, e.g. the creator on the "credited to the creator" section). %> +<% suggested = local_assigns[:suggested] %> +<%= form_with url: assign_author_author_credit_divergences_path, method: :patch, + class: "flex items-center gap-2" do %> + <%= hidden_field_tag :record_type, record.class.name, id: nil %> + <%= hidden_field_tag :record_id, record.id, id: nil %> + <%= render "filter_fields" %> + <%= select_tag :author_id, + options_for_select(suggested ? [ [ suggested.full_name, suggested.id ] ] : [], suggested&.id), + include_blank: "Select a person", + class: "rounded-md border-gray-300 text-sm", + id: nil, + data: { controller: "remote-select", remote_select_model_value: "person" } %> + <%= submit_tag "Credit", + class: "rounded-md border border-gray-300 bg-white px-2 py-1 text-xs text-gray-700 hover:bg-gray-50 cursor-pointer" %> +<% end %> diff --git a/app/views/author_credit_divergences/_filter_fields.html.erb b/app/views/author_credit_divergences/_filter_fields.html.erb new file mode 100644 index 0000000000..d01ad02454 --- /dev/null +++ b/app/views/author_credit_divergences/_filter_fields.html.erb @@ -0,0 +1,5 @@ +<%# Carries the active filters through a save so the admin lands back on the same list. %> +<% AuthorCreditDivergencesController::FILTER_KEYS.each do |key| %> + <% next if params[key].blank? %> + <%= hidden_field_tag key, params[key], id: nil %> +<% end %> diff --git a/app/views/author_credit_divergences/_filters.html.erb b/app/views/author_credit_divergences/_filters.html.erb new file mode 100644 index 0000000000..5e3992464f --- /dev/null +++ b/app/views/author_credit_divergences/_filters.html.erb @@ -0,0 +1,46 @@ +
<%= group.column %>
+ | Content | +Type | +Renders as | +Stored consent | +
|---|---|---|---|
| <%= divergence_record_link(record) %> | +<%= record.class.name.underscore.humanize %> | +<%= record.author_credit %> | ++ <%= form_with url: update_item_author_credit_divergences_path, method: :patch, + class: "flex items-center gap-2" do %> + <%= hidden_field_tag :record_type, record.class.name, id: nil %> + <%= hidden_field_tag :record_id, record.id, id: nil %> + <%= render "filter_fields" %> + <%# "None" clears the snapshot so the item just follows the profile. Hidden + for anonymous items — clearing that value would de-anonymize them. %> + <% allow_clear = record.author_credit_preference != AuthorCreditable::ANONYMOUS %> + <%= select_tag "author_credit_preference", + options_for_select(AuthorCreditable::ADMIN_FORM_OPTIONS.to_a, record.author_credit_preference), + include_blank: (allow_clear ? "None (follow profile)" : false), + id: nil, + class: "rounded-md border-gray-300 text-sm" %> + <%= submit_tag "Save", + class: "rounded-md border border-gray-300 bg-white px-2 py-1 text-xs text-gray-700 hover:bg-gray-50 cursor-pointer" %> + <% end %> + | +
+ Only Anonymous changes what renders — the other values just + re-record what was consented to. +
+ + <%# The action lands after the supportive data above: read the content, then apply. %> ++ <%= message %> +
+ <% if local_assigns[:cleanup].present? %> +<%= cleanup %>
+ <% end %> +| Content | +Type | +<%= name_header %> | +Credit to | +
|---|---|---|---|
| <%= divergence_record_link(row.record) %> | +<%= row.record.class.name.underscore.humanize %> | +<%= row.record.author_credit %> | ++ <%= render "assign_author_form", record: row.record, suggested: row.suggested_author %> + | +
<%= suggestion_note %>
+ <% end %> +Nothing matches these filters.
++ Clear them to see whether anything is left to reconcile overall. +
+Every credit resolves through a profile.
+
+ Nothing left to reconcile in any section. Ask your developers to remove any unused
+ author credit code — the stored author_credit_preference columns, the
+ legacy free-text name columns, and the creator and missing-author fallbacks in
+ AuthorCreditable.
+
+ Work these top to bottom. A record can appear in more than one section when it needs + more than one fix. +
+ + <%# ── 1. Stored consent snapshot drifted from the profile ───────────────── %> ++ These are credited correctly — the profile formats them — but the stored record of + what the submitter consented to has since drifted. Confirm which one is right. +
+ + <% if @result.preference.empty? %> + <%= render "section_clear", + message: "No preference divergences. Every stored consent snapshot matches its author's profile.", + cleanup: "Ask your developers to remove any unused author credit code." %> + <% else %> +<%= pluralize(@result.preference.size, "person") %> to review.
+
+ Pre-person records whose credit comes from a free-text column
+ (workshops.full_name, resources.legacy_author_name). The name shown
+ obeys nobody's profile and links nowhere. Crediting a real person replaces it.
+
+ These have no author_id, so the credit falls back to whoever created the record.
+ The name is right and follows that person's profile, but it never links to them and the record
+ doesn't appear on their profile. Confirm the creator is the author, or pick someone else.
+ Idea records are excluded — they have no author_id, so the creator is their
+ correct and only credit path.
+
<%= pluralize(@result.creator.size, "person") %> to confirm.
++ No author, no legacy name, and no person behind the creating account, so these fall back to a + generic placeholder like “AWBW Facilitator” or “AWBW Staff”. +
+ + <% if @result.unattributed.empty? %> + <%= render "section_clear", + message: "Nothing unattributed. Every record has someone to credit.", + cleanup: "Ask your developers to remove the generic placeholder names, since no record falls back to one any more." %> + <% else %> + <%= render "unlinked_table", rows: assignable_rows(@result.unattributed), name_header: "Renders as" %> + <% end %> +
+ Content whose credit doesn't resolve cleanly through a person's profile — either the
+ stored preference drifted, or the credit isn't coming from an author_id at all.
+ An author_id is the only path that follows the person's profile, links to it,
+ and lists the record there. Note that anonymous always wins over the
+ profile and keeps that item uncredited.
+
+ This comes from your profile and applies to everything you share. + <%= link_to "Contact us", contact_us_path, class: "underline hover:text-gray-700" %> to change it. +
+My Body
" } permission_given { true } - author_credit_preference { "full_name" } association :created_by, factory: :user association :updated_by, factory: :user diff --git a/spec/factories/workshop_ideas.rb b/spec/factories/workshop_ideas.rb index cdb54cd4ca..d612b33b58 100644 --- a/spec/factories/workshop_ideas.rb +++ b/spec/factories/workshop_ideas.rb @@ -18,7 +18,6 @@ instructions { "MyText" } optional_materials { "MyText" } notes { "MyText" } - author_credit_preference { "full_name" } association :created_by, factory: :user association :updated_by, factory: :user diff --git a/spec/factories/workshop_variation_ideas.rb b/spec/factories/workshop_variation_ideas.rb index 3fa7c4536d..4a995a89f9 100644 --- a/spec/factories/workshop_variation_ideas.rb +++ b/spec/factories/workshop_variation_ideas.rb @@ -4,7 +4,6 @@ rhino_body { "This is a variation idea description
" } youtube_url { "https://www.youtube.com/watch?v=example" } permission_given { true } - author_credit_preference { "full_name" } association :workshop association :organization association :windows_type diff --git a/spec/factories/workshop_variations.rb b/spec/factories/workshop_variations.rb index e75cd1be46..1d9b50fe8f 100644 --- a/spec/factories/workshop_variations.rb +++ b/spec/factories/workshop_variations.rb @@ -4,7 +4,6 @@ association :windows_type sequence(:name) { |n| "Variation #{n}" } rhino_body { "Variation details using CKEditor
" } - author_credit_preference { "full_name" } sequence(:position) { |n| n } published { false } diff --git a/spec/models/community_news_spec.rb b/spec/models/community_news_spec.rb index 2f7d2bceb2..5dbf937c10 100644 --- a/spec/models/community_news_spec.rb +++ b/spec/models/community_news_spec.rb @@ -58,22 +58,13 @@ end end + # Author names are no longer in the SearchCop index — see .search_by_params below, + # which ORs in `by_credited_person_name` so person search honors the credit + # preference. Indexing people.first_name/last_name here could not. context 'when searching by person name' do - it 'finds records by person first name' do - results = CommunityNews.search('John') - expect(results).to include(community_news_with_person) - expect(results).not_to include(community_news_without_person) - end - - it 'finds records by person last name' do - results = CommunityNews.search('Doe') - expect(results).to include(community_news_with_person) - expect(results).not_to include(community_news_without_person) - end - - it 'finds records by partial person name' do - results = CommunityNews.search('Joh') - expect(results).to include(community_news_with_person) + it 'does not match on the author name' do + expect(CommunityNews.search('John')).to be_empty + expect(CommunityNews.search('Doe')).to be_empty end end @@ -97,15 +88,36 @@ end context 'when searching with multiple terms' do - it 'finds records matching all terms across different fields' do - results = CommunityNews.search('John Breaking') + it 'finds records matching all terms across title and content' do + results = CommunityNews.search('Breaking technology') expect(results).to include(community_news_with_person) expect(results).not_to include(community_news_without_person) end - it 'finds records matching content and person name' do - results = CommunityNews.search('John technology') + # Deliberate tradeoff: an author name can no longer be one term of an AND + # query, because honoring the credit preference needs per-person branching + # that a flat SearchCop index can't express. + it 'does not combine an author name with a content term' do + expect(CommunityNews.search('John technology')).to be_empty + end + end + + context 'via search_by_params (the user-facing path)' do + it 'finds records by the credited author name' do + results = CommunityNews.search_by_params(query: 'John') expect(results).to include(community_news_with_person) + expect(results).not_to include(community_news_without_person) + end + + it 'stops matching once the author marks contributions anonymous' do + person.update!(contributions_anonymous: true) + expect(CommunityNews.search_by_params(query: 'John')).to be_empty + end + + it 'stops matching the last name when only the first name is credited' do + person.update!(display_name_preference: 'first_name_only') + expect(CommunityNews.search_by_params(query: 'John')).to include(community_news_with_person) + expect(CommunityNews.search_by_params(query: 'Doe')).to be_empty end end @@ -118,13 +130,13 @@ context 'AND operator behavior for multiple search terms' do it 'requires all search terms to match across different fields' do - results = CommunityNews.search('John Breaking') + results = CommunityNews.search('Breaking important') expect(results).to include(community_news_with_person) expect(results).not_to include(community_news_without_person) end it 'finds no results when terms match different records' do - results = CommunityNews.search('John Report') + results = CommunityNews.search('technology Report') expect(results).to be_empty end diff --git a/spec/models/person_spec.rb b/spec/models/person_spec.rb index de575e46b7..f5d7cb2e23 100644 --- a/spec/models/person_spec.rb +++ b/spec/models/person_spec.rb @@ -232,7 +232,7 @@ def term(cost_cents:, start_date: Date.current, subscription: nil) context "when display_name_preference is first_name_last_initial" do it "returns first name and last initial" do person.display_name_preference = "first_name_last_initial" - expect(person.name).to eq("Jane D") + expect(person.name).to eq("Jane D.") end end @@ -256,6 +256,48 @@ def term(cost_cents:, start_date: Date.current, subscription: nil) expect(person.name).to eq("Jane Doe") end end + + it "is unaffected by contributions_anonymous" do + person.display_name_preference = "full_name" + person.contributions_anonymous = true + expect(person.name).to eq("Jane Doe") + end + end + + describe "display_name_preference validation" do + it "accepts each allowed value" do + Person::DISPLAY_NAME_PREFERENCES.each do |value| + expect(build(:person, display_name_preference: value)).to be_valid + end + end + + it "allows blank" do + expect(build(:person, display_name_preference: nil)).to be_valid + end + + it "rejects anything else" do + person = build(:person, display_name_preference: "anonymous") + expect(person).not_to be_valid + expect(person.errors[:display_name_preference]).to be_present + end + end + + describe "#effective_author_credit_preference" do + let(:person) { build(:person, display_name_preference: "first_name_only") } + + it "is the display name preference by default" do + expect(person.effective_author_credit_preference).to eq("first_name_only") + end + + it "falls back to full_name when unset" do + person.display_name_preference = nil + expect(person.effective_author_credit_preference).to eq("full_name") + end + + it "is anonymous when contributions are anonymous, whatever the format" do + person.contributions_anonymous = true + expect(person.effective_author_credit_preference).to eq("anonymous") + end end describe "#published?" do diff --git a/spec/requests/author_credit_divergences_spec.rb b/spec/requests/author_credit_divergences_spec.rb new file mode 100644 index 0000000000..3c37fcbda6 --- /dev/null +++ b/spec/requests/author_credit_divergences_spec.rb @@ -0,0 +1,207 @@ +require "rails_helper" + +RSpec.describe "AuthorCreditDivergences", type: :request do + let(:admin) { create(:user, :admin) } + let(:regular_user) { create(:user) } + let(:author_user) { create(:user, :with_person) } + let(:person) { author_user.person } + + let!(:story) do + person.update!(display_name_preference: "first_name_only") + record = create(:story, created_by: author_user, author: person, author_credit_preference: nil) + person.update!(display_name_preference: "full_name") + record + end + + describe "GET /author_credit_divergences" do + it "requires an admin" do + sign_in regular_user + get author_credit_divergences_path + expect(response).not_to have_http_status(:ok) + end + + it "renders the page shell for an admin" do + sign_in admin + get author_credit_divergences_path + expect(response).to have_http_status(:ok) + expect(response.body).to include("Author credit divergences") + end + + it "renders just the results inside the turbo frame" do + sign_in admin + get author_credit_divergences_path, headers: { "Turbo-Frame" => "author_credit_divergences_results" } + expect(response).to have_http_status(:ok) + expect(response.body).to include(person.full_name) + expect(response.body).to include(story.title) + end + end + + describe "PATCH /author_credit_divergences/update_person" do + before { sign_in admin } + + it "updates the profile and stamps the person reconciled" do + patch update_person_author_credit_divergences_path, + params: { id: person.id, person: { display_name_preference: "first_name_only", contributions_anonymous: "0" } } + + expect(person.reload.display_name_preference).to eq("first_name_only") + expect(person.author_credit_reconciled_at).to be_present + end + + it "can mark contributions anonymous" do + patch update_person_author_credit_divergences_path, + params: { id: person.id, person: { display_name_preference: "full_name", contributions_anonymous: "1" } } + + expect(person.reload.contributions_anonymous).to be(true) + expect(story.reload.author_credit).to eq("Anonymous") + end + + it "updates the results in place with a Turbo Stream instead of a full-page redirect" do + patch update_person_author_credit_divergences_path, + params: { id: person.id, person: { display_name_preference: "first_name_only", contributions_anonymous: "0" } }, + as: :turbo_stream + + expect(response.media_type).to eq(Mime[:turbo_stream]) + expect(response.body).to include("author_credit_divergences_results") + end + + it "carries the active filters through the redirect" do + patch update_person_author_credit_divergences_path, + params: { id: person.id, type: "Story", + person: { display_name_preference: "full_name", contributions_anonymous: "0" } } + + expect(response).to redirect_to(author_credit_divergences_path(type: "Story")) + end + + it "rejects a non-admin" do + sign_out admin + sign_in regular_user + patch update_person_author_credit_divergences_path, + params: { id: person.id, person: { display_name_preference: "first_name_only" } } + + expect(person.reload.display_name_preference).to eq("full_name") + end + end + + describe "PATCH /author_credit_divergences/assign_author" do + before { sign_in admin } + + let(:target) { create(:person, first_name: "Rosalind", last_name: "Franklin") } + + it "updates the results in place with a Turbo Stream instead of a full-page redirect" do + workshop = create(:workshop, author: nil, full_name: "Marguerite Pre-Person") + + patch assign_author_author_credit_divergences_path, + params: { record_type: "Workshop", record_id: workshop.id, author_id: target.id }, + as: :turbo_stream + + expect(response.media_type).to eq(Mime[:turbo_stream]) + expect(response.body).to include("author_credit_divergences_results") + end + + it "credits a legacy free-text record to a real person" do + workshop = create(:workshop, author: nil, full_name: "Marguerite Pre-Person") + + patch assign_author_author_credit_divergences_path, + params: { record_type: "Workshop", record_id: workshop.id, author_id: target.id } + + expect(workshop.reload.author).to eq(target) + expect(workshop.author_credit).to eq("Rosalind Franklin") + end + + it "credits a creator-fallback record so it links to the profile" do + story = create(:story, created_by: author_user, author: nil) + expect(story.author_credit_person).to be_nil + + patch assign_author_author_credit_divergences_path, + params: { record_type: "Story", record_id: story.id, author_id: person.id } + + expect(story.reload.author_credit_person).to eq(person) + end + + it "requires a person" do + story = create(:story, created_by: author_user, author: nil) + + patch assign_author_author_credit_divergences_path, + params: { record_type: "Story", record_id: story.id, author_id: "" } + + expect(flash[:alert]).to eq("Choose a person to credit.") + expect(story.reload.author).to be_nil + end + + it "refuses a type outside the allowlist" do + patch assign_author_author_credit_divergences_path, + params: { record_type: "User", record_id: admin.id, author_id: target.id } + + expect(flash[:alert]).to eq("Unknown record type.") + end + + it "rejects a non-admin" do + sign_out admin + sign_in regular_user + story = create(:story, created_by: author_user, author: nil) + + patch assign_author_author_credit_divergences_path, + params: { record_type: "Story", record_id: story.id, author_id: target.id } + + expect(story.reload.author).to be_nil + end + end + + describe "PATCH /author_credit_divergences/update_item" do + before { sign_in admin } + + it "rewrites a single record's stored snapshot" do + patch update_item_author_credit_divergences_path, + params: { record_type: "Story", record_id: story.id, author_credit_preference: "full_name" } + + expect(story.reload.author_credit_preference).to eq("full_name") + end + + it "updates the results in place with a Turbo Stream instead of a full-page redirect" do + patch update_item_author_credit_divergences_path, + params: { record_type: "Story", record_id: story.id, author_credit_preference: "full_name" }, + as: :turbo_stream + + expect(response.media_type).to eq(Mime[:turbo_stream]) + expect(response.body).to include("author_credit_divergences_results") + expect(response.body).to include("flash_now") + end + + it "clears the stored snapshot when set to blank, so the item just follows the profile" do + story.update_column(:author_credit_preference, "last_name_only") + + patch update_item_author_credit_divergences_path, + params: { record_type: "Story", record_id: story.id, author_credit_preference: "" } + + expect(story.reload.author_credit_preference).to be_nil + end + + it "refuses to clear the snapshot of an item submitted anonymously" do + story.update_column(:author_credit_preference, "anonymous") + + patch update_item_author_credit_divergences_path, + params: { record_type: "Story", record_id: story.id, author_credit_preference: "" } + + expect(story.reload.author_credit_preference).to eq("anonymous") + expect(flash[:alert]).to be_present + end + + it "makes one item anonymous without touching the person's others" do + other = create(:story, created_by: author_user, author: person) + + patch update_item_author_credit_divergences_path, + params: { record_type: "Story", record_id: story.id, author_credit_preference: "anonymous" } + + expect(story.reload.author_credit).to eq("Anonymous") + expect(other.reload.author_credit).to eq(person.full_name) + end + + it "refuses a type outside the allowlist instead of constantizing it" do + patch update_item_author_credit_divergences_path, + params: { record_type: "User", record_id: admin.id, author_credit_preference: "anonymous" } + + expect(response).to redirect_to(author_credit_divergences_path) + expect(flash[:alert]).to eq("Unknown record type.") + end + end +end diff --git a/spec/requests/people_stories_section_spec.rb b/spec/requests/people_stories_section_spec.rb index 89b45b6503..3bd9eef8f6 100644 --- a/spec/requests/people_stories_section_spec.rb +++ b/spec/requests/people_stories_section_spec.rb @@ -38,4 +38,24 @@ def get_stories_section expect(response.body).not_to include("Only Created Story") end + + it "flags an anonymously-credited authored story" do + create(:story, :published, title: "Hush Story", + author: person, author_credit_preference: "anonymous") + + get_stories_section + + expect(response.body).to include("Hush Story") + expect(response.body).to include("Credited as Anonymous") + end + + it "never flags a spotlighted story, even when the person is anonymous" do + person.update!(contributions_anonymous: true) + create(:story, :published, title: "Spotlight Story", spotlighted_facilitator: person) + + get_stories_section + + expect(response.body).to include("Spotlight Story") + expect(response.body).not_to include("Credited as Anonymous") + end end diff --git a/spec/requests/people_workshops_section_spec.rb b/spec/requests/people_workshops_section_spec.rb index 1f33d22fdd..226ce4a3e5 100644 --- a/spec/requests/people_workshops_section_spec.rb +++ b/spec/requests/people_workshops_section_spec.rb @@ -29,4 +29,35 @@ def get_workshops_section expect(response.body).not_to include("Only Created Workshop") end + + it "flags an anonymously-credited authored workshop for an admin" do + create(:workshop, :published, title: "Hush Workshop", + author: person, author_credit_preference: "anonymous") + + get_workshops_section + + expect(response.body).to include("Hush Workshop") + expect(response.body).to include("Credited as Anonymous") + end + + it "does not flag a normally-credited workshop" do + create(:workshop, :published, title: "Loud Workshop", + author: person, author_credit_preference: "full_name") + + get_workshops_section + + expect(response.body).to include("Loud Workshop") + expect(response.body).not_to include("Credited as Anonymous") + end + + it "still shows the anonymous workshop, flagged, to the owner viewing their own profile" do + sign_in owner_user + create(:workshop, :published, title: "Hush Workshop", + author: person, author_credit_preference: "anonymous") + + get_workshops_section + + expect(response.body).to include("Hush Workshop") + expect(response.body).to include("Credited as Anonymous") + end end diff --git a/spec/routing/author_credit_divergences_routing_spec.rb b/spec/routing/author_credit_divergences_routing_spec.rb new file mode 100644 index 0000000000..a08bcedb8d --- /dev/null +++ b/spec/routing/author_credit_divergences_routing_spec.rb @@ -0,0 +1,24 @@ +require "rails_helper" + +RSpec.describe AuthorCreditDivergencesController, type: :routing do + describe "routing" do + it "routes to #index" do + expect(get: "/author_credit_divergences").to route_to("author_credit_divergences#index") + end + + it "routes to #update_person" do + expect(patch: "/author_credit_divergences/update_person") + .to route_to("author_credit_divergences#update_person") + end + + it "routes to #assign_author" do + expect(patch: "/author_credit_divergences/assign_author") + .to route_to("author_credit_divergences#assign_author") + end + + it "routes to #update_item" do + expect(patch: "/author_credit_divergences/update_item") + .to route_to("author_credit_divergences#update_item") + end + end +end diff --git a/spec/services/author_credit_divergence_query_spec.rb b/spec/services/author_credit_divergence_query_spec.rb new file mode 100644 index 0000000000..e0ed363411 --- /dev/null +++ b/spec/services/author_credit_divergence_query_spec.rb @@ -0,0 +1,214 @@ +require "rails_helper" + +RSpec.describe AuthorCreditDivergenceQuery do + let(:author_user) { create(:user, :with_person) } + let(:person) { author_user.person } + + # Snapshot "first_name_only", then move the profile so the two disagree. + def diverged_story + person.update!(display_name_preference: "first_name_only") + story = create(:story, created_by: author_user, author: person, author_credit_preference: nil) + person.update!(display_name_preference: "full_name") + story + end + + describe "#call" do + it "returns nothing when every snapshot matches its profile" do + person.update!(display_name_preference: "full_name") + create(:story, created_by: author_user, author: person, author_credit_preference: nil) + + expect(described_class.new.call.preference).to be_empty + end + + it "groups diverging records under their credited person" do + story = diverged_story + + groups = described_class.new.call.preference + + expect(groups.size).to eq(1) + expect(groups.first.person).to eq(person) + expect(groups.first.records).to include(story) + end + + it "finds records credited through the creating user's person, not just author_id" do + person.update!(display_name_preference: "first_name_only") + idea = create(:story_idea, created_by: author_user, author_credit_preference: nil) + person.update!(display_name_preference: "full_name") + + expect(described_class.new.call.preference.first.records).to include(idea) + end + + it "ignores records with no stored snapshot" do + create(:story, created_by: author_user, author: person, author_credit_preference: nil) + Story.update_all(author_credit_preference: nil) + + expect(described_class.new.call.preference).to be_empty + end + + it "orders groups by first name then last name" do + person.update!(first_name: "Zoe", last_name: "Adams") + diverged_story + early_user = create(:user, :with_person) + early_user.person.update!(first_name: "Ada", last_name: "Zimmerman") + early_user.person.update!(display_name_preference: "first_name_only") + create(:story, created_by: early_user, author: early_user.person, author_credit_preference: nil) + early_user.person.update!(display_name_preference: "full_name") + + names = described_class.new.call.preference.map { |group| group.person.first_name } + + expect(names).to eq(%w[Ada Zoe]) + end + + it "suggests the most restrictive preference across the person's records" do + diverged_story + create(:story, created_by: author_user, author: person, author_credit_preference: "anonymous") + + expect(described_class.new.call.preference.first.suggested_preference).to eq("anonymous") + end + end + + describe "filters" do + before { diverged_story } + + it "filters by person_id" do + expect(described_class.new(person_id: person.id).call.preference.size).to eq(1) + expect(described_class.new(person_id: person.id + 9999).call.preference).to be_empty + end + + it "filters by type" do + expect(described_class.new(type: "Story").call.preference.size).to eq(1) + expect(described_class.new(type: "Resource").call.preference).to be_empty + end + + it "filters by stored preference" do + expect(described_class.new(preference: "first_name_only").call.preference.size).to eq(1) + expect(described_class.new(preference: "anonymous").call.preference).to be_empty + end + + it "hides reconciled people unless asked for" do + person.update!(author_credit_reconciled_at: Time.current) + + expect(described_class.new.call.preference).to be_empty + expect(described_class.new(include_reconciled: "1").call.preference.size).to eq(1) + end + end + + describe "the legacy section" do + def legacy_records_for(column) + group = described_class.new.call.legacy.find { |g| g.column == column } + group.entries.map(&:record) + end + + it "lists records credited by a free-text name with no person" do + workshop = create(:workshop, author: nil, full_name: "Marguerite Pre-Person") + + expect(legacy_records_for("workshops.full_name")).to include(workshop) + expect(workshop.author_credit).to eq("Marguerite Pre-Person") + end + + it "drops a record once a real author is credited" do + workshop = create(:workshop, author: nil, full_name: "Marguerite Pre-Person") + workshop.update!(author: person) + + expect(legacy_records_for("workshops.full_name")).not_to include(workshop) + end + + it "keeps a group per legacy column even when it is empty, so each can be retired" do + columns = described_class.new.call.legacy.map(&:column) + + expect(columns).to contain_exactly("workshops.full_name", "resources.legacy_author_name") + expect(described_class.new.call).to be_legacy_empty + end + + it "excludes models that have no legacy column" do + create(:story, created_by: author_user, author: nil) + expect(described_class.new.call.legacy.flat_map(&:entries)).to be_empty + end + + it "suggests a person whose name matches the legacy text" do + match = create(:person, first_name: "Marguerite", last_name: "Duras") + workshop = create(:workshop, author: nil, full_name: "Marguerite Duras") + + entry = described_class.new.call.legacy + .find { |g| g.column == "workshops.full_name" } + .entries.find { |e| e.record == workshop } + + expect(entry.suggested_author).to eq(match) + end + + it "suggests nothing when no person matches" do + workshop = create(:workshop, author: nil, full_name: "Nobody Byanyname") + + entry = described_class.new.call.legacy + .find { |g| g.column == "workshops.full_name" } + .entries.find { |e| e.record == workshop } + + expect(entry.suggested_author).to be_nil + end + end + + describe "the creator section" do + it "groups records whose author_id is blank under the creating person" do + story = create(:story, created_by: author_user, author: nil) + + groups = described_class.new.call.creator + + expect(groups.map(&:person)).to include(person) + expect(groups.find { |g| g.person == person }.records).to include(story) + end + + it "excludes records that already name an author" do + story = create(:story, created_by: author_user, author: person) + expect(described_class.new.call.creator.flat_map(&:records)).not_to include(story) + end + + it "excludes idea models, whose only credit path is the creator" do + idea = create(:story_idea, created_by: author_user) + expect(described_class.new.call.creator.flat_map(&:records)).not_to include(idea) + end + + it "excludes records covered by the legacy section instead" do + workshop = create(:workshop, created_by: author_user, author: nil, full_name: "Legacy Name") + expect(described_class.new.call.creator.flat_map(&:records)).not_to include(workshop) + end + end + + describe "the unattributed section" do + let(:personless_user) { create(:user, person: nil) } + + it "lists records with no author, no legacy name, and no creator person" do + story = create(:story, created_by: personless_user, author: nil) + + expect(described_class.new.call.unattributed).to include(story) + expect(story.author_credit).to eq(story.missing_author_label) + end + + it "drops a record once an author is credited" do + story = create(:story, created_by: personless_user, author: nil) + story.update!(author: person) + + expect(described_class.new.call.unattributed).not_to include(story) + end + end + + describe "#empty?" do + it "is true only when every section is clear" do + expect(described_class.new.call).to be_empty + + create(:story, created_by: create(:user, person: nil), author: nil) + expect(described_class.new.call).not_to be_empty + end + end + + describe ".model_for" do + it "resolves an allowlisted name" do + expect(described_class.model_for("Story")).to eq(Story) + end + + it "refuses anything else rather than constantizing it" do + expect(described_class.model_for("User")).to be_nil + expect(described_class.model_for("Kernel")).to be_nil + expect(described_class.model_for("NotAClass")).to be_nil + end + end +end diff --git a/spec/support/shared_examples/author_creditable.rb b/spec/support/shared_examples/author_creditable.rb index 4d8141bc03..7f4f482bdb 100644 --- a/spec/support/shared_examples/author_creditable.rb +++ b/spec/support/shared_examples/author_creditable.rb @@ -4,68 +4,64 @@ let(:person) { author_user.person } let(:record) { create(factory, created_by: author_user) } - context "when author_credit_preference is full_name" do - it "returns the person's full name" do - record.update!(author_credit_preference: "full_name") + context "when the profile formats the name" do + it "returns the full name for full_name" do + person.update!(display_name_preference: "full_name") expect(record.author_credit).to eq(person.full_name) end - end - context "when author_credit_preference is first_name_last_initial" do - it "returns first name and last initial with period" do - record.update!(author_credit_preference: "first_name_last_initial") + it "returns first name and last initial with period for first_name_last_initial" do + person.update!(display_name_preference: "first_name_last_initial") expect(record.author_credit).to eq("#{person.first_name} #{person.last_name.first}.") end - end - context "when author_credit_preference is first_name_only" do - it "returns the person's first name" do - record.update!(author_credit_preference: "first_name_only") + it "returns the first name for first_name_only" do + person.update!(display_name_preference: "first_name_only") expect(record.author_credit).to eq(person.first_name) end - end - context "when author_credit_preference is last_name_only" do - it "returns the person's last name" do - record.update!(author_credit_preference: "last_name_only") + it "returns the last name for last_name_only" do + person.update!(display_name_preference: "last_name_only") expect(record.author_credit).to eq(person.last_name) end + + it "falls back to the full name when the profile has no preference" do + person.update!(display_name_preference: nil) + expect(record.author_credit).to eq(person.full_name) + end end - context "when author_credit_preference is anonymous" do - it "returns Anonymous" do - record.update!(author_credit_preference: "anonymous") + context "when the profile marks contributions anonymous" do + before { person.update!(contributions_anonymous: true) } + + it "returns Anonymous regardless of the name format" do + person.update!(display_name_preference: "full_name") expect(record.author_credit).to eq("Anonymous") end + + it "does not link the credit to a profile" do + expect(record.author_credit_person).to be_nil + end end - if described_class.require_author_credit_preference? - context "when the preference is unset (required)" do - it "is invalid without a credit preference" do - record.author_credit_preference = nil - expect(record).not_to be_valid - expect(record.errors[:author_credit_preference]).to be_present - end + context "when the record itself was submitted anonymously" do + before { record.update!(author_credit_preference: "anonymous") } - it "does not default new records" do - expect(described_class.new.author_credit_preference).to be_blank - end + it "stays anonymous even though the profile says otherwise" do + person.update!(display_name_preference: "full_name", contributions_anonymous: false) + expect(record.author_credit).to eq("Anonymous") end - else - context "when the preference is unset (defaulted)" do - it "defaults new records to full_name" do - expect(described_class.new.author_credit_preference).to eq("full_name") - end - - it "treats a blank preference as full_name at read time" do - record.author_credit_preference = nil - expect(record.author_credit).to eq(person.full_name) - end - - it "normalizes a blank preference to full_name on save (no backfill)" do - record.update!(author_credit_preference: nil) - expect(record.reload.author_credit_preference).to eq("full_name") - end + + it "does not link the credit to a profile" do + expect(record.author_credit_person).to be_nil + end + end + + context "when the stored preference is a name format" do + it "is ignored in favor of the profile" do + record.update!(author_credit_preference: "first_name_only") + person.update!(display_name_preference: "full_name") + expect(record.author_credit).to eq(person.full_name) end end @@ -87,20 +83,96 @@ end end + describe "the consent snapshot" do + let(:author_user) { create(:user, :with_person) } + let(:person) { author_user.person } + + it "records the profile's preference on create" do + person.update!(display_name_preference: "first_name_only") + record = create(factory, created_by: author_user, author_credit_preference: nil) + expect(record.reload.author_credit_preference).to eq("first_name_only") + end + + it "records anonymous when the profile suppresses credits" do + person.update!(contributions_anonymous: true) + record = create(factory, created_by: author_user, author_credit_preference: nil) + expect(record.reload.author_credit_preference).to eq("anonymous") + end + + it "does not overwrite a preference carried forward from an idea" do + record = create(factory, created_by: author_user, author_credit_preference: "last_name_only") + expect(record.reload.author_credit_preference).to eq("last_name_only") + end + + it "normalizes a blank preference to nil so the record just follows the profile" do + record = create(factory, created_by: author_user, author_credit_preference: "full_name") + record.update!(author_credit_preference: "") + expect(record.reload.author_credit_preference).to be_nil + end + + it "is left alone when the profile later changes, and reports the divergence" do + person.update!(display_name_preference: "first_name_only") + record = create(factory, created_by: author_user, author_credit_preference: nil) + + person.update!(display_name_preference: "full_name") + + expect(record.reload.author_credit_preference).to eq("first_name_only") + expect(record.author_credit_diverged?).to be(true) + expect(record.author_credit).to eq(person.full_name) + end + + it "reports no divergence when the snapshot matches the profile" do + person.update!(display_name_preference: "full_name") + record = create(factory, created_by: author_user, author_credit_preference: nil) + expect(record.author_credit_diverged?).to be(false) + end + end + describe ".by_credited_person_name" do let(:author_user) { create(:user, :with_person) } + let(:person) { author_user.person } let!(:record) { create(factory, created_by: author_user) } + before { person.update!(first_name: "Zephyrine", last_name: "Quixotel") } + it "matches the creating user's person by name" do - author_user.person.update!(first_name: "Zephyrine", last_name: "Quixotel") expect(described_class.by_credited_person_name("Zephyrine")).to include(record) expect(described_class.by_credited_person_name("Quixotel")).to include(record) end it "does not match an unrelated name" do - author_user.person.update!(first_name: "Zephyrine", last_name: "Quixotel") expect(described_class.by_credited_person_name("Nonexistententry")).not_to include(record) end + + it "matches nothing when the profile marks contributions anonymous" do + person.update!(contributions_anonymous: true) + expect(described_class.by_credited_person_name("Zephyrine")).not_to include(record) + expect(described_class.by_credited_person_name("Quixotel")).not_to include(record) + end + + it "matches nothing when the record was submitted anonymously" do + record.update!(author_credit_preference: "anonymous") + expect(described_class.by_credited_person_name("Zephyrine")).not_to include(record) + end + + it "does not match on last name when only the first name is shown" do + person.update!(display_name_preference: "first_name_only") + expect(described_class.by_credited_person_name("Zephyrine")).to include(record) + expect(described_class.by_credited_person_name("Quixotel")).not_to include(record) + end + + it "does not match on first name when only the last name is shown" do + person.update!(display_name_preference: "last_name_only") + expect(described_class.by_credited_person_name("Quixotel")).to include(record) + expect(described_class.by_credited_person_name("Zephyrine")).not_to include(record) + end + + it "matches only the initial when the last name is reduced to one" do + person.update!(display_name_preference: "first_name_last_initial") + expect(described_class.by_credited_person_name("Zephyrine")).to include(record) + expect(described_class.by_credited_person_name("ZephyrineQ")).to include(record) + expect(described_class.by_credited_person_name("Quixotel")).not_to include(record) + end end describe ".order_by_author" do diff --git a/spec/views/community_news/edit.html.erb_spec.rb b/spec/views/community_news/edit.html.erb_spec.rb index f8557da5af..6c65984a47 100644 --- a/spec/views/community_news/edit.html.erb_spec.rb +++ b/spec/views/community_news/edit.html.erb_spec.rb @@ -44,7 +44,6 @@ assert_select "select[name=?]", "community_news[author_id]" - assert_select "select[name=?]", "community_news[author_credit_preference]" assert_select "textarea[name=?]", "community_news[reference_url]" diff --git a/spec/views/community_news/new.html.erb_spec.rb b/spec/views/community_news/new.html.erb_spec.rb index 9b5e962916..a110886a37 100644 --- a/spec/views/community_news/new.html.erb_spec.rb +++ b/spec/views/community_news/new.html.erb_spec.rb @@ -40,7 +40,6 @@ assert_select "select[name=?]", "community_news[author_id]" - assert_select "select[name=?]", "community_news[author_credit_preference]" assert_select "textarea[name=?]", "community_news[reference_url]" diff --git a/spec/views/page_bg_class_alignment_spec.rb b/spec/views/page_bg_class_alignment_spec.rb index aa9a95bf20..a0686c38e0 100644 --- a/spec/views/page_bg_class_alignment_spec.rb +++ b/spec/views/page_bg_class_alignment_spec.rb @@ -124,6 +124,7 @@ "app/views/event_registrations/index.html.erb" => "admin-only bg-blue-100", "app/views/forms/index.html.erb" => "admin-only bg-blue-100", "app/views/forms/show.html.erb" => "admin-only bg-blue-100", + "app/views/author_credit_divergences/index.html.erb" => "admin-only bg-blue-100", "app/views/notifications/index.html.erb" => "admin-only bg-blue-100", "app/views/notifications/new.html.erb" => "admin-only bg-blue-100", "app/views/organization_statuses/index.html.erb" => "admin-only bg-blue-100", diff --git a/spec/views/stories/edit.html.erb_spec.rb b/spec/views/stories/edit.html.erb_spec.rb index 3169e3fca1..87095efdcb 100644 --- a/spec/views/stories/edit.html.erb_spec.rb +++ b/spec/views/stories/edit.html.erb_spec.rb @@ -30,7 +30,6 @@ assert_select "textarea[name=?]", "story[youtube_url]" - assert_select "select[name=?]", "story[author_credit_preference]" assert_select "select[name=?]", "story[author_id]" end diff --git a/spec/views/stories/new.html.erb_spec.rb b/spec/views/stories/new.html.erb_spec.rb index bb3440a645..4e752fa903 100644 --- a/spec/views/stories/new.html.erb_spec.rb +++ b/spec/views/stories/new.html.erb_spec.rb @@ -128,11 +128,10 @@ expect(rendered).to include(story_idea.author_credit) end - it "displays story idea author credit preference" do + it "does not offer a per-item credit preference select" do render - expect(rendered).to include("author credit preference") - expect(rendered).to include(story_idea.author_credit_preference) + assert_select "select[name=?]", "story[author_credit_preference]", count: 0 end context "with sectors and categories from story idea" do diff --git a/spec/views/story_ideas/edit.html.erb_spec.rb b/spec/views/story_ideas/edit.html.erb_spec.rb index d87e97c297..4cf460e08f 100644 --- a/spec/views/story_ideas/edit.html.erb_spec.rb +++ b/spec/views/story_ideas/edit.html.erb_spec.rb @@ -28,7 +28,6 @@ assert_select "select[name=?]", "story_idea[workshop_id]" assert_select "input[name=?][type=?]", "story_idea[rhino_body]", "hidden" assert_select "textarea[name=?]", "story_idea[youtube_url]" - assert_select "select[name=?]", "story_idea[author_credit_preference]" end end