WIP - first shot of massive ddd-hexagon refactor

This commit is contained in:
sbriat 2023-06-20 16:50:55 +02:00
parent cd84567107
commit e1989c0a52
124 changed files with 3651 additions and 2460 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

1617
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -40,21 +40,26 @@
"@grpc/grpc-js": "^1.8.14",
"@grpc/proto-loader": "^0.7.6",
"@liaoliaots/nestjs-redis": "^9.0.5",
"@mobicoop/configuration-module": "^1.1.0",
"@mobicoop/message-broker-module": "^1.0.5",
"@mobicoop/configuration-module": "^1.2.0",
"@mobicoop/message-broker-module": "^1.2.0",
"@nestjs/common": "^9.0.0",
"@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",
"oxide.ts": "^1.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 +69,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",
@ -98,7 +104,7 @@
"main.ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"testRegex": ".*\\.service.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
@ -118,6 +124,10 @@
"main.ts"
],
"coverageDirectory": "../coverage",
"moduleNameMapper": {
"^@libs(.*)": "<rootDir>/libs/$1",
"^@modules(.*)": "<rootDir>/modules/$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,7 +34,7 @@ CREATE TABLE "ad" (
);
-- CreateTable
CREATE TABLE "address" (
CREATE TABLE "waypoint" (
"uuid" UUID NOT NULL,
"adUuid" UUID NOT NULL,
"position" SMALLINT NOT NULL,
@ -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,17 +33,17 @@ 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
@ -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,6 +1,6 @@
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { HealthModule } from './modules/health/health.module';
// import { HealthModule } from './modules/health/health.module';
import { AdModule } from './modules/ad/ad.module';
import { AutomapperModule } from '@automapper/nestjs';
import { classes } from '@automapper/classes';
@ -12,13 +12,16 @@ import {
ConfigurationModule,
ConfigurationModuleOptions,
} from '@mobicoop/configuration-module';
import { EventEmitterModule } from '@nestjs/event-emitter';
import { RequestContextModule } from 'nestjs-request-context';
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
EventEmitterModule.forRoot(),
RequestContextModule,
AutomapperModule.forRoot({ strategyInitializer: classes() }),
MessageBrokerModule.forRootAsync(
{
MessageBrokerModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: async (
@ -26,12 +29,10 @@ import {
): Promise<MessageBrokerModuleOptions> => ({
uri: configService.get<string>('MESSAGE_BROKER_URI'),
exchange: configService.get<string>('MESSAGE_BROKER_EXCHANGE'),
name: 'ad',
}),
},
false,
),
ConfigurationModule.forRootAsync(
{
}),
ConfigurationModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: async (
@ -47,20 +48,12 @@ import {
password: configService.get<string>('REDIS_PASSWORD'),
port: configService.get<number>('REDIS_PORT'),
},
setConfigurationBrokerRoutingKeys: [
'configuration.create',
'configuration.update',
],
deleteConfigurationRoutingKey: 'configuration.delete',
propagateConfigurationRoutingKey: 'configuration.propagate',
setConfigurationBrokerQueue: 'ad-configuration-create-update',
deleteConfigurationQueue: 'ad-configuration-delete',
propagateConfigurationQueue: 'ad-configuration-propagate',
}),
},
true,
),
HealthModule,
}),
// HealthModule,
AdModule,
],
controllers: [],

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 count: number;
readonly limit: 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,46 @@
import { EventEmitter2 } from '@nestjs/event-emitter';
import { AggregateRoot, Mapper, RepositoryPort } from '../ddd';
import { ObjectLiteral } from '../types';
import { LoggerPort } from '../ports/logger.port';
import { None, Option, Some } from 'oxide.ts';
import { PrismaRepositoryPort } from '../ports/prisma-repository.port';
export abstract class PrismaRepositoryBase<
Aggregate extends AggregateRoot<any>,
DbModel extends ObjectLiteral,
> implements RepositoryPort<Aggregate>
{
protected constructor(
protected readonly prisma: PrismaRepositoryPort<Aggregate> | any,
protected readonly mapper: Mapper<Aggregate, DbModel>,
protected readonly eventEmitter: EventEmitter2,
protected readonly logger: LoggerPort,
) {}
async findOneById(uuid: string): Promise<Option<Aggregate>> {
try {
const entity = await this.prisma.findUnique({
where: { uuid },
});
return entity ? Some(this.mapper.toDomain(entity)) : None;
} catch (e) {
console.log('ouch on findOneById');
}
}
async insert(entity: Aggregate): Promise<void> {
try {
await this.prisma.create({
data: this.mapper.toPersistence(entity),
});
} catch (e) {
console.log(e);
console.log('ouch on insert');
}
}
async healthCheck(): Promise<boolean> {
return true;
}
}

View File

@ -0,0 +1,15 @@
import { INestApplication, Injectable, OnModuleInit } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit {
async onModuleInit() {
await this.$connect();
}
async enableShutdownHooks(app: INestApplication) {
this.$on('beforeExit', async () => {
await app.close();
});
}
}

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,11 @@
import { Entity } from './entity.base';
export interface Mapper<
DomainEntity extends Entity<any>,
DbRecord,
Response = any,
> {
toPersistence(entity: DomainEntity): DbRecord;
toDomain(record: any): DomainEntity;
toResponse(entity: DomainEntity): Response;
}

View File

@ -0,0 +1,42 @@
import { Option } from 'oxide.ts';
/* 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 count: number;
readonly limit: number;
readonly page: number;
readonly data: readonly T[];
constructor(props: Paginated<T>) {
this.count = props.count;
this.limit = props.limit;
this.page = props.page;
this.data = props.data;
}
}
export type OrderBy = { field: string | true; param: 'asc' | 'desc' };
export type PaginatedQueryParams = {
limit: number;
page: number;
offset: number;
orderBy: OrderBy;
};
export interface RepositoryPort<Entity> {
insert(entity: Entity | Entity[]): Promise<void>;
findOneById(id: string): Promise<Option<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,15 @@
/**
* 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';

View File

@ -0,0 +1,82 @@
import {
ARGUMENT_INVALID,
ARGUMENT_NOT_PROVIDED,
ARGUMENT_OUT_OF_RANGE,
CONFLICT,
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;
}

View File

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

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

@ -0,0 +1,4 @@
export interface PrismaRepositoryPort<Entity> {
findUnique(options: any): Promise<Entity>;
create(entity: any): Promise<Entity>;
}

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

@ -13,7 +13,7 @@ async function bootstrap() {
options: {
package: ['ad', 'health'],
protoPath: [
join(__dirname, 'modules/ad/adapters/primaries/ad.proto'),
join(__dirname, 'modules/ad/interface/ad.proto'),
join(__dirname, 'modules/health/adapters/primaries/health.proto'),
],
url: process.env.SERVICE_URL + ':' + process.env.SERVICE_PORT,

View File

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

View File

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

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

@ -0,0 +1,228 @@
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 { AdModel, 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 } from './ad.di-tokens';
import { TimezoneFinderPort } from './core/ports/timezone-finder.port';
import { Coordinates } from './core/types/coordinates';
import { DefaultParamsProviderPort } from './core/ports/default-params-provider.port';
import { DefaultParams } from './core/ports/default-params.type';
import { DateTime, TimeZone } from 'timezonecomplete';
/**
* 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, AdModel, AdResponseDto> {
private timezone: string;
private readonly defaultParams: DefaultParams;
constructor(
@Inject(PARAMS_PROVIDER)
private readonly defaultParamsProvider: DefaultParamsProviderPort,
@Inject(TIMEZONE_FINDER)
private readonly timezoneFinder: TimezoneFinderPort,
) {
this.defaultParams = defaultParamsProvider.getParams();
}
toPersistence = (entity: AdEntity): AdModel => {
const copy = entity.getProps();
const timezone = this.getTimezone(copy.waypoints[0].address.coordinates);
const now = new Date();
const record: AdModel = {
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
? AdMapper.toUtcDatetime(
new Date(copy.fromDate),
copy.schedule.mon,
timezone,
)
: undefined,
tueTime: copy.schedule.tue
? AdMapper.toUtcDatetime(
new Date(copy.fromDate),
copy.schedule.tue,
timezone,
)
: undefined,
wedTime: copy.schedule.wed
? AdMapper.toUtcDatetime(
new Date(copy.fromDate),
copy.schedule.wed,
timezone,
)
: undefined,
thuTime: copy.schedule.thu
? AdMapper.toUtcDatetime(
new Date(copy.fromDate),
copy.schedule.thu,
timezone,
)
: undefined,
friTime: copy.schedule.fri
? AdMapper.toUtcDatetime(
new Date(copy.fromDate),
copy.schedule.fri,
timezone,
)
: undefined,
satTime: copy.schedule.sat
? AdMapper.toUtcDatetime(
new Date(copy.fromDate),
copy.schedule.sat,
timezone,
)
: undefined,
sunTime: copy.schedule.sun
? AdMapper.toUtcDatetime(
new Date(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: AdModel): AdEntity => {
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(),
toDate: record.toDate.toISOString(),
schedule: {
mon: record.monTime.toISOString(),
tue: record.tueTime.toISOString(),
wed: record.wedTime.toISOString(),
thu: record.thuTime.toISOString(),
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.create.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.uuid = props.id;
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).
*/
private getTimezone = (coordinates: Coordinates): string => {
try {
const timezones = this.timezoneFinder.timezones(
coordinates.lon,
coordinates.lat,
);
if (timezones.length > 0) return timezones[0];
} catch (e) {}
return this.defaultParams.DEFAULT_TIMEZONE;
};
private static toUtcDatetime = (
date: Date,
time: string,
timezone: string,
): Date => {
try {
if (!date || !time || !timezone) throw new Error();
return new Date(
new DateTime(
`${date.toISOString().split('T')[0]}T${time}:00`,
TimeZone.zone(timezone, false),
)
.convert(TimeZone.zone('UTC'))
.toIsoString(),
);
} catch (e) {
return undefined;
}
};
}

