Initial commit
This commit is contained in:
@@ -0,0 +1,510 @@
|
||||
import { createHash, randomInt } from 'node:crypto'
|
||||
import { v7 as uuidv7 } from 'uuid'
|
||||
import type { User as SupabaseAuthUser } from '@supabase/supabase-js'
|
||||
import type {
|
||||
OnboardingCompleteInput,
|
||||
OnboardingResendOtpInput,
|
||||
OnboardingStartInput,
|
||||
OnboardingVerifyOtpInput,
|
||||
} from '@repo/api-contract'
|
||||
import { db } from '@/lib/prisma'
|
||||
import { sendMail } from '@/lib/mailer'
|
||||
import { getSupabaseAdminClient } from '@/lib/supabase-admin'
|
||||
|
||||
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>`,
|
||||
})
|
||||
}
|
||||
|
||||
async function findSupabaseUserByEmail(
|
||||
email: string,
|
||||
): Promise<SupabaseAuthUser | null> {
|
||||
let page = 1
|
||||
const perPage = 100
|
||||
|
||||
while (page <= 10) {
|
||||
const { data, error } = await getSupabaseAdminClient().auth.admin.listUsers({
|
||||
page,
|
||||
perPage,
|
||||
})
|
||||
|
||||
if (error) {
|
||||
throw new Error(`No se pudo consultar usuarios en Supabase: ${error.message}`)
|
||||
}
|
||||
|
||||
const found = data.users.find(
|
||||
(user) => user.email?.toLowerCase() === email.toLowerCase(),
|
||||
)
|
||||
|
||||
if (found) {
|
||||
return found
|
||||
}
|
||||
|
||||
if (data.users.length < perPage) {
|
||||
return null
|
||||
}
|
||||
|
||||
page += 1
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
async function ensureSupabaseUser(params: {
|
||||
email: string
|
||||
fullName: string
|
||||
password: string
|
||||
}): Promise<string> {
|
||||
const existingUser = await findSupabaseUserByEmail(params.email)
|
||||
|
||||
if (existingUser) {
|
||||
const { error } = await getSupabaseAdminClient().auth.admin.updateUserById(
|
||||
existingUser.id,
|
||||
{
|
||||
password: params.password,
|
||||
email_confirm: true,
|
||||
user_metadata: {
|
||||
full_name: params.fullName,
|
||||
name: params.fullName,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
if (error) {
|
||||
throw new Error(
|
||||
`No se pudo actualizar usuario existente en Supabase: ${error.message}`,
|
||||
)
|
||||
}
|
||||
|
||||
return existingUser.id
|
||||
}
|
||||
|
||||
const { data, error } = await getSupabaseAdminClient().auth.admin.createUser({
|
||||
email: params.email,
|
||||
password: params.password,
|
||||
email_confirm: true,
|
||||
user_metadata: {
|
||||
full_name: params.fullName,
|
||||
name: params.fullName,
|
||||
},
|
||||
})
|
||||
|
||||
if (error || !data.user) {
|
||||
throw new Error(
|
||||
`No se pudo crear usuario en Supabase: ${error?.message ?? 'unknown error'}`,
|
||||
)
|
||||
}
|
||||
|
||||
return data.user.id
|
||||
}
|
||||
|
||||
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 invalida o expirada.', 400)
|
||||
}
|
||||
|
||||
if (onboardingRequest.otpExpiresAt < new Date()) {
|
||||
throw new OnboardingError(
|
||||
'La sesion de onboarding expiro. 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 supabaseUserId = await ensureSupabaseUser({
|
||||
email: onboardingRequest.email,
|
||||
fullName: onboardingRequest.fullName,
|
||||
password: input.password,
|
||||
})
|
||||
|
||||
const complexSlug = await buildUniqueComplexSlug(input.complexName)
|
||||
|
||||
const result = await db.$transaction(async (tx) => {
|
||||
const user = await tx.user.upsert({
|
||||
where: { email: onboardingRequest.email },
|
||||
update: {
|
||||
fullName: onboardingRequest.fullName,
|
||||
supabaseUserId,
|
||||
},
|
||||
create: {
|
||||
id: uuidv7(),
|
||||
fullName: onboardingRequest.fullName,
|
||||
email: onboardingRequest.email,
|
||||
supabaseUserId,
|
||||
},
|
||||
})
|
||||
|
||||
const complex = await tx.complex.create({
|
||||
data: {
|
||||
id: uuidv7(),
|
||||
complexName: input.complexName.trim(),
|
||||
physicalAddress: input.physicalAddress.trim(),
|
||||
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,
|
||||
supabaseUserId,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user