Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions backend/asset-assignments/assets-assignments.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { Controller, Get, Post, Body, Param, Patch, UsePipes, ValidationPipe } from '@nestjs/common';
import { AssetAssignmentsService } from './asset-assignments.service';
import { CreateAssignmentDto } from './dto/create-assignment.dto';

@Controller('asset-assignments')
export class AssetAssignmentsController {
constructor(private readonly assignmentsService: AssetAssignmentsService) {}

@Post('assign')
@UsePipes(new ValidationPipe({ whitelist: true }))
assign(@Body() createDto: CreateAssignmentDto) {
return this.assignmentsService.assign(createDto);
}

@Patch('unassign/:assetId')
unassign(@Param('assetId') assetId: string) {
return this.assignmentsService.unassign(assetId);
}

@Get('history/:assetId')
getHistory(@Param('assetId') assetId: string) {
return this.assignmentsService.getHistoryForAsset(assetId);
}

@Get('current/:assetId')
getCurrentAssignment(@Param('assetId') assetId: string) {
return this.assignmentsService.getCurrentAssignmentForAsset(assetId);
}
}
12 changes: 12 additions & 0 deletions backend/asset-assignments/assets-assignments.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AssetAssignment } from './entities/asset-assignment.entity';
import { AssetAssignmentsService } from './asset-assignments.service';
import { AssetAssignmentsController } from './asset-assignments.controller';

@Module({
imports: [TypeOrmModule.forFeature([AssetAssignment])],
controllers: [AssetAssignmentsController],
providers: [AssetAssignmentsService],
})
export class AssetAssignmentsModule {}
88 changes: 88 additions & 0 deletions backend/asset-assignments/assets-assignments.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import {
Injectable,
NotFoundException,
ConflictException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, IsNull } from 'typeorm';
import { AssetAssignment } from './entities/asset-assignment.entity';
import { CreateAssignmentDto } from './dto/create-assignment.dto';

@Injectable()
export class AssetAssignmentsService {
constructor(
@InjectRepository(AssetAssignment)
private readonly assignmentRepository: Repository<AssetAssignment>,
) {}

async assign(createDto: CreateAssignmentDto): Promise<AssetAssignment> {
// 1. Check if the asset is already actively assigned
const existingAssignment = await this.assignmentRepository.findOne({
where: {
assetId: createDto.assetId,
unassignmentDate: IsNull(),
},
});

if (existingAssignment) {
throw new ConflictException(
`Asset with ID "${createDto.assetId}" is already assigned.`,
);
}

if (!createDto.assignedToUserId && !createDto.assignedToDepartmentId) {
throw new ConflictException(
'An assignment must have either a user ID or a department ID.',
);
}

// 2. Create the new assignment record
const newAssignment = this.assignmentRepository.create({
...createDto,
assignmentDate: new Date(),
});

return this.assignmentRepository.save(newAssignment);
}

async unassign(assetId: string): Promise<AssetAssignment> {
// Find the current active assignment for the asset
const currentAssignment = await this.assignmentRepository.findOne({
where: {
assetId,
unassignmentDate: IsNull(),
},
});

if (!currentAssignment) {
throw new NotFoundException(
`No active assignment found for asset with ID "${assetId}".`,
);
}

// Mark the assignment as historical by setting the unassignment date
currentAssignment.unassignmentDate = new Date();
return this.assignmentRepository.save(currentAssignment);
}

async getHistoryForAsset(assetId: string): Promise<AssetAssignment[]> {
return this.assignmentRepository.find({
where: { assetId },
order: { assignmentDate: 'DESC' }, // Show the most recent first
});
}

async getCurrentAssignmentForAsset(
assetId: string,
): Promise<AssetAssignment> {
const assignment = await this.assignmentRepository.findOne({
where: { assetId, unassignmentDate: IsNull() },
});
if (!assignment) {
throw new NotFoundException(
`No active assignment found for asset ID "${assetId}".`,
);
}
return assignment;
}
}
15 changes: 15 additions & 0 deletions backend/asset-assignments/dto/create-assignment.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { IsString, IsNotEmpty, IsOptional } from 'class-validator';

export class CreateAssignmentDto {
@IsString()
@IsNotEmpty()
assetId: string;

@IsString()
@IsOptional()
assignedToUserId?: string;

@IsString()
@IsOptional()
assignedToDepartmentId?: string;
}
22 changes: 22 additions & 0 deletions backend/asset-assignments/entities/asset-assignment.entity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn } from 'typeorm';

@Entity('asset_assignments')
export class AssetAssignment {
@PrimaryGeneratedColumn('uuid')
id: string;

@Column()
assetId: string;

@Column({ nullable: true })
assignedToUserId?: string;

@Column({ nullable: true })
assignedToDepartmentId?: string;

@Column({ type: 'timestamp' })
assignmentDate: Date;

@Column({ type: 'timestamp', nullable: true })
unassignmentDate?: Date; // This field tracks the history. Null means it's the current assignment.
}
17 changes: 17 additions & 0 deletions backend/licenses/dto/create-license.dto.ts
Original file line number Diff line number Diff line change
@@ -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;
}
28 changes: 28 additions & 0 deletions backend/licenses/entities/license.entity.ts
Original file line number Diff line number Diff line change
@@ -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;
}
29 changes: 29 additions & 0 deletions backend/licenses/licenses.controller.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
17 changes: 17 additions & 0 deletions backend/licenses/licenses.module.ts
Original file line number Diff line number Diff line change
@@ -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 {}
58 changes: 58 additions & 0 deletions backend/licenses/service/licenses.service.ts
Original file line number Diff line number Diff line change
@@ -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<License>,
) {}

async create(createDto: CreateLicenseDto): Promise<License> {
const license = this.licenseRepository.create(createDto);
return this.licenseRepository.save(license);
}

async findAllForAsset(assetId: string): Promise<License[]> {
return this.licenseRepository.find({ where: { assetId } });
}

async findOne(id: string): Promise<License> {
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<License[]> {
const thirtyDaysFromNow = addDays(new Date(), 30);
return this.licenseRepository.find({
where: {
expiryDate: LessThanOrEqual(thirtyDaysFromNow),
isExpiryNotified: false,
},
});
}

async markAsNotified(licenseIds: string[]): Promise<void> {
if (licenseIds.length === 0) return;
await this.licenseRepository.update(licenseIds, { isExpiryNotified: true });
}
}
42 changes: 42 additions & 0 deletions backend/licenses/tasks/license-expiry.task.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading