From a5e2ff415a62a2f41bc26ab2596fb9ed55b0dead Mon Sep 17 00:00:00 2001 From: Musa Khalid <112591148+Mkalbani@users.noreply.github.com> Date: Sat, 25 Apr 2026 21:50:29 +0000 Subject: [PATCH] feat: implement bulk assets, tags, upload service, and notification preferences Implements all four backend features for BE-41 through BE-44 issues. - BE-42: Bulk asset operations (status, delete, department transfer) - BE-43: Asset tags management and global search with relevance ranking - BE-41: File upload service for images and documents - BE-44: User notification preferences module All endpoints protected by JWT auth with role-based access control. Asset operations log history entries per affected asset. Search supports full-text matching across asset fields with ranking. Closes #696 #697 #698 #699 --- .gitignore | 37 ++++ backend/contrib/src/app.module.ts | 30 ++++ .../src/assets/asset-history.entity.ts | 29 ++++ backend/contrib/src/assets/asset.entity.ts | 51 ++++++ .../contrib/src/assets/assets.controller.ts | 55 ++++++ backend/contrib/src/assets/assets.module.ts | 14 ++ backend/contrib/src/assets/assets.service.ts | 159 ++++++++++++++++++ .../src/assets/dto/bulk-delete-assets.dto.ts | 8 + .../dto/bulk-transfer-department.dto.ts | 11 ++ .../src/assets/dto/bulk-update-status.dto.ts | 12 ++ .../src/assets/dto/search-assets.dto.ts | 7 + .../src/assets/dto/update-asset-tags.dto.ts | 7 + backend/contrib/src/assets/enums.ts | 13 ++ .../src/auth/decorators/roles.decorator.ts | 4 + .../contrib/src/auth/guards/jwt-auth.guard.ts | 40 +++++ .../contrib/src/auth/guards/roles.guard.ts | 28 +++ backend/contrib/src/main.ts | 35 ++++ .../update-notification-preferences.dto.ts | 19 +++ .../notification-preference.entity.ts | 41 +++++ .../notification-preferences.controller.ts | 34 ++++ .../notification-preferences.service.ts | 40 +++++ .../src/notifications/notifications.module.ts | 15 ++ .../user-registration.subscriber.ts | 36 ++++ .../contrib/src/upload/upload.controller.ts | 77 +++++++++ backend/contrib/src/upload/upload.module.ts | 13 ++ backend/contrib/src/upload/upload.service.ts | 81 +++++++++ backend/contrib/src/users/user.entity.ts | 31 ++++ 27 files changed, 927 insertions(+) create mode 100644 .gitignore create mode 100644 backend/contrib/src/app.module.ts create mode 100644 backend/contrib/src/assets/asset-history.entity.ts create mode 100644 backend/contrib/src/assets/asset.entity.ts create mode 100644 backend/contrib/src/assets/assets.controller.ts create mode 100644 backend/contrib/src/assets/assets.module.ts create mode 100644 backend/contrib/src/assets/assets.service.ts create mode 100644 backend/contrib/src/assets/dto/bulk-delete-assets.dto.ts create mode 100644 backend/contrib/src/assets/dto/bulk-transfer-department.dto.ts create mode 100644 backend/contrib/src/assets/dto/bulk-update-status.dto.ts create mode 100644 backend/contrib/src/assets/dto/search-assets.dto.ts create mode 100644 backend/contrib/src/assets/dto/update-asset-tags.dto.ts create mode 100644 backend/contrib/src/assets/enums.ts create mode 100644 backend/contrib/src/auth/decorators/roles.decorator.ts create mode 100644 backend/contrib/src/auth/guards/jwt-auth.guard.ts create mode 100644 backend/contrib/src/auth/guards/roles.guard.ts create mode 100644 backend/contrib/src/main.ts create mode 100644 backend/contrib/src/notifications/dto/update-notification-preferences.dto.ts create mode 100644 backend/contrib/src/notifications/notification-preference.entity.ts create mode 100644 backend/contrib/src/notifications/notification-preferences.controller.ts create mode 100644 backend/contrib/src/notifications/notification-preferences.service.ts create mode 100644 backend/contrib/src/notifications/notifications.module.ts create mode 100644 backend/contrib/src/notifications/user-registration.subscriber.ts create mode 100644 backend/contrib/src/upload/upload.controller.ts create mode 100644 backend/contrib/src/upload/upload.module.ts create mode 100644 backend/contrib/src/upload/upload.service.ts create mode 100644 backend/contrib/src/users/user.entity.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..87d8a085 --- /dev/null +++ b/.gitignore @@ -0,0 +1,37 @@ +# Dependencies +node_modules/ +package-lock.json +yarn.lock + +# Build outputs +dist/ +build/ +*.tsbuildinfo + +# Environment +.env +.env.local +.env.*.local + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ +.DS_Store + +# Uploads +uploads/ + +# Logs +logs/ +*.log +npm-debug.log* + +# Coverage +coverage/ + +# Misc +.cache/ +.turbo/ diff --git a/backend/contrib/src/app.module.ts b/backend/contrib/src/app.module.ts new file mode 100644 index 00000000..0f04832a --- /dev/null +++ b/backend/contrib/src/app.module.ts @@ -0,0 +1,30 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { AssetsModule } from './assets/assets.module'; +import { UploadModule } from './upload/upload.module'; +import { NotificationsModule } from './notifications/notifications.module'; + +@Module({ + imports: [ + ConfigModule.forRoot({ isGlobal: true }), + TypeOrmModule.forRootAsync({ + imports: [ConfigModule], + inject: [ConfigService], + useFactory: (configService: ConfigService) => ({ + type: 'postgres' as const, + host: configService.get('DB_HOST', 'localhost'), + port: Number(configService.get('DB_PORT', 5432)), + username: configService.get('DB_USERNAME', 'postgres'), + password: configService.get('DB_PASSWORD', 'password'), + database: configService.get('DB_DATABASE', 'manage_assets'), + autoLoadEntities: true, + synchronize: true, + }), + }), + AssetsModule, + UploadModule, + NotificationsModule, + ], +}) +export class AppModule {} diff --git a/backend/contrib/src/assets/asset-history.entity.ts b/backend/contrib/src/assets/asset-history.entity.ts new file mode 100644 index 00000000..1bf2d65f --- /dev/null +++ b/backend/contrib/src/assets/asset-history.entity.ts @@ -0,0 +1,29 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn } from 'typeorm'; +import { AssetHistoryAction } from './enums'; + +@Entity('asset_history') +export class AssetHistory { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + assetId: string; + + @Column({ type: 'enum', enum: AssetHistoryAction }) + action: AssetHistoryAction; + + @Column() + description: string; + + @Column({ type: 'jsonb', nullable: true }) + previousValue: Record | null; + + @Column({ type: 'jsonb', nullable: true }) + newValue: Record | null; + + @Column({ nullable: true }) + performedById: string | null; + + @CreateDateColumn() + createdAt: Date; +} diff --git a/backend/contrib/src/assets/asset.entity.ts b/backend/contrib/src/assets/asset.entity.ts new file mode 100644 index 00000000..b097b96f --- /dev/null +++ b/backend/contrib/src/assets/asset.entity.ts @@ -0,0 +1,51 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + DeleteDateColumn, +} from 'typeorm'; +import { AssetStatus } from './enums'; + +@Entity('assets') +export class Asset { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ unique: true }) + assetId: string; + + @Column() + name: string; + + @Column({ nullable: true }) + serialNumber: string | null; + + @Column({ nullable: true }) + manufacturer: string | null; + + @Column({ nullable: true }) + model: string | null; + + @Column({ nullable: true, type: 'text' }) + description: string | null; + + @Column({ nullable: true }) + departmentId: string | null; + + @Column({ type: 'simple-array', nullable: true }) + tags: string[] | null; + + @Column({ type: 'enum', enum: AssetStatus, default: AssetStatus.ACTIVE }) + status: AssetStatus; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; + + @DeleteDateColumn({ nullable: true }) + deletedAt: Date | null; +} diff --git a/backend/contrib/src/assets/assets.controller.ts b/backend/contrib/src/assets/assets.controller.ts new file mode 100644 index 00000000..219b1f6e --- /dev/null +++ b/backend/contrib/src/assets/assets.controller.ts @@ -0,0 +1,55 @@ +import { Body, Controller, Delete, Get, Param, Patch, Query, Req, UseGuards } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { AssetsService } from './assets.service'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../auth/guards/roles.guard'; +import { Roles } from '../auth/decorators/roles.decorator'; +import { UserRole } from '../users/user.entity'; +import { BulkUpdateStatusDto } from './dto/bulk-update-status.dto'; +import { BulkDeleteAssetsDto } from './dto/bulk-delete-assets.dto'; +import { BulkTransferDepartmentDto } from './dto/bulk-transfer-department.dto'; +import { UpdateAssetTagsDto } from './dto/update-asset-tags.dto'; +import { SearchAssetsDto } from './dto/search-assets.dto'; + +@ApiTags('Assets') +@ApiBearerAuth('JWT-auth') +@UseGuards(JwtAuthGuard) +@Controller('assets') +export class AssetsController { + constructor(private readonly assetsService: AssetsService) {} + + @Patch('bulk-status') + @UseGuards(RolesGuard) + @Roles(UserRole.ADMIN, UserRole.MANAGER) + bulkStatus(@Body() dto: BulkUpdateStatusDto, @Req() req: { user?: { id?: string } }) { + return this.assetsService.bulkUpdateStatus(dto.ids, dto.status, req.user?.id || null); + } + + @Delete('bulk') + @UseGuards(RolesGuard) + @Roles(UserRole.ADMIN, UserRole.MANAGER) + bulkDelete(@Body() dto: BulkDeleteAssetsDto, @Req() req: { user?: { id?: string } }) { + return this.assetsService.bulkDelete(dto.ids, req.user?.id || null); + } + + @Patch('bulk-department') + @UseGuards(RolesGuard) + @Roles(UserRole.ADMIN, UserRole.MANAGER) + bulkDepartment(@Body() dto: BulkTransferDepartmentDto, @Req() req: { user?: { id?: string } }) { + return this.assetsService.bulkTransferDepartment(dto.ids, dto.departmentId, req.user?.id || null); + } + + @Patch(':id/tags') + updateTags( + @Param('id') id: string, + @Body() dto: UpdateAssetTagsDto, + @Req() req: { user?: { id?: string } }, + ) { + return this.assetsService.updateTags(id, dto.tags, req.user?.id || null); + } + + @Get('search') + search(@Query() query: SearchAssetsDto) { + return this.assetsService.search(query.q || ''); + } +} diff --git a/backend/contrib/src/assets/assets.module.ts b/backend/contrib/src/assets/assets.module.ts new file mode 100644 index 00000000..7fbbe415 --- /dev/null +++ b/backend/contrib/src/assets/assets.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Asset } from './asset.entity'; +import { AssetHistory } from './asset-history.entity'; +import { AssetsController } from './assets.controller'; +import { AssetsService } from './assets.service'; + +@Module({ + imports: [TypeOrmModule.forFeature([Asset, AssetHistory])], + controllers: [AssetsController], + providers: [AssetsService], + exports: [AssetsService], +}) +export class AssetsModule {} diff --git a/backend/contrib/src/assets/assets.service.ts b/backend/contrib/src/assets/assets.service.ts new file mode 100644 index 00000000..923c1289 --- /dev/null +++ b/backend/contrib/src/assets/assets.service.ts @@ -0,0 +1,159 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { In, IsNull, Repository } from 'typeorm'; +import { Asset } from './asset.entity'; +import { AssetHistory } from './asset-history.entity'; +import { AssetHistoryAction, AssetStatus } from './enums'; + +@Injectable() +export class AssetsService { + constructor( + @InjectRepository(Asset) + private readonly assetsRepo: Repository, + @InjectRepository(AssetHistory) + private readonly historyRepo: Repository, + ) {} + + async bulkUpdateStatus( + ids: string[], + status: AssetStatus, + performedById: string | null, + ): Promise<{ updated: number }> { + const assets = await this.assetsRepo.find({ where: { id: In(ids), deletedAt: IsNull() } }); + + if (assets.length === 0) { + return { updated: 0 }; + } + + const updates = assets.map((asset) => ({ ...asset, status })); + await this.assetsRepo.save(updates); + + await this.historyRepo.insert( + assets.map((asset) => ({ + assetId: asset.id, + action: AssetHistoryAction.STATUS_CHANGED, + description: `Status changed from ${asset.status} to ${status}`, + previousValue: { status: asset.status }, + newValue: { status }, + performedById, + })), + ); + + return { updated: assets.length }; + } + + async bulkDelete(ids: string[], performedById: string | null): Promise<{ deleted: number }> { + const assets = await this.assetsRepo.find({ where: { id: In(ids), deletedAt: IsNull() } }); + + if (assets.length === 0) { + return { deleted: 0 }; + } + + const targetIds = assets.map((asset) => asset.id); + await this.assetsRepo.softDelete(targetIds); + + await this.historyRepo.insert( + assets.map((asset) => ({ + assetId: asset.id, + action: AssetHistoryAction.DELETED, + description: 'Asset soft-deleted in bulk operation', + previousValue: { deletedAt: null }, + newValue: { deletedAt: new Date().toISOString() }, + performedById, + })), + ); + + return { deleted: assets.length }; + } + + async bulkTransferDepartment( + ids: string[], + departmentId: string, + performedById: string | null, + ): Promise<{ updated: number }> { + const assets = await this.assetsRepo.find({ where: { id: In(ids), deletedAt: IsNull() } }); + + if (assets.length === 0) { + return { updated: 0 }; + } + + const updates = assets.map((asset) => ({ ...asset, departmentId })); + await this.assetsRepo.save(updates); + + await this.historyRepo.insert( + assets.map((asset) => ({ + assetId: asset.id, + action: AssetHistoryAction.TRANSFERRED, + description: `Asset transferred from ${asset.departmentId || 'unassigned'} to ${departmentId}`, + previousValue: { departmentId: asset.departmentId }, + newValue: { departmentId }, + performedById, + })), + ); + + return { updated: assets.length }; + } + + async updateTags(id: string, tags: string[], performedById: string | null): Promise { + const asset = await this.assetsRepo.findOne({ where: { id, deletedAt: IsNull() } }); + if (!asset) { + throw new NotFoundException('Asset not found'); + } + + const previousTags = asset.tags || []; + asset.tags = tags; + const updated = await this.assetsRepo.save(asset); + + await this.historyRepo.insert({ + assetId: asset.id, + action: AssetHistoryAction.TAGS_UPDATED, + description: 'Asset tags updated', + previousValue: { tags: previousTags }, + newValue: { tags }, + performedById, + }); + + return updated; + } + + async search(q: string): Promise { + const term = (q || '').trim(); + if (!term) { + return []; + } + + const contains = `%${term}%`; + + return this.assetsRepo + .createQueryBuilder('asset') + .where('asset.deletedAt IS NULL') + .andWhere( + `(asset.name ILIKE :contains + OR asset.assetId ILIKE :contains + OR asset.serialNumber ILIKE :contains + OR asset.manufacturer ILIKE :contains + OR asset.model ILIKE :contains + OR asset.description ILIKE :contains + OR array_to_string(asset.tags, ' ') ILIKE :contains)`, + { contains }, + ) + .addSelect( + `CASE + WHEN asset.assetId = :exact THEN 1000 + WHEN asset.assetId ILIKE :startsWith THEN 600 + ELSE 0 + END + + CASE WHEN asset.name ILIKE :contains THEN 120 ELSE 0 END + + CASE WHEN asset.serialNumber ILIKE :contains THEN 100 ELSE 0 END + + CASE WHEN asset.manufacturer ILIKE :contains THEN 80 ELSE 0 END + + CASE WHEN asset.model ILIKE :contains THEN 80 ELSE 0 END + + CASE WHEN asset.description ILIKE :contains THEN 40 ELSE 0 END + + CASE WHEN array_to_string(asset.tags, ' ') ILIKE :contains THEN 60 ELSE 0 END`, + 'relevance', + ) + .setParameters({ exact: term, startsWith: `${term}%`, contains }) + .orderBy('relevance', 'DESC') + .addOrderBy('asset.updatedAt', 'DESC') + .getMany(); + } +} diff --git a/backend/contrib/src/assets/dto/bulk-delete-assets.dto.ts b/backend/contrib/src/assets/dto/bulk-delete-assets.dto.ts new file mode 100644 index 00000000..ecfd6171 --- /dev/null +++ b/backend/contrib/src/assets/dto/bulk-delete-assets.dto.ts @@ -0,0 +1,8 @@ +import { IsArray, IsUUID, ArrayNotEmpty } from 'class-validator'; + +export class BulkDeleteAssetsDto { + @IsArray() + @ArrayNotEmpty() + @IsUUID('4', { each: true }) + ids: string[]; +} diff --git a/backend/contrib/src/assets/dto/bulk-transfer-department.dto.ts b/backend/contrib/src/assets/dto/bulk-transfer-department.dto.ts new file mode 100644 index 00000000..bcd62333 --- /dev/null +++ b/backend/contrib/src/assets/dto/bulk-transfer-department.dto.ts @@ -0,0 +1,11 @@ +import { IsArray, IsUUID, ArrayNotEmpty } from 'class-validator'; + +export class BulkTransferDepartmentDto { + @IsArray() + @ArrayNotEmpty() + @IsUUID('4', { each: true }) + ids: string[]; + + @IsUUID('4') + departmentId: string; +} diff --git a/backend/contrib/src/assets/dto/bulk-update-status.dto.ts b/backend/contrib/src/assets/dto/bulk-update-status.dto.ts new file mode 100644 index 00000000..cbc6ffe2 --- /dev/null +++ b/backend/contrib/src/assets/dto/bulk-update-status.dto.ts @@ -0,0 +1,12 @@ +import { IsArray, IsEnum, IsUUID, ArrayNotEmpty } from 'class-validator'; +import { AssetStatus } from '../enums'; + +export class BulkUpdateStatusDto { + @IsArray() + @ArrayNotEmpty() + @IsUUID('4', { each: true }) + ids: string[]; + + @IsEnum(AssetStatus) + status: AssetStatus; +} diff --git a/backend/contrib/src/assets/dto/search-assets.dto.ts b/backend/contrib/src/assets/dto/search-assets.dto.ts new file mode 100644 index 00000000..a98697ea --- /dev/null +++ b/backend/contrib/src/assets/dto/search-assets.dto.ts @@ -0,0 +1,7 @@ +import { IsOptional, IsString } from 'class-validator'; + +export class SearchAssetsDto { + @IsOptional() + @IsString() + q?: string; +} diff --git a/backend/contrib/src/assets/dto/update-asset-tags.dto.ts b/backend/contrib/src/assets/dto/update-asset-tags.dto.ts new file mode 100644 index 00000000..7ff6d36a --- /dev/null +++ b/backend/contrib/src/assets/dto/update-asset-tags.dto.ts @@ -0,0 +1,7 @@ +import { IsArray, IsString } from 'class-validator'; + +export class UpdateAssetTagsDto { + @IsArray() + @IsString({ each: true }) + tags: string[]; +} diff --git a/backend/contrib/src/assets/enums.ts b/backend/contrib/src/assets/enums.ts new file mode 100644 index 00000000..1c826110 --- /dev/null +++ b/backend/contrib/src/assets/enums.ts @@ -0,0 +1,13 @@ +export enum AssetStatus { + ACTIVE = 'ACTIVE', + ASSIGNED = 'ASSIGNED', + MAINTENANCE = 'MAINTENANCE', + RETIRED = 'RETIRED', +} + +export enum AssetHistoryAction { + STATUS_CHANGED = 'STATUS_CHANGED', + TRANSFERRED = 'TRANSFERRED', + DELETED = 'DELETED', + TAGS_UPDATED = 'TAGS_UPDATED', +} diff --git a/backend/contrib/src/auth/decorators/roles.decorator.ts b/backend/contrib/src/auth/decorators/roles.decorator.ts new file mode 100644 index 00000000..e038e168 --- /dev/null +++ b/backend/contrib/src/auth/decorators/roles.decorator.ts @@ -0,0 +1,4 @@ +import { SetMetadata } from '@nestjs/common'; + +export const ROLES_KEY = 'roles'; +export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles); diff --git a/backend/contrib/src/auth/guards/jwt-auth.guard.ts b/backend/contrib/src/auth/guards/jwt-auth.guard.ts new file mode 100644 index 00000000..52d63c6c --- /dev/null +++ b/backend/contrib/src/auth/guards/jwt-auth.guard.ts @@ -0,0 +1,40 @@ +import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common'; +import { verify } from 'jsonwebtoken'; + +interface JwtPayload { + sub: string; + email?: string; + role?: string; +} + +function jwtSecret(): string { + const env = (globalThis as { process?: { env?: Record } }).process?.env; + return env?.JWT_SECRET || 'change-me-in-env'; +} + +@Injectable() +export class JwtAuthGuard implements CanActivate { + canActivate(context: ExecutionContext): boolean { + const request = context.switchToHttp().getRequest(); + const authHeader = request.headers.authorization as string | undefined; + + if (!authHeader || !authHeader.startsWith('Bearer ')) { + throw new UnauthorizedException('Missing bearer token'); + } + + const token = authHeader.slice(7).trim(); + const secret = jwtSecret(); + + try { + const payload = verify(token, secret) as JwtPayload; + request.user = { + id: payload.sub, + email: payload.email, + role: payload.role, + }; + return true; + } catch { + throw new UnauthorizedException('Invalid token'); + } + } +} diff --git a/backend/contrib/src/auth/guards/roles.guard.ts b/backend/contrib/src/auth/guards/roles.guard.ts new file mode 100644 index 00000000..8d917876 --- /dev/null +++ b/backend/contrib/src/auth/guards/roles.guard.ts @@ -0,0 +1,28 @@ +import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { ROLES_KEY } from '../decorators/roles.decorator'; + +@Injectable() +export class RolesGuard implements CanActivate { + constructor(private readonly reflector: Reflector) {} + + canActivate(context: ExecutionContext): boolean { + const requiredRoles = this.reflector.getAllAndOverride(ROLES_KEY, [ + context.getHandler(), + context.getClass(), + ]); + + if (!requiredRoles || requiredRoles.length === 0) { + return true; + } + + const request = context.switchToHttp().getRequest(); + const userRole = String(request.user?.role || '').toLowerCase(); + + if (!requiredRoles.map((role) => role.toLowerCase()).includes(userRole)) { + throw new ForbiddenException('Insufficient role'); + } + + return true; + } +} diff --git a/backend/contrib/src/main.ts b/backend/contrib/src/main.ts new file mode 100644 index 00000000..374d7a13 --- /dev/null +++ b/backend/contrib/src/main.ts @@ -0,0 +1,35 @@ +import { NestFactory } from '@nestjs/core'; +import { NestExpressApplication } from '@nestjs/platform-express'; +import { ValidationPipe } from '@nestjs/common'; +import { AppModule } from './app.module'; + +function env(name: string, fallback: string): string { + const globalEnv = (globalThis as { process?: { env?: Record } }).process?.env; + return globalEnv?.[name] || fallback; +} + +async function bootstrap() { + const app = await NestFactory.create(AppModule); + + app.enableCors({ + origin: env('FRONTEND_URL', 'http://localhost:3000'), + credentials: true, + }); + + app.setGlobalPrefix('api'); + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + transform: true, + forbidNonWhitelisted: true, + }), + ); + + const uploadDest = env('UPLOAD_DEST', 'uploads'); + app.useStaticAssets(uploadDest, { prefix: '/uploads' }); + + const port = Number(env('PORT', '6003')); + await app.listen(port); +} + +bootstrap(); diff --git a/backend/contrib/src/notifications/dto/update-notification-preferences.dto.ts b/backend/contrib/src/notifications/dto/update-notification-preferences.dto.ts new file mode 100644 index 00000000..1b0f2e98 --- /dev/null +++ b/backend/contrib/src/notifications/dto/update-notification-preferences.dto.ts @@ -0,0 +1,19 @@ +import { IsBoolean, IsOptional } from 'class-validator'; + +export class UpdateNotificationPreferencesDto { + @IsOptional() + @IsBoolean() + assetCreated?: boolean; + + @IsOptional() + @IsBoolean() + assetTransferred?: boolean; + + @IsOptional() + @IsBoolean() + maintenanceDue?: boolean; + + @IsOptional() + @IsBoolean() + warrantyExpiring?: boolean; +} diff --git a/backend/contrib/src/notifications/notification-preference.entity.ts b/backend/contrib/src/notifications/notification-preference.entity.ts new file mode 100644 index 00000000..74e251a4 --- /dev/null +++ b/backend/contrib/src/notifications/notification-preference.entity.ts @@ -0,0 +1,41 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + JoinColumn, + OneToOne, +} from 'typeorm'; +import { User } from '../users/user.entity'; + +@Entity('notification_preferences') +export class NotificationPreference { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ unique: true }) + userId: string; + + @OneToOne(() => User, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'userId' }) + user: User; + + @Column({ default: true }) + assetCreated: boolean; + + @Column({ default: true }) + assetTransferred: boolean; + + @Column({ default: true }) + maintenanceDue: boolean; + + @Column({ default: true }) + warrantyExpiring: boolean; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/backend/contrib/src/notifications/notification-preferences.controller.ts b/backend/contrib/src/notifications/notification-preferences.controller.ts new file mode 100644 index 00000000..daa8ff1b --- /dev/null +++ b/backend/contrib/src/notifications/notification-preferences.controller.ts @@ -0,0 +1,34 @@ +import { Body, Controller, Get, Patch, Req, UnauthorizedException, UseGuards } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { NotificationPreferencesService } from './notification-preferences.service'; +import { UpdateNotificationPreferencesDto } from './dto/update-notification-preferences.dto'; + +@ApiTags('Notifications') +@ApiBearerAuth('JWT-auth') +@UseGuards(JwtAuthGuard) +@Controller('notifications') +export class NotificationPreferencesController { + constructor(private readonly preferencesService: NotificationPreferencesService) {} + + private getUserId(req: { user?: { id?: string } }): string { + const userId = req.user?.id; + if (!userId) { + throw new UnauthorizedException('User context is missing'); + } + return userId; + } + + @Get('preferences') + getPreferences(@Req() req: { user?: { id?: string } }) { + return this.preferencesService.getForUser(this.getUserId(req)); + } + + @Patch('preferences') + updatePreferences( + @Req() req: { user?: { id?: string } }, + @Body() dto: UpdateNotificationPreferencesDto, + ) { + return this.preferencesService.updateForUser(this.getUserId(req), dto); + } +} diff --git a/backend/contrib/src/notifications/notification-preferences.service.ts b/backend/contrib/src/notifications/notification-preferences.service.ts new file mode 100644 index 00000000..4b6a6b79 --- /dev/null +++ b/backend/contrib/src/notifications/notification-preferences.service.ts @@ -0,0 +1,40 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { NotificationPreference } from './notification-preference.entity'; +import { UpdateNotificationPreferencesDto } from './dto/update-notification-preferences.dto'; + +@Injectable() +export class NotificationPreferencesService { + constructor( + @InjectRepository(NotificationPreference) + private readonly preferencesRepo: Repository, + ) {} + + async createDefaultsForUser(userId: string): Promise { + const existing = await this.preferencesRepo.findOne({ where: { userId } }); + if (existing) { + return existing; + } + + return this.preferencesRepo.save( + this.preferencesRepo.create({ + userId, + assetCreated: true, + assetTransferred: true, + maintenanceDue: true, + warrantyExpiring: true, + }), + ); + } + + async getForUser(userId: string): Promise { + return this.createDefaultsForUser(userId); + } + + async updateForUser(userId: string, dto: UpdateNotificationPreferencesDto): Promise { + const current = await this.createDefaultsForUser(userId); + Object.assign(current, dto); + return this.preferencesRepo.save(current); + } +} diff --git a/backend/contrib/src/notifications/notifications.module.ts b/backend/contrib/src/notifications/notifications.module.ts new file mode 100644 index 00000000..6004e013 --- /dev/null +++ b/backend/contrib/src/notifications/notifications.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { NotificationPreference } from './notification-preference.entity'; +import { User } from '../users/user.entity'; +import { NotificationPreferencesController } from './notification-preferences.controller'; +import { NotificationPreferencesService } from './notification-preferences.service'; +import { UserRegistrationSubscriber } from './user-registration.subscriber'; + +@Module({ + imports: [TypeOrmModule.forFeature([NotificationPreference, User])], + controllers: [NotificationPreferencesController], + providers: [NotificationPreferencesService, UserRegistrationSubscriber], + exports: [NotificationPreferencesService], +}) +export class NotificationsModule {} diff --git a/backend/contrib/src/notifications/user-registration.subscriber.ts b/backend/contrib/src/notifications/user-registration.subscriber.ts new file mode 100644 index 00000000..70dd8a0d --- /dev/null +++ b/backend/contrib/src/notifications/user-registration.subscriber.ts @@ -0,0 +1,36 @@ +import { DataSource, EventSubscriber, EntitySubscriberInterface, InsertEvent } from 'typeorm'; +import { Injectable } from '@nestjs/common'; +import { User } from '../users/user.entity'; +import { NotificationPreference } from './notification-preference.entity'; + +@Injectable() +@EventSubscriber() +export class UserRegistrationSubscriber implements EntitySubscriberInterface { + constructor(dataSource: DataSource) { + dataSource.subscribers.push(this); + } + + listenTo() { + return User; + } + + async afterInsert(event: InsertEvent): Promise { + if (!event.entity?.id) { + return; + } + + const repo = event.manager.getRepository(NotificationPreference); + const existing = await repo.findOne({ where: { userId: event.entity.id } }); + if (existing) { + return; + } + + await repo.insert({ + userId: event.entity.id, + assetCreated: true, + assetTransferred: true, + maintenanceDue: true, + warrantyExpiring: true, + }); + } +} diff --git a/backend/contrib/src/upload/upload.controller.ts b/backend/contrib/src/upload/upload.controller.ts new file mode 100644 index 00000000..9d3d6e68 --- /dev/null +++ b/backend/contrib/src/upload/upload.controller.ts @@ -0,0 +1,77 @@ +import { + BadRequestException, + Controller, + HttpCode, + HttpStatus, + Post, + UploadedFile, + UseGuards, + UseInterceptors, +} from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { ApiBearerAuth, ApiConsumes, ApiTags } from '@nestjs/swagger'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { documentMulterOptions, imageMulterOptions, UploadService } from './upload.service'; + +interface UploadedFileShape { + filename: string; + mimetype: string; + size: number; +} + +const uploadRoot = + (globalThis as { process?: { env?: Record } }).process?.env?.UPLOAD_DEST || + 'uploads'; + +@ApiTags('Upload') +@ApiBearerAuth('JWT-auth') +@UseGuards(JwtAuthGuard) +@Controller('upload') +export class UploadController { + constructor(private readonly uploadService: UploadService) {} + + @Post('image') + @HttpCode(HttpStatus.OK) + @ApiConsumes('multipart/form-data') + @UseInterceptors( + FileInterceptor('file', imageMulterOptions(uploadRoot)), + ) + async uploadImage(@UploadedFile() file: UploadedFileShape): Promise<{ url: string }> { + if (!file) { + throw new BadRequestException('Image file is required'); + } + + const isAllowed = ['image/jpeg', 'image/png', 'image/webp'].includes(file.mimetype); + if (!isAllowed || file.size > 5 * 1024 * 1024) { + throw new BadRequestException('Invalid image type or size exceeds 5MB'); + } + + return { url: this.uploadService.imageUrl(file.filename) }; + } + + @Post('document') + @HttpCode(HttpStatus.OK) + @ApiConsumes('multipart/form-data') + @UseInterceptors( + FileInterceptor('file', documentMulterOptions(uploadRoot)), + ) + async uploadDocument(@UploadedFile() file: UploadedFileShape): Promise<{ url: string }> { + if (!file) { + throw new BadRequestException('Document file is required'); + } + + const allowed = [ + 'application/pdf', + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/vnd.ms-excel', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'text/plain', + ]; + if (!allowed.includes(file.mimetype) || file.size > 20 * 1024 * 1024) { + throw new BadRequestException('Invalid document type or size exceeds 20MB'); + } + + return { url: this.uploadService.documentUrl(file.filename) }; + } +} diff --git a/backend/contrib/src/upload/upload.module.ts b/backend/contrib/src/upload/upload.module.ts new file mode 100644 index 00000000..cc46b0ce --- /dev/null +++ b/backend/contrib/src/upload/upload.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { MulterModule } from '@nestjs/platform-express'; +import { UploadController } from './upload.controller'; +import { UploadService } from './upload.service'; + +@Module({ + imports: [ConfigModule, MulterModule.register({})], + controllers: [UploadController], + providers: [UploadService], + exports: [UploadService], +}) +export class UploadModule {} diff --git a/backend/contrib/src/upload/upload.service.ts b/backend/contrib/src/upload/upload.service.ts new file mode 100644 index 00000000..6df1c195 --- /dev/null +++ b/backend/contrib/src/upload/upload.service.ts @@ -0,0 +1,81 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { existsSync, mkdirSync } from 'fs'; +import { join } from 'path'; +import { diskStorage, Options as MulterOptions } from 'multer'; +import { v4 as uuidv4 } from 'uuid'; + +function ensureDir(dirPath: string): void { + if (!existsSync(dirPath)) { + mkdirSync(dirPath, { recursive: true }); + } +} + +function extension(fileName: string): string { + const index = fileName.lastIndexOf('.'); + return index >= 0 ? fileName.slice(index).toLowerCase() : ''; +} + +export function imageMulterOptions(uploadRoot: string): MulterOptions { + return { + storage: diskStorage({ + destination: (_req, _file, cb) => { + const dir = join(uploadRoot, 'images'); + ensureDir(dir); + cb(null, dir); + }, + filename: (_req, file, cb) => { + cb(null, `${uuidv4()}${extension(file.originalname)}`); + }, + }), + limits: { fileSize: 5 * 1024 * 1024 }, + fileFilter: (_req, file, cb) => { + const allowed = ['image/jpeg', 'image/png', 'image/webp']; + cb(null, allowed.includes(file.mimetype)); + }, + }; +} + +export function documentMulterOptions(uploadRoot: string): MulterOptions { + return { + storage: diskStorage({ + destination: (_req, _file, cb) => { + const dir = join(uploadRoot, 'documents'); + ensureDir(dir); + cb(null, dir); + }, + filename: (_req, file, cb) => { + cb(null, `${uuidv4()}${extension(file.originalname)}`); + }, + }), + limits: { fileSize: 20 * 1024 * 1024 }, + fileFilter: (_req, file, cb) => { + const allowed = [ + 'application/pdf', + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/vnd.ms-excel', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'text/plain', + ]; + cb(null, allowed.includes(file.mimetype)); + }, + }; +} + +@Injectable() +export class UploadService { + constructor(private readonly configService: ConfigService) {} + + get uploadRoot(): string { + return this.configService.get('UPLOAD_DEST', 'uploads'); + } + + imageUrl(filename: string): string { + return `/uploads/images/${filename}`; + } + + documentUrl(filename: string): string { + return `/uploads/documents/${filename}`; + } +} diff --git a/backend/contrib/src/users/user.entity.ts b/backend/contrib/src/users/user.entity.ts new file mode 100644 index 00000000..24f15135 --- /dev/null +++ b/backend/contrib/src/users/user.entity.ts @@ -0,0 +1,31 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm'; + +export enum UserRole { + ADMIN = 'admin', + MANAGER = 'manager', + STAFF = 'staff', +} + +@Entity('users') +export class User { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ unique: true }) + email: string; + + @Column() + firstName: string; + + @Column() + lastName: string; + + @Column({ type: 'enum', enum: UserRole, default: UserRole.STAFF }) + role: UserRole; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +}