Big ole refactor to using nestjs for some practice

This commit is contained in:
Collin Duncan 2023-05-26 15:43:14 -05:00
parent eb9991895a
commit 1def4de40c
No known key found for this signature in database
61 changed files with 6858 additions and 13766 deletions

25
.eslintrc.js Normal file
View file

@ -0,0 +1,25 @@
module.exports = {
parser: '@typescript-eslint/parser',
parserOptions: {
project: 'tsconfig.json',
tsconfigRootDir: __dirname,
sourceType: 'module',
},
plugins: ['@typescript-eslint/eslint-plugin'],
extends: [
'plugin:@typescript-eslint/recommended',
'plugin:prettier/recommended',
],
root: true,
env: {
node: true,
jest: true,
},
ignorePatterns: ['.eslintrc.js'],
rules: {
'@typescript-eslint/interface-name-prefix': 'off',
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-explicit-any': 'off',
},
};

View file

@ -1,15 +0,0 @@
{
"root": true,
"parser": "@typescript-eslint/parser",
"plugins": [
"@typescript-eslint"
],
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended"
],
"ignorePatterns": [
"**/*.js",
"!src/**/*.ts"
]
}

View file

@ -1,8 +1,7 @@
{
"$schema":"http://json.schemastore.org/prettierrc",
"trailingComma": "es5",
"singleQuote": true,
"trailingComma": "all",
"tabWidth": 2,
"useTabs": false,
"semi": false,
"singleQuote": true
"useTabs": true,
"semi": false
}

View file

@ -8,8 +8,9 @@ Automatic court reservation!
- Node.js (18.x)
- npm (8.x)
- gcc (g++-12)
- nvm (optional)
- nvm
- Docker
- redis
#### Using nvm
@ -21,31 +22,7 @@ Automatic court reservation!
```bash
npm install
npm run local <username> <password> <year> <month> <day> <startTime> <endTime> <opponentName> <opponentId>
```
## Architecture
```ascii
|======|
| User |
|======|
|
[requests reservation]
|
|
V
|===========| /---\ |==========|
| Scheduler | ---[checks possibility]--->/ ok? \--[y/ forward request]--> | Reserver |
|===========| \ / |==========|
\---/ |
| |
[n/ save request] [find possible, saved reservations]
| |
V |
|==========| |
| Database |<---------------------------|
|==========|
npm start:dev
```
## Deployment

13
data-source.ts Normal file
View file

@ -0,0 +1,13 @@
import 'reflect-metadata'
import { Reservation } from './src/reservations/entity'
import { DataSource } from 'typeorm'
export const AppDataSource = new DataSource({
type: 'sqlite',
database: 'autobaan',
logging: true,
entities: [Reservation],
migrations: [],
subscribers: [],
})

View file

@ -4,8 +4,15 @@ services:
context: ..
dockerfile: ./docker/server/Dockerfile
restart: always
env_file: ./server/.env
environment:
- PORT=3000
- REDIS_HOST=redis
- REDIS_PORT=6379
ports:
- 3000:3000
volumes:
- ./autobaan_db:/app/db
- ../db:/app/db
redis:
image: redis
ports:
- 6379

View file

@ -44,4 +44,4 @@ FROM base as app
COPY --chown=node:node --from=builder /app/dist ./dist
ENTRYPOINT node dist/server/index.js
ENTRYPOINT ["node", "./dist/main.js"]

View file

@ -1,31 +0,0 @@
import type { JestConfigWithTsJest } from 'ts-jest'
const jestConfig: JestConfigWithTsJest = {
preset: 'ts-jest',
testEnvironment: 'node',
verbose: true,
clearMocks: true,
collectCoverage: true,
coverageReporters: [
"text"
],
coveragePathIgnorePatterns: [
"/node_modules/"
],
coverageProvider: "v8",
moduleDirectories: [
"node_modules"
],
moduleFileExtensions: [
"js",
"ts"
],
roots: [
"<rootDir>/tests"
],
testMatch: [
"**/*.test.ts"
]
}
export default jestConfig

8
nest-cli.json Normal file
View file

@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

