feat: add cron job for allowance and implement cron lock

This commit is contained in:
Abdalhamid Alhamad
2024-12-30 16:10:19 +03:00
parent 0fd2066c4a
commit c0fafd3f7c
13 changed files with 202 additions and 3 deletions

View File

@ -29,7 +29,7 @@ module.exports = {
'require-await': ['error'], 'require-await': ['error'],
'no-console': ['error'], 'no-console': ['error'],
'no-multi-assign': ['error'], 'no-multi-assign': ['error'],
'no-magic-numbers': ['error', { ignoreArrayIndexes: true }], 'no-magic-numbers': ['error', { ignoreArrayIndexes: true}],
'no-multiple-empty-lines': ['error', { max: 1, maxEOF: 1, maxBOF: 0 }], 'no-multiple-empty-lines': ['error', { max: 1, maxEOF: 1, maxBOF: 0 }],
'max-len': [ 'max-len': [
'error', 'error',

53
package-lock.json generated
View File

@ -22,6 +22,7 @@
"@nestjs/microservices": "^10.4.7", "@nestjs/microservices": "^10.4.7",
"@nestjs/passport": "^10.0.3", "@nestjs/passport": "^10.0.3",
"@nestjs/platform-express": "^10.4.8", "@nestjs/platform-express": "^10.4.8",
"@nestjs/schedule": "^4.1.2",
"@nestjs/swagger": "^8.0.5", "@nestjs/swagger": "^8.0.5",
"@nestjs/terminus": "^10.2.3", "@nestjs/terminus": "^10.2.3",
"@nestjs/throttler": "^6.2.1", "@nestjs/throttler": "^6.2.1",
@ -2198,6 +2199,33 @@
"@nestjs/core": "^10.0.0" "@nestjs/core": "^10.0.0"
} }
}, },
"node_modules/@nestjs/schedule": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-4.1.2.tgz",
"integrity": "sha512-hCTQ1lNjIA5EHxeu8VvQu2Ed2DBLS1GSC6uKPYlBiQe6LL9a7zfE9iVSK+zuK8E2odsApteEBmfAQchc8Hx0Gg==",
"license": "MIT",
"dependencies": {
"cron": "3.2.1",
"uuid": "11.0.3"
},
"peerDependencies": {
"@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0",
"@nestjs/core": "^8.0.0 || ^9.0.0 || ^10.0.0"
}
},
"node_modules/@nestjs/schedule/node_modules/uuid": {
"version": "11.0.3",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.0.3.tgz",
"integrity": "sha512-d0z310fCWv5dJwnX1Y/MncBAqGMKEzlBb1AOf7z9K8ALnd0utBX/msg/fA0+sbyN1ihbMsLhrBlnl1ak7Wa0rg==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist/esm/bin/uuid"
}
},
"node_modules/@nestjs/schematics": { "node_modules/@nestjs/schematics": {
"version": "10.2.3", "version": "10.2.3",
"dev": true, "dev": true,
@ -2842,6 +2870,12 @@
"license": "MIT", "license": "MIT",
"optional": true "optional": true
}, },
"node_modules/@types/luxon": {
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.4.2.tgz",
"integrity": "sha512-TifLZlFudklWlMBfhubvgqTXRzLDI5pCbGa4P8a3wPyUQSW+1xQ5eDsreP9DWHX3tjq1ke96uYG/nwundroWcA==",
"license": "MIT"
},
"node_modules/@types/methods": { "node_modules/@types/methods": {
"version": "1.1.4", "version": "1.1.4",
"dev": true, "dev": true,
@ -5005,6 +5039,16 @@
"devOptional": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/cron": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/cron/-/cron-3.2.1.tgz",
"integrity": "sha512-w2n5l49GMmmkBFEsH9FIDhjZ1n1QgTMOCMGuQtOXs5veNiosZmso6bQGuqOJSYAXXrG84WQFVneNk+Yt0Ua9iw==",
"license": "MIT",
"dependencies": {
"@types/luxon": "~3.4.0",
"luxon": "~3.5.0"
}
},
"node_modules/cross-spawn": { "node_modules/cross-spawn": {
"version": "7.0.5", "version": "7.0.5",
"license": "MIT", "license": "MIT",
@ -9238,6 +9282,15 @@
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/luxon": {
"version": "3.5.0",
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.5.0.tgz",
"integrity": "sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ==",
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/magic-string": { "node_modules/magic-string": {
"version": "0.30.8", "version": "0.30.8",
"dev": true, "dev": true,

View File

@ -39,6 +39,7 @@
"@nestjs/microservices": "^10.4.7", "@nestjs/microservices": "^10.4.7",
"@nestjs/passport": "^10.0.3", "@nestjs/passport": "^10.0.3",
"@nestjs/platform-express": "^10.4.8", "@nestjs/platform-express": "^10.4.8",
"@nestjs/schedule": "^4.1.2",
"@nestjs/swagger": "^8.0.5", "@nestjs/swagger": "^8.0.5",
"@nestjs/terminus": "^10.2.3", "@nestjs/terminus": "^10.2.3",
"@nestjs/throttler": "^6.2.1", "@nestjs/throttler": "^6.2.1",

View File

@ -15,6 +15,6 @@ import { AllowanceChangeRequestsService, AllowancesService } from './services';
AllowanceChangeRequestsService, AllowanceChangeRequestsService,
AllowanceChangeRequestsRepository, AllowanceChangeRequestsRepository,
], ],
exports: [], exports: [AllowancesService],
}) })
export class AllowanceModule {} export class AllowanceModule {}

