fix(users): registration was broken -- users.first_name/last_name never existed
All checks were successful
build-and-release / build (cron) (push) Successful in 1m2s
build-and-release / build (catering-services) (push) Successful in 1m36s
build-and-release / build (companies) (push) Successful in 2m11s
build-and-release / build (employees) (push) Successful in 2m25s
build-and-release / build (developers) (push) Successful in 2m47s
build-and-release / build (customers) (push) Successful in 3m5s
build-and-release / build (gateway) (push) Successful in 1m46s
build-and-release / build (fitness-trainers) (push) Successful in 2m44s
build-and-release / build (graphic-designers) (push) Successful in 2m0s
build-and-release / build (jobs) (push) Successful in 1m52s
build-and-release / build (makeup-artists) (push) Successful in 1m40s
build-and-release / build (job-seekers) (push) Successful in 3m1s
build-and-release / build (social-media-managers) (push) Successful in 1m54s
build-and-release / build (payments) (push) Successful in 2m57s
build-and-release / build (photographers) (push) Successful in 2m57s
build-and-release / build (tutors) (push) Successful in 2m43s
backend-integration-tests / ai-credits (push) Successful in 1m9s
build-and-release / build (ugc-content-creators) (push) Successful in 3m12s
build-and-release / build-db-migrate (push) Successful in 2m23s
build-and-release / build (video-editors) (push) Successful in 2m46s
build-and-release / build (users) (push) Successful in 4m56s

