Skip to content
Open
Show file tree
Hide file tree
Changes from 11 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/logins_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ def complete
if @login.complete? && @login.user_session.present?
if @referral_link.present?
redirect_to referral_link_path(@referral_link)
elsif (@user.full_name.blank? || @user.phone_number.blank?) && !@login.for_application?
elsif ((@user.first_name.blank? && @user.full_name.blank?) || @user.phone_number.blank?) && !@login.for_application?
redirect_to edit_user_path(@user.slug, return_to: @login.return_to)
elsif @login.authenticated_with_backup_code && @user.backup_codes.active.empty?
redirect_to security_user_path(@user), flash: { warning: "You've just used your last backup code, and we recommend generating more." }
Expand Down
2 changes: 1 addition & 1 deletion app/controllers/stripe_cards_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,7 @@ def suggested(field)
when :phone_number
current_user.phone_number
when :name
current_user.full_name
current_user.name
when :line1
current_user&.stripe_cardholder&.stripe_billing_address_line1 ||
ecr&.last&.shipping_address_street_one
Expand Down
9 changes: 8 additions & 1 deletion app/controllers/users_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,11 @@ def update
authorize @user

@user.assign_attributes(user_params)
if params[:user][:full_name].present? && params[:user][:first_name].blank? && params[:user][:last_name].blank?
parsed = UserService::ParseName.new(full_name: params[:user][:full_name]).run
@user.first_name = parsed.first_name
@user.last_name = parsed.last_name
end

if @user.use_two_factor_authentication_changed?
return unless enforce_sudo_mode # rubocop:disable Style/SoleNestedConditional
Expand Down Expand Up @@ -315,7 +320,7 @@ def update
if @user.save
confetti! if !@user.seasonal_themes_enabled_before_last_save && @user.seasonal_themes_enabled? # confetti if the user enables seasonal themes

