diff --git a/AGENTS.md b/AGENTS.md index cf151739df..e5806ee52c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,21 +48,21 @@ This codebase (Rails 8.1) | Directory | Purpose | Count | |---|---|---| -| `app/models/` | ActiveRecord models | ~80 files | -| `app/services/` | Service objects and POROs (e.g. `MoneyFormatter` for currency display, `StoryImporter` for WordPress CSV import) | ~57 files | -| `app/jobs/` | SolidQueue background jobs | 5 files | -| `app/models/concerns/` | Shared model modules | 16 concerns | +| `app/models/` | ActiveRecord models | ~106 files | +| `app/services/` | Service objects and POROs (e.g. `MoneyFormatter` for currency display, `StoryImporter` for WordPress CSV import) | ~63 files | +| `app/jobs/` | SolidQueue background jobs | 6 files | +| `app/models/concerns/` | Shared model modules | 17 concerns | ### Presentation | Directory | Purpose | Count | |---|---|---| -| `app/controllers/` | Rails controllers (admin/, events/, home/) | ~78 files | -| `app/views/` | ERB templates | ~745 files | -| `app/decorators/` | Draper decorators for view logic | ~40 files | -| `app/policies/` | ActionPolicy authorization rules | ~55 files | +| `app/controllers/` | Rails controllers (admin/, events/, home/) | ~90 files | +| `app/views/` | ERB templates | ~799 files | +| `app/decorators/` | Draper decorators for view logic | ~49 files | +| `app/policies/` | ActionPolicy authorization rules | ~63 files | | `app/presenters/` | Presentation objects | 6 files | -| `app/helpers/` | View helpers | ~31 files | +| `app/helpers/` | View helpers | ~36 files | | `app/mailers/` | ActionMailer classes | 5 files | | `app/inputs/` | Custom SimpleForm inputs | 1 file | @@ -134,7 +134,7 @@ This codebase (Rails 8.1) |---|---| | `AgeGroupTaggable` | Splits AgeRange category taggings into primary/additional via `categorizable_items.is_primary` (Person, Organization) | | `AhoyTrackable` | Event tracking integration | -| `AuthorCreditable` | Author attribution | +| `AuthorCreditable` | Author attribution. Credits are formatted by the credited **person's profile** (`Person#display_name_preference`), not by the record. The record's `author_credit_preference` is the consent snapshot taken at create time and is human-editable only on the author credit divergences page — it no longer drives display, except `"anonymous"`, which is always honored while set (either the profile or the record can make a credit anonymous, and neither strips the other's flag — only an admin clearing the record's snapshot on that page does) | | `Featureable` | `featured`, `publicly_featured` scopes | | `Mentioner` | ActionText @mention extraction and grouping | | `NameFilterable` | Name-based filtering | @@ -215,6 +215,7 @@ action, or `authorize! :workshop, to: :summary?`). - `WorkshopSearchService` — Complex filtering, sorting, pagination with ActionPolicy - `WorkshopFromIdeaService` — Converts WorkshopIdea to Workshop with asset migration - `WorkshopVariationFromIdeaService` — Variation creation from ideas +- `AuthorCreditDivergenceQuery` — Backs the admin author credit divergences page. Returns four sections: `preference` (stored snapshot no longer matches the profile, grouped by person), `legacy` (credited by a free-text column — `workshops.full_name`, `resources.legacy_author_name`), `creator` (no `author_id`, so the credit falls back to the creating user's person — idea models excluded, since that's their only credit path), and `unattributed` (nothing to credit, renders `missing_author_label`). The last three all resolve by assigning an `author_id`, the only credit path that follows a profile and links to it. `MODEL_NAMES` doubles as the allowlist for the `type` param (never constantize a raw param) - `TaggingSearchService` — Search and filter tagging data - `PersonFromUserService` — Create Person from User account - `PersonCommentAggregator` — Unifies every comment connected to a person (their profile, event registrations, scholarships, CE registrations, topic subscriptions, and user account) into one newest-first `Comment` relation for the aggregated `/people/:id/all_comments` page diff --git a/app/controllers/author_credit_divergences_controller.rb b/app/controllers/author_credit_divergences_controller.rb new file mode 100644 index 0000000000..7f69f06d54 --- /dev/null +++ b/app/controllers/author_credit_divergences_controller.rb @@ -0,0 +1,94 @@ +class AuthorCreditDivergencesController < ApplicationController + before_action :authorize_page + + FILTER_KEYS = %i[person_id type preference include_reconciled].freeze + + def index + return unless turbo_frame_request? + + @result = AuthorCreditDivergenceQuery.new(**filters.symbolize_keys).call + render :author_credit_divergences_results + end + + # Stamped reconciled so a deliberate divergence stops reappearing on the worklist. + def update_person + person = Person.find(params[:id]) + person.assign_attributes(person_params) + person.author_credit_reconciled_at = Time.current + person.updated_by = current_user + + if person.save + render_divergence_change("Updated credit preferences for #{person.full_name}.", :notice) + else + render_divergence_change(person.errors.full_messages.to_sentence, :alert) + end + end + + # "anonymous" here suppresses this item's credit; any other value only re-records + # history (see AuthorCreditable). + def update_item + model = AuthorCreditDivergenceQuery.model_for(params[:record_type]) + return render_divergence_change("Unknown record type.", :alert) unless model + + record = model.find(params[:record_id]) + + # Clearing "anonymous" hands the item back to the profile, which may credit it — + # that's what this page exists to do. + record.author_credit_preference = params[:author_credit_preference] + record.updated_by = current_user if record.respond_to?(:updated_by=) + + if record.save + render_divergence_change("Updated credit for #{model.name.underscore.humanize.downcase} ##{record.id}.", :notice) + else + render_divergence_change(record.errors.full_messages.to_sentence, :alert) + end + end + + # The fix for every section below the first: an author_id is the only credit path + # that follows a profile, links to it, and lists the record there. + def assign_author + model = AuthorCreditDivergenceQuery.model_for(params[:record_type]) + return render_divergence_change("Unknown record type.", :alert) unless model + + record = model.find(params[:record_id]) + person = Person.find_by(id: params[:author_id]) + return render_divergence_change("Choose a person to credit.", :alert) unless person + + record.author_id = person.id + record.updated_by = current_user if record.respond_to?(:updated_by=) + + if record.save + render_divergence_change("Credited #{model.name.underscore.humanize.downcase} ##{record.id} to #{person.full_name}.", :notice) + else + render_divergence_change(record.errors.full_messages.to_sentence, :alert) + end + end + + private + + # Re-render the frame over Turbo so a save doesn't flip the whole page. + def render_divergence_change(message, type) + respond_to do |format| + format.turbo_stream do + flash.now[type] = message + @result = AuthorCreditDivergenceQuery.new(**filters.symbolize_keys).call + render :divergence_change + end + format.html { redirect_to author_credit_divergences_path(filters), flash: { type => message } } + end + end + + def authorize_page + authorize! :author_credit_divergence, to: :"#{action_name}?", with: AuthorCreditDivergencePolicy + end + + def person_params + params.require(:person).permit(:display_name_preference, :anonymous_contributions) + end + + # The write actions name their params `id` / `record_type` / `record_id` so a + # record identifier can never be mistaken for a filter. + def filters + params.permit(*FILTER_KEYS).to_h.compact_blank + end +end 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 12c150e2d9..ceede612dd 100644 --- a/app/controllers/people_controller.rb +++ b/app/controllers/people_controller.rb @@ -52,24 +52,25 @@ def show when "workshops" # Credit the person for workshops they authored — not ones their user # merely created (created_by is a pure audit trail). - @workshops = @person.workshops_as_author.order(created_at: :desc).paginate(page: params[:page], per_page: per_page) + @workshops = visible_authored_content(@person.workshops_as_author).order(created_at: :desc).paginate(page: params[:page], per_page: per_page) render partial: "people/sections/workshops", locals: { person: @person, workshops: @workshops } when "workshop_variations" # Credit the person for variations they authored — not ones their user # merely entered (created_by is a pure audit trail). - @workshop_variations = @person.workshop_variations_as_author.order(created_at: :desc).paginate(page: params[:page], per_page: per_page) + @workshop_variations = visible_authored_content(@person.workshop_variations_as_author).order(created_at: :desc).paginate(page: params[:page], per_page: per_page) render partial: "people/sections/workshop_variations", locals: { person: @person, workshop_variations: @workshop_variations } when "stories" # Credit the person for stories they authored or were spotlighted in — # not ones their user merely entered (created_by is a pure audit trail). - story_ids = @person.stories_as_author.pluck(:id) + + # The spotlight is a separate credit from authorship, so anonymity doesn't apply. + story_ids = visible_authored_content(@person.stories_as_author).pluck(:id) + @person.stories_as_spotlighted_facilitator.pluck(:id) @stories = Story.where(id: story_ids).order(created_at: :desc).paginate(page: params[:page], per_page: per_page) render partial: "people/sections/stories", locals: { person: @person, stories: @stories } when "resources" # Credit the person for resources they authored — not ones their user # merely entered (created_by is a pure audit trail). - @resources = @person.resources_as_author.order(created_at: :desc).paginate(page: params[:page], per_page: per_page) + @resources = visible_authored_content(@person.resources_as_author).order(created_at: :desc).paginate(page: params[:page], per_page: per_page) render partial: "people/sections/resources", locals: { person: @person, resources: @resources } when "events" @event_registrations = @person.event_registrations.active.includes(:event).order("events.start_date DESC").references(:events).paginate(page: params[:page], per_page: per_page) @@ -297,6 +298,13 @@ def check_duplicates private + # Showing anonymous content to anyone but the person and admins would tie an + # "Anonymous" credit back to a name. + def visible_authored_content(scope) + return scope if allowed_to?(:manage?, Person) || current_user&.person_id == @person.id + return scope.none if @person.anonymous_contributions? + scope.credited_openly + end def set_person @person = Person.find(params[:id]) @@ -527,7 +535,6 @@ def person_params :display_name_preference, :anonymous_contributions, :pronouns, - :profile_show_name_preference, :profile_is_searchable, :profile_show_pronouns, :profile_show_credentials, 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 06431d825e..286e227ec0 100644 --- a/app/controllers/stories_controller.rb +++ b/app/controllers/stories_controller.rb @@ -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 ], diff --git a/app/controllers/story_ideas_controller.rb b/app/controllers/story_ideas_controller.rb index 3b0a10bb64..17a3138457 100644 --- a/app/controllers/story_ideas_controller.rb +++ b/app/controllers/story_ideas_controller.rb @@ -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: [], 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 a305a4d081..c88daf22e3 100644 --- a/app/helpers/admin_cards_helper.rb +++ b/app/helpers/admin_cards_helper.rb @@ -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), diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index fd47e53399..2e9e419872 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -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 or inline style). diff --git a/app/helpers/author_credit_divergences_helper.rb b/app/helpers/author_credit_divergences_helper.rb new file mode 100644 index 0000000000..5acaa4e94d --- /dev/null +++ b/app/helpers/author_credit_divergences_helper.rb @@ -0,0 +1,33 @@ +module AuthorCreditDivergencesHelper + # The models label their content differently (title vs name). + def divergence_record_title(record) + record.try(:title).presence || record.try(:name).presence || "##{record.id}" + end + + # Sections 3 and 4 have no name to guess from, so any suggestion is passed in. + def assignable_rows(records, suggested_author: nil) + records.map do |record| + AuthorCreditDivergenceQuery::AssignableRow.new(record: record, suggested_author: suggested_author) + end + end + + # Otherwise the congratulations would be reporting on the filter. + def divergence_filters_applied? + AuthorCreditDivergencesController::FILTER_KEYS.any? { |key| params[key].present? } + end + + # New tab rather than an eyebrow: 8 destinations, none carrying a return_to today. + def divergence_record_link(record) + link_to divergence_record_title(record), polymorphic_path(record), + target: "_blank", rel: "noopener", + title: "Opens in a new tab", + class: "text-blue-700 hover:underline" + end + + # `anonymous` isn't a name format — it's the separate checkbox — so fall back. + def suggested_display_name_preference(group) + suggested = group.suggested_preference + return suggested if Person::DISPLAY_NAME_PREFERENCES.include?(suggested) + group.person.display_name_preference.presence || "full_name" + end +end diff --git a/app/models/community_news.rb b/app/models/community_news.rb index b75a120b8a..1932c85261 100644 --- a/app/models/community_news.rb +++ b/app/models/community_news.rb @@ -30,11 +30,6 @@ class CommunityNews < ApplicationRecord validates :title, presence: true, length: { maximum: 150 } validates :rhino_body, presence: true - # Unattributed community news is credited to the organization's staff. - def missing_author_label - "AWBW Staff" - end - # Nested attributes accepts_nested_attributes_for :primary_asset, allow_destroy: true, reject_if: :all_blank accepts_nested_attributes_for :gallery_assets, allow_destroy: true, reject_if: :all_blank @@ -45,9 +40,11 @@ def missing_author_label # SearchCop include SearchCop search_scope :search do - attributes :title, :published, person_first: "people.first_name", person_last: "people.last_name" + attributes :title, :published - scope { join_rich_texts.left_joins(:author) } + # Author names are deliberately not indexed here — see Story. Person-name + # search goes through `by_credited_person_name`, which honors the preference. + scope { join_rich_texts } attributes action_text_body: "action_text_rich_texts.plain_text_body" end diff --git a/app/models/concerns/author_creditable.rb b/app/models/concerns/author_creditable.rb index 1585dddba1..b2fe146d50 100644 --- a/app/models/concerns/author_creditable.rb +++ b/app/models/concerns/author_creditable.rb @@ -1,15 +1,11 @@ module AuthorCreditable extend ActiveSupport::Concern + # Credits render from the credited person's profile. This column is the consent + # snapshot and no longer drives display, except "anonymous", which is always honored. 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,21 +16,22 @@ 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 + before_create :snapshot_author_credit_preference + # Blank means "follow the profile"; nil keeps it off the divergence worklist. + 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); # no-op when person_id is blank. Only models with an author_id column use it. scope :authored_by, ->(person_id) { where(author_id: person_id) if person_id.present? } - # Filter to content whose creating user belongs to a person. This is the only - # authorship link the idea models have (they carry no author_id of their own), - # and it's keyed on the person rather than a user id so it stays correct — and - # empty — for a person with no user account. + # `where.not` alone would drop NULL rows, which mean "follow the profile". + scope :credited_openly, -> { + where(author_credit_preference: nil).or(where.not(author_credit_preference: ANONYMOUS)) + } + + # The idea models' only authorship link. Keyed on person, not user, so it stays + # empty for a person with no account. scope :created_by_person, ->(person_id) { joins(:created_by).where(users: { person_id: person_id }) } end @@ -44,102 +41,94 @@ def author_person primary_author_person || created_by&.person end - # The credited author person that is *not* the creator fallback — the explicit - # author. def primary_author_person author if respond_to?(:author) end - # A legacy free-text author name (no linkable person), ranked between the - # explicit author and the creator. Overridden by models that have one - # (Workshop, Resource). + # Free-text author name with no linkable person. Overridden by Workshop, Resource. 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 - # creating user's person, then `missing_author_label`. def author_credit - return "Anonymous" if author_credit_preference == "anonymous" + # Outranks every source, including a legacy name no profile can suppress. + return missing_author_label 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 = self.class.credits_creator? ? created_by&.person : nil + creator ? credit_for(creator) : missing_author_label end - # The person the credit should link to, or nil when the credit must not resolve - # to a profile. Only an explicit/legacy author links: a credit that falls back - # to the creating user's person is shown as plain text, because that person - # never declared authorship (and the record isn't listed on their profile - # either). Anonymous never links. + # Only an explicit author links — a creator fallback never declared authorship. 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 + + # A one-way latch: either side can set it, neither can strip it from the other. + def credit_anonymous?(person) + person.anonymous_contributions? || author_credit_preference == ANONYMOUS + end + + # A legacy name follows nobody's profile, and neither does a record that never + # named an author, so neither has a governing person. + def credit_governing_person + person = primary_author_person + return person if person + return nil if legacy_author_name_text.present? + self.class.credits_creator? ? created_by&.person : nil + end + + # Snapshot no longer agrees with the governing profile. + def author_credit_diverged? + return false if author_credit_preference.blank? + person = credit_governing_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 - # (e.g. Workshop shows "Facilitator"). + # Shown when there's no credited person or legacy name. The portal is behind a + # login, so this names the org's facilitators rather than hiding behind + # "Anonymous", which would read as a deliberate privacy choice. def missing_author_label - "Anonymous" + "AWBW Facilitator" 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? + # Only the governing profile, so a legacy credit doesn't snapshot the profile of + # whoever entered it and then read as drift against it. + person = credit_governing_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 missing_author_label 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 + # Whoever entered a record didn't claim it, so a model that can name an author + # credits only that author. The idea models have no author_id at all, so their + # creator is the only credit they can carry. + def credits_creator? + !column_names.include?("author_id") 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. + # Fully-qualified legacy name columns, e.g. "resources.legacy_author_name". def legacy_author_name_columns [] end - # Records whose credited author's name resembles `query`: the explicit author - # person, the creating user's person, plus any legacy sources the model folds - # in. Uses explicit LEFT JOIN aliases so it composes safely — SearchCop can't - # join `people` more than once, so callers OR this into full-text results via - # an id subquery. + # Explicit LEFT JOIN aliases, because SearchCop can't join `people` twice — + # callers OR this into full-text results via an id subquery. 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 @@ -155,30 +144,23 @@ def order_by_author(direction) private - # Person SQL aliases in credit precedence order. + # The one person a credit can name — author XOR creator, never both, so search + # and sort can't reach a person the credit never displays. def credited_person_aliases - aliases = [] - aliases << "credited_author" if column_names.include?("author_id") - aliases << "credited_creator" - aliases + credits_creator? ? [ "credited_creator" ] : [ "credited_author" ] end - # Explicit LEFT JOINs (as raw SQL strings with unique aliases) reaching every - # person that can be credited, so the aliases never collide with SearchCop's - # or Rails' own joins. def credited_person_join_sql - sql = [] - if column_names.include?("author_id") - sql << "LEFT OUTER JOIN people credited_author ON credited_author.id = #{table_name}.author_id" - end - sql << "LEFT OUTER JOIN users credited_creator_user ON credited_creator_user.id = #{table_name}.created_by_id" - sql << "LEFT OUTER JOIN people credited_creator ON credited_creator.id = credited_creator_user.person_id" - sql + return [ + "LEFT OUTER JOIN users credited_creator_user ON credited_creator_user.id = #{table_name}.created_by_id", + "LEFT OUTER JOIN people credited_creator ON credited_creator.id = credited_creator_user.person_id" + ] if credits_creator? + + [ "LEFT OUTER JOIN people credited_author ON credited_author.id = #{table_name}.author_id" ] end - # Arel COALESCE over every credited person alias (and legacy name column), - # so the ORDER BY carries no interpolated SQL. Aliases and column names come - # from model config / column_names, never user input. + # Arel keeps interpolated SQL out of the ORDER BY. Same precedence as + # `author_credit`, so a row sorts under the name it displays. def coalesced_author_arel(field, ascending) parts = credited_person_aliases.map { |sql_alias| Arel::Table.new(sql_alias)[field] } parts += legacy_author_name_columns.map do |col| @@ -189,13 +171,37 @@ 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 the name parts the credit displays, so search can't surface what + # the credit hides. + 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}.anonymous_contributions = FALSE AND #{not_anonymous_sql} AND (#{by_preference.join(' OR ')}))" + end + + # No person behind a legacy name, 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 e401863896..c5f3a3d993 100644 --- a/app/models/person.rb +++ b/app/models/person.rb @@ -71,6 +71,19 @@ 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 + + # Anonymity isn't one of these — it's the separate `anonymous_contributions` flag, + # since 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 +219,28 @@ def mailing_list_consented=(value) end end + # Drives the people index, the profile header, and every author credit. 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 + # Anonymity is a separate axis from the name format: it suppresses author credits + # without affecting how they're listed on the people index. + def effective_author_credit_preference + return "anonymous" if anonymous_contributions? + display_name_preference.presence || "full_name" + end + def full_name "#{first_name} #{last_name}" end diff --git a/app/models/resource.rb b/app/models/resource.rb index 540566486a..8947015b31 100644 --- a/app/models/resource.rb +++ b/app/models/resource.rb @@ -89,11 +89,6 @@ def legacy_author_name_text legacy_author_name end - # Unattributed resources are credited to the organization's staff. - def missing_author_label - "AWBW Staff" - end - # Scopes scope :by_created, -> { order(created_at: :desc) } scope :by_featured_first, -> { order(featured: :desc, created_at: :desc) } diff --git a/app/models/story.rb b/app/models/story.rb index 187717903c..64d6d3cf68 100644 --- a/app/models/story.rb +++ b/app/models/story.rb @@ -8,6 +8,9 @@ class Story < ApplicationRecord belongs_to :updated_by, class_name: "User" belongs_to :windows_type belongs_to :organization, optional: true + # Display this person via `.name` (honors their display_name_preference) — a + # spotlight is not an author credit, so it ignores contributions_anonymous. + # Don't route the spotlighted name through author_credit / effective_author_credit_preference. belongs_to :spotlighted_facilitator, class_name: "Person", foreign_key: "spotlighted_facilitator_id", optional: true belongs_to :author, class_name: "Person", optional: true @@ -50,10 +53,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 @@ -124,11 +130,6 @@ def communications_email author_person&.preferred_email end - # Unattributed stories are credited to the facilitator who shared them. - def missing_author_label - "AWBW Facilitator" - end - def organization_name organization&.name end diff --git a/app/models/story_idea.rb b/app/models/story_idea.rb index 907c34a8f5..1e2a708aea 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..156f4b0e23 100644 --- a/app/models/workshop.rb +++ b/app/models/workshop.rb @@ -195,15 +195,6 @@ def legacy_author_name_text full_name end - # With no credited person or legacy name, attribute to the generic facilitator. - 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.rb b/app/models/workshop_variation.rb index dd6ccf95da..f5a95f4527 100644 --- a/app/models/workshop_variation.rb +++ b/app/models/workshop_variation.rb @@ -56,11 +56,6 @@ def description rhino_body.to_plain_text end - # Unattributed workshop variations are credited to the generic facilitator. - def missing_author_label - "AWBW Facilitator" - end - def title name end 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..88198a45fc --- /dev/null +++ b/app/services/author_credit_divergence_query.rb @@ -0,0 +1,192 @@ +# Content whose credit doesn't resolve cleanly through a profile, in four sections: +# +# preference — snapshot no longer matches the profile +# legacy — credited by a free-text name column, no person at all +# creator — author_id blank, so the credit falls back to the creator +# unattributed — nothing to credit at all +# +# Comparisons run in Ruby because they walk the author fallback chain. +class AuthorCreditDivergenceQuery + # 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 one that fits all their 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) + + # Kept even when empty — 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 + 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 + + # The idea models have no author_id, so crediting through the creator is correct + # for them, 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 + + 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 + + # No credited person here, which is the whole problem — so no person filter. + 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 means, in one query for the page, not 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 + + # Grouped by the creator as a *suggestion*, not as a governing profile — the credit + # itself already shows the generic label, since nobody claimed these. + 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) { |record| record.created_by.person } + end + + 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, &grouper) + records + .group_by(&(grouper || :credit_governing_person.to_proc)) + .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..89f41f3e62 --- /dev/null +++ b/app/views/author_credit_divergences/_assign_author_form.html.erb @@ -0,0 +1,16 @@ +<%# Locals: record (required), suggested (optional Person to preselect). %> +<% 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..555fdd2ac3 --- /dev/null +++ b/app/views/author_credit_divergences/_filters.html.erb @@ -0,0 +1,45 @@ +
+ <%# The collection controller auto-submits, so there's no Filter button. %> + <%= form_with url: author_credit_divergences_path, method: :get, + data: { controller: "collection", turbo_frame: "author_credit_divergences_results" }, + html: { autocomplete: "off" }, + class: "flex flex-wrap items-end gap-6" do |f| %> +
+ <%= f.label :type, "Content type", class: "block text-xs font-semibold uppercase text-gray-500 tracking-wide mb-1" %> + <%= f.select :type, + options_for_select(AuthorCreditDivergenceQuery::MODEL_NAMES.map { |name| [ name.underscore.humanize, name ] }, params[:type]), + { include_blank: "All types" }, + class: "w-full bg-white border border-gray-300 rounded-lg px-3 py-2 focus:ring-blue-500 focus:border-blue-500", + onchange: "this.form.requestSubmit()" %> +
+ +
+ <%= f.label :preference, "Stored preference", class: "block text-xs font-semibold uppercase text-gray-500 tracking-wide mb-1" %> + <%= f.select :preference, + options_for_select(AuthorCreditable::ADMIN_FORM_OPTIONS.to_a, params[:preference]), + { include_blank: "Any preference" }, + class: "w-full bg-white border border-gray-300 rounded-lg px-3 py-2 focus:ring-blue-500 focus:border-blue-500", + onchange: "this.form.requestSubmit()" %> +
+ +
+ <%= f.label :person_id, "Person", class: "block text-xs font-semibold uppercase text-gray-500 tracking-wide mb-1" %> + <%= select_tag :person_id, + options_for_select(Person.where(id: params[:person_id]).map { |person| [ person.full_name, person.id ] }, params[:person_id]), + include_blank: true, prompt: "Search for a person", + class: "w-full bg-white border border-gray-300 rounded-lg px-3 py-2 focus:ring-blue-500 focus:border-blue-500", + data: { controller: "remote-select", remote_select_model_value: "person" } %> +
+ + + +
+ <%= link_to "Clear filters", author_credit_divergences_path, + data: { action: "collection#clearAndSubmit" }, class: "btn btn-utility" %> +
+ <% end %> +
diff --git a/app/views/author_credit_divergences/_legacy_group.html.erb b/app/views/author_credit_divergences/_legacy_group.html.erb new file mode 100644 index 0000000000..fb311eb6d1 --- /dev/null +++ b/app/views/author_credit_divergences/_legacy_group.html.erb @@ -0,0 +1,18 @@ +<%# Reported per column: clearing one is what makes that column safe to drop. %> +
+

