diff --git a/backend/src/insurance/insurance-query.dto.ts b/backend/src/insurance/insurance-query.dto.ts new file mode 100644 index 0000000..bb4fce7 --- /dev/null +++ b/backend/src/insurance/insurance-query.dto.ts @@ -0,0 +1,25 @@ +import { IsOptional, IsString, IsInt, Min } from 'class-validator'; +import { Type } from 'class-transformer'; + +export class QueryAssetInsuranceDto { + @IsOptional() + @IsString() + assetId?: string; + + @IsOptional() + @IsString() + provider?: string; + + // pagination + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(0) + skip?: number; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + take?: number; +} diff --git a/backend/src/insurance/insurance.controller.ts b/backend/src/insurance/insurance.controller.ts new file mode 100644 index 0000000..1bd03fa --- /dev/null +++ b/backend/src/insurance/insurance.controller.ts @@ -0,0 +1,59 @@ +import { + Controller, + Get, + Post, + Body, + Param, + Put, + Delete, + Query, + ParseUUIDPipe, + UsePipes, + ValidationPipe, + DefaultValuePipe, + ParseIntPipe, +} from '@nestjs/common'; +import { AssetInsuranceService } from './asset-insurance.service'; +import { CreateAssetInsuranceDto } from './dto/create-asset-insurance.dto'; +import { UpdateAssetInsuranceDto } from './dto/update-asset-insurance.dto'; +import { QueryAssetInsuranceDto } from './dto/query-asset-insurance.dto'; + +@Controller('asset-insurance') +export class AssetInsuranceController { + constructor(private readonly service: AssetInsuranceService) {} + + @Post() + @UsePipes(new ValidationPipe({ whitelist: true, transform: true })) + create(@Body() dto: CreateAssetInsuranceDto) { + return this.service.create(dto); + } + + @Get() + @UsePipes(new ValidationPipe({ whitelist: true, transform: true })) + findAll(@Query() q: QueryAssetInsuranceDto) { + const { assetId, provider, skip, take } = q; + return this.service.findAll({ assetId, provider, skip, take }); + } + + @Get(':id') + findOne(@Param('id', new ParseUUIDPipe()) id: string) { + return this.service.findOne(id); + } + + @Put(':id') + @UsePipes(new ValidationPipe({ whitelist: true, transform: true })) + update(@Param('id', new ParseUUIDPipe()) id: string, @Body() dto: UpdateAssetInsuranceDto) { + return this.service.update(id, dto); + } + + @Delete(':id') + remove(@Param('id', new ParseUUIDPipe()) id: string) { + return this.service.remove(id); + } + + // Optional quick endpoint to trigger the expiry check manually + @Post('run-expiry-check') + runExpiryCheck(@Query('days', new DefaultValuePipe(30), ParseIntPipe) days: number) { + return this.service.runExpiryNotifications(days); + } +} diff --git a/backend/src/insurance/insurance.dto.ts b/backend/src/insurance/insurance.dto.ts new file mode 100644 index 0000000..4db8cf2 --- /dev/null +++ b/backend/src/insurance/insurance.dto.ts @@ -0,0 +1,25 @@ +import { IsString, IsNotEmpty, IsOptional, IsDateString, MaxLength } from 'class-validator'; + +export class CreateAssetInsuranceDto { + @IsString() + @IsNotEmpty() + @MaxLength(100) + assetId: string; + + @IsString() + @IsNotEmpty() + @MaxLength(200) + policyNumber: string; + + @IsString() + @IsNotEmpty() + @MaxLength(200) + provider: string; + + @IsDateString() + expiryDate: string; // ISO date string + + @IsOptional() + @IsString() + notes?: string; +} diff --git a/backend/src/insurance/insurance.entity.ts b/backend/src/insurance/insurance.entity.ts new file mode 100644 index 0000000..93c2a50 --- /dev/null +++ b/backend/src/insurance/insurance.entity.ts @@ -0,0 +1,31 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, Index } from 'typeorm'; + +@Entity({ name: 'asset_insurance' }) +export class AssetInsurance { + @PrimaryGeneratedColumn('uuid') + id: string; + + // Could be a foreign key to an assets table. We store it as UUID or number depending on your asset PK. + @Column({ name: 'asset_id', type: 'varchar', length: 100 }) + @Index() + assetId: string; + + @Column({ name: 'policy_number', type: 'varchar', length: 200, unique: true }) + @Index() + policyNumber: string; + + @Column({ name: 'provider', type: 'varchar', length: 200 }) + provider: string; + + @Column({ name: 'expiry_date', type: 'timestamptz' }) + expiryDate: Date; + + @Column({ name: 'notes', type: 'text', nullable: true }) + notes?: string | null; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/backend/src/insurance/insurance.module.ts b/backend/src/insurance/insurance.module.ts new file mode 100644 index 0000000..94b7dc4 --- /dev/null +++ b/backend/src/insurance/insurance.module.ts @@ -0,0 +1,19 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { ScheduleModule } from '@nestjs/schedule'; +import { AssetInsurance } from './asset-insurance.entity'; +import { AssetInsuranceService } from './asset-insurance.service'; +import { AssetInsuranceController } from './asset-insurance.controller'; +import { NotificationService } from './notification.service'; +import { ExpirySchedulerService } from './expiry-scheduler.service'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([AssetInsurance]), + ScheduleModule, // allow injection of scheduler, but make sure ScheduleModule.forRoot() is registered in AppModule + ], + providers: [AssetInsuranceService, NotificationService, ExpirySchedulerService], + controllers: [AssetInsuranceController], + exports: [AssetInsuranceService], +}) +export class AssetInsuranceModule {} diff --git a/backend/src/insurance/insurance.service.ts b/backend/src/insurance/insurance.service.ts new file mode 100644 index 0000000..45f932b --- /dev/null +++ b/backend/src/insurance/insurance.service.ts @@ -0,0 +1,103 @@ +import { Injectable, NotFoundException, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, LessThanOrEqual, MoreThanOrEqual } from 'typeorm'; +import { AssetInsurance } from './asset-insurance.entity'; +import { CreateAssetInsuranceDto } from './dto/create-asset-insurance.dto'; +import { UpdateAssetInsuranceDto } from './dto/update-asset-insurance.dto'; +import { NotificationService } from './notification.service'; + +@Injectable() +export class AssetInsuranceService { + private readonly logger = new Logger(AssetInsuranceService.name); + + constructor( + @InjectRepository(AssetInsurance) + private readonly repo: Repository, + private readonly notificationService: NotificationService, + ) {} + + async create(dto: CreateAssetInsuranceDto): Promise { + const entity = this.repo.create({ + assetId: dto.assetId, + policyNumber: dto.policyNumber, + provider: dto.provider, + expiryDate: new Date(dto.expiryDate), + notes: dto.notes ?? null, + }); + return this.repo.save(entity); + } + + async findOne(id: string): Promise { + const found = await this.repo.findOne({ where: { id } }); + if (!found) throw new NotFoundException(`AssetInsurance ${id} not found`); + return found; + } + + async findAll(query: { assetId?: string; provider?: string; skip?: number; take?: number }) { + const qb = this.repo.createQueryBuilder('ai'); + + if (query.assetId) qb.andWhere('ai.assetId = :assetId', { assetId: query.assetId }); + if (query.provider) qb.andWhere('ai.provider ILIKE :provider', { provider: `%${query.provider}%` }); + + if (typeof query.skip === 'number') qb.skip(query.skip); + if (typeof query.take === 'number') qb.take(query.take); + + const [items, total] = await qb.getManyAndCount(); + return { items, total }; + } + + async update(id: string, dto: UpdateAssetInsuranceDto): Promise { + const entity = await this.findOne(id); + + if (dto.assetId) entity.assetId = dto.assetId; + if (dto.policyNumber) entity.policyNumber = dto.policyNumber; + if (dto.provider) entity.provider = dto.provider; + if (dto.expiryDate) entity.expiryDate = new Date(dto.expiryDate); + if (dto.notes !== undefined) entity.notes = dto.notes; + + return this.repo.save(entity); + } + + async remove(id: string): Promise { + const res = await this.repo.delete({ id }); + if (res.affected === 0) throw new NotFoundException(`AssetInsurance ${id} not found`); + } + + /** + * Return policies that will expire within the next `days` (inclusive) + */ + async findExpiringWithin(days: number) { + const now = new Date(); + const until = new Date(now); + until.setUTCDate(until.getUTCDate() + days); + + return this.repo.find({ + where: { + expiryDate: MoreThanOrEqual(now) as any, // ensure expiry >= now + }, + }).then(list => list.filter(item => item.expiryDate <= until)); + } + + /** + * Run the expiry check and send notifications via NotificationService + */ + async runExpiryNotifications(daysBeforeExpiry = 30) { + this.logger.log(`Checking policies expiring within ${daysBeforeExpiry} day(s)...`); + const now = new Date(); + const until = new Date(now); + until.setUTCDate(until.getUTCDate() + daysBeforeExpiry); + + const toNotify = await this.repo.createQueryBuilder('ai') + .where('ai.expiry_date BETWEEN :now AND :until', { now: now.toISOString(), until: until.toISOString() }) + .getMany(); + + this.logger.log(`Found ${toNotify.length} policy(ies) to notify`); + for (const policy of toNotify) { + try { + await this.notificationService.notifyExpiry(policy, daysBeforeExpiry); + } catch (err) { + this.logger.error(`Failed to send notification for policy ${policy.id}: ${err?.message ?? err}`); + } + } + } +} diff --git a/backend/src/insurance/notification.service.ts b/backend/src/insurance/notification.service.ts new file mode 100644 index 0000000..f800aaa --- /dev/null +++ b/backend/src/insurance/notification.service.ts @@ -0,0 +1,22 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { AssetInsurance } from './asset-insurance.entity'; + +/** + * Replace/extend this service to integrate with your email/SMS/push provider. + * For now it logs and pretends to send. + */ +@Injectable() +export class NotificationService { + private readonly logger = new Logger(NotificationService.name); + + async notifyExpiry(policy: AssetInsurance, daysBeforeExpiry: number) { + // Example payload; adapt to your notification template & recipients + const message = `Policy ${policy.policyNumber} for asset ${policy.assetId} expires on ${policy.expiryDate.toISOString()} (in ${daysBeforeExpiry} day(s) or less). Provider: ${policy.provider}.`; + // TODO: send email / sms / push using your provider + this.logger.warn(`[ExpiryNotification] ${message}`); + + // If you integrate email, you may add: + // await this.mailerService.sendMail({ to: 'ops@example.com', subject: 'Policy expiry', text: message }); + return Promise.resolve(true); + } +} diff --git a/backend/src/insurance/update-insurance.dto.ts b/backend/src/insurance/update-insurance.dto.ts new file mode 100644 index 0000000..ae5235b --- /dev/null +++ b/backend/src/insurance/update-insurance.dto.ts @@ -0,0 +1,9 @@ +import { PartialType } from '@nestjs/mapped-types'; +import { CreateAssetInsuranceDto } from './create-asset-insurance.dto'; +import { IsOptional, IsDateString } from 'class-validator'; + +export class UpdateAssetInsuranceDto extends PartialType(CreateAssetInsuranceDto) { + @IsOptional() + @IsDateString() + expiryDate?: string; +}