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,37 @@
import { zValidator } from '@hono/zod-validator'
import { createComplexSchema, updateComplexSchema } from '@repo/api-contract'
import { Hono } from 'hono'
import { z } from 'zod'
import { requireAuth } from '@/middlewares/require-auth.middleware'
import { createComplexHandler } from '@/modules/complex/handlers/create-complex.handler'
import { getComplexByIdHandler } from '@/modules/complex/handlers/get-complex-by-id.handler'
import { getComplexBySlugHandler } from '@/modules/complex/handlers/get-complex-by-slug.handler'
import { listMyComplexesHandler } from '@/modules/complex/handlers/list-my-complexes.handler'
import { updateComplexHandler } from '@/modules/complex/handlers/update-complex.handler'
import type { AppEnv } from '@/types/hono'
export const complexRoutes = new Hono<AppEnv>()
const complexIdParamsSchema = z.object({ id: z.uuid() })
const complexSlugParamsSchema = z.object({ slug: z.string().trim().min(1) })
complexRoutes.use('*', requireAuth)
complexRoutes.post('/', zValidator('json', createComplexSchema), createComplexHandler)
complexRoutes.get('/mine', listMyComplexesHandler)
complexRoutes.get(
'/slug/:slug',
zValidator('param', complexSlugParamsSchema),
getComplexBySlugHandler,
)
complexRoutes.get(
'/:id',
zValidator('param', complexIdParamsSchema),
getComplexByIdHandler,
)
complexRoutes.patch(
'/:id',
zValidator('param', complexIdParamsSchema),
zValidator('json', updateComplexSchema),
updateComplexHandler,
)

View File

@@ -0,0 +1,23 @@
import type { CreateComplexInput } from '@repo/api-contract'
import type { AppContext } from '@/types/hono'
import { createComplex } from '@/modules/complex/services/complex.service'
export async function createComplexHandler(c: AppContext) {
const payload = c.req.valid('json' as never) as CreateComplexInput
const authUser = c.get('authUser')
const adminEmail = authUser.email
if (!adminEmail) {
return c.json({ message: 'El usuario autenticado no tiene email.' }, 400)
}
const complex = await createComplex({
complexName: payload.complexName,
physicalAddress: payload.physicalAddress,
adminEmail,
planCode: payload.planCode,
})
return c.json(complex, 201)
}

View File

@@ -0,0 +1,16 @@
import type { AppContext } from '@/types/hono'
import { getComplexById } from '@/modules/complex/services/complex.service'
type ComplexIdParams = { id: string }
export async function getComplexByIdHandler(c: AppContext) {
const { id } = c.req.valid('param' as never) as ComplexIdParams
const complex = await getComplexById(id)
if (!complex) {
return c.json({ message: 'Complejo no encontrado.' }, 404)
}
return c.json(complex)
}

View File

@@ -0,0 +1,16 @@
import type { AppContext } from '@/types/hono'
import { getComplexBySlug } from '@/modules/complex/services/complex.service'
type ComplexSlugParams = { slug: string }
export async function getComplexBySlugHandler(c: AppContext) {
const { slug } = c.req.valid('param' as never) as ComplexSlugParams
const complex = await getComplexBySlug(slug)
if (!complex) {
return c.json({ message: 'Complejo no encontrado.' }, 404)
}
return c.json(complex)
}

View File

@@ -0,0 +1,8 @@
import type { AppContext } from '@/types/hono'
import { listMyComplexes } from '@/modules/complex/services/complex.service'
export async function listMyComplexesHandler(c: AppContext) {
const appUserId = c.get('appUserId')
const complexes = await listMyComplexes(appUserId)
return c.json(complexes)
}

View File

@@ -0,0 +1,27 @@
import type { UpdateComplexInput } from '@repo/api-contract'
import { Prisma } from '@/generated/prisma/client'
import type { AppContext } from '@/types/hono'
import { getComplexById, updateComplex } from '@/modules/complex/services/complex.service'
type ComplexIdParams = { id: string }
export async function updateComplexHandler(c: AppContext) {
const { id } = c.req.valid('param' as never) as ComplexIdParams
const payload = c.req.valid('json' as never) as UpdateComplexInput
const existing = await getComplexById(id)
if (!existing) {
return c.json({ message: 'Complejo no encontrado.' }, 404)
}
try {
const complex = await updateComplex(id, payload)
return c.json(complex)
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
return c.json({ message: 'No se pudo actualizar el complejo.' }, 409)
}
throw error
}
}

View File

@@ -0,0 +1,132 @@
import { v7 as uuidv7 } from 'uuid'
import { Prisma } from '@/generated/prisma/client'
import { db } from '@/lib/prisma'
export type CreateComplexInput = {
complexName: string
physicalAddress: string
adminEmail: string
planCode?: string
}
export type UpdateComplexInput = {
complexName?: string
physicalAddress?: string | null
complexSlug?: string
adminEmail?: string
planCode?: string | null
}
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 buildUniqueSlug(
source: string,
excludeComplexId?: string,
): Promise<string> {
const base = slugify(source)
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,
...(excludeComplexId
? {
id: {
not: excludeComplexId,
},
}
: {}),
},
select: { id: true },
})
if (!existing) return candidate
index += 1
candidate = `${fallback}-${index}`
}
}
export async function createComplex(input: CreateComplexInput) {
const complexSlug = await buildUniqueSlug(input.complexName)
return db.complex.create({
data: {
id: uuidv7(),
complexName: input.complexName,
physicalAddress: input.physicalAddress.trim(),
complexSlug,
adminEmail: input.adminEmail,
planCode: input.planCode,
},
})
}
export async function getComplexById(id: string) {
return db.complex.findUnique({
where: { id },
})
}
export async function getComplexBySlug(slug: string) {
return db.complex.findUnique({
where: { complexSlug: slug },
})
}
export async function listMyComplexes(appUserId: string) {
const complexUsers = await db.complexUser.findMany({
where: { userId: appUserId },
include: {
complex: true,
},
orderBy: {
createdAt: 'asc',
},
})
return complexUsers.map((complexUser) => complexUser.complex)
}
export async function updateComplex(id: string, input: UpdateComplexInput) {
const data: Record<string, unknown> = {}
if (input.complexName) {
data.complexName = input.complexName
data.complexSlug = await buildUniqueSlug(input.complexName, id)
}
if (input.complexSlug) {
data.complexSlug = await buildUniqueSlug(input.complexSlug, id)
}
if (input.adminEmail) {
data.adminEmail = input.adminEmail
}
if (input.planCode !== undefined) {
data.planCode = input.planCode
}
if (input.physicalAddress !== undefined) {
data.physicalAddress = input.physicalAddress?.trim() ?? null
}
return db.complex.update({
where: { id },
data: data as Prisma.ComplexUncheckedUpdateInput,
})
}

