import { createHash, randomInt } from 'node:crypto'; import { auth } from '@/lib/auth'; import { sendMail } from '@/lib/mailer'; import { db } from '@/lib/prisma'; import type { OnboardingCompleteInput, OnboardingResendOtpInput, OnboardingStartInput, OnboardingVerifyOtpInput, } from '@repo/api-contract'; import { v7 as uuidv7 } from 'uuid'; const OTP_LENGTH = 6; const OTP_TTL_MINUTES = Number(Bun.env.ONBOARDING_OTP_TTL_MINUTES ?? 10); const OTP_MAX_ATTEMPTS = Number(Bun.env.ONBOARDING_OTP_MAX_ATTEMPTS ?? 5); const OTP_RESEND_COOLDOWN_SECONDS = Number(Bun.env.ONBOARDING_OTP_RESEND_COOLDOWN_SECONDS ?? 30); type VerifyOtpResult = { message: string; verified: boolean; requestId: string; email: string | null; expiresAt: string | null; remainingAttempts: number; cooldownSeconds: number; }; type ResendOtpResult = { message: string; requestId: string; email: string; expiresAt: string; cooldownSeconds: number; remainingAttempts: number; }; type StartOnboardingResult = { message: string; requestId: string; email: string; expiresAt: string; cooldownSeconds: number; }; export class OnboardingError extends Error { status: 400 | 404 | 409 | 429; constructor(message: string, status: 400 | 404 | 409 | 429 = 400) { super(message); this.name = 'OnboardingError'; this.status = status; } } function nowPlusMinutes(minutes: number): Date { const date = new Date(); date.setMinutes(date.getMinutes() + minutes); return date; } function normalizeEmail(email: string): string { return email.trim().toLowerCase(); } function createOtpCode(): string { return randomInt(0, 10 ** OTP_LENGTH) .toString() .padStart(OTP_LENGTH, '0'); } function hashValue(value: string): string { return createHash('sha256').update(value).digest('hex'); } function getRemainingAttempts(otpAttempts: number): number { return Math.max(0, OTP_MAX_ATTEMPTS - otpAttempts); } function getCooldownSeconds(otpLastSentAt: Date): number { const elapsedMs = Date.now() - otpLastSentAt.getTime(); const remainingMs = OTP_RESEND_COOLDOWN_SECONDS * 1000 - elapsedMs; return Math.max(0, Math.ceil(remainingMs / 1000)); } function slugify(value: string): string { return value .normalize('NFD') .replace(/[\u0300-\u036f]/g, '') .toLowerCase() .trim() .replace(/[^a-z0-9\s-]/g, '') .replace(/\s+/g, '-') .replace(/-+/g, '-'); } async function buildUniqueComplexSlug(complexName: string): Promise { const base = slugify(complexName); const fallback = base.length > 0 ? base : `complex-${uuidv7().slice(0, 8)}`; let candidate = fallback; let index = 1; while (true) { const existing = await db.complex.findFirst({ where: { complexSlug: candidate }, select: { id: true }, }); if (!existing) { return candidate; } index += 1; candidate = `${fallback}-${index}`; } } async function sendOtpEmail(email: string, otpCode: string) { await sendMail({ to: email, subject: 'Codigo OTP para validar tu email', text: `Tu codigo OTP es ${otpCode}. Vence en ${OTP_TTL_MINUTES} minutos.`, html: `

Tu codigo OTP es ${otpCode}.

Vence en ${OTP_TTL_MINUTES} minutos.

`, }); } export async function startOnboarding(input: OnboardingStartInput): Promise { const email = normalizeEmail(input.email); const otpCode = createOtpCode(); const expiresAt = nowPlusMinutes(OTP_TTL_MINUTES); const now = new Date(); const request = await db.onboardingRequest.create({ data: { id: uuidv7(), fullName: input.fullName.trim(), email, otpHash: hashValue(otpCode), otpExpiresAt: expiresAt, otpAttempts: 0, otpLastSentAt: now, otpResendCount: 0, }, select: { id: true, email: true, otpExpiresAt: true, }, }); await sendOtpEmail(email, otpCode); return { message: 'Te enviamos un codigo OTP para validar tu direccion.', requestId: request.id, email: request.email, expiresAt: request.otpExpiresAt.toISOString(), cooldownSeconds: OTP_RESEND_COOLDOWN_SECONDS, }; } export async function verifyOnboardingOtp( input: OnboardingVerifyOtpInput ): Promise { const request = await db.onboardingRequest.findUnique({ where: { id: input.requestId }, }); if (!request) { return { message: 'Solicitud de onboarding invalida o expirada.', verified: false, requestId: input.requestId, email: null, expiresAt: null, remainingAttempts: 0, cooldownSeconds: 0, }; } if (request.completedAt) { return { message: 'Este onboarding ya fue completado.', verified: true, requestId: request.id, email: request.email, expiresAt: request.otpExpiresAt.toISOString(), remainingAttempts: getRemainingAttempts(request.otpAttempts), cooldownSeconds: getCooldownSeconds(request.otpLastSentAt), }; } if (request.otpExpiresAt < new Date()) { return { message: 'El codigo OTP vencio. Solicita un nuevo codigo.', verified: false, requestId: request.id, email: request.email, expiresAt: request.otpExpiresAt.toISOString(), remainingAttempts: getRemainingAttempts(request.otpAttempts), cooldownSeconds: getCooldownSeconds(request.otpLastSentAt), }; } if (request.otpAttempts >= OTP_MAX_ATTEMPTS) { return { message: 'Se agotaron los intentos de OTP. Solicita un nuevo codigo.', verified: false, requestId: request.id, email: request.email, expiresAt: request.otpExpiresAt.toISOString(), remainingAttempts: 0, cooldownSeconds: getCooldownSeconds(request.otpLastSentAt), }; } const otpMatches = hashValue(input.otp) === request.otpHash; if (!otpMatches) { const updated = await db.onboardingRequest.update({ where: { id: request.id }, data: { otpAttempts: { increment: 1, }, }, select: { id: true, email: true, otpAttempts: true, otpExpiresAt: true, otpLastSentAt: true, }, }); const remainingAttempts = getRemainingAttempts(updated.otpAttempts); return { message: remainingAttempts > 0 ? 'Codigo OTP invalido.' : 'Se agotaron los intentos de OTP. Solicita un nuevo codigo.', verified: false, requestId: updated.id, email: updated.email, expiresAt: updated.otpExpiresAt.toISOString(), remainingAttempts, cooldownSeconds: getCooldownSeconds(updated.otpLastSentAt), }; } if (!request.emailVerifiedAt) { await db.onboardingRequest.update({ where: { id: request.id }, data: { emailVerifiedAt: new Date() }, }); } return { message: 'Email verificado correctamente.', verified: true, requestId: request.id, email: request.email, expiresAt: request.otpExpiresAt.toISOString(), remainingAttempts: getRemainingAttempts(request.otpAttempts), cooldownSeconds: getCooldownSeconds(request.otpLastSentAt), }; } export async function resendOnboardingOtp( input: OnboardingResendOtpInput ): Promise { const request = await db.onboardingRequest.findUnique({ where: { id: input.requestId }, }); if (!request) { throw new OnboardingError('Solicitud de onboarding invalida o expirada.', 404); } if (request.completedAt) { throw new OnboardingError('Este onboarding ya fue completado.', 400); } if (request.emailVerifiedAt) { throw new OnboardingError('El email ya fue verificado.', 400); } const cooldownSeconds = getCooldownSeconds(request.otpLastSentAt); if (cooldownSeconds > 0) { throw new OnboardingError(`Debes esperar ${cooldownSeconds}s antes de reenviar el OTP.`, 429); } const otpCode = createOtpCode(); const expiresAt = nowPlusMinutes(OTP_TTL_MINUTES); const now = new Date(); const updated = await db.onboardingRequest.update({ where: { id: request.id }, data: { otpHash: hashValue(otpCode), otpExpiresAt: expiresAt, otpAttempts: 0, otpLastSentAt: now, otpResendCount: { increment: 1, }, }, select: { id: true, email: true, otpExpiresAt: true, otpAttempts: true, }, }); await sendOtpEmail(updated.email, otpCode); return { message: 'Te enviamos un nuevo codigo OTP.', requestId: updated.id, email: updated.email, expiresAt: updated.otpExpiresAt.toISOString(), cooldownSeconds: OTP_RESEND_COOLDOWN_SECONDS, remainingAttempts: getRemainingAttempts(updated.otpAttempts), }; } export async function completeOnboarding(input: OnboardingCompleteInput) { const onboardingRequest = await db.onboardingRequest.findUnique({ where: { id: input.onboardingRequestId }, }); if (!onboardingRequest) { throw new OnboardingError('Solicitud de onboarding inválida o expirada.', 400); } if (onboardingRequest.otpExpiresAt < new Date()) { throw new OnboardingError('La sesión de onboarding expiró. Solicita un nuevo OTP.', 400); } if (!onboardingRequest.emailVerifiedAt) { throw new OnboardingError('Debes verificar el email antes de continuar.', 400); } if (onboardingRequest.completedAt) { throw new OnboardingError('Este onboarding ya fue completado.', 400); } const plan = await db.plan.findUnique({ where: { code: input.planCode }, select: { code: true }, }); if (!plan) { throw new OnboardingError('El plan seleccionado no existe.', 400); } const { user } = await auth.api.signUpEmail({ asResponse: false, body: { email: onboardingRequest.email, password: input.password, name: onboardingRequest.fullName, }, }); const complexSlug = await buildUniqueComplexSlug(input.complexName); const result = await db.$transaction(async (tx) => { await tx.user.update({ where: { id: user.id }, data: { emailVerified: true }, }); const complex = await tx.complex.create({ data: { id: uuidv7(), complexName: input.complexName.trim(), physicalAddress: input.physicalAddress.trim(), city: input.city?.trim() || null, state: input.state?.trim() || null, country: input.country?.trim() || null, complexSlug, adminEmail: onboardingRequest.email, planCode: input.planCode, }, }); await tx.complexUser.create({ data: { complexId: complex.id, userId: user.id, role: 'ADMIN', }, }); await tx.onboardingRequest.update({ where: { id: onboardingRequest.id }, data: { completedAt: new Date(), }, }); return { userId: user.id, complexId: complex.id, complexSlug: complex.complexSlug, }; }); return { ...result, }; }