WIP handle unique constraint exception

This commit is contained in:
sbriat 2023-07-04 12:16:34 +02:00
parent 0162066557
commit f33f679e12
29 changed files with 1608 additions and 7657 deletions

8764
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -36,11 +36,13 @@
"@golevelup/nestjs-rabbitmq": "^3.4.0", "@golevelup/nestjs-rabbitmq": "^3.4.0",
"@grpc/grpc-js": "^1.8.0", "@grpc/grpc-js": "^1.8.0",
"@grpc/proto-loader": "^0.7.4", "@grpc/proto-loader": "^0.7.4",
"@mobicoop/ddd-library": "file:../../packages/dddlibrary",
"@nestjs/axios": "^1.0.1", "@nestjs/axios": "^1.0.1",
"@nestjs/common": "^9.0.0", "@nestjs/common": "^9.0.0",
"@nestjs/config": "^2.2.0", "@nestjs/config": "^2.2.0",
"@nestjs/core": "^9.0.0", "@nestjs/core": "^9.0.0",
"@nestjs/cqrs": "^9.0.1", "@nestjs/cqrs": "^9.0.1",
"@nestjs/event-emitter": "^2.0.0",
"@nestjs/microservices": "^9.2.1", "@nestjs/microservices": "^9.2.1",
"@nestjs/platform-express": "^9.0.0", "@nestjs/platform-express": "^9.0.0",
"@nestjs/terminus": "^9.2.2", "@nestjs/terminus": "^9.2.2",

View File

