Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 2 additions & 14 deletions app/controllers/community_news_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,25 +9,13 @@ def index
if turbo_frame_request?
per_page = params[:number_of_items_per_page].presence || 12
base_scope = authorized_scope(CommunityNews.includes([ :bookmarks, :primary_asset, :author,
:organization, { user_author: :person }, { created_by: :person } ]))
:organization, { legacy_author_user: :person }, { created_by: :person } ]))
filtered = base_scope.search_by_params(params)
@sort = %w[created_at title author organization].include?(params[:sort]) ? params[:sort] : "created_at"
@sort_direction = params[:direction] == "asc" ? "asc" : "desc"
filtered = case @sort
when "author"
# Match author_credit, which renders author_person: author, then the
# legacy user_author's person, then created_by's person. The join order
# fixes the people aliases (people_users, people_users_2), so keep it.
author_person_column = ->(field) {
Arel::Nodes::NamedFunction.new("COALESCE", [
Person.arel_table[field],
Person.arel_table.alias("people_users")[field],
Person.arel_table.alias("people_users_2")[field]
])
}
dir = @sort_direction.to_sym
filtered.left_joins(:author, { user_author: :person }, { created_by: :person })
.reorder(author_person_column.call(:last_name).public_send(dir), author_person_column.call(:first_name).public_send(dir))
filtered.order_by_author(@sort_direction)
when "organization"
filtered.left_joins(:organization).reorder("organizations.name #{@sort_direction}")
else
Expand Down
11 changes: 1 addition & 10 deletions app/controllers/stories_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -175,16 +175,7 @@ def apply_sort(scope, column, direction)
scope.left_joins(:workshop)
.reorder(Workshop.arel_table[:title].public_send(dir))
when "author"
# Match Story#author_person, which the column renders: sort by the explicit
# author, falling back to the creating user's person. `created_by: :person`
# is the second join to `people`, so Rails aliases it `people_users`.
creator_person = Person.arel_table.alias("people_users")
author_first_name = Arel::Nodes::NamedFunction.new(
"COALESCE",
[ Person.arel_table[:first_name], creator_person[:first_name] ]
)
scope.left_joins(:author, created_by: :person)
.reorder(author_first_name.public_send(dir))
scope.order_by_author(direction)
when "organization"
scope.left_joins(:organization)
.reorder(Organization.arel_table[:name].public_send(dir))
Expand Down
8 changes: 7 additions & 1 deletion app/controllers/workshop_variations_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,14 @@ def index

base_scope = WorkshopVariation.includes(:workshop, :author, created_by: :person)
.joins(:workshop).where(workshops: { published: true })
@sort = params[:sort]
@sort_direction = params[:direction] == "asc" ? "asc" : "desc"
filtered = base_scope.search_by_params(params)
.order("workshop_variations.created_at DESC, workshops.title, workshop_variations.name")
filtered = if @sort == "author"
filtered.order_by_author(@sort_direction)
else
filtered.order("workshop_variations.created_at DESC, workshops.title, workshop_variations.name")
end
@workshop_variations = filtered.paginate(page: params[:page], per_page: 25).decorate
@workshop_variations_count = filtered.count == base_scope.count ? base_scope.count : "#{filtered.count}/#{base_scope.count}"
end
Expand Down
20 changes: 2 additions & 18 deletions app/decorators/resource_decorator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,23 +13,7 @@ def kind_display
end

def truncated_author
h.truncate credited_author_name, length: 20
end

# Name shown on the author/credit line: the chosen person's credit when a
# person author is set, otherwise the preserved legacy free-text author,
# otherwise the creating user's person credit (AuthorCreditable fallback).
def credited_author_name
return author_credit if author.present?
legacy_author_name.presence || author_credit
end

# The person to link the credit to, when there is a linkable one. A legacy
# free-text author is just a string, so it links nowhere.
def credited_author_link_person
return author if author.present?
return nil if legacy_author_name.present?
created_by&.person
h.truncate author_credit, length: 20
end

def truncated_title
Expand All @@ -53,7 +37,7 @@ def breadcrumbs
end

def author_full_name
credited_author_name
author_credit
end

def display_date
Expand Down
4 changes: 0 additions & 4 deletions app/decorators/workshop_decorator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,6 @@ def detail_breadcrumbs
"#{breadcrumbs_title} >> #{breadcrumb_link}".html_safe
end

def author
author_credit_preference.present? ? author_credit : full_name.to_s
end

