Adding feature to add multiple people to a reservation

This commit is contained in:
Collin Duncan 2024-04-09 16:49:01 +02:00
parent ca3e374d19
commit 73b32402d3
No known key found for this signature in database
8 changed files with 137 additions and 49 deletions

View file

@ -0,0 +1,53 @@
import { MigrationInterface, QueryRunner } from 'typeorm'
export class AddingMultipleOpponents1712672829091
implements MigrationInterface
{
name = 'AddingMultipleOpponents1712672829091'
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, "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<void> {
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"`)
}
}

View file

@ -10,11 +10,26 @@ import {
Query, Query,
UseInterceptors, UseInterceptors,
} from '@nestjs/common' } 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 { DayOfWeek } from './entity'
import { RecurringReservationsService } from './service' import { RecurringReservationsService } from './service'
export class CreateRecurringReservationOpponent {
@IsString()
id: string
@IsString()
name: string
}
export class CreateRecurringReservationRequest { export class CreateRecurringReservationRequest {
@IsString() @IsString()
ownerId: string ownerId: string
@ -32,12 +47,9 @@ export class CreateRecurringReservationRequest {
timeEnd?: string timeEnd?: string
@IsOptional() @IsOptional()
@IsString() @IsArray()
opponentId?: string @ValidateNested()
opponents?: CreateRecurringReservationOpponent[]
@IsOptional()
@IsString()
opponentName?: string
} }
@Controller('recurring-reservations') @Controller('recurring-reservations')

View file

@ -3,7 +3,7 @@ import { IsEnum } from 'class-validator'
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm' import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'
import dayjs from '../common/dayjs' import dayjs from '../common/dayjs'
import { Reservation } from '../reservations/entity' import { Opponent, Reservation } from '../reservations/entity'
export enum DayOfWeek { export enum DayOfWeek {
Monday = 1, Monday = 1,
@ -33,11 +33,8 @@ export class RecurringReservation {
@Column('varchar', { length: 6, nullable: false }) @Column('varchar', { length: 6, nullable: false })
timeEnd: string timeEnd: string
@Column('varchar', { length: 32, nullable: false }) @Column('json', { nullable: false })
opponentId: string opponents: Opponent[]
@Column('varchar', { length: 255, nullable: false })
opponentName: string
constructor(partial: Partial<RecurringReservation>) { constructor(partial: Partial<RecurringReservation>) {
Object.assign(this, partial) Object.assign(this, partial)
@ -63,8 +60,7 @@ export class RecurringReservation {
ownerId: this.ownerId, ownerId: this.ownerId,
dateRangeStart: dateRangeStart, dateRangeStart: dateRangeStart,
dateRangeEnd: dateRangeEnd, dateRangeEnd: dateRangeEnd,
opponentId: this.opponentId, opponents: this.opponents,
opponentName: this.opponentName,
}) })
return reservation return reservation
} }

View file

@ -35,23 +35,20 @@ export class RecurringReservationsService {
dayOfWeek, dayOfWeek,
timeStart, timeStart,
timeEnd, timeEnd,
opponentId = '-1', opponents,
opponentName = 'Gast',
}: { }: {
ownerId: string ownerId: string
dayOfWeek: DayOfWeek dayOfWeek: DayOfWeek
timeStart: string timeStart: string
timeEnd?: string timeEnd?: string
opponentId?: string opponents?: { id: string; name: string }[]
opponentName?: string
}) { }) {
const recRes = this.recurringReservationsRepository.create({ const recRes = this.recurringReservationsRepository.create({
ownerId, ownerId,
dayOfWeek, dayOfWeek,
timeStart, timeStart,
timeEnd: timeEnd ?? timeStart, timeEnd: timeEnd ?? timeStart,
opponentId, opponents: opponents ?? [{ id: '-1', name: 'Gast' }],
opponentName,
}) })
return await this.recurringReservationsRepository.save(recRes) return await this.recurringReservationsRepository.save(recRes)
} }

View file