Root cause, traced via git history: commit a3076ed ("update DB schema -
split users.first_name, users.last_name, roles split", 2026-04-15)
rewrote ~35 files across the codebase (UserRepository, and read sites
throughout apps/users, apps/companies, apps/job_seekers,
apps/customers, crates/contracts) to use users.first_name/last_name
instead of users.full_name -- but never added the migration to
actually create those columns. UserRepository::create is the only
INSERT into users and the live registration path; it's been trying to
insert into nonexistent columns ever since.

Confirmed this independently by spinning up Postgres locally, applying
every migration in crates/db/migrations (the exact set Dockerfile.migrate
bakes into the deployed db-migrate image, so this isn't a
theoretical-config-drift argument -- it's what's actually shipped) with
DROP_EXISTING_TABLES unset against a clean DB, and running the real
UserRepository::create call: `column "first_name" of relation "users"
does not exist`.

Finishes what that commit should have done: adds first_name/last_name
to users, backfilled from the pre-existing full_name (naive split on
first space). Does NOT drop full_name -- apps/payments (invoice/billing
legal names) and job_seeker_profiles still correctly read it, so
UserRepository::create now also derives and writes full_name from
first_name/last_name, keeping both representations correct for every
user going forward instead of only whichever one a given caller reads.

Verified against a real local Postgres, not just compiled:
- All 40 migrations in this directory apply cleanly in sequence, and
  a second and third run are fully idempotent (this surfaced three
  more of my own earlier migrations this session -- job_applications
  unique constraint, company_profiles non-negative checks, lead_requests
  FKs -- that used bare `ADD CONSTRAINT` with no IF NOT EXISTS guard;
  since this repo's db-migrate has no migration-tracking table and
  re-executes every .up.sql on every deploy, those would have errored
  on the second deploy and silently blocked every migration after them
  alphabetically, including all of today's other fixes. Fixed to match
  the DO $$ ... pg_constraint IF NOT EXISTS pattern already used
  elsewhere in this migrations directory.)
- A standalone harness actually calling UserRepository::create against
  that live DB confirms registration succeeds and full_name ends up
  correctly populated.
- The backfill was verified against simulated legacy rows (full_name
  only, both single- and multi-word names).
- cargo test -p db (real integration tests, not just compile-checked --
  requires TEST_DATABASE_URL, which is why these hadn't run all session)
  passes in full: 8 tests including concurrent_reservations_cannot_
  overdraw_wallet and expired_hold_is_swept_and_credits_return_to_available.
This commit is contained in:
Ashwin Kumar Sivakumar 2026-08-18 01:58:23 +05:30
parent efd2ec6222
commit c5b3a9583d
6 changed files with 116 additions and 15 deletions

View file

@ -11,11 +11,26 @@
-- Safe to run live: any pre-existing duplicates would violate the new
-- constraint and abort the migration, so this fails loudly rather than
-- silently if the race has already produced duplicate rows somewhere.
--
-- Guarded IF NOT EXISTS (Postgres has no ADD CONSTRAINT IF NOT EXISTS) --
-- this repo's db-migrate runner has no migration-tracking table and
-- re-executes every .up.sql file on every deploy, so every migration must
-- be safe to run more than once. This one originally wasn't: a bare ADD
-- CONSTRAINT on a second run errored with "already exists" and, since the
-- runner aborts the whole batch on first error, silently blocked every
-- migration alphabetically after it from ever applying again. Matches the
-- DO $$ ... pg_constraint pattern already used elsewhere in this directory
-- (e.g. 20260721030000_create_lead_requests.up.sql).
BEGIN;
ALTER TABLE job_applications
ADD CONSTRAINT job_applications_job_id_applicant_user_id_key
UNIQUE (job_id, applicant_user_id);
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'job_applications_job_id_applicant_user_id_key') THEN
ALTER TABLE job_applications
ADD CONSTRAINT job_applications_job_id_applicant_user_id_key
UNIQUE (job_id, applicant_user_id);
END IF;
END $$;
COMMIT;

View file

@ -10,12 +10,30 @@
-- Safe to run live: fails loudly rather than silently if any row has
-- already gone negative, same reasoning as the job_applications unique
-- constraint migration.
--
-- Guarded IF NOT EXISTS (Postgres has no ADD CONSTRAINT IF NOT EXISTS) --
-- this repo's db-migrate runner has no migration-tracking table and
-- re-executes every .up.sql on every deploy, so every migration must
-- tolerate being run more than once. A bare ADD CONSTRAINT here originally
-- didn't, which (since the runner aborts the whole batch on first error)
-- would have silently blocked every migration alphabetically after this
-- one from ever applying on the second deploy onward.
BEGIN;
ALTER TABLE company_profiles
ADD CONSTRAINT company_profiles_free_job_slots_non_negative CHECK (free_job_slots >= 0),
ADD CONSTRAINT company_profiles_purchased_job_slots_non_negative CHECK (purchased_job_slots >= 0),
ADD CONSTRAINT company_profiles_free_contact_views_non_negative CHECK (free_contact_views >= 0),
ADD CONSTRAINT company_profiles_purchased_contact_views_non_negative CHECK (purchased_contact_views >= 0);
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'company_profiles_free_job_slots_non_negative') THEN
ALTER TABLE company_profiles ADD CONSTRAINT company_profiles_free_job_slots_non_negative CHECK (free_job_slots >= 0);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'company_profiles_purchased_job_slots_non_negative') THEN
ALTER TABLE company_profiles ADD CONSTRAINT company_profiles_purchased_job_slots_non_negative CHECK (purchased_job_slots >= 0);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'company_profiles_free_contact_views_non_negative') THEN
ALTER TABLE company_profiles ADD CONSTRAINT company_profiles_free_contact_views_non_negative CHECK (free_contact_views >= 0);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'company_profiles_purchased_contact_views_non_negative') THEN
ALTER TABLE company_profiles ADD CONSTRAINT company_profiles_purchased_contact_views_non_negative CHECK (purchased_contact_views >= 0);
END IF;
END $$;
COMMIT;

View file

@ -9,12 +9,21 @@
-- Safe to run live: fails loudly rather than silently if any row already
-- has a dangling reference, same reasoning as the job_applications unique
-- constraint migration.
--
-- Guarded IF NOT EXISTS (Postgres has no ADD CONSTRAINT IF NOT EXISTS) --
-- this repo's db-migrate runner has no migration-tracking table and
-- re-executes every .up.sql on every deploy, so every migration must
-- tolerate being run more than once.
BEGIN;
ALTER TABLE lead_requests
ADD CONSTRAINT lead_requests_lead_id_fkey
FOREIGN KEY (lead_id) REFERENCES leads(id),
ADD CONSTRAINT lead_requests_user_role_profile_id_fkey
FOREIGN KEY (user_role_profile_id) REFERENCES user_role_profiles(id);
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'lead_requests_lead_id_fkey') THEN
ALTER TABLE lead_requests ADD CONSTRAINT lead_requests_lead_id_fkey FOREIGN KEY (lead_id) REFERENCES leads(id);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'lead_requests_user_role_profile_id_fkey') THEN
ALTER TABLE lead_requests ADD CONSTRAINT lead_requests_user_role_profile_id_fkey FOREIGN KEY (user_role_profile_id) REFERENCES user_role_profiles(id);
END IF;
END $$;
COMMIT;

