Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
a3006e5
fix user stuff
manu-gurudath Mar 1, 2026
bc8771c
UI changes
manu-gurudath Mar 1, 2026
b6287e1
update schema
manu-gurudath Mar 1, 2026
f723e8f
Merge main into mxg-store-first-and-last-name-separately
manu-gurudath Mar 7, 2026
99638ef
Remove redundant constraint migrations
manu-gurudath Mar 7, 2026
6a0d620
Fix nil check for user full_name in user factory
manu-gurudath Mar 7, 2026
84f37f6
Refactor user name handling: replace full_name with first_name in var…
manu-gurudath Mar 7, 2026
3bce120
Refactor user name parsing: update user factory and migration to hand…
manu-gurudath Mar 7, 2026
e1bc9ae
Merge remote-tracking branch 'origin/main' into mxg-store-first-and-l…
manu-gurudath Apr 17, 2026
8e9cef6
lint
manu-gurudath Apr 17, 2026
28d8120
Refactor name parsing logic and update user model validations for ful…
manu-gurudath Apr 17, 2026
6088847
lint
manu-gurudath Apr 17, 2026
b3e1bd8
Update first name in card grant response to include full name
manu-gurudath Apr 18, 2026
0dd64bf
Update spec/factories/user_factory.rb
manu-gurudath Apr 18, 2026
ca72615
Update db/migrate/20260301000002_populate_first_and_last_names.rb
manu-gurudath Apr 18, 2026
6785ba4
Update app/services/user_service/parse_name.rb
manu-gurudath Apr 18, 2026
073f493
Update app/models/user.rb
manu-gurudath Apr 18, 2026
812492e
Update app/models/user.rb
manu-gurudath Apr 18, 2026
c39e5ab
Update app/models/user.rb
manu-gurudath Apr 18, 2026
580e703
Update app/services/user_service/parse_name.rb
manu-gurudath Apr 18, 2026
45c00a9
Merge origin/main into mxg-store-first-and-last-name-separately
manu-gurudath Oct 14, 2026
dd63be9
Merge branch 'main' into mxg-store-first-and-last-name-separately
Luke-Oldenburg Oct 14, 2026
71db97f
Merge branch 'main' into mxg-store-first-and-last-name-separately
manu-gurudath Nov 2, 2026
568cd67
revive pr
manu-gurudath Nov 2, 2026
07449f7
Merge branch 'mxg-store-first-and-last-name-separately' of https://gi…
manu-gurudath Nov 2, 2026
905ca53
Update specs that still relied on full_name as the user name input
manu-gurudath Nov 2, 2026
9f2b9cb
Use add_column instead of change_table in first/last name migration
manu-gurudath Nov 2, 2026
d1dcafb
Merge branch 'main' into mxg-store-first-and-last-name-separately
manu-gurudath Nov 7, 2026
d9fbaa8
fix merge conflicts
manu-gurudath Nov 10, 2026
fdfe631
fix name corruption
manu-gurudath Nov 10, 2026
37847f1
fix bugs with name separation
manu-gurudath Nov 10, 2026
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
2 changes: 1 addition & 1 deletion app/controllers/event/applications_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -323,7 +323,7 @@ def application_params
end

def user_params
params.require(:event_application).permit(:full_name, :preferred_name, :phone_number, :birthday)
params.require(:event_application).permit(:legal_first_name, :legal_last_name, :preferred_name, :phone_number, :birthday)
end

def record_pageview
Expand Down
2 changes: 1 addition & 1 deletion app/controllers/users/first_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@ def create
private

def user_params
params.require(:user).permit(:email, :full_name, affiliations_attributes: [:league, :team_number, :name, :team_name, :role])
params.require(:user).permit(:email, :legal_first_name, :legal_last_name, affiliations_attributes: [:league, :team_number, :name, :team_name, :role])
end

