Initial commit

This commit is contained in:
Jose Selesan
2026-04-08 22:53:11 -03:00
commit 9ae270609d
179 changed files with 28096 additions and 0 deletions

View File

@@ -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,
}
}