View file

@ -0,0 +1,5 @@
BEGIN;
ALTER TABLE users
DROP COLUMN IF EXISTS first_name,
DROP COLUMN IF EXISTS last_name;
COMMIT;

View file

@ -0,0 +1,40 @@
-- Root cause of a live bug: commit a3076ed ("update DB schema - split
-- users.first_name, users.last_name, roles split", 2026-04-15) rewrote
-- ~35 files across the codebase -- UserRepository::create/get_*, and read
-- sites throughout apps/users, apps/companies, apps/job_seekers,
-- apps/customers, crates/contracts -- to use users.first_name/last_name
-- instead of users.full_name. It never added the migration to actually
-- create those columns. users.create (the only INSERT into this table,
-- and the live registration path) has been trying to insert into
-- first_name/last_name ever since, which errors outright -- confirmed by
-- replaying every migration in this directory (the exact set baked into
-- the deployed nxtgauge-db-migrate image, see Dockerfile.migrate) against
-- a clean Postgres and attempting the same INSERT UserRepository::create
-- issues: `column "first_name" of relation "users" does not exist`.
--
-- This migration finishes what that commit should have done: add the
-- columns, backfill from the pre-existing full_name (naive split on the
-- first space -- good enough for a one-time backfill, not meant to handle
-- every name format perfectly). full_name itself is NOT dropped: other
-- code (apps/payments -- billing/invoice legal names, job_seeker_profiles)
-- still correctly reads it, so the accompanying fix to
-- UserRepository::create also writes full_name going forward so both
-- stay populated for any given user, not just whichever one happened to
-- be written.
BEGIN;
ALTER TABLE users
ADD COLUMN IF NOT EXISTS first_name VARCHAR(255),
ADD COLUMN IF NOT EXISTS last_name VARCHAR(255);
UPDATE users
SET
first_name = COALESCE(first_name, NULLIF(split_part(full_name, ' ', 1), '')),
last_name = COALESCE(
last_name,
NULLIF(trim(substring(full_name FROM length(split_part(full_name, ' ', 1)) + 1)), '')
)
WHERE full_name IS NOT NULL
AND (first_name IS NULL OR last_name IS NULL);
COMMIT;

View file

@ -51,10 +51,23 @@ pub struct UserRepository;
impl UserRepository {
pub async fn create(pool: &PgPool, payload: CreateUserPayload) -> Result<User, sqlx::Error> {
// full_name is still the column apps/payments (invoice/billing
// legal names) and job_seeker_profiles read -- see the migration
// that added first_name/last_name for the full story. Writing it
// here too, derived from first_name/last_name, keeps both
// representations correct for every newly-created user instead of
// only whichever one a given caller happens to read.
let full_name = match (&payload.first_name, &payload.last_name) {
(Some(f), Some(l)) if !l.trim().is_empty() => Some(format!("{f} {l}")),
(Some(f), _) => Some(f.clone()),
(None, Some(l)) => Some(l.clone()),
(None, None) => None,
};
let user = sqlx::query_as::<_, User>(
r#"
INSERT INTO users (first_name, last_name, email, password_hash, email_verified, phone_verified)
VALUES ($1, $2, $3, $4, false, false)
INSERT INTO users (first_name, last_name, full_name, email, password_hash, email_verified, phone_verified)
VALUES ($1, $2, $3, $4, $5, false, false)
RETURNING
id, reference_number, email, password_hash, first_name, last_name,
email_verified, phone_verified, status,
@ -65,6 +78,7 @@ impl UserRepository {
)
.bind(&payload.first_name)
.bind(&payload.last_name)
.bind(&full_name)
.bind(payload.email.to_lowercase())
.bind(payload.password_hash)
.fetch_one(pool)