diff --git a/api/migrations/1617438352492-Voucher.ts b/api/migrations/1617438352492-Voucher.ts new file mode 100644 index 0000000..77f925e --- /dev/null +++ b/api/migrations/1617438352492-Voucher.ts @@ -0,0 +1,16 @@ +import {MigrationInterface, QueryRunner} from "typeorm"; + +export class Voucher1617438352492 implements MigrationInterface { + name = 'Voucher1617438352492' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "voucher" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "code" character varying NOT NULL, "email" character varying NOT NULL, "createdAt" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, "schoolId" uuid NOT NULL, CONSTRAINT "PK_677ae75f380e81c2f103a57ffaf" PRIMARY KEY ("id"))`); + await queryRunner.query(`ALTER TABLE "voucher" ADD CONSTRAINT "FK_c837a392c774db4d54bc8d8484c" FOREIGN KEY ("schoolId") REFERENCES "school"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "voucher" DROP CONSTRAINT "FK_c837a392c774db4d54bc8d8484c"`); + await queryRunner.query(`DROP TABLE "voucher"`); + } + +} diff --git a/api/src/Application/ICodeGenerator.ts b/api/src/Application/ICodeGenerator.ts new file mode 100644 index 0000000..39f88ed --- /dev/null +++ b/api/src/Application/ICodeGenerator.ts @@ -0,0 +1,3 @@ +export interface ICodeGenerator { + generate(): string; +} diff --git a/api/src/Application/School/Command/User/AddUserToSchoolCommand.ts b/api/src/Application/School/Command/User/AddUserToSchoolCommand.ts new file mode 100644 index 0000000..fdbb496 --- /dev/null +++ b/api/src/Application/School/Command/User/AddUserToSchoolCommand.ts @@ -0,0 +1,9 @@ +import { ICommand } from 'src/Application/ICommand'; +import { User } from 'src/Domain/User/User.entity'; + +export class AddUserToSchoolCommand implements ICommand { + constructor( + public readonly user: User, + public readonly schoolId: string + ) {} +} diff --git a/api/src/Application/School/Command/User/AddUserToSchoolCommandHandler.spec.ts b/api/src/Application/School/Command/User/AddUserToSchoolCommandHandler.spec.ts new file mode 100644 index 0000000..df28e35 --- /dev/null +++ b/api/src/Application/School/Command/User/AddUserToSchoolCommandHandler.spec.ts @@ -0,0 +1,93 @@ +import { mock, instance, when, verify, anything, deepEqual } from 'ts-mockito'; +import { School } from 'src/Domain/School/School.entity'; +import { AddUserToSchoolCommandHandler } from 'src/Application/School/Command/User/AddUserToSchoolCommandHandler'; +import { AddUserToSchoolCommand } from 'src/Application/School/Command/User/AddUserToSchoolCommand'; +import { SchoolRepository } from 'src/Infrastructure/School/Repository/SchoolRepository'; +import { User, UserRole } from 'src/Domain/User/User.entity'; +import { SchoolNotFoundException } from 'src/Domain/School/Exception/SchoolNotFoundException'; +import { SchoolUserRepository } from 'src/Infrastructure/School/Repository/SchoolUserRepository'; +import { SchoolUser } from 'src/Domain/School/SchoolUser.entity'; +import { IsUserAlreadyAddedToSchool } from 'src/Domain/User/Specification/IsUserAlreadyAddedToSchool'; +import { UserAlreadyAddedToSchoolException } from 'src/Domain/User/Exception/UserAlreadyAddedToSchoolException'; + +describe('AddUserToSchoolCommandHandler', () => { + let schoolRepository: SchoolRepository; + let schoolUserRepository: SchoolUserRepository; + let isUserAlreadyAddedToSchool: IsUserAlreadyAddedToSchool; + let school: School; + let handler: AddUserToSchoolCommandHandler; + + const user = mock(User); + const createdSchoolUser = mock(SchoolUser); + + const command = new AddUserToSchoolCommand( + instance(user), + 'fcf9a99f-0c7b-45ca-b68a-bfd79d73a49f', + ); + + beforeEach(() => { + schoolRepository = mock(SchoolRepository); + schoolUserRepository = mock(SchoolUserRepository); + isUserAlreadyAddedToSchool = mock(IsUserAlreadyAddedToSchool); + school = mock(School); + + handler = new AddUserToSchoolCommandHandler( + instance(schoolRepository), + instance(schoolUserRepository), + instance(isUserAlreadyAddedToSchool), + ); + }); + + it('testDirectorSuccessfullyAdded', async () => { + when(createdSchoolUser.getId()).thenReturn('0b1d9435-4258-42f1-882d-4f314f8fb57d'); + when(user.getRole()).thenReturn(UserRole.DIRECTOR); + when(schoolRepository.findOneById('fcf9a99f-0c7b-45ca-b68a-bfd79d73a49f')) + .thenResolve(instance(school)); + when(isUserAlreadyAddedToSchool.isSatisfiedBy(instance(school), instance(user))) + .thenResolve(false); + when(schoolUserRepository.save( + deepEqual(new SchoolUser(instance(school), instance(user))) + )).thenResolve(instance(createdSchoolUser)); + + expect(await handler.execute(command)).toBe( + '0b1d9435-4258-42f1-882d-4f314f8fb57d' + ); + + verify(schoolRepository.findOneById('fcf9a99f-0c7b-45ca-b68a-bfd79d73a49f')).once(); + verify(isUserAlreadyAddedToSchool.isSatisfiedBy(instance(school), instance(user))).once(); + verify(schoolUserRepository.save( + deepEqual(new SchoolUser(instance(school), instance(user))) + )).once(); + }); + + it('testSchoolNotFound', async () => { + when(schoolRepository.findOneById('fcf9a99f-0c7b-45ca-b68a-bfd79d73a49f')) + .thenResolve(null); + try { + expect(await handler.execute(command)).toBeUndefined(); + } catch (e) { + expect(e).toBeInstanceOf(SchoolNotFoundException); + expect(e.message).toBe('schools.errors.not_found'); + verify(schoolRepository.findOneById('fcf9a99f-0c7b-45ca-b68a-bfd79d73a49f')).once(); + verify(schoolUserRepository.save(anything())).never(); + verify(isUserAlreadyAddedToSchool.isSatisfiedBy(anything(), anything())).never(); + } + }); + + it('testUserAlreadyAdded', async () => { + when(schoolRepository.findOneById('fcf9a99f-0c7b-45ca-b68a-bfd79d73a49f')) + .thenResolve(instance(school)); + when(isUserAlreadyAddedToSchool.isSatisfiedBy(instance(school), instance(user))) + .thenResolve(true); + + try { + expect(await handler.execute(command)).toBeUndefined(); + } catch (e) { + expect(e).toBeInstanceOf(UserAlreadyAddedToSchoolException); + expect(e.message).toBe('users.errors.already_assigned_to_school'); + verify(schoolRepository.findOneById('fcf9a99f-0c7b-45ca-b68a-bfd79d73a49f')).once(); + verify(schoolUserRepository.save(anything())).never(); + verify(isUserAlreadyAddedToSchool.isSatisfiedBy(instance(school), instance(user))).once(); + } + }); +}); diff --git a/api/src/Application/School/Command/User/AddUserToSchoolCommandHandler.ts b/api/src/Application/School/Command/User/AddUserToSchoolCommandHandler.ts new file mode 100644 index 0000000..e89ec52 --- /dev/null +++ b/api/src/Application/School/Command/User/AddUserToSchoolCommandHandler.ts @@ -0,0 +1,41 @@ +import { Inject } from '@nestjs/common'; +import { CommandHandler } from '@nestjs/cqrs'; +import { SchoolNotFoundException } from 'src/Domain/School/Exception/SchoolNotFoundException'; +import { ISchoolRepository } from 'src/Domain/School/Repository/ISchoolRepository'; +import { ISchoolUserRepository } from 'src/Domain/School/Repository/ISchoolUserRepository'; +import { SchoolUser } from 'src/Domain/School/SchoolUser.entity'; +import { UserAlreadyAddedToSchoolException } from 'src/Domain/User/Exception/UserAlreadyAddedToSchoolException'; +import { IsUserAlreadyAddedToSchool } from 'src/Domain/User/Specification/IsUserAlreadyAddedToSchool'; +import { AddUserToSchoolCommand } from './AddUserToSchoolCommand'; + +@CommandHandler(AddUserToSchoolCommand) +export class AddUserToSchoolCommandHandler { + constructor( + @Inject('ISchoolRepository') + private readonly schoolRepository: ISchoolRepository, + @Inject('ISchoolUserRepository') + private readonly schoolUserRepository: ISchoolUserRepository, + private readonly isUserAlreadyAddedToSchool: IsUserAlreadyAddedToSchool, + ) {} + + public async execute(command: AddUserToSchoolCommand): Promise { + const { schoolId, user } = command; + + const school = await this.schoolRepository.findOneById(schoolId); + if (!school) { + throw new SchoolNotFoundException(); + } + + if (true === (await this.isUserAlreadyAddedToSchool.isSatisfiedBy(school, user))) { + throw new UserAlreadyAddedToSchoolException(); + } + + const schoolUser = await this.schoolUserRepository.save( + new SchoolUser(school, user) + ); + + // @todo : send email + + return schoolUser.getId(); + } +} diff --git a/api/src/Application/School/Command/User/AssignUserToSchoolCommand.ts b/api/src/Application/School/Command/User/AssignUserToSchoolCommand.ts deleted file mode 100644 index e84d6ca..0000000 --- a/api/src/Application/School/Command/User/AssignUserToSchoolCommand.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { ICommand } from 'src/Application/ICommand'; - -export class AssignUserToSchoolCommand implements ICommand { - constructor( - public readonly userId: string, - public readonly schoolId: string - ) {} -} diff --git a/api/src/Application/School/Command/User/AssignUserToSchoolCommandHandler.spec.ts b/api/src/Application/School/Command/User/AssignUserToSchoolCommandHandler.spec.ts deleted file mode 100644 index 423d3e6..0000000 --- a/api/src/Application/School/Command/User/AssignUserToSchoolCommandHandler.spec.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { mock, instance, when, verify, anything, deepEqual } from 'ts-mockito'; -import { School } from 'src/Domain/School/School.entity'; -import { AssignUserToSchoolCommandHandler } from 'src/Application/School/Command/User/AssignUserToSchoolCommandHandler'; -import { AssignUserToSchoolCommand } from 'src/Application/School/Command/User/AssignUserToSchoolCommand'; -import { SchoolRepository } from 'src/Infrastructure/School/Repository/SchoolRepository'; -import { UserRepository } from 'src/Infrastructure/User/Repository/UserRepository'; -import { User, UserRole } from 'src/Domain/User/User.entity'; -import { SchoolNotFoundException } from 'src/Domain/School/Exception/SchoolNotFoundException'; -import { UserNotFoundException } from 'src/Domain/User/Exception/UserNotFoundException'; -import { SchoolUserRepository } from 'src/Infrastructure/School/Repository/SchoolUserRepository'; -import { SchoolUser } from 'src/Domain/School/SchoolUser.entity'; -import { IsUserAlreadyAssignedToSchool } from 'src/Domain/User/Specification/IsUserAlreadyAssignedToSchool'; -import { UserAlreadyAssignedToSchoolException } from 'src/Domain/User/Exception/UserAlreadyAssignedToSchoolException'; - -describe('AssignUserToSchoolCommandHandler', () => { - let schoolRepository: SchoolRepository; - let schoolUserRepository: SchoolUserRepository; - let userRepository: UserRepository; - let isUserAlreadyAssignedToSchool: IsUserAlreadyAssignedToSchool; - let school: School; - let user: User; - let handler: AssignUserToSchoolCommandHandler; - - const createdSchoolUser = mock(SchoolUser); - - const command = new AssignUserToSchoolCommand( - 'df8910f9-ac0a-412b-b9a8-dbf299340abc', - 'fcf9a99f-0c7b-45ca-b68a-bfd79d73a49f', - ); - - beforeEach(() => { - schoolRepository = mock(SchoolRepository); - schoolUserRepository = mock(SchoolUserRepository); - userRepository = mock(UserRepository); - isUserAlreadyAssignedToSchool = mock(IsUserAlreadyAssignedToSchool); - school = mock(School); - user = mock(User); - - handler = new AssignUserToSchoolCommandHandler( - instance(schoolRepository), - instance(schoolUserRepository), - instance(userRepository), - instance(isUserAlreadyAssignedToSchool), - ); - }); - - it('testDirectorSuccessfullyAssigned', async () => { - when(createdSchoolUser.getId()).thenReturn('0b1d9435-4258-42f1-882d-4f314f8fb57d'); - when(user.getRole()).thenReturn(UserRole.DIRECTOR); - when(schoolRepository.findOneById('fcf9a99f-0c7b-45ca-b68a-bfd79d73a49f')) - .thenResolve(instance(school)); - when(userRepository.findOneById('df8910f9-ac0a-412b-b9a8-dbf299340abc')) - .thenResolve(instance(user)); - when(isUserAlreadyAssignedToSchool.isSatisfiedBy(instance(school), instance(user))) - .thenResolve(false); - when(schoolUserRepository.save( - deepEqual(new SchoolUser(instance(school), instance(user))) - )).thenResolve(instance(createdSchoolUser)); - - expect(await handler.execute(command)).toBe( - '0b1d9435-4258-42f1-882d-4f314f8fb57d' - ); - - verify(schoolRepository.findOneById('fcf9a99f-0c7b-45ca-b68a-bfd79d73a49f')).once(); - verify(userRepository.findOneById('df8910f9-ac0a-412b-b9a8-dbf299340abc')).once(); - verify(isUserAlreadyAssignedToSchool.isSatisfiedBy(instance(school), instance(user))).once(); - verify(schoolUserRepository.save( - deepEqual(new SchoolUser(instance(school), instance(user))) - )).once(); - }); - - it('testSchoolNotFound', async () => { - when(schoolRepository.findOneById('fcf9a99f-0c7b-45ca-b68a-bfd79d73a49f')) - .thenResolve(null); - try { - expect(await handler.execute(command)).toBeUndefined(); - } catch (e) { - expect(e).toBeInstanceOf(SchoolNotFoundException); - expect(e.message).toBe('schools.errors.not_found'); - verify(schoolRepository.findOneById('fcf9a99f-0c7b-45ca-b68a-bfd79d73a49f')).once(); - verify(userRepository.findOneById(anything())).never(); - verify(schoolUserRepository.save(anything())).never(); - verify(isUserAlreadyAssignedToSchool.isSatisfiedBy(anything(), anything())).never(); - } - }); - - it('testUserNotFound', async () => { - when(schoolRepository.findOneById('fcf9a99f-0c7b-45ca-b68a-bfd79d73a49f')) - .thenResolve(instance(school)); - when(userRepository.findOneById('df8910f9-ac0a-412b-b9a8-dbf299340abc')) - .thenResolve(null); - - try { - expect(await handler.execute(command)).toBeUndefined(); - } catch (e) { - expect(e).toBeInstanceOf(UserNotFoundException); - expect(e.message).toBe('users.errors.not_found'); - verify(schoolRepository.findOneById('fcf9a99f-0c7b-45ca-b68a-bfd79d73a49f')).once(); - verify(userRepository.findOneById('df8910f9-ac0a-412b-b9a8-dbf299340abc')).once(); - verify(schoolUserRepository.save(anything())).never(); - verify(isUserAlreadyAssignedToSchool.isSatisfiedBy(anything(), anything())).never(); - } - }); - - it('testUserAlreadyAssigned', async () => { - when(schoolRepository.findOneById('fcf9a99f-0c7b-45ca-b68a-bfd79d73a49f')) - .thenResolve(instance(school)); - when(userRepository.findOneById('df8910f9-ac0a-412b-b9a8-dbf299340abc')) - .thenResolve(instance(user)); - when(isUserAlreadyAssignedToSchool.isSatisfiedBy(instance(school), instance(user))) - .thenResolve(true); - - try { - expect(await handler.execute(command)).toBeUndefined(); - } catch (e) { - expect(e).toBeInstanceOf(UserAlreadyAssignedToSchoolException); - expect(e.message).toBe('users.errors.already_assigned_to_school'); - verify(schoolRepository.findOneById('fcf9a99f-0c7b-45ca-b68a-bfd79d73a49f')).once(); - verify(userRepository.findOneById('df8910f9-ac0a-412b-b9a8-dbf299340abc')).once(); - verify(schoolUserRepository.save(anything())).never(); - verify(isUserAlreadyAssignedToSchool.isSatisfiedBy(instance(school), instance(user))).once(); - } - }); -}); diff --git a/api/src/Application/School/Command/User/AssignUserToSchoolCommandHandler.ts b/api/src/Application/School/Command/User/AssignUserToSchoolCommandHandler.ts deleted file mode 100644 index a0c3fec..0000000 --- a/api/src/Application/School/Command/User/AssignUserToSchoolCommandHandler.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { Inject } from '@nestjs/common'; -import { CommandHandler } from '@nestjs/cqrs'; -import { SchoolNotFoundException } from 'src/Domain/School/Exception/SchoolNotFoundException'; -import { ISchoolRepository } from 'src/Domain/School/Repository/ISchoolRepository'; -import { ISchoolUserRepository } from 'src/Domain/School/Repository/ISchoolUserRepository'; -import { SchoolUser } from 'src/Domain/School/SchoolUser.entity'; -import { UserAlreadyAssignedToSchoolException } from 'src/Domain/User/Exception/UserAlreadyAssignedToSchoolException'; -import { UserNotFoundException } from 'src/Domain/User/Exception/UserNotFoundException'; -import { IUserRepository } from 'src/Domain/User/Repository/IUserRepository'; -import { IsUserAlreadyAssignedToSchool } from 'src/Domain/User/Specification/IsUserAlreadyAssignedToSchool'; -import { AssignUserToSchoolCommand } from './AssignUserToSchoolCommand'; - -@CommandHandler(AssignUserToSchoolCommand) -export class AssignUserToSchoolCommandHandler { - constructor( - @Inject('ISchoolRepository') - private readonly schoolRepository: ISchoolRepository, - @Inject('ISchoolUserRepository') - private readonly schoolUserRepository: ISchoolUserRepository, - @Inject('IUserRepository') - private readonly userRepository: IUserRepository, - private readonly isUserAlreadyAssignedToSchool: IsUserAlreadyAssignedToSchool, - ) {} - - public async execute(command: AssignUserToSchoolCommand): Promise { - const { schoolId, userId } = command; - - const school = await this.schoolRepository.findOneById(schoolId); - if (!school) { - throw new SchoolNotFoundException(); - } - - const user = await this.userRepository.findOneById(userId); - if (!user) { - throw new UserNotFoundException(); - } - - if (true === (await this.isUserAlreadyAssignedToSchool.isSatisfiedBy(school, user))) { - throw new UserAlreadyAssignedToSchoolException(); - } - - const schoolUser = await this.schoolUserRepository.save( - new SchoolUser(school, user) - ); - - return schoolUser.getId(); - } -} diff --git a/api/src/Application/School/Command/User/RemoveSchoolUserCommand.ts b/api/src/Application/School/Command/User/RemoveSchoolUserCommand.ts new file mode 100644 index 0000000..25c834b --- /dev/null +++ b/api/src/Application/School/Command/User/RemoveSchoolUserCommand.ts @@ -0,0 +1,5 @@ +import { ICommand } from 'src/Application/ICommand'; + +export class RemoveSchoolUserCommand implements ICommand { + constructor(public readonly id: string) {} +} diff --git a/api/src/Application/School/Command/User/RemoveSchoolUserCommandHandler.spec.ts b/api/src/Application/School/Command/User/RemoveSchoolUserCommandHandler.spec.ts new file mode 100644 index 0000000..dd3be8f --- /dev/null +++ b/api/src/Application/School/Command/User/RemoveSchoolUserCommandHandler.spec.ts @@ -0,0 +1,55 @@ +import { mock, instance, when, verify, anything } from 'ts-mockito'; +import { SchoolUserRepository } from 'src/Infrastructure/School/Repository/SchoolUserRepository'; +import { SchoolUser } from 'src/Domain/School/SchoolUser.entity'; +import { RemoveSchoolUserCommandHandler } from './RemoveSchoolUserCommandHandler'; +import { RemoveSchoolUserCommand } from './RemoveSchoolUserCommand'; +import { SchoolUserNotFoundException } from 'src/Domain/School/Exception/SchoolUserNotFoundException'; + +describe('RemoveSchoolUserCommandHandler', () => { + let schooluserRepository: SchoolUserRepository; + let removedSchoolUser: SchoolUser; + let handler: RemoveSchoolUserCommandHandler; + + const command = new RemoveSchoolUserCommand('17efcbee-bd2f-410e-9e99-51684b592bad'); + + beforeEach(() => { + schooluserRepository = mock(SchoolUserRepository); + removedSchoolUser = mock(SchoolUser); + + handler = new RemoveSchoolUserCommandHandler( + instance(schooluserRepository) + ); + }); + + it('testSchoolUserRemovedSuccessfully', async () => { + when(schooluserRepository.findOneById('17efcbee-bd2f-410e-9e99-51684b592bad')) + .thenResolve(instance(removedSchoolUser)); + when(removedSchoolUser.getId()).thenReturn( + '17efcbee-bd2f-410e-9e99-51684b592bad' + ); + when( + schooluserRepository.save(instance(removedSchoolUser)) + ).thenResolve(instance(removedSchoolUser)); + + await handler.execute(command); + + verify( + schooluserRepository.remove(instance(removedSchoolUser)) + ).once(); + verify(schooluserRepository.findOneById('17efcbee-bd2f-410e-9e99-51684b592bad')).once(); + }); + + it('testSchoolUserNotFound', async () => { + when(schooluserRepository.findOneById('17efcbee-bd2f-410e-9e99-51684b592bad')) + .thenResolve(null); + + try { + expect(await handler.execute(command)).toBeUndefined(); + } catch (e) { + expect(e).toBeInstanceOf(SchoolUserNotFoundException); + expect(e.message).toBe('schools.users.errors.school_user_not_found'); + verify(schooluserRepository.findOneById('17efcbee-bd2f-410e-9e99-51684b592bad')).once(); + verify(schooluserRepository.remove(anything())).never(); + } + }); +}); diff --git a/api/src/Application/School/Command/User/RemoveSchoolUserCommandHandler.ts b/api/src/Application/School/Command/User/RemoveSchoolUserCommandHandler.ts new file mode 100644 index 0000000..2c94fa0 --- /dev/null +++ b/api/src/Application/School/Command/User/RemoveSchoolUserCommandHandler.ts @@ -0,0 +1,23 @@ +import { Inject } from '@nestjs/common'; +import { CommandHandler } from '@nestjs/cqrs'; +import { SchoolUserNotFoundException } from 'src/Domain/School/Exception/SchoolUserNotFoundException'; +import { ISchoolUserRepository } from 'src/Domain/School/Repository/ISchoolUserRepository'; +import { RemoveSchoolUserCommand } from './RemoveSchoolUserCommand'; + +@CommandHandler(RemoveSchoolUserCommand) +export class RemoveSchoolUserCommandHandler { + constructor( + @Inject('ISchoolUserRepository') + private readonly schoolUserRepository: ISchoolUserRepository, + ) {} + + public async execute({ id }: RemoveSchoolUserCommand): Promise { + const schoolUser = await this.schoolUserRepository.findOneById(id); + + if (!schoolUser) { + throw new SchoolUserNotFoundException(); + } + + await this.schoolUserRepository.remove(schoolUser); + } +} diff --git a/api/src/Application/School/Command/Voucher/CreateVoucherCommand.ts b/api/src/Application/School/Command/Voucher/CreateVoucherCommand.ts new file mode 100644 index 0000000..fd685a4 --- /dev/null +++ b/api/src/Application/School/Command/Voucher/CreateVoucherCommand.ts @@ -0,0 +1,8 @@ +import { ICommand } from 'src/Application/ICommand'; + +export class CreateVoucherCommand implements ICommand { + constructor( + public readonly schoolId: string, + public readonly email: string + ) {} +} diff --git a/api/src/Application/School/Command/Voucher/CreateVoucherCommandHandler.spec.ts b/api/src/Application/School/Command/Voucher/CreateVoucherCommandHandler.spec.ts new file mode 100644 index 0000000..61b0144 --- /dev/null +++ b/api/src/Application/School/Command/Voucher/CreateVoucherCommandHandler.spec.ts @@ -0,0 +1,109 @@ +import { mock, instance, when, verify, anything, deepEqual } from 'ts-mockito'; +import { VoucherRepository } from 'src/Infrastructure/School/Repository/VoucherRepository'; +import { SchoolRepository } from 'src/Infrastructure/School/Repository/SchoolRepository'; +import { SchoolNotFoundException } from 'src/Domain/School/Exception/SchoolNotFoundException'; +import { School } from 'src/Domain/School/School.entity'; +import { Voucher } from 'src/Domain/School/Voucher.entity'; +import { CodeGeneratorAdapter } from 'src/Infrastructure/Adapter/CodeGeneratorAdapter'; +import { CreateVoucherCommand } from './CreateVoucherCommand'; +import { CreateVoucherCommandHandler } from './CreateVoucherCommandHandler'; +import { IsVoucherAlreadyGenerated } from 'src/Domain/School/Specification/IsVoucherAlreadyGenerated'; +import { VoucherAlreadyGeneratedException } from 'src/Domain/School/Exception/VoucherAlreadyGeneratedException'; + +describe('CreateVouchersCommandHandler', () => { + let voucherRepository: VoucherRepository; + let schoolRepository: SchoolRepository; + let codeGenerator: CodeGeneratorAdapter; + let isVoucherAlreadyGenerated: IsVoucherAlreadyGenerated; + let commandHandler: CreateVoucherCommandHandler; + + const school = mock(School); + const command = new CreateVoucherCommand( + 'a18c2b89-3a52-4a5e-ba0a-4545e62c160c', + 'mathieu.marchois@gmail.com' + ); + + beforeEach(() => { + voucherRepository = mock(VoucherRepository); + schoolRepository = mock(SchoolRepository); + isVoucherAlreadyGenerated = mock(IsVoucherAlreadyGenerated); + codeGenerator = mock(CodeGeneratorAdapter); + + commandHandler = new CreateVoucherCommandHandler( + instance(voucherRepository), + instance(schoolRepository), + instance(codeGenerator), + instance(isVoucherAlreadyGenerated), + ); + }); + + it('testSchoolNotFound', async () => { + when( + schoolRepository.findOneById('a18c2b89-3a52-4a5e-ba0a-4545e62c160c') + ).thenResolve(null); + try { + expect(await commandHandler.execute(command)).toBeUndefined(); + } catch (e) { + expect(e).toBeInstanceOf(SchoolNotFoundException); + expect(e.message).toBe('schools.errors.not_found'); + verify( + schoolRepository.findOneById('a18c2b89-3a52-4a5e-ba0a-4545e62c160c') + ).once(); + verify(codeGenerator.generate()).never(); + verify(voucherRepository.save(anything())).never(); + verify(isVoucherAlreadyGenerated.isSatisfiedBy(anything(), anything())).never(); + } + }); + + it('testVoucherAlreadyGenerated', async () => { + when( + schoolRepository.findOneById('a18c2b89-3a52-4a5e-ba0a-4545e62c160c') + ).thenResolve(instance(school)); + when(isVoucherAlreadyGenerated.isSatisfiedBy('mathieu.marchois@gmail.com', instance(school))) + .thenResolve(true); + + try { + expect(await commandHandler.execute(command)).toBeUndefined(); + } catch (e) { + expect(e).toBeInstanceOf(VoucherAlreadyGeneratedException); + expect(e.message).toBe('schools.errors.voucher_already_generated'); + verify( + schoolRepository.findOneById('a18c2b89-3a52-4a5e-ba0a-4545e62c160c') + ).once(); + verify(codeGenerator.generate()).never(); + verify(voucherRepository.save(anything())).never(); + verify(isVoucherAlreadyGenerated.isSatisfiedBy('mathieu.marchois@gmail.com', instance(school))) + .once(); + } + }); + + it('testCreateVoucher', async () => { + const savedVoucher = mock(Voucher); + when(savedVoucher.getId()).thenReturn('f789cc01-974e-439e-812e-98817cce2496'); + when( + schoolRepository.findOneById('a18c2b89-3a52-4a5e-ba0a-4545e62c160c') + ).thenResolve(instance(school)); + when(isVoucherAlreadyGenerated.isSatisfiedBy('mathieu.marchois@gmail.com', instance(school))) + .thenResolve(false); + when(codeGenerator.generate()).thenReturn('xZijDk'); + when(voucherRepository.save( + deepEqual( + new Voucher('xZijDk', 'mathieu.marchois@gmail.com', instance(school)) + )) + ).thenResolve(instance(savedVoucher)); + + expect(await commandHandler.execute(command)).toBe('f789cc01-974e-439e-812e-98817cce2496'); + + verify(schoolRepository.findOneById('a18c2b89-3a52-4a5e-ba0a-4545e62c160c')).once(); + verify(codeGenerator.generate()).once(); + verify(isVoucherAlreadyGenerated.isSatisfiedBy('mathieu.marchois@gmail.com', instance(school))) + .once(); + verify( + voucherRepository.save( + deepEqual( + new Voucher('xZijDk', 'mathieu.marchois@gmail.com', instance(school)) + ) + ) + ).once(); + }); +}); diff --git a/api/src/Application/School/Command/Voucher/CreateVoucherCommandHandler.ts b/api/src/Application/School/Command/Voucher/CreateVoucherCommandHandler.ts new file mode 100644 index 0000000..0338c32 --- /dev/null +++ b/api/src/Application/School/Command/Voucher/CreateVoucherCommandHandler.ts @@ -0,0 +1,44 @@ +import { CommandHandler } from '@nestjs/cqrs'; +import { Inject } from '@nestjs/common'; +import { CreateVoucherCommand } from './CreateVoucherCommand'; +import { Voucher } from 'src/Domain/School/Voucher.entity'; +import { ISchoolRepository } from 'src/Domain/School/Repository/ISchoolRepository'; +import { SchoolNotFoundException } from 'src/Domain/School/Exception/SchoolNotFoundException'; +import { IVoucherRepository } from 'src/Domain/School/Repository/IVoucherRepository'; +import { ICodeGenerator } from 'src/Application/ICodeGenerator'; +import { IsVoucherAlreadyGenerated } from 'src/Domain/School/Specification/IsVoucherAlreadyGenerated'; +import { VoucherAlreadyGeneratedException } from 'src/Domain/School/Exception/VoucherAlreadyGeneratedException'; + +@CommandHandler(CreateVoucherCommand) +export class CreateVoucherCommandHandler { + constructor( + @Inject('IVoucherRepository') + private readonly voucherRepository: IVoucherRepository, + @Inject('ISchoolRepository') + private readonly schoolRepository: ISchoolRepository, + @Inject('ICodeGenerator') + private readonly codeGenerator: ICodeGenerator, + private readonly isVoucherAlreadyGenerated: IsVoucherAlreadyGenerated + ) {} + + public async execute(command: CreateVoucherCommand): Promise { + const { schoolId, email } = command; + + const school = await this.schoolRepository.findOneById(schoolId); + if (!school) { + throw new SchoolNotFoundException(); + } + + if (true === (await this.isVoucherAlreadyGenerated.isSatisfiedBy(email, school))) { + throw new VoucherAlreadyGeneratedException(); + } + + const voucher = await this.voucherRepository.save( + new Voucher(this.codeGenerator.generate(), email, school) + ); + + // @todo : send email + + return voucher.getId(); + } +} diff --git a/api/src/Application/School/Command/Voucher/RemoveVoucherCommand.ts b/api/src/Application/School/Command/Voucher/RemoveVoucherCommand.ts new file mode 100644 index 0000000..6d0f99d --- /dev/null +++ b/api/src/Application/School/Command/Voucher/RemoveVoucherCommand.ts @@ -0,0 +1,5 @@ +import { ICommand } from 'src/Application/ICommand'; + +export class RemoveVoucherCommand implements ICommand { + constructor(public readonly id: string) {} +} diff --git a/api/src/Application/School/Command/Voucher/RemoveVoucherCommandHandler.spec.ts b/api/src/Application/School/Command/Voucher/RemoveVoucherCommandHandler.spec.ts new file mode 100644 index 0000000..b3ed592 --- /dev/null +++ b/api/src/Application/School/Command/Voucher/RemoveVoucherCommandHandler.spec.ts @@ -0,0 +1,55 @@ +import { mock, instance, when, verify, anything } from 'ts-mockito'; +import { VoucherRepository } from 'src/Infrastructure/School/Repository/VoucherRepository'; +import { Voucher } from 'src/Domain/School/Voucher.entity'; +import { RemoveVoucherCommandHandler } from './RemoveVoucherCommandHandler'; +import { RemoveVoucherCommand } from './RemoveVoucherCommand'; +import { VoucherNotFoundException } from 'src/Domain/School/Exception/VoucherNotFoundException'; + +describe('RemoveVoucherCommandHandler', () => { + let voucherRepository: VoucherRepository; + let removedVoucher: Voucher; + let handler: RemoveVoucherCommandHandler; + + const command = new RemoveVoucherCommand('17efcbee-bd2f-410e-9e99-51684b592bad'); + + beforeEach(() => { + voucherRepository = mock(VoucherRepository); + removedVoucher = mock(Voucher); + + handler = new RemoveVoucherCommandHandler( + instance(voucherRepository) + ); + }); + + it('testVoucherRemovedSuccessfully', async () => { + when(voucherRepository.findOneById('17efcbee-bd2f-410e-9e99-51684b592bad')) + .thenResolve(instance(removedVoucher)); + when(removedVoucher.getId()).thenReturn( + '17efcbee-bd2f-410e-9e99-51684b592bad' + ); + when( + voucherRepository.save(instance(removedVoucher)) + ).thenResolve(instance(removedVoucher)); + + await handler.execute(command); + + verify( + voucherRepository.remove(instance(removedVoucher)) + ).once(); + verify(voucherRepository.findOneById('17efcbee-bd2f-410e-9e99-51684b592bad')).once(); + }); + + it('testVoucherNotFound', async () => { + when(voucherRepository.findOneById('17efcbee-bd2f-410e-9e99-51684b592bad')) + .thenResolve(null); + + try { + expect(await handler.execute(command)).toBeUndefined(); + } catch (e) { + expect(e).toBeInstanceOf(VoucherNotFoundException); + expect(e.message).toBe('schools.errors.voucher_not_found'); + verify(voucherRepository.findOneById('17efcbee-bd2f-410e-9e99-51684b592bad')).once(); + verify(voucherRepository.remove(anything())).never(); + } + }); +}); diff --git a/api/src/Application/School/Command/Voucher/RemoveVoucherCommandHandler.ts b/api/src/Application/School/Command/Voucher/RemoveVoucherCommandHandler.ts new file mode 100644 index 0000000..dbaa200 --- /dev/null +++ b/api/src/Application/School/Command/Voucher/RemoveVoucherCommandHandler.ts @@ -0,0 +1,23 @@ +import { Inject } from '@nestjs/common'; +import { CommandHandler } from '@nestjs/cqrs'; +import { VoucherNotFoundException } from 'src/Domain/School/Exception/VoucherNotFoundException'; +import { IVoucherRepository } from 'src/Domain/School/Repository/IVoucherRepository'; +import { RemoveVoucherCommand } from './RemoveVoucherCommand'; + +@CommandHandler(RemoveVoucherCommand) +export class RemoveVoucherCommandHandler { + constructor( + @Inject('IVoucherRepository') + private readonly voucherRepository: IVoucherRepository, + ) {} + + public async execute({ id }: RemoveVoucherCommand): Promise { + const voucher = await this.voucherRepository.findOneById(id); + + if (!voucher) { + throw new VoucherNotFoundException(); + } + + await this.voucherRepository.remove(voucher); + } +} diff --git a/api/src/Application/School/Query/User/GetSchoolUsersQueryHandler.spec.ts b/api/src/Application/School/Query/User/GetSchoolUsersQueryHandler.spec.ts index 94b5383..613655c 100644 --- a/api/src/Application/School/Query/User/GetSchoolUsersQueryHandler.spec.ts +++ b/api/src/Application/School/Query/User/GetSchoolUsersQueryHandler.spec.ts @@ -4,19 +4,17 @@ import { User } from 'src/Domain/User/User.entity'; import { GetSchoolUsersQuery } from './GetSchoolUsersQuery'; import { GetSchoolUsersQueryHandler } from './GetSchoolUsersQueryHandler'; import { SchoolUserRepository } from 'src/Infrastructure/School/Repository/SchoolUserRepository'; -import { UserSummaryView } from 'src/Application/User/View/UserSummaryView'; +import { SchoolUserView } from '../../View/SchoolUserView'; -describe('GetSchoolsQueryHandler', () => { +describe('GetSchoolUsersQueryHandler', () => { it('testGetSchoolUsers', async () => { const schoolUserRepository = mock(SchoolUserRepository); const user1 = mock(User); - when(user1.getId()).thenReturn('4de2ffc4-e835-44c8-95b7-17c171c09873'); - when(user1.getFirstName()).thenReturn('Mathieu'); - when(user1.getLastName()).thenReturn('MARCHOIS'); when(user1.getEmail()).thenReturn('mathieu@fairness.coop'); const schoolUser1 = mock(SchoolUser); + when(schoolUser1.getId()).thenReturn('4de2ffc4-e835-44c8-95b7-17c171c09873'); when(schoolUser1.getUser()).thenReturn(instance(user1)); when( @@ -30,11 +28,10 @@ describe('GetSchoolsQueryHandler', () => { ); const expectedResult = [ - new UserSummaryView( + new SchoolUserView( '4de2ffc4-e835-44c8-95b7-17c171c09873', - 'Mathieu', - 'MARCHOIS', 'mathieu@fairness.coop', + 'schoolUser' ) ]; diff --git a/api/src/Application/School/Query/User/GetSchoolUsersQueryHandler.ts b/api/src/Application/School/Query/User/GetSchoolUsersQueryHandler.ts index b705dca..e0bebc8 100644 --- a/api/src/Application/School/Query/User/GetSchoolUsersQueryHandler.ts +++ b/api/src/Application/School/Query/User/GetSchoolUsersQueryHandler.ts @@ -2,7 +2,7 @@ import { QueryHandler } from '@nestjs/cqrs'; import { Inject } from '@nestjs/common'; import { GetSchoolUsersQuery } from './GetSchoolUsersQuery'; import { ISchoolUserRepository } from 'src/Domain/School/Repository/ISchoolUserRepository'; -import { UserSummaryView } from 'src/Application/User/View/UserSummaryView'; +import { SchoolUserView } from 'src/Application/School/View/SchoolUserView'; @QueryHandler(GetSchoolUsersQuery) export class GetSchoolUsersQueryHandler { @@ -11,23 +11,22 @@ export class GetSchoolUsersQueryHandler { private readonly schoolUserRepository: ISchoolUserRepository ) {} - public async execute(query: GetSchoolUsersQuery): Promise { - const userViews: UserSummaryView[] = []; + public async execute(query: GetSchoolUsersQuery): Promise { + const schoolUserViews: SchoolUserView[] = []; const schoolUsers = await this.schoolUserRepository.findUsersBySchool(query.schoolId); for (const schoolUser of schoolUsers) { const user = schoolUser.getUser(); - userViews.push( - new UserSummaryView( - user.getId(), - user.getFirstName(), - user.getLastName(), - user.getEmail() + schoolUserViews.push( + new SchoolUserView( + schoolUser.getId(), + user.getEmail(), + 'schoolUser' ) ); } - return userViews; + return schoolUserViews; } } diff --git a/api/src/Application/School/Query/Voucher/GetSchoolVouchersQuery.ts b/api/src/Application/School/Query/Voucher/GetSchoolVouchersQuery.ts new file mode 100644 index 0000000..d68f1c1 --- /dev/null +++ b/api/src/Application/School/Query/Voucher/GetSchoolVouchersQuery.ts @@ -0,0 +1,7 @@ +import { IQuery } from 'src/Application/IQuery'; + +export class GetSchoolVouchersQuery implements IQuery { + constructor( + public readonly schoolId: string + ) {} +} diff --git a/api/src/Application/School/Query/Voucher/GetSchoolVouchersQueryHandler.spec.ts b/api/src/Application/School/Query/Voucher/GetSchoolVouchersQueryHandler.spec.ts new file mode 100644 index 0000000..2394e3c --- /dev/null +++ b/api/src/Application/School/Query/Voucher/GetSchoolVouchersQueryHandler.spec.ts @@ -0,0 +1,41 @@ +import { Voucher } from 'src/Domain/School/Voucher.entity'; +import { VoucherRepository } from 'src/Infrastructure/School/Repository/VoucherRepository'; +import { mock, instance, when, verify } from 'ts-mockito'; +import { SchoolUserView } from '../../View/SchoolUserView'; +import { GetSchoolVouchersQuery } from './GetSchoolVouchersQuery'; +import { GetSchoolVouchersQueryHandler } from './GetSchoolVouchersQueryHandler'; + +describe('GetSchoolVouchersQueryHandler', () => { + it('testGetSchoolVouchers', async () => { + const voucherRepository = mock(VoucherRepository); + + const voucher1 = mock(Voucher); + when(voucher1.getEmail()).thenReturn('mathieu@fairness.coop'); + when(voucher1.getId()).thenReturn('4de2ffc4-e835-44c8-95b7-17c171c09873'); + + when( + voucherRepository.findBySchool('5eb3173b-97ab-4bbc-b31c-878d4bfafbc1') + ).thenResolve([instance(voucher1)]); + + const queryHandler = new GetSchoolVouchersQueryHandler( + instance(voucherRepository) + ); + + const expectedResult = [ + new SchoolUserView( + '4de2ffc4-e835-44c8-95b7-17c171c09873', + 'mathieu@fairness.coop', + 'voucher' + ) + ]; + + expect( + await queryHandler.execute( + new GetSchoolVouchersQuery('5eb3173b-97ab-4bbc-b31c-878d4bfafbc1') + ) + ).toMatchObject(expectedResult); + verify( + voucherRepository.findBySchool('5eb3173b-97ab-4bbc-b31c-878d4bfafbc1') + ).once(); + }); +}); diff --git a/api/src/Application/School/Query/Voucher/GetSchoolVouchersQueryHandler.ts b/api/src/Application/School/Query/Voucher/GetSchoolVouchersQueryHandler.ts new file mode 100644 index 0000000..d18a97f --- /dev/null +++ b/api/src/Application/School/Query/Voucher/GetSchoolVouchersQueryHandler.ts @@ -0,0 +1,30 @@ +import { QueryHandler } from '@nestjs/cqrs'; +import { Inject } from '@nestjs/common'; +import { GetSchoolVouchersQuery } from './GetSchoolVouchersQuery'; +import { IVoucherRepository } from 'src/Domain/School/Repository/IVoucherRepository'; +import { SchoolUserView } from '../../View/SchoolUserView'; + +@QueryHandler(GetSchoolVouchersQuery) +export class GetSchoolVouchersQueryHandler { + constructor( + @Inject('IVoucherRepository') + private readonly voucherRepository: IVoucherRepository + ) {} + + public async execute(query: GetSchoolVouchersQuery): Promise { + const schoolUserViews: SchoolUserView[] = []; + const vouchers = await this.voucherRepository.findBySchool(query.schoolId); + + for (const voucher of vouchers) { + schoolUserViews.push( + new SchoolUserView( + voucher.getId(), + voucher.getEmail(), + 'voucher' + ) + ); + } + + return schoolUserViews; + } +} diff --git a/api/src/Application/School/View/SchoolUserView.ts b/api/src/Application/School/View/SchoolUserView.ts new file mode 100644 index 0000000..814b229 --- /dev/null +++ b/api/src/Application/School/View/SchoolUserView.ts @@ -0,0 +1,7 @@ +export class SchoolUserView { + constructor( + public readonly id: string, + public readonly email: string, + public readonly type: 'voucher' | 'schoolUser' + ) {} +} diff --git a/api/src/Application/User/Query/GetUserByEmailQuery.ts b/api/src/Application/User/Query/GetUserByEmailQuery.ts new file mode 100644 index 0000000..de64db9 --- /dev/null +++ b/api/src/Application/User/Query/GetUserByEmailQuery.ts @@ -0,0 +1,5 @@ +import { IQuery } from 'src/Application/IQuery'; + +export class GetUserByEmailQuery implements IQuery { + constructor(public readonly email: string) {} +} diff --git a/api/src/Application/User/Query/GetUserByEmailQueryHandler.spec.ts b/api/src/Application/User/Query/GetUserByEmailQueryHandler.spec.ts new file mode 100644 index 0000000..2e98da8 --- /dev/null +++ b/api/src/Application/User/Query/GetUserByEmailQueryHandler.spec.ts @@ -0,0 +1,26 @@ +import { mock, instance, when, verify } from 'ts-mockito'; +import { GetUserByEmailQueryHandler } from './GetUserByEmailQueryHandler'; +import { GetUserByEmailQuery } from './GetUserByEmailQuery'; +import { UserRepository } from 'src/Infrastructure/User/Repository/UserRepository'; +import { User } from 'src/Domain/User/User.entity'; + +describe('GetUserByEmailQueryHandler', () => { + const query = new GetUserByEmailQuery('eb9e1d9b-dce2-48a9-b64f-f0872f3157d2'); + + it('testGetUser', async () => { + const userRepository = mock(UserRepository); + const queryHandler = new GetUserByEmailQueryHandler(instance(userRepository)); + const user = mock(User); + when( + userRepository.findOneByEmail('eb9e1d9b-dce2-48a9-b64f-f0872f3157d2') + ).thenResolve(instance(user)); + + expect(await queryHandler.execute(query)).toMatchObject( + instance(user) + ); + + verify( + userRepository.findOneByEmail('eb9e1d9b-dce2-48a9-b64f-f0872f3157d2') + ).once(); + }); +}); diff --git a/api/src/Application/User/Query/GetUserByEmailQueryHandler.ts b/api/src/Application/User/Query/GetUserByEmailQueryHandler.ts new file mode 100644 index 0000000..9951f5f --- /dev/null +++ b/api/src/Application/User/Query/GetUserByEmailQueryHandler.ts @@ -0,0 +1,17 @@ +import { Inject } from '@nestjs/common'; +import { QueryHandler } from '@nestjs/cqrs'; +import { IUserRepository } from 'src/Domain/User/Repository/IUserRepository'; +import { User } from 'src/Domain/User/User.entity'; +import { GetUserByEmailQuery } from './GetUserByEmailQuery'; + +@QueryHandler(GetUserByEmailQuery) +export class GetUserByEmailQueryHandler { + constructor( + @Inject('IUserRepository') + private readonly userRepository: IUserRepository + ) {} + + public async execute(query: GetUserByEmailQuery): Promise { + return await this.userRepository.findOneByEmail(query.email); + } +} diff --git a/api/src/Domain/School/Exception/SchoolUserNotFoundException.ts b/api/src/Domain/School/Exception/SchoolUserNotFoundException.ts new file mode 100644 index 0000000..5a6d1ad --- /dev/null +++ b/api/src/Domain/School/Exception/SchoolUserNotFoundException.ts @@ -0,0 +1,5 @@ +export class SchoolUserNotFoundException extends Error { + constructor() { + super('schools.users.errors.school_user_not_found'); + } +} diff --git a/api/src/Domain/School/Exception/VoucherAlreadyGeneratedException.ts b/api/src/Domain/School/Exception/VoucherAlreadyGeneratedException.ts new file mode 100644 index 0000000..e64712e --- /dev/null +++ b/api/src/Domain/School/Exception/VoucherAlreadyGeneratedException.ts @@ -0,0 +1,5 @@ +export class VoucherAlreadyGeneratedException extends Error { + constructor() { + super('schools.errors.voucher_already_generated'); + } +} diff --git a/api/src/Domain/School/Exception/VoucherNotFoundException.ts b/api/src/Domain/School/Exception/VoucherNotFoundException.ts new file mode 100644 index 0000000..7bcf65c --- /dev/null +++ b/api/src/Domain/School/Exception/VoucherNotFoundException.ts @@ -0,0 +1,5 @@ +export class VoucherNotFoundException extends Error { + constructor() { + super('schools.errors.voucher_not_found'); + } +} diff --git a/api/src/Domain/School/Repository/ISchoolUserRepository.ts b/api/src/Domain/School/Repository/ISchoolUserRepository.ts index 75fdc4a..7c6c0b1 100644 --- a/api/src/Domain/School/Repository/ISchoolUserRepository.ts +++ b/api/src/Domain/School/Repository/ISchoolUserRepository.ts @@ -4,6 +4,8 @@ import { SchoolUser } from '../SchoolUser.entity'; export interface ISchoolUserRepository { save(schoolUser: SchoolUser): Promise; + remove(schoolUser: SchoolUser): void; + findOneById(id :string): Promise; findOneByUserAndSchool(user: User, school: School): Promise; findUsersBySchool(schoolId: string): Promise; } diff --git a/api/src/Domain/School/Repository/IVoucherRepository.ts b/api/src/Domain/School/Repository/IVoucherRepository.ts new file mode 100644 index 0000000..f20556b --- /dev/null +++ b/api/src/Domain/School/Repository/IVoucherRepository.ts @@ -0,0 +1,10 @@ +import { School } from '../School.entity'; +import { Voucher } from '../Voucher.entity'; + +export interface IVoucherRepository { + save(voucher: Voucher): Promise; + remove(voucher: Voucher): void; + findOneByEmailAndSchool(email: string, school: School): Promise; + findOneById(id: string): Promise; + findBySchool(schoolId: string): Promise; +} diff --git a/api/src/Domain/School/Specification/IsVoucherAlreadyGenerated.spec.ts b/api/src/Domain/School/Specification/IsVoucherAlreadyGenerated.spec.ts new file mode 100644 index 0000000..d3bf0b1 --- /dev/null +++ b/api/src/Domain/School/Specification/IsVoucherAlreadyGenerated.spec.ts @@ -0,0 +1,40 @@ +import { mock, instance, when, verify, anything } from 'ts-mockito'; +import { VoucherRepository } from 'src/Infrastructure/School/Repository/VoucherRepository'; +import { Voucher } from 'src/Domain/School/Voucher.entity'; +import { IsVoucherAlreadyGenerated } from './IsVoucherAlreadyGenerated'; +import { School } from '../School.entity'; + +describe('IsVoucherAlreadyExist', () => { + let voucherRepository: VoucherRepository; + let isVoucherAlreadyExist: IsVoucherAlreadyGenerated; + const school = mock(School); + + beforeEach(() => { + voucherRepository = mock(VoucherRepository); + isVoucherAlreadyExist = new IsVoucherAlreadyGenerated( + instance(voucherRepository) + ); + }); + + it('testVoucherAlreadyExist', async () => { + when(voucherRepository.findOneByEmailAndSchool('mathieu@fairness.coop', instance(school))).thenResolve( + new Voucher( + anything(), + anything(), + anything() + ) + ); + expect(await isVoucherAlreadyExist.isSatisfiedBy('mathieu@fairness.coop', instance(school))).toBe( + true + ); + verify(voucherRepository.findOneByEmailAndSchool('mathieu@fairness.coop', instance(school))).once(); + }); + + it('testVoucherDontExist', async () => { + when(voucherRepository.findOneByEmailAndSchool('mathieu@fairness.coop', instance(school))).thenResolve(null); + expect(await isVoucherAlreadyExist.isSatisfiedBy('mathieu@fairness.coop', instance(school))).toBe( + false + ); + verify(voucherRepository.findOneByEmailAndSchool('mathieu@fairness.coop', instance(school))).once(); + }); +}); diff --git a/api/src/Domain/School/Specification/IsVoucherAlreadyGenerated.ts b/api/src/Domain/School/Specification/IsVoucherAlreadyGenerated.ts new file mode 100644 index 0000000..eb1f572 --- /dev/null +++ b/api/src/Domain/School/Specification/IsVoucherAlreadyGenerated.ts @@ -0,0 +1,17 @@ +import { Inject } from '@nestjs/common'; +import { IVoucherRepository } from '../Repository/IVoucherRepository'; +import { School } from '../School.entity'; +import { Voucher } from '../Voucher.entity'; + +export class IsVoucherAlreadyGenerated { + constructor( + @Inject('IVoucherRepository') + private readonly voucherRepository: IVoucherRepository + ) {} + + public async isSatisfiedBy(email: string, school: School): Promise { + return ( + (await this.voucherRepository.findOneByEmailAndSchool(email, school)) instanceof Voucher + ); + } +} diff --git a/api/src/Domain/School/Voucher.entity.spec.ts b/api/src/Domain/School/Voucher.entity.spec.ts new file mode 100644 index 0000000..f64141c --- /dev/null +++ b/api/src/Domain/School/Voucher.entity.spec.ts @@ -0,0 +1,20 @@ +import { mock, instance } from 'ts-mockito'; +import { Voucher } from './Voucher.entity'; +import { School } from './School.entity'; + +describe('Voucher.entity', () => { + it('testGetters', () => { + const school = mock(School); + const voucher = new Voucher( + 'x78hsKj', + 'mathieu.marchois@gmail.com', + instance(school) + ); + + expect(voucher.getId()).toBeUndefined(); + expect(voucher.getEmail()).toBe('mathieu.marchois@gmail.com'); + expect(voucher.getCode()).toBe('x78hsKj'); + expect(voucher.getSchool()).toBe(instance(school)); + expect(voucher.getCreatedAt()).toBeUndefined(); + }); +}); diff --git a/api/src/Domain/School/Voucher.entity.ts b/api/src/Domain/School/Voucher.entity.ts new file mode 100644 index 0000000..f5c1c91 --- /dev/null +++ b/api/src/Domain/School/Voucher.entity.ts @@ -0,0 +1,46 @@ +import { Entity, Column, PrimaryGeneratedColumn, ManyToOne } from 'typeorm'; +import { School } from './School.entity'; + +@Entity() +export class Voucher { + @PrimaryGeneratedColumn('uuid') + private id: string; + + @Column({type: 'varchar', nullable: false}) + private code: string; + + @Column({type: 'varchar', nullable: false}) + private email: string; + + @ManyToOne(type => School, {onDelete: 'CASCADE', nullable: false}) + private school: School; + + @Column({type: 'timestamp', default: () => 'CURRENT_TIMESTAMP'}) + private createdAt: string; + + constructor(code: string, email: string, school: School) { + this.code = code; + this.email = email; + this.school = school; + } + + public getId(): string { + return this.id; + } + + public getCode(): string { + return this.code; + } + + public getEmail(): string { + return this.email; + } + + public getSchool(): School { + return this.school; + } + + public getCreatedAt(): string { + return this.createdAt; + } +} diff --git a/api/src/Domain/User/Exception/UserAlreadyAssignedToSchoolException.ts b/api/src/Domain/User/Exception/UserAlreadyAddedToSchoolException.ts similarity index 54% rename from api/src/Domain/User/Exception/UserAlreadyAssignedToSchoolException.ts rename to api/src/Domain/User/Exception/UserAlreadyAddedToSchoolException.ts index c713e4d..6c8b68e 100644 --- a/api/src/Domain/User/Exception/UserAlreadyAssignedToSchoolException.ts +++ b/api/src/Domain/User/Exception/UserAlreadyAddedToSchoolException.ts @@ -1,4 +1,4 @@ -export class UserAlreadyAssignedToSchoolException extends Error { +export class UserAlreadyAddedToSchoolException extends Error { constructor() { super('users.errors.already_assigned_to_school'); } diff --git a/api/src/Domain/User/Specification/IsUserAlreadyAssignedToSchool.spec.ts b/api/src/Domain/User/Specification/IsUserAlreadyAddedToSchool.spec.ts similarity index 69% rename from api/src/Domain/User/Specification/IsUserAlreadyAssignedToSchool.spec.ts rename to api/src/Domain/User/Specification/IsUserAlreadyAddedToSchool.spec.ts index 207524c..3c81645 100644 --- a/api/src/Domain/User/Specification/IsUserAlreadyAssignedToSchool.spec.ts +++ b/api/src/Domain/User/Specification/IsUserAlreadyAddedToSchool.spec.ts @@ -3,20 +3,20 @@ import { School } from 'src/Domain/School/School.entity'; import { SchoolUserRepository } from 'src/Infrastructure/School/Repository/SchoolUserRepository'; import { UserRepository } from 'src/Infrastructure/User/Repository/UserRepository'; import { User, UserRole } from '../User.entity'; -import { IsUserAlreadyAssignedToSchool } from './IsUserAlreadyAssignedToSchool'; +import { IsUserAlreadyAddedToSchool } from './IsUserAlreadyAddedToSchool'; import { SchoolUser } from 'src/Domain/School/SchoolUser.entity'; -describe('IsUserAlreadyAssignedToSchool', () => { +describe('IsUserAlreadyAddedToSchool', () => { let school: School; let user: User; let schoolUserRepository: SchoolUserRepository; - let isUserAlreadyAssignedToSchool: IsUserAlreadyAssignedToSchool; + let isUserAlreadyAddedToSchool: IsUserAlreadyAddedToSchool; beforeEach(() => { school = mock(School); user = mock(User); schoolUserRepository = mock(SchoolUserRepository); - isUserAlreadyAssignedToSchool = new IsUserAlreadyAssignedToSchool( + isUserAlreadyAddedToSchool = new IsUserAlreadyAddedToSchool( instance(schoolUserRepository) ); }); @@ -24,26 +24,26 @@ describe('IsUserAlreadyAssignedToSchool', () => { it('testPhotographer', async () => { when(user.getRole()).thenReturn(UserRole.PHOTOGRAPHER); expect( - await isUserAlreadyAssignedToSchool.isSatisfiedBy(instance(school), instance(user)) + await isUserAlreadyAddedToSchool.isSatisfiedBy(instance(school), instance(user)) ).toBe(true); verify(schoolUserRepository.findOneByUserAndSchool(anything(), anything())).never(); }); - it('testDirectorNotAssigned', async () => { + it('testDirectorNotAdded', async () => { when(schoolUserRepository.findOneByUserAndSchool(instance(user), instance(school))) .thenResolve(null); expect( - await isUserAlreadyAssignedToSchool.isSatisfiedBy(instance(school), instance(user)) + await isUserAlreadyAddedToSchool.isSatisfiedBy(instance(school), instance(user)) ).toBe(false); verify(schoolUserRepository.findOneByUserAndSchool(instance(user), instance(school))).once(); }); - it('testDirectorAlreadyAssigned', async () => { + it('testDirectorAlreadyAdded', async () => { when(schoolUserRepository.findOneByUserAndSchool(instance(user), instance(school))) .thenResolve(new SchoolUser(instance(school), instance(user))); expect( - await isUserAlreadyAssignedToSchool.isSatisfiedBy(instance(school), instance(user)) + await isUserAlreadyAddedToSchool.isSatisfiedBy(instance(school), instance(user)) ).toBe(true); verify(schoolUserRepository.findOneByUserAndSchool(instance(user), instance(school))).once(); }); diff --git a/api/src/Domain/User/Specification/IsUserAlreadyAssignedToSchool.ts b/api/src/Domain/User/Specification/IsUserAlreadyAddedToSchool.ts similarity index 94% rename from api/src/Domain/User/Specification/IsUserAlreadyAssignedToSchool.ts rename to api/src/Domain/User/Specification/IsUserAlreadyAddedToSchool.ts index ea2a87a..53f69ef 100644 --- a/api/src/Domain/User/Specification/IsUserAlreadyAssignedToSchool.ts +++ b/api/src/Domain/User/Specification/IsUserAlreadyAddedToSchool.ts @@ -4,7 +4,7 @@ import { SchoolUser } from 'src/Domain/School/SchoolUser.entity'; import { School } from '../../School/School.entity'; import { User, UserRole } from '../User.entity'; -export class IsUserAlreadyAssignedToSchool { +export class IsUserAlreadyAddedToSchool { constructor( @Inject('ISchoolUserRepository') private readonly schoolUserRepository: ISchoolUserRepository diff --git a/api/src/Infrastructure/Adapter/CodeGeneratorAdapter.spec.ts b/api/src/Infrastructure/Adapter/CodeGeneratorAdapter.spec.ts new file mode 100644 index 0000000..8c379b3 --- /dev/null +++ b/api/src/Infrastructure/Adapter/CodeGeneratorAdapter.spec.ts @@ -0,0 +1,8 @@ +import { CodeGeneratorAdapter } from './CodeGeneratorAdapter'; + +describe('CodeGeneratorAdapter', () => { + it('testGenerate', () => { + const codeGenerator = new CodeGeneratorAdapter(); + expect(codeGenerator.generate()).toBeDefined(); + }); +}); diff --git a/api/src/Infrastructure/Adapter/CodeGeneratorAdapter.ts b/api/src/Infrastructure/Adapter/CodeGeneratorAdapter.ts new file mode 100644 index 0000000..82a6bbf --- /dev/null +++ b/api/src/Infrastructure/Adapter/CodeGeneratorAdapter.ts @@ -0,0 +1,10 @@ +import { Injectable } from '@nestjs/common'; +import * as shortid from 'shortid'; +import { ICodeGenerator } from 'src/Application/ICodeGenerator'; + +@Injectable() +export class CodeGeneratorAdapter implements ICodeGenerator { + public generate(): string { + return shortid.generate(); + } +} diff --git a/api/src/Infrastructure/Common/DTO/EmailDTO.spec.ts b/api/src/Infrastructure/Common/DTO/EmailDTO.spec.ts new file mode 100644 index 0000000..ceac98c --- /dev/null +++ b/api/src/Infrastructure/Common/DTO/EmailDTO.spec.ts @@ -0,0 +1,22 @@ +import { EmailDTO } from './EmailDTO'; +import { validate } from 'class-validator'; + +describe('EmailDTO', () => { + it('testValidDTO', async () => { + const dto = new EmailDTO(); + dto.email = 'mathieu.marchois@gmail.com'; + + const validation = await validate(dto); + expect(validation).toHaveLength(0); + }); + + it('testInvalidDTO', async () => { + const dto = new EmailDTO(); + + const validation = await validate(dto); + expect(validation).toHaveLength(1); + expect(validation[0].constraints).toMatchObject({ + isEmail: 'email must be an email' + }); + }); +}); diff --git a/api/src/Infrastructure/Common/DTO/EmailDTO.ts b/api/src/Infrastructure/Common/DTO/EmailDTO.ts new file mode 100644 index 0000000..85555df --- /dev/null +++ b/api/src/Infrastructure/Common/DTO/EmailDTO.ts @@ -0,0 +1,12 @@ +import { + IsEmail, + IsNotEmpty +} from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class EmailDTO { + @IsNotEmpty() + @IsEmail() + @ApiProperty() + public email: string; +} diff --git a/api/src/Infrastructure/School/Action/Product/CountSchoolProductsAction.ts b/api/src/Infrastructure/School/Action/Product/CountSchoolProductsAction.ts index dcd7037..4954c99 100644 --- a/api/src/Infrastructure/School/Action/Product/CountSchoolProductsAction.ts +++ b/api/src/Infrastructure/School/Action/Product/CountSchoolProductsAction.ts @@ -9,7 +9,7 @@ import { Roles } from 'src/Infrastructure/User/Decorator/Roles'; import { RolesGuard } from 'src/Infrastructure/User/Security/RolesGuard'; @Controller('schools') -@ApiTags('School') +@ApiTags('School product') @ApiBearerAuth() @UseGuards(AuthGuard('bearer'), RolesGuard) export class CountSchoolProductsAction { @@ -20,7 +20,7 @@ export class CountSchoolProductsAction { @Get(':id/count-products') @Roles(UserRole.PHOTOGRAPHER) - @ApiOperation({ summary: 'Count products for a specific school' }) + @ApiOperation({ summary: 'Count school products' }) public async index(@Param() { id }: IdDTO): Promise { try { const total = await this.queryBus.execute(new CountSchoolProductsQuery(id)); diff --git a/api/src/Infrastructure/School/Action/Product/CreateSchoolProductAction.ts b/api/src/Infrastructure/School/Action/Product/CreateSchoolProductAction.ts index 5cee3ea..2366044 100644 --- a/api/src/Infrastructure/School/Action/Product/CreateSchoolProductAction.ts +++ b/api/src/Infrastructure/School/Action/Product/CreateSchoolProductAction.ts @@ -18,7 +18,7 @@ import { RolesGuard } from 'src/Infrastructure/User/Security/RolesGuard'; import { SchoolProductDTO } from '../../DTO/SchoolProductDTO'; @Controller('schools') -@ApiTags('School') +@ApiTags('School product') @ApiBearerAuth() @UseGuards(AuthGuard('bearer'), RolesGuard) export class CreateSchoolProductAction { @@ -29,7 +29,7 @@ export class CreateSchoolProductAction { @Post(':id/products') @Roles(UserRole.PHOTOGRAPHER) - @ApiOperation({summary: 'Assign a product to a specific school'}) + @ApiOperation({summary: 'Add a product to a school'}) public async index(@Param() idDto: IdDTO, @Body() dto: SchoolProductDTO) { const { parentUnitPrice, photographerUnitPrice, productId } = dto; diff --git a/api/src/Infrastructure/School/Action/Product/GetSchoolProductAction.ts b/api/src/Infrastructure/School/Action/Product/GetSchoolProductAction.ts index fd4d9a3..5f27285 100644 --- a/api/src/Infrastructure/School/Action/Product/GetSchoolProductAction.ts +++ b/api/src/Infrastructure/School/Action/Product/GetSchoolProductAction.ts @@ -17,7 +17,7 @@ import { Roles } from 'src/Infrastructure/User/Decorator/Roles'; import { RolesGuard } from 'src/Infrastructure/User/Security/RolesGuard'; @Controller('schools/:schoolId/products') -@ApiTags('School') +@ApiTags('School product') @ApiBearerAuth() @UseGuards(AuthGuard('bearer'), RolesGuard) export class GetSchoolProductAction { diff --git a/api/src/Infrastructure/School/Action/Product/GetSchoolProductsAction.ts b/api/src/Infrastructure/School/Action/Product/GetSchoolProductsAction.ts index 409e2d1..52cdc74 100644 --- a/api/src/Infrastructure/School/Action/Product/GetSchoolProductsAction.ts +++ b/api/src/Infrastructure/School/Action/Product/GetSchoolProductsAction.ts @@ -10,7 +10,7 @@ import { RolesGuard } from 'src/Infrastructure/User/Security/RolesGuard'; import { UserRole } from 'src/Domain/User/User.entity'; @Controller('schools') -@ApiTags('School') +@ApiTags('School product') @ApiBearerAuth() @UseGuards(AuthGuard('bearer'), RolesGuard) export class GetSchoolProductsAction { @@ -21,7 +21,7 @@ export class GetSchoolProductsAction { @Get(':id/products') @Roles(UserRole.PHOTOGRAPHER) - @ApiOperation({summary: 'Get all products for a specific school'}) + @ApiOperation({ summary: 'Get school products '}) public async index(@Param() dto: IdDTO): Promise { try { return await this.queryBus.execute(new GetSchoolProductsQuery(dto.id)); diff --git a/api/src/Infrastructure/School/Action/Product/RemoveSchoolProductAction.ts b/api/src/Infrastructure/School/Action/Product/RemoveSchoolProductAction.ts index 8ae329f..74071f2 100644 --- a/api/src/Infrastructure/School/Action/Product/RemoveSchoolProductAction.ts +++ b/api/src/Infrastructure/School/Action/Product/RemoveSchoolProductAction.ts @@ -16,7 +16,7 @@ import { Roles } from 'src/Infrastructure/User/Decorator/Roles'; import { RolesGuard } from 'src/Infrastructure/User/Security/RolesGuard'; @Controller('schools/:schoolId/products') -@ApiTags('School') +@ApiTags('School product') @ApiBearerAuth() @UseGuards(AuthGuard('bearer'), RolesGuard) export class RemoveSchoolProductAction { @@ -27,7 +27,7 @@ export class RemoveSchoolProductAction { @Delete(':id') @Roles(UserRole.PHOTOGRAPHER) - @ApiOperation({summary: 'Remove school product'}) + @ApiOperation({ summary: 'Remove school product' }) public async index(@Param() { id }: IdDTO) { try { await this.commandBus.execute(new RemoveSchoolProductCommand(id)); diff --git a/api/src/Infrastructure/School/Action/Product/UpdateSchoolProductAction.ts b/api/src/Infrastructure/School/Action/Product/UpdateSchoolProductAction.ts index 8fa4129..c86fe30 100644 --- a/api/src/Infrastructure/School/Action/Product/UpdateSchoolProductAction.ts +++ b/api/src/Infrastructure/School/Action/Product/UpdateSchoolProductAction.ts @@ -18,7 +18,7 @@ import { RolesGuard } from 'src/Infrastructure/User/Security/RolesGuard'; import { UnitPriceDTO } from '../../DTO/UnitPriceDTO'; @Controller('schools/:schoolId/products') -@ApiTags('School') +@ApiTags('School product') @ApiBearerAuth() @UseGuards(AuthGuard('bearer'), RolesGuard) export class UpdateSchoolProductAction { @@ -29,7 +29,7 @@ export class UpdateSchoolProductAction { @Put(':id') @Roles(UserRole.PHOTOGRAPHER) - @ApiOperation({ summary: 'Edit school product unit price' }) + @ApiOperation({ summary: ' Edit school product unit price' }) public async index( @Param() { id }: IdDTO, @Body() { parentUnitPrice, photographerUnitPrice }: UnitPriceDTO diff --git a/api/src/Infrastructure/School/Action/User/AddOrInviteUserToSchoolAction.ts b/api/src/Infrastructure/School/Action/User/AddOrInviteUserToSchoolAction.ts new file mode 100644 index 0000000..671724a --- /dev/null +++ b/api/src/Infrastructure/School/Action/User/AddOrInviteUserToSchoolAction.ts @@ -0,0 +1,50 @@ +import { + Controller, + Inject, + Body, + BadRequestException, + UseGuards, + Param, + Post +} from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; +import { ICommandBus } from 'src/Application/ICommandBus'; +import { IQueryBus } from 'src/Application/IQueryBus'; +import { AddUserToSchoolCommand } from 'src/Application/School/Command/User/AddUserToSchoolCommand'; +import { CreateVoucherCommand } from 'src/Application/School/Command/Voucher/CreateVoucherCommand'; +import { GetUserByEmailQuery } from 'src/Application/User/Query/GetUserByEmailQuery'; +import { User, UserRole } from 'src/Domain/User/User.entity'; +import { EmailDTO } from 'src/Infrastructure/Common/DTO/EmailDTO'; +import { IdDTO } from 'src/Infrastructure/Common/DTO/IdDTO'; +import { Roles } from 'src/Infrastructure/User/Decorator/Roles'; +import { RolesGuard } from 'src/Infrastructure/User/Security/RolesGuard'; + +@Controller('schools') +@ApiTags('School user') +@ApiBearerAuth() +@UseGuards(AuthGuard('bearer'), RolesGuard) +export class AddOrInviteUserToSchoolAction { + constructor( + @Inject('ICommandBus') + private readonly commandBus: ICommandBus, + @Inject('IQueryBus') + private readonly queryBus: IQueryBus, + ) {} + + @Post(':id/users') + @Roles(UserRole.PHOTOGRAPHER) + @ApiOperation({ summary: 'Add or invite a user to a school' }) + public async index(@Param() { id }: IdDTO, @Body() { email }: EmailDTO) { + try { + const user: User = await this.queryBus.execute(new GetUserByEmailQuery(email)); + if (user instanceof User) { + await this.commandBus.execute(new AddUserToSchoolCommand(user, id)); + } else { + await this.commandBus.execute(new CreateVoucherCommand(id, email)); + } + } catch (e) { + throw new BadRequestException(e.message); + } + } +} diff --git a/api/src/Infrastructure/School/Action/User/GetSchoolUsersAction.ts b/api/src/Infrastructure/School/Action/User/GetSchoolUsersAction.ts index 110f342..5d6ebab 100644 --- a/api/src/Infrastructure/School/Action/User/GetSchoolUsersAction.ts +++ b/api/src/Infrastructure/School/Action/User/GetSchoolUsersAction.ts @@ -7,10 +7,11 @@ import { Roles } from 'src/Infrastructure/User/Decorator/Roles'; import { RolesGuard } from 'src/Infrastructure/User/Security/RolesGuard'; import { UserRole } from 'src/Domain/User/User.entity'; import { GetSchoolUsersQuery } from 'src/Application/School/Query/User/GetSchoolUsersQuery'; -import { UserSummaryView } from 'src/Application/User/View/UserSummaryView'; +import { SchoolUserView } from 'src/Application/School/View/SchoolUserView'; +import { GetSchoolVouchersQuery } from 'src/Application/School/Query/Voucher/GetSchoolVouchersQuery'; @Controller('schools') -@ApiTags('School') +@ApiTags('School user') @ApiBearerAuth() @UseGuards(AuthGuard('bearer'), RolesGuard) export class GetSchoolUsersAction { @@ -20,11 +21,16 @@ export class GetSchoolUsersAction { ) {} @Get(':id/users') - @Roles(UserRole.PHOTOGRAPHER, UserRole.DIRECTOR) - @ApiOperation({summary: 'Get users for a specific school'}) - public async index(@Param() dto: IdDTO): Promise { + @Roles(UserRole.PHOTOGRAPHER) + @ApiOperation({summary: 'Get users and vouchers for a specific school'}) + public async index(@Param() { id }: IdDTO): Promise { try { - return await this.queryBus.execute(new GetSchoolUsersQuery(dto.id)); + const [ users, vouchers ] = await Promise.all([ + this.queryBus.execute(new GetSchoolUsersQuery(id)), + this.queryBus.execute(new GetSchoolVouchersQuery(id)), + ]); + + return users.concat(vouchers); } catch (e) { throw new NotFoundException(e.message); } diff --git a/api/src/Infrastructure/School/Action/User/AssignUserToSchoolAction.ts b/api/src/Infrastructure/School/Action/User/RemoveSchoolUserAction.ts similarity index 62% rename from api/src/Infrastructure/School/Action/User/AssignUserToSchoolAction.ts rename to api/src/Infrastructure/School/Action/User/RemoveSchoolUserAction.ts index 699f23b..eb8d7b6 100644 --- a/api/src/Infrastructure/School/Action/User/AssignUserToSchoolAction.ts +++ b/api/src/Infrastructure/School/Action/User/RemoveSchoolUserAction.ts @@ -1,40 +1,36 @@ import { Controller, Inject, - Body, BadRequestException, UseGuards, Param, - Put + Delete } from '@nestjs/common'; import { AuthGuard } from '@nestjs/passport'; import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { ICommandBus } from 'src/Application/ICommandBus'; -import { AssignUserToSchoolCommand } from 'src/Application/School/Command/User/AssignUserToSchoolCommand'; +import { RemoveSchoolUserCommand } from 'src/Application/School/Command/User/RemoveSchoolUserCommand'; import { UserRole } from 'src/Domain/User/User.entity'; import { IdDTO } from 'src/Infrastructure/Common/DTO/IdDTO'; import { Roles } from 'src/Infrastructure/User/Decorator/Roles'; -import { UserIdDTO } from 'src/Infrastructure/User/DTO/UserIdDTO'; import { RolesGuard } from 'src/Infrastructure/User/Security/RolesGuard'; @Controller('schools') -@ApiTags('School') +@ApiTags('School user') @ApiBearerAuth() @UseGuards(AuthGuard('bearer'), RolesGuard) -export class AssignUserToSchoolAction { +export class RemoveSchoolUserAction { constructor( @Inject('ICommandBus') private readonly commandBus: ICommandBus ) {} - @Put(':id/users') + @Delete('/:schoolId/users/:id') @Roles(UserRole.PHOTOGRAPHER) - @ApiOperation({ summary: 'Assign a user to a school' }) - public async index(@Param() { id }: IdDTO, @Body() { userId }: UserIdDTO) { + @ApiOperation({ summary: 'Remove school user' }) + public async index(@Param() { id }: IdDTO) { try { - await this.commandBus.execute(new AssignUserToSchoolCommand(userId, id)); - - return { id }; + await this.commandBus.execute(new RemoveSchoolUserCommand(id)); } catch (e) { throw new BadRequestException(e.message); } diff --git a/api/src/Infrastructure/School/Action/Voucher/RemoveVoucherAction.ts b/api/src/Infrastructure/School/Action/Voucher/RemoveVoucherAction.ts new file mode 100644 index 0000000..844418c --- /dev/null +++ b/api/src/Infrastructure/School/Action/Voucher/RemoveVoucherAction.ts @@ -0,0 +1,38 @@ +import { + Controller, + Inject, + BadRequestException, + UseGuards, + Param, + Delete +} from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; +import { ICommandBus } from 'src/Application/ICommandBus'; +import { RemoveVoucherCommand } from 'src/Application/School/Command/Voucher/RemoveVoucherCommand'; +import { UserRole } from 'src/Domain/User/User.entity'; +import { IdDTO } from 'src/Infrastructure/Common/DTO/IdDTO'; +import { Roles } from 'src/Infrastructure/User/Decorator/Roles'; +import { RolesGuard } from 'src/Infrastructure/User/Security/RolesGuard'; + +@Controller('schools') +@ApiTags('School voucher') +@ApiBearerAuth() +@UseGuards(AuthGuard('bearer'), RolesGuard) +export class RemoveVoucherAction { + constructor( + @Inject('ICommandBus') + private readonly commandBus: ICommandBus + ) {} + + @Delete('/:schoolId/vouchers/:id') + @Roles(UserRole.PHOTOGRAPHER) + @ApiOperation({ summary: 'Remove voucher' }) + public async index(@Param() { id }: IdDTO) { + try { + await this.commandBus.execute(new RemoveVoucherCommand(id)); + } catch (e) { + throw new BadRequestException(e.message); + } + } +} diff --git a/api/src/Infrastructure/School/Repository/SchoolUserRepository.ts b/api/src/Infrastructure/School/Repository/SchoolUserRepository.ts index 52386c8..8bcc817 100644 --- a/api/src/Infrastructure/School/Repository/SchoolUserRepository.ts +++ b/api/src/Infrastructure/School/Repository/SchoolUserRepository.ts @@ -13,8 +13,20 @@ export class SchoolUserRepository implements ISchoolUserRepository { private readonly repository: Repository ) {} - public save(schooluser: SchoolUser): Promise { - return this.repository.save(schooluser); + public save(schoolUser: SchoolUser): Promise { + return this.repository.save(schoolUser); + } + + public remove(schoolUser: SchoolUser): void { + this.repository.delete(schoolUser.getId()); + } + + public findOneById(id: string): Promise { + return this.repository + .createQueryBuilder('schoolUser') + .select('schoolUser.id') + .where('schoolUser.id = :id', { id }) + .getOne(); } public findOneByUserAndSchool(user: User, school: School): Promise { @@ -39,13 +51,7 @@ export class SchoolUserRepository implements ISchoolUserRepository { public findUsersBySchool(schoolId: string): Promise { return this.repository .createQueryBuilder('schoolUser') - .select([ - 'schoolUser.id', - 'user.id', - 'user.firstName', - 'user.lastName', - 'user.email' - ]) + .select([ 'schoolUser.id', 'user.email' ]) .innerJoin('schoolUser.user', 'user') .innerJoin('schoolUser.school', 'school', 'school.id = :schoolId', { schoolId }) .orderBy('user.lastName', 'ASC') diff --git a/api/src/Infrastructure/School/Repository/VoucherRepository.ts b/api/src/Infrastructure/School/Repository/VoucherRepository.ts new file mode 100644 index 0000000..322ff65 --- /dev/null +++ b/api/src/Infrastructure/School/Repository/VoucherRepository.ts @@ -0,0 +1,47 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { IVoucherRepository } from 'src/Domain/School/Repository/IVoucherRepository'; +import { Voucher } from 'src/Domain/School/Voucher.entity'; +import { School } from 'src/Domain/School/School.entity'; + +@Injectable() +export class VoucherRepository implements IVoucherRepository { + constructor( + @InjectRepository(Voucher) + private readonly repository: Repository + ) {} + + public save(voucher: Voucher): Promise { + return this.repository.save(voucher); + } + + public remove(voucher: Voucher): void { + this.repository.delete(voucher.getId()); + } + + public findOneById(id: string): Promise { + return this.repository + .createQueryBuilder('voucher') + .select([ 'voucher.id' ]) + .where('voucher.id = :id', { id }) + .getOne(); + } + + public findOneByEmailAndSchool(email: string, school: School): Promise { + return this.repository + .createQueryBuilder('voucher') + .select([ 'voucher.id' ]) + .where('lower(voucher.email) = :email', { email: email.toLowerCase() }) + .innerJoin('voucher.school', 'school', 'school.id = :id', { id: school.getId()}) + .getOne(); + } + + public findBySchool(schoolId: string): Promise { + return this.repository + .createQueryBuilder('voucher') + .select([ 'voucher.id', 'voucher.email' ]) + .innerJoin('voucher.school', 'school', 'school.id = :schoolId', { schoolId }) + .getMany(); + } +} diff --git a/api/src/Infrastructure/School/school.module.ts b/api/src/Infrastructure/School/school.module.ts index 08dd17a..26635b8 100644 --- a/api/src/Infrastructure/School/school.module.ts +++ b/api/src/Infrastructure/School/school.module.ts @@ -34,15 +34,26 @@ import { GetSchoolProductsQueryHandler } from 'src/Application/School/Query/Prod import { CountSchoolProductsQueryHandler } from 'src/Application/School/Query/Product/CountSchoolProductsQueryHandler'; import { CountSchoolProductsAction } from './Action/Product/CountSchoolProductsAction'; import { User } from 'src/Domain/User/User.entity'; -import { AssignUserToSchoolCommandHandler } from 'src/Application/School/Command/User/AssignUserToSchoolCommandHandler'; +import { AddUserToSchoolCommandHandler } from 'src/Application/School/Command/User/AddUserToSchoolCommandHandler'; import { UserRepository } from '../User/Repository/UserRepository'; -import { AssignUserToSchoolAction } from './Action/User/AssignUserToSchoolAction'; +import { AddOrInviteUserToSchoolAction } from './Action/User/AddOrInviteUserToSchoolAction'; import { CanUserAccessToSchool } from 'src/Domain/User/Specification/CanUserAccessToSchool'; import { SchoolUser } from 'src/Domain/School/SchoolUser.entity'; import { SchoolUserRepository } from './Repository/SchoolUserRepository'; -import { IsUserAlreadyAssignedToSchool } from 'src/Domain/User/Specification/IsUserAlreadyAssignedToSchool'; +import { IsUserAlreadyAddedToSchool } from 'src/Domain/User/Specification/IsUserAlreadyAddedToSchool'; import { GetSchoolUsersAction } from './Action/User/GetSchoolUsersAction'; import { GetSchoolUsersQueryHandler } from 'src/Application/School/Query/User/GetSchoolUsersQueryHandler'; +import { CodeGeneratorAdapter } from '../Adapter/CodeGeneratorAdapter'; +import { VoucherRepository } from './Repository/VoucherRepository'; +import { Voucher } from 'src/Domain/School/Voucher.entity'; +import { GetUserByEmailQueryHandler } from 'src/Application/User/Query/GetUserByEmailQueryHandler'; +import { IsVoucherAlreadyGenerated } from 'src/Domain/School/Specification/IsVoucherAlreadyGenerated'; +import { CreateVoucherCommandHandler } from 'src/Application/School/Command/Voucher/CreateVoucherCommandHandler'; +import { GetSchoolVouchersQueryHandler } from 'src/Application/School/Query/Voucher/GetSchoolVouchersQueryHandler'; +import { RemoveVoucherAction } from './Action/Voucher/RemoveVoucherAction'; +import { RemoveVoucherCommandHandler } from 'src/Application/School/Command/Voucher/RemoveVoucherCommandHandler'; +import { RemoveSchoolUserAction } from './Action/User/RemoveSchoolUserAction'; +import { RemoveSchoolUserCommandHandler } from 'src/Application/School/Command/User/RemoveSchoolUserCommandHandler'; @Module({ imports: [ @@ -54,7 +65,8 @@ import { GetSchoolUsersQueryHandler } from 'src/Application/School/Query/User/Ge SchoolProduct, SchoolUser, Product, - User + User, + Voucher ]) ], controllers: [ @@ -62,18 +74,22 @@ import { GetSchoolUsersQueryHandler } from 'src/Application/School/Query/User/Ge CreateSchoolAction, GetSchoolAction, UpdateSchoolAction, - AssignUserToSchoolAction, + AddOrInviteUserToSchoolAction, GetSchoolUsersAction, + RemoveSchoolUserAction, CreateSchoolProductAction, GetSchoolProductAction, CountSchoolProductsAction, GetSchoolProductsAction, UpdateSchoolProductAction, RemoveSchoolProductAction, + RemoveVoucherAction, ], providers: [ + { provide: 'ICodeGenerator', useClass: CodeGeneratorAdapter }, { provide: 'IPhotoRepository', useClass: PhotoRepository }, { provide: 'ISchoolUserRepository', useClass: SchoolUserRepository }, + { provide: 'IVoucherRepository', useClass: VoucherRepository }, { provide: 'IUserRepository', useClass: UserRepository }, { provide: 'IAccessTokenRepository', useClass: AccessTokenRepository }, { provide: 'ISchoolRepository', useClass: SchoolRepository }, @@ -91,10 +107,16 @@ import { GetSchoolUsersQueryHandler } from 'src/Application/School/Query/User/Ge GetSchoolProductByIdQueryHandler, RemoveSchoolProductCommandHandler, CountSchoolProductsQueryHandler, - AssignUserToSchoolCommandHandler, + AddUserToSchoolCommandHandler, CanUserAccessToSchool, - IsUserAlreadyAssignedToSchool, - GetSchoolUsersQueryHandler + IsUserAlreadyAddedToSchool, + GetSchoolUsersQueryHandler, + GetUserByEmailQueryHandler, + IsVoucherAlreadyGenerated, + CreateVoucherCommandHandler, + GetSchoolVouchersQueryHandler, + RemoveVoucherCommandHandler, + RemoveSchoolUserCommandHandler ] }) export class SchoolModule {} diff --git a/client/i18n/fr.json b/client/i18n/fr.json index deeee9d..dd8e3d3 100644 --- a/client/i18n/fr.json +++ b/client/i18n/fr.json @@ -155,12 +155,16 @@ "title": "Edition de l'établissement" }, "users": { + "status": { + "enable": "actif", + "waiting": "en attente d'activation" + }, "delete": { "confirm": "Êtes-vous sûr de vouloir supprimer cet utilisateur ?" }, "add": { - "title": "Créer un compte utilisateur", - "notice": "L'utilisateur que vous allez créer içi aura uniquement à cet établissement." + "title": "Création d'un utilisateur", + "notice": "Veuillez renseigner ci-dessous l'adresse mail de vos contacts au sein de l'établissement. Ils recevront un e-mail les invitant à créer leur compte et choisir leur mot de passe." } }, "dashboard": { @@ -195,7 +199,10 @@ }, "errors": { "already_exist": "Un établissement avec cette référence existe déjà.", - "not_found": "L'établissement recherchée n'existe pas." + "not_found": "L'établissement recherchée n'existe pas.", + "voucher_already_generated": "Une invitation a déjà été générée avec cette adresse email. Veuillez saisir une autre adresse email.", + "voucher_not_found": "Cette invitation n'est plus active.", + "school_user_not_found": "La personne recherchée dans cet établissement n'existe pas." }, "products": { "title": "Produits", @@ -205,7 +212,7 @@ "photographer_unit_price": "Prix photographe TTC" }, "add": { - "title": "Associer un nouveau produit" + "title": "Ajouter un produit" }, "edit": { "title": "Edition du prix unitaire de \"{product}\"", @@ -222,6 +229,7 @@ }, "errors": { "already_exist": "Ce produit a déjà été associé à l'établissement", + "parent_price_should_be_greater_than_photogtapher_price": "Le prix photographe doit être supérieur au prix parent.", "not_found": "Le produit de l'établissement recherché n'existe pas." } } diff --git a/client/src/components/badges/GreenBadge.svelte b/client/src/components/badges/GreenBadge.svelte new file mode 100644 index 0000000..2b8c545 --- /dev/null +++ b/client/src/components/badges/GreenBadge.svelte @@ -0,0 +1,8 @@ + + + + {value} + diff --git a/client/src/components/badges/OrangeBadge.svelte b/client/src/components/badges/OrangeBadge.svelte new file mode 100644 index 0000000..51a1620 --- /dev/null +++ b/client/src/components/badges/OrangeBadge.svelte @@ -0,0 +1,8 @@ + + + + {value} + diff --git a/client/src/components/badges/RedBadge.svelte b/client/src/components/badges/RedBadge.svelte new file mode 100644 index 0000000..fc67f31 --- /dev/null +++ b/client/src/components/badges/RedBadge.svelte @@ -0,0 +1,8 @@ + + + + {value} + diff --git a/client/src/routes/admin/schools/[id]/_SchoolUsers.svelte b/client/src/routes/admin/schools/[id]/_SchoolUsers.svelte index 42d108c..5594d45 100644 --- a/client/src/routes/admin/schools/[id]/_SchoolUsers.svelte +++ b/client/src/routes/admin/schools/[id]/_SchoolUsers.svelte @@ -1,19 +1,25 @@ + +
+ +