if @user.full_name_before_last_save.blank?
if @user.first_name_before_last_save.blank? && @user.full_name_before_last_save.blank?
flash[:success] = "Profile created!"
redirect_to(return_to || root_path)
else
Expand Down Expand Up @@ -422,6 +427,8 @@ def set_onboarding
def user_params
attributes = [
# account
:first_name,
:last_name,
:full_name,
:preferred_name,
:phone_number,
Expand Down
2 changes: 1 addition & 1 deletion app/helpers/stripe_cards_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ def suggested(field)
when :phone_number
current_user.phone_number
when :name
current_user.full_name
current_user.name
when :line1
current_user&.stripe_cards&.physical&.last&.stripe_shipping_address_line1 ||
current_user&.stripe_cardholder&.stripe_billing_address_line1 ||
Expand Down
67 changes: 57 additions & 10 deletions app/models/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@
# comment_notifications :integer default("all_threads"), not null
# creation_method :integer
# email :text not null
# first_name :string
# full_name :string
# joined_as_teenager :boolean
# last_name :string
# locked_at :datetime
# monthly_donation_summary :boolean default(TRUE)
# monthly_follower_summary :boolean default(TRUE)
Expand Down Expand Up @@ -44,6 +46,12 @@
# index_users_on_slug (slug) UNIQUE
#
class User < ApplicationRecord
# Character regex for name validation (supports international characters)
NAME_CHARACTERS_REGEX = /\A[a-zA-ZàáâäãåąčćęèéêëėįìíîïłńòóôöõøùúûüųūÿýżźñçčšžÀÁÂÄÃÅĄĆČĖĘÈÉÊËÌÍÎÏĮŁŃÒÓÔÖÕØÙÚÛÜŲŪŸÝŻŹÑßÇŒÆČŠŽ∂ð.,' -]+\z/

# Full name format regex (must have first and last name - at least two words)
FULL_NAME_FORMAT_REGEX = /\A[a-zA-ZàáâäãåąčćęèéêëėįìíîïłńòóôöõøùúûüųūÿýżźñçčšžÀÁÂÄÃÅĄĆČĖĘÈÉÊËÌÍÎÏĮŁŃÒÓÔÖÕØÙÚÛÜŲŪŸÝŻŹÑßÇŒÆČŠŽ∂ð.,'-]+ [a-zA-ZàáâäãåąčćęèéêëėįìíîïłńòóôöõøùúûüųūÿýżźñçčšžÀÁÂÄÃÅĄĆČĖĘÈÉÊËÌÍÎÏĮŁŃÒÓÔÖÕØÙÚÛÜŲŪŸÝŻŹÑßÇŒÆČŠŽ∂ð.,' -]+\z/

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

include Hashid::Rails
Expand All @@ -64,7 +72,7 @@ class User < ApplicationRecord
tracked owner: proc{ |controller, record| record }, recipient: proc { |controller, record| record }, only: [:create, :update]

include PgSearch::Model
pg_search_scope :search_name, against: [:id, :full_name, :email, :phone_number], associated_against: { email_updates: :original }, using: { tsearch: { prefix: true, dictionary: "english" } }
pg_search_scope :search_name, against: [:id, :first_name, :last_name, :full_name, :email, :phone_number], associated_against: { email_updates: :original }, using: { tsearch: { prefix: true, dictionary: "english" } }

friendly_id :slug_candidates, use: :slugged
scope :admin, -> { where(access_level: [:admin, :superadmin]) }
Expand Down Expand Up @@ -173,6 +181,7 @@ class User < ApplicationRecord
before_update(if: :will_save_change_to_birthday?) { self.joined_as_teenager = was_teenager_on_join? }

before_create :format_number
before_save :sync_full_name_from_split_names, if: -> { first_name_changed? || last_name_changed? }
before_save :on_phone_number_update
validate :second_factor_present_for_2fa

Expand All @@ -190,10 +199,24 @@ class User < ApplicationRecord
validates_presence_of :birthday, if: -> { birthday_ciphertext_in_database.present? }

validates :full_name, format: {
with: /\A[a-zA-ZàáâäãåąčćęèéêëėįìíîïłńòóôöõøùúûüųūÿýżźñçčšžÀÁÂÄÃÅĄĆČĖĘÈÉÊËÌÍÎÏĮŁŃÒÓÔÖÕØÙÚÛÜŲŪŸÝŻŹÑßÇŒÆČŠŽ∂ð.,'-]+ [a-zA-ZàáâäãåąčćęèéêëėįìíîïłńòóôöõøùúûüųūÿýżźñçčšžÀÁÂÄÃÅĄĆČĖĘÈÉÊËÌÍÎÏĮŁŃÒÓÔÖÕØÙÚÛÜŲŪŸÝŻŹÑßÇŒÆČŠŽ∂ð.,' -]+\z/,
with: FULL_NAME_FORMAT_REGEX,
message: "must contain your first and last name, and can't contain special characters.", allow_blank: true,
}, unless: -> { first_name_in_database.present? || last_name_in_database.present? }
Comment thread
manu-gurudath marked this conversation as resolved.
Resolved

validates :first_name, presence: true, if: -> { first_name_in_database.present? || last_name_in_database.present? }
validates :first_name, format: {
with: NAME_CHARACTERS_REGEX,
message: "must contain only letters and can't contain special characters.", allow_blank: true,
}

validates :last_name, format: {
with: NAME_CHARACTERS_REGEX,
message: "must contain only letters and can't contain special characters.", allow_blank: true,
Comment thread
manu-gurudath marked this conversation as resolved.
Resolved
}

normalizes :first_name, with: ->(name) { name.strip.presence }
normalizes :last_name, with: ->(name) { name.strip.presence }

validates :email, uniqueness: true, presence: true
validates_email_format_of :email
normalizes :email, with: ->(email) { email.strip.downcase }
Expand Down Expand Up @@ -288,31 +311,51 @@ def remove_admin!
end

def first_name(legal: false)
@first_name ||= (namae(legal:)&.given || namae(legal:)&.particle)&.split(" ")&.first
# Return the stored first_name as-is. The database stores the complete first name
# (which may include particles like "von" from Namae parsing).
# For legal names, fall back to parsing full_name if first_name is not set.
read_attribute(:first_name) || (legal ? (namae(legal:)&.given || namae(legal:)&.particle)&.join(" ") : nil)
Comment thread
manu-gurudath marked this conversation as resolved.
Resolved
end

def last_name(legal: false)
@last_name ||= namae(legal:)&.family&.split(" ")&.last
# Return the stored last_name as-is. The database stores the complete last name
# (which may include suffixes like "Jr." from Namae parsing).
# For legal names, fall back to parsing full_name if last_name is not set.
read_attribute(:last_name) || (legal ? namae(legal:)&.family : nil)
end

def reconstructed_full_name
parts = [self[:first_name], self[:last_name]].compact_blank
parts.empty? ? nil : parts.join(" ")
end

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]}"
first = (read_attribute(:first_name)&.split(" ")&.first || read_attribute(:last_name)&.split(" ")&.first || name)[0..20]
last_source = read_attribute(:last_name) || read_attribute(:first_name) || ""
Comment thread
manu-gurudath marked this conversation as resolved.
Resolved
last = (last_source.split(" ").first || "")[0, 1]
last.blank? ? first : "#{first} #{last}"
end
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