@ -14,7 +14,7 @@ CREATE TABLE "auth" (
-- CreateTable -- CreateTable
CREATE TABLE "username" ( CREATE TABLE "username" (
"username" TEXT NOT NULL, "username" TEXT NOT NULL,
"uuid" UUID NOT NULL, "authUuid" UUID NOT NULL,
"type" "Type" NOT NULL DEFAULT 'EMAIL', "type" "Type" NOT NULL DEFAULT 'EMAIL',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL, "updatedAt" TIMESTAMP(3) NOT NULL,
@ -23,4 +23,7 @@ CREATE TABLE "username" (
); );
-- CreateIndex -- CreateIndex
CREATE UNIQUE INDEX "username_uuid_type_key" ON "username"("uuid", "type"); CREATE UNIQUE INDEX "username_authUuid_type_key" ON "username"("authUuid", "type");
-- AddForeignKey
ALTER TABLE "username" ADD CONSTRAINT "username_authUuid_fkey" FOREIGN KEY ("authUuid") REFERENCES "auth"("uuid") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -11,22 +11,24 @@ datasource db {
} }
model Auth { model Auth {
uuid String @id @db.Uuid uuid String @id @default(uuid()) @db.Uuid
password String password String
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
usernames Username[]
@@map("auth") @@map("auth")
} }
model Username { model Username {
username String @id username String @id
uuid String @db.Uuid authUuid String @db.Uuid
type Type @default(EMAIL) // type is needed in case of username update type Type @default(EMAIL) // type is needed in case of username update
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
Auth Auth @relation(fields: [authUuid], references: [uuid], onDelete: Cascade)
@@unique([uuid, type]) @@unique([authUuid, type])
@@map("username") @@map("username")
} }

View File

@ -2,13 +2,15 @@ import { classes } from '@automapper/classes';
import { AutomapperModule } from '@automapper/nestjs'; import { AutomapperModule } from '@automapper/nestjs';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config'; import { ConfigModule } from '@nestjs/config';
import { AuthenticationModule } from './modules/authentication/authentication.module';
import { AuthorizationModule } from './modules/authorization/authorization.module'; import { AuthorizationModule } from './modules/authorization/authorization.module';
import { HealthModule } from './modules/health/health.module'; import { HealthModule } from './modules/health/health.module';
import { AuthenticationModule } from '@modules/newauthentication/authentication.module';
import { EventEmitterModule } from '@nestjs/event-emitter';
@Module({ @Module({
imports: [ imports: [
ConfigModule.forRoot({ isGlobal: true }), ConfigModule.forRoot({ isGlobal: true }),
EventEmitterModule.forRoot(),
AutomapperModule.forRoot({ strategyInitializer: classes() }), AutomapperModule.forRoot({ strategyInitializer: classes() }),
AuthenticationModule, AuthenticationModule,
AuthorizationModule, AuthorizationModule,

View File

@ -15,7 +15,7 @@ async function bootstrap() {
protoPath: [ protoPath: [
join( join(
__dirname, __dirname,
'modules/authentication/adapters/primaries/authentication.proto', 'modules/newauthentication/interface/grpc-controllers/authentication.proto',
), ),
join( join(
__dirname, __dirname,

View File

@ -79,7 +79,7 @@ export abstract class PrismaRepository<T> implements IRepository<T> {
// TODO : using any is not good, but needed for nested entities // TODO : using any is not good, but needed for nested entities
// TODO : Refactor for good clean architecture ? // TODO : Refactor for good clean architecture ?
create = async (entity: Partial<T> | any, include?: any): Promise<T> => { async create(entity: Partial<T> | any, include?: any): Promise<T> {
try { try {
const res = await this.prisma[this.model].create({ const res = await this.prisma[this.model].create({
data: entity, data: entity,
@ -98,7 +98,7 @@ export abstract class PrismaRepository<T> implements IRepository<T> {
throw new DatabaseException(); throw new DatabaseException();
} }
} }
}; }
update = async (uuid: string, entity: Partial<T>): Promise<T> => { update = async (uuid: string, entity: Partial<T>): Promise<T> => {
try { try {

View File

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

View File

@ -0,0 +1,66 @@
import { Mapper } from '@mobicoop/ddd-library';
import { Injectable } from '@nestjs/common';
import { AuthenticationEntity } from './core/domain/authentication.entity';
import { AuthenticationResponseDto } from './interface/dtos/authentication.response.dto';
import {
AuthenticationReadModel,
AuthenticationWriteModel,
UsernameModel,
} from './infrastructure/authentication.repository';
import { Type, UsernameProps } from './core/domain/username.types';
/**
* 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 AuthenticationMapper
implements
Mapper<
AuthenticationEntity,
AuthenticationReadModel,
AuthenticationWriteModel,
AuthenticationResponseDto
>
{
toPersistence = (entity: AuthenticationEntity): AuthenticationWriteModel => {
const copy = entity.getProps();
const record: AuthenticationWriteModel = {
uuid: copy.id,
password: copy.password,
usernames: {
create: copy.usernames.map((username: UsernameProps) => ({
username: username.name,
type: username.type,
})),
},
createdAt: copy.createdAt,
updatedAt: copy.updatedAt,
};
return record;
};
toDomain = (record: AuthenticationReadModel): AuthenticationEntity => {
const entity = new AuthenticationEntity({
id: record.uuid,
createdAt: new Date(record.createdAt),
updatedAt: new Date(record.updatedAt),
props: {
password: record.password,
usernames: record.usernames.map((username: UsernameModel) => ({
name: username.username,
type: Type[username.type],
})),
},
});
return entity;
};
toResponse = (entity: AuthenticationEntity): AuthenticationResponseDto => {
const response = new AuthenticationResponseDto(entity);
return response;
};
}

View File

@ -0,0 +1,31 @@
import { Module, Provider } from '@nestjs/common';
import { CreateAuthenticationGrpcController } from './interface/grpc-controllers/create-authentication.grpc.controller';
import { CreateAuthenticationService } from './core/application/commands/create-authentication/create-authentication.service';
import { AuthenticationMapper } from './authentication.mapper';
import { AUTHENTICATION_REPOSITORY } from './authentication.di-tokens';
import { AuthenticationRepository } from './infrastructure/authentication.repository';
import { PrismaService } from './infrastructure/prisma.service';
import { CqrsModule } from '@nestjs/cqrs';
const grpcControllers = [CreateAuthenticationGrpcController];
const commandHandlers: Provider[] = [CreateAuthenticationService];
const mappers: Provider[] = [AuthenticationMapper];
const repositories: Provider[] = [
{
provide: AUTHENTICATION_REPOSITORY,
useClass: AuthenticationRepository,
},
];
const orms: Provider[] = [PrismaService];
@Module({
imports: [CqrsModule],
controllers: [...grpcControllers],
providers: [...commandHandlers, ...mappers, ...repositories, ...orms],
exports: [PrismaService, AuthenticationMapper, AUTHENTICATION_REPOSITORY],
})
export class AuthenticationModule {}

View File

@ -0,0 +1,13 @@
import { Command, CommandProps } from '@mobicoop/ddd-library';
import { Username } from '../types/username';
export class CreateAuthenticationCommand extends Command {
readonly password: string;
readonly usernames: Username[];
constructor(props: CommandProps<CreateAuthenticationCommand>) {
super(props);
this.password = props.password;
this.usernames = props.usernames;
}
}

View File

@ -0,0 +1,47 @@
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { Inject } from '@nestjs/common';
import {
AggregateID,
ConflictException,
UniqueConflictException,
} from '@mobicoop/ddd-library';
import { CreateAuthenticationCommand } from './create-authentication.command';
import { AUTHENTICATION_REPOSITORY } from '@modules/newauthentication/authentication.di-tokens';
import { AuthenticationRepositoryPort } from '../ports/authentication.repository.port';
import { AuthenticationEntity } from '@modules/newauthentication/core/domain/authentication.entity';
import {
AuthenticationAlreadyExistsException,
UsernameAlreadyExistsException,
} from '@modules/newauthentication/core/domain/authentication.errors';
@CommandHandler(CreateAuthenticationCommand)
export class CreateAuthenticationService implements ICommandHandler {
constructor(
@Inject(AUTHENTICATION_REPOSITORY)
private readonly repository: AuthenticationRepositoryPort,
) {}
async execute(command: CreateAuthenticationCommand): Promise<AggregateID> {
const authentication = await AuthenticationEntity.create({
password: command.password,
usernames: command.usernames,
});
try {
await this.repository.insert(authentication);
return authentication.id;
} catch (error: any) {
console.log('error', error.cause);
if (error instanceof ConflictException) {
throw new AuthenticationAlreadyExistsException(error);
}
if (
error instanceof UniqueConflictException &&
error.message.includes('username')
) {
throw new UsernameAlreadyExistsException(error);
}
throw error;
}
}
}

View File

@ -0,0 +1,4 @@
import { RepositoryPort } from '@mobicoop/ddd-library';
import { AuthenticationEntity } from '@modules/newauthentication/core/domain/authentication.entity';
export type AuthenticationRepositoryPort = RepositoryPort<AuthenticationEntity>;

View File

@ -0,0 +1,6 @@
import { Type } from '@modules/newauthentication/core/domain/username.types';
export type Username = {
name: string;
type: Type;
};

View File

@ -0,0 +1,35 @@
import { AggregateRoot, AggregateID } from '@mobicoop/ddd-library';
import { v4 } from 'uuid';
import * as bcrypt from 'bcrypt';
import {
AuthenticationProps,
CreateAuthenticationProps,
} from './authentication.types';
import { AuthenticationCreatedDomainEvent } from './events/authentication-created.domain-events';
export class AuthenticationEntity extends AggregateRoot<AuthenticationProps> {
protected readonly _id: AggregateID;
static create = async (
create: CreateAuthenticationProps,
): Promise<AuthenticationEntity> => {
const id = v4();
const props: AuthenticationProps = { ...create };
const hash = await bcrypt.hash(props.password, 10);
const authentication = new AuthenticationEntity({
id,
props: {
password: hash,
usernames: props.usernames,
},
});
authentication.addEvent(
new AuthenticationCreatedDomainEvent({ aggregateId: id }),
);
return authentication;
};
validate(): void {
// entity business rules validation to protect it's invariant before saving entity to a database
}
}

View File

@ -0,0 +1,21 @@
import { ExceptionBase } from '@mobicoop/ddd-library';
export class AuthenticationAlreadyExistsException extends ExceptionBase {
static readonly message = 'Authentication already exists';
public readonly code = 'AUTHENTICATION.ALREADY_EXISTS';
constructor(cause?: Error, metadata?: unknown) {
super(AuthenticationAlreadyExistsException.message, cause, metadata);
}
}
export class UsernameAlreadyExistsException extends ExceptionBase {
static readonly message = 'Username already exists';
public readonly code = 'USERNAME.ALREADY_EXISTS';
constructor(cause?: Error, metadata?: unknown) {
super(UsernameAlreadyExistsException.message, cause, metadata);
}
}

View File

@ -0,0 +1,13 @@
import { UsernameProps } from './username.types';
// All properties that an Authentication has
export interface AuthenticationProps {
password: string;
usernames: UsernameProps[];
}
// Properties that are needed for an Authentication creation
export interface CreateAuthenticationProps {
password: string;
usernames: UsernameProps[];
}

View File

@ -0,0 +1,7 @@
import { DomainEvent, DomainEventProps } from '@mobicoop/ddd-library';
export class AuthenticationCreatedDomainEvent extends DomainEvent {
constructor(props: DomainEventProps<AuthenticationCreatedDomainEvent>) {
super(props);
}
}

View File

@ -0,0 +1,9 @@
import { Entity } from '@mobicoop/ddd-library';
import { UsernameProps } from './username.types';
export class UsernameEntity extends Entity<UsernameProps> {
protected _id: string;
validate(): void {
throw new Error('Method not implemented.');
}
}

View File

@ -0,0 +1,16 @@
// All properties that a Username has
export interface UsernameProps {
name: string;
type: Type;
}
// Properties that are needed for a Username creation
export interface CreateUsernameProps {
name: string;
type: Type;
}
export enum Type {
EMAIL = 'EMAIL',
PHONE = 'PHONE',
}

View File

@ -0,0 +1,56 @@
import { Injectable, Logger } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { PrismaRepositoryBase } from '@mobicoop/ddd-library';
import { AuthenticationEntity } from '../core/domain/authentication.entity';
import { AuthenticationRepositoryPort } from '../core/application/commands/ports/authentication.repository.port';
import { PrismaService } from './prisma.service';
import { AuthenticationMapper } from '../authentication.mapper';
export type AuthenticationBaseModel = {
uuid: string;
password: string;
createdAt: Date;
updatedAt: Date;
};
export type AuthenticationReadModel = AuthenticationBaseModel & {
usernames: UsernameModel[];
};
export type AuthenticationWriteModel = AuthenticationBaseModel & {
usernames: {
create: UsernameModel[];
};
};
export type UsernameModel = {
username: string;
type: string;
};
/**
* Repository is used for retrieving/saving domain entities
* */
@Injectable()
export class AuthenticationRepository
extends PrismaRepositoryBase<
AuthenticationEntity,
AuthenticationReadModel,
AuthenticationWriteModel
>
implements AuthenticationRepositoryPort
{
constructor(
prisma: PrismaService,
mapper: AuthenticationMapper,
eventEmitter: EventEmitter2,
) {
super(
prisma.auth,
prisma,
mapper,
eventEmitter,
new Logger(AuthenticationRepository.name),
);
}
}

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,6 @@
import { PaginatedResponseDto } from '@mobicoop/ddd-library';
import { AuthenticationResponseDto } from './authentication.response.dto';
export class AauthenticationPaginatedResponseDto extends PaginatedResponseDto<AuthenticationResponseDto> {
readonly data: readonly AuthenticationResponseDto[];
}

View File

@ -0,0 +1,3 @@
import { ResponseBase } from '@mobicoop/ddd-library';
export class AuthenticationResponseDto extends ResponseBase {}

View File

@ -0,0 +1,40 @@
syntax = "proto3";
package authentication;
service AuthenticationService {
rpc Validate(AuthenticationByUsernamePassword) returns (Id);
rpc Create(Authentication) returns (Id);
rpc AddUsername(Username) returns (Id);
rpc UpdatePassword(Password) returns (Id);
rpc UpdateUsername(Username) returns (Id);
rpc DeleteUsername(Username) returns (Id);
rpc Delete(Id) returns (Empty);
}
message AuthenticationByUsernamePassword {
string username = 1;
string password = 2;
}
message Authentication {
repeated Username usernames = 1;
string password = 2;
}
message Password {
string id = 1;
string password = 2;
}
message Username {
string id = 1;
string name = 2;
string type = 3;
}
message Id {
string id = 1;
}
message Empty {}

View File

@ -0,0 +1,43 @@
import {
AggregateID,
IdResponse,
RpcExceptionCode,
RpcValidationPipe,
} from '@mobicoop/ddd-library';
import { Controller, UsePipes } from '@nestjs/common';
import { CommandBus } from '@nestjs/cqrs';
import { GrpcMethod, RpcException } from '@nestjs/microservices';
import { CreateAuthenticationRequestDto } from './dtos/create-authentication.request.dto';
import { CreateAuthenticationCommand } from '@modules/newauthentication/core/application/commands/create-authentication/create-authentication.command';
import { AuthenticationAlreadyExistsException } from '@modules/newauthentication/core/domain/authentication.errors';
@UsePipes(
new RpcValidationPipe({
whitelist: true,
forbidUnknownValues: false,
}),
)
@Controller()
export class CreateAuthenticationGrpcController {
constructor(private readonly commandBus: CommandBus) {}
@GrpcMethod('AuthenticationService', 'Create')
async validate(data: CreateAuthenticationRequestDto): Promise<IdResponse> {
try {
const aggregateID: AggregateID = await this.commandBus.execute(
new CreateAuthenticationCommand(data),
);
return new IdResponse(aggregateID);
} catch (error: any) {
if (error instanceof AuthenticationAlreadyExistsException)
throw new RpcException({
code: RpcExceptionCode.ALREADY_EXISTS,
message: error.message,
});
throw new RpcException({
code: RpcExceptionCode.UNKNOWN,
message: error.message,
});
}
}
}

View File

@ -0,0 +1,21 @@
import { Type } from 'class-transformer';
import {
ArrayMinSize,
IsArray,
IsNotEmpty,
IsString,
ValidateNested,
} from 'class-validator';
import { UsernameDto } from './username.dto';
export class CreateAuthenticationRequestDto {
@Type(() => UsernameDto)
@IsArray()
@ArrayMinSize(1)
@ValidateNested({ each: true })
usernames: UsernameDto[];
@IsString()
@IsNotEmpty()
password: string;
}

View File

@ -0,0 +1,11 @@
import { Type } from '@modules/newauthentication/core/domain/username.types';
import { IsEnum, IsNotEmpty, IsString } from 'class-validator';
export class UsernameDto {
@IsString()
name: string;
@IsEnum(Type)
@IsNotEmpty()
type: Type;
}

View File

@ -16,6 +16,10 @@
"noImplicitAny": false, "noImplicitAny": false,
"strictBindCallApply": false, "strictBindCallApply": false,
"forceConsistentCasingInFileNames": false, "forceConsistentCasingInFileNames": false,
"noFallthroughCasesInSwitch": false "noFallthroughCasesInSwitch": false,
"paths": {
"@modules/*": ["src/modules/*"],
"@src/*": ["src/*"]
}
} }
} }