Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(aoc): AOC Simulation (Part 1) #146

Draft
wants to merge 10 commits into
base: main
Choose a base branch
from
124 changes: 124 additions & 0 deletions src/aoc/aoc-connection.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { Body, CacheInterceptor, CacheTTL, Controller, Delete, Get, Param, Post, Put, Query, Request, UseGuards, UseInterceptors, ValidationPipe } from '@nestjs/common';
import { ApiBadRequestResponse, ApiBody, ApiCreatedResponse, ApiNotFoundResponse, ApiOkResponse, ApiParam, ApiQuery, ApiSecurity, ApiTags } from '@nestjs/swagger';
import { AocService } from './aoc.service';
import { CreateAocConnectionDto } from './dto/create-aoc-connection.dto';
import { FlightToken } from '../auth/flights/flight-token.class';
import { PaginatedAocConnectionDto } from './dto/paginated-aoc-connection.dto';
import { PaginationDto } from '../common/Pagination';
import { BoundsDto } from '../common/Bounds';
import { AocConnectionSearchResultDto } from './dto/aoc-connection-search-result.dto';
import { AocConnection } from './entities/aoc-connection.entity';
import { FlightAuthGuard } from '../auth/flights/flight-auth-guard.service';
import { UpdateAocConnectionDto } from './dto/update-aoc-connection.dto';

@ApiTags('AOC')
@Controller('aoc')
@UseInterceptors(CacheInterceptor)
export class AocConnectionController {
constructor(private aoc: AocService) {
}

@Get()
@CacheTTL(15)
@ApiOkResponse({ description: 'The paginated list of connections', type: PaginatedAocConnectionDto })
@ApiQuery({
name: 'take',
type: Number,
required: false,
description: 'The number of connections to take',
schema: { maximum: 25, minimum: 0, default: 25 },
})
@ApiQuery({
name: 'skip',
type: Number,
required: false,
description: 'The number of connections to skip',
schema: { minimum: 0, default: 0 },
})
@ApiQuery({
name: 'north',
type: Number,
required: false,
description: 'Latitude for the north edge of the bounding box',
schema: { minimum: -90, maximum: 90, default: 90 },
})
@ApiQuery({
name: 'east',
type: Number,
required: false,
description: 'Longitude for the east edge of the bounding box',
schema: { minimum: -180, maximum: 180, default: 180 },
})
@ApiQuery({
name: 'south',
type: Number,
required: false,
description: 'Latitude for the south edge of the bounding box',
schema: { minimum: -90, maximum: 90, default: -90 },
})
@ApiQuery({
name: 'west',
type: Number,
required: false,
description: 'Longitude for the west edge of the bounding box',
schema: { minimum: -180, maximum: 180, default: -180 },
})
getAllActiveConnections(@Query(new ValidationPipe({ transform: true })) pagination: PaginationDto,
@Query(new ValidationPipe({ transform: true })) bounds: BoundsDto): Promise<PaginatedAocConnectionDto> {
return this.aoc.getActiveConnections(pagination, bounds);
}

@Get('_find')
@CacheTTL(15)
@ApiQuery({ name: 'flight', description: 'The flight number', example: 'AAL456' })
@ApiOkResponse({ description: 'All connections matching the query', type: AocConnectionSearchResultDto })
findConnection(@Query('flight') flight: string): Promise<AocConnectionSearchResultDto> {
return this.aoc.findActiveConnectionByFlight(flight);
}

@Get('_count')
@CacheTTL(15)
@ApiOkResponse({ description: 'The total number of active AOC connections', type: Number })
countConnections(): Promise<number> {
return this.aoc.countActiveConnections();
}

@Get(':id')
@CacheTTL(15)
@ApiParam({ name: 'id', description: 'The connection ID', example: '6571f19e-21f7-4080-b239-c9d649347101' })
@ApiOkResponse({ description: 'The connection with the given ID was found', type: AocConnection })
@ApiNotFoundResponse({ description: 'The connection with the given ID could not be found' })
getSingleConnection(@Param('id') id: string): Promise<AocConnection> {
return this.aoc.getSingleConnection(id);
}

@Post()
@ApiBody({
description: 'The new connection containing the flight number and current location',
type: CreateAocConnectionDto,
})
@ApiCreatedResponse({ description: 'An AOC connection got created', type: FlightToken })
@ApiBadRequestResponse({ description: 'An active flight with the given flight number is already in use' })
addNewConnection(@Body() body: CreateAocConnectionDto): Promise<FlightToken> {
return this.aoc.addNewConnection(body);
}

@Put()
@UseGuards(FlightAuthGuard)
@ApiSecurity('jwt')
@ApiBody({ description: 'The updated connection', type: UpdateAocConnectionDto })
@ApiOkResponse({ description: 'The connection got updated', type: AocConnection })
@ApiNotFoundResponse({ description: 'The connection with the given ID could not be found' })
updateConnection(@Body() body: UpdateAocConnectionDto, @Request() req): Promise<AocConnection> {
return this.aoc.updateConnection(req.user.connectionId, body);
}

@Delete()
@UseGuards(FlightAuthGuard)
@ApiSecurity('jwt')
@ApiOkResponse({ description: 'The connection got disabled' })
@ApiNotFoundResponse({ description: 'The connection with the given ID could not be found' })
disableConnection(@Request() req): Promise<void> {
return this.aoc.disableConnection(req.user.connectionId);
}
}
17 changes: 17 additions & 0 deletions src/aoc/aoc.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AocConnection } from './entities/aoc-connection.entity';
import { AocConnectionController } from './aoc-connection.controller';
import { AocService } from './aoc.service';
import { AuthModule } from '../auth/auth.module';