17165
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,53 +1,82 @@
{
"name": "autobaan",
"version": "1.0.0",
"version": "0.0.1",
"description": "",
"author": "",
"private": true,
"engines": {
"node": "18"
},
"license": "UNLICENSED",
"scripts": {
"preinstall": "export CXX=g++-12",
"clean": "rm -r ./dist || true",
"prebuild": "npm run clean",
"build": "tsc",
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:unit": "jest tests/unit/*",
"test:unit:clean": "npm run test:clear-cache && npm run test:unit",
"test:integration": "jest tests/integration/*",
"test:integration:clean": "npm run test:clear-cache && npm run test:integration",
"test:clear-cache": "jest --clearCache",
"lint": "eslint src/ --ext ts",
"prettier": "prettier src tests -w",
"local": "npx ts-node src/local/index.ts",
"docker:build": "docker build . -f docker/server/Dockerfile",
"docker:start": "docker compose -f docker/docker-compose.yml up",
"docker:stop": "docker compose -f docker/docker-compose.yml down"
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json"
},
"author": "Collin Duncan",
"license": "ISC",
"dependencies": {
"argon2": "^0.30.3",
"axios": "^1.2.0",
"dayjs": "^1.11.6",
"node-cron": "^3.0.2",
"puppeteer": "^19.5.2",
"sqlite3": "^5.1.4",
"uuid": "^9.0.0"
"@nestjs/bull": "^0.6.3",
"@nestjs/common": "^9.0.0",
"@nestjs/config": "^2.3.2",
"@nestjs/core": "^9.0.0",
"@nestjs/platform-express": "^9.0.0",
"@nestjs/schedule": "^2.2.2",
"@nestjs/typeorm": "^9.0.1",
"@types/cron": "^2.0.1",
"bull": "^4.10.4",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.0",
"dayjs": "^1.11.7",
"puppeteer": "^20.4.0",
"reflect-metadata": "^0.1.13",
"rxjs": "^7.2.0",
"sqlite3": "^5.1.6",
"typeorm": "^0.3.16"
},
"devDependencies": {
"@types/jest": "^29.2.3",
"@types/node": "^18.11.9",
"@types/node-cron": "^3.0.6",
"@nestjs/cli": "^9.0.0",
"@nestjs/schematics": "^9.0.0",
"@nestjs/testing": "^9.0.0",
"@types/express": "^4.17.13",
"@types/jest": "29.5.1",
"@types/node": "18.16.12",
"@types/puppeteer": "^7.0.4",
"@types/uuid": "^8.3.4",
"@typescript-eslint/eslint-plugin": "^5.42.0",
"@typescript-eslint/parser": "^5.42.0",
"babel-jest": "^29.2.1",
"eslint": "^8.27.0",
"jest": "^29.2.1",
"prettier": "^2.7.1",
"ts-jest": "^29.0.3",
"typescript": "^4.9.4"
"@types/supertest": "^2.0.11",
"@typescript-eslint/eslint-plugin": "^5.0.0",
"@typescript-eslint/parser": "^5.0.0",
"eslint": "^8.0.1",
"eslint-config-prettier": "^8.3.0",
"eslint-plugin-prettier": "^4.0.0",
"jest": "29.5.0",
"prettier": "^2.3.2",
"source-map-support": "^0.5.20",
"supertest": "^6.1.3",
"ts-jest": "29.1.0",
"ts-loader": "^9.2.3",
"ts-node": "^10.0.0",
"tsconfig-paths": "4.2.0",
"typescript": "^5.0.0"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
}
}

54
package.json.old Normal file
View file

@ -0,0 +1,54 @@
{
"name": "autobaan",
"version": "1.0.0",
"description": "",
"private": true,
"engines": {
"node": "18"
},
"scripts": {
"preinstall": "export CXX=g++-12",
"clean": "rm -r ./dist || true",
"prebuild": "npm run clean",
"build": "tsc",
"test": "jest",
"test:unit": "jest tests/unit/*",
"test:unit:clean": "npm run test:clear-cache && npm run test:unit",
"test:integration": "jest tests/integration/*",
"test:integration:clean": "npm run test:clear-cache && npm run test:integration",
"test:clear-cache": "jest --clearCache",
"lint": "eslint src/ --ext ts",
"prettier": "prettier src tests -w",
"local": "npx ts-node src/local/index.ts",
"docker:build": "docker build . -f docker/server/Dockerfile",
"docker:start": "docker compose -f docker/docker-compose.yml up",
"docker:stop": "docker compose -f docker/docker-compose.yml down"
},
"author": "Collin Duncan",
"license": "ISC",
"dependencies": {
"@nestjs/cli": "^9.5.0",
"argon2": "^0.30.3",
"axios": "^1.2.0",
"dayjs": "^1.11.6",
"node-cron": "^3.0.2",
"puppeteer": "^19.5.2",
"sqlite3": "^5.1.4",
"uuid": "^9.0.0"
},
"devDependencies": {
"@types/jest": "^29.2.3",
"@types/node": "^18.11.9",
"@types/node-cron": "^3.0.6",
"@types/puppeteer": "^7.0.4",
"@types/uuid": "^8.3.4",
"@typescript-eslint/eslint-plugin": "^5.42.0",
"@typescript-eslint/parser": "^5.42.0",
"babel-jest": "^29.2.1",
"eslint": "^8.27.0",
"jest": "^29.2.1",
"prettier": "^2.7.1",
"ts-jest": "^29.0.3",
"typescript": "^4.9.4"
}
}

41
src/app.module.ts Normal file
View file

@ -0,0 +1,41 @@
import { BullModule } from '@nestjs/bull'
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common'
import { ScheduleModule } from '@nestjs/schedule'
import { TypeOrmModule } from '@nestjs/typeorm'
import { resolve } from 'path'
import { ReservationsModule } from './reservations/module'
import { RunnerModule } from './runner/module'
import { ConfigModule } from '@nestjs/config'
import { LoggerMiddleware } from './logger/middleware'
import { LoggerModule } from './logger/module'
@Module({
imports: [
TypeOrmModule.forRoot({
type: 'sqlite',
database: resolve('./db/autobaan_db'),
autoLoadEntities: true,
logging: true,
synchronize: true,
}),
BullModule.forRoot({
redis: {
host: process.env.REDIS_HOST,
port: Number.parseInt(process.env.REDIS_PORT || '6379'),
},
defaultJobOptions: {
removeOnComplete: true,
}
}),
ScheduleModule.forRoot(),
ConfigModule.forRoot({ isGlobal: true }),
ReservationsModule,
RunnerModule,
LoggerModule,
],
})
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer.apply(LoggerMiddleware).forRoutes('*')
}
}

View file

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

View file

@ -1,46 +0,0 @@
import { resolve } from 'path'
import sqlite from 'sqlite3'
import { asyncLocalStorage } from '../logger'
import { CREATE_TABLE_reservations } from './sql'
const getDatabase = () => new sqlite.Database(resolve('./db/autobaan_db'))
export const run = async (sql: string, params?: unknown) => {
const db = getDatabase()
await new Promise<void>((res, rej) => {
asyncLocalStorage
.getStore()
?.debug(`<database> run ~> ${sql.replace(/\s*\n\s*/g, ' ')} (${params})`)
db.run(sql, params, (err) => {
if (err) rej(err)
res()
})
})
db.close()
}
export const all = async <T>(sql: string, params?: unknown) => {
const db = getDatabase()
const rows = await new Promise<T[]>((res, rej) => {
asyncLocalStorage
.getStore()
?.debug(`<database> all ~> ${sql.replace(/\s*\n\s*/g, ' ')} (${params})`)
db.all(sql, params, (err, rows) => {
if (err) rej(err)
res(rows)
})
})
db.close()
return rows
}
export const init = async () => {
try {
await run(CREATE_TABLE_reservations)
} catch (err: unknown) {
console.error(err)
}
}
export class DatabaseError extends Error {}
export class DatabaseEnvironmentError extends DatabaseError {}

View file

@ -1,11 +0,0 @@
export const CREATE_TABLE_reservations = `
CREATE TABLE IF NOT EXISTS reservations (
id VARCHAR(36) NOT NULL PRIMARY KEY,
username VARCHAR(64) NOT NULL,
password VARCHAR(255) NOT NULL,
date_range_start DATETIME NOT NULL,
date_range_end DATETIME NOT NULL,
opponent_id VARCHAR(32) NOT NULL,
opponent_name VARCHAR(255) NOT NULL
);
`

View file

@ -1,7 +1,7 @@
import dayjs from 'dayjs'
import isSameOrBefore from 'dayjs/plugin/isSameOrBefore'
import utc from 'dayjs/plugin/utc'
import timezone from 'dayjs/plugin/timezone'
import * as dayjs from 'dayjs'
import * as isSameOrBefore from 'dayjs/plugin/isSameOrBefore'
import * as utc from 'dayjs/plugin/utc'
import * as timezone from 'dayjs/plugin/timezone'
import 'dayjs/locale/nl'
dayjs.extend(isSameOrBefore)
@ -11,6 +11,24 @@ dayjs.locale('nl')
dayjs.tz.setDefault('Europe/Amsterdam')
export interface DateRange {
start: dayjs.Dayjs
end: dayjs.Dayjs
}
export interface SerializedDateRange {
start: string
end: string
}
export const convertDateRangeStringToObject = ({
start,
end,
}: {
start: string
end: string
}): DateRange => ({ start: dayjs(start), end: dayjs(end) })
const dayjsTz = (
date?: string | number | Date | dayjs.Dayjs | null | undefined
) => {

View file

@ -1,93 +0,0 @@
import { AsyncLocalStorage } from 'async_hooks'
import dayjs from './dayjs'
export enum LogLevel {
DEBUG,
INFO,
ERROR,
}
export const asyncLocalStorage = new AsyncLocalStorage<Logger>()
export class Logger {
private readonly tag: string
private readonly correlationId: string
private readonly level: LogLevel
public constructor(
tag: string,
correlationId: string,
level = LogLevel.ERROR
) {
this.tag = tag
this.correlationId = correlationId
this.level = level
}
private log(logLevel: LogLevel, message: string, details?: unknown): void {
if (logLevel < this.level) {
return
}
let levelString
switch (logLevel) {
case LogLevel.ERROR:
levelString = 'ERROR'
break
case LogLevel.INFO:
levelString = 'INFO'
break
case LogLevel.DEBUG:
default:
levelString = 'DEBUG'
break
}
let fmtString = '(%s) <%s> [%s] %s: %s'
const params: Array<unknown> = [
dayjs().format(),
this.tag,
this.correlationId,
levelString,
message,
]
if (details) {
if (typeof details === 'object') {
const toObfuscate = ['password']
toObfuscate.forEach((key) => {
if ((details as Record<string, unknown>)[key]) {
// Prettier and eslint are fighting
// eslint-disable-next-line @typescript-eslint/no-extra-semi
;(details as Record<string, unknown>)[key] = '***'
}
})
}
params.push(details)
fmtString += ' - %O'
}
if (logLevel === LogLevel.ERROR) {
console.error(fmtString, ...params)
} else {
console.log(fmtString, ...params)
}
}
public debug(message: string, details?: unknown): void {
this.log(LogLevel.DEBUG, message, details)
}
public info(message: string, details?: unknown): void {
this.log(LogLevel.INFO, message, details)
}
public error(message: string, details?: unknown): void {
this.log(LogLevel.ERROR, message, details)
}
}
export class LoggableError extends Error {
toString() {
return `${this.name} - ${this.message}\n${this.stack}`
}
}

View file

@ -1,54 +1,55 @@
import argon2 from 'argon2'
import crypto from 'crypto'
import { asyncLocalStorage } from './logger'
// TODO: refactor to nestjs if needed
// import argon2 from 'argon2'
// import crypto from 'crypto'
// import { asyncLocalStorage } from './logger'
const SALT_LENGTH = Number.parseInt(process.env.SALT_LENGTH || '32', 10)
// const SALT_LENGTH = Number.parseInt(process.env.SALT_LENGTH || '32', 10)
const randomFillPromise = (buffer: Buffer) => {
return new Promise<Buffer>((res, rej) => {
crypto.randomFill(buffer, (err, buff) => {
if (err) {
rej(err)
}
res(buff)
})
})
}
// const randomFillPromise = (buffer: Buffer) => {
// return new Promise<Buffer>((res, rej) => {
// crypto.randomFill(buffer, (err, buff) => {
// if (err) {
// rej(err)
// }
// res(buff)
// })
// })
// }
export const generateSalt = async () => {
const saltBuffer = Buffer.alloc(SALT_LENGTH)
return randomFillPromise(saltBuffer)
}
// export const generateSalt = async () => {
// const saltBuffer = Buffer.alloc(SALT_LENGTH)
// return randomFillPromise(saltBuffer)
// }
export const generateHash = async (password: string, saltBuffer: Buffer) => {
const hashOptions: argon2.Options & { raw: false } = {
hashLength: 32,
parallelism: 1,
memoryCost: 1 << 14,
timeCost: 2,
type: argon2.argon2id,
salt: saltBuffer,
saltLength: saltBuffer.length,
raw: false,
}
// export const generateHash = async (password: string, saltBuffer: Buffer) => {
// const hashOptions: argon2.Options & { raw: false } = {
// hashLength: 32,
// parallelism: 1,
// memoryCost: 1 << 14,
// timeCost: 2,
// type: argon2.argon2id,
// salt: saltBuffer,
// saltLength: saltBuffer.length,
// raw: false,
// }
const hash = await argon2.hash(password, hashOptions)
return hash
}
// const hash = await argon2.hash(password, hashOptions)
// return hash
// }
export const hashPassword = async (password: string) => {
try {
const saltBuffer = await generateSalt()
const hash = await generateHash(password, saltBuffer)
return hash
} catch (err: any) {
asyncLocalStorage
.getStore()
?.error('Error hashing and salting password', { message: err.message })
throw err
}
}
// export const hashPassword = async (password: string) => {
// try {
// const saltBuffer = await generateSalt()
// const hash = await generateHash(password, saltBuffer)
// return hash
// } catch (err: any) {
// asyncLocalStorage
// .getStore()
// ?.error('Error hashing and salting password', { message: err.message })
// throw err
// }
// }
export const verifyPassword = async (hash: string, password: string) => {
return await argon2.verify(hash, password)
}
// export const verifyPassword = async (hash: string, password: string) => {
// return await argon2.verify(hash, password)
// }

View file

@ -1,115 +0,0 @@
import dayjs from './dayjs'
import { DateRange, Opponent, Reservation } from './reservation'
export enum ValidationErrorCode {
UNDEFINED_REQUEST_BODY,
INVALID_JSON,
INVALID_REQUEST_BODY,
INVALID_DATE_RANGE,
INVALID_START_OR_END_DATE,
INVALID_OPPONENT,
}
export class ValidationError extends Error {
public readonly code: ValidationErrorCode
constructor(message: string, code: ValidationErrorCode) {
super(message)
this.code = code
}
}
export const validateJSONRequest = async (
body: Record<string, unknown>
): Promise<Reservation> => {
const request = await validateRequestBody(body)
validateRequestDateRange(request.dateRange)
validateRequestOpponent(request.opponent)
return request
}
const validateRequestBody = async (
body?: Record<string, unknown>
): Promise<Reservation> => {
if (body === undefined) {
throw new ValidationError(
'Invalid request',
ValidationErrorCode.UNDEFINED_REQUEST_BODY
)
}
const { username, password, dateRange, opponent }: Record<string, any> = body
if (
!username ||
username.length < 1 ||
!password ||
password.length < 1 ||
!dateRange ||
!dateRange.start ||
!dateRange.end ||
(opponent && opponent.id && opponent.id.length < 1) ||
(opponent && opponent.name && opponent.name.length < 1)
) {
throw new ValidationError(
'Invalid request',
ValidationErrorCode.INVALID_REQUEST_BODY
)
}
const reservation = new Reservation(
{ username, password },
convertDateRangeStringToObject(dateRange),
opponent
)
return reservation
}
const convertDateRangeStringToObject = ({
start,
end,
}: {
start: string
end: string
}): DateRange => ({ start: dayjs(start), end: dayjs(end) })
const validateRequestDateRange = (dateRange: DateRange): void => {
// checking that both dates are valid
const { start, end } = dateRange
if (!start.isValid() || !end.isValid()) {
throw new ValidationError(
'Invalid request',
ValidationErrorCode.INVALID_DATE_RANGE
)
}
// checking that:
// 1. start occurs after now
// 2. start occurs before or same as end
// 3. start and end fall on same YYYY/MM/DD
if (
!start.isAfter(dayjs()) ||
!start.isSameOrBefore(end) ||
start.format('YYYY MM DD') !== end.format('YYYY MM DD')
) {
throw new ValidationError(
'Invalid request',
ValidationErrorCode.INVALID_START_OR_END_DATE
)
}
}
const validateRequestOpponent = (opponent?: Opponent): void => {
if (!opponent) return
const idRegex = /^-1$|^[^-]\d+$/
const nameRegex = /^[A-Za-z0-9 -.'()]+$/
const { id, name } = opponent
if (!idRegex.test(id) || !nameRegex.test(name)) {
throw new ValidationError(
'Invalid request',
ValidationErrorCode.INVALID_OPPONENT
)
}
}

View file

@ -1,352 +0,0 @@
import { Dayjs } from 'dayjs'
import { v4 } from 'uuid'
import dayjs from './dayjs'
import { all, run } from './database'
const RESERVATION_AVAILABLE_WITHIN_DAYS = 7
export interface User {
username: string
password: string
}
export interface Opponent {
id: string
name: string
}
export interface DateRange {
start: Dayjs
end: Dayjs
}
export class Reservation {
public readonly id: string
public readonly user: User
public readonly dateRange: DateRange
public readonly opponent: Opponent
public readonly possibleDates: Dayjs[]
public booked = false
constructor(
user: User,
dateRange: DateRange,
opponent: Opponent,
possibleDates?: Dayjs[],
id = v4()
) {
this.id = id
this.user = user
this.dateRange = dateRange
this.opponent = opponent
this.possibleDates = possibleDates || this.createPossibleDates()
}
private createPossibleDates(): Dayjs[] {
const possibleDates: Dayjs[] = []
const { start, end } = this.dateRange
let possibleDate = dayjs(start).second(0).millisecond(0)
while (possibleDate.isSameOrBefore(end)) {
possibleDates.push(possibleDate)
possibleDate = possibleDate.add(15, 'minute')
}
return possibleDates
}
/**
* Method to check if a reservation is available for reservation in the system
* @returns is reservation date within 7 days
*/
public isAvailableForReservation(): boolean {
return (
Math.floor(this.dateRange.start.diff(dayjs(), 'days', true)) <=
RESERVATION_AVAILABLE_WITHIN_DAYS
)
}
public getAllowedReservationDate(): Dayjs {
return this.dateRange.start
.hour(0)
.minute(0)
.second(0)
.millisecond(0)
.subtract(RESERVATION_AVAILABLE_WITHIN_DAYS, 'days')
}
public toString(obfuscate = false) {
return JSON.stringify(this.format(obfuscate))
}
public format(obfuscate = false) {
return {
id: this.id,
user: {
username: this.user.username,
password: obfuscate ? '???' : this.user.password,
},
opponent: this.opponent,
booked: this.booked,
possibleDates: this.possibleDates.map((date) => date.format()),
dateRange: {
start: this.dateRange.start.format(),
end: this.dateRange.end.format(),
},
}
}
public serializeToJson(): SerializedReservation {
return {
id: this.id,
user: this.user,
opponent: this.opponent,
booked: this.booked,
possibleDates: this.possibleDates.map((date) => date.format()),
dateRange: {
start: this.dateRange.start.format(),
end: this.dateRange.end.format(),
},
}
}
public static deserializeFromJson(
serializedData: SerializedReservation
): Reservation {
const start = dayjs(serializedData.dateRange.start)
const end = dayjs(serializedData.dateRange.end)
return new Reservation(
serializedData.user,
{ start, end },
serializedData.opponent,
Reservation.deserializePossibleDates(serializedData.possibleDates)
)
}
public static deserializePossibleDates(dates: string[]): Dayjs[] {
return dates.map((date) => dayjs(date))
}
public async save() {
await run(
`
INSERT INTO reservations
(
id,
username,
password,
date_range_start,
date_range_end,
opponent_id,
opponent_name
)
VALUES
(
?,
?,
?,
?,
?,
?,
?
)
`,
[
this.id,
this.user.username,
this.user.password,
this.dateRange.start.utc().format('YYYY-MM-DD HH:mm:ss'),
this.dateRange.end.utc().format('YYYY-MM-DD HH:mm:ss'),
this.opponent.id,
this.opponent.name,
]
)
}
public async delete() {
await run(
`
DELETE FROM reservations
WHERE id = ?
`,
[this.id]
)
}
public static async deleteById(id: string) {
await run(
`
DELETE FROM reservations
WHERE id = ?
`,
[id]
)
}
public static async fetchById(id: string): Promise<Reservation | null> {
const response = await all<SqlReservation>(
`
SELECT *
FROM reservations
WHERE id = ?;
`,
[id]
)
if (response.length === 1) {
const sqlReservation = response[0]
const res = new Reservation(
{
username: sqlReservation.username,
password: sqlReservation.password,
},
{
start: dayjs(sqlReservation.date_range_start),
end: dayjs(sqlReservation.date_range_end),
},
{ id: sqlReservation.opponent_id, name: sqlReservation.opponent_name },
undefined,
sqlReservation.id
)
return res
}
return null
}
public static async fetchFirst(): Promise<Reservation | null> {
const response = await all<SqlReservation>(
`
SELECT *
FROM reservations
ORDER BY date_range_start ASC
LIMIT 1;
`
)
if (response.length === 1) {
const sqlReservation = response[0]
const res = new Reservation(
{
username: sqlReservation.username,
password: sqlReservation.password,
},
{
start: dayjs(sqlReservation.date_range_start),
end: dayjs(sqlReservation.date_range_end),
},
{ id: sqlReservation.opponent_id, name: sqlReservation.opponent_name },
undefined,
sqlReservation.id
)
return res
}
return null
}
public static async fetchByDate(
date: Dayjs,
limit = 20
): Promise<Reservation[]> {
const response = await all<SqlReservation>(
`
SELECT *
FROM reservations
WHERE DATE(date_range_start, '-7 day') = ?
ORDER BY date_range_start ASC
LIMIT ?;
`,
[date.format('YYYY-MM-DD'), limit]
)
if (response.length > 0) {
return response.map(
(sqlReservation) =>
new Reservation(
{
username: sqlReservation.username,
password: sqlReservation.password,
},
{
start: dayjs(sqlReservation.date_range_start),
end: dayjs(sqlReservation.date_range_end),
},
{
id: sqlReservation.opponent_id,
name: sqlReservation.opponent_name,
},
undefined,
sqlReservation.id
)
)
}
return []
}
public static async fetchByPage(
pageNumber: number,
pageSize = 50
): Promise<Reservation[]> {
const response = await all<SqlReservation>(
`
SELECT *
FROM reservations
ORDER BY date_range_start ASC
LIMIT ?
OFFSET ?;
`,
[pageSize, pageSize * pageNumber]
)
if (response.length > 0) {
return response.map(
(sqlReservation) =>
new Reservation(
{
username: sqlReservation.username,
password: sqlReservation.password,
},
{
start: dayjs(sqlReservation.date_range_start),
end: dayjs(sqlReservation.date_range_end),
},
{
id: sqlReservation.opponent_id,
name: sqlReservation.opponent_name,
},
undefined,
sqlReservation.id
)
)
}
return []
}
}
export interface SerializedDateRange {
start: string
end: string
}
export interface SerializedReservation {
id: string
user: User
opponent: Opponent
booked: boolean
possibleDates: string[]
dateRange: SerializedDateRange
}
export interface SqlReservation {
id: string
username: string
password: string
date_range_start: string
date_range_end: string
opponent_id: string
opponent_name: string
}

View file

@ -1,48 +0,0 @@
import dayjs from './dayjs'
import { asyncLocalStorage as l, LoggableError } from './logger'
import { Reservation } from './reservation'
import { Runner } from './runner'
let runner: Runner | undefined
const getRunner = () => {
if (!runner) {
runner = new Runner({
headless: true,
})
}
return runner
}
export const reserve = async (
reservations?: Reservation[]
): Promise<boolean> => {
let reservationsToPerform = reservations
if (!reservationsToPerform) {
l.getStore()?.debug('No reservation provided, fetching first in database')
reservationsToPerform =
(await Reservation.fetchByDate(dayjs())) || undefined
}
if (!reservationsToPerform || reservationsToPerform.length === 0) {
l.getStore()?.info('No reservation to perform')
return true
}
for (const reservationToPerform of reservationsToPerform) {
l.getStore()?.debug('Trying to perform reservation', {
reservationToPerform: reservationToPerform.toString(true),
})
const runner = getRunner()
try {
await runner.run(reservationToPerform)
await reservationToPerform.delete()
} catch (error) {
l.getStore()?.error('Failed to perform reservation', {
error: (error as LoggableError).toString(),
})
return false
}
}
return true
}

View file

@ -1,57 +0,0 @@
import { Dayjs } from 'dayjs'
import { asyncLocalStorage } from './logger'
import { Reservation } from './reservation'
import { validateJSONRequest } from './request'
import { reserve } from './reserver'
export interface ScheduledReservation {
reservation: Reservation
scheduledFor?: Dayjs
}
export interface SchedulerResult {
scheduledReservation?: ScheduledReservation
}
export type SchedulerInput = Record<string, unknown>
export const schedule = async (
payload: SchedulerInput
): Promise<SchedulerResult> => {
const logger = asyncLocalStorage.getStore()
logger?.debug('Handling reservation', { payload })
let reservation: Reservation
try {
reservation = await validateJSONRequest(payload)
} catch (err) {
logger?.error('Failed to validate request', { err })
throw err
}
logger?.debug('Successfully validated request', {
reservation: reservation.toString(true),
})
if (!reservation.isAvailableForReservation()) {
logger?.debug(
'Reservation date is more than 7 days away; saving for later reservation'
)
await reservation.save()
return {
scheduledReservation: {
reservation,
scheduledFor: reservation.getAllowedReservationDate(),
},
}
}
logger?.info('Reservation request can be performed now')
await reserve([reservation])
return {
scheduledReservation: { reservation },
}
}

View file

@ -1,59 +0,0 @@
import dayjs from '../common/dayjs'
import { Reservation } from '../common/reservation'
import { Runner } from '../common/runner'
const run = async (request: Record<string, any>) => {
const { user, dateRange, opponent } = request
const reservation = new Reservation(user, dateRange, opponent)
const runner = new Runner({ headless: false })
await runner.run(reservation)
}
// get supplied args
const args = process.argv.filter((_, index) => index >= 2)
if (args.length !== 9) {
console.error(
'Usage: npm run local <username> <password> <year> <month> <day> <startTime> <endTime> <opponentName> <opponentId>'
)
process.exit(1)
}
const [
username,
password,
year,
month,
day,
startTime,
endTime,
opponentName,
opponentId,
] = args
const [startHour, startMinute] = startTime
.split(':')
.map((t) => Number.parseInt(t))
const [endHour, endMinute] = endTime.split(':').map((t) => Number.parseInt(t))
run({
user: {
username: username,
password: password,
},
dateRange: {
start: dayjs(`${year}-${month}-${day}T${startHour}:${startMinute}`),
end: dayjs(`${year}-${month}-${day}T${endHour}:${endMinute}`),
},
opponent: {
name: opponentName,
id: opponentId,
},
})
.then(() => {
console.log('Success')
process.exit(0)
})
.catch((e) => {
console.error(e)
process.exit(1)
})

16
src/logger/middleware.ts Normal file
View file

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

9
src/logger/module.ts Normal file
View file

@ -0,0 +1,9 @@
import { Module } from '@nestjs/common'
import { LoggerService } from './service'
import { LoggerMiddleware } from './middleware'
@Module({
providers: [LoggerService, LoggerMiddleware],
exports: [LoggerService, LoggerMiddleware],
})
export class LoggerModule {}

14
src/logger/service.ts Normal file
View file

@ -0,0 +1,14 @@
import { ConsoleLogger, Injectable, Scope } from '@nestjs/common'
@Injectable({ scope: Scope.REQUEST })
export class LoggerService extends ConsoleLogger {
log(message: any, ...optionalParams: any[]) {
super.log(message, ...optionalParams)
}
error(message: any, ...optionalParams: any[]) {
super.error(message, ...optionalParams)
}
warn(message: any, ...optionalParams: any[]) {
super.warn(message, ...optionalParams)
}
}

15
src/main.ts Normal file
View file

@ -0,0 +1,15 @@
import { NestFactory } from '@nestjs/core'
import { AppModule } from './app.module'
import { ConfigService } from '@nestjs/config'
import { CustomResponseTransformInterceptor } from './common/customResponse'
async function bootstrap() {
const app = await NestFactory.create(AppModule, { abortOnError: false })
const config = app.get(ConfigService)
const port = config.get('PORT')
app.enableShutdownHooks()
app.useGlobalInterceptors(new CustomResponseTransformInterceptor())
await app.listen(port)
}
bootstrap()

View file

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

View file

@ -0,0 +1,73 @@
import {
Controller,
Get,
Post,
Body,
Param,
Delete,
Inject,
UsePipes,
ValidationPipe,
UseInterceptors,
ClassSerializerInterceptor,
Query,
Response,
} from '@nestjs/common'
import { InjectQueue } from '@nestjs/bull'
import { Dayjs } from 'dayjs'
import { Queue } from 'bull'
import { RESERVATIONS_QUEUE_NAME } from './config'
import { Reservation } from './entity'
import { ReservationsService } from './service'
import { LoggerService } from '../logger/service'
@Controller('reservations')
@UseInterceptors(ClassSerializerInterceptor)
export class ReservationsController {
constructor(
@Inject(ReservationsService)
private reservationsService: ReservationsService,
@InjectQueue(RESERVATIONS_QUEUE_NAME)
private reservationsQueue: Queue<Reservation>,
@Inject(LoggerService)
private loggerService: LoggerService,
) {}
@Get()
getReservations(@Query('date') date?: Dayjs) {
if (date) {
return this.reservationsService.getByDate(date)
}
return this.reservationsService.getAll()
}
@Get(':id')
getReservationById(@Param('id') id: string) {
return this.reservationsService.getById(id)
}
@Post()
@UsePipes(
new ValidationPipe({
transform: true,
transformOptions: { groups: ['password'] },
groups: ['password'],
}),
)
async createReservation(@Body() reservation: Reservation) {
if (!reservation.isAvailableForReservation()) {
await this.reservationsService.create(reservation)
return 'Reservation saved'
}
await this.reservationsQueue.add(reservation)
return 'Reservation queued'
}
@Delete(':id')
async deleteReservationById(@Param('id') id: string) {
await this.reservationsService.deleteById(id)
return 'Reservation deleted'
}
}

31
src/reservations/cron.ts Normal file
View file

@ -0,0 +1,31 @@
import { InjectQueue } from '@nestjs/bull'
import { Inject, Injectable } from '@nestjs/common'
import { Cron, CronExpression } from '@nestjs/schedule'
import { Queue } from 'bull'
import { RESERVATIONS_QUEUE_NAME } from './config'
import { ReservationsService } from './service'
import { LoggerService } from '../logger/service'
@Injectable()
export class ReservationsCronService {
constructor(
@Inject(ReservationsService)
private readonly reservationService: ReservationsService,
@InjectQueue(RESERVATIONS_QUEUE_NAME)
private readonly reservationsQueue: Queue,
@Inject(LoggerService)
private readonly logger: LoggerService,
) {}
@Cron(CronExpression.EVERY_DAY_AT_7AM, {
name: 'handleDailyReservations',
timeZone: 'Europe/Amsterdam',
})
async handleDailyReservations() {
const reservationsToPerform = await this.reservationService.getByDate()
this.logger.log(`Found ${reservationsToPerform.length} reservations to perform`)
await this.reservationsQueue.addBulk(reservationsToPerform.map((r) => ({ data: r })))
}
}

View file

@ -0,0 +1,71 @@
import { Exclude, Transform, Type } from 'class-transformer'
import { Dayjs } from 'dayjs'
import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm'
import dayjs from '../common/dayjs'
@Entity({ name: 'reservations' })
export class Reservation {
@PrimaryGeneratedColumn('uuid')
id: string
@Column('varchar', { length: 64, nullable: false })
username: string
@Transform(({ value, options: { groups = [] } }) =>
groups.includes('password') ? value : '***',
)
@Column('varchar', { length: 255, nullable: false })
password: string
@Column('datetime', { nullable: false })
@Type(() => Dayjs)
@Transform(({ value }) => dayjs(value).format())
dateRangeStart: Dayjs
@Column('datetime', { nullable: false })
@Type(() => Dayjs)
@Transform(({ value }) => dayjs(value).format())
dateRangeEnd: Dayjs
@Column('varchar', { length: 32, nullable: false })
opponentId: string
@Column('varchar', { length: 255, nullable: false })
opponentName: string
constructor(partial: Partial<Reservation>) {
Object.assign(this, partial)
}
@Exclude()
public createPossibleDates(): Dayjs[] {
const possibleDates: Dayjs[] = []
let possibleDate = dayjs(this.dateRangeStart).second(0).millisecond(0)
while (possibleDate.isSameOrBefore(this.dateRangeEnd)) {
possibleDates.push(possibleDate)
possibleDate = possibleDate.add(15, 'minute')
}
return possibleDates
}
/**
* Method to check if a reservation is available for reservation in the system
* @returns is reservation date within 7 days
*/
@Exclude()
public isAvailableForReservation(): boolean {
return dayjs().diff(this.dateRangeStart, 'day') <= 7
}
@Exclude()
public getAllowedReservationDate(): Dayjs {
return this.dateRangeStart
.hour(0)
.minute(0)
.second(0)
.millisecond(0)
.subtract(7, 'days')
}
}

View file

@ -0,0 +1,25 @@
import { BullModule } from '@nestjs/bull'
import { Module } from '@nestjs/common'
import { ConfigModule } from '@nestjs/config'
import { TypeOrmModule } from '@nestjs/typeorm'
import { Reservation } from './entity'
import { ReservationsController } from './controller'
import config, { RESERVATIONS_QUEUE_NAME } from './config'
import { ReservationsService } from './service'
import { ReservationsWorker } from './worker'
import { LoggerModule } from '../logger/module'
import { RunnerModule } from '../runner/module'
@Module({
imports: [
LoggerModule,
TypeOrmModule.forFeature([Reservation]),
BullModule.registerQueue({ name: RESERVATIONS_QUEUE_NAME }),
RunnerModule,
ConfigModule.forFeature(config),
],
controllers: [ReservationsController],
providers: [ReservationsService, ReservationsWorker],
})
export class ReservationsModule {}

View file

@ -0,0 +1,35 @@
import { Injectable } from '@nestjs/common'
import { InjectRepository } from '@nestjs/typeorm'
import { Repository } from 'typeorm'
import { Reservation } from './entity'
import dayjs from '../common/dayjs'
@Injectable()
export class ReservationsService {
constructor(
@InjectRepository(Reservation)
private reservationsRepository: Repository<Reservation>,
) {}
getAll() {
return this.reservationsRepository.find()
}
getById(id: string) {
return this.reservationsRepository.findOneBy({ id })
}
getByDate(date = dayjs()) {
return this.reservationsRepository.createQueryBuilder()
.where(`DATE(dateRangeStart, '-7 day') = DATE(:date)`, { date })
.getMany()
}
create(reservation: Reservation) {
return this.reservationsRepository.save(reservation)
}
async deleteById(id: string) {
await this.reservationsRepository.delete({ id })
}
}

View file

@ -0,0 +1,30 @@
import { Inject } from '@nestjs/common'
import { Process, Processor } from '@nestjs/bull'
import { Job } from 'bull'
import { instanceToPlain, plainToInstance } from 'class-transformer'
import { RESERVATIONS_QUEUE_NAME } from './config'
import { Reservation } from './entity'
import { BaanReserverenService } from '../runner/baanreserveren/service'
import { LoggerService } from '../logger/service'
@Processor(RESERVATIONS_QUEUE_NAME)
export class ReservationsWorker {
constructor(
@Inject(BaanReserverenService)
private readonly brService: BaanReserverenService,
@Inject(LoggerService)
private readonly logger: LoggerService,
) {}
@Process()
async handleReservationJob(job: Job<Reservation>) {
const reservation = plainToInstance(Reservation, job.data, { groups: ['password'] })
this.logger.log('Handling reservation', { reservation: instanceToPlain(reservation) })
await this.performReservation(reservation)
}
async performReservation(reservation: Reservation) {
await this.brService.performReservation(reservation)
}
}

View file

@ -1,15 +1,17 @@
import { Inject, Injectable } from '@nestjs/common'
import { Dayjs } from 'dayjs'
import dayjs from './dayjs'
import puppeteer, {
Browser,
BrowserConnectOptions,
BrowserLaunchArgumentOptions,
ElementHandle,
LaunchOptions,
Page,
} from 'puppeteer'
import { asyncLocalStorage as l, LoggableError } from './logger'
import { Opponent, Reservation } from './reservation'
import { ElementHandle, Page } from 'puppeteer'
import { RunnerService } from '../service'
import { EmptyPage } from '../pages/empty'
import dayjs from '../../common/dayjs'
import { Reservation } from '../../reservations/entity'
const baanReserverenRoot = 'https://squashcity.baanreserveren.nl'
export enum BaanReserverenUrls {
Reservations = '/reservations',
Logout = '/auth/logout',
}
enum SessionAction {
NoAction,
@ -17,67 +19,25 @@ enum SessionAction {
Login,
}
interface RunnerSession {
interface BaanReserverenSession {
username: string
loggedInAt: Dayjs
startedAt: Dayjs
}
export class Runner {
private browser?: Browser
private page?: Page
private options?: LaunchOptions &
BrowserLaunchArgumentOptions &
BrowserConnectOptions
private session?: RunnerSession
@Injectable()
export class BaanReserverenService {
private session: BaanReserverenSession | null = null
constructor(
options?: LaunchOptions &
BrowserLaunchArgumentOptions &
BrowserConnectOptions
) {
const defaultArgs = ['--disable-setuid-sandbox', '--no-sandbox']
this.options = { args: defaultArgs, ...options }
}
@Inject(RunnerService)
private readonly runnerService: RunnerService,
public async test() {
l.getStore()?.debug('Runner test')
try {
if (!this.browser) {
this.browser = await puppeteer.launch(this.options)
}
} catch (error: unknown) {
l.getStore()?.error('Browser error', { error: (error as Error).message })
throw new PuppeteerBrowserLaunchError(error as Error)
}
@Inject(EmptyPage)
private readonly page: Page,
) {}
try {
this.page = await this.browser?.newPage()
} catch (error) {
l.getStore()?.error('Page error', { error: (error as Error).message })
throw new PuppeteerNewPageError(error as Error)
}
}
public async run(reservation: Reservation) {
try {
if (!this.browser) {
l.getStore()?.debug('Launching browser')
this.browser = await puppeteer.launch(this.options)
}
} catch (error) {
throw new PuppeteerBrowserLaunchError(error as Error)
}
await this.startSession(reservation)
await this.makeReservation(reservation)
}
private async checkSession(username: string): Promise<SessionAction> {
if (
this.page
?.url()
.startsWith('https://squashcity.baanreserveren.nl/reservations')
) {
l.getStore()?.info('Already logged in', { username })
private checkSession(username: string) {
if (this.page.url().endsWith(BaanReserverenUrls.Reservations)) {
return this.session?.username !== username
? SessionAction.Logout
: SessionAction.NoAction
@ -85,83 +45,69 @@ export class Runner {
return SessionAction.Login
}
private async startSession(reservation: Reservation) {
if (!this.page) {
try {
this.page = await this.browser?.newPage()
} catch (error) {
throw new PuppeteerNewPageError(error as Error)
}
private startSession(username: string) {
if (this.session && this.session.username !== username) {
throw new Error('Session already started')
}
this.page
?.goto('https://squashcity.baanreserveren.nl/reservations')
.catch((e: Error) => {
throw new RunnerNewSessionError(e)
})
if (this.session?.username === username) {
return
}
const sessionAction = await this.checkSession(reservation.user.username)
switch (sessionAction) {
case SessionAction.Login: {
await this.login(reservation.user.username, reservation.user.password)
break
}
case SessionAction.Logout: {
this.session = {
username,
startedAt: dayjs(),
}
}
private endSession() {
this.session = null
}
private async login(username: string, password: string) {
await this.page
.waitForSelector('input[name=username]')
.then((i) => i?.type(username))
.catch((e: Error) => {
// throw new RunnerLoginUsernameInputError(e)
})
await this.page
.$('input[name=password]')
.then((i) => i?.type(password))
.catch((e: Error) => {
// throw new RunnerLoginPasswordInputError(e)
})
await this.page
.$('button')
.then((b) => b?.click())
.catch((e: Error) => {
// throw new RunnerLoginSubmitError(e)
})
this.startSession(username)
}
private async logout() {
await this.page.goto(`${baanReserverenRoot}${BaanReserverenUrls.Logout}`)
this.endSession()
}
private async init(reservation: Reservation) {
await this.page.goto(baanReserverenRoot)
const action = await this.checkSession(reservation.username)
switch (action) {
case SessionAction.Logout:
await this.logout()
await this.login(reservation.user.username, reservation.user.password)
await this.login(reservation.username, reservation.password)
break
case SessionAction.Login:
await this.login(reservation.username, reservation.password)
break
}
case SessionAction.NoAction:
default:
break
}
}
private async login(username: string, password: string) {
l.getStore()?.debug('Logging in', { username })
await this.page
?.waitForSelector('input[name=username]')
.then((i) => i?.type(username))
.catch((e: Error) => {
throw new RunnerLoginUsernameInputError(e)
})
await this.page
?.$('input[name=password]')
.then((i) => i?.type(password))
.catch((e: Error) => {
throw new RunnerLoginPasswordInputError(e)
})
await this.page
?.$('button')
.then((b) => b?.click())
.catch((e: Error) => {
throw new RunnerLoginSubmitError(e)
})
this.session = {
loggedInAt: dayjs(),
username,
}
}
private async logout() {
l.getStore()?.debug('Logging out', { username: this.session?.username })
await this.page
?.goto('https://squashcity.baanreserveren.nl/auth/logout')
.catch((e: Error) => {
throw new RunnerLogoutError(e)
})
this.session = undefined
}
private async makeReservation(reservation: Reservation) {
await this.navigateToDay(reservation.dateRange.start)
await this.selectAvailableTime(reservation)
await this.selectOpponent(reservation.opponent)
await this.confirmReservation()
reservation.booked = true
}
private getLastVisibleDay(): Dayjs {
const lastDayOfMonth = dayjs().add(1, 'month').set('date', 0)
let daysToAdd = 0
@ -177,10 +123,7 @@ export class Runner {
}
private async navigateToDay(date: Dayjs): Promise<void> {
l.getStore()?.debug(`Navigating to ${date.format()}`)
if (this.getLastVisibleDay().isBefore(date)) {
l.getStore()?.debug('Date is on different page, increase month')
await this.page
?.waitForSelector('td.month.next')
.then((d) => d?.click())
@ -210,14 +153,12 @@ export class Runner {
})
}
private async selectAvailableTime(res: Reservation): Promise<void> {
l.getStore()?.debug('Selecting available time', {
reservation: res.toString(true),
})
private async selectAvailableTime(reservation: Reservation): Promise<void> {
let freeCourt: ElementHandle | null | undefined
let i = 0
while (i < res.possibleDates.length && !freeCourt) {
const possibleDate = res.possibleDates[i]
const possibleDates = reservation.createPossibleDates()
while (i < possibleDates.length && !freeCourt) {
const possibleDate = possibleDates[i]
const timeString = possibleDate.format('HH:mm')
const selector =
`tr[data-time='${timeString}']` + `> td.free[rowspan='3'][type='free']`
@ -234,14 +175,13 @@ export class Runner {
})
}
private async selectOpponent(opponent: Opponent): Promise<void> {
l.getStore()?.debug('Selecting opponent', { opponent })
private async selectOpponent(id: string, name: string): Promise<void> {
const player2Search = await this.page
?.waitForSelector('tr.res-make-player-2 > td > input')
.catch((e: Error) => {
throw new RunnerOpponentSearchError(e)
})
await player2Search?.type(opponent.name).catch((e: Error) => {
await player2Search?.type(name).catch((e: Error) => {
throw new RunnerOpponentSearchInputError(e)
})
await this.page?.waitForNetworkIdle().catch((e: Error) => {
@ -249,7 +189,7 @@ export class Runner {
})
await this.page
?.$('select.br-user-select[name="players[2]"]')
.then((d) => d?.select(opponent.id))
.then((d) => d?.select(id))
.catch((e: Error) => {
throw new RunnerOpponentSearchSelectionError(e)
})
@ -269,9 +209,17 @@ export class Runner {
throw new RunnerReservationConfirmSubmitError(e)
})
}
public async performReservation(reservation: Reservation) {
await this.init(reservation)
await this.navigateToDay(reservation.dateRangeStart)
await this.selectAvailableTime(reservation)
await this.selectOpponent(reservation.opponentId, reservation.opponentName)
// await this.confirmReservation()
}
}
export class RunnerError extends LoggableError {
export class RunnerError extends Error {
constructor(error: Error) {
super(error.message)
this.stack = error.stack
@ -295,7 +243,7 @@ export class RunnerNavigationDayError extends RunnerError {}
export class RunnerNavigationSelectionError extends RunnerError {}
export class RunnerCourtSelectionError extends RunnerError {}
export class NoCourtAvailableError extends LoggableError {}
export class NoCourtAvailableError extends Error {}
export class RunnerOpponentSearchError extends RunnerError {}
export class RunnerOpponentSearchInputError extends RunnerError {}

View file

@ -0,0 +1,324 @@
// import { Dayjs } from 'dayjs'
// import { Injectable } from '@nestjs/common'
// import dayjs from '../common/dayjs'
// import puppeteer, {
// Browser,
// BrowserConnectOptions,
// BrowserLaunchArgumentOptions,
// ElementHandle,
// LaunchOptions,
// Page,
// } from 'puppeteer'
// import { asyncLocalStorage as l, LoggableError } from '../common/logger'
// import { Reservation } from '../reservations/model'
// import { Opponent } from '../common/opponent'
// enum SessionAction {
// NoAction,
// Logout,
// Login,
// }
// interface RunnerSession {
// username: string
// loggedInAt: Dayjs
// }
// export class RunnerInstance {
// private static runner: Runner
// static getRunner(): Runner {
// if (!RunnerInstance.runner) {
// RunnerInstance.runner = new Runner({
// headless: true,
// })
// }
// return RunnerInstance.runner
// }
// }
// @Injectable()
// export class Runner {
// private browser?: Browser
// private page?: Page
// private options?: LaunchOptions &
// BrowserLaunchArgumentOptions &
// BrowserConnectOptions
// private session?: RunnerSession
// constructor(
// options?: LaunchOptions &
// BrowserLaunchArgumentOptions &
// BrowserConnectOptions
// ) {
// const defaultArgs = ['--disable-setuid-sandbox', '--no-sandbox']
// this.options = { args: defaultArgs, ...options }
// }
// public async test() {
// l.getStore()?.debug('Runner test')
// try {
// if (!this.browser) {
// this.browser = await puppeteer.launch(this.options)
// }
// } catch (error: unknown) {
// l.getStore()?.error('Browser error', { error: (error as Error).message })
// throw new PuppeteerBrowserLaunchError(error as Error)
// }
// try {
// this.page = await this.browser?.newPage()
// } catch (error) {
// l.getStore()?.error('Page error', { error: (error as Error).message })
// throw new PuppeteerNewPageError(error as Error)
// }
// }
// public async run(reservations: Reservation[]) {
// try {
// if (!this.browser) {
// l.getStore()?.debug('Launching browser')
// this.browser = await puppeteer.launch(this.options)
// }
// } catch (error) {
// throw new PuppeteerBrowserLaunchError(error as Error)
// }
// for (const reservation of reservations) {
// await this.startSession(reservation)
// await this.makeReservation(reservation)
// }
// }
// private async checkSession(username: string): Promise<SessionAction> {
// if (
// this.page
// ?.url()
// .startsWith('https://squashcity.baanreserveren.nl/reservations')
// ) {
// l.getStore()?.info('Already logged in', { username })
// return this.session?.username !== username
// ? SessionAction.Logout
// : SessionAction.NoAction
// }
// return SessionAction.Login
// }
// private async startSession(reservation: Reservation) {
// if (!this.page) {
// try {
// this.page = await this.browser?.newPage()
// } catch (error) {
// throw new PuppeteerNewPageError(error as Error)
// }
// }
// this.page
// ?.goto('https://squashcity.baanreserveren.nl/reservations')
// .catch((e: Error) => {
// throw new RunnerNewSessionError(e)
// })
// const sessionAction = await this.checkSession(reservation.user.username)
// switch (sessionAction) {
// case SessionAction.Login: {
// await this.login(reservation.user.username, reservation.user.password)
// break
// }
// case SessionAction.Logout: {
// await this.logout()
// await this.login(reservation.user.username, reservation.user.password)
// break
// }
// case SessionAction.NoAction:
// default:
// break
// }
// }
// private async login(username: string, password: string) {
// l.getStore()?.debug('Logging in', { username })
// await this.page
// ?.waitForSelector('input[name=username]')
// .then((i) => i?.type(username))
// .catch((e: Error) => {
// throw new RunnerLoginUsernameInputError(e)
// })
// await this.page
// ?.$('input[name=password]')
// .then((i) => i?.type(password))
// .catch((e: Error) => {
// throw new RunnerLoginPasswordInputError(e)
// })
// await this.page
// ?.$('button')
// .then((b) => b?.click())
// .catch((e: Error) => {
// throw new RunnerLoginSubmitError(e)
// })
// this.session = {
// loggedInAt: dayjs(),
// username,
// }
// }
// private async logout() {
// l.getStore()?.debug('Logging out', { username: this.session?.username })
// await this.page
// ?.goto('https://squashcity.baanreserveren.nl/auth/logout')
// .catch((e: Error) => {
// throw new RunnerLogoutError(e)
// })
// this.session = undefined
// }
// private async makeReservation(reservation: Reservation) {
// await this.navigateToDay(reservation.dateRange.start)
// await this.selectAvailableTime(reservation)
// await this.selectOpponent(reservation.opponent)
// await this.confirmReservation()
// reservation.booked = true
// }
// private getLastVisibleDay(): Dayjs {
// const lastDayOfMonth = dayjs().add(1, 'month').set('date', 0)
// let daysToAdd = 0
// switch (lastDayOfMonth.day()) {
// case 0:
// daysToAdd = 0
// break
// default:
// daysToAdd = 7 - lastDayOfMonth.day()
// break
// }
// return lastDayOfMonth.add(daysToAdd, 'day')
// }
// private async navigateToDay(date: Dayjs): Promise<void> {
// l.getStore()?.debug(`Navigating to ${date.format()}`)
// if (this.getLastVisibleDay().isBefore(date)) {
// l.getStore()?.debug('Date is on different page, increase month')
// await this.page
// ?.waitForSelector('td.month.next')
// .then((d) => d?.click())
// .catch((e: Error) => {
// throw new RunnerNavigationMonthError(e)
// })
// }
// await this.page
// ?.waitForSelector(
// `td#cal_${date.get('year')}_${date.get('month') + 1}_${date.get(
// 'date'
// )}`
// )
// .then((d) => d?.click())
// .catch((e: Error) => {
// throw new RunnerNavigationDayError(e)
// })
// await this.page
// ?.waitForSelector(
// `td#cal_${date.get('year')}_${date.get('month') + 1}_${date.get(
// 'date'
// )}.selected`
// )
// .catch((e: Error) => {
// throw new RunnerNavigationSelectionError(e)
// })
// }
// private async selectAvailableTime(res: Reservation): Promise<void> {
// l.getStore()?.debug('Selecting available time', {
// reservation: res.toString(true),
// })
// let freeCourt: ElementHandle | null | undefined
// let i = 0
// while (i < res.possibleDates.length && !freeCourt) {
// const possibleDate = res.possibleDates[i]
// const timeString = possibleDate.format('HH:mm')
// const selector =
// `tr[data-time='${timeString}']` + `> td.free[rowspan='3'][type='free']`
// freeCourt = await this.page?.$(selector)
// i++
// }
// if (!freeCourt) {
// throw new NoCourtAvailableError()
// }
// await freeCourt.click().catch((e: Error) => {
// throw new RunnerCourtSelectionError(e)
// })
// }
// private async selectOpponent(opponent: Opponent): Promise<void> {
// l.getStore()?.debug('Selecting opponent', { opponent })
// const player2Search = await this.page
// ?.waitForSelector('tr.res-make-player-2 > td > input')
// .catch((e: Error) => {
// throw new RunnerOpponentSearchError(e)
// })
// await player2Search?.type(opponent.name).catch((e: Error) => {
// throw new RunnerOpponentSearchInputError(e)
// })
// await this.page?.waitForNetworkIdle().catch((e: Error) => {
// throw new RunnerOpponentSearchNetworkError(e)
// })
// await this.page
// ?.$('select.br-user-select[name="players[2]"]')
// .then((d) => d?.select(opponent.id))
// .catch((e: Error) => {
// throw new RunnerOpponentSearchSelectionError(e)
// })
// }
// private async confirmReservation(): Promise<void> {
// await this.page
// ?.$('input#__make_submit')
// .then((b) => b?.click())
// .catch((e: Error) => {
// throw new RunnerReservationConfirmButtonError(e)
// })
// await this.page
// ?.waitForSelector('input#__make_submit2')
// .then((b) => b?.click())
// .catch((e: Error) => {
// throw new RunnerReservationConfirmSubmitError(e)
// })
// }
// }
// export class RunnerError extends LoggableError {
// constructor(error: Error) {
// super(error.message)
// this.stack = error.stack
// }
// }
// export class PuppeteerError extends RunnerError {}
// export class PuppeteerBrowserLaunchError extends PuppeteerError {}
// export class PuppeteerNewPageError extends PuppeteerError {}
// export class RunnerNewSessionError extends RunnerError {}
// export class RunnerLogoutError extends RunnerError {}
// export class RunnerLoginNavigationError extends RunnerError {}
// export class RunnerLoginUsernameInputError extends RunnerError {}
// export class RunnerLoginPasswordInputError extends RunnerError {}
// export class RunnerLoginSubmitError extends RunnerError {}
// export class RunnerNavigationMonthError extends RunnerError {}
// export class RunnerNavigationDayError extends RunnerError {}
// export class RunnerNavigationSelectionError extends RunnerError {}
// export class RunnerCourtSelectionError extends RunnerError {}
// export class NoCourtAvailableError extends LoggableError {}
// export class RunnerOpponentSearchError extends RunnerError {}
// export class RunnerOpponentSearchInputError extends RunnerError {}
// export class RunnerOpponentSearchNetworkError extends RunnerError {}
// export class RunnerOpponentSearchSelectionError extends RunnerError {}
// export class RunnerReservationConfirmButtonError extends RunnerError {}
// export class RunnerReservationConfirmSubmitError extends RunnerError {}

