diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 1898a70d..27200bc7 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -26,6 +26,8 @@ import { ContractsModule } from './contracts/contracts.module'; import { LicensesModule } from './licenses/licenses.module'; import { PurchaseOrdersModule } from './purchase-orders/purchase-orders.module'; import { TasksModule } from './tasks/tasks.module'; +import { ReportsModule } from './reports/reports.module'; +import { NotificationsModule } from './notifications/notifications.module'; import { ApiKeysModule } from './api-keys/api-keys.module'; import { StellarModule } from './stellar/stellar.module'; import { NotificationModule } from './notifications/notification.module'; @@ -110,6 +112,8 @@ import { NotificationModule } from './notifications/notification.module'; InventoryModule, VendorsModule, DashboardModule, + ReportsModule, + NotificationsModule, ApiKeysModule, StellarModule, NotificationModule, diff --git a/backend/src/notifications/notification.entity.ts b/backend/src/notifications/notification.entity.ts new file mode 100644 index 00000000..bc873399 --- /dev/null +++ b/backend/src/notifications/notification.entity.ts @@ -0,0 +1,27 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, +} from 'typeorm'; + +@Entity('notifications') +export class Notification { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + userId: string; + + @Column() + type: string; + + @Column('text') + message: string; + + @Column({ default: false }) + read: boolean; + + @CreateDateColumn() + createdAt: Date; +} diff --git a/backend/src/notifications/notifications.controller.ts b/backend/src/notifications/notifications.controller.ts new file mode 100644 index 00000000..02c2dfdd --- /dev/null +++ b/backend/src/notifications/notifications.controller.ts @@ -0,0 +1,24 @@ +import { Controller, Get, Patch, Param, Req, UseGuards } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { NotificationsService } from './notifications.service'; + +@Controller('notifications') +@UseGuards(AuthGuard('jwt')) +export class NotificationsController { + constructor(private readonly notificationsService: NotificationsService) {} + + @Get() + findAll(@Req() req: any) { + return this.notificationsService.findByUser(req.user?.id); + } + + @Patch(':id/read') + markRead(@Param('id') id: string, @Req() req: any) { + return this.notificationsService.markRead(id, req.user?.id); + } + + @Patch('read-all') + markAllRead(@Req() req: any) { + return this.notificationsService.markAllRead(req.user?.id); + } +} diff --git a/backend/src/notifications/notifications.gateway.ts b/backend/src/notifications/notifications.gateway.ts new file mode 100644 index 00000000..613df5de --- /dev/null +++ b/backend/src/notifications/notifications.gateway.ts @@ -0,0 +1,44 @@ +import { + WebSocketGateway, + WebSocketServer, + SubscribeMessage, + OnGatewayConnection, + OnGatewayDisconnect, +} from '@nestjs/websockets'; +import { Server, Socket } from 'socket.io'; +import { JwtService } from '@nestjs/jwt'; +import { Injectable } from '@nestjs/common'; + +@Injectable() +@WebSocketGateway({ namespace: '/notifications', cors: { origin: '*' } }) +export class NotificationsGateway + implements OnGatewayConnection, OnGatewayDisconnect +{ + @WebSocketServer() + server: Server; + + constructor(private readonly jwtService: JwtService) {} + + handleConnection(client: Socket) { + try { + const token = (client.handshake.auth?.token || + client.handshake.query?.token) as string; + const payload = this.jwtService.verify(token); + client.data.userId = payload.sub; + void client.join(`user:${String(payload.sub)}`); + } catch { + client.disconnect(); + } + } + + handleDisconnect(_client: Socket) {} + + sendToUser(userId: string, event: string, data: unknown) { + this.server.to(`user:${userId}`).emit(event, data); + } + + @SubscribeMessage('ping') + handlePing() { + return { event: 'pong', data: {} }; + } +} diff --git a/backend/src/notifications/notifications.module.ts b/backend/src/notifications/notifications.module.ts new file mode 100644 index 00000000..c57f072e --- /dev/null +++ b/backend/src/notifications/notifications.module.ts @@ -0,0 +1,27 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { JwtModule } from '@nestjs/jwt'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { Notification } from './entities/notification.entity'; +import { NotificationsGateway } from './notifications.gateway'; +import { NotificationsService } from './notifications.service'; +import { NotificationsController } from './notifications.controller'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([Notification]), + JwtModule.registerAsync({ + imports: [ConfigModule], + inject: [ConfigService], + useFactory: (config: ConfigService) => ({ + secret: config.get('JWT_SECRET', 'change-me-in-env'), + signOptions: { expiresIn: '15m' }, + }), + }), + ConfigModule, + ], + controllers: [NotificationsController], + providers: [NotificationsGateway, NotificationsService], + exports: [NotificationsService, NotificationsGateway], +}) +export class NotificationsModule {} diff --git a/backend/src/notifications/notifications.service.ts b/backend/src/notifications/notifications.service.ts index efd2fba8..f2c84d56 100644 --- a/backend/src/notifications/notifications.service.ts +++ b/backend/src/notifications/notifications.service.ts @@ -56,4 +56,36 @@ export class NotificationsService { where: { userId, isRead: false }, }); } + + async sendToUser( + userId: string, + type: string, + message: string, + ): Promise { + return this.create({ + userId, + event: type as any, + title: type, + message, + emailTemplate: '', + emailSubject: '', + emailContext: {}, + } as any); + } + + async findByUser(userId: string): Promise { + return this.findByUserId(userId); + } + + async markRead(id: string, userId: string): Promise { + const notification = await this.notificationRepository.findOne({ + where: { id, userId }, + }); + if (!notification) return null; + return this.markAsRead(id); + } + + async markAllRead(userId: string): Promise { + return this.markAllAsRead(userId); + } } diff --git a/backend/src/reports/report-schedule.entity.ts b/backend/src/reports/report-schedule.entity.ts new file mode 100644 index 00000000..13ef7399 --- /dev/null +++ b/backend/src/reports/report-schedule.entity.ts @@ -0,0 +1,40 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, +} from 'typeorm'; + +@Entity('report_schedules') +export class ReportSchedule { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + userId: string; + + @Column() + reportType: string; + + @Column() + frequency: string; + + @Column() + email: string; + + @Column({ default: true }) + isActive: boolean; + + @Column({ nullable: true, type: 'timestamp' }) + lastRunAt: Date; + + @Column({ nullable: true, type: 'timestamp' }) + nextRunAt: Date; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/backend/src/reports/reports.controller.ts b/backend/src/reports/reports.controller.ts new file mode 100644 index 00000000..3cb553a9 --- /dev/null +++ b/backend/src/reports/reports.controller.ts @@ -0,0 +1,69 @@ +import { + Controller, + Get, + Post, + Delete, + Query, + Body, + Param, + Req, + UseGuards, +} from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { ReportsService } from './reports.service'; + +@Controller('reports') +@UseGuards(AuthGuard('jwt')) +export class ReportsController { + constructor(private readonly reportsService: ReportsService) {} + + @Get('summary') + getSummary() { + return this.reportsService.getSummary(); + } + + @Get('warranty-expiring') + getWarrantyExpiring(@Query('days') days?: string) { + return this.reportsService.getWarrantyExpiring( + days ? parseInt(days, 10) : 30, + ); + } + + @Get('maintenance-costs') + getMaintenanceCosts() { + return this.reportsService.getMaintenanceCosts(); + } + + @Get('depreciation') + getDepreciation() { + return this.reportsService.getDepreciation(); + } + + @Get('asset-utilisation') + getAssetUtilisation() { + return this.reportsService.getAssetUtilisation(); + } + + @Get('schedules') + getSchedules(@Req() req: any) { + return this.reportsService.getSchedules(req.user?.id); + } + + @Post('schedules') + createSchedule( + @Req() req: any, + @Body() body: { reportType: string; frequency: string; email: string }, + ) { + return this.reportsService.createSchedule( + req.user?.id, + body.reportType, + body.frequency, + body.email, + ); + } + + @Delete('schedules/:id') + deleteSchedule(@Param('id') id: string, @Req() req: any) { + return this.reportsService.deleteSchedule(id, req.user?.id); + } +} diff --git a/backend/src/reports/reports.module.ts b/backend/src/reports/reports.module.ts new file mode 100644 index 00000000..54f3a7d1 --- /dev/null +++ b/backend/src/reports/reports.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Asset } from '../assets/asset.entity'; +import { ReportSchedule } from './report-schedule.entity'; +import { ReportsService } from './reports.service'; +import { ReportsController } from './reports.controller'; + +@Module({ + imports: [TypeOrmModule.forFeature([Asset, ReportSchedule])], + controllers: [ReportsController], + providers: [ReportsService], + exports: [ReportsService], +}) +export class ReportsModule {} diff --git a/backend/src/reports/reports.service.ts b/backend/src/reports/reports.service.ts new file mode 100644 index 00000000..3d1825d3 --- /dev/null +++ b/backend/src/reports/reports.service.ts @@ -0,0 +1,116 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Asset } from '../assets/asset.entity'; +import { ReportSchedule } from './report-schedule.entity'; + +@Injectable() +export class ReportsService { + constructor( + @InjectRepository(Asset) + private readonly assetRepository: Repository, + @InjectRepository(ReportSchedule) + private readonly scheduleRepository: Repository, + ) {} + + async getSummary() { + const assets = await this.assetRepository.find(); + const total = assets.length; + const byStatus: Record = {}; + const byCondition: Record = {}; + let totalValue = 0; + for (const asset of assets) { + byStatus[asset.status] = (byStatus[asset.status] || 0) + 1; + byCondition[asset.condition] = (byCondition[asset.condition] || 0) + 1; + totalValue += Number(asset.purchasePrice) || 0; + } + return { + total, + byStatus, + byCondition, + totalValue: Math.round(totalValue * 100) / 100, + }; + } + + async getWarrantyExpiring(daysAhead = 30) { + const cutoff = new Date(); + cutoff.setDate(cutoff.getDate() + daysAhead); + return this.assetRepository + .createQueryBuilder('asset') + .where('asset.warrantyExpiration <= :cutoff', { + cutoff: cutoff.toISOString().split('T')[0], + }) + .andWhere('asset.warrantyExpiration >= :today', { + today: new Date().toISOString().split('T')[0], + }) + .getMany(); + } + + async getMaintenanceCosts() { + return this.assetRepository + .createQueryBuilder('asset') + .select('asset.categoryId', 'categoryId') + .addSelect('COUNT(asset.id)', 'assetCount') + .addSelect('SUM(asset.purchasePrice)', 'totalValue') + .groupBy('asset.categoryId') + .getRawMany(); + } + + async getDepreciation() { + const assets = await this.assetRepository.find({ + where: { status: 'ACTIVE' }, + }); + return assets.map((asset) => { + const age = asset.purchaseDate + ? Math.floor( + (Date.now() - new Date(asset.purchaseDate).getTime()) / + (365.25 * 24 * 3600 * 1000), + ) + : 0; + const depreciatedValue = Number(asset.purchasePrice) * Math.pow(0.8, age); + return { + id: asset.id, + name: asset.name, + purchasePrice: asset.purchasePrice, + age, + depreciatedValue: Math.round(depreciatedValue * 100) / 100, + }; + }); + } + + async getAssetUtilisation() { + return this.assetRepository + .createQueryBuilder('asset') + .select('asset.departmentId', 'departmentId') + .addSelect('COUNT(asset.id)', 'total') + .addSelect( + "SUM(CASE WHEN asset.status = 'ASSIGNED' THEN 1 ELSE 0 END)", + 'assigned', + ) + .groupBy('asset.departmentId') + .getRawMany(); + } + + async createSchedule( + userId: string, + reportType: string, + frequency: string, + email: string, + ): Promise { + const schedule = this.scheduleRepository.create({ + userId, + reportType, + frequency, + email, + }); + return this.scheduleRepository.save(schedule); + } + + async getSchedules(userId: string): Promise { + return this.scheduleRepository.find({ where: { userId, isActive: true } }); + } + + async deleteSchedule(id: string, userId: string): Promise { + await this.scheduleRepository.delete({ id, userId }); + } +}