Compare commits
1 commit
c5002fc18a
...
0beb5f3323
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0beb5f3323 |
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')
|
@PrimaryGeneratedColumn('uuid')
|
||||||
id: string
|
id: string
|
||||||
|
|
||||||
|
@Column('varchar', { length: 32, nullable: true })
|
||||||
|
externalId?: string
|
||||||
|
|
||||||
@Column('varchar', { length: 32, nullable: false })
|
@Column('varchar', { length: 32, nullable: false })
|
||||||
ownerId: string
|
ownerId: string
|
||||||
|
|
||||||
|
|
@ -57,6 +60,18 @@ export class Reservation {
|
||||||
@Column('int', { nullable: true })
|
@Column('int', { nullable: true })
|
||||||
waitingListId: number
|
waitingListId: number
|
||||||
|
|
||||||
|
@Column('varchar', {
|
||||||
|
length: 32,
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
bookingCourtNumber?: string
|
||||||
|
|
||||||
|
@Column('varchar', {
|
||||||
|
length: 32,
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
bookingStartAt?: string
|
||||||
|
|
||||||
constructor(partial: Partial<Reservation>) {
|
constructor(partial: Partial<Reservation>) {
|
||||||
Object.assign(this, partial)
|
Object.assign(this, partial)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -101,6 +101,9 @@ export class ReservationsService {
|
||||||
const reservation = await this.getById(id)
|
const reservation = await this.getById(id)
|
||||||
if (!reservation) return
|
if (!reservation) return
|
||||||
|
|
||||||
|
if (reservation.status === ReservationStatus.Booked)
|
||||||
|
await this.brService.cancelReservation(reservation)
|
||||||
|
else if (reservation.status === ReservationStatus.OnWaitingList)
|
||||||
await this.brService.removeReservationFromWaitList(reservation)
|
await this.brService.removeReservationFromWaitList(reservation)
|
||||||
return await this.reservationsRepository.delete({ id: reservation.id })
|
return await this.reservationsRepository.delete({ id: reservation.id })
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -83,13 +83,33 @@ export class ReservationsWorker {
|
||||||
speedyMode = true,
|
speedyMode = true,
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
|
let externalId: string
|
||||||
|
let startAt: string
|
||||||
|
let court: number
|
||||||
if (speedyMode) {
|
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 {
|
} 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, {
|
await this.reservationsService.update(reservation.id, {
|
||||||
status: ReservationStatus.Booked,
|
status: ReservationStatus.Booked,
|
||||||
|
externalId: externalId == null ? undefined : `${externalId}`,
|
||||||
|
bookingCourtNumber: `${court}`,
|
||||||
|
bookingStartAt: startAt,
|
||||||
})
|
})
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
await this.handleReservationErrors(
|
await this.handleReservationErrors(
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ export enum BaanReserverenUrls {
|
||||||
Logout = '/auth/logout',
|
Logout = '/auth/logout',
|
||||||
WaitingList = '/waitinglist',
|
WaitingList = '/waitinglist',
|
||||||
WaitingListAdd = '/waitinglist/add',
|
WaitingListAdd = '/waitinglist/add',
|
||||||
|
FutureReservations = '/user/future',
|
||||||
}
|
}
|
||||||
|
|
||||||
enum SessionAction {
|
enum SessionAction {
|
||||||
|
|
@ -340,6 +341,17 @@ export class BaanReserverenService {
|
||||||
return lastDayOfMonth.add(daysToAdd, 'day')
|
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) {
|
private async navigateToReservationPrompt(courtSlot: CourtSlot, date: Dayjs) {
|
||||||
this.loggerService.debug('Navigating to reservation prompt', {
|
this.loggerService.debug('Navigating to reservation prompt', {
|
||||||
courtSlot,
|
courtSlot,
|
||||||
|
|
@ -414,6 +426,22 @@ export class BaanReserverenService {
|
||||||
await this.page.waitForNetworkIdle()
|
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[]> {
|
private async recordWaitingListEntries(): Promise<number[]> {
|
||||||
const waitingListEntriesElements = await this.page.$$(
|
const waitingListEntriesElements = await this.page.$$(
|
||||||
'#content tbody tr td:nth-child(1)',
|
'#content tbody tr td:nth-child(1)',
|
||||||
|
|
@ -479,11 +507,11 @@ export class BaanReserverenService {
|
||||||
|
|
||||||
private async sortCourtsByRank(
|
private async sortCourtsByRank(
|
||||||
freeCourts: ElementHandle<Element>[],
|
freeCourts: ElementHandle<Element>[],
|
||||||
): Promise<ElementHandle | null> {
|
): Promise<{ handle: ElementHandle; courtSlot: CourtSlot } | null> {
|
||||||
if (freeCourts.length === 0) return null
|
if (freeCourts.length === 0) return null
|
||||||
const freeCourtsWithJson = await Promise.all(
|
const freeCourtsWithJson = await Promise.all(
|
||||||
freeCourts.map(async (fc) => ({
|
freeCourts.map(async (fc) => ({
|
||||||
elementHAndle: fc,
|
elementHandle: fc,
|
||||||
jsonValue: await fc.jsonValue(),
|
jsonValue: await fc.jsonValue(),
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
|
|
@ -492,36 +520,46 @@ export class BaanReserverenService {
|
||||||
(CourtRank[a.jsonValue.slot as CourtSlot] ?? 99) -
|
(CourtRank[a.jsonValue.slot as CourtSlot] ?? 99) -
|
||||||
(CourtRank[b.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) {
|
private async selectAvailableTime(reservation: Reservation) {
|
||||||
this.loggerService.debug('Selecting available time', {
|
this.loggerService.debug('Selecting available time', {
|
||||||
reservation: instanceToPlain(reservation),
|
reservation: instanceToPlain(reservation),
|
||||||
})
|
})
|
||||||
let freeCourt: ElementHandle | null | undefined
|
let freeCourtHandle: ElementHandle | null | undefined
|
||||||
|
let freeCourtSlot: CourtSlot | undefined
|
||||||
|
let time: string | undefined
|
||||||
let i = 0
|
let i = 0
|
||||||
const possibleDates = reservation.createPossibleDates()
|
const possibleDates = reservation.createPossibleDates()
|
||||||
this.loggerService.debug('Possible dates', { possibleDates })
|
this.loggerService.debug('Possible dates', { possibleDates })
|
||||||
while (i < possibleDates.length && !freeCourt) {
|
while (i < possibleDates.length && !freeCourtHandle) {
|
||||||
const possibleDate = possibleDates[i]
|
const possibleDate = possibleDates[i]
|
||||||
const timeString = possibleDate.format('HH:mm')
|
const timeString = possibleDate.format('HH:mm')
|
||||||
const selector =
|
const selector =
|
||||||
`tr[data-time='${timeString}']` + `> td.free[rowspan='3'][type='free']`
|
`tr[data-time='${timeString}']` + `> td.free[rowspan='3'][type='free']`
|
||||||
const freeCourts = await this.page.$$(selector)
|
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++
|
i++
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!freeCourt) {
|
if (!freeCourtHandle || !freeCourtSlot || !time) {
|
||||||
throw new NoCourtAvailableError('No court available for reservation')
|
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)
|
throw new RunnerCourtSelectionError(e)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
return { time, court: CourtSlotToNumber[freeCourtSlot] }
|
||||||
}
|
}
|
||||||
|
|
||||||
private async selectOwner(id: string) {
|
private async selectOwner(id: string) {
|
||||||
|
|
@ -596,6 +634,23 @@ export class BaanReserverenService {
|
||||||
throw new RunnerReservationConfirmSubmitError(e)
|
throw new RunnerReservationConfirmSubmitError(e)
|
||||||
})
|
})
|
||||||
await this.page.waitForNetworkIdle()
|
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) {
|
private async inputWaitingListDetails(reservation: Reservation) {
|
||||||
|
|
@ -702,17 +757,22 @@ export class BaanReserverenService {
|
||||||
try {
|
try {
|
||||||
await this.init()
|
await this.init()
|
||||||
await this.navigateToDay(reservation.dateRangeStart)
|
await this.navigateToDay(reservation.dateRangeStart)
|
||||||
await this.selectAvailableTime(reservation)
|
const { time, court } = await this.selectAvailableTime(reservation)
|
||||||
await this.selectOwner(reservation.ownerId)
|
await this.selectOwner(reservation.ownerId)
|
||||||
await this.selectOpponents(reservation.opponents)
|
await this.selectOpponents(reservation.opponents)
|
||||||
await this.confirmReservation()
|
const id = await this.confirmReservation()
|
||||||
|
return { id, startAt: time, court }
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
if (!timeSensitive) await this.handleError()
|
if (!timeSensitive) await this.handleError()
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async performSpeedyReservation(reservation: Reservation) {
|
public async performSpeedyReservation(reservation: Reservation): Promise<{
|
||||||
|
id: string
|
||||||
|
startAt: string
|
||||||
|
court: number
|
||||||
|
}> {
|
||||||
await this.init()
|
await this.init()
|
||||||
const courtSlots = this.getCourtSlotsForDate(reservation.dateRangeStart)
|
const courtSlots = this.getCourtSlotsForDate(reservation.dateRangeStart)
|
||||||
|
|
||||||
|
|
@ -721,7 +781,6 @@ export class BaanReserverenService {
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const courtSlot of courtSlots) {
|
for (const courtSlot of courtSlots) {
|
||||||
let errorReserving = false
|
|
||||||
try {
|
try {
|
||||||
await this.navigateToReservationPrompt(
|
await this.navigateToReservationPrompt(
|
||||||
courtSlot,
|
courtSlot,
|
||||||
|
|
@ -729,24 +788,51 @@ export class BaanReserverenService {
|
||||||
)
|
)
|
||||||
await this.selectOwner(reservation.ownerId)
|
await this.selectOwner(reservation.ownerId)
|
||||||
await this.selectOpponents(reservation.opponents, true)
|
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) {
|
} catch (error: unknown) {
|
||||||
if (
|
if (
|
||||||
error instanceof CourtNotAvailableError ||
|
error instanceof CourtNotAvailableError ||
|
||||||
error instanceof RunnerReservationConfirmSubmitError
|
error instanceof RunnerReservationConfirmSubmitError
|
||||||
) {
|
) {
|
||||||
this.loggerService.warn('Court taken, retrying', { courtSlot })
|
this.loggerService.warn('Court taken, retrying', { courtSlot })
|
||||||
errorReserving = true
|
|
||||||
} else {
|
} else {
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!errorReserving) return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new NoCourtAvailableError('Could not reserve court')
|
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(
|
public async addReservationToWaitingList(
|
||||||
reservation: Reservation,
|
reservation: Reservation,
|
||||||
timeSensitive = true,
|
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 {
|
export class RunnerWaitingListNavigationError extends RunnerError {
|
||||||
constructor(error: Error) {
|
constructor(error: Error) {
|
||||||
super(error, 'RunnerWaitingListNavigationError')
|
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 {
|
export class RunnerOwnerSearchNetworkError extends RunnerError {
|
||||||
constructor(error: Error) {
|
constructor(error: Error) {
|
||||||
super(error, 'RunnerOwnerSearchNetworkError')
|
super(error, 'RunnerOwnerSearchNetworkError')
|
||||||
|
|
@ -1000,12 +1099,19 @@ export class RunnerReservationConfirmButtonError extends RunnerError {
|
||||||
super(error, 'RunnerReservationConfirmButtonError')
|
super(error, 'RunnerReservationConfirmButtonError')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class RunnerReservationConfirmSubmitError extends RunnerError {
|
export class RunnerReservationConfirmSubmitError extends RunnerError {
|
||||||
constructor(error: Error) {
|
constructor(error: Error) {
|
||||||
super(error, 'RunnerReservationConfirmSubmitError')
|
super(error, 'RunnerReservationConfirmSubmitError')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class RunnerReservationCancelButtonError extends RunnerError {
|
||||||
|
constructor(error: Error) {
|
||||||
|
super(error, 'RunnerReservationConfirmButtonError')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class SessionStartError extends Error {
|
export class SessionStartError extends Error {
|
||||||
constructor(message: string) {
|
constructor(message: string) {
|
||||||
super(message)
|
super(message)
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue