diff --git a/.eslintrc.js b/.eslintrc.js index f38abfe..cd85623 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -29,7 +29,7 @@ module.exports = { 'require-await': ['error'], 'no-console': ['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 }], 'max-len': [ 'error', diff --git a/package-lock.json b/package-lock.json index 2080cb9..b79a1b2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "@nestjs/microservices": "^10.4.7", "@nestjs/passport": "^10.0.3", "@nestjs/platform-express": "^10.4.8", + "@nestjs/schedule": "^4.1.2", "@nestjs/swagger": "^8.0.5", "@nestjs/terminus": "^10.2.3", "@nestjs/throttler": "^6.2.1", @@ -2198,6 +2199,33 @@ "@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": { "version": "10.2.3", "dev": true, @@ -2842,6 +2870,12 @@ "license": "MIT", "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": { "version": "1.1.4", "dev": true, @@ -5005,6 +5039,16 @@ "devOptional": true, "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": { "version": "7.0.5", "license": "MIT", @@ -9238,6 +9282,15 @@ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "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": { "version": "0.30.8", "dev": true, diff --git a/package.json b/package.json index 636ba95..22a7486 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "@nestjs/microservices": "^10.4.7", "@nestjs/passport": "^10.0.3", "@nestjs/platform-express": "^10.4.8", + "@nestjs/schedule": "^4.1.2", "@nestjs/swagger": "^8.0.5", "@nestjs/terminus": "^10.2.3", "@nestjs/throttler": "^6.2.1", diff --git a/src/allowance/allowance.module.ts b/src/allowance/allowance.module.ts index cd9c8ac..ff2541c 100644 --- a/src/allowance/allowance.module.ts +++ b/src/allowance/allowance.module.ts @@ -15,6 +15,6 @@ import { AllowanceChangeRequestsService, AllowancesService } from './services'; AllowanceChangeRequestsService, AllowanceChangeRequestsRepository, ], - exports: [], + exports: [AllowancesService], }) export class AllowanceModule {} diff --git a/src/allowance/entities/allowance.entity.ts b/src/allowance/entities/allowance.entity.ts index f12cb89..a0c4c84 100644 --- a/src/allowance/entities/allowance.entity.ts +++ b/src/allowance/entities/allowance.entity.ts @@ -1,3 +1,4 @@ +import moment from 'moment'; import { Column, CreateDateColumn, @@ -13,7 +14,6 @@ import { Guardian } from '~/guardian/entities/guradian.entity'; import { Junior } from '~/junior/entities'; import { AllowanceFrequency, AllowanceType } from '../enums'; import { AllowanceChangeRequest } from './allowance-change-request.entity'; - @Entity('allowances') export class Allowance { @PrimaryGeneratedColumn('uuid') @@ -71,4 +71,31 @@ export class Allowance { @DeleteDateColumn({ name: 'deleted_at', type: 'timestamp with time zone', nullable: true }) 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; + } + } } diff --git a/src/allowance/repositories/allowances.repository.ts b/src/allowance/repositories/allowances.repository.ts index b584a7b..2914ff1 100644 --- a/src/allowance/repositories/allowances.repository.ts +++ b/src/allowance/repositories/allowances.repository.ts @@ -44,4 +44,21 @@ export class AllowancesRepository { deleteAllowance(guardianId: string, allowanceId: string) { 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; + } + } } diff --git a/src/allowance/services/allowances.service.ts b/src/allowance/services/allowances.service.ts index f8a435d..9d3547a 100644 --- a/src/allowance/services/allowances.service.ts +++ b/src/allowance/services/allowances.service.ts @@ -89,6 +89,13 @@ export class AllowancesService { 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[]) { this.logger.log(`Preparing document for allowances`); await Promise.all( diff --git a/src/app.module.ts b/src/app.module.ts index e2dc6d3..4c2b94b 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -2,6 +2,7 @@ import { MiddlewareConsumer, Module } from '@nestjs/common'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { APP_FILTER, APP_PIPE } from '@nestjs/core'; import { EventEmitterModule } from '@nestjs/event-emitter'; +import { ScheduleModule } from '@nestjs/schedule'; import { TypeOrmModule } from '@nestjs/typeorm'; import { I18nMiddleware, I18nModule } from 'nestjs-i18n'; import { LoggerModule } from 'nestjs-pino'; @@ -17,6 +18,7 @@ import { AllExceptionsFilter, buildI18nValidationExceptionFilter } from './core/ import { buildConfigOptions, buildLoggerOptions, buildTypeormOptions } from './core/module-options'; import { buildI18nOptions } from './core/module-options/i18n-options'; import { buildValidationPipe } from './core/pipes'; +import { CronModule } from './cron/cron.module'; import { CustomerModule } from './customer/customer.module'; import { migrations } from './db'; import { DocumentModule } from './document/document.module'; @@ -56,6 +58,7 @@ import { UserModule } from './user/user.module'; I18nModule.forRoot(buildI18nOptions()), CacheModule, EventEmitterModule.forRoot(), + ScheduleModule.forRoot(), // App modules AuthModule, CustomerModule, @@ -75,6 +78,8 @@ import { UserModule } from './user/user.module'; HealthModule, UserModule, + + CronModule, ], providers: [ // Global Pipes diff --git a/src/cron/cron.module.ts b/src/cron/cron.module.ts new file mode 100644 index 0000000..973cd29 --- /dev/null +++ b/src/cron/cron.module.ts @@ -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 {} diff --git a/src/cron/services/base-cron.service.ts b/src/cron/services/base-cron.service.ts new file mode 100644 index 0000000..02cd69b --- /dev/null +++ b/src/cron/services/base-cron.service.ts @@ -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); + } +} diff --git a/src/cron/services/index.ts b/src/cron/services/index.ts new file mode 100644 index 0000000..7916949 --- /dev/null +++ b/src/cron/services/index.ts @@ -0,0 +1 @@ +export * from './base-cron.service'; diff --git a/src/cron/tasks/allowance.task.ts b/src/cron/tasks/allowance.task.ts new file mode 100644 index 0000000..9efd787 --- /dev/null +++ b/src/cron/tasks/allowance.task.ts @@ -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 + } + } + } + } +} diff --git a/src/cron/tasks/index.ts b/src/cron/tasks/index.ts new file mode 100644 index 0000000..42da291 --- /dev/null +++ b/src/cron/tasks/index.ts @@ -0,0 +1 @@ +export * from './allowance.task';