Adding reservation cancellation
This commit is contained in:
parent
7d436f7c70
commit
a85f743be4
5 changed files with 196 additions and 19 deletions
33
database/migrations/1774364085498-ReservationCancellation.ts
Normal file
33
database/migrations/1774364085498-ReservationCancellation.ts
Normal 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"`)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue