From 367457f9046cb478543e2495501913c9a08f9379 Mon Sep 17 00:00:00 2001 From: bigben-7 Date: Sat, 28 Mar 2026 12:59:37 +0100 Subject: [PATCH] feat: implement auth, storage, caching, and export updates --- backend/src/app.module.ts | 36 ++++++++++ backend/src/assets/assets.controller.ts | 41 +++++++++++ backend/src/assets/assets.module.ts | 2 + backend/src/assets/assets.service.ts | 33 ++++++++- backend/src/auth/auth.controller.ts | 20 ++++++ backend/src/auth/auth.module.ts | 8 ++- backend/src/auth/auth.service.ts | 34 +++++++++ backend/src/auth/dto/forgot-password.dto.ts | 8 +++ backend/src/auth/dto/reset-password.dto.ts | 13 ++++ .../entities/password-reset-token.entity.ts | 27 +++++++ .../auth/services/password-reset.service.ts | 53 ++++++++++++++ .../src/categories/categories.controller.ts | 17 ++++- backend/src/categories/categories.service.ts | 27 ++++++- .../src/departments/departments.controller.ts | 17 ++++- .../src/departments/departments.service.ts | 27 ++++++- backend/src/mail/mail.module.ts | 10 +++ backend/src/mail/mail.service.ts | 52 ++++++++++++++ backend/src/reports/reports.controller.ts | 52 +++++++++++++- backend/src/reports/reports.service.ts | 27 +++++++ backend/src/storage/storage.module.ts | 10 +++ backend/src/storage/storage.service.ts | 70 +++++++++++++++++++ backend/src/users/users.service.ts | 5 ++ 22 files changed, 580 insertions(+), 9 deletions(-) create mode 100644 backend/src/auth/dto/forgot-password.dto.ts create mode 100644 backend/src/auth/dto/reset-password.dto.ts create mode 100644 backend/src/auth/entities/password-reset-token.entity.ts create mode 100644 backend/src/auth/services/password-reset.service.ts create mode 100644 backend/src/mail/mail.module.ts create mode 100644 backend/src/mail/mail.service.ts create mode 100644 backend/src/storage/storage.module.ts create mode 100644 backend/src/storage/storage.service.ts diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index dc0bcd4..5e8ebaa 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -1,5 +1,6 @@ // src/app.module.ts import { Module } from '@nestjs/common'; +import { CacheModule } from '@nestjs/cache-manager'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { TypeOrmModule } from '@nestjs/typeorm'; import { ScheduleModule } from '@nestjs/schedule'; @@ -8,12 +9,41 @@ import { RolesGuard } from './auth/guards/roles.guard'; import { AppController } from './app.controller'; import { AppService } from './app.service'; import { LocationsModule } from './locations/locations.module'; +import { AuthModule } from './auth/auth.module'; +import { AssetsModule } from './assets/assets.module'; +import { UsersModule } from './users/users.module'; +import { DepartmentsModule } from './departments/departments.module'; +import { CategoriesModule } from './categories/categories.module'; +import { ReportsModule } from './reports/reports.module'; @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true, }), + CacheModule.registerAsync({ + imports: [ConfigModule], + isGlobal: true, + inject: [ConfigService], + useFactory: async (configService: ConfigService) => { + const redisHost = configService.get('REDIS_HOST'); + const redisPort = Number(configService.get('REDIS_PORT')); + const baseOptions = { ttl: 300 }; + if (redisHost && redisPort) { + const redisModule = await import('cache-manager-redis-store'); + const redisStore = redisModule.redisStore ?? redisModule.default; + if (redisStore) { + return { + ...baseOptions, + store: redisStore, + host: redisHost, + port: redisPort, + }; + } + } + return baseOptions; + }, + }), ScheduleModule.forRoot(), ThrottlerModule.forRoot([ { @@ -35,6 +65,12 @@ import { LocationsModule } from './locations/locations.module'; }), inject: [ConfigService], }), + AuthModule, + UsersModule, + DepartmentsModule, + CategoriesModule, + AssetsModule, + ReportsModule, LocationsModule, ], controllers: [AppController], diff --git a/backend/src/assets/assets.controller.ts b/backend/src/assets/assets.controller.ts index 69137e5..4afcb51 100644 --- a/backend/src/assets/assets.controller.ts +++ b/backend/src/assets/assets.controller.ts @@ -8,8 +8,11 @@ import { Param, Query, UseGuards, + UseInterceptors, + UploadedFile, HttpCode, HttpStatus, + BadRequestException, } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { AssetsService } from './assets.service'; @@ -27,6 +30,9 @@ import { RolesGuard } from '../auth/guards/roles.guard'; import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { Roles } from '../auth/decorators/roles.decorator'; import { User, UserRole } from '../users/user.entity'; +import { FileInterceptor } from '@nestjs/platform-express'; +import * as multer from 'multer'; +import { Express } from 'express'; @ApiTags('Assets') @ApiBearerAuth('JWT-auth') @@ -138,12 +144,47 @@ export class AssetsController { return this.service.getDocuments(id); } +const MAX_DOCUMENT_SIZE = 10 * 1024 * 1024; +const ALLOWED_MIME_TYPES = new Set([ + 'application/pdf', + 'image/jpeg', + 'image/jpg', + 'image/png', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', +]); + +const documentUploadOptions = { + storage: multer.memoryStorage(), + limits: { fileSize: MAX_DOCUMENT_SIZE }, + fileFilter(_: unknown, file: Express.Multer.File, callback: multer.FileFilterCallback) { + if (!ALLOWED_MIME_TYPES.has(file.mimetype)) { + return callback(new BadRequestException('Unsupported file type'), false); + } + callback(null, true); + }, +}; + @Post(':id/documents') @ApiOperation({ summary: 'Attach a document (URL) to an asset' }) addDocument(@Param('id') id: string, @Body() dto: CreateDocumentDto, @CurrentUser() user: User) { return this.service.addDocument(id, dto, user); } + @Post(':id/documents/upload') + @UseInterceptors(FileInterceptor('file', documentUploadOptions)) + @ApiOperation({ summary: 'Upload a document to storage' }) + uploadDocument( + @Param('id') id: string, + @UploadedFile() file: Express.Multer.File, + @CurrentUser() user: User, + ) { + if (!file) { + throw new BadRequestException('File is required'); + } + return this.service.uploadDocument(id, file, user); + } + @Delete(':id/documents/:documentId') @HttpCode(HttpStatus.NO_CONTENT) @ApiOperation({ summary: 'Remove a document from an asset' }) diff --git a/backend/src/assets/assets.module.ts b/backend/src/assets/assets.module.ts index 901cdf7..b806978 100644 --- a/backend/src/assets/assets.module.ts +++ b/backend/src/assets/assets.module.ts @@ -11,6 +11,7 @@ import { DepartmentsModule } from '../departments/departments.module'; import { CategoriesModule } from '../categories/categories.module'; import { UsersModule } from '../users/users.module'; import { StellarModule } from '../stellar/stellar.module'; +import { StorageModule } from '../storage/storage.module'; @Module({ imports: [ @@ -19,6 +20,7 @@ import { StellarModule } from '../stellar/stellar.module'; CategoriesModule, UsersModule, StellarModule, + StorageModule, ], controllers: [AssetsController], providers: [AssetsService], diff --git a/backend/src/assets/assets.service.ts b/backend/src/assets/assets.service.ts index c8014aa..3c7dd2f 100644 --- a/backend/src/assets/assets.service.ts +++ b/backend/src/assets/assets.service.ts @@ -1,4 +1,4 @@ -import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { Injectable, Logger, NotFoundException, BadRequestException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { ConfigService } from '@nestjs/config'; @@ -22,6 +22,8 @@ import { CategoriesService } from '../categories/categories.service'; import { UsersService } from '../users/users.service'; import { StellarService } from '../stellar/stellar.service'; import { User } from '../users/user.entity'; +import { StorageService } from '../storage/storage.service'; +import { Express } from 'express'; @Injectable() export class AssetsService { @@ -43,6 +45,7 @@ export class AssetsService { private readonly usersService: UsersService, private readonly configService: ConfigService, private readonly stellarService: StellarService, + private readonly storageService: StorageService, ) {} async findAll(filters: AssetFiltersDto): Promise<{ data: Asset[]; total: number; page: number; limit: number }> { @@ -350,6 +353,34 @@ export class AssetsService { return saved; } + async uploadDocument(assetId: string, file: Express.Multer.File, currentUser: User): Promise { + await this.findOne(assetId); + + if (!this.storageService.isEnabled) { + throw new BadRequestException('Object storage is not configured'); + } + + const upload = await this.storageService.uploadFile(file, `assets/${assetId}/documents`); + const doc = this.documentsRepo.create({ + assetId, + name: file.originalname, + url: upload.url, + type: file.mimetype ?? 'application/octet-stream', + size: file.size ?? null, + uploadedBy: currentUser, + }); + const saved = await this.documentsRepo.save(doc); + await this.logHistory( + { id: assetId } as Asset, + AssetHistoryAction.DOCUMENT_UPLOADED, + `Document uploaded: ${file.originalname}`, + null, + { name: file.originalname, url: upload.url }, + currentUser, + ); + return saved; + } + async deleteDocument(assetId: string, documentId: string): Promise { const doc = await this.documentsRepo.findOne({ where: { id: documentId, assetId } }); if (!doc) throw new NotFoundException('Document not found'); diff --git a/backend/src/auth/auth.controller.ts b/backend/src/auth/auth.controller.ts index b47b601..e1f7392 100644 --- a/backend/src/auth/auth.controller.ts +++ b/backend/src/auth/auth.controller.ts @@ -20,6 +20,8 @@ import { ConfigService } from '@nestjs/config'; import { AuthService } from './auth.service'; import { RegisterDto } from './dto/register.dto'; import { LoginDto } from './dto/login.dto'; +import { ForgotPasswordDto } from './dto/forgot-password.dto'; +import { ResetPasswordDto } from './dto/reset-password.dto'; import { JwtAuthGuard } from './guards/jwt-auth.guard'; import { CurrentUser } from './decorators/current-user.decorator'; import { User } from '../users/user.entity'; @@ -94,6 +96,24 @@ export class AuthController { return tokens; } + @Post('forgot-password') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Send a password reset link to an email' }) + @ApiResponse({ status: 200, description: 'Password reset email accepted' }) + async forgotPassword(@Body() dto: ForgotPasswordDto) { + await this.authService.forgotPassword(dto.email); + return { message: 'If an account exists for that email, a reset link has been sent.' }; + } + + @Post('reset-password') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Reset password using a token' }) + @ApiResponse({ status: 200, description: 'Password updated' }) + async resetPassword(@Body() dto: ResetPasswordDto) { + await this.authService.resetPassword(dto.token, dto.newPassword); + return { message: 'Password has been reset successfully.' }; + } + @Post('logout') @HttpCode(HttpStatus.NO_CONTENT) @UseGuards(JwtAuthGuard) diff --git a/backend/src/auth/auth.module.ts b/backend/src/auth/auth.module.ts index c65b607..287c5d9 100644 --- a/backend/src/auth/auth.module.ts +++ b/backend/src/auth/auth.module.ts @@ -1,19 +1,25 @@ import { Module } from '@nestjs/common'; import { JwtModule } from '@nestjs/jwt'; import { PassportModule } from '@nestjs/passport'; +import { TypeOrmModule } from '@nestjs/typeorm'; import { AuthController } from './auth.controller'; import { AuthService } from './auth.service'; import { JwtStrategy } from './strategies/jwt.strategy'; import { UsersModule } from '../users/users.module'; +import { MailModule } from '../mail/mail.module'; +import { PasswordResetToken } from './entities/password-reset-token.entity'; +import { PasswordResetService } from './services/password-reset.service'; @Module({ imports: [ UsersModule, PassportModule, JwtModule.register({}), + MailModule, + TypeOrmModule.forFeature([PasswordResetToken]), ], controllers: [AuthController], - providers: [AuthService, JwtStrategy], + providers: [AuthService, JwtStrategy, PasswordResetService], exports: [AuthService, JwtModule], }) export class AuthModule {} diff --git a/backend/src/auth/auth.service.ts b/backend/src/auth/auth.service.ts index a6f28b7..9a9d068 100644 --- a/backend/src/auth/auth.service.ts +++ b/backend/src/auth/auth.service.ts @@ -2,6 +2,7 @@ import { Injectable, ConflictException, UnauthorizedException, + BadRequestException, } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { ConfigService } from '@nestjs/config'; @@ -10,6 +11,8 @@ import { UsersService } from '../users/users.service'; import { RegisterDto } from './dto/register.dto'; import { LoginDto } from './dto/login.dto'; import { User } from '../users/user.entity'; +import { MailService } from '../mail/mail.service'; +import { PasswordResetService } from './services/password-reset.service'; export interface AuthTokens { accessToken: string; @@ -22,6 +25,8 @@ export class AuthService { private readonly usersService: UsersService, private readonly jwtService: JwtService, private readonly configService: ConfigService, + private readonly mailService: MailService, + private readonly passwordResetService: PasswordResetService, ) {} async register(dto: RegisterDto): Promise<{ user: User; tokens: AuthTokens }> { @@ -81,6 +86,35 @@ export class AuthService { await this.usersService.updateRefreshToken(userId, null); } + async forgotPassword(email: string): Promise { + const user = await this.usersService.findByEmail(email.toLowerCase()); + if (!user) { + return; + } + + const rawToken = await this.passwordResetService.issueToken(user.id); + const frontendUrl = this.configService.get('FRONTEND_URL', 'http://localhost:3000').replace(/\/$/, ''); + const resetUrl = `${frontendUrl}/reset-password?token=${rawToken}`; + + await this.mailService.sendMail({ + to: user.email, + subject: 'Reset your AssetsUp password', + text: `Use the link below to reset your password:\n${resetUrl}\n\nIf you did not request this, ignore this message.`, + html: `

