diff --git a/apps/api/src/app/app.module.ts b/apps/api/src/app/app.module.ts index 13430b56..52d0567c 100644 --- a/apps/api/src/app/app.module.ts +++ b/apps/api/src/app/app.module.ts @@ -15,6 +15,7 @@ import { errorFormatterFactory, getMongoEncrKmsFromConfig, } from '@kordis/api/shared'; +import { TetraModule } from '@kordis/api/tetra'; import { UsersModule } from '@kordis/api/user'; import { AppResolver } from './app.resolver'; @@ -29,6 +30,7 @@ const isNextOrProdEnv = ['next', 'prod'].includes( const FEATURE_MODULES = [ OrganizationModule, + TetraModule, UsersModule.forRoot(process.env.AUTH_PROVIDER === 'dev' ? 'dev' : 'aadb2c'), ]; const UTILITY_MODULES = [ diff --git a/libs/api/auth/src/lib/interceptors/auth.interceptor.spec.ts b/libs/api/auth/src/lib/interceptors/auth.interceptor.spec.ts index 13ca952c..abe7ff69 100644 --- a/libs/api/auth/src/lib/interceptors/auth.interceptor.spec.ts +++ b/libs/api/auth/src/lib/interceptors/auth.interceptor.spec.ts @@ -1,5 +1,5 @@ import { DeepMocked, createMock } from '@golevelup/ts-jest'; -import { CallHandler, ExecutionContext } from '@nestjs/common'; +import { CallHandler } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; import { Observable, firstValueFrom, of } from 'rxjs'; @@ -37,7 +37,7 @@ describe('AuthInterceptor', () => { await expect( firstValueFrom( await service.intercept( - createMock(), + createGqlContextForRequest(createMock()), createMock(), ), ), @@ -60,7 +60,7 @@ describe('AuthInterceptor', () => { await expect( firstValueFrom( await service.intercept( - createMock(), + createGqlContextForRequest(createMock()), createMock(), ), ), @@ -92,31 +92,38 @@ describe('AuthInterceptor', () => { firstValueFrom(await service.intercept(gqlCtx, handler)), ).resolves.toBeTruthy(); - const httpCtx = createHttpContextForRequest(createMock()); + const httpCtx = createHttpContextForRequest( + createMock({ + path: '/graphql', + }), + ); await expect( firstValueFrom(await service.intercept(httpCtx, handler)), ).resolves.toBeTruthy(); }); - it('should continue request pipeline for health-check request', async () => { - const handler = createMock({ - handle(): Observable { - return of(true); - }, - }); + it.each(['/health-check', '/webhooks/foo', '/webhooks/bar'])( + 'should continue request pipeline for %p and webhook request', + async (path: string) => { + const handler = createMock({ + handle(): Observable { + return of(true); + }, + }); - await expect( - firstValueFrom( - await service.intercept( - createHttpContextForRequest( - createMock({ - path: '/health-check', - }), + await expect( + firstValueFrom( + await service.intercept( + createHttpContextForRequest( + createMock({ + path, + }), + ), + handler, ), - handler, ), - ), - ).resolves.toBeTruthy(); - }); + ).resolves.toBeTruthy(); + }, + ); }); diff --git a/libs/api/auth/src/lib/interceptors/auth.interceptor.ts b/libs/api/auth/src/lib/interceptors/auth.interceptor.ts index 3ea4a43a..67573475 100644 --- a/libs/api/auth/src/lib/interceptors/auth.interceptor.ts +++ b/libs/api/auth/src/lib/interceptors/auth.interceptor.ts @@ -38,7 +38,7 @@ export class AuthInterceptor implements NestInterceptor { } else { req = context.switchToHttp().getRequest(); - if (req.path === '/health-check') { + if (req.path === '/health-check' || req.path.startsWith('/webhooks')) { return next.handle(); } } diff --git a/libs/api/organization/src/lib/infra/repository/organization.repository.ts b/libs/api/organization/src/lib/infra/repository/organization.repository.ts index 4ac73f7a..598ad98d 100644 --- a/libs/api/organization/src/lib/infra/repository/organization.repository.ts +++ b/libs/api/organization/src/lib/infra/repository/organization.repository.ts @@ -35,13 +35,14 @@ export class ImplOrganizationRepository implements OrganizationRepository { } async findById(id: string): Promise { - const orgDoc = await this.organizationModel.findById(id).exec(); + const orgDoc = await this.organizationModel.findById(id).lean().exec(); + if (!orgDoc) { return null; } return this.mapper.mapAsync( - orgDoc.toObject(), + orgDoc, OrganizationDocument, OrganizationEntity, ); diff --git a/libs/api/test-helpers/src/lib/mongo.test-helper.ts b/libs/api/test-helpers/src/lib/mongo.test-helper.ts index 8a98ff30..d81921b1 100644 --- a/libs/api/test-helpers/src/lib/mongo.test-helper.ts +++ b/libs/api/test-helpers/src/lib/mongo.test-helper.ts @@ -1,8 +1,8 @@ import { Model } from 'mongoose'; export function mockModelMethodResult( - model: Model, - document: Record, + model: Model, + document: Record | null, method: keyof Model, ) { const findByIdSpy = jest.spyOn(model, method as any); @@ -12,6 +12,11 @@ export function mockModelMethodResult( ...document, toObject: jest.fn().mockReturnValue(document), }), + lean: jest.fn().mockReturnValue({ + ...document, + toObject: jest.fn().mockReturnValue(document), + exec: jest.fn().mockReturnValue(document), + }), } as any); return findByIdSpy; diff --git a/libs/api/tetra/.eslintrc.json b/libs/api/tetra/.eslintrc.json new file mode 100644 index 00000000..79fd7c1d --- /dev/null +++ b/libs/api/tetra/.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/tetra/README.md b/libs/api/tetra/README.md new file mode 100644 index 00000000..fca3bb87 --- /dev/null +++ b/libs/api/tetra/README.md @@ -0,0 +1,17 @@ +# Radio Communication System + +This module covers the radio communication currently via tetra control. The +`TetraControlService` can send Call Outs, SDS and Status changes. Also, a +webhook handler for incoming status changes is implemented. + +The configuraton for Tetra (credentials for sending and receiving data, and the +API URL of the Tetra server) are stored in the database per tenant. When the +incoming webhook is called, Kordis retrieves the Tetra settings by the provided +API key. The webhook for incoming requests is +`/webhooks/tetra-control?key=`. + +Upon receiving a new status change, a `NewTetraStatusEvent` is emitted. + +## Running unit tests + +Run `nx test api-tetra` to execute the unit tests via [Jest](https://jestjs.io). diff --git a/libs/api/tetra/jest.config.ts b/libs/api/tetra/jest.config.ts new file mode 100644 index 00000000..e9f0ac3b --- /dev/null +++ b/libs/api/tetra/jest.config.ts @@ -0,0 +1,11 @@ +/* eslint-disable */ +export default { + displayName: 'api-tetra', + preset: '../../../jest.preset.js', + testEnvironment: 'node', + transform: { + '^.+\\.[tj]s$': ['ts-jest', { tsconfig: '/tsconfig.spec.json' }], + }, + moduleFileExtensions: ['ts', 'js', 'html'], + coverageDirectory: '../../../coverage/libs/api/tetra', +}; diff --git a/libs/api/tetra/project.json b/libs/api/tetra/project.json new file mode 100644 index 00000000..5a143d7b --- /dev/null +++ b/libs/api/tetra/project.json @@ -0,0 +1,30 @@ +{ + "name": "api-tetra", + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/api/tetra/src", + "projectType": "library", + "targets": { + "lint": { + "executor": "@nx/linter:eslint", + "outputs": ["{options.outputFile}"], + "options": { + "lintFilePatterns": ["libs/api/tetra/**/*.ts"] + } + }, + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "libs/api/tetra/jest.config.ts", + "passWithNoTests": true + }, + "configurations": { + "ci": { + "ci": true, + "codeCoverage": true + } + } + } + }, + "tags": [] +} diff --git a/libs/api/tetra/src/index.ts b/libs/api/tetra/src/index.ts new file mode 100644 index 00000000..d8c08af5 --- /dev/null +++ b/libs/api/tetra/src/index.ts @@ -0,0 +1,2 @@ +export * from './lib/infra/tetra.module'; +export * from './lib/core/event/new-tetra-status.event'; diff --git a/libs/api/tetra/src/lib/core/command/handle-tetra-control-webhook.command.spec.ts b/libs/api/tetra/src/lib/core/command/handle-tetra-control-webhook.command.spec.ts new file mode 100644 index 00000000..b3ba58a0 --- /dev/null +++ b/libs/api/tetra/src/lib/core/command/handle-tetra-control-webhook.command.spec.ts @@ -0,0 +1,95 @@ +import { DeepMocked, createMock } from '@golevelup/ts-jest'; +import { EventBus } from '@nestjs/cqrs'; +import { Test } from '@nestjs/testing'; + +import { TetraConfig } from '../entity/tetra-config.entitiy'; +import { NewTetraStatusEvent } from '../event/new-tetra-status.event'; +import { UnknownTetraControlWebhookKeyException } from '../exception/unknown-tetra-control-webhook-key.exception'; +import { TetraControlStatusPayload } from '../model/tetra-control-status-payload.model'; +import { + TETRA_CONFIG_REPOSITORY, + TetraConfigRepository, +} from '../repository/tetra-config.repository'; +import { TETRA_SERVICE, TetraService } from '../service/tetra.service'; +import { HandleTetraControlWebhookHandler } from './handle-tetra-control-webhook.command'; + +describe('HandleTetraControlWebhookHandler', () => { + let handler: HandleTetraControlWebhookHandler; + let tetraConfigRepository: DeepMocked; + let eventBus: DeepMocked; + + beforeEach(async () => { + const moduleRef = await Test.createTestingModule({ + providers: [ + HandleTetraControlWebhookHandler, + { provide: TETRA_SERVICE, useValue: createMock() }, + { + provide: TETRA_CONFIG_REPOSITORY, + useValue: createMock(), + }, + { provide: EventBus, useValue: createMock() }, + ], + }).compile(); + + handler = moduleRef.get( + HandleTetraControlWebhookHandler, + ); + tetraConfigRepository = moduleRef.get>( + TETRA_CONFIG_REPOSITORY, + ); + eventBus = moduleRef.get>(EventBus); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should throw UnknownTetraControlWebhookKeyException when key not found', async () => { + tetraConfigRepository.findByWebhookAccessKey.mockResolvedValueOnce(null); + + await expect( + handler.execute({ + payload: { data: { type: 'status' } } as TetraControlStatusPayload, + key: 'test', + }), + ).rejects.toThrow(UnknownTetraControlWebhookKeyException); + }); + + it('should dispatch new status', async () => { + tetraConfigRepository.findByWebhookAccessKey.mockResolvedValueOnce({ + orgId: 'orgId', + } as TetraConfig); + const date = new Date('1998-09-16'); + await handler.execute({ + payload: { + data: { type: 'status', status: '1' }, + sender: 'sender', + timestamp: `/Date(${date.getTime()})/`, + } as TetraControlStatusPayload, + key: 'test', + }); + + expect(eventBus.publish).toHaveBeenCalledWith( + new NewTetraStatusEvent('orgId', 'sender', 1, date, 'tetracontrol'), + ); + }); + + it('should not dispatch new status when no', async () => { + tetraConfigRepository.findByWebhookAccessKey.mockResolvedValueOnce({ + orgId: 'orgId', + } as TetraConfig); + const date = new Date('1998-09-16'); + await handler.execute({ + payload: { + data: { type: 'status', status: '1' }, + sender: 'sender', + timestamp: `/Date(${date.getTime()})/`, + } as TetraControlStatusPayload, + key: 'test', + }); + + expect(eventBus.publish).toHaveBeenCalledWith( + new NewTetraStatusEvent('orgId', 'sender', 1, date, 'tetracontrol'), + ); + }); +}); diff --git a/libs/api/tetra/src/lib/core/command/handle-tetra-control-webhook.command.ts b/libs/api/tetra/src/lib/core/command/handle-tetra-control-webhook.command.ts new file mode 100644 index 00000000..ed1795d2 --- /dev/null +++ b/libs/api/tetra/src/lib/core/command/handle-tetra-control-webhook.command.ts @@ -0,0 +1,67 @@ +import { Inject } from '@nestjs/common'; +import { CommandHandler, EventBus, ICommandHandler } from '@nestjs/cqrs'; + +import { NewTetraStatusEvent } from '../event/new-tetra-status.event'; +import { UnhandledTetraControlWebhookTypeException } from '../exception/unhandled-tetra-control-webhook-type.exception'; +import { UnknownTetraControlWebhookKeyException } from '../exception/unknown-tetra-control-webhook-key.exception'; +import { TetraControlStatusPayload } from '../model/tetra-control-status-payload.model'; +import { + TETRA_CONFIG_REPOSITORY, + TetraConfigRepository, +} from '../repository/tetra-config.repository'; +import { TETRA_SERVICE, TetraService } from '../service/tetra.service'; + +export class HandleTetraControlWebhookCommand { + constructor( + readonly payload: TetraControlStatusPayload, + readonly key: string, + ) {} +} + +@CommandHandler(HandleTetraControlWebhookCommand) +export class HandleTetraControlWebhookHandler + implements ICommandHandler +{ + constructor( + @Inject(TETRA_SERVICE) private readonly tetraService: TetraService, + @Inject(TETRA_CONFIG_REPOSITORY) + private readonly tetraConfigRepository: TetraConfigRepository, + private readonly eventBus: EventBus, + ) {} + + async execute({ + payload, + key, + }: HandleTetraControlWebhookCommand): Promise { + const tetraConfig = + await this.tetraConfigRepository.findByWebhookAccessKey(key); + if (!tetraConfig) { + throw new UnknownTetraControlWebhookKeyException(); + } + + switch (payload.data.type) { + case 'status': + this.eventBus.publish( + new NewTetraStatusEvent( + tetraConfig.orgId, + payload.sender, + parseInt(payload.data.status), + this.getSanitizedTimestamp(payload.timestamp), + 'tetracontrol', + ), + ); + break; + default: + throw new UnhandledTetraControlWebhookTypeException(payload.data.type); + } + } + + 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(); + } +} diff --git a/libs/api/tetra/src/lib/core/command/send-tetra-sds.command.spec.ts b/libs/api/tetra/src/lib/core/command/send-tetra-sds.command.spec.ts new file mode 100644 index 00000000..ab5383ec --- /dev/null +++ b/libs/api/tetra/src/lib/core/command/send-tetra-sds.command.spec.ts @@ -0,0 +1,51 @@ +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 orgId = 'orgId'; + const issi = '12345'; + const message = 'Test message'; + const isFlash = true; + + const command = new SendTetraSDSCommand(orgId, issi, message, isFlash); + + await handler.execute(command); + + expect(tetraServiceMock.sendSDS).toHaveBeenCalledWith( + orgId, + issi, + message, + isFlash, + ); + }); + + it('should throw an error if tetraService.sendSDS throws an error', async () => { + const orgId = 'orgId'; + const issi = '12345'; + const message = 'Test message'; + const isFlash = true; + + const command = new SendTetraSDSCommand(orgId, issi, message, isFlash); + + tetraServiceMock.sendSDS.mockRejectedValue(new Error()); + + await expect(handler.execute(command)).rejects.toThrow( + SdsNotAbleToSendException, + ); + }); +}); diff --git a/libs/api/tetra/src/lib/core/command/send-tetra-sds.command.ts b/libs/api/tetra/src/lib/core/command/send-tetra-sds.command.ts new file mode 100644 index 00000000..2c9b5d87 --- /dev/null +++ b/libs/api/tetra/src/lib/core/command/send-tetra-sds.command.ts @@ -0,0 +1,36 @@ +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 orgId: string, + 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.orgId, + command.issi, + command.message, + command.isFlash, + ); + } catch (error) { + throw new SdsNotAbleToSendException(error); + } + } +} diff --git a/libs/api/tetra/src/lib/core/entity/tetra-config.entitiy.ts b/libs/api/tetra/src/lib/core/entity/tetra-config.entitiy.ts new file mode 100644 index 00000000..eaedcde2 --- /dev/null +++ b/libs/api/tetra/src/lib/core/entity/tetra-config.entitiy.ts @@ -0,0 +1,6 @@ +export class TetraConfig { + orgId: string; + tetraControlApiUrl: string; + tetraControlApiUserKey: string; + webhookAccessKey: string; +} diff --git a/libs/api/tetra/src/lib/core/event/new-tetra-status.event.ts b/libs/api/tetra/src/lib/core/event/new-tetra-status.event.ts new file mode 100644 index 00000000..413bd0bb --- /dev/null +++ b/libs/api/tetra/src/lib/core/event/new-tetra-status.event.ts @@ -0,0 +1,11 @@ +import { IEvent } from '@nestjs/cqrs'; + +export class NewTetraStatusEvent implements IEvent { + constructor( + public readonly orgId: string, + public readonly issi: string, + public readonly status: number, + public readonly sentAt: Date, + public readonly source: string, + ) {} +} diff --git a/libs/api/tetra/src/lib/core/exception/sds-not-able-to-send.exception.ts b/libs/api/tetra/src/lib/core/exception/sds-not-able-to-send.exception.ts new file mode 100644 index 00000000..922415cd --- /dev/null +++ b/libs/api/tetra/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/tetra/src/lib/core/exception/unhandled-tetra-control-webhook-type.exception.ts b/libs/api/tetra/src/lib/core/exception/unhandled-tetra-control-webhook-type.exception.ts new file mode 100644 index 00000000..bd5e3cc8 --- /dev/null +++ b/libs/api/tetra/src/lib/core/exception/unhandled-tetra-control-webhook-type.exception.ts @@ -0,0 +1,5 @@ +export class UnhandledTetraControlWebhookTypeException extends Error { + constructor(type: string) { + super(`Tetra control ${type} has no handler.`); + } +} diff --git a/libs/api/tetra/src/lib/core/exception/unknown-tetra-control-webhook-key.exception.ts b/libs/api/tetra/src/lib/core/exception/unknown-tetra-control-webhook-key.exception.ts new file mode 100644 index 00000000..6f18392f --- /dev/null +++ b/libs/api/tetra/src/lib/core/exception/unknown-tetra-control-webhook-key.exception.ts @@ -0,0 +1,5 @@ +export class UnknownTetraControlWebhookKeyException extends Error { + constructor() { + super('Tetra Control Webhook Key unknown'); + } +} diff --git a/libs/api/tetra/src/lib/core/model/tetra-control-status-payload.model.ts b/libs/api/tetra/src/lib/core/model/tetra-control-status-payload.model.ts new file mode 100644 index 00000000..f99ee2c4 --- /dev/null +++ b/libs/api/tetra/src/lib/core/model/tetra-control-status-payload.model.ts @@ -0,0 +1,20 @@ +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; + }; +} diff --git a/libs/api/tetra/src/lib/core/repository/tetra-config.repository.ts b/libs/api/tetra/src/lib/core/repository/tetra-config.repository.ts new file mode 100644 index 00000000..eeb9f950 --- /dev/null +++ b/libs/api/tetra/src/lib/core/repository/tetra-config.repository.ts @@ -0,0 +1,9 @@ +import { TetraConfig } from '../entity/tetra-config.entitiy'; + +export const TETRA_CONFIG_REPOSITORY = Symbol('TetraConfigRepository'); + +export interface TetraConfigRepository { + findByWebhookAccessKey(key: string): Promise; + + findByOrgId(orgId: string): Promise; +} diff --git a/libs/api/tetra/src/lib/core/service/tetra.service.ts b/libs/api/tetra/src/lib/core/service/tetra.service.ts new file mode 100644 index 00000000..d72e5465 --- /dev/null +++ b/libs/api/tetra/src/lib/core/service/tetra.service.ts @@ -0,0 +1,24 @@ +export const TETRA_SERVICE = Symbol('TETRA_SERVICE'); + +export interface TetraService { + sendSDS( + orgId: string, + issi: string, + message: string, + isFlash?: boolean, + ): Promise; + + sendCallOut( + orgId: string, + 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( + orgId: string, + issi: string, + status: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8, + ): Promise; +} diff --git a/libs/api/tetra/src/lib/infra/controller/sds.resolver.spec.ts b/libs/api/tetra/src/lib/infra/controller/sds.resolver.spec.ts new file mode 100644 index 00000000..e336c436 --- /dev/null +++ b/libs/api/tetra/src/lib/infra/controller/sds.resolver.spec.ts @@ -0,0 +1,81 @@ +import { DeepMocked, createMock } from '@golevelup/ts-jest'; +import { CommandBus } from '@nestjs/cqrs'; +import { Test, TestingModule } from '@nestjs/testing'; + +import { AuthUser } from '@kordis/shared/model'; + +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 { SDSResolver } from './sds.resolver'; + +describe('SDSResolver', () => { + let resolver: SDSResolver; + let commandBus: DeepMocked; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + SDSResolver, + { + provide: CommandBus, + useValue: createMock(), + }, + ], + }).compile(); + + resolver = module.get(SDSResolver); + 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; + const orgId = 'orgId'; + + await resolver.sendSDS( + { organizationId: orgId } as AuthUser, + issi, + message, + isFlash, + ); + + expect(commandBus.execute).toHaveBeenCalledWith( + new SendTetraSDSCommand(orgId, 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( + { organizationId: 'orgId' } as AuthUser, + issi, + message, + isFlash, + ), + ).rejects.toThrow(PresentableSdsNotSendException); + + const unknownError = new Error('i am an unknown error'); + commandBus.execute.mockRejectedValueOnce(unknownError); + + await expect( + resolver.sendSDS( + { organizationId: ' ' } as AuthUser, + issi, + message, + isFlash, + ), + ).rejects.toThrow(unknownError); + }); + }); +}); diff --git a/libs/api/tetra/src/lib/infra/controller/sds.resolver.ts b/libs/api/tetra/src/lib/infra/controller/sds.resolver.ts new file mode 100644 index 00000000..1b0494c3 --- /dev/null +++ b/libs/api/tetra/src/lib/infra/controller/sds.resolver.ts @@ -0,0 +1,37 @@ +import { CommandBus } from '@nestjs/cqrs'; +import { Args, Mutation, Resolver } from '@nestjs/graphql'; + +import { RequestUser } from '@kordis/api/auth'; +import { AuthUser } from '@kordis/shared/model'; + +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 SDSResolver { + constructor(private readonly commandBus: CommandBus) {} + + @Mutation(() => Boolean) + async sendSDS( + @RequestUser() { organizationId }: AuthUser, + @Args('issi') + issi: string, + @Args('message') message: string, + @Args('isFlash') isFlash?: boolean, + ): Promise { + try { + await this.commandBus.execute( + new SendTetraSDSCommand(organizationId, issi, message, !!isFlash), + ); + } catch (error) { + if (error instanceof SdsNotAbleToSendException) { + throw new PresentableSdsNotSendException(); + } + + throw error; + } + + return true; + } +} diff --git a/libs/api/tetra/src/lib/infra/controller/tetra-control-webhook.controller.spec.ts b/libs/api/tetra/src/lib/infra/controller/tetra-control-webhook.controller.spec.ts new file mode 100644 index 00000000..e6b0fec9 --- /dev/null +++ b/libs/api/tetra/src/lib/infra/controller/tetra-control-webhook.controller.spec.ts @@ -0,0 +1,91 @@ +import { createMock } from '@golevelup/ts-jest'; +import { BadRequestException, UnauthorizedException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { CommandBus, CqrsModule } from '@nestjs/cqrs'; +import { Test, TestingModule } from '@nestjs/testing'; + +import { + HandleTetraControlWebhookCommand, + HandleTetraControlWebhookHandler, +} from '../../core/command/handle-tetra-control-webhook.command'; +import { UnhandledTetraControlWebhookTypeException } from '../../core/exception/unhandled-tetra-control-webhook-type.exception'; +import { UnknownTetraControlWebhookKeyException } from '../../core/exception/unknown-tetra-control-webhook-key.exception'; +import { + TETRA_CONFIG_REPOSITORY, + TetraConfigRepository, +} from '../../core/repository/tetra-config.repository'; +import { TETRA_SERVICE, TetraService } from '../../core/service/tetra.service'; +import { TetraControlWebhookController } from './tetra-control-webhook.controller'; + +describe('TetraControlWebhookController', () => { + let controller: TetraControlWebhookController; + let configService: ConfigService; + let commandBus: CommandBus; + + 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({ + imports: [CqrsModule], + controllers: [TetraControlWebhookController], + providers: [ + { provide: ConfigService, useValue: configService }, + { provide: TETRA_SERVICE, useValue: createMock() }, + { + provide: TETRA_CONFIG_REPOSITORY, + useValue: createMock(), + }, + HandleTetraControlWebhookHandler, + ], + }).compile(); + await module.init(); + + controller = module.get( + TetraControlWebhookController, + ); + commandBus = module.get(CommandBus); + }); + + it('should throw UnauthorizedException key is invalid', async () => { + const payload = { data: { type: 'status' } }; + const commendHandlerMock = jest.spyOn(commandBus, 'execute'); + + commendHandlerMock.mockRejectedValueOnce( + new UnknownTetraControlWebhookKeyException(), + ); + 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' } }; + const commendHandlerMock = jest.spyOn(commandBus, 'execute'); + + commendHandlerMock.mockRejectedValueOnce( + new UnhandledTetraControlWebhookTypeException(''), + ); + await expect( + controller.handleWebhook(payload as any, 'validKey', '192.168.0.1'), + ).rejects.toThrow(BadRequestException); + }); + + it('should emit HandleTetraControlWebhookCommand', async () => { + const payload = { data: { type: 'status' } }; + const key = 'validKey'; + + return new Promise((done) => { + commandBus.subscribe((command) => { + expect(command).toBeInstanceOf(HandleTetraControlWebhookCommand); + expect(command).toMatchObject({ + payload, + key, + }); + done(); + }); + controller.handleWebhook(payload as any, 'validKey', '192.168.0.1'); + }); + }); +}); diff --git a/libs/api/tetra/src/lib/infra/controller/tetra-control-webhook.controller.ts b/libs/api/tetra/src/lib/infra/controller/tetra-control-webhook.controller.ts new file mode 100644 index 00000000..a1303d23 --- /dev/null +++ b/libs/api/tetra/src/lib/infra/controller/tetra-control-webhook.controller.ts @@ -0,0 +1,58 @@ +import { + BadRequestException, + Body, + Controller, + HttpCode, + HttpStatus, + Ip, + Logger, + Post, + Query, + UnauthorizedException, +} from '@nestjs/common'; +import { CommandBus } from '@nestjs/cqrs'; + +import { KordisLogger } from '@kordis/api/observability'; + +import { HandleTetraControlWebhookCommand } from '../../core/command/handle-tetra-control-webhook.command'; +import { UnhandledTetraControlWebhookTypeException } from '../../core/exception/unhandled-tetra-control-webhook-type.exception'; +import { UnknownTetraControlWebhookKeyException } from '../../core/exception/unknown-tetra-control-webhook-key.exception'; +import { TetraControlStatusPayload } from '../../core/model/tetra-control-status-payload.model'; + +@Controller('webhooks/tetra-control') +export class TetraControlWebhookController { + private readonly logger: KordisLogger = new Logger( + TetraControlWebhookController.name, + ); + + constructor(private readonly commandBus: CommandBus) {} + + @Post() + @HttpCode(HttpStatus.NO_CONTENT) + async handleWebhook( + @Body() payload: TetraControlStatusPayload, + @Query('key') key: string, + @Ip() ip: string, + ): Promise { + this.logger.log('Received tetra control webhook', { payload }); + try { + await this.commandBus.execute( + new HandleTetraControlWebhookCommand(payload, key), + ); + } catch (error: unknown) { + if (error instanceof UnhandledTetraControlWebhookTypeException) { + this.logger.warn('Unhandled tetra control webhook type', error); + throw new BadRequestException('Unhandled tetra control webhook type'); + } else if (error instanceof UnknownTetraControlWebhookKeyException) { + this.logger.warn('Unknown tetra control webhook key', { + key, + ip, + payload, + }); + throw new UnauthorizedException(); + } + + throw error; + } + } +} diff --git a/libs/api/tetra/src/lib/infra/exception/presentable-sds-not-send.exception.ts b/libs/api/tetra/src/lib/infra/exception/presentable-sds-not-send.exception.ts new file mode 100644 index 00000000..238de749 --- /dev/null +++ b/libs/api/tetra/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/tetra/src/lib/infra/repository/tetra-config.repository.spec.ts b/libs/api/tetra/src/lib/infra/repository/tetra-config.repository.spec.ts new file mode 100644 index 00000000..a3b44c9b --- /dev/null +++ b/libs/api/tetra/src/lib/infra/repository/tetra-config.repository.spec.ts @@ -0,0 +1,98 @@ +import { classes } from '@automapper/classes'; +import { AutomapperModule } from '@automapper/nestjs'; +import { createMock } from '@golevelup/ts-jest'; +import { getModelToken } from '@nestjs/mongoose'; +import { Test } from '@nestjs/testing'; +import { Model } from 'mongoose'; + +import { mockModelMethodResult } from '@kordis/api/test-helpers'; + +import { TetraConfig } from '../../core/entity/tetra-config.entitiy'; +import { TetraConfigRepository } from '../../core/repository/tetra-config.repository'; +import { TetraConfigDocument } from '../schema/tetra-config.schema'; +import { TetraConfigMapperProfile } from '../tetra-config.mapper-profile'; +import { TetraConfigRepositoryImpl } from './tetra-config.repository'; + +describe('TetraConfigRepository', () => { + let repository: TetraConfigRepository; + let tetraConfigModel: Model; + + beforeEach(async () => { + const moduleRef = await Test.createTestingModule({ + imports: [ + AutomapperModule.forRoot({ + strategyInitializer: classes(), + }), + ], + providers: [ + TetraConfigMapperProfile, + TetraConfigRepositoryImpl, + { + provide: getModelToken(TetraConfigDocument.name), + useValue: createMock>(), + }, + ], + }).compile(); + + repository = moduleRef.get( + TetraConfigRepositoryImpl, + ); + tetraConfigModel = moduleRef.get>( + getModelToken(TetraConfigDocument.name), + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('findByOrgId', () => { + it('should return tetra config', async () => { + const orgId = 'orgId'; + const expected: TetraConfig = new TetraConfig(); + expected.orgId = orgId; + expected.tetraControlApiUrl = 'url'; + expected.tetraControlApiUserKey = 'userKey'; + expected.webhookAccessKey = 'webhook access key'; + + mockModelMethodResult(tetraConfigModel, expected, 'findOne'); + + const result = await repository.findByOrgId(orgId); + + expect(result).toEqual(expected); + }); + + it('should return null if no result', async () => { + mockModelMethodResult(tetraConfigModel, null, 'findOne'); + + const result = await repository.findByOrgId('orgId'); + + expect(result).toEqual(null); + }); + }); + + describe('findByWebhookAccessKey', () => { + it('should return tetra config', async () => { + const accessKey = 'accessKey'; + const expected: TetraConfig = new TetraConfig(); + expected.orgId = 'orgId'; + expected.tetraControlApiUrl = 'url'; + expected.tetraControlApiUserKey = 'userKey'; + expected.webhookAccessKey = accessKey; + + mockModelMethodResult(tetraConfigModel, expected, 'findOne'); + + const result = await repository.findByWebhookAccessKey(accessKey); + + expect(result).toEqual(expected); + }); + + it('should return null if no result', async () => { + mockModelMethodResult(tetraConfigModel, null, 'findOne'); + + const result = await repository.findByWebhookAccessKey('orgId'); + + expect(result).toEqual(null); + }); + }); +}); diff --git a/libs/api/tetra/src/lib/infra/repository/tetra-config.repository.ts b/libs/api/tetra/src/lib/infra/repository/tetra-config.repository.ts new file mode 100644 index 00000000..a60e75fd --- /dev/null +++ b/libs/api/tetra/src/lib/infra/repository/tetra-config.repository.ts @@ -0,0 +1,42 @@ +import { Mapper } from '@automapper/core'; +import { getMapperToken } from '@automapper/nestjs'; +import { Inject } from '@nestjs/common'; +import { InjectModel } from '@nestjs/mongoose'; +import { Model } from 'mongoose'; + +import { TetraConfig } from '../../core/entity/tetra-config.entitiy'; +import { TetraConfigRepository } from '../../core/repository/tetra-config.repository'; +import { TetraConfigDocument } from '../schema/tetra-config.schema'; + +export class TetraConfigRepositoryImpl implements TetraConfigRepository { + constructor( + @InjectModel(TetraConfigDocument.name) + private readonly tetraConfigModel: Model, + @Inject(getMapperToken()) private readonly mapper: Mapper, + ) {} + + async findByOrgId(orgId: string): Promise { + const doc = await this.tetraConfigModel.findOne({ orgId }).lean().exec(); + + if (!doc) { + return null; + } + + return this.mapper.mapAsync(doc, TetraConfigDocument, TetraConfig); + } + + async findByWebhookAccessKey(key: string): Promise { + const doc = await this.tetraConfigModel + .findOne({ + webhookAccessKey: key, + }) + .lean() + .exec(); + + if (!doc) { + return null; + } + + return this.mapper.mapAsync(doc, TetraConfigDocument, TetraConfig); + } +} diff --git a/libs/api/tetra/src/lib/infra/schema/tetra-config.schema.ts b/libs/api/tetra/src/lib/infra/schema/tetra-config.schema.ts new file mode 100644 index 00000000..b0fda075 --- /dev/null +++ b/libs/api/tetra/src/lib/infra/schema/tetra-config.schema.ts @@ -0,0 +1,20 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { Document } from 'mongoose'; + +@Schema({ collection: 'tetra-configs' }) +export class TetraConfigDocument extends Document { + @Prop({ unique: true }) + orgId: string; + + @Prop() + tetraControlApiUrl: string; + + @Prop() + tetraControlApiUserKey: string; + + @Prop({ index: true }) + webhookAccessKey: string; +} + +export const TetraConfigSchema = + SchemaFactory.createForClass(TetraConfigDocument); diff --git a/libs/api/tetra/src/lib/infra/service/tetra-control.service.spec.ts b/libs/api/tetra/src/lib/infra/service/tetra-control.service.spec.ts new file mode 100644 index 00000000..35b40262 --- /dev/null +++ b/libs/api/tetra/src/lib/infra/service/tetra-control.service.spec.ts @@ -0,0 +1,86 @@ +import { DeepMocked, createMock } from '@golevelup/ts-jest'; +import { HttpService } from '@nestjs/axios'; +import { Test } from '@nestjs/testing'; +import { of } from 'rxjs'; + +import { + TETRA_CONFIG_REPOSITORY, + TetraConfigRepository, +} from '../../core/repository/tetra-config.repository'; +import { TetraControlService } from './tetra-control.service'; + +describe('TetraControlService', () => { + let service: TetraControlService; + let httpServiceMock: DeepMocked; + + beforeEach(async () => { + const moduleRef = await Test.createTestingModule({ + providers: [ + TetraControlService, + { + provide: TETRA_CONFIG_REPOSITORY, + useValue: createMock({ + findByOrgId: () => + Promise.resolve({ + orgId: 'orgId', + webhookAccessKey: 'accessKey', + tetraControlApiUrl: 'https://tetra-control-service.com', + tetraControlApiUserKey: 'mock_key', + }), + findByWebhookAccessKey: () => + Promise.resolve({ + orgId: 'orgId', + webhookAccessKey: 'accessKey', + tetraControlApiUrl: 'https://tetra-control-service.com', + tetraControlApiUserKey: 'mock_key', + }), + }), + }, + { + provide: HttpService, + useValue: createMock({ + get: jest.fn().mockReturnValue(of({})), + }), + }, + ], + }).compile(); + + service = moduleRef.get(TetraControlService); + httpServiceMock = moduleRef.get(HttpService); + }); + + 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('orgId', 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('orgId', 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('orgId', issi, status); + + expect(httpServiceMock.get).toHaveBeenCalledWith( + 'https://tetra-control-service.com/API/ISSIUPD?issi=12345&status=8005&userkey=mock_key', + ); + }); +}); diff --git a/libs/api/tetra/src/lib/infra/service/tetra-control.service.ts b/libs/api/tetra/src/lib/infra/service/tetra-control.service.ts new file mode 100644 index 00000000..a5c91270 --- /dev/null +++ b/libs/api/tetra/src/lib/infra/service/tetra-control.service.ts @@ -0,0 +1,100 @@ +import { HttpService } from '@nestjs/axios'; +import { Inject } from '@nestjs/common'; +import * as querystring from 'querystring'; +import { firstValueFrom } from 'rxjs'; + +import { TetraConfig } from '../../core/entity/tetra-config.entitiy'; +import { + TETRA_CONFIG_REPOSITORY, + TetraConfigRepository, +} from '../../core/repository/tetra-config.repository'; +import { TetraService } from '../../core/service/tetra.service'; + +export class TetraControlService implements TetraService { + constructor( + @Inject(TETRA_CONFIG_REPOSITORY) + private readonly tetraConfigRepository: TetraConfigRepository, + private readonly httpService: HttpService, + ) {} + + async sendCallOut( + orgId: string, + 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 config = await this.getTetraConfigOrThrow(orgId); + + const params: Record = { + Ziel: issi, + Typ: 195, + Text: message, + noreply: noReply ? 1 : 0, + userkey: config.tetraControlApiUserKey, + }; + + if (prio) { + params['COPrio'] = prio; + } + + const queryParams = querystring.encode(params); + const url = `${config.tetraControlApiUrl}/API/SDS?${queryParams}`; + + await firstValueFrom(this.httpService.get(url)); + } + + async sendSDS( + orgId: string, + issi: string, + message: string, + isFlash?: boolean, + ): Promise { + const config = await this.getTetraConfigOrThrow(orgId); + + const params: Record = { + Ziel: issi, + Text: message, + Flash: isFlash ? 1 : 0, + userkey: config.tetraControlApiUserKey, + }; + + const queryParams = querystring.encode(params); + const url = `${config.tetraControlApiUrl}/API/SDS?${queryParams}`; + + await firstValueFrom(this.httpService.get(url)); + } + + async sendStatus( + orgId: string, + issi: string, + status: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8, + ): Promise { + const config = await this.getTetraConfigOrThrow(orgId); + + const params: Record = { + issi, + status: this.convertFmsStatusToTetraStatus(status), + userkey: config.tetraControlApiUserKey, + }; + + const queryParams = querystring.encode(params); + const url = `${config.tetraControlApiUrl}/API/ISSIUPD?${queryParams}`; + + await firstValueFrom(this.httpService.get(url)); + } + + private convertFmsStatusToTetraStatus(fmsStatus: number): string { + const hexDecimalStatusEquivalent = 32770 + fmsStatus; + return hexDecimalStatusEquivalent.toString(16).toUpperCase(); + } + + private async getTetraConfigOrThrow(orgId: string): Promise { + const config = await this.tetraConfigRepository.findByOrgId(orgId); + if (!config) { + throw new Error('Tetra config not found for orgId ' + orgId); + } + + return config; + } +} diff --git a/libs/api/tetra/src/lib/infra/tetra-config.mapper-profile.ts b/libs/api/tetra/src/lib/infra/tetra-config.mapper-profile.ts new file mode 100644 index 00000000..c9cd7487 --- /dev/null +++ b/libs/api/tetra/src/lib/infra/tetra-config.mapper-profile.ts @@ -0,0 +1,40 @@ +import type { Mapper } from '@automapper/core'; +import { createMap, forMember, mapFrom } from '@automapper/core'; +import { AutomapperProfile, getMapperToken } from '@automapper/nestjs'; +import { Inject, Injectable } from '@nestjs/common'; + +import { TetraConfig } from '../core/entity/tetra-config.entitiy'; +import { TetraConfigDocument } from './schema/tetra-config.schema'; + +@Injectable() +export class TetraConfigMapperProfile extends AutomapperProfile { + constructor(@Inject(getMapperToken()) mapper: Mapper) { + super(mapper); + } + + override get profile() { + return (mapper: Mapper): void => { + createMap( + mapper, + TetraConfigDocument, + TetraConfig, + forMember( + (d) => d.orgId, + mapFrom((s) => s.orgId), + ), + forMember( + (d) => d.tetraControlApiUrl, + mapFrom((s) => s.tetraControlApiUrl), + ), + forMember( + (d) => d.tetraControlApiUserKey, + mapFrom((s) => s.tetraControlApiUserKey), + ), + forMember( + (d) => d.webhookAccessKey, + mapFrom((s) => s.webhookAccessKey), + ), + ); + }; + } +} diff --git a/libs/api/tetra/src/lib/infra/tetra.module.ts b/libs/api/tetra/src/lib/infra/tetra.module.ts new file mode 100644 index 00000000..fe7879d1 --- /dev/null +++ b/libs/api/tetra/src/lib/infra/tetra.module.ts @@ -0,0 +1,43 @@ +import { Module } from '@nestjs/common'; +import { CqrsModule } from '@nestjs/cqrs'; +import { MongooseModule } from '@nestjs/mongoose'; + +import { HandleTetraControlWebhookHandler } from '../core/command/handle-tetra-control-webhook.command'; +import { SendTetraSDSHandler } from '../core/command/send-tetra-sds.command'; +import { TETRA_CONFIG_REPOSITORY } from '../core/repository/tetra-config.repository'; +import { TETRA_SERVICE } from '../core/service/tetra.service'; +import { TetraControlWebhookController } from './controller/tetra-control-webhook.controller'; +import { TetraConfigRepositoryImpl } from './repository/tetra-config.repository'; +import { + TetraConfigDocument, + TetraConfigSchema, +} from './schema/tetra-config.schema'; +import { TetraControlService } from './service/tetra-control.service'; +import { TetraConfigMapperProfile } from './tetra-config.mapper-profile'; + +@Module({ + imports: [ + MongooseModule.forFeature([ + { + name: TetraConfigDocument.name, + schema: TetraConfigSchema, + }, + ]), + CqrsModule, + ], + controllers: [TetraControlWebhookController], + providers: [ + TetraConfigMapperProfile, + { + provide: TETRA_SERVICE, + useClass: TetraControlService, + }, + { + provide: TETRA_CONFIG_REPOSITORY, + useClass: TetraConfigRepositoryImpl, + }, + HandleTetraControlWebhookHandler, + SendTetraSDSHandler, + ], +}) +export class TetraModule {} diff --git a/libs/api/tetra/tsconfig.json b/libs/api/tetra/tsconfig.json new file mode 100644 index 00000000..9b4125f5 --- /dev/null +++ b/libs/api/tetra/tsconfig.json @@ -0,0 +1,23 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "module": "commonjs", + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + "strictPropertyInitialization": false, + "noFallthroughCasesInSwitch": true, + "esModuleInterop": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/libs/api/tetra/tsconfig.lib.json b/libs/api/tetra/tsconfig.lib.json new file mode 100644 index 00000000..f7abb4b6 --- /dev/null +++ b/libs/api/tetra/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/tetra/tsconfig.spec.json b/libs/api/tetra/tsconfig.spec.json new file mode 100644 index 00000000..231650b3 --- /dev/null +++ b/libs/api/tetra/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/tsconfig.base.json b/tsconfig.base.json index 100dd4d7..d29d32c0 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -20,6 +20,7 @@ "@kordis/api/organization": ["libs/api/organization/src/index.ts"], "@kordis/api/shared": ["libs/api/shared/src/index.ts"], "@kordis/api/test-helpers": ["libs/api/test-helpers/src/index.ts"], + "@kordis/api/tetra": ["libs/api/tetra/src/index.ts"], "@kordis/api/user": ["libs/api/user/src/index.ts"], "@kordis/shared/model": ["libs/shared/model/src/index.ts"], "@kordis/shared/test-helpers": ["libs/shared/test-helpers/src/index.ts"],