Compare commits

...

7 commits

Author SHA1 Message Date
Collin Duncan
c5002fc18a
Adding reservation cancellation
Some checks are pending
ci/woodpecker/pr/test Pipeline is pending
ci/woodpecker/push/test Pipeline was successful
2026-03-25 11:05:34 +01:00
468f22f3e9
Adding status badge from woodpecker to README
All checks were successful
ci/woodpecker/push/test Pipeline was successful
ci/woodpecker/push/deploy Pipeline was successful
2026-03-24 17:03:31 +01:00
63f77321c7
Removing unused volumes from test workflow
Some checks failed
ci/woodpecker/push/test Pipeline failed
ci/woodpecker/push/deploy unknown status
2026-03-24 17:02:33 +01:00
aea4376642
Adding back push event type to test workflow
Some checks are pending
ci/woodpecker/push/deploy Pipeline is pending
ci/woodpecker/push/test Pipeline is pending
2026-03-24 17:01:51 +01:00
dd4e759fb6
Correcting CD section of readme 2026-03-24 17:01:32 +01:00
f099ebd332
Correcting name of deploy step 2026-03-24 16:58:07 +01:00
b4ef4c8ca6
Improving CICD to not deploy on pull_request events 😄
Some checks failed
ci/woodpecker/manual/test Pipeline failed
ci/woodpecker/manual/deploy unknown status
2026-03-24 16:56:21 +01:00
8 changed files with 219 additions and 23 deletions

View file

@ -1,13 +1,19 @@
when:
- event: push
branch: main
- event: manual
branch: main
steps:
- name: test-and-deploy
- name: deploy
image: docker.io/node:hydrogen
volumes:
- /etc/ssh:/etc/ssh
commands:
- npm ci
- npm run test:unit
- npm run deploy
depends_on:
- test
runs_on: [success]

11
.woodpecker/test.yaml Normal file
View file

@ -0,0 +1,11 @@
when:
- event: pull_request
- event: push
- event: manual
steps:
- name: test
image: docker.io/node:hydrogen
commands:
- npm ci
- npm run test:unit

View file

@ -1,5 +1,7 @@
# autobaan
![alt text](https://woody.collinduncan.com/api/badges/1/status.svg)
Automatic court reservation!
## Setup
@ -33,4 +35,4 @@ npm start:dev
### CD
So I don't forget... I am using GHA to create a container image which I pull on my server using podman. This then restarts the container on my server with the latest image. The container is backed by a systemd service to restart and start on boot.
Using woodpecker to deploy this via pm2.

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)