diff --git a/backend/.env.example b/backend/.env.example index de8edb12..961906fd 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -26,6 +26,12 @@ SMTP_PASS= ASSET_ID_PREFIX=AST ASSET_ID_START=1000 +# AWS S3 Configuration +AWS_REGION=us-east-1 +AWS_ACCESS_KEY_ID= +AWS_SECRET_ACCESS_KEY= +AWS_S3_BUCKET=assetsup-uploads + # Caching Configuration REDIS_HOST=localhost REDIS_PORT=6379 diff --git a/backend/src/app.controller.ts b/backend/src/app.controller.ts index 265479a1..8a5a5724 100644 --- a/backend/src/app.controller.ts +++ b/backend/src/app.controller.ts @@ -10,12 +10,12 @@ export class AppController { return this.appService.getHello(); } - @Get() + @Get('health') getHealth() { return { status: 'OK', message: 'Server is running', - Timestamp: Date.now(), + timestamp: Date.now(), }; } } diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 414286bd..09843f71 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -7,6 +7,8 @@ import { AppController } from './app.controller'; import { AppService } from './app.service'; import { UsersModule } from './users/users.module'; import { AuthModule } from './auth/auth.module'; +import { QueueModule } from './queue/queue.module'; +import { StorageModule } from './storage/storage.module'; import { CacheService } from './cache/cache.service'; @Module({ @@ -56,6 +58,8 @@ import { CacheService } from './cache/cache.service'; }, }), + QueueModule, + StorageModule, UsersModule, AuthModule, ], diff --git a/backend/src/mail/mail.module.ts b/backend/src/mail/mail.module.ts index 1ea1d72c..4e7ef6c4 100644 --- a/backend/src/mail/mail.module.ts +++ b/backend/src/mail/mail.module.ts @@ -1,7 +1,9 @@ import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; import { MailService } from './mail.service'; @Module({ + imports: [ConfigModule], providers: [MailService], exports: [MailService], }) diff --git a/backend/src/mail/mail.service.ts b/backend/src/mail/mail.service.ts index 35df271a..cb47a35a 100644 --- a/backend/src/mail/mail.service.ts +++ b/backend/src/mail/mail.service.ts @@ -1,16 +1,79 @@ import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import * as nodemailer from 'nodemailer'; @Injectable() export class MailService { private readonly logger = new Logger(MailService.name); + private readonly transporter: nodemailer.Transporter; + + constructor(private readonly configService: ConfigService) { + this.transporter = nodemailer.createTransport({ + host: configService.get('SMTP_HOST', ''), + port: parseInt(configService.get('SMTP_PORT', '587'), 10), + secure: configService.get('SMTP_SECURE', 'false') === 'true', + auth: { + user: configService.get('SMTP_USER', ''), + pass: configService.get('SMTP_PASS', ''), + }, + }); + } async sendPasswordResetEmail(email: string, resetLink: string) { - this.logger.log(`[MailService] Sending password reset email to ${email}. Link: ${resetLink}`); - // Inn a real application, implement NodeMailer or similar here. + await this.sendTemplateEmail(email, 'Password Reset', 'password-reset', { resetLink }); + } + + async sendWelcomeEmail(email: string, name: string) { + await this.sendTemplateEmail(email, 'Welcome to AssetsUp', 'welcome', { name }); + } + + 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'), + to, + subject, + html, + }); + this.logger.log(`Email sent to ${to} with subject "${subject}"`); + } catch (error) { + this.logger.error(`Failed to send email to ${to}: ${error.message}`); + } + } + + private renderTemplate(template: string, context: Record): string { + const templates: Record) => string> = { + 'password-reset': (ctx) => ` +

Password Reset

+

Click the link below to reset your password:

+ ${ctx.resetLink} +

This link expires in 1 hour.

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

Welcome to AssetsUp!

+

Hello ${ctx.name},

+

Your account has been created successfully.

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

Asset Checked Out

+

Asset ${ctx.assetName} has been checked out to ${ctx.assignedTo}.

+

Due date: ${ctx.dueDate}

+ `, + 'maintenance-due': (ctx) => ` +

Maintenance Due

+

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

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

Warranty Expiry Notice

+

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

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

${subject}