14
src/runner/module.ts Normal file
View file

@ -0,0 +1,14 @@
import { Module } from '@nestjs/common'
import { BullModule } from '@nestjs/bull'
import { EmptyPageFactory } from './pages/empty'
import { RunnerService } from './service'
import { BaanReserverenService } from './baanreserveren/service'
import { LoggerModule } from '../logger/module'
@Module({
providers: [RunnerService, BaanReserverenService, EmptyPageFactory],
imports: [LoggerModule, BullModule.registerQueue({ name: 'reservations' })],
exports: [EmptyPageFactory, BaanReserverenService],
})
export class RunnerModule {}

17
src/runner/pages/empty.ts Normal file
View file

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

35
src/runner/router.ts Normal file
View file

@ -0,0 +1,35 @@
// import { IncomingMessage, ServerResponse } from 'http'
// import { Router } from '../server/http/routers/index'
// import { Runner } from './controller'
// export class RunnerRouter extends Router {
// public async handleRequest(
// req: IncomingMessage,
// res: ServerResponse<IncomingMessage>
// ) {
// const { url = '', method } = req
// const [route] = url.split('?')
// switch (true) {
// case /^\/runner\/test$/.test(route) && method === 'GET': {
// await this.GET_runner_test(req, res)
// break
// }
// default: {
// this.handle404(req, res)
// }
// }
// }
// private async GET_runner_test(
// _req: IncomingMessage,
// res: ServerResponse<IncomingMessage>
// ) {
// try {
// const runner = new Runner({ headless: true })
// await runner.test()
// res.writeHead(200, 'OK')
// } catch (e) {
// res.writeHead(500, 'Internal server error')
// }
// }
// }