+ <%= group.model.name.underscore.humanize %> + <%= group.column %> +

+ + <% if group.empty? %> + <%= render "section_clear", + message: "No #{group.model.name.underscore.humanize.downcase} records are credited by #{group.field} any more.", + cleanup: "Ask your developers to remove the unused #{group.column} field." %> + <% else %> + <%= render "unlinked_table", + rows: group.entries, + name_header: "Legacy name", + suggestion_note: "Suggested: a person whose name matches the legacy text." %> + <% end %> +
diff --git a/app/views/author_credit_divergences/_preference_group.html.erb b/app/views/author_credit_divergences/_preference_group.html.erb new file mode 100644 index 0000000000..7900b536b7 --- /dev/null +++ b/app/views/author_credit_divergences/_preference_group.html.erb @@ -0,0 +1,95 @@ +<% person = group.person %> +<% highlighted = params[:highlight].to_s == person.id.to_s %> +
"> +
+
+ <%= link_to person.full_name, person_path(person), + target: "_blank", rel: "noopener", title: "Opens in a new tab", + class: "font-semibold text-gray-900 hover:underline" %> + + — profile currently credits as + <%= AuthorCreditable::ADMIN_FORM_OPTIONS.key(person.effective_author_credit_preference) %> + +
+ <% if person.author_credit_reconciled_at.present? %> + <%= render "shared/badge", + label: "Reconciled #{person.author_credit_reconciled_at.strftime('%b %-d, %Y')}", + classes: "bg-gray-50 text-gray-600 border-gray-200", + icon: "fa-solid fa-circle-info" %> + <% end %> +
+ + + + + + + + + + + + <% group.records.each do |record| %> + + + + + + + <% end %> + +
ContentTypeRenders asStored 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 follows the profile. %> + <%= select_tag "author_credit_preference", + options_for_select(AuthorCreditable::ADMIN_FORM_OPTIONS.to_a, record.author_credit_preference), + include_blank: "None (follow profile)", + 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 (to the generic + “AWBW Facilitator” credit) — the other values just re-record what was + consented to. +

+ +
+ <%= form_with url: update_person_author_credit_divergences_path, method: :patch, + class: "flex flex-wrap items-end gap-3" do %> + <%= hidden_field_tag :id, person.id, id: nil %> + <%= render "filter_fields" %> + +
+ <%= label_tag "person_display_name_preference_#{person.id}", "Set profile to", + class: "block text-xs font-medium text-gray-700" %> + <%= select_tag "person[display_name_preference]", + options_for_select(Person::DISPLAY_NAME_PREFERENCE_LABELS.invert.to_a, + suggested_display_name_preference(group)), + id: "person_display_name_preference_#{person.id}", + class: "mt-1 rounded-md border-gray-300 text-sm" %> +
+ + + + <%= submit_tag "Apply to profile", + class: "rounded-md bg-blue-600 px-3 py-2 text-sm font-medium text-white hover:bg-blue-700 cursor-pointer" %> + + Suggested: <%= AuthorCreditable::ADMIN_FORM_OPTIONS.key(group.suggested_preference) %> + + <% end %> +
+
diff --git a/app/views/author_credit_divergences/_results_skeleton.html.erb b/app/views/author_credit_divergences/_results_skeleton.html.erb new file mode 100644 index 0000000000..1d73a997c8 --- /dev/null +++ b/app/views/author_credit_divergences/_results_skeleton.html.erb @@ -0,0 +1,9 @@ +
+ <% 3.times do %> +
+
+
+
+
+ <% end %> +
diff --git a/app/views/author_credit_divergences/_section_clear.html.erb b/app/views/author_credit_divergences/_section_clear.html.erb new file mode 100644 index 0000000000..7a109efbff --- /dev/null +++ b/app/views/author_credit_divergences/_section_clear.html.erb @@ -0,0 +1,18 @@ +<%# Locals: message (required), cleanup (optional). Withheld under a filter, where + an empty section is the filter's doing rather than a milestone. %> +<% if divergence_filters_applied? %> +
+

+ Nothing in this section matches the current filters. +

+
+<% else %> +
+

+ <%= message %> +

