import { and, desc, eq, gt, isNull } from 'drizzle-orm'; import postgres from 'postgres'; import { auth } from '@/lib/auth'; import { createAuditLog } from '@/lib/audit'; 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'; interface AuditMeta { userId: string; portId: string; ipAddress: string; userAgent: string; } 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 }; } // ─── Admin operations ──────────────────────────────────────────────────────── export interface InviteRow { id: string; email: string; name: string | null; isSuperAdmin: boolean; expiresAt: Date; usedAt: Date | null; createdAt: Date; status: 'pending' | 'accepted' | 'expired'; } export async function listCrmInvites(): Promise { const rows = await db .select({ id: crmUserInvites.id, email: crmUserInvites.email, name: crmUserInvites.name, isSuperAdmin: crmUserInvites.isSuperAdmin, expiresAt: crmUserInvites.expiresAt, usedAt: crmUserInvites.usedAt, createdAt: crmUserInvites.createdAt, }) .from(crmUserInvites) .orderBy(desc(crmUserInvites.createdAt)) .limit(200); const now = Date.now(); return rows.map((r) => { let status: InviteRow['status']; if (r.usedAt) status = 'accepted'; else if (r.expiresAt.getTime() < now) status = 'expired'; else status = 'pending'; return { ...r, status }; }); } export async function revokeCrmInvite(inviteId: string, meta: AuditMeta): Promise { const invite = await db.query.crmUserInvites.findFirst({ where: eq(crmUserInvites.id, inviteId), }); if (!invite) throw new NotFoundError('Invite'); if (invite.usedAt) throw new ConflictError('Invite already accepted — cannot revoke'); // Force expiration; tokenHash stays in place so any in-flight click fails // the `expiresAt > now` check at consume time. await db .update(crmUserInvites) .set({ expiresAt: new Date(0) }) .where(eq(crmUserInvites.id, inviteId)); void createAuditLog({ userId: meta.userId, portId: meta.portId, action: 'revoke_invite', entityType: 'crm_invite', entityId: inviteId, metadata: { email: invite.email }, ipAddress: meta.ipAddress, userAgent: meta.userAgent, }); } export async function resendCrmInvite( inviteId: string, meta: AuditMeta, ): Promise<{ link: string }> { const invite = await db.query.crmUserInvites.findFirst({ where: eq(crmUserInvites.id, inviteId), }); if (!invite) throw new NotFoundError('Invite'); if (invite.usedAt) throw new ConflictError('Invite already accepted — nothing to resend'); // Mint a fresh token + push expiry forward so the resent link is the only // working one. The old token hash is overwritten so prior emails become // dead links. const { raw, hash } = mintToken(); const expiresAt = new Date(Date.now() + INVITE_TTL_HOURS * 3600 * 1000); await db .update(crmUserInvites) .set({ tokenHash: hash, expiresAt }) .where(eq(crmUserInvites.id, inviteId)); const link = `${env.APP_URL}/set-password?token=${raw}`; const { subject, html, text } = crmInviteEmail({ link, ttlHours: INVITE_TTL_HOURS, recipientName: invite.name ?? undefined, isSuperAdmin: invite.isSuperAdmin, }); await sendEmail(invite.email, subject, html, undefined, text); void createAuditLog({ userId: meta.userId, portId: meta.portId, action: 'resend_invite', entityType: 'crm_invite', entityId: inviteId, metadata: { email: invite.email }, ipAddress: meta.ipAddress, userAgent: meta.userAgent, }); return { link }; }