def user_ref_params
Expand Down
3 changes: 2 additions & 1 deletion app/controllers/users_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -576,7 +576,8 @@ def set_onboarding
def user_params
attributes = [
# account
:full_name,
:legal_first_name,
:legal_last_name,
:preferred_name,
:phone_number,
:birthday,
Expand Down
13 changes: 9 additions & 4 deletions app/models/stripe_cardholder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -128,15 +128,20 @@ def reset_billing_address_to_default!
end

def self.first_name(user)
clean_name(user.first_name(legal: true))
clean_name(user.first_name(legal: true), field: "first name")
end

def self.last_name(user)
clean_name(user.last_name(legal: true))
clean_name(user.last_name(legal: true), field: "last name")
end

def self.clean_name(name)
name = ActiveSupport::Inflector.transliterate(name || "")
def self.clean_name(name, field: "name")
# HCB lets people go by a first name alone, but Stripe Issuing needs both names.
if name.blank?
raise Errors::StripeInvalidNameError, "A legal #{field} is required to issue cards. Please add it in your account settings."
end

name = ActiveSupport::Inflector.transliterate(name)

# Remove invalid characters
requirements = <<~REQ.squish
Expand Down
121 changes: 93 additions & 28 deletions app/models/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
# email :text not null
# full_name :string
# joined_as_teenager :boolean
# legal_first_name :string
# legal_last_name :string
# locked_at :datetime
# monthly_donation_summary :boolean default(TRUE)
# monthly_follower_summary :boolean default(TRUE)
Expand Down Expand Up @@ -48,6 +50,10 @@
# index_users_on_slug (slug) UNIQUE
#
class User < ApplicationRecord
# Stripe Issuing only accepts latin names.
NAME_CHARACTERS_REGEX = /\A[a-zA-ZàáâäãåąčćęèéêëėįìíîïłńòóôöõøùúûüųūÿýżźñçčšžÀÁÂÄÃÅĄĆČĖĘÈÉÊËÌÍÎÏĮŁŃÒÓÔÖÕØÙÚÛÜŲŪŸÝŻŹÑßÇŒÆČŠŽ∂ð.,' -]+\z/
NAME_CHARACTERS_MESSAGE = "may only contain letters, spaces, hyphens, apostrophes, periods, and commas."

has_paper_trail skip: [:birthday] # ciphertext columns will still be tracked

include Hashid::Rails
Expand Down Expand Up @@ -199,6 +205,10 @@ class User < ApplicationRecord
before_save :clear_pretend_is_not_admin, if: -> { pretend_is_not_admin? && !admin_override_pretend? }

before_create :format_number
# The legal name columns are the source of truth; `full_name` is kept in sync with them.
before_validation :fill_legacy_legal_name, if: -> { awaiting_legal_name_backfill? && legal_name_parts_changed? && !full_name_changed? }
before_validation :split_full_name, if: -> { full_name_changed? && !legal_name_parts_changed? }
before_validation :sync_full_name, if: :legal_name_edited?
before_save :on_phone_number_update
validate :second_factor_present_for_2fa

Expand All @@ -217,13 +227,15 @@ class User < ApplicationRecord

before_update :set_default_seasonal_theme

validates_presence_of :full_name, if: -> { full_name_in_database.present? }
validates_presence_of :birthday, if: -> { birthday_ciphertext_in_database.present? }

validates :full_name, format: {
with: /\A[a-zA-ZàáâäãåąčćęèéêëėįìíîïłńòóôöõøùúûüųūÿýżźñçčšžÀÁÂÄÃÅĄĆČĖĘÈÉÊËÌÍÎÏĮŁŃÒÓÔÖÕØÙÚÛÜŲŪŸÝŻŹÑßÇŒÆČŠŽ∂ð.,'-]+ [a-zA-ZàáâäãåąčćęèéêëėįìíîïłńòóôöõøùúûüųūÿýżźñçčšžÀÁÂÄÃÅĄĆČĖĘÈÉÊËÌÍÎÏĮŁŃÒÓÔÖÕØÙÚÛÜŲŪŸÝŻŹÑßÇŒÆČŠŽ∂ð.,' -]+\z/,
message: "must contain your first and last name, and only contain characters in the latin alphabet.", allow_blank: true,
}
# Names are added during onboarding, and can then be changed but not removed.
validates :legal_first_name, presence: true, if: -> { legal_last_name.present? || clearing_existing_name? }
# Stripe Issuing requires a last name.
validates :legal_last_name, presence: { message: "is required for HCB cards" }, if: -> { legal_last_name_changed? && stripe_cardholder.present? }
validates :legal_first_name, :legal_last_name, format: { with: NAME_CHARACTERS_REGEX, message: NAME_CHARACTERS_MESSAGE, allow_blank: true }

normalizes :legal_first_name, :legal_last_name, with: ->(name) { name.squish.presence }

validates :email, uniqueness: true, presence: true
validates_email_format_of :email
Expand All @@ -238,7 +250,7 @@ class User < ApplicationRecord

validates :preferred_name, length: { maximum: 30 }
validates :preferred_name, format: {
with: /\A[a-zA-ZàáâäãåąčćęèéêëėįìíîïłńòóôöõøùúûüųūÿýżźñçčšžÀÁÂÄÃÅĄĆČĖĘÈÉÊËÌÍÎÏĮŁŃÒÓÔÖÕØÙÚÛÜŲŪŸÝŻŹÑßÇŒÆČŠŽ∂ð.,' -]+\z/,
with: NAME_CHARACTERS_REGEX,
message: "must only contain characters in the latin alphabet.", allow_blank: true,
}, if: :preferred_name_changed?

Expand Down Expand Up @@ -331,28 +343,40 @@ def make_admin!
admin!
end

# The legal first name may include middle names ("Donald Ervin").
# Otherwise, the first word of the name we greet the user by ("Hey Donald,").
def first_name(legal: false)
@first_name ||= (namae(legal:)&.given || namae(legal:)&.particle)&.split(" ")&.first
stored = legal_name_split? ? legal_first_name : legacy_parsed_name.first_name
return stored if legal

display_name = preferred_name.presence || stored.presence || (email_handle if email.present?)
display_name&.split(" ")&.first
end

# The legal last name may include particles and a suffix ("de la Cruz", "King Jr.").
def last_name(legal: false)
@last_name ||= namae(legal:)&.family&.split(" ")&.last
return preferred_name_parts.last if !legal && preferred_name.present?

legal_name_split? ? legal_last_name : legacy_parsed_name.last_name
end

# "Fiona H", shown to other users.
def initial_name
@initial_name ||= if name.strip.split(" ").count == 1
name
else
"#{(first_name || last_name)[0..20]} #{(last_name || first_name)[0, 1]}"
end
first = first_name
last = last_name
# Only the first word, so a full name typed into the first name field stays private.
return first || name if last.blank?

# Initial of the surname itself, skipping lowercase particles ("de la Cruz" -> "C").
"#{first[0..20]} #{last[/[[:upper:]]/] || last[0]}"
end

def safe_name
# stripe requires names to be 24 chars or less, and must include a last name
# stripe requires cardholder names to be 24 chars or less
return name unless name.length > 24
return full_name unless full_name.length > 24
return full_name if full_name.present? && full_name.length <= 24

initial_name
initial_name.first(24)
end

def name
Expand Down Expand Up @@ -748,6 +772,59 @@ def onboarding_contractor_positions

private

def split_full_name
parsed = UserService::ParseName.new(full_name:).run
self.legal_first_name = parsed.first_name
self.legal_last_name = parsed.last_name
end

def sync_full_name
self.full_name = [legal_first_name, legal_last_name].compact_blank.join(" ").presence
end

def legal_name_parts_changed?
legal_first_name_changed? || legal_last_name_changed?
end

def awaiting_legal_name_backfill?
legal_first_name_in_database.blank? && full_name_in_database.present?
end

# A blank part keeps its legacy value, so a partial edit can't drop half of the name.
def fill_legacy_legal_name
self.legal_first_name ||= legacy_parsed_name.first_name
self.legal_last_name ||= legacy_parsed_name.last_name
end

# Saving a legacy name back unchanged only backfills it, leaving `full_name` (and any title) as is.
def legal_name_edited?
return false unless legal_name_parts_changed?

!awaiting_legal_name_backfill? || [legal_first_name, legal_last_name] != legacy_parsed_name.to_a
end

# Only checked when the name is edited, so rows awaiting backfill can still save unrelated updates.
def clearing_existing_name?
full_name_in_database.present? && (legal_name_parts_changed? || full_name_changed?)
end

# Preferred names are free-form, so everything but the last word is the first name.
def preferred_name_parts
words = preferred_name.squish.split(" ")
return [words.first, nil] if words.one?

[words[0..-2].join(" "), words.last]
end

# False only for rows awaiting backfill, whose names are still parsed from `full_name`.
def legal_name_split?
legal_first_name_in_database.present? || legal_name_parts_changed?
end

def legacy_parsed_name
UserService::ParseName.new(full_name:).run
end

def create_legal_entity
legal_entities.create!(entity_type: :person, name: full_name)
end
Expand Down Expand Up @@ -777,18 +854,6 @@ def update_stripe_cardholder
)
end

def namae(legal: false)
if legal
@legal_namae ||= Namae.parse(full_name).first
else
@namae ||= Namae.parse(name).first || Namae.parse(name_simplified).first || Namae::Name.new(given: name_simplified)
end
end

def name_simplified
name.split(/[^[[:word:]]]+/).join(" ")
end

def email_handle
@email_handle ||= email.split("@").first
end
Expand Down
56 changes: 56 additions & 0 deletions app/services/user_service/parse_name.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# frozen_string_literal: true

module UserService
# Splits a legacy single-field full name into a first and last name.
# Namae handles commas, particles and suffixes; anything it can't read is split positionally.
class ParseName
Result = Struct.new(:first_name, :last_name, keyword_init: true)

# Credentials after a full name ("Jane Smith, PhD") aren't part of the legal name.
TRAILING_CREDENTIAL = /\A(\S+ .+?),? (?:m\.?d|ph\.?d)\.?\z/i

def initialize(full_name:)
@full_name = full_name.to_s.squish.sub(TRAILING_CREDENTIAL, '\1')
end

def run
return Result.new(first_name: nil, last_name: nil) if @full_name.blank?

names = Namae.parse(@full_name)
# Namae returns nothing for surnames on its title list, and several names for "John and Jane Doe".
return positional_split unless names.one?

parsed = names.first
return positional_split if trailing_word_taken_as_title?(parsed) || given_name_taken_as_particle?(parsed)

# Particles belong to the surname: "Juan de la Cruz" is "Juan" + "de la Cruz".
first = parsed.given.presence
last = [parsed.particle, parsed.family, parsed.suffix].compact_blank.join(" ").presence

# A lone name ("Madonna", or "Smith" from "Mr. Smith") goes in first_name.
first, last = last, nil if first.nil?

Result.new(first_name: first, last_name: last)
end

private

# "Jane W Elder" parses as given "Jane", family "W", title "Elder".
def trailing_word_taken_as_title?(parsed)
parsed.title.present? && @full_name.downcase.end_with?(parsed.title.downcase)
end

# "john smith" parses with no given name and "john" as a particle.
def given_name_taken_as_particle?(parsed)
parsed.given.blank? && parsed.particle.present?
end

def positional_split
words = @full_name.split(" ")
return Result.new(first_name: words.first, last_name: nil) if words.one?

Result.new(first_name: words[0..-2].join(" "), last_name: words.last)
end

end
end
18 changes: 18 additions & 0 deletions app/tasks/maintenance/backfill_user_legal_names_task.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# frozen_string_literal: true

module Maintenance
class BackfillUserLegalNamesTask < MaintenanceTasks::Task
def collection
User.where(legal_first_name: nil).where.not(full_name: [nil, ""])
end

def process(user)
parsed = UserService::ParseName.new(full_name: user.full_name).run

# Scoped to still-unsplit rows so a name the user saved in the meantime is never overwritten.
User.where(id: user.id, legal_first_name: nil, full_name: user.full_name)
.update_all(legal_first_name: parsed.first_name, legal_last_name: parsed.last_name)
end

end
end
3 changes: 3 additions & 0 deletions app/views/api/v4/users/_user.json.jbuilder
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,10 @@ object_shape(json, user, created_at: false) do
json.email user.email
end

# Other org members only see `name`, which respects the preferred name.
expand_pii(override_if: user == current_user) do
json.first_name user.first_name(legal: true)
json.last_name user.last_name(legal: true)
json.birthday user.birthday
if expand?(:shipping_address)
json.shipping_address do
Expand Down
15 changes: 10 additions & 5 deletions app/views/event/applications/edit.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -122,17 +122,22 @@

<div class="md:flex gap-2">
<div class="field flex-1">
<%= form.label :full_name do %>
Full name <span class="text-primary">*</span>
<%= form.label :legal_first_name do %>
Legal first name <span class="text-primary">*</span>
<% end %>
<%= form.text_field :full_name, value: @application.user.full_name.presence, required: true, placeholder: "Fiona Hackworth III", style: "max-width:100%", pattern: '^\S+.+' %>
<%= form.text_field :legal_first_name, value: @application.user.first_name(legal: true).presence, required: true, placeholder: "Fiona", style: "max-width:100%", autocomplete: "given-name" %>
</div>
<div class="field flex-1">
<%= form.label :preferred_name, "Preferred name (optional)" %>
<%= form.text_field :preferred_name, value: @application.user.preferred_name.presence, style: "max-width:100%", placeholder: "FiFi Hacks", maxlength: 30 %>
<%= form.label :legal_last_name, "Legal last name (optional)" %>
<%= form.text_field :legal_last_name, value: @application.user.last_name(legal: true).presence, placeholder: "Hackworth", style: "max-width:100%", autocomplete: "family-name" %>
</div>
</div>

<div class="field">
<%= form.label :preferred_name, "Preferred name (optional)" %>
<%= form.text_field :preferred_name, value: @application.user.preferred_name.presence, style: "max-width:100%", placeholder: "FiFi Hacks", maxlength: 30, autocomplete: "nickname" %>
</div>

<div class="md:flex items-center gap-2">
<div class="field flex-1">
<%= form.label :phone_number do %>
Expand Down
15 changes: 10 additions & 5 deletions app/views/event/applications/personal_info.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -17,17 +17,22 @@
<div class="card mb-6" x-data="{ minor: <%= @application.user.is_minor? || false %>, country: <%= @application.address_country.presence.to_json %>, disallowed_countries: <%= Event::Application::DISALLOWED_COUNTRIES.to_json %> }">
<div class="md:flex items-end gap-2">
<div class="field flex-1">
<%= form.label :full_name do %>
Full name <span class="text-primary">*</span>
<%= form.label :legal_first_name do %>
Legal first name <span class="text-primary">*</span>
<% end %>
<%= form.text_field :full_name, value: @application.user.full_name.presence, required: true, placeholder: "Fiona Hackworth III", style: "max-width:100%", pattern: '^\S+.+' %>
<%= form.text_field :legal_first_name, value: @application.user.first_name(legal: true).presence, required: true, placeholder: "Fiona", style: "max-width:100%", autocomplete: "given-name" %>
</div>
<div class="field flex-1">
<%= form.label :preferred_name, "Preferred name (optional)" %>
<%= form.text_field :preferred_name, value: @application.user.preferred_name.presence, style: "max-width:100%", placeholder: "FiFi Hacks", maxlength: 30 %>
<%= form.label :legal_last_name, "Legal last name (optional)" %>
<%= form.text_field :legal_last_name, value: @application.user.last_name(legal: true).presence, placeholder: "Hackworth", style: "max-width:100%", autocomplete: "family-name" %>
</div>
</div>

<div class="field">
<%= form.label :preferred_name, "Preferred name (optional)" %>
<%= form.text_field :preferred_name, value: @application.user.preferred_name.presence, style: "max-width:100%", placeholder: "FiFi Hacks", maxlength: 30, autocomplete: "nickname" %>
</div>

<div class="md:flex items-end gap-2">
<div class="field flex-1">
<%= form.label :phone_number do %>
Expand Down
Loading
Loading