feat: add city/state/country to complex, new settings page with sidebar, and Biome linting

- Added city, state, and country optional fields to Complex model
- Updated onboarding to include optional location fields
- Created new settings page with sidebar navigation (Datos del Complejo, Canchas)
- Replaced ESLint with Biome for frontend and backend linting
- Added parallel dev script with concurrently
- Migrated register-routes to use direct app.route() pattern
This commit is contained in:
Jose Selesan
2026-04-10 15:36:15 -03:00
parent 77004b14b0
commit 9ee98a4cb4
129 changed files with 2929 additions and 3186 deletions

View File

@@ -1,86 +1,86 @@
import { createHash, randomInt } from 'node:crypto'
import { v7 as uuidv7 } from 'uuid'
import type { User as SupabaseAuthUser } from '@supabase/supabase-js'
import { createHash, randomInt } from 'node:crypto';
import { sendMail } from '@/lib/mailer';
import { db } from '@/lib/prisma';
import { getSupabaseAdminClient } from '@/lib/supabase-admin';
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'
} from '@repo/api-contract';
import type { User as SupabaseAuthUser } from '@supabase/supabase-js';
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,
)
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
}
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
}
message: string;
requestId: string;
email: string;
expiresAt: string;
cooldownSeconds: number;
remainingAttempts: number;
};
type StartOnboardingResult = {
message: string
requestId: string
email: string
expiresAt: string
cooldownSeconds: number
}
message: string;
requestId: string;
email: string;
expiresAt: string;
cooldownSeconds: number;
};
export class OnboardingError extends Error {
status: 400 | 404 | 409 | 429
status: 400 | 404 | 409 | 429;
constructor(message: string, status: 400 | 404 | 409 | 429 = 400) {
super(message)
this.name = 'OnboardingError'
this.status = status
super(message);
this.name = 'OnboardingError';
this.status = status;
}
}
function nowPlusMinutes(minutes: number): Date {
const date = new Date()
date.setMinutes(date.getMinutes() + minutes)
return date
const date = new Date();
date.setMinutes(date.getMinutes() + minutes);
return date;
}
function normalizeEmail(email: string): string {
return email.trim().toLowerCase()
return email.trim().toLowerCase();
}
function createOtpCode(): string {
return randomInt(0, 10 ** OTP_LENGTH).toString().padStart(OTP_LENGTH, '0')
return randomInt(0, 10 ** OTP_LENGTH)
.toString()
.padStart(OTP_LENGTH, '0');
}
function hashValue(value: string): string {
return createHash('sha256').update(value).digest('hex')
return createHash('sha256').update(value).digest('hex');
}
function getRemainingAttempts(otpAttempts: number): number {
return Math.max(0, OTP_MAX_ATTEMPTS - otpAttempts)
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))
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 {
@@ -91,28 +91,28 @@ function slugify(value: string): string {
.trim()
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/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)}`
const base = slugify(complexName);
const fallback = base.length > 0 ? base : `complex-${uuidv7().slice(0, 8)}`;
let candidate = fallback
let index = 1
let candidate = fallback;
let index = 1;
while (true) {
const existing = await db.complex.findFirst({
where: { complexSlug: candidate },
select: { id: true },
})
});
if (!existing) {
return candidate
return candidate;
}
index += 1
candidate = `${fallback}-${index}`
index += 1;
candidate = `${fallback}-${index}`;
}
}
@@ -122,70 +122,61 @@ async function sendOtpEmail(email: string, otpCode: string) {
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
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}`)
throw new Error(`No se pudo consultar usuarios en Supabase: ${error.message}`);
}
const found = data.users.find(
(user) => user.email?.toLowerCase() === email.toLowerCase(),
)
const found = data.users.find((user) => user.email?.toLowerCase() === email.toLowerCase());
if (found) {
return found
return found;
}
if (data.users.length < perPage) {
return null
return null;
}
page += 1
page += 1;
}
return null
return null;
}
async function ensureSupabaseUser(params: {
email: string
fullName: string
password: string
email: string;
fullName: string;
password: string;
}): Promise<string> {
const existingUser = await findSupabaseUserByEmail(params.email)
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,
},
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}`,
)
throw new Error(`No se pudo actualizar usuario existente en Supabase: ${error.message}`);
}
return existingUser.id
return existingUser.id;
}
const { data, error } = await getSupabaseAdminClient().auth.admin.createUser({
@@ -196,24 +187,20 @@ async function ensureSupabaseUser(params: {
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'}`,
)
throw new Error(`No se pudo crear usuario en Supabase: ${error?.message ?? 'unknown error'}`);
}
return data.user.id
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()
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: {
@@ -231,9 +218,9 @@ export async function startOnboarding(
email: true,
otpExpiresAt: true,
},
})
});
await sendOtpEmail(email, otpCode)
await sendOtpEmail(email, otpCode);
return {
message: 'Te enviamos un codigo OTP para validar tu direccion.',
@@ -241,15 +228,15 @@ export async function startOnboarding(
email: request.email,
expiresAt: request.otpExpiresAt.toISOString(),
cooldownSeconds: OTP_RESEND_COOLDOWN_SECONDS,
}
};
}
export async function verifyOnboardingOtp(
input: OnboardingVerifyOtpInput,
input: OnboardingVerifyOtpInput
): Promise<VerifyOtpResult> {
const request = await db.onboardingRequest.findUnique({
where: { id: input.requestId },
})
});
if (!request) {
return {
@@ -260,7 +247,7 @@ export async function verifyOnboardingOtp(
expiresAt: null,
remainingAttempts: 0,
cooldownSeconds: 0,
}
};
}
if (request.completedAt) {
@@ -272,7 +259,7 @@ export async function verifyOnboardingOtp(
expiresAt: request.otpExpiresAt.toISOString(),
remainingAttempts: getRemainingAttempts(request.otpAttempts),
cooldownSeconds: getCooldownSeconds(request.otpLastSentAt),
}
};
}
if (request.otpExpiresAt < new Date()) {
@@ -284,7 +271,7 @@ export async function verifyOnboardingOtp(
expiresAt: request.otpExpiresAt.toISOString(),
remainingAttempts: getRemainingAttempts(request.otpAttempts),
cooldownSeconds: getCooldownSeconds(request.otpLastSentAt),
}
};
}
if (request.otpAttempts >= OTP_MAX_ATTEMPTS) {
@@ -296,10 +283,10 @@ export async function verifyOnboardingOtp(
expiresAt: request.otpExpiresAt.toISOString(),
remainingAttempts: 0,
cooldownSeconds: getCooldownSeconds(request.otpLastSentAt),
}
};
}
const otpMatches = hashValue(input.otp) === request.otpHash
const otpMatches = hashValue(input.otp) === request.otpHash;
if (!otpMatches) {
const updated = await db.onboardingRequest.update({
@@ -316,9 +303,9 @@ export async function verifyOnboardingOtp(
otpExpiresAt: true,
otpLastSentAt: true,
},
})
});
const remainingAttempts = getRemainingAttempts(updated.otpAttempts)
const remainingAttempts = getRemainingAttempts(updated.otpAttempts);
return {
message:
@@ -331,14 +318,14 @@ export async function verifyOnboardingOtp(
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 {
@@ -349,39 +336,36 @@ export async function verifyOnboardingOtp(
expiresAt: request.otpExpiresAt.toISOString(),
remainingAttempts: getRemainingAttempts(request.otpAttempts),
cooldownSeconds: getCooldownSeconds(request.otpLastSentAt),
}
};
}
export async function resendOnboardingOtp(
input: OnboardingResendOtpInput,
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)
throw new OnboardingError('Solicitud de onboarding invalida o expirada.', 404);
}
if (request.completedAt) {
throw new OnboardingError('Este onboarding ya fue completado.', 400)
throw new OnboardingError('Este onboarding ya fue completado.', 400);
}
if (request.emailVerifiedAt) {
throw new OnboardingError('El email ya fue verificado.', 400)
throw new OnboardingError('El email ya fue verificado.', 400);
}
const cooldownSeconds = getCooldownSeconds(request.otpLastSentAt)
const cooldownSeconds = getCooldownSeconds(request.otpLastSentAt);
if (cooldownSeconds > 0) {
throw new OnboardingError(
`Debes esperar ${cooldownSeconds}s antes de reenviar el OTP.`,
429,
)
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 otpCode = createOtpCode();
const expiresAt = nowPlusMinutes(OTP_TTL_MINUTES);
const now = new Date();
const updated = await db.onboardingRequest.update({
where: { id: request.id },
@@ -400,9 +384,9 @@ export async function resendOnboardingOtp(
otpExpiresAt: true,
otpAttempts: true,
},
})
});
await sendOtpEmail(updated.email, otpCode)
await sendOtpEmail(updated.email, otpCode);
return {
message: 'Te enviamos un nuevo codigo OTP.',
@@ -411,49 +395,46 @@ export async function resendOnboardingOtp(
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)
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,
)
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)
throw new OnboardingError('Debes verificar el email antes de continuar.', 400);
}
if (onboardingRequest.completedAt) {
throw new OnboardingError('Este onboarding ya fue completado.', 400)
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)
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 complexSlug = await buildUniqueComplexSlug(input.complexName);
const result = await db.$transaction(async (tx) => {
const user = await tx.user.upsert({
@@ -468,7 +449,7 @@ export async function completeOnboarding(input: OnboardingCompleteInput) {
email: onboardingRequest.email,
supabaseUserId,
},
})
});
const complex = await tx.complex.create({
data: {
@@ -482,7 +463,7 @@ export async function completeOnboarding(input: OnboardingCompleteInput) {
adminEmail: onboardingRequest.email,
planCode: input.planCode,
},
})
});
await tx.complexUser.create({
data: {
@@ -490,24 +471,24 @@ export async function completeOnboarding(input: OnboardingCompleteInput) {
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,
}
};
}