refactor(onboarding): restructure onboarding routes and components

- Removed obsolete onboarding routes: complete, create-complex, index, verify-email, and verify.
- Introduced new setup stepper page for onboarding process.
- Created a multi-step setup component to handle complex creation with validation.
- Added new schemas for complex creation and plan features.
- Implemented detailed steps for complex name, location, plan selection, and court setup.
- Enhanced error handling and user feedback during the onboarding process.
This commit is contained in:
Jose Selesan
2026-06-02 10:02:28 -03:00
parent 2aaa91444d
commit 4544911f0e
51 changed files with 1006 additions and 2383 deletions

View File

@@ -40,6 +40,11 @@ export async function createComplexHandler(c: AppContext) {
city: payload.city,
state: payload.state,
country: payload.country,
setupCourts: payload.setupCourts,
courtSportId: payload.courtSportId,
courtStartTime: payload.courtStartTime,
courtEndTime: payload.courtEndTime,
courtDaysOfWeek: payload.courtDaysOfWeek,
});
return c.json(complex, 201);

View File

@@ -11,8 +11,47 @@ export type CreateComplexInput = {
city?: string;
state?: string;
country?: string;
setupCourts?: boolean;
courtSportId?: string;
courtStartTime?: string;
courtEndTime?: string;
courtDaysOfWeek?: string[];
};
type DayOfWeek = 'MONDAY' | 'TUESDAY' | 'WEDNESDAY' | 'THURSDAY' | 'FRIDAY' | 'SATURDAY' | 'SUNDAY';
function toMinutes(value: string): number {
const [hours, minutes] = value.split(':').map((part) => Number(part));
return hours * 60 + minutes;
}
function assertAvailabilityRanges(
availability: Array<{
dayOfWeek: DayOfWeek;
startTime: string;
endTime: string;
}>
) {
for (const range of availability) {
const start = toMinutes(range.startTime);
const end = toMinutes(range.endTime);
if (start >= end) {
throw new Error(`El rango ${range.startTime}-${range.endTime} es inválido.`);
}
}
}
async function ensureActiveSport(sportId: string) {
const sport = await db.sport.findFirst({
where: { id: sportId, isActive: true },
select: { id: true, name: true },
});
if (!sport) {
throw new Error('El deporte seleccionado no existe o está inactivo.');
}
return sport;
}
export type UpdateComplexInput = {
complexName?: string;
physicalAddress?: string | null;
@@ -94,6 +133,38 @@ export async function createComplex(input: CreateComplexInput) {
});
}
if (input.setupCourts && input.courtSportId) {
const sport = await ensureActiveSport(input.courtSportId);
const availability = (input.courtDaysOfWeek ?? []).map((day) => ({
dayOfWeek: day as DayOfWeek,
startTime: input.courtStartTime ?? '08:00',
endTime: input.courtEndTime ?? '22:00',
}));
assertAvailabilityRanges(availability);
const court = await tx.court.create({
data: {
id: uuidv7(),
complexId: complex.id,
sportId: input.courtSportId,
name: `${sport.name} 1`,
slotDurationMinutes: 60,
basePrice: 0,
},
});
await tx.courtAvailability.createMany({
data: availability.map((avail) => ({
id: uuidv7(),
courtId: court.id,
dayOfWeek: avail.dayOfWeek,
startTime: avail.startTime,
endTime: avail.endTime,
})),
});
}
return complex;
});
}

View File

@@ -1,27 +0,0 @@
import {
OnboardingError,
completeOnboarding,
} from '@/modules/onboarding/services/onboarding.service';
import type { AppContext } from '@/types/hono';
import type { OnboardingCompleteInput } from '@repo/api-contract';
export async function completeOnboardingHandler(c: AppContext) {
const payload = c.req.valid('json' as never) as OnboardingCompleteInput;
try {
const result = await completeOnboarding(payload);
return c.json({
message: 'Onboarding completado correctamente.',
...result,
});
} catch (error) {
if (error instanceof OnboardingError) {
return c.json({ message: error.message }, error.status);
}
if (error instanceof Error) {
return c.json({ message: error.message }, 400);
}
throw error;
}
}