+ <% if local_assigns[:cleanup].present? %> +

<%= cleanup %>

+ <% end %> +
+<% end %> diff --git a/app/views/author_credit_divergences/_unlinked_table.html.erb b/app/views/author_credit_divergences/_unlinked_table.html.erb new file mode 100644 index 0000000000..3bf9b67aeb --- /dev/null +++ b/app/views/author_credit_divergences/_unlinked_table.html.erb @@ -0,0 +1,29 @@ +<%# Locals: rows (responding to #record and #suggested_author), name_header, + suggestion_note (optional). %> +
+ + + + + + + + + + + <% rows.each do |row| %> + + + + + + + <% end %> + +
ContentType<%= 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 %> +
+ <% if local_assigns[:suggestion_note].present? %> +

<%= suggestion_note %>

+ <% end %> +
diff --git a/app/views/author_credit_divergences/author_credit_divergences_results.html.erb b/app/views/author_credit_divergences/author_credit_divergences_results.html.erb new file mode 100644 index 0000000000..ee99d9d4a0 --- /dev/null +++ b/app/views/author_credit_divergences/author_credit_divergences_results.html.erb @@ -0,0 +1,142 @@ +<%= turbo_frame_tag :author_credit_divergences_results do %> + <% if @result.empty? && divergence_filters_applied? %> +
+ +

Nothing matches these filters.

+

+ Clear them to see whether anything is left to reconcile overall. +

+
+ <% elsif @result.empty? %> +
+ +

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. +

+
+ <% else %> + <% overview = [ + { anchor: "section-preference", num: 1, label: "Preference drift", count: @result.preference.size, unit: "person" }, + { anchor: "section-legacy", num: 2, label: "Legacy name", count: @result.legacy.sum { |g| g.entries.size }, unit: "record" }, + { anchor: "section-creator", num: 3, label: "Creator fallback", count: @result.creator.size, unit: "person" }, + { anchor: "section-unattributed", num: 4, label: "No author", count: @result.unattributed.size, unit: "record" } + ] %> +
+ <% overview.each do |section| %> + <%# A zero under a filter is the filter's doing, so it isn't reported as clear. %> + <% clear = section[:count].zero? && !divergence_filters_applied? %> + <%= link_to "##{section[:anchor]}", + class: "block rounded-lg border p-3 transition-colors #{clear ? "border-green-200 bg-green-50 hover:bg-green-100" : "border-gray-200 bg-white shadow-sm hover:bg-gray-50"}" do %> +
+ <%= section[:num] %>. <%= section[:label] %> + <% if clear %> + + <% end %> +
+
"><%= section[:count] %>
+
<%= clear ? "clear" : "#{section[:unit].pluralize(section[:count])} to review" %>
+ <% end %> + <% end %> +
+ +

+ 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 ───────────────── %> +
+

1. Preference no longer matches 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.

+
+ <%= render partial: "preference_group", collection: @result.preference, as: :group %> +
+ <% end %> +
+ + <%# ── 2. Credited by a legacy free-text name ────────────────────────────── %> +
+

2. Credited by a legacy name, not a person

+

+ 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. +

+ + <%= render partial: "legacy_group", collection: @result.legacy, as: :group %> + + <% if @result.legacy_empty? %> + <%= render "section_clear", + message: "Every legacy name column is clear.", + cleanup: "Ask your developers to remove both unused fields and the legacy-name handling in AuthorCreditable that reads them." %> + <% end %> +
+ + <%# ── 3. author_id blank, credit falls back to the creator ──────────────── %> +
+

3. No author named, but someone entered it

+

+ These have no author_id, so they credit the generic + “AWBW Facilitator” — entering a record isn't claiming it. The person who + created it is the most likely author, so they're grouped and suggested here, but confirm it + rather than assume it. Idea records are excluded — they have no author_id + at all, so the creator is their correct and only credit path. +

+ + <% if @result.creator.empty? %> + <%= render "section_clear", + message: "No creator fallbacks. Every record names its author explicitly.", + cleanup: "Ask your developers to remove the created_by fallback in AuthorCreditable#author_credit." %> + <% else %> +

<%= pluralize(@result.creator.size, "person") %> to confirm.

+
+ <% @result.creator.each do |group| %> +
+
+ <%= link_to group.person.full_name, person_path(group.person), + target: "_blank", rel: "noopener", title: "Opens in a new tab", + class: "font-semibold text-gray-900 hover:underline" %> + — created <%= pluralize(group.records.size, "record") %> with no author set +
+ <%= render "unlinked_table", + rows: assignable_rows(group.records, suggested_author: group.person), + name_header: "Renders as", + suggestion_note: "Suggested: the person who created the record." %> +
+ <% end %> +
+ <% end %> +
+ + <%# ── 4. Nothing to credit at all ───────────────────────────────────────── %> +
+

4. No author at all

+

+ No author, no legacy name, and no person behind the creating account, so these fall back to the + generic “AWBW Facilitator” credit. +

+ + <% 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 %> +
+ <% end %> +<% end %> diff --git a/app/views/author_credit_divergences/divergence_change.turbo_stream.erb b/app/views/author_credit_divergences/divergence_change.turbo_stream.erb new file mode 100644 index 0000000000..63d97dbb3f --- /dev/null +++ b/app/views/author_credit_divergences/divergence_change.turbo_stream.erb @@ -0,0 +1,9 @@ +<%# Re-render the results frame and flash in place, so saving a divergence fix + doesn't reload the whole page. Shared by update_item and update_person. %> +<%= turbo_stream.replace "author_credit_divergences_results" do %> + <%= render template: "author_credit_divergences/author_credit_divergences_results", formats: :html %> +<% end %> + +<%= turbo_stream.replace "flash_now" do %> + <%= render "shared/flash_messages" %> +<% end %> diff --git a/app/views/author_credit_divergences/index.html.erb b/app/views/author_credit_divergences/index.html.erb new file mode 100644 index 0000000000..0938b8e64c --- /dev/null +++ b/app/views/author_credit_divergences/index.html.erb @@ -0,0 +1,23 @@ +<% content_for(:page_bg_class, "admin-only bg-blue-100") %> +
+
+
+

Author credit divergences

+

+ 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. +