View File

@@ -0,0 +1,33 @@
import { zValidator } from '@hono/zod-validator'
import { createCourtSchema, updateCourtSchema } from '@repo/api-contract'
import { Hono } from 'hono'
import { z } from 'zod'
import { requireAuth } from '@/middlewares/require-auth.middleware'
import { createCourtHandler } from '@/modules/court/handlers/create-court.handler'
import { listCourtsByComplexHandler } from '@/modules/court/handlers/list-courts-by-complex.handler'
import { updateCourtHandler } from '@/modules/court/handlers/update-court.handler'
import type { AppEnv } from '@/types/hono'
export const courtRoutes = new Hono<AppEnv>()
const complexIdParamsSchema = z.object({ complexId: z.uuid() })
const courtIdParamsSchema = z.object({ id: z.uuid() })
courtRoutes.use('*', requireAuth)
courtRoutes.get(
'/complex/:complexId',
zValidator('param', complexIdParamsSchema),
listCourtsByComplexHandler,
)
courtRoutes.post(
'/complex/:complexId',
zValidator('param', complexIdParamsSchema),
zValidator('json', createCourtSchema),
createCourtHandler,
)
courtRoutes.patch(
'/:id',
zValidator('param', courtIdParamsSchema),
zValidator('json', updateCourtSchema),
updateCourtHandler,
)

View File

@@ -0,0 +1,21 @@
import type { CreateCourtInput } from '@repo/api-contract'
import { CourtServiceError, createCourt } from '@/modules/court/services/court.service'
import type { AppContext } from '@/types/hono'
type ComplexIdParams = { complexId: string }
export async function createCourtHandler(c: AppContext) {
const { complexId } = c.req.valid('param' as never) as ComplexIdParams
const payload = c.req.valid('json' as never) as CreateCourtInput
const appUserId = c.get('appUserId')
try {
const court = await createCourt(appUserId, complexId, payload)
return c.json(court, 201)
} catch (error) {
if (error instanceof CourtServiceError) {
return c.json({ message: error.message }, error.status)
}
throw error
}
}

View File

@@ -0,0 +1,19 @@
import { CourtServiceError, listCourtsByComplex } from '@/modules/court/services/court.service'
import type { AppContext } from '@/types/hono'
type ComplexIdParams = { complexId: string }
export async function listCourtsByComplexHandler(c: AppContext) {
const { complexId } = c.req.valid('param' as never) as ComplexIdParams
const appUserId = c.get('appUserId')
try {
const courts = await listCourtsByComplex(complexId, appUserId)
return c.json(courts)
} catch (error) {
if (error instanceof CourtServiceError) {
return c.json({ message: error.message }, error.status)
}
throw error
}
}

View File

@@ -0,0 +1,21 @@
import type { UpdateCourtInput } from '@repo/api-contract'
import { CourtServiceError, updateCourt } from '@/modules/court/services/court.service'
import type { AppContext } from '@/types/hono'
type CourtIdParams = { id: string }
export async function updateCourtHandler(c: AppContext) {
const { id } = c.req.valid('param' as never) as CourtIdParams
const payload = c.req.valid('json' as never) as UpdateCourtInput
const appUserId = c.get('appUserId')
try {
const court = await updateCourt(appUserId, id, payload)
return c.json(court)
} catch (error) {
if (error instanceof CourtServiceError) {
return c.json({ message: error.message }, error.status)
}
throw error
}
}

View File

