---
convoy: reconcile-historical-add-scripts
brief_number: 5
depends_on: []
files:
  - migrations/1781000000005_reconcile-user-profile.js
---

# Brief 5: Reconcile user profile fields into migration history

## Goal (1 sentence)

Capture the deduplicated union of `scripts/add-user-profile-columns.js` and `scripts/add-user-profile-fields.js` (15 new `users` columns, 2 new tables `user_settings` + `user_avatars`, 6 CHECK constraints, 6 indexes) into a single new `node-pg-migrate` migration so a brand-new Neon branch ends up with the user-profile surface after `npm run setup-db`.

## Scope (files in scope — do not edit anything else)

- `migrations/1781000000005_reconcile-user-profile.js` — **new**

## Source scripts (read-only audit reference; DO NOT EDIT — both are no-go-zones)

### `scripts/add-user-profile-columns.js` lines 12-18 (the earlier, narrower script):

```js
await 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
`;
```

Plus per-row UPDATE backfill of defaults (lines 25-42). **Do NOT fold the backfill DML into this migration** — fresh envs have no rows to backfill; column DEFAULTs handle new rows.

### `scripts/add-user-profile-fields.js` lines 27-115 (the later, broader script — superset of #8 plus additional columns + 2 new tables + 6 CHECK constraints + 6 indexes):

```js
// Basic profile fields (overlaps add-user-profile-columns.js for first_name/last_name/username,
// but ADDS bio + avatar_url; idempotent overlap because of IF NOT EXISTS)
await 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 bio TEXT,
  ADD COLUMN IF NOT EXISTS avatar_url TEXT
`;

// Preference fields
await sql`
  ALTER TABLE users 
  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'
`;

// Notification + 2FA settings
await sql`
  ALTER TABLE users 
  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
`;

// Display settings
await sql`
  ALTER TABLE users 
  ADD COLUMN IF NOT EXISTS theme VARCHAR(10) DEFAULT 'system',
  ADD COLUMN IF NOT EXISTS language VARCHAR(5) DEFAULT 'en'
`;

// user_settings table
await 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)
  )
`;

// user_avatars table
await 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
  )
`;

// 6 indexes
await sql`CREATE INDEX IF NOT EXISTS idx_users_username ON users(username)`;
await sql`CREATE INDEX IF NOT EXISTS idx_users_email ON users(email)`;
await sql`CREATE INDEX IF NOT EXISTS idx_user_settings_user_id ON user_settings(user_id)`;
await sql`CREATE INDEX IF NOT EXISTS idx_user_settings_key ON user_settings(setting_key)`;
await sql`CREATE INDEX IF NOT EXISTS idx_user_avatars_user_id ON user_avatars(user_id)`;
await sql`CREATE INDEX IF NOT EXISTS idx_user_avatars_active ON user_avatars(user_id, is_active)`;

// 6 CHECK constraints (each wrapped in JS try/catch to swallow 'already exists')
await sql`ALTER TABLE users ADD CONSTRAINT check_collection_visibility CHECK (collection_visibility IN ('private', 'public', 'unlisted'))`;
await sql`ALTER TABLE users ADD CONSTRAINT check_preferred_currency   CHECK (preferred_currency IN ('USD', 'EUR', 'GBP', 'CAD', 'JPY'))`;
await sql`ALTER TABLE users ADD CONSTRAINT check_cards_per_page       CHECK (cards_per_page IN (25, 50, 100))`;
await sql`ALTER TABLE users ADD CONSTRAINT check_default_view         CHECK (default_view IN ('grid', 'list'))`;
await sql`ALTER TABLE users ADD CONSTRAINT check_theme                CHECK (theme IN ('light', 'dark', 'system'))`;
await sql`ALTER TABLE users ADD CONSTRAINT check_language             CHECK (language IN ('en', 'es', 'fr', 'de', 'ja'))`;
```

Plus a per-row UPDATE backfill of defaults (lines 198-222). **Do NOT fold the backfill DML in** — column DEFAULTs handle new rows; fresh envs have no rows to backfill.

### Important note on the column dedup

Scripts #8 and #9 overlap on `first_name`, `last_name`, `username` — both use `ADD COLUMN IF NOT EXISTS` so on prod the second-running script no-ops those three columns. Both scripts have run on prod, so the union of their columns is what's actually present:

