diff --git a/libs/api/radio-communication-system/.eslintrc.json b/libs/api/radio-communication-system/.eslintrc.json new file mode 100644 index 000000000..79fd7c1d9 --- /dev/null +++ b/libs/api/radio-communication-system/.eslintrc.json @@ -0,0 +1,18 @@ +{ + "extends": ["../../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.ts", "*.tsx"], + "rules": {} + }, + { + "files": ["*.js", "*.jsx"], + "rules": {} + } + ] +} diff --git a/libs/api/radio-communication-system/README.md b/libs/api/radio-communication-system/README.md new file mode 100644 index 000000000..2e78f9eb3 --- /dev/null +++ b/libs/api/radio-communication-system/README.md @@ -0,0 +1,16 @@ +# Radio Communication System + +This module covers the radio communication currently over tetra control. The +TetraControlService can send out Call Outs, SDS and Status changes. Also, a +webhook handler for incoming status changes is implemented. The webhook has to +be configured with a key via `TETRA_CONTROL_WEBHOOK_KEY`, which has to be +provided in Tetra Control +`/webhooks/tetra-control?key=`. With +`TETRA_CONTROL_WEBHOOK_VALID_IPS`, a list of allowed IPs can be provided +(delimited by a comma). If the list is empty, all IPs are allowed. If a new SDS +is received, a `NewTetraStatusEvent` is emitted. + +## Running unit tests + +Run `nx test api-radio-communication-system` to execute the unit tests via +[Jest](https://jestjs.io). diff --git a/libs/api/radio-communication-system/jest.config.ts b/libs/api/radio-communication-system/jest.config.ts new file mode 100644 index 000000000..99d185a63 --- /dev/null +++ b/libs/api/radio-communication-system/jest.config.ts @@ -0,0 +1,11 @@ +/* eslint-disable */ +export default { + displayName: 'api-radio-communication-system', + preset: '../../../jest.preset.js', + testEnvironment: 'node', + transform: { + '^.+\\.[tj]s$': ['ts-jest', { tsconfig: '/tsconfig.spec.json' }], + }, + moduleFileExtensions: ['ts', 'js', 'html'], + coverageDirectory: '../../../coverage/libs/api/radio-communication-system', +}; diff --git a/libs/api/radio-communication-system/project.json b/libs/api/radio-communication-system/project.json new file mode 100644 index 000000000..a15752729 --- /dev/null +++ b/libs/api/radio-communication-system/project.json @@ -0,0 +1,30 @@ +{ + "name": "api-radio-communication-system", + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/api/radio-communication-system/src", + "projectType": "library", + "targets": { + "lint": { + "executor": "@nx/linter:eslint", + "outputs": ["{options.outputFile}"], + "options": { + "lintFilePatterns": ["libs/api/radio-communication-system/**/*.ts"] + } + }, + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "libs/api/radio-communication-system/jest.config.ts", + "passWithNoTests": true + }, + "configurations": { + "ci": { + "ci": true, + "codeCoverage": true + } + } + } + }, + "tags": [] +} diff --git a/libs/api/radio-communication-system/src/index.ts b/libs/api/radio-communication-system/src/index.ts new file mode 100644 index 000000000..434f49ae1 --- /dev/null +++ b/libs/api/radio-communication-system/src/index.ts @@ -0,0 +1,2 @@ +export * from './lib/radio-communication-system.module'; +export * from './lib/core/event/new-tetra-status.event'; diff --git a/libs/api/radio-communication-system/src/lib/core/command/publish-tetra-status.command.spec.ts b/libs/api/radio-communication-system/src/lib/core/command/publish-tetra-status.command.spec.ts new file mode 100644 index 000000000..28d28d602 --- /dev/null +++ b/libs/api/radio-communication-system/src/lib/core/command/publish-tetra-status.command.spec.ts @@ -0,0 +1,36 @@ +import { createMock } from '@golevelup/ts-jest'; +import { EventBus } from '@nestjs/cqrs'; + +import { NewTetraStatusEvent } from '../event/new-tetra-status.event'; +import { + PublishTetraStatusCommand, + PublishTetraStatusHandler, +} from './publish-tetra-status.command'; + +describe('PublishTetraStatusHandler', () => { + let handler: PublishTetraStatusHandler; + let eventBus: EventBus; + + beforeEach(() => { + eventBus = createMock(); + handler = new PublishTetraStatusHandler(eventBus); + }); + + it('should publish NewRadioCallStatusReportEvent', async () => { + const sendingIssi = '12345'; + const fmsStatus = 1; + const sentAt = new Date(); + + const command = new PublishTetraStatusCommand( + sendingIssi, + fmsStatus, + sentAt, + ); + const publishSpy = jest.spyOn(eventBus, 'publish'); + await handler.execute(command); + + expect(publishSpy).toHaveBeenCalledWith( + new NewTetraStatusEvent(sendingIssi, fmsStatus, sentAt), + ); + }); +}); diff --git a/libs/api/radio-communication-system/src/lib/core/command/publish-tetra-status.command.ts b/libs/api/radio-communication-system/src/lib/core/command/publish-tetra-status.command.ts new file mode 100644 index 000000000..115cb64e0 --- /dev/null +++ b/libs/api/radio-communication-system/src/lib/core/command/publish-tetra-status.command.ts @@ -0,0 +1,33 @@ +import { + CommandHandler, + EventBus, + ICommand, + ICommandHandler, +} from '@nestjs/cqrs'; + +import { NewTetraStatusEvent } from '../event/new-tetra-status.event'; + +export class PublishTetraStatusCommand implements ICommand { + constructor( + readonly sendingIssi: string, + readonly fmsStatus: number, + readonly sentAt: Date, + ) {} +} + +@CommandHandler(PublishTetraStatusCommand) +export class PublishTetraStatusHandler + implements ICommandHandler +{ + constructor(private readonly eventBus: EventBus) {} + + async execute(command: PublishTetraStatusCommand): Promise { + this.eventBus.publish( + new NewTetraStatusEvent( + command.sendingIssi, + command.fmsStatus, + command.sentAt, + ), + ); + } +} diff --git a/libs/api/radio-communication-system/src/lib/core/command/send-tetra-sds.command.spec.ts b/libs/api/radio-communication-system/src/lib/core/command/send-tetra-sds.command.spec.ts new file mode 100644 index 000000000..983bb0cd9 --- /dev/null +++ b/libs/api/radio-communication-system/src/lib/core/command/send-tetra-sds.command.spec.ts @@ -0,0 +1,48 @@ +import { DeepMocked, createMock } from '@golevelup/ts-jest'; + +import { TetraControlService } from '../../infra/service/tetra-control.service'; +import { SdsNotAbleToSendException } from '../exception/sds-not-able-to-send.exception'; +import { + SendTetraSDSCommand, + SendTetraSDSHandler, +} from './send-tetra-sds.command'; + +describe('SendTetraSDSHandler', () => { + let handler: SendTetraSDSHandler; + let tetraServiceMock: DeepMocked; + + beforeEach(() => { + tetraServiceMock = createMock(); + handler = new SendTetraSDSHandler(tetraServiceMock); + }); + + it('should call send sds through tetra service', async () => { + const issi = '12345'; + const message = 'Test message'; + const isFlash = true; + + const command = new SendTetraSDSCommand(issi, message, isFlash); + + await handler.execute(command); + + expect(tetraServiceMock.sendSDS).toHaveBeenCalledWith( + issi, + message, + isFlash, + ); + }); + + it('should throw an error if tetraService.sendSDS throws an error', async () => { + const issi = '12345'; + const message = 'Test message'; + const isFlash = true; + + const command = new SendTetraSDSCommand(issi, message, isFlash); + + tetraServiceMock.sendSDS.mockRejectedValue(new Error()); + + await expect(handler.execute(command)).rejects.toThrow( + SdsNotAbleToSendException, + ); + }); +}); diff --git a/libs/api/radio-communication-system/src/lib/core/command/send-tetra-sds.command.ts b/libs/api/radio-communication-system/src/lib/core/command/send-tetra-sds.command.ts new file mode 100644 index 000000000..f0e5c704e --- /dev/null +++ b/libs/api/radio-communication-system/src/lib/core/command/send-tetra-sds.command.ts @@ -0,0 +1,34 @@ +import { Inject } from '@nestjs/common'; +import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; + +import { SdsNotAbleToSendException } from '../exception/sds-not-able-to-send.exception'; +import { TETRA_SERVICE, TetraService } from '../service/tetra.service'; + +export class SendTetraSDSCommand { + constructor( + readonly issi: string, + readonly message: string, + readonly isFlash: boolean, + ) {} +} + +@CommandHandler(SendTetraSDSCommand) +export class SendTetraSDSHandler + implements ICommandHandler +{ + constructor( + @Inject(TETRA_SERVICE) private readonly tetraService: TetraService, + ) {} + + async execute(command: SendTetraSDSCommand): Promise { + try { + await this.tetraService.sendSDS( + command.issi, + command.message, + command.isFlash, + ); + } catch (error) { + throw new SdsNotAbleToSendException(error); + } + } +} diff --git a/libs/api/radio-communication-system/src/lib/core/event/new-tetra-status.event.ts b/libs/api/radio-communication-system/src/lib/core/event/new-tetra-status.event.ts new file mode 100644 index 000000000..0d44ea975 --- /dev/null +++ b/libs/api/radio-communication-system/src/lib/core/event/new-tetra-status.event.ts @@ -0,0 +1,9 @@ +import { IEvent } from '@nestjs/cqrs'; + +export class NewTetraStatusEvent implements IEvent { + constructor( + public readonly issi: string, + public readonly status: number, + public readonly sentAt: Date, + ) {} +} diff --git a/libs/api/radio-communication-system/src/lib/core/exception/sds-not-able-to-send.exception.ts b/libs/api/radio-communication-system/src/lib/core/exception/sds-not-able-to-send.exception.ts new file mode 100644 index 000000000..922415cd7 --- /dev/null +++ b/libs/api/radio-communication-system/src/lib/core/exception/sds-not-able-to-send.exception.ts @@ -0,0 +1,5 @@ +export class SdsNotAbleToSendException extends Error { + constructor(error: unknown) { + super(`Not able to send SDS: ${error}`); + } +} diff --git a/libs/api/radio-communication-system/src/lib/core/service/tetra.service.ts b/libs/api/radio-communication-system/src/lib/core/service/tetra.service.ts new file mode 100644 index 000000000..8e1923520 --- /dev/null +++ b/libs/api/radio-communication-system/src/lib/core/service/tetra.service.ts @@ -0,0 +1,17 @@ +export const TETRA_SERVICE = Symbol('TETRA_SERVICE'); + +export interface TetraService { + sendSDS(issi: string, message: string, isFlash?: boolean): Promise; + + sendCallOut( + issi: string, + message: string, + noReply: boolean, + prio?: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15, + ): Promise; + + sendStatus( + issi: string, + status: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8, + ): Promise; +} diff --git a/libs/api/radio-communication-system/src/lib/infra/controller/rcs.resolver.spec.ts b/libs/api/radio-communication-system/src/lib/infra/controller/rcs.resolver.spec.ts new file mode 100644 index 000000000..02e1bdd2e --- /dev/null +++ b/libs/api/radio-communication-system/src/lib/infra/controller/rcs.resolver.spec.ts @@ -0,0 +1,63 @@ +import { DeepMocked, createMock } from '@golevelup/ts-jest'; +import { CommandBus } from '@nestjs/cqrs'; +import { Test, TestingModule } from '@nestjs/testing'; + +import { SendTetraSDSCommand } from '../../core/command/send-tetra-sds.command'; +import { SdsNotAbleToSendException } from '../../core/exception/sds-not-able-to-send.exception'; +import { PresentableSdsNotSendException } from '../exception/presentable-sds-not-send.exception'; +import { RCSResolver } from './rcs.resolver'; + +describe('RCSResolver', () => { + let resolver: RCSResolver; + let commandBus: DeepMocked; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + RCSResolver, + { + provide: CommandBus, + useValue: createMock(), + }, + ], + }).compile(); + + resolver = module.get(RCSResolver); + commandBus = module.get>(CommandBus); + }); + + describe('sendSDS', () => { + it('should send a Send Tetra SDS command', async () => { + const issi = '123456'; + const message = 'Test message'; + const isFlash = true; + + await resolver.sendSDS(issi, message, isFlash); + + expect(commandBus.execute).toHaveBeenCalledWith( + new SendTetraSDSCommand(issi, message, isFlash), + ); + }); + + it('should throw presentable error on SDS error', async () => { + const issi = '123456'; + const message = 'Test message'; + const isFlash = false; + + commandBus.execute.mockRejectedValueOnce( + new SdsNotAbleToSendException(new Error()), + ); + + await expect(resolver.sendSDS(issi, message, isFlash)).rejects.toThrow( + PresentableSdsNotSendException, + ); + + const unknownError = new Error('i am an unknown error'); + commandBus.execute.mockRejectedValueOnce(unknownError); + + await expect(resolver.sendSDS(issi, message, isFlash)).rejects.toThrow( + unknownError, + ); + }); + }); +}); diff --git a/libs/api/radio-communication-system/src/lib/infra/controller/rcs.resolver.ts b/libs/api/radio-communication-system/src/lib/infra/controller/rcs.resolver.ts new file mode 100644 index 000000000..5bebcbac1 --- /dev/null +++ b/libs/api/radio-communication-system/src/lib/infra/controller/rcs.resolver.ts @@ -0,0 +1,32 @@ +import { CommandBus } from '@nestjs/cqrs'; +import { Args, Mutation, Resolver } from '@nestjs/graphql'; + +import { SendTetraSDSCommand } from '../../core/command/send-tetra-sds.command'; +import { SdsNotAbleToSendException } from '../../core/exception/sds-not-able-to-send.exception'; +import { PresentableSdsNotSendException } from '../exception/presentable-sds-not-send.exception'; + +@Resolver() +export class RCSResolver { + constructor(private readonly commandBus: CommandBus) {} + + @Mutation(() => Boolean) + async sendSDS( + @Args('issi') issi: string, + @Args('message') message: string, + @Args('isFlash') isFlash?: boolean, + ): Promise { + try { + await this.commandBus.execute( + new SendTetraSDSCommand(issi, message, !!isFlash), + ); + } catch (error) { + if (error instanceof SdsNotAbleToSendException) { + throw new PresentableSdsNotSendException(); + } + + throw error; + } + + return true; + } +} diff --git a/libs/api/radio-communication-system/src/lib/infra/controller/tetra-control-webhook.controller.spec.ts b/libs/api/radio-communication-system/src/lib/infra/controller/tetra-control-webhook.controller.spec.ts new file mode 100644 index 000000000..ba69facef --- /dev/null +++ b/libs/api/radio-communication-system/src/lib/infra/controller/tetra-control-webhook.controller.spec.ts @@ -0,0 +1,58 @@ +import { createMock } from '@golevelup/ts-jest'; +import { BadRequestException, UnauthorizedException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { CommandBus } from '@nestjs/cqrs'; +import { Test, TestingModule } from '@nestjs/testing'; + +import { TETRA_SERVICE } from '../../core/service/tetra.service'; +import { TetraControlService } from '../service/tetra-control.service'; +import { TetraControlWebhookController } from './tetra-control-webhook.controller'; + +describe('TetraControlWebhookController', () => { + let controller: TetraControlWebhookController; + let configService: ConfigService; + let tetraService: TetraControlService; + + beforeEach(async () => { + configService = createMock({ + getOrThrow: jest.fn().mockReturnValue('validKey'), + get: jest.fn().mockReturnValue('192.168.0.1,192.168.0.2'), + }); + const module: TestingModule = await Test.createTestingModule({ + controllers: [TetraControlWebhookController], + providers: [ + { provide: TETRA_SERVICE, useValue: createMock() }, + { provide: ConfigService, useValue: configService }, + ], + }).compile(); + + controller = module.get( + TetraControlWebhookController, + ); + tetraService = module.get(TETRA_SERVICE); + }); + + it('should throw UnauthorizedException when IP or key is invalid', async () => { + const payload = { data: { type: 'status' } }; + + await expect( + controller.handleWebhook(payload as any, 'invalidKey', '192.168.1.0'), + ).rejects.toThrow(UnauthorizedException); + }); + + it('should throw BadRequestException when payload type has no handler', async () => { + const payload = { data: { type: 'invalidType' } }; + + await expect( + controller.handleWebhook(payload as any, 'validKey', '192.168.0.1'), + ).rejects.toThrow(BadRequestException); + }); + + it('should execute command when payload type has a handler', async () => { + const payload = { data: { type: 'status' } }; + + await controller.handleWebhook(payload as any, 'validKey', '192.168.0.2'); + + expect(tetraService.handleStatusWebhook).toHaveBeenCalledWith(payload); + }); +}); diff --git a/libs/api/radio-communication-system/src/lib/infra/controller/tetra-control-webhook.controller.ts b/libs/api/radio-communication-system/src/lib/infra/controller/tetra-control-webhook.controller.ts new file mode 100644 index 000000000..7f65e4edd --- /dev/null +++ b/libs/api/radio-communication-system/src/lib/infra/controller/tetra-control-webhook.controller.ts @@ -0,0 +1,75 @@ +import { + BadRequestException, + Body, + Controller, + HttpCode, + HttpStatus, + Inject, + Ip, + Logger, + Post, + Query, + UnauthorizedException, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +import { TETRA_SERVICE } from '../../core/service/tetra.service'; +import { + TetraControlStatusPayload, + TetraControlWebhookHandlers, +} from '../service/tetra-control.service'; + +@Controller('webhooks/tetra-control') +export class TetraControlWebhookController { + private readonly logger = new Logger(TetraControlWebhookController.name); + private readonly key: string; + private readonly validIpAddresses?: ReadonlySet; + + constructor( + @Inject(TETRA_SERVICE) + private readonly tetraControlService: TetraControlWebhookHandlers, + config: ConfigService, + ) { + this.key = config.getOrThrow('TETRA_CONTROL_WEBHOOK_KEY'); + const validIpAddresses = config + .get('TETRA_CONTROL_WEBHOOK_VALID_IPS') + ?.split(','); + if ((validIpAddresses?.length ?? 0) > 0) { + this.validIpAddresses = Object.freeze(new Set(validIpAddresses)); + } + } + + @Post() + @HttpCode(HttpStatus.NO_CONTENT) + async handleWebhook( + @Body() payload: { data: { type: 'status' } }, + @Query('key') key: string, + @Ip() ip: string, + ): Promise { + if ( + (this.validIpAddresses && !this.validIpAddresses.has(ip)) || + key !== this.key + ) { + this.logger.warn('Unauthorized webhook request', { + body: payload, + key, + ip, + }); + throw new UnauthorizedException(); + } + + this.logger.log('Received tetra control webhook', { payload }); + + switch (payload.data.type) { + case 'status': + await this.tetraControlService.handleStatusWebhook( + payload as TetraControlStatusPayload, + ); + break; + default: + throw new BadRequestException( + `Tetra control ${payload.data.type} has no handler.`, + ); + } + } +} diff --git a/libs/api/radio-communication-system/src/lib/infra/exception/presentable-sds-not-send.exception.ts b/libs/api/radio-communication-system/src/lib/infra/exception/presentable-sds-not-send.exception.ts new file mode 100644 index 000000000..238de749c --- /dev/null +++ b/libs/api/radio-communication-system/src/lib/infra/exception/presentable-sds-not-send.exception.ts @@ -0,0 +1,9 @@ +import { PresentableException } from '@kordis/api/shared'; + +export class PresentableSdsNotSendException extends PresentableException { + readonly code = 'SDS_NOT_SEND_EXCEPTION'; + + constructor() { + super('Die SDS konnte nicht gesendet werden.'); + } +} diff --git a/libs/api/radio-communication-system/src/lib/infra/service/tetra-control.service.spec.ts b/libs/api/radio-communication-system/src/lib/infra/service/tetra-control.service.spec.ts new file mode 100644 index 000000000..f2d76abd0 --- /dev/null +++ b/libs/api/radio-communication-system/src/lib/infra/service/tetra-control.service.spec.ts @@ -0,0 +1,133 @@ +import { createMock } from '@golevelup/ts-jest'; +import { HttpService } from '@nestjs/axios'; +import { ConfigService } from '@nestjs/config'; +import { CommandBus } from '@nestjs/cqrs'; +import { of } from 'rxjs'; + +import { PublishTetraStatusCommand } from '../../core/command/publish-tetra-status.command'; +import { + TetraControlService, + TetraControlStatusPayload, +} from './tetra-control.service'; + +describe('TetraControlService', () => { + let service: TetraControlService; + let httpServiceMock: HttpService; + let commandBusMock: CommandBus; + let configServiceMock: ConfigService; + + beforeEach(() => { + httpServiceMock = createMock({ + get: jest.fn().mockReturnValue(of({})), + }); + commandBusMock = createMock(); + configServiceMock = createMock({ + getOrThrow: jest + .fn() + .mockReturnValueOnce('https://tetra-control-service.com') + .mockReturnValueOnce('mock_key'), + }); + + service = new TetraControlService( + httpServiceMock, + commandBusMock, + configServiceMock, + ); + }); + + it('should send a call out with the provided parameters', async () => { + const issi = '12345'; + const message = 'Test message'; + const noReply = false; + const prio = 5; + + await service.sendCallOut(issi, message, noReply, prio); + + expect(httpServiceMock.get).toHaveBeenCalledWith( + 'https://tetra-control-service.com/API/SDS?Ziel=12345&Typ=195&Text=Test%20message&noreply=0&userkey=mock_key&COPrio=5', + ); + }); + + it('should send an SDS with the provided parameters', async () => { + const issi = '12345'; + const message = 'Test message'; + const isFlash = true; + + await service.sendSDS(issi, message, isFlash); + expect(httpServiceMock.get).toHaveBeenCalledWith( + 'https://tetra-control-service.com/API/SDS?Ziel=12345&Text=Test%20message&Flash=1&userkey=mock_key', + ); + }); + + it('should send a status with the provided parameters', async () => { + const issi = '12345'; + const status = 3; + + await service.sendStatus(issi, status); + + expect(httpServiceMock.get).toHaveBeenCalledWith( + 'https://tetra-control-service.com/API/ISSIUPD?issi=12345&status=8005&userkey=mock_key', + ); + }); + + it('should execute SaveTetraStatusCommand with the provided payload', async () => { + const payload: TetraControlStatusPayload = { + message: 'Test message', + sender: 'sender', + type: 'type', + timestamp: '/Date(1624800000000)/', + data: { + type: 'status', + status: '1', + statusCode: 'statusCode', + statusText: 'statusText', + destSSI: 'destSSI', + destName: 'destName', + srcSSI: 'srcSSI', + srcName: 'srcName', + ts: 'ts', + radioID: 1, + radioName: 'radioName', + remark: 'remark', + }, + }; + + commandBusMock.execute = jest.fn().mockResolvedValueOnce(undefined); + + await service.handleStatusWebhook(payload); + + const expectedCommand = new PublishTetraStatusCommand( + payload.sender, + 1, + new Date(1624800000000), + ); + expect(commandBusMock.execute).toHaveBeenCalledWith(expectedCommand); + }); + + it('should not execute SaveTetraStatusCommand if payload type is not of interest', async () => { + const payload = { + message: 'Test message', + sender: 'sender', + type: 'type', + timestamp: '/Date(1624800000000)/', + data: { + type: 'falsy-tyoe', + status: '', + statusCode: 'statusCode', + statusText: 'statusText', + destSSI: 'destSSI', + destName: 'destName', + srcSSI: 'srcSSI', + srcName: 'srcName', + ts: 'ts', + radioID: 1, + radioName: 'radioName', + remark: 'remark', + }, + }; + + commandBusMock.execute = jest.fn().mockResolvedValueOnce(undefined); + await service.handleStatusWebhook(payload as TetraControlStatusPayload); + expect(commandBusMock.execute).not.toHaveBeenCalled(); + }); +}); diff --git a/libs/api/radio-communication-system/src/lib/infra/service/tetra-control.service.ts b/libs/api/radio-communication-system/src/lib/infra/service/tetra-control.service.ts new file mode 100644 index 000000000..6e00de971 --- /dev/null +++ b/libs/api/radio-communication-system/src/lib/infra/service/tetra-control.service.ts @@ -0,0 +1,135 @@ +import { HttpService } from '@nestjs/axios'; +import { ConfigService } from '@nestjs/config'; +import { CommandBus } from '@nestjs/cqrs'; +import * as querystring from 'querystring'; +import { firstValueFrom } from 'rxjs'; + +import { PublishTetraStatusCommand } from '../../core/command/publish-tetra-status.command'; +import { TetraService } from '../../core/service/tetra.service'; + +export interface TetraControlStatusPayload { + message: string; + sender: string; + type: string; + timestamp: string; + data: { + type: 'status'; + status: string; + statusCode: string; + statusText: string; + destSSI: string; + destName: string; + srcSSI: string; + srcName: string; + ts: string; + radioID: number; + radioName: string; + remark: string; + }; +} + +export interface TetraControlWebhookHandlers { + handleStatusWebhook(payload: TetraControlStatusPayload): Promise; +} + +export class TetraControlService + implements TetraService, TetraControlWebhookHandlers +{ + private readonly tetraControlUrl: string; + private readonly tetraControlKey: string; + + constructor( + private readonly httpService: HttpService, + private readonly commandBus: CommandBus, + config: ConfigService, + ) { + this.tetraControlUrl = config.getOrThrow('TETRA_CONTROL_URL'); + this.tetraControlKey = config.getOrThrow('TETRA_CONTROL_KEY'); + } + + async sendCallOut( + issi: string, + message: string, + noReply: boolean, + prio?: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15, + ): Promise { + const params: Record = { + Ziel: issi, + Typ: 195, + Text: message, + noreply: noReply ? 1 : 0, + userkey: this.tetraControlKey, + }; + + if (prio) { + params['COPrio'] = prio; + } + + const queryParams = querystring.encode(params); + const url = `${this.tetraControlUrl}/API/SDS?${queryParams}`; + + await firstValueFrom(this.httpService.get(url)); + } + + async sendSDS( + issi: string, + message: string, + isFlash?: boolean, + ): Promise { + const params: Record = { + Ziel: issi, + Text: message, + Flash: isFlash ? 1 : 0, + userkey: this.tetraControlKey, + }; + + const queryParams = querystring.encode(params); + const url = `${this.tetraControlUrl}/API/SDS?${queryParams}`; + + await firstValueFrom(this.httpService.get(url)); + } + + async sendStatus( + issi: string, + status: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8, + ): Promise { + const params: Record = { + issi, + status: this.convertFmsStatusToTetraStatus(status), + userkey: this.tetraControlKey, + }; + + const queryParams = querystring.encode(params); + const url = `${this.tetraControlUrl}/API/ISSIUPD?${queryParams}`; + + await firstValueFrom(this.httpService.get(url)); + } + + async handleStatusWebhook(payload: TetraControlStatusPayload): Promise { + if (!payload.data.status) { + return; + } + + await this.commandBus.execute( + new PublishTetraStatusCommand( + payload.sender, + parseInt(payload.data.status), + this.getSanitizedTimestamp(payload.timestamp), + ), + ); + } + + private getSanitizedTimestamp(timestamp: string): Date { + const parsedTimestamp = /\/Date\((-?\d+)\)\//.exec(timestamp); + if (parsedTimestamp && parsedTimestamp.length > 1) { + return new Date(parseInt(parsedTimestamp[1])); + } + + return new Date(); + } + + private convertFmsStatusToTetraStatus(fmsStatus: number): string { + const hexDecimalStatusEquivalent = 32770 + fmsStatus; + return hexDecimalStatusEquivalent.toString(16).toUpperCase(); + } +} diff --git a/libs/api/radio-communication-system/src/lib/radio-communication-system.module.ts b/libs/api/radio-communication-system/src/lib/radio-communication-system.module.ts new file mode 100644 index 000000000..ed068f4de --- /dev/null +++ b/libs/api/radio-communication-system/src/lib/radio-communication-system.module.ts @@ -0,0 +1,20 @@ +import { Module } from '@nestjs/common'; + +import { PublishTetraStatusHandler } from './core/command/publish-tetra-status.command'; +import { SendTetraSDSHandler } from './core/command/send-tetra-sds.command'; +import { TETRA_SERVICE } from './core/service/tetra.service'; +import { TetraControlWebhookController } from './infra/controller/tetra-control-webhook.controller'; +import { TetraControlService } from './infra/service/tetra-control.service'; + +@Module({ + controllers: [TetraControlWebhookController], + providers: [ + { + provide: TETRA_SERVICE, + useClass: TetraControlService, + }, + PublishTetraStatusHandler, + SendTetraSDSHandler, + ], +}) +export class RadioCommunicationSystemModule {} diff --git a/libs/api/radio-communication-system/tsconfig.json b/libs/api/radio-communication-system/tsconfig.json new file mode 100644 index 000000000..a933b2731 --- /dev/null +++ b/libs/api/radio-communication-system/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "module": "commonjs", + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + "strictPropertyInitialization": false, + "noFallthroughCasesInSwitch": true, + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json", + }, + { + "path": "./tsconfig.spec.json", + }, + ], +} diff --git a/libs/api/radio-communication-system/tsconfig.lib.json b/libs/api/radio-communication-system/tsconfig.lib.json new file mode 100644 index 000000000..f7abb4b6d --- /dev/null +++ b/libs/api/radio-communication-system/tsconfig.lib.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "declaration": true, + "types": ["node"], + "target": "es2021", + "strictNullChecks": true, + "noImplicitAny": true, + "strictBindCallApply": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src/**/*.ts", "../../../reset.d.ts"], + "exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"] +} diff --git a/libs/api/radio-communication-system/tsconfig.spec.json b/libs/api/radio-communication-system/tsconfig.spec.json new file mode 100644 index 000000000..231650b3d --- /dev/null +++ b/libs/api/radio-communication-system/tsconfig.spec.json @@ -0,0 +1,14 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "module": "commonjs", + "types": ["jest", "node"] + }, + "include": [ + "jest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.d.ts" + ] +} diff --git a/package-lock.json b/package-lock.json index 4c9c4c340..c8cd9d35e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "@automapper/core": "^8.8.1", "@automapper/nestjs": "^8.8.1", "@nestjs/apollo": "^12.0.3", + "@nestjs/axios": "^3.0.0", "@nestjs/common": "10.2.4", "@nestjs/config": "^3.0.0", "@nestjs/core": "10.2.4", @@ -5005,6 +5006,17 @@ } } }, + "node_modules/@nestjs/axios": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@nestjs/axios/-/axios-3.0.1.tgz", + "integrity": "sha512-VlOZhAGDmOoFdsmewn8AyClAdGpKXQQaY1+3PGB+g6ceurGIdTxZgRX3VXc1T6Zs60PedWjg3A82TDOB05mrzQ==", + "peerDependencies": { + "@nestjs/common": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0", + "axios": "^1.3.1", + "reflect-metadata": "^0.1.12", + "rxjs": "^6.0.0 || ^7.0.0" + } + }, "node_modules/@nestjs/common": { "version": "10.2.4", "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-10.2.4.tgz", @@ -5566,6 +5578,70 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/@npmcli/package-json": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/package-json/-/package-json-5.0.0.tgz", + "integrity": "sha512-OI2zdYBLhQ7kpNPaJxiflofYIpkNLi+lnGdzqUOfRmCF3r2l1nadcjtCYMJKv/Utm/ZtlffaUuTiAktPHbc17g==", + "dev": true, + "dependencies": { + "@npmcli/git": "^5.0.0", + "glob": "^10.2.2", + "hosted-git-info": "^7.0.0", + "json-parse-even-better-errors": "^3.0.0", + "normalize-package-data": "^6.0.0", + "proc-log": "^3.0.0", + "semver": "^7.5.3" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/@npmcli/package-json/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@npmcli/package-json/node_modules/glob": { + "version": "10.3.10", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.10.tgz", + "integrity": "sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==", + "dev": true, + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^2.3.5", + "minimatch": "^9.0.1", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0", + "path-scurry": "^1.10.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@npmcli/package-json/node_modules/minimatch": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", + "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/@npmcli/promise-spawn": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/@npmcli/promise-spawn/-/promise-spawn-7.0.1.tgz", @@ -5603,15 +5679,15 @@ } }, "node_modules/@npmcli/run-script": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/@npmcli/run-script/-/run-script-7.0.3.tgz", - "integrity": "sha512-ZMWGLHpzMq3rBGIwPyeaoaleaLMvrBrH8nugHxTi5ACkJZXTxXPtVuEH91ifgtss5hUwJQ2VDnzDBWPmz78rvg==", + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/@npmcli/run-script/-/run-script-7.0.4.tgz", + "integrity": "sha512-9ApYM/3+rBt9V80aYg6tZfzj3UWdiYyCt7gJUD1VJKvWF5nwKDSICXbYIQbspFTq6TOpbsEtIC0LArB8d9PFmg==", "dev": true, "dependencies": { "@npmcli/node-gyp": "^3.0.0", + "@npmcli/package-json": "^5.0.0", "@npmcli/promise-spawn": "^7.0.0", "node-gyp": "^10.0.0", - "read-package-json-fast": "^3.0.0", "which": "^4.0.0" }, "engines": { @@ -19048,10 +19124,13 @@ "dev": true }, "node_modules/json-parse-even-better-errors": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", - "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "dev": true + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-3.0.1.tgz", + "integrity": "sha512-aatBvbL26wVUCLmbWdCpeu9iF5wOyWpagiKkInA+kfws3sWdBrTnsvN2CKcyCYyUrc7rebNBlK6+kteg7ksecg==", + "dev": true, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } }, "node_modules/json-schema-traverse": { "version": "1.0.0", @@ -21624,6 +21703,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse-json/node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true + }, "node_modules/parse-json/node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -23047,15 +23132,6 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, - "node_modules/read-package-json-fast/node_modules/json-parse-even-better-errors": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-3.0.1.tgz", - "integrity": "sha512-aatBvbL26wVUCLmbWdCpeu9iF5wOyWpagiKkInA+kfws3sWdBrTnsvN2CKcyCYyUrc7rebNBlK6+kteg7ksecg==", - "dev": true, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, "node_modules/read-package-json/node_modules/brace-expansion": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", @@ -23087,15 +23163,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/read-package-json/node_modules/json-parse-even-better-errors": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-3.0.1.tgz", - "integrity": "sha512-aatBvbL26wVUCLmbWdCpeu9iF5wOyWpagiKkInA+kfws3sWdBrTnsvN2CKcyCYyUrc7rebNBlK6+kteg7ksecg==", - "dev": true, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, "node_modules/read-package-json/node_modules/minimatch": { "version": "9.0.3", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", @@ -27177,6 +27244,12 @@ "node": ">=4.0" } }, + "node_modules/webpack/node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true + }, "node_modules/webpack/node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", diff --git a/package.json b/package.json index b384d2a3c..36a7678be 100644 --- a/package.json +++ b/package.json @@ -89,6 +89,7 @@ "@automapper/core": "^8.8.1", "@automapper/nestjs": "^8.8.1", "@nestjs/apollo": "^12.0.3", + "@nestjs/axios": "^3.0.0", "@nestjs/common": "10.2.4", "@nestjs/config": "^3.0.0", "@nestjs/core": "10.2.4", diff --git a/tsconfig.base.json b/tsconfig.base.json index 3c2fd85bd..d78a9871d 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -18,6 +18,9 @@ "@kordis/api/auth": ["libs/api/auth/src/index.ts"], "@kordis/api/observability": ["libs/api/observability/src/index.ts"], "@kordis/api/organization": ["libs/api/organization/src/index.ts"], + "@kordis/api/radio-communication-system": [ + "libs/api/radio-communication-system/src/index.ts" + ], "@kordis/api/shared": ["libs/api/shared/src/index.ts"], "@kordis/api/test-helpers": ["libs/api/test-helpers/src/index.ts"], "@kordis/shared/auth": ["libs/shared/auth/src/index.ts"],