Adding reservation cancellation
All checks were successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/push/test Pipeline was successful
ci/woodpecker/push/deploy Pipeline was successful

This commit is contained in:
Collin Duncan 2025-04-02 12:08:41 +02:00 committed by collin
parent 7d436f7c70
commit a85f743be4
No known key found for this signature in database
5 changed files with 196 additions and 19 deletions

View file

@ -0,0 +1,33 @@
import { MigrationInterface, QueryRunner } from 'typeorm'
export class ReservationCancellation1774364085498
implements MigrationInterface
{
name = 'ReservationCancellation1774364085498'
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
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"`)
}
}

View file

@ -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<Reservation>) {
Object.assign(this, partial)
}

View file

@ -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 })
}
}

View file

@ -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(

View file

@ -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<Pick<Reservation, 'externalId'>>,
) {
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<number[]> {
const waitingListEntriesElements = await this.page.$$(
'#content tbody tr td:nth-child(1)',
@ -479,11 +507,11 @@ export class BaanReserverenService {
private async sortCourtsByRank(
freeCourts: ElementHandle<Element>[],
): Promise<ElementHandle | null> {
): 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)