Merge branch 'refactorToBetterHexagon' into 'main'

Refactor to better hexagon

See merge request v3/service/ad!10
This commit is contained in:
Sylvain Briat 2023-06-29 09:00:56 +00:00
commit c03edba904
181 changed files with 7437 additions and 5431 deletions

View File

@ -22,9 +22,12 @@ DEPARTURE_MARGIN=900
# DEFAULT ROLE
ROLE=passenger
# SEATS PROVIDED AS DRIVER / REQUESTED AS PASSENGER
SEATS_PROVIDED=3
# SEATS PROPOSED AS DRIVER / REQUESTED AS PASSENGER
SEATS_PROPOSED=3
SEATS_REQUESTED=1
# ACCEPT ONLY SAME FREQUENCY REQUESTS
STRICT_FREQUENCY=false
# default timezone
DEFAULT_TIMEZONE=Europe/Paris

View File

@ -48,30 +48,34 @@ npm run migrate
The app exposes the following [gRPC](https://grpc.io/) services :
- **FindByUuid** : find an ad by its uuid
- **FindById** : find an ad by its id
```json
{
"uuid": "80126a61-d128-4f96-afdb-92e33c75a3e1"
"id": "80126a61-d128-4f96-afdb-92e33c75a3e1"
}
```
- **Create** : create an ad (note that uuid is optional, a uuid will be automatically attributed if it is not provided)
- **Create** : create an ad (note that id is optional, an id (as a uuid) will be automatically attributed if it is not provided)
Punctual driver ad :
```json
{
"userUuid": "80c9bb02-0931-4a1d-bea6-22d358992245",
"userId": "80c9bb02-0931-4a1d-bea6-22d358992245",
"driver": true,
"seatsDriver": 3,
"seatsProposed": 3,
"frequency": "PUNCTUAL",
"departureDateTime": "2023-01-15 09:00",
"addresses": [
"fromDate": "2023-01-15",
"toDate": "2023-01-15",
"schedule": {
"thu": "09:00"
},
"waypoints": [
{
"position": 0,
"lon": 48.68944505415954,
"lat": 6.176510296462267,
"lon": 48.689445,
"lat": 6.17651,
"houseNumber": "5",
"street": "Avenue Foch",
"locality": "Nancy",
@ -94,18 +98,22 @@ The app exposes the following [gRPC](https://grpc.io/) services :
```json
{
"userUuid": "80c9bb02-0931-4a1d-bea6-22d358992245",
"userId": "80c9bb02-0931-4a1d-bea6-22d358992245",
"driver": true,
"pasenger": true,
"seatsDriver": 3,
"seatsPassenger": 1,
"seatsProposed": 3,
"seatsRequested": 1,
"frequency": "PUNCTUAL",
"departureDateTime": "2023-01-15 09:00",
"addresses": [
"fromDate": "2023-01-15",
"toDate": "2023-01-15",
"schedule": {
"thu": "09:00"
},
"waypoints": [
{
"position": 0,
"lon": 48.68944505415954,
"lat": 6.176510296462267,
"lon": 48.689445,
"lat": 6.17651,
"houseNumber": "5",
"street": "Avenue Foch",
"locality": "Nancy",
@ -128,7 +136,7 @@ The app exposes the following [gRPC](https://grpc.io/) services :
```json
{
"userUuid": "80c9bb02-0931-4a1d-bea6-22d358992245",
"userId": "80c9bb02-0931-4a1d-bea6-22d358992245",
"passenger": true,
"seatsPassenger": 1,
"frequency": "RECURRRENT",
@ -139,11 +147,11 @@ The app exposes the following [gRPC](https://grpc.io/) services :
"tue": "07:05",
"fri": "07:10"
},
"addresses": [
"waypoints": [
{
"position": 0,
"lon": 48.68944505415954,
"lat": 6.176510296462267,
"lon": 48.689445,
"lat": 6.17651,
"houseNumber": "5",
"street": "Avenue Foch",
"locality": "Nancy",
@ -164,15 +172,14 @@ The app exposes the following [gRPC](https://grpc.io/) services :
The list of possible options when creating an ad :
- uuid (optional): the uuid of the ad
- userUuid: the user uuid
- id (optional): the id of the ad (as a uuid)
- userId: the user id (as a uuid)
- driver (boolean, optional): if the ad is a driver ad
- passenger (boolean, optional): if the ad is a passenger ad
- frequency: `PUNCTUAL` or `RECURRENT`
- departureDateTime (required if punctual): departure date and hour/minute for a punctual ad
- fromDate (required if recurrent): start date for recurrent ad
- toDate (required if recurrent): end date for recurrent ad
- schedule (required if recurrent): an object with the departure time for each carpooled day in the week
- fromDate: start date for recurrent ad, carpool date for punctual ad
- toDate: end date for recurrent ad, same as fromDate for punctual ad
- schedule: an object with the departure time for each carpooled day in the week (only the carpooled day for punctual ad)
- marginDurations (optional): an object with the margin duration (in seconds) for each carpooled day in the week, eg:
{
@ -181,10 +188,10 @@ The app exposes the following [gRPC](https://grpc.io/) services :
"fri": 950
}
- seatsDriver (optional): number of seats proposed as driver;
- seatsPassenger (optional): number of seats requested as passenger;
- seatsProposed (optional): number of seats proposed as driver
- seatsRequested (optional): number of seats requested as passenger
- strict (boolean, optional): if set to true, allow matching only with similar frequency ads
- addresses: an array of adresses that represent the waypoints of the journey (only first and last waypoints are used for passenger ads)
- waypoints: an array of addresses that represent the waypoints of the journey (only first and last waypoints are used for passenger ads). Note that positions are **required** and **must** be consecutives
Default values must be set in `.env` file.

3308
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{
"name": "@mobicoop/ad",
"version": "0.0.1",
"version": "1.0.0",
"description": "Mobicoop V3 Ad",
"author": "sbriat",
"private": true,
@ -34,9 +34,6 @@
"migrate:deploy": "npx prisma migrate deploy"
},
"dependencies": {
"@automapper/classes": "^8.7.7",
"@automapper/core": "^8.7.7",
"@automapper/nestjs": "^8.7.7",
"@grpc/grpc-js": "^1.8.14",
"@grpc/proto-loader": "^0.7.6",
"@liaoliaots/nestjs-redis": "^9.0.5",
@ -46,15 +43,19 @@
"@nestjs/config": "^2.3.1",
"@nestjs/core": "^9.0.0",
"@nestjs/cqrs": "^9.0.3",
"@nestjs/event-emitter": "^1.4.2",
"@nestjs/microservices": "^9.4.0",
"@nestjs/platform-express": "^9.0.0",
"@nestjs/terminus": "^9.2.2",
"@prisma/client": "^4.13.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.0",
"geo-tz": "^7.0.7",
"ioredis": "^5.3.2",
"nestjs-request-context": "^2.1.0",
"reflect-metadata": "^0.1.13",
"rxjs": "^7.2.0"
"rxjs": "^7.2.0",
"timezonecomplete": "^5.12.4"
},
"devDependencies": {
"@nestjs/cli": "^9.0.0",
@ -64,6 +65,7 @@
"@types/jest": "29.5.0",
"@types/node": "18.15.11",
"@types/supertest": "^2.0.11",
"@types/uuid": "^9.0.2",
"@typescript-eslint/eslint-plugin": "^5.0.0",
"@typescript-eslint/parser": "^5.0.0",
"dotenv-cli": "^7.2.1",
@ -88,13 +90,16 @@
"ts"
],
"modulePathIgnorePatterns": [
".controller.ts",
".module.ts",
".request.ts",
".presenter.ts",
".profile.ts",
".exception.ts",
".dto.ts",
".constants.ts",
".response.ts",
".response.base.ts",
".port.ts",
"libs/exceptions",
"libs/types",
"prisma.service.ts",
"convert-props-to-object.util.ts",
"main.ts"
],
"rootDir": "src",
@ -106,18 +111,24 @@
"**/*.(t|j)s"
],
"coveragePathIgnorePatterns": [
".validator.ts",
".controller.ts",
".module.ts",
".request.ts",
".presenter.ts",
".profile.ts",
".exception.ts",
".dto.ts",
".constants.ts",
".interfaces.ts",
".response.ts",
".response.base.ts",
".port.ts",
"libs/exceptions",
"libs/types",
"prisma.service.ts",
"convert-props-to-object.util.ts",
"main.ts"
],
"coverageDirectory": "../coverage",
"moduleNameMapper": {
"^@libs(.*)": "<rootDir>/libs/$1",
"^@modules(.*)": "<rootDir>/modules/$1",
"^@src(.*)": "<rootDir>$1"
},
"testEnvironment": "node"
}
}

View File

@ -10,13 +10,13 @@ CREATE TABLE "ad" (
"frequency" "Frequency" NOT NULL,
"fromDate" DATE NOT NULL,
"toDate" DATE NOT NULL,
"monTime" TEXT,
"tueTime" TEXT,
"wedTime" TEXT,
"thuTime" TEXT,
"friTime" TEXT,
"satTime" TEXT,
"sunTime" TEXT,
"monTime" TIMESTAMPTZ,
"tueTime" TIMESTAMPTZ,
"wedTime" TIMESTAMPTZ,
"thuTime" TIMESTAMPTZ,
"friTime" TIMESTAMPTZ,
"satTime" TIMESTAMPTZ,
"sunTime" TIMESTAMPTZ,
"monMargin" INTEGER NOT NULL,
"tueMargin" INTEGER NOT NULL,
"wedMargin" INTEGER NOT NULL,
@ -24,8 +24,8 @@ CREATE TABLE "ad" (
"friMargin" INTEGER NOT NULL,
"satMargin" INTEGER NOT NULL,
"sunMargin" INTEGER NOT NULL,
"seatsDriver" SMALLINT NOT NULL,
"seatsPassenger" SMALLINT NOT NULL,
"seatsProposed" SMALLINT NOT NULL,
"seatsRequested" SMALLINT NOT NULL,
"strict" BOOLEAN NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
@ -34,12 +34,12 @@ CREATE TABLE "ad" (
);
-- CreateTable
CREATE TABLE "address" (
CREATE TABLE "waypoint" (
"uuid" UUID NOT NULL,
"adUuid" UUID NOT NULL,
"position" SMALLINT NOT NULL,
"lon" DOUBLE PRECISION NOT NULL,
"lat" DOUBLE PRECISION NOT NULL,
"lon" DECIMAL(9,6) NOT NULL,
"lat" DECIMAL(8,6) NOT NULL,
"name" TEXT,
"houseNumber" TEXT,
"street" TEXT,
@ -49,8 +49,8 @@ CREATE TABLE "address" (
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "address_pkey" PRIMARY KEY ("uuid")
CONSTRAINT "waypoint_pkey" PRIMARY KEY ("uuid")
);
-- AddForeignKey
ALTER TABLE "address" ADD CONSTRAINT "address_adUuid_fkey" FOREIGN KEY ("adUuid") REFERENCES "ad"("uuid") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "waypoint" ADD CONSTRAINT "waypoint_adUuid_fkey" FOREIGN KEY ("adUuid") REFERENCES "ad"("uuid") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -19,13 +19,13 @@ model Ad {
frequency Frequency
fromDate DateTime @db.Date
toDate DateTime @db.Date
monTime String?
tueTime String?
wedTime String?
thuTime String?
friTime String?
satTime String?
sunTime String?
monTime DateTime? @db.Timestamptz()
tueTime DateTime? @db.Timestamptz()
wedTime DateTime? @db.Timestamptz()
thuTime DateTime? @db.Timestamptz()
friTime DateTime? @db.Timestamptz()
satTime DateTime? @db.Timestamptz()
sunTime DateTime? @db.Timestamptz()
monMargin Int
tueMargin Int
wedMargin Int
@ -33,22 +33,22 @@ model Ad {
friMargin Int
satMargin Int
sunMargin Int
seatsDriver Int @db.SmallInt
seatsPassenger Int @db.SmallInt
seatsProposed Int @db.SmallInt
seatsRequested Int @db.SmallInt
strict Boolean
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
addresses Address[]
waypoints Waypoint[]
@@map("ad")
}
model Address {
model Waypoint {
uuid String @id @default(uuid()) @db.Uuid
adUuid String @db.Uuid
position Int @db.SmallInt
lon Float
lat Float
lon Decimal @db.Decimal(9, 6)
lat Decimal @db.Decimal(8, 6)
name String?
houseNumber String?
street String?
@ -59,7 +59,7 @@ model Address {
updatedAt DateTime @default(now()) @updatedAt
Ad Ad @relation(fields: [adUuid], references: [uuid], onDelete: Cascade)
@@map("address")
@@map("waypoint")
}
enum Frequency {

View File

@ -1,9 +1,6 @@
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { HealthModule } from './modules/health/health.module';
import { AdModule } from './modules/ad/ad.module';
import { AutomapperModule } from '@automapper/nestjs';
import { classes } from '@automapper/classes';
import {
MessageBrokerModule,
MessageBrokerModuleOptions,
@ -12,11 +9,15 @@ import {
ConfigurationModule,
ConfigurationModuleOptions,
} from '@mobicoop/configuration-module';
import { EventEmitterModule } from '@nestjs/event-emitter';
import { RequestContextModule } from 'nestjs-request-context';
import { HealthModule } from '@modules/health/health.module';
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
AutomapperModule.forRoot({ strategyInitializer: classes() }),
EventEmitterModule.forRoot(),
RequestContextModule,
MessageBrokerModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
@ -44,8 +45,6 @@ import {
password: configService.get<string>('REDIS_PASSWORD'),
port: configService.get<number>('REDIS_PORT'),
},
propagateConfigurationRoutingKey: 'configuration.propagate',
setConfigurationBrokerQueue: 'ad-configuration-create-update',
deleteConfigurationQueue: 'ad-configuration-delete',
propagateConfigurationQueue: 'ad-configuration-propagate',
@ -54,7 +53,5 @@ import {
HealthModule,
AdModule,
],
controllers: [],
providers: [],
})
export class AppModule {}

View File

@ -0,0 +1,19 @@
export class ApiErrorResponse {
readonly statusCode: number;
readonly message: string;
readonly error: string;
readonly correlationId: string;
readonly subErrors?: string[];
constructor(body: ApiErrorResponse) {
this.statusCode = body.statusCode;
this.message = body.message;
this.error = body.error;
this.correlationId = body.correlationId;
this.subErrors = body.subErrors;
}
}

View File

@ -0,0 +1,7 @@
export class IdResponse {
constructor(id: string) {
this.id = id;
}
readonly id: string;
}

View File

@ -0,0 +1,8 @@
import { Paginated } from '../ddd';
export abstract class PaginatedResponseDto<T> extends Paginated<T> {
readonly total: number;
readonly perPage: number;
readonly page: number;
abstract readonly data: readonly T[];
}

View File

@ -0,0 +1,23 @@
import { IdResponse } from './id.response.dto';
export interface BaseResponseProps {
id: string;
createdAt: Date;
updatedAt: Date;
}
/**
* Most of our response objects will have properties like
* id, createdAt and updatedAt so we can move them to a
* separate class and extend it to avoid duplication.
*/
export class ResponseBase extends IdResponse {
constructor(props: BaseResponseProps) {
super(props.id);
this.createdAt = new Date(props.createdAt).toISOString();
this.updatedAt = new Date(props.updatedAt).toISOString();
}
readonly createdAt: string;
readonly updatedAt: string;
}

View File

@ -0,0 +1,66 @@
import { EventEmitter2 } from '@nestjs/event-emitter';
import { AggregateRoot, Mapper, RepositoryPort } from '../ddd';
import { ObjectLiteral } from '../types';
import { LoggerPort } from '../ports/logger.port';
import {
PrismaRawRepositoryPort,
PrismaRepositoryPort,
} from '../ports/prisma-repository.port';
import { Prisma } from '@prisma/client';
import {
ConflictException,
DatabaseErrorException,
NotFoundException,
} from '@libs/exceptions';
export abstract class PrismaRepositoryBase<
Aggregate extends AggregateRoot<any>,
DbReadModel extends ObjectLiteral,
DbWriteModel extends ObjectLiteral,
> implements RepositoryPort<Aggregate>
{
protected constructor(
protected readonly prisma: PrismaRepositoryPort<Aggregate> | any,
protected readonly prismaRaw: PrismaRawRepositoryPort,
protected readonly mapper: Mapper<Aggregate, DbReadModel, DbWriteModel>,
protected readonly eventEmitter: EventEmitter2,
protected readonly logger: LoggerPort,
) {}
async findOneById(id: string, include?: any): Promise<Aggregate> {
const entity = await this.prisma.findUnique({
where: { uuid: id },
include,
});
if (entity) return this.mapper.toDomain(entity);
throw new NotFoundException('Record not found');
}
async insert(entity: Aggregate): Promise<void> {
try {
await this.prisma.create({
data: this.mapper.toPersistence(entity),
});
entity.publishEvents(this.logger, this.eventEmitter);
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError) {
if (e.message.includes('Already exists')) {
throw new ConflictException('Record already exists', e);
}
}
throw e;
}
}
async healthCheck(): Promise<boolean> {
try {
await this.prismaRaw.$queryRaw`SELECT 1`;
return true;
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseErrorException(e.message);
}
throw new DatabaseErrorException();
}
}
}

View File

@ -0,0 +1,35 @@
import { Entity } from './entity.base';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { LoggerPort } from '@libs/ports/logger.port';
import { DomainEvent } from './domain-event.base';
export abstract class AggregateRoot<EntityProps> extends Entity<EntityProps> {
private _domainEvents: DomainEvent[] = [];
get domainEvents(): DomainEvent[] {
return this._domainEvents;
}
protected addEvent(domainEvent: DomainEvent): void {
this._domainEvents.push(domainEvent);
}
public clearEvents(): void {
this._domainEvents = [];
}
public async publishEvents(
logger: LoggerPort,
eventEmitter: EventEmitter2,
): Promise<void> {
await Promise.all(
this.domainEvents.map(async (event) => {
logger.debug(
`"${event.constructor.name}" event published for aggregate ${this.constructor.name} : ${this.id}`,
);
return eventEmitter.emitAsync(event.constructor.name, event);
}),
);
this.clearEvents();
}
}

View File

@ -0,0 +1,52 @@
import { v4 } from 'uuid';
import { ArgumentNotProvidedException } from '../exceptions';
import { Guard } from '../guard';
export type CommandProps<T> = Omit<T, 'id' | 'metadata'> & Partial<Command>;
type CommandMetadata = {
/** ID for correlation purposes (for commands that
* arrive from other microservices,logs correlation, etc). */
readonly correlationId: string;
/**
* Causation id to reconstruct execution order if needed
*/
readonly causationId?: string;
/**
* ID of a user who invoker the command. Can be useful for
* logging and tracking execution of commands and events
*/
readonly userId?: string;
/**
* Time when the command occurred. Mostly for tracing purposes
*/
readonly timestamp: number;
};
export class Command {
/**
* Command id, in case if we want to save it
* for auditing purposes and create a correlation/causation chain
*/
readonly id: string;
readonly metadata: CommandMetadata;
constructor(props: CommandProps<unknown>) {
if (Guard.isEmpty(props)) {
throw new ArgumentNotProvidedException(
'Command props should not be empty',
);
}
this.id = props.id || v4();
this.metadata = {
correlationId: props?.metadata?.correlationId,
causationId: props?.metadata?.causationId,
timestamp: props?.metadata?.timestamp || Date.now(),
userId: props?.metadata?.userId,
};
}
}

View File

@ -0,0 +1,52 @@
import { ArgumentNotProvidedException } from '../exceptions';
import { Guard } from '../guard';
import { v4 } from 'uuid';
type DomainEventMetadata = {
/** Timestamp when this domain event occurred */
readonly timestamp: number;
/** ID for correlation purposes (for Integration Events,logs correlation, etc).
*/
readonly correlationId: string;
/**
* Causation id used to reconstruct execution order if needed
*/
readonly causationId?: string;
/**
* User ID for debugging and logging purposes
*/
readonly userId?: string;
};
export type DomainEventProps<T> = Omit<T, 'id' | 'metadata'> & {
aggregateId: string;
metadata?: DomainEventMetadata;
};
export abstract class DomainEvent {
public readonly id: string;
/** Aggregate ID where domain event occurred */
public readonly aggregateId: string;
public readonly metadata: DomainEventMetadata;
constructor(props: DomainEventProps<unknown>) {
if (Guard.isEmpty(props)) {
throw new ArgumentNotProvidedException(
'DomainEvent props should not be empty',
);
}
this.id = v4();
this.aggregateId = props.aggregateId;
this.metadata = {
correlationId: props?.metadata?.correlationId,
causationId: props?.metadata?.causationId,
timestamp: props?.metadata?.timestamp || Date.now(),
userId: props?.metadata?.userId,
};
}
}

150
src/libs/ddd/entity.base.ts Normal file
View File

@ -0,0 +1,150 @@
import {
ArgumentNotProvidedException,
ArgumentInvalidException,
ArgumentOutOfRangeException,
} from '../exceptions';
import { Guard } from '../guard';
import { convertPropsToObject } from '../utils';
export type AggregateID = string;
export interface BaseEntityProps {
id: AggregateID;
createdAt: Date;
updatedAt: Date;
}
export interface CreateEntityProps<T> {
id: AggregateID;
props: T;
createdAt?: Date;
updatedAt?: Date;
}
export abstract class Entity<EntityProps> {
constructor({
id,
createdAt,
updatedAt,
props,
}: CreateEntityProps<EntityProps>) {
this.setId(id);
this.validateProps(props);
const now = new Date();
this._createdAt = createdAt || now;
this._updatedAt = updatedAt || now;
this.props = props;
this.validate();
}
protected readonly props: EntityProps;
/**
* ID is set in the concrete entity implementation to support
* different ID types depending on your needs.
* For example it could be a UUID for aggregate root,
* and shortid / nanoid for child entities.
*/
protected abstract _id: AggregateID;
private readonly _createdAt: Date;
private _updatedAt: Date;
get id(): AggregateID {
return this._id;
}
private setId(id: AggregateID): void {
this._id = id;
}
get createdAt(): Date {
return this._createdAt;
}
get updatedAt(): Date {
return this._updatedAt;
}
static isEntity(entity: unknown): entity is Entity<unknown> {
return entity instanceof Entity;
}
/**
* Checks if two entities are the same Entity by comparing ID field.
* @param object Entity
*/
public equals(object?: Entity<EntityProps>): boolean {
if (object === null || object === undefined) {
return false;
}
if (this === object) {
return true;
}
if (!Entity.isEntity(object)) {
return false;
}
return this.id ? this.id === object.id : false;
}
/**
* Returns entity properties.
* @return {*} {Props & EntityProps}
* @memberof Entity
*/
public getProps(): EntityProps & BaseEntityProps {
const propsCopy = {
id: this._id,
createdAt: this._createdAt,
updatedAt: this._updatedAt,
...this.props,
};
return Object.freeze(propsCopy);
}
/**
* Convert an Entity and all sub-entities/Value Objects it
* contains to a plain object with primitive types. Can be
* useful when logging an entity during testing/debugging
*/
public toObject(): unknown {
const plainProps = convertPropsToObject(this.props);
const result = {
id: this._id,
createdAt: this._createdAt,
updatedAt: this._updatedAt,
...plainProps,
};
return Object.freeze(result);
}
/**
* There are certain rules that always have to be true (invariants)
* for each entity. Validate method is called every time before
* saving an entity to the database to make sure those rules are respected.
*/
public abstract validate(): void;
private validateProps(props: EntityProps): void {
const MAX_PROPS = 50;
if (Guard.isEmpty(props)) {
throw new ArgumentNotProvidedException(
'Entity props should not be empty',
);
}
if (typeof props !== 'object') {
throw new ArgumentInvalidException('Entity props should be an object');
}
if (Object.keys(props as any).length > MAX_PROPS) {
throw new ArgumentOutOfRangeException(
`Entity props should not have more than ${MAX_PROPS} properties`,
);
}
}
}

7
src/libs/ddd/index.ts Normal file
View File

@ -0,0 +1,7 @@
export * from './aggregate-root.base';
export * from './command.base';
export * from './domain-event.base';
export * from './entity.base';
export * from './mapper.interface';
export * from './repository.port';
export * from './value-object.base';

View File

@ -0,0 +1,12 @@
import { Entity } from './entity.base';
export interface Mapper<
DomainEntity extends Entity<any>,
DbReadRecord,
DbWriteRecord,
Response = any,
> {
toPersistence(entity: DomainEntity): DbWriteRecord;
toDomain(record: DbReadRecord): DomainEntity;
toResponse(entity: DomainEntity): Response;
}

View File

@ -0,0 +1,31 @@
import { OrderBy, PaginatedQueryParams } from './repository.port';
/**
* Base class for regular queries
*/
export abstract class QueryBase {}
/**
* Base class for paginated queries
*/
export abstract class PaginatedQueryBase extends QueryBase {
perPage: number;
offset: number;
orderBy: OrderBy;
page: number;
constructor(props: PaginatedParams<PaginatedQueryBase>) {
super();
this.perPage = props.perPage || 10;
this.offset = props.page ? props.page * this.perPage : 0;
this.page = props.page || 0;
this.orderBy = props.orderBy || { field: true, param: 'desc' };
}
}
// Paginated query parameters
export type PaginatedParams<T> = Omit<
T,
'perPage' | 'offset' | 'orderBy' | 'page'
> &
Partial<Omit<PaginatedQueryParams, 'offset'>>;

View File

@ -0,0 +1,40 @@
/* Most of repositories will probably need generic
save/find/delete operations, so it's easier
to have some shared interfaces.
More specific queries should be defined
in a respective repository.
*/
export class Paginated<T> {
readonly total: number;
readonly perPage: number;
readonly page: number;
readonly data: readonly T[];
constructor(props: Paginated<T>) {
this.total = props.total;
this.perPage = props.perPage;
this.page = props.page;
this.data = props.data;
}
}
export type OrderBy = { field: string | true; param: 'asc' | 'desc' };
export type PaginatedQueryParams = {
perPage: number;
page: number;
offset: number;
orderBy: OrderBy;
};
export interface RepositoryPort<Entity> {
insert(entity: Entity | Entity[]): Promise<void>;
findOneById(id: string, include?: any): Promise<Entity>;
healthCheck(): Promise<boolean>;
// findAll(): Promise<Entity[]>;
// findAllPaginated(params: PaginatedQueryParams): Promise<Paginated<Entity>>;
// delete(entity: Entity): Promise<boolean>;
// transaction<T>(handler: () => Promise<T>): Promise<T>;
}

View File

@ -0,0 +1,71 @@
import { ArgumentNotProvidedException } from '../exceptions';
import { Guard } from '../guard';
import { convertPropsToObject } from '../utils';
/**
* Domain Primitive is an object that contains only a single value
*/
export type Primitives = string | number | boolean;
export interface DomainPrimitive<T extends Primitives | Date> {
value: T;
}
type ValueObjectProps<T> = T extends Primitives | Date ? DomainPrimitive<T> : T;
export abstract class ValueObject<T> {
protected readonly props: ValueObjectProps<T>;
constructor(props: ValueObjectProps<T>) {
this.checkIfEmpty(props);
this.validate(props);
this.props = props;
}
protected abstract validate(props: ValueObjectProps<T>): void;
static isValueObject(obj: unknown): obj is ValueObject<unknown> {
return obj instanceof ValueObject;
}
/**
* Check if two Value Objects are equal. Checks structural equality.
* @param vo ValueObject
*/
public equals(vo?: ValueObject<T>): boolean {
if (vo === null || vo === undefined) {
return false;
}
return JSON.stringify(this) === JSON.stringify(vo);
}
/**
* Unpack a value object to get its raw properties
*/
public unpack(): T {
if (this.isDomainPrimitive(this.props)) {
return this.props.value;
}
const propsCopy = convertPropsToObject(this.props);
return Object.freeze(propsCopy);
}
private checkIfEmpty(props: ValueObjectProps<T>): void {
if (
Guard.isEmpty(props) ||
(this.isDomainPrimitive(props) && Guard.isEmpty(props.value))
) {
throw new ArgumentNotProvidedException('Property cannot be empty');
}
}
private isDomainPrimitive(
obj: unknown,
): obj is DomainPrimitive<T & (Primitives | Date)> {
if (Object.prototype.hasOwnProperty.call(obj, 'value')) {
return true;
}
return false;
}
}

View File

@ -0,0 +1,63 @@
export interface SerializedException {
message: string;
code: string;
correlationId: string;
stack?: string;
cause?: string;
metadata?: unknown;
/**
* ^ Consider adding optional `metadata` object to
* exceptions (if language doesn't support anything
* similar by default) and pass some useful technical
* information about the exception when throwing.
* This will make debugging easier.
*/
}
/**
* Base class for custom exceptions.
*
* @abstract
* @class ExceptionBase
* @extends {Error}
*/
export abstract class ExceptionBase extends Error {
abstract code: string;
public readonly correlationId: string;
/**
* @param {string} message
* @param {ObjectLiteral} [metadata={}]
* **BE CAREFUL** not to include sensitive info in 'metadata'
* to prevent leaks since all exception's data will end up
* in application's log files. Only include non-sensitive
* info that may help with debugging.
*/
constructor(
readonly message: string,
readonly cause?: Error,
readonly metadata?: unknown,
) {
super(message);
Error.captureStackTrace(this, this.constructor);
}
/**
* By default in NodeJS Error objects are not
* serialized properly when sending plain objects
* to external processes. This method is a workaround.
* Keep in mind not to return a stack trace to user when in production.
* https://iaincollins.medium.com/error-handling-in-javascript-a6172ccdf9af
*/
toJSON(): SerializedException {
return {
message: this.message,
code: this.code,
stack: this.stack,
correlationId: this.correlationId,
cause: JSON.stringify(this.cause),
metadata: this.metadata,
};
}
}

View File

@ -0,0 +1,16 @@
/**
* Adding a `code` string with a custom status code for every
* exception is a good practice, since when that exception
* is transferred to another process `instanceof` check
* cannot be performed anymore so a `code` string is used instead.
* code constants can be stored in a separate file so they
* can be shared and reused on a receiving side (code sharing is
* useful when developing fullstack apps or microservices)
*/
export const ARGUMENT_INVALID = 'GENERIC.ARGUMENT_INVALID';
export const ARGUMENT_OUT_OF_RANGE = 'GENERIC.ARGUMENT_OUT_OF_RANGE';
export const ARGUMENT_NOT_PROVIDED = 'GENERIC.ARGUMENT_NOT_PROVIDED';
export const NOT_FOUND = 'GENERIC.NOT_FOUND';
export const CONFLICT = 'GENERIC.CONFLICT';
export const INTERNAL_SERVER_ERROR = 'GENERIC.INTERNAL_SERVER_ERROR';
export const DATABASE_ERROR = 'GENERIC.DATABASE_ERROR';

View File

@ -0,0 +1,99 @@
import {
ARGUMENT_INVALID,
ARGUMENT_NOT_PROVIDED,
ARGUMENT_OUT_OF_RANGE,
CONFLICT,
DATABASE_ERROR,
INTERNAL_SERVER_ERROR,
NOT_FOUND,
} from '.';
import { ExceptionBase } from './exception.base';
/**
* Used to indicate that an incorrect argument was provided to a method/function/class constructor
*
* @class ArgumentInvalidException
* @extends {ExceptionBase}
*/
export class ArgumentInvalidException extends ExceptionBase {
readonly code = ARGUMENT_INVALID;
}
/**
* Used to indicate that an argument was not provided (is empty object/array, null of undefined).
*
* @class ArgumentNotProvidedException
* @extends {ExceptionBase}
*/
export class ArgumentNotProvidedException extends ExceptionBase {
readonly code = ARGUMENT_NOT_PROVIDED;
}
/**
* Used to indicate that an argument is out of allowed range
* (for example: incorrect string/array length, number not in allowed min/max range etc)
*
* @class ArgumentOutOfRangeException
* @extends {ExceptionBase}
*/
export class ArgumentOutOfRangeException extends ExceptionBase {
readonly code = ARGUMENT_OUT_OF_RANGE;
}
/**
* Used to indicate conflicting entities (usually in the database)
*
* @class ConflictException
* @extends {ExceptionBase}
*/
export class ConflictException extends ExceptionBase {
readonly code = CONFLICT;
}
/**
* Used to indicate that entity is not found
*
* @class NotFoundException
* @extends {ExceptionBase}
*/
export class NotFoundException extends ExceptionBase {
static readonly message = 'Not found';
constructor(message = NotFoundException.message) {
super(message);
}
readonly code = NOT_FOUND;
}
/**
* Used to indicate an internal server error that does not fall under all other errors
*
* @class InternalServerErrorException
* @extends {ExceptionBase}
*/
export class InternalServerErrorException extends ExceptionBase {
static readonly message = 'Internal server error';
constructor(message = InternalServerErrorException.message) {
super(message);
}
readonly code = INTERNAL_SERVER_ERROR;
}
/**
* Used to indicate a database error
*
* @class DatabaseErrorException
* @extends {ExceptionBase}
*/
export class DatabaseErrorException extends ExceptionBase {
static readonly message = 'Database error';
constructor(message = DatabaseErrorException.message) {
super(message);
}
readonly code = DATABASE_ERROR;
}

View File

@ -0,0 +1,4 @@
export * from './exception.base';
export * from './exception.codes';
export * from './exceptions';
export * from './rpc-exception.codes.enum';

View File

@ -0,0 +1,19 @@
export enum RpcExceptionCode {
OK = 0,
CANCELLED = 1,
UNKNOWN = 2,
INVALID_ARGUMENT = 3,
DEADLINE_EXCEEDED = 4,
NOT_FOUND = 5,
ALREADY_EXISTS = 6,
PERMISSION_DENIED = 7,
RESOURCE_EXHAUSTED = 8,
FAILED_PRECONDITION = 9,
ABORTED = 10,
OUT_OF_RANGE = 11,
UNIMPLEMENTED = 12,
INTERNAL = 13,
UNAVAILABLE = 14,
DATA_LOSS = 15,
UNAUTHENTICATED = 16,
}

55
src/libs/guard.ts Normal file
View File

@ -0,0 +1,55 @@
export class Guard {
/**
* Checks if value is empty. Accepts strings, numbers, booleans, objects and arrays.
*/
static isEmpty(value: unknown): boolean {
if (typeof value === 'number' || typeof value === 'boolean') {
return false;
}
if (typeof value === 'undefined' || value === null) {
return true;
}
if (value instanceof Date) {
return false;
}
if (value instanceof Object && !Object.keys(value).length) {
return true;
}
if (Array.isArray(value)) {
if (value.length === 0) {
return true;
}
if (value.every((item) => Guard.isEmpty(item))) {
return true;
}
}
if (value === '') {
return true;
}
return false;
}
/**
* Checks length range of a provided number/string/array
*/
static lengthIsBetween(
value: number | string | Array<unknown>,
min: number,
max: number,
): boolean {
if (Guard.isEmpty(value)) {
throw new Error(
'Cannot check length of a value. Provided value is empty',
);
}
const valueLength =
typeof value === 'number'
? Number(value).toString().length
: value.length;
if (valueLength >= min && valueLength <= max) {
return true;
}
return false;
}
}

View File

@ -0,0 +1,6 @@
export interface LoggerPort {
log(message: string, ...meta: unknown[]): void;
error(message: string, trace?: unknown, ...meta: unknown[]): void;
warn(message: string, ...meta: unknown[]): void;
debug(message: string, ...meta: unknown[]): void;
}

View File

@ -1,3 +1,3 @@
export interface IPublishMessage {
export interface MessagePublisherPort {
publish(routingKey: string, message: string): void;
}

View File

@ -0,0 +1,11 @@
export interface PrismaRepositoryPort<Entity> {
findUnique(options: any): Promise<Entity>;
create(entity: any): Promise<Entity>;
}
export interface PrismaRawRepositoryPort {
$queryRaw<T = unknown>(
query: TemplateStringsArray,
...values: any[]
): Promise<T>;
}

View File

@ -0,0 +1,287 @@
import { ResponseBase } from '@libs/api/response.base';
import { PrismaRepositoryBase } from '@libs/db/prisma-repository.base';
import { PrismaService } from '@libs/db/prisma.service';
import { AggregateID, AggregateRoot, Mapper, RepositoryPort } from '@libs/ddd';
import {
ConflictException,
DatabaseErrorException,
NotFoundException,
} from '@libs/exceptions';
import { Injectable, Logger } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { Test, TestingModule } from '@nestjs/testing';
import { Prisma } from '@prisma/client';
import { v4 } from 'uuid';
interface FakeProps {
name: string;
}
interface CreateFakeProps {
name: string;
}
class FakeEntity extends AggregateRoot<FakeProps> {
protected readonly _id: AggregateID;
static create = (create: CreateFakeProps): FakeEntity => {
const id = v4();
const props: FakeProps = { ...create };
const fake = new FakeEntity({ id, props });
return fake;
};
validate(): void {
// not implemented
}
}
type FakeModel = {
uuid: string;
name: string;
createdAt: Date;
updatedAt: Date;
};
type FakeRepositoryPort = RepositoryPort<FakeEntity>;
class FakeResponseDto extends ResponseBase {
name: string;
}
const fakeRecord: FakeModel = {
uuid: 'd567ea3b-4981-43c9-9449-a409b5fa9fed',
name: 'fakeName',
createdAt: new Date('2023-06-28T16:02:00Z'),
updatedAt: new Date('2023-06-28T16:02:00Z'),
};
let recordId = 2;
const recordUuid = 'uuid-';
const recordName = 'fakeName-';
const createRandomRecord = (): FakeModel => {
const fakeRecord: FakeModel = {
uuid: `${recordUuid}${recordId}`,
name: `${recordName}${recordId}`,
createdAt: new Date('2023-06-30T08:00:00Z'),
updatedAt: new Date('2023-06-30T08:00:00Z'),
};
recordId++;
return fakeRecord;
};
const fakeRecords: FakeModel[] = [];
Array.from({ length: 10 }).forEach(() => {
fakeRecords.push(createRandomRecord());
});
@Injectable()
class FakeMapper
implements Mapper<FakeEntity, FakeModel, FakeModel, FakeResponseDto>
{
toPersistence = (entity: FakeEntity): FakeModel => {
const copy = entity.getProps();
const record: FakeModel = {
uuid: copy.id,
name: copy.name,
createdAt: copy.createdAt,
updatedAt: copy.updatedAt,
};
return record;
};
toDomain = (record: FakeModel): FakeEntity => {
const entity = new FakeEntity({
id: record.uuid,
createdAt: new Date(record.createdAt),
updatedAt: new Date(record.updatedAt),
props: {
name: record.name,
},
});
return entity;
};
toResponse = (entity: FakeEntity): FakeResponseDto => {
const props = entity.getProps();
const response = new FakeResponseDto(entity);
response.name = props.name;
return response;
};
}
@Injectable()
class FakePrismaService extends PrismaService {
fake: any;
}
const mockPrismaService = {
$queryRaw: jest
.fn()
.mockImplementationOnce(() => {
return true;
})
.mockImplementation(() => {
throw new Prisma.PrismaClientKnownRequestError('Database unavailable', {
code: 'code',
clientVersion: 'version',
});
})
.mockImplementationOnce(() => {
throw new Error();
}),
fake: {
create: jest
.fn()
.mockResolvedValueOnce(fakeRecord)
.mockImplementationOnce(() => {
throw new Prisma.PrismaClientKnownRequestError('Already exists', {
code: 'code',
clientVersion: 'version',
});
})
.mockImplementationOnce(() => {
throw new Error('An unknown error');
}),
findUnique: jest.fn().mockImplementation(async (params?: any) => {
let record: FakeModel;
if (params?.where?.uuid) {
record = fakeRecords.find(
(record) => record.uuid === params?.where?.uuid,
);
}
if (!record && params?.where?.uuid == 'uuid-triggering-error') {
throw new Prisma.PrismaClientKnownRequestError('unknown request', {
code: 'code',
clientVersion: 'version',
});
}
return record;
}),
},
};
@Injectable()
class FakeRepository
extends PrismaRepositoryBase<FakeEntity, FakeModel, FakeModel>
implements FakeRepositoryPort
{
constructor(
prisma: FakePrismaService,
mapper: FakeMapper,
eventEmitter: EventEmitter2,
) {
super(
prisma.fake,
prisma,
mapper,
eventEmitter,
new Logger(FakeRepository.name),
);
}
}
describe('PrismaRepositoryBase', () => {
let fakeRepository: FakeRepository;
let prisma: FakePrismaService;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
EventEmitter2,
FakeRepository,
FakeMapper,
{
provide: FakePrismaService,
useValue: mockPrismaService,
},
],
}).compile();
fakeRepository = module.get<FakeRepository>(FakeRepository);
prisma = module.get<FakePrismaService>(FakePrismaService);
});
it('should be defined', () => {
expect(fakeRepository).toBeDefined();
expect(prisma).toBeDefined();
});
describe('insert', () => {
it('should create a record', async () => {
jest.spyOn(prisma.fake, 'create');
await fakeRepository.insert(
FakeEntity.create({
name: 'someFakeName',
}),
);
expect(prisma.fake.create).toHaveBeenCalledTimes(1);
});
it('should throw a ConflictException if record already exists', async () => {
await expect(
fakeRepository.insert(
FakeEntity.create({
name: 'someFakeName',
}),
),
).rejects.toBeInstanceOf(ConflictException);
});
it('should throw an Error if an error occurs', async () => {
await expect(
fakeRepository.insert(
FakeEntity.create({
name: 'someFakeName',
}),
),
).rejects.toBeInstanceOf(Error);
});
});
describe('findOneById', () => {
it('should find a record by its id', async () => {
const record = await fakeRepository.findOneById('uuid-3');
expect(record.getProps().name).toBe('fakeName-3');
});
it('should throw an Error for client error', async () => {
await expect(
fakeRepository.findOneById('uuid-triggering-error'),
).rejects.toBeInstanceOf(Error);
});
it('should throw a NotFoundException if id is not found', async () => {
await expect(
fakeRepository.findOneById('wrong-id'),
).rejects.toBeInstanceOf(NotFoundException);
});
});
describe('healthCheck', () => {
it('should return a healthy result', async () => {
const res = await fakeRepository.healthCheck();
expect(res).toBeTruthy();
});
it('should throw an exception if database is not available', async () => {
await expect(fakeRepository.healthCheck()).rejects.toBeInstanceOf(
DatabaseErrorException,
);
});
it('should throw a DatabaseErrorException if an error occurs', async () => {
await expect(fakeRepository.healthCheck()).rejects.toBeInstanceOf(
DatabaseErrorException,
);
});
});
});

View File

@ -0,0 +1,76 @@
import {
AggregateID,
AggregateRoot,
DomainEvent,
DomainEventProps,
} from '@libs/ddd';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { v4 } from 'uuid';
interface FakeProps {
name: string;
}
interface CreateFakeProps {
name: string;
}
class FakeRecordCreatedDomainEvent extends DomainEvent {
readonly name: string;
constructor(props: DomainEventProps<FakeRecordCreatedDomainEvent>) {
super(props);
this.name = props.name;
}
}
class FakeEntity extends AggregateRoot<FakeProps> {
protected readonly _id: AggregateID;
static create = (create: CreateFakeProps): FakeEntity => {
const id = v4();
const props: FakeProps = { ...create };
const fake = new FakeEntity({ id, props });
fake.addEvent(
new FakeRecordCreatedDomainEvent({
aggregateId: id,
name: props.name,
}),
);
return fake;
};
validate(): void {
// not implemented
}
}
const mockLogger = {
debug: jest.fn(),
log: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
};
describe('AggregateRoot Base', () => {
it('should define an aggregate root based object instance', () => {
const fakeInstance = FakeEntity.create({
name: 'someFakeName',
});
expect(fakeInstance).toBeDefined();
expect(fakeInstance.domainEvents.length).toBe(1);
});
it('should publish domain events', async () => {
jest.spyOn(mockLogger, 'debug');
const eventEmitter = new EventEmitter2();
jest.spyOn(eventEmitter, 'emitAsync');
const fakeInstance = FakeEntity.create({
name: 'someFakeName',
});
await fakeInstance.publishEvents(mockLogger, eventEmitter);
expect(mockLogger.debug).toHaveBeenCalledTimes(1);
expect(eventEmitter.emitAsync).toHaveBeenCalledTimes(1);
expect(fakeInstance.domainEvents.length).toBe(0);
});
});

View File

@ -0,0 +1,47 @@
import { Command, CommandProps } from '@libs/ddd';
import { ArgumentNotProvidedException } from '@libs/exceptions';
class FakeCommand extends Command {
readonly name: string;
constructor(props: CommandProps<FakeCommand>) {
super(props);
this.name = props.name;
}
}
class BadFakeCommand extends Command {
constructor(props: CommandProps<BadFakeCommand>) {
super(props);
}
}
describe('Command Base', () => {
it('should define a command based object instance', () => {
const fakeCommand = new FakeCommand({ name: 'fakeName' });
expect(fakeCommand).toBeDefined();
expect(fakeCommand.id.length).toBe(36);
});
it('should define a command based object instance with a provided id', () => {
const fakeCommand = new FakeCommand({ id: 'some-id', name: 'fakeName' });
expect(fakeCommand.id).toBe('some-id');
});
it('should define a command based object instance with metadata', () => {
const fakeCommand = new FakeCommand({
name: 'fakeName',
metadata: {
correlationId: 'some-correlation-id',
causationId: 'some-causation-id',
userId: 'some-user-id',
timestamp: new Date('2023-06-28T05:00:00Z').getTime(),
},
});
expect(fakeCommand.metadata.timestamp).toBe(1687928400000);
});
it('should throw an exception if props are empty', () => {
expect(() => new BadFakeCommand({})).toThrow(ArgumentNotProvidedException);
});
});

View File

@ -0,0 +1,42 @@
import { DomainEvent, DomainEventProps } from '@libs/ddd';
import { ArgumentNotProvidedException } from '@libs/exceptions';
class FakeDomainEvent extends DomainEvent {
readonly name: string;
constructor(props: DomainEventProps<FakeDomainEvent>) {
super(props);
this.name = props.name;
}
}
describe('DomainEvent Base', () => {
it('should define a domain event based object instance', () => {
const fakeDomainEvent = new FakeDomainEvent({
aggregateId: 'some-id',
name: 'some-name',
});
expect(fakeDomainEvent).toBeDefined();
expect(fakeDomainEvent.id.length).toBe(36);
});
it('should define a domain event based object instance with metadata', () => {
const fakeDomainEvent = new FakeDomainEvent({
aggregateId: 'some-id',
name: 'some-name',
metadata: {
correlationId: 'some-correlation-id',
causationId: 'some-causation-id',
userId: 'some-user-id',
timestamp: new Date('2023-06-28T05:00:00Z').getTime(),
},
});
expect(fakeDomainEvent.metadata.timestamp).toBe(1687928400000);
});
it('should throw an exception if props are empty', () => {
const emptyProps: DomainEventProps<FakeDomainEvent> = undefined;
expect(() => new FakeDomainEvent(emptyProps)).toThrow(
ArgumentNotProvidedException,
);
});
});

View File

@ -0,0 +1,209 @@
import { Entity } from '@libs/ddd';
import { ArgumentOutOfRangeException } from '@libs/exceptions';
interface FakeProps {
name: string;
}
class FakeEntity extends Entity<FakeProps> {
protected _id: string;
validate(): void {
// not implemented
}
}
describe('Entity Base', () => {
it('should define an entity based object instance', () => {
const fakeInstance = new FakeEntity({
id: 'some-id',
props: {
name: 'some-name',
},
});
expect(fakeInstance).toBeDefined();
expect(fakeInstance.id).toBe('some-id');
expect(fakeInstance.createdAt).toBeInstanceOf(Date);
expect(fakeInstance.updatedAt).toBeInstanceOf(Date);
expect(FakeEntity.isEntity(fakeInstance)).toBeTruthy();
});
it('should define an entity with given created and updated dates', () => {
const fakeInstance = new FakeEntity({
id: 'some-id',
createdAt: new Date('2023-06-28T05:00:00Z'),
updatedAt: new Date('2023-06-28T06:00:00Z'),
props: {
name: 'some-name',
},
});
expect(fakeInstance.createdAt.getUTCHours()).toBe(5);
expect(fakeInstance.updatedAt.getUTCHours()).toBe(6);
});
it('should compare entities', () => {
const fakeInstance = new FakeEntity({
id: 'some-id',
props: {
name: 'some-name',
},
});
const fakeInstanceClone = new FakeEntity({
id: 'some-id',
props: {
name: 'some-name',
},
});
const fakeInstanceNotReallyClone = new FakeEntity({
id: 'some-slightly-different-id',
props: {
name: 'some-name',
},
});
const undefinedFakeInstance: FakeEntity = undefined;
expect(fakeInstance.equals(undefinedFakeInstance)).toBeFalsy();
expect(fakeInstance.equals(fakeInstance)).toBeTruthy();
expect(fakeInstance.equals(fakeInstanceClone)).toBeTruthy();
expect(fakeInstance.equals(fakeInstanceNotReallyClone)).toBeFalsy();
});
it('should convert entity to plain object', () => {
const fakeInstance = new FakeEntity({
id: 'some-id',
createdAt: new Date('2023-06-28T05:00:00Z'),
updatedAt: new Date('2023-06-28T06:00:00Z'),
props: {
name: 'some-name',
},
});
expect(fakeInstance.toObject()).toEqual({
id: 'some-id',
createdAt: new Date('2023-06-28T05:00:00.000Z'),
updatedAt: new Date('2023-06-28T06:00:00.000Z'),
name: 'some-name',
});
});
it('should throw an exception if props number is too high', () => {
interface BigFakeProps {
prop1: string;
prop2: string;
prop3: string;
prop4: string;
prop5: string;
prop6: string;
prop7: string;
prop8: string;
prop9: string;
prop10: string;
prop11: string;
prop12: string;
prop13: string;
prop14: string;
prop15: string;
prop16: string;
prop17: string;
prop18: string;
prop19: string;
prop20: string;
prop21: string;
prop22: string;
prop23: string;
prop24: string;
prop25: string;
prop26: string;
prop27: string;
prop28: string;
prop29: string;
prop30: string;
prop31: string;
prop32: string;
prop33: string;
prop34: string;
prop35: string;
prop36: string;
prop37: string;
prop38: string;
prop39: string;
prop40: string;
prop41: string;
prop42: string;
prop43: string;
prop44: string;
prop45: string;
prop46: string;
prop47: string;
prop48: string;
prop49: string;
prop50: string;
prop51: string;
}
class BigFakeEntity extends Entity<BigFakeProps> {
protected _id: string;
validate(): void {
// not implemented
}
}
expect(
() =>
new BigFakeEntity({
id: 'some-id',
props: {
prop1: 'some-name',
prop2: 'some-name',
prop3: 'some-name',
prop4: 'some-name',
prop5: 'some-name',
prop6: 'some-name',
prop7: 'some-name',
prop8: 'some-name',
prop9: 'some-name',
prop10: 'some-name',
prop11: 'some-name',
prop12: 'some-name',
prop13: 'some-name',
prop14: 'some-name',
prop15: 'some-name',
prop16: 'some-name',
prop17: 'some-name',
prop18: 'some-name',
prop19: 'some-name',
prop20: 'some-name',
prop21: 'some-name',
prop22: 'some-name',
prop23: 'some-name',
prop24: 'some-name',
prop25: 'some-name',
prop26: 'some-name',
prop27: 'some-name',
prop28: 'some-name',
prop29: 'some-name',
prop30: 'some-name',
prop31: 'some-name',
prop32: 'some-name',
prop33: 'some-name',
prop34: 'some-name',
prop35: 'some-name',
prop36: 'some-name',
prop37: 'some-name',
prop38: 'some-name',
prop39: 'some-name',
prop40: 'some-name',
prop41: 'some-name',
prop42: 'some-name',
prop43: 'some-name',
prop44: 'some-name',
prop45: 'some-name',
prop46: 'some-name',
prop47: 'some-name',
prop48: 'some-name',
prop49: 'some-name',
prop50: 'some-name',
prop51: 'some-name',
},
}),
).toThrow(ArgumentOutOfRangeException);
});
});

View File

@ -0,0 +1,40 @@
import {
PaginatedParams,
PaginatedQueryBase,
QueryBase,
} from '@libs/ddd/query.base';
class FakeQuery extends QueryBase {
readonly id: string;
constructor(id: string) {
super();
this.id = id;
}
}
describe('Query Base', () => {
it('should define a query based object instance', () => {
const fakeQuery = new FakeQuery('some-id');
expect(fakeQuery).toBeDefined();
});
});
class FakePaginatedQuery extends PaginatedQueryBase {
readonly id: string;
constructor(props: PaginatedParams<FakePaginatedQuery>) {
super(props);
this.id = props.id;
}
}
describe('Paginated Query Base', () => {
it('should define a paginated query based object instance', () => {
const fakePaginatedQuery = new FakePaginatedQuery({
id: 'some-id',
page: 1,
});
expect(fakePaginatedQuery).toBeDefined();
});
});

View File

@ -0,0 +1,42 @@
import { ValueObject } from '@libs/ddd';
interface FakeProps {
name: string;
}
class FakeValueObject extends ValueObject<FakeProps> {
get name(): string {
return this.props.name;
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
protected validate(props: FakeProps): void {
return;
}
}
describe('Value Object Base', () => {
it('should create a base value object', () => {
const fakeValueObject = new FakeValueObject({ name: 'fakeName' });
expect(fakeValueObject).toBeDefined();
expect(ValueObject.isValueObject(fakeValueObject)).toBeTruthy();
});
it('should compare value objects', () => {
const fakeValueObject = new FakeValueObject({ name: 'fakeName' });
const fakeValueObjectClone = new FakeValueObject({ name: 'fakeName' });
const undefinedFakeValueObject: FakeValueObject = undefined;
const nullFakeValueObject: FakeValueObject = null;
expect(fakeValueObject.equals(undefinedFakeValueObject)).toBeFalsy();
expect(fakeValueObject.equals(nullFakeValueObject)).toBeFalsy();
expect(fakeValueObject.equals(fakeValueObject)).toBeTruthy();
expect(fakeValueObject.equals(fakeValueObjectClone)).toBeTruthy();
});
it('should unpack value object props', () => {
const fakeValueObject = new FakeValueObject({ name: 'fakeName' });
expect(fakeValueObject.unpack()).toEqual({
name: 'fakeName',
});
});
});

View File

@ -0,0 +1,65 @@
import { Guard } from '@libs/guard';
describe('Guard', () => {
describe('isEmpty', () => {
it('should return false for a number', () => {
expect(Guard.isEmpty(1)).toBeFalsy();
});
it('should return false for a falsy boolean', () => {
expect(Guard.isEmpty(false)).toBeFalsy();
});
it('should return false for a truthy boolean', () => {
expect(Guard.isEmpty(true)).toBeFalsy();
});
it('should return true for undefined', () => {
expect(Guard.isEmpty(undefined)).toBeTruthy();
});
it('should return true for null', () => {
expect(Guard.isEmpty(null)).toBeTruthy();
});
it('should return false for a Date', () => {
expect(Guard.isEmpty(new Date('2023-06-28'))).toBeFalsy();
});
it('should return false for an object with keys', () => {
expect(Guard.isEmpty({ key: 'value' })).toBeFalsy();
});
it('should return true for an object without keys', () => {
expect(Guard.isEmpty({})).toBeTruthy();
});
it('should return true for an array without values', () => {
expect(Guard.isEmpty([])).toBeTruthy();
});
it('should return true for an array with only empty values', () => {
expect(Guard.isEmpty([null, undefined])).toBeTruthy();
});
it('should return false for an array with some empty values', () => {
expect(Guard.isEmpty([1, null, undefined])).toBeFalsy();
});
it('should return true for an empty string', () => {
expect(Guard.isEmpty('')).toBeTruthy();
});
});
describe('lengthIsBetween', () => {
it('should return true for a string in the range', () => {
expect(Guard.lengthIsBetween('test', 0, 4)).toBeTruthy();
});
it('should return true for a number in the range', () => {
expect(Guard.lengthIsBetween(2, 0, 4)).toBeTruthy();
});
it('should return true for an array with number of elements in the range', () => {
expect(Guard.lengthIsBetween([1, 2, 3], 0, 4)).toBeTruthy();
});
it('should return false for a string not in the range', () => {
expect(Guard.lengthIsBetween('test', 5, 9)).toBeFalsy();
});
it('should return false for a number not in the range', () => {
expect(Guard.lengthIsBetween(2, 3, 6)).toBeFalsy();
});
it('should return false for an array with number of elements not in the range', () => {
expect(Guard.lengthIsBetween([1, 2, 3], 10, 12)).toBeFalsy();
});
it('should throw an exception if value is empty', () => {
expect(() => Guard.lengthIsBetween(undefined, 0, 4)).toThrow();
});
});
});

View File

@ -1,6 +1,6 @@
import { ArgumentMetadata } from '@nestjs/common';
import { RpcValidationPipe } from '../../pipes/rpc.validation-pipe';
import { FindAdByUuidRequest } from '../../../modules/ad/domain/dtos/find-ad-by-uuid.request';
import { FindAdByIdRequestDto } from '@modules/ad/interface/grpc-controllers/dtos/find-ad-by-id.request.dto';
import { RpcValidationPipe } from '@libs/utils/pipes/rpc.validation-pipe';
describe('RpcValidationPipe', () => {
it('should not validate request', async () => {
@ -10,10 +10,10 @@ describe('RpcValidationPipe', () => {
});
const metadata: ArgumentMetadata = {
type: 'body',
metatype: FindAdByUuidRequest,
metatype: FindAdByIdRequestDto,
data: '',
};
await target.transform(<FindAdByUuidRequest>{}, metadata).catch((err) => {
await target.transform(<FindAdByIdRequestDto>{}, metadata).catch((err) => {
expect(err.message).toEqual('Rpc Exception');
});
});

6
src/libs/types/index.ts Normal file
View File

@ -0,0 +1,6 @@
/** Consider creating a bunch of shared custom utility
* types for different situations.
* Alternatively you can use a library like
* https://github.com/andnp/SimplyTyped
*/
export * from './object-literal.type';

View File

@ -0,0 +1,6 @@
/**
* Interface of the simple literal object with any string keys.
*/
export interface ObjectLiteral {
[key: string]: unknown;
}

View File

@ -0,0 +1,47 @@
/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types */
import { Entity } from '../ddd/entity.base';
import { ValueObject } from '../ddd/value-object.base';
function isEntity(obj: unknown): obj is Entity<unknown> {
/**
* 'instanceof Entity' causes error here for some reason.
* Probably creates some circular dependency. This is a workaround
* until I find a solution :)
*/
return (
Object.prototype.hasOwnProperty.call(obj, 'toObject') &&
Object.prototype.hasOwnProperty.call(obj, 'id') &&
ValueObject.isValueObject((obj as Entity<unknown>).id)
);
}
function convertToPlainObject(item: any): any {
if (ValueObject.isValueObject(item)) {
return item.unpack();
}
if (isEntity(item)) {
return item.toObject();
}
return item;
}
/**
* Converts Entity/Value Objects props to a plain object.
* Useful for testing and debugging.
* @param props
*/
export function convertPropsToObject(props: any): any {
const propsCopy = structuredClone(props);
// eslint-disable-next-line guard-for-in
for (const prop in propsCopy) {
if (Array.isArray(propsCopy[prop])) {
propsCopy[prop] = (propsCopy[prop] as Array<unknown>).map((item) => {
return convertToPlainObject(item);
});
}
propsCopy[prop] = convertToPlainObject(propsCopy[prop]);
}
return propsCopy;
}

1
src/libs/utils/index.ts Normal file
View File

@ -0,0 +1 @@
export * from './convert-props-to-object.util';

View File

@ -1,3 +1,4 @@
import { RpcExceptionCode } from '@libs/exceptions/rpc-exception.codes.enum';
import { Injectable, ValidationPipe } from '@nestjs/common';
import { RpcException } from '@nestjs/microservices';
@ -6,7 +7,7 @@ export class RpcValidationPipe extends ValidationPipe {
createExceptionFactory() {
return (validationErrors = []) => {
return new RpcException({
code: 3,
code: RpcExceptionCode.INVALID_ARGUMENT,
message: this.flattenValidationErrors(validationErrors),
});
};

View File

@ -13,10 +13,13 @@ async function bootstrap() {
options: {
package: ['ad', 'health'],
protoPath: [
join(__dirname, 'modules/ad/adapters/primaries/ad.proto'),
join(__dirname, 'modules/health/adapters/primaries/health.proto'),
join(__dirname, 'modules/ad/interface/grpc-controllers/ad.proto'),
join(
__dirname,
'modules/health/interface/grpc-controllers/health.proto',
),
],
url: process.env.SERVICE_URL + ':' + process.env.SERVICE_PORT,
url: `${process.env.SERVICE_URL}:${process.env.SERVICE_PORT}`,
loader: { keepCase: true },
},
});

View File

@ -1 +0,0 @@
export const PARAMS_PROVIDER = Symbol();

View File

@ -0,0 +1,4 @@
export const PARAMS_PROVIDER = Symbol('PARAMS_PROVIDER');
export const TIMEZONE_FINDER = Symbol('TIMEZONE_FINDER');
export const TIME_CONVERTER = Symbol('TIME_CONVERTER');
export const AD_REPOSITORY = Symbol('AD_REPOSITORY');

247
src/modules/ad/ad.mapper.ts Normal file
View File

@ -0,0 +1,247 @@
import { Mapper } from '@libs/ddd';
import { AdResponseDto } from './interface/dtos/ad.response.dto';
import { Inject, Injectable } from '@nestjs/common';
import { AdEntity } from './core/ad.entity';
import {
AdWriteModel,
AdReadModel,
WaypointModel,
} from './infrastructure/ad.repository';
import { Frequency } from './core/ad.types';
import { WaypointProps } from './core/value-objects/waypoint.value-object';
import { v4 } from 'uuid';
import {
PARAMS_PROVIDER,
TIMEZONE_FINDER,
TIME_CONVERTER,
} from './ad.di-tokens';
import { TimezoneFinderPort } from './core/ports/timezone-finder.port';
import { DefaultParamsProviderPort } from './core/ports/default-params-provider.port';
import { DefaultParams } from './core/ports/default-params.type';
import { TimeConverterPort } from './core/ports/time-converter.port';
/**
* Mapper constructs objects that are used in different layers:
* Record is an object that is stored in a database,
* Entity is an object that is used in application domain layer,
* and a ResponseDTO is an object returned to a user (usually as json).
*/
@Injectable()
export class AdMapper
implements Mapper<AdEntity, AdReadModel, AdWriteModel, AdResponseDto>
{
private readonly _defaultParams: DefaultParams;
constructor(
@Inject(PARAMS_PROVIDER)
private readonly defaultParamsProvider: DefaultParamsProviderPort,
@Inject(TIMEZONE_FINDER)
private readonly timezoneFinder: TimezoneFinderPort,
@Inject(TIME_CONVERTER)
private readonly timeConverter: TimeConverterPort,
) {
this._defaultParams = defaultParamsProvider.getParams();
}
toPersistence = (entity: AdEntity): AdWriteModel => {
const copy = entity.getProps();
const { lon, lat } = copy.waypoints[0].address.coordinates;
const timezone = this.timezoneFinder.timezones(
lon,
lat,
this._defaultParams.DEFAULT_TIMEZONE,
)[0];
const now = new Date();
const record: AdWriteModel = {
uuid: copy.id,
userUuid: copy.userId,
driver: copy.driver,
passenger: copy.passenger,
frequency: copy.frequency,
fromDate: new Date(copy.fromDate),
toDate: new Date(copy.toDate),
monTime: copy.schedule.mon
? this.timeConverter.localDateTimeToUtc(
copy.fromDate,
copy.schedule.mon,
timezone,
)
: undefined,
tueTime: copy.schedule.tue
? this.timeConverter.localDateTimeToUtc(
copy.fromDate,
copy.schedule.tue,
timezone,
)
: undefined,
wedTime: copy.schedule.wed
? this.timeConverter.localDateTimeToUtc(
copy.fromDate,
copy.schedule.wed,
timezone,
)
: undefined,
thuTime: copy.schedule.thu
? this.timeConverter.localDateTimeToUtc(
copy.fromDate,
copy.schedule.thu,
timezone,
)
: undefined,
friTime: copy.schedule.fri
? this.timeConverter.localDateTimeToUtc(
copy.fromDate,
copy.schedule.fri,
timezone,
)
: undefined,
satTime: copy.schedule.sat
? this.timeConverter.localDateTimeToUtc(
copy.fromDate,
copy.schedule.sat,
timezone,
)
: undefined,
sunTime: copy.schedule.sun
? this.timeConverter.localDateTimeToUtc(
copy.fromDate,
copy.schedule.sun,
timezone,
)
: undefined,
monMargin: copy.marginDurations.mon,
tueMargin: copy.marginDurations.tue,
wedMargin: copy.marginDurations.wed,
thuMargin: copy.marginDurations.thu,
friMargin: copy.marginDurations.fri,
satMargin: copy.marginDurations.sat,
sunMargin: copy.marginDurations.sun,
seatsProposed: copy.seatsProposed,
seatsRequested: copy.seatsRequested,
strict: copy.strict,
waypoints: {
create: copy.waypoints.map((waypoint: WaypointProps) => ({
uuid: v4(),
position: waypoint.position,
name: waypoint.address.name,
houseNumber: waypoint.address.houseNumber,
street: waypoint.address.street,
locality: waypoint.address.locality,
postalCode: waypoint.address.postalCode,
country: waypoint.address.country,
lon: waypoint.address.coordinates.lon,
lat: waypoint.address.coordinates.lat,
createdAt: now,
updatedAt: now,
})),
},
createdAt: copy.createdAt,
updatedAt: copy.updatedAt,
};
return record;
};
toDomain = (record: AdReadModel): AdEntity => {
const timezone = this.timezoneFinder.timezones(
record.waypoints[0].lon,
record.waypoints[0].lat,
this._defaultParams.DEFAULT_TIMEZONE,
)[0];
const entity = new AdEntity({
id: record.uuid,
createdAt: new Date(record.createdAt),
updatedAt: new Date(record.updatedAt),
props: {
userId: record.userUuid,
driver: record.driver,
passenger: record.passenger,
frequency: Frequency[record.frequency],
fromDate: record.fromDate.toISOString().split('T')[0],
toDate: record.toDate.toISOString().split('T')[0],
schedule: {
mon: record.monTime?.toISOString(),
tue: record.tueTime?.toISOString(),
wed: record.wedTime
? this.timeConverter.utcDatetimeToLocalTime(
record.wedTime.toISOString(),
timezone,
)
: undefined,
thu: record.thuTime
? this.timeConverter.utcDatetimeToLocalTime(
record.thuTime.toISOString(),
timezone,
)
: undefined,
fri: record.friTime?.toISOString(),
sat: record.satTime?.toISOString(),
sun: record.sunTime?.toISOString(),
},
marginDurations: {
mon: record.monMargin,
tue: record.tueMargin,
wed: record.wedMargin,
thu: record.thuMargin,
fri: record.friMargin,
sat: record.satMargin,
sun: record.sunMargin,
},
seatsProposed: record.seatsProposed,
seatsRequested: record.seatsRequested,
strict: record.strict,
waypoints: record.waypoints.map((waypoint: WaypointModel) => ({
position: waypoint.position,
address: {
name: waypoint.name,
houseNumber: waypoint.houseNumber,
street: waypoint.street,
postalCode: waypoint.postalCode,
locality: waypoint.locality,
country: waypoint.country,
coordinates: {
lon: waypoint.lon,
lat: waypoint.lat,
},
},
})),
},
});
return entity;
};
toResponse = (entity: AdEntity): AdResponseDto => {
const props = entity.getProps();
const response = new AdResponseDto(entity);
response.userId = props.userId;
response.driver = props.driver;
response.passenger = props.passenger;
response.frequency = props.frequency;
response.fromDate = props.fromDate;
response.toDate = props.toDate;
response.schedule = { ...props.schedule };
response.marginDurations = { ...props.marginDurations };
response.seatsProposed = props.seatsProposed;
response.seatsRequested = props.seatsRequested;
response.waypoints = props.waypoints.map((waypoint: WaypointProps) => ({
position: waypoint.position,
name: waypoint.address.name,
houseNumber: waypoint.address.houseNumber,
street: waypoint.address.street,
postalCode: waypoint.address.postalCode,
locality: waypoint.address.locality,
country: waypoint.address.country,
lon: waypoint.address.coordinates.lon,
lat: waypoint.address.coordinates.lat,
}));
return response;
};
/* ^ Data returned to the user is whitelisted to avoid leaks.
If a new property is added, like password or a
credit card number, it won't be returned
unless you specifically allow this.
(avoid blacklisting, which will return everything
but blacklisted items, which can lead to a data leak).
*/
}

View File

@ -1,29 +1,51 @@
import { Module } from '@nestjs/common';
import { AdController } from './adapters/primaries/ad.controller';
import { DatabaseModule } from '../database/database.module';
import { Module, Provider } from '@nestjs/common';
import { CreateAdGrpcController } from './interface/grpc-controllers/create-ad.grpc.controller';
import { CqrsModule } from '@nestjs/cqrs';
import { AdProfile } from './mappers/ad.profile';
import { AdsRepository } from './adapters/secondaries/ads.repository';
import { FindAdByUuidUseCase } from './domain/usecases/find-ad-by-uuid.usecase';
import { CreateAdUseCase } from './domain/usecases/create-ad.usecase';
import { DefaultParamsProvider } from './adapters/secondaries/default-params.provider';
import { PARAMS_PROVIDER } from './ad.constants';
import { MESSAGE_BROKER_PUBLISHER, MESSAGE_PUBLISHER } from 'src/app.constants';
import {
AD_REPOSITORY,
PARAMS_PROVIDER,
TIMEZONE_FINDER,
TIME_CONVERTER,
} from './ad.di-tokens';
import {
MESSAGE_BROKER_PUBLISHER,
MESSAGE_PUBLISHER,
} from '@src/app.constants';
import { MessageBrokerPublisher } from '@mobicoop/message-broker-module';
import { MessagePublisher } from './adapters/secondaries/message-publisher';
import { AdRepository } from './infrastructure/ad.repository';
import { DefaultParamsProvider } from './infrastructure/default-params-provider';
import { MessagePublisher } from './infrastructure/message-publisher';
import { AdMapper } from './ad.mapper';
import { CreateAdService } from './core/commands/create-ad/create-ad.service';
import { TimezoneFinder } from './infrastructure/timezone-finder';
import { PrismaService } from '@libs/db/prisma.service';
import { TimeConverter } from './infrastructure/time-converter';
import { FindAdByIdGrpcController } from './interface/grpc-controllers/find-ad-by-id.grpc.controller';
import { FindAdByIdQueryHandler } from './core/queries/find-ad-by-id/find-ad-by-id.query-handler';
import { PublishMessageWhenAdIsCreatedDomainEventHandler } from './core/event-handlers/publish-message-when-ad-is-created.domain-event-handler';
import { PublishLogMessageWhenAdIsCreatedDomainEventHandler } from './core/event-handlers/publish-log-message-when-ad-is-created.domain-event-handler';
@Module({
imports: [DatabaseModule, CqrsModule],
controllers: [AdController],
providers: [
AdProfile,
AdsRepository,
FindAdByUuidUseCase,
CreateAdUseCase,
const grpcControllers = [CreateAdGrpcController, FindAdByIdGrpcController];
const eventHandlers: Provider[] = [
PublishMessageWhenAdIsCreatedDomainEventHandler,
PublishLogMessageWhenAdIsCreatedDomainEventHandler,
];
const commandHandlers: Provider[] = [CreateAdService];
const queryHandlers: Provider[] = [FindAdByIdQueryHandler];
const mappers: Provider[] = [AdMapper];
const repositories: Provider[] = [
{
provide: PARAMS_PROVIDER,
useClass: DefaultParamsProvider,
provide: AD_REPOSITORY,
useClass: AdRepository,
},
];
const messageBrokers: Provider[] = [
{
provide: MESSAGE_BROKER_PUBLISHER,
useClass: MessageBrokerPublisher,
@ -32,6 +54,44 @@ import { MessagePublisher } from './adapters/secondaries/message-publisher';
provide: MESSAGE_PUBLISHER,
useClass: MessagePublisher,
},
];
const orms: Provider[] = [PrismaService];
const utilities: Provider[] = [
{
provide: PARAMS_PROVIDER,
useClass: DefaultParamsProvider,
},
{
provide: TIMEZONE_FINDER,
useClass: TimezoneFinder,
},
{
provide: TIME_CONVERTER,
useClass: TimeConverter,
},
];
@Module({
imports: [CqrsModule],
controllers: [...grpcControllers],
providers: [
...eventHandlers,
...commandHandlers,
...queryHandlers,
...mappers,
...repositories,
...messageBrokers,
...orms,
...utilities,
],
exports: [
PrismaService,
AdMapper,
AD_REPOSITORY,
PARAMS_PROVIDER,
TIMEZONE_FINDER,
],
})
export class AdModule {}

View File

@ -1,59 +0,0 @@
import { Mapper } from '@automapper/core';
import { InjectMapper } from '@automapper/nestjs';
import { Controller, UsePipes } from '@nestjs/common';
import { CommandBus, QueryBus } from '@nestjs/cqrs';
import { GrpcMethod, RpcException } from '@nestjs/microservices';
import { RpcValidationPipe } from '../../../../utils/pipes/rpc.validation-pipe';
import { FindAdByUuidRequest } from '../../domain/dtos/find-ad-by-uuid.request';
import { AdPresenter } from './ad.presenter';
import { FindAdByUuidQuery } from '../../queries/find-ad-by-uuid.query';
import { Ad } from '../../domain/entities/ad';
import { CreateAdRequest } from '../../domain/dtos/create-ad.request';
import { CreateAdCommand } from '../../commands/create-ad.command';
import { DatabaseException } from '../../../database/exceptions/database.exception';
@UsePipes(
new RpcValidationPipe({
whitelist: false,
forbidUnknownValues: false,
}),
)
@Controller()
export class AdController {
constructor(
private readonly commandBus: CommandBus,
private readonly queryBus: QueryBus,
@InjectMapper() private readonly mapper: Mapper,
) {}
@GrpcMethod('AdsService', 'FindOneByUuid')
async findOnebyUuid(data: FindAdByUuidRequest): Promise<AdPresenter> {
try {
const ad = await this.queryBus.execute(new FindAdByUuidQuery(data));
return this.mapper.map(ad, Ad, AdPresenter);
} catch (e) {
throw new RpcException({
code: e.code,
message: e.message,
});
}
}
@GrpcMethod('AdsService', 'Create')
async createAd(data: CreateAdRequest): Promise<AdPresenter> {
try {
const ad = await this.commandBus.execute(new CreateAdCommand(data));
return this.mapper.map(ad, Ad, AdPresenter);
} catch (e) {
if (e instanceof DatabaseException) {
if (e.message.includes('Already exists')) {
throw new RpcException({
code: 6,
message: 'Ad already exists',
});
}
}
throw new RpcException({});
}
}
}

View File

@ -1,6 +0,0 @@
import { AutoMap } from '@automapper/classes';
export class AdPresenter {
@AutoMap()
uuid: string;
}

View File

@ -1,83 +0,0 @@
syntax = "proto3";
package ad;
service AdsService {
rpc FindOneByUuid(AdByUuid) returns (Ad);
rpc FindAll(AdFilter) returns (Ads);
rpc Create(Ad) returns (AdByUuid);
rpc Update(Ad) returns (Ad);
rpc Delete(AdByUuid) returns (Empty);
}
message AdByUuid {
string uuid = 1;
}
message Ad {
string uuid = 1;
string userUuid = 2;
bool driver = 3;
bool passenger = 4;
Frequency frequency = 5;
optional string departureDateTime = 6;
string fromDate = 7;
string toDate = 8;
Schedule schedule = 9;
MarginDurations marginDurations = 10;
int32 seatsPassenger = 11;
int32 seatsDriver = 12;
bool strict = 13;
repeated Address addresses = 14;
}
message Schedule {
optional string mon = 1;
optional string tue = 2;
optional string wed = 3;
optional string thu = 4;
optional string fri = 5;
optional string sat = 6;
optional string sun = 7;
}
message MarginDurations {
int32 mon = 1;
int32 tue = 2;
int32 wed = 3;
int32 thu = 4;
int32 fri = 5;
int32 sat = 6;
int32 sun = 7;
}
message Address {
string uuid = 1;
int32 position = 2;
float lon = 3;
float lat = 4;
optional string name = 5;
optional string houseNumber = 6;
optional string street = 7;
optional string locality = 8;
optional string postalCode = 9;
string country = 10;
}
enum Frequency {
PUNCTUAL = 1;
RECURRENT = 2;
}
message AdFilter {
int32 page = 1;
int32 perPage = 2;
}
message Ads {
repeated Ad data = 1;
int32 total = 2;
}
message Empty {}

View File

@ -1,8 +0,0 @@
import { Injectable } from '@nestjs/common';
import { AdRepository } from '../../../database/domain/ad-repository';
import { Ad } from '../../domain/entities/ad';
//TODO : properly implement mutate operation to prisma
@Injectable()
export class AdsRepository extends AdRepository<Ad> {
protected model = 'ad';
}

View File

@ -1,23 +0,0 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { DefaultParams } from '../../domain/types/default-params.type';
import { IProvideParams } from '../../domain/interfaces/param-provider.interface';
@Injectable()
export class DefaultParamsProvider implements IProvideParams {
constructor(private readonly configService: ConfigService) {}
getParams = (): DefaultParams => ({
MON_MARGIN: parseInt(this.configService.get('DEPARTURE_MARGIN')),
TUE_MARGIN: parseInt(this.configService.get('DEPARTURE_MARGIN')),
WED_MARGIN: parseInt(this.configService.get('DEPARTURE_MARGIN')),
THU_MARGIN: parseInt(this.configService.get('DEPARTURE_MARGIN')),
FRI_MARGIN: parseInt(this.configService.get('DEPARTURE_MARGIN')),
SAT_MARGIN: parseInt(this.configService.get('DEPARTURE_MARGIN')),
SUN_MARGIN: parseInt(this.configService.get('DEPARTURE_MARGIN')),
DRIVER: this.configService.get('ROLE') == 'driver',
SEATS_PROVIDED: parseInt(this.configService.get('SEATS_PROVIDED')),
PASSENGER: this.configService.get('ROLE') == 'passenger',
SEATS_REQUESTED: parseInt(this.configService.get('SEATS_REQUESTED')),
STRICT: this.configService.get('STRICT_FREQUENCY') == 'true',
});
}

View File

@ -1,9 +0,0 @@
import { CreateAdRequest } from '../domain/dtos/create-ad.request';
export class CreateAdCommand {
readonly createAdRequest: CreateAdRequest;
constructor(request: CreateAdRequest) {
this.createAdRequest = request;
}
}

View File

@ -0,0 +1,137 @@
import { AggregateRoot, AggregateID } from '@libs/ddd';
import { v4 } from 'uuid';
import { AdCreatedDomainEvent } from './events/ad-created.domain-events';
import { AdProps, CreateAdProps, DefaultAdProps } from './ad.types';
import { Waypoint } from './value-objects/waypoint.value-object';
import { MarginDurationsProps } from './value-objects/margin-durations.value-object';
export class AdEntity extends AggregateRoot<AdProps> {
protected readonly _id: AggregateID;
static create = (
create: CreateAdProps,
defaultAdProps: DefaultAdProps,
): AdEntity => {
const id = v4();
const props: AdProps = { ...create };
const ad = new AdEntity({ id, props })
.setMissingMarginDurations(defaultAdProps.marginDurations)
.setMissingStrict(defaultAdProps.strict)
.setDefaultDriverAndPassengerParameters({
driver: defaultAdProps.driver,
passenger: defaultAdProps.passenger,
seatsProposed: defaultAdProps.seatsProposed,
seatsRequested: defaultAdProps.seatsRequested,
})
.setMissingWaypointsPosition();
ad.addEvent(
new AdCreatedDomainEvent({
aggregateId: id,
userId: props.userId,
driver: props.driver,
passenger: props.passenger,
frequency: props.frequency,
fromDate: props.fromDate,
toDate: props.toDate,
monTime: props.schedule.mon,
tueTime: props.schedule.tue,
wedTime: props.schedule.wed,
thuTime: props.schedule.thu,
friTime: props.schedule.fri,
satTime: props.schedule.sat,
sunTime: props.schedule.sun,
monMarginDuration: props.marginDurations.mon,
tueMarginDuration: props.marginDurations.tue,
wedMarginDuration: props.marginDurations.wed,
thuMarginDuration: props.marginDurations.thu,
friMarginDuration: props.marginDurations.fri,
satMarginDuration: props.marginDurations.sat,
sunMarginDuration: props.marginDurations.sun,
seatsProposed: props.seatsProposed,
seatsRequested: props.seatsRequested,
strict: props.strict,
waypoints: props.waypoints.map((waypoint: Waypoint) => ({
position: waypoint.position,
name: waypoint.address.name,
houseNumber: waypoint.address.houseNumber,
street: waypoint.address.street,
postalCode: waypoint.address.postalCode,
locality: waypoint.address.locality,
country: waypoint.address.postalCode,
lon: waypoint.address.coordinates.lon,
lat: waypoint.address.coordinates.lat,
})),
}),
);
return ad;
};
private setMissingMarginDurations = (
defaultMarginDurations: MarginDurationsProps,
): AdEntity => {
if (!this.props.marginDurations) this.props.marginDurations = {};
if (!this.props.marginDurations.mon)
this.props.marginDurations.mon = defaultMarginDurations.mon;
if (!this.props.marginDurations.tue)
this.props.marginDurations.tue = defaultMarginDurations.tue;
if (!this.props.marginDurations.wed)
this.props.marginDurations.wed = defaultMarginDurations.wed;
if (!this.props.marginDurations.thu)
this.props.marginDurations.thu = defaultMarginDurations.thu;
if (!this.props.marginDurations.fri)
this.props.marginDurations.fri = defaultMarginDurations.fri;
if (!this.props.marginDurations.sat)
this.props.marginDurations.sat = defaultMarginDurations.sat;
if (!this.props.marginDurations.sun)
this.props.marginDurations.sun = defaultMarginDurations.sun;
return this;
};
private setMissingStrict = (strict: boolean): AdEntity => {
if (this.props.strict === undefined) this.props.strict = strict;
return this;
};
private setDefaultDriverAndPassengerParameters = (
defaultDriverAndPassengerParameters: DefaultDriverAndPassengerParameters,
): AdEntity => {
this.props.driver = !!this.props.driver;
this.props.passenger = !!this.props.passenger;
if (!this.props.driver && !this.props.passenger) {
this.props.driver = defaultDriverAndPassengerParameters.driver;
this.props.seatsProposed =
defaultDriverAndPassengerParameters.seatsProposed;
this.props.passenger = defaultDriverAndPassengerParameters.passenger;
this.props.seatsRequested =
defaultDriverAndPassengerParameters.seatsRequested;
return this;
}
if (!this.props.seatsProposed || this.props.seatsProposed <= 0)
this.props.seatsProposed =
defaultDriverAndPassengerParameters.seatsProposed;
if (!this.props.seatsRequested || this.props.seatsRequested <= 0)
this.props.seatsRequested =
defaultDriverAndPassengerParameters.seatsRequested;
return this;
};
private setMissingWaypointsPosition = (): AdEntity => {
if (this.props.waypoints[0].position === undefined) {
for (let i = 0; i < this.props.waypoints.length; i++) {
this.props.waypoints[i].position = i;
}
}
return this;
};
validate(): void {
// entity business rules validation to protect it's invariant before saving entity to a database
}
}
interface DefaultDriverAndPassengerParameters {
driver: boolean;
passenger: boolean;
seatsProposed: number;
seatsRequested: number;
}

View File

@ -0,0 +1,11 @@
import { ExceptionBase } from '@libs/exceptions';
export class AdAlreadyExistsException extends ExceptionBase {
static readonly message = 'Ad already exists';
public readonly code = 'AD.ALREADY_EXISTS';
constructor(cause?: Error, metadata?: unknown) {
super(AdAlreadyExistsException.message, cause, metadata);
}
}

View File

@ -0,0 +1,49 @@
import { MarginDurationsProps } from './value-objects/margin-durations.value-object';
import { ScheduleProps } from './value-objects/schedule.value-object';
import { WaypointProps } from './value-objects/waypoint.value-object';
// All properties that an Ad has
export interface AdProps {
userId: string;
driver: boolean;
passenger: boolean;
frequency: Frequency;
fromDate: string;
toDate: string;
schedule: ScheduleProps;
marginDurations: MarginDurationsProps;
seatsProposed: number;
seatsRequested: number;
strict: boolean;
waypoints: WaypointProps[];
}
// Properties that are needed for an Ad creation
export interface CreateAdProps {
userId: string;
driver: boolean;
passenger: boolean;
frequency: Frequency;
fromDate: string;
toDate: string;
schedule: ScheduleProps;
marginDurations: MarginDurationsProps;
seatsProposed: number;
seatsRequested: number;
strict: boolean;
waypoints: WaypointProps[];
}
export interface DefaultAdProps {
driver: boolean;
passenger: boolean;
marginDurations: MarginDurationsProps;
strict: boolean;
seatsProposed: number;
seatsRequested: number;
}
export enum Frequency {
PUNCTUAL = 'PUNCTUAL',
RECURRENT = 'RECURRENT',
}

View File

@ -0,0 +1,36 @@
import { Command, CommandProps } from '@libs/ddd';
import { Frequency } from '@modules/ad/core/ad.types';
import { Schedule } from '../../types/schedule';
import { MarginDurations } from '../../types/margin-durations';
import { Waypoint } from '../../types/waypoint';
export class CreateAdCommand extends Command {
readonly userId: string;
readonly driver?: boolean;
readonly passenger?: boolean;
readonly frequency?: Frequency;
readonly fromDate: string;
readonly toDate: string;
readonly schedule: Schedule;
readonly marginDurations?: MarginDurations;
readonly seatsProposed?: number;
readonly seatsRequested?: number;
readonly strict?: boolean;
readonly waypoints: Waypoint[];
constructor(props: CommandProps<CreateAdCommand>) {
super(props);
this.userId = props.userId;
this.driver = props.driver;
this.passenger = props.passenger;
this.frequency = props.frequency;
this.fromDate = props.fromDate;
this.toDate = props.toDate;
this.schedule = props.schedule;
this.marginDurations = props.marginDurations;
this.seatsProposed = props.seatsProposed;
this.seatsRequested = props.seatsRequested;
this.strict = props.strict;
this.waypoints = props.waypoints;
}
}

View File

@ -0,0 +1,85 @@
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { CreateAdCommand } from './create-ad.command';
import { DefaultParams } from '@modules/ad/core/ports/default-params.type';
import { Inject } from '@nestjs/common';
import { AD_REPOSITORY, PARAMS_PROVIDER } from '@modules/ad/ad.di-tokens';
import { AdRepositoryPort } from '@modules/ad/core/ports/ad.repository.port';
import { DefaultParamsProviderPort } from '@modules/ad/core/ports/default-params-provider.port';
import { AggregateID } from '@libs/ddd';
import { AdAlreadyExistsException } from '@modules/ad/core/ad.errors';
import { AdEntity } from '@modules/ad/core/ad.entity';
import { ConflictException } from '@libs/exceptions';
import { Waypoint } from '../../types/waypoint';
@CommandHandler(CreateAdCommand)
export class CreateAdService implements ICommandHandler {
private readonly _defaultParams: DefaultParams;
constructor(
@Inject(AD_REPOSITORY)
private readonly repository: AdRepositoryPort,
@Inject(PARAMS_PROVIDER)
private readonly defaultParamsProvider: DefaultParamsProviderPort,
) {
this._defaultParams = defaultParamsProvider.getParams();
}
async execute(command: CreateAdCommand): Promise<AggregateID> {
const ad = AdEntity.create(
{
userId: command.userId,
driver: command.driver,
passenger: command.passenger,
frequency: command.frequency,
fromDate: command.fromDate,
toDate: command.toDate,
schedule: command.schedule,
marginDurations: command.marginDurations,
seatsProposed: command.seatsProposed,
seatsRequested: command.seatsRequested,
strict: command.strict,
waypoints: command.waypoints.map((waypoint: Waypoint) => ({
position: waypoint.position,
address: {
name: waypoint.name,
houseNumber: waypoint.houseNumber,
street: waypoint.street,
postalCode: waypoint.postalCode,
locality: waypoint.locality,
country: waypoint.country,
coordinates: {
lon: waypoint.lon,
lat: waypoint.lat,
},
},
})),
},
{
driver: this._defaultParams.DRIVER,
passenger: this._defaultParams.PASSENGER,
marginDurations: {
mon: this._defaultParams.MON_MARGIN,
tue: this._defaultParams.TUE_MARGIN,
wed: this._defaultParams.WED_MARGIN,
thu: this._defaultParams.THU_MARGIN,
fri: this._defaultParams.FRI_MARGIN,
sat: this._defaultParams.SAT_MARGIN,
sun: this._defaultParams.SUN_MARGIN,
},
strict: this._defaultParams.STRICT,
seatsProposed: this._defaultParams.SEATS_PROPOSED,
seatsRequested: this._defaultParams.SEATS_REQUESTED,
},
);
try {
await this.repository.insert(ad);
return ad.id;
} catch (error: any) {
if (error instanceof ConflictException) {
throw new AdAlreadyExistsException(error);
}
throw error;
}
}
}

View File

@ -0,0 +1,21 @@
import { MessagePublisherPort } from '@libs/ports/message-publisher.port';
import { Inject, Injectable } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { MESSAGE_PUBLISHER } from '@src/app.constants';
import { AdCreatedDomainEvent } from '../events/ad-created.domain-events';
@Injectable()
export class PublishLogMessageWhenAdIsCreatedDomainEventHandler {
constructor(
@Inject(MESSAGE_PUBLISHER)
private readonly messagePublisher: MessagePublisherPort,
) {}
@OnEvent(AdCreatedDomainEvent.name, { async: true, promisify: true })
async handle(event: AdCreatedDomainEvent): Promise<any> {
this.messagePublisher.publish(
'logging.ad.created.info',
JSON.stringify(event),
);
}
}

View File

@ -0,0 +1,18 @@
import { MessagePublisherPort } from '@libs/ports/message-publisher.port';
import { Inject, Injectable } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { MESSAGE_PUBLISHER } from '@src/app.constants';
import { AdCreatedDomainEvent } from '../events/ad-created.domain-events';
@Injectable()
export class PublishMessageWhenAdIsCreatedDomainEventHandler {
constructor(
@Inject(MESSAGE_PUBLISHER)
private readonly messagePublisher: MessagePublisherPort,
) {}
@OnEvent(AdCreatedDomainEvent.name, { async: true, promisify: true })
async handle(event: AdCreatedDomainEvent): Promise<any> {
this.messagePublisher.publish('ad.created', JSON.stringify(event));
}
}

View File

@ -0,0 +1,68 @@
import { DomainEvent, DomainEventProps } from '@libs/ddd';
export class AdCreatedDomainEvent extends DomainEvent {
readonly userId: string;
readonly driver: boolean;
readonly passenger: boolean;
readonly frequency: string;
readonly fromDate: string;
readonly toDate: string;
readonly monTime: string;
readonly tueTime: string;
readonly wedTime: string;
readonly thuTime: string;
readonly friTime: string;
readonly satTime: string;
readonly sunTime: string;
readonly monMarginDuration: number;
readonly tueMarginDuration: number;
readonly wedMarginDuration: number;
readonly thuMarginDuration: number;
readonly friMarginDuration: number;
readonly satMarginDuration: number;
readonly sunMarginDuration: number;
readonly seatsProposed: number;
readonly seatsRequested: number;
readonly strict: boolean;
readonly waypoints: Waypoint[];
constructor(props: DomainEventProps<AdCreatedDomainEvent>) {
super(props);
this.userId = props.userId;
this.driver = props.driver;
this.passenger = props.passenger;
this.frequency = props.frequency;
this.fromDate = props.fromDate;
this.toDate = props.toDate;
this.monTime = props.monTime;
this.tueTime = props.tueTime;
this.wedTime = props.wedTime;
this.thuTime = props.thuTime;
this.friTime = props.friTime;
this.satTime = props.satTime;
this.sunTime = props.sunTime;
this.monMarginDuration = props.monMarginDuration;
this.tueMarginDuration = props.tueMarginDuration;
this.wedMarginDuration = props.wedMarginDuration;
this.thuMarginDuration = props.thuMarginDuration;
this.friMarginDuration = props.friMarginDuration;
this.satMarginDuration = props.satMarginDuration;
this.sunMarginDuration = props.sunMarginDuration;
this.seatsProposed = props.seatsProposed;
this.seatsRequested = props.seatsRequested;
this.strict = props.strict;
this.waypoints = props.waypoints;
}
}
export class Waypoint {
position: number;
name?: string;
houseNumber?: string;
street?: string;
locality?: string;
postalCode?: string;
country: string;
lon: number;
lat: number;
}

View File

@ -0,0 +1,4 @@
import { RepositoryPort } from '@libs/ddd';
import { AdEntity } from '../ad.entity';
export type AdRepositoryPort = RepositoryPort<AdEntity>;

View File

@ -0,0 +1,5 @@
import { DefaultParams } from './default-params.type';
export interface DefaultParamsProviderPort {
getParams(): DefaultParams;
}

View File

@ -7,8 +7,9 @@ export type DefaultParams = {
SAT_MARGIN: number;
SUN_MARGIN: number;
DRIVER: boolean;
SEATS_PROVIDED: number;
SEATS_PROPOSED: number;
PASSENGER: boolean;
SEATS_REQUESTED: number;
STRICT: boolean;
DEFAULT_TIMEZONE: string;
};

View File

@ -0,0 +1,9 @@
export interface TimeConverterPort {
localDateTimeToUtc(
date: string,
time: string,
timezone: string,
dst?: boolean,
): Date;
utcDatetimeToLocalTime(isoString: string, timezone: string): string;
}

View File

@ -0,0 +1,3 @@
export interface TimezoneFinderPort {
timezones(lon: number, lat: number, defaultTimezone?: string): string[];
}

View File

@ -0,0 +1,17 @@
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import { FindAdByIdQuery } from './find-ad-by-id.query';
import { AD_REPOSITORY } from '@modules/ad/ad.di-tokens';
import { AdRepositoryPort } from '../../ports/ad.repository.port';
import { Inject } from '@nestjs/common';
import { AdEntity } from '../../ad.entity';
@QueryHandler(FindAdByIdQuery)
export class FindAdByIdQueryHandler implements IQueryHandler {
constructor(
@Inject(AD_REPOSITORY)
private readonly repository: AdRepositoryPort,
) {}
async execute(query: FindAdByIdQuery): Promise<AdEntity> {
return await this.repository.findOneById(query.id, { waypoints: true });
}
}

View File

@ -0,0 +1,10 @@
import { QueryBase } from '@libs/ddd/query.base';
export class FindAdByIdQuery extends QueryBase {
readonly id: string;
constructor(id: string) {
super();
this.id = id;
}
}

View File

@ -0,0 +1,10 @@
import { Coordinates } from './coordinates';
export type Address = {
name?: string;
houseNumber?: string;
street?: string;
locality?: string;
postalCode?: string;
country: string;
} & Coordinates;

View File

@ -0,0 +1,4 @@
export type Coordinates = {
lon: number;
lat: number;
};

View File

@ -0,0 +1,9 @@
export type MarginDurations = {
mon?: number;
tue?: number;
wed?: number;
thu?: number;
fri?: number;
sat?: number;
sun?: number;
};

View File

@ -0,0 +1,9 @@
export type Schedule = {
mon?: string;
tue?: string;
wed?: string;
thu?: string;
fri?: string;
sat?: string;
sun?: string;
};

View File

@ -0,0 +1,5 @@
import { Address } from './address';
export type Waypoint = {
position?: number;
} & Address;

View File

@ -0,0 +1,52 @@
import { ValueObject } from '@libs/ddd';
import { CoordinatesProps } from './coordinates.value-object';
/** Note:
* Value Objects with multiple properties can contain
* other Value Objects inside if needed.
* */
export interface AddressProps {
name?: string;
houseNumber?: string;
street?: string;
locality?: string;
postalCode?: string;
country: string;
coordinates: CoordinatesProps;
}
export class Address extends ValueObject<AddressProps> {
get name(): string {
return this.props.name;
}
get houseNumber(): string {
return this.props.houseNumber;
}
get street(): string {
return this.props.street;
}
get locality(): string {
return this.props.locality;
}
get postalCode(): string {
return this.props.postalCode;
}
get country(): string {
return this.props.country;
}
get coordinates(): CoordinatesProps {
return this.props.coordinates;
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
protected validate(props: AddressProps): void {
return;
}
}

View File

@ -0,0 +1,26 @@
import { ValueObject } from '@libs/ddd';
/** Note:
* Value Objects with multiple properties can contain
* other Value Objects inside if needed.
* */
export interface CoordinatesProps {
lon: number;
lat: number;
}
export class Coordinates extends ValueObject<CoordinatesProps> {
get lon(): number {
return this.props.lon;
}
get lat(): number {
return this.props.lat;
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
protected validate(props: CoordinatesProps): void {
return;
}
}

View File

@ -0,0 +1,79 @@
import { ValueObject } from '@libs/ddd';
/** Note:
* Value Objects with multiple properties can contain
* other Value Objects inside if needed.
* */
export interface MarginDurationsProps {
mon?: number;
tue?: number;
wed?: number;
thu?: number;
fri?: number;
sat?: number;
sun?: number;
}
export class MarginDurations extends ValueObject<MarginDurationsProps> {
get mon(): number {
return this.props.mon;
}
set mon(margin: number) {
this.props.mon = margin;
}
get tue(): number {
return this.props.tue;
}
set tue(margin: number) {
this.props.tue = margin;
}
get wed(): number {
return this.props.wed;
}
set wed(margin: number) {
this.props.wed = margin;
}
get thu(): number {
return this.props.thu;
}
set thu(margin: number) {
this.props.thu = margin;
}
get fri(): number {
return this.props.fri;
}
set fri(margin: number) {
this.props.fri = margin;
}
get sat(): number {
return this.props.sat;
}
set sat(margin: number) {
this.props.sat = margin;
}
get sun(): number {
return this.props.sun;
}
set sun(margin: number) {
this.props.sun = margin;
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
protected validate(props: MarginDurationsProps): void {
return;
}
}

View File

@ -0,0 +1,51 @@
import { ValueObject } from '@libs/ddd';
/** Note:
* Value Objects with multiple properties can contain
* other Value Objects inside if needed.
* */
export interface ScheduleProps {
mon?: string;
tue?: string;
wed?: string;
thu?: string;
fri?: string;
sat?: string;
sun?: string;
}
export class Schedule extends ValueObject<ScheduleProps> {
get mon(): string | undefined {
return this.props.mon;
}
get tue(): string | undefined {
return this.props.tue;
}
get wed(): string | undefined {
return this.props.wed;
}
get thu(): string | undefined {
return this.props.thu;
}
get fri(): string | undefined {
return this.props.fri;
}
get sat(): string | undefined {
return this.props.sat;
}
get sun(): string | undefined {
return this.props.sun;
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
protected validate(props: ScheduleProps): void {
return;
}
}

View File

@ -0,0 +1,27 @@
import { ValueObject } from '@libs/ddd';
import { AddressProps } from './address.value-object';
/** Note:
* Value Objects with multiple properties can contain
* other Value Objects inside if needed.
* */
export interface WaypointProps {
position: number;
address: AddressProps;
}
export class Waypoint extends ValueObject<WaypointProps> {
get position(): number {
return this.props.position;
}
get address(): AddressProps {
return this.props.address;
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
protected validate(props: WaypointProps): void {
return;
}
}

View File

@ -1,86 +0,0 @@
import { AutoMap } from '@automapper/classes';
import { Frequency } from '../types/frequency.enum';
import { Address } from '../entities/address';
export class AdCreation {
@AutoMap()
uuid: string;
@AutoMap()
userUuid: string;
@AutoMap()
driver: boolean;
@AutoMap()
passenger: boolean;
@AutoMap()
frequency: Frequency;
@AutoMap()
fromDate: Date;
@AutoMap()
toDate: Date;
@AutoMap()
monTime?: string;
@AutoMap()
tueTime?: string;
@AutoMap()
wedTime?: string;
@AutoMap()
thuTime?: string;
@AutoMap()
friTime?: string;
@AutoMap()
satTime?: string;
@AutoMap()
sunTime?: string;
@AutoMap()
monMargin: number;
@AutoMap()
tueMargin: number;
@AutoMap()
wedMargin: number;
@AutoMap()
thuMargin: number;
@AutoMap()
friMargin: number;
@AutoMap()
satMargin: number;
@AutoMap()
sunMargin: number;
@AutoMap()
seatsDriver: number;
@AutoMap()
seatsPassenger: number;
@AutoMap()
strict: boolean;
@AutoMap()
createdAt: Date;
@AutoMap()
updatedAt?: Date;
@AutoMap()
addresses: { create: Address[] };
}

View File

@ -1,62 +0,0 @@
import { AutoMap } from '@automapper/classes';
import {
IsInt,
IsLatitude,
IsLongitude,
IsOptional,
IsString,
IsUUID,
} from 'class-validator';
export class AddressDTO {
@IsOptional()
@IsUUID(4)
@AutoMap()
uuid?: string;
@IsOptional()
@IsUUID(4)
@AutoMap()
adUuid?: string;
@IsOptional()
@IsInt()
@AutoMap()
position?: number;
@IsLongitude()
@AutoMap()
lon: number;
@IsLatitude()
@AutoMap()
lat: number;
@IsOptional()
@AutoMap()
name?: string;
@IsOptional()
@IsString()
@AutoMap()
houseNumber?: string;
@IsOptional()
@IsString()
@AutoMap()
street?: string;
@IsOptional()
@IsString()
@AutoMap()
locality?: string;
@IsOptional()
@IsString()
@AutoMap()
postalCode?: string;
@IsString()
@AutoMap()
country: string;
}

View File

@ -1,107 +0,0 @@
import { AutoMap } from '@automapper/classes';
import {
IsOptional,
IsBoolean,
IsDate,
IsInt,
IsEnum,
ValidateNested,
ArrayMinSize,
IsUUID,
} from 'class-validator';
import { Frequency } from '../types/frequency.enum';
import { Transform, Type } from 'class-transformer';
import { intToFrequency } from './validators/frequency.mapping';
import { MarginDTO } from './margin.dto';
import { ScheduleDTO } from './schedule.dto';
import { AddressDTO } from './address.dto';
import { IsPunctualOrRecurrent } from './validators/decorators/is-punctual-or-recurrent.validator';
import { HasProperDriverSeats } from './validators/decorators/has-driver-seats.validator';
import { HasProperPassengerSeats } from './validators/decorators/has-passenger-seats.validator';
import { HasProperPositionIndexes } from './validators/decorators/address-position.validator';
export class CreateAdRequest {
@IsOptional()
@IsUUID(4)
@AutoMap()
uuid?: string;
@IsUUID(4)
@AutoMap()
userUuid: string;
@IsOptional()
@IsBoolean()
@AutoMap()
driver?: boolean;
@IsOptional()
@IsBoolean()
@AutoMap()
passenger?: boolean;
@Transform(({ value }) => intToFrequency(value), {
toClassOnly: true,
})
@IsEnum(Frequency)
@AutoMap()
frequency: Frequency;
@IsOptional()
@IsPunctualOrRecurrent()
@Type(() => Date)
@IsDate()
@AutoMap()
departureDateTime?: Date;
@IsOptional()
@IsPunctualOrRecurrent()
@Type(() => Date)
@IsDate()
@AutoMap()
fromDate?: Date;
@IsOptional()
@IsPunctualOrRecurrent()
@Type(() => Date)
@IsDate()
@AutoMap()
toDate?: Date;
@IsOptional()
@Type(() => ScheduleDTO)
@IsPunctualOrRecurrent()
@ValidateNested({ each: true })
@AutoMap()
schedule: ScheduleDTO = {};
@IsOptional()
@Type(() => MarginDTO)
@ValidateNested({ each: true })
@AutoMap()
marginDurations?: MarginDTO;
@IsOptional()
@HasProperDriverSeats()
@IsInt()
@AutoMap()
seatsDriver?: number;
@IsOptional()
@HasProperPassengerSeats()
@IsInt()
@AutoMap()
seatsPassenger?: number;
@IsOptional()
@IsBoolean()
@AutoMap()
strict?: boolean;
@ArrayMinSize(2)
@Type(() => AddressDTO)
@HasProperPositionIndexes()
@ValidateNested({ each: true })
@AutoMap()
addresses: AddressDTO[];
}

View File

@ -1,13 +0,0 @@
import { AddressDTO } from '../address.dto';
export const hasProperPositionIndexes = (value: AddressDTO[]): boolean => {
if (value.every((address) => address.position === undefined)) return true;
if (value.every((address) => typeof address.position === 'number')) {
value.sort((a, b) => a.position - b.position);
for (let i = 1; i < value.length; i++) {
if (value[i - 1].position >= value[i].position) return false;
}
return true;
}
return false;
};

View File

@ -1,24 +0,0 @@
import { ValidateBy, ValidationOptions, buildMessage } from 'class-validator';
import { AddressDTO } from '../../address.dto';
import { hasProperPositionIndexes } from '../address-position';
export const HasProperPositionIndexes = (
validationOptions?: ValidationOptions,
): PropertyDecorator =>
ValidateBy(
{
name: '',
constraints: [],
validator: {
validate: (value: AddressDTO[]): boolean =>
hasProperPositionIndexes(value),
defaultMessage: buildMessage(
() =>
`indexes position incorrect, please provide a complete list of indexes or ordened list of adresses from start to end of journey`,
validationOptions,
),
},
},
validationOptions,
);

View File

@ -1,26 +0,0 @@
import {
ValidateBy,
ValidationArguments,
ValidationOptions,
buildMessage,
} from 'class-validator';
import { hasProperDriverSeats } from '../has-driver-seats';
export const HasProperDriverSeats = (
validationOptions?: ValidationOptions,
): PropertyDecorator =>
ValidateBy(
{
name: '',
constraints: [],
validator: {
validate: (value: any, args: ValidationArguments): boolean =>
hasProperDriverSeats(args),
defaultMessage: buildMessage(
() => `driver and driver seats are not correct`,
validationOptions,
),
},
},
validationOptions,
);

View File

@ -1,27 +0,0 @@
import {
ValidateBy,
ValidationArguments,
ValidationOptions,
buildMessage,
} from 'class-validator';
import { isPunctualOrRecurrent } from '../is-punctual-or-recurrent';
export const IsPunctualOrRecurrent = (
validationOptions?: ValidationOptions,
): PropertyDecorator =>
ValidateBy(
{
name: '',
constraints: [],
validator: {
validate: (value, args: ValidationArguments): boolean =>
isPunctualOrRecurrent(args),
defaultMessage: buildMessage(
() =>
`the departure Date and time , from date, to date and schedule must be properly set on recurrent or punctual ad`,
validationOptions,
),
},
},
validationOptions,
);

View File

@ -1,7 +0,0 @@
import { Frequency } from '../../types/frequency.enum';
export const intToFrequency = (index: number): Frequency => {
if (index == 1) return Frequency.PUNCTUAL;
if (index == 2) return Frequency.RECURRENT;
return undefined;
};

View File

@ -1,19 +0,0 @@
import { ValidationArguments } from 'class-validator';
export const hasProperDriverSeats = (args: ValidationArguments): boolean => {
if (
args.object['driver'] === true &&
typeof args.object['seatsDriver'] === 'number'
)
return args.object['seatsDriver'] > 0;
if (
(args.object['driver'] === false ||
args.object['driver'] === null ||
args.object['driver'] === undefined) &&
(args.object['seatsDriver'] === 0 ||
args.object['seatsDriver'] === null ||
args.object['seatsDriver'] === undefined)
)
return true;
return false;
};

View File

@ -1,19 +0,0 @@
import { ValidationArguments } from 'class-validator';
export const hasProperPassengerSeats = (args: ValidationArguments): boolean => {
if (
args.object['passenger'] === true &&
typeof args.object['seatsPassenger'] === 'number'
)
return args.object['seatsPassenger'] > 0;
if (
(args.object['passenger'] === false ||
args.object['passenger'] === null ||
args.object['passenger'] === undefined) &&
(args.object['seatsPassenger'] === 0 ||
args.object['seatsPassenger'] === null ||
args.object['seatsPassenger'] === undefined)
)
return true;
return false;
};

View File

@ -1,16 +0,0 @@
import { ValidationArguments } from 'class-validator';
import { Frequency } from '../../types/frequency.enum';
const isPunctual = (args: ValidationArguments): boolean =>
args.object['frequency'] === Frequency.PUNCTUAL &&
args.object['departureDateTime'] instanceof Date &&
!Object.keys(args.object['schedule']).length;
const isRecurrent = (args: ValidationArguments): boolean =>
args.object['frequency'] === Frequency.RECURRENT &&
args.object['fromDate'] instanceof Date &&
args.object['toDate'] instanceof Date &&
Object.keys(args.object['schedule']).length > 0;
export const isPunctualOrRecurrent = (args: ValidationArguments): boolean =>
isPunctual(args) || isRecurrent(args);

View File

@ -1,132 +0,0 @@
import { AutoMap } from '@automapper/classes';
import {
IsOptional,
IsString,
IsBoolean,
IsDate,
IsInt,
IsEnum,
ValidateNested,
ArrayMinSize,
IsUUID,
} from 'class-validator';
import { Address } from '../entities/address';
import { Frequency } from '../types/frequency.enum';
export class Ad {
@IsUUID(4)
@AutoMap()
uuid: string;
@IsUUID(4)
@AutoMap()
userUuid: string;
@IsBoolean()
@AutoMap()
driver: boolean;
@IsBoolean()
@AutoMap()
passenger: boolean;
@IsEnum(Frequency)
@AutoMap()
frequency: Frequency;
@IsDate()
@AutoMap()
fromDate: Date;
@IsDate()
@AutoMap()
toDate: Date;
@IsOptional()
@IsDate()
@AutoMap()
monTime?: string;
@IsOptional()
@IsString()
@AutoMap()
tueTime?: string;
@IsOptional()
@IsString()
@AutoMap()
wedTime?: string;
@IsOptional()
@IsString()
@AutoMap()
thuTime?: string;
@IsOptional()
@IsString()
@AutoMap()
friTime?: string;
@IsOptional()
@IsString()
@AutoMap()
satTime?: string;
@IsOptional()
@IsString()
@AutoMap()
sunTime?: string;
@IsInt()
@AutoMap()
monMargin: number;
@IsInt()
@AutoMap()
tueMargin: number;
@IsInt()
@AutoMap()
wedMargin: number;
@IsInt()
@AutoMap()
thuMargin: number;
@IsInt()
@AutoMap()
friMargin: number;
@IsInt()
@AutoMap()
satMargin: number;
@IsInt()
@AutoMap()
sunMargin: number;
@IsInt()
@AutoMap()
seatsDriver: number;
@IsInt()
@AutoMap()
seatsPassenger: number;
@IsBoolean()
@AutoMap()
strict: boolean;
@IsDate()
@AutoMap()
createdAt: Date;
@IsDate()
@AutoMap()
updatedAt?: Date;
@ArrayMinSize(2)
@ValidateNested({ each: true })
@AutoMap(() => [Address])
addresses: Address[];
}

View File

@ -1,40 +0,0 @@
import { AutoMap } from '@automapper/classes';
import { IsInt, IsUUID } from 'class-validator';
export class Address {
@IsUUID(4)
@AutoMap()
uuid: string;
@IsUUID(4)
@AutoMap()
adUuid: string;
@IsInt()
@AutoMap()
position: number;
@AutoMap()
lon: number;
@AutoMap()
lat: number;
@AutoMap()
name?: string;
@AutoMap()
houseNumber?: string;
@AutoMap()
street?: string;
@AutoMap()
locality: string;
@AutoMap()
postalCode: string;
@AutoMap()
country: string;
}

View File

@ -1,35 +0,0 @@
import { CreateAdRequest } from '../dtos/create-ad.request';
import { Day } from '../types/day.enum';
import { Frequency } from '../types/frequency.enum';
export class FrequencyNormaliser {
fromDateResolver(createAdRequest: CreateAdRequest): Date {
if (createAdRequest.frequency === Frequency.PUNCTUAL)
return createAdRequest.departureDateTime;
return createAdRequest.fromDate;
}
toDateResolver(createAdRequest: CreateAdRequest): Date {
if (createAdRequest.frequency === Frequency.PUNCTUAL)
return createAdRequest.departureDateTime;
return createAdRequest.toDate;
}
scheduleResolver = (
createAdRequest: CreateAdRequest,
day: number,
): string => {
if (
Object.keys(createAdRequest.schedule).length === 0 &&
createAdRequest.frequency == Frequency.PUNCTUAL &&
createAdRequest.departureDateTime.getDay() === day
)
return `${createAdRequest.departureDateTime
.getHours()
.toString()
.padStart(2, '0')}:${createAdRequest.departureDateTime
.getMinutes()
.toString()
.padStart(2, '0')}`;
return createAdRequest.schedule[Day[day]];
};
}

View File

@ -1,4 +0,0 @@
import { DefaultParams } from '../types/default-params.type';
export interface IProvideParams {
getParams(): DefaultParams;
}

View File

@ -1,9 +0,0 @@
export enum Day {
sun = 0,
mon,
tue,
wed,
thu,
fri,
sat,
}

View File

@ -1,4 +0,0 @@
export enum Frequency {
PUNCTUAL = 'PUNCTUAL',
RECURRENT = 'RECURRENT',
}

View File

@ -1,111 +0,0 @@
import { Mapper } from '@automapper/core';
import { InjectMapper } from '@automapper/nestjs';
import { Inject } from '@nestjs/common';
import { CommandHandler } from '@nestjs/cqrs';
import { AdsRepository } from '../../adapters/secondaries/ads.repository';
import { CreateAdCommand } from '../../commands/create-ad.command';
import { CreateAdRequest } from '../dtos/create-ad.request';
import { IProvideParams } from '../interfaces/param-provider.interface';
import { DefaultParams } from '../types/default-params.type';
import { AdCreation } from '../dtos/ad.creation';
import { Ad } from '../entities/ad';
import { PARAMS_PROVIDER } from '../../ad.constants';
import { IPublishMessage } from '../../../../interfaces/message-publisher';
import { MESSAGE_PUBLISHER } from '../../../../app.constants';
@CommandHandler(CreateAdCommand)
export class CreateAdUseCase {
private readonly defaultParams: DefaultParams;
private ad: AdCreation;
constructor(
private readonly repository: AdsRepository,
@Inject(MESSAGE_PUBLISHER)
private readonly messagePublisher: IPublishMessage,
@InjectMapper() private readonly mapper: Mapper,
@Inject(PARAMS_PROVIDER)
private readonly defaultParamsProvider: IProvideParams,
) {
this.defaultParams = defaultParamsProvider.getParams();
}
async execute(command: CreateAdCommand): Promise<Ad> {
this.ad = this.mapper.map(
command.createAdRequest,
CreateAdRequest,
AdCreation,
);
this.setDefaultMarginDurations();
this.setDefaultAddressesPosition();
this.setDefaultDriverAndPassengerParameters();
this.setDefaultStrict();
try {
const adCreated: Ad = await this.repository.create(this.ad);
this.messagePublisher.publish('ad.create', JSON.stringify(adCreated));
this.messagePublisher.publish(
'logging.ad.create.info',
JSON.stringify(adCreated),
);
return adCreated;
} catch (error) {
let key = 'logging.ad.create.crit';
if (error.message.includes('Already exists')) {
key = 'logging.ad.create.warning';
}
this.messagePublisher.publish(
key,
JSON.stringify({
command,
error,
}),
);
throw error;
}
}
private setDefaultMarginDurations = (): void => {
if (this.ad.monMargin === undefined)
this.ad.monMargin = this.defaultParams.MON_MARGIN;
if (this.ad.tueMargin === undefined)
this.ad.tueMargin = this.defaultParams.TUE_MARGIN;
if (this.ad.wedMargin === undefined)
this.ad.wedMargin = this.defaultParams.WED_MARGIN;
if (this.ad.thuMargin === undefined)
this.ad.thuMargin = this.defaultParams.THU_MARGIN;
if (this.ad.friMargin === undefined)
this.ad.friMargin = this.defaultParams.FRI_MARGIN;
if (this.ad.satMargin === undefined)
this.ad.satMargin = this.defaultParams.SAT_MARGIN;
if (this.ad.sunMargin === undefined)
this.ad.sunMargin = this.defaultParams.SUN_MARGIN;
};
private setDefaultStrict = (): void => {
if (this.ad.strict === undefined)
this.ad.strict = this.defaultParams.STRICT;
};
private setDefaultDriverAndPassengerParameters = (): void => {
this.ad.driver = !!this.ad.driver;
this.ad.passenger = !!this.ad.passenger;
if (!this.ad.driver && !this.ad.passenger) {
this.ad.driver = this.defaultParams.DRIVER;
this.ad.seatsDriver = this.defaultParams.SEATS_PROVIDED;
this.ad.passenger = this.defaultParams.PASSENGER;
this.ad.seatsPassenger = this.defaultParams.SEATS_REQUESTED;
return;
}
if (!this.ad.seatsDriver || this.ad.seatsDriver <= 0)
this.ad.seatsDriver = this.defaultParams.SEATS_PROVIDED;
if (!this.ad.seatsPassenger || this.ad.seatsPassenger <= 0)
this.ad.seatsPassenger = this.defaultParams.SEATS_REQUESTED;
};
private setDefaultAddressesPosition = (): void => {
if (this.ad.addresses.create[0].position === undefined) {
for (let i = 0; i < this.ad.addresses.create.length; i++) {
this.ad.addresses.create[i].position = i;
}
}
};
}

View File

@ -1,33 +0,0 @@
import { Inject, NotFoundException } from '@nestjs/common';
import { QueryHandler } from '@nestjs/cqrs';
import { FindAdByUuidQuery } from '../../queries/find-ad-by-uuid.query';
import { AdsRepository } from '../../adapters/secondaries/ads.repository';
import { Ad } from '../entities/ad';
import { MESSAGE_PUBLISHER } from '../../../../app.constants';
import { IPublishMessage } from '../../../../interfaces/message-publisher';
@QueryHandler(FindAdByUuidQuery)
export class FindAdByUuidUseCase {
constructor(
private readonly repository: AdsRepository,
@Inject(MESSAGE_PUBLISHER)
private readonly messagePublisher: IPublishMessage,
) {}
async execute(findAdByUuid: FindAdByUuidQuery): Promise<Ad> {
try {
const ad = await this.repository.findOneByUuid(findAdByUuid.uuid);
if (!ad) throw new NotFoundException();
return ad;
} catch (error) {
this.messagePublisher.publish(
'logging.ad.read.warning',
JSON.stringify({
query: findAdByUuid,
error,
}),
);
throw error;
}
}
}

Some files were not shown because too many files have changed in this diff Show More