@@ -0,0 +1,339 @@
import type { CreateCourtInput, DayOfWeek, UpdateCourtInput } from '@repo/api-contract'
import { v7 as uuidv7 } from 'uuid'
import type {
Court,
CourtAvailability,
CourtPriceRule,
Sport,
} from '@/generated/prisma/client'
import { db } from '@/lib/prisma'
import { evaluatePlanUsage, parsePlanRules } from '@/modules/plan/services/plan-rules.service'
type CourtWithRelations = Court & {
sport: Sport
availabilities: CourtAvailability[]
priceRules: CourtPriceRule[]
}
export class CourtServiceError extends Error {
status: 400 | 403 | 404 | 409
constructor(message: string, status: 400 | 403 | 404 | 409) {
super(message)
this.name = 'CourtServiceError'
this.status = status
}
}
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
}>,
) {
const grouped = new Map<DayOfWeek, Array<{ start: number; end: number }>>()
for (const range of availability) {
const start = toMinutes(range.startTime)
const end = toMinutes(range.endTime)
if (start >= end) {
throw new CourtServiceError(
`El rango ${range.startTime}-${range.endTime} es invalido.`,
400,
)
}
const current = grouped.get(range.dayOfWeek) ?? []
current.push({ start, end })
grouped.set(range.dayOfWeek, current)
}
for (const ranges of grouped.values()) {
ranges.sort((a, b) => a.start - b.start)
for (let index = 1; index < ranges.length; index += 1) {
const previous = ranges[index - 1]
const current = ranges[index]
if (previous.end > current.start) {
throw new CourtServiceError(
'Hay rangos horarios superpuestos para el mismo dia.',
400,
)
}
}
}
}
async function ensureComplexAccess(complexId: string, appUserId: string) {
const complexUser = await db.complexUser.findUnique({
where: {
complexId_userId: {
complexId,
userId: appUserId,
},
},
include: {
complex: {
select: {
id: true,
plan: {
select: {
rules: true,
},
},
},
},
},
})
if (!complexUser) {
throw new CourtServiceError(
'No tienes permisos para administrar este complejo.',
403,
)
}
return complexUser.complex
}
async function ensureActiveSport(sportId: string) {
const sport = await db.sport.findFirst({
where: {
id: sportId,
isActive: true,
},
select: { id: true },
})
if (!sport) {
throw new CourtServiceError('El deporte seleccionado no existe o esta inactivo.', 400)
}
}
async function enforcePlanCourtLimit(complexId: string) {
const complex = await db.complex.findUnique({
where: { id: complexId },
select: {
plan: {
select: {
rules: true,
},
},
},
})
if (!complex?.plan) {
return
}
const rules = parsePlanRules(complex.plan.rules)
const courtsCount = await db.court.count({
where: { complexId },
})
const violations = evaluatePlanUsage(rules, {
courtsCount,
bookingsToday: 0,
})
const maxCourtViolation = violations.find(
(violation) => violation.code === 'MAX_COURTS_REACHED',
)
if (maxCourtViolation) {
throw new CourtServiceError(maxCourtViolation.message, 409)
}
}
async function getCourtByIdForUser(courtId: string, appUserId: string) {
return db.court.findFirst({
where: {
id: courtId,
complex: {
users: {
some: {
userId: appUserId,
},
},
},
},
include: {
sport: true,
availabilities: {
orderBy: [{ dayOfWeek: 'asc' }, { startTime: 'asc' }],
},
priceRules: {
where: { isActive: true },
orderBy: [{ dayOfWeek: 'asc' }, { startTime: 'asc' }],
},
},
})
}
function mapCourtResponse(court: CourtWithRelations) {
return {
id: court.id,
complexId: court.complexId,
name: court.name,
sportId: court.sportId,
sport: {
id: court.sport.id,
name: court.sport.name,
slug: court.sport.slug,
},
slotDurationMinutes: court.slotDurationMinutes,
basePrice: Number(court.basePrice),
availability: court.availabilities.map((availability) => ({
id: availability.id,
dayOfWeek: availability.dayOfWeek,
startTime: availability.startTime,
endTime: availability.endTime,
})),
priceRules: court.priceRules.map((rule) => ({
id: rule.id,
dayOfWeek: rule.dayOfWeek,
startTime: rule.startTime,
endTime: rule.endTime,
price: Number(rule.price),
isActive: rule.isActive,
})),
hasCustomPricing: court.priceRules.length > 0,
createdAt: court.createdAt.toISOString(),
updatedAt: court.updatedAt.toISOString(),
}
}
export async function listCourtsByComplex(complexId: string, appUserId: string) {
await ensureComplexAccess(complexId, appUserId)
const courts = await db.court.findMany({
where: { complexId },
include: {
sport: true,
availabilities: {
orderBy: [{ dayOfWeek: 'asc' }, { startTime: 'asc' }],
},
priceRules: {
where: { isActive: true },
orderBy: [{ dayOfWeek: 'asc' }, { startTime: 'asc' }],
},
},
orderBy: {
createdAt: 'asc',
},
})
return courts.map((court) => mapCourtResponse(court))
}
export async function createCourt(
appUserId: string,
complexId: string,
input: CreateCourtInput,
) {
await ensureComplexAccess(complexId, appUserId)
await ensureActiveSport(input.sportId)
await enforcePlanCourtLimit(complexId)
assertAvailabilityRanges(input.availability)
const createdCourt = await db.$transaction(async (tx) => {
const court = await tx.court.create({
data: {
id: uuidv7(),
complexId,
sportId: input.sportId,
name: input.name.trim(),
slotDurationMinutes: input.slotDurationMinutes,
basePrice: input.basePrice,
},
})
await tx.courtAvailability.createMany({
data: input.availability.map((availability) => ({
id: uuidv7(),
courtId: court.id,
dayOfWeek: availability.dayOfWeek,
startTime: availability.startTime,
endTime: availability.endTime,
})),
})
return court
})
const court = await getCourtByIdForUser(createdCourt.id, appUserId)
if (!court) {
throw new CourtServiceError('No se pudo obtener la cancha creada.', 404)
}
return mapCourtResponse(court)
}
export async function updateCourt(
appUserId: string,
courtId: string,
input: UpdateCourtInput,
) {
const existingCourt = await getCourtByIdForUser(courtId, appUserId)
if (!existingCourt) {
throw new CourtServiceError('Cancha no encontrada.', 404)
}
if (input.sportId) {
await ensureActiveSport(input.sportId)
}
if (input.availability) {
assertAvailabilityRanges(input.availability)
}
await db.$transaction(async (tx) => {
await tx.court.update({
where: {
id: courtId,
},
data: {
...(input.name !== undefined ? { name: input.name.trim() } : {}),
...(input.sportId !== undefined ? { sportId: input.sportId } : {}),
...(input.slotDurationMinutes !== undefined
? { slotDurationMinutes: input.slotDurationMinutes }
: {}),
...(input.basePrice !== undefined ? { basePrice: input.basePrice } : {}),
},
})
if (input.availability) {
await tx.courtAvailability.deleteMany({
where: { courtId },
})
await tx.courtAvailability.createMany({
data: input.availability.map((availability) => ({
id: uuidv7(),
courtId,
dayOfWeek: availability.dayOfWeek,
startTime: availability.startTime,
endTime: availability.endTime,
})),
})
}
})
const updatedCourt = await getCourtByIdForUser(courtId, appUserId)
if (!updatedCourt) {
throw new CourtServiceError('Cancha no encontrada.', 404)
}
return mapCourtResponse(updatedCourt)
}