def list_sectors
sectorable_items.map(&:sector).map(&:name).to_sentence
end
Expand Down
13 changes: 13 additions & 0 deletions app/helpers/application_helper.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,17 @@
module ApplicationHelper
# Byline for an AuthorCreditable record. Links to the credited author's person
# profile when the credit resolves to a searchable person; otherwise renders
# plain text. The text always honors the credit preference (author_credit), so
# anonymous and legacy free-text credits never link to a profile.
def credited_author_link(record, **link_options)
person = record.author_credit_person
if person&.profile_is_searchable
link_to record.author_credit, person_path(person), **link_options
else
record.author_credit
end
end

# Tags an admin may use in a form field name / group header that should
# render (rather than escape) on the public form. Block + inline formatting,
# links, line breaks, and font sizing/coloring (via <font> or inline style).
Expand Down
24 changes: 19 additions & 5 deletions app/models/community_news.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ class CommunityNews < ApplicationRecord
belongs_to :author, class_name: "Person", inverse_of: :community_news_as_author, optional: true
# Legacy "display author" pick, before author became a person. Kept so
# existing rows credit the chosen person without a backfill.
belongs_to :user_author, class_name: "User", foreign_key: :user_author_id, optional: true
belongs_to :legacy_author_user, class_name: "User", foreign_key: :legacy_author_user_id, optional: true
belongs_to :created_by, class_name: "User"
belongs_to :updated_by, class_name: "User"
has_many :bookmarks, as: :bookmarkable, dependent: :destroy
Expand All @@ -34,10 +34,16 @@ class CommunityNews < ApplicationRecord
validates :rhino_body, presence: true

# Credit the explicit person author, then the legacy display-author user's
# person, then the creating user's person β€” so existing rows keep their
# attribution without a backfill. Overrides AuthorCreditable's default.
def author_person
author || user_author&.person || created_by&.person
# person (the creating user's person is AuthorCreditable's final fallback) β€”
# so existing rows keep their attribution without a backfill.
def primary_author_person
author || legacy_author_user&.person
end

# Fold the legacy display-author user's person into credited-name search and
# sort, matching author_person's precedence.
def self.legacy_credited_user_columns
[ [ "legacy_author_user_id", "credited_legacy_author" ] ]
end

# Nested attributes
Expand Down Expand Up @@ -74,6 +80,14 @@ def self.search_by_params(params)
community_news = self.search(conditions)
end

# SearchCop's free-text query covers title + body + the explicit author. Also
# match the credited author/legacy author/creator by name, OR-ed in via id
# subqueries so the extra person joins stay isolated from SearchCop's joins.
if params[:query].present?
community_news = self.where(id: community_news.select("community_news.id"))
.or(self.where(id: by_credited_person_name(params[:query]).select("community_news.id")))
end

community_news = community_news.by_year(params[:year]) if params[:year].present? && params[:year].match?(/\A\d{4}\z/)
community_news = community_news.sector_names_all(params[:sector_names_all]) if params[:sector_names_all].present?
community_news = community_news.category_names_all(params[:category_names_all]) if params[:category_names_all].present?
Expand Down
169 changes: 158 additions & 11 deletions app/models/concerns/author_creditable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,24 +19,171 @@ module AuthorCreditable
"Anonymous" => "anonymous"
}.freeze

# The person whose name is credited: the explicitly chosen author when the
# model has one (e.g. Story, Workshop), otherwise the creating user's person.
included do
# Default to the full name in the UI (new records and unset form fields).
# Existing rows with no preference are treated as "full_name" β€” at read time
# by `author_credit` and, on their next save, normalized by the callback below.
# There is no data backfill.
attribute :author_credit_preference, :string, default: "full_name"
before_validation :default_author_credit_preference

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸ€– From Claude: Blank preference normalizes to full_name here on save, so legacy rows migrate lazily through normal use β€” no bulk backfill. Reads are covered by author_credit's default branch.

validates :author_credit_preference, inclusion: { in: AUTHOR_CREDIT_PREFERENCES }
end

# The linkable credited person, in precedence order: the explicit/legacy author
# person, otherwise the creating user's person. This is what views link to.
def author_person
(author if respond_to?(:author)) || created_by&.person
primary_author_person || created_by&.person
end

# The credited author person that is *not* the creator fallback β€” the explicit
# author. Overridden by models with an additional legacy person tier (e.g.
# CommunityNews folds in its legacy_author_user's person).
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).
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
person = author_person
return "Anonymous" if author_credit_preference == "anonymous"
person = primary_author_person
return format_person_credit(person) if person
return legacy_author_name_text if legacy_author_name_text.present?
format_person_credit(created_by&.person)
end

# The person the credit should link to, or nil when the displayed credit is an
# anonymous, legacy free-text, or generic label that must not resolve to a
# profile. Mirrors author_credit's precedence so the linked person always
# matches the shown name.
def author_credit_person

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸ€– From Claude: author_credit_person returns nil for anonymous and legacy free-text credits, so the byline helper never links those to a profile even when a person exists behind them.

