diff --git a/src/app.module.ts b/src/app.module.ts index 2e3db91..3457f09 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -28,6 +28,7 @@ import { HealthModule } from './health/health.module'; import { JuniorModule } from './junior/junior.module'; import { UserModule } from './user/user.module'; import { WebhookModule } from './webhook/webhook.module'; +import { MoneyRequestModule } from './money-request/money-request.module'; @Module({ controllers: [], @@ -74,6 +75,7 @@ import { WebhookModule } from './webhook/webhook.module'; CronModule, NeoLeapModule, WebhookModule, + MoneyRequestModule, ], providers: [ // Global Pipes diff --git a/src/db/migrations/1757349525708-create-money-requests-table.ts b/src/db/migrations/1757349525708-create-money-requests-table.ts new file mode 100644 index 0000000..ebcfac9 --- /dev/null +++ b/src/db/migrations/1757349525708-create-money-requests-table.ts @@ -0,0 +1,25 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateMoneyRequestsTable1757349525708 implements MigrationInterface { + name = 'CreateMoneyRequestsTable1757349525708'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "money_requests" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "amount" numeric(10,2) NOT NULL, "reason" character varying NOT NULL, "status" character varying NOT NULL DEFAULT 'PENDING', "rejection_reason" text, "junior_id" uuid NOT NULL, "guardian_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_28cff23e9fb06cd5dbf73cd53e7" PRIMARY KEY ("id"))`, + ); + await queryRunner.query(`ALTER TABLE "cards" ALTER COLUMN "color" SET DEFAULT 'DEEP_MAGENTA'`); + await queryRunner.query( + `ALTER TABLE "money_requests" ADD CONSTRAINT "FK_f7084c83efe7efaca37297d57ae" FOREIGN KEY ("junior_id") REFERENCES "juniors"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "money_requests" ADD CONSTRAINT "FK_09eadf4c4133b323f467ffc90f3" FOREIGN KEY ("guardian_id") REFERENCES "guardians"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "money_requests" DROP CONSTRAINT "FK_09eadf4c4133b323f467ffc90f3"`); + await queryRunner.query(`ALTER TABLE "money_requests" DROP CONSTRAINT "FK_f7084c83efe7efaca37297d57ae"`); + await queryRunner.query(`ALTER TABLE "cards" ALTER COLUMN "color" SET DEFAULT 'BLUE'`); + await queryRunner.query(`DROP TABLE "money_requests"`); + } +} diff --git a/src/db/migrations/index.ts b/src/db/migrations/index.ts index 2ed92f6..5f892a3 100644 --- a/src/db/migrations/index.ts +++ b/src/db/migrations/index.ts @@ -1,3 +1,4 @@ export * from './1754913378460-initial-migration'; export * from './1754915164809-create-neoleap-related-entities'; export * from './1754915164810-seed-default-avatar'; +export * from './1757349525708-create-money-requests-table'; diff --git a/src/guardian/entities/guradian.entity.ts b/src/guardian/entities/guradian.entity.ts index a578bdc..15f6271 100644 --- a/src/guardian/entities/guradian.entity.ts +++ b/src/guardian/entities/guradian.entity.ts @@ -11,6 +11,7 @@ import { } from 'typeorm'; import { Customer } from '~/customer/entities'; import { Junior } from '~/junior/entities'; +import { MoneyRequest } from '~/money-request/entities/money-request.entity'; @Entity('guardians') export class Guardian extends BaseEntity { @@ -27,6 +28,9 @@ export class Guardian extends BaseEntity { @OneToMany(() => Junior, (junior) => junior.guardian) juniors!: Junior[]; + @OneToMany(() => MoneyRequest, (moneyRequest) => moneyRequest.guardian) + moneyRequests!: MoneyRequest[]; + @CreateDateColumn({ name: 'created_at', type: 'timestamp with time zone', default: () => 'CURRENT_TIMESTAMP' }) createdAt!: Date; diff --git a/src/i18n/ar/app.json b/src/i18n/ar/app.json index ea7f2f4..ce28fcb 100644 --- a/src/i18n/ar/app.json +++ b/src/i18n/ar/app.json @@ -72,12 +72,9 @@ }, "MONEY_REQUEST": { - "START_DATE_IN_THE_PAST": "لا يمكن أن يكون تاريخ البدء في الماضي.", - "END_DATE_IN_THE_PAST": "لا يمكن أن يكون تاريخ النهاية في الماضي.", - "END_DATE_BEFORE_START_DATE": "لا يمكن أن يكون تاريخ النهاية قبل تاريخ البدء.", "NOT_FOUND": "لم يتم العثور على طلب المال.", - "ENDED": "تم انتهاء طلب المال.", - "ALREADY_REVIEWED": "تمت مراجعة طلب المال بالفعل." + "ALREADY_APPROVED": "تمت الموافقة على طلب المال بالفعل.", + "ALREADY_REJECTED": "تم رفض طلب المال بالفعل." }, "GOAL": { diff --git a/src/i18n/en/app.json b/src/i18n/en/app.json index 3373254..06654e5 100644 --- a/src/i18n/en/app.json +++ b/src/i18n/en/app.json @@ -71,12 +71,9 @@ }, "MONEY_REQUEST": { - "START_DATE_IN_THE_PAST": "The start date cannot be in the past.", - "END_DATE_IN_THE_PAST": "The end date cannot be in the past.", - "END_DATE_BEFORE_START_DATE": "The end date cannot be before the start date.", "NOT_FOUND": "The money request was not found.", - "ENDED": "The money request has ended.", - "ALREADY_REVIEWED": "The money request has already been reviewed." + "ALREADY_APPROVED": "The money request has already been approved.", + "ALREADY_REJECTED": "The money request has already been rejected." }, "GOAL": { diff --git a/src/junior/entities/junior.entity.ts b/src/junior/entities/junior.entity.ts index 87c6fab..47891a2 100644 --- a/src/junior/entities/junior.entity.ts +++ b/src/junior/entities/junior.entity.ts @@ -5,12 +5,14 @@ import { Entity, JoinColumn, ManyToOne, + OneToMany, OneToOne, PrimaryColumn, UpdateDateColumn, } from 'typeorm'; import { Customer } from '~/customer/entities'; import { Guardian } from '~/guardian/entities/guradian.entity'; +import { MoneyRequest } from '~/money-request/entities/money-request.entity'; import { Relationship } from '../enums'; import { Theme } from './theme.entity'; @@ -39,6 +41,9 @@ export class Junior extends BaseEntity { @JoinColumn({ name: 'guardian_id' }) guardian!: Guardian; + @OneToMany(() => MoneyRequest, (moneyRequest) => moneyRequest.junior) + moneyRequests!: MoneyRequest[]; + @CreateDateColumn({ name: 'created_at', type: 'timestamp with time zone', default: () => 'CURRENT_TIMESTAMP' }) createdAt!: Date; diff --git a/src/money-request/controllers/index.ts b/src/money-request/controllers/index.ts new file mode 100644 index 0000000..fba9009 --- /dev/null +++ b/src/money-request/controllers/index.ts @@ -0,0 +1 @@ +export * from './money-requests.controller'; diff --git a/src/money-request/controllers/money-requests.controller.ts b/src/money-request/controllers/money-requests.controller.ts new file mode 100644 index 0000000..edc525a --- /dev/null +++ b/src/money-request/controllers/money-requests.controller.ts @@ -0,0 +1,81 @@ +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 { AccessTokenGuard, RolesGuard } from '~/common/guards'; +import { ApiDataResponse } from '~/core/decorators'; +import { ResponseFactory } from '~/core/utils'; +import { CreateMoneyRequestDto, MoneyRequestsFiltersRequestDto, RejectionMoneyRequestDto } from '../dtos/request'; +import { MoneyRequestResponseDto } from '../dtos/response/money-request.response.dto'; +import { MoneyRequestsService } from '../services/money-requests.service'; + +@Controller('money-requests') +@ApiTags('Money Requests') +@UseGuards(AccessTokenGuard, RolesGuard) +@ApiBearerAuth() +export class MoneyRequestsController { + constructor(private readonly moneyRequestsService: MoneyRequestsService) {} + @Post() + @AllowedRoles(Roles.JUNIOR) + @ApiDataResponse(MoneyRequestResponseDto) + async createMoneyRequest(@AuthenticatedUser() { sub }: IJwtPayload, @Body() body: CreateMoneyRequestDto) { + const moneyRequest = await this.moneyRequestsService.createMoneyRequest(sub, body); + + return ResponseFactory.data(new MoneyRequestResponseDto(moneyRequest)); + } + + @Get() + @AllowedRoles(Roles.JUNIOR, Roles.GUARDIAN) + @ApiDataResponse(MoneyRequestResponseDto) + async getMoneyRequests( + @AuthenticatedUser() { sub, roles }: IJwtPayload, + @Query() filters: MoneyRequestsFiltersRequestDto, + ) { + const [moneyRequests, count] = await this.moneyRequestsService.findMoneyRequests( + sub, + roles.includes(Roles.GUARDIAN) ? Roles.GUARDIAN : Roles.JUNIOR, + filters, + ); + return ResponseFactory.dataPage( + moneyRequests.map((mr) => new MoneyRequestResponseDto(mr)), + { + page: filters.page, + size: filters.size, + itemCount: count, + }, + ); + } + + @Get(':id') + @AllowedRoles(Roles.JUNIOR, Roles.GUARDIAN) + @ApiDataResponse(MoneyRequestResponseDto) + async getMoneyRequest(@Param('id') id: string, @AuthenticatedUser() { sub, roles }: IJwtPayload) { + const moneyRequest = await this.moneyRequestsService.findById( + id, + sub, + roles.includes(Roles.GUARDIAN) ? Roles.GUARDIAN : Roles.JUNIOR, + ); + return ResponseFactory.data(new MoneyRequestResponseDto(moneyRequest)); + } + + @Patch(':id/approve') + @AllowedRoles(Roles.GUARDIAN) + @ApiDataResponse(MoneyRequestResponseDto) + @HttpCode(HttpStatus.NO_CONTENT) + async approveMoneyRequest(@Param('id') id: string, @AuthenticatedUser() { sub }: IJwtPayload) { + await this.moneyRequestsService.approveMoneyRequest(id, sub); + } + + @Patch(':id/reject') + @AllowedRoles(Roles.GUARDIAN) + @ApiDataResponse(MoneyRequestResponseDto) + @HttpCode(HttpStatus.NO_CONTENT) + async rejectMoneyRequest( + @Param('id') id: string, + @AuthenticatedUser() { sub }: IJwtPayload, + @Body() rejectionReasondto: RejectionMoneyRequestDto, + ) { + await this.moneyRequestsService.rejectMoneyRequest(id, sub, rejectionReasondto); + } +} diff --git a/src/money-request/dtos/request/create-money-request.request.dto.ts b/src/money-request/dtos/request/create-money-request.request.dto.ts new file mode 100644 index 0000000..fa9a227 --- /dev/null +++ b/src/money-request/dtos/request/create-money-request.request.dto.ts @@ -0,0 +1,15 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNumber, IsString } from 'class-validator'; +import { i18nValidationMessage as i18n } from 'nestjs-i18n'; +export class CreateMoneyRequestDto { + @ApiProperty({ example: 300.42 }) + @IsNumber( + { maxDecimalPlaces: 3 }, + { message: i18n('validation.IsNumber', { path: 'general', property: 'moneyRequest.amount' }) }, + ) + amount!: number; + + @ApiProperty({ example: 'For school supplies' }) + @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'moneyRequest.reason' }) }) + reason!: string; +} diff --git a/src/money-request/dtos/request/index.ts b/src/money-request/dtos/request/index.ts new file mode 100644 index 0000000..252cf75 --- /dev/null +++ b/src/money-request/dtos/request/index.ts @@ -0,0 +1,3 @@ +export * from './create-money-request.request.dto'; +export * from './money-requests-filters.request.dto'; +export * from './rejection.request.dto'; diff --git a/src/money-request/dtos/request/money-requests-filters.request.dto.ts b/src/money-request/dtos/request/money-requests-filters.request.dto.ts new file mode 100644 index 0000000..3258fd9 --- /dev/null +++ b/src/money-request/dtos/request/money-requests-filters.request.dto.ts @@ -0,0 +1,13 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsEnum, IsOptional } from 'class-validator'; +import { i18nValidationMessage as i18n } from 'nestjs-i18n'; +import { PageOptionsRequestDto } from '~/core/dtos'; +import { MoneyRequestStatus } from '~/money-request/enums'; +export class MoneyRequestsFiltersRequestDto extends PageOptionsRequestDto { + @ApiPropertyOptional({ example: MoneyRequestStatus.APPROVED, enum: MoneyRequestStatus }) + @IsEnum(MoneyRequestStatus, { + message: i18n('validation.enum', { property: 'moneyRequest.status', enum: MoneyRequestStatus }), + }) + @IsOptional() + status?: MoneyRequestStatus; +} diff --git a/src/money-request/dtos/request/rejection.request.dto.ts b/src/money-request/dtos/request/rejection.request.dto.ts new file mode 100644 index 0000000..9692ff7 --- /dev/null +++ b/src/money-request/dtos/request/rejection.request.dto.ts @@ -0,0 +1,9 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsOptional, IsString } from 'class-validator'; +import { i18nValidationMessage as i18n } from 'nestjs-i18n'; +export class RejectionMoneyRequestDto { + @ApiProperty({ example: 'You are spending too much' }) + @IsString({ message: i18n('validation.string', { property: 'rejectionReason' }) }) + @IsOptional() + rejectionReason!: string; +} diff --git a/src/money-request/dtos/response/money-request.response.dto.ts b/src/money-request/dtos/response/money-request.response.dto.ts new file mode 100644 index 0000000..95009d5 --- /dev/null +++ b/src/money-request/dtos/response/money-request.response.dto.ts @@ -0,0 +1,41 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { JuniorResponseDto } from '~/junior/dtos/response'; +import { MoneyRequest } from '~/money-request/entities/money-request.entity'; +import { MoneyRequestStatus } from '~/money-request/enums'; + +export class MoneyRequestResponseDto { + @ApiProperty({ example: '123e4567-e89b-12d3-a456-426614174000' }) + id!: string; + + @ApiProperty({ example: 300.42 }) + amount!: number; + + @ApiProperty({ example: 'For school supplies' }) + reason!: string; + + @ApiProperty({ enum: MoneyRequestStatus, example: MoneyRequestStatus.PENDING }) + status!: MoneyRequestStatus; + + @ApiProperty({ example: null }) + rejectionReason!: string | null; + + @ApiProperty({ type: JuniorResponseDto }) + junior!: JuniorResponseDto; + + @ApiProperty({ example: '2024-01-01T00:00:00.000Z' }) + createdAt!: Date; + + @ApiProperty({ example: '2024-01-02T00:00:00.000Z' }) + updatedAt!: Date; + + constructor(data: MoneyRequest) { + this.id = data.id; + this.amount = Number(data.amount); + this.reason = data.reason; + this.status = data.status; + this.rejectionReason = data.rejectionReason; + this.junior = new JuniorResponseDto(data.junior); + this.createdAt = data.createdAt; + this.updatedAt = data.updatedAt; + } +} diff --git a/src/money-request/entities/money-request.entity.ts b/src/money-request/entities/money-request.entity.ts new file mode 100644 index 0000000..b870241 --- /dev/null +++ b/src/money-request/entities/money-request.entity.ts @@ -0,0 +1,51 @@ +import { + BaseEntity, + Column, + CreateDateColumn, + Entity, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; +import { Guardian } from '~/guardian/entities/guradian.entity'; +import { Junior } from '~/junior/entities'; +import { MoneyRequestStatus } from '../enums'; + +@Entity('money_requests') +export class MoneyRequest extends BaseEntity { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ precision: 10, scale: 2, type: 'decimal', name: 'amount' }) + amount!: number; + + @Column({ type: 'varchar', name: 'reason' }) + reason!: string; + + @Column({ type: 'varchar', name: 'status', default: MoneyRequestStatus.PENDING }) + status!: MoneyRequestStatus; + + @Column({ type: 'text', name: 'rejection_reason', nullable: true }) + rejectionReason!: string | null; + + @Column({ type: 'uuid', name: 'junior_id' }) + juniorId!: string; + + @Column({ type: 'uuid', name: 'guardian_id' }) + guardianId!: string; + + @ManyToOne(() => Junior, (junior) => junior.moneyRequests, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'junior_id' }) + junior!: Junior; + + @ManyToOne(() => Guardian, (guardian) => guardian.moneyRequests, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'guardian_id' }) + guardian!: Guardian; + + @CreateDateColumn({ type: 'timestamp with time zone', default: () => 'CURRENT_TIMESTAMP', name: 'created_at' }) + createdAt!: Date; + + @UpdateDateColumn({ type: 'timestamp with time zone', default: () => 'CURRENT_TIMESTAMP', name: 'updated_at' }) + updatedAt!: Date; +} diff --git a/src/money-request/enums/index.ts b/src/money-request/enums/index.ts new file mode 100644 index 0000000..46a67f6 --- /dev/null +++ b/src/money-request/enums/index.ts @@ -0,0 +1 @@ +export * from './money-request-status.enum'; diff --git a/src/money-request/enums/money-request-status.enum.ts b/src/money-request/enums/money-request-status.enum.ts new file mode 100644 index 0000000..bf317f7 --- /dev/null +++ b/src/money-request/enums/money-request-status.enum.ts @@ -0,0 +1,5 @@ +export enum MoneyRequestStatus { + PENDING = 'PENDING', + APPROVED = 'APPROVED', + REJECTED = 'REJECTED', +} diff --git a/src/money-request/money-request.module.ts b/src/money-request/money-request.module.ts new file mode 100644 index 0000000..2b3688b --- /dev/null +++ b/src/money-request/money-request.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { JuniorModule } from '~/junior/junior.module'; +import { MoneyRequestsController } from './controllers'; +import { MoneyRequest } from './entities/money-request.entity'; +import { MoneyRequestsRepository } from './repositories'; +import { MoneyRequestsService } from './services'; + +@Module({ + imports: [TypeOrmModule.forFeature([MoneyRequest]), JuniorModule], + controllers: [MoneyRequestsController], + providers: [MoneyRequestsService, MoneyRequestsRepository], +}) +export class MoneyRequestModule {} diff --git a/src/money-request/repositories/index.ts b/src/money-request/repositories/index.ts new file mode 100644 index 0000000..ff6ac5f --- /dev/null +++ b/src/money-request/repositories/index.ts @@ -0,0 +1 @@ +export * from './money-requests.repository'; diff --git a/src/money-request/repositories/money-requests.repository.ts b/src/money-request/repositories/money-requests.repository.ts new file mode 100644 index 0000000..af206c3 --- /dev/null +++ b/src/money-request/repositories/money-requests.repository.ts @@ -0,0 +1,70 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Roles } from '~/auth/enums/roles.enum'; +import { CreateMoneyRequestDto, MoneyRequestsFiltersRequestDto } from '../dtos/request'; +import { MoneyRequest } from '../entities/money-request.entity'; +import { MoneyRequestStatus } from '../enums'; +const FIRST_PAGE = 1; +@Injectable() +export class MoneyRequestsRepository { + constructor(@InjectRepository(MoneyRequest) private readonly moneyRequestRepository: Repository) {} + + findAll(userId: string, role: Roles, filters: MoneyRequestsFiltersRequestDto): Promise<[MoneyRequest[], number]> { + const queryBuilder = this.moneyRequestRepository.createQueryBuilder('moneyRequest'); + + if (role === Roles.JUNIOR) { + queryBuilder.where('moneyRequest.juniorId = :userId', { userId }); + } else if (role === Roles.GUARDIAN) { + queryBuilder.where('moneyRequest.guardianId = :userId', { userId }); + } + + queryBuilder.leftJoinAndSelect('moneyRequest.junior', 'junior'); + queryBuilder.leftJoinAndSelect('junior.customer', 'customer'); + queryBuilder.leftJoinAndSelect('customer.user', 'user'); + queryBuilder.leftJoinAndSelect('user.profilePicture', 'profilePicture'); + + if (filters.status) { + queryBuilder.andWhere('moneyRequest.status = :status', { status: filters.status }); + } + + queryBuilder.skip((filters.page - FIRST_PAGE) * filters.size); + queryBuilder.take(filters.size); + + queryBuilder.orderBy('moneyRequest.createdAt', 'DESC'); + return queryBuilder.getManyAndCount(); + } + + createMoneyRequest(juniorId: string, guardianId: string, body: CreateMoneyRequestDto): Promise { + return this.moneyRequestRepository.save( + this.moneyRequestRepository.create({ + amount: body.amount, + reason: body.reason, + status: MoneyRequestStatus.PENDING, + juniorId, + guardianId, + }), + ); + } + + findById(id: string, userId?: string, role?: Roles): Promise { + const whereCondition: any = { id }; + if (role === Roles.JUNIOR) { + whereCondition.juniorId = userId; + } else { + whereCondition.guardianId = userId; + } + return this.moneyRequestRepository.findOne({ + where: whereCondition, + relations: ['junior', 'junior.customer', 'junior.customer.user', 'junior.customer.user.profilePicture'], + }); + } + + approveMoneyRequest(id: string) { + return this.moneyRequestRepository.update({ id }, { status: MoneyRequestStatus.APPROVED }); + } + + rejectMoneyRequest(id: string, rejectionReason?: string) { + return this.moneyRequestRepository.update({ id }, { status: MoneyRequestStatus.REJECTED, rejectionReason }); + } +} diff --git a/src/money-request/services/index.ts b/src/money-request/services/index.ts new file mode 100644 index 0000000..5abf42b --- /dev/null +++ b/src/money-request/services/index.ts @@ -0,0 +1 @@ +export * from './money-requests.service'; diff --git a/src/money-request/services/money-requests.service.ts b/src/money-request/services/money-requests.service.ts new file mode 100644 index 0000000..6895206 --- /dev/null +++ b/src/money-request/services/money-requests.service.ts @@ -0,0 +1,102 @@ +import { BadRequestException, Injectable, Logger } from '@nestjs/common'; +import { Transactional } from 'typeorm-transactional'; +import { Roles } from '~/auth/enums'; +import { OciService } from '~/document/services'; +import { Junior } from '~/junior/entities/junior.entity'; +import { JuniorService } from '~/junior/services'; +import { CreateMoneyRequestDto, MoneyRequestsFiltersRequestDto, RejectionMoneyRequestDto } from '../dtos/request'; +import { MoneyRequest } from '../entities/money-request.entity'; +import { MoneyRequestStatus } from '../enums'; +import { MoneyRequestsRepository } from '../repositories'; + +@Injectable() +export class MoneyRequestsService { + private readonly logger = new Logger(MoneyRequestsService.name); + constructor( + private readonly moneyRequestsRepository: MoneyRequestsRepository, + private readonly juniorService: JuniorService, + private readonly ociService: OciService, + ) {} + async createMoneyRequest(juniorId: string, body: CreateMoneyRequestDto) { + const junior = await this.juniorService.findJuniorById(juniorId); + const moneyRequest = await this.moneyRequestsRepository.createMoneyRequest(junior.id, junior.guardianId, body); + return this.findById(moneyRequest.id); + } + + async findById(id: string, userId?: string, role?: Roles): Promise { + const moneyRequest = await this.moneyRequestsRepository.findById(id, userId, role); + if (!moneyRequest) { + throw new BadRequestException('MONEY_REQUEST.NOT_FOUND'); + } + await this.prepareJuniorImages([moneyRequest.junior]); + return moneyRequest; + } + + async findMoneyRequests( + userId: string, + role: Roles, + filters: MoneyRequestsFiltersRequestDto, + ): Promise<[MoneyRequest[], number]> { + const [moneyRequests, count] = await this.moneyRequestsRepository.findAll(userId, role, filters); + const juniors = moneyRequests.map((moneyRequest) => moneyRequest.junior); + await this.prepareJuniorImages(juniors); + return [moneyRequests, count]; + } + + @Transactional() + async approveMoneyRequest(id: string, guardianId: string): Promise { + const moneyRequest = await this.moneyRequestsRepository.findById(id, guardianId, Roles.GUARDIAN); + + if (!moneyRequest) { + throw new BadRequestException('MONEY_REQUEST.NOT_FOUND'); + } + + if (moneyRequest.status == MoneyRequestStatus.APPROVED) { + throw new BadRequestException('MONEY_REQUEST.ALREADY_APPROVED'); + } + + await Promise.all([ + this.moneyRequestsRepository.approveMoneyRequest(id), + this.juniorService.transferToJunior( + moneyRequest.juniorId, + { amount: moneyRequest.amount }, + moneyRequest.guardianId, + ), + ]); + } + + async rejectMoneyRequest( + id: string, + guardianId: string, + rejectionReasondto: RejectionMoneyRequestDto, + ): Promise { + const moneyRequest = await this.moneyRequestsRepository.findById(id, guardianId, Roles.GUARDIAN); + + if (!moneyRequest) { + throw new BadRequestException('MONEY_REQUEST.NOT_FOUND'); + } + + if (moneyRequest.status == MoneyRequestStatus.APPROVED) { + throw new BadRequestException('MONEY_REQUEST.ALREADY_APPROVED'); + } + + if (moneyRequest.status == MoneyRequestStatus.REJECTED) { + throw new BadRequestException('MONEY_REQUEST.ALREADY_REJECTED'); + } + + await this.moneyRequestsRepository.rejectMoneyRequest(id, rejectionReasondto?.rejectionReason); + } + + private async prepareJuniorImages(juniors: Junior[]) { + this.logger.log(`Preparing junior images`); + await Promise.all( + juniors.map(async (junior) => { + const profilePicture = junior.customer.user.profilePicture; + + if (profilePicture) { + profilePicture.url = await this.ociService.generatePreSignedUrl(profilePicture); + } + }), + ); + } +}