View File

@@ -0,0 +1,27 @@
import type { OnboardingCompleteInput } from '@repo/api-contract'
import type { AppContext } from '@/types/hono'
import {
OnboardingError,
completeOnboarding,
} from '@/modules/onboarding/services/onboarding.service'
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

@@ -0,0 +1,25 @@
import type { OnboardingResendOtpInput } from '@repo/api-contract'
import type { AppContext } from '@/types/hono'
import {
OnboardingError,
resendOnboardingOtp,
} from '@/modules/onboarding/services/onboarding.service'
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

@@ -0,0 +1,10 @@
import type { OnboardingStartInput } from '@repo/api-contract'
import type { AppContext } from '@/types/hono'
import { startOnboarding } from '@/modules/onboarding/services/onboarding.service'
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

@@ -0,0 +1,10 @@
import type { OnboardingVerifyOtpInput } from '@repo/api-contract'
import type { AppContext } from '@/types/hono'
import { verifyOnboardingOtp } from '@/modules/onboarding/services/onboarding.service'
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

@@ -0,0 +1,39 @@
import { zValidator } from '@hono/zod-validator'
import {
onboardingCompleteSchema,
onboardingResendOtpSchema,
onboardingStartSchema,
onboardingVerifyOtpSchema,
} from '@repo/api-contract'
import { Hono } from 'hono'
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'
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

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

View File

@@ -0,0 +1,23 @@
import type { AppContext } from '@/types/hono'
import { db } from '@/lib/prisma'
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),
})),
)
}

View File

@@ -0,0 +1,7 @@
import { Hono } from 'hono'
import { listPlansHandler } from '@/modules/plan/handlers/list-plans.handler'
import type { AppEnv } from '@/types/hono'
export const planRoutes = new Hono<AppEnv>()
planRoutes.get('/', listPlansHandler)

View File

@@ -0,0 +1,63 @@
import type { PlanRules } from '@repo/api-contract'
import { planRulesSchema } from '@repo/api-contract'
export type PlanUsageSnapshot = {
courtsCount: number
bookingsToday: number
activeUsersCount?: number
}
export type PlanViolationCode =
| 'MAX_COURTS_REACHED'
| 'MAX_BOOKINGS_PER_DAY_REACHED'
| 'MAX_ACTIVE_USERS_REACHED'
export type PlanViolation = {
code: PlanViolationCode
message: string
}
export function parsePlanRules(input: unknown): PlanRules {
return planRulesSchema.parse(input)
}
export function evaluatePlanUsage(
rules: PlanRules,
usage: PlanUsageSnapshot,
): PlanViolation[] {
const violations: PlanViolation[] = []
if (usage.courtsCount >= rules.limits.maxCourts) {
violations.push({
code: 'MAX_COURTS_REACHED',
message: `El plan permite hasta ${rules.limits.maxCourts} canchas.`,
})
}
if (usage.bookingsToday >= rules.limits.maxBookingsPerDay) {
violations.push({
code: 'MAX_BOOKINGS_PER_DAY_REACHED',
message: `El plan permite hasta ${rules.limits.maxBookingsPerDay} turnos por dia.`,
})
}
if (
typeof rules.limits.maxActiveUsers === 'number' &&
typeof usage.activeUsersCount === 'number' &&
usage.activeUsersCount >= rules.limits.maxActiveUsers
) {
violations.push({
code: 'MAX_ACTIVE_USERS_REACHED',
message: `El plan permite hasta ${rules.limits.maxActiveUsers} usuarios activos.`,
})
}
return violations
}
export function isFeatureEnabled(
rules: PlanRules,
feature: keyof PlanRules['features'],
): boolean {
return rules.features[feature]
}

View File

@@ -0,0 +1,24 @@
import type { CreatePublicBookingInput } from '@repo/api-contract'
import {
PublicBookingServiceError,
createPublicBooking,
} from '@/modules/public-booking/services/public-booking.service'
import type { AppContext } from '@/types/hono'
type ComplexSlugParams = { complexSlug: string }
export async function createPublicBookingHandler(c: AppContext) {
const { complexSlug } = c.req.valid('param' as never) as ComplexSlugParams
const payload = c.req.valid('json' as never) as CreatePublicBookingInput
try {
const booking = await createPublicBooking(complexSlug, payload)
return c.json(booking, 201)
} catch (error) {
if (error instanceof PublicBookingServiceError) {
return c.json({ message: error.message }, error.status)
}
throw error
}
}

View File

@@ -0,0 +1,25 @@
import {
PublicBookingServiceError,
getPublicBookingConfirmation,
} from '@/modules/public-booking/services/public-booking.service'
import type { AppContext } from '@/types/hono'
type BookingCodeParams = {
complexSlug: string
bookingCode: string
}
export async function getPublicBookingConfirmationHandler(c: AppContext) {
const { complexSlug, bookingCode } = c.req.valid('param' as never) as BookingCodeParams
try {
const booking = await getPublicBookingConfirmation(complexSlug, bookingCode)
return c.json(booking)
} catch (error) {
if (error instanceof PublicBookingServiceError) {
return c.json({ message: error.message }, error.status)
}
throw error
}
}

View File