115
src/runner/service.ts Normal file
View file

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

View file

@ -1,75 +0,0 @@
import {
schedule,
ScheduledTask,
ScheduleOptions,
getTasks as getCronTasks,
} from 'node-cron'
import { v4 } from 'uuid'
import { asyncLocalStorage, Logger, LogLevel } from '../common/logger'
import { reserve } from '../common/reserver'
const tasks: ScheduledTask[] = []
const getTaskConfig = (name: string): ScheduleOptions => ({
name,
recoverMissedExecutions: false,
timezone: 'Europe/Amsterdam',
})
const logger = new Logger('cron', 'default', LogLevel.DEBUG)
export const getStatus = () => {
const tasks = getCronTasks()
if (tasks.get('reserver cron')) {
return true
}
return false
}
export const startTasks = () => {
try {
if (tasks.length === 0) {
const task = schedule(
'0 7 * * *',
async (timestamp) => {
asyncLocalStorage.run(
new Logger('cron', v4(), LogLevel.DEBUG),
async () => {
const childLogger = asyncLocalStorage.getStore()
childLogger?.info('Running cron job', { timestamp })
try {
const result = await reserve()
if (!result) {
throw new Error('Failed to complete reservation')
}
childLogger?.info('Completed running cron job')
} catch (error) {
childLogger?.error('Error running cron job', {
error: (error as Error).message,
})
stopTasks()
}
}
)
},
getTaskConfig('reserver cron')
)
logger.debug('Started cron task')
tasks.push(task)
}
} catch (error) {
logger.error('Failed to start tasks', { error: (error as Error).message })
}
}
export const stopTasks = () => {
try {
if (tasks.length > 0) {
tasks.map((task) => task.stop())
logger.debug('Stopped cron tasks')
}
} catch (error) {
logger.error('Failed to stop tasks', { error: (error as Error).message })
}
}

