diff --git a/app/controllers/admin/settings_controller.rb b/app/controllers/admin/settings_controller.rb index b12a5d7c61..2689e4f5eb 100644 --- a/app/controllers/admin/settings_controller.rb +++ b/app/controllers/admin/settings_controller.rb @@ -24,6 +24,7 @@ def setting_params params.require(:setting).permit(:organization_id, :info_email, :reply_to_email, :invoice_prefix, :return_address_id, :remittance_address_id, :contact_attention, :ce_administrator_id, :ce_administrator_title, :import_user_id, + :participant_credit_person_id, :ce_provider_number, :ce_accreditation_abbreviation, :ce_accreditation_body, :ce_accreditation_url, :programs_email, :no_reply_email, diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 89b2757e4b..44fd16bed1 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -19,7 +19,8 @@ def hidden_filter_fields(params_hash) end # Byline for an AuthorCreditable record. Links each credited author to their person - # profile when the viewer can open it; anyone else renders as plain text. The + # profile when the viewer can open it; anyone else, and the participant stand-in + # (Setting.participant_credit_person_id), renders as plain text. The # names and the separator come from the record (`credited_name_for_person`, # `CREDIT_SEPARATOR`), so this matches the plain-text credit everywhere else. Falls # back to the plain author_credit label when the credit resolves to nobody linkable @@ -30,7 +31,8 @@ def credited_author_link(record, **link_options) safe_join(people.map { |person| name = record.credited_name_for_person(person) - allowed_to?(:show?, person) ? link_to(name, person_path(person), **link_options) : name + linkable = allowed_to?(:show?, person) && !person.participant_credit? + linkable ? link_to(name, person_path(person), **link_options) : name }, AuthorCreditable::CREDIT_SEPARATOR) end @@ -43,7 +45,7 @@ def credited_author_link(record, **link_options) # now also passes. def credited_author_edit_button(record) person = record.author_credit_person - return credited_author_link(record) unless person && allowed_to?(:manage?, person) + return credited_author_link(record) unless person && allowed_to?(:manage?, person) && !person.participant_credit? person_edit_button(person, display_name: record.author_credit, diff --git a/app/models/person.rb b/app/models/person.rb index 4ce051c13a..ad42831a58 100644 --- a/app/models/person.rb +++ b/app/models/person.rb @@ -628,6 +628,10 @@ def placeholder_email? PlaceholderEmail.match?(email) end + def participant_credit? + persisted? && id == Setting.participant_credit_person_id + end + # The lowest numbered placeholder nobody with this name is using yet. def next_free_placeholder_email scope = Person.where("LOWER(first_name) = ? AND LOWER(last_name) = ?", first_name.downcase, last_name.downcase) diff --git a/app/models/setting.rb b/app/models/setting.rb index 09e0fe3079..9112414642 100644 --- a/app/models/setting.rb +++ b/app/models/setting.rb @@ -17,6 +17,7 @@ class Setting < ApplicationRecord belongs_to :return_address, class_name: "Address", optional: true belongs_to :remittance_address, class_name: "Address", optional: true belongs_to :ce_administrator, class_name: "Person", optional: true + belongs_to :participant_credit_person, class_name: "Person", optional: true belongs_to :import_user, class_name: "User", optional: true # singleton is a constant true behind a unique index: MySQL has no CHECK-based @@ -165,6 +166,12 @@ def self.import_user stored_association(:import_user) end + # The stand-in person credited on content by an unnamed participant. Their byline + # reads as a generic label, so it never links to a profile. + def self.participant_credit_person_id + stored(:participant_credit_person_id) + end + # The signatory's title, printed beside their name on the certificate. def self.ce_administrator_title stored(:ce_administrator_title) || DEFAULT_CE_ADMINISTRATOR_TITLE diff --git a/app/views/admin/settings/edit.html.erb b/app/views/admin/settings/edit.html.erb index 961e9610f7..baa3a66edf 100644 --- a/app/views/admin/settings/edit.html.erb +++ b/app/views/admin/settings/edit.html.erb @@ -132,6 +132,22 @@ input_html: { data: { controller: "remote-select", remote_select_model_value: SearchController::USER_ANY_STATUS_MODEL } } %> +
+

Author credits

+

+ A generic person credited on content by an unnamed participant. Like + "<%= AuthorCreditable::FACILITATOR_AUTHOR_LABEL %>", their name shows as plain text, not a profile link. +

+ <% participant_credit_person = Setting.current.participant_credit_person %> + <%= f.input :participant_credit_person_id, + collection: participant_credit_person.present? ? [ [ participant_credit_person.remote_search_label[:label], participant_credit_person.id ] ] : [], + selected: participant_credit_person&.id, + prompt: "Search people", + label: "Participant stand-in", + hint: "e.g. an \"AWBW Participant\" person record", + input_html: { data: { controller: "remote-select", remote_select_model_value: "person" } } %> +
+

Mailboxes

<%= f.input :info_email, diff --git a/db/migrate/20261103122719_add_participant_credit_person_to_settings.rb b/db/migrate/20261103122719_add_participant_credit_person_to_settings.rb new file mode 100644 index 0000000000..ddc5afb8b0 --- /dev/null +++ b/db/migrate/20261103122719_add_participant_credit_person_to_settings.rb @@ -0,0 +1,13 @@ +class AddParticipantCreditPersonToSettings < ActiveRecord::Migration[8.1] + def up + add_reference :settings, :participant_credit_person unless column_exists?(:settings, :participant_credit_person_id) + unless foreign_key_exists?(:settings, column: :participant_credit_person_id) + add_foreign_key :settings, :people, column: :participant_credit_person_id, on_delete: :nullify + end + end + + def down + remove_foreign_key :settings, column: :participant_credit_person_id if foreign_key_exists?(:settings, column: :participant_credit_person_id) + remove_reference :settings, :participant_credit_person if column_exists?(:settings, :participant_credit_person_id) + end +end diff --git a/db/schema.rb b/db/schema.rb index 423f4ce48b..d5b44845bb 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_10_31_142321) do +ActiveRecord::Schema[8.1].define(version: 2026_11_03_122719) do create_table "action_text_mentions", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| t.bigint "action_text_rich_text_id", null: false t.datetime "created_at", null: false @@ -2114,10 +2114,12 @@ t.string "ce_accreditation_abbreviation" t.integer "import_user_id" t.integer "home_featured_stories_limit" + t.bigint "participant_credit_person_id" t.index ["ce_administrator_id"], name: "index_settings_on_ce_administrator_id" t.index ["created_by_id"], name: "index_settings_on_created_by_id" t.index ["import_user_id"], name: "index_settings_on_import_user_id" t.index ["organization_id"], name: "index_settings_on_organization_id" + t.index ["participant_credit_person_id"], name: "index_settings_on_participant_credit_person_id" t.index ["remittance_address_id"], name: "index_settings_on_remittance_address_id" t.index ["return_address_id"], name: "index_settings_on_return_address_id" t.index ["singleton"], name: "index_settings_on_singleton", unique: true @@ -2951,6 +2953,7 @@ add_foreign_key "settings", "addresses", column: "return_address_id", on_delete: :nullify add_foreign_key "settings", "organizations" add_foreign_key "settings", "people", column: "ce_administrator_id", on_delete: :nullify + add_foreign_key "settings", "people", column: "participant_credit_person_id", on_delete: :nullify add_foreign_key "settings", "users", column: "import_user_id", on_delete: :nullify add_foreign_key "staff_aliases", "users" add_foreign_key "staff_taggings", "staff_tags" diff --git a/db/seeds/features/2026-11-03-participant-stand-in-byline-not-linked.yml b/db/seeds/features/2026-11-03-participant-stand-in-byline-not-linked.yml new file mode 100644 index 0000000000..c7f7aeb990 --- /dev/null +++ b/db/seeds/features/2026-11-03-participant-stand-in-byline-not-linked.yml @@ -0,0 +1,8 @@ +name: "Participant stand-in bylines no longer link to a profile" +area: stories +display_status: admin_facing +released_on: 2026-11-03 +action_path: "/admin/settings" +summary: >- + App settings has a new Author credits section where you pick the generic "AWBW Participant" + person. Content credited to them shows the name as plain text, like "AWBW Facilitator". diff --git a/spec/helpers/application_helper_spec.rb b/spec/helpers/application_helper_spec.rb index f0a071f9c4..f32a9e19b3 100644 --- a/spec/helpers/application_helper_spec.rb +++ b/spec/helpers/application_helper_spec.rb @@ -117,6 +117,15 @@ def strip_for(marker) ).at_css("a") expect(link["data-turbo-frame"]).to eq("_top") end + + it "renders the participant stand-in as plain text, even to a viewer who can open it" do + Setting.create!(participant_credit_person: person) + allow(helper).to receive(:allowed_to?).with(:show?, person).and_return(true) + workshop = create(:workshop, author_credit_preference: "full_name") + allow(workshop).to receive(:author).and_return(person) + + expect(helper.credited_author_link(workshop)).to eq("Ada Lovelace") + end end describe "#credited_author_edit_button" do @@ -149,6 +158,14 @@ def strip_for(marker) expect(helper.credited_author_edit_button(anonymous)).to eq("AWBW Staff") end + + it "stays plain text for the participant stand-in, even to an admin" do + Setting.create!(participant_credit_person: person) + allow(helper).to receive(:allowed_to?).with(:manage?, person).and_return(true) + allow(helper).to receive(:allowed_to?).with(:show?, person).and_return(true) + + expect(helper.credited_author_edit_button(workshop)).to eq("Ada Lovelace") + end end describe "#timezone_visibility_hint" do diff --git a/spec/models/person_spec.rb b/spec/models/person_spec.rb index 418d23b127..d6a432a47a 100644 --- a/spec/models/person_spec.rb +++ b/spec/models/person_spec.rb @@ -383,6 +383,26 @@ end end + describe "#participant_credit?" do + let(:person) { create(:person) } + + it "is true for the participant stand-in chosen in settings" do + Setting.create!(participant_credit_person: person) + + expect(person.participant_credit?).to be(true) + end + + it "is false for everyone else" do + Setting.create!(participant_credit_person: create(:person)) + + expect(person.participant_credit?).to be(false) + end + + it "is false when no stand-in is chosen" do + expect(person.participant_credit?).to be(false) + end + end + describe "#effective_author_credit_preference" do let(:person) { build(:person, display_name_preference: "first_name_only") } diff --git a/spec/models/setting_spec.rb b/spec/models/setting_spec.rb index a8b159ff7a..2dd91d78b8 100644 --- a/spec/models/setting_spec.rb +++ b/spec/models/setting_spec.rb @@ -253,6 +253,19 @@ def stub_env(values) end end + describe ".participant_credit_person_id" do + it "is the id of the person the row points at" do + person = create(:person) + Setting.create!(participant_credit_person: person) + + expect(described_class.participant_credit_person_id).to eq(person.id) + end + + it "is blank until an admin picks someone" do + expect(described_class.participant_credit_person_id).to be_nil + end + end + describe ".import_user" do it "is the user the row points at" do user = create(:user) diff --git a/spec/requests/admin/settings_spec.rb b/spec/requests/admin/settings_spec.rb index 584cf2e80d..2801b64c66 100644 --- a/spec/requests/admin/settings_spec.rb +++ b/spec/requests/admin/settings_spec.rb @@ -89,6 +89,16 @@ expect(options.find { |option| option["selected"] }.text).to include("imports@example.com") end + it "offers the chosen participant stand-in as the picker's current choice" do + Setting.create!(participant_credit_person: create(:person, first_name: "AWBW", last_name: "Participant")) + sign_in admin + + get admin_settings_path + + options = Nokogiri::HTML(response.body).at_css("select[name='setting[participant_credit_person_id]']").css("option") + expect(options.find { |option| option["selected"] }.text).to include("AWBW Participant") + end + it "links the deployed build to what's merged on GitHub since" do allow(ENV).to receive(:[]).and_call_original allow(ENV).to receive(:[]).with("GIT_SHA").and_return("a1b2c3d4e5f6") @@ -182,6 +192,15 @@ expect(Setting.import_user).to eq(user) end + it "names the participant stand-in whose bylines never link" do + person = create(:person) + + patch admin_settings_path, params: { setting: { participant_credit_person_id: person.id } } + + expect(response).to have_http_status(:see_other) + expect(Setting.participant_credit_person_id).to eq(person.id) + end + it "saves the attention line shown on the contact page" do patch admin_settings_path, params: { setting: { contact_attention: "Front Desk, Operations" } }