View File

@ -1,3 +1,4 @@
import moment from 'moment';
import { import {
Column, Column,
CreateDateColumn, CreateDateColumn,
@ -13,7 +14,6 @@ import { Guardian } from '~/guardian/entities/guradian.entity';
import { Junior } from '~/junior/entities'; import { Junior } from '~/junior/entities';
import { AllowanceFrequency, AllowanceType } from '../enums'; import { AllowanceFrequency, AllowanceType } from '../enums';
import { AllowanceChangeRequest } from './allowance-change-request.entity'; import { AllowanceChangeRequest } from './allowance-change-request.entity';
@Entity('allowances') @Entity('allowances')
export class Allowance { export class Allowance {
@PrimaryGeneratedColumn('uuid') @PrimaryGeneratedColumn('uuid')
@ -71,4 +71,31 @@ export class Allowance {
@DeleteDateColumn({ name: 'deleted_at', type: 'timestamp with time zone', nullable: true }) @DeleteDateColumn({ name: 'deleted_at', type: 'timestamp with time zone', nullable: true })
deletedAt?: Date; deletedAt?: Date;
get nextPaymentDate(): Date | 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);
return nextDate.isSameOrAfter(now) ? nextDate.toDate() : nextDate.add('1', unit).toDate();
};
switch (this.frequency) {
case AllowanceFrequency.DAILY:
return calculateNextDate('days');
case AllowanceFrequency.WEEKLY:
return calculateNextDate('weeks');
case AllowanceFrequency.MONTHLY:
return calculateNextDate('months');
default:
return null;
}
}
} }

View File

@ -44,4 +44,21 @@ export class AllowancesRepository {
deleteAllowance(guardianId: string, allowanceId: string) { deleteAllowance(guardianId: string, allowanceId: string) {
return this.allowancesRepository.softDelete({ id: allowanceId, guardianId }); return this.allowancesRepository.softDelete({ id: allowanceId, guardianId });
} }
async *findAllowancesChunks(chunkSize: number) {
let offset = 0;
while (true) {
const allowances = await this.allowancesRepository.find({
take: chunkSize,
skip: offset,
});
if (!allowances.length) {
break;
}
yield allowances;
offset += chunkSize;
}
}
} }

View File