return nil if author_credit_preference == "anonymous"
primary_author_person || (created_by&.person if legacy_author_name_text.blank?)
end

# Shown when there is no credited person or legacy name. Overridable per model
# (e.g. Workshop shows "Facilitator").
def missing_author_label
"Anonymous"
end

# Treat an unset preference as "full_name" so legacy rows normalize on save
# without a bulk backfill.
def default_author_credit_preference
self.author_credit_preference = "full_name" if author_credit_preference.blank?
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 "full_name" then person&.full_name || "Anonymous"
when "first_name_last_initial"
first = person&.first_name
last_initial = person&.last_name&.first
first.present? ? "#{first} #{last_initial}." : "Anonymous"
when "first_name_only" then person&.first_name || "Anonymous"
when "last_name_only" then person&.last_name || "Anonymous"
when "anonymous" then "Anonymous"
else person&.name || "Anonymous"
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
end

class_methods do
# Legacy free-text columns (fully qualified, e.g. "resources.legacy_author_name")
# that also hold an author's name. Overridden per model that has one.
def legacy_author_name_columns
[]
end

# Legacy "belongs_to a User" columns whose person should also be credited,
# as [ foreign_key_column, sql_alias ] pairs. Overridden per model (e.g.
# CommunityNews folds in its legacy_author_user). Alias must be SQL-safe.
def legacy_credited_user_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.
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" }
joins(credited_person_join_sql).where(clauses.join(" OR "), name: "%#{sanitized}%")
end

# Orders by the credited author's name, matching `author_person` precedence:
# explicit author, then any legacy sources, then the creating user's person.
def order_by_author(direction)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸ€– From Claude: Sorts by first name then last name to match the default displayed credit ("First Last"). CommunityNews previously sorted last-name-first; this unifies on the visible order.

ascending = direction.to_s.casecmp("asc").zero?
# By first name then last name, matching the credit displayed by default
# ("First Last"), so the ordering follows the visible column.
joins(credited_person_join_sql)
.reorder(coalesced_author_arel(:first_name, ascending), coalesced_author_arel(:last_name, ascending))
end

private

# Person SQL aliases in credit precedence order.
def credited_person_aliases
aliases = []
aliases << "credited_author" if column_names.include?("author_id")
legacy_credited_user_columns.each { |_, sql_alias| aliases << sql_alias }
aliases << "credited_creator"
aliases
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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸ€– From Claude: Explicit LEFT JOIN aliases (not Rails' positional people_users guesses) so this composes with SearchCop/other joins without alias collisions; callers OR it in via an id subquery.

sql = []
if column_names.include?("author_id")
sql << "LEFT OUTER JOIN people credited_author ON credited_author.id = #{table_name}.author_id"
end
legacy_credited_user_columns.each do |fk_column, sql_alias|
sql << "LEFT OUTER JOIN users #{sql_alias}_user ON #{sql_alias}_user.id = #{table_name}.#{fk_column}"
sql << "LEFT OUTER JOIN people #{sql_alias} ON #{sql_alias}.id = #{sql_alias}_user.person_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
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.
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|
table, column = col.split(".")
Arel::Table.new(table)[column]
end
node = Arel::Nodes::NamedFunction.new("COALESCE", parts)
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"
]
end
end
end
18 changes: 17 additions & 1 deletion app/models/resource.rb
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,16 @@ def self.mentionable_rich_text_fields
options :action_text_body, type: :text, default: true, default_operator: :or
end

# Fold the legacy free-text author into credit display, credited-name search,
# and sort.
def self.legacy_author_name_columns
[ "resources.legacy_author_name" ]
end

def legacy_author_name_text
legacy_author_name
end

# Scopes
scope :by_created, -> { order(created_at: :desc) }
scope :by_featured_first, -> { order(featured: :desc, created_at: :desc) }
Expand All @@ -107,7 +117,13 @@ def self.mentionable_rich_text_fields

def self.search_by_params(params)
resources = is_a?(ActiveRecord::Relation) ? self : all
resources = resources.search(params[:query]) if params[:query].present? # SearchCop incl title, author, body
if params[:query].present?
# SearchCop covers title + legacy author name + body; OR in the credited
# author/creator person name via id subqueries (isolated person joins).
by_text = resources.search(params[:query]).select("resources.id")
by_person = resources.by_credited_person_name(params[:query]).select("resources.id")
resources = resources.where(id: by_text).or(resources.where(id: by_person))
end
resources = resources.sector_names_all(params[:sector_names_all]) if params[:sector_names_all].present?
resources = resources.category_names_all(params[:category_names_all]) if params[:category_names_all].present?
resources = resources.windows_type_name(params[:windows_type_name]) if params[:windows_type_name].present?
Expand Down
Loading