View file

@ -1,51 +0,0 @@
import http from 'http'
import { v4 } from 'uuid'
import { asyncLocalStorage, Logger, LogLevel } from '../../common/logger'
import { CronRouter } from './routes/cron'
import { ReservationsRouter } from './routes/reservations'
import { RunnerRouter } from './routes/runner'
const cronRouter = new CronRouter()
const reservationsRouter = new ReservationsRouter()
const runnerRouter = new RunnerRouter()
// Handles POST requests to /reservations
const server = http.createServer(async (req, res) => {
await asyncLocalStorage.run(
new Logger('request', v4(), LogLevel.DEBUG),
async () => {
const logger = asyncLocalStorage.getStore()
const { url, method } = req
logger?.debug('Incoming request', { url, method })
if (!url || !method) {
logger?.info('Weird request', { url, method })
res.writeHead(400, 'Bad request')
res.end()
return
}
switch (true) {
case /^\/cron/.test(url): {
await cronRouter.handleRequest(req, res)
break
}
case /^\/reservations/.test(url): {
await reservationsRouter.handleRequest(req, res)
break
}
case /^\/runner/.test(url): {
await runnerRouter.handleRequest(req, res)
break
}
default: {
logger?.info('Not found', { url, method, location: 'root' })
res.writeHead(404, 'Not found')
}
}
res.end()
}
)
})
export default server