`; + } + return render(context); } } -// async sendPasswordResetEmail(email: string, resetLink: string) { -// this.logger.log(`[MailService] Sending password reset email to ${email}. Link: ${resetLink}`); -// // Inn a real application, implement NodeMailer or similar here. -// } -// } diff --git a/backend/src/queue/processors/email.processor.ts b/backend/src/queue/processors/email.processor.ts new file mode 100644 index 00000000..77796c93 --- /dev/null +++ b/backend/src/queue/processors/email.processor.ts @@ -0,0 +1,17 @@ +import { Processor, Process } from '@nestjs/bull'; +import { Job } from 'bull'; +import { Logger } from '@nestjs/common'; +import { MailService } from '../../mail/mail.service'; + +@Processor('email') +export class EmailProcessor { + private readonly logger = new Logger(EmailProcessor.name); + + constructor(private readonly mailService: MailService) {} + + @Process('send') + 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); + } +} diff --git a/backend/src/queue/queue.module.ts b/backend/src/queue/queue.module.ts new file mode 100644 index 00000000..971a417c --- /dev/null +++ b/backend/src/queue/queue.module.ts @@ -0,0 +1,26 @@ +import { Module } from '@nestjs/common'; +import { BullModule } from '@nestjs/bull'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { QueueService } from './queue.service'; +import { EmailProcessor } from './processors/email.processor'; + +@Module({ + imports: [ + BullModule.forRootAsync({ + imports: [ConfigModule], + inject: [ConfigService], + useFactory: (configService: ConfigService) => ({ + redis: { + host: configService.get('REDIS_HOST', 'localhost'), + port: parseInt(configService.get('REDIS_PORT', '6379'), 10), + }, + }), + }), + BullModule.registerQueue({ + name: 'email', + }), + ], + providers: [QueueService, EmailProcessor], + exports: [QueueService, BullModule], +}) +export class QueueModule {} diff --git a/backend/src/queue/queue.service.ts b/backend/src/queue/queue.service.ts new file mode 100644 index 00000000..5ada87b2 --- /dev/null +++ b/backend/src/queue/queue.service.ts @@ -0,0 +1,17 @@ +import { Injectable } from '@nestjs/common'; +import { InjectQueue } from '@nestjs/bull'; +import { Queue } from 'bull'; + +@Injectable() +export class QueueService { + constructor( + @InjectQueue('email') private readonly emailQueue: Queue, + ) {} + + 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/storage/storage.controller.ts b/backend/src/storage/storage.controller.ts new file mode 100644 index 00000000..73252e37 --- /dev/null +++ b/backend/src/storage/storage.controller.ts @@ -0,0 +1,21 @@ +import { Controller, Post, Delete, Param, UseInterceptors, UploadedFile, Body } from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { StorageService } from './storage.service'; + +@Controller('storage') +export class StorageController { + constructor(private readonly storageService: StorageService) {} + + @Post('upload') + @UseInterceptors(FileInterceptor('file')) + async uploadFile(@UploadedFile() file: Express.Multer.File) { + const key = await this.storageService.upload(file); + return { key, url: await this.storageService.getSignedUrl(key) }; + } + + @Delete(':key') + async deleteFile(@Param('key') key: string) { + await this.storageService.delete(key); + return { message: 'File deleted' }; + } +} diff --git a/backend/src/storage/storage.module.ts b/backend/src/storage/storage.module.ts new file mode 100644 index 00000000..9a608af2 --- /dev/null +++ b/backend/src/storage/storage.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { StorageService } from './storage.service'; +import { StorageController } from './storage.controller'; + +@Module({ + imports: [ConfigModule], + controllers: [StorageController], + 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 00000000..501a5412 --- /dev/null +++ b/backend/src/storage/storage.service.ts @@ -0,0 +1,52 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { S3Client, PutObjectCommand, DeleteObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3'; +import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; + +@Injectable() +export class StorageService { + private readonly logger = new Logger(StorageService.name); + private readonly s3: S3Client; + private readonly bucket: string; + + constructor(private readonly configService: ConfigService) { + this.s3 = new S3Client({ + region: configService.get('AWS_REGION', 'us-east-1'), + credentials: { + accessKeyId: configService.get('AWS_ACCESS_KEY_ID', ''), + secretAccessKey: configService.get('AWS_SECRET_ACCESS_KEY', ''), + }, + }); + this.bucket = configService.get('AWS_S3_BUCKET', 'assetsup-uploads'); + } + + async upload(file: Express.Multer.File, key?: string): Promise { + const fileKey = key || `${Date.now()}-${file.originalname}`; + await this.s3.send( + new PutObjectCommand({ + Bucket: this.bucket, + Key: fileKey, + Body: file.buffer, + ContentType: file.mimetype, + }), + ); + return fileKey; + } + + async delete(key: string): Promise { + await this.s3.send( + new DeleteObjectCommand({ + Bucket: this.bucket, + Key: key, + }), + ); + } + + async getSignedUrl(key: string, expiresIn = 3600): Promise { + return getSignedUrl( + this.s3, + new GetObjectCommand({ Bucket: this.bucket, Key: key }), + { expiresIn }, + ); + } +}