diff --git a/backend/.eslintrc.js b/backend/.eslintrc.js index 259de13c7..d6f1f85cb 100644 --- a/backend/.eslintrc.js +++ b/backend/.eslintrc.js @@ -21,5 +21,6 @@ module.exports = { '@typescript-eslint/explicit-function-return-type': 'off', '@typescript-eslint/explicit-module-boundary-types': 'off', '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], }, }; diff --git a/backend/package-lock.json b/backend/package-lock.json index 57f56a07b..01ea04f03 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -10,6 +10,7 @@ "license": "UNLICENSED", "dependencies": { "@aws-sdk/client-s3": "^3.975.0", + "@aws-sdk/s3-request-presigner": "^3.1075.0", "@nestjs/bull": "^11.0.4", "@nestjs/cache-manager": "^3.1.0", "@nestjs/common": "^10.0.0", @@ -53,6 +54,7 @@ "passport-google-oauth20": "^2.0.0", "passport-jwt": "^4.0.1", "passport-local": "^1.0.0", + "passport-microsoft": "^1.0.0", "pdfkit": "^0.17.2", "pg": "^8.11.3", "qrcode": "^1.5.4", @@ -76,7 +78,7 @@ "@types/jest": "^29.5.2", "@types/json2csv": "^5.0.7", "@types/jsonwebtoken": "^9.0.10", - "@types/node": "^20.19.30", + "@types/node": "^20.19.43", "@types/node-cron": "^3.0.11", "@types/nodemailer": "^7.0.9", "@types/otplib": "^7.0.0", @@ -84,7 +86,7 @@ "@types/passport-google-oauth20": "^2.0.17", "@types/passport-jwt": "^4.0.1", "@types/passport-local": "^1.0.38", - "@types/pdfkit": "^0.17.3", + "@types/pdfkit": "^0.17.6", "@types/pg": "^8.10.0", "@types/qrcode": "^1.5.6", "@types/supertest": "^6.0.0", @@ -548,23 +550,18 @@ } }, "node_modules/@aws-sdk/core": { - "version": "3.974.2", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.2.tgz", - "integrity": "sha512-oav5AOAz+1XkwUfp6SrEm42UPDpUP5D4jNYXkDwFR1VfWqYX62+jpytdfzURmJ9McSoJIQwi0OJlC4oCi6t0VQ==", + "version": "3.974.23", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.23.tgz", + "integrity": "sha512-MiWR/uWjxjFXGzrE0Ghc5lWxUxzHsUWFhV+OX7M4cR9SrmrnZs6TXavnCWnzzdwJeFri34xQo81rvGNzK3c4BQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.8", - "@aws-sdk/xml-builder": "^3.972.18", - "@smithy/core": "^3.23.15", - "@smithy/node-config-provider": "^4.3.14", - "@smithy/property-provider": "^4.2.14", - "@smithy/protocol-http": "^5.3.14", - "@smithy/signature-v4": "^5.3.14", - "@smithy/smithy-client": "^4.12.11", - "@smithy/types": "^4.14.1", - "@smithy/util-base64": "^4.3.2", - "@smithy/util-middleware": "^4.2.14", - "@smithy/util-utf8": "^4.2.2", + "@aws-sdk/types": "^3.973.13", + "@aws-sdk/xml-builder": "^3.972.31", + "@aws/lambda-invoke-store": "^0.2.2", + "@smithy/core": "^3.24.6", + "@smithy/signature-v4": "^5.4.6", + "@smithy/types": "^4.14.3", + "bowser": "^2.11.0", "tslib": "^2.6.2" }, "engines": { @@ -983,17 +980,32 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/s3-request-presigner": { + "version": "3.1075.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/s3-request-presigner/-/s3-request-presigner-3.1075.0.tgz", + "integrity": "sha512-++ftTvAGZSTuzFVHEPk8lLi7mybBD8PzJ9USWBvwnE4kSrXOyqYVJ5Ixd06xUEWS/xsrhpkI07mzCLGIxrRymA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.23", + "@aws-sdk/signature-v4-multi-region": "^3.996.35", + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@aws-sdk/signature-v4-multi-region": { - "version": "3.996.19", - "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.19.tgz", - "integrity": "sha512-7Sy8+GhfwUi06NQNLplxuJuXMKJURDsNQfK8yTW6E9wN2J1B+8S5dWZG7vg3InvPPhaXqkcYTr8pzeE+dLjMbQ==", + "version": "3.996.35", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.35.tgz", + "integrity": "sha512-6L/VWs+Wch2stHemCGTmUNqKLMzURxQDK5boNG3Jn3kAOp71meDUuS5sbObpEvFxHDq0uWeSLFDNSYsjNt+Dlg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/middleware-sdk-s3": "^3.972.31", - "@aws-sdk/types": "^3.973.8", - "@smithy/protocol-http": "^5.3.14", - "@smithy/signature-v4": "^5.3.14", - "@smithy/types": "^4.14.1", + "@aws-sdk/types": "^3.973.13", + "@smithy/signature-v4": "^5.4.6", + "@smithy/types": "^4.14.3", "tslib": "^2.6.2" }, "engines": { @@ -1019,12 +1031,12 @@ } }, "node_modules/@aws-sdk/types": { - "version": "3.973.8", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.8.tgz", - "integrity": "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw==", + "version": "3.973.13", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.13.tgz", + "integrity": "sha512-pEHZqRkAlHfnfAU9tK+WpKv/gBNjGJrHMgA3A0iYRGyswBS2t0pfez+lWlwktb3Bqa0ovh7w/QJTFwp3fDxLNg==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.14.1", + "@smithy/types": "^4.14.3", "tslib": "^2.6.2" }, "engines": { @@ -1109,13 +1121,12 @@ } }, "node_modules/@aws-sdk/xml-builder": { - "version": "3.972.18", - "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.18.tgz", - "integrity": "sha512-BMDNVG1ETXRhl1tnisQiYBef3RShJ1kfZA7x7afivTFMLirfHNTb6U71K569HNXhSXbQZsweHvSDZ6euBw8hPA==", + "version": "3.972.31", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.31.tgz", + "integrity": "sha512-SzE4Pgyl+hDF+BuyuzxUSpwnuUu9lJuO1YGgteG89/4Qv0+2IQiVQqdbPV32IozLvXWQChPQcdkk/sKvb1QHiQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.14.1", - "fast-xml-parser": "5.5.8", + "@smithy/types": "^4.14.3", "tslib": "^2.6.2" }, "engines": { @@ -3958,20 +3969,13 @@ } }, "node_modules/@smithy/core": { - "version": "3.23.16", - "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.23.16.tgz", - "integrity": "sha512-JStomOrINQA1VqNEopLsgcdgwd42au7mykKqVr30XFw89wLt9sDxJDi4djVPRwQmmzyTGy/uOvTc2ultMpFi1w==", + "version": "3.26.0", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.26.0.tgz", + "integrity": "sha512-mLUktFAn+Pa2agl1J7VgtYNFWCX8/b4GMJSK1hCu4YCvtBfM6F8Os3EP4ry+DFFlXOf3wyvlgXhuUdFoy52D3g==", "license": "Apache-2.0", "dependencies": { - "@smithy/protocol-http": "^5.3.14", - "@smithy/types": "^4.14.1", - "@smithy/url-parser": "^4.2.14", - "@smithy/util-base64": "^4.3.2", - "@smithy/util-body-length-browser": "^4.2.2", - "@smithy/util-middleware": "^4.2.14", - "@smithy/util-stream": "^4.5.24", - "@smithy/util-utf8": "^4.2.2", - "@smithy/uuid": "^1.1.2", + "@aws-crypto/crc32": "5.2.0", + "@smithy/types": "^4.15.0", "tslib": "^2.6.2" }, "engines": { @@ -4354,18 +4358,13 @@ } }, "node_modules/@smithy/signature-v4": { - "version": "5.3.14", - "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.3.14.tgz", - "integrity": "sha512-1D9Y/nmlVjCeSivCbhZ7hgEpmHyY1h0GvpSZt3l0xcD9JjmjVC1CHOozS6+Gh+/ldMH8JuJ6cujObQqfayAVFA==", + "version": "5.5.2", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.5.2.tgz", + "integrity": "sha512-7xHpmPY4rt0IOmeAA8EfjgEH8isT+587TCdy9H6a7d4OMi5CQ0oEHhWllunvPu4j4Cq0vTFwdxXN/kABWPjdyA==", "license": "Apache-2.0", "dependencies": { - "@smithy/is-array-buffer": "^4.2.2", - "@smithy/protocol-http": "^5.3.14", - "@smithy/types": "^4.14.1", - "@smithy/util-hex-encoding": "^4.2.2", - "@smithy/util-middleware": "^4.2.14", - "@smithy/util-uri-escape": "^4.2.2", - "@smithy/util-utf8": "^4.2.2", + "@smithy/core": "^3.26.0", + "@smithy/types": "^4.15.0", "tslib": "^2.6.2" }, "engines": { @@ -4391,9 +4390,9 @@ } }, "node_modules/@smithy/types": { - "version": "4.14.1", - "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.14.1.tgz", - "integrity": "sha512-59b5HtSVrVR/eYNei3BUj3DCPKD/G7EtDDe7OEJE7i7FtQFugYo6MxbotS8mVJkLNVf8gYaAlEBwwtJ9HzhWSg==", + "version": "4.15.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.15.0.tgz", + "integrity": "sha512-Z5TAOxygoFvybJV3igo5SloFflSokHx2hu1eFA+DxDTcn+FtKxUSui+rbTRG1pAafMA888Z3MVvCWUuvCrTXjg==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -5059,9 +5058,9 @@ } }, "node_modules/@types/node": { - "version": "20.19.39", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz", - "integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==", + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -8691,41 +8690,6 @@ "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", "license": "MIT" }, - "node_modules/fast-xml-builder": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.5.tgz", - "integrity": "sha512-4TJn/8FKLeslLAH3dnohXqE3QSoxkhvaMzepOIZytwJXZO69Bfz0HBdDHzOTOon6G59Zrk6VQ2bEiv1t61rfkA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "dependencies": { - "path-expression-matcher": "^1.1.3" - } - }, - "node_modules/fast-xml-parser": { - "version": "5.5.8", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.8.tgz", - "integrity": "sha512-Z7Fh2nVQSb2d+poDViM063ix2ZGt9jmY1nWhPfHBOK2Hgnb/OW3P4Et3P/81SEej0J7QbWtJqxO05h8QYfK7LQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "dependencies": { - "fast-xml-builder": "^1.1.4", - "path-expression-matcher": "^1.2.0", - "strnum": "^2.2.0" - }, - "bin": { - "fxparser": "src/cli/cli.js" - } - }, "node_modules/fastq": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", @@ -12145,6 +12109,17 @@ "node": ">= 0.4.0" } }, + "node_modules/passport-microsoft": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/passport-microsoft/-/passport-microsoft-1.1.0.tgz", + "integrity": "sha512-yJyynEkGakK8SveCqILAvrpMBOKpx6TNyxL1ry+eW4m9/qqqDDOUahLdHj7wPSuDReHQ4jArGheH5v0/pNwR+g==", + "dependencies": { + "passport-oauth2": "1.8.0" + }, + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/passport-oauth2": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/passport-oauth2/-/passport-oauth2-1.8.0.tgz", @@ -12182,21 +12157,6 @@ "node": ">=8" } }, - "node_modules/path-expression-matcher": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", - "integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, "node_modules/path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -13947,18 +13907,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/strnum": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.3.tgz", - "integrity": "sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT" - }, "node_modules/strtok3": { "version": "10.3.5", "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.5.tgz", diff --git a/backend/package.json b/backend/package.json index 998060451..b94830966 100644 --- a/backend/package.json +++ b/backend/package.json @@ -26,6 +26,7 @@ }, "dependencies": { "@aws-sdk/client-s3": "^3.975.0", + "@aws-sdk/s3-request-presigner": "^3.1075.0", "@nestjs/bull": "^11.0.4", "@nestjs/cache-manager": "^3.1.0", "@nestjs/common": "^10.0.0", @@ -67,9 +68,9 @@ "papaparse": "^5.5.3", "passport": "^0.7.0", "passport-google-oauth20": "^2.0.0", - "passport-microsoft": "^1.0.0", "passport-jwt": "^4.0.1", "passport-local": "^1.0.0", + "passport-microsoft": "^1.0.0", "pdfkit": "^0.17.2", "pg": "^8.11.3", "qrcode": "^1.5.4", @@ -93,7 +94,7 @@ "@types/jest": "^29.5.2", "@types/json2csv": "^5.0.7", "@types/jsonwebtoken": "^9.0.10", - "@types/node": "^20.19.30", + "@types/node": "^20.19.43", "@types/node-cron": "^3.0.11", "@types/nodemailer": "^7.0.9", "@types/otplib": "^7.0.0", @@ -101,7 +102,7 @@ "@types/passport-google-oauth20": "^2.0.17", "@types/passport-jwt": "^4.0.1", "@types/passport-local": "^1.0.38", - "@types/pdfkit": "^0.17.3", + "@types/pdfkit": "^0.17.6", "@types/pg": "^8.10.0", "@types/qrcode": "^1.5.6", "@types/supertest": "^6.0.0", diff --git a/backend/src/activity-log/activity-log.controller.ts b/backend/src/activity-log/activity-log.controller.ts index 35ecd1e14..a01f1b8d7 100644 --- a/backend/src/activity-log/activity-log.controller.ts +++ b/backend/src/activity-log/activity-log.controller.ts @@ -1,4 +1,14 @@ -import { Controller, Get, Post, Delete, Param, Body, Query, Req, UseGuards } from '@nestjs/common'; +import { + Controller, + Get, + Post, + Delete, + Param, + Body, + Query, + Req, + UseGuards, +} from '@nestjs/common'; import { AuthGuard } from '@nestjs/passport'; import { ActivityLogService } from './activity-log.service'; import { CreateActivityLogDto } from './dtos/create-activity-log.dto'; diff --git a/backend/src/activity-log/activity-log.service.ts b/backend/src/activity-log/activity-log.service.ts index 39f673bf2..73216d2e7 100644 --- a/backend/src/activity-log/activity-log.service.ts +++ b/backend/src/activity-log/activity-log.service.ts @@ -17,9 +17,21 @@ export class ActivityLogService { return this.activityLogRepository.save(log); } - async findAll(query: ActivityLogQueryDto): Promise<{ data: ActivityLog[]; total: number }> { - const { page = 1, limit = 20, userId, action, entityType, entityId, startDate, endDate } = query; - const qb = this.activityLogRepository.createQueryBuilder('log') + async findAll( + query: ActivityLogQueryDto, + ): Promise<{ data: ActivityLog[]; total: number }> { + const { + page = 1, + limit = 20, + userId, + action, + entityType, + entityId, + startDate, + endDate, + } = query; + const qb = this.activityLogRepository + .createQueryBuilder('log') .leftJoinAndSelect('log.user', 'user') .skip((page - 1) * limit) .take(limit) diff --git a/backend/src/activity-log/entities/activity-log.entity.ts b/backend/src/activity-log/entities/activity-log.entity.ts index 828d6a7f5..8cbcff4ca 100644 --- a/backend/src/activity-log/entities/activity-log.entity.ts +++ b/backend/src/activity-log/entities/activity-log.entity.ts @@ -1,4 +1,12 @@ -import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, ManyToOne, JoinColumn } from 'typeorm'; +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; import { User } from '../../users/entities/user.entity'; @Entity('activity_logs') diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 3cbb2e379..a46c653c2 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -3,10 +3,8 @@ import { ConfigModule, ConfigService } from '@nestjs/config'; import { TypeOrmModule } from '@nestjs/typeorm'; import { CacheModule } from '@nestjs/cache-manager'; import { redisStore } from 'cache-manager-redis-store'; -import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler'; +import { ThrottlerGuard } from '@nestjs/throttler'; import { APP_GUARD } from '@nestjs/core'; -import { AcceptLanguageResolver, I18nModule, QueryResolver } from 'nestjs-i18n'; -import * as path from 'path'; import { AppController } from './app.controller'; import { AppService } from './app.service'; import { UsersModule } from './users/users.module'; @@ -28,6 +26,7 @@ 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 { NotificationModule } from './notifications/notification.module'; @Module({ imports: [ @@ -54,7 +53,10 @@ import { TasksModule } from './tasks/tasks.module'; inject: [ConfigService], useFactory: async (configService: ConfigService) => { const host = configService.get('REDIS_HOST', 'localhost'); - const port = parseInt(configService.get('REDIS_PORT', '6379'), 10); + const port = parseInt( + configService.get('REDIS_PORT', '6379'), + 10, + ); const ttl = parseInt(configService.get('CACHE_TTL', '300'), 10); return { @@ -64,7 +66,9 @@ import { TasksModule } from './tasks/tasks.module'; ttl, retry_strategy: (options: any) => { if (options.error && options.error.code === 'ECONNREFUSED') { - return new Error('Redis connection refused. Operating with inline graceful fallback.'); + return new Error( + 'Redis connection refused. Operating with inline graceful fallback.', + ); } return Math.min(options.attempt * 100, 3000); }, @@ -85,14 +89,12 @@ import { TasksModule } from './tasks/tasks.module'; LicensesModule, PurchaseOrdersModule, TasksModule, - ], LocationsModule, - ], ActivityLogModule, - ], InventoryModule, VendorsModule, DashboardModule, + NotificationModule, ], controllers: [AppController], providers: [ @@ -103,8 +105,6 @@ import { TasksModule } from './tasks/tasks.module'; useClass: ThrottlerGuard, }, ], - exports: [ - CacheService, - ], + exports: [CacheService], }) export class AppModule {} diff --git a/backend/src/assets/asset-audit.controller.ts b/backend/src/assets/asset-audit.controller.ts index 6eedaf00b..90d217da8 100644 --- a/backend/src/assets/asset-audit.controller.ts +++ b/backend/src/assets/asset-audit.controller.ts @@ -1,4 +1,12 @@ -import { Controller, Get, Post, Param, Body, Query, Req, UseGuards } from '@nestjs/common'; +import { + Controller, + Get, + Post, + Param, + Body, + Query, + UseGuards, +} from '@nestjs/common'; import { AuthGuard } from '@nestjs/passport'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; @@ -13,7 +21,10 @@ export class AssetAuditController { ) {} @Get(':id') - async getAuditTrail(@Param('id') id: string, @Query() query: { page?: number; limit?: number }) { + async getAuditTrail( + @Param('id') id: string, + @Query() query: { page?: number; limit?: number }, + ) { const page = query.page || 1; const limit = query.limit || 20; const [data, total] = await this.historyRepository.findAndCount({ @@ -26,8 +37,13 @@ export class AssetAuditController { } @Post(':id/revert') - async revertAuditEntry(@Param('id') id: string, @Body() body: { historyId: string }) { - const entry = await this.historyRepository.findOne({ where: { id: body.historyId } }); + async revertAuditEntry( + @Param('id') id: string, + @Body() body: { historyId: string }, + ) { + const entry = await this.historyRepository.findOne({ + where: { id: body.historyId }, + }); if (!entry) { return { message: 'Audit entry not found' }; } diff --git a/backend/src/assets/asset.entity.ts b/backend/src/assets/asset.entity.ts index 83779b558..07d70785b 100644 --- a/backend/src/assets/asset.entity.ts +++ b/backend/src/assets/asset.entity.ts @@ -1,4 +1,12 @@ -import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, ManyToOne, JoinColumn } from 'typeorm'; +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; import { User } from '../users/entities/user.entity'; @Entity('assets') @@ -97,6 +105,18 @@ export class Asset { @Column({ default: false }) endOfLifeNotificationSent: boolean; + @Column({ type: 'date', nullable: true }) + disposalDate: string; + + @Column({ nullable: true }) + disposalMethod: string; + + @Column({ nullable: true, type: 'text' }) + disposalReason: string; + + @Column({ nullable: true }) + disposalApprovedById: string; + @Column({ nullable: true, type: 'text' }) notes: string; diff --git a/backend/src/assets/assets-extended.controller.ts b/backend/src/assets/assets-extended.controller.ts index e0ca0a47e..26ed9d33d 100644 --- a/backend/src/assets/assets-extended.controller.ts +++ b/backend/src/assets/assets-extended.controller.ts @@ -1,4 +1,17 @@ -import { Controller, Post, Get, Patch, Delete, Param, Body, Query, Req, UseGuards, UseInterceptors, UploadedFile } from '@nestjs/common'; +import { + Controller, + Post, + Get, + Patch, + Delete, + Param, + Body, + Query, + Req, + UseGuards, + UseInterceptors, + UploadedFile, +} from '@nestjs/common'; import { AuthGuard } from '@nestjs/passport'; import { FileInterceptor } from '@nestjs/platform-express'; import { AssetsExtendedService } from './assets-extended.service'; @@ -13,7 +26,11 @@ export class AssetsExtendedController { constructor(private readonly assetsExtendedService: AssetsExtendedService) {} @Post(':id/transfer') - async transfer(@Param('id') id: string, @Body() dto: TransferAssetDto, @Req() req: any) { + async transfer( + @Param('id') id: string, + @Body() dto: TransferAssetDto, + @Req() req: any, + ) { return this.assetsExtendedService.transfer(id, dto, req.user?.id); } @@ -24,7 +41,11 @@ export class AssetsExtendedController { @Post(':id/documents') @UseInterceptors(FileInterceptor('file')) - async uploadDocument(@Param('id') id: string, @UploadedFile() file: Express.Multer.File, @Req() req: any) { + async uploadDocument( + @Param('id') id: string, + @UploadedFile() file: Express.Multer.File, + @Req() req: any, + ) { return this.assetsExtendedService.addDocument(id, file, req.user?.id); } @@ -34,13 +55,20 @@ export class AssetsExtendedController { } @Delete(':id/documents/:documentId') - async deleteDocument(@Param('id') id: string, @Param('documentId') documentId: string) { + async deleteDocument( + @Param('id') id: string, + @Param('documentId') documentId: string, + ) { await this.assetsExtendedService.deleteDocument(id, documentId); return { message: 'Document deleted' }; } @Post(':id/maintenance') - async createMaintenance(@Param('id') id: string, @Body() dto: CreateMaintenanceDto, @Req() req: any) { + async createMaintenance( + @Param('id') id: string, + @Body() dto: CreateMaintenanceDto, + @Req() req: any, + ) { return this.assetsExtendedService.createMaintenance(id, dto, req.user?.id); } @@ -50,7 +78,11 @@ export class AssetsExtendedController { } @Patch(':id/maintenance/:recordId') - async updateMaintenance(@Param('id') id: string, @Param('recordId') recordId: string, @Body() dto: UpdateMaintenanceDto) { + async updateMaintenance( + @Param('id') id: string, + @Param('recordId') recordId: string, + @Body() dto: UpdateMaintenanceDto, + ) { return this.assetsExtendedService.updateMaintenance(id, recordId, dto); } } diff --git a/backend/src/assets/assets-extended.module.ts b/backend/src/assets/assets-extended.module.ts index 7487886b5..f1784325c 100644 --- a/backend/src/assets/assets-extended.module.ts +++ b/backend/src/assets/assets-extended.module.ts @@ -7,9 +7,18 @@ import { MaintenanceRecord } from './entities/maintenance-record.entity'; import { AssetsExtendedService } from './assets-extended.service'; import { AssetsExtendedController } from './assets-extended.controller'; import { AssetAuditController } from './asset-audit.controller'; +import { NotificationModule } from '../notifications/notification.module'; @Module({ - imports: [TypeOrmModule.forFeature([Asset, AssetHistory, AssetDocument, MaintenanceRecord])], + imports: [ + TypeOrmModule.forFeature([ + Asset, + AssetHistory, + AssetDocument, + MaintenanceRecord, + ]), + NotificationModule, + ], controllers: [AssetsExtendedController, AssetAuditController], providers: [AssetsExtendedService], exports: [AssetsExtendedService], diff --git a/backend/src/assets/assets-extended.service.ts b/backend/src/assets/assets-extended.service.ts index 4b5851b2f..ed9425fb1 100644 --- a/backend/src/assets/assets-extended.service.ts +++ b/backend/src/assets/assets-extended.service.ts @@ -9,6 +9,11 @@ import { TransferAssetDto } from './dtos/transfer-asset.dto'; import { CreateMaintenanceDto } from './dtos/create-maintenance.dto'; import { UpdateMaintenanceDto } from './dtos/update-maintenance.dto'; import { HistoryQueryDto } from './dtos/history-query.dto'; +import { + NotificationDispatchService, + DispatchNotificationDto, +} from '../notifications/notification-dispatch.service'; +import { NotificationEvent } from '../notifications/enums/notification-event.enum'; @Injectable() export class AssetsExtendedService { @@ -21,10 +26,18 @@ export class AssetsExtendedService { private readonly documentRepository: Repository, @InjectRepository(MaintenanceRecord) private readonly maintenanceRepository: Repository, + private readonly notificationDispatchService: NotificationDispatchService, ) {} - async transfer(id: string, dto: TransferAssetDto, userId?: string): Promise { - const asset = await this.assetRepository.findOne({ where: { id }, relations: ['assignedTo'] }); + async transfer( + id: string, + dto: TransferAssetDto, + userId?: string, + ): Promise { + const asset = await this.assetRepository.findOne({ + where: { id }, + relations: ['assignedTo'], + }); if (!asset) throw new NotFoundException('Asset not found'); const previousValue: Record = { @@ -49,25 +62,66 @@ export class AssetsExtendedService { }), ); + // Send notification for asset transfer + if (dto.assignedToId) { + const notificationDto: DispatchNotificationDto = { + userId: dto.assignedToId, + event: NotificationEvent.ASSET_TRANSFERRED, + title: 'Asset Transferred', + message: `Asset ${asset.name} (${asset.assetId}) has been transferred to you.`, + entityType: 'Asset', + entityId: id, + metadata: { + assetName: asset.name, + assetId: asset.assetId, + previousAssignee: previousValue.assignedToId, + newAssignee: dto.assignedToId, + }, + emailTemplate: 'asset-transferred', + emailSubject: `Asset Transferred: ${asset.name}`, + emailContext: { + assetName: asset.name, + assetId: asset.assetId, + assignedTo: dto.assignedToId, + location: dto.location || asset.location, + assetLink: `${process.env.FRONTEND_URL}/assets/${id}`, + }, + }; + await this.notificationDispatchService.dispatch(notificationDto); + } + return asset; } async getHistory(assetId: string, query: HistoryQueryDto) { - const qb = this.historyRepository.createQueryBuilder('h') + const qb = this.historyRepository + .createQueryBuilder('h') .leftJoinAndSelect('h.performedBy', 'performedBy') .where('h.assetId = :assetId', { assetId }) .orderBy('h.createdAt', 'DESC'); - if (query.action) qb.andWhere('h.action = :action', { action: query.action }); - if (query.startDate) qb.andWhere('h.createdAt >= :startDate', { startDate: query.startDate }); - if (query.endDate) qb.andWhere('h.createdAt <= :endDate', { endDate: query.endDate }); - if (query.search) qb.andWhere('h.description ILIKE :search', { search: `%${query.search}%` }); + if (query.action) + qb.andWhere('h.action = :action', { action: query.action }); + if (query.startDate) + qb.andWhere('h.createdAt >= :startDate', { startDate: query.startDate }); + if (query.endDate) + qb.andWhere('h.createdAt <= :endDate', { endDate: query.endDate }); + if (query.search) + qb.andWhere('h.description ILIKE :search', { + search: `%${query.search}%`, + }); return qb.getMany(); } - async addDocument(assetId: string, file: Express.Multer.File, userId?: string): Promise { - const asset = await this.assetRepository.findOne({ where: { id: assetId } }); + async addDocument( + assetId: string, + file: Express.Multer.File, + userId?: string, + ): Promise { + const asset = await this.assetRepository.findOne({ + where: { id: assetId }, + }); if (!asset) throw new NotFoundException('Asset not found'); const doc = this.documentRepository.create({ @@ -102,13 +156,21 @@ export class AssetsExtendedService { } async deleteDocument(assetId: string, documentId: string): Promise { - const doc = await this.documentRepository.findOne({ where: { id: documentId, assetId } }); + const doc = await this.documentRepository.findOne({ + where: { id: documentId, assetId }, + }); if (!doc) throw new NotFoundException('Document not found'); await this.documentRepository.remove(doc); } - async createMaintenance(assetId: string, dto: CreateMaintenanceDto, userId?: string): Promise { - const asset = await this.assetRepository.findOne({ where: { id: assetId } }); + async createMaintenance( + assetId: string, + dto: CreateMaintenanceDto, + userId?: string, + ): Promise { + const asset = await this.assetRepository.findOne({ + where: { id: assetId }, + }); if (!asset) throw new NotFoundException('Asset not found'); const record = this.maintenanceRepository.create({ @@ -138,8 +200,14 @@ export class AssetsExtendedService { }); } - async updateMaintenance(assetId: string, recordId: string, dto: UpdateMaintenanceDto): Promise { - const record = await this.maintenanceRepository.findOne({ where: { id: recordId, assetId } }); + async updateMaintenance( + assetId: string, + recordId: string, + dto: UpdateMaintenanceDto, + ): Promise { + const record = await this.maintenanceRepository.findOne({ + where: { id: recordId, assetId }, + }); if (!record) throw new NotFoundException('Maintenance record not found'); Object.assign(record, dto); return this.maintenanceRepository.save(record); diff --git a/backend/src/assets/assets-ops.controller.ts b/backend/src/assets/assets-ops.controller.ts index e729c9f8d..2c887e451 100644 --- a/backend/src/assets/assets-ops.controller.ts +++ b/backend/src/assets/assets-ops.controller.ts @@ -1,4 +1,14 @@ -import { Controller, Post, Get, Delete, Param, Body, Req, UseGuards, Query } from '@nestjs/common'; +import { + Controller, + Post, + Get, + Delete, + Param, + Body, + Req, + UseGuards, + Query, +} from '@nestjs/common'; import { AuthGuard } from '@nestjs/passport'; import { AssetsOpsService } from './assets-ops.service'; import { CreateNoteDto } from './dtos/create-note.dto'; @@ -12,7 +22,11 @@ export class AssetsOpsController { constructor(private readonly assetsOpsService: AssetsOpsService) {} @Post(':id/notes') - async createNote(@Param('id') id: string, @Body() dto: CreateNoteDto, @Req() req: any) { + async createNote( + @Param('id') id: string, + @Body() dto: CreateNoteDto, + @Req() req: any, + ) { return this.assetsOpsService.createNote(id, dto, req.user?.id); } @@ -41,7 +55,10 @@ export class AssetsOpsController { @Post('bulk/status') async bulkStatusUpdate(@Body() dto: BulkStatusDto, @Req() req: any) { - const count = await this.assetsOpsService.bulkStatusUpdate(dto, req.user?.id); + const count = await this.assetsOpsService.bulkStatusUpdate( + dto, + req.user?.id, + ); return { updated: count }; } diff --git a/backend/src/assets/assets-ops.service.ts b/backend/src/assets/assets-ops.service.ts index a7537a580..7f585ded6 100644 --- a/backend/src/assets/assets-ops.service.ts +++ b/backend/src/assets/assets-ops.service.ts @@ -19,8 +19,14 @@ export class AssetsOpsService { private readonly noteRepository: Repository, ) {} - async createNote(assetId: string, dto: CreateNoteDto, userId?: string): Promise { - const asset = await this.assetRepository.findOne({ where: { id: assetId } }); + async createNote( + assetId: string, + dto: CreateNoteDto, + userId?: string, + ): Promise { + const asset = await this.assetRepository.findOne({ + where: { id: assetId }, + }); if (!asset) throw new NotFoundException('Asset not found'); const note = this.noteRepository.create({ @@ -40,16 +46,24 @@ export class AssetsOpsService { } async deleteNote(assetId: string, noteId: string): Promise { - const note = await this.noteRepository.findOne({ where: { id: noteId, assetId } }); + const note = await this.noteRepository.findOne({ + where: { id: noteId, assetId }, + }); if (!note) throw new NotFoundException('Note not found'); await this.noteRepository.remove(note); } async generateQRCode(assetId: string): Promise { - const asset = await this.assetRepository.findOne({ where: { id: assetId } }); + const asset = await this.assetRepository.findOne({ + where: { id: assetId }, + }); if (!asset) throw new NotFoundException('Asset not found'); - const qrData = JSON.stringify({ id: asset.id, assetId: asset.assetId, name: asset.name }); + const qrData = JSON.stringify({ + id: asset.id, + assetId: asset.assetId, + name: asset.name, + }); const qrCode = await QRCode.toDataURL(qrData); await this.assetRepository.update(assetId, { qrCode }); @@ -57,21 +71,26 @@ export class AssetsOpsService { } async generateBarcode(assetId: string): Promise { - const asset = await this.assetRepository.findOne({ where: { id: assetId } }); + const asset = await this.assetRepository.findOne({ + where: { id: assetId }, + }); if (!asset) throw new NotFoundException('Asset not found'); const barcode = await new Promise((resolve, reject) => { - bwipjs.toBuffer({ - bcid: 'code128', - text: asset.assetId, - scale: 3, - height: 10, - includetext: true, - textxalign: 'center', - }, (err: Error | null, buffer?: Buffer) => { - if (err) reject(err); - else resolve(`data:image/png;base64,${buffer.toString('base64')}`); - }); + bwipjs.toBuffer( + { + bcid: 'code128', + text: asset.assetId, + scale: 3, + height: 10, + includetext: true, + textxalign: 'center', + }, + (err: Error | null, buffer?: Buffer) => { + if (err) reject(err); + else resolve(`data:image/png;base64,${buffer.toString('base64')}`); + }, + ); }); await this.assetRepository.update(assetId, { barcode }); @@ -106,6 +125,9 @@ export class AssetsOpsService { async bulkExport(ids?: string[]) { const where = ids ? { id: In(ids) } : {}; - return this.assetRepository.find({ where, relations: ['assignedTo', 'createdBy'] }); + return this.assetRepository.find({ + where, + relations: ['assignedTo', 'createdBy'], + }); } } diff --git a/backend/src/assets/assets.controller.ts b/backend/src/assets/assets.controller.ts index 510c6f96c..070f27e6e 100644 --- a/backend/src/assets/assets.controller.ts +++ b/backend/src/assets/assets.controller.ts @@ -1,4 +1,16 @@ -import { Controller, Get, Post, Put, Patch, Delete, Param, Body, Query, Req, UseGuards } from '@nestjs/common'; +import { + Controller, + Get, + Post, + Put, + Patch, + Delete, + Param, + Body, + Query, + Req, + UseGuards, +} from '@nestjs/common'; import { AuthGuard } from '@nestjs/passport'; import { AssetsService } from './assets.service'; import { CreateAssetDto } from './dtos/create-asset.dto'; @@ -27,7 +39,11 @@ export class AssetsController { } @Put(':id') - async update(@Param('id') id: string, @Body() dto: UpdateAssetDto, @Req() req: any) { + async update( + @Param('id') id: string, + @Body() dto: UpdateAssetDto, + @Req() req: any, + ) { return this.assetsService.update(id, dto, req.user?.id); } @@ -38,7 +54,11 @@ export class AssetsController { } @Patch(':id/status') - async updateStatus(@Param('id') id: string, @Body() dto: UpdateStatusDto, @Req() req: any) { + async updateStatus( + @Param('id') id: string, + @Body() dto: UpdateStatusDto, + @Req() req: any, + ) { return this.assetsService.updateStatus(id, dto, req.user?.id); } diff --git a/backend/src/assets/assets.service.ts b/backend/src/assets/assets.service.ts index aebbf68e3..87d624c1c 100644 --- a/backend/src/assets/assets.service.ts +++ b/backend/src/assets/assets.service.ts @@ -1,6 +1,6 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository, Like } from 'typeorm'; +import { Repository } from 'typeorm'; import { ConfigService } from '@nestjs/config'; import { Asset } from './asset.entity'; import { CreateAssetDto } from './dtos/create-asset.dto'; @@ -18,7 +18,10 @@ export class AssetsService { private readonly assetRepository: Repository, private readonly configService: ConfigService, ) { - this.nextAssetNumber = parseInt(configService.get('ASSET_ID_START', '1000'), 10); + this.nextAssetNumber = parseInt( + configService.get('ASSET_ID_START', '1000'), + 10, + ); } async create(dto: CreateAssetDto, userId?: string): Promise { @@ -35,8 +38,21 @@ export class AssetsService { } async findAll(query: AssetListQueryDto): Promise> { - const { search, status, condition, categoryId, departmentId, assignedToId, location, sortBy, sortOrder, page, limit } = query; - const qb = this.assetRepository.createQueryBuilder('asset') + const { + search, + status, + condition, + categoryId, + departmentId, + assignedToId, + location, + sortBy, + sortOrder, + page, + limit, + } = query; + const qb = this.assetRepository + .createQueryBuilder('asset') .leftJoinAndSelect('asset.assignedTo', 'assignedTo') .leftJoinAndSelect('asset.createdBy', 'createdBy') .leftJoinAndSelect('asset.updatedBy', 'updatedBy'); @@ -49,13 +65,29 @@ export class AssetsService { } if (status) qb.andWhere('asset.status = :status', { status }); if (condition) qb.andWhere('asset.condition = :condition', { condition }); - if (categoryId) qb.andWhere('asset.categoryId = :categoryId', { categoryId }); - if (departmentId) qb.andWhere('asset.departmentId = :departmentId', { departmentId }); - if (assignedToId) qb.andWhere('asset.assignedToId = :assignedToId', { assignedToId }); - if (location) qb.andWhere('asset.location ILIKE :location', { location: `%${location}%` }); + if (categoryId) + qb.andWhere('asset.categoryId = :categoryId', { categoryId }); + if (departmentId) + qb.andWhere('asset.departmentId = :departmentId', { departmentId }); + if (assignedToId) + qb.andWhere('asset.assignedToId = :assignedToId', { assignedToId }); + if (location) + qb.andWhere('asset.location ILIKE :location', { + location: `%${location}%`, + }); - const allowedSortFields = ['name', 'assetId', 'status', 'condition', 'createdAt', 'updatedAt', 'purchaseDate', 'purchasePrice']; - const sortField = sortBy && allowedSortFields.includes(sortBy) ? sortBy : 'createdAt'; + const allowedSortFields = [ + 'name', + 'assetId', + 'status', + 'condition', + 'createdAt', + 'updatedAt', + 'purchaseDate', + 'purchasePrice', + ]; + const sortField = + sortBy && allowedSortFields.includes(sortBy) ? sortBy : 'createdAt'; qb.orderBy(`asset.${sortField}`, sortOrder || 'DESC'); qb.skip((page - 1) * limit).take(limit); @@ -81,7 +113,11 @@ export class AssetsService { return asset; } - async update(id: string, dto: UpdateAssetDto, userId?: string): Promise { + async update( + id: string, + dto: UpdateAssetDto, + userId?: string, + ): Promise { const asset = await this.findById(id); Object.assign(asset, dto); if (userId) asset.updatedById = userId; @@ -93,14 +129,26 @@ export class AssetsService { await this.assetRepository.softDelete(asset.id); } - async updateStatus(id: string, dto: UpdateStatusDto, userId?: string): Promise { + async updateStatus( + id: string, + dto: UpdateStatusDto, + userId?: string, + ): Promise { const asset = await this.findById(id); asset.status = dto.status; asset.updatedById = userId; return this.assetRepository.save(asset); } - async dispose(id: string, dto: { disposalMethod?: string; disposalReason?: string; disposalApprovedById?: string }, userId?: string): Promise { + async dispose( + id: string, + dto: { + disposalMethod?: string; + disposalReason?: string; + disposalApprovedById?: string; + }, + userId?: string, + ): Promise { const asset = await this.findById(id); asset.status = 'DISPOSED'; asset.disposalDate = new Date().toISOString().split('T')[0]; diff --git a/backend/src/assets/dashboard.controller.ts b/backend/src/assets/dashboard.controller.ts index 408fb37bb..b85462d98 100644 --- a/backend/src/assets/dashboard.controller.ts +++ b/backend/src/assets/dashboard.controller.ts @@ -48,9 +48,15 @@ export class DashboardController { @Get('summary') async getSummary() { const totalAssets = await this.assetRepository.count(); - const activeAssets = await this.assetRepository.count({ where: { status: 'ACTIVE' } }); - const maintenanceAssets = await this.assetRepository.count({ where: { status: 'MAINTENANCE' } }); - const retiredAssets = await this.assetRepository.count({ where: { status: 'RETIRED' } }); + const activeAssets = await this.assetRepository.count({ + where: { status: 'ACTIVE' }, + }); + const maintenanceAssets = await this.assetRepository.count({ + where: { status: 'MAINTENANCE' }, + }); + const retiredAssets = await this.assetRepository.count({ + where: { status: 'RETIRED' }, + }); return { totalAssets, diff --git a/backend/src/assets/dtos/asset-list-query.dto.ts b/backend/src/assets/dtos/asset-list-query.dto.ts index dba05012e..b09a4606d 100644 --- a/backend/src/assets/dtos/asset-list-query.dto.ts +++ b/backend/src/assets/dtos/asset-list-query.dto.ts @@ -1,4 +1,4 @@ -import { IsOptional, IsString, IsEnum, IsInt, Min, Max } from 'class-validator'; +import { IsOptional, IsString, IsInt, Min, Max } from 'class-validator'; import { Type } from 'class-transformer'; export class AssetListQueryDto { diff --git a/backend/src/assets/dtos/create-asset.dto.ts b/backend/src/assets/dtos/create-asset.dto.ts index d376e9f54..ae3bca478 100644 --- a/backend/src/assets/dtos/create-asset.dto.ts +++ b/backend/src/assets/dtos/create-asset.dto.ts @@ -1,4 +1,4 @@ -import { IsString, IsOptional, IsNumber, IsArray, IsEnum } from 'class-validator'; +import { IsString, IsOptional, IsNumber, IsArray } from 'class-validator'; export class CreateAssetDto { @IsString() diff --git a/backend/src/assets/dtos/create-maintenance.dto.ts b/backend/src/assets/dtos/create-maintenance.dto.ts index 561e2d34e..9d165b870 100644 --- a/backend/src/assets/dtos/create-maintenance.dto.ts +++ b/backend/src/assets/dtos/create-maintenance.dto.ts @@ -1,4 +1,4 @@ -import { IsString, IsOptional, IsNumber } from 'class-validator'; +import { IsString, IsOptional } from 'class-validator'; export class CreateMaintenanceDto { @IsString() diff --git a/backend/src/assets/entities/asset-document.entity.ts b/backend/src/assets/entities/asset-document.entity.ts index bed40bfd7..7a7cdf840 100644 --- a/backend/src/assets/entities/asset-document.entity.ts +++ b/backend/src/assets/entities/asset-document.entity.ts @@ -1,4 +1,11 @@ -import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, ManyToOne, JoinColumn } from 'typeorm'; +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; import { Asset } from './asset.entity'; import { User } from '../../users/entities/user.entity'; diff --git a/backend/src/assets/entities/asset-history.entity.ts b/backend/src/assets/entities/asset-history.entity.ts index 4b2b53a6c..6fcb10b81 100644 --- a/backend/src/assets/entities/asset-history.entity.ts +++ b/backend/src/assets/entities/asset-history.entity.ts @@ -1,4 +1,11 @@ -import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, ManyToOne, JoinColumn } from 'typeorm'; +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; import { Asset } from './asset.entity'; import { User } from '../../users/entities/user.entity'; diff --git a/backend/src/assets/entities/asset-note.entity.ts b/backend/src/assets/entities/asset-note.entity.ts index 3efa91c1f..b30ab0504 100644 --- a/backend/src/assets/entities/asset-note.entity.ts +++ b/backend/src/assets/entities/asset-note.entity.ts @@ -1,4 +1,12 @@ -import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, ManyToOne, JoinColumn } from 'typeorm'; +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; import { Asset } from './asset.entity'; import { User } from '../../users/entities/user.entity'; diff --git a/backend/src/assets/entities/asset.entity.ts b/backend/src/assets/entities/asset.entity.ts index 4688f575b..3c9d334a9 100644 --- a/backend/src/assets/entities/asset.entity.ts +++ b/backend/src/assets/entities/asset.entity.ts @@ -1,4 +1,12 @@ -import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, ManyToOne, JoinColumn } from 'typeorm'; +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; import { User } from '../../users/entities/user.entity'; @Entity('assets') diff --git a/backend/src/assets/entities/maintenance-record.entity.ts b/backend/src/assets/entities/maintenance-record.entity.ts index 1b5523937..4bf2ed172 100644 --- a/backend/src/assets/entities/maintenance-record.entity.ts +++ b/backend/src/assets/entities/maintenance-record.entity.ts @@ -1,4 +1,12 @@ -import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, ManyToOne, JoinColumn } from 'typeorm'; +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; import { Asset } from './asset.entity'; import { User } from '../../users/entities/user.entity'; diff --git a/backend/src/auth/auth.controller.ts b/backend/src/auth/auth.controller.ts index 1653ce4e1..e1b4b2d17 100644 --- a/backend/src/auth/auth.controller.ts +++ b/backend/src/auth/auth.controller.ts @@ -65,8 +65,7 @@ export class AuthController { @Get('google') @UseGuards(AuthGuard('google')) - async googleAuth() { - } + async googleAuth() {} @Get('google/callback') @UseGuards(AuthGuard('google')) diff --git a/backend/src/auth/auth.module.ts b/backend/src/auth/auth.module.ts index d13af6e86..e236b8762 100644 --- a/backend/src/auth/auth.module.ts +++ b/backend/src/auth/auth.module.ts @@ -29,8 +29,7 @@ import { MailModule } from '../mail/mail.module'; MailModule, ], controllers: [AuthController], - providers: [AuthService, GoogleStrategy, JwtStrategy], - providers: [AuthService, GoogleStrategy, MicrosoftStrategy], + providers: [AuthService, GoogleStrategy, JwtStrategy, MicrosoftStrategy], exports: [AuthService], }) export class AuthModule {} diff --git a/backend/src/auth/auth.service.spec.ts b/backend/src/auth/auth.service.spec.ts index 7abadd6fb..807d7698f 100644 --- a/backend/src/auth/auth.service.spec.ts +++ b/backend/src/auth/auth.service.spec.ts @@ -3,13 +3,25 @@ import { AuthService } from './auth.service'; import { UsersService } from '../users/users.service'; import { MailService } from '../mail/mail.service'; import { ConfigService } from '@nestjs/config'; +import { JwtService } from '@nestjs/jwt'; import { getRepositoryToken } from '@nestjs/typeorm'; import { PasswordResetToken } from './entities/password-reset-token.entity'; +import { RefreshToken } from './entities/refresh-token.entity'; const mockRepo = { create: jest.fn(), save: jest.fn(), findOne: jest.fn() }; -const mockUsersService = { findByEmail: jest.fn(), update: jest.fn(), create: jest.fn() }; +const mockUsersService = { + findByEmail: jest.fn(), + update: jest.fn(), + create: jest.fn(), +}; const mockMailService = { sendPasswordResetEmail: jest.fn() }; -const mockConfig = { get: jest.fn((key: string, def?: string) => def ?? 'http://localhost:3000') }; +const mockConfig = { + get: jest.fn((key: string, def?: string) => def ?? 'http://localhost:3000'), +}; +const mockJwtService = { + sign: jest.fn(() => 'mock-jwt-token'), + verify: jest.fn(() => ({ sub: 'user-id', email: 'test@example.com' })), +}; describe('AuthService', () => { let service: AuthService; @@ -21,7 +33,9 @@ describe('AuthService', () => { { provide: UsersService, useValue: mockUsersService }, { provide: MailService, useValue: mockMailService }, { provide: ConfigService, useValue: mockConfig }, + { provide: JwtService, useValue: mockJwtService }, { provide: getRepositoryToken(PasswordResetToken), useValue: mockRepo }, + { provide: getRepositoryToken(RefreshToken), useValue: mockRepo }, ], }).compile(); service = module.get(AuthService); @@ -35,7 +49,9 @@ describe('AuthService', () => { describe('forgotPassword', () => { it('returns silently if user not found', async () => { mockUsersService.findByEmail.mockResolvedValue(null); - await expect(service.forgotPassword('no@email.com')).resolves.toBeUndefined(); + await expect( + service.forgotPassword('no@email.com'), + ).resolves.toBeUndefined(); expect(mockMailService.sendPasswordResetEmail).not.toHaveBeenCalled(); }); @@ -47,18 +63,25 @@ describe('AuthService', () => { mockMailService.sendPasswordResetEmail.mockResolvedValue(undefined); await service.forgotPassword('test@example.com'); expect(mockRepo.save).toHaveBeenCalled(); - expect(mockMailService.sendPasswordResetEmail).toHaveBeenCalledWith('test@example.com', expect.stringContaining('reset-password')); + expect(mockMailService.sendPasswordResetEmail).toHaveBeenCalledWith( + 'test@example.com', + expect.stringContaining('reset-password'), + ); }); }); describe('resetPassword', () => { it('throws BadRequestException for invalid token format', async () => { - await expect(service.resetPassword('invalidtoken', 'newpass')).rejects.toThrow('Invalid token format'); + await expect( + service.resetPassword('invalidtoken', 'newpass'), + ).rejects.toThrow('Invalid token format'); }); it('throws BadRequestException when token not found', async () => { mockRepo.findOne.mockResolvedValue(null); - await expect(service.resetPassword('id.rawtoken', 'newpass')).rejects.toThrow(); + await expect( + service.resetPassword('id.rawtoken', 'newpass'), + ).rejects.toThrow(); }); }); @@ -66,7 +89,11 @@ describe('AuthService', () => { it('returns tokens and creates new user if not found', async () => { const profile = { id: 'g1', emails: [{ value: 'new@example.com' }] }; mockUsersService.findByEmail.mockResolvedValue(null); - mockUsersService.create.mockResolvedValue({ id: 'u2', email: 'new@example.com', googleId: 'g1' }); + mockUsersService.create.mockResolvedValue({ + id: 'u2', + email: 'new@example.com', + googleId: 'g1', + }); const result = await service.validateOAuthLogin(profile); expect(result).toHaveProperty('accessToken'); expect(mockUsersService.create).toHaveBeenCalled(); @@ -74,10 +101,16 @@ describe('AuthService', () => { it('updates googleId on existing user without one', async () => { const profile = { id: 'g2', emails: [{ value: 'exists@example.com' }] }; - mockUsersService.findByEmail.mockResolvedValue({ id: 'u3', email: 'exists@example.com', googleId: null }); + mockUsersService.findByEmail.mockResolvedValue({ + id: 'u3', + email: 'exists@example.com', + googleId: null, + }); mockUsersService.update.mockResolvedValue({ id: 'u3', googleId: 'g2' }); await service.validateOAuthLogin(profile); - expect(mockUsersService.update).toHaveBeenCalledWith('u3', { googleId: 'g2' }); + expect(mockUsersService.update).toHaveBeenCalledWith('u3', { + googleId: 'g2', + }); }); }); -}); \ No newline at end of file +}); diff --git a/backend/src/auth/auth.service.ts b/backend/src/auth/auth.service.ts index 98efb5b7e..6f1294da0 100644 --- a/backend/src/auth/auth.service.ts +++ b/backend/src/auth/auth.service.ts @@ -1,4 +1,8 @@ -import { Injectable, BadRequestException, UnauthorizedException } from '@nestjs/common'; +import { + Injectable, + BadRequestException, + UnauthorizedException, +} from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { ConfigService } from '@nestjs/config'; @@ -57,7 +61,6 @@ export class AuthService { } async refresh(refreshToken: string) { - const tokenHash = await bcrypt.hash(refreshToken, 10); const tokens = await this.refreshTokenRepository.find({ where: { revokedAt: null }, relations: ['user'], @@ -113,7 +116,10 @@ export class AuthService { }); await this.tokenRepository.save(resetToken); - const frontendUrl = this.configService.get('FRONTEND_URL', 'http://localhost:3000'); + const frontendUrl = this.configService.get( + 'FRONTEND_URL', + 'http://localhost:3000', + ); const resetLink = `${frontendUrl}/auth/reset-password?token=${resetToken.id}.${rawToken}`; await this.mailService.sendPasswordResetEmail(email, resetLink); @@ -126,19 +132,27 @@ export class AuthService { } const [tokenId, rawToken] = parts; - const resetToken = await this.tokenRepository.findOne({ where: { id: tokenId } }); + const resetToken = await this.tokenRepository.findOne({ + where: { id: tokenId }, + }); if (!resetToken) { - throw new BadRequestException('Token not found, expired, or already used'); + throw new BadRequestException( + 'Token not found, expired, or already used', + ); } if (resetToken.usedAt || resetToken.expiresAt < new Date()) { - throw new BadRequestException('Token not found, expired, or already used'); + throw new BadRequestException( + 'Token not found, expired, or already used', + ); } const isValid = await bcrypt.compare(rawToken, resetToken.tokenHash); if (!isValid) { - throw new BadRequestException('Token not found, expired, or already used'); + throw new BadRequestException( + 'Token not found, expired, or already used', + ); } const passwordHash = await bcrypt.hash(newPassword, 10); @@ -154,7 +168,9 @@ export class AuthService { if (user) { if (!user.googleId) { - user = await this.usersService.update(user.id, { googleId: profile.id }); + user = await this.usersService.update(user.id, { + googleId: profile.id, + }); } } else { user = await this.usersService.create({ diff --git a/backend/src/auth/dtos/2fa/authenticate-two-factor.dto.ts b/backend/src/auth/dtos/2fa/authenticate-two-factor.dto.ts index ae8586c35..8a40224ce 100644 --- a/backend/src/auth/dtos/2fa/authenticate-two-factor.dto.ts +++ b/backend/src/auth/dtos/2fa/authenticate-two-factor.dto.ts @@ -7,4 +7,4 @@ export class AuthenticateTwoFactorDto { @IsString() @Length(6, 6) code: string; -} \ No newline at end of file +} diff --git a/backend/src/auth/dtos/2fa/disable-two-factor.dto.ts b/backend/src/auth/dtos/2fa/disable-two-factor.dto.ts index 663f81d01..befe62e80 100644 --- a/backend/src/auth/dtos/2fa/disable-two-factor.dto.ts +++ b/backend/src/auth/dtos/2fa/disable-two-factor.dto.ts @@ -4,4 +4,4 @@ export class DisableTwoFactorDto { @IsString() @Length(6, 6) code: string; -} \ No newline at end of file +} diff --git a/backend/src/auth/dtos/2fa/enable-two-factor.dto.ts b/backend/src/auth/dtos/2fa/enable-two-factor.dto.ts index e268e8e4f..464025216 100644 --- a/backend/src/auth/dtos/2fa/enable-two-factor.dto.ts +++ b/backend/src/auth/dtos/2fa/enable-two-factor.dto.ts @@ -4,4 +4,4 @@ export class EnableTwoFactorDto { @IsString() @Length(6, 6) code: string; -} \ No newline at end of file +} diff --git a/backend/src/auth/entities/password-reset-token.entity.ts b/backend/src/auth/entities/password-reset-token.entity.ts index 270d07fdc..d9f691b06 100644 --- a/backend/src/auth/entities/password-reset-token.entity.ts +++ b/backend/src/auth/entities/password-reset-token.entity.ts @@ -1,4 +1,11 @@ -import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, ManyToOne, JoinColumn } from 'typeorm'; +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; import { User } from '../../users/entities/user.entity'; @Entity('password_reset_tokens') diff --git a/backend/src/auth/entities/refresh-token.entity.ts b/backend/src/auth/entities/refresh-token.entity.ts index 3a963969d..1341f4981 100644 --- a/backend/src/auth/entities/refresh-token.entity.ts +++ b/backend/src/auth/entities/refresh-token.entity.ts @@ -1,4 +1,11 @@ -import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, ManyToOne, JoinColumn } from 'typeorm'; +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; import { User } from '../../users/entities/user.entity'; @Entity('refresh_tokens') diff --git a/backend/src/auth/providers/two-factor.provider.ts b/backend/src/auth/providers/two-factor.provider.ts index 5a7dee644..a55b5da84 100644 --- a/backend/src/auth/providers/two-factor.provider.ts +++ b/backend/src/auth/providers/two-factor.provider.ts @@ -1,14 +1,16 @@ +import { Injectable } from '@nestjs/common'; + @Injectable() export class TwoFactorProvider { - async generateSecret(email: string) {} + async generateSecret(_email: string) {} - async generateQrCode(otpauthUrl: string) {} + async generateQrCode(_otpauthUrl: string) {} - verifyCode(secret: string, token: string) {} + verifyCode(_secret: string, _token: string) {} generateBackupCodes() {} - encryptSecret(secret: string) {} + encryptSecret(_secret: string) {} - decryptSecret(secret: string) {} -} \ No newline at end of file + decryptSecret(_secret: string) {} +} diff --git a/backend/src/auth/strategies/google.strategy.ts b/backend/src/auth/strategies/google.strategy.ts index 147834854..740525e7a 100644 --- a/backend/src/auth/strategies/google.strategy.ts +++ b/backend/src/auth/strategies/google.strategy.ts @@ -13,12 +13,20 @@ export class GoogleStrategy extends PassportStrategy(Strategy, 'google') { super({ clientID: configService.get('GOOGLE_CLIENT_ID', 'dummy'), clientSecret: configService.get('GOOGLE_CLIENT_SECRET', 'dummy'), - callbackURL: configService.get('GOOGLE_CALLBACK_URL', 'http://localhost:3000/auth/google/callback'), + callbackURL: configService.get( + 'GOOGLE_CALLBACK_URL', + 'http://localhost:3000/auth/google/callback', + ), scope: ['email', 'profile'], }); } - async validate(accessToken: string, refreshToken: string, profile: any, done: VerifyCallback): Promise { + async validate( + accessToken: string, + refreshToken: string, + profile: any, + done: VerifyCallback, + ): Promise { try { const result = await this.authService.validateOAuthLogin(profile); done(null, result); diff --git a/backend/src/auth/strategies/microsoft.strategy.ts b/backend/src/auth/strategies/microsoft.strategy.ts index d0201f4d4..02fda8f61 100644 --- a/backend/src/auth/strategies/microsoft.strategy.ts +++ b/backend/src/auth/strategies/microsoft.strategy.ts @@ -13,13 +13,21 @@ export class MicrosoftStrategy extends PassportStrategy(Strategy, 'microsoft') { super({ clientID: configService.get('MICROSOFT_CLIENT_ID', ''), clientSecret: configService.get('MICROSOFT_CLIENT_SECRET', ''), - callbackURL: configService.get('MICROSOFT_CALLBACK_URL', 'http://localhost:3000/api/auth/microsoft/callback'), + callbackURL: configService.get( + 'MICROSOFT_CALLBACK_URL', + 'http://localhost:3000/api/auth/microsoft/callback', + ), scope: ['user.read'], tenant: configService.get('MICROSOFT_TENANT_ID', 'common'), }); } - async validate(accessToken: string, refreshToken: string, profile: any, done: VerifyCallback): Promise { + async validate( + accessToken: string, + refreshToken: string, + profile: any, + done: VerifyCallback, + ): Promise { try { const result = await this.authService.validateOAuthLogin(profile); done(null, result); diff --git a/backend/src/cache/cache.service.ts b/backend/src/cache/cache.service.ts index c28ebec61..a592a756a 100644 --- a/backend/src/cache/cache.service.ts +++ b/backend/src/cache/cache.service.ts @@ -12,7 +12,9 @@ export class CacheService { try { return await this.cacheManager.get(key); } catch (error) { - this.logger.warn(`Failed to fetch key "${key}" from cache: ${error.message}`); + this.logger.warn( + `Failed to fetch key "${key}" from cache: ${error.message}`, + ); return null; // Graceful fallback } } @@ -22,7 +24,9 @@ export class CacheService { // Pass configurations safely matching your cache-manager library specification await this.cacheManager.set(key, value, ttl); } catch (error) { - this.logger.warn(`Failed to set key "${key}" into cache: ${error.message}`); + this.logger.warn( + `Failed to set key "${key}" into cache: ${error.message}`, + ); } } @@ -30,7 +34,9 @@ export class CacheService { try { await this.cacheManager.del(key); } catch (error) { - this.logger.warn(`Failed to delete key "${key}" from cache: ${error.message}`); + this.logger.warn( + `Failed to delete key "${key}" from cache: ${error.message}`, + ); } } -} \ No newline at end of file +} diff --git a/backend/src/categories/categories.controller.ts b/backend/src/categories/categories.controller.ts index c1dccf2d3..8d3a9cbd7 100644 --- a/backend/src/categories/categories.controller.ts +++ b/backend/src/categories/categories.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Get, Post, Body, Put, Param, Delete, UseInterceptors } from '@nestjs/common'; +import { Controller, Get, Post, Body, UseInterceptors } from '@nestjs/common'; import { CacheInterceptor, CacheKey } from '@nestjs/cache-manager'; import { CacheService } from '../cache/cache.service'; @@ -17,11 +17,11 @@ export class CategoriesController { } @Post() - async createCategory(@Body() body: any) { + async createCategory(@Body() _body: any) { // 1. Process standard write operations to database repository context - + // 2. Clear out the cached asset layout lists instantly to invalidate stale states await this.cacheService.del(this.CATEGORIES_CACHE_KEY); return { success: true }; } -} \ No newline at end of file +} diff --git a/backend/src/checkin/checkin.controller.ts b/backend/src/checkin/checkin.controller.ts index fa882ba46..0f792997e 100644 --- a/backend/src/checkin/checkin.controller.ts +++ b/backend/src/checkin/checkin.controller.ts @@ -1,4 +1,12 @@ -import { Controller, Post, Get, Param, Body, Req, UseGuards } from '@nestjs/common'; +import { + Controller, + Post, + Get, + Param, + Body, + Req, + UseGuards, +} from '@nestjs/common'; import { AuthGuard } from '@nestjs/passport'; import { CheckinService } from './checkin.service'; import { CheckoutDto } from './dtos/checkout.dto'; diff --git a/backend/src/checkin/checkin.entity.ts b/backend/src/checkin/checkin.entity.ts index 1356a0bb1..72beed98f 100644 --- a/backend/src/checkin/checkin.entity.ts +++ b/backend/src/checkin/checkin.entity.ts @@ -1,4 +1,12 @@ -import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, ManyToOne, JoinColumn } from 'typeorm'; +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; import { User } from '../users/entities/user.entity'; @Entity('checkin_records') diff --git a/backend/src/checkin/checkin.module.ts b/backend/src/checkin/checkin.module.ts index e459c13ca..5c7d32a73 100644 --- a/backend/src/checkin/checkin.module.ts +++ b/backend/src/checkin/checkin.module.ts @@ -4,9 +4,13 @@ import { CheckinRecord } from './checkin.entity'; import { Asset } from '../assets/entities/asset.entity'; import { CheckinService } from './checkin.service'; import { CheckinController } from './checkin.controller'; +import { NotificationModule } from '../notifications/notification.module'; @Module({ - imports: [TypeOrmModule.forFeature([CheckinRecord, Asset])], + imports: [ + TypeOrmModule.forFeature([CheckinRecord, Asset]), + NotificationModule, + ], controllers: [CheckinController], providers: [CheckinService], exports: [CheckinService], diff --git a/backend/src/checkin/checkin.service.ts b/backend/src/checkin/checkin.service.ts index 12ff5cc63..8587e799f 100644 --- a/backend/src/checkin/checkin.service.ts +++ b/backend/src/checkin/checkin.service.ts @@ -1,10 +1,19 @@ -import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; +import { + Injectable, + NotFoundException, + BadRequestException, +} from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { CheckinRecord } from './checkin.entity'; import { Asset } from '../assets/entities/asset.entity'; import { CheckoutDto } from './dtos/checkout.dto'; import { CheckinDto } from './dtos/checkin.dto'; +import { + NotificationDispatchService, + DispatchNotificationDto, +} from '../notifications/notification-dispatch.service'; +import { NotificationEvent } from '../notifications/enums/notification-event.enum'; @Injectable() export class CheckinService { @@ -13,10 +22,13 @@ export class CheckinService { private readonly checkinRepository: Repository, @InjectRepository(Asset) private readonly assetRepository: Repository, + private readonly notificationDispatchService: NotificationDispatchService, ) {} async checkout(dto: CheckoutDto, userId?: string): Promise { - const asset = await this.assetRepository.findOne({ where: { id: dto.assetId } }); + const asset = await this.assetRepository.findOne({ + where: { id: dto.assetId }, + }); if (!asset) throw new NotFoundException('Asset not found'); const active = await this.checkinRepository.findOne({ @@ -81,4 +93,51 @@ export class CheckinService { order: { checkedOutAt: 'DESC' }, }); } + + async markOverdue(checkinId: string): Promise { + const record = await this.checkinRepository.findOne({ + where: { id: checkinId, status: 'CHECKED_OUT' }, + relations: ['assignedTo'], + }); + if (!record) throw new NotFoundException('Checkin record not found'); + + const asset = await this.assetRepository.findOne({ + where: { id: record.assetId }, + }); + if (!asset) throw new NotFoundException('Asset not found'); + + record.status = 'OVERDUE'; + await this.checkinRepository.save(record); + + // Send notification for overdue checkout + const notificationDto: DispatchNotificationDto = { + userId: record.assignedToId, + event: NotificationEvent.CHECKOUT_OVERDUE, + title: 'Checkout Overdue', + message: `Asset ${asset.name} checkout is overdue.`, + entityType: 'Checkin', + entityId: checkinId, + metadata: { + assetName: asset.name, + assetId: asset.assetId, + dueDate: record.dueDate, + checkedOutAt: record.checkedOutAt, + }, + emailTemplate: 'checkout-overdue', + emailSubject: `Checkout Overdue: ${asset.name}`, + emailContext: { + assetName: asset.name, + assetId: asset.assetId, + assignedTo: record.assignedTo?.email || 'Unknown', + dueDate: record.dueDate?.toISOString().split('T')[0], + daysOverdue: Math.floor( + (Date.now() - record.dueDate.getTime()) / (1000 * 60 * 60 * 24), + ), + checkoutLink: `${process.env.FRONTEND_URL}/checkouts/${checkinId}`, + }, + }; + await this.notificationDispatchService.dispatch(notificationDto); + + return record; + } } diff --git a/backend/src/common/api-response.ts b/backend/src/common/api-response.ts index 17f3749ce..f06269beb 100644 --- a/backend/src/common/api-response.ts +++ b/backend/src/common/api-response.ts @@ -10,4 +10,4 @@ export class ApiResponse { static fail(message: string): ApiResponse { return { success: false, data: null as T, message }; } -} \ No newline at end of file +} diff --git a/backend/src/common/depreciation/depreciation.service.ts b/backend/src/common/depreciation/depreciation.service.ts index 81974662c..1b28efbde 100644 --- a/backend/src/common/depreciation/depreciation.service.ts +++ b/backend/src/common/depreciation/depreciation.service.ts @@ -22,8 +22,12 @@ export class DepreciationService { const annualDepreciation = (purchasePrice - salvageValue) / usefulLife; const monthlyDepreciation = annualDepreciation / 12; const now = new Date(); - const yearsOwned = (now.getTime() - purchaseDate.getTime()) / (365.25 * 24 * 60 * 60 * 1000); - const accumulatedDepreciation = Math.min(annualDepreciation * yearsOwned, purchasePrice - salvageValue); + const yearsOwned = + (now.getTime() - purchaseDate.getTime()) / (365.25 * 24 * 60 * 60 * 1000); + const accumulatedDepreciation = Math.min( + annualDepreciation * yearsOwned, + purchasePrice - salvageValue, + ); const currentBookValue = purchasePrice - accumulatedDepreciation; const remainingLife = Math.max(0, usefulLife - yearsOwned); @@ -36,16 +40,23 @@ export class DepreciationService { }; } - calculateDecliningBalance(input: DepreciationInput, rate = 2): DepreciationResult { + calculateDecliningBalance( + input: DepreciationInput, + rate = 2, + ): DepreciationResult { const { purchasePrice, salvageValue, usefulLife, purchaseDate } = input; const now = new Date(); - const yearsOwned = (now.getTime() - purchaseDate.getTime()) / (365.25 * 24 * 60 * 60 * 1000); + const yearsOwned = + (now.getTime() - purchaseDate.getTime()) / (365.25 * 24 * 60 * 60 * 1000); const annualRate = rate / usefulLife; let currentBookValue = purchasePrice; let accumulatedDepreciation = 0; for (let year = 0; year < Math.floor(yearsOwned); year++) { - const depreciation = Math.min(currentBookValue * annualRate, currentBookValue - salvageValue); + const depreciation = Math.min( + currentBookValue * annualRate, + currentBookValue - salvageValue, + ); accumulatedDepreciation += depreciation; currentBookValue -= depreciation; if (currentBookValue <= salvageValue) { @@ -56,8 +67,12 @@ export class DepreciationService { const remainingMonths = (yearsOwned - Math.floor(yearsOwned)) * 12; if (remainingMonths > 0 && currentBookValue > salvageValue) { - const partialDepreciation = (currentBookValue * annualRate) * (remainingMonths / 12); - const cappedPartial = Math.min(partialDepreciation, currentBookValue - salvageValue); + const partialDepreciation = + currentBookValue * annualRate * (remainingMonths / 12); + const cappedPartial = Math.min( + partialDepreciation, + currentBookValue - salvageValue, + ); accumulatedDepreciation += cappedPartial; currentBookValue -= cappedPartial; } @@ -67,7 +82,7 @@ export class DepreciationService { return { annualDepreciation: Math.round(annualDepreciation * 100) / 100, - monthlyDepreciation: Math.round(annualDepreciation / 12 * 100) / 100, + monthlyDepreciation: Math.round((annualDepreciation / 12) * 100) / 100, currentBookValue: Math.round(currentBookValue * 100) / 100, accumulatedDepreciation: Math.round(accumulatedDepreciation * 100) / 100, remainingLife: Math.round(remainingLife * 10) / 10, diff --git a/backend/src/common/dto/pagination.dto.ts b/backend/src/common/dto/pagination.dto.ts index 3225aaa60..be7ea9cb6 100644 --- a/backend/src/common/dto/pagination.dto.ts +++ b/backend/src/common/dto/pagination.dto.ts @@ -30,4 +30,4 @@ export class PaginatedResponse { this.limit = limit; this.totalPages = Math.ceil(total / limit); } -} \ No newline at end of file +} diff --git a/backend/src/common/filters/global-exception.filter.ts b/backend/src/common/filters/global-exception.filter.ts index 9d10c0acf..847d9596e 100644 --- a/backend/src/common/filters/global-exception.filter.ts +++ b/backend/src/common/filters/global-exception.filter.ts @@ -1,4 +1,11 @@ -import { ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus, Logger } from '@nestjs/common'; +import { + ExceptionFilter, + Catch, + ArgumentsHost, + HttpException, + HttpStatus, + Logger, +} from '@nestjs/common'; import { Request, Response } from 'express'; @Catch() @@ -10,15 +17,19 @@ export class GlobalExceptionFilter implements ExceptionFilter { const response = ctx.getResponse(); const request = ctx.getRequest(); - const status = exception instanceof HttpException - ? exception.getStatus() - : HttpStatus.INTERNAL_SERVER_ERROR; + const status = + exception instanceof HttpException + ? exception.getStatus() + : HttpStatus.INTERNAL_SERVER_ERROR; - const message = exception instanceof HttpException - ? (exception.getResponse() as string | { message: string }) - : 'Internal server error'; + const message = + exception instanceof HttpException + ? (exception.getResponse() as string | { message: string }) + : 'Internal server error'; - this.logger.error(${request.method} - , exception instanceof Error ? exception.stack : String(exception)); + this.logger.error( + `${request.method} ${request.url} - ${exception instanceof Error ? exception.stack : String(exception)}`, + ); response.status(status).json({ success: false, @@ -28,4 +39,4 @@ export class GlobalExceptionFilter implements ExceptionFilter { path: request.url, }); } -} \ No newline at end of file +} diff --git a/backend/src/common/interceptors/response.interceptor.ts b/backend/src/common/interceptors/response.interceptor.ts index a3840cb1e..a91bb06f7 100644 --- a/backend/src/common/interceptors/response.interceptor.ts +++ b/backend/src/common/interceptors/response.interceptor.ts @@ -1,4 +1,9 @@ -import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common'; +import { + Injectable, + NestInterceptor, + ExecutionContext, + CallHandler, +} from '@nestjs/common'; import { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; @@ -10,13 +15,19 @@ export interface ApiResponse { } @Injectable() -export class ResponseInterceptor implements NestInterceptor> { - intercept(context: ExecutionContext, next: CallHandler): Observable> { +export class ResponseInterceptor implements NestInterceptor< + T, + ApiResponse +> { + intercept( + context: ExecutionContext, + next: CallHandler, + ): Observable> { const ctx = context.switchToHttp(); const request = ctx.getRequest(); return next.handle().pipe( - map(data => ({ + map((data) => ({ success: true, data, timestamp: new Date().toISOString(), diff --git a/backend/src/common/logger/logger.service.ts b/backend/src/common/logger/logger.service.ts index 0f3a3ba9f..e7da1f0cc 100644 --- a/backend/src/common/logger/logger.service.ts +++ b/backend/src/common/logger/logger.service.ts @@ -1,4 +1,9 @@ -import { Injectable, Logger, LoggerService as NestLoggerService, LogLevel } from '@nestjs/common'; +import { + Injectable, + Logger, + LoggerService as NestLoggerService, + LogLevel, +} from '@nestjs/common'; @Injectable() export class LoggerService implements NestLoggerService { diff --git a/backend/src/contracts/contracts.controller.ts b/backend/src/contracts/contracts.controller.ts index 7c02314a1..e2d5863f4 100644 --- a/backend/src/contracts/contracts.controller.ts +++ b/backend/src/contracts/contracts.controller.ts @@ -1,4 +1,17 @@ -import { Controller, Get, Post, Put, Delete, Param, Body, Query, Req, UseGuards, UseInterceptors, UploadedFile } from '@nestjs/common'; +import { + Controller, + Get, + Post, + Put, + Delete, + Param, + Body, + Query, + Req, + UseGuards, + UseInterceptors, + UploadedFile, +} from '@nestjs/common'; import { AuthGuard } from '@nestjs/passport'; import { FileInterceptor } from '@nestjs/platform-express'; import { ContractsService } from './contracts.service'; @@ -22,7 +35,7 @@ export class ContractsController { @Post('upload') @UseInterceptors(FileInterceptor('file')) - async upload(@UploadedFile() file: Express.Multer.File, @Req() req: any) { + async upload(@UploadedFile() file: Express.Multer.File) { const key = `contracts/${Date.now()}-${file.originalname}`; await this.storageService.upload(file, key); const url = await this.storageService.getSignedUrl(key); @@ -40,7 +53,11 @@ export class ContractsController { } @Put(':id') - async update(@Param('id') id: string, @Body() dto: UpdateContractDto, @Req() req: any) { + async update( + @Param('id') id: string, + @Body() dto: UpdateContractDto, + @Req() req: any, + ) { return this.contractsService.update(id, dto, req.user?.id); } diff --git a/backend/src/contracts/contracts.service.ts b/backend/src/contracts/contracts.service.ts index 190d868c5..40f5cf997 100644 --- a/backend/src/contracts/contracts.service.ts +++ b/backend/src/contracts/contracts.service.ts @@ -16,7 +16,10 @@ export class ContractsService { private readonly contractRepository: Repository, private readonly configService: ConfigService, ) { - this.nextNumber = parseInt(configService.get('CONTRACT_ID_START', '500'), 10); + this.nextNumber = parseInt( + configService.get('CONTRACT_ID_START', '500'), + 10, + ); } async create(dto: CreateContractDto, userId?: string): Promise { @@ -30,9 +33,12 @@ export class ContractsService { return this.contractRepository.save(contract); } - async findAll(query: ContractQueryDto): Promise<{ data: Contract[]; total: number }> { + async findAll( + query: ContractQueryDto, + ): Promise<{ data: Contract[]; total: number }> { const { search, status, vendor, assignedToId, page, limit } = query; - const qb = this.contractRepository.createQueryBuilder('contract') + const qb = this.contractRepository + .createQueryBuilder('contract') .leftJoinAndSelect('contract.createdBy', 'createdBy') .leftJoinAndSelect('contract.assignedTo', 'assignedTo'); @@ -43,8 +49,10 @@ export class ContractsService { ); } if (status) qb.andWhere('contract.status = :status', { status }); - if (vendor) qb.andWhere('contract.vendor ILIKE :vendor', { vendor: `%${vendor}%` }); - if (assignedToId) qb.andWhere('contract.assignedToId = :assignedToId', { assignedToId }); + if (vendor) + qb.andWhere('contract.vendor ILIKE :vendor', { vendor: `%${vendor}%` }); + if (assignedToId) + qb.andWhere('contract.assignedToId = :assignedToId', { assignedToId }); qb.orderBy('contract.createdAt', 'DESC'); qb.skip((page - 1) * limit).take(limit); @@ -60,7 +68,11 @@ export class ContractsService { return contract; } - async update(id: string, dto: UpdateContractDto, userId?: string): Promise { + async update( + id: string, + dto: UpdateContractDto, + _userId?: string, + ): Promise { const contract = await this.findById(id); Object.assign(contract, dto); return this.contractRepository.save(contract); diff --git a/backend/src/contracts/entities/contract.entity.ts b/backend/src/contracts/entities/contract.entity.ts index 4b6818c0e..7dc3a37c6 100644 --- a/backend/src/contracts/entities/contract.entity.ts +++ b/backend/src/contracts/entities/contract.entity.ts @@ -1,4 +1,12 @@ -import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, ManyToOne, JoinColumn } from 'typeorm'; +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; import { User } from '../../users/entities/user.entity'; @Entity('contracts') diff --git a/backend/src/data-source.ts b/backend/src/data-source.ts index 94ff5cb7a..335358a43 100644 --- a/backend/src/data-source.ts +++ b/backend/src/data-source.ts @@ -16,4 +16,4 @@ export const AppDataSource = new DataSource({ entities: ['src/**/*.entity.ts'], migrations: ['src/migrations/*.ts'], synchronize: false, -}); \ No newline at end of file +}); diff --git a/backend/src/inventory/dtos/inventory-query.dto.ts b/backend/src/inventory/dtos/inventory-query.dto.ts index 0e976077f..fc6574f06 100644 --- a/backend/src/inventory/dtos/inventory-query.dto.ts +++ b/backend/src/inventory/dtos/inventory-query.dto.ts @@ -1,4 +1,11 @@ -import { IsOptional, IsString, IsBoolean, IsInt, Min, Max } from 'class-validator'; +import { + IsOptional, + IsString, + IsBoolean, + IsInt, + Min, + Max, +} from 'class-validator'; import { Type } from 'class-transformer'; export class InventoryQueryDto { diff --git a/backend/src/inventory/entities/inventory.entity.ts b/backend/src/inventory/entities/inventory.entity.ts index 34dda1e0d..ba1bbbce8 100644 --- a/backend/src/inventory/entities/inventory.entity.ts +++ b/backend/src/inventory/entities/inventory.entity.ts @@ -1,4 +1,12 @@ -import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, ManyToOne, JoinColumn } from 'typeorm'; +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; import { Asset } from '../../assets/asset.entity'; @Entity('inventory') diff --git a/backend/src/inventory/inventory.controller.ts b/backend/src/inventory/inventory.controller.ts index 4dbac7489..112d711e0 100644 --- a/backend/src/inventory/inventory.controller.ts +++ b/backend/src/inventory/inventory.controller.ts @@ -1,4 +1,14 @@ -import { Controller, Get, Post, Put, Delete, Param, Body, Query, UseGuards } from '@nestjs/common'; +import { + Controller, + Get, + Post, + Put, + Delete, + Param, + Body, + Query, + UseGuards, +} from '@nestjs/common'; import { AuthGuard } from '@nestjs/passport'; import { InventoryService } from './inventory.service'; import { CreateInventoryDto } from './dtos/create-inventory.dto'; diff --git a/backend/src/inventory/inventory.service.ts b/backend/src/inventory/inventory.service.ts index 8cd5a6d78..21212a456 100644 --- a/backend/src/inventory/inventory.service.ts +++ b/backend/src/inventory/inventory.service.ts @@ -14,26 +14,40 @@ export class InventoryService { ) {} async create(dto: CreateInventoryDto): Promise { - const totalValue = dto.quantity && dto.unitPrice ? dto.quantity * dto.unitPrice : 0; + const totalValue = + dto.quantity && dto.unitPrice ? dto.quantity * dto.unitPrice : 0; const item = this.inventoryRepository.create({ ...dto, totalValue }); return this.inventoryRepository.save(item); } - async findAll(query: InventoryQueryDto): Promise<{ data: Inventory[]; total: number }> { - const { page = 1, limit = 20, categoryId, location, search, lowStock } = query; - const qb = this.inventoryRepository.createQueryBuilder('item') + async findAll( + query: InventoryQueryDto, + ): Promise<{ data: Inventory[]; total: number }> { + const { + page = 1, + limit = 20, + categoryId, + location, + search, + lowStock, + } = query; + const qb = this.inventoryRepository + .createQueryBuilder('item') .leftJoinAndSelect('item.asset', 'asset') .skip((page - 1) * limit) .take(limit) .orderBy('item.createdAt', 'DESC'); - if (categoryId) qb.andWhere('item.categoryId = :categoryId', { categoryId }); - if (location) qb.andWhere('item.location ILIKE :location', { location: `%${location}%` }); + if (categoryId) + qb.andWhere('item.categoryId = :categoryId', { categoryId }); + if (location) + qb.andWhere('item.location ILIKE :location', { + location: `%${location}%`, + }); if (search) { - qb.andWhere( - '(item.notes ILIKE :search OR item.location ILIKE :search)', - { search: `%${search}%` }, - ); + qb.andWhere('(item.notes ILIKE :search OR item.location ILIKE :search)', { + search: `%${search}%`, + }); } if (lowStock) { qb.andWhere('item.quantity <= item.reorderLevel'); @@ -56,7 +70,8 @@ export class InventoryService { const item = await this.findById(id); Object.assign(item, dto); if (dto.quantity !== undefined || dto.unitPrice !== undefined) { - item.totalValue = (dto.quantity ?? item.quantity) * (dto.unitPrice ?? item.unitPrice); + item.totalValue = + (dto.quantity ?? item.quantity) * (dto.unitPrice ?? item.unitPrice); } return this.inventoryRepository.save(item); } diff --git a/backend/src/licenses/entities/license.entity.ts b/backend/src/licenses/entities/license.entity.ts index 4ed86fefb..af09b68c4 100644 --- a/backend/src/licenses/entities/license.entity.ts +++ b/backend/src/licenses/entities/license.entity.ts @@ -1,4 +1,12 @@ -import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, ManyToOne, JoinColumn } from 'typeorm'; +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; import { User } from '../../users/entities/user.entity'; @Entity('licenses') diff --git a/backend/src/licenses/licenses.controller.ts b/backend/src/licenses/licenses.controller.ts index f354fe1ca..777db389e 100644 --- a/backend/src/licenses/licenses.controller.ts +++ b/backend/src/licenses/licenses.controller.ts @@ -1,4 +1,15 @@ -import { Controller, Get, Post, Put, Delete, Param, Body, Query, Req, UseGuards } from '@nestjs/common'; +import { + Controller, + Get, + Post, + Put, + Delete, + Param, + Body, + Query, + Req, + UseGuards, +} from '@nestjs/common'; import { AuthGuard } from '@nestjs/passport'; import { LicensesService } from './licenses.service'; import { CreateLicenseDto } from './dtos/create-license.dto'; @@ -26,7 +37,11 @@ export class LicensesController { } @Put(':id') - async update(@Param('id') id: string, @Body() dto: UpdateLicenseDto, @Req() req: any) { + async update( + @Param('id') id: string, + @Body() dto: UpdateLicenseDto, + @Req() req: any, + ) { return this.licensesService.update(id, dto, req.user?.id); } diff --git a/backend/src/licenses/licenses.service.ts b/backend/src/licenses/licenses.service.ts index 0dacb5e67..db7bf37c5 100644 --- a/backend/src/licenses/licenses.service.ts +++ b/backend/src/licenses/licenses.service.ts @@ -23,9 +23,12 @@ export class LicensesService { return this.licenseRepository.save(license); } - async findAll(query: LicenseQueryDto): Promise<{ data: License[]; total: number }> { + async findAll( + query: LicenseQueryDto, + ): Promise<{ data: License[]; total: number }> { const { search, status, vendor, page, limit } = query; - const qb = this.licenseRepository.createQueryBuilder('license') + const qb = this.licenseRepository + .createQueryBuilder('license') .leftJoinAndSelect('license.assignedTo', 'assignedTo') .leftJoinAndSelect('license.createdBy', 'createdBy'); @@ -36,7 +39,8 @@ export class LicensesService { ); } if (status) qb.andWhere('license.status = :status', { status }); - if (vendor) qb.andWhere('license.vendor ILIKE :vendor', { vendor: `%${vendor}%` }); + if (vendor) + qb.andWhere('license.vendor ILIKE :vendor', { vendor: `%${vendor}%` }); qb.orderBy('license.createdAt', 'DESC'); qb.skip((page - 1) * limit).take(limit); @@ -52,7 +56,11 @@ export class LicensesService { return license; } - async update(id: string, dto: UpdateLicenseDto, userId?: string): Promise { + async update( + id: string, + dto: UpdateLicenseDto, + _userId?: string, + ): Promise { const license = await this.findById(id); Object.assign(license, dto); return this.licenseRepository.save(license); diff --git a/backend/src/locations/entities/location.entity.ts b/backend/src/locations/entities/location.entity.ts index 5494dc032..e237cce10 100644 --- a/backend/src/locations/entities/location.entity.ts +++ b/backend/src/locations/entities/location.entity.ts @@ -1,4 +1,10 @@ -import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm'; +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, +} from 'typeorm'; @Entity('locations') export class Location { diff --git a/backend/src/locations/locations.controller.ts b/backend/src/locations/locations.controller.ts index cbec5cb2e..a14d202a9 100644 --- a/backend/src/locations/locations.controller.ts +++ b/backend/src/locations/locations.controller.ts @@ -1,4 +1,14 @@ -import { Controller, Get, Post, Put, Delete, Param, Body, Query, UseGuards } from '@nestjs/common'; +import { + Controller, + Get, + Post, + Put, + Delete, + Param, + Body, + Query, + UseGuards, +} from '@nestjs/common'; import { AuthGuard } from '@nestjs/passport'; import { LocationsService } from './locations.service'; import { CreateLocationDto } from './dtos/create-location.dto'; @@ -15,7 +25,9 @@ export class LocationsController { } @Get() - async findAll(@Query() query: { page?: number; limit?: number; isActive?: boolean }) { + async findAll( + @Query() query: { page?: number; limit?: number; isActive?: boolean }, + ) { return this.locationsService.findAll(query); } diff --git a/backend/src/locations/locations.service.ts b/backend/src/locations/locations.service.ts index 7c2a001c6..6a7f592e9 100644 --- a/backend/src/locations/locations.service.ts +++ b/backend/src/locations/locations.service.ts @@ -17,13 +17,17 @@ export class LocationsService { return this.locationRepository.save(location); } - async findAll(query: { page?: number; limit?: number; isActive?: boolean } = {}): Promise<{ data: Location[]; total: number }> { + async findAll( + query: { page?: number; limit?: number; isActive?: boolean } = {}, + ): Promise<{ data: Location[]; total: number }> { const { page = 1, limit = 20, isActive } = query; - const qb = this.locationRepository.createQueryBuilder('location') + const qb = this.locationRepository + .createQueryBuilder('location') .skip((page - 1) * limit) .take(limit); - if (isActive !== undefined) qb.andWhere('location.isActive = :isActive', { isActive }); + if (isActive !== undefined) + qb.andWhere('location.isActive = :isActive', { isActive }); const [data, total] = await qb.getManyAndCount(); return { data, total }; diff --git a/backend/src/mail/mail.service.ts b/backend/src/mail/mail.service.ts index cb47a35a8..efe36e8ac 100644 --- a/backend/src/mail/mail.service.ts +++ b/backend/src/mail/mail.service.ts @@ -20,29 +20,57 @@ export class MailService { } async sendPasswordResetEmail(email: string, resetLink: string) { - await this.sendTemplateEmail(email, 'Password Reset', 'password-reset', { resetLink }); + await this.sendTemplateEmail(email, 'Password Reset', 'password-reset', { + resetLink, + }); } async sendWelcomeEmail(email: string, name: string) { - await this.sendTemplateEmail(email, 'Welcome to AssetsUp', 'welcome', { name }); + await this.sendTemplateEmail(email, 'Welcome to AssetsUp', 'welcome', { + name, + }); } - async sendTemplateEmail(to: string, subject: string, template: string, context: Record) { + async sendTemplateEmail( + to: string, + subject: string, + template: string, + context: Record, + attachment?: { filename: string; content: Buffer; contentType: string }, + ) { const html = this.renderTemplate(template, context); try { - await this.transporter.sendMail({ - from: this.configService.get('MAIL_FROM', 'noreply@assetsup.local'), + const mailOptions: any = { + from: this.configService.get( + 'MAIL_FROM', + 'noreply@assetsup.local', + ), to, subject, html, - }); + }; + + if (attachment) { + mailOptions.attachments = [ + { + filename: attachment.filename, + content: attachment.content, + contentType: attachment.contentType, + }, + ]; + } + + await this.transporter.sendMail(mailOptions); this.logger.log(`Email sent to ${to} with subject "${subject}"`); } catch (error) { this.logger.error(`Failed to send email to ${to}: ${error.message}`); } } - private renderTemplate(template: string, context: Record): string { + private renderTemplate( + template: string, + context: Record, + ): string { const templates: Record) => string> = { 'password-reset': (ctx) => `

Password Reset

@@ -50,7 +78,7 @@ export class MailService { ${ctx.resetLink}

This link expires in 1 hour.

`, - 'welcome': (ctx) => ` + welcome: (ctx) => `

Welcome to AssetsUp!

Hello ${ctx.name},

Your account has been created successfully.

@@ -62,17 +90,92 @@ export class MailService { `, 'maintenance-due': (ctx) => `

Maintenance Due

-

Asset ${ctx.assetName} has scheduled maintenance due on ${ctx.dueDate}.

+

Asset ${ctx.assetName} (${ctx.assetId}) has scheduled maintenance due on ${ctx.dueDate}.

+

Description: ${ctx.description}

+

View Asset

`, 'warranty-expiry': (ctx) => `

Warranty Expiry Notice

-

The warranty for ${ctx.assetName} expires on ${ctx.expiryDate}.

+

The warranty for ${ctx.assetName} (${ctx.assetId}) expires on ${ctx.expiryDate}.

+ ${ctx.daysRemaining ? `

Days remaining: ${ctx.daysRemaining}

` : ''} +

View Asset

+ `, + 'export-ready': (ctx) => ` +

Your Report is Ready

+

Your ${ctx.reportName} report has been generated successfully.

+

Format: ${ctx.format.toUpperCase()}

+

Total rows: ${ctx.rowCount}

+

Please find the report attached to this email.

+ `, + 'asset-transferred': (ctx) => ` +

Asset Transferred

+

Asset ${ctx.assetName} (${ctx.assetId}) has been transferred.

+ ${ctx.assignedTo ? `

Assigned to: ${ctx.assignedTo}

` : ''} + ${ctx.location ? `

Location: ${ctx.location}

` : ''} +

View Asset

+ `, + 'asset-status-changed': (ctx) => ` +

Asset Status Changed

+

Asset ${ctx.assetName} (${ctx.assetId}) status changed from ${ctx.oldStatus} to ${ctx.newStatus}.

+

View Asset

+ `, + 'work-order-assigned': (ctx) => ` +

Work Order Assigned

+

You have been assigned to work order ${ctx.workOrderId}.

+

Asset: ${ctx.assetName}

+

Description: ${ctx.description}

+ ${ctx.dueDate ? `

Due Date: ${ctx.dueDate}

` : ''} +

View Work Order

+ `, + 'work-order-completed': (ctx) => ` +

Work Order Completed

+

Work order ${ctx.workOrderId} has been completed.

+

Asset: ${ctx.assetName}

+

Completed by: ${ctx.completedBy}

+

View Work Order

+ `, + + 'checkout-overdue': (ctx) => ` +

Checkout Overdue

+

Asset ${ctx.assetName} (${ctx.assetId}) checkout is overdue.

+

Checked out to: ${ctx.assignedTo}

+

Due date: ${ctx.dueDate}

+

Days overdue: ${ctx.daysOverdue}

+

View Checkout

+ `, + 'contract-expiring': (ctx) => ` +

Contract Expiring Soon

+

Contract ${ctx.contractName} expires on ${ctx.expiryDate}.

+ ${ctx.daysRemaining ? `

Days remaining: ${ctx.daysRemaining}

` : ''} +

View Contract

+ `, + 'low-stock': (ctx) => ` +

Low Stock Alert

+

Asset ${ctx.assetName} is running low.

+

Current quantity: ${ctx.currentQuantity}

+

Minimum threshold: ${ctx.minThreshold}

+

View Asset

+ `, + 'booking-confirmed': (ctx) => ` +

Booking Confirmed

+

Your booking for ${ctx.assetName} has been confirmed.

+

Start date: ${ctx.startDate}

+

End date: ${ctx.endDate}

+

View Booking

+ `, + 'booking-cancelled': (ctx) => ` +

Booking Cancelled

+

Your booking for ${ctx.assetName} has been cancelled.

+

Start date: ${ctx.startDate}

+

End date: ${ctx.endDate}

+ ${ctx.reason ? `

Reason: ${ctx.reason}

` : ''} +

View Details

`, }; const render = templates[template]; if (!render) { this.logger.warn(`Unknown email template: ${template}`); - return `

${subject}

`; + return `

Email notification

`; } return render(context); } diff --git a/backend/src/migrations/1700000000000-InitialSchema.ts b/backend/src/migrations/1700000000000-InitialSchema.ts index 0c13206eb..4871f07a2 100644 --- a/backend/src/migrations/1700000000000-InitialSchema.ts +++ b/backend/src/migrations/1700000000000-InitialSchema.ts @@ -8,7 +8,13 @@ export class InitialSchema1700000000000 implements MigrationInterface { new Table({ name: 'users', columns: [ - { name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' }, + { + name: 'id', + type: 'uuid', + isPrimary: true, + generationStrategy: 'uuid', + default: 'uuid_generate_v4()', + }, { name: 'email', type: 'varchar', isUnique: true }, { name: 'password_hash', type: 'varchar', isNullable: true }, { name: 'google_id', type: 'varchar', isNullable: true }, @@ -23,4 +29,4 @@ export class InitialSchema1700000000000 implements MigrationInterface { public async down(queryRunner: QueryRunner): Promise { await queryRunner.dropTable('users'); } -} \ No newline at end of file +} diff --git a/backend/src/notifications/dtos/create-notification.dto.ts b/backend/src/notifications/dtos/create-notification.dto.ts new file mode 100644 index 000000000..658a74aea --- /dev/null +++ b/backend/src/notifications/dtos/create-notification.dto.ts @@ -0,0 +1,11 @@ +import { NotificationEvent } from '../enums/notification-event.enum'; + +export class CreateNotificationDto { + userId: string; + event: NotificationEvent; + title: string; + message: string; + entityType?: string; + entityId?: string; + metadata?: Record; +} diff --git a/backend/src/notifications/entities/notification.entity.ts b/backend/src/notifications/entities/notification.entity.ts new file mode 100644 index 000000000..6f7e9bb41 --- /dev/null +++ b/backend/src/notifications/entities/notification.entity.ts @@ -0,0 +1,53 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { User } from '../../users/entities/user.entity'; +import { NotificationEvent } from '../enums/notification-event.enum'; + +@Entity('notifications') +export class Notification { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + userId: string; + + @ManyToOne(() => User, { nullable: true }) + @JoinColumn({ name: 'userId' }) + user: User; + + @Column({ type: 'varchar' }) + event: NotificationEvent; + + @Column() + title: string; + + @Column({ type: 'text' }) + message: string; + + @Column({ nullable: true }) + entityType: string; + + @Column({ nullable: true }) + entityId: string; + + @Column({ type: 'jsonb', nullable: true }) + metadata: Record; + + @Column({ default: false }) + isRead: boolean; + + @Column({ nullable: true }) + readAt: Date; + + @Column({ nullable: true }) + emailSent: boolean; + + @CreateDateColumn() + createdAt: Date; +} diff --git a/backend/src/notifications/entities/user-notification-preference.entity.ts b/backend/src/notifications/entities/user-notification-preference.entity.ts new file mode 100644 index 000000000..5bf7265b8 --- /dev/null +++ b/backend/src/notifications/entities/user-notification-preference.entity.ts @@ -0,0 +1,52 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { User } from '../../users/entities/user.entity'; +import { NotificationEvent } from '../enums/notification-event.enum'; + +@Entity('user_notification_preferences') +export class UserNotificationPreference { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ unique: true }) + userId: string; + + @ManyToOne(() => User, { nullable: false }) + @JoinColumn({ name: 'userId' }) + user: User; + + @Column({ type: 'jsonb', default: defaultPreferences }) + emailPreferences: Record; + + @Column({ type: 'jsonb', default: defaultPreferences }) + inAppPreferences: Record; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} + +function defaultPreferences(): Record { + return { + [NotificationEvent.ASSET_TRANSFERRED]: true, + [NotificationEvent.ASSET_STATUS_CHANGED]: true, + [NotificationEvent.WORK_ORDER_ASSIGNED]: true, + [NotificationEvent.WORK_ORDER_COMPLETED]: true, + [NotificationEvent.MAINTENANCE_DUE]: true, + [NotificationEvent.WARRANTY_EXPIRING]: true, + [NotificationEvent.CHECKOUT_OVERDUE]: true, + [NotificationEvent.CONTRACT_EXPIRING]: true, + [NotificationEvent.LOW_STOCK]: true, + [NotificationEvent.BOOKING_CONFIRMED]: true, + [NotificationEvent.BOOKING_CANCELLED]: true, + }; +} diff --git a/backend/src/notifications/enums/notification-event.enum.ts b/backend/src/notifications/enums/notification-event.enum.ts new file mode 100644 index 000000000..a6175a477 --- /dev/null +++ b/backend/src/notifications/enums/notification-event.enum.ts @@ -0,0 +1,13 @@ +export enum NotificationEvent { + ASSET_TRANSFERRED = 'ASSET_TRANSFERRED', + ASSET_STATUS_CHANGED = 'ASSET_STATUS_CHANGED', + WORK_ORDER_ASSIGNED = 'WORK_ORDER_ASSIGNED', + WORK_ORDER_COMPLETED = 'WORK_ORDER_COMPLETED', + MAINTENANCE_DUE = 'MAINTENANCE_DUE', + WARRANTY_EXPIRING = 'WARRANTY_EXPIRING', + CHECKOUT_OVERDUE = 'CHECKOUT_OVERDUE', + CONTRACT_EXPIRING = 'CONTRACT_EXPIRING', + LOW_STOCK = 'LOW_STOCK', + BOOKING_CONFIRMED = 'BOOKING_CONFIRMED', + BOOKING_CANCELLED = 'BOOKING_CANCELLED', +} diff --git a/backend/src/notifications/notification-dispatch.service.ts b/backend/src/notifications/notification-dispatch.service.ts new file mode 100644 index 000000000..5fb059bf7 --- /dev/null +++ b/backend/src/notifications/notification-dispatch.service.ts @@ -0,0 +1,102 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { NotificationEvent } from './enums/notification-event.enum'; +import { UserNotificationPreference } from './entities/user-notification-preference.entity'; +import { User } from '../users/entities/user.entity'; +import { NotificationsService } from './notifications.service'; +import { QueueService } from '../queue/queue.service'; + +export interface DispatchNotificationDto { + userId: string; + event: NotificationEvent; + title: string; + message: string; + entityType?: string; + entityId?: string; + metadata?: Record; + emailTemplate: string; + emailSubject: string; + emailContext: Record; +} + +@Injectable() +export class NotificationDispatchService { + private readonly logger = new Logger(NotificationDispatchService.name); + + constructor( + @InjectRepository(UserNotificationPreference) + private readonly preferenceRepository: Repository, + @InjectRepository(User) + private readonly userRepository: Repository, + private readonly notificationsService: NotificationsService, + private readonly queueService: QueueService, + ) {} + + async dispatch(dto: DispatchNotificationDto): Promise { + const preferences = await this.getUserPreferences(dto.userId); + const user = await this.getUserEmail(dto.userId); + + if (!user) { + this.logger.warn(`User ${dto.userId} not found, skipping notification`); + return; + } + + const emailEnabled = preferences?.emailPreferences?.[dto.event] ?? true; + const inAppEnabled = preferences?.inAppPreferences?.[dto.event] ?? true; + + if (inAppEnabled) { + await this.notificationsService.create({ + userId: dto.userId, + event: dto.event, + title: dto.title, + message: dto.message, + entityType: dto.entityType, + entityId: dto.entityId, + metadata: dto.metadata, + }); + this.logger.log( + `In-app notification sent to user ${dto.userId} for event ${dto.event}`, + ); + } + + if (emailEnabled && user.email) { + await this.queueService.sendEmail({ + to: user.email, + subject: dto.emailSubject, + template: dto.emailTemplate, + context: dto.emailContext, + }); + this.logger.log( + `Email queued for user ${dto.userId} (${user.email}) for event ${dto.event}`, + ); + } + } + + private async getUserPreferences( + userId: string, + ): Promise { + try { + return await this.preferenceRepository.findOne({ + where: { userId }, + relations: ['user'], + }); + } catch { + this.logger.warn( + `Failed to fetch preferences for user ${userId}, using defaults`, + ); + return null; + } + } + + private async getUserEmail(userId: string): Promise { + try { + return await this.userRepository.findOne({ + where: { id: userId }, + }); + } catch { + this.logger.warn(`Failed to fetch user ${userId}`); + return null; + } + } +} diff --git a/backend/src/notifications/notification.module.ts b/backend/src/notifications/notification.module.ts new file mode 100644 index 000000000..352b7f2c8 --- /dev/null +++ b/backend/src/notifications/notification.module.ts @@ -0,0 +1,17 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Notification } from './entities/notification.entity'; +import { UserNotificationPreference } from './entities/user-notification-preference.entity'; +import { NotificationsService } from './notifications.service'; +import { NotificationDispatchService } from './notification-dispatch.service'; +import { QueueModule } from '../queue/queue.module'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([Notification, UserNotificationPreference]), + QueueModule, + ], + providers: [NotificationsService, NotificationDispatchService], + exports: [NotificationsService, NotificationDispatchService, TypeOrmModule], +}) +export class NotificationModule {} diff --git a/backend/src/notifications/notifications.service.ts b/backend/src/notifications/notifications.service.ts new file mode 100644 index 000000000..efd2fba82 --- /dev/null +++ b/backend/src/notifications/notifications.service.ts @@ -0,0 +1,59 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Notification } from './entities/notification.entity'; +import { CreateNotificationDto } from './dtos/create-notification.dto'; + +@Injectable() +export class NotificationsService { + private readonly logger = new Logger(NotificationsService.name); + + constructor( + @InjectRepository(Notification) + private readonly notificationRepository: Repository, + ) {} + + async create(dto: CreateNotificationDto): Promise { + const notification = this.notificationRepository.create(dto); + return this.notificationRepository.save(notification); + } + + async findByUserId( + userId: string, + isRead?: boolean, + ): Promise { + const where: any = { userId }; + if (isRead !== undefined) { + where.isRead = isRead; + } + return this.notificationRepository.find({ + where, + order: { createdAt: 'DESC' }, + }); + } + + async markAsRead(notificationId: string): Promise { + const notification = await this.notificationRepository.findOne({ + where: { id: notificationId }, + }); + if (!notification) { + throw new Error('Notification not found'); + } + notification.isRead = true; + notification.readAt = new Date(); + return this.notificationRepository.save(notification); + } + + async markAllAsRead(userId: string): Promise { + await this.notificationRepository.update( + { userId, isRead: false }, + { isRead: true, readAt: new Date() }, + ); + } + + async getUnreadCount(userId: string): Promise { + return this.notificationRepository.count({ + where: { userId, isRead: false }, + }); + } +} diff --git a/backend/src/purchase-orders/dtos/create-purchase-order.dto.ts b/backend/src/purchase-orders/dtos/create-purchase-order.dto.ts index 407ffd0b6..3fa75711d 100644 --- a/backend/src/purchase-orders/dtos/create-purchase-order.dto.ts +++ b/backend/src/purchase-orders/dtos/create-purchase-order.dto.ts @@ -1,4 +1,10 @@ -import { IsString, IsOptional, IsNumber, IsArray, ValidateNested } from 'class-validator'; +import { + IsString, + IsOptional, + IsNumber, + IsArray, + ValidateNested, +} from 'class-validator'; import { Type } from 'class-transformer'; class PurchaseOrderItemDto { diff --git a/backend/src/purchase-orders/dtos/update-purchase-order.dto.ts b/backend/src/purchase-orders/dtos/update-purchase-order.dto.ts index d0c366a8b..ba6a0c8d5 100644 --- a/backend/src/purchase-orders/dtos/update-purchase-order.dto.ts +++ b/backend/src/purchase-orders/dtos/update-purchase-order.dto.ts @@ -1,4 +1,10 @@ -import { IsString, IsOptional, IsNumber, IsArray, ValidateNested } from 'class-validator'; +import { + IsString, + IsOptional, + IsNumber, + IsArray, + ValidateNested, +} from 'class-validator'; import { Type } from 'class-transformer'; class PurchaseOrderItemDto { diff --git a/backend/src/purchase-orders/entities/purchase-order.entity.ts b/backend/src/purchase-orders/entities/purchase-order.entity.ts index 40e04b486..5350b9624 100644 --- a/backend/src/purchase-orders/entities/purchase-order.entity.ts +++ b/backend/src/purchase-orders/entities/purchase-order.entity.ts @@ -1,4 +1,12 @@ -import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, ManyToOne, JoinColumn } from 'typeorm'; +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; import { User } from '../../users/entities/user.entity'; @Entity('purchase_orders') diff --git a/backend/src/purchase-orders/purchase-orders.controller.ts b/backend/src/purchase-orders/purchase-orders.controller.ts index c9bccd815..d67c4dcd8 100644 --- a/backend/src/purchase-orders/purchase-orders.controller.ts +++ b/backend/src/purchase-orders/purchase-orders.controller.ts @@ -1,4 +1,16 @@ -import { Controller, Get, Post, Put, Patch, Delete, Param, Body, Query, Req, UseGuards } from '@nestjs/common'; +import { + Controller, + Get, + Post, + Put, + Patch, + Delete, + Param, + Body, + Query, + Req, + UseGuards, +} from '@nestjs/common'; import { AuthGuard } from '@nestjs/passport'; import { PurchaseOrdersService } from './purchase-orders.service'; import { CreatePurchaseOrderDto } from './dtos/create-purchase-order.dto'; @@ -26,13 +38,25 @@ export class PurchaseOrdersController { } @Put(':id') - async update(@Param('id') id: string, @Body() dto: UpdatePurchaseOrderDto, @Req() req: any) { + async update( + @Param('id') id: string, + @Body() dto: UpdatePurchaseOrderDto, + @Req() req: any, + ) { return this.purchaseOrdersService.update(id, dto, req.user?.id); } @Patch(':id/status') - async updateStatus(@Param('id') id: string, @Body('status') status: string, @Req() req: any) { - return this.purchaseOrdersService.update(id, { status } as UpdatePurchaseOrderDto, req.user?.id); + async updateStatus( + @Param('id') id: string, + @Body('status') status: string, + @Req() req: any, + ) { + return this.purchaseOrdersService.update( + id, + { status } as UpdatePurchaseOrderDto, + req.user?.id, + ); } @Delete(':id') diff --git a/backend/src/purchase-orders/purchase-orders.service.ts b/backend/src/purchase-orders/purchase-orders.service.ts index 1c9862959..1d42dc050 100644 --- a/backend/src/purchase-orders/purchase-orders.service.ts +++ b/backend/src/purchase-orders/purchase-orders.service.ts @@ -16,17 +16,25 @@ export class PurchaseOrdersService { private readonly poRepository: Repository, private readonly configService: ConfigService, ) { - this.nextNumber = parseInt(configService.get('PO_ID_START', '1000'), 10); + this.nextNumber = parseInt( + configService.get('PO_ID_START', '1000'), + 10, + ); } - async create(dto: CreatePurchaseOrderDto, userId?: string): Promise { + async create( + dto: CreatePurchaseOrderDto, + userId?: string, + ): Promise { const prefix = this.configService.get('PO_ID_PREFIX', 'PO'); const poNumber = `${prefix}-${this.nextNumber++}`; - const items = dto.items?.map(item => ({ - ...item, - total: item.total ?? item.quantity * item.unitPrice, - })) || []; - const subtotal = dto.subtotal ?? items.reduce((sum, item) => sum + item.total, 0); + const items = + dto.items?.map((item) => ({ + ...item, + total: item.total ?? item.quantity * item.unitPrice, + })) || []; + const subtotal = + dto.subtotal ?? items.reduce((sum, item) => sum + item.total, 0); const total = dto.total ?? subtotal + (dto.tax ?? 0); const po = this.poRepository.create({ @@ -40,9 +48,12 @@ export class PurchaseOrdersService { return this.poRepository.save(po); } - async findAll(query: PurchaseOrderQueryDto): Promise<{ data: PurchaseOrder[]; total: number }> { + async findAll( + query: PurchaseOrderQueryDto, + ): Promise<{ data: PurchaseOrder[]; total: number }> { const { search, status, vendor, page, limit } = query; - const qb = this.poRepository.createQueryBuilder('po') + const qb = this.poRepository + .createQueryBuilder('po') .leftJoinAndSelect('po.createdBy', 'createdBy') .leftJoinAndSelect('po.approvedBy', 'approvedBy'); @@ -53,7 +64,8 @@ export class PurchaseOrdersService { ); } if (status) qb.andWhere('po.status = :status', { status }); - if (vendor) qb.andWhere('po.vendor ILIKE :vendor', { vendor: `%${vendor}%` }); + if (vendor) + qb.andWhere('po.vendor ILIKE :vendor', { vendor: `%${vendor}%` }); qb.orderBy('po.createdAt', 'DESC'); qb.skip((page - 1) * limit).take(limit); @@ -69,10 +81,14 @@ export class PurchaseOrdersService { return po; } - async update(id: string, dto: UpdatePurchaseOrderDto, userId?: string): Promise { + async update( + id: string, + dto: UpdatePurchaseOrderDto, + _userId?: string, + ): Promise { const po = await this.findById(id); if (dto.items) { - dto.items = dto.items.map(item => ({ + dto.items = dto.items.map((item) => ({ ...item, total: item.total ?? item.quantity * item.unitPrice, })); diff --git a/backend/src/queue/processors/email.processor.ts b/backend/src/queue/processors/email.processor.ts index 77796c937..a47ab6dd2 100644 --- a/backend/src/queue/processors/email.processor.ts +++ b/backend/src/queue/processors/email.processor.ts @@ -2,16 +2,54 @@ import { Processor, Process } from '@nestjs/bull'; import { Job } from 'bull'; import { Logger } from '@nestjs/common'; import { MailService } from '../../mail/mail.service'; +import { ActivityLogService } from '../../activity-log/activity-log.service'; @Processor('email') export class EmailProcessor { private readonly logger = new Logger(EmailProcessor.name); - constructor(private readonly mailService: MailService) {} + constructor( + private readonly mailService: MailService, + private readonly activityLogService: ActivityLogService, + ) {} @Process('send') - async handleSend(job: Job<{ to: string; subject: string; template: string; context: Record }>) { + async handleSend( + job: Job<{ + to: string; + subject: string; + template: string; + context: Record; + }>, + ) { this.logger.log(`Processing email job #${job.id} to ${job.data.to}`); - await this.mailService.sendTemplateEmail(job.data.to, job.data.subject, job.data.template, job.data.context); + try { + await this.mailService.sendTemplateEmail( + job.data.to, + job.data.subject, + job.data.template, + job.data.context, + ); + } catch (error) { + this.logger.error( + `Email job #${job.id} failed permanently after ${job.attemptsMade} attempts`, + ); + + // Log permanent failure to audit log + await this.activityLogService.create({ + action: 'EMAIL_SEND_FAILED', + entityType: 'Notification', + entityId: job.id?.toString(), + metadata: { + to: job.data.to, + subject: job.data.subject, + template: job.data.template, + error: error.message, + attemptsMade: job.attemptsMade, + }, + }); + + throw error; + } } } diff --git a/backend/src/queue/processors/export.processor.ts b/backend/src/queue/processors/export.processor.ts new file mode 100644 index 000000000..1e6f2fb16 --- /dev/null +++ b/backend/src/queue/processors/export.processor.ts @@ -0,0 +1,81 @@ +import { Processor, Process } from '@nestjs/bull'; +import { Job } from 'bull'; +import { Logger } from '@nestjs/common'; +import { ExportService, ExportColumn } from '../../reporting/export.service'; +import { MailService } from '../../mail/mail.service'; + +interface ExportJobData { + email: string; + reportName: string; + format: 'pdf' | 'xlsx'; + columns: ExportColumn[]; + rows: Record[]; +} + +@Processor('export') +export class ExportProcessor { + private readonly logger = new Logger(ExportProcessor.name); + + constructor( + private readonly exportService: ExportService, + private readonly mailService: MailService, + ) {} + + @Process('generate') + async handleExport(job: Job) { + const { email, reportName, format, columns, rows } = job.data; + + this.logger.log( + `Processing export job #${job.id}: ${reportName} (${format}) for ${email}`, + ); + + try { + let buffer: Buffer; + let filename: string; + let contentType: string; + + if (format === 'pdf') { + buffer = await this.exportService.generatePdf( + reportName, + columns, + rows, + ); + const date = new Date().toISOString().split('T')[0]; + filename = `report-${date}.pdf`; + contentType = 'application/pdf'; + } else { + buffer = await this.exportService.generateExcel( + reportName, + columns, + rows, + ); + const date = new Date().toISOString().split('T')[0]; + filename = `report-${date}.xlsx`; + contentType = + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'; + } + + // Send email with attachment + await this.mailService.sendTemplateEmail( + email, + `Your ${reportName} report is ready`, + 'export-ready', + { + reportName, + format, + rowCount: rows.length, + }, + { + filename, + content: buffer, + contentType, + }, + ); + + this.logger.log(`Export job #${job.id} completed successfully`); + } catch (error) { + this.logger.error(`Export job #${job.id} failed: ${error.message}`); + throw error; + } + } +} diff --git a/backend/src/queue/queue.module.ts b/backend/src/queue/queue.module.ts index 971a417c2..6b1938b8d 100644 --- a/backend/src/queue/queue.module.ts +++ b/backend/src/queue/queue.module.ts @@ -1,8 +1,12 @@ -import { Module } from '@nestjs/common'; +import { Module, forwardRef } from '@nestjs/common'; import { BullModule } from '@nestjs/bull'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { QueueService } from './queue.service'; import { EmailProcessor } from './processors/email.processor'; +import { ExportProcessor } from './processors/export.processor'; +import { ReportingModule } from '../reporting/reporting.module'; +import { MailModule } from '../mail/mail.module'; +import { ActivityLogModule } from '../activity-log/activity-log.module'; @Module({ imports: [ @@ -16,11 +20,12 @@ import { EmailProcessor } from './processors/email.processor'; }, }), }), - BullModule.registerQueue({ - name: 'email', - }), + BullModule.registerQueue({ name: 'email' }, { name: 'export' }), + forwardRef(() => ReportingModule), + MailModule, + ActivityLogModule, ], - providers: [QueueService, EmailProcessor], + providers: [QueueService, EmailProcessor, ExportProcessor], exports: [QueueService, BullModule], }) export class QueueModule {} diff --git a/backend/src/queue/queue.service.ts b/backend/src/queue/queue.service.ts index 5ada87b21..58f20fdd8 100644 --- a/backend/src/queue/queue.service.ts +++ b/backend/src/queue/queue.service.ts @@ -1,17 +1,38 @@ import { Injectable } from '@nestjs/common'; import { InjectQueue } from '@nestjs/bull'; import { Queue } from 'bull'; +import { ExportColumn } from '../reporting/export.service'; @Injectable() export class QueueService { constructor( @InjectQueue('email') private readonly emailQueue: Queue, + @InjectQueue('export') private readonly exportQueue: Queue, ) {} - async sendEmail(data: { to: string; subject: string; template: string; context: Record }): Promise { + async sendEmail(data: { + to: string; + subject: string; + template: string; + context: Record; + }): Promise { await this.emailQueue.add('send', data, { attempts: 3, backoff: { type: 'exponential', delay: 2000 }, }); } + + async queueExport(data: { + email: string; + reportName: string; + format: 'pdf' | 'xlsx'; + columns: ExportColumn[]; + rows: Record[]; + }): Promise<{ jobId: string }> { + const job = await this.exportQueue.add('generate', data, { + attempts: 3, + backoff: { type: 'exponential', delay: 5000 }, + }); + return { jobId: job.id.toString() }; + } } diff --git a/backend/src/reporting/export.service.ts b/backend/src/reporting/export.service.ts new file mode 100644 index 000000000..d4f032096 --- /dev/null +++ b/backend/src/reporting/export.service.ts @@ -0,0 +1,211 @@ +import { Injectable, Logger } from '@nestjs/common'; +import * as PDFDocument from 'pdfkit'; +import * as ExcelJS from 'exceljs'; + +export interface ExportColumn { + header: string; + key: string; + width?: number; +} + +@Injectable() +export class ExportService { + private readonly logger = new Logger(ExportService.name); + + async generatePdf( + title: string, + columns: ExportColumn[], + rows: Record[], + ): Promise { + return new Promise((resolve, reject) => { + try { + const doc = new PDFDocument({ margin: 50 }); + const chunks: Buffer[] = []; + + doc.on('data', (chunk) => chunks.push(chunk)); + doc.on('end', () => resolve(Buffer.concat(chunks))); + doc.on('error', reject); + + const pageWidth = doc.page.width - 100; + const now = new Date().toLocaleString(); + + // Header + doc + .fillColor('#1a1a2e') + .fontSize(24) + .font('Helvetica-Bold') + .text('AssetsUp', 50, 50); + + doc + .fillColor('#666666') + .fontSize(12) + .font('Helvetica') + .text(`Report: ${title}`, 50, 80); + + doc + .fillColor('#999999') + .fontSize(10) + .text(`Generated: ${now}`, 50, 100); + + // Add a line separator + doc + .moveTo(50, 115) + .lineTo(pageWidth + 50, 115) + .strokeColor('#cccccc') + .stroke(); + + // Table headers + const startY = 130; + const colWidths = this.calculateColumnWidths(columns, pageWidth); + let x = 50; + let y = startY; + + // Header background + doc + .fillColor('#4a4e69') + .roundedRect(50, y - 15, pageWidth, 25, 3) + .fill(); + + // Header text + doc.fillColor('#ffffff').fontSize(10).font('Helvetica-Bold'); + columns.forEach((col, index) => { + doc.text(col.header, x, y - 10, { width: colWidths[index] }); + x += colWidths[index]; + }); + + // Table rows + doc.font('Helvetica').fontSize(9); + y += 20; + + rows.forEach((row, rowIndex) => { + // Check if we need a new page + if (y > doc.page.height - 100) { + doc.addPage(); + y = 50; + + // Re-add headers on new page + doc + .fillColor('#4a4e69') + .roundedRect(50, y - 15, pageWidth, 25, 3) + .fill(); + doc.fillColor('#ffffff').fontSize(10).font('Helvetica-Bold'); + x = 50; + columns.forEach((col, index) => { + doc.text(col.header, x, y - 10, { width: colWidths[index] }); + x += colWidths[index]; + }); + doc.font('Helvetica').fontSize(9); + y += 20; + } + + // Alternate row background + if (rowIndex % 2 === 0) { + doc + .fillColor('#f8f9fa') + .roundedRect(50, y - 12, pageWidth, 18, 2) + .fill(); + } + + // Row data + doc.fillColor('#333333'); + x = 50; + columns.forEach((col, index) => { + const value = row[col.key] ?? ''; + doc.text(String(value), x, y - 8, { width: colWidths[index] }); + x += colWidths[index]; + }); + + y += 18; + }); + + // Footer with page numbers + const totalPages = doc.bufferedPageRange().count; + for (let i = 0; i < totalPages; i++) { + doc.switchToPage(i); + doc + .fillColor('#999999') + .fontSize(8) + .font('Helvetica') + .text(`Page ${i + 1} of ${totalPages}`, 50, doc.page.height - 50, { + align: 'center', + }); + } + + doc.end(); + } catch (error) { + reject(error); + } + }); + } + + async generateExcel( + sheetName: string, + columns: ExportColumn[], + rows: Record[], + ): Promise { + const workbook = new ExcelJS.Workbook(); + const worksheet = workbook.addWorksheet(sheetName, { + properties: { tabColor: { argb: '4a4e69' } }, + }); + + // Add columns with headers + const excelColumns = columns.map((col, _index) => ({ + header: col.header, + key: col.key, + width: col.width || 15, + })); + + worksheet.columns = excelColumns; + + // Style header row + worksheet.getRow(1).font = { bold: true, color: { argb: 'FFFFFF' } }; + worksheet.getRow(1).fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: '4a4e69' }, + }; + worksheet.getRow(1).alignment = { vertical: 'middle' }; + + // Add data rows + rows.forEach((row) => { + worksheet.addRow(row); + }); + + // Freeze top row + worksheet.views = [{ state: 'frozen', ySplit: 1 }]; + + // Auto-adjust column widths based on content + worksheet.columns.forEach((column) => { + let maxLength = 0; + if (column.eachCell) { + column.eachCell({ includeEmpty: true }, (cell) => { + const cellValue = cell.value ? cell.value.toString() : ''; + maxLength = Math.max(maxLength, cellValue.length); + }); + } + column.width = Math.min(Math.max(maxLength + 2, 10), 50); + }); + + // Add borders to all cells + worksheet.eachRow((row) => { + row.eachCell((cell) => { + cell.border = { + top: { style: 'thin' }, + left: { style: 'thin' }, + bottom: { style: 'thin' }, + right: { style: 'thin' }, + }; + }); + }); + + return (await workbook.xlsx.writeBuffer()) as unknown as Buffer; + } + + private calculateColumnWidths( + columns: ExportColumn[], + totalWidth: number, + ): number[] { + const equalWidth = totalWidth / columns.length; + return columns.map((col) => col.width || equalWidth); + } +} diff --git a/backend/src/reporting/reporting.controller.ts b/backend/src/reporting/reporting.controller.ts index fdff6f561..01e4847d2 100644 --- a/backend/src/reporting/reporting.controller.ts +++ b/backend/src/reporting/reporting.controller.ts @@ -1,29 +1,283 @@ -import { Controller, Get, UseGuards } from '@nestjs/common'; +import { Controller, Get, Query, Res, Req, UseGuards } from '@nestjs/common'; import { AuthGuard } from '@nestjs/passport'; +import { Response, Request } from 'express'; import { ReportingService } from './reporting.service'; +import { ExportService, ExportColumn } from './export.service'; +import { QueueService } from '../queue/queue.service'; @Controller('reports') @UseGuards(AuthGuard('jwt')) export class ReportingController { - constructor(private readonly reportingService: ReportingService) {} + constructor( + private readonly reportingService: ReportingService, + private readonly exportService: ExportService, + private readonly queueService: QueueService, + ) {} + + private async handleExport( + reportName: string, + columns: ExportColumn[], + rows: Record[], + format: string | undefined, + res: Response, + req: Request, + ) { + // Default to JSON if no format specified + if (!format || format === 'json') { + return res.json(rows); + } + + // For large exports, use background job + if (rows.length > 1000) { + const user = (req as any).user; + const email = user?.email || user?.username; + + if (!email) { + return res + .status(400) + .json({ error: 'Email required for large exports' }); + } + + const job = await this.queueService.queueExport({ + email, + reportName, + format: format as 'pdf' | 'xlsx', + columns, + rows, + }); + + return res.json({ + jobId: job.jobId, + message: 'Export queued, will be emailed when ready', + }); + } + + // For small exports, generate inline + let buffer: Buffer; + let filename: string; + let contentType: string; + const date = new Date().toISOString().split('T')[0]; + + if (format === 'pdf') { + buffer = await this.exportService.generatePdf(reportName, columns, rows); + filename = `report-${date}.pdf`; + contentType = 'application/pdf'; + } else if (format === 'xlsx') { + buffer = await this.exportService.generateExcel( + reportName, + columns, + rows, + ); + filename = `report-${date}.xlsx`; + contentType = + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'; + } else { + return res.json(rows); + } + + res.set({ + 'Content-Type': contentType, + 'Content-Disposition': `attachment; filename="${filename}"`, + }); + + return res.send(buffer); + } + + @Get('summary') + async getAssetSummary( + @Query('format') format: string, + @Res() res: Response, + @Req() req: Request, + ) { + const data = await this.reportingService.getAssetSummary(); + + // Convert summary object to array format for export + const rows = [ + { metric: 'Total Assets', value: data.total }, + { metric: 'Total Value', value: data.totalValue }, + ...Object.entries(data.byStatus).map(([status, count]) => ({ + metric: `Status: ${status}`, + value: count, + })), + ...Object.entries(data.byCondition).map(([condition, count]) => ({ + metric: `Condition: ${condition}`, + value: count, + })), + ]; + + const columns: ExportColumn[] = [ + { header: 'Metric', key: 'metric', width: 40 }, + { header: 'Value', key: 'value', width: 20 }, + ]; + + return this.handleExport('Asset Summary', columns, rows, format, res, req); + } @Get('asset-summary') - async getAssetSummary() { - return this.reportingService.getAssetSummary(); + async getAssetSummaryLegacy(@Res() res: Response) { + const data = await this.reportingService.getAssetSummary(); + return res.json(data); + } + + @Get('warranty-expiring') + async getWarrantyExpiring( + @Query('format') format: string, + @Query('days') days: number, + @Res() res: Response, + @Req() req: Request, + ) { + const rows = await this.reportingService.getWarrantyExpiring(days || 90); + + const columns: ExportColumn[] = [ + { header: 'Asset ID', key: 'asset_id', width: 20 }, + { header: 'Name', key: 'asset_name', width: 30 }, + { header: 'Serial Number', key: 'asset_serialNumber', width: 25 }, + { + header: 'Warranty Expiration', + key: 'asset_warrantyExpiration', + width: 25, + }, + { header: 'Status', key: 'asset_status', width: 15 }, + ]; + + return this.handleExport( + 'Warranty Expiring', + columns, + rows, + format, + res, + req, + ); + } + + @Get('maintenance-costs') + async getMaintenanceCosts( + @Query('format') format: string, + @Res() res: Response, + @Req() req: Request, + ) { + const rows = await this.reportingService.getMaintenanceCosts(); + + const columns: ExportColumn[] = [ + { header: 'Asset ID', key: 'asset_id', width: 20 }, + { header: 'Name', key: 'asset_name', width: 30 }, + { header: 'Category', key: 'asset_categoryId', width: 20 }, + { + header: 'Total Maintenance Cost', + key: 'totalMaintenanceCost', + width: 25, + }, + { header: 'Maintenance Count', key: 'maintenanceCount', width: 20 }, + ]; + + return this.handleExport( + 'Maintenance Costs', + columns, + rows, + format, + res, + req, + ); + } + + @Get('depreciation') + async getDepreciation( + @Query('format') format: string, + @Res() res: Response, + @Req() req: Request, + ) { + const rows = await this.reportingService.getDepreciationReport(); + + const columns: ExportColumn[] = [ + { header: 'Asset ID', key: 'id', width: 20 }, + { header: 'Name', key: 'name', width: 30 }, + { header: 'Purchase Price', key: 'purchasePrice', width: 20 }, + { header: 'Current Value', key: 'currentValue', width: 20 }, + { header: 'Purchase Date', key: 'purchaseDate', width: 20 }, + { header: 'Age (Years)', key: 'ageInYears', width: 15 }, + { header: 'Total Depreciation', key: 'totalDepreciation', width: 20 }, + { header: 'Annual Depreciation', key: 'annualDepreciation', width: 20 }, + ]; + + return this.handleExport( + 'Depreciation Report', + columns, + rows, + format, + res, + req, + ); } @Get('by-department') - async getDepartmentReport() { - return this.reportingService.getDepartmentReport(); + async getDepartmentReport( + @Query('format') format: string, + @Res() res: Response, + @Req() req: Request, + ) { + const rows = await this.reportingService.getDepartmentReport(); + + const columns: ExportColumn[] = [ + { header: 'Department ID', key: 'departmentId', width: 25 }, + { header: 'Asset Count', key: 'assetCount', width: 15 }, + { header: 'Total Value', key: 'totalValue', width: 20 }, + ]; + + return this.handleExport( + 'Department Report', + columns, + rows, + format, + res, + req, + ); } @Get('by-category') - async getCategoryReport() { - return this.reportingService.getCategoryReport(); + async getCategoryReport( + @Query('format') format: string, + @Res() res: Response, + @Req() req: Request, + ) { + const rows = await this.reportingService.getCategoryReport(); + + const columns: ExportColumn[] = [ + { header: 'Category ID', key: 'categoryId', width: 25 }, + { header: 'Asset Count', key: 'assetCount', width: 15 }, + { header: 'Total Value', key: 'totalValue', width: 20 }, + ]; + + return this.handleExport( + 'Category Report', + columns, + rows, + format, + res, + req, + ); } @Get('value-over-time') - async getValueOverTime() { - return this.reportingService.getValueOverTime(); + async getValueOverTime( + @Query('format') format: string, + @Res() res: Response, + @Req() req: Request, + ) { + const rows = await this.reportingService.getValueOverTime(); + + const columns: ExportColumn[] = [ + { header: 'Date', key: 'date', width: 20 }, + { header: 'Total Value', key: 'totalValue', width: 20 }, + { header: 'Asset Count', key: 'assetCount', width: 15 }, + ]; + + return this.handleExport( + 'Value Over Time', + columns, + rows, + format, + res, + req, + ); } } diff --git a/backend/src/reporting/reporting.module.ts b/backend/src/reporting/reporting.module.ts index 7b1e0d2d5..97107abe9 100644 --- a/backend/src/reporting/reporting.module.ts +++ b/backend/src/reporting/reporting.module.ts @@ -1,13 +1,15 @@ -import { Module } from '@nestjs/common'; +import { Module, forwardRef } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { Asset } from '../assets/asset.entity'; import { ReportingService } from './reporting.service'; import { ReportingController } from './reporting.controller'; +import { ExportService } from './export.service'; +import { QueueModule } from '../queue/queue.module'; @Module({ - imports: [TypeOrmModule.forFeature([Asset])], + imports: [TypeOrmModule.forFeature([Asset]), forwardRef(() => QueueModule)], controllers: [ReportingController], - providers: [ReportingService], - exports: [ReportingService], + providers: [ReportingService, ExportService], + exports: [ReportingService, ExportService], }) export class ReportingModule {} diff --git a/backend/src/reporting/reporting.service.ts b/backend/src/reporting/reporting.service.ts index 41e21b86e..02638a2ce 100644 --- a/backend/src/reporting/reporting.service.ts +++ b/backend/src/reporting/reporting.service.ts @@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { Asset } from '../assets/asset.entity'; +import { addDays } from 'date-fns'; @Injectable() export class ReportingService { @@ -10,7 +11,12 @@ export class ReportingService { private readonly assetRepository: Repository, ) {} - async getAssetSummary(): Promise<{ total: number; byStatus: Record; byCondition: Record; totalValue: number }> { + async getAssetSummary(): Promise<{ + total: number; + byStatus: Record; + byCondition: Record; + totalValue: number; + }> { const assets = await this.assetRepository.find(); const total = assets.length; const byStatus: Record = {}; @@ -23,10 +29,17 @@ export class ReportingService { totalValue += Number(asset.purchasePrice) || 0; } - return { total, byStatus, byCondition, totalValue: Math.round(totalValue * 100) / 100 }; + return { + total, + byStatus, + byCondition, + totalValue: Math.round(totalValue * 100) / 100, + }; } - async getDepartmentReport(): Promise<{ departmentId: string; assetCount: number; totalValue: number }[]> { + async getDepartmentReport(): Promise< + { departmentId: string; assetCount: number; totalValue: number }[] + > { return this.assetRepository .createQueryBuilder('asset') .select('asset.departmentId', 'departmentId') @@ -36,7 +49,9 @@ export class ReportingService { .getRawMany(); } - async getCategoryReport(): Promise<{ categoryId: string; assetCount: number; totalValue: number }[]> { + async getCategoryReport(): Promise< + { categoryId: string; assetCount: number; totalValue: number }[] + > { return this.assetRepository .createQueryBuilder('asset') .select('asset.categoryId', 'categoryId') @@ -46,7 +61,9 @@ export class ReportingService { .getRawMany(); } - async getValueOverTime(): Promise<{ date: string; totalValue: number; assetCount: number }[]> { + async getValueOverTime(): Promise< + { date: string; totalValue: number; assetCount: number }[] + > { return this.assetRepository .createQueryBuilder('asset') .select("DATE_TRUNC('month', asset.createdAt)", 'date') @@ -56,4 +73,60 @@ export class ReportingService { .orderBy("DATE_TRUNC('month', asset.createdAt)", 'ASC') .getRawMany(); } + + async getWarrantyExpiring(daysAhead: number = 90): Promise { + const cutoffDate = addDays(new Date(), daysAhead); + return this.assetRepository + .createQueryBuilder('asset') + .where('asset.warrantyExpiration IS NOT NULL') + .andWhere('asset.warrantyExpiration <= :cutoffDate', { cutoffDate }) + .andWhere('asset.warrantyExpiration >= NOW()') + .select([ + 'asset.id', + 'asset.name', + 'asset.serialNumber', + 'asset.warrantyExpiration', + 'asset.status', + ]) + .orderBy('asset.warrantyExpiration', 'ASC') + .getRawMany(); + } + + async getMaintenanceCosts(): Promise { + // This would typically join with a maintenance_orders table + // For now, returning placeholder data structure + return this.assetRepository + .createQueryBuilder('asset') + .select(['asset.id', 'asset.name', 'asset.categoryId']) + .addSelect('0', 'totalMaintenanceCost') + .addSelect('0', 'maintenanceCount') + .getRawMany(); + } + + async getDepreciationReport(): Promise { + const assets = await this.assetRepository.find(); + return assets.map((asset) => { + const purchasePrice = Number(asset.purchasePrice) || 0; + const currentValue = Number(asset.currentValue) || purchasePrice; + const purchaseDate = asset.purchaseDate || asset.createdAt; + const ageInYears = + (Date.now() - new Date(purchaseDate).getTime()) / + (1000 * 60 * 60 * 24 * 365); + + // Simple straight-line depreciation calculation + const depreciation = purchasePrice - currentValue; + const annualDepreciation = ageInYears > 0 ? depreciation / ageInYears : 0; + + return { + id: asset.id, + name: asset.name, + purchasePrice, + currentValue, + purchaseDate, + ageInYears: Math.round(ageInYears * 100) / 100, + totalDepreciation: Math.round(depreciation * 100) / 100, + annualDepreciation: Math.round(annualDepreciation * 100) / 100, + }; + }); + } } diff --git a/backend/src/storage/storage.controller.ts b/backend/src/storage/storage.controller.ts index 6dc14b9ca..3af28d7b5 100644 --- a/backend/src/storage/storage.controller.ts +++ b/backend/src/storage/storage.controller.ts @@ -1,5 +1,11 @@ -import { Controller, Post, Delete, Param, UseInterceptors, UploadedFile } from '@nestjs/common'; -import { Controller, Post, Delete, Param, UseInterceptors, UploadedFile, Body } from '@nestjs/common'; +import { + Controller, + Post, + Delete, + Param, + UseInterceptors, + UploadedFile, +} from '@nestjs/common'; import { FileInterceptor } from '@nestjs/platform-express'; import { StorageService } from './storage.service'; diff --git a/backend/src/storage/storage.service.ts b/backend/src/storage/storage.service.ts index 501a54122..a3cb82cec 100644 --- a/backend/src/storage/storage.service.ts +++ b/backend/src/storage/storage.service.ts @@ -1,6 +1,11 @@ import { Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import { S3Client, PutObjectCommand, DeleteObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3'; +import { + S3Client, + PutObjectCommand, + DeleteObjectCommand, + GetObjectCommand, +} from '@aws-sdk/client-s3'; import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; @Injectable() @@ -17,7 +22,10 @@ export class StorageService { secretAccessKey: configService.get('AWS_SECRET_ACCESS_KEY', ''), }, }); - this.bucket = configService.get('AWS_S3_BUCKET', 'assetsup-uploads'); + this.bucket = configService.get( + 'AWS_S3_BUCKET', + 'assetsup-uploads', + ); } async upload(file: Express.Multer.File, key?: string): Promise { @@ -44,7 +52,7 @@ export class StorageService { async getSignedUrl(key: string, expiresIn = 3600): Promise { return getSignedUrl( - this.s3, + this.s3 as any, new GetObjectCommand({ Bucket: this.bucket, Key: key }), { expiresIn }, ); diff --git a/backend/src/tasks/tasks.module.ts b/backend/src/tasks/tasks.module.ts index bdcd43e92..c33c00c42 100644 --- a/backend/src/tasks/tasks.module.ts +++ b/backend/src/tasks/tasks.module.ts @@ -3,14 +3,17 @@ import { ScheduleModule } from '@nestjs/schedule'; import { TypeOrmModule } from '@nestjs/typeorm'; import { Asset } from '../assets/entities/asset.entity'; import { Department } from '../users/entities/department.entity'; +import { User } from '../users/entities/user.entity'; import { MailModule } from '../mail/mail.module'; +import { NotificationModule } from '../notifications/notification.module'; import { TasksService } from './tasks.service'; @Module({ imports: [ ScheduleModule.forRoot(), - TypeOrmModule.forFeature([Asset, Department]), + TypeOrmModule.forFeature([Asset, Department, User]), MailModule, + NotificationModule, ], providers: [TasksService], }) diff --git a/backend/src/tasks/tasks.service.ts b/backend/src/tasks/tasks.service.ts index cf10754f8..bfd3eec8e 100644 --- a/backend/src/tasks/tasks.service.ts +++ b/backend/src/tasks/tasks.service.ts @@ -4,7 +4,13 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { Asset } from '../assets/entities/asset.entity'; import { Department } from '../users/entities/department.entity'; +import { User } from '../users/entities/user.entity'; import { MailService } from '../mail/mail.service'; +import { + NotificationDispatchService, + DispatchNotificationDto, +} from '../notifications/notification-dispatch.service'; +import { NotificationEvent } from '../notifications/enums/notification-event.enum'; @Injectable() export class TasksService { @@ -15,21 +21,30 @@ export class TasksService { private readonly assetRepository: Repository, @InjectRepository(Department) private readonly departmentRepository: Repository, + @InjectRepository(User) + private readonly userRepository: Repository, private readonly mailService: MailService, + private readonly notificationDispatchService: NotificationDispatchService, ) {} @Cron(CronExpression.EVERY_WEEKDAY) async sendDepartmentAssetSummaries() { this.logger.log('Starting daily department asset summary task'); - const departments = await this.departmentRepository.find({ relations: ['children'] }); + const departments = await this.departmentRepository.find({ + relations: ['children'], + }); for (const dept of departments) { const [assets, total] = await this.assetRepository.findAndCount({ where: { departmentId: dept.id }, }); - const active = assets.filter(a => a.status === 'ACTIVE').length; - const assigned = assets.filter(a => a.status === 'ASSIGNED').length; - const maintenance = assets.filter(a => a.status === 'MAINTENANCE').length; - this.logger.log(`Department ${dept.name}: ${total} assets (${active} active, ${assigned} assigned, ${maintenance} maintenance)`); + const active = assets.filter((a) => a.status === 'ACTIVE').length; + const assigned = assets.filter((a) => a.status === 'ASSIGNED').length; + const maintenance = assets.filter( + (a) => a.status === 'MAINTENANCE', + ).length; + this.logger.log( + `Department ${dept.name}: ${total} assets (${active} active, ${assigned} assigned, ${maintenance} maintenance)`, + ); } this.logger.log('Daily department asset summary task completed'); } @@ -49,4 +64,121 @@ export class TasksService { this.logger.log(` ${row.status}: ${row.count}`); } } + + @Cron('0 8 * * *') + async checkMaintenanceDue() { + this.logger.log('Checking for maintenance due assets'); + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + + const assets = await this.assetRepository + .createQueryBuilder('asset') + .leftJoinAndSelect('asset.maintenanceRecords', 'maintenance') + .where('maintenance.scheduledDate <= :tomorrow', { tomorrow }) + .andWhere('maintenance.status = :status', { status: 'SCHEDULED' }) + .getMany(); + + for (const asset of assets) { + const users = await this.userRepository.find({ + where: [ + { departmentId: asset.departmentId }, + { id: asset.createdById }, + ], + }); + + for (const user of users) { + const notificationDto: DispatchNotificationDto = { + userId: user.id, + event: NotificationEvent.MAINTENANCE_DUE, + title: 'Maintenance Due', + message: `Asset ${asset.name} has maintenance due soon.`, + entityType: 'Asset', + entityId: asset.id, + metadata: { + assetName: asset.name, + assetId: asset.assetId, + }, + emailTemplate: 'maintenance-due', + emailSubject: `Maintenance Due: ${asset.name}`, + emailContext: { + assetName: asset.name, + assetId: asset.assetId, + dueDate: tomorrow.toISOString().split('T')[0], + description: 'Scheduled maintenance', + assetLink: `${process.env.FRONTEND_URL}/assets/${asset.id}`, + }, + }; + await this.notificationDispatchService.dispatch(notificationDto); + } + } + this.logger.log( + `Maintenance check completed, notified for ${assets.length} assets`, + ); + } + + @Cron('0 9 * * *') + async checkWarrantyExpiring() { + this.logger.log('Checking for warranty expiring assets'); + const thirtyDaysFromNow = new Date(); + thirtyDaysFromNow.setDate(thirtyDaysFromNow.getDate() + 30); + + const assets = await this.assetRepository + .createQueryBuilder('asset') + .where('asset.warrantyExpiration <= :thirtyDaysFromNow', { + thirtyDaysFromNow, + }) + .andWhere('asset.warrantyExpiration >= :today', { today: new Date() }) + .andWhere('asset.endOfLifeNotificationSent = :notSent', { + notSent: false, + }) + .getMany(); + + for (const asset of assets) { + const users = await this.userRepository.find({ + where: [ + { departmentId: asset.departmentId }, + { id: asset.createdById }, + ], + }); + + for (const user of users) { + const daysRemaining = Math.floor( + (new Date(asset.warrantyExpiration).getTime() - Date.now()) / + (1000 * 60 * 60 * 24), + ); + + const notificationDto: DispatchNotificationDto = { + userId: user.id, + event: NotificationEvent.WARRANTY_EXPIRING, + title: 'Warranty Expiring', + message: `Asset ${asset.name} warranty expires in ${daysRemaining} days.`, + entityType: 'Asset', + entityId: asset.id, + metadata: { + assetName: asset.name, + assetId: asset.assetId, + warrantyEndDate: asset.warrantyExpiration, + daysRemaining, + }, + emailTemplate: 'warranty-expiry', + emailSubject: `Warranty Expiring: ${asset.name}`, + emailContext: { + assetName: asset.name, + assetId: asset.assetId, + expiryDate: asset.warrantyExpiration, + daysRemaining, + assetLink: `${process.env.FRONTEND_URL}/assets/${asset.id}`, + }, + }; + await this.notificationDispatchService.dispatch(notificationDto); + } + + // Mark notification as sent to avoid duplicates + asset.endOfLifeNotificationSent = true; + await this.assetRepository.save(asset); + } + this.logger.log( + `Warranty check completed, notified for ${assets.length} assets`, + ); + } } diff --git a/backend/src/users/decorators/roles.decorator.ts b/backend/src/users/decorators/roles.decorator.ts index b8af15ba0..ec335687a 100644 --- a/backend/src/users/decorators/roles.decorator.ts +++ b/backend/src/users/decorators/roles.decorator.ts @@ -1,4 +1,5 @@ import { SetMetadata } from '@nestjs/common'; export const ROLES_KEY = 'permissions'; -export const RequirePermissions = (...permissions: string[]) => SetMetadata(ROLES_KEY, permissions); +export const RequirePermissions = (...permissions: string[]) => + SetMetadata(ROLES_KEY, permissions); diff --git a/backend/src/users/departments.controller.ts b/backend/src/users/departments.controller.ts index 7446b747e..d7ab0ce77 100644 --- a/backend/src/users/departments.controller.ts +++ b/backend/src/users/departments.controller.ts @@ -1,4 +1,13 @@ -import { Controller, Get, Post, Put, Delete, Param, Body, UseGuards } from '@nestjs/common'; +import { + Controller, + Get, + Post, + Put, + Delete, + Param, + Body, + UseGuards, +} from '@nestjs/common'; import { AuthGuard } from '@nestjs/passport'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; diff --git a/backend/src/users/dtos/create-user.dto.ts b/backend/src/users/dtos/create-user.dto.ts index 0fd2417ea..9c9d07fe7 100644 --- a/backend/src/users/dtos/create-user.dto.ts +++ b/backend/src/users/dtos/create-user.dto.ts @@ -8,6 +8,10 @@ export class CreateUserDto { @IsString() password?: string; + @IsOptional() + @IsString() + passwordHash?: string; + @IsOptional() @IsString() firstName?: string; @@ -27,4 +31,16 @@ export class CreateUserDto { @IsOptional() @IsBoolean() isActive?: boolean; + + @IsOptional() + @IsString() + googleId?: string; + + @IsOptional() + @IsString() + microsoftId?: string; + + @IsOptional() + @IsString() + avatarUrl?: string; } diff --git a/backend/src/users/dtos/update-user.dto.ts b/backend/src/users/dtos/update-user.dto.ts index e2af173cd..38b4cdef9 100644 --- a/backend/src/users/dtos/update-user.dto.ts +++ b/backend/src/users/dtos/update-user.dto.ts @@ -20,4 +20,20 @@ export class UpdateUserDto { @IsOptional() @IsBoolean() isActive?: boolean; + + @IsOptional() + @IsString() + passwordHash?: string; + + @IsOptional() + @IsString() + googleId?: string; + + @IsOptional() + @IsString() + microsoftId?: string; + + @IsOptional() + @IsString() + avatarUrl?: string; } diff --git a/backend/src/users/entities/department.entity.ts b/backend/src/users/entities/department.entity.ts index 38d4c864c..a5afe8609 100644 --- a/backend/src/users/entities/department.entity.ts +++ b/backend/src/users/entities/department.entity.ts @@ -1,4 +1,13 @@ -import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, Tree, TreeParent, TreeChildren } from 'typeorm'; +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Tree, + TreeParent, + TreeChildren, +} from 'typeorm'; @Entity('departments') @Tree('closure-table') diff --git a/backend/src/users/entities/role.entity.ts b/backend/src/users/entities/role.entity.ts index 23b27b2e5..af7db9324 100644 --- a/backend/src/users/entities/role.entity.ts +++ b/backend/src/users/entities/role.entity.ts @@ -1,4 +1,10 @@ -import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm'; +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, +} from 'typeorm'; @Entity('roles') export class Role { diff --git a/backend/src/users/entities/user.entity.ts b/backend/src/users/entities/user.entity.ts index d27f04ab9..cb4bafda4 100644 --- a/backend/src/users/entities/user.entity.ts +++ b/backend/src/users/entities/user.entity.ts @@ -1,4 +1,12 @@ -import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, ManyToOne, JoinColumn } from 'typeorm'; +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; import { Role } from './role.entity'; import { Department } from './department.entity'; diff --git a/backend/src/users/guards/roles.guard.ts b/backend/src/users/guards/roles.guard.ts index a02c5d31f..80307228f 100644 --- a/backend/src/users/guards/roles.guard.ts +++ b/backend/src/users/guards/roles.guard.ts @@ -7,10 +7,10 @@ export class RolesGuard implements CanActivate { constructor(private readonly reflector: Reflector) {} canActivate(context: ExecutionContext): boolean { - const requiredPermissions = this.reflector.getAllAndOverride(ROLES_KEY, [ - context.getHandler(), - context.getClass(), - ]); + const requiredPermissions = this.reflector.getAllAndOverride( + ROLES_KEY, + [context.getHandler(), context.getClass()], + ); if (!requiredPermissions || requiredPermissions.length === 0) { return true; } diff --git a/backend/src/users/roles.controller.ts b/backend/src/users/roles.controller.ts index 2811f052a..cae444f8c 100644 --- a/backend/src/users/roles.controller.ts +++ b/backend/src/users/roles.controller.ts @@ -1,4 +1,13 @@ -import { Controller, Get, Post, Put, Delete, Param, Body, UseGuards } from '@nestjs/common'; +import { + Controller, + Get, + Post, + Put, + Delete, + Param, + Body, + UseGuards, +} from '@nestjs/common'; import { AuthGuard } from '@nestjs/passport'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; diff --git a/backend/src/users/users.controller.ts b/backend/src/users/users.controller.ts index 61f813457..979a45848 100644 --- a/backend/src/users/users.controller.ts +++ b/backend/src/users/users.controller.ts @@ -1,4 +1,17 @@ -import { Controller, Get, Post, Put, Delete, Param, Body, UseGuards, Req, UseInterceptors, UploadedFile, Query } from '@nestjs/common'; +import { + Controller, + Get, + Post, + Put, + Delete, + Param, + Body, + UseGuards, + Req, + UseInterceptors, + UploadedFile, + Query, +} from '@nestjs/common'; import { AuthGuard } from '@nestjs/passport'; import { FileInterceptor } from '@nestjs/platform-express'; import { UsersService } from './users.service'; @@ -54,7 +67,10 @@ export class UsersController { @Post('avatar') @UseInterceptors(FileInterceptor('file')) - async uploadAvatar(@Req() req: any, @UploadedFile() file: Express.Multer.File) { + async uploadAvatar( + @Req() req: any, + @UploadedFile() file: Express.Multer.File, + ) { const key = `avatars/${req.user.id}-${Date.now()}-${file.originalname}`; await this.storageService.upload(file, key); const avatarUrl = await this.storageService.getSignedUrl(key); diff --git a/backend/src/users/users.module.ts b/backend/src/users/users.module.ts index efba29bff..49e8a59fb 100644 --- a/backend/src/users/users.module.ts +++ b/backend/src/users/users.module.ts @@ -10,10 +10,7 @@ import { DepartmentsController } from './departments.controller'; import { StorageModule } from '../storage/storage.module'; @Module({ - imports: [ - TypeOrmModule.forFeature([User, Role, Department]), - StorageModule, - ], + imports: [TypeOrmModule.forFeature([User, Role, Department]), StorageModule], controllers: [UsersController, RolesController, DepartmentsController], providers: [UsersService], exports: [UsersService], diff --git a/backend/src/users/users.service.ts b/backend/src/users/users.service.ts index c737c6cd5..189c66293 100644 --- a/backend/src/users/users.service.ts +++ b/backend/src/users/users.service.ts @@ -12,17 +12,28 @@ export class UsersService { private readonly userRepository: Repository, ) {} - async findAll(query: { page?: number; limit?: number; roleId?: string; departmentId?: string; isActive?: boolean } = {}): Promise<{ data: User[]; total: number }> { + async findAll( + query: { + page?: number; + limit?: number; + roleId?: string; + departmentId?: string; + isActive?: boolean; + } = {}, + ): Promise<{ data: User[]; total: number }> { const { page = 1, limit = 20, roleId, departmentId, isActive } = query; - const qb = this.userRepository.createQueryBuilder('user') + const qb = this.userRepository + .createQueryBuilder('user') .leftJoinAndSelect('user.role', 'role') .leftJoinAndSelect('user.department', 'department') .skip((page - 1) * limit) .take(limit); if (roleId) qb.andWhere('user.roleId = :roleId', { roleId }); - if (departmentId) qb.andWhere('user.departmentId = :departmentId', { departmentId }); - if (isActive !== undefined) qb.andWhere('user.isActive = :isActive', { isActive }); + if (departmentId) + qb.andWhere('user.departmentId = :departmentId', { departmentId }); + if (isActive !== undefined) + qb.andWhere('user.isActive = :isActive', { isActive }); const [data, total] = await qb.getManyAndCount(); return { data, total }; @@ -42,18 +53,6 @@ export class UsersService { }); } - async findById(id: string): Promise { - return this.userRepository.findOne({ where: { id } }); - } - - async findById(id: string): Promise { - return this.userRepository.findOne({ where: { id } }); - } - - async findById(id: string): Promise { - return this.userRepository.findOne({ where: { id } }); - } - async findByGoogleId(googleId: string): Promise { return this.userRepository.findOne({ where: { googleId } }); } diff --git a/backend/src/vendors/entities/vendor.entity.ts b/backend/src/vendors/entities/vendor.entity.ts index 196dabd91..b07946f08 100644 --- a/backend/src/vendors/entities/vendor.entity.ts +++ b/backend/src/vendors/entities/vendor.entity.ts @@ -1,4 +1,10 @@ -import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm'; +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, +} from 'typeorm'; @Entity('vendors') export class Vendor { diff --git a/backend/src/vendors/vendors.controller.ts b/backend/src/vendors/vendors.controller.ts index 5a19abe94..721f9c674 100644 --- a/backend/src/vendors/vendors.controller.ts +++ b/backend/src/vendors/vendors.controller.ts @@ -1,4 +1,14 @@ -import { Controller, Get, Post, Put, Delete, Param, Body, Query, UseGuards } from '@nestjs/common'; +import { + Controller, + Get, + Post, + Put, + Delete, + Param, + Body, + Query, + UseGuards, +} from '@nestjs/common'; import { AuthGuard } from '@nestjs/passport'; import { VendorsService } from './vendors.service'; import { CreateVendorDto } from './dtos/create-vendor.dto'; @@ -15,7 +25,15 @@ export class VendorsController { } @Get() - async findAll(@Query() query: { page?: number; limit?: number; isActive?: boolean; search?: string }) { + async findAll( + @Query() + query: { + page?: number; + limit?: number; + isActive?: boolean; + search?: string; + }, + ) { return this.vendorsService.findAll(query); } diff --git a/backend/src/vendors/vendors.service.ts b/backend/src/vendors/vendors.service.ts index 625e688c7..582988b0d 100644 --- a/backend/src/vendors/vendors.service.ts +++ b/backend/src/vendors/vendors.service.ts @@ -17,14 +17,23 @@ export class VendorsService { return this.vendorRepository.save(vendor); } - async findAll(query: { page?: number; limit?: number; isActive?: boolean; search?: string } = {}): Promise<{ data: Vendor[]; total: number }> { + async findAll( + query: { + page?: number; + limit?: number; + isActive?: boolean; + search?: string; + } = {}, + ): Promise<{ data: Vendor[]; total: number }> { const { page = 1, limit = 20, isActive, search } = query; - const qb = this.vendorRepository.createQueryBuilder('vendor') + const qb = this.vendorRepository + .createQueryBuilder('vendor') .skip((page - 1) * limit) .take(limit) .orderBy('vendor.createdAt', 'DESC'); - if (isActive !== undefined) qb.andWhere('vendor.isActive = :isActive', { isActive }); + if (isActive !== undefined) + qb.andWhere('vendor.isActive = :isActive', { isActive }); if (search) { qb.andWhere( '(vendor.name ILIKE :search OR vendor.email ILIKE :search OR vendor.contactPerson ILIKE :search)', diff --git a/backend/test/asset-crud.e2e-spec.ts b/backend/test/asset-crud.e2e-spec.ts index b0bbe3f2e..8a351210f 100644 --- a/backend/test/asset-crud.e2e-spec.ts +++ b/backend/test/asset-crud.e2e-spec.ts @@ -36,4 +36,4 @@ describe('Asset CRUD (e2e)', () => { .send({ token: 'bad-token', newPassword: 'newpass123' }) .expect(400); }); -}); \ No newline at end of file +}); diff --git a/backend/test/contracts.e2e-spec.ts b/backend/test/contracts.e2e-spec.ts index 3612ca1aa..a3bed33ca 100644 --- a/backend/test/contracts.e2e-spec.ts +++ b/backend/test/contracts.e2e-spec.ts @@ -20,9 +20,7 @@ describe('Contracts (e2e)', () => { }); it('GET /api/contracts returns 401 without auth', () => { - return request(app.getHttpServer()) - .get('/api/contracts') - .expect(401); + return request(app.getHttpServer()).get('/api/contracts').expect(401); }); it('POST /api/contracts returns 401 without auth', () => { diff --git a/backend/test/licenses.e2e-spec.ts b/backend/test/licenses.e2e-spec.ts index 493fc0c91..f64fe198f 100644 --- a/backend/test/licenses.e2e-spec.ts +++ b/backend/test/licenses.e2e-spec.ts @@ -20,9 +20,7 @@ describe('Licenses (e2e)', () => { }); it('GET /api/licenses returns 401 without auth', () => { - return request(app.getHttpServer()) - .get('/api/licenses') - .expect(401); + return request(app.getHttpServer()).get('/api/licenses').expect(401); }); it('POST /api/licenses returns 401 without auth', () => { diff --git a/backend/test/locations.e2e-spec.ts b/backend/test/locations.e2e-spec.ts index ed8185e5c..89e24c184 100644 --- a/backend/test/locations.e2e-spec.ts +++ b/backend/test/locations.e2e-spec.ts @@ -34,7 +34,7 @@ describe('Locations (e2e)', () => { .set('Authorization', `Bearer ${authToken}`) .send({ name: 'Main Office', city: 'New York' }) .expect(201) - .then(res => { + .then((res) => { locationId = res.body.data?.id || res.body.id; }); }); diff --git a/backend/test/purchase-orders.e2e-spec.ts b/backend/test/purchase-orders.e2e-spec.ts index 17e39f5cc..2a004478f 100644 --- a/backend/test/purchase-orders.e2e-spec.ts +++ b/backend/test/purchase-orders.e2e-spec.ts @@ -20,9 +20,7 @@ describe('PurchaseOrders (e2e)', () => { }); it('GET /api/purchase-orders returns 401 without auth', () => { - return request(app.getHttpServer()) - .get('/api/purchase-orders') - .expect(401); + return request(app.getHttpServer()).get('/api/purchase-orders').expect(401); }); it('POST /api/purchase-orders returns 401 without auth', () => { diff --git a/frontend/app/(dashboard)/asset-notes/page.tsx b/frontend/app/(dashboard)/asset-notes/page.tsx index efadc244d..5d74e10bb 100644 --- a/frontend/app/(dashboard)/asset-notes/page.tsx +++ b/frontend/app/(dashboard)/asset-notes/page.tsx @@ -1,40 +1,58 @@ -'use client'; +"use client"; -import { useState, useRef } from 'react'; -import { Send } from 'lucide-react'; -import { Button } from '@/components/ui/button'; +import { useState, useRef } from "react"; +import { Send } from "lucide-react"; +import { Button } from "@/components/ui/button"; -const USERS = ['alice.m', 'bob.k', 'carol.s', 'dave.t', 'emma.w']; +const USERS = ["alice.m", "bob.k", "carol.s", "dave.t", "emma.w"]; -interface Note { id: string; author: string; text: string; timestamp: string } +interface Note { + id: string; + author: string; + text: string; + timestamp: string; +} const MOCK_NOTES: Note[] = [ - { id:'1', author:'Alice M.', text:'Sent for repair. @bob.k please follow up with vendor.', timestamp:'2024-01-20T10:00:00Z' }, - { id:'2', author:'Bob K.', text:'Will check @carol.s for parts availability.', timestamp:'2024-01-20T10:30:00Z' }, + { + id: "1", + author: "Alice M.", + text: "Sent for repair. @bob.k please follow up with vendor.", + timestamp: "2024-01-20T10:00:00Z", + }, + { + id: "2", + author: "Bob K.", + text: "Will check @carol.s for parts availability.", + timestamp: "2024-01-20T10:30:00Z", + }, ]; export default function AssetNotesPage() { const [notes, setNotes] = useState(MOCK_NOTES); - const [text, setText] = useState(''); + const [text, setText] = useState(""); const [suggestions, setSuggestions] = useState([]); const [mentionStart, setMentionStart] = useState(-1); const inputRef = useRef(null); const handleChange = (val: string) => { setText(val); - const atIdx = val.lastIndexOf('@'); - if (atIdx >= 0 && atIdx === val.length - 1 - (val.slice(atIdx + 1).length) + atIdx) { + const atIdx = val.lastIndexOf("@"); + if ( + atIdx >= 0 && + atIdx === val.length - 1 - val.slice(atIdx + 1).length + atIdx + ) { const fragment = val.slice(atIdx + 1); - const matches = USERS.filter(u => u.startsWith(fragment)); + const matches = USERS.filter((u) => u.startsWith(fragment)); setSuggestions(matches); setMentionStart(atIdx); - } else if (val.slice(val.lastIndexOf('@') + 1).includes(' ')) { + } else if (val.slice(val.lastIndexOf("@") + 1).includes(" ")) { setSuggestions([]); } }; const insertMention = (user: string) => { - const newText = text.slice(0, mentionStart) + @ ; + const newText = text.slice(0, mentionStart) + `@${user} `; setText(newText); setSuggestions([]); inputRef.current?.focus(); @@ -42,36 +60,82 @@ export default function AssetNotesPage() { const addNote = () => { if (!text.trim()) return; - setNotes(prev => [...prev, { id: Date.now().toString(), author:'You', text, timestamp: new Date().toISOString() }]); - setText(''); + setNotes((prev) => [ + ...prev, + { + id: Date.now().toString(), + author: "You", + text, + timestamp: new Date().toISOString(), + }, + ]); + setText(""); }; - const renderText = (t: string) => t.replace(/@(\w+\.\w+)/g, '@'); + const renderText = (t: string) => + t.replace( + /@(\w+\.\w+)/g, + '@', + ); return (
-

Asset Notes

@mention support with user autocomplete

+
+

Asset Notes

+

+ @mention support with user autocomplete +

+
- {notes.map(n => ( -
+ {notes.map((n) => ( +
- {n.author} - {new Date(n.timestamp).toLocaleString()} + + {n.author} + + + {new Date(n.timestamp).toLocaleString()} +
-

+

))}
-