416 lines
11 KiB
TypeScript
416 lines
11 KiB
TypeScript
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<string> {
|
|
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: `<p>Tu codigo OTP es <strong>${otpCode}</strong>.</p><p>Vence en ${OTP_TTL_MINUTES} minutos.</p>`,
|
|
});
|
|
}
|
|
|
|
export async function startOnboarding(input: OnboardingStartInput): Promise<StartOnboardingResult> {
|
|
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<VerifyOtpResult> {
|
|
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<ResendOtpResult> {
|
|
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,
|
|
};
|
|
}
|