@@ -0,0 +1,24 @@
import type { PublicAvailabilityQuery } from '@repo/api-contract'
import {
PublicBookingServiceError,
listPublicAvailability,
} from '@/modules/public-booking/services/public-booking.service'
import type { AppContext } from '@/types/hono'
type ComplexSlugParams = { complexSlug: string }
export async function listPublicAvailabilityHandler(c: AppContext) {
const { complexSlug } = c.req.valid('param' as never) as ComplexSlugParams
const query = c.req.valid('query' as never) as PublicAvailabilityQuery
try {
const availability = await listPublicAvailability(complexSlug, query)
return c.json(availability)
} catch (error) {
if (error instanceof PublicBookingServiceError) {
return c.json({ message: error.message }, error.status)
}
throw error
}
}

View File

@@ -0,0 +1,38 @@
import { zValidator } from '@hono/zod-validator'
import {
createPublicBookingSchema,
publicAvailabilityQuerySchema,
} from '@repo/api-contract'
import { Hono } from 'hono'
import { z } from 'zod'
import { createPublicBookingHandler } from '@/modules/public-booking/handlers/create-public-booking.handler'
import { getPublicBookingConfirmationHandler } from '@/modules/public-booking/handlers/get-public-booking-confirmation.handler'
import { listPublicAvailabilityHandler } from '@/modules/public-booking/handlers/list-public-availability.handler'
import type { AppEnv } from '@/types/hono'
export const publicBookingRoutes = new Hono<AppEnv>()
const complexSlugParamsSchema = z.object({ complexSlug: z.string().trim().min(1) })
const confirmationParamsSchema = z.object({
complexSlug: z.string().trim().min(1),
bookingCode: z.string().trim().min(6).max(8),
})
publicBookingRoutes.get(
'/complex/:complexSlug/availability',
zValidator('param', complexSlugParamsSchema),
zValidator('query', publicAvailabilityQuerySchema),
listPublicAvailabilityHandler,
)
publicBookingRoutes.post(
'/complex/:complexSlug',
zValidator('param', complexSlugParamsSchema),
zValidator('json', createPublicBookingSchema),
createPublicBookingHandler,
)
publicBookingRoutes.get(
'/complex/:complexSlug/confirmation/:bookingCode',
zValidator('param', confirmationParamsSchema),
getPublicBookingConfirmationHandler,
)

View File

