Compare commits

..

12 commits

Author SHA1 Message Date
Collin Duncan
01a6013093
Fixing incorrect inequality when fetching schedulable reservations
Some checks are pending
Push to main / test (push) Waiting to run
Push to main / build-image (push) Blocked by required conditions
2025-04-22 09:23:27 +02:00
Collin Duncan
94ddf55639
Making end date inclusive for schedulable reservations 2025-04-15 08:54:32 +02:00
Collin Duncan
afb733608d
Only get pending reservations when looking for schedulable reservations 2025-04-09 12:53:04 +02:00
Collin Duncan
82633908a4
Adding some unit tests for getting court slots based on date 2025-04-03 16:44:11 +02:00
Collin Duncan
5b109226e6
Changing the DayjsTransformer to transform from plain date string to dayjs in correct timezone 2025-04-03 16:15:40 +02:00
Collin Duncan
82ff838359
Correcting tests regarding court prioritization 2025-04-02 14:21:05 +02:00
Collin Duncan
f0d4207880
Changing waiting list query to only look at dateRangeStart 2025-04-02 13:18:10 +02:00
Collin Duncan
b6e2ea4c5a
Changing court priorities 2025-03-11 10:48:47 +01:00
Collin Duncan
25efd61c99
Adding status to query params of reservations controller endpoints 2025-03-11 10:46:15 +01:00
Collin Duncan
671084dc7b
Adding status to reservations and stopping deletion on booking 2025-03-11 10:39:12 +01:00
Collin Duncan
25fb2c9bdc
Fixing monitors to have proper createdAt 2025-02-12 14:18:26 +01:00
Collin Duncan
9e9b0194da
Moving some dayjs stuff to common folder to be able to re-use it 2025-02-12 13:49:14 +01:00
13 changed files with 230 additions and 114 deletions

43
.github/workflows/main.yml vendored Normal file
View file

@ -0,0 +1,43 @@
name: Push to main
on:
push:
branches:
- main
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: lts/hydrogen
- run: npm ci
- run: npm run test:unit
build-image:
needs:
- test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ secrets.GHCR_USERNAME }}
password: ${{ secrets.GHCR_PASSWORD }}
- name: Declare GIT_COMMIT
shell: bash
run: |
echo "GIT_COMMIT=$(git rev-parse --short "$GITHUB_SHA")" >> "$GITHUB_ENV"
- name: Build docker image
uses: docker/build-push-action@v5
with:
context: .
file: ./docker/server/Dockerfile
push: true
tags: ghcr.io/cgduncan7/autobaan:latest
cache-from: type=gha
cache-to: type=gha,mode=max
build-args: GIT_COMMIT=${{ env.GIT_COMMIT }}

View file

@ -1,10 +0,0 @@
when:
- branch: main
event: push
steps:
- name: test
image: docker.io/node:hydrogen-slim
commands:
- npm ci
- npm run test:unit

View file

@ -0,0 +1,31 @@
import { MigrationInterface, QueryRunner } from 'typeorm'
export class ReservationStatuses1739366336570 implements MigrationInterface {
name = 'ReservationStatuses1739366336570'
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)`,
)
await queryRunner.query(
`INSERT INTO "temporary_reservations"("id", "dateRangeStart", "dateRangeEnd", "status", "waitingListId", "ownerId", "opponents") SELECT "id", "dateRangeStart", "dateRangeEnd", CASE "waitListed" WHEN true THEN "on_waiting_list" ELSE "pending" END, "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, "waitListed" boolean NOT NULL DEFAULT (0), "waitingListId" integer, "ownerId" varchar(32) NOT NULL, "opponents" json NOT NULL)`,
)
await queryRunner.query(
`INSERT INTO "reservations"("id", "dateRangeStart", "dateRangeEnd", "waitListed", "waitingListId", "ownerId", "opponents") SELECT "id", "dateRangeStart", "dateRangeEnd", CASE "status" WHEN "on_waiting_list" THEN true ELSE false, "waitingListId", "ownerId", "opponents" FROM "temporary_reservations"`,
)
await queryRunner.query(`DROP TABLE "temporary_reservations"`)
}
}

View file

