diff --git a/src/http/handlers.ts b/src/http/handlers.ts index 4f1354a..4bcb3fb 100644 --- a/src/http/handlers.ts +++ b/src/http/handlers.ts @@ -89,27 +89,29 @@ export function createBooking(deps: AppDependencies): Handler { throw new HttpError(violation.code === 'INVALID_DATE' ? 400 : 422, violation.code, violation.message); } - const sameDay = deps.service.bookingsForCourt(court.id, request.date).filter((b) => b.status === 'confirmed'); - if (sameDay.some((b) => overlaps({ startHour: b.start_hour, hours: b.hours }, request))) { - throw new HttpError(409, 'SLOT_TAKEN', 'La cancha ya está reservada en ese horario.'); - } - - const priceCents = quotePriceCents({ - sport: court.sport, - date: request.date, - startHour: request.startHour, - hours: request.hours, - plan: member.plan, - }); - const id = deps.service.saveBooking({ - court_id: court.id, - member_id: member.id, - date: request.date, - start_hour: request.startHour, - hours: request.hours, - team_name: request.teamName, - price_cents: priceCents, - created_at: deps.clock.now().toISOString(), + const id = deps.service.atomically(() => { + const sameDay = deps.service.bookingsForCourt(court.id, request.date).filter((b) => b.status === 'confirmed'); + if (sameDay.some((b) => overlaps({ startHour: b.start_hour, hours: b.hours }, request))) { + throw new HttpError(409, 'SLOT_TAKEN', 'La cancha ya está reservada en ese horario.'); + } + + const priceCents = quotePriceCents({ + sport: court.sport, + date: request.date, + startHour: request.startHour, + hours: request.hours, + plan: member.plan, + }); + return deps.service.saveBooking({ + court_id: court.id, + member_id: member.id, + date: request.date, + start_hour: request.startHour, + hours: request.hours, + team_name: request.teamName, + price_cents: priceCents, + created_at: deps.clock.now().toISOString(), + }); }); const booking = toBookingJson(deps.service.findBooking(id)!); diff --git a/src/services/booking-service.ts b/src/services/booking-service.ts index 55e62cf..1ee0dee 100644 --- a/src/services/booking-service.ts +++ b/src/services/booking-service.ts @@ -10,8 +10,10 @@ export class BookingService { #courts: CourtRepository; #members: MemberRepository; #bookings: BookingRepository; + #db: DatabaseSync; constructor(db: DatabaseSync) { + this.#db = db; this.#courts = new CourtRepository(db); this.#members = new MemberRepository(db); this.#bookings = new BookingRepository(db); @@ -56,4 +58,18 @@ export class BookingService { markCancelled(id: number, cancelledAt: string): void { this.#bookings.updateStatus(id, 'cancelled', cancelledAt); } + + // Corre `work` con el lock de escritura tomado desde el principio: entre + // la revisión y el guardado ninguna otra conexión puede escribir. + atomically(work: () => T): T { + this.#db.exec('BEGIN IMMEDIATE'); + try { + const result = work(); + this.#db.exec('COMMIT'); + return result; + } catch (error) { + this.#db.exec('ROLLBACK'); + throw error; + } + } } diff --git a/src/store/database.ts b/src/store/database.ts index c477503..efc5ba7 100644 --- a/src/store/database.ts +++ b/src/store/database.ts @@ -38,7 +38,8 @@ const MIGRATIONS: string[] = [ ]; export function openDatabase(path: string): DatabaseSync { - const db = new DatabaseSync(path); + // Si otra conexión tiene el lock, esperar hasta 5 s en vez de fallar enseguida. + const db = new DatabaseSync(path, { timeout: 5000 }); // SQLite no revisa las claves foráneas si no se le pide en cada conexión. db.exec('PRAGMA foreign_keys = ON'); migrate(db);