diff --git a/backend/.eslintrc.js b/backend/.eslintrc.js index 259de13c..b071dc62 100644 --- a/backend/.eslintrc.js +++ b/backend/.eslintrc.js @@ -21,5 +21,10 @@ 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: '^_', + varsIgnorePattern: '^_', + ignoreRestSiblings: true, + }], }, }; diff --git a/backend/package-lock.json b/backend/package-lock.json index 57f56a07..58901f3a 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", @@ -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" @@ -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 99806045..ce0d9ee4 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", diff --git a/backend/src/activity-log/activity-log.controller.ts b/backend/src/activity-log/activity-log.controller.ts index 35ecd1e1..a01f1b8d 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 39f673bf..73216d2e 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 828d6a7f..8cbcff4c 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 3cbb2e37..74d13d71 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -28,6 +28,9 @@ 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 { NotificationsModule } from './notifications/notifications.module'; +import { WebhooksModule } from './webhooks/webhooks.module'; +import { StellarModule } from './stellar/stellar.module'; @Module({ imports: [ @@ -54,7 +57,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 +70,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); }, @@ -72,6 +80,20 @@ import { TasksModule } from './tasks/tasks.module'; }, }), + ThrottlerModule.forRoot([{ ttl: 60000, limit: 100 }]), + + I18nModule.forRoot({ + fallbackLanguage: 'en', + loaderOptions: { + path: path.join(__dirname, '/i18n/'), + watch: true, + }, + resolvers: [ + { use: QueryResolver, options: ['lang'] }, + AcceptLanguageResolver, + ], + }), + AssetsExtendedModule, AssetsOpsModule, CheckinModule, @@ -85,14 +107,14 @@ import { TasksModule } from './tasks/tasks.module'; LicensesModule, PurchaseOrdersModule, TasksModule, - ], LocationsModule, - ], ActivityLogModule, - ], InventoryModule, VendorsModule, DashboardModule, + NotificationsModule, + WebhooksModule, + StellarModule, ], controllers: [AppController], providers: [ @@ -103,8 +125,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 6eedaf00..90d217da 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 83779b55..f91211b2 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') @@ -119,4 +127,16 @@ export class Asset { @UpdateDateColumn() updatedAt: Date; + + @Column({ nullable: true, type: 'date' }) + disposalDate: string; + + @Column({ nullable: true }) + disposalMethod: string; + + @Column({ nullable: true, type: 'text' }) + disposalReason: string; + + @Column({ nullable: true }) + disposalApprovedById: string; } diff --git a/backend/src/assets/assets-extended.controller.ts b/backend/src/assets/assets-extended.controller.ts index e0ca0a47..26ed9d33 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 7487886b..9024628e 100644 --- a/backend/src/assets/assets-extended.module.ts +++ b/backend/src/assets/assets-extended.module.ts @@ -9,7 +9,14 @@ import { AssetsExtendedController } from './assets-extended.controller'; import { AssetAuditController } from './asset-audit.controller'; @Module({ - imports: [TypeOrmModule.forFeature([Asset, AssetHistory, AssetDocument, MaintenanceRecord])], + imports: [ + TypeOrmModule.forFeature([ + Asset, + AssetHistory, + AssetDocument, + MaintenanceRecord, + ]), + ], 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 4b5851b2..999a3c6e 100644 --- a/backend/src/assets/assets-extended.service.ts +++ b/backend/src/assets/assets-extended.service.ts @@ -23,8 +23,15 @@ export class AssetsExtendedService { private readonly maintenanceRepository: Repository, ) {} - 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 = { @@ -53,21 +60,34 @@ export class AssetsExtendedService { } 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 +122,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 +166,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 e729c9f8..2c887e45 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 a7537a58..7f585ded 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 510c6f96..070f27e6 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 aebbf68e..87d624c1 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 408fb37b..b85462d9 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 dba05012..b09a4606 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 d376e9f5..ae3bca47 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 561e2d34..9d165b87 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 bed40bfd..7a7cdf84 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 4b2b53a6..6fcb10b8 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 3efa91c1..b30ab050 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 4688f575..3c9d334a 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 1b552393..4bf2ed17 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 1653ce4e..e1b4b2d1 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 d13af6e8..00f4c0b4 100644 --- a/backend/src/auth/auth.module.ts +++ b/backend/src/auth/auth.module.ts @@ -1,4 +1,4 @@ -import { Module } from '@nestjs/common'; +import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { PassportModule } from '@nestjs/passport'; import { JwtModule } from '@nestjs/jwt'; @@ -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 7abadd6f..45650cf9 100644 --- a/backend/src/auth/auth.service.spec.ts +++ b/backend/src/auth/auth.service.spec.ts @@ -7,9 +7,15 @@ import { getRepositoryToken } from '@nestjs/typeorm'; import { PasswordResetToken } from './entities/password-reset-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'), +}; describe('AuthService', () => { let service: AuthService; @@ -35,7 +41,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 +55,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 +81,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 +93,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 98efb5b7..c0d8beb1 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,8 @@ export class AuthService { } async refresh(refreshToken: string) { - const tokenHash = await bcrypt.hash(refreshToken, 10); + const _tokenHash = await bcrypt.hash(refreshToken, 10); + void _tokenHash; const tokens = await this.refreshTokenRepository.find({ where: { revokedAt: null }, relations: ['user'], @@ -113,7 +118,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 +134,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 +170,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 ae8586c3..8a40224c 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 663f81d0..befe62e8 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 e268e8e4..46402521 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 270d07fd..d9f691b0 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 3a963969..1341f498 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 5a7dee64..ac3f6c9e 100644 --- a/backend/src/auth/providers/two-factor.provider.ts +++ b/backend/src/auth/providers/two-factor.provider.ts @@ -1,14 +1,15 @@ +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 14783485..740525e7 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 d0201f4d..02fda8f6 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 c28ebec6..a592a756 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 c1dccf2d..5328f7bf 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'; @@ -12,16 +12,12 @@ export class CategoriesController { @UseInterceptors(CacheInterceptor) @CacheKey('categories_list_all') async getAllCategories() { - // Database retrieval execution logic goes here return [{ id: 1, name: 'DeFi Vaults' }]; } @Post() - 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 + async createCategory(@Body() _body: any) { 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 fa882ba4..0f792997 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 1356a0bb..72beed98 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.service.ts b/backend/src/checkin/checkin.service.ts index 12ff5cc6..0c5c5c7a 100644 --- a/backend/src/checkin/checkin.service.ts +++ b/backend/src/checkin/checkin.service.ts @@ -1,4 +1,8 @@ -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'; @@ -16,7 +20,9 @@ export class CheckinService { ) {} 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({ diff --git a/backend/src/common/api-response.ts b/backend/src/common/api-response.ts index 17f3749c..f06269be 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 81974662..1b28efbd 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 3225aaa6..be7ea9cb 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 9d10c0ac..9719442a 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,20 @@ 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 +40,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 a3840cb1..a91bb06f 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 0f3a3ba9..e7da1f0c 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 7c02314a..a6593ca4 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'; @@ -16,13 +29,13 @@ export class ContractsController { ) {} @Post() - async create(@Body() dto: CreateContractDto, @Req() req: any) { - return this.contractsService.create(dto, req.user?.id); + async create(@Body() dto: CreateContractDto, @Req() _req: any) { + return this.contractsService.create(dto, _req.user?.id); } @Post('upload') @UseInterceptors(FileInterceptor('file')) - async upload(@UploadedFile() file: Express.Multer.File, @Req() req: any) { + async upload(@UploadedFile() file: Express.Multer.File, @Req() _req: any) { const key = `contracts/${Date.now()}-${file.originalname}`; await this.storageService.upload(file, key); const url = await this.storageService.getSignedUrl(key); @@ -40,8 +53,12 @@ export class ContractsController { } @Put(':id') - async update(@Param('id') id: string, @Body() dto: UpdateContractDto, @Req() req: any) { - return this.contractsService.update(id, dto, req.user?.id); + async update( + @Param('id') id: string, + @Body() dto: UpdateContractDto, + @Req() _req: any, + ) { + return this.contractsService.update(id, dto, _req.user?.id); } @Delete(':id') diff --git a/backend/src/contracts/contracts.service.ts b/backend/src/contracts/contracts.service.ts index 190d868c..30d23749 100644 --- a/backend/src/contracts/contracts.service.ts +++ b/backend/src/contracts/contracts.service.ts @@ -1,4 +1,4 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { Injectable, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { ConfigService } from '@nestjs/config'; @@ -16,23 +16,29 @@ 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 { + async create(dto: CreateContractDto, _userId?: string): Promise { const prefix = this.configService.get('CONTRACT_ID_PREFIX', 'CTR'); const contractId = `${prefix}-${this.nextNumber++}`; const contract = this.contractRepository.create({ ...dto, contractId, - createdById: userId, + createdById: _userId, }); 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 4b6818c0..7dc3a37c 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 94ff5cb7..335358a4 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 0e976077..fc6574f0 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 34dda1e0..ba1bbbce 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 4dbac748..112d711e 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 8cd5a6d7..21212a45 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 4ed86fef..af09b68c 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 f354fe1c..777db389 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 0dacb5e6..0f837ad2 100644 --- a/backend/src/licenses/licenses.service.ts +++ b/backend/src/licenses/licenses.service.ts @@ -1,4 +1,4 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { Injectable, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { License } from './entities/license.entity'; @@ -13,19 +13,22 @@ export class LicensesService { private readonly licenseRepository: Repository, ) {} - async create(dto: CreateLicenseDto, userId?: string): Promise { + async create(dto: CreateLicenseDto, _userId?: string): Promise { const licenseKey = `LIC-${Date.now()}-${Math.random().toString(36).substring(2, 8).toUpperCase()}`; const license = this.licenseRepository.create({ ...dto, licenseKey, - createdById: userId, + createdById: _userId, }); 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 5494dc03..e237cce1 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 cbec5cb2..a14d202a 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 7c2a001c..6a7f592e 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 cb47a35a..5ce4ec9f 100644 --- a/backend/src/mail/mail.service.ts +++ b/backend/src/mail/mail.service.ts @@ -1,4 +1,4 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import * as nodemailer from 'nodemailer'; @@ -20,18 +20,30 @@ 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, + ) { const html = this.renderTemplate(template, context); try { await this.transporter.sendMail({ - from: this.configService.get('MAIL_FROM', 'noreply@assetsup.local'), + from: this.configService.get( + 'MAIL_FROM', + 'noreply@assetsup.local', + ), to, subject, html, @@ -42,7 +54,10 @@ export class MailService { } } - 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 +65,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.

