diff --git a/src/allowance/allowance.module.ts b/src/allowance/allowance.module.ts new file mode 100644 index 0000000..cd9c8ac --- /dev/null +++ b/src/allowance/allowance.module.ts @@ -0,0 +1,20 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { JuniorModule } from '~/junior/junior.module'; +import { AllowanceChangeRequestController, AllowancesController } from './controllers'; +import { Allowance, AllowanceChangeRequest } from './entities'; +import { AllowanceChangeRequestsRepository, AllowancesRepository } from './repositories'; +import { AllowanceChangeRequestsService, AllowancesService } from './services'; + +@Module({ + controllers: [AllowancesController, AllowanceChangeRequestController], + imports: [TypeOrmModule.forFeature([Allowance, AllowanceChangeRequest]), JuniorModule], + providers: [ + AllowancesService, + AllowancesRepository, + AllowanceChangeRequestsService, + AllowanceChangeRequestsRepository, + ], + exports: [], +}) +export class AllowanceModule {} diff --git a/src/allowance/controllers/allowance-change-request.controller.ts b/src/allowance/controllers/allowance-change-request.controller.ts new file mode 100644 index 0000000..e2ed0ef --- /dev/null +++ b/src/allowance/controllers/allowance-change-request.controller.ts @@ -0,0 +1,80 @@ +import { Body, Controller, Get, HttpCode, HttpStatus, Param, Patch, Post, Query, UseGuards } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { Roles } from '~/auth/enums'; +import { IJwtPayload } from '~/auth/interfaces'; +import { AllowedRoles, AuthenticatedUser } from '~/common/decorators'; +import { RolesGuard } from '~/common/guards'; +import { ApiDataPageResponse, ApiDataResponse } from '~/core/decorators'; +import { PageOptionsRequestDto } from '~/core/dtos'; +import { CustomParseUUIDPipe } from '~/core/pipes'; +import { ResponseFactory } from '~/core/utils'; +import { CreateAllowanceChangeRequestDto } from '../dtos/request'; +import { AllowanceChangeRequestResponseDto } from '../dtos/response'; +import { AllowanceChangeRequestsService } from '../services'; + +@Controller('allowance-change-requests') +@ApiTags('Allowance Change Requests') +@ApiBearerAuth() +export class AllowanceChangeRequestController { + constructor(private readonly allowanceChangeRequestsService: AllowanceChangeRequestsService) {} + + @Post() + @UseGuards(RolesGuard) + @AllowedRoles(Roles.JUNIOR) + @HttpCode(HttpStatus.NO_CONTENT) + requestAllowanceChange(@AuthenticatedUser() { sub }: IJwtPayload, @Body() body: CreateAllowanceChangeRequestDto) { + return this.allowanceChangeRequestsService.createAllowanceChangeRequest(sub, body); + } + + @Get() + @UseGuards(RolesGuard) + @AllowedRoles(Roles.GUARDIAN) + @ApiDataPageResponse(AllowanceChangeRequestResponseDto) + async findAllowanceChangeRequests(@AuthenticatedUser() { sub }: IJwtPayload, @Query() query: PageOptionsRequestDto) { + const [requests, itemCount] = await this.allowanceChangeRequestsService.findAllowanceChangeRequests(sub, query); + + return ResponseFactory.dataPage( + requests.map((request) => new AllowanceChangeRequestResponseDto(request)), + { + itemCount, + page: query.page, + size: query.size, + }, + ); + } + + @Get('/:changeRequestId') + @UseGuards(RolesGuard) + @AllowedRoles(Roles.GUARDIAN) + @ApiDataResponse(AllowanceChangeRequestResponseDto) + async findAllowanceChangeRequestById( + @AuthenticatedUser() { sub }: IJwtPayload, + @Param('changeRequestId', CustomParseUUIDPipe) changeRequestId: string, + ) { + const request = await this.allowanceChangeRequestsService.findAllowanceChangeRequestById(sub, changeRequestId); + + return ResponseFactory.data(new AllowanceChangeRequestResponseDto(request)); + } + + @Patch(':changeRequestId/approve') + @UseGuards(RolesGuard) + @AllowedRoles(Roles.GUARDIAN) + @HttpCode(HttpStatus.NO_CONTENT) + approveAllowanceChangeRequest( + @AuthenticatedUser() { sub }: IJwtPayload, + @Param('changeRequestId', CustomParseUUIDPipe) changeRequestId: string, + ) { + return this.allowanceChangeRequestsService.approveAllowanceChangeRequest(sub, changeRequestId); + } + + @Patch(':changeRequestId/reject') + @UseGuards(RolesGuard) + @AllowedRoles(Roles.GUARDIAN) + @HttpCode(HttpStatus.NO_CONTENT) + rejectAllowanceChangeRequest( + @AuthenticatedUser() { sub }: IJwtPayload, + @Param('changeRequestId', CustomParseUUIDPipe) changeRequestId: string, + ) { + return this.allowanceChangeRequestsService.rejectAllowanceChangeRequest(sub, changeRequestId); + } +} diff --git a/src/allowance/controllers/allowances.controller.ts b/src/allowance/controllers/allowances.controller.ts new file mode 100644 index 0000000..f028fff --- /dev/null +++ b/src/allowance/controllers/allowances.controller.ts @@ -0,0 +1,72 @@ +import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Query, UseGuards } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { Roles } from '~/auth/enums'; +import { IJwtPayload } from '~/auth/interfaces'; +import { AllowedRoles, AuthenticatedUser } from '~/common/decorators'; +import { RolesGuard } from '~/common/guards'; +import { ApiDataPageResponse, ApiDataResponse } from '~/core/decorators'; +import { PageOptionsRequestDto } from '~/core/dtos'; +import { CustomParseUUIDPipe } from '~/core/pipes'; +import { ResponseFactory } from '~/core/utils'; +import { CreateAllowanceRequestDto } from '../dtos/request'; +import { AllowanceResponseDto } from '../dtos/response'; +import { AllowancesService } from '../services'; + +@Controller('allowances') +@ApiTags('Allowances') +@ApiBearerAuth() +export class AllowancesController { + constructor(private readonly allowancesService: AllowancesService) {} + + @Post() + @UseGuards(RolesGuard) + @AllowedRoles(Roles.GUARDIAN) + @ApiDataResponse(AllowanceResponseDto) + async createAllowance(@AuthenticatedUser() { sub }: IJwtPayload, @Body() body: CreateAllowanceRequestDto) { + const allowance = await this.allowancesService.createAllowance(sub, body); + + return ResponseFactory.data(new AllowanceResponseDto(allowance)); + } + + @Get() + @UseGuards(RolesGuard) + @AllowedRoles(Roles.GUARDIAN) + @ApiDataPageResponse(AllowanceResponseDto) + async findAllowances(@AuthenticatedUser() { sub }: IJwtPayload, @Query() query: PageOptionsRequestDto) { + const [allowances, itemCount] = await this.allowancesService.findAllowances(sub, query); + + return ResponseFactory.dataPage( + allowances.map((allowance) => new AllowanceResponseDto(allowance)), + { + itemCount, + page: query.page, + size: query.size, + }, + ); + } + + @Get(':allowanceId') + @UseGuards(RolesGuard) + @AllowedRoles(Roles.GUARDIAN) + @ApiDataResponse(AllowanceResponseDto) + async findAllowanceById( + @AuthenticatedUser() { sub }: IJwtPayload, + @Param('allowanceId', CustomParseUUIDPipe) allowanceId: string, + ) { + const allowance = await this.allowancesService.findAllowanceById(allowanceId, sub); + + return ResponseFactory.data(new AllowanceResponseDto(allowance)); + } + + @Delete(':allowanceId') + @UseGuards(RolesGuard) + @AllowedRoles(Roles.GUARDIAN) + @ApiDataResponse(AllowanceResponseDto) + @HttpCode(HttpStatus.NO_CONTENT) + deleteAllowance( + @AuthenticatedUser() { sub }: IJwtPayload, + @Param('allowanceId', CustomParseUUIDPipe) allowanceId: string, + ) { + return this.allowancesService.deleteAllowance(sub, allowanceId); + } +} diff --git a/src/allowance/controllers/index.ts b/src/allowance/controllers/index.ts new file mode 100644 index 0000000..0c78bea --- /dev/null +++ b/src/allowance/controllers/index.ts @@ -0,0 +1,2 @@ +export * from './allowance-change-request.controller'; +export * from './allowances.controller'; diff --git a/src/allowance/dtos/request/create-allowance-change.request.dto.ts b/src/allowance/dtos/request/create-allowance-change.request.dto.ts new file mode 100644 index 0000000..350bf5e --- /dev/null +++ b/src/allowance/dtos/request/create-allowance-change.request.dto.ts @@ -0,0 +1,28 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsNumber, IsPositive, IsString, IsUUID } from 'class-validator'; +import { i18nValidationMessage as i18n } from 'nestjs-i18n'; + +export class CreateAllowanceChangeRequestDto { + @ApiProperty({ example: 'I want to change the amount of the allowance' }) + @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'allowanceChangeRequest.reason' }) }) + @IsNotEmpty({ + message: i18n('validation.IsNotEmpty', { path: 'general', property: 'allowanceChangeRequest.reason' }), + }) + reason!: string; + + @ApiProperty({ example: 100 }) + @IsNumber( + {}, + { message: i18n('validation.IsNumber', { path: 'general', property: 'allowanceChangeRequest.amount' }) }, + ) + @IsPositive({ + message: i18n('validation.IsPositive', { path: 'general', property: 'allowanceChangeRequest.amount' }), + }) + amount!: number; + + @ApiProperty({ example: 'd641bb71-2e7c-4e62-96fa-2785f0a651c6' }) + @IsUUID('4', { + message: i18n('validation.IsUUID', { path: 'general', property: 'allowanceChangeRequest.allowanceId' }), + }) + allowanceId!: string; +} diff --git a/src/allowance/dtos/request/create-allowance.request.dto.ts b/src/allowance/dtos/request/create-allowance.request.dto.ts new file mode 100644 index 0000000..2aed6ef --- /dev/null +++ b/src/allowance/dtos/request/create-allowance.request.dto.ts @@ -0,0 +1,52 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; +import { IsDate, IsEnum, IsInt, IsNotEmpty, IsNumber, IsPositive, IsString, IsUUID, ValidateIf } from 'class-validator'; +import moment from 'moment'; +import { i18nValidationMessage as i18n } from 'nestjs-i18n'; +import { AllowanceFrequency, AllowanceType } from '~/allowance/enums'; +export class CreateAllowanceRequestDto { + @ApiProperty({ example: 'Allowance name' }) + @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'allowance.name' }) }) + @IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'allowance.name' }) }) + name!: string; + + @ApiProperty({ example: 100 }) + @IsNumber({}, { message: i18n('validation.IsNumber', { path: 'general', property: 'allowance.amount' }) }) + @IsPositive({ message: i18n('validation.IsPositive', { path: 'general', property: 'allowance.amount' }) }) + amount!: number; + + @ApiProperty({ example: AllowanceFrequency.WEEKLY }) + @IsEnum(AllowanceFrequency, { + message: i18n('validation.IsEnum', { path: 'general', property: 'allowance.frequency' }), + }) + frequency!: AllowanceFrequency; + + @ApiProperty({ example: AllowanceType.BY_END_DATE }) + @IsEnum(AllowanceType, { message: i18n('validation.IsEnum', { path: 'general', property: 'allowance.type' }) }) + type!: AllowanceType; + + @ApiProperty({ example: new Date() }) + @IsDate({ message: i18n('validation.IsDate', { path: 'general', property: 'allowance.startDate' }) }) + @Transform(({ value }) => moment(value).startOf('day').toDate()) + startDate!: Date; + + @ApiProperty({ example: new Date() }) + @IsDate({ message: i18n('validation.IsDate', { path: 'general', property: 'allowance.endDate' }) }) + @Transform(({ value }) => moment(value).endOf('day').toDate()) + @ValidateIf((o) => o.type === AllowanceType.BY_END_DATE) + endDate?: Date; + + @ApiProperty({ example: 10 }) + @IsNumber( + {}, + { message: i18n('validation.IsNumber', { path: 'general', property: 'allowance.numberOfTransactions' }) }, + ) + @IsInt({ message: i18n('validation.IsInt', { path: 'general', property: 'allowance.amount' }) }) + @IsPositive({ message: i18n('validation.IsPositive', { path: 'general', property: 'allowance.amount' }) }) + @ValidateIf((o) => o.type === AllowanceType.BY_COUNT) + numberOfTransactions?: number; + + @ApiProperty({ example: 'e7b1b3b4-4b3b-4b3b-4b3b-4b3b4b3b4b3b' }) + @IsUUID('4', { message: i18n('validation.IsUUID', { path: 'general', property: 'allowance.juniorId' }) }) + juniorId!: string; +} diff --git a/src/allowance/dtos/request/index.ts b/src/allowance/dtos/request/index.ts new file mode 100644 index 0000000..bde72ae --- /dev/null +++ b/src/allowance/dtos/request/index.ts @@ -0,0 +1,2 @@ +export * from './create-allowance-change.request.dto'; +export * from './create-allowance.request.dto'; diff --git a/src/allowance/dtos/response/allowance-change-request.response.dto.ts b/src/allowance/dtos/response/allowance-change-request.response.dto.ts new file mode 100644 index 0000000..0d9b674 --- /dev/null +++ b/src/allowance/dtos/response/allowance-change-request.response.dto.ts @@ -0,0 +1,45 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { AllowanceChangeRequest } from '~/allowance/entities'; +import { AllowanceChangeRequestStatus } from '~/allowance/enums'; +import { JuniorResponseDto } from '~/junior/dtos/response'; + +export class AllowanceChangeRequestResponseDto { + @ApiProperty({ example: 'd641bb71-2e7c-4e62-96fa-2785f0a651c6' }) + id!: string; + + @ApiProperty({ example: AllowanceChangeRequestStatus.APPROVED }) + status!: AllowanceChangeRequestStatus; + + @ApiProperty({ example: 'Allowance name' }) + name!: string; + + @ApiProperty({ example: '100' }) + oldAmount!: number; + + @ApiProperty({ example: '200' }) + newAmount!: number; + + @ApiProperty({ example: 'Some reason' }) + reason!: string; + + @ApiProperty({ example: 'd641bb71-2e7c-4e62-96fa-2785f0a651c6' }) + allowanceId!: string; + + @ApiProperty({ type: JuniorResponseDto }) + junior!: JuniorResponseDto; + + @ApiProperty({ example: new Date() }) + createdAt!: Date; + + constructor(allowanceChangeRequest: AllowanceChangeRequest) { + this.id = allowanceChangeRequest.id; + this.status = allowanceChangeRequest.status; + this.name = allowanceChangeRequest.allowance.name; + this.oldAmount = allowanceChangeRequest.allowance.amount; + this.newAmount = allowanceChangeRequest.amount; + this.reason = allowanceChangeRequest.reason; + this.allowanceId = allowanceChangeRequest.allowanceId; + this.junior = new JuniorResponseDto(allowanceChangeRequest.allowance.junior); + this.createdAt = allowanceChangeRequest.createdAt; + } +} diff --git a/src/allowance/dtos/response/allowance.response.dto.ts b/src/allowance/dtos/response/allowance.response.dto.ts new file mode 100644 index 0000000..95b4542 --- /dev/null +++ b/src/allowance/dtos/response/allowance.response.dto.ts @@ -0,0 +1,53 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Allowance } from '~/allowance/entities'; +import { AllowanceFrequency, AllowanceType } from '~/allowance/enums'; +import { JuniorResponseDto } from '~/junior/dtos/response'; + +export class AllowanceResponseDto { + @ApiProperty({ example: 'd641bb71-2e7c-4e62-96fa-2785f0a651c6' }) + id!: string; + + @ApiProperty({ example: 'Allowance name' }) + name!: string; + + @ApiProperty({ example: 100 }) + amount!: number; + + @ApiProperty({ example: AllowanceFrequency.WEEKLY }) + frequency!: AllowanceFrequency; + + @ApiProperty({ example: AllowanceType.BY_END_DATE }) + type!: AllowanceType; + + @ApiProperty({ example: new Date() }) + startDate!: Date; + + @ApiProperty({ example: new Date() }) + endDate?: Date; + + @ApiProperty({ example: 10 }) + numberOfTransactions?: number; + + @ApiProperty({ type: JuniorResponseDto }) + junior!: JuniorResponseDto; + + @ApiProperty({ example: new Date() }) + createdAt!: Date; + + @ApiProperty({ example: new Date() }) + updatedAt!: Date; + + constructor(allowance: Allowance) { + this.id = allowance.id; + this.name = allowance.name; + this.amount = allowance.amount; + this.frequency = allowance.frequency; + this.type = allowance.type; + this.startDate = allowance.startDate; + this.endDate = allowance.endDate; + this.numberOfTransactions = allowance.numberOfTransactions; + this.junior = new JuniorResponseDto(allowance.junior); + this.createdAt = allowance.createdAt; + this.updatedAt = allowance.updatedAt; + } +} diff --git a/src/allowance/dtos/response/index.ts b/src/allowance/dtos/response/index.ts new file mode 100644 index 0000000..7147613 --- /dev/null +++ b/src/allowance/dtos/response/index.ts @@ -0,0 +1,2 @@ +export * from './allowance-change-request.response.dto'; +export * from './allowance.response.dto'; diff --git a/src/allowance/entities/allowance-change-request.entity.ts b/src/allowance/entities/allowance-change-request.entity.ts new file mode 100644 index 0000000..57ab265 --- /dev/null +++ b/src/allowance/entities/allowance-change-request.entity.ts @@ -0,0 +1,45 @@ +import { + Column, + CreateDateColumn, + Entity, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; +import { AllowanceChangeRequestStatus } from '../enums'; +import { Allowance } from './allowance.entity'; + +@Entity('allowance_change_requests') +export class AllowanceChangeRequest { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ type: 'text', name: 'reason' }) + reason!: string; + + @Column({ + type: 'decimal', + precision: 10, + scale: 2, + name: 'amount', + transformer: { to: (value: number) => value, from: (value: string) => parseFloat(value) }, + }) + amount!: number; + + @Column({ type: 'varchar', length: 255, name: 'status', default: AllowanceChangeRequestStatus.PENDING }) + status!: AllowanceChangeRequestStatus; + + @Column({ type: 'uuid', name: 'allowance_id' }) + allowanceId!: string; + + @ManyToOne(() => Allowance, (allowance) => allowance.changeRequests) + @JoinColumn({ name: 'allowance_id' }) + allowance!: Allowance; + + @CreateDateColumn({ name: 'created_at', type: 'timestamp with time zone', default: () => 'CURRENT_TIMESTAMP' }) + createdAt!: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamp with time zone', default: () => 'CURRENT_TIMESTAMP' }) + updatedAt!: Date; +} diff --git a/src/allowance/entities/allowance.entity.ts b/src/allowance/entities/allowance.entity.ts new file mode 100644 index 0000000..b82da96 --- /dev/null +++ b/src/allowance/entities/allowance.entity.ts @@ -0,0 +1,87 @@ +import { + Column, + CreateDateColumn, + DeleteDateColumn, + Entity, + JoinColumn, + ManyToOne, + OneToMany, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; +import { Guardian } from '~/guardian/entities/guradian.entity'; +import { Junior } from '~/junior/entities'; +import { AllowanceFrequency, AllowanceType } from '../enums'; +import { AllowanceChangeRequest } from './allowance-change-request.entity'; +/** + * id string [primary key] + amount number + freq enum //[DAILY,WEEKLY,MONTHLY] + type enum // [BY_END_DATE, BY_COUNT] + startDate date + endDate date + numberOfTransactions number + guardianId string [ref:> Guardians.id] + juniorId string [ref:> Juniors.id] + createdAt datetime + updatedAt datetime + * + */ +@Entity('allowances') +export class Allowance { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ type: 'varchar', length: 255, name: 'name' }) + name!: string; + + @Column({ + type: 'decimal', + precision: 10, + scale: 2, + name: 'amount', + transformer: { to: (value: number) => value, from: (value: string) => parseFloat(value) }, + }) + amount!: number; + + @Column({ type: 'varchar', length: 255, name: 'frequency' }) + frequency!: AllowanceFrequency; + + @Column({ type: 'varchar', length: 255, name: 'type' }) + type!: AllowanceType; + + @Column({ type: 'timestamp with time zone', name: 'start_date' }) + startDate!: Date; + + @Column({ type: 'timestamp with time zone', name: 'end_date', nullable: true }) + endDate?: Date; + + @Column({ type: 'int', name: 'number_of_transactions', nullable: true }) + numberOfTransactions?: number; + + @Column({ type: 'uuid', name: 'guardian_id' }) + guardianId!: string; + + @Column({ type: 'uuid', name: 'junior_id' }) + juniorId!: string; + + @ManyToOne(() => Guardian, (guardian) => guardian.allowances) + @JoinColumn({ name: 'guardian_id' }) + guardian!: Guardian; + + @ManyToOne(() => Junior, (junior) => junior.allowances) + @JoinColumn({ name: 'junior_id' }) + junior!: Junior; + + @OneToMany(() => AllowanceChangeRequest, (changeRequest) => changeRequest.allowance) + changeRequests!: AllowanceChangeRequest[]; + + @CreateDateColumn({ name: 'created_at', type: 'timestamp with time zone', default: () => 'CURRENT_TIMESTAMP' }) + createdAt!: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamp with time zone', default: () => 'CURRENT_TIMESTAMP' }) + updatedAt!: Date; + + @DeleteDateColumn({ name: 'deleted_at', type: 'timestamp with time zone', nullable: true }) + deletedAt?: Date; +} diff --git a/src/allowance/entities/index.ts b/src/allowance/entities/index.ts new file mode 100644 index 0000000..450ddc4 --- /dev/null +++ b/src/allowance/entities/index.ts @@ -0,0 +1,2 @@ +export * from './allowance-change-request.entity'; +export * from './allowance.entity'; diff --git a/src/allowance/enums/allowance-change-request-status.enum.ts b/src/allowance/enums/allowance-change-request-status.enum.ts new file mode 100644 index 0000000..e257bf1 --- /dev/null +++ b/src/allowance/enums/allowance-change-request-status.enum.ts @@ -0,0 +1,5 @@ +export enum AllowanceChangeRequestStatus { + PENDING = 'PENDING', + APPROVED = 'APPROVED', + REJECTED = 'REJECTED', +} diff --git a/src/allowance/enums/allowance-frequency.enum.ts b/src/allowance/enums/allowance-frequency.enum.ts new file mode 100644 index 0000000..4b6a5ec --- /dev/null +++ b/src/allowance/enums/allowance-frequency.enum.ts @@ -0,0 +1,5 @@ +export enum AllowanceFrequency { + DAILY = 'DAILY', + WEEKLY = 'WEEKLY', + MONTHLY = 'MONTHLY', +} diff --git a/src/allowance/enums/allowance-type.enum.ts b/src/allowance/enums/allowance-type.enum.ts new file mode 100644 index 0000000..4fa9180 --- /dev/null +++ b/src/allowance/enums/allowance-type.enum.ts @@ -0,0 +1,4 @@ +export enum AllowanceType { + BY_END_DATE = 'BY_END_DATE', + BY_COUNT = 'BY_COUNT', +} diff --git a/src/allowance/enums/index.ts b/src/allowance/enums/index.ts new file mode 100644 index 0000000..d537ace --- /dev/null +++ b/src/allowance/enums/index.ts @@ -0,0 +1,3 @@ +export * from './allowance-change-request-status.enum'; +export * from './allowance-frequency.enum'; +export * from './allowance-type.enum'; diff --git a/src/allowance/repositories/allowance-change-request.repository.ts b/src/allowance/repositories/allowance-change-request.repository.ts new file mode 100644 index 0000000..de2941f --- /dev/null +++ b/src/allowance/repositories/allowance-change-request.repository.ts @@ -0,0 +1,50 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { FindOptionsWhere, Repository } from 'typeorm'; +import { PageOptionsRequestDto } from '~/core/dtos'; +import { CreateAllowanceChangeRequestDto } from '../dtos/request'; +import { AllowanceChangeRequest } from '../entities'; +import { AllowanceChangeRequestStatus } from '../enums'; +const ONE = 1; +@Injectable() +export class AllowanceChangeRequestsRepository { + constructor( + @InjectRepository(AllowanceChangeRequest) + private readonly allowanceChangeRequestsRepository: Repository, + ) {} + + createAllowanceChangeRequest(allowanceId: string, body: CreateAllowanceChangeRequestDto) { + return this.allowanceChangeRequestsRepository.save( + this.allowanceChangeRequestsRepository.create({ + allowanceId, + amount: body.amount, + reason: body.reason, + }), + ); + } + + findAllowanceChangeRequestBy(where: FindOptionsWhere, withRelations = false) { + const relations = withRelations + ? ['allowance', 'allowance.junior', 'allowance.junior.customer', 'allowance.junior.customer.profilePicture'] + : []; + return this.allowanceChangeRequestsRepository.findOne({ where, relations }); + } + + updateAllowanceChangeRequestStatus(requestId: string, status: AllowanceChangeRequestStatus) { + return this.allowanceChangeRequestsRepository.update({ id: requestId }, { status }); + } + + findAllowanceChangeRequests(guardianId: string, query: PageOptionsRequestDto) { + return this.allowanceChangeRequestsRepository.findAndCount({ + where: { allowance: { guardianId } }, + take: query.size, + skip: query.size * (query.page - ONE), + relations: [ + 'allowance', + 'allowance.junior', + 'allowance.junior.customer', + 'allowance.junior.customer.profilePicture', + ], + }); + } +} diff --git a/src/allowance/repositories/allowances.repository.ts b/src/allowance/repositories/allowances.repository.ts new file mode 100644 index 0000000..b584a7b --- /dev/null +++ b/src/allowance/repositories/allowances.repository.ts @@ -0,0 +1,47 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { PageOptionsRequestDto } from '~/core/dtos'; +import { CreateAllowanceRequestDto } from '../dtos/request'; +import { Allowance } from '../entities'; +const ONE = 1; +@Injectable() +export class AllowancesRepository { + constructor(@InjectRepository(Allowance) private readonly allowancesRepository: Repository) {} + + createAllowance(guardianId: string, body: CreateAllowanceRequestDto) { + return this.allowancesRepository.save( + this.allowancesRepository.create({ + guardianId, + name: body.name, + amount: body.amount, + frequency: body.frequency, + type: body.type, + startDate: body.startDate, + endDate: body.endDate, + numberOfTransactions: body.numberOfTransactions, + juniorId: body.juniorId, + }), + ); + } + + findAllowanceById(allowanceId: string, guardianId?: string) { + return this.allowancesRepository.findOne({ + where: { id: allowanceId, guardianId }, + relations: ['junior', 'junior.customer', 'junior.customer.profilePicture'], + }); + } + + findAllowances(guardianId: string, query: PageOptionsRequestDto) { + return this.allowancesRepository.findAndCount({ + where: { guardianId }, + relations: ['junior', 'junior.customer', 'junior.customer.profilePicture'], + take: query.size, + skip: query.size * (query.page - ONE), + }); + } + + deleteAllowance(guardianId: string, allowanceId: string) { + return this.allowancesRepository.softDelete({ id: allowanceId, guardianId }); + } +} diff --git a/src/allowance/repositories/index.ts b/src/allowance/repositories/index.ts new file mode 100644 index 0000000..93cb12e --- /dev/null +++ b/src/allowance/repositories/index.ts @@ -0,0 +1,2 @@ +export * from './allowance-change-request.repository'; +export * from './allowances.repository'; diff --git a/src/allowance/services/allowance-change-requests.service.ts b/src/allowance/services/allowance-change-requests.service.ts new file mode 100644 index 0000000..77916b3 --- /dev/null +++ b/src/allowance/services/allowance-change-requests.service.ts @@ -0,0 +1,115 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { FindOptionsWhere } from 'typeorm'; +import { PageOptionsRequestDto } from '~/core/dtos'; +import { OciService } from '~/document/services'; +import { CreateAllowanceChangeRequestDto } from '../dtos/request'; +import { AllowanceChangeRequest } from '../entities'; +import { AllowanceChangeRequestStatus } from '../enums'; +import { AllowanceChangeRequestsRepository } from '../repositories'; +import { AllowancesService } from './allowances.service'; + +@Injectable() +export class AllowanceChangeRequestsService { + constructor( + private readonly allowanceChangeRequestsRepository: AllowanceChangeRequestsRepository, + private readonly ociService: OciService, + private readonly allowanceService: AllowancesService, + ) {} + + async createAllowanceChangeRequest(juniorId: string, body: CreateAllowanceChangeRequestDto) { + const allowance = await this.allowanceService.validateAllowanceForJunior(juniorId, body.allowanceId); + + if (allowance.amount === body.amount) { + throw new BadRequestException('ALLOWANCE_CHANGE_REQUEST.SAME_AMOUNT'); + } + + const requestWithTheSameAmount = await this.findAllowanceChangeRequestBy({ + allowanceId: body.allowanceId, + amount: body.amount, + status: AllowanceChangeRequestStatus.PENDING, + }); + + if (requestWithTheSameAmount) { + throw new BadRequestException('ALLOWANCE_CHANGE_REQUEST.SAME_AMOUNT_PENDING'); + } + + return this.allowanceChangeRequestsRepository.createAllowanceChangeRequest(body.allowanceId, body); + } + + findAllowanceChangeRequestBy(where: FindOptionsWhere) { + return this.allowanceChangeRequestsRepository.findAllowanceChangeRequestBy(where); + } + + async approveAllowanceChangeRequest(guardianId: string, requestId: string) { + const request = await this.findAllowanceChangeRequestBy({ id: requestId, allowance: { guardianId } }); + + if (!request) { + throw new BadRequestException('ALLOWANCE_CHANGE_REQUEST.NOT_FOUND'); + } + if (request.status === AllowanceChangeRequestStatus.APPROVED) { + throw new BadRequestException('ALLOWANCE_CHANGE_REQUEST.ALREADY_APPROVED'); + } + return this.allowanceChangeRequestsRepository.updateAllowanceChangeRequestStatus( + requestId, + AllowanceChangeRequestStatus.APPROVED, + ); + } + + async rejectAllowanceChangeRequest(guardianId: string, requestId: string) { + const request = await this.findAllowanceChangeRequestBy({ id: requestId, allowance: { guardianId } }); + + if (!request) { + throw new BadRequestException('ALLOWANCE_CHANGE_REQUEST.NOT_FOUND'); + } + if (request.status === AllowanceChangeRequestStatus.REJECTED) { + throw new BadRequestException('ALLOWANCE_CHANGE_REQUEST.ALREADY_REJECTED'); + } + return this.allowanceChangeRequestsRepository.updateAllowanceChangeRequestStatus( + requestId, + AllowanceChangeRequestStatus.REJECTED, + ); + } + + async findAllowanceChangeRequests( + guardianId: string, + query: PageOptionsRequestDto, + ): Promise<[AllowanceChangeRequest[], number]> { + const [requests, itemCount] = await this.allowanceChangeRequestsRepository.findAllowanceChangeRequests( + guardianId, + query, + ); + + await this.prepareAllowanceChangeRequestsImages(requests); + + return [requests, itemCount]; + } + + async findAllowanceChangeRequestById(guardianId: string, requestId: string) { + const request = await this.allowanceChangeRequestsRepository.findAllowanceChangeRequestBy( + { + id: requestId, + allowance: { guardianId }, + }, + true, + ); + + if (!request) { + throw new BadRequestException('ALLOWANCE_CHANGE_REQUEST.NOT_FOUND'); + } + + await this.prepareAllowanceChangeRequestsImages([request]); + + return request; + } + + private prepareAllowanceChangeRequestsImages(requests: AllowanceChangeRequest[]) { + return Promise.all( + requests.map(async (request) => { + const profilePicture = request.allowance.junior.customer.profilePicture; + if (profilePicture) { + profilePicture.url = await this.ociService.generatePreSignedUrl(profilePicture); + } + }), + ); + } +} diff --git a/src/allowance/services/allowances.service.ts b/src/allowance/services/allowances.service.ts new file mode 100644 index 0000000..96f9194 --- /dev/null +++ b/src/allowance/services/allowances.service.ts @@ -0,0 +1,85 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import moment from 'moment'; +import { PageOptionsRequestDto } from '~/core/dtos'; +import { OciService } from '~/document/services'; +import { JuniorService } from '~/junior/services'; +import { CreateAllowanceRequestDto } from '../dtos/request'; +import { Allowance } from '../entities'; +import { AllowancesRepository } from '../repositories'; + +@Injectable() +export class AllowancesService { + constructor( + private readonly allowancesRepository: AllowancesRepository, + private readonly juniorService: JuniorService, + private readonly ociService: OciService, + ) {} + + async createAllowance(guardianId: string, body: CreateAllowanceRequestDto) { + if (moment(body.startDate).isBefore(moment().startOf('day'))) { + throw new BadRequestException('ALLOWANCE.START_DATE_BEFORE_TODAY'); + } + if (moment(body.startDate).isAfter(body.endDate)) { + throw new BadRequestException('ALLOWANCE.START_DATE_AFTER_END_DATE'); + } + + const doesJuniorBelongToGuardian = await this.juniorService.doesJuniorBelongToGuardian(guardianId, body.juniorId); + + if (!doesJuniorBelongToGuardian) { + throw new BadRequestException('JUNIOR.DOES_NOT_BELONG_TO_GUARDIAN'); + } + + const allowance = await this.allowancesRepository.createAllowance(guardianId, body); + + return this.findAllowanceById(allowance.id); + } + + async findAllowanceById(allowanceId: string, guardianId?: string) { + const allowance = await this.allowancesRepository.findAllowanceById(allowanceId, guardianId); + + if (!allowance) { + throw new BadRequestException('ALLOWANCE.NOT_FOUND'); + } + await this.prepareAllowanceDocuments([allowance]); + return allowance; + } + + async findAllowances(guardianId: string, query: PageOptionsRequestDto): Promise<[Allowance[], number]> { + const [allowances, itemCount] = await this.allowancesRepository.findAllowances(guardianId, query); + await this.prepareAllowanceDocuments(allowances); + return [allowances, itemCount]; + } + + async deleteAllowance(guardianId: string, allowanceId: string) { + const { affected } = await this.allowancesRepository.deleteAllowance(guardianId, allowanceId); + + if (!affected) { + throw new BadRequestException('ALLOWANCE.NOT_FOUND'); + } + } + + async validateAllowanceForJunior(juniorId: string, allowanceId: string) { + const allowance = await this.allowancesRepository.findAllowanceById(allowanceId); + + if (!allowance) { + throw new BadRequestException('ALLOWANCE.NOT_FOUND'); + } + + if (allowance.juniorId !== juniorId) { + throw new BadRequestException('ALLOWANCE.DOES_NOT_BELONG_TO_JUNIOR'); + } + + return allowance; + } + + private async prepareAllowanceDocuments(allowance: Allowance[]) { + await Promise.all( + allowance.map(async (allowance) => { + const profilePicture = allowance.junior.customer.profilePicture; + if (profilePicture) { + profilePicture.url = await this.ociService.generatePreSignedUrl(profilePicture); + } + }), + ); + } +} diff --git a/src/allowance/services/index.ts b/src/allowance/services/index.ts new file mode 100644 index 0000000..1302512 --- /dev/null +++ b/src/allowance/services/index.ts @@ -0,0 +1,2 @@ +export * from './allowance-change-requests.service'; +export * from './allowances.service'; diff --git a/src/app.module.ts b/src/app.module.ts index bf206e7..2b1100a 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -6,6 +6,7 @@ import { I18nMiddleware, I18nModule } from 'nestjs-i18n'; import { LoggerModule } from 'nestjs-pino'; import { DataSource } from 'typeorm'; import { addTransactionalDataSource } from 'typeorm-transactional'; +import { AllowanceModule } from './allowance/allowance.module'; import { AuthModule } from './auth/auth.module'; import { CacheModule } from './common/modules/cache/cache.module'; import { LookupModule } from './common/modules/lookup/lookup.module'; @@ -54,9 +55,12 @@ import { TaskModule } from './task/task.module'; AuthModule, CustomerModule, JuniorModule, + TaskModule, GuardianModule, SavingGoalsModule, + AllowanceModule, + MoneyRequestModule, OtpModule, diff --git a/src/db/migrations/1734601976591-create-allowance-entities.ts b/src/db/migrations/1734601976591-create-allowance-entities.ts new file mode 100644 index 0000000..c35ac45 --- /dev/null +++ b/src/db/migrations/1734601976591-create-allowance-entities.ts @@ -0,0 +1,53 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateAllowanceEntities1734601976591 implements MigrationInterface { + name = 'CreateAllowanceEntities1734601976591'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "allowances" + ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "name" character varying(255) NOT NULL, + "amount" numeric(10,2) NOT NULL, + "frequency" character varying(255) NOT NULL, + "type" character varying(255) NOT NULL, + "start_date" TIMESTAMP WITH TIME ZONE NOT NULL, + "end_date" TIMESTAMP WITH TIME ZONE, + "number_of_transactions" integer, + "guardian_id" uuid NOT NULL, + "junior_id" uuid NOT NULL, + "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + "deleted_at" TIMESTAMP WITH TIME ZONE, + CONSTRAINT "PK_3731e781e7c4e932ba4d4213ac1" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE TABLE "allowance_change_requests" + ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "reason" text NOT NULL, + "amount" numeric(10,2) NOT NULL, + "status" character varying(255) NOT NULL DEFAULT 'PENDING', + "allowance_id" uuid NOT NULL, + "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + CONSTRAINT "PK_664715670e1e72c64ce65a078de" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `ALTER TABLE "allowances" ADD CONSTRAINT "FK_80b144a74e630ed63311e97427b" FOREIGN KEY ("guardian_id") REFERENCES "guardians"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "allowances" ADD CONSTRAINT "FK_61e6e612f6d4644f8910d453cc9" FOREIGN KEY ("junior_id") REFERENCES "juniors"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "allowance_change_requests" ADD CONSTRAINT "FK_4ea6382927f50cb93873fae16d2" FOREIGN KEY ("allowance_id") REFERENCES "allowances"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "allowance_change_requests" DROP CONSTRAINT "FK_4ea6382927f50cb93873fae16d2"`); + await queryRunner.query(`ALTER TABLE "allowances" DROP CONSTRAINT "FK_61e6e612f6d4644f8910d453cc9"`); + await queryRunner.query(`ALTER TABLE "allowances" DROP CONSTRAINT "FK_80b144a74e630ed63311e97427b"`); + await queryRunner.query(`DROP TABLE "allowance_change_requests"`); + await queryRunner.query(`DROP TABLE "allowances"`); + } +} diff --git a/src/db/migrations/index.ts b/src/db/migrations/index.ts index 9c91d8d..0b2e6f2 100644 --- a/src/db/migrations/index.ts +++ b/src/db/migrations/index.ts @@ -14,3 +14,4 @@ export * from './1734246386471-create-saving-goals-entities'; export * from './1734247702310-seeds-goals-categories'; export * from './1734262619426-create-junior-registration-token-table'; export * from './1734503895302-create-money-request-entity'; +export * from './1734601976591-create-allowance-entities'; diff --git a/src/guardian/entities/guradian.entity.ts b/src/guardian/entities/guradian.entity.ts index 2b9c15d..be8d625 100644 --- a/src/guardian/entities/guradian.entity.ts +++ b/src/guardian/entities/guradian.entity.ts @@ -9,6 +9,7 @@ import { PrimaryColumn, UpdateDateColumn, } from 'typeorm'; +import { Allowance } from '~/allowance/entities'; import { Customer } from '~/customer/entities'; import { Junior } from '~/junior/entities'; import { MoneyRequest } from '~/money-request/entities'; @@ -35,6 +36,9 @@ export class Guardian extends BaseEntity { @OneToMany(() => MoneyRequest, (moneyRequest) => moneyRequest.reviewer) moneyRequests?: MoneyRequest[]; + @OneToMany(() => Allowance, (allowance) => allowance.guardian) + allowances?: Allowance[]; + @CreateDateColumn({ name: 'created_at', type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' }) createdAt!: Date; diff --git a/src/junior/entities/junior.entity.ts b/src/junior/entities/junior.entity.ts index e0c4a55..ed20d25 100644 --- a/src/junior/entities/junior.entity.ts +++ b/src/junior/entities/junior.entity.ts @@ -10,6 +10,7 @@ import { PrimaryColumn, UpdateDateColumn, } from 'typeorm'; +import { Allowance } from '~/allowance/entities'; import { Customer } from '~/customer/entities'; import { Document } from '~/document/entities'; import { Guardian } from '~/guardian/entities/guradian.entity'; @@ -74,6 +75,9 @@ export class Junior extends BaseEntity { @OneToMany(() => MoneyRequest, (moneyRequest) => moneyRequest.requester) moneyRequests!: MoneyRequest[]; + @OneToMany(() => Allowance, (allowance) => allowance.junior) + allowances!: Allowance[]; + @CreateDateColumn({ name: 'created_at', type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' }) createdAt!: Date; diff --git a/src/junior/junior.module.ts b/src/junior/junior.module.ts index 9901461..ae2fcdd 100644 --- a/src/junior/junior.module.ts +++ b/src/junior/junior.module.ts @@ -15,6 +15,6 @@ import { JuniorService, JuniorTokenService, QrcodeService } from './services'; forwardRef(() => AuthModule), CustomerModule, ], - exports: [JuniorTokenService, JuniorService], + exports: [JuniorService, JuniorTokenService], }) export class JuniorModule {} diff --git a/src/junior/repositories/junior.repository.ts b/src/junior/repositories/junior.repository.ts index 7dd1687..1e84c18 100644 --- a/src/junior/repositories/junior.repository.ts +++ b/src/junior/repositories/junior.repository.ts @@ -13,7 +13,7 @@ export class JuniorRepository { findJuniorsByGuardianId(guardianId: string, pageOptions: PageOptionsRequestDto) { return this.juniorRepository.findAndCount({ where: { guardianId }, - relations: ['customer', 'customer.user'], + relations: ['customer', 'customer.user', 'customer.profilePicture'], skip: (pageOptions.page - FIRST_PAGE) * pageOptions.size, take: pageOptions.size, }); diff --git a/src/junior/services/junior.service.ts b/src/junior/services/junior.service.ts index 24bfa08..913b2ff 100644 --- a/src/junior/services/junior.service.ts +++ b/src/junior/services/junior.service.ts @@ -1,4 +1,4 @@ -import { BadRequestException, Injectable } from '@nestjs/common'; +import { BadRequestException, forwardRef, Inject, Injectable } from '@nestjs/common'; import { Transactional } from 'typeorm-transactional'; import { Roles } from '~/auth/enums'; import { UserService } from '~/auth/services'; @@ -14,9 +14,9 @@ import { JuniorTokenService } from './junior-token.service'; export class JuniorService { constructor( private readonly juniorRepository: JuniorRepository, - private readonly userService: UserService, private readonly customerService: CustomerService, private readonly juniorTokenService: JuniorTokenService, + @Inject(forwardRef(() => UserService)) private readonly userService: UserService, ) {} @Transactional() @@ -86,4 +86,10 @@ export class JuniorService { generateToken(juniorId: string) { return this.juniorTokenService.generateToken(juniorId); } + + async doesJuniorBelongToGuardian(guardianId: string, juniorId: string) { + const junior = await this.findJuniorById(juniorId, false, guardianId); + + return !!junior; + } }