Formatting fixes

This commit is contained in:
Collin Duncan 2023-06-27 16:06:19 +02:00
parent a3a2f12082
commit 9da2d5e2f2
No known key found for this signature in database
14 changed files with 359 additions and 340 deletions

View file

@ -25,7 +25,7 @@ import { LoggerModule } from './logger/module'
}, },
defaultJobOptions: { defaultJobOptions: {
removeOnComplete: true, removeOnComplete: true,
} },
}), }),
ScheduleModule.forRoot(), ScheduleModule.forRoot(),
ConfigModule.forRoot({ isGlobal: true }), ConfigModule.forRoot({ isGlobal: true }),

View file

@ -1,14 +1,24 @@
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common' import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
} from '@nestjs/common'
import { Observable } from 'rxjs' import { Observable } from 'rxjs'
import { map } from 'rxjs/operators' import { map } from 'rxjs/operators'
export interface CustomResponse<T = unknown> { export interface CustomResponse<T = unknown> {
data: T data: T
} }
@Injectable() @Injectable()
export class CustomResponseTransformInterceptor<T> implements NestInterceptor<T, CustomResponse<T>> { export class CustomResponseTransformInterceptor<T>
intercept(_context: ExecutionContext, next: CallHandler): Observable<CustomResponse<T>> { implements NestInterceptor<T, CustomResponse<T>>
return next.handle().pipe(map(data => ({ data }))) {
} intercept(
_context: ExecutionContext,
next: CallHandler,
): Observable<CustomResponse<T>> {
return next.handle().pipe(map((data) => ({ data })))
}
} }

View file

@ -12,27 +12,27 @@ dayjs.locale('nl')
dayjs.tz.setDefault('Europe/Amsterdam') dayjs.tz.setDefault('Europe/Amsterdam')
export interface DateRange { export interface DateRange {
start: dayjs.Dayjs start: dayjs.Dayjs
end: dayjs.Dayjs end: dayjs.Dayjs
} }
export interface SerializedDateRange { export interface SerializedDateRange {
start: string start: string
end: string end: string
} }
export const convertDateRangeStringToObject = ({ export const convertDateRangeStringToObject = ({
start, start,
end, end,
}: { }: {
start: string start: string
end: string end: string
}): DateRange => ({ start: dayjs(start), end: dayjs(end) }) }): DateRange => ({ start: dayjs(start), end: dayjs(end) })
const dayjsTz = ( const dayjsTz = (
date?: string | number | Date | dayjs.Dayjs | null | undefined date?: string | number | Date | dayjs.Dayjs | null | undefined,
) => { ) => {
return dayjs(date).tz() return dayjs(date).tz()
} }
export default dayjsTz export default dayjsTz

View file

@ -4,13 +4,13 @@ import { LoggerService } from './service'
@Injectable() @Injectable()
export class LoggerMiddleware implements NestMiddleware { export class LoggerMiddleware implements NestMiddleware {
constructor( constructor(
@Inject(LoggerService) @Inject(LoggerService)
private readonly logger: LoggerService, private readonly logger: LoggerService,
) {} ) {}
use(req: Request, _res: Response, next: NextFunction) { use(req: Request, _res: Response, next: NextFunction) {
this.logger.log(`${req.method} ${req.originalUrl}`) this.logger.log(`${req.method} ${req.originalUrl}`)
next() next()
} }
} }

View file

@ -1,5 +1,5 @@
export const RESERVATIONS_QUEUE_NAME = 'reservations' export const RESERVATIONS_QUEUE_NAME = 'reservations'
export default () => ({ export default () => ({
queueName: RESERVATIONS_QUEUE_NAME queueName: RESERVATIONS_QUEUE_NAME,
}) })

View file

@ -24,8 +24,12 @@ export class ReservationsCronService {
timeZone: 'Europe/Amsterdam', timeZone: 'Europe/Amsterdam',
}) })
async handleDailyReservations() { async handleDailyReservations() {
const reservationsToPerform = await this.reservationService.getByDate() const reservationsToPerform = await this.reservationService.getByDate()
this.logger.log(`Found ${reservationsToPerform.length} reservations to perform`) this.logger.log(
await this.reservationsQueue.addBulk(reservationsToPerform.map((r) => ({ data: r }))) `Found ${reservationsToPerform.length} reservations to perform`,
} )
await this.reservationsQueue.addBulk(
reservationsToPerform.map((r) => ({ data: r })),
)
}
} }

View file

@ -39,33 +39,33 @@ export class Reservation {
@Exclude() @Exclude()
public createPossibleDates(): Dayjs[] { public createPossibleDates(): Dayjs[] {
const possibleDates: Dayjs[] = [] const possibleDates: Dayjs[] = []
let possibleDate = dayjs(this.dateRangeStart).second(0).millisecond(0) let possibleDate = dayjs(this.dateRangeStart).second(0).millisecond(0)
while (possibleDate.isSameOrBefore(this.dateRangeEnd)) { while (possibleDate.isSameOrBefore(this.dateRangeEnd)) {
possibleDates.push(possibleDate) possibleDates.push(possibleDate)
possibleDate = possibleDate.add(15, 'minute') possibleDate = possibleDate.add(15, 'minute')
} }
return possibleDates return possibleDates
} }
/** /**
* Method to check if a reservation is available for reservation in the system * Method to check if a reservation is available for reservation in the system
* @returns is reservation date within 7 days * @returns is reservation date within 7 days
*/ */
@Exclude() @Exclude()
public isAvailableForReservation(): boolean { public isAvailableForReservation(): boolean {
return dayjs().diff(this.dateRangeStart, 'day') <= 7 return dayjs().diff(this.dateRangeStart, 'day') <= 7
} }
@Exclude() @Exclude()
public getAllowedReservationDate(): Dayjs { public getAllowedReservationDate(): Dayjs {
return this.dateRangeStart return this.dateRangeStart
.hour(0) .hour(0)
.minute(0) .minute(0)
.second(0) .second(0)
.millisecond(0) .millisecond(0)
.subtract(7, 'days') .subtract(7, 'days')
} }
} }

View file

