Compare commits

..

3 commits

23 changed files with 920 additions and 589 deletions

2
.nvmrc
View file

@ -1 +1 @@
v24.13.0
lts/hydrogen

View file

@ -6,9 +6,7 @@ when:
steps:
- name: deploy
image: docker.io/node:24
environment:
PUPPETEER_SKIP_DOWNLOAD: true
image: docker.io/node:hydrogen
volumes:
- /etc/ssh:/etc/ssh
commands:
@ -17,3 +15,5 @@ steps:
depends_on:
- test
runs_on: [success]

View file

@ -6,9 +6,7 @@ when:
steps:
- name: test
image: docker.io/node:24-slim
environment:
PUPPETEER_SKIP_DOWNLOAD: true
image: docker.io/node:hydrogen
commands:
- npm ci
- npm run test:unit

22
docker/docker-compose.yml Normal file
View file

@ -0,0 +1,22 @@
services:
http:
build:
context: ..
dockerfile: ./docker/server/Dockerfile
args:
- PORT=3000
- REDIS_HOST=redis
- REDIS_PORT=6379
restart: always
environment:
- PORT=3000
- REDIS_HOST=redis
- REDIS_PORT=6379
ports:
- 3000:3000
volumes:
- ../db:/app/db
redis:
image: redis
ports:
- 6379

32
docker/server/Dockerfile Normal file
View file

@ -0,0 +1,32 @@
FROM node:18 as app
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD true
ENV PUPPETEER_EXECUTABLE_PATH /usr/bin/chromium
RUN apt-get update \
&& apt-get install -y gcc chromium fonts-ipafont-gothic fonts-wqy-zenhei fonts-thai-tlwg fonts-kacst fonts-freefont-ttf libxss1 --no-install-recommends
RUN npm i -g node-gyp
WORKDIR /app
RUN chown -R node:node .
USER node
COPY --chown=node:node package.json package.json
COPY --chown=node:node package-lock.json package-lock.json
RUN CXX=g++-12 npm install
COPY --chown=node:node data-source.ts data-source.ts
COPY --chown=node:node tsconfig.json tsconfig.json
COPY --chown=node:node tsconfig.build.json tsconfig.build.json
COPY --chown=node:node database database
COPY --chown=node:node src src
RUN npm run migrations
ARG GIT_COMMIT
ENV GIT_COMMIT ${GIT_COMMIT:-unknown}
ENTRYPOINT ["npm", "run", "start"]

View file

@ -3,7 +3,7 @@ module.exports = {
{
name: 'autobaan',
script: 'npm',
args: ['run', 'start:prod'],
args: 'run start',
},
],
deploy: {
@ -13,9 +13,8 @@ module.exports = {
ref: 'origin/main',
repo: 'https://fred.collinduncan.com/collin/autobaan.git',
path: '/root/autobaan',
'pre-deploy': 'npm install && npm run build',
'post-deploy':
'GIT_COMMIT=$(git show -s --format=%h) pm2 startOrRestart ecosystem.config.js --name autobaan --update-env',
'npm install && GIT_COMMIT=$(git show -s --format=%h) pm2 startOrRestart ecosystem.config.js --name autobaan --update-env',
},
},
}

982
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -13,7 +13,7 @@
"prestart:dev": "npm run migrations",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/src/main",
"start:prod": "node dist/main",
"repl": "npm run start -- --entryFile repl",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test:e2e": "jest --config ./test/jest-e2e.json",
@ -38,7 +38,6 @@
"dayjs": "^1.11.7",
"imap": "^0.8.19",
"mailparser-mit": "^1.0.0",
"prom-client": "^15.1.3",
"puppeteer": "^20.4.0",
"reflect-metadata": "^0.1.13",
"rxjs": "^7.2.0",
@ -63,7 +62,7 @@
"eslint-plugin-prettier": "^4.0.0",
"eslint-plugin-simple-import-sort": "^10.0.0",
"jest": "29.5.0",
"pm2": "^7.0.1",
"pm2": "^6.0.5",
"prettier": "^2.3.2",
"source-map-support": "^0.5.20",
"supertest": "^6.1.3",

View file