+
+ + <%= render "filters" %> + + <% result_src = author_credit_divergences_path(request.query_parameters) %> + + <%= turbo_frame_tag :author_credit_divergences_results, src: result_src, data: { turbo: "temporary" } do %> + <%= render "results_skeleton" %> + <% end %> +
+
diff --git a/app/views/community_news/_form.html.erb b/app/views/community_news/_form.html.erb index cf39270a64..fc28baf69f 100644 --- a/app/views/community_news/_form.html.erb +++ b/app/views/community_news/_form.html.erb @@ -50,24 +50,20 @@ selected: f.object.organization_id, input_html: { class: "block w-full rounded-md border-gray-300 shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm" } %> + <%# Optional on the model, so the form has to allow no author — the credit then + falls to the creator's person, or to "AWBW Facilitator" when there isn't one. %> <%= f.input :author_id, as: :select, label: "Author", - required: true, - collection: f.object.author_person.present? ? [[ f.object.author_person.remote_search_label[:label], f.object.author_person.id ]] : [], - selected: f.object.author_id || current_user&.person_id, - input_html: { required: true, - class: "block w-full rounded-md border-gray-300 shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm", + collection: author_picker_person(f.object).present? ? [[ author_picker_person(f.object).remote_search_label[:label], author_picker_person(f.object).id ]] : [], + selected: author_picker_person(f.object)&.id, + include_blank: "Select an author", + input_html: { class: "block w-full rounded-md border-gray-300 shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm", data: { controller: "remote-select", remote_select_model_value: "person" } } %> + <%= render "shared/author_credit_note", record: f.object %> - <%= f.input :author_credit_preference, - as: :select, - label: "Author credit preference", - hint: "Controls how the author's name is displayed", - collection: AuthorCreditable::ADMIN_FORM_OPTIONS, - selected: f.object.author_credit_preference, - input_html: { class: "block w-full rounded-md border-gray-300 shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm" } %> + <%= render "shared/author_credit_warning", record: f.object %> <%= render "shared/form_image_fields", f: f, include_primary_asset: true %> diff --git a/app/views/people/_form.html.erb b/app/views/people/_form.html.erb index e34d575fca..0178f3df12 100644 --- a/app/views/people/_form.html.erb +++ b/app/views/people/_form.html.erb @@ -457,7 +457,7 @@ -
+
Profile display preferences
@@ -472,12 +472,8 @@ <%= f.input :display_name_preference, as: :select, - collection: [ - ["First and Last Name", "full_name"], - ["First Name and Last Initial", "first_name_last_initial"], - ["First Name Only", "first_name_only"], - ["Last Name Only", "last_name_only"] - ], + collection: Person::DISPLAY_NAME_PREFERENCE_LABELS.invert.to_a, + hint: "Applies everywhere this person's name appears, including author credits", selected: f.object.display_name_preference || "full_name" %> <%= f.input :anonymous_contributions, diff --git a/app/views/people/_show_card.html.erb b/app/views/people/_show_card.html.erb index c3924e1f80..1e4c6edf69 100644 --- a/app/views/people/_show_card.html.erb +++ b/app/views/people/_show_card.html.erb @@ -2,8 +2,9 @@ <% record_title ||= record.title %> <% title_font_size ||= nil %> <% bookmarkable ||= record.object %> +<% anonymous ||= false %> -
+
">
<%= render "bookmarks/editable_bookmark_icon", resource: bookmarkable %> @@ -36,6 +37,15 @@
+ <% if anonymous %> + <%# Admin/owner-only marker: this credit renders "Anonymous" publicly, so + the item is hidden from everyone else. %> + + + Anonymous + + <% end %>
<%= link_to record.link_target, data: { turbo_frame: "_top"}, diff --git a/app/views/people/sections/_resources.html.erb b/app/views/people/sections/_resources.html.erb index d21e233c86..2323f89999 100644 --- a/app/views/people/sections/_resources.html.erb +++ b/app/views/people/sections/_resources.html.erb @@ -2,7 +2,8 @@ <% if resources.any? %>
<% resources.each do |resource| %> - <%= render "show_card", record: resource.decorate, title_font_size: "text-sm" %> + <%= render "show_card", record: resource.decorate, title_font_size: "text-sm", + anonymous: resource.credit_anonymous?(person) %> <% end %>
diff --git a/app/views/people/sections/_stories.html.erb b/app/views/people/sections/_stories.html.erb index c0608ad0f8..92ea0c3822 100644 --- a/app/views/people/sections/_stories.html.erb +++ b/app/views/people/sections/_stories.html.erb @@ -2,7 +2,10 @@ <% if stories.any? %>
<% stories.each do |story| %> - <%= render "show_card", record: story.decorate, title_font_size: "text-sm" %> + <%# Spotlight-only stories (person isn't the author) are never anonymized — + the anonymity flag governs authorship credit, not the spotlight. %> + <%= render "show_card", record: story.decorate, title_font_size: "text-sm", + anonymous: story.author_id == person.id && story.credit_anonymous?(person) %> <% end %>
diff --git a/app/views/people/sections/_workshop_variations.html.erb b/app/views/people/sections/_workshop_variations.html.erb index 086b1156de..a02b90f9fd 100644 --- a/app/views/people/sections/_workshop_variations.html.erb +++ b/app/views/people/sections/_workshop_variations.html.erb @@ -5,7 +5,8 @@ <%= render "show_card", record_title: "#{workshop_variation.name}
" + "WORKSHOP: #{workshop_variation.workshop.name}", - record: workshop_variation.decorate, title_font_size: "text-sm" %> + record: workshop_variation.decorate, title_font_size: "text-sm", + anonymous: workshop_variation.credit_anonymous?(person) %> <% end %>
diff --git a/app/views/people/sections/_workshops.html.erb b/app/views/people/sections/_workshops.html.erb index 70e4366c9f..4949d45e06 100644 --- a/app/views/people/sections/_workshops.html.erb +++ b/app/views/people/sections/_workshops.html.erb @@ -2,7 +2,8 @@ <% if workshops.any? %>
<% workshops.each do |workshop| %> - <%= render "show_card", record: workshop.decorate, title_font_size: "text-sm" %> + <%= render "show_card", record: workshop.decorate, title_font_size: "text-sm", + anonymous: workshop.credit_anonymous?(person) %> <% end %>
diff --git a/app/views/resources/_form.html.erb b/app/views/resources/_form.html.erb index ee21d2d789..3a1bb122b1 100644 --- a/app/views/resources/_form.html.erb +++ b/app/views/resources/_form.html.erb @@ -62,8 +62,8 @@
<%= f.input :author_id, as: :select, - collection: f.object.author_person.present? ? [[ f.object.author_person.remote_search_label[:label], f.object.author_person.id ]] : [], - selected: f.object.author_person&.id || current_user.person_id, + collection: author_picker_person(f.object).present? ? [[ author_picker_person(f.object).remote_search_label[:label], author_picker_person(f.object).id ]] : [], + selected: author_picker_person(f.object)&.id, include_blank: "Select an author", label: (f.object.author ? ( link_to "Resource author", @@ -73,21 +73,11 @@ input_html: { class: "w-full rounded border-gray-300", data: { controller: "remote-select", remote_select_model_value: "person" } } %> - <% if f.object.legacy_author_name.present? %> -

Legacy author credit: <%= f.object.legacy_author_name %>

- <% end %> + <%= render "shared/author_credit_note", record: f.object %>
- <%= f.input :author_credit_preference, - as: :select, - collection: AuthorCreditable::ADMIN_FORM_OPTIONS, - include_blank: "Select a preference", - selected: f.object.author_credit_preference || "full_name", - label: "Author credit preference", - hint: "Controls how the author’s name is displayed", - label_html: { class: "block font-medium mb-1 text-gray-700" }, - input_html: { class: "w-full rounded border-gray-300" } %> + <%= render "shared/author_credit_warning", record: f.object %>
diff --git a/app/views/shared/_author_credit_note.html.erb b/app/views/shared/_author_credit_note.html.erb new file mode 100644 index 0000000000..f8eba66ba7 --- /dev/null +++ b/app/views/shared/_author_credit_note.html.erb @@ -0,0 +1,25 @@ +<%# Sits under an author picker and says what the credit actually resolves to when + that isn't just the selected author: the person's profile suppresses credits, or + no author is set and a legacy free-text name is standing in. Locals: record. %> +<% person = author_picker_person(record) %> +<% if person&.anonymous_contributions? %> +

+ + + <%= person.full_name %>'s profile marks contributions anonymous, so this credit renders + “AWBW Facilitator” wherever it appears. + <%= link_to "Change it on their profile", edit_person_path(person, anchor: "profile-preferences"), + target: "_blank", rel: "noopener", title: "Opens in a new tab", + class: "underline hover:text-amber-900" %> + +

+<% elsif record.author.blank? && record.legacy_author_name_text.present? %> +

+ + + No author is set, so this is credited to the legacy name + “<%= record.legacy_author_name_text %>”, which links nowhere and follows nobody's + profile. Pick a person to replace it. + +

+<% end %> diff --git a/app/views/shared/_author_credit_preview.html.erb b/app/views/shared/_author_credit_preview.html.erb new file mode 100644 index 0000000000..1d5f4b3c81 --- /dev/null +++ b/app/views/shared/_author_credit_preview.html.erb @@ -0,0 +1,14 @@ +<%# Submitter-facing. A new record has no stored snapshot to diverge from, so this is + unconditional — it's the only place a submitter learns how they'll be credited. %> +<% person = current_user&.person %> +
+ <% if person %> + You'll be credited as <%= person.anonymous_contributions? ? "AWBW Facilitator" : person.name %>. + <% else %> + You'll be credited as AWBW Facilitator. + <% end %> +

+ 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. +

+
diff --git a/app/views/shared/_author_credit_status.html.erb b/app/views/shared/_author_credit_status.html.erb new file mode 100644 index 0000000000..29da8b7e77 --- /dev/null +++ b/app/views/shared/_author_credit_status.html.erb @@ -0,0 +1,7 @@ +<%# Idea forms are reached both by submitters (new) and admins (edit), so show the + submitter-facing preview on a new record and the divergence warning on a saved one. %> +<% if record.new_record? %> + <%= render "shared/author_credit_preview" %> +<% else %> + <%= render "shared/author_credit_warning", record: record %> +<% end %> diff --git a/app/views/shared/_author_credit_warning.html.erb b/app/views/shared/_author_credit_warning.html.erb new file mode 100644 index 0000000000..52cc57bb7f --- /dev/null +++ b/app/views/shared/_author_credit_warning.html.erb @@ -0,0 +1,23 @@ +<%# Renders only when the stored consent snapshot disagrees with the author's profile. + Credits render from the profile, so on most records this is silent. %> +<% if record.persisted? && record.author_credit_diverged? %> + <% stored = AuthorCreditable::ADMIN_FORM_OPTIONS.key(record.author_credit_preference) %> + <% person = record.credit_governing_person %> + <% profile = AuthorCreditable::ADMIN_FORM_OPTIONS.key(person.effective_author_credit_preference) %> + + <% if record.author_credit_preference == AuthorCreditable::ANONYMOUS %> +
+ + Submitted anonymously. This item stays anonymous regardless of the profile setting. + <%= link_to "Reconcile", author_credit_divergences_path(person_id: person.id, highlight: person.id, anchor: dom_id(person, :divergence)), + class: "underline hover:text-gray-700" %> +
+ <% else %> +
+ ⚠ Submitted as "<%= stored %>", but this profile is now set to "<%= profile %>". + This item is credited using the profile setting. + <%= link_to "Reconcile", author_credit_divergences_path(person_id: person.id, highlight: person.id, anchor: dom_id(person, :divergence)), + class: "underline hover:text-amber-900" %> +
+ <% end %> +<% end %> diff --git a/app/views/stories/_form.html.erb b/app/views/stories/_form.html.erb index 167e59f429..d623c29ccd 100644 --- a/app/views/stories/_form.html.erb +++ b/app/views/stories/_form.html.erb @@ -209,18 +209,7 @@
- <%= f.input :author_credit_preference, - as: :select, - collection: AuthorCreditable::ADMIN_FORM_OPTIONS, - prompt: "Select a preference", - selected: f.object.author_credit_preference || story_idea&.author_credit_preference, - label: "Author credit preference", - hint: "Controls how the author's name is displayed", - input_html: { - class: select_caret_class(blank: f.object.author_credit_preference.blank?), - style: custom_caret_style, - onchange: select_caret_onchange - } %> + <%= render "shared/author_credit_warning", record: f.object %>
@@ -243,27 +232,20 @@
Story idea author credit:
- <% if story_idea.created_by.person %> - <%= person_profile_button(story_idea.created_by.person, display_name: story_idea.author_credit, subtitle: story_idea.created_by.person.full_name) %> - <% else %> - <%= story_idea.author_credit %> - <% end %> -
- Story idea "author credit preference": -
-
<%= link_to story_idea.author_credit_preference, edit_story_idea_path(story_idea, anchor: "publish-preferences"), class: "hover:underline hover:text-blue-600" %>
+ <%= credited_author_link(story_idea) %> + <%= render "shared/author_credit_warning", record: story_idea %>
<% end %>
- <% story_author_id = f.object.author_person&.id || current_user.person_id %> + <% story_author = author_picker_person(f.object) %> <%= f.input :author_id, - collection: f.object.author_person.present? ? [[ f.object.author_person.remote_search_label[:label], f.object.author_person.id ]] : [], + collection: story_author.present? ? [[ story_author.remote_search_label[:label], story_author.id ]] : [], prompt: "Select an author", - selected: story_author_id, - hint: "Defaults to the creator; change to credit someone else.", + selected: story_author&.id, + hint: "Defaults to the creator on a new story; change to credit someone else.", label: (f.object.author ? ( link_to "Story author", person_path(f.object.author), @@ -273,6 +255,7 @@ data: { controller: "remote-select", remote_select_model_value: "person" } } %> + <%= render "shared/author_credit_note", record: f.object %>
diff --git a/app/views/story_ideas/_form.html.erb b/app/views/story_ideas/_form.html.erb index 30e2b843d3..4d14196947 100644 --- a/app/views/story_ideas/_form.html.erb +++ b/app/views/story_ideas/_form.html.erb @@ -292,17 +292,7 @@
- <%= f.input :author_credit_preference, - as: :select, - required: true, - collection: AuthorCreditable::IDEA_FORM_OPTIONS, - prompt: "Select a preference", - selected: f.object.author_credit_preference, - input_html: { - class: select_caret_class(blank: f.object.author_credit_preference.blank?), - style: custom_caret_style, - onchange: select_caret_onchange - } %> + <%= render "shared/author_credit_status", record: f.object %> <%= f.input :youtube_url, as: :text, label: "YouTube link (optional)".html_safe, diff --git a/app/views/story_ideas/show.html.erb b/app/views/story_ideas/show.html.erb index caecbbe87d..46ca04a52a 100644 --- a/app/views/story_ideas/show.html.erb +++ b/app/views/story_ideas/show.html.erb @@ -42,10 +42,7 @@
Author credit: - <%= @story_idea.author_credit_preference&.humanize || "—" %> - <% if @story_idea.author_credit_preference.present? %> - (<%= @story_idea.author_credit %>) - <% end %> + <%= credited_author_link(@story_idea) %>
Permission given: diff --git a/app/views/workshop_ideas/_form.html.erb b/app/views/workshop_ideas/_form.html.erb index 4f3a623849..54b604b2ed 100644 --- a/app/views/workshop_ideas/_form.html.erb +++ b/app/views/workshop_ideas/_form.html.erb @@ -86,20 +86,15 @@ class: "block w-full rounded-md border-gray-300 shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm" } %>
-
- <%= f.input :author_credit_preference, - as: :select, - collection: AuthorCreditable::IDEA_FORM_OPTIONS, - include_blank: "Select a preference", - selected: f.object.author_credit_preference, - label: "Author credit preference", - hint: "Controls how the author's name is displayed" %> -
<% end %> + <%# Outside the admin block: this is how a submitter learns they'll be credited, + so it can't be behind the manage check like the staff fields above. %> + <%= render "shared/author_credit_status", record: f.object %> + <%= render "shared/form_dropdown", field: :body, label: "Body", hidden: false, padding: "mb-4 border border-gray-300 rounded-md" do %> <%= render "shared/form_dropdown", form: f, field: :objective, label: "Workshop objective" do %> diff --git a/app/views/workshop_ideas/show.html.erb b/app/views/workshop_ideas/show.html.erb index 24372358f9..5c36c139ac 100644 --- a/app/views/workshop_ideas/show.html.erb +++ b/app/views/workshop_ideas/show.html.erb @@ -31,10 +31,7 @@
Author credit: - <%= @workshop_idea.author_credit_preference&.humanize || "—" %> - <% if @workshop_idea.author_credit_preference.present? %> - (<%= @workshop_idea.author_credit %>) - <% end %> + <%= credited_author_link(@workshop_idea) %>
Windows audience: @@ -46,12 +43,12 @@
Created by: - <%= @workshop_idea.created_by&.full_name %> + <%= @workshop_idea.created_by&.name %>
<%= @workshop_idea.created_at.in_time_zone.strftime("%Y-%m-%d %I:%M %P") %>
Updated by: - <%= @workshop_idea.updated_by&.full_name %> + <%= @workshop_idea.updated_by&.name %>
<%= @workshop_idea.updated_at.in_time_zone.strftime("%Y-%m-%d %I:%M %P") %>
diff --git a/app/views/workshop_variation_ideas/_form.html.erb b/app/views/workshop_variation_ideas/_form.html.erb index 16a1212a41..165b3dd3ea 100644 --- a/app/views/workshop_variation_ideas/_form.html.erb +++ b/app/views/workshop_variation_ideas/_form.html.erb @@ -103,13 +103,7 @@ <% end %>
- <%= f.input :author_credit_preference, - as: :select, - required: true, - collection: AuthorCreditable::IDEA_FORM_OPTIONS, - include_blank: "Select a preference", - selected: f.object.author_credit_preference, - input_html: { class: "block w-full h-10 rounded-md border-gray-300 shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm" } %> + <%= render "shared/author_credit_status", record: f.object %> <%= f.input :youtube_url, as: :text, label: "YouTube link (optional)".html_safe, diff --git a/app/views/workshop_variation_ideas/index.html.erb b/app/views/workshop_variation_ideas/index.html.erb index caed96eaea..314478f833 100644 --- a/app/views/workshop_variation_ideas/index.html.erb +++ b/app/views/workshop_variation_ideas/index.html.erb @@ -67,14 +67,7 @@ <% end %> - <% display_name = workshop_variation_idea.author_credit.presence || workshop_variation_idea.created_by&.name %> - <% if workshop_variation_idea.created_by&.person %> - <%= link_to display_name, - person_path(workshop_variation_idea.created_by.person), - class: "text-gray-500 hover:text-gray-700" %> - <% elsif display_name %> - <%= display_name %> - <% end %> + <%= credited_author_link(workshop_variation_idea, class: "text-gray-500 hover:text-gray-700") %> <% promoted_variation = workshop_variation_idea.workshop_variations.first %> diff --git a/app/views/workshop_variation_ideas/show.html.erb b/app/views/workshop_variation_ideas/show.html.erb index 0a70276b40..3b4a38c359 100644 --- a/app/views/workshop_variation_ideas/show.html.erb +++ b/app/views/workshop_variation_ideas/show.html.erb @@ -62,10 +62,7 @@
Author credit: - <%= @workshop_variation_idea.author_credit_preference&.humanize || "—" %> - <% if @workshop_variation_idea.author_credit_preference.present? %> - (<%= @workshop_variation_idea.author_credit %>) - <% end %> + <%= credited_author_link(@workshop_variation_idea) %>
Permission given: diff --git a/app/views/workshop_variations/_form.html.erb b/app/views/workshop_variations/_form.html.erb index b7c14b83c4..8128d6ed61 100644 --- a/app/views/workshop_variations/_form.html.erb +++ b/app/views/workshop_variations/_form.html.erb @@ -90,15 +90,7 @@
- <%= f.input :author_credit_preference, - as: :select, - required: true, - collection: AuthorCreditable::ADMIN_FORM_OPTIONS, - include_blank: "Select a preference", - selected: f.object.author_credit_preference || "full_name", - label: "Author credit preference", - hint: "Controls how the author’s name is displayed", - input_html: { class: "h-10 rounded-md" } %> + <%= render "shared/author_credit_warning", record: f.object %> <% if allowed_to?(:manage?, WorkshopVariation) %>
@@ -111,8 +103,8 @@ link_to "Variation author", person_path(f.object.author), class: "hover:underline") : "Variation author").html_safe, - collection: f.object.author_person.present? ? [[ f.object.author_person.remote_search_label[:label], f.object.author_person.id ]] : [], - selected: f.object.author_person&.id || current_user.person_id, + collection: author_picker_person(f.object).present? ? [[ author_picker_person(f.object).remote_search_label[:label], author_picker_person(f.object).id ]] : [], + selected: author_picker_person(f.object)&.id, include_blank: true, input_html: { data: { @@ -120,6 +112,7 @@ remote_select_model_value: "person" } } %> + <%= render "shared/author_credit_note", record: f.object %>
<% end %>
diff --git a/app/views/workshops/_form.html.erb b/app/views/workshops/_form.html.erb index 0eba1a082f..0d21e3384f 100644 --- a/app/views/workshops/_form.html.erb +++ b/app/views/workshops/_form.html.erb @@ -62,22 +62,17 @@ link_to "Author", person_path(f.object.author), class: "hover:underline") : "Author").html_safe, - collection: f.object.author_person.present? ? [[ f.object.author_person.remote_search_label[:label], f.object.author_person.id ]] : [], - selected: (f.object.author_person&.id || current_user.person_id), - hint: "Credited author — any person, not just active users. Defaults to the creator; change to credit someone else.", + collection: author_picker_person(f.object).present? ? [[ author_picker_person(f.object).remote_search_label[:label], author_picker_person(f.object).id ]] : [], + selected: author_picker_person(f.object)&.id, + hint: "Credited author — any person, not just active users. Defaults to the creator on a new workshop; change to credit someone else.", input_html: { class: "block w-full rounded-md border-gray-300 shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm", data: { controller: "remote-select", remote_select_model_value: "person" } } %> + <%= render "shared/author_credit_note", record: f.object %>
- <%= f.input :author_credit_preference, - as: :select, - collection: AuthorCreditable::ADMIN_FORM_OPTIONS, - include_blank: "Select a preference", - selected: f.object.author_credit_preference, - label: "Author credit preference", - hint: "Controls how the author's name is displayed" %> + <%= render "shared/author_credit_warning", record: f.object %>
<%= f.input :workshop_idea_id, diff --git a/app/views/workshops/_show_associations.html.erb b/app/views/workshops/_show_associations.html.erb index 94f95533cf..afb420ed9d 100644 --- a/app/views/workshops/_show_associations.html.erb +++ b/app/views/workshops/_show_associations.html.erb @@ -86,7 +86,7 @@ link_to_object: true, file: spotlight.decorate.display_image %>