View File

@@ -1,25 +0,0 @@
import {
OnboardingError,
resendOnboardingOtp,
} from '@/modules/onboarding/services/onboarding.service';
import type { AppContext } from '@/types/hono';
import type { OnboardingResendOtpInput } from '@repo/api-contract';
export async function resendOtpHandler(c: AppContext) {
const payload = c.req.valid('json' as never) as OnboardingResendOtpInput;
try {
const result = await resendOnboardingOtp(payload);
return c.json(result);
} catch (error) {
if (error instanceof OnboardingError) {
return c.json({ message: error.message }, error.status);
}
if (error instanceof Error) {
return c.json({ message: error.message }, 400);
}
throw error;
}
}

View File

@@ -1,10 +0,0 @@
import { startOnboarding } from '@/modules/onboarding/services/onboarding.service';
import type { AppContext } from '@/types/hono';
import type { OnboardingStartInput } from '@repo/api-contract';
export async function startOnboardingHandler(c: AppContext) {
const payload = c.req.valid('json' as never) as OnboardingStartInput;
const result = await startOnboarding(payload);
return c.json(result, 202);
}

View File

@@ -1,10 +0,0 @@
import { verifyOnboardingOtp } from '@/modules/onboarding/services/onboarding.service';
import type { AppContext } from '@/types/hono';
import type { OnboardingVerifyOtpInput } from '@repo/api-contract';
export async function verifyOtpHandler(c: AppContext) {
const payload = c.req.valid('json' as never) as OnboardingVerifyOtpInput;
const result = await verifyOnboardingOtp(payload);
return c.json(result);
}

View File

@@ -1,35 +0,0 @@
import { completeOnboardingHandler } from '@/modules/onboarding/handlers/complete-onboarding.handler';
import { resendOtpHandler } from '@/modules/onboarding/handlers/resend-otp.handler';
import { startOnboardingHandler } from '@/modules/onboarding/handlers/start-onboarding.handler';
import { verifyOtpHandler } from '@/modules/onboarding/handlers/verify-otp.handler';
import type { AppEnv } from '@/types/hono';
import { zValidator } from '@hono/zod-validator';
import {
onboardingCompleteSchema,
onboardingResendOtpSchema,
onboardingStartSchema,
onboardingVerifyOtpSchema,
} from '@repo/api-contract';
import { Hono } from 'hono';
export const onboardingRoutes = new Hono<AppEnv>();
onboardingRoutes.post('/start', zValidator('json', onboardingStartSchema), startOnboardingHandler);
onboardingRoutes.post(
'/verify-otp',
zValidator('json', onboardingVerifyOtpSchema),
verifyOtpHandler
);
onboardingRoutes.post(
'/resend-otp',
zValidator('json', onboardingResendOtpSchema),
resendOtpHandler
);
onboardingRoutes.post(
'/complete',
zValidator('json', onboardingCompleteSchema),
completeOnboardingHandler
);

View File

@@ -1,415 +0,0 @@
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,
};
}

View File

@@ -1,23 +1,24 @@
import { db } from '@/lib/prisma';
import { parsePlanRules } from '@/modules/plan/services/plan-rules.service';
import type { AppContext } from '@/types/hono';
export async function listPlansHandler(c: AppContext) {
const plans = await db.plan.findMany({
select: {
code: true,
name: true,
price: true,
},
orderBy: {
price: 'asc',
},
});
return c.json(
plans.map((plan) => ({
code: plan.code,
name: plan.name,
price: Number(plan.price),
}))
plans.map((plan) => {
const rules = parsePlanRules(plan.rules);
return {
code: plan.code,
name: plan.name,
price: Number(plan.price),
features: rules.features,
limits: rules.limits,
};
})
);
}