@ -13,7 +13,13 @@ import {
UseInterceptors, UseInterceptors,
} from '@nestjs/common' } from '@nestjs/common'
import { Transform, TransformationType } from 'class-transformer' 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 'dayjs'
import dayjs from '../common/dayjs' import dayjs from '../common/dayjs'
@ -32,6 +38,14 @@ export class GetReservationsQueryParams {
readonly schedulable?: boolean readonly schedulable?: boolean
} }
export class CreateReservationOpponent {
@IsString()
id: string
@IsString()
name: string
}
export class CreateReservationRequest { export class CreateReservationRequest {
@IsString() @IsString()
ownerId: string ownerId: string
@ -62,12 +76,9 @@ export class CreateReservationRequest {
dateRangeEnd?: Dayjs dateRangeEnd?: Dayjs
@IsOptional() @IsOptional()
@IsString() @IsArray()
opponentId?: string @ValidateNested()
opponents?: CreateReservationOpponent[]
@IsOptional()
@IsString()
opponentName?: string
} }
@Controller('reservations') @Controller('reservations')

View file

@ -5,6 +5,11 @@ import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'
import dayjs from '../common/dayjs' import dayjs from '../common/dayjs'
export interface Opponent {
id: string
name: string
}
@Entity({ name: 'reservations' }) @Entity({ name: 'reservations' })
export class Reservation { export class Reservation {
@PrimaryGeneratedColumn('uuid') @PrimaryGeneratedColumn('uuid')
@ -53,11 +58,8 @@ export class Reservation {
}) })
dateRangeEnd: Dayjs dateRangeEnd: Dayjs
@Column('varchar', { length: 32, nullable: false }) @Column('json', { nullable: false })
opponentId: string opponents: Opponent[]
@Column('varchar', { length: 255, nullable: false })
opponentName: string
@Column('boolean', { default: false }) @Column('boolean', { default: false })
waitListed: boolean waitListed: boolean

View file

@ -73,21 +73,18 @@ export class ReservationsService {
ownerId, ownerId,
dateRangeStart, dateRangeStart,
dateRangeEnd, dateRangeEnd,
opponentId = '-1', opponents,
opponentName = 'Gast',
}: { }: {
ownerId: string ownerId: string
dateRangeStart: Dayjs dateRangeStart: Dayjs
dateRangeEnd?: Dayjs dateRangeEnd?: Dayjs
opponentId?: string opponents?: { id: string; name: string }[]
opponentName?: string
}) { }) {
const res = this.reservationsRepository.create({ const res = this.reservationsRepository.create({
ownerId, ownerId,
dateRangeStart, dateRangeStart,
dateRangeEnd: dateRangeEnd ?? dateRangeStart, dateRangeEnd: dateRangeEnd ?? dateRangeStart,
opponentId, opponents: opponents ?? [{ id: '-1', name: 'Gast' }],
opponentName,
}) })
return await this.reservationsRepository.save(res) return await this.reservationsRepository.save(res)
} }

View file

@ -9,7 +9,7 @@ import dayjs from '../../common/dayjs'
import { LoggerService } from '../../logger/service.logger' import { LoggerService } from '../../logger/service.logger'
import { MONITORING_QUEUE_NAME, MonitoringQueue } from '../../monitoring/config' import { MONITORING_QUEUE_NAME, MonitoringQueue } from '../../monitoring/config'
import { MonitorType } from '../../monitoring/entity' import { MonitorType } from '../../monitoring/entity'
import { Reservation } from '../../reservations/entity' import { Opponent, Reservation } from '../../reservations/entity'
import { EmptyPage } from '../pages/empty' import { EmptyPage } from '../pages/empty'
const BAAN_RESERVEREN_ROOT_URL = 'https://squashcity.baanreserveren.nl' 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 }) this.loggerService.debug('Selecting opponent', { id, name })
const player2Search = await this.page const playerSearch = await this.page
.waitForSelector('input:has(~ select[name="players[2]"])') .waitForSelector(`input:has(~ select[name="players[${resolvedIndex}]"])`)
.catch((e: Error) => { .catch((e: Error) => {
throw new RunnerOpponentSearchError(e) throw new RunnerOpponentSearchError(e)
}) })
await player2Search await playerSearch
?.type(name, { delay: this.getTypingDelay() }) ?.type(name, { delay: this.getTypingDelay() })
.catch((e: Error) => { .catch((e: Error) => {
throw new RunnerOpponentSearchInputError(e) throw new RunnerOpponentSearchInputError(e)
@ -433,7 +456,7 @@ export class BaanReserverenService {
throw new RunnerOpponentSearchNetworkError(e) throw new RunnerOpponentSearchNetworkError(e)
}) })
await this.page await this.page
.$('select.br-user-select[name="players[2]"]') .$(`select.br-user-select[name="players[${resolvedIndex}]"]`)
.then((d) => d?.select(id)) .then((d) => d?.select(id))
.catch((e: Error) => { .catch((e: Error) => {
throw new RunnerOpponentSearchSelectionError(e) throw new RunnerOpponentSearchSelectionError(e)
@ -517,10 +540,7 @@ export class BaanReserverenService {
await this.monitorCourtReservations() await this.monitorCourtReservations()
await this.selectAvailableTime(reservation) await this.selectAvailableTime(reservation)
await this.selectOwner(reservation.ownerId) await this.selectOwner(reservation.ownerId)
await this.selectOpponent( await this.selectOpponents(reservation.opponents)
reservation.opponentId,
reservation.opponentName,
)
await this.confirmReservation() await this.confirmReservation()
} catch (error: unknown) { } catch (error: unknown) {
await this.handleError() await this.handleError()