@@ -72,7 +87,7 @@ export class MailService { const render = templates[template]; if (!render) { this.logger.warn(`Unknown email template: ${template}`); - return `

${subject}

`; + return `

Unknown template: ${template}

`; } return render(context); } diff --git a/backend/src/migrations/1700000000000-InitialSchema.ts b/backend/src/migrations/1700000000000-InitialSchema.ts index 0c13206e..4871f07a 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/notification-preference.entity.ts b/backend/src/notifications/notification-preference.entity.ts new file mode 100644 index 00000000..bea38331 --- /dev/null +++ b/backend/src/notifications/notification-preference.entity.ts @@ -0,0 +1,31 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, +} from 'typeorm'; + +@Entity('notification_preferences') +export class NotificationPreference { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + userId: string; + + @Column() + channel: string; + + @Column({ default: true }) + enabled: boolean; + + @Column('simple-array', { nullable: true }) + events: string[]; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/backend/src/notifications/notification-preference.service.ts b/backend/src/notifications/notification-preference.service.ts new file mode 100644 index 00000000..ca4ec073 --- /dev/null +++ b/backend/src/notifications/notification-preference.service.ts @@ -0,0 +1,46 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { NotificationPreference } from './notification-preference.entity'; + +@Injectable() +export class NotificationPreferenceService { + constructor( + @InjectRepository(NotificationPreference) + private readonly repo: Repository, + ) {} + + async findByUser(userId: string): Promise { + return this.repo.find({ where: { userId } }); + } + + async upsert( + userId: string, + channel: string, + enabled: boolean, + events?: string[], + ): Promise { + let pref = await this.repo.findOne({ where: { userId, channel } }); + if (!pref) { + pref = this.repo.create({ userId, channel }); + } + pref.enabled = enabled; + if (events !== undefined) pref.events = events; + return this.repo.save(pref); + } + + async upsertAll( + userId: string, + preferences: { channel: string; enabled: boolean; events?: string[] }[], + ): Promise { + return Promise.all( + preferences.map((p) => + this.upsert(userId, p.channel, p.enabled, p.events), + ), + ); + } + + async reset(userId: string): Promise { + await this.repo.delete({ userId }); + } +} diff --git a/backend/src/notifications/notifications.controller.ts b/backend/src/notifications/notifications.controller.ts new file mode 100644 index 00000000..f0e65f14 --- /dev/null +++ b/backend/src/notifications/notifications.controller.ts @@ -0,0 +1,38 @@ +import { + Controller, + Get, + Put, + Post, + Body, + Req, + UseGuards, +} from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { NotificationPreferenceService } from './notification-preference.service'; + +@Controller('users/me/notification-preferences') +@UseGuards(AuthGuard('jwt')) +export class NotificationsController { + constructor(private readonly prefService: NotificationPreferenceService) {} + + @Get() + findAll(@Req() req: any) { + return this.prefService.findByUser(req.user?.id); + } + + @Put() + upsertAll( + @Req() req: any, + @Body() + body: { + preferences: { channel: string; enabled: boolean; events?: string[] }[]; + }, + ) { + return this.prefService.upsertAll(req.user?.id, body.preferences); + } + + @Post('reset') + reset(@Req() req: any) { + return this.prefService.reset(req.user?.id); + } +} diff --git a/backend/src/notifications/notifications.module.ts b/backend/src/notifications/notifications.module.ts new file mode 100644 index 00000000..3bdbfc09 --- /dev/null +++ b/backend/src/notifications/notifications.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { NotificationPreference } from './notification-preference.entity'; +import { NotificationPreferenceService } from './notification-preference.service'; +import { NotificationsController } from './notifications.controller'; + +@Module({ + imports: [TypeOrmModule.forFeature([NotificationPreference])], + controllers: [NotificationsController], + providers: [NotificationPreferenceService], + exports: [NotificationPreferenceService], +}) +export class NotificationsModule {} 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 407ffd0b..3fa75711 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 d0c366a8..ba6a0c8d 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 40e04b48..5350b962 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 c9bccd81..d67c4dcd 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 1c986295..95f36664 100644 --- a/backend/src/purchase-orders/purchase-orders.service.ts +++ b/backend/src/purchase-orders/purchase-orders.service.ts @@ -1,4 +1,4 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { Injectable, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { ConfigService } from '@nestjs/config'; @@ -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({ @@ -35,14 +43,17 @@ export class PurchaseOrdersService { poNumber, subtotal, total, - createdById: userId, + createdById: _userId, }); 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 77796c93..06dcbcb9 100644 --- a/backend/src/queue/processors/email.processor.ts +++ b/backend/src/queue/processors/email.processor.ts @@ -10,8 +10,20 @@ export class EmailProcessor { constructor(private readonly mailService: MailService) {} @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); + await this.mailService.sendTemplateEmail( + job.data.to, + job.data.subject, + job.data.template, + job.data.context, + ); } } diff --git a/backend/src/queue/queue.service.ts b/backend/src/queue/queue.service.ts index 5ada87b2..50aeb205 100644 --- a/backend/src/queue/queue.service.ts +++ b/backend/src/queue/queue.service.ts @@ -4,11 +4,14 @@ import { Queue } from 'bull'; @Injectable() export class QueueService { - constructor( - @InjectQueue('email') private readonly emailQueue: Queue, - ) {} + constructor(@InjectQueue('email') private readonly emailQueue: 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 }, diff --git a/backend/src/reporting/reporting.service.ts b/backend/src/reporting/reporting.service.ts index 41e21b86..b41bd06a 100644 --- a/backend/src/reporting/reporting.service.ts +++ b/backend/src/reporting/reporting.service.ts @@ -10,7 +10,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 +28,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 +48,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 +60,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') diff --git a/backend/src/stellar/stellar-dividends.controller.ts b/backend/src/stellar/stellar-dividends.controller.ts new file mode 100644 index 00000000..c7dd325a --- /dev/null +++ b/backend/src/stellar/stellar-dividends.controller.ts @@ -0,0 +1,57 @@ +import { + Controller, + Get, + Post, + Body, + Param, + UseGuards, + Req, +} from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { StellarService } from './stellar.service'; + +@Controller('stellar') +@UseGuards(AuthGuard('jwt')) +export class StellarDividendsController { + constructor(private readonly stellarService: StellarService) {} + + @Post('assets/:id/dividends/distribute') + distributeDividends( + @Param('id') id: string, + @Body() body: { amount: number; recipients: string[] }, + ) { + return this.stellarService.distributeDividends( + id, + body.amount, + body.recipients, + ); + } + + @Post('assets/:id/voting/proposals') + createProposal( + @Param('id') id: string, + @Body() body: { title: string; description: string; options: string[] }, + ) { + return this.stellarService.createVotingProposal( + id, + body.title, + body.description, + body.options, + ); + } + + @Post('assets/:id/voting/proposals/:proposalId/vote') + castVote( + @Param('id') id: string, + @Param('proposalId') proposalId: string, + @Body() body: { vote: string }, + @Req() _req: any, + ) { + return this.stellarService.castVote(id, proposalId, body.vote); + } + + @Get('assets/:id/voting/proposals/:proposalId/results') + getResults(@Param('id') id: string, @Param('proposalId') proposalId: string) { + return this.stellarService.getVotingResults(id, proposalId); + } +} diff --git a/backend/src/stellar/stellar-kyc.controller.ts b/backend/src/stellar/stellar-kyc.controller.ts new file mode 100644 index 00000000..ded5b22f --- /dev/null +++ b/backend/src/stellar/stellar-kyc.controller.ts @@ -0,0 +1,43 @@ +import { + Controller, + Get, + Post, + Body, + Param, + UseGuards, + Req, +} from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { StellarService } from './stellar.service'; + +@Controller('stellar') +@UseGuards(AuthGuard('jwt')) +export class StellarKycController { + constructor(private readonly stellarService: StellarService) {} + + @Post('assets/:id/lease') + createLease( + @Param('id') id: string, + @Body() body: { lessee: string; terms: Record }, + ) { + return this.stellarService.createLease('', id, body.lessee, body.terms); + } + + @Get('assets/:id/insurance') + getInsurance(@Param('id') id: string) { + return this.stellarService.getInsurancePolicy('', id); + } + + @Post('kyc/submit') + submitKyc( + @Req() req: any, + @Body() body: { documents: Record }, + ) { + return this.stellarService.submitKyc(req.user?.id, body.documents); + } + + @Get('kyc/status') + getKycStatus(@Req() req: any) { + return this.stellarService.getKycStatus(req.user?.id); + } +} diff --git a/backend/src/stellar/stellar.module.ts b/backend/src/stellar/stellar.module.ts new file mode 100644 index 00000000..4ba797b6 --- /dev/null +++ b/backend/src/stellar/stellar.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { StellarService } from './stellar.service'; +import { StellarDividendsController } from './stellar-dividends.controller'; +import { StellarKycController } from './stellar-kyc.controller'; + +@Module({ + imports: [ConfigModule], + controllers: [StellarDividendsController, StellarKycController], + providers: [StellarService], + exports: [StellarService], +}) +export class StellarModule {} diff --git a/backend/src/stellar/stellar.service.ts b/backend/src/stellar/stellar.service.ts new file mode 100644 index 00000000..22a85dcb --- /dev/null +++ b/backend/src/stellar/stellar.service.ts @@ -0,0 +1,82 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Keypair, Networks } from '@stellar/stellar-sdk'; + +@Injectable() +export class StellarService { + private readonly networkPassphrase: string; + + constructor(private readonly config: ConfigService) { + this.networkPassphrase = config.get('STELLAR_NETWORK', Networks.TESTNET); + } + + getNetworkPassphrase(): string { + return this.networkPassphrase; + } + + generateKeypair(): { publicKey: string; secretKey: string } { + const pair = Keypair.random(); + return { publicKey: pair.publicKey(), secretKey: pair.secret() }; + } + + async distributeDividends( + _contractId: string, + _amount: number, + _recipients: string[], + ): Promise<{ txHash: string }> { + return { txHash: '' }; + } + + async createVotingProposal( + _contractId: string, + _title: string, + _description: string, + _options: string[], + ): Promise<{ proposalId: string }> { + return { proposalId: '' }; + } + + async castVote( + _contractId: string, + _proposalId: string, + _vote: string, + ): Promise<{ txHash: string }> { + return { txHash: '' }; + } + + async getVotingResults( + _contractId: string, + _proposalId: string, + ): Promise<{ results: Record }> { + return { results: {} }; + } + + async createLease( + _contractId: string, + _assetId: string, + _lessee: string, + _terms: Record, + ): Promise<{ leaseId: string; txHash: string }> { + return { leaseId: '', txHash: '' }; + } + + async getInsurancePolicy( + _contractId: string, + _assetId: string, + ): Promise<{ policyId: string; status: string }> { + return { policyId: '', status: 'none' }; + } + + async submitKyc( + _userId: string, + _documents: Record, + ): Promise<{ kycId: string; status: string }> { + return { kycId: '', status: 'pending' }; + } + + async getKycStatus( + _userId: string, + ): Promise<{ status: string; verifiedAt?: Date }> { + return { status: 'pending' }; + } +} diff --git a/backend/src/storage/storage.controller.ts b/backend/src/storage/storage.controller.ts index 6dc14b9c..4480f537 100644 --- a/backend/src/storage/storage.controller.ts +++ b/backend/src/storage/storage.controller.ts @@ -1,5 +1,12 @@ -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, + Body, +} from '@nestjs/common'; import { FileInterceptor } from '@nestjs/platform-express'; import { StorageService } from './storage.service'; @@ -9,7 +16,10 @@ export class StorageController { @Post('upload') @UseInterceptors(FileInterceptor('file')) - async uploadFile(@UploadedFile() file: Express.Multer.File) { + async uploadFile( + @UploadedFile() file: Express.Multer.File, + @Body() _body: any, + ) { const key = await this.storageService.upload(file); return { key, url: await this.storageService.getSignedUrl(key) }; } diff --git a/backend/src/storage/storage.service.ts b/backend/src/storage/storage.service.ts index 501a5412..2230400f 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 { 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.service.ts b/backend/src/tasks/tasks.service.ts index cf10754f..3851d21a 100644 --- a/backend/src/tasks/tasks.service.ts +++ b/backend/src/tasks/tasks.service.ts @@ -21,15 +21,21 @@ export class TasksService { @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'); } diff --git a/backend/src/users/decorators/roles.decorator.ts b/backend/src/users/decorators/roles.decorator.ts index b8af15ba..ec335687 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 7446b747..d7ab0ce7 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 0fd2417e..4c6ad3ad 100644 --- a/backend/src/users/dtos/create-user.dto.ts +++ b/backend/src/users/dtos/create-user.dto.ts @@ -1,6 +1,17 @@ -import { IsEmail, IsOptional, IsString, IsBoolean } from 'class-validator'; +import { IsEmail, IsOptional, IsString, IsBoolean } from 'class-validator'; export class CreateUserDto { + @IsOptional() + @IsString() + passwordHash?: string; + + @IsOptional() + @IsString() + googleId?: string; + + @IsOptional() + @IsString() + microsoftId?: string; @IsEmail() email: string; diff --git a/backend/src/users/dtos/update-user.dto.ts b/backend/src/users/dtos/update-user.dto.ts index e2af173c..82bb61fb 100644 --- a/backend/src/users/dtos/update-user.dto.ts +++ b/backend/src/users/dtos/update-user.dto.ts @@ -1,6 +1,21 @@ -import { IsOptional, IsString, IsBoolean } from 'class-validator'; +import { IsOptional, IsString, IsBoolean } from 'class-validator'; export class UpdateUserDto { + @IsOptional() + @IsString() + passwordHash?: string; + + @IsOptional() + @IsString() + avatarUrl?: string; + + @IsOptional() + @IsString() + googleId?: string; + + @IsOptional() + @IsString() + microsoftId?: string; @IsOptional() @IsString() firstName?: string; diff --git a/backend/src/users/entities/department.entity.ts b/backend/src/users/entities/department.entity.ts index 38d4c864..a5afe860 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 23b27b2e..af7db932 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 d27f04ab..cb4bafda 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 a02c5d31..80307228 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 2811f052..cae444f8 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 61f81345..979a4584 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 efba29bf..49e8a59f 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 c737c6cd..429242a2 100644 --- a/backend/src/users/users.service.ts +++ b/backend/src/users/users.service.ts @@ -1,4 +1,4 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { Injectable, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { User } from './entities/user.entity'; @@ -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 196dabd9..b07946f0 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 5a19abe9..721f9c67 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 625e688c..582988b0 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/src/webhooks/webhook.entity.ts b/backend/src/webhooks/webhook.entity.ts new file mode 100644 index 00000000..55cff8f0 --- /dev/null +++ b/backend/src/webhooks/webhook.entity.ts @@ -0,0 +1,40 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, +} from 'typeorm'; + +@Entity('webhooks') +export class Webhook { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + userId: string; + + @Column() + name: string; + + @Column() + url: string; + + @Column({ nullable: true }) + secretHash: string; + + @Column('simple-array', { default: '' }) + events: string[]; + + @Column({ default: true }) + isActive: boolean; + + @Column({ nullable: true, type: 'timestamp' }) + lastTriggeredAt: Date; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/backend/src/webhooks/webhooks.controller.ts b/backend/src/webhooks/webhooks.controller.ts new file mode 100644 index 00000000..55b76cde --- /dev/null +++ b/backend/src/webhooks/webhooks.controller.ts @@ -0,0 +1,48 @@ +import { + Controller, + Get, + Post, + Delete, + Param, + Body, + Req, + UseGuards, +} from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { WebhooksService } from './webhooks.service'; + +@Controller('webhooks') +@UseGuards(AuthGuard('jwt')) +export class WebhooksController { + constructor(private readonly webhooksService: WebhooksService) {} + + @Post() + create( + @Req() req: any, + @Body() + body: { name: string; url: string; events: string[]; secret?: string }, + ) { + return this.webhooksService.create( + req.user?.id, + body.name, + body.url, + body.events, + body.secret, + ); + } + + @Get() + findAll(@Req() req: any) { + return this.webhooksService.findByUser(req.user?.id); + } + + @Delete(':id') + remove(@Param('id') id: string, @Req() req: any) { + return this.webhooksService.remove(id, req.user?.id); + } + + @Post(':id/test') + test(@Param('id') id: string, @Req() req: any) { + return this.webhooksService.test(id, req.user?.id); + } +} diff --git a/backend/src/webhooks/webhooks.module.ts b/backend/src/webhooks/webhooks.module.ts new file mode 100644 index 00000000..24f84174 --- /dev/null +++ b/backend/src/webhooks/webhooks.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Webhook } from './webhook.entity'; +import { WebhooksService } from './webhooks.service'; +import { WebhooksController } from './webhooks.controller'; + +@Module({ + imports: [TypeOrmModule.forFeature([Webhook])], + controllers: [WebhooksController], + providers: [WebhooksService], + exports: [WebhooksService], +}) +export class WebhooksModule {} diff --git a/backend/src/webhooks/webhooks.service.ts b/backend/src/webhooks/webhooks.service.ts new file mode 100644 index 00000000..c5961d67 --- /dev/null +++ b/backend/src/webhooks/webhooks.service.ts @@ -0,0 +1,59 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import * as bcrypt from 'bcrypt'; +import { Webhook } from './webhook.entity'; + +@Injectable() +export class WebhooksService { + constructor( + @InjectRepository(Webhook) + private readonly repo: Repository, + ) {} + + async create( + userId: string, + name: string, + url: string, + events: string[], + secret?: string, + ): Promise { + const secretHash = secret ? await bcrypt.hash(secret, 10) : undefined; + const webhook = this.repo.create({ userId, name, url, events, secretHash }); + return this.repo.save(webhook); + } + + async findByUser(userId: string): Promise { + return this.repo.find({ where: { userId, isActive: true } }); + } + + async remove(id: string, userId: string): Promise { + const webhook = await this.repo.findOne({ where: { id, userId } }); + if (!webhook) throw new NotFoundException('Webhook not found'); + webhook.isActive = false; + await this.repo.save(webhook); + } + + async test( + id: string, + userId: string, + ): Promise<{ success: boolean; status?: number }> { + const webhook = await this.repo.findOne({ where: { id, userId } }); + if (!webhook) throw new NotFoundException('Webhook not found'); + try { + const res = await fetch(webhook.url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + event: 'test', + timestamp: new Date().toISOString(), + }), + }); + webhook.lastTriggeredAt = new Date(); + await this.repo.save(webhook); + return { success: res.ok, status: res.status }; + } catch { + return { success: false }; + } + } +} diff --git a/backend/test/asset-crud.e2e-spec.ts b/backend/test/asset-crud.e2e-spec.ts index b0bbe3f2..8a351210 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 3612ca1a..a3bed33c 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 493fc0c9..f64fe198 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 ed8185e5..89e24c18 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 17e39f5c..2a004478 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', () => {