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,12 @@
{
"name": "@repo/api-contract",
"private": true,
"version": "0.0.0",
"type": "module",
"dependencies": {
"hono": "^4.12.10"
},
"exports": {
".": "./src/index.ts"
}
}

View File

@@ -0,0 +1,83 @@
import { z } from 'zod'
export const complexSchema = z.object({
id: z.uuid(),
complexName: z.string(),
physicalAddress: z.string().nullable(),
complexSlug: z.string(),
adminEmail: z.email(),
planCode: z.string().max(10).nullable(),
createdAt: z.string().datetime(),
updatedAt: z.string().datetime(),
})
export const createComplexSchema = z.object({
complexName: z
.string()
.trim()
.min(3, 'El nombre del complejo debe tener al menos 3 caracteres.')
.max(120, 'El nombre del complejo no puede superar los 120 caracteres.'),
physicalAddress: z
.string()
.trim()
.min(5, 'La direccion debe tener al menos 5 caracteres.')
.max(200, 'La direccion no puede superar los 200 caracteres.'),
planCode: z
.string()
.trim()
.min(1, 'El planCode no puede estar vacío.')
.max(10, 'El planCode no puede superar los 10 caracteres.')
.optional(),
})
export const updateComplexSchema = z
.object({
complexName: z
.string()
.trim()
.min(3, 'El nombre del complejo debe tener al menos 3 caracteres.')
.max(120, 'El nombre del complejo no puede superar los 120 caracteres.')
.optional(),
physicalAddress: z
.string()
.trim()
.min(5, 'La direccion debe tener al menos 5 caracteres.')
.max(200, 'La direccion no puede superar los 200 caracteres.')
.nullable()
.optional(),
complexSlug: z
.string()
.trim()
.min(3, 'El slug del complejo debe tener al menos 3 caracteres.')
.max(120, 'El slug del complejo no puede superar los 120 caracteres.')
.optional(),
adminEmail: z.email('El adminEmail debe ser un email válido.').optional(),
planCode: z
.string()
.trim()
.min(1, 'El planCode no puede estar vacío.')
.max(10, 'El planCode no puede superar los 10 caracteres.')
.nullable()
.optional(),
})
.refine(
(payload) =>
Boolean(
payload.complexName ||
payload.physicalAddress !== undefined ||
payload.complexSlug ||
payload.adminEmail ||
payload.planCode !== undefined,
),
{
message: 'Debes enviar al menos un campo para actualizar.',
},
)
export type Complex = z.infer<typeof complexSchema>
export type CreateComplexInput = z.infer<typeof createComplexSchema>
export type UpdateComplexInput = z.infer<typeof updateComplexSchema>
export type CreateComplexPayload = CreateComplexInput
export type CreateComplexResponse = Complex
export type UpdateComplexPayload = UpdateComplexInput
export type UpdateComplexResponse = Complex

View File

@@ -0,0 +1,187 @@
import { z } from 'zod'
const TIME_REGEX = /^([01]\d|2[0-3]):([0-5]\d)$/
function toMinutes(value: string): number {
const [hours, minutes] = value.split(':').map((part) => Number(part))
return hours * 60 + minutes
}
function hasOverlaps(
availability: Array<{
dayOfWeek: DayOfWeek
startTime: string
endTime: string
}>,
): boolean {
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)
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) {
return true
}
}
}
return false
}
function refineAvailability(
availability: Array<{
dayOfWeek: DayOfWeek
startTime: string
endTime: string
}>,
context: z.RefinementCtx,
) {
for (const range of availability) {
if (toMinutes(range.startTime) >= toMinutes(range.endTime)) {
context.addIssue({
code: z.ZodIssueCode.custom,
message: `El rango ${range.startTime}-${range.endTime} es invalido. La hora de inicio debe ser menor a la de fin.`,
})
}
}
if (hasOverlaps(availability)) {
context.addIssue({
code: z.ZodIssueCode.custom,
message: 'Hay rangos horarios superpuestos para el mismo dia.',
})
}
}
export const dayOfWeekSchema = z.enum([
'MONDAY',
'TUESDAY',
'WEDNESDAY',
'THURSDAY',
'FRIDAY',
'SATURDAY',
'SUNDAY',
])
export type DayOfWeek = z.infer<typeof dayOfWeekSchema>
export const courtAvailabilitySchema = z.object({
id: z.uuid(),
dayOfWeek: dayOfWeekSchema,
startTime: z.string().regex(TIME_REGEX, 'La hora debe tener formato HH:mm.'),
endTime: z.string().regex(TIME_REGEX, 'La hora debe tener formato HH:mm.'),
})
export const courtAvailabilityInputSchema = z.object({
dayOfWeek: dayOfWeekSchema,
startTime: z.string().regex(TIME_REGEX, 'La hora debe tener formato HH:mm.'),
endTime: z.string().regex(TIME_REGEX, 'La hora debe tener formato HH:mm.'),
})
export const courtPriceRuleSchema = z.object({
id: z.uuid(),
dayOfWeek: dayOfWeekSchema.nullable(),
startTime: z.string().nullable(),
endTime: z.string().nullable(),
price: z.number().nonnegative(),
isActive: z.boolean(),
})
export const courtSportSchema = z.object({
id: z.uuid(),
name: z.string(),
slug: z.string(),
})
export const courtSchema = z.object({
id: z.uuid(),
complexId: z.uuid(),
name: z.string(),
sportId: z.uuid(),
sport: courtSportSchema,
slotDurationMinutes: z.int().positive(),
basePrice: z.number().nonnegative(),
availability: z.array(courtAvailabilitySchema),
priceRules: z.array(courtPriceRuleSchema),
hasCustomPricing: z.boolean(),
createdAt: z.string().datetime(),
updatedAt: z.string().datetime(),
})
export const createCourtSchema = z
.object({
name: z
.string()
.trim()
.min(2, 'La cancha debe tener al menos 2 caracteres.')
.max(120, 'La cancha no puede superar los 120 caracteres.'),
sportId: z.uuid('El sportId debe ser un UUID valido.'),
slotDurationMinutes: z
.int()
.min(15, 'La duracion minima por turno es 15 minutos.')
.max(480, 'La duracion maxima por turno es 480 minutos.'),
basePrice: z.number().nonnegative('El precio base no puede ser negativo.'),
availability: z.array(courtAvailabilityInputSchema).min(1, 'Debes definir al menos un rango horario.'),
})
.superRefine((payload, context) => refineAvailability(payload.availability, context))
export const updateCourtSchema = z
.object({
name: z
.string()
.trim()
.min(2, 'La cancha debe tener al menos 2 caracteres.')
.max(120, 'La cancha no puede superar los 120 caracteres.')
.optional(),
sportId: z.uuid('El sportId debe ser un UUID valido.').optional(),
slotDurationMinutes: z
.int()
.min(15, 'La duracion minima por turno es 15 minutos.')
.max(480, 'La duracion maxima por turno es 480 minutos.')
.optional(),
basePrice: z.number().nonnegative('El precio base no puede ser negativo.').optional(),
availability: z.array(courtAvailabilityInputSchema).min(1, 'Debes definir al menos un rango horario.').optional(),
})
.superRefine((payload, context) => {
if (
payload.name === undefined &&
payload.sportId === undefined &&
payload.slotDurationMinutes === undefined &&
payload.basePrice === undefined &&
payload.availability === undefined
) {
context.addIssue({
code: z.ZodIssueCode.custom,
message: 'Debes enviar al menos un campo para actualizar.',
})
}
if (payload.availability) {
refineAvailability(payload.availability, context)
}
})
export type Court = z.infer<typeof courtSchema>
export type CourtAvailability = z.infer<typeof courtAvailabilitySchema>
export type CourtAvailabilityInput = z.infer<typeof courtAvailabilityInputSchema>
export type CourtPriceRule = z.infer<typeof courtPriceRuleSchema>
export type CreateCourtInput = z.infer<typeof createCourtSchema>
export type UpdateCourtInput = z.infer<typeof updateCourtSchema>

View File

@@ -0,0 +1,81 @@
export { API_ROUTE_PATHS, registerApiRoutes } from './register-routes'
export {
planFeatureFlagsSchema,
planRulesSchema,
planRulesV1Schema,
planSchema,
planSummarySchema,
} from './plan'
export type { Plan, PlanFeatureFlags, PlanRules, PlanRulesV1, PlanSummary } from './plan'
export {
onboardingCompleteResponseSchema,
onboardingCompleteSchema,
onboardingResendOtpResponseSchema,
onboardingResendOtpSchema,
onboardingStartResponseSchema,
onboardingStartSchema,
onboardingVerifyOtpResponseSchema,
onboardingVerifyOtpSchema,
} from './onboarding'
export type {
OnboardingCompleteInput,
OnboardingCompleteResponse,
OnboardingResendOtpInput,
OnboardingResendOtpResponse,
OnboardingStartInput,
OnboardingStartResponse,
OnboardingVerifyOtpInput,
OnboardingVerifyOtpResponse,
} from './onboarding'
export { complexSchema, createComplexSchema, updateComplexSchema } from './complex'
export type {
Complex,
CreateComplexInput,
CreateComplexPayload,
CreateComplexResponse,
UpdateComplexInput,
UpdateComplexPayload,
UpdateComplexResponse,
} from './complex'
export {
dayOfWeekSchema,
courtAvailabilityInputSchema,
courtAvailabilitySchema,
courtPriceRuleSchema,
courtSchema,
createCourtSchema,
updateCourtSchema,
} from './court'
export type {
DayOfWeek,
Court,
CourtAvailability,
CourtAvailabilityInput,
CourtPriceRule,
CreateCourtInput,
UpdateCourtInput,
} from './court'
export {
createPublicBookingSchema,
publicAvailabilityCourtSchema,
publicAvailabilityQuerySchema,
publicAvailabilityResponseSchema,
publicBookingConfirmationSchema,
publicBookingSchema,
publicBookingSlotSchema,
publicBookingSportSchema,
} from './public-booking'
export type {
CreatePublicBookingInput,
PublicAvailabilityCourt,
PublicAvailabilityQuery,
PublicAvailabilityResponse,
PublicBooking,
PublicBookingConfirmation,
PublicBookingSlot,
PublicBookingSport,
} from './public-booking'
export { sportSchema, createSportSchema, updateSportSchema } from './sport'
export type { Sport, CreateSportInput, UpdateSportInput } from './sport'
export { userProfileSchema } from './user'
export type { UserProfile, UserProfileResponse } from './user'

View File

@@ -0,0 +1,98 @@
import { z } from 'zod'
const otpSchema = z
.string()
.trim()
.regex(/^\d{6}$/, 'El OTP debe tener 6 digitos numericos.')
export const onboardingStartSchema = z.object({
fullName: z
.string()
.trim()
.min(2, 'El nombre debe tener al menos 2 caracteres.')
.max(80, 'El nombre no puede superar los 80 caracteres.'),
email: z.email('Ingresa un email valido.'),
})
export const onboardingVerifyOtpSchema = z.object({
requestId: z.uuid('El requestId de onboarding no es valido.'),
otp: otpSchema,
})
export const onboardingResendOtpSchema = z.object({
requestId: z.uuid('El requestId de onboarding no es valido.'),
})
export const onboardingCompleteSchema = z.object({
onboardingRequestId: z.uuid('El onboardingRequestId no es valido.'),
password: z
.string()
.min(8, 'La contrasena debe tener al menos 8 caracteres.')
.max(72, 'La contrasena no puede superar los 72 caracteres.'),
complexName: z
.string()
.trim()
.min(3, 'El nombre del complejo debe tener al menos 3 caracteres.')
.max(120, 'El nombre del complejo no puede superar los 120 caracteres.'),
physicalAddress: z
.string()
.trim()
.min(5, 'La direccion debe tener al menos 5 caracteres.')
.max(200, 'La direccion no puede superar los 200 caracteres.'),
planCode: z
.string()
.trim()
.min(1, 'Debes seleccionar un plan.')
.max(10, 'El plan seleccionado no es valido.'),
})
export const onboardingStartResponseSchema = z.object({
message: z.string(),
requestId: z.uuid(),
email: z.email(),
expiresAt: z.string().datetime(),
cooldownSeconds: z.int().nonnegative(),
})
export const onboardingVerifyOtpResponseSchema = z.object({
message: z.string(),
verified: z.boolean(),
requestId: z.uuid(),
email: z.email().nullable(),
expiresAt: z.string().datetime().nullable(),
remainingAttempts: z.int().nonnegative(),
cooldownSeconds: z.int().nonnegative(),
})
export const onboardingResendOtpResponseSchema = z.object({
message: z.string(),
requestId: z.uuid(),
email: z.email(),
expiresAt: z.string().datetime(),
cooldownSeconds: z.int().nonnegative(),
remainingAttempts: z.int().nonnegative(),
})
export const onboardingCompleteResponseSchema = z.object({
message: z.string(),
userId: z.uuid(),
complexId: z.uuid(),
complexSlug: z.string(),
supabaseUserId: z.uuid(),
})
export type OnboardingStartInput = z.infer<typeof onboardingStartSchema>
export type OnboardingVerifyOtpInput = z.infer<typeof onboardingVerifyOtpSchema>
export type OnboardingResendOtpInput = z.infer<typeof onboardingResendOtpSchema>
export type OnboardingCompleteInput = z.infer<typeof onboardingCompleteSchema>
export type OnboardingStartResponse = z.infer<typeof onboardingStartResponseSchema>
export type OnboardingVerifyOtpResponse = z.infer<
typeof onboardingVerifyOtpResponseSchema
>
export type OnboardingResendOtpResponse = z.infer<
typeof onboardingResendOtpResponseSchema
>
export type OnboardingCompleteResponse = z.infer<
typeof onboardingCompleteResponseSchema
>

View File

@@ -0,0 +1,56 @@
import { z } from 'zod'
const nonNegativeInt = z.int().nonnegative()
const positiveInt = z.int().positive()
export const planFeatureFlagsSchema = z.object({
onlinePayments: z.boolean().default(false),
publicBookingPage: z.boolean().default(false),
advancedReports: z.boolean().default(false),
whatsappReminders: z.boolean().default(false),
})
export const planRulesV1Schema = z.object({
version: z.literal('v1'),
limits: z.object({
maxCourts: positiveInt,
maxBookingsPerDay: positiveInt,
maxActiveUsers: positiveInt.optional(),
maxConcurrentBookingsPerSlot: positiveInt.optional(),
}),
policies: z
.object({
maxAdvanceBookingDays: positiveInt.optional(),
minCancellationNoticeHours: nonNegativeInt.optional(),
allowOverbooking: z.boolean().default(false),
})
.default({ allowOverbooking: false }),
features: planFeatureFlagsSchema.default({
onlinePayments: false,
publicBookingPage: false,
advancedReports: false,
whatsappReminders: false,
}),
})
export const planRulesSchema = z.discriminatedUnion('version', [planRulesV1Schema])
export const planSchema = z.object({
code: z.string().trim().min(1).max(10),
name: z.string().trim().min(1).max(30),
price: z.number().nonnegative(),
lastUpdatedAt: z.string().datetime(),
rules: planRulesSchema,
})
export const planSummarySchema = z.object({
code: z.string().trim().min(1).max(10),
name: z.string().trim().min(1).max(30),
price: z.number().nonnegative(),
})
export type PlanFeatureFlags = z.infer<typeof planFeatureFlagsSchema>
export type PlanRulesV1 = z.infer<typeof planRulesV1Schema>
export type PlanRules = z.infer<typeof planRulesSchema>
export type Plan = z.infer<typeof planSchema>
export type PlanSummary = z.infer<typeof planSummarySchema>

View File