@@ -0,0 +1,616 @@
import type {
CreatePublicBookingInput,
DayOfWeek,
PublicAvailabilityQuery,
} from '@repo/api-contract'
import { randomInt } from 'node:crypto'
import { v7 as uuidv7 } from 'uuid'
import { db } from '@/lib/prisma'
import { evaluatePlanUsage, parsePlanRules } from '@/modules/plan/services/plan-rules.service'
type Slot = {
startTime: string
endTime: string
}
type ComplexWithPublicBookingData = Awaited<ReturnType<typeof getComplexWithBookingData>>
const DAY_OF_WEEK_BY_INDEX: DayOfWeek[] = [
'SUNDAY',
'MONDAY',
'TUESDAY',
'WEDNESDAY',
'THURSDAY',
'FRIDAY',
'SATURDAY',
]
const BOOKING_CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
const BOOKING_CODE_LENGTH = 6
export class PublicBookingServiceError extends Error {
status: 400 | 403 | 404 | 409
constructor(message: string, status: 400 | 403 | 404 | 409) {
super(message)
this.name = 'PublicBookingServiceError'
this.status = status
}
}
function toMinutes(value: string): number {
const [hours, minutes] = value.split(':').map((part) => Number(part))
return hours * 60 + minutes
}
function minutesToTime(minutes: number): string {
const safeMinutes = Math.max(0, minutes)
const hours = Math.floor(safeMinutes / 60)
const mins = safeMinutes % 60
return `${String(hours).padStart(2, '0')}:${String(mins).padStart(2, '0')}`
}
function parseIsoDate(date: string): { bookingDate: Date; dayOfWeek: DayOfWeek } {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date)
if (!match) {
throw new PublicBookingServiceError('La fecha debe tener formato YYYY-MM-DD.', 400)
}
const year = Number(match[1])
const month = Number(match[2])
const day = Number(match[3])
const bookingDate = new Date(Date.UTC(year, month - 1, day))
if (
Number.isNaN(bookingDate.getTime()) ||
bookingDate.getUTCFullYear() !== year ||
bookingDate.getUTCMonth() + 1 !== month ||
bookingDate.getUTCDate() !== day
) {
throw new PublicBookingServiceError('La fecha enviada no es valida.', 400)
}
const dayOfWeek = DAY_OF_WEEK_BY_INDEX[bookingDate.getUTCDay()]
if (!dayOfWeek) {
throw new PublicBookingServiceError('No se pudo resolver el dia de la semana.', 400)
}
return { bookingDate, dayOfWeek }
}
function formatIsoDate(date: Date): string {
return date.toISOString().slice(0, 10)
}
function generateBookingCode(): string {
let code = ''
for (let index = 0; index < BOOKING_CODE_LENGTH; index += 1) {
code += BOOKING_CODE_ALPHABET[randomInt(0, BOOKING_CODE_ALPHABET.length)]
}
return code
}
function hasOverlap(slot: Slot, existing: Slot): boolean {
return slot.startTime < existing.endTime && slot.endTime > existing.startTime
}
function buildSlots(
availability: Array<{
startTime: string
endTime: string
}>,
slotDurationMinutes: number,
): Slot[] {
const slots: Slot[] = []
for (const range of availability) {
const start = toMinutes(range.startTime)
const end = toMinutes(range.endTime)
for (
let current = start;
current + slotDurationMinutes <= end;
current += slotDurationMinutes
) {
slots.push({
startTime: minutesToTime(current),
endTime: minutesToTime(current + slotDurationMinutes),
})
}
}
return slots
}
function resolveSports(data: ComplexWithPublicBookingData) {
const map = new Map<string, { id: string; name: string; slug: string }>()
for (const court of data.courts) {
if (!court.sport.isActive) {
continue
}
if (!map.has(court.sport.id)) {
map.set(court.sport.id, {
id: court.sport.id,
name: court.sport.name,
slug: court.sport.slug,
})
}
}
return [...map.values()].sort((a, b) => a.name.localeCompare(b.name, 'es'))
}
function validateSportSelection(
availableSports: Array<{ id: string }>,
sportId: string | undefined,
): { sportSelectionRequired: boolean; selectedSportId: string | undefined } {
const sportSelectionRequired = availableSports.length > 1
if (sportSelectionRequired && !sportId) {
throw new PublicBookingServiceError(
'Debes seleccionar un deporte para consultar disponibilidad.',
400,
)
}
if (sportId && !availableSports.some((sport) => sport.id === sportId)) {
throw new PublicBookingServiceError('El deporte seleccionado no pertenece al complejo.', 400)
}
if (sportSelectionRequired) {
return {
sportSelectionRequired,
selectedSportId: sportId,
}
}
return {
sportSelectionRequired,
selectedSportId: sportId ?? availableSports[0]?.id,
}
}
async function getComplexWithBookingData(complexSlug: string) {
const complex = await db.complex.findUnique({
where: { complexSlug },
select: {
id: true,
complexName: true,
complexSlug: true,
plan: {
select: {
rules: true,
},
},
courts: {
include: {
sport: {
select: {
id: true,
name: true,
slug: true,
isActive: true,
},
},
availabilities: {
orderBy: [{ dayOfWeek: 'asc' }, { startTime: 'asc' }],
},
},
orderBy: { createdAt: 'asc' },
},
},
})
if (!complex) {
throw new PublicBookingServiceError('Complejo no encontrado.', 404)
}
if (complex.plan) {
const rules = parsePlanRules(complex.plan.rules)
if (!rules.features.publicBookingPage) {
throw new PublicBookingServiceError(
'El complejo no tiene habilitada la reserva publica.',
403,
)
}
}
return complex
}
function mapBookingResponse(input: {
bookingId: string
bookingCode: string
bookingDate: Date
createdAt: Date
startTime: string
endTime: string
customerName: string
customerPhone: string
status: 'CONFIRMED' | 'CANCELLED'
court: {
id: string
name: string
complexId: string
complexName: string
complexSlug: string
sport: {
id: string
name: string
slug: string
}
}
}) {
return {
id: input.bookingId,
bookingCode: input.bookingCode,
complexId: input.court.complexId,
complexName: input.court.complexName,
complexSlug: input.court.complexSlug,
courtId: input.court.id,
courtName: input.court.name,
sport: input.court.sport,
date: formatIsoDate(input.bookingDate),
startTime: input.startTime,
endTime: input.endTime,
customerName: input.customerName,
customerPhone: input.customerPhone,
status: input.status,
createdAt: input.createdAt.toISOString(),
}
}
export async function getPublicBookingConfirmation(complexSlug: string, bookingCode: string) {
const normalizedBookingCode = bookingCode.toUpperCase()
const booking = await db.courtBooking.findFirst({
where: {
bookingCode: normalizedBookingCode,
court: {
complex: {
complexSlug,
},
},
},
select: {
bookingCode: true,
bookingDate: true,
startTime: true,
endTime: true,
status: true,
createdAt: true,
court: {
select: {
name: true,
sport: {
select: {
id: true,
name: true,
slug: true,
},
},
complex: {
select: {
complexName: true,
complexSlug: true,
},
},
},
},
},
})
if (!booking) {
throw new PublicBookingServiceError('Reserva no encontrada.', 404)
}
return {
bookingCode: booking.bookingCode,
complexName: booking.court.complex.complexName,
complexSlug: booking.court.complex.complexSlug,
date: formatIsoDate(booking.bookingDate),
startTime: booking.startTime,
endTime: booking.endTime,
courtName: booking.court.name,
sport: {
id: booking.court.sport.id,
name: booking.court.sport.name,
slug: booking.court.sport.slug,
},
status: booking.status,
createdAt: booking.createdAt.toISOString(),
}
}
export async function listPublicAvailability(
complexSlug: string,
query: PublicAvailabilityQuery,
) {
const { bookingDate, dayOfWeek } = parseIsoDate(query.date)
const complex = await getComplexWithBookingData(complexSlug)
const sports = resolveSports(complex)
const sportSelectionRequired = sports.length > 1
if (query.sportId && !sports.some((sport) => sport.id === query.sportId)) {
throw new PublicBookingServiceError('El deporte seleccionado no pertenece al complejo.', 400)
}
const selectedSportId = sportSelectionRequired
? query.sportId
: (query.sportId ?? sports[0]?.id)
const candidateCourts = complex.courts.filter((court) => {
if (!court.sport.isActive) {
return false
}
if (selectedSportId) {
return court.sportId === selectedSportId
}
return true
})
const courtIds = candidateCourts.map((court) => court.id)
const bookings =
courtIds.length > 0
? await db.courtBooking.findMany({
where: {
bookingDate,
status: 'CONFIRMED',
courtId: {
in: courtIds,
},
},
select: {
courtId: true,
startTime: true,
endTime: true,
},
})
: []
const bookingsByCourt = new Map<string, Slot[]>()
for (const booking of bookings) {
const current = bookingsByCourt.get(booking.courtId) ?? []
current.push({
startTime: booking.startTime,
endTime: booking.endTime,
})
bookingsByCourt.set(booking.courtId, current)
}
const courts = candidateCourts
.map((court) => {
const dayAvailability = court.availabilities.filter(
(availability) => availability.dayOfWeek === dayOfWeek,
)
const allSlots = buildSlots(dayAvailability, court.slotDurationMinutes)
const occupiedSlots = bookingsByCourt.get(court.id) ?? []
const availableSlots = allSlots.filter(
(slot) => !occupiedSlots.some((occupied) => hasOverlap(slot, occupied)),
)
return {
courtId: court.id,
courtName: court.name,
sport: {
id: court.sport.id,
name: court.sport.name,
slug: court.sport.slug,
},
slotDurationMinutes: court.slotDurationMinutes,
availabilityDay: dayOfWeek,
availableSlots,
}
})
.filter((court) => court.availableSlots.length > 0)
return {
complexId: complex.id,
complexName: complex.complexName,
complexSlug: complex.complexSlug,
date: query.date,
sportSelectionRequired,
sports,
courts,
}
}
export async function createPublicBooking(
complexSlug: string,
input: CreatePublicBookingInput,
) {
const { bookingDate, dayOfWeek } = parseIsoDate(input.date)
const complex = await getComplexWithBookingData(complexSlug)
const sports = resolveSports(complex)
const { sportSelectionRequired } = validateSportSelection(sports, input.sportId)
const selectedCourt = complex.courts.find(
(court) => court.id === input.courtId && court.sport.isActive,
)
if (!selectedCourt) {
throw new PublicBookingServiceError('La cancha seleccionada no existe en el complejo.', 404)
}
if (sportSelectionRequired && !input.sportId) {
throw new PublicBookingServiceError(
'Debes seleccionar un deporte para reservar en este complejo.',
400,
)
}
if (input.sportId && input.sportId !== selectedCourt.sportId) {
throw new PublicBookingServiceError(
'La cancha seleccionada no corresponde al deporte indicado.',
400,
)
}
const dayAvailability = selectedCourt.availabilities.filter(
(availability) => availability.dayOfWeek === dayOfWeek,
)
const validSlots = buildSlots(dayAvailability, selectedCourt.slotDurationMinutes)
const selectedStartMinutes = toMinutes(input.startTime)
const selectedEndMinutes = selectedStartMinutes + selectedCourt.slotDurationMinutes
const selectedSlot = {
startTime: input.startTime,
endTime: minutesToTime(selectedEndMinutes),
}
const slotExists = validSlots.some(
(slot) => slot.startTime === selectedSlot.startTime && slot.endTime === selectedSlot.endTime,
)
if (!slotExists) {
throw new PublicBookingServiceError(
'El horario seleccionado no esta disponible para esa cancha.',
409,
)
}
for (let attempt = 0; attempt < 5; attempt += 1) {
try {
const booking = await db.$transaction(async (tx) => {
if (complex.plan) {
const rules = parsePlanRules(complex.plan.rules)
const bookingsForDate = await tx.courtBooking.count({
where: {
bookingDate,
status: 'CONFIRMED',
court: {
complexId: complex.id,
},
},
})
const violations = evaluatePlanUsage(rules, {
courtsCount: complex.courts.length,
bookingsToday: bookingsForDate,
})
const maxBookingsViolation = violations.find(
(violation) => violation.code === 'MAX_BOOKINGS_PER_DAY_REACHED',
)
if (maxBookingsViolation) {
throw new PublicBookingServiceError(maxBookingsViolation.message, 409)
}
}
const overlappingBooking = await tx.courtBooking.findFirst({
where: {
courtId: selectedCourt.id,
bookingDate,
status: 'CONFIRMED',
startTime: {
lt: selectedSlot.endTime,
},
endTime: {
gt: selectedSlot.startTime,
},
},
})
if (overlappingBooking) {
throw new PublicBookingServiceError('El horario seleccionado ya fue reservado.', 409)
}
return tx.courtBooking.create({
data: {
id: uuidv7(),
bookingCode: generateBookingCode(),
courtId: selectedCourt.id,
bookingDate,
startTime: selectedSlot.startTime,
endTime: selectedSlot.endTime,
customerName: input.customerName.trim(),
customerPhone: input.customerPhone.trim(),
status: 'CONFIRMED',
},
select: {
id: true,
bookingCode: true,
bookingDate: true,
createdAt: true,
startTime: true,
endTime: true,
customerName: true,
customerPhone: true,
status: true,
},
})
})
return mapBookingResponse({
bookingId: booking.id,
bookingCode: booking.bookingCode,
bookingDate: booking.bookingDate,
createdAt: booking.createdAt,
startTime: booking.startTime,
endTime: booking.endTime,
customerName: booking.customerName,
customerPhone: booking.customerPhone,
status: booking.status,
court: {
id: selectedCourt.id,
name: selectedCourt.name,
complexId: complex.id,
complexName: complex.complexName,
complexSlug: complex.complexSlug,
sport: {
id: selectedCourt.sport.id,
name: selectedCourt.sport.name,
slug: selectedCourt.sport.slug,
},
},
})
} catch (error) {
if (error instanceof PublicBookingServiceError) {
throw error
}
const prismaError = error as {
code?: string
meta?: {
target?: string[] | string
}
}
if (prismaError.code === 'P2002') {
const targets = Array.isArray(prismaError.meta?.target)
? prismaError.meta?.target
: [prismaError.meta?.target]
const isBookingCodeCollision = targets.some((target) =>
String(target).includes('booking_code'),
)
if (isBookingCodeCollision) {
continue
}
throw new PublicBookingServiceError('El horario seleccionado ya fue reservado.', 409)
}
throw error
}
}
throw new PublicBookingServiceError(
'No se pudo generar un codigo de reserva unico. Intenta nuevamente.',
409,
)
}

View File

@@ -0,0 +1,18 @@
import type { CreateSportInput } from '@repo/api-contract'
import { Prisma } from '@/generated/prisma/client'
import type { AppContext } from '@/types/hono'
import { createSport } from '@/modules/sport/services/sport.service'
export async function createSportHandler(c: AppContext) {
const payload = c.req.valid('json' as never) as CreateSportInput
try {
const sport = await createSport(payload)
return c.json(sport, 201)
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
return c.json({ message: 'No se pudo crear el deporte.' }, 409)
}
throw error
}
}

View File

@@ -0,0 +1,7 @@
import type { AppContext } from '@/types/hono'
import { listSports } from '@/modules/sport/services/sport.service'
export async function listSportsHandler(c: AppContext) {
const sports = await listSports()
return c.json(sports)
}

View File

@@ -0,0 +1,27 @@
import type { UpdateSportInput } from '@repo/api-contract'
import { Prisma } from '@/generated/prisma/client'
import type { AppContext } from '@/types/hono'
import { getSportById, updateSport } from '@/modules/sport/services/sport.service'
type SportIdParams = { id: string }
export async function updateSportHandler(c: AppContext) {
const { id } = c.req.valid('param' as never) as SportIdParams
const payload = c.req.valid('json' as never) as UpdateSportInput
const existing = await getSportById(id)
if (!existing) {
return c.json({ message: 'Deporte no encontrado.' }, 404)
}
try {
const sport = await updateSport(id, payload)
return c.json(sport)
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
return c.json({ message: 'No se pudo actualizar el deporte.' }, 409)
}
throw error
}
}