View file

@ -1,61 +0,0 @@
import { IncomingMessage, ServerResponse } from 'http'
import { asyncLocalStorage as l } from '../../../common/logger'
import { Router } from './index'
import { getStatus, startTasks, stopTasks } from '../../cron'
export class CronRouter extends Router {
public async handleRequest(
req: IncomingMessage,
res: ServerResponse<IncomingMessage>
) {
const { url = '', method } = req
const [route] = url.split('?')
switch (true) {
case /^\/cron\/?$/.test(route) && method === 'GET': {
await this.GET_cron(req, res)
break
}
case /^\/cron\/enable$/.test(route) && method === 'POST': {
await this.POST_cron_enable(req, res)
break
}
case /^\/cron\/disable$/.test(route) && method === 'POST': {
await this.POST_cron_disable(req, res)
break
}
default: {
this.handle404(req, res)
}
}
}
private async GET_cron(
_req: IncomingMessage,
res: ServerResponse<IncomingMessage>
) {
l.getStore()?.debug('Checking cron status')
const status = getStatus()
res.writeHead(200, undefined, {
'content-type': 'text/plain',
})
res.write(status ? 'Enabled' : 'Disabled')
}
private async POST_cron_enable(
_req: IncomingMessage,
res: ServerResponse<IncomingMessage>
) {
l.getStore()?.debug('Enabling cron')
startTasks()
res.writeHead(200)
}
private async POST_cron_disable(
_req: IncomingMessage,
res: ServerResponse<IncomingMessage>
) {
l.getStore()?.debug('Disabling cron')
stopTasks()
res.writeHead(200)
}
}

View file

@ -1,50 +0,0 @@
import { IncomingMessage, ServerResponse } from 'http'
import {
asyncLocalStorage,
asyncLocalStorage as l,
LoggableError,
} from '../../../common/logger'
import { parseJson } from '../../utils'
export abstract class Router {
protected async parseJsonContent(req: IncomingMessage) {
let jsonBody: Record<string, unknown>
const contentType = req.headers['content-type'] || 'application/json'
if (contentType !== 'application/json') {
l.getStore()?.error('Invalid content type', { contentType })
throw new RouterUnsupportedContentTypeError()
}
try {
const length = Number.parseInt(req.headers['content-length'] || '0')
const encoding = req.readableEncoding || 'utf8'
jsonBody = await parseJson(length, encoding, req)
} catch (error: unknown) {
l.getStore()?.error('Failed to parse body', {
error: (error as Error).message,
stack: (error as Error).stack,
})
throw new RouterBadRequestError()
}
return jsonBody
}
protected handle404(
req: IncomingMessage,
res: ServerResponse<IncomingMessage>
) {
const { url, method } = req
asyncLocalStorage.getStore()?.info('Not found', { url, method })
res.writeHead(404, 'Not found')
}
public abstract handleRequest(
req: IncomingMessage,
res: ServerResponse<IncomingMessage>
): Promise<void>
}
export class RouterError extends LoggableError {}
export class RouterBadRequestError extends RouterError {}
export class RouterUnsupportedContentTypeError extends RouterError {}

