Skip to content

Commit

Permalink
feat(api): add protocol (#737)
Browse files Browse the repository at this point in the history
  • Loading branch information
JSPRH authored Jun 12, 2024
1 parent 0384ee9 commit fb8d85a
Show file tree
Hide file tree
Showing 52 changed files with 2,226 additions and 15 deletions.
2 changes: 2 additions & 0 deletions apps/api/src/app/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { AuthModule } from '@kordis/api/auth';
import { DeploymentModule } from '@kordis/api/deployment';
import { ObservabilityModule } from '@kordis/api/observability';
import { OrganizationModule } from '@kordis/api/organization';
import { ProtocolModule } from '@kordis/api/protocol';
import {
DataLoaderContainer,
DataLoaderContextProvider,
Expand All @@ -34,6 +35,7 @@ const isNextOrProdEnv = ['next', 'prod'].includes(

const FEATURE_MODULES = [
OrganizationModule,
ProtocolModule,
UsersModule.forRoot(process.env.AUTH_PROVIDER === 'dev' ? 'dev' : 'aadb2c'),
UnitModule,
TetraModule,
Expand Down
1 change: 1 addition & 0 deletions apps/api/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ async function bootstrap(): Promise<void> {
const app = await NestFactory.create(AppModule, { cors: true, bufferLogs: true });
app.useLogger(app.get(Logger));
app.useGlobalPipes(new ValidationPipe({
transform: true,
exceptionFactory: (errors) => PresentableValidationException.fromClassValidationErrors(errors)
}
));
Expand Down
18 changes: 18 additions & 0 deletions libs/api/protocol/.eslintrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"extends": ["../../../.eslintrc.json"],
"ignorePatterns": ["!**/*"],
"overrides": [
{
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
"rules": {}
},
{
"files": ["*.ts", "*.tsx"],
"rules": {}
},
{
"files": ["*.js", "*.jsx"],
"rules": {}
}
]
}
17 changes: 17 additions & 0 deletions libs/api/protocol/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# api-protocol

This library is responsible for providing the API and business logic for the
protocol feature. The protocol is a collection of entries documenting the
overall events and communication both manually added by the users as well as
automatically added by transactions like starting an operation or logging in a
station.

Currently the protocol documents events with the following entry types:

- [`CommunicationMessage`](./src/lib/core/entity/protocol-entries/communication-message.entity.ts):
Documenting the communication between any two units

## Running unit tests

Run `nx test api-protocol` to execute the unit tests via
[Jest](https://jestjs.io).
11 changes: 11 additions & 0 deletions libs/api/protocol/jest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/* eslint-disable */
export default {
displayName: 'api-protocol',
preset: '../../../jest.preset.js',
testEnvironment: 'node',
transform: {
'^.+\\.[tj]s$': ['ts-jest', { tsconfig: '<rootDir>/tsconfig.spec.json' }],
},
moduleFileExtensions: ['ts', 'js', 'html'],
coverageDirectory: '../../../coverage/libs/api/protocol',
};
20 changes: 20 additions & 0 deletions libs/api/protocol/project.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"name": "api-protocol",
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/api/protocol/src",
"projectType": "library",
"targets": {
"lint": {
"executor": "@nx/eslint:lint",
"outputs": ["{options.outputFile}"]
},
"test": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"jestConfig": "libs/api/protocol/jest.config.ts"
}
}
},
"tags": []
}
1 change: 1 addition & 0 deletions libs/api/protocol/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './lib/protocol.module';
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { createMock } from '@golevelup/ts-jest';
import { EventBus } from '@nestjs/cqrs';
import { plainToInstance } from 'class-transformer';
import { after, before } from 'node:test';

import { AuthUser } from '@kordis/shared/model';

import { UserProducer } from '../entity/partials/producer-partial.entity';
import { UnknownUnit } from '../entity/partials/unit-partial.entity';
import {
CommunicationMessage,
CommunicationMessagePayload,
} from '../entity/protocol-entries/communication-message.entity';
import { ProtocolEntryCreatedEvent } from '../event/protocol-entry-created.event';
import { ProtocolEntryRepository } from '../repository/protocol-entry.repository';
import {
CreateCommunicationMessageCommand,
CreateCommunicationMessageHandler,
} from './create-communication-message.command';

describe('CreateCommunicationMessageCommand', () => {
let handler: CreateCommunicationMessageHandler;
const repositoryMock = createMock<ProtocolEntryRepository>();
const eventBusMock = createMock<EventBus>();

before(() => {
jest.useFakeTimers({ now: new Date(0) });
});

beforeEach(() => {
handler = new CreateCommunicationMessageHandler(
repositoryMock,
eventBusMock,
);
});

afterEach(() => {
jest.resetAllMocks();
});

it('should create communication message protocol entry and emit event', async () => {
const sender = new UnknownUnit();
sender.name = 'Bob';
const recipient = new UnknownUnit();
recipient.name = 'Alice';
const time = new Date('1913-10-19T00:00:00');
const message = '🛥️';
const channel = 'D';
const authUser = {
id: 'user-id',
organizationId: 'org-id',
firstName: 'John',
lastName: 'Doe',
} as unknown as AuthUser;

const command = new CreateCommunicationMessageCommand(
time,
sender,
recipient,
message,
channel,
authUser,
);

const expectedCommMsg = plainToInstance(CommunicationMessage, {
sender,
recipient,
time,
searchableText: message,
channel,
payload: plainToInstance(CommunicationMessagePayload, { message }),
producer: plainToInstance(UserProducer, {
userId: authUser.id,
firstName: authUser.firstName,
lastName: authUser.lastName,
}),
orgId: authUser.organizationId,
});
repositoryMock.create.mockResolvedValueOnce(expectedCommMsg);

await handler.execute(command);

expect(repositoryMock.create).toHaveBeenCalledWith(expectedCommMsg);
expect(eventBusMock.publish).toHaveBeenCalledWith(
new ProtocolEntryCreatedEvent('org-id', expectedCommMsg),
);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { Inject, Logger } from '@nestjs/common';
import { CommandHandler, EventBus, ICommandHandler } from '@nestjs/cqrs';

import type { KordisLogger } from '@kordis/api/observability';
import { AuthUser } from '@kordis/shared/model';

import { UserProducer } from '../entity/partials/producer-partial.entity';
import {
RegisteredUnit,
UnknownUnit,
} from '../entity/partials/unit-partial.entity';
import {
CommunicationMessage,
CommunicationMessagePayload,
} from '../entity/protocol-entries/communication-message.entity';
import { ProtocolEntryCreatedEvent } from '../event/protocol-entry-created.event';
import {
PROTOCOL_ENTRY_REPOSITORY,
ProtocolEntryRepository,
} from '../repository/protocol-entry.repository';

export class CreateCommunicationMessageCommand {
constructor(
public readonly time: Date,
public readonly sender: RegisteredUnit | UnknownUnit,
public readonly recipient: RegisteredUnit | UnknownUnit,
public readonly message: string,
public readonly channel: string,
public readonly requestUser: AuthUser,
) {}
}

@CommandHandler(CreateCommunicationMessageCommand)
export class CreateCommunicationMessageHandler
implements ICommandHandler<CreateCommunicationMessageCommand>
{
private readonly logger: KordisLogger = new Logger(
CreateCommunicationMessageHandler.name,
);

constructor(
@Inject(PROTOCOL_ENTRY_REPOSITORY)
private readonly repository: ProtocolEntryRepository,
private readonly eventBus: EventBus,
) {}

async execute(
command: CreateCommunicationMessageCommand,
): Promise<CommunicationMessage> {
let commMsg = this.createCommunicationMessageFromCommand(command);

commMsg.validOrThrow();

commMsg = await this.repository.create(commMsg);

this.logger.log('Communication message created', { commMsgId: commMsg.id });

this.eventBus.publish(
new ProtocolEntryCreatedEvent(
command.requestUser.organizationId,
commMsg,
),
);

return commMsg;
}

private createCommunicationMessageFromCommand({
time,
sender,
recipient,
message,
channel,
requestUser,
}: CreateCommunicationMessageCommand): CommunicationMessage {
const msgPayload = new CommunicationMessagePayload();
msgPayload.message = message;

const producer = new UserProducer();
producer.userId = requestUser.id;
producer.firstName = requestUser.firstName;
producer.lastName = requestUser.lastName;

const commMsg = new CommunicationMessage();
commMsg.time = time;
commMsg.sender = sender;
commMsg.recipient = recipient;
commMsg.payload = msgPayload;
commMsg.producer = producer;
commMsg.channel = channel;
commMsg.searchableText = message;
commMsg.orgId = requestUser.organizationId;

return commMsg;
}
}
6 changes: 6 additions & 0 deletions libs/api/protocol/src/lib/core/entity/page.entity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export class Page<T> {
nodes: T[];
totalEdges: number;
hasNext: boolean;
hasPrevious: boolean;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { AutoMap } from '@automapper/classes';
import { Field, ObjectType, createUnionType } from '@nestjs/graphql';

@ObjectType({ isAbstract: true })
export abstract class Producer {}

@ObjectType()
export class UserProducer extends Producer {
@Field()
@AutoMap()
userId: string;

@Field()
@AutoMap()
firstName: string;

@Field()
@AutoMap()
lastName: string;
}

@ObjectType()
export class SystemProducer extends Producer {
@Field()
@AutoMap()
name: string;
}

export const ProducerUnion = createUnionType({
name: 'ProducerUnion',
types: () => [SystemProducer, UserProducer] as const,
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { AutoMap } from '@automapper/classes';
import { Field, InputType, ObjectType, createUnionType } from '@nestjs/graphql';

import { UnitViewModel } from '@kordis/api/unit';

@ObjectType({ isAbstract: true })
export abstract class Unit {}

@ObjectType()
@InputType('RegisteredUnitInput')
export class RegisteredUnit extends Unit {
@Field(() => UnitViewModel)
@AutoMap()
unit: { id: string };
}

@ObjectType()
@InputType('UnknownUnitInput')
export class UnknownUnit extends Unit {
@Field()
@AutoMap()
name: string;
}

export const UnitUnion = createUnionType({
name: 'UnitUnion',
types: () => [RegisteredUnit, UnknownUnit] as const,
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { AutoMap } from '@automapper/classes';
import { Field, ObjectType } from '@nestjs/graphql';
import { IsNotEmpty, IsString } from 'class-validator';

import { ProtocolCommunicationEntryBase } from './protocol-entry-base.entity';

@ObjectType()
export class CommunicationMessagePayload {
@Field()
@AutoMap()
@IsString()
@IsNotEmpty()
message: string;
}

@ObjectType()
export class CommunicationMessage extends ProtocolCommunicationEntryBase {
@Field(() => CommunicationMessagePayload)
@AutoMap()
payload: CommunicationMessagePayload;
}
Loading

0 comments on commit fb8d85a

Please sign in to comment.