diff --git a/apps/backend/prisma/court.prisma b/apps/backend/prisma/court.prisma index 6262bc2..75af280 100644 --- a/apps/backend/prisma/court.prisma +++ b/apps/backend/prisma/court.prisma @@ -11,6 +11,7 @@ enum DayOfWeek { enum CourtBookingStatus { CONFIRMED CANCELLED + COMPLETED } model Sport { diff --git a/apps/backend/prisma/migrations/20260409153000_add_completed_booking_status/migration.sql b/apps/backend/prisma/migrations/20260409153000_add_completed_booking_status/migration.sql new file mode 100644 index 0000000..9641fe8 --- /dev/null +++ b/apps/backend/prisma/migrations/20260409153000_add_completed_booking_status/migration.sql @@ -0,0 +1 @@ +ALTER TYPE "CourtBookingStatus" ADD VALUE 'COMPLETED'; diff --git a/apps/backend/src/generated/prisma/enums.ts b/apps/backend/src/generated/prisma/enums.ts index 79d67bb..0455865 100644 --- a/apps/backend/src/generated/prisma/enums.ts +++ b/apps/backend/src/generated/prisma/enums.ts @@ -31,7 +31,8 @@ export type DayOfWeek = (typeof DayOfWeek)[keyof typeof DayOfWeek] export const CourtBookingStatus = { CONFIRMED: 'CONFIRMED', - CANCELLED: 'CANCELLED' + CANCELLED: 'CANCELLED', + COMPLETED: 'COMPLETED' } as const export type CourtBookingStatus = (typeof CourtBookingStatus)[keyof typeof CourtBookingStatus] diff --git a/apps/backend/src/generated/prisma/internal/class.ts b/apps/backend/src/generated/prisma/internal/class.ts index d10462e..59b22cf 100644 --- a/apps/backend/src/generated/prisma/internal/class.ts +++ b/apps/backend/src/generated/prisma/internal/class.ts @@ -20,7 +20,7 @@ const config: runtime.GetPrismaClientConfig = { "clientVersion": "7.6.0", "engineVersion": "75cbdc1eb7150937890ad5465d861175c6624711", "activeProvider": "postgresql", - "inlineSchema": "model Complex {\n id String @id @db.Uuid\n complexName String @map(\"complex_name\")\n physicalAddress String? @map(\"physical_address\") @db.VarChar(200)\n complexSlug String @unique @map(\"complex_slug\")\n adminEmail String @map(\"admin_email\")\n planCode String? @map(\"plan_code\") @db.VarChar(10)\n createdAt DateTime @default(now()) @map(\"created_at\")\n updatedAt DateTime @updatedAt @map(\"updated_at\")\n plan Plan? @relation(fields: [planCode], references: [code])\n users ComplexUser[]\n courts Court[]\n\n @@index([planCode])\n @@index([complexSlug])\n @@map(\"complexes\")\n}\n\nenum ComplexUserRole {\n ADMIN\n}\n\nmodel ComplexUser {\n complexId String @map(\"complex_id\") @db.Uuid\n userId String @map(\"user_id\") @db.Uuid\n role ComplexUserRole @default(ADMIN)\n createdAt DateTime @default(now()) @map(\"created_at\")\n complex Complex @relation(fields: [complexId], references: [id], onDelete: Cascade)\n user User @relation(fields: [userId], references: [id], onDelete: Cascade)\n\n @@id([complexId, userId])\n @@index([userId])\n @@map(\"complex_users\")\n}\n\nenum DayOfWeek {\n MONDAY\n TUESDAY\n WEDNESDAY\n THURSDAY\n FRIDAY\n SATURDAY\n SUNDAY\n}\n\nenum CourtBookingStatus {\n CONFIRMED\n CANCELLED\n}\n\nmodel Sport {\n id String @id @db.Uuid\n name String @unique\n slug String @unique\n isActive Boolean @default(true) @map(\"is_active\")\n createdAt DateTime @default(now()) @map(\"created_at\")\n updatedAt DateTime @updatedAt @map(\"updated_at\")\n courts Court[]\n\n @@map(\"sports\")\n}\n\nmodel Court {\n id String @id @db.Uuid\n complexId String @map(\"complex_id\") @db.Uuid\n sportId String @map(\"sport_id\") @db.Uuid\n name String\n slotDurationMinutes Int @map(\"slot_duration_minutes\")\n basePrice Decimal @map(\"base_price\") @db.Decimal(19, 2)\n createdAt DateTime @default(now()) @map(\"created_at\")\n updatedAt DateTime @updatedAt @map(\"updated_at\")\n complex Complex @relation(fields: [complexId], references: [id], onDelete: Cascade)\n sport Sport @relation(fields: [sportId], references: [id])\n availabilities CourtAvailability[]\n priceRules CourtPriceRule[]\n bookings CourtBooking[]\n\n @@index([complexId])\n @@index([sportId])\n @@map(\"courts\")\n}\n\nmodel CourtAvailability {\n id String @id @db.Uuid\n courtId String @map(\"court_id\") @db.Uuid\n dayOfWeek DayOfWeek @map(\"day_of_week\")\n startTime String @map(\"start_time\") @db.VarChar(5)\n endTime String @map(\"end_time\") @db.VarChar(5)\n createdAt DateTime @default(now()) @map(\"created_at\")\n court Court @relation(fields: [courtId], references: [id], onDelete: Cascade)\n\n @@index([courtId, dayOfWeek])\n @@map(\"court_availabilities\")\n}\n\nmodel CourtPriceRule {\n id String @id @db.Uuid\n courtId String @map(\"court_id\") @db.Uuid\n dayOfWeek DayOfWeek? @map(\"day_of_week\")\n startTime String? @map(\"start_time\") @db.VarChar(5)\n endTime String? @map(\"end_time\") @db.VarChar(5)\n price Decimal @db.Decimal(19, 2)\n isActive Boolean @default(true) @map(\"is_active\")\n createdAt DateTime @default(now()) @map(\"created_at\")\n updatedAt DateTime @updatedAt @map(\"updated_at\")\n court Court @relation(fields: [courtId], references: [id], onDelete: Cascade)\n\n @@index([courtId, isActive])\n @@map(\"court_price_rules\")\n}\n\nmodel CourtBooking {\n id String @id @db.Uuid\n bookingCode String @unique @map(\"booking_code\") @db.VarChar(8)\n courtId String @map(\"court_id\") @db.Uuid\n bookingDate DateTime @map(\"booking_date\") @db.Date\n startTime String @map(\"start_time\") @db.VarChar(5)\n endTime String @map(\"end_time\") @db.VarChar(5)\n customerName String @map(\"customer_name\") @db.VarChar(120)\n customerPhone String @map(\"customer_phone\") @db.VarChar(30)\n status CourtBookingStatus @default(CONFIRMED)\n createdAt DateTime @default(now()) @map(\"created_at\")\n updatedAt DateTime @updatedAt @map(\"updated_at\")\n court Court @relation(fields: [courtId], references: [id], onDelete: Cascade)\n\n @@unique([courtId, bookingDate, startTime])\n @@index([courtId, bookingDate])\n @@index([bookingDate])\n @@map(\"court_bookings\")\n}\n\nmodel OnboardingRequest {\n id String @id @db.Uuid\n fullName String @map(\"full_name\")\n email String\n otpHash String @map(\"otp_hash\")\n otpExpiresAt DateTime @map(\"otp_expires_at\")\n otpAttempts Int @default(0) @map(\"otp_attempts\")\n otpLastSentAt DateTime @map(\"otp_last_sent_at\")\n otpResendCount Int @default(0) @map(\"otp_resend_count\")\n emailVerifiedAt DateTime? @map(\"email_verified_at\")\n completedAt DateTime? @map(\"completed_at\")\n createdAt DateTime @default(now()) @map(\"created_at\")\n updatedAt DateTime @updatedAt @map(\"updated_at\")\n\n @@index([email])\n @@index([otpExpiresAt])\n @@map(\"onboarding_requests\")\n}\n\nmodel Plan {\n code String @id @db.VarChar(10)\n name String @db.VarChar(30)\n price Decimal @db.Decimal(19, 2)\n rules Json\n lastUpdatedAt DateTime @default(now()) @map(\"last_updated_at\")\n complexes Complex[]\n\n @@map(\"plans\")\n}\n\ngenerator client {\n provider = \"prisma-client\"\n output = \"../src/generated/prisma\"\n}\n\ndatasource db {\n provider = \"postgresql\"\n}\n\nmodel User {\n id String @id @db.Uuid\n supabaseUserId String @unique @map(\"supabase_user_id\") @db.Uuid\n email String @unique\n fullName String @map(\"full_name\")\n createdAt DateTime @default(now()) @map(\"created_at\")\n updatedAt DateTime @updatedAt @map(\"updated_at\")\n complexes ComplexUser[]\n\n @@map(\"users\")\n}\n", + "inlineSchema": "model Complex {\n id String @id @db.Uuid\n complexName String @map(\"complex_name\")\n physicalAddress String? @map(\"physical_address\") @db.VarChar(200)\n complexSlug String @unique @map(\"complex_slug\")\n adminEmail String @map(\"admin_email\")\n planCode String? @map(\"plan_code\") @db.VarChar(10)\n createdAt DateTime @default(now()) @map(\"created_at\")\n updatedAt DateTime @updatedAt @map(\"updated_at\")\n plan Plan? @relation(fields: [planCode], references: [code])\n users ComplexUser[]\n courts Court[]\n\n @@index([planCode])\n @@index([complexSlug])\n @@map(\"complexes\")\n}\n\nenum ComplexUserRole {\n ADMIN\n}\n\nmodel ComplexUser {\n complexId String @map(\"complex_id\") @db.Uuid\n userId String @map(\"user_id\") @db.Uuid\n role ComplexUserRole @default(ADMIN)\n createdAt DateTime @default(now()) @map(\"created_at\")\n complex Complex @relation(fields: [complexId], references: [id], onDelete: Cascade)\n user User @relation(fields: [userId], references: [id], onDelete: Cascade)\n\n @@id([complexId, userId])\n @@index([userId])\n @@map(\"complex_users\")\n}\n\nenum DayOfWeek {\n MONDAY\n TUESDAY\n WEDNESDAY\n THURSDAY\n FRIDAY\n SATURDAY\n SUNDAY\n}\n\nenum CourtBookingStatus {\n CONFIRMED\n CANCELLED\n COMPLETED\n}\n\nmodel Sport {\n id String @id @db.Uuid\n name String @unique\n slug String @unique\n isActive Boolean @default(true) @map(\"is_active\")\n createdAt DateTime @default(now()) @map(\"created_at\")\n updatedAt DateTime @updatedAt @map(\"updated_at\")\n courts Court[]\n\n @@map(\"sports\")\n}\n\nmodel Court {\n id String @id @db.Uuid\n complexId String @map(\"complex_id\") @db.Uuid\n sportId String @map(\"sport_id\") @db.Uuid\n name String\n slotDurationMinutes Int @map(\"slot_duration_minutes\")\n basePrice Decimal @map(\"base_price\") @db.Decimal(19, 2)\n createdAt DateTime @default(now()) @map(\"created_at\")\n updatedAt DateTime @updatedAt @map(\"updated_at\")\n complex Complex @relation(fields: [complexId], references: [id], onDelete: Cascade)\n sport Sport @relation(fields: [sportId], references: [id])\n availabilities CourtAvailability[]\n priceRules CourtPriceRule[]\n bookings CourtBooking[]\n\n @@index([complexId])\n @@index([sportId])\n @@map(\"courts\")\n}\n\nmodel CourtAvailability {\n id String @id @db.Uuid\n courtId String @map(\"court_id\") @db.Uuid\n dayOfWeek DayOfWeek @map(\"day_of_week\")\n startTime String @map(\"start_time\") @db.VarChar(5)\n endTime String @map(\"end_time\") @db.VarChar(5)\n createdAt DateTime @default(now()) @map(\"created_at\")\n court Court @relation(fields: [courtId], references: [id], onDelete: Cascade)\n\n @@index([courtId, dayOfWeek])\n @@map(\"court_availabilities\")\n}\n\nmodel CourtPriceRule {\n id String @id @db.Uuid\n courtId String @map(\"court_id\") @db.Uuid\n dayOfWeek DayOfWeek? @map(\"day_of_week\")\n startTime String? @map(\"start_time\") @db.VarChar(5)\n endTime String? @map(\"end_time\") @db.VarChar(5)\n price Decimal @db.Decimal(19, 2)\n isActive Boolean @default(true) @map(\"is_active\")\n createdAt DateTime @default(now()) @map(\"created_at\")\n updatedAt DateTime @updatedAt @map(\"updated_at\")\n court Court @relation(fields: [courtId], references: [id], onDelete: Cascade)\n\n @@index([courtId, isActive])\n @@map(\"court_price_rules\")\n}\n\nmodel CourtBooking {\n id String @id @db.Uuid\n bookingCode String @unique @map(\"booking_code\") @db.VarChar(8)\n courtId String @map(\"court_id\") @db.Uuid\n bookingDate DateTime @map(\"booking_date\") @db.Date\n startTime String @map(\"start_time\") @db.VarChar(5)\n endTime String @map(\"end_time\") @db.VarChar(5)\n customerName String @map(\"customer_name\") @db.VarChar(120)\n customerPhone String @map(\"customer_phone\") @db.VarChar(30)\n status CourtBookingStatus @default(CONFIRMED)\n createdAt DateTime @default(now()) @map(\"created_at\")\n updatedAt DateTime @updatedAt @map(\"updated_at\")\n court Court @relation(fields: [courtId], references: [id], onDelete: Cascade)\n\n @@unique([courtId, bookingDate, startTime])\n @@index([courtId, bookingDate])\n @@index([bookingDate])\n @@map(\"court_bookings\")\n}\n\nmodel OnboardingRequest {\n id String @id @db.Uuid\n fullName String @map(\"full_name\")\n email String\n otpHash String @map(\"otp_hash\")\n otpExpiresAt DateTime @map(\"otp_expires_at\")\n otpAttempts Int @default(0) @map(\"otp_attempts\")\n otpLastSentAt DateTime @map(\"otp_last_sent_at\")\n otpResendCount Int @default(0) @map(\"otp_resend_count\")\n emailVerifiedAt DateTime? @map(\"email_verified_at\")\n completedAt DateTime? @map(\"completed_at\")\n createdAt DateTime @default(now()) @map(\"created_at\")\n updatedAt DateTime @updatedAt @map(\"updated_at\")\n\n @@index([email])\n @@index([otpExpiresAt])\n @@map(\"onboarding_requests\")\n}\n\nmodel Plan {\n code String @id @db.VarChar(10)\n name String @db.VarChar(30)\n price Decimal @db.Decimal(19, 2)\n rules Json\n lastUpdatedAt DateTime @default(now()) @map(\"last_updated_at\")\n complexes Complex[]\n\n @@map(\"plans\")\n}\n\ngenerator client {\n provider = \"prisma-client\"\n output = \"../src/generated/prisma\"\n}\n\ndatasource db {\n provider = \"postgresql\"\n}\n\nmodel User {\n id String @id @db.Uuid\n supabaseUserId String @unique @map(\"supabase_user_id\") @db.Uuid\n email String @unique\n fullName String @map(\"full_name\")\n createdAt DateTime @default(now()) @map(\"created_at\")\n updatedAt DateTime @updatedAt @map(\"updated_at\")\n complexes ComplexUser[]\n\n @@map(\"users\")\n}\n", "runtimeDataModel": { "models": {}, "enums": {}, diff --git a/apps/backend/src/modules/admin-booking/admin-booking.routes.ts b/apps/backend/src/modules/admin-booking/admin-booking.routes.ts new file mode 100644 index 0000000..e9e283e --- /dev/null +++ b/apps/backend/src/modules/admin-booking/admin-booking.routes.ts @@ -0,0 +1,40 @@ +import { zValidator } from '@hono/zod-validator' +import { + createAdminBookingSchema, + listAdminBookingsQuerySchema, + updateAdminBookingStatusSchema, +} from '@repo/api-contract' +import { Hono } from 'hono' +import { z } from 'zod' +import { requireAuth } from '@/middlewares/require-auth.middleware' +import { createAdminBookingHandler } from '@/modules/admin-booking/handlers/create-admin-booking.handler' +import { listAdminBookingsHandler } from '@/modules/admin-booking/handlers/list-admin-bookings.handler' +import { updateAdminBookingStatusHandler } from '@/modules/admin-booking/handlers/update-admin-booking-status.handler' +import type { AppEnv } from '@/types/hono' + +export const adminBookingRoutes = new Hono() +const complexIdParamsSchema = z.object({ complexId: z.uuid() }) +const bookingIdParamsSchema = z.object({ id: z.uuid() }) + +adminBookingRoutes.use('*', requireAuth) + +adminBookingRoutes.get( + '/complex/:complexId', + zValidator('param', complexIdParamsSchema), + zValidator('query', listAdminBookingsQuerySchema), + listAdminBookingsHandler, +) + +adminBookingRoutes.post( + '/complex/:complexId', + zValidator('param', complexIdParamsSchema), + zValidator('json', createAdminBookingSchema), + createAdminBookingHandler, +) + +adminBookingRoutes.patch( + '/:id/status', + zValidator('param', bookingIdParamsSchema), + zValidator('json', updateAdminBookingStatusSchema), + updateAdminBookingStatusHandler, +) diff --git a/apps/backend/src/modules/admin-booking/handlers/create-admin-booking.handler.ts b/apps/backend/src/modules/admin-booking/handlers/create-admin-booking.handler.ts new file mode 100644 index 0000000..1c28b4b --- /dev/null +++ b/apps/backend/src/modules/admin-booking/handlers/create-admin-booking.handler.ts @@ -0,0 +1,24 @@ +import type { CreateAdminBookingInput } from '@repo/api-contract' +import { + AdminBookingServiceError, + createAdminBooking, +} from '@/modules/admin-booking/services/admin-booking.service' +import type { AppContext } from '@/types/hono' + +type ComplexIdParams = { complexId: string } + +export async function createAdminBookingHandler(c: AppContext) { + const { complexId } = c.req.valid('param' as never) as ComplexIdParams + const payload = c.req.valid('json' as never) as CreateAdminBookingInput + const appUserId = c.get('appUserId') + + try { + const booking = await createAdminBooking(appUserId, complexId, payload) + return c.json(booking, 201) + } catch (error) { + if (error instanceof AdminBookingServiceError) { + return c.json({ message: error.message }, error.status) + } + throw error + } +} diff --git a/apps/backend/src/modules/admin-booking/handlers/list-admin-bookings.handler.ts b/apps/backend/src/modules/admin-booking/handlers/list-admin-bookings.handler.ts new file mode 100644 index 0000000..f5bcd66 --- /dev/null +++ b/apps/backend/src/modules/admin-booking/handlers/list-admin-bookings.handler.ts @@ -0,0 +1,24 @@ +import type { ListAdminBookingsQuery } from '@repo/api-contract' +import { + AdminBookingServiceError, + listAdminBookings, +} from '@/modules/admin-booking/services/admin-booking.service' +import type { AppContext } from '@/types/hono' + +type ComplexIdParams = { complexId: string } + +export async function listAdminBookingsHandler(c: AppContext) { + const { complexId } = c.req.valid('param' as never) as ComplexIdParams + const query = c.req.valid('query' as never) as ListAdminBookingsQuery + const appUserId = c.get('appUserId') + + try { + const response = await listAdminBookings(appUserId, complexId, query) + return c.json(response) + } catch (error) { + if (error instanceof AdminBookingServiceError) { + return c.json({ message: error.message }, error.status) + } + throw error + } +} diff --git a/apps/backend/src/modules/admin-booking/handlers/update-admin-booking-status.handler.ts b/apps/backend/src/modules/admin-booking/handlers/update-admin-booking-status.handler.ts new file mode 100644 index 0000000..32362f3 --- /dev/null +++ b/apps/backend/src/modules/admin-booking/handlers/update-admin-booking-status.handler.ts @@ -0,0 +1,24 @@ +import type { UpdateAdminBookingStatusInput } from '@repo/api-contract' +import { + AdminBookingServiceError, + updateAdminBookingStatus, +} from '@/modules/admin-booking/services/admin-booking.service' +import type { AppContext } from '@/types/hono' + +type BookingIdParams = { id: string } + +export async function updateAdminBookingStatusHandler(c: AppContext) { + const { id } = c.req.valid('param' as never) as BookingIdParams + const payload = c.req.valid('json' as never) as UpdateAdminBookingStatusInput + const appUserId = c.get('appUserId') + + try { + const booking = await updateAdminBookingStatus(appUserId, id, payload) + return c.json(booking) + } catch (error) { + if (error instanceof AdminBookingServiceError) { + return c.json({ message: error.message }, error.status) + } + throw error + } +} diff --git a/apps/backend/src/modules/admin-booking/services/admin-booking.service.ts b/apps/backend/src/modules/admin-booking/services/admin-booking.service.ts new file mode 100644 index 0000000..8f583dc --- /dev/null +++ b/apps/backend/src/modules/admin-booking/services/admin-booking.service.ts @@ -0,0 +1,527 @@ +import type { + AdminBooking, + CreateAdminBookingInput, + ListAdminBookingsQuery, + UpdateAdminBookingStatusInput, +} from '@repo/api-contract' +import { randomInt } from 'node:crypto' +import { v7 as uuidv7 } from 'uuid' +import { CourtBookingStatus } from '@/generated/prisma/enums' +import { db } from '@/lib/prisma' +import { evaluatePlanUsage, parsePlanRules } from '@/modules/plan/services/plan-rules.service' + +type Slot = { + startTime: string + endTime: string +} + +const DAY_OF_WEEK_BY_INDEX = [ + 'SUNDAY', + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + 'SATURDAY', +] as const +const BOOKING_CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' +const BOOKING_CODE_LENGTH = 6 + +export class AdminBookingServiceError extends Error { + status: 400 | 403 | 404 | 409 + + constructor(message: string, status: 400 | 403 | 404 | 409) { + super(message) + this.name = 'AdminBookingServiceError' + 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): Date { + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date) + + if (!match) { + throw new AdminBookingServiceError('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 AdminBookingServiceError('La fecha enviada no es valida.', 400) + } + + return bookingDate +} + +function getDayOfWeek(date: Date) { + const dayOfWeek = DAY_OF_WEEK_BY_INDEX[date.getUTCDay()] + + if (!dayOfWeek) { + throw new AdminBookingServiceError('No se pudo resolver el dia de la semana.', 400) + } + + return 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 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 +} + +async function ensureComplexAccess(complexId: string, appUserId: string) { + const complexUser = await db.complexUser.findUnique({ + where: { + complexId_userId: { + complexId, + userId: appUserId, + }, + }, + include: { + complex: { + select: { + id: true, + complexName: true, + plan: { + select: { + rules: true, + }, + }, + }, + }, + }, + }) + + if (!complexUser) { + throw new AdminBookingServiceError('No tienes permisos para administrar este complejo.', 403) + } + + return complexUser.complex +} + +function mapBookingResponse(booking: { + id: string + bookingCode: string + bookingDate: Date + startTime: string + endTime: string + customerName: string + customerPhone: string + status: 'CONFIRMED' | 'CANCELLED' | 'COMPLETED' + createdAt: Date + updatedAt: Date + court: { + id: string + name: string + complex: { + id: string + complexName: string + } + sport: { + id: string + name: string + slug: string + } + } +}): AdminBooking { + return { + id: booking.id, + bookingCode: booking.bookingCode, + complexId: booking.court.complex.id, + complexName: booking.court.complex.complexName, + courtId: booking.court.id, + courtName: booking.court.name, + sport: { + id: booking.court.sport.id, + name: booking.court.sport.name, + slug: booking.court.sport.slug, + }, + date: formatIsoDate(booking.bookingDate), + startTime: booking.startTime, + endTime: booking.endTime, + customerName: booking.customerName, + customerPhone: booking.customerPhone, + status: booking.status, + createdAt: booking.createdAt.toISOString(), + updatedAt: booking.updatedAt.toISOString(), + } +} + +export async function listAdminBookings( + appUserId: string, + complexId: string, + query: ListAdminBookingsQuery, +) { + await ensureComplexAccess(complexId, appUserId) + const fromDate = parseIsoDate(query.fromDate) + + const bookings = await db.courtBooking.findMany({ + where: { + bookingDate: { + gte: fromDate, + }, + court: { + complexId, + }, + }, + include: { + court: { + select: { + id: true, + name: true, + sport: { + select: { + id: true, + name: true, + slug: true, + }, + }, + complex: { + select: { + id: true, + complexName: true, + }, + }, + }, + }, + }, + orderBy: [{ bookingDate: 'asc' }, { startTime: 'asc' }, { createdAt: 'asc' }], + }) + + return { + bookings: bookings.map((booking) => mapBookingResponse(booking)), + } +} + +export async function createAdminBooking( + appUserId: string, + complexId: string, + input: CreateAdminBookingInput, +) { + const complex = await ensureComplexAccess(complexId, appUserId) + const bookingDate = parseIsoDate(input.date) + const dayOfWeek = getDayOfWeek(bookingDate) + + const court = await db.court.findFirst({ + where: { + id: input.courtId, + complexId, + }, + include: { + availabilities: { + where: { + dayOfWeek, + }, + orderBy: { + startTime: 'asc', + }, + }, + sport: { + select: { + id: true, + name: true, + slug: true, + }, + }, + }, + }) + + if (!court) { + throw new AdminBookingServiceError('La cancha seleccionada no existe en el complejo.', 404) + } + + const validSlots = buildSlots(court.availabilities, court.slotDurationMinutes) + const selectedStartMinutes = toMinutes(input.startTime) + const selectedEndMinutes = selectedStartMinutes + court.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 AdminBookingServiceError( + '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, + }, + }, + }) + + const courtsCount = await tx.court.count({ + where: { + complexId, + }, + }) + + const violations = evaluatePlanUsage(rules, { + courtsCount, + bookingsToday: bookingsForDate, + }) + + const maxBookingsViolation = violations.find( + (violation) => violation.code === 'MAX_BOOKINGS_PER_DAY_REACHED', + ) + + if (maxBookingsViolation) { + throw new AdminBookingServiceError(maxBookingsViolation.message, 409) + } + } + + const overlappingBooking = await tx.courtBooking.findFirst({ + where: { + courtId: court.id, + bookingDate, + status: 'CONFIRMED', + startTime: { + lt: selectedSlot.endTime, + }, + endTime: { + gt: selectedSlot.startTime, + }, + }, + }) + + if (overlappingBooking) { + throw new AdminBookingServiceError('El horario seleccionado ya fue reservado.', 409) + } + + return tx.courtBooking.create({ + data: { + id: uuidv7(), + bookingCode: generateBookingCode(), + courtId: court.id, + bookingDate, + startTime: selectedSlot.startTime, + endTime: selectedSlot.endTime, + customerName: input.customerName.trim(), + customerPhone: input.customerPhone.trim(), + status: 'CONFIRMED', + }, + include: { + court: { + select: { + id: true, + name: true, + sport: { + select: { + id: true, + name: true, + slug: true, + }, + }, + complex: { + select: { + id: true, + complexName: true, + }, + }, + }, + }, + }, + }) + }) + + return mapBookingResponse(booking) + } catch (error) { + if (error instanceof AdminBookingServiceError) { + 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 AdminBookingServiceError('El horario seleccionado ya fue reservado.', 409) + } + + throw error + } + } + + throw new AdminBookingServiceError( + 'No se pudo generar un codigo de reserva unico. Intenta nuevamente.', + 409, + ) +} + +export async function updateAdminBookingStatus( + appUserId: string, + bookingId: string, + input: UpdateAdminBookingStatusInput, +) { + const booking = await db.courtBooking.findFirst({ + where: { + id: bookingId, + court: { + complex: { + users: { + some: { + userId: appUserId, + }, + }, + }, + }, + }, + include: { + court: { + select: { + id: true, + name: true, + sport: { + select: { + id: true, + name: true, + slug: true, + }, + }, + complex: { + select: { + id: true, + complexName: true, + }, + }, + }, + }, + }, + }) + + if (!booking) { + throw new AdminBookingServiceError('Reserva no encontrada.', 404) + } + + if (input.status === 'CANCELLED' && booking.status !== 'CONFIRMED') { + throw new AdminBookingServiceError( + 'Solo se pueden cancelar reservas en estado confirmada.', + 409, + ) + } + + if (input.status === 'COMPLETED' && booking.status !== 'CONFIRMED') { + throw new AdminBookingServiceError( + 'Solo se pueden marcar como cumplidas las reservas confirmadas.', + 409, + ) + } + + const updated = await db.courtBooking.update({ + where: { id: booking.id }, + data: { + status: + input.status === 'COMPLETED' + ? CourtBookingStatus.COMPLETED + : CourtBookingStatus.CANCELLED, + }, + include: { + court: { + select: { + id: true, + name: true, + sport: { + select: { + id: true, + name: true, + slug: true, + }, + }, + complex: { + select: { + id: true, + complexName: true, + }, + }, + }, + }, + }, + }) + + return mapBookingResponse(updated) +} diff --git a/apps/backend/src/modules/public-booking/services/public-booking.service.ts b/apps/backend/src/modules/public-booking/services/public-booking.service.ts index 2d2a78c..941b524 100644 --- a/apps/backend/src/modules/public-booking/services/public-booking.service.ts +++ b/apps/backend/src/modules/public-booking/services/public-booking.service.ts @@ -234,7 +234,7 @@ function mapBookingResponse(input: { endTime: string customerName: string customerPhone: string - status: 'CONFIRMED' | 'CANCELLED' + status: 'CONFIRMED' | 'CANCELLED' | 'COMPLETED' court: { id: string name: string diff --git a/apps/backend/src/register-routes.ts b/apps/backend/src/register-routes.ts index a3b8b76..4be0c1d 100644 --- a/apps/backend/src/register-routes.ts +++ b/apps/backend/src/register-routes.ts @@ -2,6 +2,7 @@ import { registerApiRoutes } from '@repo/api-contract' import type { Hono } from 'hono' import { serveStatic } from 'hono/bun' import path from 'node:path' +import { adminBookingRoutes } from '@/modules/admin-booking/admin-booking.routes' import { complexRoutes } from '@/modules/complex/complex.routes' import { courtRoutes } from '@/modules/court/court.routes' import { onboardingRoutes } from '@/modules/onboarding/onboarding.routes' @@ -22,6 +23,7 @@ export function registerRoutes(app: Hono) { }) app.route('/api/public-bookings', publicBookingRoutes) + app.route('/api/admin-bookings', adminBookingRoutes) app.use('*', serveStatic({ root: './public' })) app.get('*', async (c) => { diff --git a/apps/frontend/src/features/home/home-page.tsx b/apps/frontend/src/features/home/home-page.tsx index 59949ba..09d7e5a 100644 --- a/apps/frontend/src/features/home/home-page.tsx +++ b/apps/frontend/src/features/home/home-page.tsx @@ -1,67 +1,552 @@ import { zodResolver } from '@hookform/resolvers/zod' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { useEffect, useMemo, useState } from 'react' import { useForm } from 'react-hook-form' import { z } from 'zod' +import type { AdminBooking } from '@repo/api-contract' import { Button } from '@/components/ui/button' import { Field, FieldError, FieldLabel } from '@/components/ui/field' import { Input } from '@/components/ui/input' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { ApiClientError, apiClient } from '@/lib/api-client' +import { + getCurrentComplexSlug, + setCurrentComplexSlug as persistCurrentComplexSlug, +} from '@/lib/current-complex' -const emailSchema = z.object({ - email: z.string().email('Ingresa un email válido.'), +const manualBookingSchema = z.object({ + customerName: z + .string() + .trim() + .min(2, 'Ingresa un nombre valido.') + .max(120, 'El nombre no puede superar los 120 caracteres.'), + customerPhone: z + .string() + .trim() + .min(6, 'Ingresa un telefono valido.') + .max(30, 'El telefono no puede superar los 30 caracteres.'), }) -type EmailForm = z.infer +type ManualBookingForm = z.infer + +function toIsoDateLocal(date: Date) { + const year = date.getFullYear() + const month = String(date.getMonth() + 1).padStart(2, '0') + const day = String(date.getDate()).padStart(2, '0') + return `${year}-${month}-${day}` +} + +function extractMessage(error: unknown, fallback: string) { + if (error instanceof ApiClientError) { + return error.message || fallback + } + + return fallback +} + +function formatDateLabel(dateIso: string) { + const [year, month, day] = dateIso.split('-').map(Number) + + if (!year || !month || !day) return dateIso + + const date = new Date(year, month - 1, day) + return new Intl.DateTimeFormat('es-AR', { + weekday: 'long', + day: '2-digit', + month: 'long', + }).format(date) +} + +function statusLabel(status: 'CONFIRMED' | 'CANCELLED' | 'COMPLETED') { + if (status === 'COMPLETED') return 'Cumplida' + if (status === 'CANCELLED') return 'Cancelada' + return 'Confirmada' +} + +function statusClassName(status: 'CONFIRMED' | 'CANCELLED' | 'COMPLETED') { + if (status === 'COMPLETED') { + return 'border-emerald-200 bg-emerald-50 text-emerald-700' + } + + if (status === 'CANCELLED') { + return 'border-rose-200 bg-rose-50 text-rose-700' + } + + return 'border-sky-200 bg-sky-50 text-sky-700' +} export function HomePage() { - const { - register, - handleSubmit, - watch, - formState: { errors, isValid, isSubmitSuccessful }, - } = useForm({ - resolver: zodResolver(emailSchema), - mode: 'onChange', - defaultValues: { - email: '', + const queryClient = useQueryClient() + const todayIso = useMemo(() => toIsoDateLocal(new Date()), []) + + const [currentComplexSlug, setCurrentComplexSlug] = useState(null) + const [fromDate, setFromDate] = useState(todayIso) + const [manualDate, setManualDate] = useState(todayIso) + const [selectedSportId, setSelectedSportId] = useState() + const [selectedCourtId, setSelectedCourtId] = useState('') + const [selectedStartTime, setSelectedStartTime] = useState('') + + useEffect(() => { + setCurrentComplexSlug(getCurrentComplexSlug()) + }, []) + + const myComplexesQuery = useQuery({ + queryKey: ['my-complexes'], + queryFn: () => apiClient.complexes.listMine(), + }) + + const selectedComplex = useMemo(() => { + const complexes = myComplexesQuery.data ?? [] + + if (complexes.length === 0) return null + + if (currentComplexSlug) { + const match = complexes.find((complex) => complex.complexSlug === currentComplexSlug) + + if (match) return match + } + + return complexes[0] + }, [currentComplexSlug, myComplexesQuery.data]) + + useEffect(() => { + if (!selectedComplex) return + + setCurrentComplexSlug(selectedComplex.complexSlug) + persistCurrentComplexSlug(selectedComplex.complexSlug) + }, [selectedComplex]) + + const bookingsQuery = useQuery({ + queryKey: ['admin-bookings', selectedComplex?.id, fromDate], + enabled: Boolean(selectedComplex?.id), + queryFn: () => + apiClient.adminBookings.listByComplex(selectedComplex?.id as string, { + fromDate, + }), + }) + + const manualAvailabilityQuery = useQuery({ + queryKey: ['manual-booking-availability', selectedComplex?.complexSlug, manualDate, selectedSportId], + enabled: Boolean(selectedComplex?.complexSlug && manualDate), + queryFn: () => + apiClient.publicBookings.getAvailability(selectedComplex?.complexSlug as string, { + date: manualDate, + ...(selectedSportId ? { sportId: selectedSportId } : {}), + }), + }) + + useEffect(() => { + const availability = manualAvailabilityQuery.data + + if (!availability) return + + if (!availability.sportSelectionRequired) { + if (availability.sports[0] && !selectedSportId) { + setSelectedSportId(availability.sports[0].id) + } + } else if ( + selectedSportId && + !availability.sports.some((sport) => sport.id === selectedSportId) + ) { + setSelectedSportId(undefined) + } + }, [manualAvailabilityQuery.data, selectedSportId]) + + useEffect(() => { + if (!manualAvailabilityQuery.data) return + + if ( + selectedCourtId && + !manualAvailabilityQuery.data.courts.some((court) => court.courtId === selectedCourtId) + ) { + setSelectedCourtId('') + setSelectedStartTime('') + } + }, [manualAvailabilityQuery.data, selectedCourtId]) + + const selectedCourt = useMemo(() => { + return manualAvailabilityQuery.data?.courts.find((court) => court.courtId === selectedCourtId) + }, [manualAvailabilityQuery.data?.courts, selectedCourtId]) + + useEffect(() => { + if (!selectedCourt) { + setSelectedStartTime('') + return + } + + if (!selectedCourt.availableSlots.some((slot) => slot.startTime === selectedStartTime)) { + setSelectedStartTime('') + } + }, [selectedCourt, selectedStartTime]) + + const groupedBookings = useMemo(() => { + const groups = new Map() + + for (const booking of bookingsQuery.data?.bookings ?? []) { + const current = groups.get(booking.date) ?? [] + current.push(booking) + groups.set(booking.date, current) + } + + return [...groups.entries()] + }, [bookingsQuery.data?.bookings]) + + const updateStatusMutation = useMutation({ + mutationFn: (payload: { bookingId: string; status: 'CANCELLED' | 'COMPLETED' }) => + apiClient.adminBookings.updateStatus(payload.bookingId, { status: payload.status }), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ['admin-bookings', selectedComplex?.id] }) + void queryClient.invalidateQueries({ + queryKey: ['manual-booking-availability', selectedComplex?.complexSlug], + }) }, }) - const emailValue = watch('email') + const { + register, + handleSubmit, + reset, + formState: { errors, isSubmitting, isValid }, + } = useForm({ + resolver: zodResolver(manualBookingSchema), + mode: 'onChange', + defaultValues: { + customerName: '', + customerPhone: '', + }, + }) - const onSubmit = (_data: EmailForm) => { - // Demo form: no-op; success state is managed by react-hook-form. + const createManualBookingMutation = useMutation({ + mutationFn: async (values: ManualBookingForm) => { + if (!selectedComplex?.id) { + throw new Error('No se encontró el complejo actual.') + } + + if (!selectedCourtId || !selectedStartTime) { + throw new Error('Debes seleccionar cancha y horario.') + } + + return apiClient.adminBookings.create(selectedComplex.id, { + date: manualDate, + courtId: selectedCourtId, + startTime: selectedStartTime, + customerName: values.customerName, + customerPhone: values.customerPhone, + }) + }, + onSuccess: async () => { + reset() + setSelectedCourtId('') + setSelectedStartTime('') + + await queryClient.invalidateQueries({ queryKey: ['admin-bookings', selectedComplex?.id] }) + await queryClient.invalidateQueries({ + queryKey: ['manual-booking-availability', selectedComplex?.complexSlug], + }) + }, + }) + + const onSubmitManualBooking = async (values: ManualBookingForm) => { + await createManualBookingMutation.mutateAsync(values) + } + + if (myComplexesQuery.isLoading) { + return ( +
+

Cargando panel...

+
+ ) + } + + if (myComplexesQuery.isError) { + return ( +
+

+ {extractMessage(myComplexesQuery.error, 'No pudimos cargar tus complejos.')} +

+
+ ) + } + + if (!selectedComplex) { + return ( +
+

+ No tienes complejos asignados todavía. +

+
+ ) } return ( -
-
-

Frontend listo

-

- React + Vite + TypeScript + shadcn + zod + react-hook-form. +

+
+

Panel de reservas

+

+ Gestiona turnos del día y futuros para {selectedComplex.complexName}.

+
-
- - Email +
+
+ + Mostrar desde { + setFromDate(event.target.value) + }} /> - +
- - + {bookingsQuery.isLoading && ( +

Cargando reservas...

+ )} - {isSubmitSuccessful && !errors.email && emailValue && ( -

- Email válido según esquema zod. + {bookingsQuery.isError && ( +

+ {extractMessage(bookingsQuery.error, 'No pudimos cargar las reservas.')}

)} + + {!bookingsQuery.isLoading && + !bookingsQuery.isError && + groupedBookings.length === 0 && ( +

+ No hay reservas para la fecha seleccionada en adelante. +

+ )} + +
+ {groupedBookings.map(([date, bookings]) => ( +
+

{formatDateLabel(date)}

+
+ {bookings.map((booking) => { + const canManage = booking.status === 'CONFIRMED' + const isMutating = updateStatusMutation.isPending + + return ( +
+
+

+ {booking.startTime} - {booking.endTime} · {booking.courtName} +

+

+ {booking.customerName} ({booking.customerPhone}) · Código {booking.bookingCode} +

+
+ +
+ + {statusLabel(booking.status)} + + + {canManage && ( + <> + + + + )} +
+
+ ) + })} +
+
+ ))} +
+ + {updateStatusMutation.isError && ( +

+ {extractMessage(updateStatusMutation.error, 'No pudimos actualizar la reserva.')} +

+ )} +
+ +
+

Reserva manual

+

+ Crea un turno para atención telefónica o mostrador. +

+ +
+ + Fecha + { + setManualDate(event.target.value) + setSelectedCourtId('') + setSelectedStartTime('') + }} + /> + + + {manualAvailabilityQuery.data?.sportSelectionRequired && ( + + Deporte + + + )} + + + Cancha + + +
+ +
+ + Horario + + +
+ + {manualAvailabilityQuery.isLoading && ( +

Cargando horarios disponibles...

+ )} + + {manualAvailabilityQuery.isError && ( +

+ {extractMessage( + manualAvailabilityQuery.error, + 'No pudimos cargar disponibilidad para la reserva manual.', + )} +

+ )} + +
+ + Nombre + + + + + + Teléfono + + + + + {createManualBookingMutation.isError && ( +

+ {extractMessage( + createManualBookingMutation.error, + 'No pudimos crear la reserva manual.', + )} +

+ )} + + +
) diff --git a/apps/frontend/src/lib/api-client.ts b/apps/frontend/src/lib/api-client.ts index a8b770a..e2d71d2 100644 --- a/apps/frontend/src/lib/api-client.ts +++ b/apps/frontend/src/lib/api-client.ts @@ -1,5 +1,7 @@ import axios, { AxiosError } from 'axios' import type { + AdminBooking, + CreateAdminBookingInput, Court, CreateCourtInput, CreatePublicBookingInput, @@ -20,6 +22,7 @@ import type { PublicBooking, PublicBookingConfirmation, Sport, + UpdateAdminBookingStatusInput, UpdateCourtInput, UpdateComplexPayload, UpdateComplexResponse, @@ -261,6 +264,39 @@ export const apiClient = { return response.data }, }, + adminBookings: { + listByComplex: async ( + complexId: string, + params: { + fromDate: string + }, + ) => { + const response = await http.get<{ bookings: AdminBooking[] }>( + `/api/admin-bookings/complex/${complexId}`, + { + params, + }, + ) + return response.data + }, + create: async (complexId: string, payload: CreateAdminBookingInput) => { + const response = await http.post( + `/api/admin-bookings/complex/${complexId}`, + payload, + ) + return response.data + }, + updateStatus: async ( + bookingId: string, + payload: UpdateAdminBookingStatusInput, + ) => { + const response = await http.patch( + `/api/admin-bookings/${bookingId}/status`, + payload, + ) + return response.data + }, + }, } export type ApiClient = typeof apiClient diff --git a/packages/api-contract/src/admin-booking.ts b/packages/api-contract/src/admin-booking.ts new file mode 100644 index 0000000..12fd5ab --- /dev/null +++ b/packages/api-contract/src/admin-booking.ts @@ -0,0 +1,67 @@ +import { z } from 'zod' + +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 bookingStatusSchema = z.enum(['CONFIRMED', 'CANCELLED', 'COMPLETED']) + +export const adminBookingSportSchema = z.object({ + id: z.uuid(), + name: z.string(), + slug: z.string(), +}) + +export const adminBookingSchema = z.object({ + id: z.uuid(), + bookingCode: z.string().regex(BOOKING_CODE_REGEX), + complexId: z.uuid(), + complexName: z.string(), + courtId: z.uuid(), + courtName: z.string(), + sport: adminBookingSportSchema, + 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: bookingStatusSchema, + createdAt: z.string().datetime(), + updatedAt: z.string().datetime(), +}) + +export const listAdminBookingsQuerySchema = z.object({ + fromDate: z.string().regex(ISO_DATE_REGEX, 'La fecha debe tener formato YYYY-MM-DD.'), +}) + +export const listAdminBookingsResponseSchema = z.object({ + bookings: z.array(adminBookingSchema), +}) + +export const createAdminBookingSchema = z.object({ + date: z.string().regex(ISO_DATE_REGEX, 'La fecha debe tener formato YYYY-MM-DD.'), + 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 updateAdminBookingStatusSchema = z.object({ + status: z.enum(['CANCELLED', 'COMPLETED']), +}) + +export type BookingStatus = z.infer +export type AdminBookingSport = z.infer +export type AdminBooking = z.infer +export type ListAdminBookingsQuery = z.infer +export type ListAdminBookingsResponse = z.infer +export type CreateAdminBookingInput = z.infer +export type UpdateAdminBookingStatusInput = z.infer diff --git a/packages/api-contract/src/index.ts b/packages/api-contract/src/index.ts index 2c88e43..5e892b5 100644 --- a/packages/api-contract/src/index.ts +++ b/packages/api-contract/src/index.ts @@ -55,6 +55,24 @@ export type { CreateCourtInput, UpdateCourtInput, } from './court' +export { + adminBookingSchema, + adminBookingSportSchema, + bookingStatusSchema, + createAdminBookingSchema, + listAdminBookingsQuerySchema, + listAdminBookingsResponseSchema, + updateAdminBookingStatusSchema, +} from './admin-booking' +export type { + AdminBooking, + AdminBookingSport, + BookingStatus, + CreateAdminBookingInput, + ListAdminBookingsQuery, + ListAdminBookingsResponse, + UpdateAdminBookingStatusInput, +} from './admin-booking' export { createPublicBookingSchema, publicAvailabilityCourtSchema, diff --git a/packages/api-contract/src/public-booking.ts b/packages/api-contract/src/public-booking.ts index 8107299..2c39b5d 100644 --- a/packages/api-contract/src/public-booking.ts +++ b/packages/api-contract/src/public-booking.ts @@ -71,7 +71,7 @@ export const publicBookingSchema = z.object({ endTime: z.string().regex(TIME_REGEX), customerName: z.string(), customerPhone: z.string(), - status: z.enum(['CONFIRMED', 'CANCELLED']), + status: z.enum(['CONFIRMED', 'CANCELLED', 'COMPLETED']), createdAt: z.string().datetime(), }) @@ -84,7 +84,7 @@ export const publicBookingConfirmationSchema = z.object({ endTime: z.string().regex(TIME_REGEX), courtName: z.string(), sport: publicBookingSportSchema, - status: z.enum(['CONFIRMED', 'CANCELLED']), + status: z.enum(['CONFIRMED', 'CANCELLED', 'COMPLETED']), createdAt: z.string().datetime(), })