@Module({
imports: [
TypeOrmModule.forFeature([AocConnection]),
AuthModule,
],
providers: [AocService],
controllers: [AocConnectionController],
exports: [AocService],
})
export class AocModule {}
163 changes: 163 additions & 0 deletions src/aoc/aoc.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import { HttpException, Injectable, Logger } from '@nestjs/common';
import { Repository } from 'typeorm';
import { InjectRepository } from '@nestjs/typeorm';
import { ConfigService } from '@nestjs/config';
import { Cron } from '@nestjs/schedule';
import { BannedFlightNumbers } from '../telex/filters';
import { CreateAocConnectionDto } from './dto/create-aoc-connection.dto';
import { AuthService } from '../auth/auth.service';
import { FlightToken } from '../auth/flights/flight-token.class';
import { AocConnection } from './entities/aoc-connection.entity';
import { PaginatedAocConnectionDto } from './dto/paginated-aoc-connection.dto';
import { PaginationDto } from '../common/Pagination';
import { BoundsDto } from '../common/Bounds';
import { AocConnectionSearchResultDto } from './dto/aoc-connection-search-result.dto';
import { UpdateAocConnectionDto } from './dto/update-aoc-connection.dto';

@Injectable()
export class AocService {
private readonly logger = new Logger(AocService.name);

constructor(
@InjectRepository(AocConnection)
private readonly connectionRepository: Repository<AocConnection>,
private readonly configService: ConfigService,
private readonly authService: AuthService,
) {}

@Cron('*/5 * * * * *')
private async checkForStaleConnections() {
if (this.configService.get<boolean>('aoc.disableCleanup')) {
return;
}

const timeout = this.configService.get<number>('aoc.timeoutMin');
this.logger.verbose(`Trying to cleanup stale AOC connections older than ${timeout} minutes`);

const connections = await this.connectionRepository
.createQueryBuilder()
.update()
.set({ isActive: false })
.andWhere(`lastContact < NOW() - INTERVAL ${timeout} MINUTE`)
.andWhere('isActive = 1')
.execute();

this.logger.debug(`Set ${connections.affected} state connection to inactive`);
}

async getActiveConnections(pagination: PaginationDto, bounds: BoundsDto): Promise<PaginatedAocConnectionDto> {
this.logger.log(`Trying to get ${pagination.take} AOC connections, skipped ${pagination.skip}`);

const [results, total] = await this.connectionRepository
.createQueryBuilder()
.select()
.skip(pagination.skip)
.take(pagination.take)
.where({ isActive: true })
.andWhere(
`ST_Contains(ST_MakeEnvelope(ST_GeomFromText('POINT(${bounds.west} ${bounds.north})'),`
+ ` ST_GeomFromText('Point(${bounds.east} ${bounds.south})')), location)`,
)
.orderBy('firstContact', 'ASC')
.getManyAndCount();

return {
results,
count: results.length,
total,
};
}

async getSingleConnection(id: string, active?: boolean): Promise<AocConnection> {
this.logger.log(`Trying to get single active AOC connection with ID '${id}'`);

const conn = await this.connectionRepository.findOne(id);
if (!conn || (active !== undefined && conn.isActive !== active)) {
const message = `${active ? 'Active f' : 'F'}light with ID '${id}' does not exist`;
this.logger.error(message);
throw new HttpException(message, 404);
}

return conn;
}

async findActiveConnectionByFlight(query: string): Promise<AocConnectionSearchResultDto> {
this.logger.log(`Trying to search for active AOC connections with flight number '${query}'`);

const matches = await this.connectionRepository
.createQueryBuilder()
.select()
.where(`UPPER(flight) LIKE UPPER('${query}%')`)
.andWhere('isActive = 1')
.orderBy('flight', 'ASC')
.limit(50)
.getMany();

return {
matches,
fullMatch: matches.find((x) => x.flight === query) ?? null,
};
}

async countActiveConnections(): Promise<number> {
this.logger.debug('Trying to get total number of active connections');

return this.connectionRepository.count({ isActive: true });
}

async addNewConnection(connection: CreateAocConnectionDto): Promise<FlightToken> {
this.logger.log(`Trying to register new AOC connection '${connection.flight}'`);

if (BannedFlightNumbers.includes(connection.flight.toUpperCase())) {
const message = `User tried to use banned flight number: '${connection.flight}'`;
this.logger.log(message);
throw new HttpException(message, 400);
}

const existingFlight = await this.connectionRepository.findOne({ flight: connection.flight, isActive: true });

if (existingFlight) {
const message = `An active flight with the number '${connection.flight}' is already in use`;
this.logger.error(message);
throw new HttpException(message, 409);
}

const newFlight: AocConnection = { ...connection };

this.logger.log(`Registering new flight '${connection.flight}'`);
await this.connectionRepository.save(newFlight);

return this.authService.registerFlight(newFlight.flight, newFlight.id);
}

async updateConnection(connectionId: string, connection: UpdateAocConnectionDto): Promise<AocConnection> {
this.logger.log(`Trying to update flight with ID '${connectionId}'`);

const change = await this.connectionRepository.update({ id: connectionId, isActive: true }, connection);

if (!change.affected) {
const message = `Active flight with ID '${connectionId}' does not exist`;
this.logger.error(message);
throw new HttpException(message, 404);
}

this.logger.log(`Updated flight with id '${connectionId}'`);
return this.connectionRepository.findOne(connectionId);
}

async disableConnection(connectionId: string): Promise<void> {
this.logger.log(`Trying to disable TELEX connection with ID '${connectionId}'`);

const existingFlight = await this.connectionRepository.findOne({ id: connectionId, isActive: true });

if (!existingFlight) {
const message = `Active flight with ID '${connectionId}' does not exist`;
this.logger.error(message);
throw new HttpException(message, 404);
}

this.logger.log(`Disabling flight with ID '${connectionId}'`);

await this.connectionRepository.update(existingFlight.id, { isActive: false });
}
}
10 changes: 10 additions & 0 deletions src/aoc/dto/aoc-connection-search-result.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { ApiProperty } from '@nestjs/swagger';
import { AocConnection } from '../entities/aoc-connection.entity';