Use the link below to reset your password:

${resetUrl}

`, + }); + } + + async resetPassword(token: string, newPassword: string): Promise { + const tokenRecord = await this.passwordResetService.findValidToken(token); + if (!tokenRecord) { + throw new BadRequestException('Invalid or expired password reset token'); + } + + const hashedPassword = await bcrypt.hash(newPassword, 12); + await this.usersService.updatePassword(tokenRecord.userId, hashedPassword); + await this.passwordResetService.markUsed(tokenRecord.id); + } + private async signTokens(user: User): Promise { const payload = { sub: user.id, email: user.email, role: user.role }; diff --git a/backend/src/auth/dto/forgot-password.dto.ts b/backend/src/auth/dto/forgot-password.dto.ts new file mode 100644 index 0000000..d24ddbc --- /dev/null +++ b/backend/src/auth/dto/forgot-password.dto.ts @@ -0,0 +1,8 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsEmail } from 'class-validator'; + +export class ForgotPasswordDto { + @ApiProperty() + @IsEmail() + email: string; +} diff --git a/backend/src/auth/dto/reset-password.dto.ts b/backend/src/auth/dto/reset-password.dto.ts new file mode 100644 index 0000000..986f352 --- /dev/null +++ b/backend/src/auth/dto/reset-password.dto.ts @@ -0,0 +1,13 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString, MinLength } from 'class-validator'; + +export class ResetPasswordDto { + @ApiProperty() + @IsString() + token: string; + + @ApiProperty() + @IsString() + @MinLength(8) + newPassword: string; +} diff --git a/backend/src/auth/entities/password-reset-token.entity.ts b/backend/src/auth/entities/password-reset-token.entity.ts new file mode 100644 index 0000000..0ea25f3 --- /dev/null +++ b/backend/src/auth/entities/password-reset-token.entity.ts @@ -0,0 +1,27 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, +} from 'typeorm'; + +@Entity('password_reset_tokens') +export class PasswordResetToken { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + userId: string; + + @Column() + token: string; + + @Column({ type: 'timestamptz' }) + expiresAt: Date; + + @Column({ type: 'timestamptz', nullable: true }) + usedAt: Date | null; + + @CreateDateColumn() + createdAt: Date; +} diff --git a/backend/src/auth/services/password-reset.service.ts b/backend/src/auth/services/password-reset.service.ts new file mode 100644 index 0000000..3c5bf1b --- /dev/null +++ b/backend/src/auth/services/password-reset.service.ts @@ -0,0 +1,53 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, MoreThan } from 'typeorm'; +import * as bcrypt from 'bcrypt'; +import { randomBytes } from 'crypto'; +import { PasswordResetToken } from '../entities/password-reset-token.entity'; + +@Injectable() +export class PasswordResetService { + constructor( + @InjectRepository(PasswordResetToken) + private readonly tokensRepo: Repository, + ) {} + + async issueToken(userId: string): Promise { + const rawToken = randomBytes(32).toString('hex'); + const hashed = await bcrypt.hash(rawToken, 12); + const expiresAt = new Date(Date.now() + 60 * 60 * 1000); + + await this.tokensRepo.save( + this.tokensRepo.create({ + userId, + token: hashed, + expiresAt, + }), + ); + + return rawToken; + } + + async findValidToken(rawToken: string): Promise { + const now = new Date(); + const tokens = await this.tokensRepo.find({ + where: { + usedAt: null, + expiresAt: MoreThan(now), + }, + order: { createdAt: 'DESC' }, + }); + + for (const token of tokens) { + if (await bcrypt.compare(rawToken, token.token)) { + return token; + } + } + + return null; + } + + async markUsed(id: string): Promise { + await this.tokensRepo.update(id, { usedAt: new Date() }); + } +} diff --git a/backend/src/categories/categories.controller.ts b/backend/src/categories/categories.controller.ts index 4c3c7f7..73cef14 100644 --- a/backend/src/categories/categories.controller.ts +++ b/backend/src/categories/categories.controller.ts @@ -1,4 +1,14 @@ -import { Controller, Get, Post, Delete, Body, Param, UseGuards, Patch } from '@nestjs/common'; +import { + Controller, + Get, + Post, + Delete, + Body, + Param, + UseGuards, + Patch, + UseInterceptors, +} from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { CategoriesService } from './categories.service'; import { CreateCategoryDto } from './dto/create-category.dto'; @@ -7,6 +17,7 @@ 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 { CacheInterceptor, CacheTTL } from '@nestjs/cache-manager'; @ApiTags('Categories') @ApiBearerAuth('JWT-auth') @@ -16,12 +27,16 @@ export class CategoriesController { constructor(private readonly service: CategoriesService) {} @Get() + @UseInterceptors(CacheInterceptor) + @CacheTTL(300) @ApiOperation({ summary: 'List all asset categories' }) findAll() { return this.service.findAll(); } @Get(':id') + @UseInterceptors(CacheInterceptor) + @CacheTTL(300) @ApiOperation({ summary: 'Get a category by ID' }) findOne(@Param('id') id: string) { return this.service.findOne(id); diff --git a/backend/src/categories/categories.service.ts b/backend/src/categories/categories.service.ts index 0f70d73..a23b241 100644 --- a/backend/src/categories/categories.service.ts +++ b/backend/src/categories/categories.service.ts @@ -2,9 +2,12 @@ import { ConflictException, Injectable, NotFoundException, + Inject, + CACHE_MANAGER, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; +import { Cache } from 'cache-manager'; import { Category } from './category.entity'; import { CreateCategoryDto } from './dto/create-category.dto'; import { UpdateCategoryDto } from './dto/update-category.dto'; @@ -15,11 +18,26 @@ export interface CategoryWithCount extends Category { @Injectable() export class CategoriesService { + private readonly listCacheKey = 'GET:/api/categories'; + constructor( @InjectRepository(Category) private readonly repo: Repository, + @Inject(CACHE_MANAGER) + private readonly cacheManager: Cache, ) {} + private cacheDetailKey(id: string): string { + return `GET:/api/categories/${id}`; + } + + private async invalidateCache(id?: string): Promise { + await this.cacheManager.del(this.listCacheKey); + if (id) { + await this.cacheManager.del(this.cacheDetailKey(id)); + } + } + async findAll(): Promise { const rows: (Category & { assetCount: string })[] = await this.repo.query(` SELECT c.*, COALESCE(COUNT(a.id), 0)::int AS "assetCount" @@ -39,7 +57,9 @@ export class CategoriesService { async create(dto: CreateCategoryDto): Promise { await this.ensureNameUnique(dto.name); - return this.repo.save(this.repo.create(dto)); + const saved = await this.repo.save(this.repo.create(dto)); + await this.invalidateCache(saved.id); + return saved; } async update(id: string, dto: UpdateCategoryDto): Promise { @@ -49,12 +69,15 @@ export class CategoriesService { } Object.assign(category, dto); - return this.repo.save(category); + const saved = await this.repo.save(category); + await this.invalidateCache(id); + return saved; } async remove(id: string): Promise { const cat = await this.findOne(id); await this.repo.remove(cat); + await this.invalidateCache(id); } private async ensureNameUnique(name: string): Promise { diff --git a/backend/src/departments/departments.controller.ts b/backend/src/departments/departments.controller.ts index 668bb47..77a7a29 100644 --- a/backend/src/departments/departments.controller.ts +++ b/backend/src/departments/departments.controller.ts @@ -1,4 +1,14 @@ -import { Controller, Get, Post, Delete, Body, Param, UseGuards, Patch } from '@nestjs/common'; +import { + Controller, + Get, + Post, + Delete, + Body, + Param, + UseGuards, + Patch, + UseInterceptors, +} from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { DepartmentsService } from './departments.service'; import { CreateDepartmentDto } from './dto/create-department.dto'; @@ -7,6 +17,7 @@ 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 { CacheInterceptor, CacheTTL } from '@nestjs/cache-manager'; @ApiTags('Departments') @ApiBearerAuth('JWT-auth') @@ -16,12 +27,16 @@ export class DepartmentsController { constructor(private readonly service: DepartmentsService) {} @Get() + @UseInterceptors(CacheInterceptor) + @CacheTTL(300) @ApiOperation({ summary: 'List all departments' }) findAll() { return this.service.findAll(); } @Get(':id') + @UseInterceptors(CacheInterceptor) + @CacheTTL(300) @ApiOperation({ summary: 'Get a department by ID' }) findOne(@Param('id') id: string) { return this.service.findOne(id); diff --git a/backend/src/departments/departments.service.ts b/backend/src/departments/departments.service.ts index 30cda09..0e5bb41 100644 --- a/backend/src/departments/departments.service.ts +++ b/backend/src/departments/departments.service.ts @@ -2,9 +2,12 @@ import { ConflictException, Injectable, NotFoundException, + Inject, + CACHE_MANAGER, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; +import { Cache } from 'cache-manager'; import { Department } from './department.entity'; import { CreateDepartmentDto } from './dto/create-department.dto'; import { UpdateDepartmentDto } from './dto/update-department.dto'; @@ -15,11 +18,26 @@ export interface DepartmentWithCount extends Department { @Injectable() export class DepartmentsService { + private readonly listCacheKey = 'GET:/api/departments'; + constructor( @InjectRepository(Department) private readonly repo: Repository, + @Inject(CACHE_MANAGER) + private readonly cacheManager: Cache, ) {} + private cacheDetailKey(id: string): string { + return `GET:/api/departments/${id}`; + } + + private async invalidateCache(id?: string): Promise { + await this.cacheManager.del(this.listCacheKey); + if (id) { + await this.cacheManager.del(this.cacheDetailKey(id)); + } + } + async findAll(): Promise { const rows: (Department & { assetCount: string })[] = await this.repo.query(` SELECT d.*, COALESCE(COUNT(a.id), 0)::int AS "assetCount" @@ -39,7 +57,9 @@ export class DepartmentsService { async create(dto: CreateDepartmentDto): Promise { await this.ensureNameUnique(dto.name); - return this.repo.save(this.repo.create(dto)); + const saved = await this.repo.save(this.repo.create(dto)); + await this.invalidateCache(saved.id); + return saved; } async update(id: string, dto: UpdateDepartmentDto): Promise { @@ -49,12 +69,15 @@ export class DepartmentsService { } Object.assign(dept, dto); - return this.repo.save(dept); + const saved = await this.repo.save(dept); + await this.invalidateCache(id); + return saved; } async remove(id: string): Promise { const dept = await this.findOne(id); await this.repo.remove(dept); + await this.invalidateCache(id); } private async ensureNameUnique(name: string): Promise { diff --git a/backend/src/mail/mail.module.ts b/backend/src/mail/mail.module.ts new file mode 100644 index 0000000..4e7ef6c --- /dev/null +++ b/backend/src/mail/mail.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { MailService } from './mail.service'; + +@Module({ + imports: [ConfigModule], + providers: [MailService], + exports: [MailService], +}) +export class MailModule {} diff --git a/backend/src/mail/mail.service.ts b/backend/src/mail/mail.service.ts new file mode 100644 index 0000000..1b2a76c --- /dev/null +++ b/backend/src/mail/mail.service.ts @@ -0,0 +1,52 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import nodemailer, { SendMailOptions, Transporter } from 'nodemailer'; + +@Injectable() +export class MailService { + private readonly logger = new Logger(MailService.name); + private readonly transporter: Transporter | null; + private readonly defaultFrom: string; + + constructor(private readonly configService: ConfigService) { + const host = this.configService.get('SMTP_HOST'); + const port = Number(this.configService.get('SMTP_PORT') ?? 0); + const user = this.configService.get('SMTP_USER'); + const pass = this.configService.get('SMTP_PASS'); + this.defaultFrom = this.configService.get('SMTP_FROM', 'no-reply@assetsup.app'); + + if (!host || !port) { + this.logger.warn('SMTP host/port not configured; email delivery disabled'); + this.transporter = null; + return; + } + + const secure = port === 465; + const auth = user && pass ? { user, pass } : undefined; + + this.transporter = nodemailer.createTransport({ + host, + port, + secure, + auth, + }); + } + + async sendMail(options: SendMailOptions): Promise { + if (!this.transporter) { + this.logger.warn(`Skipping email to ${options.to} because SMTP is not configured`); + return; + } + + const mailOptions: SendMailOptions = { + from: options.from ?? this.defaultFrom, + ...options, + }; + + try { + await this.transporter.sendMail(mailOptions); + } catch (error) { + this.logger.error('Failed to send email', error); + } + } +} diff --git a/backend/src/reports/reports.controller.ts b/backend/src/reports/reports.controller.ts index 89f50e6..9aee8e6 100644 --- a/backend/src/reports/reports.controller.ts +++ b/backend/src/reports/reports.controller.ts @@ -1,7 +1,10 @@ -import { Controller, Get, UseGuards } from '@nestjs/common'; +import { Controller, Get, UseGuards, Query, Res } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { ReportsService } from './reports.service'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { AssetFiltersDto } from '../assets/dto/asset-filters.dto'; +import { Response } from 'express'; +import { Parser } from 'json2csv'; @ApiTags('Reports') @ApiBearerAuth('JWT-auth') @@ -15,4 +18,51 @@ export class ReportsController { getSummary() { return this.service.getSummary(); } + + @Get('assets/export/csv') + @ApiOperation({ summary: 'Export assets filtered list as CSV' }) + async exportAssets( + @Query() filters: AssetFiltersDto, + @Res() res: Response, + ) { + const assets = await this.service.exportAssets(filters); + const rows = assets.map((asset) => ({ + 'Asset ID': asset.assetId, + Name: asset.name, + Category: asset.category?.name ?? '', + Department: asset.department?.name ?? '', + Status: asset.status, + Condition: asset.condition, + Location: asset.location ?? '', + 'Assigned To': asset.assignedTo + ? `${asset.assignedTo.firstName} ${asset.assignedTo.lastName}` + : '', + 'Purchase Date': asset.purchaseDate + ? asset.purchaseDate.toISOString().split('T')[0] + : '', + 'Purchase Price': asset.purchasePrice ?? '', + 'Serial Number': asset.serialNumber ?? '', + })); + + const parser = new Parser({ + fields: [ + 'Asset ID', + 'Name', + 'Category', + 'Department', + 'Status', + 'Condition', + 'Location', + 'Assigned To', + 'Purchase Date', + 'Purchase Price', + 'Serial Number', + ], + }); + + const csv = parser.parse(rows); + res.header('Content-Type', 'text/csv'); + res.header('Content-Disposition', 'attachment; filename="assets-export.csv"'); + res.send(csv); + } } diff --git a/backend/src/reports/reports.service.ts b/backend/src/reports/reports.service.ts index 5c0289b..7abd5d6 100644 --- a/backend/src/reports/reports.service.ts +++ b/backend/src/reports/reports.service.ts @@ -3,6 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { Asset } from '../assets/asset.entity'; import { AssetStatus } from '../assets/enums'; +import { AssetFiltersDto } from '../assets/dto/asset-filters.dto'; @Injectable() export class ReportsService { @@ -63,4 +64,30 @@ export class ReportsService { return { total, byStatus, byCategory, byDepartment, recent }; } + + async exportAssets(filters: AssetFiltersDto): Promise { + const { search, status, condition, categoryId, departmentId } = filters; + const qb = this.assetsRepo + .createQueryBuilder('asset') + .leftJoinAndSelect('asset.category', 'category') + .leftJoinAndSelect('asset.department', 'department') + .leftJoinAndSelect('asset.assignedTo', 'assignedTo') + .leftJoinAndSelect('asset.createdBy', 'createdBy') + .leftJoinAndSelect('asset.updatedBy', 'updatedBy'); + + if (search) { + qb.andWhere( + '(asset.name ILIKE :search OR asset.assetId ILIKE :search OR asset.serialNumber ILIKE :search OR asset.manufacturer ILIKE :search OR asset.model ILIKE :search)', + { search: `%${search}%` }, + ); + } + if (status) qb.andWhere('asset.status = :status', { status }); + if (condition) qb.andWhere('asset.condition = :condition', { condition }); + if (categoryId) qb.andWhere('category.id = :categoryId', { categoryId }); + if (departmentId) qb.andWhere('department.id = :departmentId', { departmentId }); + + qb.orderBy('asset.createdAt', 'DESC'); + + return qb.getMany(); + } } diff --git a/backend/src/storage/storage.module.ts b/backend/src/storage/storage.module.ts new file mode 100644 index 0000000..34d142d --- /dev/null +++ b/backend/src/storage/storage.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { StorageService } from './storage.service'; + +@Module({ + imports: [ConfigModule], + providers: [StorageService], + exports: [StorageService], +}) +export class StorageModule {} diff --git a/backend/src/storage/storage.service.ts b/backend/src/storage/storage.service.ts new file mode 100644 index 0000000..7814be7 --- /dev/null +++ b/backend/src/storage/storage.service.ts @@ -0,0 +1,70 @@ +import { Injectable, BadRequestException, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3'; +import path from 'path'; + +@Injectable() +export class StorageService { + private readonly logger = new Logger(StorageService.name); + private readonly bucket: string | null; + private readonly region: string; + private readonly client: S3Client | null; + + constructor(private readonly configService: ConfigService) { + this.bucket = this.configService.get('AWS_S3_BUCKET')?.trim() || null; + this.region = this.configService.get('AWS_REGION', 'us-east-1'); + + if (!this.bucket) { + this.logger.warn('AWS_S3_BUCKET not configured; storage uploads are disabled'); + this.client = null; + return; + } + + const accessKeyId = this.configService.get('AWS_ACCESS_KEY_ID'); + const secretAccessKey = this.configService.get('AWS_SECRET_ACCESS_KEY'); + const credentials = + accessKeyId && secretAccessKey ? { accessKeyId, secretAccessKey } : undefined; + + this.client = new S3Client({ + region: this.region, + credentials, + }); + } + + get isEnabled(): boolean { + return Boolean(this.client && this.bucket); + } + + async uploadFile( + file: Express.Multer.File, + folder: string, + ): Promise<{ url: string; key: string; bucket: string }> { + if (!this.isEnabled || !file.buffer) { + throw new BadRequestException('Object storage is not configured or file is missing'); + } + + const normalizedFolder = folder?.replace(/^\/|\/$/g, '') ?? ''; + const baseName = path.basename(file.originalname); + const key = `${normalizedFolder ? `${normalizedFolder}/` : ''}${Date.now()}-${baseName}`; + + await this.client!.send( + new PutObjectCommand({ + Bucket: this.bucket!, + Key: key, + Body: file.buffer, + ContentType: file.mimetype, + }), + ); + + const hostname = + this.region === 'us-east-1' + ? `https://${this.bucket}.s3.amazonaws.com` + : `https://${this.bucket}.s3.${this.region}.amazonaws.com`; + + return { + url: `${hostname}/${encodeURI(key)}`, + key, + bucket: this.bucket!, + }; + } +} diff --git a/backend/src/users/users.service.ts b/backend/src/users/users.service.ts index 998fe18..1a743ba 100644 --- a/backend/src/users/users.service.ts +++ b/backend/src/users/users.service.ts @@ -61,6 +61,11 @@ export class UsersService { return this.usersRepo.save(user); } + async updatePassword(id: string, hashedPassword: string): Promise { + await this.findById(id); + await this.usersRepo.update(id, { password: hashedPassword }); + } + async updateRefreshToken(id: string, token: string | null): Promise { await this.usersRepo.update(id, { refreshToken: token }); }