Fix Google sign-in for existing email and ticket-buyer accounts.

Google sign-in only worked for people who already had a linked google
row in auth_accounts. Anyone who first appeared another way -- a guest
ticket purchase, or an email/password signup made after the Better Auth
migration -- got a 401 "account not linked".

trustedProviders: ['google'] defeats only one of better-auth's two
linking gates. The second, requireLocalEmailVerified, defaults to true
and refuses the link whenever the LOCAL users.email_verified is false,
independently of whether the provider is trusted. That flag is false for
every guest-booking row and for every post-migration signup, since
requireEmailVerification is off and no verification mail is sent.

Turn that gate off: the Google id_token is signature-verified against
Google's JWKS with issuer/audience/max-age checks and carries its own
email_verified, so the local column proves nothing extra here.

Linking alone was not enough. getAuthUser() rejects any session whose
user is not 'active', so a ticket buyer would link Google, receive a
cookie, and still look logged out. A databaseHooks.account.create.after
hook now promotes unclaimed rows to claimed/active when a google account
is attached, scoped in the WHERE clause so a suspended account is never
reactivated this way.

Also normalize users.email. The unique index is case-sensitive while
better-auth lowercases every lookup, so someone who booked as
John@Gmail.com was invisible to sign-in and Google minted a SECOND user
row, stranding their tickets on the first. normalizeEmail() covers the
find-or-create sites in tickets.ts and door.ts plus the claim-eligibility
lookup, and an idempotent migration lowercases existing rows -- skipping
any that would collide and reporting those for manual merge, since
merging two people's tickets and payments is not a migration's call.
tickets.attendeeEmail still stores the address exactly as typed.

Tests drive the real signInSocial id-token path with Google stubbed by
signing tokens with a throwaway RS256 key and serving our own JWKS, so
the actual verification runs without network or credentials. That also
makes the deprecation risk loud: requireLocalEmailVerified is marked for
removal upstream, and an upgrade that drops it now fails CI instead of
silently locking ticket buyers out again.

