From ad155d63447a49d966f8e6469a198f9589cb48a8 Mon Sep 17 00:00:00 2001 From: grabowski Date: Tue, 21 Apr 2026 16:40:40 +0700 Subject: [PATCH] create-user: look up company by slug and heal corrupted name If a previous run stored the company name with literal quotes ('B4L'), the current run's name-based select missed the row and the insert collided on the unique slug. Looking up by slug is the natural idempotency key here: slug is derived deterministically from name, so a new run and the corrupted row produce the same slug and therefore resolve to the same company row. If the stored name differs from the new one, heal it with an UPDATE and log the rename. Also tightens the membership lookup to (user_id, company_id) instead of first-match on user_id so re-running on a user with multiple companies does the right thing. Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/create-user.ts | 29 ++++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/scripts/create-user.ts b/scripts/create-user.ts index fac0221..a047bea 100644 --- a/scripts/create-user.ts +++ b/scripts/create-user.ts @@ -1,11 +1,18 @@ import 'dotenv/config'; -import { eq } from 'drizzle-orm'; +import { and, eq } from 'drizzle-orm'; import { pool } from '../src/lib/server/db/client'; import { db } from '../src/lib/server/db/client'; import { users, companies, companyUsers } from '../src/lib/server/db/schema/tenancy'; import { hashPassword } from '../src/lib/server/auth/password'; import { normalizeEmail } from '../src/lib/utils/email'; +function slugify(s: string): string { + return s + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, ''); +} + function stripSurroundingQuotes(s: string | undefined): string | undefined { if (!s || s.length < 2) return s; const first = s[0]; @@ -52,27 +59,39 @@ async function main() { } if (companyName) { - let [company] = await db.select().from(companies).where(eq(companies.name, companyName)).limit(1); + const slug = slugify(companyName); + // Look up by slug so a previously-corrupted name (e.g. "'B4L'" with literal + // quotes) doesn't cause a duplicate-slug insert on re-run. + let [company] = await db.select().from(companies).where(eq(companies.slug, slug)).limit(1); if (!company) { - const slug = companyName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); const [created] = await db .insert(companies) .values({ name: companyName, slug }) .returning(); company = created; console.log(`Created company ${companyName} (id ${company.id}).`); + } else if (company.name !== companyName) { + await db.update(companies).set({ name: companyName }).where(eq(companies.id, company.id)); + console.log( + `Renamed company ${JSON.stringify(company.name)} → ${JSON.stringify(companyName)} (id ${company.id}).` + ); + company = { ...company, name: companyName }; } + const [link] = await db .select() .from(companyUsers) - .where(eq(companyUsers.userId, userId)) + .where(and(eq(companyUsers.userId, userId), eq(companyUsers.companyId, company.id))) .limit(1); - if (!link || link.companyId !== company.id) { + if (!link) { await db .insert(companyUsers) .values({ companyId: company.id, userId, role }) .onConflictDoNothing(); console.log(`Linked user to company ${company.name} as ${role}.`); + } else if (link.role !== role) { + await db.update(companyUsers).set({ role }).where(eq(companyUsers.id, link.id)); + console.log(`Updated role in ${company.name} to ${role}.`); } }