Big ole refactor to using nestjs for some practice
This commit is contained in:
parent
eb9991895a
commit
1def4de40c
61 changed files with 6858 additions and 13766 deletions
25
.eslintrc.js
Normal file
25
.eslintrc.js
Normal 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',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
{
|
|
||||||
"root": true,
|
|
||||||
"parser": "@typescript-eslint/parser",
|
|
||||||
"plugins": [
|
|
||||||
"@typescript-eslint"
|
|
||||||
],
|
|
||||||
"extends": [
|
|
||||||
"eslint:recommended",
|
|
||||||
"plugin:@typescript-eslint/recommended"
|
|
||||||
],
|
|
||||||
"ignorePatterns": [
|
|
||||||
"**/*.js",
|
|
||||||
"!src/**/*.ts"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
{
|
{
|
||||||
"$schema":"http://json.schemastore.org/prettierrc",
|
"singleQuote": true,
|
||||||
"trailingComma": "es5",
|
"trailingComma": "all",
|
||||||
"tabWidth": 2,
|
"tabWidth": 2,
|
||||||
"useTabs": false,
|
"useTabs": true,
|
||||||
"semi": false,
|
"semi": false
|
||||||
"singleQuote": true
|
|
||||||
}
|
}
|
||||||
31
README.md
31
README.md
|
|
@ -8,8 +8,9 @@ Automatic court reservation!
|
||||||
|
|
||||||
- Node.js (18.x)
|
- Node.js (18.x)
|
||||||
- npm (8.x)
|
- npm (8.x)
|
||||||
- gcc (g++-12)
|
- nvm
|
||||||
- nvm (optional)
|
- Docker
|
||||||
|
- redis
|
||||||
|
|
||||||
#### Using nvm
|
#### Using nvm
|
||||||
|
|
||||||
|
|
@ -21,31 +22,7 @@ Automatic court reservation!
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install
|
npm install
|
||||||
npm run local <username> <password> <year> <month> <day> <startTime> <endTime> <opponentName> <opponentId>
|
npm start:dev
|
||||||
```
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
```ascii
|
|
||||||
|======|
|
|
||||||
| User |
|
|
||||||
|======|
|
|
||||||
|
|
|
||||||
[requests reservation]
|
|
||||||
|
|
|
||||||
|
|
|
||||||
V
|
|
||||||
|===========| /---\ |==========|
|
|
||||||
| Scheduler | ---[checks possibility]--->/ ok? \--[y/ forward request]--> | Reserver |
|
|
||||||
|===========| \ / |==========|
|
|
||||||
\---/ |
|
|
||||||
| |
|
|
||||||
[n/ save request] [find possible, saved reservations]
|
|
||||||
| |
|
|
||||||
V |
|
|
||||||
|==========| |
|
|
||||||
| Database |<---------------------------|
|
|
||||||
|==========|
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
|
|
|
||||||
13
data-source.ts
Normal file
13
data-source.ts
Normal 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: [],
|
||||||
|
})
|
||||||
|
|
@ -4,8 +4,15 @@ services:
|
||||||
context: ..
|
context: ..
|
||||||
dockerfile: ./docker/server/Dockerfile
|
dockerfile: ./docker/server/Dockerfile
|
||||||
restart: always
|
restart: always
|
||||||
env_file: ./server/.env
|
environment:
|
||||||
|
- PORT=3000
|
||||||
|
- REDIS_HOST=redis
|
||||||
|
- REDIS_PORT=6379
|
||||||
ports:
|
ports:
|
||||||
- 3000:3000
|
- 3000:3000
|
||||||
volumes:
|
volumes:
|
||||||
- ./autobaan_db:/app/db
|
- ../db:/app/db
|
||||||
|
redis:
|
||||||
|
image: redis
|
||||||
|
ports:
|
||||||
|
- 6379
|
||||||
|
|
@ -44,4 +44,4 @@ FROM base as app
|
||||||
|
|
||||||
COPY --chown=node:node --from=builder /app/dist ./dist
|
COPY --chown=node:node --from=builder /app/dist ./dist
|
||||||
|
|
||||||
ENTRYPOINT node dist/server/index.js
|
ENTRYPOINT ["node", "./dist/main.js"]
|
||||||
|
|
@ -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
8
nest-cli.json
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/nest-cli",
|
||||||
|
"collection": "@nestjs/schematics",
|
||||||
|
"sourceRoot": "src",
|
||||||
|
"compilerOptions": {
|
||||||
|
"deleteOutDir": true
|
||||||
|
}
|
||||||
|
}
|
||||||
17161
package-lock.json
generated
17161
package-lock.json
generated
File diff suppressed because it is too large
Load diff
109
package.json
109
package.json
|
|
@ -1,53 +1,82 @@
|
||||||
{
|
{
|
||||||
"name": "autobaan",
|
"name": "autobaan",
|
||||||
"version": "1.0.0",
|
"version": "0.0.1",
|
||||||
"description": "",
|
"description": "",
|
||||||
|
"author": "",
|
||||||
"private": true,
|
"private": true,
|
||||||
"engines": {
|
"license": "UNLICENSED",
|
||||||
"node": "18"
|
|
||||||
},
|
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"preinstall": "export CXX=g++-12",
|
"build": "nest build",
|
||||||
"clean": "rm -r ./dist || true",
|
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||||
"prebuild": "npm run clean",
|
"start": "nest start",
|
||||||
"build": "tsc",
|
"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": "jest",
|
||||||
"test:unit": "jest tests/unit/*",
|
"test:watch": "jest --watch",
|
||||||
"test:unit:clean": "npm run test:clear-cache && npm run test:unit",
|
"test:cov": "jest --coverage",
|
||||||
"test:integration": "jest tests/integration/*",
|
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||||
"test:integration:clean": "npm run test:clear-cache && npm run test:integration",
|
"test:e2e": "jest --config ./test/jest-e2e.json"
|
||||||
"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": {
|
"dependencies": {
|
||||||
"argon2": "^0.30.3",
|
"@nestjs/bull": "^0.6.3",
|
||||||
"axios": "^1.2.0",
|
"@nestjs/common": "^9.0.0",
|
||||||
"dayjs": "^1.11.6",
|
"@nestjs/config": "^2.3.2",
|
||||||
"node-cron": "^3.0.2",
|
"@nestjs/core": "^9.0.0",
|
||||||
"puppeteer": "^19.5.2",
|
"@nestjs/platform-express": "^9.0.0",
|
||||||
"sqlite3": "^5.1.4",
|
"@nestjs/schedule": "^2.2.2",
|
||||||
"uuid": "^9.0.0"
|
"@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": {
|
"devDependencies": {
|
||||||
"@types/jest": "^29.2.3",
|
"@nestjs/cli": "^9.0.0",
|
||||||
"@types/node": "^18.11.9",
|
"@nestjs/schematics": "^9.0.0",
|
||||||
"@types/node-cron": "^3.0.6",
|
"@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/puppeteer": "^7.0.4",
|
||||||
"@types/uuid": "^8.3.4",
|
"@types/supertest": "^2.0.11",
|
||||||
"@typescript-eslint/eslint-plugin": "^5.42.0",
|
"@typescript-eslint/eslint-plugin": "^5.0.0",
|
||||||
"@typescript-eslint/parser": "^5.42.0",
|
"@typescript-eslint/parser": "^5.0.0",
|
||||||
"babel-jest": "^29.2.1",
|
"eslint": "^8.0.1",
|
||||||
"eslint": "^8.27.0",
|
"eslint-config-prettier": "^8.3.0",
|
||||||
"jest": "^29.2.1",
|
"eslint-plugin-prettier": "^4.0.0",
|
||||||
"prettier": "^2.7.1",
|
"jest": "29.5.0",
|
||||||
"ts-jest": "^29.0.3",
|
"prettier": "^2.3.2",
|
||||||
"typescript": "^4.9.4"
|
"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
54
package.json.old
Normal 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
41
src/app.module.ts
Normal 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('*')
|
||||||
|
}
|
||||||
|
}
|
||||||
14
src/common/customResponse.ts
Normal file
14
src/common/customResponse.ts
Normal 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 })))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 {}
|
|
||||||
|
|
@ -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
|
|
||||||
);
|
|
||||||
`
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import dayjs from 'dayjs'
|
import * as dayjs from 'dayjs'
|
||||||
import isSameOrBefore from 'dayjs/plugin/isSameOrBefore'
|
import * as isSameOrBefore from 'dayjs/plugin/isSameOrBefore'
|
||||||
import utc from 'dayjs/plugin/utc'
|
import * as utc from 'dayjs/plugin/utc'
|
||||||
import timezone from 'dayjs/plugin/timezone'
|
import * as timezone from 'dayjs/plugin/timezone'
|
||||||
import 'dayjs/locale/nl'
|
import 'dayjs/locale/nl'
|
||||||
|
|
||||||
dayjs.extend(isSameOrBefore)
|
dayjs.extend(isSameOrBefore)
|
||||||
|
|
@ -11,6 +11,24 @@ dayjs.locale('nl')
|
||||||
|
|
||||||
dayjs.tz.setDefault('Europe/Amsterdam')
|
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 = (
|
const dayjsTz = (
|
||||||
date?: string | number | Date | dayjs.Dayjs | null | undefined
|
date?: string | number | Date | dayjs.Dayjs | null | undefined
|
||||||
) => {
|
) => {
|
||||||
|
|
|
||||||
|
|
@ -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}`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,54 +1,55 @@
|
||||||
import argon2 from 'argon2'
|
// TODO: refactor to nestjs if needed
|
||||||
import crypto from 'crypto'
|
// import argon2 from 'argon2'
|
||||||
import { asyncLocalStorage } from './logger'
|
// 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) => {
|
// const randomFillPromise = (buffer: Buffer) => {
|
||||||
return new Promise<Buffer>((res, rej) => {
|
// return new Promise<Buffer>((res, rej) => {
|
||||||
crypto.randomFill(buffer, (err, buff) => {
|
// crypto.randomFill(buffer, (err, buff) => {
|
||||||
if (err) {
|
// if (err) {
|
||||||
rej(err)
|
// rej(err)
|
||||||
}
|
// }
|
||||||
res(buff)
|
// res(buff)
|
||||||
})
|
// })
|
||||||
})
|
// })
|
||||||
}
|
// }
|
||||||
|
|
||||||
export const generateSalt = async () => {
|
// export const generateSalt = async () => {
|
||||||
const saltBuffer = Buffer.alloc(SALT_LENGTH)
|
// const saltBuffer = Buffer.alloc(SALT_LENGTH)
|
||||||
return randomFillPromise(saltBuffer)
|
// return randomFillPromise(saltBuffer)
|
||||||
}
|
// }
|
||||||
|
|
||||||
export const generateHash = async (password: string, saltBuffer: Buffer) => {
|
// export const generateHash = async (password: string, saltBuffer: Buffer) => {
|
||||||
const hashOptions: argon2.Options & { raw: false } = {
|
// const hashOptions: argon2.Options & { raw: false } = {
|
||||||
hashLength: 32,
|
// hashLength: 32,
|
||||||
parallelism: 1,
|
// parallelism: 1,
|
||||||
memoryCost: 1 << 14,
|
// memoryCost: 1 << 14,
|
||||||
timeCost: 2,
|
// timeCost: 2,
|
||||||
type: argon2.argon2id,
|
// type: argon2.argon2id,
|
||||||
salt: saltBuffer,
|
// salt: saltBuffer,
|
||||||
saltLength: saltBuffer.length,
|
// saltLength: saltBuffer.length,
|
||||||
raw: false,
|
// raw: false,
|
||||||
}
|
// }
|
||||||
|
|
||||||
const hash = await argon2.hash(password, hashOptions)
|
// const hash = await argon2.hash(password, hashOptions)
|
||||||
return hash
|
// return hash
|
||||||
}
|
// }
|
||||||
|
|
||||||
export const hashPassword = async (password: string) => {
|
// export const hashPassword = async (password: string) => {
|
||||||
try {
|
// try {
|
||||||
const saltBuffer = await generateSalt()
|
// const saltBuffer = await generateSalt()
|
||||||
const hash = await generateHash(password, saltBuffer)
|
// const hash = await generateHash(password, saltBuffer)
|
||||||
return hash
|
// return hash
|
||||||
} catch (err: any) {
|
// } catch (err: any) {
|
||||||
asyncLocalStorage
|
// asyncLocalStorage
|
||||||
.getStore()
|
// .getStore()
|
||||||
?.error('Error hashing and salting password', { message: err.message })
|
// ?.error('Error hashing and salting password', { message: err.message })
|
||||||
throw err
|
// throw err
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
|
|
||||||
export const verifyPassword = async (hash: string, password: string) => {
|
// export const verifyPassword = async (hash: string, password: string) => {
|
||||||
return await argon2.verify(hash, password)
|
// return await argon2.verify(hash, password)
|
||||||
}
|
// }
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -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 },
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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
16
src/logger/middleware.ts
Normal 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
9
src/logger/module.ts
Normal 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
14
src/logger/service.ts
Normal 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
15
src/main.ts
Normal 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()
|
||||||
5
src/reservations/config.ts
Normal file
5
src/reservations/config.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
export const RESERVATIONS_QUEUE_NAME = 'reservations'
|
||||||
|
|
||||||
|
export default () => ({
|
||||||
|
queueName: RESERVATIONS_QUEUE_NAME
|
||||||
|
})
|
||||||
73
src/reservations/controller.ts
Normal file
73
src/reservations/controller.ts
Normal 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
31
src/reservations/cron.ts
Normal 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 })))
|
||||||
|
}
|
||||||
|
}
|
||||||
71
src/reservations/entity.ts
Normal file
71
src/reservations/entity.ts
Normal 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')
|
||||||
|
}
|
||||||
|
}
|
||||||
25
src/reservations/module.ts
Normal file
25
src/reservations/module.ts
Normal 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 {}
|
||||||
35
src/reservations/service.ts
Normal file
35
src/reservations/service.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
30
src/reservations/worker.ts
Normal file
30
src/reservations/worker.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,15 +1,17 @@
|
||||||
|
import { Inject, Injectable } from '@nestjs/common'
|
||||||
import { Dayjs } from 'dayjs'
|
import { Dayjs } from 'dayjs'
|
||||||
import dayjs from './dayjs'
|
import { ElementHandle, Page } from 'puppeteer'
|
||||||
import puppeteer, {
|
import { RunnerService } from '../service'
|
||||||
Browser,
|
import { EmptyPage } from '../pages/empty'
|
||||||
BrowserConnectOptions,
|
import dayjs from '../../common/dayjs'
|
||||||
BrowserLaunchArgumentOptions,
|
import { Reservation } from '../../reservations/entity'
|
||||||
ElementHandle,
|
|
||||||
LaunchOptions,
|
const baanReserverenRoot = 'https://squashcity.baanreserveren.nl'
|
||||||
Page,
|
|
||||||
} from 'puppeteer'
|
export enum BaanReserverenUrls {
|
||||||
import { asyncLocalStorage as l, LoggableError } from './logger'
|
Reservations = '/reservations',
|
||||||
import { Opponent, Reservation } from './reservation'
|
Logout = '/auth/logout',
|
||||||
|
}
|
||||||
|
|
||||||
enum SessionAction {
|
enum SessionAction {
|
||||||
NoAction,
|
NoAction,
|
||||||
|
|
@ -17,67 +19,25 @@ enum SessionAction {
|
||||||
Login,
|
Login,
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RunnerSession {
|
interface BaanReserverenSession {
|
||||||
username: string
|
username: string
|
||||||
loggedInAt: Dayjs
|
startedAt: Dayjs
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Runner {
|
@Injectable()
|
||||||
private browser?: Browser
|
export class BaanReserverenService {
|
||||||
private page?: Page
|
private session: BaanReserverenSession | null = null
|
||||||
private options?: LaunchOptions &
|
|
||||||
BrowserLaunchArgumentOptions &
|
|
||||||
BrowserConnectOptions
|
|
||||||
private session?: RunnerSession
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
options?: LaunchOptions &
|
@Inject(RunnerService)
|
||||||
BrowserLaunchArgumentOptions &
|
private readonly runnerService: RunnerService,
|
||||||
BrowserConnectOptions
|
|
||||||
) {
|
|
||||||
const defaultArgs = ['--disable-setuid-sandbox', '--no-sandbox']
|
|
||||||
this.options = { args: defaultArgs, ...options }
|
|
||||||
}
|
|
||||||
|
|
||||||
public async test() {
|
@Inject(EmptyPage)
|
||||||
l.getStore()?.debug('Runner test')
|
private readonly page: Page,
|
||||||
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 {
|
private checkSession(username: string) {
|
||||||
this.page = await this.browser?.newPage()
|
if (this.page.url().endsWith(BaanReserverenUrls.Reservations)) {
|
||||||
} 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 })
|
|
||||||
return this.session?.username !== username
|
return this.session?.username !== username
|
||||||
? SessionAction.Logout
|
? SessionAction.Logout
|
||||||
: SessionAction.NoAction
|
: SessionAction.NoAction
|
||||||
|
|
@ -85,83 +45,69 @@ export class Runner {
|
||||||
return SessionAction.Login
|
return SessionAction.Login
|
||||||
}
|
}
|
||||||
|
|
||||||
private async startSession(reservation: Reservation) {
|
private startSession(username: string) {
|
||||||
if (!this.page) {
|
if (this.session && this.session.username !== username) {
|
||||||
try {
|
throw new Error('Session already started')
|
||||||
this.page = await this.browser?.newPage()
|
}
|
||||||
} catch (error) {
|
|
||||||
throw new PuppeteerNewPageError(error as Error)
|
if (this.session?.username === username) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.session = {
|
||||||
|
username,
|
||||||
|
startedAt: dayjs(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.page
|
private endSession() {
|
||||||
?.goto('https://squashcity.baanreserveren.nl/reservations')
|
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) => {
|
.catch((e: Error) => {
|
||||||
throw new RunnerNewSessionError(e)
|
// 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)
|
||||||
|
}
|
||||||
|
|
||||||
const sessionAction = await this.checkSession(reservation.user.username)
|
private async logout() {
|
||||||
switch (sessionAction) {
|
await this.page.goto(`${baanReserverenRoot}${BaanReserverenUrls.Logout}`)
|
||||||
case SessionAction.Login: {
|
this.endSession()
|
||||||
await this.login(reservation.user.username, reservation.user.password)
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
case SessionAction.Logout: {
|
|
||||||
|
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.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
|
break
|
||||||
}
|
|
||||||
case SessionAction.NoAction:
|
case SessionAction.NoAction:
|
||||||
default:
|
default:
|
||||||
break
|
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 {
|
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
|
||||||
|
|
@ -177,10 +123,7 @@ export class Runner {
|
||||||
}
|
}
|
||||||
|
|
||||||
private async navigateToDay(date: Dayjs): Promise<void> {
|
private async navigateToDay(date: Dayjs): Promise<void> {
|
||||||
l.getStore()?.debug(`Navigating to ${date.format()}`)
|
|
||||||
|
|
||||||
if (this.getLastVisibleDay().isBefore(date)) {
|
if (this.getLastVisibleDay().isBefore(date)) {
|
||||||
l.getStore()?.debug('Date is on different page, increase month')
|
|
||||||
await this.page
|
await this.page
|
||||||
?.waitForSelector('td.month.next')
|
?.waitForSelector('td.month.next')
|
||||||
.then((d) => d?.click())
|
.then((d) => d?.click())
|
||||||
|
|
@ -210,14 +153,12 @@ export class Runner {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
private async selectAvailableTime(res: Reservation): Promise<void> {
|
private async selectAvailableTime(reservation: Reservation): Promise<void> {
|
||||||
l.getStore()?.debug('Selecting available time', {
|
|
||||||
reservation: res.toString(true),
|
|
||||||
})
|
|
||||||
let freeCourt: ElementHandle | null | undefined
|
let freeCourt: ElementHandle | null | undefined
|
||||||
let i = 0
|
let i = 0
|
||||||
while (i < res.possibleDates.length && !freeCourt) {
|
const possibleDates = reservation.createPossibleDates()
|
||||||
const possibleDate = res.possibleDates[i]
|
while (i < possibleDates.length && !freeCourt) {
|
||||||
|
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']`
|
||||||
|
|
@ -234,14 +175,13 @@ export class Runner {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
private async selectOpponent(opponent: Opponent): Promise<void> {
|
private async selectOpponent(id: string, name: string): Promise<void> {
|
||||||
l.getStore()?.debug('Selecting opponent', { opponent })
|
|
||||||
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(opponent.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) => {
|
||||||
|
|
@ -249,7 +189,7 @@ export class Runner {
|
||||||
})
|
})
|
||||||
await this.page
|
await this.page
|
||||||
?.$('select.br-user-select[name="players[2]"]')
|
?.$('select.br-user-select[name="players[2]"]')
|
||||||
.then((d) => d?.select(opponent.id))
|
.then((d) => d?.select(id))
|
||||||
.catch((e: Error) => {
|
.catch((e: Error) => {
|
||||||
throw new RunnerOpponentSearchSelectionError(e)
|
throw new RunnerOpponentSearchSelectionError(e)
|
||||||
})
|
})
|
||||||
|
|
@ -269,9 +209,17 @@ export class Runner {
|
||||||
throw new RunnerReservationConfirmSubmitError(e)
|
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) {
|
constructor(error: Error) {
|
||||||
super(error.message)
|
super(error.message)
|
||||||
this.stack = error.stack
|
this.stack = error.stack
|
||||||
|
|
@ -295,7 +243,7 @@ export class RunnerNavigationDayError extends RunnerError {}
|
||||||
export class RunnerNavigationSelectionError extends RunnerError {}
|
export class RunnerNavigationSelectionError extends RunnerError {}
|
||||||
|
|
||||||
export class RunnerCourtSelectionError extends RunnerError {}
|
export class RunnerCourtSelectionError extends RunnerError {}
|
||||||
export class NoCourtAvailableError extends LoggableError {}
|
export class NoCourtAvailableError extends Error {}
|
||||||
|
|
||||||
export class RunnerOpponentSearchError extends RunnerError {}
|
export class RunnerOpponentSearchError extends RunnerError {}
|
||||||
export class RunnerOpponentSearchInputError extends RunnerError {}
|
export class RunnerOpponentSearchInputError extends RunnerError {}
|
||||||
324
src/runner/controller.ts.old
Normal file
324
src/runner/controller.ts.old
Normal 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
14
src/runner/module.ts
Normal 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
17
src/runner/pages/empty.ts
Normal 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
35
src/runner/router.ts
Normal 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
115
src/runner/service.ts
Normal 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 {}
|
||||||
|
|
@ -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 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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 {}
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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')
|
|
||||||
})
|
|
||||||
|
|
@ -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
24
test/app.e2e-spec.ts
Normal 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
9
test/jest-e2e.json
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"moduleFileExtensions": ["js", "json", "ts"],
|
||||||
|
"rootDir": ".",
|
||||||
|
"testEnvironment": "node",
|
||||||
|
"testRegex": ".e2e-spec.ts$",
|
||||||
|
"transform": {
|
||||||
|
"^.+\\.(t|j)s$": "ts-jest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
describe('failure', () => {
|
|
||||||
test('should fail', () => {
|
|
||||||
expect(true).toBeFalsy()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -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",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
@ -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: '***' }
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -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\$.+$/)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -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)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
@ -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
|
|
||||||
)
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -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
4
tsconfig.build.json
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||||
|
}
|
||||||
|
|
@ -1,21 +1,21 @@
|
||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/tsconfig",
|
|
||||||
|
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"lib": ["esnext", "dom"],
|
|
||||||
"module": "commonjs",
|
"module": "commonjs",
|
||||||
"moduleResolution": "node",
|
"declaration": true,
|
||||||
"target": "es6",
|
"removeComments": true,
|
||||||
"allowJs": true,
|
"emitDecoratorMetadata": true,
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"target": "es2017",
|
||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
|
"outDir": "./dist",
|
||||||
"strict": true,
|
"baseUrl": "./",
|
||||||
"esModuleInterop": true,
|
"incremental": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strictNullChecks": true,
|
||||||
|
"noImplicitAny": true,
|
||||||
|
"strictBindCallApply": true,
|
||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"outDir": "./dist/"
|
"noFallthroughCasesInSwitch": true
|
||||||
},
|
}
|
||||||
|
|
||||||
"include": [
|
|
||||||
"./src/**/*.ts"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
Loading…
Add table
Reference in a new issue