View file

@ -1,137 +0,0 @@
import { IncomingMessage, ServerResponse } from 'http'
import { schedule } from '../../../common/scheduler'
import { asyncLocalStorage as l } from '../../../common/logger'
import { Router } from './index'
import { Reservation } from '../../../common/reservation'
import { parse } from 'querystring'
import dayjs from '../../../common/dayjs'
import { Dayjs } from 'dayjs'
export class ReservationsRouter extends Router {
public async handleRequest(
req: IncomingMessage,
res: ServerResponse<IncomingMessage>
) {
const { url = '', method } = req
const [route] = url.split('?')
switch (true) {
case /^\/reservations$/.test(route) && method === 'GET': {
await this.GET_reservations(req, res)
break
}
case /^\/reservations$/.test(route) && method === 'POST': {
await this.POST_reservations(req, res)
break
}
case /^\/reservations\/\S+$/.test(route) && method === 'DELETE': {
await this.DELETE_reservation(req, res)
break
}
default: {
this.handle404(req, res)
}
}
}
private async GET_reservations(
req: IncomingMessage,
res: ServerResponse<IncomingMessage>
) {
const { url = '' } = req
const [, queryParams] = url.split('?')
let pageNumber = 0
let pageSize = 0
let date: Dayjs | undefined = undefined
const {
pageNumber: rawPageNumber = '0',
pageSize: rawPageSize = '50',
date: rawDate,
} = parse(queryParams)
if (typeof rawPageNumber === 'string') {
pageNumber = Number.parseInt(rawPageNumber)
} else {
pageNumber = 0
}
if (typeof rawPageSize === 'string') {
pageSize = Math.min(Number.parseInt(rawPageSize), 50)
} else {
pageSize = 50
}
if (typeof rawDate === 'string') {
date = dayjs(rawDate)
}
l.getStore()?.debug('Fetching reservations', {
pageNumber,
pageSize,
date: date?.format(),
})
try {
let reservations: Reservation[]
if (date) {
reservations = await Reservation.fetchByDate(date, 50)
} else {
reservations = await Reservation.fetchByPage(pageNumber, pageSize)
}
res.setHeader('content-type', 'application/json')
l.getStore()?.debug('Found reservations', {
reservations: reservations.map((r) => r.toString(true)),
})
return new Promise<void>((resolve, reject) => {
res.write(
`[${reservations.map((r) => r.toString(true)).join(',')}]`,
(err) => {
if (err) {
reject(err)
}
resolve()
}
)
})
} catch (error) {
l.getStore()?.error('Failed to get reservations', {
message: (error as Error).message,
stack: (error as Error).stack,
})
res.writeHead(500)
}
}
private async POST_reservations(
req: IncomingMessage,
res: ServerResponse<IncomingMessage>
) {
const jsonBody = await this.parseJsonContent(req)
try {
await schedule(jsonBody)
} catch (error: unknown) {
l.getStore()?.error('Failed to create reservation', {
message: (error as Error).message,
stack: (error as Error).stack,
})
res.writeHead(400)
}
}
private async DELETE_reservation(
req: IncomingMessage,
res: ServerResponse<IncomingMessage>
) {
const { url = '' } = req
const [, , id] = url.split('/')
l.getStore()?.debug('Deleting reservation', { id })
try {
await Reservation.deleteById(id)
res.writeHead(200)
} catch (error) {
l.getStore()?.error('Failed to get reservations', {
message: (error as Error).message,
stack: (error as Error).stack,
})
res.writeHead(500)
}
}
}

View file

@ -1,37 +0,0 @@
import { IncomingMessage, ServerResponse } from 'http'
import { asyncLocalStorage as l } from '../../../common/logger'
import { Router } from './index'
import { getStatus, startTasks, stopTasks } from '../../cron'
import { Runner } from '../../../common/runner'
export class RunnerRouter extends Router {
public async handleRequest(
req: IncomingMessage,
res: ServerResponse<IncomingMessage>
) {
const { url = '', method } = req
const [route] = url.split('?')
switch (true) {
case /^\/runner\/test$/.test(route) && method === 'GET': {
await this.GET_runner_test(req, res)
break
}
default: {
this.handle404(req, res)
}
}
}
private async GET_runner_test(
_req: IncomingMessage,
res: ServerResponse<IncomingMessage>
) {
try {
const runner = new Runner({ headless: true })
await runner.test()
res.writeHead(200, 'OK')
} catch (e) {
res.writeHead(500, 'Internal server error')
}
}
}

View file

@ -1,28 +0,0 @@
import { init } from '../common/database'
import { Logger, LogLevel } from '../common/logger'
import server from './http'
import { startTasks, stopTasks } from './cron'
const logger = new Logger('process', 'default', LogLevel.DEBUG)
process.on('unhandledRejection', (reason) => {
logger.error('unhandled rejection', { reason })
})
process.on('uncaughtException', (error, origin) => {
logger.error('uncaught exception', { error, origin })
})
process.on('beforeExit', async () => {
stopTasks()
})
const port = process.env.SERVER_PORT || 3000
startTasks()
server.listen(port, async () => {
logger.info('server ready and listening', { port })
await init()
logger.info('database connection established')
})

View file

@ -1,34 +0,0 @@
import { Readable } from 'stream'
export const parseJson = async <T extends Record<string, unknown>>(
length: number,
encoding: BufferEncoding,
readable: Readable
) => {
return new Promise<T>((res, rej) => {
let jsonBuffer: Buffer
try {
jsonBuffer = Buffer.alloc(length, encoding)
readable.setEncoding(encoding)
} catch (error: any) {
rej(error)
}
readable.on('data', (chunk) => {
try {
jsonBuffer.write(chunk, encoding)
} catch (error: any) {
rej(error)
}
})
readable.on('end', () => {
try {
const jsonObject = JSON.parse(jsonBuffer.toString())
res(jsonObject)
} catch (error: any) {
rej(error)
}
})
})
}

24
test/app.e2e-spec.ts Normal file
View file

@ -0,0 +1,24 @@
import { Test, TestingModule } from '@nestjs/testing'
import { INestApplication } from '@nestjs/common'
import * as request from 'supertest'
import { AppModule } from './../src/app.module'
describe('AppController (e2e)', () => {
let app: INestApplication
beforeEach(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile()
app = moduleFixture.createNestApplication()
await app.init()
})
it('/ (GET)', () => {
return request(app.getHttpServer())
.get('/')
.expect(200)
.expect('Hello World!')
})
})

9
test/jest-e2e.json Normal file
View file

@ -0,0 +1,9 @@
{
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".",
"testEnvironment": "node",
"testRegex": ".e2e-spec.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
}
}

View file

@ -1,5 +0,0 @@
describe('failure', () => {
test('should fail', () => {
expect(true).toBeFalsy()
})
})

View file

@ -1,165 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`scheduler should handle valid requests outside of reservation window 1`] = `
{
"scheduledReservation": {
"reservation": {
"booked": false,
"dateRange": {
"end": {
"$D": 16,
"$H": 1,
"$L": "nl",
"$M": 0,
"$W": 0,
"$d": {},
"$m": 15,
"$ms": 0,
"$offset": 60,
"$s": 0,
"$u": false,
"$x": {
"$timezone": "Europe/Amsterdam",
},
"$y": 2022,
},
"start": {
"$D": 16,
"$H": 1,
"$L": "nl",
"$M": 0,
"$W": 0,
"$d": {},
"$m": 0,
"$ms": 0,
"$offset": 60,
"$s": 0,
"$u": false,
"$x": {
"$timezone": "Europe/Amsterdam",
},
"$y": 2022,
},
},
"id": "1234",
"opponent": {
"id": "123",
"name": "collin",
},
"possibleDates": [
{
"$D": 16,
"$H": 1,
"$L": "nl",
"$M": 0,
"$W": 0,
"$d": {},
"$m": 0,
"$ms": 0,
"$offset": 60,
"$s": 0,
"$x": {
"$timezone": "Europe/Amsterdam",
},
"$y": 2022,
},
{
"$D": 16,
"$H": 1,
"$L": "nl",
"$M": 0,
"$W": 0,
"$d": {},
"$m": 15,
"$ms": 0,
"$offset": 60,
"$s": 0,
"$x": {
"$timezone": "Europe/Amsterdam",
},
"$y": 2022,
},
],
"user": {
"password": Any<String>,
"username": "collin",
},
},
"scheduledFor": {
"$D": 9,
"$H": 0,
"$L": "nl",
"$M": 0,
"$W": 0,
"$d": {},
"$m": 0,
"$ms": 0,
"$offset": 60,
"$s": 0,
"$x": {
"$timezone": "Europe/Amsterdam",
},
"$y": 2022,
},
},
}
`;
exports[`scheduler should handle valid requests within reservation window 1`] = `
{
"scheduledReservation": {
"reservation": {
"booked": false,
"dateRange": {
"end": {
"$D": 1,
"$H": 1,
"$L": "nl",
"$M": 0,
"$W": 6,
"$d": {},
"$m": 30,
"$ms": 0,
"$offset": 60,
"$s": 0,
"$u": false,
"$x": {
"$timezone": "Europe/Amsterdam",
},
"$y": 2022,
},
"start": {
"$D": 1,
"$H": 1,
"$L": "nl",
"$M": 0,
"$W": 6,
"$d": {},
"$m": 15,
"$ms": 0,
"$offset": 60,
"$s": 0,
"$u": false,
"$x": {
"$timezone": "Europe/Amsterdam",
},
"$y": 2022,
},
},
"id": Any<String>,
"opponent": {
"id": "123",
"name": "collin",
},
"possibleDates": [
"2022-01-01T00:15:00.000Z",
"2022-01-01T00:30:00.000Z",
],
"user": {
"password": Any<String>,
"username": "collin",
},
},
},
}
`;

View file