@ -13,7 +13,6 @@ import { DatabaseLoggerService } from './logger/service.database_logger'
import { MembersModule } from './members/module'
import { MonitoringModule } from './monitoring/module'
import { NtfyModule } from './ntfy/module'
import { PrometheusModule } from './prometheus/module'
import { RecurringReservationsModule } from './recurringReservations/module'
import { ReservationsModule } from './reservations/module'
import { RunnerModule } from './runner/module'
@ -64,7 +63,6 @@ import { WaitingListModule } from './waitingList/module'
WaitingListModule,
NtfyModule,
MonitoringModule,
PrometheusModule,
],
})
export class AppModule implements NestModule {

View file

@ -3,6 +3,7 @@ import { ConfigService } from '@nestjs/config'
import { NestFactory } from '@nestjs/core'
import { AppModule } from './app.module'
import { CustomResponseTransformInterceptor } from './common/customResponse'
import { setDefaults } from './common/dayjs'
async function bootstrap() {
@ -14,6 +15,7 @@ async function bootstrap() {
const port = config.get('PORT', 3000)
app.enableShutdownHooks()
app.useGlobalPipes(new ValidationPipe({ transform: true }))
app.useGlobalInterceptors(new CustomResponseTransformInterceptor())
setDefaults()
await app.listen(port, () => console.log(`Listening on port ${port}`))
}

View file

@ -1,10 +1,8 @@
import { Controller, Get, Inject, Query, UseInterceptors } from '@nestjs/common'
import { Controller, Get, Inject, Query } from '@nestjs/common'
import { CustomResponseTransformInterceptor } from '../common/customResponse'
import { MembersService } from './service'
@Controller('members')
@UseInterceptors(CustomResponseTransformInterceptor)
export class MembersController {
constructor(
@Inject(MembersService)

View file

@ -41,7 +41,7 @@ export class NtfyClient {
throw new Error(`${response.status} - ${response.statusText}`)
}
} catch (error: unknown) {
this.loggerService.error('ntfy client failed', { error: error instanceof Error ? error.message : JSON.stringify(error) })
this.loggerService.error('ntfy client failed', { error })
}
}
}

View file

@ -62,7 +62,9 @@ export class NtfyProvider implements OnApplicationBootstrap {
async sendBootstrappedNotification() {
const version =
this.configService.get<string>('GIT_COMMIT') ??
(this.configService.get('LOCAL') === 'true' ? 'LOCAL' : 'unknown')
this.configService.get('LOCAL') === 'true'
? 'LOCAL'
: 'unknown'
await this.publishQueue.add(
...NtfyProvider.defaultJob({
title: 'Autobaan up and running',
@ -105,6 +107,39 @@ export class NtfyProvider implements OnApplicationBootstrap {
)
}
async sendPerformingRiskyReservationNotification(
reservationId: string,
startTime: Dayjs,
endTime: Dayjs,
) {
const url = `${this.configService.get(
'BASE_URL',
)}/reservations/${reservationId}`
await this.publishQueue.add(
...NtfyProvider.defaultJob({
title: 'Handling risky reservation. Waiting for confirmation',
message: `Waiting for ${reservationId} - ${startTime.format()} to ${endTime.format()}`,
actions: [
{
action: 'http',
label: 'Accept',
url: `${url}/resume`,
method: 'POST',
clear: true,
},
{
action: 'http',
label: 'Reject',
url,
method: 'DELETE',
clear: true,
},
],
tags: [MessageTags.warning, MessageTags.passport_control],
}),
)
}
async sendErrorPerformingReservationNotification(
reservationId: string,
startTime: Dayjs,

View file

@ -49,6 +49,7 @@ export enum MessageTags {
clock11 = 'clock11',
clock1130 = 'clock1130',
badminton = 'badminton',
passport_control = 'passport_control',
}
export enum MessagePriority {
@ -59,13 +60,60 @@ export enum MessagePriority {
max = 5,
}
type MessageActionType = 'broadcast' | 'copy' | 'http' | 'view'
export interface MessageActionConfig {
action: MessageActionType
/**
* Label displayed on the action button
*/
label: string
/**
* Clear notification after action button is tapped
*/
clear?: boolean
}
export interface BroadcastActionConfig extends MessageActionConfig {
action: 'broadcast'
intent?: string
extras?: Record<string, string>
}
export interface CopyActionConfig extends MessageActionConfig {
action: 'copy'
value: string
}
export interface HttpActionConfig extends MessageActionConfig {
action: 'http'
url: string
/**
* @default 'POST'
*/
method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' // there are more but if I use them I'll have gone insane
headers?: Record<string, string>
body?: string
}
export interface ViewActionConfig extends MessageActionConfig {
action: 'view'
url: string
clear?: boolean
}
export type MessageAction =
| BroadcastActionConfig
| CopyActionConfig
| HttpActionConfig
| ViewActionConfig
export interface MessageConfig {
topic: string
message?: string
title?: string
tags?: MessageTags[]
priority?: MessagePriority
actions?: object[]
actions?: MessageAction[]
markdown?: boolean
icon?: string
}

View file

@ -1,16 +0,0 @@
import { Controller, Get, Inject } from '@nestjs/common'
import { PrometheusProvider } from './provider'
@Controller('metrics')
export class PrometheusController {
constructor(
@Inject(PrometheusProvider)
private readonly promProvider: PrometheusProvider,
) {}
@Get('/')
async getMetrics() {
return await this.promProvider.getMetrics()
}
}

View file

@ -1,10 +0,0 @@
import { Module } from '@nestjs/common'
import { PrometheusController } from './controller'
import { PrometheusProvider } from './provider'
@Module({
controllers: [PrometheusController],
providers: [PrometheusProvider],
})
export class PrometheusModule {}

View file

@ -1,34 +0,0 @@
import { OnApplicationBootstrap } from '@nestjs/common'
import { collectDefaultMetrics, Counter, Registry } from 'prom-client'
export class PrometheusProvider implements OnApplicationBootstrap {
private readonly registry: Registry
private readonly counters: Map<string, Counter>
constructor() {
this.registry = new Registry()
this.counters = new Map()
}
onApplicationBootstrap() {
collectDefaultMetrics({
register: this.registry,
prefix: 'autobaan_',
labels: { GIT_COMMIT: process.env.GIT_COMMIT ?? 'unknown' },
})
}
async getMetrics() {
return this.registry.metrics()
}
async count(name: string, help: string, increment = 1) {
let counter = this.counters.get(name)
if (counter == null) {
counter = new Counter({ name, help })
this.counters.set(name, counter)
}
counter.inc(increment)
}
}

View file

@ -20,7 +20,6 @@ import {
ValidateNested,
} from 'class-validator'
import { CustomResponseTransformInterceptor } from '../common/customResponse'
import { DayOfWeek } from './entity'
import { RecurringReservationsService } from './service'
@ -80,7 +79,7 @@ export class UpdateRecurringReservationRequest {
}
@Controller('recurring-reservations')
@UseInterceptors(ClassSerializerInterceptor, CustomResponseTransformInterceptor)
@UseInterceptors(ClassSerializerInterceptor)
export class RecurringReservationsController {
constructor(
@Inject(RecurringReservationsService)

View file

@ -23,7 +23,6 @@ import {
} from 'class-validator'
import { Dayjs } from 'dayjs'
import { CustomResponseTransformInterceptor } from '../common/customResponse'
import { DayjsTransformer } from '../common/dayjs'
import { LoggerService } from '../logger/service.logger'
import { RESERVATIONS_QUEUE_NAME, ReservationsQueue } from './config'
@ -71,7 +70,7 @@ export class CreateReservationRequest {
}
@Controller('reservations')
@UseInterceptors(ClassSerializerInterceptor, CustomResponseTransformInterceptor)
@UseInterceptors(ClassSerializerInterceptor)
export class ReservationsController {
constructor(
@Inject(ReservationsService)

View file

@ -97,9 +97,7 @@ export class ReservationsCronService {
await this.ntfyProvider.sendCronStartNotification(
'cleanUpExpiredReservations',
)
const reservations = await this.reservationService.getOlderThanDate(
dayjs().subtract(7, 'day'),
)
const reservations = await this.reservationService.getByDate()
this.loggerService.debug(
`Found ${reservations.length} reservations to delete`,
)

View file

@ -8,6 +8,12 @@ import { LoggerService } from '../logger/service.logger'
import { BaanReserverenService } from '../runner/baanreserveren/service'
import { Reservation, ReservationStatus } from './entity'
export enum ReservationDangerLevel {
Safe = 'safe',
Risky = 'risky',
Lethal = 'lethal',
}
@Injectable()
export class ReservationsService {
constructor(
@ -41,17 +47,6 @@ export class ReservationsService {
return await qb.orderBy('dateRangeStart', 'ASC').getMany()
}
async getOlderThanDate(date = dayjs()) {
const query = this.reservationsRepository
.createQueryBuilder()
.where(`DATE(dateRangeStart) < DATE(:startDate)`, {
startDate: date.toISOString(),
})
.orderBy('dateRangeStart', 'ASC')
return await query.getMany()
}
/**
* Gets all reservations that have not been scheduled that are within the reservation window
* @returns Reservations that can be scheduled
@ -104,6 +99,19 @@ export class ReservationsService {
return await this.reservationsRepository.save(res)
}
getDangerLevel(reservation: Reservation) {
// don't book something within the ~~danger zone~~
const now = dayjs()
const hourDiff = now.diff(reservation.dateRangeStart, 'hours')
if (hourDiff > 8) {
return ReservationDangerLevel.Safe
} else if (hourDiff >= 5) {
return ReservationDangerLevel.Risky
} else {
return ReservationDangerLevel.Lethal
}
}
async update(reservationId: string, update: Partial<Reservation>) {
return await this.reservationsRepository.update(reservationId, update)
}
@ -112,14 +120,10 @@ export class ReservationsService {
const reservation = await this.getById(id)
if (!reservation) return
try {
if (reservation.status === ReservationStatus.Booked) {
if (reservation.status === ReservationStatus.Booked)
await this.brService.cancelReservation(reservation)
} else if (reservation.status === ReservationStatus.OnWaitingList) {
else if (reservation.status === ReservationStatus.OnWaitingList)
await this.brService.removeReservationFromWaitList(reservation)
}
} catch (error: unknown) {}
return await this.reservationsRepository.delete({ id: reservation.id })
}
}

View file

@ -119,8 +119,7 @@ export const StartTimeClassCourtSlots: Record<
} as const
const StartTimeClassStartTimes = {
[StartTimeClass.First]: {
weekday: [
[StartTimeClass.First]: [
'07:15',
'08:00',
'08:45',
@ -143,25 +142,7 @@ const StartTimeClassStartTimes = {
'21:30',
'22:15',
],
weekend: [
'09:00',
'09:45',
'10:30',
'11:15',
'12:00',
'12:45',
'13:30',
'14:15',
'15:00',
'15:45',
'16:30',
'17:15',
'18:00',
'18:45',
],
},
[StartTimeClass.Second]: {
weekday: [
[StartTimeClass.Second]: [
'07:45',
'08:30',
'09:15',
@ -183,25 +164,7 @@ const StartTimeClassStartTimes = {
'21:15',
'22:00',
],
weekend: [
'09:15',
'10:00',
'10:45',
'11:30',
'12:15',
'13:00',
'13:45',
'14:30',
'15:15',
'16:00',
'16:45',
'17:30',
'18:15',
'19:00',
],
},
[StartTimeClass.Third]: {
weekday: [
[StartTimeClass.Third]: [
'07:30',
'08:15',
'09:00',
@ -224,23 +187,6 @@ const StartTimeClassStartTimes = {
'21:45',
'22:30',
],
weekend: [
'08:45',
'09:30',
'10:15',
'11:00',
'11:45',
'12:30',
'13:15',
'14:00',
'14:45',
'15:30',
'16:15',
'17:00',
'17:45',
'18:30',
],
},
}
const TYPING_DELAY_MS = 2
@ -793,12 +739,7 @@ export class BaanReserverenService {
public getCourtSlotsForDate(date: Dayjs) {
const time = date.format('HH:mm')
const weekdayEndSelector =
date.day() !== 0 || date.day() !== 6 ? 'weekday' : 'weekend'
for (const [timeClass, timesForWeekdayAndWeekend] of Object.entries(
StartTimeClassStartTimes,
)) {
const times = timesForWeekdayAndWeekend[weekdayEndSelector]
for (const [timeClass, times] of Object.entries(StartTimeClassStartTimes)) {
if (times.includes(time)) {
const courtSlots = [
...StartTimeClassCourtSlots[timeClass as StartTimeClass],
@ -971,12 +912,8 @@ export class BaanReserverenService {
try {
currentAttempt++
await this.init()
break
} catch (err: unknown) {
if (err instanceof RunningReservationsNavigationError) {
this.loggerService.warn('Hit expected warmup error, retrying', {
currentAttempt,
})
await new Promise((res) => setTimeout(res, delay))
} else {
throw err

View file

@ -12,7 +12,11 @@ import {
RESERVATIONS_QUEUE_NAME,
ReservationsQueue,
} from '../reservations/config'
import { ReservationsService } from '../reservations/service'
import { Reservation } from '../reservations/entity'
import {
ReservationDangerLevel,
ReservationsService,
} from '../reservations/service'
import { WaitingListDetails } from './types'
const EMAIL_SUBJECT_REGEX = new RegExp(
@ -90,12 +94,45 @@ export class WaitingListService {
return
}
// don't book something within the ~~danger zone~~
const partitioned = reservations.reduce<{
safe: Reservation[]
risky: Reservation[]
lethal: Reservation[]
}>(
(acc, res) => {
switch (this.reservationsService.getDangerLevel(res)) {
case ReservationDangerLevel.Safe:
return {
safe: [...acc.safe, res],
risky: acc.risky,
lethal: acc.lethal,
}
case ReservationDangerLevel.Risky:
return {
safe: acc.safe,
risky: [...acc.risky, res],
lethal: acc.lethal,
}
case ReservationDangerLevel.Lethal:
return {
safe: acc.safe,
risky: acc.risky,
lethal: [...acc.lethal, res],
}
}
},
{ safe: [], risky: [], lethal: [] },
)
this.loggerService.debug(
`Found ${reservations.length} reservations on waiting list`,
`Found the reservations in given categories: ${JSON.stringify(
partitioned,
)}`,
)
await this.reservationsQueue.addBulk(
reservations.map((r) => ({
partitioned.safe.map((r) => ({
data: { reservation: r, speedyMode: false },
opts: { attempts: 1 },
})),