@ -1,5 +1,6 @@
import 'dayjs/locale/nl'
import { TransformationType, TransformFnParams } from 'class-transformer'
import * as dayjs from 'dayjs'
import * as customParseFormat from 'dayjs/plugin/customParseFormat'
import * as isSameOrBefore from 'dayjs/plugin/isSameOrBefore'
@ -41,4 +42,20 @@ const dayjsTz = (
return dayjs(date, format).tz('Europe/Amsterdam')
}
export const DayjsTransformer = ({ value, type }: TransformFnParams) => {
switch (type) {
case TransformationType.PLAIN_TO_CLASS:
return dayjsTz(value)
case TransformationType.CLASS_TO_PLAIN:
return value.format()
default:
return value
}
}
export const DayjsColumnTransformer = {
to: (value: dayjs.Dayjs) => value.format(),
from: (value: Date) => dayjsTz(value),
}
export default dayjsTz

View file

@ -19,7 +19,6 @@ export class Monitor {
@Column('datetime', {
nullable: false,
default: dayjs(),
transformer: {
to: (value?: Dayjs) => (value ?? dayjs()).format(),
from: (value: Date) => dayjs(value),

View file

@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common'
import { InjectRepository } from '@nestjs/typeorm'
import { Repository } from 'typeorm'
import dayjsTz from '../common/dayjs'
import { Monitor, MonitorType } from './entity'
@Injectable()
@ -13,7 +14,7 @@ export class MonitorsService {
async performMonitor(type: MonitorType, data: string) {
await this.monitorsRepository.save(
this.monitorsRepository.create({ type, data }),
this.monitorsRepository.create({ type, data, createdAt: dayjsTz() }),
)
}
}

View file

@ -121,14 +121,14 @@ export class NtfyProvider implements OnApplicationBootstrap {
)
}
async sendReservationWaitlistedNotification(
async sendReservationOnWaitingListNotification(
reservationId: string,
startTime: Dayjs,
endTime: Dayjs,
) {
await this.publishQueue.add(
...NtfyProvider.defaultJob({
title: 'Reservation waitlisted',
title: 'Reservation added to waiting list',
message: `${reservationId} - ${startTime.format()} to ${endTime.format()}`,
tags: [MessageTags.badminton, MessageTags.hourglass],
}),
@ -138,7 +138,7 @@ export class NtfyProvider implements OnApplicationBootstrap {
async sendWaitListEmailReceivedNotification(subject: string) {
await this.publishQueue.add(
...NtfyProvider.defaultJob({
title: 'Wait listed reservation available',
title: 'Reservation on waiting list has become available',
message: `${subject}`,
tags: [MessageTags.badminton, MessageTags.hourglass],
}),

View file

@ -12,73 +12,61 @@ import {
Query,
UseInterceptors,
} from '@nestjs/common'
import { Transform, TransformationType } from 'class-transformer'
import { Transform } from 'class-transformer'
import {
IsArray,
IsBoolean,
IsEnum,
IsOptional,
IsString,
ValidateNested,
} from 'class-validator'
import { Dayjs } from 'dayjs'
import dayjs from '../common/dayjs'
import { DayjsTransformer } from '../common/dayjs'
import { LoggerService } from '../logger/service.logger'
import { RESERVATIONS_QUEUE_NAME, ReservationsQueue } from './config'
import { ReservationStatus } from './entity'
import { ReservationsService } from './service'
export class GetReservationsQueryParams {
@IsOptional()
@Transform(() => Dayjs)
date?: Dayjs
readonly date?: Dayjs
@IsOptional()
@IsBoolean()
@Transform(({ value }) => value === 'true')
readonly schedulable?: boolean
@IsOptional()
@IsEnum(ReservationStatus)
readonly status?: ReservationStatus
}
export class CreateReservationOpponent {
@IsString()
id: string
readonly id: string
@IsString()
name: string
readonly name: string
}
export class CreateReservationRequest {
@IsString()
ownerId: string
readonly ownerId: string
@Transform(({ value, type }) => {
switch (type) {
case TransformationType.PLAIN_TO_CLASS:
return dayjs(value)
case TransformationType.CLASS_TO_PLAIN:
return value.format()
default:
return value
}
})
dateRangeStart: Dayjs
@Transform(DayjsTransformer)
readonly dateRangeStart: Dayjs
@IsOptional()
@Transform(({ value, type }) => {
switch (type) {
case TransformationType.PLAIN_TO_CLASS:
return dayjs(value)
case TransformationType.CLASS_TO_PLAIN:
return value.format()
default:
return value
}
})
dateRangeEnd?: Dayjs
@Transform(DayjsTransformer)
readonly dateRangeEnd?: Dayjs
@IsOptional()
@IsArray()
@ValidateNested()
opponents?: CreateReservationOpponent[]
readonly opponents?: CreateReservationOpponent[]
}
@Controller('reservations')
@ -97,14 +85,14 @@ export class ReservationsController {
@Get()
getReservations(@Query() params: GetReservationsQueryParams) {
const { schedulable, date } = params
const { schedulable, date, status } = params
if (schedulable) {
return this.reservationsService.getSchedulable()
}
if (date) {
return this.reservationsService.getByDate(date)
return this.reservationsService.getByDate(date, status)
}
return this.reservationsService.getAll()
return this.reservationsService.getAll(status)
}
@Get(':id')

View file

@ -1,15 +1,24 @@
import { Exclude, Transform, Type } from 'class-transformer'
import { TransformationType } from 'class-transformer'
import { IsEnum } from 'class-validator'
import { Dayjs } from 'dayjs'
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'
import dayjs from '../common/dayjs'
import dayjs, {
DayjsColumnTransformer,
DayjsTransformer,
} from '../common/dayjs'
export interface Opponent {
id: string
name: string
}
export enum ReservationStatus {
Pending = 'pending',
OnWaitingList = 'on_waiting_list',
Booked = 'booked',
}
@Entity({ name: 'reservations' })
export class Reservation {
@PrimaryGeneratedColumn('uuid')
@ -20,49 +29,30 @@ export class Reservation {
@Column('datetime', {
nullable: false,
transformer: {
to: (value: Dayjs) => value.format(),
from: (value: Date) => dayjs(value),
},
transformer: DayjsColumnTransformer,
})
@Type(() => Dayjs)
@Transform(({ value, type }) => {
switch (type) {
case TransformationType.PLAIN_TO_CLASS:
return dayjs(value)
case TransformationType.CLASS_TO_PLAIN:
return value.format()
default:
return value
}
})
@Transform(DayjsTransformer)
dateRangeStart: Dayjs
@Column('datetime', {
nullable: false,
transformer: {
to: (value: Dayjs) => value.format(),
from: (value: Date) => dayjs(value),
},
transformer: DayjsColumnTransformer,
})
@Type(() => Dayjs)
@Transform(({ value, type }) => {
switch (type) {
case TransformationType.PLAIN_TO_CLASS:
return dayjs(value)
case TransformationType.CLASS_TO_PLAIN:
return value.format()
default:
return value
}
})
@Transform(DayjsTransformer)
dateRangeEnd: Dayjs
@Column('json', { nullable: false })
opponents: Opponent[]
@Column('boolean', { default: false })
waitListed: boolean
@Column('varchar', {
length: 32,
nullable: false,
default: ReservationStatus.Pending,
})
@IsEnum(ReservationStatus)
status: ReservationStatus
@Column('int', { nullable: true })
waitingListId: number

View file

@ -6,7 +6,7 @@ import { Repository } from 'typeorm'
import dayjs from '../common/dayjs'
import { LoggerService } from '../logger/service.logger'
import { BaanReserverenService } from '../runner/baanreserveren/service'
import { Reservation } from './entity'
import { Reservation, ReservationStatus } from './entity'
@Injectable()
export class ReservationsService {
@ -21,19 +21,24 @@ export class ReservationsService {
private readonly loggerService: LoggerService,
) {}
async getAll() {
return await this.reservationsRepository.find()
async getAll(status?: ReservationStatus) {
return await this.reservationsRepository.find({ where: { status } })
}
async getById(id: string) {
return await this.reservationsRepository.findOneBy({ id })
}
async getByDate(date = dayjs()) {
return await this.reservationsRepository
async getByDate(date = dayjs(), status?: ReservationStatus) {
let qb = this.reservationsRepository
.createQueryBuilder()
.where(`DATE(dateRangeStart) = DATE(:date)`, { date: date.toISOString() })
.getMany()
if (status != null) {
qb = qb.andWhere(`status = :status`, { status })
}
return await qb.orderBy('dateRangeStart', 'ASC').getMany()
}
/**
@ -44,13 +49,15 @@ export class ReservationsService {
const query = this.reservationsRepository
.createQueryBuilder()
.where(
`DATE(dateRangeStart) BETWEEN DATE(:startDate) AND DATE(:endDate)`,
`(DATE(dateRangeStart) >= DATE(:startDate) OR DATE(dateRangeStart) <= DATE(:endDate))`,
{
startDate: dayjs().add(1, 'days').toISOString(),
endDate: dayjs().add(7, 'days').toISOString(),
},
)
.andWhere(`waitListed = false`)
.andWhere('status = :status', {
statuses: ReservationStatus.Pending,
})
.orderBy('dateRangeStart', 'ASC')
return await query.getMany()
@ -59,13 +66,10 @@ export class ReservationsService {
async getByDateOnWaitingList(date = dayjs()) {
return await this.reservationsRepository
.createQueryBuilder()
.where(`DATE(dateRangeStart) <= DATE(:date)`, {
.where(`DATE(dateRangeStart) = DATE(:date)`, {
date: date.toISOString(),
})
.andWhere(`DATE(dateRangeEnd) >= DATE(:date)`, {
date: date.toISOString(),
})
.andWhere('waitListed = true')
.andWhere('status = :status', { status: ReservationStatus.OnWaitingList })
.getMany()
}

View file

@ -1,6 +1,5 @@
import { Process, Processor } from '@nestjs/bull'
import { Inject } from '@nestjs/common'
import { Job } from 'bull'
import { instanceToPlain, plainToInstance } from 'class-transformer'
import { LoggerService } from '../logger/service.logger'
@ -11,7 +10,7 @@ import {
} from '../runner/baanreserveren/service'
import { RESERVATIONS_QUEUE_NAME, ReservationsJob } from './config'
import { DAILY_RESERVATIONS_ATTEMPTS } from './cron'
import { Reservation } from './entity'
import { Reservation, ReservationStatus } from './entity'
import { ReservationsService } from './service'
@Processor(RESERVATIONS_QUEUE_NAME)
@ -63,10 +62,10 @@ export class ReservationsWorker {
}
if (
(shouldWaitlist || attemptsMade === DAILY_RESERVATIONS_ATTEMPTS) &&
!reservation.waitListed
reservation.status !== ReservationStatus.OnWaitingList
) {
this.loggerService.log('Adding reservation to waiting list')
await this.ntfyProvider.sendReservationWaitlistedNotification(
await this.ntfyProvider.sendReservationOnWaitingListNotification(
reservation.id,
reservation.dateRangeStart,
reservation.dateRangeEnd,
@ -89,7 +88,9 @@ export class ReservationsWorker {
} else {
await this.brService.performReservation(reservation, timeSensitive)
}
await this.reservationsService.deleteById(reservation.id)
await this.reservationsService.update(reservation.id, {
status: ReservationStatus.Booked,
})
} catch (error: unknown) {
await this.handleReservationErrors(
error as Error,
@ -105,12 +106,12 @@ export class ReservationsWorker {
timeSensitive = true,
) {
try {
const waitingListId = await this.brService.addReservationToWaitList(
const waitingListId = await this.brService.addReservationToWaitingList(
reservation,
timeSensitive,
)
await this.reservationsService.update(reservation.id, {
waitListed: true,
status: ReservationStatus.OnWaitingList,
waitingListId,
})
} catch (error: unknown) {

View file

@ -9,7 +9,11 @@ import dayjs from '../../common/dayjs'
import { LoggerService } from '../../logger/service.logger'
import { MONITORING_QUEUE_NAME, MonitoringQueue } from '../../monitoring/config'
import { MonitorType } from '../../monitoring/entity'
import { Opponent, Reservation } from '../../reservations/entity'
import {
Opponent,
Reservation,
ReservationStatus,
} from '../../reservations/entity'
import { EmptyPage } from '../pages/empty'
export const BAAN_RESERVEREN_ROOT_URL = 'https://squashcity.baanreserveren.nl'
@ -67,12 +71,12 @@ const CourtSlotToNumber: Record<CourtSlot, number> = {
// Lower is better
const CourtRank: Record<CourtSlot, number> = {
[CourtSlot.One]: 2,
[CourtSlot.Two]: 1, // team at squash city has this pre-booked at 19.15 on Tuesday :sad:
[CourtSlot.Three]: 1, // team at squash city has this pre-booked at 19.15 on Tuesday :sad:
[CourtSlot.One]: 0,
[CourtSlot.Two]: 2, // team at squash city has this pre-booked at 19.15 on Tuesday :sad:
[CourtSlot.Three]: 2, // team at squash city has this pre-booked at 19.15 on Tuesday :sad:
[CourtSlot.Four]: 0,
[CourtSlot.Five]: 99, // shitty
[CourtSlot.Six]: 99, // shitty
[CourtSlot.Five]: 0,
[CourtSlot.Six]: 0,
[CourtSlot.Seven]: 0,
[CourtSlot.Eight]: 0,
[CourtSlot.Nine]: 0,
@ -82,13 +86,16 @@ const CourtRank: Record<CourtSlot, number> = {
[CourtSlot.Thirteen]: 9, // no one likes upstairs
} as const
enum StartTimeClass {
export enum StartTimeClass {
First = 'first',
Second = 'second',
Third = 'third',
}
const StartTimeClassCourtSlots: Record<StartTimeClass, readonly CourtSlot[]> = {
export const StartTimeClassCourtSlots: Record<
StartTimeClass,
readonly CourtSlot[]
> = {
[StartTimeClass.First]: [
CourtSlot.One,
CourtSlot.Two,
@ -676,7 +683,7 @@ export class BaanReserverenService {
return courtStatuses
}
private getCourtSlotsForDate(date: Dayjs) {
public getCourtSlotsForDate(date: Dayjs) {
const time = date.format('HH:mm')
for (const [timeClass, times] of Object.entries(StartTimeClassStartTimes)) {
if (times.includes(time)) {
@ -741,7 +748,7 @@ export class BaanReserverenService {
throw new NoCourtAvailableError('Could not reserve court')
}
public async addReservationToWaitList(
public async addReservationToWaitingList(
reservation: Reservation,
timeSensitive = true,
) {
@ -774,7 +781,11 @@ export class BaanReserverenService {
public async removeReservationFromWaitList(reservation: Reservation) {
try {
if (!reservation.waitListed || !reservation.waitingListId) return
if (
reservation.status !== ReservationStatus.OnWaitingList ||
!reservation.waitingListId
)
return
await this.init()
await this.navigateToWaitingList()
await this.deleteWaitingListEntryRowById(reservation.waitingListId)

View file

@ -10,6 +10,8 @@ import {
BAAN_RESERVEREN_ROOT_URL,
BaanReserverenService,
CourtSlot,
StartTimeClass,
StartTimeClassCourtSlots,
} from '../../../src/runner/baanreserveren/service'
import { EmptyPage } from '../../../src/runner/pages/empty'
@ -21,6 +23,7 @@ describe('baanreserveren.service', () => {
beforeAll(async () => {
pageGotoSpy = jest
.fn()
.mockClear()
.mockImplementation(() => Promise.resolve({ status: () => 200 }))
module = await Test.createTestingModule({
providers: [
@ -54,10 +57,12 @@ describe('baanreserveren.service', () => {
brService = module.get<BaanReserverenService>(BaanReserverenService)
})
beforeEach(() => pageGotoSpy.mockClear())
describe('performSpeedyReservation', () => {
it.each([
[18, 15, CourtSlot.Seven, CourtSlot.Six],
[18, 30, CourtSlot.Four, CourtSlot.Two],
[18, 15, CourtSlot.Six, CourtSlot.Seven],
[18, 30, CourtSlot.One, CourtSlot.Four],
[18, 45, CourtSlot.Twelve, CourtSlot.Thirteen],
])(
'should try highest ranked court first',
@ -89,8 +94,8 @@ describe('baanreserveren.service', () => {
)
it.each([
[18, 15, CourtSlot.Seven, CourtSlot.Eight],
[18, 30, CourtSlot.Four, CourtSlot.Two],
[18, 15, CourtSlot.Six, CourtSlot.Seven],
[18, 30, CourtSlot.One, CourtSlot.Four],
[18, 45, CourtSlot.Twelve, CourtSlot.Thirteen],
])(
'should try backup if first rank is taken',
@ -132,4 +137,40 @@ describe('baanreserveren.service', () => {
},
)
})
describe.only('getCourtSlotsForDate', () => {
it.each([
{
date: '2025-04-10T16:30:00.000Z',
expectedCourtSlots: StartTimeClassCourtSlots[StartTimeClass.First],
},
{
date: '2025-04-10T16:45:00.000Z',
expectedCourtSlots: StartTimeClassCourtSlots[StartTimeClass.Third],
},
{
date: '2025-04-10T17:00:00.000Z',
expectedCourtSlots: StartTimeClassCourtSlots[StartTimeClass.Second],
},
{
date: '2025-01-10T17:30:00.000Z',
expectedCourtSlots: StartTimeClassCourtSlots[StartTimeClass.First],
},
{
date: '2025-01-10T17:45:00.000Z',
expectedCourtSlots: StartTimeClassCourtSlots[StartTimeClass.Third],
},
{
date: '2025-01-10T18:00:00.000Z',
expectedCourtSlots: StartTimeClassCourtSlots[StartTimeClass.Second],
},
])(
'should get correct court slots for $date',
({ date, expectedCourtSlots }) => {
expect(brService.getCourtSlotsForDate(dayjs(date))).toEqual(
expect.arrayContaining(expectedCourtSlots as CourtSlot[]),
)
},
)
})
})