@@ -0,0 +1,98 @@
import { z } from 'zod'
import { dayOfWeekSchema } from './court'
const TIME_REGEX = /^([01]\d|2[0-3]):([0-5]\d)$/
const ISO_DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/
const BOOKING_CODE_REGEX = /^[A-Z0-9]{6,8}$/
export const publicAvailabilityQuerySchema = z.object({
date: z.string().regex(ISO_DATE_REGEX, 'La fecha debe tener formato YYYY-MM-DD.'),
sportId: z.uuid('El sportId debe ser un UUID valido.').optional(),
})
export const publicBookingSportSchema = z.object({
id: z.uuid(),
name: z.string(),
slug: z.string(),
})
export const publicBookingSlotSchema = z.object({
startTime: z.string().regex(TIME_REGEX),
endTime: z.string().regex(TIME_REGEX),
})
export const publicAvailabilityCourtSchema = z.object({
courtId: z.uuid(),
courtName: z.string(),
sport: publicBookingSportSchema,
slotDurationMinutes: z.int().positive(),
availabilityDay: dayOfWeekSchema,
availableSlots: z.array(publicBookingSlotSchema),
})
export const publicAvailabilityResponseSchema = z.object({
complexId: z.uuid(),
complexName: z.string(),
complexSlug: z.string(),
date: z.string().regex(ISO_DATE_REGEX),
sportSelectionRequired: z.boolean(),
sports: z.array(publicBookingSportSchema),
courts: z.array(publicAvailabilityCourtSchema),
})
export const createPublicBookingSchema = z.object({
date: z.string().regex(ISO_DATE_REGEX, 'La fecha debe tener formato YYYY-MM-DD.'),
sportId: z.uuid('El sportId debe ser un UUID valido.').optional(),
courtId: z.uuid('El courtId debe ser un UUID valido.'),
startTime: z.string().regex(TIME_REGEX, 'La hora debe tener formato HH:mm.'),
customerName: z
.string()
.trim()
.min(2, 'El nombre debe tener al menos 2 caracteres.')
.max(120, 'El nombre no puede superar los 120 caracteres.'),
customerPhone: z
.string()
.trim()
.min(6, 'El telefono debe tener al menos 6 caracteres.')
.max(30, 'El telefono no puede superar los 30 caracteres.'),
})
export const publicBookingSchema = z.object({
id: z.uuid(),
bookingCode: z.string().regex(BOOKING_CODE_REGEX),
complexId: z.uuid(),
complexName: z.string(),
complexSlug: z.string(),
courtId: z.uuid(),
courtName: z.string(),
sport: publicBookingSportSchema,
date: z.string().regex(ISO_DATE_REGEX),
startTime: z.string().regex(TIME_REGEX),
endTime: z.string().regex(TIME_REGEX),
customerName: z.string(),
customerPhone: z.string(),
status: z.enum(['CONFIRMED', 'CANCELLED']),
createdAt: z.string().datetime(),
})
export const publicBookingConfirmationSchema = z.object({
bookingCode: z.string().regex(BOOKING_CODE_REGEX),
complexName: z.string(),
complexSlug: z.string(),
date: z.string().regex(ISO_DATE_REGEX),
startTime: z.string().regex(TIME_REGEX),
endTime: z.string().regex(TIME_REGEX),
courtName: z.string(),
sport: publicBookingSportSchema,
status: z.enum(['CONFIRMED', 'CANCELLED']),
createdAt: z.string().datetime(),
})
export type PublicAvailabilityQuery = z.infer<typeof publicAvailabilityQuerySchema>
export type PublicBookingSport = z.infer<typeof publicBookingSportSchema>
export type PublicBookingSlot = z.infer<typeof publicBookingSlotSchema>
export type PublicAvailabilityCourt = z.infer<typeof publicAvailabilityCourtSchema>
export type PublicAvailabilityResponse = z.infer<typeof publicAvailabilityResponseSchema>
export type CreatePublicBookingInput = z.infer<typeof createPublicBookingSchema>
export type PublicBooking = z.infer<typeof publicBookingSchema>
export type PublicBookingConfirmation = z.infer<typeof publicBookingConfirmationSchema>

