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",
"@grpc/grpc-js": "^1.8.0",
"@grpc/proto-loader": "^0.7.4",
"@mobicoop/ddd-library": "file:../../packages/dddlibrary",
"@nestjs/axios": "^1.0.1",
"@nestjs/common": "^9.0.0",
"@nestjs/config": "^2.2.0",
"@nestjs/core": "^9.0.0",
"@nestjs/cqrs": "^9.0.1",
"@nestjs/event-emitter": "^2.0.0",
"@nestjs/microservices": "^9.2.1",
"@nestjs/platform-express": "^9.0.0",
"@nestjs/terminus": "^9.2.2",

View File

@ -14,7 +14,7 @@ CREATE TABLE "auth" (
-- CreateTable
CREATE TABLE "username" (
"username" TEXT NOT NULL,
"uuid" UUID NOT NULL,
"authUuid" UUID NOT NULL,
"type" "Type" NOT NULL DEFAULT 'EMAIL',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
@ -23,4 +23,7 @@ CREATE TABLE "username" (
);
-- 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 {
uuid String @id @db.Uuid
uuid String @id @default(uuid()) @db.Uuid
password String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
usernames Username[]
@@map("auth")
}
model Username {
username String @id
uuid String @db.Uuid
authUuid String @db.Uuid
type Type @default(EMAIL) // type is needed in case of username update
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
Auth Auth @relation(fields: [authUuid], references: [uuid], onDelete: Cascade)
@@unique([uuid, type])
@@unique([authUuid, type])
@@map("username")
}

View File

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

View File

@ -15,7 +15,7 @@ async function bootstrap() {
protoPath: [
join(
__dirname,
'modules/authentication/adapters/primaries/authentication.proto',
'modules/newauthentication/interface/grpc-controllers/authentication.proto',
),
join(
__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 : 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 {
const res = await this.prisma[this.model].create({
data: entity,
@ -98,7 +98,7 @@ export abstract class PrismaRepository<T> implements IRepository<T> {
throw new DatabaseException();
}
}
};
}
update = async (uuid: string, entity: Partial<T>): Promise<T> => {
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,
"strictBindCallApply": false,
"forceConsistentCasingInFileNames": false,
"noFallthroughCasesInSwitch": false
"noFallthroughCasesInSwitch": false,
"paths": {
"@modules/*": ["src/modules/*"],
"@src/*": ["src/*"]
}
}
}