diff --git a/database/migrations/1712672829091-AddingMultipleOpponents.ts b/database/migrations/1712672829091-AddingMultipleOpponents.ts new file mode 100644 index 0000000..0b4ba4e --- /dev/null +++ b/database/migrations/1712672829091-AddingMultipleOpponents.ts @@ -0,0 +1,53 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class AddingMultipleOpponents1712672829091 + implements MigrationInterface +{ + name = 'AddingMultipleOpponents1712672829091' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "temporary_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 "temporary_reservations"("id", "dateRangeStart", "dateRangeEnd", "waitListed", "waitingListId", "ownerId", "opponents") SELECT "id", "dateRangeStart", "dateRangeEnd", "waitListed", "waitingListId", "ownerId", json_array(json_object('id', "opponentId", 'name', "opponentName")) as "opponents" FROM "reservations"`, + ) + await queryRunner.query(`DROP TABLE "reservations"`) + await queryRunner.query( + `ALTER TABLE "temporary_reservations" RENAME TO "reservations"`, + ) + await queryRunner.query( + `CREATE TABLE "temporary_recurring_reservations" ("id" varchar PRIMARY KEY NOT NULL, "dayOfWeek" integer NOT NULL, "timeStart" varchar(6) NOT NULL, "timeEnd" varchar(6) NOT NULL, "ownerId" varchar(32) NOT NULL, "opponents" json NOT NULL)`, + ) + await queryRunner.query( + `INSERT INTO "temporary_recurring_reservations"("id", "dayOfWeek", "timeStart", "timeEnd", "ownerId", "opponents") SELECT "id", "dayOfWeek", "timeStart", "timeEnd", "ownerId", json_array(json_object('id', "opponentId", 'name', "opponentName")) as "opponents" FROM "recurring_reservations"`, + ) + await queryRunner.query(`DROP TABLE "recurring_reservations"`) + await queryRunner.query( + `ALTER TABLE "temporary_recurring_reservations" RENAME TO "recurring_reservations"`, + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "recurring_reservations" RENAME TO "temporary_recurring_reservations"`, + ) + await queryRunner.query( + `CREATE TABLE "recurring_reservations" ("id" varchar PRIMARY KEY NOT NULL, "dayOfWeek" integer NOT NULL, "timeStart" varchar(6) NOT NULL, "timeEnd" varchar(6) NOT NULL, "opponentId" varchar(32) NOT NULL, "opponentName" varchar(255) NOT NULL, "ownerId" varchar(32) NOT NULL)`, + ) + await queryRunner.query( + `INSERT INTO "recurring_reservations"("id", "dayOfWeek", "timeStart", "timeEnd", "ownerId", "opponentId", "opponentName") SELECT "id", "dayOfWeek", "timeStart", "timeEnd", "ownerId", "opponents"->0->>'id' as "opponentId", "opponents"->0->>'name' as "opponentName" FROM "temporary_recurring_reservations"`, + ) + await queryRunner.query(`DROP TABLE "temporary_recurring_reservations"`) + 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, "opponentId" varchar(32) NOT NULL, "opponentName" varchar(255) NOT NULL, "waitListed" boolean NOT NULL DEFAULT (0), "waitingListId" integer, "ownerId" varchar(32) NOT NULL)`, + ) + await queryRunner.query( + `INSERT INTO "reservations"("id", "dateRangeStart", "dateRangeEnd", "waitListed", "waitingListId", "ownerId", "opponentId", "opponentName") SELECT "id", "dateRangeStart", "dateRangeEnd", "waitListed", "waitingListId", "ownerId", "opponents"->0->>'id' as "opponentId", "opponents"->0->>'name' as "opponentName" FROM "temporary_reservations"`, + ) + await queryRunner.query(`DROP TABLE "temporary_reservations"`) + } +} diff --git a/src/recurringReservations/controller.ts b/src/recurringReservations/controller.ts index fd05268..1aa1368 100644 --- a/src/recurringReservations/controller.ts +++ b/src/recurringReservations/controller.ts @@ -10,11 +10,26 @@ import { Query, UseInterceptors, } from '@nestjs/common' -import { IsEnum, IsOptional, IsString, Matches } from 'class-validator' +import { + IsArray, + IsEnum, + IsOptional, + IsString, + Matches, + ValidateNested, +} from 'class-validator' import { DayOfWeek } from './entity' import { RecurringReservationsService } from './service' +export class CreateRecurringReservationOpponent { + @IsString() + id: string + + @IsString() + name: string +} + export class CreateRecurringReservationRequest { @IsString() ownerId: string @@ -32,12 +47,9 @@ export class CreateRecurringReservationRequest { timeEnd?: string @IsOptional() - @IsString() - opponentId?: string - - @IsOptional() - @IsString() - opponentName?: string + @IsArray() + @ValidateNested() + opponents?: CreateRecurringReservationOpponent[] } @Controller('recurring-reservations') diff --git a/src/recurringReservations/entity.ts b/src/recurringReservations/entity.ts index be97bb1..32189c7 100644 --- a/src/recurringReservations/entity.ts +++ b/src/recurringReservations/entity.ts @@ -3,7 +3,7 @@ import { IsEnum } from 'class-validator' import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm' import dayjs from '../common/dayjs' -import { Reservation } from '../reservations/entity' +import { Opponent, Reservation } from '../reservations/entity' export enum DayOfWeek { Monday = 1, @@ -33,11 +33,8 @@ export class RecurringReservation { @Column('varchar', { length: 6, nullable: false }) timeEnd: string - @Column('varchar', { length: 32, nullable: false }) - opponentId: string - - @Column('varchar', { length: 255, nullable: false }) - opponentName: string + @Column('json', { nullable: false }) + opponents: Opponent[] constructor(partial: Partial) { Object.assign(this, partial) @@ -63,8 +60,7 @@ export class RecurringReservation { ownerId: this.ownerId, dateRangeStart: dateRangeStart, dateRangeEnd: dateRangeEnd, - opponentId: this.opponentId, - opponentName: this.opponentName, + opponents: this.opponents, }) return reservation } diff --git a/src/recurringReservations/service.ts b/src/recurringReservations/service.ts index 41f961a..ec48524 100644 --- a/src/recurringReservations/service.ts +++ b/src/recurringReservations/service.ts @@ -35,23 +35,20 @@ export class RecurringReservationsService { dayOfWeek, timeStart, timeEnd, - opponentId = '-1', - opponentName = 'Gast', + opponents, }: { ownerId: string dayOfWeek: DayOfWeek timeStart: string timeEnd?: string - opponentId?: string - opponentName?: string + opponents?: { id: string; name: string }[] }) { const recRes = this.recurringReservationsRepository.create({ ownerId, dayOfWeek, timeStart, timeEnd: timeEnd ?? timeStart, - opponentId, - opponentName, + opponents: opponents ?? [{ id: '-1', name: 'Gast' }], }) return await this.recurringReservationsRepository.save(recRes) } diff --git a/src/reservations/controller.ts b/src/reservations/controller.ts index 0121d6d..a04ab32 100644 --- a/src/reservations/controller.ts +++ b/src/reservations/controller.ts @@ -13,7 +13,13 @@ import { UseInterceptors, } from '@nestjs/common' import { Transform, TransformationType } from 'class-transformer' -import { IsBoolean, IsOptional, IsString } from 'class-validator' +import { + IsArray, + IsBoolean, + IsOptional, + IsString, + ValidateNested, +} from 'class-validator' import { Dayjs } from 'dayjs' import dayjs from '../common/dayjs' @@ -32,6 +38,14 @@ export class GetReservationsQueryParams { readonly schedulable?: boolean } +export class CreateReservationOpponent { + @IsString() + id: string + + @IsString() + name: string +} + export class CreateReservationRequest { @IsString() ownerId: string @@ -62,12 +76,9 @@ export class CreateReservationRequest { dateRangeEnd?: Dayjs @IsOptional() - @IsString() - opponentId?: string - - @IsOptional() - @IsString() - opponentName?: string + @IsArray() + @ValidateNested() + opponents?: CreateReservationOpponent[] } @Controller('reservations') diff --git a/src/reservations/entity.ts b/src/reservations/entity.ts index 9bc290a..11ac9e7 100644 --- a/src/reservations/entity.ts +++ b/src/reservations/entity.ts @@ -5,6 +5,11 @@ import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm' import dayjs from '../common/dayjs' +export interface Opponent { + id: string + name: string +} + @Entity({ name: 'reservations' }) export class Reservation { @PrimaryGeneratedColumn('uuid') @@ -53,11 +58,8 @@ export class Reservation { }) dateRangeEnd: Dayjs - @Column('varchar', { length: 32, nullable: false }) - opponentId: string - - @Column('varchar', { length: 255, nullable: false }) - opponentName: string + @Column('json', { nullable: false }) + opponents: Opponent[] @Column('boolean', { default: false }) waitListed: boolean diff --git a/src/reservations/service.ts b/src/reservations/service.ts index 39e3914..fef2d9e 100644 --- a/src/reservations/service.ts +++ b/src/reservations/service.ts @@ -73,21 +73,18 @@ export class ReservationsService { ownerId, dateRangeStart, dateRangeEnd, - opponentId = '-1', - opponentName = 'Gast', + opponents, }: { ownerId: string dateRangeStart: Dayjs dateRangeEnd?: Dayjs - opponentId?: string - opponentName?: string + opponents?: { id: string; name: string }[] }) { const res = this.reservationsRepository.create({ ownerId, dateRangeStart, dateRangeEnd: dateRangeEnd ?? dateRangeStart, - opponentId, - opponentName, + opponents: opponents ?? [{ id: '-1', name: 'Gast' }], }) return await this.reservationsRepository.save(res) } diff --git a/src/runner/baanreserveren/service.ts b/src/runner/baanreserveren/service.ts index 6f93569..2dd04d2 100644 --- a/src/runner/baanreserveren/service.ts +++ b/src/runner/baanreserveren/service.ts @@ -9,7 +9,7 @@ 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 { Reservation } from '../../reservations/entity' +import { Opponent, Reservation } from '../../reservations/entity' import { EmptyPage } from '../pages/empty' const BAAN_RESERVEREN_ROOT_URL = 'https://squashcity.baanreserveren.nl' @@ -417,14 +417,37 @@ export class BaanReserverenService { }) } - private async selectOpponent(id: string, name: string) { + private async selectOpponents(opponents: Opponent[]) { + try { + for (let idx = 0; idx < opponents.length; idx += 1) { + const { id, name } = opponents[idx] + await this.selectOpponent(id, name, idx) + } + } catch (error: unknown) { + if (error instanceof RunnerOwnerSearchSelectionError) { + this.loggerService.warn( + 'Improper opponents selected, falling back to guest opponent', + ) + return await this.selectOpponent('-1', 'Gast', 0) + } + throw error + } + } + + private async selectOpponent(id: string, name: string, index: number) { + if (index < 0 || index > 4) { + throw new RunnerOwnerSearchSelectionError( + new Error('Invalid opponent index'), + ) + } + const resolvedIndex = index + 2 // players[1] is the owner; players[2,3,4] are the opponents this.loggerService.debug('Selecting opponent', { id, name }) - const player2Search = await this.page - .waitForSelector('input:has(~ select[name="players[2]"])') + const playerSearch = await this.page + .waitForSelector(`input:has(~ select[name="players[${resolvedIndex}]"])`) .catch((e: Error) => { throw new RunnerOpponentSearchError(e) }) - await player2Search + await playerSearch ?.type(name, { delay: this.getTypingDelay() }) .catch((e: Error) => { throw new RunnerOpponentSearchInputError(e) @@ -433,7 +456,7 @@ export class BaanReserverenService { throw new RunnerOpponentSearchNetworkError(e) }) await this.page - .$('select.br-user-select[name="players[2]"]') + .$(`select.br-user-select[name="players[${resolvedIndex}]"]`) .then((d) => d?.select(id)) .catch((e: Error) => { throw new RunnerOpponentSearchSelectionError(e) @@ -517,10 +540,7 @@ export class BaanReserverenService { await this.monitorCourtReservations() await this.selectAvailableTime(reservation) await this.selectOwner(reservation.ownerId) - await this.selectOpponent( - reservation.opponentId, - reservation.opponentName, - ) + await this.selectOpponents(reservation.opponents) await this.confirmReservation() } catch (error: unknown) { await this.handleError()