View File

@@ -0,0 +1,95 @@
import type { Env, Hono, Schema } from 'hono'
type HonoLike<E extends Env = Env, S extends Schema = Schema, B extends string = string> =
Hono<E, S, B>
type ApiRouteModules<
UE extends Env = Env,
US extends Schema = Schema,
UB extends string = string,
PLE extends Env = Env,
PLS extends Schema = Schema,
PLB extends string = string,
OE extends Env = Env,
OS extends Schema = Schema,
OB extends string = string,
CE extends Env = Env,
CS extends Schema = Schema,
CB extends string = string,
SPE extends Env = Env,
SPS extends Schema = Schema,
SPB extends string = string,
COE extends Env = Env,
COS extends Schema = Schema,
COB extends string = string,
> = {
user: HonoLike<UE, US, UB>
plans: HonoLike<PLE, PLS, PLB>
onboarding: HonoLike<OE, OS, OB>
complexes: HonoLike<CE, CS, CB>
sports: HonoLike<SPE, SPS, SPB>
courts: HonoLike<COE, COS, COB>
}
export const API_ROUTE_PATHS = {
user: '/api/user',
plans: '/api/plans',
onboarding: '/api/onboarding',
complexes: '/api/complexes',
sports: '/api/sports',
courts: '/api/courts',
} as const
export function registerApiRoutes<
E extends Env,
S extends Schema,
B extends string,
UE extends Env,
US extends Schema,
UB extends string,
PLE extends Env,
PLS extends Schema,
PLB extends string,
OE extends Env,
OS extends Schema,
OB extends string,
CE extends Env,
CS extends Schema,
CB extends string,
SPE extends Env,
SPS extends Schema,
SPB extends string,
COE extends Env,
COS extends Schema,
COB extends string,
>(
app: HonoLike<E, S, B>,
modules: ApiRouteModules<
UE,
US,
UB,
PLE,
PLS,
PLB,
OE,
OS,
OB,
CE,
CS,
CB,
SPE,
SPS,
SPB,
COE,
COS,
COB
>,
) {
return app
.route(API_ROUTE_PATHS.user, modules.user)
.route(API_ROUTE_PATHS.plans, modules.plans)
.route(API_ROUTE_PATHS.onboarding, modules.onboarding)
.route(API_ROUTE_PATHS.complexes, modules.complexes)
.route(API_ROUTE_PATHS.sports, modules.sports)
.route(API_ROUTE_PATHS.courts, modules.courts)
}

View File

@@ -0,0 +1,36 @@
import { z } from 'zod'
export const sportSchema = z.object({
id: z.uuid(),
name: z.string(),
slug: z.string(),
isActive: z.boolean(),
createdAt: z.string().datetime(),
updatedAt: z.string().datetime(),
})
export const createSportSchema = z.object({
name: z
.string()
.trim()
.min(2, 'El deporte debe tener al menos 2 caracteres.')
.max(80, 'El deporte no puede superar los 80 caracteres.'),
})
export const updateSportSchema = z
.object({
name: z
.string()
.trim()
.min(2, 'El deporte debe tener al menos 2 caracteres.')
.max(80, 'El deporte no puede superar los 80 caracteres.')
.optional(),
isActive: z.boolean().optional(),
})
.refine((payload) => payload.name !== undefined || payload.isActive !== undefined, {
message: 'Debes enviar al menos un campo para actualizar.',
})
export type Sport = z.infer<typeof sportSchema>
export type CreateSportInput = z.infer<typeof createSportSchema>
export type UpdateSportInput = z.infer<typeof updateSportSchema>

View File

@@ -0,0 +1,13 @@
import { z } from 'zod'
export const userProfileSchema = z.object({
id: z.string(),
fullName: z.string(),
email: z.email().nullable(),
role: z.string(),
avatarUrl: z.url().nullable(),
createdAt: z.string(),
})
export type UserProfile = z.infer<typeof userProfileSchema>
export type UserProfileResponse = UserProfile