import { and, eq, gt, isNull } from 'drizzle-orm'; import postgres from 'postgres'; import { auth } from '@/lib/auth'; import { db } from '@/lib/db'; import { crmUserInvites } from '@/lib/db/schema/crm-invites'; import { userProfiles } from '@/lib/db/schema/users'; import { env } from '@/lib/env'; import { sendEmail } from '@/lib/email'; import { crmInviteEmail } from '@/lib/email/templates/crm-invite'; import { ConflictError, NotFoundError, ValidationError } from '@/lib/errors'; import { hashToken, mintToken } from '@/lib/portal/passwords'; const INVITE_TTL_HOURS = 72; const MIN_PASSWORD_LENGTH = 9; export async function createCrmInvite(args: { email: string; name?: string; isSuperAdmin?: boolean; }): Promise<{ inviteId: string; link: string }> { const email = args.email.toLowerCase().trim(); const isSuperAdmin = args.isSuperAdmin ?? false; // Reject if there's already a better-auth user with this email — they // should reset their password instead. const sql = postgres(env.DATABASE_URL); try { const existing = await sql<{ id: string }[]>` SELECT id FROM "user" WHERE email = ${email} LIMIT 1 `; if (existing.length > 0) { throw new ConflictError(`A CRM user already exists for ${email}`); } } finally { await sql.end(); } const { raw, hash } = mintToken(); const expiresAt = new Date(Date.now() + INVITE_TTL_HOURS * 3600 * 1000); const [row] = await db .insert(crmUserInvites) .values({ email, name: args.name ?? null, tokenHash: hash, isSuperAdmin, expiresAt, }) .returning({ id: crmUserInvites.id }); if (!row) throw new Error('Failed to create CRM invite'); const link = `${env.APP_URL}/set-password?token=${raw}`; const { subject, html, text } = crmInviteEmail({ link, ttlHours: INVITE_TTL_HOURS, recipientName: args.name, isSuperAdmin, }); await sendEmail(email, subject, html, undefined, text); return { inviteId: row.id, link }; } export async function consumeCrmInvite(args: { token: string; password: string; }): Promise<{ userId: string; email: string }> { if (args.password.length < MIN_PASSWORD_LENGTH) { throw new ValidationError(`Password must be at least ${MIN_PASSWORD_LENGTH} characters`); } const tokenHash = hashToken(args.token); const invite = await db.query.crmUserInvites.findFirst({ where: and( eq(crmUserInvites.tokenHash, tokenHash), isNull(crmUserInvites.usedAt), gt(crmUserInvites.expiresAt, new Date()), ), }); if (!invite) { throw new NotFoundError('Invite link is invalid or has expired'); } // Create the better-auth user with the chosen password. const result = await auth.api.signUpEmail({ body: { email: invite.email, password: args.password, name: invite.name ?? invite.email.split('@')[0] ?? 'User', }, }); const userId = result.user.id; // Create the matching user_profiles extension row. await db .insert(userProfiles) .values({ id: crypto.randomUUID(), userId, displayName: invite.name ?? invite.email, isSuperAdmin: invite.isSuperAdmin, isActive: true, preferences: {}, }) .onConflictDoNothing(); await db .update(crmUserInvites) .set({ usedAt: new Date() }) .where(eq(crmUserInvites.id, invite.id)); return { userId, email: invite.email }; }