reconstructed = reconstructed_full_name
return reconstructed if reconstructed && reconstructed.length <= 24

# fall back to the legacy full_name which may contain a suffix that was
# stripped during migration but is still shorter than the preferred name
return full_name if full_name && full_name.length <= 24

initial_name
end

def name
preferred_name.presence || full_name.presence || email_handle
preferred_name.presence || reconstructed_full_name || full_name.presence || email_handle
end

def possessive_name
Expand Down Expand Up @@ -377,11 +420,11 @@ def unlock!

def onboarding?
# in_database to prevent a blank name update attempt from triggering onboarding.
full_name_in_database.blank?
first_name_in_database.blank? && full_name_in_database.blank?
end

def was_onboarding?
full_name_before_last_save.blank? && full_name_previously_changed?
first_name_before_last_save.blank? && full_name_before_last_save.blank? && (first_name_previously_changed? || full_name_previously_changed?)
end

def active_mailbox_address
Expand Down Expand Up @@ -625,6 +668,10 @@ def to_combobox_display

private

def sync_full_name_from_split_names
self.full_name = reconstructed_full_name if reconstructed_full_name.present?
end

def accessible_events(roles:)
event_ids = User::PermissionsOverview.new(user: self).role_by_event_id.select { |_, role| role.in?(roles) }.keys
Event.where(id: event_ids)
Expand Down
55 changes: 55 additions & 0 deletions app/services/user_service/parse_name.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# frozen_string_literal: true

require "namae"

module UserService
# Parses a full name into first and last name components using the Namae gem.
# Handles complex names with particles (von, de, etc.) and suffixes (Jr., Sr., III, etc.)
class ParseName
attr_reader :given_name, :particle, :family_name, :suffix

def initialize(full_name:)
@full_name = full_name
@given_name = nil
@particle = nil
@family_name = nil
@suffix = nil
end

# Parse the full name and extract components
def run
return self if @full_name.blank?

parsed = Namae.parse(@full_name).first
return self if parsed.nil?

@given_name = parsed.given
@particle = parsed.particle
@family_name = parsed.family
@suffix = parsed.suffix

self
end

# Returns the formatted first name (given name + particle if present).
# Stores what should go in the database first_name column.
# Example: "von Braun" for "Wernher von Braun"
Comment thread
manu-gurudath marked this conversation as resolved.
Resolved
def first_name
return nil if @given_name.nil? && @family_name.nil?

parts = [@given_name, @particle].compact_blank
return @family_name if parts.empty?

parts.join(" ").presence
end

# Returns the formatted last name (family name + suffix if present).
# Stores what should go in the database last_name column.
# Example: "King Jr." for "Martin Luther King Jr."
def last_name
parts = [@family_name, @suffix].compact_blank
parts.empty? ? nil : parts.join(" ").presence
end
Comment thread
manu-gurudath marked this conversation as resolved.
Resolved
end
end

Comment thread
manu-gurudath marked this conversation as resolved.
Resolved
2 changes: 2 additions & 0 deletions app/views/api/v4/users/_user.json.jbuilder
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ json.name user.initial_name
# those which we'd like to expose less of:
expand_pii(override_if: user == current_user || local_assigns[:show_email]) do
json.email user.email
json.first_name user.first_name
json.last_name user.last_name
end