@ -10,7 +10,6 @@ import { ReservationsWorker } from './worker'
import { LoggerModule } from '../logger/module' import { LoggerModule } from '../logger/module'
import { RunnerModule } from '../runner/module' import { RunnerModule } from '../runner/module'
@Module({ @Module({
imports: [ imports: [
LoggerModule, LoggerModule,

View file

@ -20,7 +20,8 @@ export class ReservationsService {
} }
getByDate(date = dayjs()) { getByDate(date = dayjs()) {
return this.reservationsRepository.createQueryBuilder() return this.reservationsRepository
.createQueryBuilder()
.where(`DATE(dateRangeStart, '-7 day') = DATE(:date)`, { date }) .where(`DATE(dateRangeStart, '-7 day') = DATE(:date)`, { date })
.getMany() .getMany()
} }

View file

@ -9,22 +9,26 @@ import { LoggerService } from '../logger/service'
@Processor(RESERVATIONS_QUEUE_NAME) @Processor(RESERVATIONS_QUEUE_NAME)
export class ReservationsWorker { export class ReservationsWorker {
constructor( constructor(
@Inject(BaanReserverenService) @Inject(BaanReserverenService)
private readonly brService: BaanReserverenService, private readonly brService: BaanReserverenService,
@Inject(LoggerService) @Inject(LoggerService)
private readonly logger: LoggerService, private readonly logger: LoggerService,
) {} ) {}
@Process() @Process()
async handleReservationJob(job: Job<Reservation>) { async handleReservationJob(job: Job<Reservation>) {
const reservation = plainToInstance(Reservation, job.data, { groups: ['password'] }) const reservation = plainToInstance(Reservation, job.data, {
this.logger.log('Handling reservation', { reservation: instanceToPlain(reservation) }) groups: ['password'],
await this.performReservation(reservation) })
} this.logger.log('Handling reservation', {
reservation: instanceToPlain(reservation),
})
await this.performReservation(reservation)
}
async performReservation(reservation: Reservation) { async performReservation(reservation: Reservation) {
await this.brService.performReservation(reservation) await this.brService.performReservation(reservation)
} }
} }

View file

@ -9,221 +9,221 @@ import { Reservation } from '../../reservations/entity'
const baanReserverenRoot = 'https://squashcity.baanreserveren.nl' const baanReserverenRoot = 'https://squashcity.baanreserveren.nl'
export enum BaanReserverenUrls { export enum BaanReserverenUrls {
Reservations = '/reservations', Reservations = '/reservations',
Logout = '/auth/logout', Logout = '/auth/logout',
} }
enum SessionAction { enum SessionAction {
NoAction, NoAction,
Logout, Logout,
Login, Login,
} }
interface BaanReserverenSession { interface BaanReserverenSession {
username: string username: string
startedAt: Dayjs startedAt: Dayjs
} }
@Injectable() @Injectable()
export class BaanReserverenService { export class BaanReserverenService {
private session: BaanReserverenSession | null = null private session: BaanReserverenSession | null = null
constructor( constructor(
@Inject(RunnerService) @Inject(RunnerService)
private readonly runnerService: RunnerService, private readonly runnerService: RunnerService,
@Inject(EmptyPage) @Inject(EmptyPage)
private readonly page: Page, private readonly page: Page,
) {} ) {}
private checkSession(username: string) { private checkSession(username: string) {
if (this.page.url().endsWith(BaanReserverenUrls.Reservations)) { if (this.page.url().endsWith(BaanReserverenUrls.Reservations)) {
return this.session?.username !== username return this.session?.username !== username
? SessionAction.Logout ? SessionAction.Logout
: SessionAction.NoAction : SessionAction.NoAction
} }
return SessionAction.Login return SessionAction.Login
} }
private startSession(username: string) { private startSession(username: string) {
if (this.session && this.session.username !== username) { if (this.session && this.session.username !== username) {
throw new Error('Session already started') throw new Error('Session already started')
} }
if (this.session?.username === username) { if (this.session?.username === username) {
return return
} }
this.session = { this.session = {
username, username,
startedAt: dayjs(), startedAt: dayjs(),
} }
} }
private endSession() { private endSession() {
this.session = null this.session = null
} }
private async login(username: string, password: string) { private async login(username: string, password: string) {
await this.page await this.page
.waitForSelector('input[name=username]') .waitForSelector('input[name=username]')
.then((i) => i?.type(username)) .then((i) => i?.type(username))
.catch((e: Error) => { .catch((e: Error) => {
// throw new RunnerLoginUsernameInputError(e) // throw new RunnerLoginUsernameInputError(e)
}) })
await this.page await this.page
.$('input[name=password]') .$('input[name=password]')
.then((i) => i?.type(password)) .then((i) => i?.type(password))
.catch((e: Error) => { .catch((e: Error) => {
// throw new RunnerLoginPasswordInputError(e) // throw new RunnerLoginPasswordInputError(e)
}) })
await this.page await this.page
.$('button') .$('button')
.then((b) => b?.click()) .then((b) => b?.click())
.catch((e: Error) => { .catch((e: Error) => {
// throw new RunnerLoginSubmitError(e) // throw new RunnerLoginSubmitError(e)
}) })
this.startSession(username) this.startSession(username)
} }
private async logout() { private async logout() {
await this.page.goto(`${baanReserverenRoot}${BaanReserverenUrls.Logout}`) await this.page.goto(`${baanReserverenRoot}${BaanReserverenUrls.Logout}`)
this.endSession() this.endSession()
} }
private async init(reservation: Reservation) { private async init(reservation: Reservation) {
await this.page.goto(baanReserverenRoot) await this.page.goto(baanReserverenRoot)
const action = await this.checkSession(reservation.username) const action = await this.checkSession(reservation.username)
switch (action) { switch (action) {
case SessionAction.Logout: case SessionAction.Logout:
await this.logout() await this.logout()
await this.login(reservation.username, reservation.password) await this.login(reservation.username, reservation.password)
break break
case SessionAction.Login: case SessionAction.Login:
await this.login(reservation.username, reservation.password) await this.login(reservation.username, reservation.password)
break break
case SessionAction.NoAction: case SessionAction.NoAction:
default: default:
break break
} }
} }
private getLastVisibleDay(): Dayjs { private getLastVisibleDay(): Dayjs {
const lastDayOfMonth = dayjs().add(1, 'month').set('date', 0) const lastDayOfMonth = dayjs().add(1, 'month').set('date', 0)
let daysToAdd = 0 let daysToAdd = 0
switch (lastDayOfMonth.day()) { switch (lastDayOfMonth.day()) {
case 0: case 0:
daysToAdd = 0 daysToAdd = 0
break break
default: default:
daysToAdd = 7 - lastDayOfMonth.day() daysToAdd = 7 - lastDayOfMonth.day()
break break
} }
return lastDayOfMonth.add(daysToAdd, 'day') return lastDayOfMonth.add(daysToAdd, 'day')
} }
private async navigateToDay(date: Dayjs): Promise<void> { private async navigateToDay(date: Dayjs): Promise<void> {
if (this.getLastVisibleDay().isBefore(date)) { if (this.getLastVisibleDay().isBefore(date)) {
await this.page await this.page
?.waitForSelector('td.month.next') ?.waitForSelector('td.month.next')
.then((d) => d?.click()) .then((d) => d?.click())
.catch((e: Error) => { .catch((e: Error) => {
throw new RunnerNavigationMonthError(e) throw new RunnerNavigationMonthError(e)
}) })
} }
await this.page await this.page
?.waitForSelector( ?.waitForSelector(
`td#cal_${date.get('year')}_${date.get('month') + 1}_${date.get( `td#cal_${date.get('year')}_${date.get('month') + 1}_${date.get(
'date' 'date',
)}` )}`,
) )
.then((d) => d?.click()) .then((d) => d?.click())
.catch((e: Error) => { .catch((e: Error) => {
throw new RunnerNavigationDayError(e) throw new RunnerNavigationDayError(e)
}) })
await this.page await this.page
?.waitForSelector( ?.waitForSelector(
`td#cal_${date.get('year')}_${date.get('month') + 1}_${date.get( `td#cal_${date.get('year')}_${date.get('month') + 1}_${date.get(
'date' 'date',
)}.selected` )}.selected`,
) )
.catch((e: Error) => { .catch((e: Error) => {
throw new RunnerNavigationSelectionError(e) throw new RunnerNavigationSelectionError(e)
}) })
} }
private async selectAvailableTime(reservation: Reservation): Promise<void> { private async selectAvailableTime(reservation: Reservation): Promise<void> {
let freeCourt: ElementHandle | null | undefined let freeCourt: ElementHandle | null | undefined
let i = 0 let i = 0
const possibleDates = reservation.createPossibleDates() const possibleDates = reservation.createPossibleDates()
while (i < possibleDates.length && !freeCourt) { while (i < possibleDates.length && !freeCourt) {
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']`
freeCourt = await this.page?.$(selector) freeCourt = await this.page?.$(selector)
i++ i++
} }
if (!freeCourt) { if (!freeCourt) {
throw new NoCourtAvailableError() throw new NoCourtAvailableError()
} }
await freeCourt.click().catch((e: Error) => { await freeCourt.click().catch((e: Error) => {
throw new RunnerCourtSelectionError(e) throw new RunnerCourtSelectionError(e)
}) })
} }
private async selectOpponent(id: string, name: string): Promise<void> { private async selectOpponent(id: string, name: string): Promise<void> {
const player2Search = await this.page const player2Search = await this.page
?.waitForSelector('tr.res-make-player-2 > td > input') ?.waitForSelector('tr.res-make-player-2 > td > input')
.catch((e: Error) => { .catch((e: Error) => {
throw new RunnerOpponentSearchError(e) throw new RunnerOpponentSearchError(e)
}) })
await player2Search?.type(name).catch((e: Error) => { await player2Search?.type(name).catch((e: Error) => {
throw new RunnerOpponentSearchInputError(e) throw new RunnerOpponentSearchInputError(e)
}) })
await this.page?.waitForNetworkIdle().catch((e: Error) => { await this.page?.waitForNetworkIdle().catch((e: Error) => {
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[2]"]')
.then((d) => d?.select(id)) .then((d) => d?.select(id))
.catch((e: Error) => { .catch((e: Error) => {
throw new RunnerOpponentSearchSelectionError(e) throw new RunnerOpponentSearchSelectionError(e)
}) })
} }
private async confirmReservation(): Promise<void> { private async confirmReservation(): Promise<void> {
await this.page await this.page
?.$('input#__make_submit') ?.$('input#__make_submit')
.then((b) => b?.click()) .then((b) => b?.click())
.catch((e: Error) => { .catch((e: Error) => {
throw new RunnerReservationConfirmButtonError(e) throw new RunnerReservationConfirmButtonError(e)
}) })
await this.page await this.page
?.waitForSelector('input#__make_submit2') ?.waitForSelector('input#__make_submit2')
.then((b) => b?.click()) .then((b) => b?.click())
.catch((e: Error) => { .catch((e: Error) => {
throw new RunnerReservationConfirmSubmitError(e) throw new RunnerReservationConfirmSubmitError(e)
}) })
} }
public async performReservation(reservation: Reservation) { public async performReservation(reservation: Reservation) {
await this.init(reservation) await this.init(reservation)
await this.navigateToDay(reservation.dateRangeStart) await this.navigateToDay(reservation.dateRangeStart)
await this.selectAvailableTime(reservation) await this.selectAvailableTime(reservation)
await this.selectOpponent(reservation.opponentId, reservation.opponentName) await this.selectOpponent(reservation.opponentId, reservation.opponentName)
// await this.confirmReservation() // await this.confirmReservation()
} }
} }
export class RunnerError extends Error { export class RunnerError extends Error {
constructor(error: Error) { constructor(error: Error) {
super(error.message) super(error.message)
this.stack = error.stack this.stack = error.stack
} }
} }
export class PuppeteerError extends RunnerError {} export class PuppeteerError extends RunnerError {}
export class PuppeteerBrowserLaunchError extends PuppeteerError {} export class PuppeteerBrowserLaunchError extends PuppeteerError {}

View file

@ -6,9 +6,8 @@ import { BaanReserverenService } from './baanreserveren/service'
import { LoggerModule } from '../logger/module' import { LoggerModule } from '../logger/module'
@Module({ @Module({
providers: [RunnerService, BaanReserverenService, EmptyPageFactory], providers: [RunnerService, BaanReserverenService, EmptyPageFactory],
imports: [LoggerModule, BullModule.registerQueue({ name: 'reservations' })], imports: [LoggerModule, BullModule.registerQueue({ name: 'reservations' })],
exports: [EmptyPageFactory, BaanReserverenService], exports: [EmptyPageFactory, BaanReserverenService],
}) })
export class RunnerModule {} export class RunnerModule {}

View file

@ -5,13 +5,13 @@ import { Page } from 'puppeteer'
export const EmptyPage = Symbol.for('EmptyPage') export const EmptyPage = Symbol.for('EmptyPage')
export const EmptyPageFactory: FactoryProvider<Page> = { export const EmptyPageFactory: FactoryProvider<Page> = {
provide: EmptyPage, provide: EmptyPage,
useFactory: async (runnerService: RunnerService) => { useFactory: async (runnerService: RunnerService) => {
const browser = await runnerService.getBrowser() const browser = await runnerService.getBrowser()
const page = await browser.newPage() const page = await browser.newPage()
return page return page
}, },
inject: [RunnerService], inject: [RunnerService],
scope: Scope.TRANSIENT, scope: Scope.TRANSIENT,
} }

View file

@ -1,90 +1,92 @@
import { Inject, Injectable, BeforeApplicationShutdown, OnModuleInit, Scope } from '@nestjs/common' import {
import puppeteer, { Browser, BrowserConnectOptions, BrowserLaunchArgumentOptions, LaunchOptions, Page } from 'puppeteer' Injectable,
import { LoggerService } from '../logger/service' BeforeApplicationShutdown,
OnModuleInit,
enum SessionAction { } from '@nestjs/common'
NoAction, import puppeteer, {
Logout, Browser,
Login, BrowserConnectOptions,
} BrowserLaunchArgumentOptions,
LaunchOptions,
} from 'puppeteer'
interface RunnerSession { interface RunnerSession {
username: string username: string
loggedInAt: Date loggedInAt: Date
} }
@Injectable() @Injectable()
export class RunnerService implements OnModuleInit, BeforeApplicationShutdown { export class RunnerService implements OnModuleInit, BeforeApplicationShutdown {
private browser?: Browser private browser?: Browser
private options: LaunchOptions & private options: LaunchOptions &
BrowserLaunchArgumentOptions & BrowserLaunchArgumentOptions &
BrowserConnectOptions = { BrowserConnectOptions = {
args: ['--disable-setuid-sandbox', '--no-sandbox'], args: ['--disable-setuid-sandbox', '--no-sandbox'],
headless: 'new' headless: 'new',
} }
private session: RunnerSession | null = null private session: RunnerSession | null = null
private async init() { private async init() {
try { try {
if (!this.browser) { if (!this.browser) {
this.browser = await puppeteer.launch(this.options) this.browser = await puppeteer.launch(this.options)
} }
} catch (error) { } catch (error) {
throw new PuppeteerBrowserLaunchError(error) throw new PuppeteerBrowserLaunchError(error)
} }
} }
public async onModuleInit() { public async onModuleInit() {
await this.init() await this.init()
} }
public async beforeApplicationShutdown() { public async beforeApplicationShutdown() {
try { try {
if (this.browser && this.browser.isConnected()) { if (this.browser && this.browser.isConnected()) {
await this.browser.close() await this.browser.close()
} }
} catch (error) { } catch (error) {
console.error('error shutting down browser', error) console.error('error shutting down browser', error)
} }
} }
public async getBrowser(): Promise<Browser> { public async getBrowser(): Promise<Browser> {
await this.init() await this.init()
if (!this.browser) { if (!this.browser) {
throw new Error('Browser not initialized') throw new Error('Browser not initialized')
} }
return this.browser return this.browser
} }
public async getSession(): Promise<RunnerSession | null> { public async getSession(): Promise<RunnerSession | null> {
return this.session return this.session
} }
public startSession(username: string) { public startSession(username: string) {
if (this.session && this.session.username !== username) { if (this.session && this.session.username !== username) {
throw new RunnerNewSessionError(new Error('Session already started')) throw new RunnerNewSessionError(new Error('Session already started'))
} }
if (this.session?.username === username) { if (this.session?.username === username) {
return return
} }
this.session = { this.session = {
username, username,
loggedInAt: new Date(), loggedInAt: new Date(),
} }
} }
public endSession() { public endSession() {
this.session = null this.session = null
} }
} }
export class RunnerError extends Error { export class RunnerError extends Error {
constructor(error: Error) { constructor(error: Error) {
super(error.message) super(error.message)
this.stack = error.stack this.stack = error.stack
} }
} }
export class PuppeteerError extends RunnerError {} export class PuppeteerError extends RunnerError {}
export class PuppeteerBrowserLaunchError extends PuppeteerError {} export class PuppeteerBrowserLaunchError extends PuppeteerError {}