@ -89,6 +89,13 @@ export class AllowancesService {
return allowance; return allowance;
} }
async findAllowancesChunks(chunkSize: number) {
this.logger.log(`Finding allowances chunks`);
const allowances = await this.allowancesRepository.findAllowancesChunks(chunkSize);
this.logger.log(`Returning allowances chunks`);
return allowances;
}
private async prepareAllowanceDocuments(allowance: Allowance[]) { private async prepareAllowanceDocuments(allowance: Allowance[]) {
this.logger.log(`Preparing document for allowances`); this.logger.log(`Preparing document for allowances`);
await Promise.all( await Promise.all(

View File

@ -2,6 +2,7 @@ import { MiddlewareConsumer, Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config'; import { ConfigModule, ConfigService } from '@nestjs/config';
import { APP_FILTER, APP_PIPE } from '@nestjs/core'; import { APP_FILTER, APP_PIPE } from '@nestjs/core';
import { EventEmitterModule } from '@nestjs/event-emitter'; import { EventEmitterModule } from '@nestjs/event-emitter';
import { ScheduleModule } from '@nestjs/schedule';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { I18nMiddleware, I18nModule } from 'nestjs-i18n'; import { I18nMiddleware, I18nModule } from 'nestjs-i18n';
import { LoggerModule } from 'nestjs-pino'; import { LoggerModule } from 'nestjs-pino';
@ -17,6 +18,7 @@ import { AllExceptionsFilter, buildI18nValidationExceptionFilter } from './core/
import { buildConfigOptions, buildLoggerOptions, buildTypeormOptions } from './core/module-options'; import { buildConfigOptions, buildLoggerOptions, buildTypeormOptions } from './core/module-options';
import { buildI18nOptions } from './core/module-options/i18n-options'; import { buildI18nOptions } from './core/module-options/i18n-options';
import { buildValidationPipe } from './core/pipes'; import { buildValidationPipe } from './core/pipes';
import { CronModule } from './cron/cron.module';
import { CustomerModule } from './customer/customer.module'; import { CustomerModule } from './customer/customer.module';
import { migrations } from './db'; import { migrations } from './db';
import { DocumentModule } from './document/document.module'; import { DocumentModule } from './document/document.module';
@ -56,6 +58,7 @@ import { UserModule } from './user/user.module';
I18nModule.forRoot(buildI18nOptions()), I18nModule.forRoot(buildI18nOptions()),
CacheModule, CacheModule,
EventEmitterModule.forRoot(), EventEmitterModule.forRoot(),
ScheduleModule.forRoot(),
// App modules // App modules
AuthModule, AuthModule,
CustomerModule, CustomerModule,
@ -75,6 +78,8 @@ import { UserModule } from './user/user.module';
HealthModule, HealthModule,
UserModule, UserModule,
CronModule,
], ],
providers: [ providers: [
// Global Pipes // Global Pipes

10
src/cron/cron.module.ts Normal file
View File

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { AllowanceModule } from '~/allowance/allowance.module';
import { BaseCronService } from './services';
import { AllowanceTask } from './tasks';
@Module({
imports: [AllowanceModule],
providers: [AllowanceTask, BaseCronService],
})
export class CronModule {}

View File

@ -0,0 +1,23 @@
import { Injectable } from '@nestjs/common';
import { CacheService } from '~/common/modules/cache/services';
@Injectable()
export class BaseCronService {
constructor(private readonly cacheService: CacheService) {}
async acquireLock(key: string, ttl: number) {
const lock = await this.cacheService.get(key);
if (lock) {
return false;
}
await this.cacheService.set(key, true, ttl);
return true;
}
async releaseLock(key: string) {
await this.cacheService.delete(key);
}
}

View File

@ -0,0 +1 @@
export * from './base-cron.service';

View File

@ -0,0 +1,54 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import moment from 'moment';
import { AllowancesService } from '~/allowance/services';
import { CacheService } from '~/common/modules/cache/services';
import { BaseCronService } from '../services/base-cron.service';
const TEN = 10;
const SIXTY = 60;
const THOUSAND = 1000;
const TEN_MINUTES = TEN * SIXTY * THOUSAND;
const CHUNK_SIZE = 50;
@Injectable()
export class AllowanceTask extends BaseCronService {
private readonly logger = new Logger(AllowanceTask.name);
private readonly cronLockKey = `${AllowanceTask.name}-lock`;
private readonly cronLockTtl = TEN_MINUTES;
constructor(cacheService: CacheService, private allowanceService: AllowancesService) {
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.');
return;
}
this.logger.log('Processing cron job');
await this.processJob();
} catch (error) {
this.logger.error('Error processing cron job', error);
} finally {
this.logger.log('Releasing lock');
await this.releaseLock(this.cronLockKey);
}
}
private async processJob() {
const today = moment().startOf('day');
const allowancesChunks = await this.allowanceService.findAllowancesChunks(CHUNK_SIZE);
for await (const allowances of allowancesChunks) {
for (const allowance of allowances) {
this.logger.log(`Processing allowance ${allowance.id} with next payment date ${allowance.nextPaymentDate}`);
// if today is the same as allowance payment date
if (moment(allowance.nextPaymentDate).startOf('day').isSame(today)) {
this.logger.log(`Today is the payment date for allowance ${allowance.id}`);
//@TODO: Implement payment logic
}
}
}
}
}

1
src/cron/tasks/index.ts Normal file
View File

@ -0,0 +1 @@
export * from './allowance.task';