mirror of
https://github.com/HamzaSha1/zod-backend.git
synced 2025-08-25 21:59:40 +00:00
feat: add money request cronjob, and edit money request entity
This commit is contained in:
@ -84,7 +84,13 @@ export class Allowance {
|
|||||||
const calculateNextDate = (unit: moment.unitOfTime.Diff) => {
|
const calculateNextDate = (unit: moment.unitOfTime.Diff) => {
|
||||||
const diff = now.diff(startDate, unit);
|
const diff = now.diff(startDate, unit);
|
||||||
const nextDate = startDate.clone().add(diff, unit);
|
const nextDate = startDate.clone().add(diff, unit);
|
||||||
return nextDate.isSameOrAfter(now) ? nextDate.toDate() : nextDate.add('1', unit).toDate();
|
const adjustedDate = nextDate.isSameOrAfter(now) ? nextDate : nextDate.add('1', unit);
|
||||||
|
|
||||||
|
if (endDate && adjustedDate.isAfter(endDate)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return adjustedDate.toDate();
|
||||||
};
|
};
|
||||||
|
|
||||||
switch (this.frequency) {
|
switch (this.frequency) {
|
||||||
|
@ -1,10 +1,11 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { AllowanceModule } from '~/allowance/allowance.module';
|
import { AllowanceModule } from '~/allowance/allowance.module';
|
||||||
|
import { MoneyRequestModule } from '~/money-request/money-request.module';
|
||||||
import { BaseCronService } from './services';
|
import { BaseCronService } from './services';
|
||||||
import { AllowanceTask } from './tasks';
|
import { AllowanceTask, MoneyRequestTask } from './tasks';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [AllowanceModule],
|
imports: [AllowanceModule, MoneyRequestModule],
|
||||||
providers: [AllowanceTask, BaseCronService],
|
providers: [AllowanceTask, MoneyRequestTask, BaseCronService],
|
||||||
})
|
})
|
||||||
export class CronModule {}
|
export class CronModule {}
|
||||||
|
@ -15,7 +15,7 @@ export class AllowanceTask extends BaseCronService {
|
|||||||
private readonly logger = new Logger(AllowanceTask.name);
|
private readonly logger = new Logger(AllowanceTask.name);
|
||||||
private readonly cronLockKey = `${AllowanceTask.name}-lock`;
|
private readonly cronLockKey = `${AllowanceTask.name}-lock`;
|
||||||
private readonly cronLockTtl = TEN_MINUTES;
|
private readonly cronLockTtl = TEN_MINUTES;
|
||||||
constructor(cacheService: CacheService, private allowanceService: AllowancesService) {
|
constructor(cacheService: CacheService, private readonly allowanceService: AllowancesService) {
|
||||||
super(cacheService);
|
super(cacheService);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1 +1,2 @@
|
|||||||
export * from './allowance.task';
|
export * from './allowance.task';
|
||||||
|
export * from './money-request.task';
|
||||||
|
54
src/cron/tasks/money-request.task.ts
Normal file
54
src/cron/tasks/money-request.task.ts
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||||
|
import moment from 'moment';
|
||||||
|
import { CacheService } from '~/common/modules/cache/services';
|
||||||
|
import { MoneyRequestsService } from '~/money-request/services';
|
||||||
|
import { BaseCronService } from '../services';
|
||||||
|
const TEN = 10;
|
||||||
|
const SIXTY = 60;
|
||||||
|
const THOUSAND = 1000;
|
||||||
|
const TEN_MINUTES = TEN * SIXTY * THOUSAND;
|
||||||
|
const CHUNK_SIZE = 50;
|
||||||
|
@Injectable()
|
||||||
|
export class MoneyRequestTask extends BaseCronService {
|
||||||
|
private readonly cronLockKey = `${MoneyRequestTask.name}-lock`;
|
||||||
|
private readonly cronLockTtl = TEN_MINUTES;
|
||||||
|
private readonly logger = new Logger(MoneyRequestTask.name);
|
||||||
|
constructor(cacheService: CacheService, private readonly moneyRequestService: MoneyRequestsService) {
|
||||||
|
super(cacheService);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Cron(CronExpression.EVERY_DAY_AT_3AM)
|
||||||
|
async handleCron() {
|
||||||
|
try {
|
||||||
|
const isLockAcquired = await this.acquireLock(this.cronLockKey, this.cronLockTtl);
|
||||||
|
if (!isLockAcquired) {
|
||||||
|
this.logger.log('Lock already acquired. Skipping cron job for MoneyRequestTask.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.logger.log('Processing cron job for MoneyRequestTask');
|
||||||
|
await this.processJob();
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('Error processing MoneyRequestTask cron job', error);
|
||||||
|
} finally {
|
||||||
|
this.logger.log('Releasing lock for MoneyRequestTask');
|
||||||
|
await this.releaseLock(this.cronLockKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async processJob() {
|
||||||
|
const today = moment().startOf('day');
|
||||||
|
const moneyRequestsChunks = await this.moneyRequestService.findMoneyRequestsChunks(CHUNK_SIZE);
|
||||||
|
for await (const moneyRequests of moneyRequestsChunks) {
|
||||||
|
for (const moneyRequest of moneyRequests) {
|
||||||
|
this.logger.log(
|
||||||
|
`Processing money request ${moneyRequest.id} with next payment date ${moneyRequest.nextPaymentDate}`,
|
||||||
|
);
|
||||||
|
// if today is the same as money request payment date
|
||||||
|
if (moment(moneyRequest.nextPaymentDate).startOf('day').isSame(today)) {
|
||||||
|
this.logger.log(`Today is the payment date for money request ${moneyRequest.id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -12,6 +12,7 @@ export class CreateMoneyRequestEntity1734503895302 implements MigrationInterface
|
|||||||
"frequency" character varying NOT NULL DEFAULT 'ONE_TIME',
|
"frequency" character varying NOT NULL DEFAULT 'ONE_TIME',
|
||||||
"status" character varying NOT NULL DEFAULT 'PENDING',
|
"status" character varying NOT NULL DEFAULT 'PENDING',
|
||||||
"reviewed_at" TIMESTAMP WITH TIME ZONE,
|
"reviewed_at" TIMESTAMP WITH TIME ZONE,
|
||||||
|
"start_date" TIMESTAMP WITH TIME ZONE,
|
||||||
"end_date" TIMESTAMP WITH TIME ZONE,
|
"end_date" TIMESTAMP WITH TIME ZONE,
|
||||||
"requester_id" uuid NOT NULL,
|
"requester_id" uuid NOT NULL,
|
||||||
"reviewer_id" uuid NOT NULL,
|
"reviewer_id" uuid NOT NULL,
|
||||||
|
@ -1,5 +1,15 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { IsDateString, IsEnum, IsNotEmpty, IsNumber, IsOptional, IsString, Max, Min } from 'class-validator';
|
import {
|
||||||
|
IsDateString,
|
||||||
|
IsEnum,
|
||||||
|
IsNotEmpty,
|
||||||
|
IsNumber,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
Max,
|
||||||
|
Min,
|
||||||
|
ValidateIf,
|
||||||
|
} from 'class-validator';
|
||||||
import { i18nValidationMessage as i18n } from 'nestjs-i18n';
|
import { i18nValidationMessage as i18n } from 'nestjs-i18n';
|
||||||
import { MoneyRequestFrequency } from '~/money-request/enums';
|
import { MoneyRequestFrequency } from '~/money-request/enums';
|
||||||
const MIN_REQUESTED_AMOUNT = 0.01;
|
const MIN_REQUESTED_AMOUNT = 0.01;
|
||||||
@ -30,6 +40,14 @@ export class CreateMoneyRequestRequestDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
frequency: MoneyRequestFrequency = MoneyRequestFrequency.ONE_TIME;
|
frequency: MoneyRequestFrequency = MoneyRequestFrequency.ONE_TIME;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: '2021-01-01' })
|
||||||
|
@IsDateString(
|
||||||
|
{},
|
||||||
|
{ message: i18n('validation.IsDateString', { path: 'general', property: 'moneyRequest.startDate' }) },
|
||||||
|
)
|
||||||
|
@ValidateIf((o) => o.frequency !== MoneyRequestFrequency.ONE_TIME)
|
||||||
|
startDate?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ example: '2021-01-01' })
|
@ApiPropertyOptional({ example: '2021-01-01' })
|
||||||
@IsDateString({}, { message: i18n('validation.IsDateString', { path: 'general', property: 'moneyRequest.endDate' }) })
|
@IsDateString({}, { message: i18n('validation.IsDateString', { path: 'general', property: 'moneyRequest.endDate' }) })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
@ -22,6 +22,9 @@ export class MoneyRequestResponseDto {
|
|||||||
@ApiProperty({ example: MoneyRequestFrequency.ONE_TIME })
|
@ApiProperty({ example: MoneyRequestFrequency.ONE_TIME })
|
||||||
frequency!: MoneyRequestFrequency;
|
frequency!: MoneyRequestFrequency;
|
||||||
|
|
||||||
|
@ApiProperty({ example: '2021-01-01' })
|
||||||
|
startDate!: Date | null;
|
||||||
|
|
||||||
@ApiProperty({ example: '2021-01-01' })
|
@ApiProperty({ example: '2021-01-01' })
|
||||||
endDate!: Date | null;
|
endDate!: Date | null;
|
||||||
|
|
||||||
@ -41,6 +44,7 @@ export class MoneyRequestResponseDto {
|
|||||||
this.requestedAmount = moneyRequest.requestedAmount;
|
this.requestedAmount = moneyRequest.requestedAmount;
|
||||||
this.message = moneyRequest.message;
|
this.message = moneyRequest.message;
|
||||||
this.frequency = moneyRequest.frequency;
|
this.frequency = moneyRequest.frequency;
|
||||||
|
this.startDate = moneyRequest.startDate || null;
|
||||||
this.endDate = moneyRequest.endDate || null;
|
this.endDate = moneyRequest.endDate || null;
|
||||||
this.status = moneyRequest.status;
|
this.status = moneyRequest.status;
|
||||||
this.reviewedAt = moneyRequest.reviewedAt || null;
|
this.reviewedAt = moneyRequest.reviewedAt || null;
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import moment from 'moment';
|
||||||
import {
|
import {
|
||||||
Column,
|
Column,
|
||||||
CreateDateColumn,
|
CreateDateColumn,
|
||||||
@ -31,6 +32,9 @@ export class MoneyRequest {
|
|||||||
@Column({ type: 'timestamp with time zone', name: 'reviewed_at', nullable: true })
|
@Column({ type: 'timestamp with time zone', name: 'reviewed_at', nullable: true })
|
||||||
reviewedAt!: Date;
|
reviewedAt!: Date;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamp with time zone', name: 'start_date', nullable: true })
|
||||||
|
startDate!: Date | null;
|
||||||
|
|
||||||
@Column({ type: 'timestamp with time zone', name: 'end_date', nullable: true })
|
@Column({ type: 'timestamp with time zone', name: 'end_date', nullable: true })
|
||||||
endDate!: Date | null;
|
endDate!: Date | null;
|
||||||
|
|
||||||
@ -53,4 +57,41 @@ export class MoneyRequest {
|
|||||||
|
|
||||||
@UpdateDateColumn({ type: 'timestamp with time zone', name: 'updated_at' })
|
@UpdateDateColumn({ type: 'timestamp with time zone', name: 'updated_at' })
|
||||||
updatedAt!: Date;
|
updatedAt!: Date;
|
||||||
|
|
||||||
|
get nextPaymentDate(): Date | null {
|
||||||
|
if (this.frequency === MoneyRequestFrequency.ONE_TIME) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const startDate = moment(this.startDate).clone().startOf('day');
|
||||||
|
const endDate = this.endDate ? moment(this.endDate).endOf('day') : null;
|
||||||
|
const now = moment().startOf('day');
|
||||||
|
|
||||||
|
if (endDate && moment().isAfter(endDate)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const calculateNextDate = (unit: moment.unitOfTime.Diff) => {
|
||||||
|
const diff = now.diff(startDate, unit);
|
||||||
|
const nextDate = startDate.clone().add(diff, unit);
|
||||||
|
|
||||||
|
const adjustedDate = nextDate.isSameOrAfter(now) ? nextDate : nextDate.add('1', unit);
|
||||||
|
|
||||||
|
if (endDate && adjustedDate.isAfter(endDate)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return adjustedDate.toDate();
|
||||||
|
};
|
||||||
|
|
||||||
|
switch (this.frequency) {
|
||||||
|
case MoneyRequestFrequency.DAILY:
|
||||||
|
return calculateNextDate('days');
|
||||||
|
case MoneyRequestFrequency.WEEKLY:
|
||||||
|
return calculateNextDate('weeks');
|
||||||
|
case MoneyRequestFrequency.MONTHLY:
|
||||||
|
return calculateNextDate('months');
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -9,7 +9,7 @@ import { MoneyRequestsService } from './services';
|
|||||||
@Module({
|
@Module({
|
||||||
controllers: [MoneyRequestsController],
|
controllers: [MoneyRequestsController],
|
||||||
providers: [MoneyRequestsService, MoneyRequestsRepository],
|
providers: [MoneyRequestsService, MoneyRequestsRepository],
|
||||||
exports: [],
|
exports: [MoneyRequestsService],
|
||||||
imports: [TypeOrmModule.forFeature([MoneyRequest]), JuniorModule],
|
imports: [TypeOrmModule.forFeature([MoneyRequest]), JuniorModule],
|
||||||
})
|
})
|
||||||
export class MoneyRequestModule {}
|
export class MoneyRequestModule {}
|
||||||
|
@ -1,9 +1,9 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { IsNull, MoreThan, Not, Repository } from 'typeorm';
|
||||||
import { CreateMoneyRequestRequestDto, MoneyRequestsFiltersRequestDto } from '../dtos/request';
|
import { CreateMoneyRequestRequestDto, MoneyRequestsFiltersRequestDto } from '../dtos/request';
|
||||||
import { MoneyRequest } from '../entities';
|
import { MoneyRequest } from '../entities';
|
||||||
import { MoneyRequestStatus } from '../enums';
|
import { MoneyRequestFrequency, MoneyRequestStatus } from '../enums';
|
||||||
const ONE = 1;
|
const ONE = 1;
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class MoneyRequestsRepository {
|
export class MoneyRequestsRepository {
|
||||||
@ -16,6 +16,7 @@ export class MoneyRequestsRepository {
|
|||||||
reviewerId,
|
reviewerId,
|
||||||
requestedAmount: body.requestedAmount,
|
requestedAmount: body.requestedAmount,
|
||||||
message: body.message,
|
message: body.message,
|
||||||
|
startDate: body.startDate,
|
||||||
endDate: body.endDate,
|
endDate: body.endDate,
|
||||||
frequency: body.frequency,
|
frequency: body.frequency,
|
||||||
}),
|
}),
|
||||||
@ -46,4 +47,33 @@ export class MoneyRequestsRepository {
|
|||||||
updateMoneyRequestStatus(moneyRequestId: string, reviewerId: string, status: MoneyRequestStatus) {
|
updateMoneyRequestStatus(moneyRequestId: string, reviewerId: string, status: MoneyRequestStatus) {
|
||||||
return this.moneyRequestRepository.update({ id: moneyRequestId, reviewerId }, { status, reviewedAt: new Date() });
|
return this.moneyRequestRepository.update({ id: moneyRequestId, reviewerId }, { status, reviewedAt: new Date() });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async *findMoneyRequestsChunks(chunkSize: number) {
|
||||||
|
let offset = 0;
|
||||||
|
while (true) {
|
||||||
|
const moneyRequests = await this.moneyRequestRepository.find({
|
||||||
|
take: chunkSize,
|
||||||
|
skip: offset,
|
||||||
|
where: [
|
||||||
|
{
|
||||||
|
status: MoneyRequestStatus.APPROVED,
|
||||||
|
frequency: Not(MoneyRequestFrequency.ONE_TIME),
|
||||||
|
endDate: MoreThan(new Date()),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: MoneyRequestStatus.APPROVED,
|
||||||
|
frequency: Not(MoneyRequestFrequency.ONE_TIME),
|
||||||
|
endDate: IsNull(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!moneyRequests.length) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
yield moneyRequests;
|
||||||
|
offset += chunkSize;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -20,6 +20,12 @@ export class MoneyRequestsService {
|
|||||||
if (body.frequency === MoneyRequestFrequency.ONE_TIME) {
|
if (body.frequency === MoneyRequestFrequency.ONE_TIME) {
|
||||||
this.logger.log(`Setting end date to null for one time money request`);
|
this.logger.log(`Setting end date to null for one time money request`);
|
||||||
delete body.endDate;
|
delete body.endDate;
|
||||||
|
delete body.startDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.startDate && new Date(body.startDate) < new Date()) {
|
||||||
|
this.logger.error(`Start date ${body.startDate} is in the past`);
|
||||||
|
throw new BadRequestException('MONEY_REQUEST.START_DATE_IN_THE_PAST');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (body.endDate && new Date(body.endDate) < new Date()) {
|
if (body.endDate && new Date(body.endDate) < new Date()) {
|
||||||
@ -27,6 +33,11 @@ export class MoneyRequestsService {
|
|||||||
throw new BadRequestException('MONEY_REQUEST.END_DATE_IN_THE_PAST');
|
throw new BadRequestException('MONEY_REQUEST.END_DATE_IN_THE_PAST');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (body.endDate && body.startDate && new Date(body.endDate) < new Date(body.startDate)) {
|
||||||
|
this.logger.error(`End date ${body.endDate} is before start date ${body.startDate}`);
|
||||||
|
throw new BadRequestException('MONEY_REQUEST.END_DATE_BEFORE_START_DATE');
|
||||||
|
}
|
||||||
|
|
||||||
const junior = await this.juniorService.findJuniorById(userId, true);
|
const junior = await this.juniorService.findJuniorById(userId, true);
|
||||||
|
|
||||||
const moneyRequest = await this.moneyRequestsRepository.createMoneyRequest(junior.id, junior.guardianId, body);
|
const moneyRequest = await this.moneyRequestsRepository.createMoneyRequest(junior.id, junior.guardianId, body);
|
||||||
@ -86,6 +97,11 @@ export class MoneyRequestsService {
|
|||||||
//@TODO send notification
|
//@TODO send notification
|
||||||
}
|
}
|
||||||
|
|
||||||
|
findMoneyRequestsChunks(chunkSize: number) {
|
||||||
|
this.logger.log(`Finding money requests chunks`);
|
||||||
|
return this.moneyRequestsRepository.findMoneyRequestsChunks(chunkSize);
|
||||||
|
}
|
||||||
|
|
||||||
private async prepareMoneyRequestDocument(moneyRequests: MoneyRequest[]) {
|
private async prepareMoneyRequestDocument(moneyRequests: MoneyRequest[]) {
|
||||||
this.logger.log(`Preparing document for money requests`);
|
this.logger.log(`Preparing document for money requests`);
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
@ -107,6 +123,12 @@ export class MoneyRequestsService {
|
|||||||
this.logger.error(`Money request ${moneyRequestId} not found`);
|
this.logger.error(`Money request ${moneyRequestId} not found`);
|
||||||
throw new BadRequestException('MONEY_REQUEST.NOT_FOUND');
|
throw new BadRequestException('MONEY_REQUEST.NOT_FOUND');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (moneyRequest.endDate && new Date(moneyRequest.endDate) < new Date()) {
|
||||||
|
this.logger.error(`Money request ${moneyRequestId} has already ended`);
|
||||||
|
throw new BadRequestException('MONEY_REQUEST.ENDED');
|
||||||
|
}
|
||||||
|
|
||||||
if (moneyRequest.status !== MoneyRequestStatus.PENDING) {
|
if (moneyRequest.status !== MoneyRequestStatus.PENDING) {
|
||||||
this.logger.error(`Money request ${moneyRequestId} already reviewed`);
|
this.logger.error(`Money request ${moneyRequestId} already reviewed`);
|
||||||
throw new BadRequestException('MONEY_REQUEST.ALREADY_REVIEWED');
|
throw new BadRequestException('MONEY_REQUEST.ALREADY_REVIEWED');
|
||||||
|
Reference in New Issue
Block a user