- From #8 only: `profile_image_url` (TEXT) — a column that ONLY #8 added.
- From #9 only: `bio`, `avatar_url`, `favorite_games`, `collection_visibility`, `preferred_currency`, `cards_per_page`, `default_view`, `notifications_email`, `notifications_marketing`, `two_factor_enabled`, `theme`, `language` (12 columns) + the 2 new tables + 6 CHECK constraints + 6 indexes.
- From both (idempotent overlap): `first_name`, `last_name`, `username`.

The migration must add **all 15 columns** (4 from #8 ∪ 12 from #9 with 3 in the intersection = 4 + 12 - 3 = 13 unique users columns; wait, let me recount: #8 adds 4 (first_name, last_name, username, profile_image_url); #9 adds 5 basic (first_name, last_name, username, bio, avatar_url) + 5 prefs + 3 notif + 2 display = 15. Union: first_name, last_name, username (shared) + profile_image_url (#8) + bio, avatar_url, favorite_games, collection_visibility, preferred_currency, cards_per_page, default_view, notifications_email, notifications_marketing, two_factor_enabled, theme, language (#9) = **3 + 1 + 12 = 16 columns**). So the migration adds 16 columns to `users`.

The redundant `profile_image_url` vs `avatar_url` pair is documented in `docs/SCHEMA_MAP.md` § "Known schema smells" #1 and surfaced as the `unify-user-avatar-column` follow-up. Both must be in fresh envs for parity.

## Target migration file

Path: `migrations/1781000000005_reconcile-user-profile.js`

Contents:

```js
/**
 * Reconcile two overlapping historical user-profile scripts into the
 * migration history, taking the union of their effects:
 *
 *   - scripts/add-user-profile-columns.js (the earlier, narrower
 *     script): first_name, last_name, username UNIQUE, profile_image_url
 *
 *   - scripts/add-user-profile-fields.js (the later, broader script;
 *     overlaps the earlier script on first_name / last_name / username
 *     and additionally adds): bio, avatar_url, favorite_games (JSONB
 *     DEFAULT '["MTG"]'), collection_visibility, preferred_currency,
 *     cards_per_page, default_view, notifications_email,
 *     notifications_marketing, two_factor_enabled, theme, language,
 *     + the new user_settings + user_avatars tables, + 6 CHECK
 *     constraints, + 6 indexes.
 *
 * Result on a fresh env: 16 new columns on `users`, 2 new tables,
 * 6 CHECK constraints, 6 indexes. On any long-lived env: every
 * statement is a no-op (IF NOT EXISTS / DO $$ EXCEPTION).
 *
 * Per-row UPDATE backfills from both scripts are intentionally NOT
 * folded in — column DEFAULTs handle new rows; fresh envs have no
 * rows to backfill.
 *
 * The profile_image_url / avatar_url redundancy is intentional for
 * parity with prod and is flagged in docs/SCHEMA_MAP.md § "Known
 * schema smells" #1; future cleanup is the queued
 * `unify-user-avatar-column` follow-up.
 *
 * CHECK constraint adds wrap in DO $$ ... EXCEPTION WHEN
 * duplicate_object THEN NULL END $$ because Postgres doesn't accept
 * ADD CONSTRAINT ... IF NOT EXISTS for CHECK. Each constraint gets
 * its own DO block so a failure in one doesn't block the rest.
 *
 * @type {import('node-pg-migrate').ColumnDefinitions | undefined}
 */
export const shorthands = undefined;

/**
 * @param {import('node-pg-migrate').MigrationBuilder} pgm
 */
export const up = (pgm) => {
  pgm.sql(`
    -- 16 columns on users (union of add-user-profile-columns.js + add-user-profile-fields.js)
    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';

    -- user_settings table
    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)
    );

    -- user_avatars table
    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
    );

    -- 6 indexes
    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);

    -- 6 CHECK constraints (each in its own DO block so one failure doesn't block the rest)
    DO $$ BEGIN
      ALTER TABLE users ADD CONSTRAINT check_collection_visibility
        CHECK (collection_visibility IN ('private', 'public', 'unlisted'));
    EXCEPTION WHEN duplicate_object THEN NULL; END $$;

    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 $$;

    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 $$;

    DO $$ BEGIN
      ALTER TABLE users ADD CONSTRAINT check_default_view
        CHECK (default_view IN ('grid', 'list'));
    EXCEPTION WHEN duplicate_object THEN NULL; END $$;

    DO $$ BEGIN
      ALTER TABLE users ADD CONSTRAINT check_theme
        CHECK (theme IN ('light', 'dark', 'system'));
    EXCEPTION WHEN duplicate_object THEN NULL; END $$;

    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 intentionally throws. Dropping 16 user-profile columns
 * + user_settings + user_avatars on a long-lived env would erase every
 * user's profile data, preferences, avatar history, and settings, and
 * break runtime reads in pages/api/user/{settings,profile,avatar}.js.
 *
 * @returns {void}
 */
export const down = () => {
  throw new Error(
    '[migration:1781000000005_reconcile-user-profile] Down not supported. ' +
      'Dropping these columns + tables would erase every user profile, preference, ' +
      'avatar history, and settings row, and break runtime reads in ' +
      'pages/api/user/{settings,profile,avatar}.js. Write a new dated migration ' +
      'for any future schema correction.'
  );
};
```

## Conventions to follow

- `.cursor/rules/db-and-schema.mdc` § "Schema source of truth" — `migrations/` is canonical.
- `.cursor/rules/no-go-zones.mdc` — both source scripts are append-only history.
- Style: raw `pgm.sql(...)` template literals. Per D2.
- ESM exports; `"type": "module"`.
- `IF NOT EXISTS` on every ALTER + CREATE INDEX + CREATE TABLE.
- CHECK constraints in `DO $$ ... EXCEPTION WHEN duplicate_object THEN NULL END $$;` blocks, one block per constraint.
- Match the source scripts' column types, defaults, FK rules, and CHECK values verbatim.

## Acceptance criteria

- [ ] `migrations/1781000000005_reconcile-user-profile.js` exists with the exact filename above.
- [ ] The file's `up()` adds exactly **16 columns** to `users` (per the dedup count in the source-scripts note above), creates **2 tables** (`user_settings`, `user_avatars`), creates **6 indexes**, and adds **6 CHECK constraints** each in its own `DO $$ EXCEPTION` block.
- [ ] BOTH `profile_image_url` AND `avatar_url` are present (parity with prod; redundancy is documented).
- [ ] The file's `down()` throws with a clear message.
- [ ] The file's docstring cites both source scripts + the convoy file + the `unify-user-avatar-column` follow-up.
- [ ] Per-row UPDATE backfill DML is NOT in the migration.
- [ ] `node --check migrations/1781000000005_reconcile-user-profile.js` passes.
- [ ] `node -e "import('./migrations/1781000000005_reconcile-user-profile.js').then(m => console.log(typeof m.up, typeof m.down, typeof m.shorthands))"` prints `function function undefined`.
- [ ] `npm run lint` matches baseline.
- [ ] `npm run test:run` reports 21/21 passing.

## Verification

```bash
node --check migrations/1781000000005_reconcile-user-profile.js
node -e "import('./migrations/1781000000005_reconcile-user-profile.js').then(m => console.log(typeof m.up, typeof m.down, typeof m.shorthands))"
npm run lint
npm run test:run
```

Expected: `node --check` silent, `node -e` prints `function function undefined`, lint baseline, 21/21 tests pass.

**Do NOT** run `npm run migrate up` against any environment.

## Commit message

```
feat(migrations): reconcile user profile fields into migration history (brief 5/7)

Captures the deduplicated union of scripts/add-user-profile-columns.js
and scripts/add-user-profile-fields.js into one new node-pg-migrate
migration:

  - 16 columns on users (basic profile + preferences + notifications + display)
  - 2 new tables (user_settings, user_avatars)
  - 6 CHECK constraints (each in its own DO $$ EXCEPTION block)
  - 6 indexes

Both profile_image_url (from script #1) and avatar_url (from script #2)
are added for parity with prod. The redundancy is flagged in
docs/SCHEMA_MAP.md § "Known schema smells" #1 and queued for cleanup as
the `unify-user-avatar-column` follow-up.

Per-row UPDATE backfill DML from both scripts is intentionally NOT
folded in — column DEFAULTs handle new rows; fresh envs have no rows
to backfill.

Per .convoys/reconcile-historical-add-scripts.md § Brief outline → B5.
Idempotent re-apply (IF NOT EXISTS guards + per-CHECK DO blocks).
```

## PR shape

**Title:** `feat(migrations): reconcile user profile fields into migration history (brief 5/7)`

**Body template:**

```markdown
Brief 5 of the `reconcile-historical-add-scripts` convoy. See
[`.convoys/reconcile-historical-add-scripts.md`](../.convoys/reconcile-historical-add-scripts.md)
for the full plan and rationale.

## What this PR does

Adds `migrations/1781000000005_reconcile-user-profile.js` — captures
the deduplicated union of `scripts/add-user-profile-columns.js`
(earlier, narrower) and `scripts/add-user-profile-fields.js` (later,
broader) into a single new `node-pg-migrate` migration.

- 16 new columns on `users` (first_name, last_name, username UNIQUE,
  profile_image_url, bio, avatar_url, favorite_games JSONB, …, theme,
  language) — all `ADD COLUMN IF NOT EXISTS`
- 2 new tables: `user_settings`, `user_avatars` — both
  `CREATE TABLE IF NOT EXISTS`
- 6 indexes — `CREATE INDEX IF NOT EXISTS`
- 6 CHECK constraints (each in its own
  `DO $$ ... EXCEPTION WHEN duplicate_object THEN NULL END $$;` block)

## Why both `profile_image_url` and `avatar_url`?

Script #1 added `profile_image_url`, script #2 added `avatar_url` —
both exist on prod and both are in this migration for fresh-env
parity. The redundancy is flagged in
`docs/SCHEMA_MAP.md` § "Known schema smells" #1 and queued for cleanup
as the `unify-user-avatar-column` follow-up.

## What this PR does NOT do

- Does **NOT** edit either source script (no-go-zones).
- Does **NOT** fold in the per-row UPDATE backfill DML from either
  source script — column DEFAULTs handle new rows.
- Does **NOT** unify `profile_image_url` / `avatar_url` — that's the
  `unify-user-avatar-column` follow-up.
- Does **NOT** edit `scripts/setup-neon-db.js`, `package.json`, README,
  `AGENTS.md`, or `docs/SCHEMA_MAP.md` (B7 owns SCHEMA_MAP).
- Does **NOT** run `npm run migrate up` against any environment.

## Verification checklist

- [ ] `node --check migrations/1781000000005_reconcile-user-profile.js` exits 0
- [ ] `node -e "import('./migrations/1781000000005_reconcile-user-profile.js').then(m => console.log(typeof m.up, typeof m.down, typeof m.shorthands))"` prints `function function undefined`
- [ ] `npm run lint` matches baseline
- [ ] `npm run test:run` reports 21/21 passing
- [ ] Did not run `npm run migrate up` against any environment in this PR

## Cross-references

- Convoy file: `.convoys/reconcile-historical-add-scripts.md`
- Source scripts (no-go-zones, audit reference only):
  `scripts/add-user-profile-columns.js`, `scripts/add-user-profile-fields.js`
```

## DO NOT

- DO NOT edit either source script or any file under `scripts/`.
- DO NOT edit any other file under `migrations/`.
- DO NOT edit `scripts/setup-neon-db.js`, `package.json`, README, `AGENTS.md`, or `docs/SCHEMA_MAP.md`.
- DO NOT run `npm run migrate up` against any environment.
- DO NOT pick one of `profile_image_url` / `avatar_url` to omit — both must be present for parity.
- DO NOT collapse the 6 CHECK constraints into a single `DO $$ EXCEPTION` block — one block per constraint so one duplicate doesn't swallow the others.
- DO NOT include the per-row UPDATE backfill DML.
- DO NOT call `npm run migrate create`.

## Rationale (≤3 sentences)

This is the largest single migration in the convoy because both historical scripts are tightly coupled to the `users` table surface and splitting them would create artificial boundaries (e.g., separating "users columns" from "CHECK constraints on users columns" makes no sense). Per-CHECK `DO $$ EXCEPTION` blocks mirror the historical scripts' per-statement try/catch pattern and ensure one duplicate-constraint failure doesn't block the rest. Keeping both avatar-style columns matches prod-as-is and explicitly defers the cleanup to a scoped follow-up convoy.