- <%= spotlight.author %> + <%= spotlight.author_credit %> <%= spotlight.title %>

<% end %> diff --git a/config/brakeman.ignore b/config/brakeman.ignore index d4deeea56f..4460969218 100644 --- a/config/brakeman.ignore +++ b/config/brakeman.ignore @@ -68,29 +68,6 @@ ], "note": "Admin-only reminder confirmation. Same as preview_reminder: the raw value is the server-rendered email HTML; the embedded custom message is sanitized via reminder_message_html (SafeListSanitizer) and the custom subject is shown escaped, separately, so no unsanitized user input reaches the page." }, - { - "warning_type": "Redirect", - "warning_code": 18, - "fingerprint": "6b0a218f30eb40af7b6cf4ec15de35003eb42e1cea35ecd6acb69139643a338b", - "check_name": "Redirect", - "message": "Possible unprotected redirect", - "file": "app/controllers/events/callouts_controller.rb", - "line": 257, - "link": "https://brakemanscanner.org/docs/warning_types/redirect/", - "code": "redirect_to(EventRegistration.find_by!(:slug => params[:slug]).registrant.payment_processor.checkout(:mode => \"payment\", :metadata => ({ :ce_registration_id => ce_registration.id, :event_registration_id => EventRegistration.find_by!(:slug => params[:slug]).id, :event_id => EventRegistration.find_by!(:slug => params[:slug]).event.id }), :payment_intent_data => ({ :metadata => ({ :ce_registration_id => ce_registration.id, :event_registration_id => EventRegistration.find_by!(:slug => params[:slug]).id, :event_id => EventRegistration.find_by!(:slug => params[:slug]).event.id }), :description => (\"CE Hours: #{EventRegistration.find_by!(:slug => params[:slug]).event.title}\") }), :line_items => ([{ :price_data => ({ :currency => \"usd\", :product_data => ({ :name => (\"CE Hours: #{EventRegistration.find_by!(:slug => params[:slug]).event.title}\") }), :unit_amount => ce_registration.remaining_cost }), :quantity => 1 }]), :success_url => registration_ce_url(EventRegistration.find_by!(:slug => params[:slug]).slug, :checkout => \"success\"), :cancel_url => registration_ce_url(EventRegistration.find_by!(:slug => params[:slug]).slug, :checkout => \"cancelled\")).url, :allow_other_host => true, :status => :see_other)", - "render_path": null, - "location": { - "type": "method", - "class": "Events::CalloutsController", - "method": "redirect_to_ce_stripe_checkout" - }, - "user_input": "EventRegistration.find_by!(:slug => params[:slug]).registrant.payment_processor.checkout(:mode => \"payment\", :metadata => ({ :ce_registration_id => ce_registration.id, :event_registration_id => EventRegistration.find_by!(:slug => params[:slug]).id, :event_id => EventRegistration.find_by!(:slug => params[:slug]).event.id }), :payment_intent_data => ({ :metadata => ({ :ce_registration_id => ce_registration.id, :event_registration_id => EventRegistration.find_by!(:slug => params[:slug]).id, :event_id => EventRegistration.find_by!(:slug => params[:slug]).event.id }), :description => (\"CE Hours: #{EventRegistration.find_by!(:slug => params[:slug]).event.title}\") }), :line_items => ([{ :price_data => ({ :currency => \"usd\", :product_data => ({ :name => (\"CE Hours: #{EventRegistration.find_by!(:slug => params[:slug]).event.title}\") }), :unit_amount => ce_registration.remaining_cost }), :quantity => 1 }]), :success_url => registration_ce_url(EventRegistration.find_by!(:slug => params[:slug]).slug, :checkout => \"success\"), :cancel_url => registration_ce_url(EventRegistration.find_by!(:slug => params[:slug]).slug, :checkout => \"cancelled\")).url", - "confidence": "Weak", - "cwe_id": [ - 601 - ], - "note": "known redirect for stripe" - }, { "warning_type": "Cross-Site Scripting", "warning_code": 2, diff --git a/config/routes.rb b/config/routes.rb index 76743101a6..249456f68e 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -207,6 +207,13 @@ resource :invoice, only: [ :show ], module: :events get "form_submissions/:person_id", to: "events/form_submissions#show", as: :registrant_submissions end + resources :author_credit_divergences, only: :index do + collection do + patch :update_person + patch :update_item + patch :assign_author + end + end resources :people do collection do get :check_duplicates diff --git a/db/migrate/20260804140743_add_author_credit_preferences_to_people.rb b/db/migrate/20260804140743_add_author_credit_preferences_to_people.rb new file mode 100644 index 0000000000..fec8236ecf --- /dev/null +++ b/db/migrate/20260804140743_add_author_credit_preferences_to_people.rb @@ -0,0 +1,11 @@ +class AddAuthorCreditPreferencesToPeople < ActiveRecord::Migration[8.0] + def up + unless column_exists?(:people, :author_credit_reconciled_at) + add_column :people, :author_credit_reconciled_at, :datetime + end + end + + def down + remove_column :people, :author_credit_reconciled_at, if_exists: true + end +end diff --git a/db/schema.rb b/db/schema.rb index 8da6a717f6..199cea8dad 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -1029,6 +1029,7 @@ create_table "people", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| t.boolean "anonymous_contributions", default: false, null: false + t.datetime "author_credit_reconciled_at" t.string "best_time_to_call" t.text "bio" t.boolean "blog_contributor", default: false, null: false diff --git a/db/seeds/dev/people_profiles.rb b/db/seeds/dev/people_profiles.rb index a5749bbcc8..d03f214575 100644 --- a/db/seeds/dev/people_profiles.rb +++ b/db/seeds/dev/people_profiles.rb @@ -121,6 +121,10 @@ email: data[:email], email_2: data[:email_2], profile_is_searchable: data[:searchable], + # Spread the credit preferences across seeded people so the author credit + # divergences page has something to triage in dev. + display_name_preference: Person::DISPLAY_NAME_PREFERENCES.sample, + anonymous_contributions: [ true, false, false, false ].sample, created_by: admin_user, updated_by: admin_user } diff --git a/db/seeds/dev/workshops.rb b/db/seeds/dev/workshops.rb index d71c19805e..0d4e6a95f6 100644 --- a/db/seeds/dev/workshops.rb +++ b/db/seeds/dev/workshops.rb @@ -541,7 +541,7 @@ position: var_data[:position], published: [ true, true, false ].sample, windows_type_id: windows_type_id, - author_credit_preference: "anonymous" + author_credit_preference: "anonymous" # a real anonymity latch, kept per-item ) variation.save! end diff --git a/spec/factories/people.rb b/spec/factories/people.rb index 30d1e116d0..35694d6d26 100644 --- a/spec/factories/people.rb +++ b/spec/factories/people.rb @@ -6,6 +6,10 @@ first_name { Faker::Name.first_name.gsub("'", " ") } last_name { Faker::Name.last_name.gsub("'", " ") } + trait :anonymous_contributions do + anonymous_contributions { true } + end + trait :with_organization do after(:create) do |person| person.organizations << create(:organization) diff --git a/spec/factories/story_ideas.rb b/spec/factories/story_ideas.rb index 4d411b6c33..286e42f191 100644 --- a/spec/factories/story_ideas.rb +++ b/spec/factories/story_ideas.rb @@ -6,7 +6,6 @@ title { "My Title" } rhino_body { "

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/helpers/application_helper_spec.rb b/spec/helpers/application_helper_spec.rb index 8c4fbfc817..51ee32233d 100644 --- a/spec/helpers/application_helper_spec.rb +++ b/spec/helpers/application_helper_spec.rb @@ -23,12 +23,12 @@ expect(helper.credited_author_link(workshop)).to eq("Ada Lovelace") end - it "never links an anonymous credit, even to a searchable person" do + it "never links a suppressed credit, even to a searchable person" do allow(person).to receive(:profile_is_searchable).and_return(true) workshop = create(:workshop, author_credit_preference: "anonymous") allow(workshop).to receive(:author).and_return(person) - expect(helper.credited_author_link(workshop)).to eq("Anonymous") + expect(helper.credited_author_link(workshop)).to eq("AWBW Facilitator") end it "renders a legacy free-text author as plain text with no link" do @@ -38,7 +38,7 @@ expect(helper.credited_author_link(workshop)).to eq("Jane Legacy") end - it "shows a creator-fallback credit as plain text, never linking the creator's profile" do + it "credits the org, not the creator, when no author is named" do creator_person = create(:person, first_name: "Cara", last_name: "Creator") allow(creator_person).to receive(:profile_is_searchable).and_return(true) creator = create(:user) @@ -48,7 +48,7 @@ allow(workshop).to receive(:created_by).and_return(creator) result = helper.credited_author_link(workshop) - expect(result).to eq("Cara Creator") + expect(result).to eq("AWBW Facilitator") expect(result).not_to include(" "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", anonymous_contributions: "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", anonymous_contributions: "1" } } + + expect(person.reload.anonymous_contributions).to be(true) + expect(story.reload.author_credit).to eq("AWBW Facilitator") + 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", anonymous_contributions: "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", anonymous_contributions: "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 "clears the snapshot of an item submitted anonymously, handing it to the profile" 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 be_nil + expect(story.author_credit).to eq(person.full_name) + end + + it "suppresses one item's credit 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("AWBW Facilitator") + 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/community_news_spec.rb b/spec/requests/community_news_spec.rb index b59717d786..a2dd1e9ac9 100644 --- a/spec/requests/community_news_spec.rb +++ b/spec/requests/community_news_spec.rb @@ -56,17 +56,19 @@ expect(response).to be_successful end - it "sorts by the credited person, falling back to the creator when no author" do + it "sorts by the credited author, not by whoever entered the record" do aaron = create(:person, first_name: "Aaron", last_name: "Adams") - late_creator = create(:user, :with_person) - late_creator.person.update!(first_name: "Zeke", last_name: "Zimmer") + zeke = create(:person, first_name: "Zeke", last_name: "Zimmer") + early_creator = create(:user, :with_person) + early_creator.person.update!(first_name: "Aaron", last_name: "Aardvark") - CommunityNews.create!(valid_attributes.merge(title: "Has Author", author: aaron)) - CommunityNews.create!(valid_attributes.merge(title: "No Author", author: nil, created_by: late_creator)) + CommunityNews.create!(valid_attributes.merge(title: "Adams Authored", author: aaron)) + # Entered by an even earlier-sorting person, who must not influence the order. + CommunityNews.create!(valid_attributes.merge(title: "Zimmer Authored", author: zeke, + created_by: early_creator)) get community_news_index_url(sort: "author", direction: "asc"), headers: { "Turbo-Frame" => "community_news_results" } - # Aaron Adams (explicit author) sorts before Zeke Zimmer (creator fallback). - expect(response.body.index("Has Author")).to be < response.body.index("No Author") + expect(response.body.index("Adams Authored")).to be < response.body.index("Zimmer Authored") end it "filters by organization_id on lazy turbo-frame request" do diff --git a/spec/requests/idea_form_author_credit_spec.rb b/spec/requests/idea_form_author_credit_spec.rb new file mode 100644 index 0000000000..0014661ab9 --- /dev/null +++ b/spec/requests/idea_form_author_credit_spec.rb @@ -0,0 +1,50 @@ +require "rails_helper" + +# The idea forms are the only submission path a non-admin reaches, so they're the only +# place a submitter learns how they'll be credited. They state the profile's current +# answer rather than asking — the choice belongs on the profile, not per submission. +RSpec.describe "Idea form author credit notice", type: :request do + # WorkshopIdeaPolicy#new? is admin-only for now ("temp block until stakeholders are + # ready"), so that form is driven by an admin until it opens up. + FORMS = { + "story idea" => { path: :new_story_idea_path, admin_only: false }, + "workshop variation idea" => { path: :new_workshop_variation_idea_path, admin_only: false }, + "workshop idea" => { path: :new_workshop_idea_path, admin_only: true } + }.freeze + + FORMS.each do |label, config| + describe "the new #{label} form" do + let(:submitter) do + config[:admin_only] ? create(:user, :admin, :with_person) : create(:user, :with_person) + end + let(:person) { submitter.person } + + before { sign_in submitter } + + it "states how the submitter will be credited, formatted by their profile" do + person.update!(display_name_preference: "first_name_last_initial") + + get public_send(config[:path]) + + expect(response.body).to include("credited as") + expect(response.body).to include("#{person.first_name} #{person.last_name.first}.") + end + + it "names the generic credit when the profile suppresses credits" do + person.update!(anonymous_contributions: true) + + get public_send(config[:path]) + + expect(response.body).to include("AWBW Facilitator") + expect(response.body).not_to include("credited as #{person.full_name}") + end + + it "points at contact us instead of asking the submitter to choose" do + get public_send(config[:path]) + + expect(response.body).to include(contact_us_path) + expect(response.body).not_to include("author_credit_preference") + end + end + end +end diff --git a/spec/requests/people_stories_section_spec.rb b/spec/requests/people_stories_section_spec.rb index 89b45b6503..efbe7d195a 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!(anonymous_contributions: 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/services/workshop_search_service_spec.rb b/spec/services/workshop_search_service_spec.rb index 239a15e7db..5183731835 100644 --- a/spec/services/workshop_search_service_spec.rb +++ b/spec/services/workshop_search_service_spec.rb @@ -421,8 +421,9 @@ context "filtering by author_name" do let!(:author_user) { create(:user, :with_person) } let!(:author_person) { author_user.person } - let!(:workshop_by_user) do - create(:workshop, :published, title: "User Workshop", created_by: author_user) + # Names its author explicitly — entering a record no longer credits the enterer. + let!(:workshop_by_person) do + create(:workshop, :published, title: "User Workshop", created_by: author_user, author: author_person) end let!(:workshop_with_full_name) do create(:workshop, :published, title: "Full Name Workshop", full_name: "#{author_person.first_name} #{author_person.last_name}") @@ -433,13 +434,13 @@ it "finds workshops by person first_name" do service = WorkshopSearchService.new({ author_name: author_person.first_name }, user: user).call - expect(service.workshops).to include(workshop_by_user) + expect(service.workshops).to include(workshop_by_person) expect(service.workshops).not_to include(workshop_no_match) end it "finds workshops by person last_name" do service = WorkshopSearchService.new({ author_name: author_person.last_name }, user: user).call - expect(service.workshops).to include(workshop_by_user) + expect(service.workshops).to include(workshop_by_person) expect(service.workshops).not_to include(workshop_no_match) end @@ -450,17 +451,17 @@ it "finds workshops by person name with reversed order" do service = WorkshopSearchService.new({ author_name: "#{author_person.last_name}#{author_person.first_name}" }, user: user).call - expect(service.workshops).to include(workshop_by_user) + expect(service.workshops).to include(workshop_by_person) end it "is case insensitive" do service = WorkshopSearchService.new({ author_name: "#{author_person.first_name} #{author_person.last_name}".downcase }, user: user).call - expect(service.workshops).to include(workshop_by_user) + expect(service.workshops).to include(workshop_by_person) end it "ignores blank author_name" do service = WorkshopSearchService.new({ author_name: "" }, user: user).call - expect(service.workshops).to include(workshop_by_user, workshop_no_match) + expect(service.workshops).to include(workshop_by_person, workshop_no_match) end context "with an explicit person author who is not the creator" do diff --git a/spec/support/shared_examples/author_creditable.rb b/spec/support/shared_examples/author_creditable.rb index 4d8141bc03..11753d99e5 100644 --- a/spec/support/shared_examples/author_creditable.rb +++ b/spec/support/shared_examples/author_creditable.rb @@ -1,71 +1,81 @@ RSpec.shared_examples "author_creditable" do |factory:| + # A model with an author_id must name its author — the credit never falls back to + # whoever entered the record. The idea models have no author_id, so their creator + # is the only credit path they have. + def credits_creator? + !described_class.column_names.include?("author_id") + end + + def credited_record(factory, user, person, **attrs) + attrs[:author] = person unless credits_creator? + create(factory, created_by: user, **attrs) + end + describe "#author_credit" do let(:author_user) { create(:user, :with_person) } let(:person) { author_user.person } - let(:record) { create(factory, created_by: author_user) } + let(:record) { credited_record(factory, author_user, person) } - 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") - expect(record.author_credit).to eq("Anonymous") + context "when the profile marks contributions anonymous" do + before { person.update!(anonymous_contributions: true) } + + # Behind a login, so a suppressed credit names the org rather than saying + # "Anonymous", which would read as being about the reader's access. + it "returns the generic label regardless of the name format" do + person.update!(display_name_preference: "full_name") + expect(record.author_credit).to eq("AWBW Facilitator") + 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 suppressed even though the profile says otherwise" do + person.update!(display_name_preference: "full_name", anonymous_contributions: false) + expect(record.author_credit).to eq("AWBW Facilitator") 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 @@ -78,29 +88,128 @@ end context "when there is no credited person" do - it "falls back to the model's missing_author_label" do - user_without_person = create(:user, person: nil) - record.update!(created_by: user_without_person) - expect(record.author_credit).to eq(record.missing_author_label) - expect(record.missing_author_label).to be_present + # The portal is behind a login, so an unattributed credit names the org's + # facilitators rather than hiding behind "Anonymous". + it "falls back to AWBW Facilitator" do + record.update!(created_by: create(:user, person: nil)) + record.update!(author: nil) unless credits_creator? + expect(record.missing_author_label).to eq("AWBW Facilitator") + expect(record.author_credit).to eq("AWBW Facilitator") end 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 = credited_record(factory, author_user, person, 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!(anonymous_contributions: true) + record = credited_record(factory, author_user, person, 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 = credited_record(factory, author_user, person, 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 = credited_record(factory, author_user, person, 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 = credited_record(factory, author_user, person, 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 = credited_record(factory, author_user, person, author_credit_preference: nil) + expect(record.author_credit_diverged?).to be(false) + end + end + + describe ".credited_openly" do + let(:author_user) { create(:user, :with_person) } + let!(:record) { create(factory, created_by: author_user, author_credit_preference: "full_name") } + + it "includes a record whose snapshot is a name format" do + expect(described_class.credited_openly).to include(record) + end + + it "excludes a record submitted anonymously" do + record.update!(author_credit_preference: "anonymous") + expect(described_class.credited_openly).not_to include(record) + end + + it "includes a record with no snapshot, which just follows the profile" do + # The un-backfilled state of every pre-callback row, and what clearing the + # snapshot on the divergences page writes back. + described_class.where(id: record.id).update_all(author_credit_preference: nil) + expect(described_class.credited_openly).to include(record) + end + end + describe ".by_credited_person_name" do let(:author_user) { create(:user, :with_person) } - let!(:record) { create(factory, created_by: author_user) } + let(:person) { author_user.person } + let!(:record) { credited_record(factory, author_user, person) } + + 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") + it "matches the credited person by name" do 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!(anonymous_contributions: 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 bd401c4af1..23409e7b30 100644 --- a/spec/views/page_bg_class_alignment_spec.rb +++ b/spec/views/page_bg_class_alignment_spec.rb @@ -133,6 +133,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-white", "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 diff --git a/spec/views/workshop_ideas/show.html.erb_spec.rb b/spec/views/workshop_ideas/show.html.erb_spec.rb index 96e08f0544..7dcf31542a 100644 --- a/spec/views/workshop_ideas/show.html.erb_spec.rb +++ b/spec/views/workshop_ideas/show.html.erb_spec.rb @@ -15,4 +15,15 @@ expect(rendered).to include(/MyTitle/) expect(rendered).to include(/MyDescription/) end + + it "shows the created-by name through the person's display preference" do + author = create(:user, :with_person) + author.person.update!(first_name: "Rosalind", last_name: "Franklin", + display_name_preference: "first_name_last_initial") + workshop_idea.update!(created_by: author, updated_by: author) + render + + expect(rendered).to include("Rosalind F.") + expect(rendered).not_to include("Rosalind Franklin") + end end diff --git a/spec/views/workshops/_show_associations.html.erb_spec.rb b/spec/views/workshops/_show_associations.html.erb_spec.rb index 56e0f9e111..c66f2f293f 100644 --- a/spec/views/workshops/_show_associations.html.erb_spec.rb +++ b/spec/views/workshops/_show_associations.html.erb_spec.rb @@ -13,6 +13,32 @@ assign(:mentionees, {}) end + describe "facilitator spotlights" do + let(:author) { create(:person, first_name: "Rosalind", last_name: "Franklin") } + let(:spotlight) { create(:resource, kind: "LeaderSpotlight", published: true, author: author) } + + before do + assign(:leader_spotlights, [ spotlight ]) + allow(view).to receive(:allowed_to?).and_return(false) + end + + it "credits the spotlight through the author's profile, not the raw association" do + author.update!(display_name_preference: "first_name_last_initial") + render partial: "workshops/show_associations", locals: { workshop: workshop.decorate } + + expect(rendered).to include("Rosalind F.") + expect(rendered).not_to include("Rosalind Franklin") + end + + it "renders the generic credit when the author's profile suppresses credits" do + author.update!(anonymous_contributions: true) + render partial: "workshops/show_associations", locals: { workshop: workshop.decorate } + + expect(rendered).to include("AWBW Facilitator") + expect(rendered).not_to include("Rosalind") + end + end + context "when user can manage WorkshopVariation" do before do allow(view).to receive(:allowed_to?).and_return(false)