brackt/scripts/migrate.mjs
Chris Parsons 2ced7f3ebf Add email verification for email/password signups
New email/password accounts must verify before signing in. Social login
users (Google, Discord) are unaffected. Existing users are grandfathered
via an idempotent migration. Adds a /check-email interstitial with a
resend button, and a --success CSS token for consistent styling.

Closes #343

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 23:53:13 -07:00

229 lines
7.3 KiB
JavaScript

import { drizzle } from "drizzle-orm/postgres-js";
import { migrate } from "drizzle-orm/postgres-js/migrator";
import postgres from "postgres";
import { fileURLToPath } from "url";
import path from "path";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const migrationUrl = process.env.DATABASE_DIRECT_URL || process.env.DATABASE_URL;
if (!migrationUrl) {
console.error("ERROR: DATABASE_URL (or DATABASE_DIRECT_URL) is required");
process.exit(1);
}
console.log("Running database migrations...");
const client = postgres(migrationUrl, {
max: 1,
onnotice: () => {}, // suppress NOTICE messages (schema/table already exists, etc.)
});
try {
const db = drizzle(client);
await migrate(db, { migrationsFolder: path.resolve(__dirname, "../drizzle") });
console.log("Migrations completed successfully");
} catch (err) {
console.error("Migration failed:", err);
await client.end().catch(() => {});
process.exit(1);
}
// ── Clerk → BetterAuth one-time data migration ────────────────────────────────
// FK conversion and email_verified always run (idempotent WHERE clauses).
// OAuth account import only runs when CLERK_SECRET_KEY is set.
console.log("Running FK conversion...");
try {
await convertForeignKeys(client);
} catch (err) {
console.error("FK conversion failed:", err);
await client.end().catch(() => {});
process.exit(1);
}
const CLERK_SECRET_KEY = process.env.CLERK_SECRET_KEY;
if (!CLERK_SECRET_KEY) {
console.log("CLERK_SECRET_KEY not set — skipping Clerk OAuth account import");
} else {
console.log("Running Clerk OAuth account import...");
try {
await importClerkOAuthAccounts(client, CLERK_SECRET_KEY);
console.log("Clerk OAuth account import complete");
} catch (err) {
console.error("Clerk OAuth account import failed:", err);
await client.end().catch(() => {});
process.exit(1);
}
}
await client.end().catch(() => {});
process.exit(0);
// ─────────────────────────────────────────────────────────────────────────────
async function convertForeignKeys(sql) {
// Convert teams.owner_id from Clerk IDs to users.id UUIDs.
// Safe to run repeatedly — WHERE clause only matches unconverted rows.
const teamsResult = await sql`
UPDATE teams t
SET owner_id = u.id::text
FROM users u
WHERE t.owner_id = u.clerk_id
AND u.clerk_id IS NOT NULL
`;
console.log(` teams.owner_id: ${teamsResult.count} rows converted`);
// Convert commissioners.user_id from Clerk IDs to users.id UUIDs
const commissResult = await sql`
UPDATE commissioners c
SET user_id = u.id::text
FROM users u
WHERE c.user_id = u.clerk_id
AND u.clerk_id IS NOT NULL
`;
console.log(` commissioners.user_id: ${commissResult.count} rows converted`);
// Convert leagues.created_by from Clerk IDs to users.id UUIDs
const leaguesResult = await sql`
UPDATE leagues l
SET created_by = u.id::text
FROM users u
WHERE l.created_by = u.clerk_id
AND u.clerk_id IS NOT NULL
`;
console.log(` leagues.created_by: ${leaguesResult.count} rows converted`);
// Mark all Clerk users as email-verified (they already verified via Clerk)
await sql`
UPDATE users
SET email_verified = true
WHERE clerk_id IS NOT NULL
AND email_verified = false
`;
// Grandfather pre-enforcement BetterAuth signups: they have no clerk_id and
// registered before email verification was required.
const grandfatherResult = await sql`
UPDATE users
SET email_verified = true
WHERE email_verified = false
AND clerk_id IS NULL
`;
if (grandfatherResult.count > 0) {
console.log(` grandfathered ${grandfatherResult.count} pre-enforcement email/password users`);
}
}
async function importClerkOAuthAccounts(sql, secretKey) {
// Migrate OAuth accounts from Clerk into BetterAuth's accounts table.
// Skip entirely if any OAuth accounts already exist to avoid duplicates.
// We check only OAuth providers (not 'credential') so running migrate-clerk-passwords.mjs
// first doesn't prevent OAuth accounts from being created.
const [{ count: existingCount }] = await sql`
SELECT COUNT(*)::int AS count FROM accounts WHERE provider_id IN ('google', 'discord')
`;
if (existingCount > 0) {
console.log(` ${existingCount} OAuth accounts already exist — skipping Clerk account import`);
return;
}
// Fetch all users with Clerk IDs from our DB
const dbUsers = await sql`
SELECT id, clerk_id FROM users WHERE clerk_id IS NOT NULL
`;
if (dbUsers.length === 0) {
console.log(" No Clerk users found in DB — nothing to migrate");
return;
}
console.log(` Found ${dbUsers.length} Clerk users in DB, fetching from Clerk API...`);
const dbUserByClerkId = new Map(dbUsers.map((u) => [u.clerk_id, u.id]));
// Fetch all users from Clerk API (paginated)
const clerkUsers = [];
let offset = 0;
const limit = 100;
while (true) {
const res = await fetch(
`https://api.clerk.com/v1/users?limit=${limit}&offset=${offset}`,
{ headers: { Authorization: `Bearer ${secretKey}` } }
);
if (!res.ok) {
throw new Error(`Clerk API error ${res.status}: ${await res.text()}`);
}
const batch = await res.json();
clerkUsers.push(...batch);
if (batch.length < limit) break;
offset += limit;
}
console.log(` Fetched ${clerkUsers.length} users from Clerk API`);
let googleCount = 0;
let discordCount = 0;
let emailPasswordCount = 0;
let skippedCount = 0;
for (const clerkUser of clerkUsers) {
const userId = dbUserByClerkId.get(clerkUser.id);
if (!userId) {
skippedCount++;
continue;
}
for (const ext of clerkUser.external_accounts ?? []) {
if (ext.provider === "google") {
await sql`
INSERT INTO accounts (id, account_id, provider_id, user_id, created_at, updated_at)
SELECT
gen_random_uuid()::text,
${ext.provider_user_id},
'google',
${userId}::uuid,
now(),
now()
WHERE NOT EXISTS (
SELECT 1 FROM accounts
WHERE user_id = ${userId}::uuid AND provider_id = 'google'
)
`;
googleCount++;
}
if (ext.provider === "discord") {
await sql`
INSERT INTO accounts (id, account_id, provider_id, user_id, created_at, updated_at)
SELECT
gen_random_uuid()::text,
${ext.provider_user_id},
'discord',
${userId}::uuid,
now(),
now()
WHERE NOT EXISTS (
SELECT 1 FROM accounts
WHERE user_id = ${userId}::uuid AND provider_id = 'discord'
)
`;
discordCount++;
}
}
if (clerkUser.password_enabled) {
emailPasswordCount++;
}
}
console.log(
` Accounts created: ${googleCount} Google, ${discordCount} Discord`
);
if (emailPasswordCount > 0) {
console.log(
` ${emailPasswordCount} email/password users — they must use "Forgot Password" on first login`
);
}
if (skippedCount > 0) {
console.log(` Skipped ${skippedCount} Clerk users with no matching DB row`);
}
}