expand_pii(override_if: user == current_user) do
Expand Down
2 changes: 1 addition & 1 deletion app/views/stripe_cards/_form.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@
<h2 class="grid__fill">Shipping info</h2>
<div class="field">
<%= form.label :stripe_shipping_name, "Shipping name" %>
<%= form.text_field :stripe_shipping_name, value: current_user&.full_name, disabled: @event&.demo_mode? %>
<%= form.text_field :stripe_shipping_name, value: current_user&.name, disabled: @event&.demo_mode? %>
</div>
<div class="field">
<%= form.label :stripe_shipping_address_line1, "Address (line 1)" %>
Expand Down
34 changes: 26 additions & 8 deletions app/views/users/edit.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -34,15 +34,25 @@
<%= form_errors @user, "profile", "We couldn't update your" %>
<div class="md:flex gap-2">
<div class="field flex-1">
<%= form.label :full_name %>
<%= form.text_field :full_name, required: true, placeholder: "Fiona Hackworth III", style: "max-width:100%", pattern: '^\S+.+' %>
<% @user.errors.full_messages_for(:full_name).each do |message| %>
<%= form.label :first_name %>
<%= form.text_field :first_name, required: true, placeholder: "Fiona", style: "max-width:100%" %>
<% @user.errors.full_messages_for(:first_name).each do |message| %>
<div class="primary"><%= message %></div>
<% end %>
</div>
<div class="field flex-1">
<%= form.label :last_name, "Last name (optional)" %>
<%= form.text_field :last_name, style: "max-width:100%", placeholder: "Hackworth" %>
<% @user.errors.full_messages_for(:last_name).each do |message| %>
<div class="primary"><%= message %></div>
<% end %>
</div>
</div>

<div class="md:flex gap-2">
<div class="field flex-1">
<%= form.label :preferred_name, "Preferred name (optional)" %>
<%= form.text_field :preferred_name, style: "max-width:100%", placeholder: @user.full_name.presence || "FiFi Hacks", maxlength: 30 %>
<%= form.text_field :preferred_name, style: "max-width:100%", placeholder: "FiFi Hacks", maxlength: 30 %>
</div>
</div>

Expand Down Expand Up @@ -137,16 +147,24 @@
<% end %>

<div class="field">
<%= form.label :full_name %>
<%= form.text_field :full_name, required: true, placeholder: "Fiona Hackworth III", pattern: '^\S+.+', disabled: %>
<% @user.errors.full_messages_for(:full_name).each do |message| %>
<%= form.label :first_name %>
<%= form.text_field :first_name, required: true, placeholder: "Fiona", disabled: %>
<% @user.errors.full_messages_for(:first_name).each do |message| %>
<div class="primary"><%= message %></div>
<% end %>
</div>
<div class="field">
<%= form.label :last_name %>
<p class="h5 muted mt0 mb1">Optional - you can have just a first name if you prefer.</p>
<%= form.text_field :last_name, placeholder: "Hackworth", disabled: %>
<% @user.errors.full_messages_for(:last_name).each do |message| %>
<div class="primary"><%= message %></div>
<% end %>
</div>
<div class="field">
<%= form.label :preferred_name %>
<p class="h5 muted mt0 mb1">If you go by a different name.</p>
<%= form.text_field :preferred_name, placeholder: @user.full_name.presence || "FiFi Hacks", maxlength: 30, disabled: %>
<%= form.text_field :preferred_name, placeholder: "FiFi Hacks", maxlength: 30, disabled: %>
</div>

<div class="field">
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
class AddFirstAndLastNamesToUsers < ActiveRecord::Migration[8.0]
def change
add_column :users, :first_name, :string
add_column :users, :last_name, :string
end
end
29 changes: 29 additions & 0 deletions db/migrate/20260301000002_populate_first_and_last_names.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
class PopulateFirstAndLastNames < ActiveRecord::Migration[8.0]
BATCH_SIZE = 500

def up
require "namae"

updates = []

User.where.not(full_name: nil).select(:id, :full_name).find_each do |user|
namae = Namae.parse(user.full_name).first
updates << {
id: user.id,
first_name: [namae&.given, namae&.particle].compact_blank.join(" ").presence || namae&.family,
last_name: [namae&.family, namae&.suffix].compact_blank.join(" ").presence,
}

if updates.size >= BATCH_SIZE
User.upsert_all(updates, update_only: [:first_name, :last_name], unique_by: :id)
updates.clear
end
end

User.upsert_all(updates, update_only: [:first_name, :last_name], unique_by: :id) if updates.any?
Comment thread
manu-gurudath marked this conversation as resolved.
Resolved
end

def down
User.update_all(first_name: nil, last_name: nil)
end
end
2 changes: 2 additions & 0 deletions db/schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2653,8 +2653,10 @@
t.integer "creation_method"
t.string "discord_id"
t.text "email", null: false
t.string "first_name"
t.string "full_name"
t.boolean "joined_as_teenager"
t.string "last_name"
t.datetime "locked_at", precision: nil
t.boolean "monthly_donation_summary", default: true
t.boolean "monthly_follower_summary", default: true
Expand Down
Loading
Loading