@ -1,84 +0,0 @@
import dayjs from '../../../src/common/dayjs'
import { Logger, LogLevel } from '../../../src/common/logger'
jest.useFakeTimers().setSystemTime(new Date('2023-01-01'))
describe('Logger', () => {
beforeEach(() => {
jest.resetAllMocks()
})
test('should log messages', () => {
const consoleLogSpy = jest.fn()
const consoleErrorSpy = jest.fn()
jest.spyOn(console, 'log').mockImplementation(consoleLogSpy)
jest.spyOn(console, 'error').mockImplementation(consoleErrorSpy)
const logger = new Logger('tag', 'abc', LogLevel.DEBUG)
logger.debug('first')
logger.info('second')
logger.error('third', { errorMessage: 'test' })
expect(consoleLogSpy).toHaveBeenCalledTimes(2)
expect(consoleLogSpy).toHaveBeenNthCalledWith(
1,
'(%s) <%s> [%s] %s: %s',
dayjs().format(),
'tag',
'abc',
'DEBUG',
'first'
)
expect(consoleLogSpy).toHaveBeenNthCalledWith(
2,
'(%s) <%s> [%s] %s: %s',
dayjs().format(),
'tag',
'abc',
'INFO',
'second'
)
expect(consoleErrorSpy).toHaveBeenCalledTimes(1)
expect(consoleErrorSpy).toHaveBeenCalledWith(
'(%s) <%s> [%s] %s: %s - %O',
dayjs().format(),
'tag',
'abc',
'ERROR',
'third',
{ errorMessage: 'test' }
)
})
test('should log only when level is >= LogLevel of LoggerInstance', () => {
const consoleLogSpy = jest.fn()
jest.spyOn(console, 'log').mockImplementationOnce(consoleLogSpy)
const logger = new Logger('tag', 'abc', LogLevel.INFO)
logger.debug("shouldn't appear")
expect(consoleLogSpy).not.toHaveBeenCalled()
})
test('should obfuscate password from message', () => {
const consoleLogSpy = jest.fn()
const consoleErrorSpy = jest.fn()
jest.spyOn(console, 'log').mockImplementation(consoleLogSpy)
jest.spyOn(console, 'error').mockImplementation(consoleErrorSpy)
const logger = new Logger('tag', 'abc', LogLevel.DEBUG)
logger.info('first', { password: 'test' })
expect(consoleLogSpy).toHaveBeenCalledTimes(1)
expect(consoleLogSpy).toHaveBeenNthCalledWith(
1,
'(%s) <%s> [%s] %s: %s - %O',
dayjs().format(),
'tag',
'abc',
'INFO',
'first',
{ password: '***' }
)
})
})

View file

@ -1,27 +0,0 @@
import * as password from '../../../src/common/password'
describe('password', () => {
describe('generateSalt', () => {
test('should generate salt of 32 bytes', async () => {
const saltBuffer = await password.generateSalt()
expect(saltBuffer.length).toEqual(32)
})
})
describe('generateHash', () => {
test('should generate a hash of 64 bytes', async () => {
const saltBuffer = Buffer.alloc(32, 1)
const hash = await password.generateHash('abc123', saltBuffer)
expect(hash).toEqual(
'$argon2id$v=19$m=16384,t=2,p=1$AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE$jF3SXC/JRI9d1jr48kQkvWaSVlf3XGNSRUCNnNp5IaI'
)
})
})
describe('hashPassword', () => {
test('it should create salt and hash password', async () => {
const hash = await password.hashPassword('abc123')
expect(hash).toMatch(/^\$argon2id\$v=19\$m=16384,t=2,p=1\$.+$/)
})
})
})

View file

@ -1,80 +0,0 @@
import dayjs from '../../../src/common/dayjs'
import {
validateJSONRequest,
ValidationError,
} from '../../../src/common/request'
describe('request', () => {
const testDate = dayjs().add(1, 'day')
describe('validateJSONRequest', () => {
test('should return ReservationRequest', async () => {
const body = {
username: 'collin',
password: '123abc',
dateRange: {
start: testDate.clone().toISOString(),
end: testDate.add(15, 'minutes').toISOString(),
},
opponent: {
id: '123',
name: 'collin',
},
}
const res = await validateJSONRequest(body)
expect(res).toBeDefined()
expect(res.dateRange.start.format()).toEqual(testDate.format())
})
test('should throw error for undefined body', async () => {
// @ts-expect-error undefined body
expect(() => validateJSONRequest(undefined)).rejects.toThrowError(
ValidationError
)
})
test('should throw error for invalid body', () => {
expect(() =>
validateJSONRequest({ username: '', password: '' })
).rejects.toThrowError(ValidationError)
})
test('should throw error for invalid date range', () => {
expect(() =>
validateJSONRequest({
username: 'test',
password: 'test',
dateRange: { start: 'a', end: 'a' },
opponent: { id: 1, name: 'test' },
})
).rejects.toThrowError(ValidationError)
})
test('should throw error for incorrect date range', () => {
expect(() =>
validateJSONRequest({
username: 'test',
password: 'test',
dateRange: { start: '2022-01-01', end: '2021-01-01' },
opponent: { id: 1, name: 'test' },
})
).rejects.toThrowError(ValidationError)
})
test('should throw error for incorrect date range', () => {
expect(() =>
validateJSONRequest({
username: 'test',
password: 'test',
dateRange: {
start: testDate.toString(),
end: testDate.add(15, 'minute').toString(),
},
opponent: { id: 1, name: 'test' },
})
).rejects.toThrowError(ValidationError)
})
})
})

View file

@ -1,74 +0,0 @@
import { Dayjs } from 'dayjs'
import dayjs from '../../../src/common/dayjs'
import { DateRange, Reservation } from '../../../src/common/reservation'
describe('Reservation', () => {
test('will create correct possible dates', () => {
const startDate = dayjs().hour(12).minute(0).second(0).millisecond(0)
const endDate = startDate.add(1, 'hour')
const dateRange: DateRange = {
start: startDate,
end: endDate,
}
const res = new Reservation(
{ username: 'collin', password: 'password' },
dateRange,
{
id: 'collin',
name: 'collin',
}
)
expect(res.possibleDates).toHaveLength(5)
console.log(res.possibleDates[0].format())
expect(res.possibleDates[0]).toEqual(startDate)
expect(res.possibleDates[1]).toEqual(startDate.add(15, 'minute'))
expect(res.possibleDates[2]).toEqual(startDate.add(30, 'minute'))
expect(res.possibleDates[3]).toEqual(startDate.add(45, 'minute'))
expect(res.possibleDates[4]).toEqual(startDate.add(60, 'minute'))
})
test.each([
{ reservationDate: dayjs().add(7, 'days'), expected: true },
{ reservationDate: dayjs().add(1, 'days'), expected: true },
{
reservationDate: dayjs().add(8, 'days').add(5, 'minutes'),
expected: false,
},
])(
'will properly mark reservation availability according to date',
({ reservationDate, expected }) => {
const res = new Reservation(
{ username: 'collin', password: 'collin' },
{ start: reservationDate, end: reservationDate },
{ id: 'collin', name: 'collin' }
)
expect(res.isAvailableForReservation()).toBe(expected)
}
)
const zeroTime = (date: Dayjs): Dayjs =>
date.hour(0).minute(0).second(0).millisecond(0)
test.each([
{
date: dayjs().add(8, 'days'),
expected: zeroTime(dayjs().add(1, 'days')),
},
{
date: dayjs().add(31, 'days'),
expected: zeroTime(dayjs().add(24, 'days')),
},
])(
'should return value indicating if reservation is possible now',
({ date, expected }) => {
const res = new Reservation(
{ username: 'collin', password: 'collin' },
{ start: date, end: date },
{ id: 'collin', name: 'collin' }
)
expect(res.getAllowedReservationDate()).toStrictEqual(expected)
}
)
})

View file

@ -1,89 +0,0 @@
import dayjs from '../../../src/common/dayjs'
import {
ValidationError,
ValidationErrorCode,
} from '../../../src/common/request'
import { Reservation } from '../../../src/common/reservation'
import { schedule, SchedulerInput } from '../../../src/common/scheduler'
import * as database from '../../../src/common/database'
jest.mock('../../../src/common/logger')
jest.mock('../../../src/common/reserver')
jest.mock('uuid', () => ({ v4: () => '1234' }))
jest.useFakeTimers().setSystemTime(new Date('2022-01-01'))
describe('scheduler', () => {
test('should handle valid requests within reservation window', async () => {
jest.spyOn(database, 'run').mockResolvedValueOnce()
const start = dayjs().add(15, 'minutes')
const end = start.add(15, 'minutes')
const payload: SchedulerInput = {
username: 'collin',
password: 'password',
dateRange: { start: start.toISOString(), end: end.toISOString() },
opponent: { id: '123', name: 'collin' },
}
expect(await schedule(payload)).toMatchSnapshot({
scheduledReservation: {
reservation: {
id: expect.any(String),
user: {
username: 'collin',
password: expect.any(String),
},
dateRange: { start, end },
opponent: { id: '123', name: 'collin' },
},
},
})
})
test('should handle valid requests outside of reservation window', async () => {
const start = dayjs().add(15, 'days')
const end = start.add(15, 'minutes')
const payload: SchedulerInput = {
username: 'collin',
password: 'password',
dateRange: { start: start.toISOString(), end: end.toISOString() },
opponent: { id: '123', name: 'collin' },
}
await expect(await schedule(payload)).toMatchSnapshot({
scheduledReservation: {
reservation: new Reservation(
{ username: 'collin', password: expect.any(String) },
{ start, end },
{ id: '123', name: 'collin' },
undefined,
'1234'
),
scheduledFor: start
.subtract(7, 'days')
.hour(0)
.minute(0)
.second(0)
.millisecond(0),
},
})
})
test('should throw error for invalid requests', async () => {
const start = dayjs().add(15, 'days')
const end = start.add(15, 'minutes')
const payload: SchedulerInput = {
password: 'password',
dateRange: { start: start.toISOString(), end: end.toISOString() },
opponent: { id: '123', name: 'collin' },
}
await expect(schedule(payload)).rejects.toThrowError(
new ValidationError(
'Invalid request',
ValidationErrorCode.INVALID_REQUEST_BODY
)
)
})
})

View file

@ -1,59 +0,0 @@
import axios from 'axios'
import server from '../../../src/server/http'
import * as scheduler from '../../../src/common/scheduler'
import * as utils from '../../../src/server/utils'
const port = Math.round(Math.random() * 50000 + 10000)
const baseUrl = `http://localhost:${port}`
describe('server', () => {
const consoleLogSpy = jest.fn()
const consoleErrorSpy = jest.fn()
beforeAll(() => {
server.listen(port)
console.log = consoleLogSpy
console.error = consoleErrorSpy
})
afterAll(() => {
server.close()
})
beforeEach(() => {
jest.resetAllMocks()
})
test('should accept POST to /reservations', async () => {
jest
.spyOn(scheduler, 'schedule')
.mockImplementationOnce(() => Promise.resolve({}))
const response = await axios.post(
`${baseUrl}/reservations`,
{},
{ headers: { 'content-type': 'application/json' } }
)
expect(response.status).toBe(200)
})
test('should accept POST to /cron', async () => {
jest
.spyOn(scheduler, 'schedule')
.mockImplementationOnce(() => Promise.resolve({}))
const response = await axios.post(
`${baseUrl}/cron/disable`,
{},
{ headers: { 'content-type': 'application/json' } }
)
expect(response.status).toBe(200)
})
test('should reject request to other route', async () => {
jest
.spyOn(scheduler, 'schedule')
.mockImplementationOnce(() => Promise.resolve({}))
await expect(() => axios.post(`${baseUrl}/something-else`)).rejects.toThrow(
axios.AxiosError
)
})
})

4
tsconfig.build.json Normal file
View file

@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}

View file

@ -1,21 +1,21 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"lib": ["esnext", "dom"],
"module": "commonjs",
"moduleResolution": "node",
"target": "es6",
"allowJs": true,
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "es2017",
"sourceMap": true,
"strict": true,
"esModuleInterop": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": true,
"noImplicitAny": true,
"strictBindCallApply": true,
"forceConsistentCasingInFileNames": true,
"outDir": "./dist/"
},
"include": [
"./src/**/*.ts"
]
"noFallthroughCasesInSwitch": true
}
}