diff --git a/backend/licenses/dto/create-license.dto.ts b/backend/licenses/dto/create-license.dto.ts new file mode 100644 index 0000000..f6adbe1 --- /dev/null +++ b/backend/licenses/dto/create-license.dto.ts @@ -0,0 +1,17 @@ +import { IsString, IsNotEmpty, IsDateString, IsUrl } from 'class-validator'; + +export class CreateLicenseDto { + @IsString() + @IsNotEmpty() + assetId: string; + + @IsString() + @IsNotEmpty() + licenseType: string; + + @IsDateString() + expiryDate: string; + + @IsUrl() + documentUrl: string; +} \ No newline at end of file diff --git a/backend/licenses/entities/license.entity.ts b/backend/licenses/entities/license.entity.ts new file mode 100644 index 0000000..401bcd5 --- /dev/null +++ b/backend/licenses/entities/license.entity.ts @@ -0,0 +1,28 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm'; + +@Entity('licenses') +export class License { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + assetId: string; // The ID of the asset this license belongs to + + @Column() + licenseType: string; // e.g., "Vehicle Registration", "Software License" + + @Column({ type: 'date' }) + expiryDate: Date; + + @Column() + documentUrl: string; // URL to the stored document (e.g., in S3) + + @Column({ default: false }) + isExpiryNotified: boolean; // Flag to prevent sending repeated alerts + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} \ No newline at end of file diff --git a/backend/licenses/licenses.controller.ts b/backend/licenses/licenses.controller.ts new file mode 100644 index 0000000..c34a6b9 --- /dev/null +++ b/backend/licenses/licenses.controller.ts @@ -0,0 +1,29 @@ +import { Controller, Get, Post, Body, Param, Delete, UsePipes, ValidationPipe } from '@nestjs/common'; +import { LicensesService } from './services/licenses.service'; +import { CreateLicenseDto } from './dto/create-license.dto'; + +@Controller('licenses') +export class LicensesController { + constructor(private readonly licensesService: LicensesService) {} + + @Post() + @UsePipes(new ValidationPipe({ whitelist: true })) + create(@Body() createDto: CreateLicenseDto) { + return this.licensesService.create(createDto); + } + + @Get('asset/:assetId') + findAllForAsset(@Param('assetId') assetId: string) { + return this.licensesService.findAllForAsset(assetId); + } + + @Get(':id') + findOne(@Param('id') id: string) { + return this.licensesService.findOne(id); + } + + @Delete(':id') + remove(@Param('id') id: string) { + return this.licensesService.remove(id); + } +} \ No newline at end of file diff --git a/backend/licenses/licenses.module.ts b/backend/licenses/licenses.module.ts new file mode 100644 index 0000000..1fcdb7d --- /dev/null +++ b/backend/licenses/licenses.module.ts @@ -0,0 +1,17 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { ScheduleModule } from '@nestjs/schedule'; +import { License } from './entities/license.entity'; +import { LicensesService } from './services/licenses.service'; +import { LicensesController } from './licenses.controller'; +import { LicenseExpiryTask } from './tasks/license-expiry.task'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([License]), + ScheduleModule.forRoot(), // Initialize the scheduler + ], + controllers: [LicensesController], + providers: [LicensesService, LicenseExpiryTask], +}) +export class LicensesModule {} \ No newline at end of file diff --git a/backend/licenses/service/licenses.service.ts b/backend/licenses/service/licenses.service.ts new file mode 100644 index 0000000..30be4f6 --- /dev/null +++ b/backend/licenses/service/licenses.service.ts @@ -0,0 +1,58 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, LessThanOrEqual } from 'typeorm'; +import { License } from '../entities/license.entity'; +import { CreateLicenseDto } from '../dto/create-license.dto'; +import { LessThan } from 'typeorm'; +import { addDays } from 'date-fns'; + +@Injectable() +export class LicensesService { + constructor( + @InjectRepository(License) + private readonly licenseRepository: Repository, + ) {} + + async create(createDto: CreateLicenseDto): Promise { + const license = this.licenseRepository.create(createDto); + return this.licenseRepository.save(license); + } + + async findAllForAsset(assetId: string): Promise { + return this.licenseRepository.find({ where: { assetId } }); + } + + async findOne(id: string): Promise { + const license = await this.licenseRepository.findOne({ where: { id } }); + if (!license) { + throw new NotFoundException(`License with ID "${id}" not found.`); + } + return license; + } + + async remove(id: string): Promise<{ deleted: boolean }> { + const result = await this.licenseRepository.delete(id); + if (result.affected === 0) { + throw new NotFoundException(`License with ID "${id}" not found.`); + } + return { deleted: true }; + } + + /** + * Finds all licenses that will expire within the next 30 days and have not been notified yet. + */ + async findLicensesNearingExpiry(): Promise { + const thirtyDaysFromNow = addDays(new Date(), 30); + return this.licenseRepository.find({ + where: { + expiryDate: LessThanOrEqual(thirtyDaysFromNow), + isExpiryNotified: false, + }, + }); + } + + async markAsNotified(licenseIds: string[]): Promise { + if (licenseIds.length === 0) return; + await this.licenseRepository.update(licenseIds, { isExpiryNotified: true }); + } +} diff --git a/backend/licenses/tasks/license-expiry.task.ts b/backend/licenses/tasks/license-expiry.task.ts new file mode 100644 index 0000000..cabb867 --- /dev/null +++ b/backend/licenses/tasks/license-expiry.task.ts @@ -0,0 +1,42 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import { LicensesService } from '../services/licenses.service'; +import { EventEmitter2 } from '@nestjs/event-emitter'; + +@Injectable() +export class LicenseExpiryTask { + private readonly logger = new Logger(LicenseExpiryTask.name); + + constructor( + private readonly licensesService: LicensesService, + private readonly eventEmitter: EventEmitter2, + ) {} + + @Cron(CronExpression.EVERY_DAY_AT_9AM, { name: 'checkLicenseExpiry' }) + async handleCron() { + this.logger.log('Running scheduled job: Checking for expiring licenses...'); + + const expiringLicenses = await this.licensesService.findLicensesNearingExpiry(); + + if (expiringLicenses.length === 0) { + this.logger.log('No new licenses nearing expiry found.'); + return; + } + + this.logger.log(`Found ${expiringLicenses.length} licenses nearing expiry. Triggering alerts...`); + + for (const license of expiringLicenses) { + // Emit an event for the notification module to handle + this.eventEmitter.emit('license.nearing_expiry', { + licenseId: license.id, + assetId: license.assetId, + expiryDate: license.expiryDate, + // Add user/admin details here for notification routing + }); + } + + // Mark these licenses as notified to prevent duplicate alerts + const idsToUpdate = expiringLicenses.map(l => l.id); + await this.licensesService.markAsNotified(idsToUpdate); + } +} \ No newline at end of file diff --git a/backend/package-lock.json b/backend/package-lock.json index 0b0a5a2..5406774 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -16,6 +16,7 @@ "@nestjs/mapped-types": "*", "@nestjs/passport": "^11.0.5", "@nestjs/platform-express": "^10.0.0", + "@nestjs/schedule": "^6.0.1", "@nestjs/swagger": "^7.3.0", "@nestjs/typeorm": "^10.0.2", "@types/multer": "^2.0.0", @@ -1773,6 +1774,19 @@ "@nestjs/core": "^10.0.0" } }, + "node_modules/@nestjs/schedule": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-6.0.1.tgz", + "integrity": "sha512-v3yO6cSPAoBSSyH67HWnXHzuhPhSNZhRmLY38JvCt2sqY8sPMOODpcU1D79iUMFf7k16DaMEbL4Mgx61ZhiC8Q==", + "license": "MIT", + "dependencies": { + "cron": "4.3.3" + }, + "peerDependencies": { + "@nestjs/common": "^10.0.0 || ^11.0.0", + "@nestjs/core": "^10.0.0 || ^11.0.0" + } + }, "node_modules/@nestjs/schematics": { "version": "10.2.3", "resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-10.2.3.tgz", @@ -2287,6 +2301,12 @@ "@types/node": "*" } }, + "node_modules/@types/luxon": { + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.7.1.tgz", + "integrity": "sha512-H3iskjFIAn5SlJU7OuxUmTEpebK6TKB8rxZShDslBMZJ5u9S//KM1sbdAisiSrqwLQncVjnpi2OK2J51h+4lsg==", + "license": "MIT" + }, "node_modules/@types/methods": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", @@ -3949,6 +3969,19 @@ "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", "devOptional": true }, + "node_modules/cron": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/cron/-/cron-4.3.3.tgz", + "integrity": "sha512-B/CJj5yL3sjtlun6RtYHvoSB26EmQ2NUmhq9ZiJSyKIM4K/fqfh9aelDFlIayD2YMeFZqWLi9hHV+c+pq2Djkw==", + "license": "MIT", + "dependencies": { + "@types/luxon": "~3.7.0", + "luxon": "~3.7.0" + }, + "engines": { + "node": ">=18.x" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -6779,6 +6812,15 @@ "yallist": "^3.0.2" } }, + "node_modules/luxon": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", + "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/magic-string": { "version": "0.30.8", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.8.tgz", diff --git a/backend/package.json b/backend/package.json index ae3d2ca..fc2f2ef 100644 --- a/backend/package.json +++ b/backend/package.json @@ -27,6 +27,7 @@ "@nestjs/mapped-types": "*", "@nestjs/passport": "^11.0.5", "@nestjs/platform-express": "^10.0.0", + "@nestjs/schedule": "^6.0.1", "@nestjs/swagger": "^7.3.0", "@nestjs/typeorm": "^10.0.2", "@types/multer": "^2.0.0",