View File

@@ -0,0 +1,96 @@
import { v7 as uuidv7 } from 'uuid'
import { Prisma } from '@/generated/prisma/client'
import { db } from '@/lib/prisma'
export type CreateSportInput = {
name: string
}
export type UpdateSportInput = {
name?: string
isActive?: boolean
}
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 buildUniqueSlug(
source: string,
excludeSportId?: string,
): Promise<string> {
const base = slugify(source)
const fallback = base.length > 0 ? base : `sport-${uuidv7().slice(0, 8)}`
let candidate = fallback
let index = 1
while (true) {
const existing = await db.sport.findFirst({
where: {
slug: candidate,
...(excludeSportId
? {
id: {
not: excludeSportId,
},
}
: {}),
},
select: { id: true },
})
if (!existing) return candidate
index += 1
candidate = `${fallback}-${index}`
}
}
export async function listSports() {
return db.sport.findMany({
orderBy: { name: 'asc' },
})
}
export async function createSport(input: CreateSportInput) {
const slug = await buildUniqueSlug(input.name)
return db.sport.create({
data: {
id: uuidv7(),
name: input.name.trim(),
slug,
isActive: true,
},
})
}
export async function getSportById(id: string) {
return db.sport.findUnique({ where: { id } })
}
export async function updateSport(id: string, input: UpdateSportInput) {
const data: Prisma.SportUncheckedUpdateInput = {}
if (input.name) {
data.name = input.name
data.slug = await buildUniqueSlug(input.name, id)
}
if (input.isActive !== undefined) {
data.isActive = input.isActive
}
return db.sport.update({
where: { id },
data,
})
}

View File

@@ -0,0 +1,30 @@
import { zValidator } from '@hono/zod-validator'
import { createSportSchema, updateSportSchema } from '@repo/api-contract'
import { Hono } from 'hono'
import { z } from 'zod'
import { requireAuth } from '@/middlewares/require-auth.middleware'
import { requireSuperAdmin } from '@/middlewares/require-super-admin.middleware'
import { createSportHandler } from '@/modules/sport/handlers/create-sport.handler'
import { listSportsHandler } from '@/modules/sport/handlers/list-sports.handler'
import { updateSportHandler } from '@/modules/sport/handlers/update-sport.handler'
import type { AppEnv } from '@/types/hono'
export const sportRoutes = new Hono<AppEnv>()
const sportIdParamsSchema = z.object({ id: z.uuid() })
sportRoutes.use('*', requireAuth)
sportRoutes.get('/', listSportsHandler)
sportRoutes.post(
'/',
requireSuperAdmin,
zValidator('json', createSportSchema),
createSportHandler,
)
sportRoutes.patch(
'/:id',
requireSuperAdmin,
zValidator('param', sportIdParamsSchema),
zValidator('json', updateSportSchema),
updateSportHandler,
)

View File

@@ -0,0 +1,8 @@
import type { AppContext } from '@/types/hono'
import { getUserProfile } from '@/modules/user/services/user.service'
export function getUserProfileHandler(c: AppContext) {
const authUser = c.get('authUser')
const profile = getUserProfile(authUser)
return c.json(profile)
}

View File

@@ -0,0 +1,28 @@
import type { AuthUser } from '@/types/auth-user'
import type { UserProfile } from '@repo/api-contract'
export function getUserProfile(user: AuthUser): UserProfile {
const fullName =
(typeof user.user_metadata?.full_name === 'string' &&
user.user_metadata.full_name) ||
(typeof user.user_metadata?.name === 'string' && user.user_metadata.name) ||
(user.email ?? 'Usuario')
const role =
(typeof user.app_metadata?.role === 'string' && user.app_metadata.role) ||
'member'
const avatarUrl =
(typeof user.user_metadata?.avatar_url === 'string' &&
user.user_metadata.avatar_url) ||
null
return {
id: user.id,
fullName,
email: user.email ?? null,
role,
avatarUrl,
createdAt: user.created_at,
}
}

View File

@@ -0,0 +1,9 @@
import { Hono } from 'hono'
import { requireAuth } from '@/middlewares/require-auth.middleware'
import type { AppEnv } from '@/types/hono'
import { getUserProfileHandler } from '@/modules/user/handlers/get-user-profile.handler'
export const userRoutes = new Hono<AppEnv>()
userRoutes.use('*', requireAuth)
userRoutes.get('/profile', getUserProfileHandler)