export class AocConnectionSearchResultDto {
@ApiProperty({ description: 'A possible full text match' })
fullMatch?: AocConnection;

@ApiProperty({ description: 'All possible matches', type: [AocConnection] })
matches: AocConnection[];
}
9 changes: 9 additions & 0 deletions src/aoc/dto/create-aoc-connection.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { IsNotEmpty } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
import { UpdateAocConnectionDto } from './update-aoc-connection.dto';

export class CreateAocConnectionDto extends UpdateAocConnectionDto {
@IsNotEmpty()
@ApiProperty({ description: 'The flight number', example: 'OS355' })
flight: string;
}
13 changes: 13 additions & 0 deletions src/aoc/dto/paginated-aoc-connection.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { ApiProperty } from '@nestjs/swagger';
import { AocConnection } from '../entities/aoc-connection.entity';

export class PaginatedAocConnectionDto {
@ApiProperty({ description: 'List of AOC connections', type: [AocConnection] })
results: AocConnection[];

@ApiProperty({ description: 'Amount of connections returned', example: 25 })
count: number;

@ApiProperty({ description: 'The number of total active connections in the boundary', example: 1237 })
total: number;
}
33 changes: 33 additions & 0 deletions src/aoc/dto/update-aoc-connection.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { IsNotEmpty, IsOptional } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
import { Point } from '../../utilities/point.entity';

export class UpdateAocConnectionDto {
@IsNotEmpty()
@ApiProperty({ description: 'The current location of the aircraft ' })
location: Point;

@IsNotEmpty()
@ApiProperty({ description: 'The altitude above sea level of the aircraft in feet', example: 3500 })
trueAltitude: number;

@IsNotEmpty()
@ApiProperty({ description: 'The heading of the aircraft in degrees', example: 250.46, minimum: 0, maximum: 360 })
heading: number;

@IsOptional()
@ApiProperty({ description: 'Whether the user wants to receive freetext messages', example: true })
freetextEnabled: boolean = true;

@IsOptional()
@ApiProperty({ description: 'The aircraft type the connection associated with', example: 'A32NX' })
aircraftType: string = 'unknown';

@IsOptional()
@ApiProperty({ description: 'The destination of the flgiht', example: 'KSFO', required: false })
destination?: string;

@IsOptional()
@ApiProperty({ description: 'The origin of the flight', example: 'KLAX', required: false })
origin?: string;
}
Loading