View File

@ -1,25 +1,33 @@
import { Module } from '@nestjs/common';
import { AdController } from './adapters/primaries/ad.controller';
import { CreateAdGrpcController } from './interface/grpc-controllers/create-ad.grpc.controller';
import { DatabaseModule } from '../database/database.module';
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 {
AD_REPOSITORY,
PARAMS_PROVIDER,
TIMEZONE_FINDER,
} 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 { PrismaService } from './infrastructure/prisma-service';
import { AdMapper } from './ad.mapper';
import { CreateAdService } from './core/commands/create-ad/create-ad.service';
import { TimezoneFinder } from './infrastructure/timezone-finder';
@Module({
imports: [DatabaseModule, CqrsModule],
controllers: [AdController],
controllers: [CreateAdGrpcController],
providers: [
AdProfile,
AdsRepository,
FindAdByUuidUseCase,
CreateAdUseCase,
CreateAdService,
PrismaService,
AdMapper,
{
provide: AD_REPOSITORY,
useClass: AdRepository,
},
{
provide: PARAMS_PROVIDER,
useClass: DefaultParamsProvider,
@ -32,6 +40,10 @@ import { MessagePublisher } from './adapters/secondaries/message-publisher';
provide: MESSAGE_PUBLISHER,
useClass: MessagePublisher,
},
{
provide: TIMEZONE_FINDER,
useClass: TimezoneFinder,
},
],
})
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,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 departure = 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,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 AdAlreadyExistsError extends ExceptionBase {
static readonly message = 'Ad already exists';
public readonly code = 'AD.ALREADY_EXISTS';
constructor(cause?: Error, metadata?: unknown) {
super(AdAlreadyExistsError.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,88 @@
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 { Err, Ok, Result } from 'oxide.ts';
import { AggregateID } from '@libs/ddd';
import { AdAlreadyExistsError } 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<Result<AggregateID, AdAlreadyExistsError>> {
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 Ok(ad.id);
} catch (error: any) {
if (error instanceof ConflictException) {
return Err(new AdAlreadyExistsError(error));
}
throw error;
}
}
}

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,3 @@
export interface TimezoneFinderPort {
timezones(lon: number, lat: number): string[];
}

View File

@ -0,0 +1,9 @@
import { FindAdByIdRequestDTO } from '../../../interface/queries/find-ad-by-id/dtos/find-ad-by-id.request.dto';
export class FindAdByIdQuery {
readonly id: string;
constructor(findAdByIdRequestDTO: FindAdByIdRequestDTO) {
this.id = findAdByIdRequestDTO.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()
departure?: 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, 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['departure'] 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.departure;
return createAdRequest.fromDate;
}
toDateResolver(createAdRequest: CreateAdRequest): Date {
if (createAdRequest.frequency === Frequency.PUNCTUAL)
return createAdRequest.departure;
return createAdRequest.toDate;
}
scheduleResolver = (
createAdRequest: CreateAdRequest,
day: number,
): string => {
if (
Object.keys(createAdRequest.schedule).length === 0 &&
createAdRequest.frequency == Frequency.PUNCTUAL &&
createAdRequest.departure.getDay() === day
)
return `${createAdRequest.departure
.getHours()
.toString()
.padStart(2, '0')}:${createAdRequest.departure
.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;
}
}
}

View File

@ -0,0 +1,71 @@
import { Injectable, Logger } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { AdRepositoryPort } from '../core/ports/ad.repository.port';
import { AdEntity } from '../core/ad.entity';
import { PrismaRepositoryBase } from '@libs/db/prisma-repository.base';
import { AdMapper } from '../ad.mapper';
import { PrismaService } from './prisma-service';
export type AdModel = {
uuid: string;
userUuid: string;
driver: boolean;
passenger: boolean;
frequency: string;
fromDate: Date;
toDate: Date;
monTime: Date;
tueTime: Date;
wedTime: Date;
thuTime: Date;
friTime: Date;
satTime: Date;
sunTime: Date;
monMargin: number;
tueMargin: number;
wedMargin: number;
thuMargin: number;
friMargin: number;
satMargin: number;
sunMargin: number;
seatsProposed: number;
seatsRequested: number;
strict: boolean;
waypoints: {
create: WaypointModel[];
};
createdAt: Date;
updatedAt: Date;
};
export type WaypointModel = {
uuid: string;
position: number;
lon: number;
lat: number;
name?: string;
houseNumber?: string;
street?: string;
locality?: string;
postalCode?: string;
country: string;
createdAt: Date;
updatedAt: Date;
};
/**
* Repository is used for retrieving/saving domain entities
* */
@Injectable()
export class AdRepository
extends PrismaRepositoryBase<AdEntity, AdModel>
implements AdRepositoryPort
{
constructor(
prisma: PrismaService,
mapper: AdMapper,
eventEmitter: EventEmitter2,
) {
super(prisma.ad, mapper, eventEmitter, new Logger(AdRepository.name));
}
}

View File

@ -1,10 +1,10 @@
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';
import { DefaultParamsProviderPort } from '../core/ports/default-params-provider.port';
import { DefaultParams } from '../core/ports/default-params.type';
@Injectable()
export class DefaultParamsProvider implements IProvideParams {
export class DefaultParamsProvider implements DefaultParamsProviderPort {
constructor(private readonly configService: ConfigService) {}
getParams = (): DefaultParams => ({
MON_MARGIN: parseInt(this.configService.get('DEPARTURE_MARGIN')),
@ -15,9 +15,10 @@ export class DefaultParamsProvider implements IProvideParams {
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')),
SEATS_PROPOSED: parseInt(this.configService.get('SEATS_PROPOSED')),
PASSENGER: this.configService.get('ROLE') == 'passenger',
SEATS_REQUESTED: parseInt(this.configService.get('SEATS_REQUESTED')),
STRICT: this.configService.get('STRICT_FREQUENCY') == 'true',
DEFAULT_TIMEZONE: this.configService.get('DEFAULT_TIMEZONE'),
});
}

View File

@ -1,10 +1,10 @@
import { Inject, Injectable } from '@nestjs/common';
import { MESSAGE_BROKER_PUBLISHER } from '../../../../app.constants';
import { MessageBrokerPublisher } from '@mobicoop/message-broker-module';
import { IPublishMessage } from 'src/interfaces/message-publisher';
import { MessagePublisherPort } from '@ports/message-publisher.port';
import { MESSAGE_BROKER_PUBLISHER } from '@src/app.constants';
@Injectable()
export class MessagePublisher implements IPublishMessage {
export class MessagePublisher implements MessagePublisherPort {
constructor(
@Inject(MESSAGE_BROKER_PUBLISHER)
private readonly messageBrokerPublisher: MessageBrokerPublisher,

View File

@ -0,0 +1,15 @@
import { INestApplication, Injectable, OnModuleInit } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit {
async onModuleInit() {
await this.$connect();
}
async enableShutdownHooks(app: INestApplication) {
this.$on('beforeExit', async () => {
await app.close();
});
}
}

View File

@ -0,0 +1,8 @@
import { Injectable } from '@nestjs/common';
import { TimezoneFinderPort } from '../core/ports/timezone-finder.port';
import { find } from 'geo-tz';
@Injectable()
export class TimezoneFinder implements TimezoneFinderPort {
timezones = (lon: number, lat: number): string[] => find(lat, lon);
}

View File

@ -2,5 +2,5 @@ import { AutoMap } from '@automapper/classes';
export class AdPresenter {
@AutoMap()
uuid: string;
id: string;
}

View File

@ -0,0 +1,80 @@
syntax = "proto3";
package ad;
service AdsService {
rpc FindOneById(AdById) returns (Ad);
rpc FindAll(AdFilter) returns (Ads);
rpc Create(Ad) returns (AdById);
rpc Update(Ad) returns (Ad);
rpc Delete(AdById) returns (Empty);
}
message AdById {
string id = 1;
}
message Ad {
string id = 1;
string userId = 2;
bool driver = 3;
bool passenger = 4;
Frequency frequency = 5;
string fromDate = 6;
string toDate = 7;
Schedule schedule = 8;
MarginDurations marginDurations = 9;
int32 seatsProposed = 10;
int32 seatsRequested = 11;
bool strict = 12;
repeated Waypoint waypoints = 13;
}
message Schedule {
string mon = 1;
string tue = 2;
string wed = 3;
string thu = 4;
string fri = 5;
string sat = 6;
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 Waypoint {
int32 position = 1;
float lon = 2;
float lat = 3;
string name = 4;
string houseNumber = 5;
string street = 6;
string locality = 7;
string postalCode = 8;
string country = 9;
}
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

@ -0,0 +1,6 @@
import { PaginatedResponseDto } from '@libs/api/paginated.response.base';
import { AdResponseDto } from './ad.response.dto';
export class AdPaginatedResponseDto extends PaginatedResponseDto<AdResponseDto> {
readonly data: readonly AdResponseDto[];
}

View File

@ -0,0 +1,5 @@
import { ResponseBase } from '@libs/api/response.base';
export class AdResponseDto extends ResponseBase {
uuid: string;
}

View File

@ -0,0 +1,43 @@
import { Controller, UsePipes } from '@nestjs/common';
import { CommandBus } from '@nestjs/cqrs';
import { GrpcMethod, RpcException } from '@nestjs/microservices';
import { RpcValidationPipe } from '../../../../utils/pipes/rpc.validation-pipe';
import { AdPresenter } from '../ad.presenter';
import { CreateAdRequestDTO } from './dtos/create-ad.request.dto';
import { CreateAdCommand } from '../../core/commands/create-ad/create-ad.command';
import { Result, match } from 'oxide.ts';
import { AggregateID } from '@libs/ddd';
import { AdAlreadyExistsError } from '../../core/ad.errors';
import { IdResponse } from '@libs/api/id.response.dto';
@UsePipes(
new RpcValidationPipe({
whitelist: false,
forbidUnknownValues: false,
}),
)
@Controller()
export class CreateAdGrpcController {
constructor(private readonly commandBus: CommandBus) {}
@GrpcMethod('AdsService', 'Create')
async create(data: CreateAdRequestDTO): Promise<AdPresenter> {
const result: Result<AggregateID, AdAlreadyExistsError> =
await this.commandBus.execute(new CreateAdCommand(data));
// Deciding what to do with a Result (similar to Rust matching)
// if Ok we return a response with an id
// if Error decide what to do with it depending on its type
return match(result, {
Ok: (id: string) => new IdResponse(id),
Err: (error: Error) => {
if (error instanceof AdAlreadyExistsError)
throw new RpcException({
code: 6,
message: 'Ad already exists',
});
throw new RpcException({});
},
});
}
}

View File

@ -0,0 +1,33 @@
import { AutoMap } from '@automapper/classes';
import { IsOptional, IsString } from 'class-validator';
import { CoordinatesDTO } from './coordinates.dto';
export class AddressDTO extends CoordinatesDTO {
@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

@ -0,0 +1,12 @@
import { AutoMap } from '@automapper/classes';
import { IsLatitude, IsLongitude } from 'class-validator';
export class CoordinatesDTO {
@IsLongitude()
@AutoMap()
lon: number;
@IsLatitude()
@AutoMap()
lat: number;
}

View File

@ -0,0 +1,91 @@
import { AutoMap } from '@automapper/classes';
import {
IsOptional,
IsBoolean,
IsInt,
IsEnum,
ValidateNested,
ArrayMinSize,
IsUUID,
IsArray,
IsISO8601,
} from 'class-validator';
import { Transform, Type } from 'class-transformer';
import { ScheduleDTO } from './schedule.dto';
import { MarginDurationsDTO } from './margin-durations.dto';
import { WaypointDTO } from './waypoint.dto';
import { intToFrequency } from './validators/frequency.mapping';
import { IsSchedule } from './validators/decorators/is-schedule.validator';
import { HasValidPositionIndexes } from './validators/decorators/valid-position-indexes.validator';
import { Frequency } from '@modules/ad/core/ad.types';
export class CreateAdRequestDTO {
@IsUUID(4)
@AutoMap()
userId: string;
@IsOptional()
@IsBoolean()
@AutoMap()
driver?: boolean;
@IsOptional()
@IsBoolean()
@AutoMap()
passenger?: boolean;
@Transform(({ value }) => intToFrequency(value), {
toClassOnly: true,
})
@IsEnum(Frequency)
@AutoMap()
frequency: Frequency;
@IsISO8601({
strict: true,
strictSeparator: true,
})
@AutoMap()
fromDate: string;
@IsISO8601({
strict: true,
strictSeparator: true,
})
@AutoMap()
toDate: string;
@Type(() => ScheduleDTO)
@IsSchedule()
@ValidateNested({ each: true })
@AutoMap()
schedule: ScheduleDTO;
@IsOptional()
@Type(() => MarginDurationsDTO)
@ValidateNested({ each: true })
@AutoMap()
marginDurations?: MarginDurationsDTO;
@IsOptional()
@IsInt()
@AutoMap()
seatsProposed?: number;
@IsOptional()
@IsInt()
@AutoMap()
seatsRequested?: number;
@IsOptional()
@IsBoolean()
@AutoMap()
strict?: boolean;
@IsArray()
@ArrayMinSize(2)
@HasValidPositionIndexes()
@ValidateNested({ each: true })
@AutoMap()
waypoints: WaypointDTO[];
}

View File

@ -1,7 +1,7 @@
import { AutoMap } from '@automapper/classes';
import { IsInt, IsOptional } from 'class-validator';
export class MarginDTO {
export class MarginDurationsDTO {
@IsOptional()
@IsInt()
@AutoMap()

View File

@ -4,9 +4,8 @@ import {
ValidationOptions,
buildMessage,
} from 'class-validator';
import { hasProperPassengerSeats } from '../has-passenger-seats';
export const HasProperPassengerSeats = (
export const IsSchedule = (
validationOptions?: ValidationOptions,
): PropertyDecorator =>
ValidateBy(
@ -14,10 +13,11 @@ export const HasProperPassengerSeats = (
name: '',
constraints: [],
validator: {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
validate: (value, args: ValidationArguments): boolean =>
hasProperPassengerSeats(args),
Object.keys(value).length > 0,
defaultMessage: buildMessage(
() => `passenger and passenger seats are not correct`,
() => `schedule is invalid`,
validationOptions,
),
},

View File

@ -0,0 +1,22 @@
import { ValidateBy, ValidationOptions, buildMessage } from 'class-validator';
import { hasValidPositionIndexes } from '../waypoint-position';
import { WaypointDTO } from '../../waypoint.dto';
export const HasValidPositionIndexes = (
validationOptions?: ValidationOptions,
): PropertyDecorator =>
ValidateBy(
{
name: '',
constraints: [],
validator: {
validate: (waypoints: WaypointDTO[]): boolean =>
hasValidPositionIndexes(waypoints),
defaultMessage: buildMessage(
() => `invalid waypoints positions`,
validationOptions,
),
},
},
validationOptions,
);

View File

@ -0,0 +1,7 @@
import { Frequency } from '@modules/ad/core/ad.types';
export const intToFrequency = (frequencyAsInt: number): Frequency => {
if (frequencyAsInt == 1) return Frequency.PUNCTUAL;
if (frequencyAsInt == 2) return Frequency.RECURRENT;
throw new Error('Unknown frequency value');
};

View File

@ -0,0 +1,15 @@
import { WaypointDTO } from '../waypoint.dto';
export const hasValidPositionIndexes = (waypoints: WaypointDTO[]): boolean => {
if (!waypoints) return;
if (waypoints.every((waypoint) => waypoint.position === undefined))
return true;
if (waypoints.every((waypoint) => typeof waypoint.position === 'number')) {
waypoints.sort((a, b) => a.position - b.position);
for (let i = 1; i < waypoints.length; i++) {
if (waypoints[i - 1].position >= waypoints[i].position) return false;
}
return true;
}
return false;
};

View File

@ -0,0 +1,10 @@
import { AutoMap } from '@automapper/classes';
import { IsInt, IsOptional } from 'class-validator';
import { AddressDTO } from './address.dto';
export class WaypointDTO extends AddressDTO {
@IsOptional()
@IsInt()
@AutoMap()
position?: number;
}

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