From 630dedb507a65e0c02533e69ee5da0c542619650 Mon Sep 17 00:00:00 2001 From: Jose Selesan Date: Mon, 8 Jun 2026 15:46:17 -0300 Subject: [PATCH] feat(booking): add validation to prevent past bookings and implement slot validation logic --- apps/backend/src/lib/slot-validator.ts | 34 +++++++ .../services/admin-booking.service.ts | 5 ++ .../services/public-booking.service.ts | 5 ++ .../public-booking/slot-validator.test.ts | 90 +++++++++++++++++++ 4 files changed, 134 insertions(+) create mode 100644 apps/backend/src/lib/slot-validator.ts create mode 100644 apps/backend/test/public-booking/slot-validator.test.ts diff --git a/apps/backend/src/lib/slot-validator.ts b/apps/backend/src/lib/slot-validator.ts new file mode 100644 index 0000000..aea8e16 --- /dev/null +++ b/apps/backend/src/lib/slot-validator.ts @@ -0,0 +1,34 @@ +function toMinutes(value: string): number { + const [hours, minutes] = value.split(':').map((part) => Number(part)); + return hours * 60 + minutes; +} + +export function isSlotInPast( + bookingDate: Date, + startTime: string, + slotDurationMinutes: number, + now?: Date +): boolean { + const currentTime = now ?? new Date(); + + const todayStart = new Date( + currentTime.getFullYear(), + currentTime.getMonth(), + currentTime.getDate() + ); + + const bookingLocalStart = new Date( + bookingDate.getUTCFullYear(), + bookingDate.getUTCMonth(), + bookingDate.getUTCDate() + ); + + if (bookingLocalStart < todayStart) return true; + if (bookingLocalStart > todayStart) return false; + + const currentMinutes = currentTime.getHours() * 60 + currentTime.getMinutes(); + const slotStartMinutes = toMinutes(startTime); + const elapsed = currentMinutes - slotStartMinutes; + + return elapsed > slotDurationMinutes / 2; +} 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 index 95eb35b..6cec611 100644 --- a/apps/backend/src/modules/admin-booking/services/admin-booking.service.ts +++ b/apps/backend/src/modules/admin-booking/services/admin-booking.service.ts @@ -1,6 +1,7 @@ import { randomInt } from 'node:crypto'; import { CourtBookingStatus } from '@/generated/prisma/enums'; import { db } from '@/lib/prisma'; +import { isSlotInPast } from '@/lib/slot-validator'; import { evaluatePlanUsage, parsePlanRules } from '@/modules/plan/services/plan-rules.service'; import type { DayOfWeek } from '@repo/api-contract'; import type { @@ -361,6 +362,10 @@ export async function createAdminBooking( ); } + if (isSlotInPast(bookingDate, input.startTime, court.slotDurationMinutes)) { + throw new AdminBookingServiceError('No se pueden crear reservas en el pasado.', 400); + } + for (let attempt = 0; attempt < 5; attempt += 1) { try { const booking = await db.$transaction(async (tx) => { 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 7fd8368..4ce197d 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 @@ -1,5 +1,6 @@ import { randomInt } from 'node:crypto'; import { db } from '@/lib/prisma'; +import { isSlotInPast } from '@/lib/slot-validator'; import { evaluatePlanUsage, parsePlanRules } from '@/modules/plan/services/plan-rules.service'; import type { CancelPublicBookingInput, @@ -574,6 +575,10 @@ export async function createPublicBooking(complexSlug: string, input: CreatePubl ); } + if (isSlotInPast(bookingDate, input.startTime, selectedCourt.slotDurationMinutes)) { + throw new PublicBookingServiceError('No se pueden crear reservas en el pasado.', 400); + } + for (let attempt = 0; attempt < 5; attempt += 1) { try { const booking = await db.$transaction(async (tx) => { diff --git a/apps/backend/test/public-booking/slot-validator.test.ts b/apps/backend/test/public-booking/slot-validator.test.ts new file mode 100644 index 0000000..829aff7 --- /dev/null +++ b/apps/backend/test/public-booking/slot-validator.test.ts @@ -0,0 +1,90 @@ +import { expect, test } from 'bun:test'; + +import { isSlotInPast } from '@/lib/slot-validator'; + +/** + * Helper to create a bookingDate like parseIsoDate does (UTC midnight). + */ +function bookingDate(year: number, month: number, day: number): Date { + return new Date(Date.UTC(year, month - 1, day)); +} + +/** + * Helper to create a "now" date at a specific local time. + * Using local date constructor guarantees the date parts + * (getFullYear, getMonth, getDate) match the arguments + * regardless of the test runner's timezone. + */ +function localDate(year: number, month: number, day: number, hours: number, minutes: number): Date { + return new Date(year, month - 1, day, hours, minutes, 0, 0); +} + +test('future date — always allowed', () => { + const future = bookingDate(2027, 6, 10); // 2027-06-10 + const now = localDate(2026, 6, 8, 12, 0); // 2026-06-08 12:00 + + expect(isSlotInPast(future, '10:00', 60, now)).toBe(false); +}); + +test('past date — rejected', () => { + const past = bookingDate(2025, 6, 8); // 2025-06-08 + const now = localDate(2026, 6, 8, 12, 0); // 2026-06-08 12:00 + + expect(isSlotInPast(past, '10:00', 60, now)).toBe(true); +}); + +test('today, slot not started yet — allowed', () => { + const today = bookingDate(2026, 6, 8); // 2026-06-08 + const now = localDate(2026, 6, 8, 9, 0); // 09:00, slot empieza a las 10:00 + + expect(isSlotInPast(today, '10:00', 60, now)).toBe(false); +}); + +test('today, slot in progress less than half elapsed — allowed', () => { + const today = bookingDate(2026, 6, 8); + const now = localDate(2026, 6, 8, 10, 10); // 10:10, slot 10:00-11:00 (60 min), 10 min elapsed + + expect(isSlotInPast(today, '10:00', 60, now)).toBe(false); +}); + +test('today, slot in progress more than half elapsed — rejected', () => { + const today = bookingDate(2026, 6, 8); + const now = localDate(2026, 6, 8, 10, 40); // 10:40, slot 10:00-11:00 (60 min), 40 min elapsed + + expect(isSlotInPast(today, '10:00', 60, now)).toBe(true); +}); + +test('today, slot already ended — rejected', () => { + const today = bookingDate(2026, 6, 8); + const now = localDate(2026, 6, 8, 11, 1); // 11:01, slot 10:00-11:00 terminó + + expect(isSlotInPast(today, '10:00', 60, now)).toBe(true); +}); + +test('today, exactly half elapsed — allowed (inclusive boundary)', () => { + const today = bookingDate(2026, 6, 8); + const now = localDate(2026, 6, 8, 10, 30); // 10:30, slot 10:00-11:00 (60 min), exactamente 30 min + + expect(isSlotInPast(today, '10:00', 60, now)).toBe(false); +}); + +test('today, slot starts exactly now — allowed', () => { + const today = bookingDate(2026, 6, 8); + const now = localDate(2026, 6, 8, 10, 0); // 10:00 exacto + + expect(isSlotInPast(today, '10:00', 60, now)).toBe(false); +}); + +test('90 min slot with 10 min elapsed — allowed', () => { + const today = bookingDate(2026, 6, 8); + const now = localDate(2026, 6, 8, 12, 10); // 12:10, slot 12:00-13:30, 10/90 elapsed + + expect(isSlotInPast(today, '12:00', 90, now)).toBe(false); +}); + +test('90 min slot with 50 min elapsed — rejected', () => { + const today = bookingDate(2026, 6, 8); + const now = localDate(2026, 6, 8, 12, 50); // 12:50, slot 12:00-13:30, 50/90 elapsed + + expect(isSlotInPast(today, '12:00', 90, now)).toBe(true); +});