Compare commits

...

1 commit

Author SHA1 Message Date
Randall Stillwell
5db58baa93 feat(migrations): reconcile user profile missed by initial-schema backfill (B5)
Folds the union of DDL effects from `scripts/add-user-profile-columns.js`
(#8) and `scripts/add-user-profile-fields.js` (#9) into the migration
history so a brand-new Neon branch reaches structural parity with prod.

Captures:
- 16 ALTER TABLE users ADD COLUMN IF NOT EXISTS (first_name, last_name,
  username UNIQUE, profile_image_url, bio, avatar_url, favorite_games,
  collection_visibility, preferred_currency, cards_per_page, default_view,
  notifications_email, notifications_marketing, two_factor_enabled, theme,
  language). Per Finding 2, BOTH profile_image_url and avatar_url are kept
  (cleanup deferred to queued unify-user-avatar-column).
- 2 CREATE TABLE IF NOT EXISTS (user_settings, user_avatars) — FK to users
  with ON DELETE CASCADE; user_settings has UNIQUE(user_id, setting_key).
- 6 CREATE INDEX IF NOT EXISTS (idx_users_username, idx_users_email,
  idx_user_settings_user_id, idx_user_settings_key, idx_user_avatars_user_id,
  idx_user_avatars_active).
- 6 CHECK constraints wrapped in DO $$ EXCEPTION WHEN duplicate_object
  blocks (Postgres pre-15 has no ADD CONSTRAINT IF NOT EXISTS for CHECK):
  check_collection_visibility, check_preferred_currency, check_cards_per_page,
  check_default_view, check_theme, check_language.

Defaults-backfill DML from the historical script is intentionally NOT
replicated; column DEFAULTs handle fresh-env semantics and prod rows
already have the values from the historical run.

down() is a hard stub (rolling back would drop columns runtime code reads).

Convoy: reconcile-historical-add-scripts (Brief 5/7).
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-14 08:04:53 -05:00

View file

@ -0,0 +1,191 @@
/**
* Reconcile user profile schema (B5 of reconcile-historical-add-scripts).
*
* Captures the union of DDL effects from two historical scripts that ran
* against every long-lived env but were never folded into the migration
* history:
*
* - `scripts/add-user-profile-columns.js` (script #8) adds `first_name`,
* `last_name`, `username UNIQUE`, `profile_image_url`.
* - `scripts/add-user-profile-fields.js` (script #9) adds the same first
* three plus `bio`, `avatar_url`, 10 preference / notification / display
* columns, two new tables (`user_settings`, `user_avatars`), 6 CHECK
* constraints, and 6 indexes.
*
* Per the convoy's Finding 2, BOTH `profile_image_url` (from #8) AND
* `avatar_url` (from #9) coexist in prod and are read by runtime code
* (`pages/api/auth/register.js` reads `profile_image_url`; the user/avatar
* surface reads `avatar_url`). The redundancy is documented as a separate
* follow-up convoy `unify-user-avatar-column`; this migration must NOT pick
* one and drop the other.
*
* Idempotency (D2):
* - Every column ADD uses `ADD COLUMN IF NOT EXISTS`.
* - Both new tables use `CREATE TABLE IF NOT EXISTS`.
* - Every index uses `CREATE INDEX IF NOT EXISTS`.
* - CHECK constraints are wrapped in `DO $$ ... EXCEPTION WHEN
* duplicate_object THEN NULL; END $$;` blocks because Postgres pre-15
* does not accept `ADD CONSTRAINT ... IF NOT EXISTS` for CHECK
* constraints (matches the historical script's try/catch pattern, R5).
*
* Defaults-backfill DML (the `UPDATE users SET ... WHERE ... IS NULL` block
* at the end of script #9) is intentionally NOT replicated. The column
* DEFAULT clauses are sufficient on a fresh env (every new row gets the
* default at insert time) and prod-existing rows already have the values
* from the historical script's run.
*
* Down-migration is a hard stub. This is a reconciliation migration; rolling
* it back on prod would drop columns that runtime code reads.
*
* @type {import('node-pg-migrate').ColumnDefinitions | undefined}
*/
export const shorthands = undefined;
/**
* @param {import('node-pg-migrate').MigrationBuilder} pgm
* @returns {void}
*/
export const up = (pgm) => {
pgm.sql(`
ALTER TABLE users
ADD COLUMN IF NOT EXISTS first_name VARCHAR(255),
ADD COLUMN IF NOT EXISTS last_name VARCHAR(255),
ADD COLUMN IF NOT EXISTS username VARCHAR(255) UNIQUE,
ADD COLUMN IF NOT EXISTS profile_image_url TEXT,
ADD COLUMN IF NOT EXISTS bio TEXT,
ADD COLUMN IF NOT EXISTS avatar_url TEXT,
ADD COLUMN IF NOT EXISTS favorite_games JSONB DEFAULT '["MTG"]',
ADD COLUMN IF NOT EXISTS collection_visibility VARCHAR(20) DEFAULT 'private',
ADD COLUMN IF NOT EXISTS preferred_currency VARCHAR(3) DEFAULT 'USD',
ADD COLUMN IF NOT EXISTS cards_per_page INTEGER DEFAULT 50,
ADD COLUMN IF NOT EXISTS default_view VARCHAR(10) DEFAULT 'grid',
ADD COLUMN IF NOT EXISTS notifications_email BOOLEAN DEFAULT true,
ADD COLUMN IF NOT EXISTS notifications_marketing BOOLEAN DEFAULT false,
ADD COLUMN IF NOT EXISTS two_factor_enabled BOOLEAN DEFAULT false,
ADD COLUMN IF NOT EXISTS theme VARCHAR(10) DEFAULT 'system',
ADD COLUMN IF NOT EXISTS language VARCHAR(5) DEFAULT 'en';
`);
pgm.sql(`
CREATE TABLE IF NOT EXISTS user_settings (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
setting_key VARCHAR(100) NOT NULL,
setting_value JSONB NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id, setting_key)
);
`);
pgm.sql(`
CREATE TABLE IF NOT EXISTS user_avatars (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
filename VARCHAR(255) NOT NULL,
original_name VARCHAR(255),
mime_type VARCHAR(100),
file_size INTEGER,
file_path TEXT NOT NULL,
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
`);
pgm.sql(`
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
CREATE INDEX IF NOT EXISTS idx_user_settings_user_id ON user_settings(user_id);
CREATE INDEX IF NOT EXISTS idx_user_settings_key ON user_settings(setting_key);
CREATE INDEX IF NOT EXISTS idx_user_avatars_user_id ON user_avatars(user_id);
CREATE INDEX IF NOT EXISTS idx_user_avatars_active ON user_avatars(user_id, is_active);
`);
pgm.sql(`
DO $$
BEGIN
ALTER TABLE users
ADD CONSTRAINT check_collection_visibility
CHECK (collection_visibility IN ('private', 'public', 'unlisted'));
EXCEPTION
WHEN duplicate_object THEN NULL;
END$$;
`);
pgm.sql(`
DO $$
BEGIN
ALTER TABLE users
ADD CONSTRAINT check_preferred_currency
CHECK (preferred_currency IN ('USD', 'EUR', 'GBP', 'CAD', 'JPY'));
EXCEPTION
WHEN duplicate_object THEN NULL;
END$$;
`);
pgm.sql(`
DO $$
BEGIN
ALTER TABLE users
ADD CONSTRAINT check_cards_per_page
CHECK (cards_per_page IN (25, 50, 100));
EXCEPTION
WHEN duplicate_object THEN NULL;
END$$;
`);
pgm.sql(`
DO $$
BEGIN
ALTER TABLE users
ADD CONSTRAINT check_default_view
CHECK (default_view IN ('grid', 'list'));
EXCEPTION
WHEN duplicate_object THEN NULL;
END$$;
`);
pgm.sql(`
DO $$
BEGIN
ALTER TABLE users
ADD CONSTRAINT check_theme
CHECK (theme IN ('light', 'dark', 'system'));
EXCEPTION
WHEN duplicate_object THEN NULL;
END$$;
`);
pgm.sql(`
DO $$
BEGIN
ALTER TABLE users
ADD CONSTRAINT check_language
CHECK (language IN ('en', 'es', 'fr', 'de', 'ja'));
EXCEPTION
WHEN duplicate_object THEN NULL;
END$$;
`);
};
/**
* Down-migration is a hard stub. Rolling back this reconciliation would drop
* columns that runtime code reads (e.g. `pages/api/auth/register.js` writes
* and reads `profile_image_url`; `pages/api/user/settings.js` reads
* `theme` / `language` / etc.) and would also drop the `user_settings` and
* `user_avatars` tables along with any rows in them. If you need to roll
* forward instead, write a NEW dated migration with a real `down`.
*
* @returns {void}
*/
export const down = () => {
throw new Error(
'[migration:1781000000005_reconcile-user-profile] Refusing to roll back. ' +
'This is a reconciliation migration that captures DDL already in prod via ' +
'scripts/add-user-profile-columns.js and scripts/add-user-profile-fields.js; ' +
'rolling it back would drop columns that runtime code reads (profile_image_url, ' +
'avatar_url, theme, language, etc.) and the user_settings / user_avatars tables. ' +
'Write a new dated migration if a real schema correction is needed.'
);
};