Frontend carries error.code through so OAUTH_LINK_ERROR renders an
actionable message in both locales rather than a bare "account not
linked".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Michilis
2026-08-23 05:31:02 +00:00
co-authored by Claude Opus 5
parent 3f7b2d51db
commit 87bf9a6151
9 changed files with 402 additions and 22 deletions
+53 -2
View File
@@ -1,6 +1,6 @@
import 'dotenv/config';
import { db, dbAll, events } from './index.js';
import { sql, eq } from 'drizzle-orm';
import { db, dbAll, dbGet, events, users } from './index.js';
import { sql, eq, ne } from 'drizzle-orm';
import { uniqueSlug } from '../lib/slugify.js';
const dbType = process.env.DB_TYPE || 'sqlite';
@@ -1344,6 +1344,57 @@ async function migrate() {
`);
}
// ==================== users.email normalization ====================
// Better Auth lowercases the address on every lookup and write it performs,
// but the users.email unique index is case-sensitive on both dialects. Rows
// written outside Better Auth (guest bookings, door sales, admin-added
// tickets) used to keep the address exactly as typed, so a buyer who entered
// "John@Gmail.com" was invisible to sign-in and to Google account linking:
// signing in with Google minted a SECOND user row and left their tickets
// stranded on the first. lib/utils.ts normalizeEmail() fixes new writes; this
// fixes the rows already in the table.
//
// Idempotent, and deliberately conservative: a row is only lowercased when
// nothing already occupies the lowercase address. A genuine collision means
// two user rows for the same person, each with its own tickets, invoices and
// payments — merging those is a judgement call, not a migration, so they are
// reported for manual review instead.
const lowercaseEmailsSql = `
UPDATE users SET email = LOWER(email)
WHERE email <> LOWER(email)
AND NOT EXISTS (
SELECT 1 FROM users u2 WHERE u2.id <> users.id AND u2.email = LOWER(users.email)
)
`;
if (dbType === 'sqlite') {
await (db as any).run(sql.raw(lowercaseEmailsSql));
} else {
await (db as any).execute(sql.raw(lowercaseEmailsSql));
}
// Whatever still differs from its own lowercase form is exactly the set the
// UPDATE refused to touch, i.e. the collisions.
const collisions = await dbAll<{ id: string; email: string }>(
(db as any)
.select({ id: (users as any).id, email: (users as any).email })
.from(users)
.where(ne((users as any).email, sql`LOWER(${(users as any).email})`))
);
if (collisions.length > 0) {
console.warn(
`WARNING: ${collisions.length} users row(s) keep a mixed-case email because the ` +
`lowercase address is already taken. Sign-in and Google linking only ever reach ` +
`the lowercase row, so these need a manual merge:`
);
for (const row of collisions) {
const canonical = row.email.toLowerCase();
const existing = await dbGet<{ id: string }>(
(db as any).select({ id: (users as any).id }).from(users).where(eq((users as any).email, canonical))
);
console.warn(` ${row.id} (${row.email}) -> keeps losing to ${existing?.id} (${canonical})`);
}
}
// Backfill slugs for any events that don't have one yet (shared across DB types).
// Ordered by creation so duplicate titles get deterministic -2, -3 suffixes.
const allEvents = await dbAll<{ id: string; title: string; slug: string | null }>(
+227 -1
View File
@@ -3,6 +3,8 @@ import { execFileSync } from 'child_process';
import { mkdtempSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { exportJWK, generateKeyPair, SignJWT } from 'jose';
import { normalizeEmail } from './utils.js';
// Environment must be pinned BEFORE the db/betterAuth singletons are imported
// (dotenv never overrides pre-set values).
@@ -13,7 +15,13 @@ process.env.DATABASE_URL = dbPath;
process.env.FRONTEND_URL = 'http://localhost:3002';
process.env.BETTER_AUTH_SECRET = 'integration-test-secret-0123456789abcdef';
delete process.env.REDIS_URL; // memory lockout/rate-limit backends
delete process.env.GOOGLE_CLIENT_ID;
// Google IS configured here: account linking is the whole point of the tests at
// the bottom of this file, and betterAuth.ts omits `socialProviders` entirely
// when this is unset. No real credentials are involved — the id tokens are
// signed with a throwaway keypair and Google's JWKS endpoint is stubbed below.
const GOOGLE_CLIENT_ID = 'spanglish-test.apps.googleusercontent.com';
process.env.GOOGLE_CLIENT_ID = GOOGLE_CLIENT_ID;
// Capture outgoing auth emails (magic links, password resets)
const sentEmails: Array<{ to: string; subject: string; html: string }> = [];
@@ -41,6 +49,85 @@ function extractToken(html: string, param = 'token'): string {
return decodeURIComponent(match![1]);
}
// ---- Google Identity Services stub -------------------------------------
// verifyGoogleIdToken() checks signature, issuer, audience and max age against
// Google's published JWKS; its only network call is that JWKS fetch. Signing
// with our own key and serving our own JWKS exercises the real verification
// path without touching the network or needing OAuth credentials.
const GOOGLE_KID = 'spanglish-test-key';
let googlePrivateKey: CryptoKey;
async function installGoogleStub() {
const { publicKey, privateKey } = await generateKeyPair('RS256', { extractable: true });
googlePrivateKey = privateKey as CryptoKey;
const jwk = { ...(await exportJWK(publicKey)), kid: GOOGLE_KID, alg: 'RS256', use: 'sig' };
const realFetch = globalThis.fetch;
globalThis.fetch = (async (input: any, init?: any) => {
const url = typeof input === 'string' ? input : (input?.url ?? String(input));
if (url.startsWith('https://www.googleapis.com/oauth2/v3/certs')) {
return new Response(JSON.stringify({ keys: [jwk] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
return realFetch(input, init);
}) as typeof fetch;
}
function googleIdToken(opts: { email: string; sub: string; name?: string; emailVerified?: boolean }) {
return new SignJWT({
email: opts.email,
email_verified: opts.emailVerified ?? true,
name: opts.name ?? 'Google User',
picture: 'https://example.test/avatar.png',
})
.setProtectedHeader({ alg: 'RS256', kid: GOOGLE_KID })
.setIssuer('https://accounts.google.com')
.setAudience(GOOGLE_CLIENT_ID)
.setSubject(opts.sub)
.setIssuedAt()
.setExpirationTime('10m')
.sign(googlePrivateKey);
}
async function signInWithGoogle(
opts: Parameters<typeof googleIdToken>[0],
returnHeaders = false
): Promise<any> {
const token = await googleIdToken(opts);
return auth.api.signInSocial({
body: { provider: 'google', idToken: { token } },
headers: new Headers(),
...(returnHeaders ? { returnHeaders: true } : {}),
} as any);
}
/** Insert a user the way a guest booking does (routes/tickets.ts, routes/door.ts):
* unclaimed, unverified, and with no auth_accounts row at all. */
function insertBookingUser(id: string, email: string, name = 'Ticket Buyer') {
const now = new Date().toISOString();
sqlite
.prepare(
`INSERT INTO users (id, email, password, name, role, is_claimed, account_status, email_verified, created_at, updated_at)
VALUES (?, ?, NULL, ?, 'user', 0, 'unclaimed', 0, ?, ?)`
)
.run(id, email, name, now, now);
return id;
}
function userRow(email: string) {
return sqlite
.prepare('SELECT id, email, is_claimed, account_status, email_verified FROM users WHERE email = ?')
.get(email);
}
function googleAccountsFor(userId: string) {
return sqlite
.prepare("SELECT id, account_id FROM auth_accounts WHERE user_id = ? AND provider_id = 'google'")
.all(userId);
}
function cookieHeaders(setCookie: string | null): Headers {
const sessionPart = (setCookie || '')
.split(/,(?=[^ ;]+=)/)
@@ -60,6 +147,7 @@ beforeAll(() => {
({ db } = await import('../db/index.js'));
const Database = (await import('better-sqlite3')).default;
sqlite = new Database(dbPath);
await installGoogleStub();
})();
}, 120_000);
@@ -291,3 +379,141 @@ describe('Better Auth integration', () => {
expect(days).toBeLessThan(7.5);
});
});
describe('Google sign-in and account linking', () => {
it('links Google onto a guest-booking user instead of failing with "account not linked"', async () => {
const id = insertBookingUser('booking-user-1', 'buyer@test.py');
expect(userRow('buyer@test.py').email_verified).toBe(0);
const res = await signInWithGoogle({ email: 'buyer@test.py', sub: 'google-sub-buyer' });
expect(res.user.id).toBe(id);
expect(googleAccountsFor(id)).toHaveLength(1);
expect(sqlite.prepare('SELECT COUNT(*) AS n FROM users WHERE email = ?').get('buyer@test.py').n).toBe(1);
});
it('claims the booking account so the resulting session is actually accepted', async () => {
// getAuthUser() (lib/auth.ts) rejects any session whose user is not
// 'active', so linking alone would leave the user looking logged out.
const id = insertBookingUser('booking-user-2', 'buyer2@test.py');
const { headers, response } = await signInWithGoogle(
{ email: 'buyer2@test.py', sub: 'google-sub-buyer2' },
true
);
expect(response.user.id).toBe(id);
const row = userRow('buyer2@test.py');
expect(row.account_status).toBe('active');
expect(row.is_claimed).toBe(1);
expect(row.email_verified).toBe(1);
const session = await auth.api.getSession({
headers: cookieHeaders(headers.get('set-cookie')),
});
expect(session?.user.id).toBe(id);
expect((session?.user as any).accountStatus).toBe('active');
});
it('never reactivates a suspended account through a Google link', async () => {
const now = new Date().toISOString();
sqlite
.prepare(
`INSERT INTO users (id, email, name, role, is_claimed, account_status, email_verified, banned, created_at, updated_at)
VALUES (?, ?, 'Suspended', 'user', 1, 'suspended', 1, 1, ?, ?)`
)
.run('suspended-google', 'suspended-google@test.py', now, now);
await expect(
signInWithGoogle({ email: 'suspended-google@test.py', sub: 'google-sub-suspended' })
).rejects.toThrow();
expect(userRow('suspended-google@test.py').account_status).toBe('suspended');
});
it('links Google onto an email/password account created after the Better Auth migration', async () => {
// Better Auth writes email_verified = 0 on sign-up (requireEmailVerification
// is off), which used to be enough to block linking on its own.
await auth.api.signUpEmail({
body: { email: 'pwuser@test.py', password: 'PwUserPass1!x', name: 'Pw User' },
});
expect(userRow('pwuser@test.py').email_verified).toBe(0);
const id = userRow('pwuser@test.py').id;
const res = await signInWithGoogle({ email: 'pwuser@test.py', sub: 'google-sub-pwuser' });
expect(res.user.id).toBe(id);
expect(googleAccountsFor(id)).toHaveLength(1);
// The credential account survives: they can still sign in with a password.
const after = await auth.api.signInEmail({
body: { email: 'pwuser@test.py', password: 'PwUserPass1!x' },
});
expect(after.user.id).toBe(id);
});
it('creates exactly one user for a brand-new Google address and reuses it on the next sign-in', async () => {
const first = await signInWithGoogle({ email: 'fresh@test.py', sub: 'google-sub-fresh' });
const row = userRow('fresh@test.py');
expect(row.id).toBe(first.user.id);
expect(row.email_verified).toBe(1);
expect(row.account_status).toBe('active');
const second = await signInWithGoogle({ email: 'fresh@test.py', sub: 'google-sub-fresh' });
expect(second.user.id).toBe(first.user.id);
expect(sqlite.prepare('SELECT COUNT(*) AS n FROM users WHERE email = ?').get('fresh@test.py').n).toBe(1);
expect(googleAccountsFor(first.user.id)).toHaveLength(1);
});
it('rejects an id token minted for a different client id', async () => {
const token = await new SignJWT({ email: 'forged@test.py', email_verified: true, name: 'F' })
.setProtectedHeader({ alg: 'RS256', kid: GOOGLE_KID })
.setIssuer('https://accounts.google.com')
.setAudience('some-other-app.apps.googleusercontent.com')
.setSubject('google-sub-forged')
.setIssuedAt()
.setExpirationTime('10m')
.sign(googlePrivateKey);
await expect(
auth.api.signInSocial({
body: { provider: 'google', idToken: { token } },
headers: new Headers(),
} as any)
).rejects.toThrow();
expect(userRow('forged@test.py')).toBeUndefined();
});
});
describe('users.email normalization', () => {
it('normalizes an address to its canonical stored form', () => {
expect(normalizeEmail(' John@Example.COM ')).toBe('john@example.com');
});
it('lowercases legacy mixed-case rows on migrate, and reports collisions instead of merging', async () => {
const now = new Date().toISOString();
const insert = (id: string, email: string, status = 'unclaimed') =>
sqlite
.prepare(
`INSERT INTO users (id, email, name, role, is_claimed, account_status, email_verified, created_at, updated_at)
VALUES (?, ?, 'Legacy', 'user', 0, ?, 0, ?, ?)`
)
.run(id, email, status, now, now);
insert('legacy-mixed', 'John@Example.com');
// A pair that genuinely collides: the migration must leave both alone.
insert('legacy-dup-lower', 'dup@example.com');
insert('legacy-dup-mixed', 'Dup@Example.com');
// The backfill lives in migrate.ts and is idempotent, so just re-run it.
execFileSync('npx', ['tsx', 'src/db/migrate.ts'], { env: { ...process.env }, stdio: 'pipe' });
expect(userRow('john@example.com').id).toBe('legacy-mixed');
expect(userRow('John@Example.com')).toBeUndefined();
expect(userRow('Dup@Example.com').id).toBe('legacy-dup-mixed');
expect(userRow('dup@example.com').id).toBe('legacy-dup-lower');
// ...and the lowercased row is now reachable by Google sign-in.
const res = await signInWithGoogle({ email: 'john@example.com', sub: 'google-sub-legacy' });
expect(res.user.id).toBe('legacy-mixed');
}, 120_000);
});
+58
View File
@@ -209,6 +209,25 @@ export const auth = betterAuth({
// Google verifies email ownership, so linking by email is safe — this
// matches the legacy /api/auth/google auto-link behavior.
trustedProviders: ['google'],
// `trustedProviders` alone is NOT enough: better-auth ORs a second,
// independent gate — `requireLocalEmailVerified` (default true) — which
// refuses the link whenever the LOCAL users.email_verified is false.
// That is the state of every guest-booking user (routes/tickets.ts,
// routes/door.ts insert email_verified = false) and of every
// email/password signup made after the Better Auth migration, so Google
// sign-in failed for them with "account not linked".
//
// The local flag adds nothing here: the Google ID token is signature-
// verified against Google's JWKS with issuer/audience/max-age checks and
// carries its own `email_verified`, so Google — not our column — is what
// proves ownership of the address.
//
// NOTE: upstream marks this option deprecated ("the gate will become
// unconditional"). better-auth is pinned exactly at 1.6.25 in both
// workspaces, and betterAuth.integration.test.ts covers this path, so an
// upgrade that drops the option fails CI rather than silently locking
// ticket buyers out again.
requireLocalEmailVerified: false,
},
},
@@ -246,6 +265,45 @@ export const auth = betterAuth({
},
},
},
account: {
create: {
// Fires for both branches of the OAuth path: createOAuthUser (new user)
// and linkAccount (existing user), since both go through the adapter's
// createWithHooks(..., 'account').
after: async (account) => {
if (account.providerId !== 'google') return;
// Attaching a Google account proves ownership of the address, so a
// row created during guest booking is now a real, claimed account.
// Without this, getAuthUser() (lib/auth.ts) rejects the brand-new
// session because accountStatus is still 'unclaimed' — the user gets
// a cookie and still looks logged out. Mirrors the tail of the
// magic-link claim flow in routes/authExt.ts.
//
// Scoped to 'unclaimed' in the WHERE clause so a suspended account is
// never silently reactivated by linking Google to it.
try {
await (db as any)
.update(authUsers)
// `emailVerified` is deliberately left alone: better-auth's link
// branch sets it right after this hook, but only when Google's
// id_token actually asserted email_verified.
.set({
isClaimed: true,
accountStatus: 'active',
updatedAt: new Date(),
})
.where(
and(
eq((authUsers as any).id, account.userId),
eq((authUsers as any).accountStatus, 'unclaimed')
)
);
} catch (err: any) {
console.error('[auth] Failed to claim account on Google link:', err?.message || err);
}
},
},
},
},
hooks: {
+13
View File
@@ -22,6 +22,19 @@ export function generateTicketCode(): string {
return `TKT-${nanoid(8).toUpperCase()}`;
}
/**
* Canonical form for `users.email`.
*
* Better Auth lowercases the address on every lookup and write it performs,
* but the `users.email` unique index is case-sensitive on both dialects. Any
* row written outside Better Auth (guest bookings, door sales, admin-added
* tickets) must therefore be normalized the same way, or the row becomes
* invisible to sign-in / Google linking and a duplicate person gets created.
*/
export function normalizeEmail(email: string): string {
return email.trim().toLowerCase();
}
/**
* Get current timestamp in the format appropriate for the database type.
* - SQLite: returns ISO string
+3 -2
View File
@@ -5,7 +5,7 @@ import { eq } from 'drizzle-orm';
import { auth } from '../lib/betterAuth.js';
import { validatePassword } from '../lib/passwordPolicy.js';
import { db, dbGet, users } from '../db/index.js';
import { getNow, toDbBool } from '../lib/utils.js';
import { getNow, toDbBool, normalizeEmail } from '../lib/utils.js';
import { rateLimitMiddleware } from '../lib/rateLimit.js';
// Custom auth flows that Better Auth doesn't provide out of the box. Mounted
@@ -83,8 +83,9 @@ authExt.get('/claim-eligibility', authExtRateLimit, async (c) => {
return c.json({ canClaim: false });
}
// Normalized to match how the row is stored (see lib/utils.ts normalizeEmail)
const user = await dbGet<any>(
(db as any).select().from(users).where(eq((users as any).email, email))
(db as any).select().from(users).where(eq((users as any).email, normalizeEmail(email)))
);
const canClaim = !!user && !user.banned && user.accountStatus !== 'suspended'
+9 -4
View File
@@ -23,7 +23,7 @@ import {
db, dbGet, dbAll, tickets, events, users, payments, idempotencyKeys,
} from '../db/index.js';
import { requireAuth } from '../lib/auth.js';
import { generateId, generateTicketCode, getNow, toDbBool, toDbDate } from '../lib/utils.js';
import { generateId, generateTicketCode, getNow, toDbBool, toDbDate, normalizeEmail } from '../lib/utils.js';
import { runOps, insertOp, updateOp, deleteOp, type TxOp } from '../lib/txOps.js';
import { seatHolderCountQuery } from '../lib/capacity.js';
import {
@@ -370,9 +370,14 @@ doorRouter.post(
// No email is the fast path; a placeholder keeps the users.email unique
// constraint satisfied without ever mailing anyone.
const accountEmail = hasEmail
? attendee.email!.trim()
: `${tenderMethod === 'guest' ? 'guest' : 'door'}-${generateId()}@doorentry.local`;
// Normalized: Better Auth lowercases every lookup it makes, and the
// users.email unique index is case-sensitive, so a mixed-case address
// written here would be invisible to sign-in and Google linking.
const accountEmail = normalizeEmail(
hasEmail
? attendee.email!
: `${tenderMethod === 'guest' ? 'guest' : 'door'}-${generateId()}@doorentry.local`
);
let user = hasEmail
? await dbGet<any>((db as any).select().from(users).where(eq((users as any).email, accountEmail)))
+15 -10
View File
@@ -4,7 +4,7 @@ import { z } from 'zod';
import { db, dbGet, dbAll, tickets, events, users, payments, paymentOptions, eventPaymentOverrides, siteSettings, isSqlite } from '../db/index.js';
import { eq, and, or, sql, inArray } from 'drizzle-orm';
import { requireAuth, getAuthUser } from '../lib/auth.js';
import { generateId, generateTicketCode, getNow, toDbDate, toDbBool, calculateAvailableSeats, isEventSoldOut } from '../lib/utils.js';
import { generateId, generateTicketCode, getNow, toDbDate, toDbBool, normalizeEmail, calculateAvailableSeats, isEventSoldOut } from '../lib/utils.js';
import { createInvoice, isLNbitsConfigured, LNBITS_INVOICE_EXPIRY_SECONDS } from '../lib/lnbits.js';
import { rateLimitMiddleware } from '../lib/rateLimit.js';
import emailService from '../lib/email.js';
@@ -148,9 +148,12 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
}, 400);
}
// Find or create user
// Find or create user. The account row is keyed on the normalized address so
// it stays reachable from Better Auth (which lowercases every lookup) —
// tickets.attendeeEmail below keeps the address exactly as the buyer typed it.
const accountEmail = normalizeEmail(data.email);
let user = await dbGet<any>(
(db as any).select().from(users).where(eq((users as any).email, data.email))
(db as any).select().from(users).where(eq((users as any).email, accountEmail))
);
const now = getNow();
@@ -163,7 +166,7 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
const userId = generateId();
user = {
id: userId,
email: data.email,
email: accountEmail,
password: null, // No password for guest bookings; set on claim (Better Auth credential account)
name: fullName,
phone: data.phone || null,
@@ -1425,9 +1428,10 @@ ticketsRouter.post('/admin/create', requireAuth(['admin', 'organizer', 'staff'])
? data.email.trim()
: `door-${generateId()}@doorentry.local`;
// Find or create user
// Find or create user (see the note on `accountEmail` in the booking route)
const accountEmail = normalizeEmail(attendeeEmail);
let user = await dbGet<any>(
(db as any).select().from(users).where(eq((users as any).email, attendeeEmail))
(db as any).select().from(users).where(eq((users as any).email, accountEmail))
);
const adminFullName = data.lastName && data.lastName.trim()
@@ -1438,7 +1442,7 @@ ticketsRouter.post('/admin/create', requireAuth(['admin', 'organizer', 'staff'])
const userId = generateId();
user = {
id: userId,
email: attendeeEmail,
email: accountEmail,
password: null,
name: adminFullName,
phone: data.phone || null,
@@ -1585,16 +1589,17 @@ ticketsRouter.post('/admin/add', requireAuth(['admin', 'organizer', 'staff']), z
? `${firstName} ${data.lastName.trim()}`
: firstName;
// Find or create user
// Find or create user (see the note on `accountEmail` in the booking route)
const accountEmail = normalizeEmail(attendeeEmail);
let user = await dbGet<any>(
(db as any).select().from(users).where(eq((users as any).email, attendeeEmail))
(db as any).select().from(users).where(eq((users as any).email, accountEmail))
);
if (!user) {
const userId = generateId();
user = {
id: userId,
email: attendeeEmail,
email: accountEmail,
password: null,
name: fullName,
phone: data.phone || null,