From a85f743be432253bd3f00941482d0f05a54f3ab1 Mon Sep 17 00:00:00 2001 From: Collin Duncan <3679940+cgduncan7@users.noreply.github.com> Date: Wed, 2 Apr 2025 12:08:41 +0200 Subject: [PATCH] Adding reservation cancellation --- .../1774364085498-ReservationCancellation.ts | 33 +++++ src/reservations/entity.ts | 15 ++ src/reservations/service.ts | 5 +- src/reservations/worker.ts | 24 ++- src/runner/baanreserveren/service.ts | 138 ++++++++++++++++-- 5 files changed, 196 insertions(+), 19 deletions(-) create mode 100644 database/migrations/1774364085498-ReservationCancellation.ts diff --git a/database/migrations/1774364085498-ReservationCancellation.ts b/database/migrations/1774364085498-ReservationCancellation.ts new file mode 100644 index 0000000..12066ea --- /dev/null +++ b/database/migrations/1774364085498-ReservationCancellation.ts @@ -0,0 +1,33 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class ReservationCancellation1774364085498 + implements MigrationInterface +{ + name = 'ReservationCancellation1774364085498' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "temporary_reservations" ("id" varchar PRIMARY KEY NOT NULL, "dateRangeStart" datetime NOT NULL, "dateRangeEnd" datetime NOT NULL, "status" varchar(32) NOT NULL DEFAULT ('pending'), "waitingListId" integer, "ownerId" varchar(32) NOT NULL, "opponents" json NOT NULL, "externalId" varchar(32), "bookingCourtNumber" varchar(32), "bookingStartAt" varchar(32))`, + ) + await queryRunner.query( + `INSERT INTO "temporary_reservations"("id", "dateRangeStart", "dateRangeEnd", "status", "waitingListId", "ownerId", "opponents") SELECT "id", "dateRangeStart", "dateRangeEnd", "status", "waitingListId", "ownerId", "opponents" FROM "reservations"`, + ) + await queryRunner.query(`DROP TABLE "reservations"`) + await queryRunner.query( + `ALTER TABLE "temporary_reservations" RENAME TO "reservations"`, + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "reservations" RENAME TO "temporary_reservations"`, + ) + await queryRunner.query( + `CREATE TABLE "reservations" ("id" varchar PRIMARY KEY NOT NULL, "dateRangeStart" datetime NOT NULL, "dateRangeEnd" datetime NOT NULL, "status" varchar(32) NOT NULL DEFAULT ('pending'), "waitingListId" integer, "ownerId" varchar(32) NOT NULL, "opponents" json NOT NULL)`, + ) + await queryRunner.query( + `INSERT INTO "reservations"("id", "dateRangeStart", "dateRangeEnd", "status", "waitingListId", "ownerId", "opponents") SELECT "id", "dateRangeStart", "dateRangeEnd", "status", "waitingListId", "ownerId", "opponents" FROM "temporary_reservations"`, + ) + await queryRunner.query(`DROP TABLE "temporary_reservations"`) + } +} diff --git a/src/reservations/entity.ts b/src/reservations/entity.ts index 5c06821..4b6cc31 100644 --- a/src/reservations/entity.ts +++ b/src/reservations/entity.ts @@ -24,6 +24,9 @@ export class Reservation { @PrimaryGeneratedColumn('uuid') id: string + @Column('varchar', { length: 32, nullable: true }) + externalId?: string + @Column('varchar', { length: 32, nullable: false }) ownerId: string @@ -57,6 +60,18 @@ export class Reservation { @Column('int', { nullable: true }) waitingListId: number + @Column('varchar', { + length: 32, + nullable: true, + }) + bookingCourtNumber?: string + + @Column('varchar', { + length: 32, + nullable: true, + }) + bookingStartAt?: string + constructor(partial: Partial) { Object.assign(this, partial) } diff --git a/src/reservations/service.ts b/src/reservations/service.ts index ad3deb4..4d4a3e1 100644 --- a/src/reservations/service.ts +++ b/src/reservations/service.ts @@ -101,7 +101,10 @@ export class ReservationsService { const reservation = await this.getById(id) if (!reservation) return - await this.brService.removeReservationFromWaitList(reservation) + if (reservation.status === ReservationStatus.Booked) + await this.brService.cancelReservation(reservation) + else if (reservation.status === ReservationStatus.OnWaitingList) + await this.brService.removeReservationFromWaitList(reservation) return await this.reservationsRepository.delete({ id: reservation.id }) } } diff --git a/src/reservations/worker.ts b/src/reservations/worker.ts index c7415aa..741367f 100644 --- a/src/reservations/worker.ts +++ b/src/reservations/worker.ts @@ -83,13 +83,33 @@ export class ReservationsWorker { speedyMode = true, ) { try { + let externalId: string + let startAt: string + let court: number if (speedyMode) { - await this.brService.performSpeedyReservation(reservation) + const { + id, + startAt: _startAt, + court: _court, + } = await this.brService.performSpeedyReservation(reservation) + externalId = id + startAt = _startAt + court = _court } else { - await this.brService.performReservation(reservation, timeSensitive) + const { + id, + startAt: _startAt, + court: _court, + } = await this.brService.performReservation(reservation, timeSensitive) + externalId = id + startAt = _startAt + court = _court } await this.reservationsService.update(reservation.id, { status: ReservationStatus.Booked, + externalId: externalId == null ? undefined : `${externalId}`, + bookingCourtNumber: `${court}`, + bookingStartAt: startAt, }) } catch (error: unknown) { await this.handleReservationErrors( diff --git a/src/runner/baanreserveren/service.ts b/src/runner/baanreserveren/service.ts index 1d433c0..396c683 100644 --- a/src/runner/baanreserveren/service.ts +++ b/src/runner/baanreserveren/service.ts @@ -23,6 +23,7 @@ export enum BaanReserverenUrls { Logout = '/auth/logout', WaitingList = '/waitinglist', WaitingListAdd = '/waitinglist/add', + FutureReservations = '/user/future', } enum SessionAction { @@ -340,6 +341,17 @@ export class BaanReserverenService { return lastDayOfMonth.add(daysToAdd, 'day') } + private async navigateToReservationCancellation( + reservation: Reservation & Required>, + ) { + this.loggerService.debug('Navigating to reservation', { + externalId: reservation.externalId, + }) + await this.page.goto( + `${BAAN_RESERVEREN_ROOT_URL}/reservations/${reservation.externalId}/cancel`, + ) + } + private async navigateToReservationPrompt(courtSlot: CourtSlot, date: Dayjs) { this.loggerService.debug('Navigating to reservation prompt', { courtSlot, @@ -414,6 +426,22 @@ export class BaanReserverenService { await this.page.waitForNetworkIdle() } + private findFutureReservationLink(): string { + return '' + } + + private async navigateToFutureReservations() { + this.loggerService.debug('Navigating to future reservations') + await this.page + .goto( + `${BAAN_RESERVEREN_ROOT_URL}/${BaanReserverenUrls.FutureReservations}`, + ) + .catch((e) => { + throw new RunningFutureReservationsNavigationError(e) + }) + await this.page.waitForNetworkIdle() + } + private async recordWaitingListEntries(): Promise { const waitingListEntriesElements = await this.page.$$( '#content tbody tr td:nth-child(1)', @@ -479,11 +507,11 @@ export class BaanReserverenService { private async sortCourtsByRank( freeCourts: ElementHandle[], - ): Promise { + ): Promise<{ handle: ElementHandle; courtSlot: CourtSlot } | null> { if (freeCourts.length === 0) return null const freeCourtsWithJson = await Promise.all( freeCourts.map(async (fc) => ({ - elementHAndle: fc, + elementHandle: fc, jsonValue: await fc.jsonValue(), })), ) @@ -492,36 +520,46 @@ export class BaanReserverenService { (CourtRank[a.jsonValue.slot as CourtSlot] ?? 99) - (CourtRank[b.jsonValue.slot as CourtSlot] ?? 99), ) - return freeCourtsWithJson[0].elementHAndle + return { + handle: freeCourtsWithJson[0].elementHandle, + courtSlot: freeCourtsWithJson[0].jsonValue.slot as CourtSlot, + } } private async selectAvailableTime(reservation: Reservation) { this.loggerService.debug('Selecting available time', { reservation: instanceToPlain(reservation), }) - let freeCourt: ElementHandle | null | undefined + let freeCourtHandle: ElementHandle | null | undefined + let freeCourtSlot: CourtSlot | undefined + let time: string | undefined let i = 0 const possibleDates = reservation.createPossibleDates() this.loggerService.debug('Possible dates', { possibleDates }) - while (i < possibleDates.length && !freeCourt) { + while (i < possibleDates.length && !freeCourtHandle) { const possibleDate = possibleDates[i] const timeString = possibleDate.format('HH:mm') const selector = `tr[data-time='${timeString}']` + `> td.free[rowspan='3'][type='free']` const freeCourts = await this.page.$$(selector) - freeCourt = await this.sortCourtsByRank(freeCourts) + const firstFreeCourt = await this.sortCourtsByRank(freeCourts) + freeCourtHandle = firstFreeCourt?.handle + freeCourtSlot = firstFreeCourt?.courtSlot + time = timeString i++ } - if (!freeCourt) { + if (!freeCourtHandle || !freeCourtSlot || !time) { throw new NoCourtAvailableError('No court available for reservation') } - this.loggerService.debug('Free court found') + this.loggerService.debug('Free court found', { court: freeCourtSlot }) - await freeCourt.click().catch((e: Error) => { + await freeCourtHandle.click().catch((e: Error) => { throw new RunnerCourtSelectionError(e) }) + + return { time, court: CourtSlotToNumber[freeCourtSlot] } } private async selectOwner(id: string) { @@ -596,6 +634,23 @@ export class BaanReserverenService { throw new RunnerReservationConfirmSubmitError(e) }) await this.page.waitForNetworkIdle() + + const regexResult = /reservations\/(\d+)$/.exec(this.page.url()) + if (regexResult != null) { + return regexResult[1] + } + + return 'unknown' + } + + private async confirmCancellation() { + this.loggerService.debug('Cancelling reservation') + await this.page + .$('input[value="Reservering annuleren"]') + .then((b) => b?.click()) + .catch((e: Error) => { + throw new RunnerReservationCancelButtonError(e) + }) } private async inputWaitingListDetails(reservation: Reservation) { @@ -702,17 +757,22 @@ export class BaanReserverenService { try { await this.init() await this.navigateToDay(reservation.dateRangeStart) - await this.selectAvailableTime(reservation) + const { time, court } = await this.selectAvailableTime(reservation) await this.selectOwner(reservation.ownerId) await this.selectOpponents(reservation.opponents) - await this.confirmReservation() + const id = await this.confirmReservation() + return { id, startAt: time, court } } catch (error: unknown) { if (!timeSensitive) await this.handleError() throw error } } - public async performSpeedyReservation(reservation: Reservation) { + public async performSpeedyReservation(reservation: Reservation): Promise<{ + id: string + startAt: string + court: number + }> { await this.init() const courtSlots = this.getCourtSlotsForDate(reservation.dateRangeStart) @@ -721,7 +781,6 @@ export class BaanReserverenService { } for (const courtSlot of courtSlots) { - let errorReserving = false try { await this.navigateToReservationPrompt( courtSlot, @@ -729,24 +788,51 @@ export class BaanReserverenService { ) await this.selectOwner(reservation.ownerId) await this.selectOpponents(reservation.opponents, true) - await this.confirmReservation() + const id = await this.confirmReservation() + return { + id, + startAt: reservation.dateRangeStart.format('HH:mm'), + court: CourtSlotToNumber[courtSlot], + } } catch (error: unknown) { if ( error instanceof CourtNotAvailableError || error instanceof RunnerReservationConfirmSubmitError ) { this.loggerService.warn('Court taken, retrying', { courtSlot }) - errorReserving = true } else { throw error } } - if (!errorReserving) return } throw new NoCourtAvailableError('Could not reserve court') } + public async cancelReservation(reservation: Reservation) { + if (reservation.externalId == null) { + this.loggerService.warn( + 'Trying to cancel a reservation without externalId', + { + reservation: reservation.id, + }, + ) + throw new CancellationError('No externalId') + } + + if (reservation.status !== ReservationStatus.Booked) { + this.loggerService.warn('Trying to cancel a non-booked reservation', { + status: reservation.status, + }) + throw new CancellationError('Not booked') + } + + await this.init() + // @ts-expect-error TS doesn't see that externalId must be defined + await this.navigateToReservationCancellation(reservation) + await this.confirmCancellation() + } + public async addReservationToWaitingList( reservation: Reservation, timeSensitive = true, @@ -915,6 +1001,12 @@ export class RunningReservationsNavigationError extends RunnerError { } } +export class RunningFutureReservationsNavigationError extends RunnerError { + constructor(error: Error) { + super(error, 'RunningFutureReservationsNavigationError') + } +} + export class RunnerWaitingListNavigationError extends RunnerError { constructor(error: Error) { super(error, 'RunnerWaitingListNavigationError') @@ -963,6 +1055,13 @@ export class CourtNotAvailableError extends Error { } } +export class CancellationError extends Error { + constructor(message: string) { + super(message) + this.name = 'CancellationError' + } +} + export class RunnerOwnerSearchNetworkError extends RunnerError { constructor(error: Error) { super(error, 'RunnerOwnerSearchNetworkError') @@ -1000,12 +1099,19 @@ export class RunnerReservationConfirmButtonError extends RunnerError { super(error, 'RunnerReservationConfirmButtonError') } } + export class RunnerReservationConfirmSubmitError extends RunnerError { constructor(error: Error) { super(error, 'RunnerReservationConfirmSubmitError') } } +export class RunnerReservationCancelButtonError extends RunnerError { + constructor(error: Error) { + super(error, 'RunnerReservationConfirmButtonError') + } +} + export class SessionStartError extends Error { constructor(message: string) { super(message) -- 2.47.2