diff --git a/backend/package-lock.json b/backend/package-lock.json index e4c6116..e990dae 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -16,7 +16,10 @@ "@nestjs/mongoose": "^11.0.3", "@nestjs/platform-express": "^11.0.1", "bcrypt": "^6.0.0", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.2", "mongoose": "^8.18.1", + "nodemailer": "^7.0.10", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1" }, @@ -2963,6 +2966,12 @@ "@types/superagent": "^8.1.0" } }, + "node_modules/@types/validator": { + "version": "13.15.8", + "resolved": "https://registry.npmmirror.com/@types/validator/-/validator-13.15.8.tgz", + "integrity": "sha512-/NAHBJ0RwpsbLzzbLoLm/GnvCGB+A0/p5S61RUIsh7j3MP2dMkdUbWNdFqnluLlUheAs1CR2GlX2R7uzb7Tc0w==", + "license": "MIT" + }, "node_modules/@types/webidl-conversions": { "version": "7.0.3", "resolved": "https://registry.npmmirror.com/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz", @@ -4442,6 +4451,23 @@ "dev": true, "license": "MIT" }, + "node_modules/class-transformer": { + "version": "0.5.1", + "resolved": "https://registry.npmmirror.com/class-transformer/-/class-transformer-0.5.1.tgz", + "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==", + "license": "MIT" + }, + "node_modules/class-validator": { + "version": "0.14.2", + "resolved": "https://registry.npmmirror.com/class-validator/-/class-validator-0.14.2.tgz", + "integrity": "sha512-3kMVRF2io8N8pY1IFIXlho9r8IPUUIfHe2hYVtiebvAzU2XeQFXTv+XI4WX+TnXmtwXMDcjngcpkiPM0O9PvLw==", + "license": "MIT", + "dependencies": { + "@types/validator": "^13.11.8", + "libphonenumber-js": "^1.11.1", + "validator": "^13.9.0" + } + }, "node_modules/cli-cursor": { "version": "3.1.0", "resolved": "https://registry.npmmirror.com/cli-cursor/-/cli-cursor-3.1.0.tgz", @@ -7423,6 +7449,12 @@ "node": ">= 0.8.0" } }, + "node_modules/libphonenumber-js": { + "version": "1.12.26", + "resolved": "https://registry.npmmirror.com/libphonenumber-js/-/libphonenumber-js-1.12.26.tgz", + "integrity": "sha512-MagMOuqEXB2Pa90cWE+BoCmcKJx+de5uBIicaUkQ+uiEslZ0OBMNOkSZT/36syXNHu68UeayTxPm3DYM2IHoLQ==", + "license": "MIT" + }, "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmmirror.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -8064,6 +8096,15 @@ "dev": true, "license": "MIT" }, + "node_modules/nodemailer": { + "version": "7.0.10", + "resolved": "https://registry.npmmirror.com/nodemailer/-/nodemailer-7.0.10.tgz", + "integrity": "sha512-Us/Se1WtT0ylXgNFfyFSx4LElllVLJXQjWi2Xz17xWw7amDKO2MLtFnVp1WACy7GkVGs+oBlRopVNUzlrGSw1w==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmmirror.com/normalize-path/-/normalize-path-3.0.0.tgz", @@ -10208,6 +10249,15 @@ "node": ">=10.12.0" } }, + "node_modules/validator": { + "version": "13.15.23", + "resolved": "https://registry.npmmirror.com/validator/-/validator-13.15.23.tgz", + "integrity": "sha512-4yoz1kEWqUjzi5zsPbAS/903QXSYp0UOtHsPpp7p9rHAw/W+dkInskAE386Fat3oKRROwO98d9ZB0G4cObgUyw==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmmirror.com/vary/-/vary-1.1.2.tgz", diff --git a/backend/package.json b/backend/package.json index 731c9c5..c0ef817 100644 --- a/backend/package.json +++ b/backend/package.json @@ -27,7 +27,10 @@ "@nestjs/mongoose": "^11.0.3", "@nestjs/platform-express": "^11.0.1", "bcrypt": "^6.0.0", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.2", "mongoose": "^8.18.1", + "nodemailer": "^7.0.10", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1" }, diff --git a/backend/src/api/article/article.module.ts b/backend/src/api/article/article.module.ts index 17a0268..629097d 100644 --- a/backend/src/api/article/article.module.ts +++ b/backend/src/api/article/article.module.ts @@ -1,15 +1,18 @@ // article.module.ts import { Module } from '@nestjs/common'; import { MongooseModule } from '@nestjs/mongoose'; +import { ConfigModule } from '@nestjs/config'; import { ArticleController } from './article.controller'; import { ArticleService } from './article.service'; import { Article, ArticleSchema } from './article.schema'; +import { EmailService } from '../../services/email.service'; @Module({ imports: [ MongooseModule.forFeature([{ name: Article.name, schema: ArticleSchema }]), + ConfigModule, ], controllers: [ArticleController], - providers: [ArticleService], + providers: [ArticleService, EmailService], }) export class ArticleModule {} diff --git a/backend/src/api/article/article.service.spec.ts b/backend/src/api/article/article.service.spec.ts index 094ad9c..28e9042 100644 --- a/backend/src/api/article/article.service.spec.ts +++ b/backend/src/api/article/article.service.spec.ts @@ -3,6 +3,8 @@ import { getModelToken } from '@nestjs/mongoose'; import { ArticleService } from './article.service'; import { Article, ArticleStatus, EvidenceType } from './article.schema'; import { Model } from 'mongoose'; +import { EmailService } from '../../services/email.service'; +import { ConfigService } from '@nestjs/config'; // Mock Article model const mockArticleModel = { @@ -16,6 +18,17 @@ const mockArticleModel = { exec: jest.fn(), }; +// Mock EmailService +const mockEmailService = { + sendMail: jest.fn().mockResolvedValue(true), + sendBulkMail: jest.fn().mockResolvedValue(true), +}; + +// Mock ConfigService +const mockConfigService = { + get: jest.fn(), +}; + describe('ArticleService', () => { let service: ArticleService; let model: Model
; @@ -28,6 +41,14 @@ describe('ArticleService', () => { provide: getModelToken(Article.name), useValue: mockArticleModel, }, + { + provide: EmailService, + useValue: mockEmailService, + }, + { + provide: ConfigService, + useValue: mockConfigService, + }, ], }).compile(); diff --git a/backend/src/api/article/article.service.ts b/backend/src/api/article/article.service.ts index 55c16b2..2eab37a 100644 --- a/backend/src/api/article/article.service.ts +++ b/backend/src/api/article/article.service.ts @@ -4,11 +4,13 @@ import { InjectModel } from '@nestjs/mongoose'; import { Model } from 'mongoose'; import { Article, ArticleDocument, ArticleStatus } from './article.schema'; import { CreateArticleDto, ReviewArticleDto } from './create-article.dto'; +import { EmailService } from '../../services/email.service'; @Injectable() export class ArticleService { constructor( @InjectModel(Article.name) private articleModel: Model, + private emailService: EmailService, ) {} // Get all articles (with optional filtering) @@ -39,7 +41,10 @@ export class ArticleService { } // Find articles with similar DOIs (for duplicate checking) - async findArticlesBySimilarDOI(doi: string, excludeId?: string): Promise { + async findArticlesBySimilarDOI( + doi: string, + excludeId?: string, + ): Promise { // Query object to build the search criteria const query: any = { doi }; @@ -175,7 +180,10 @@ export class ArticleService { updateData['isDuplicate'] = true; // Prevent an article from being marked as duplicate of itself if (reviewData.duplicateOf === id) { - throw new HttpException('Cannot mark article as duplicate of itself', HttpStatus.BAD_REQUEST); + throw new HttpException( + 'Cannot mark article as duplicate of itself', + HttpStatus.BAD_REQUEST, + ); } updateData['duplicateOf'] = reviewData.duplicateOf; } else { @@ -195,9 +203,98 @@ export class ArticleService { ); } + // Send email notification to submitter + console.log(`Attempting to send notification email for article ${updatedArticle.customId} with status ${updatedArticle.status}`); + await this.sendReviewNotificationEmail(updatedArticle); + return updatedArticle; } + // Send email notification to submitter about article review status + private async sendReviewNotificationEmail(article: Article) { + console.log(`Processing notification for article ${article.customId} with status ${article.status} and submitter email ${article.submitterEmail}`); + + if (!article.submitterEmail) { + console.warn(`No submitter email found for article ${article.customId}`); + return; + } + + let subject = ''; + let htmlContent = ''; + + // Determine the content based on the status + if (article.status === ArticleStatus.APPROVED) { + subject = 'Your Article Has Been Approved - CISE_SPEED'; + htmlContent = ` +

Article Review Result Notification

+

Hello,

+

Your article "${article.title}" has been approved and added to the database.

+

Article Details:

+
    +
  • Title: ${article.title}
  • +
  • ID: ${article.customId}
  • +
  • Authors: ${article.authors}
  • +
  • Publication Year: ${article.pubyear}
  • +
  • Evidence Type: ${article.evidence}
  • +
+

Thank you for contributing to the CISE_SPEED database!

+

If you have any questions, please feel free to contact us.

+ `; + } else if (article.status === ArticleStatus.REJECTED) { + subject = 'Article Review Result - CISE_SPEED'; + htmlContent = ` +

Article Review Result Notification

+

Hello,

+

Unfortunately, your article "${article.title}" did not pass the review.

+ + ${article.reviewComment ? `

Review Comment: ${article.reviewComment}

` : ''} + +

Article Details:

+
    +
  • Title: ${article.title}
  • +
  • ID: ${article.customId}
  • +
  • Authors: ${article.authors}
  • +
  • Publication Year: ${article.pubyear}
  • +
+

Thank you for your interest and contribution to the CISE_SPEED database!

+

If you have any questions, please feel free to contact us.

+ `; + } else { + // For other status changes (like back to PENDING), we could send a generic notification + console.log( + `Article ${article.customId} status changed to ${article.status}, no email notification required.`, + ); + return; + } + + console.log(`Attempting to send email to ${article.submitterEmail} with subject: ${subject}`); + + try { + const result = await this.emailService.sendMail( + article.submitterEmail, + subject, + htmlContent, + `CISE_SPEED: Your article "${article.title}" has been reviewed`, // text + 'CISE_SPEED System', // fromName + ); + + if (result) { + console.log( + `Notification email sent successfully to ${article.submitterEmail} for article ${article.customId}`, + ); + } else { + console.error( + `Failed to send notification email to ${article.submitterEmail} for article ${article.customId}`, + ); + } + } catch (error) { + console.error( + `Error sending notification email to ${article.submitterEmail} for article ${article.customId}:`, + error, + ); + } + } + // Search articles by keywords and filters with sorting async searchArticles( keywords: string, diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index d0bd6e4..f1f0016 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -5,6 +5,7 @@ import { MongooseModule } from '@nestjs/mongoose'; import { ConfigModule } from '@nestjs/config'; import { ArticleModule } from './api/article/article.module'; import { UserModule } from './api/user/user.module'; +import { EmailModule } from './services/email.module'; @Module({ imports: [ @@ -12,6 +13,7 @@ import { UserModule } from './api/user/user.module'; MongooseModule.forRoot(process.env.DB_URI || ''), ArticleModule, UserModule, + EmailModule, ], controllers: [AppController], providers: [AppService], diff --git a/backend/src/controllers/email.controller.ts b/backend/src/controllers/email.controller.ts new file mode 100644 index 0000000..91c36db --- /dev/null +++ b/backend/src/controllers/email.controller.ts @@ -0,0 +1,109 @@ +import { Controller, Post, Get, Body, Query, UsePipes, ValidationPipe } from '@nestjs/common'; +import { EmailService } from '../services/email.service'; +import { IsEmail, IsString, IsArray, ValidateNested, IsOptional } from 'class-validator'; +import { Type } from 'class-transformer'; + +class SendEmailDto { + @IsEmail() + to: string; + + @IsString() + subject: string; + + @IsString() + html: string; + + @IsString() + @IsOptional() + text?: string; + + @IsString() + @IsOptional() + fromName?: string; +} + +class SendBulkEmailDto { + @IsArray() + @IsEmail({}, { each: true }) + to: string[]; + + @IsString() + subject: string; + + @IsString() + html: string; + + @IsString() + @IsOptional() + text?: string; + + @IsString() + @IsOptional() + fromName?: string; +} + +@Controller('email') +export class EmailController { + constructor(private readonly emailService: EmailService) {} + + @Post('send') + @UsePipes(new ValidationPipe({ transform: true })) + async sendEmail(@Body() sendEmailDto: SendEmailDto) { + const { to, subject, html, text, fromName } = sendEmailDto; + const result = await this.emailService.sendMail(to, subject, html, text, fromName); + + if (result) { + return { + success: true, + message: 'Email sent successfully', + }; + } else { + return { + success: false, + message: 'Email sending failed', + }; + } + } + + @Post('send-bulk') + @UsePipes(new ValidationPipe({ transform: true })) + async sendBulkEmail(@Body() sendBulkEmailDto: SendBulkEmailDto) { + const { to, subject, html, text, fromName } = sendBulkEmailDto; + const result = await this.emailService.sendBulkMail(to, subject, html, text, fromName); + + if (result) { + return { + success: true, + message: 'Bulk email sent successfully', + }; + } else { + return { + success: false, + message: 'Bulk email sending failed', + }; + } + } + + @Get('test') + async testEmail(@Query('to') to: string, @Query('fromName') fromName?: string) { + const result = await this.emailService.sendMail( + to, + 'Test Email', + '

This is a test email

Email sending functionality is working properly.

', + 'This is a test email, email sending functionality is working properly.', + fromName, + ); + + if (result) { + return { + success: true, + message: 'Test email sent successfully', + }; + } else { + return { + success: false, + message: 'Test email sending failed', + }; + } + } +} \ No newline at end of file diff --git a/backend/src/services/email.module.ts b/backend/src/services/email.module.ts new file mode 100644 index 0000000..a7f73ae --- /dev/null +++ b/backend/src/services/email.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { EmailService } from './email.service'; +import { EmailController } from '../controllers/email.controller'; +import { ConfigService } from '@nestjs/config'; + +@Module({ + providers: [EmailService, ConfigService], + controllers: [EmailController], + exports: [EmailService], +}) +export class EmailModule {} \ No newline at end of file diff --git a/backend/src/services/email.service.ts b/backend/src/services/email.service.ts new file mode 100644 index 0000000..5e54891 --- /dev/null +++ b/backend/src/services/email.service.ts @@ -0,0 +1,83 @@ +import { Injectable } from '@nestjs/common'; +import * as nodemailer from 'nodemailer'; +import { ConfigService } from '@nestjs/config'; + +@Injectable() +export class EmailService { + private transporter: nodemailer.Transporter; + + constructor(private configService: ConfigService) { + // Get email configuration from environment + const emailHost = this.configService.get('EMAIL_HOST'); + const emailPort = this.configService.get('EMAIL_PORT'); + const emailUser = this.configService.get('EMAIL_USER'); + const emailPassword = this.configService.get('EMAIL_PASSWORD'); + const emailSecure = this.configService.get('EMAIL_SECURE') === 'true'; + + this.transporter = nodemailer.createTransport({ + host: emailHost, + port: emailPort, + secure: emailSecure, // true for 465, false for other ports + auth: { + user: emailUser, // email address + pass: emailPassword, // email password/authorization code + }, + }); + } + + async sendMail( + to: string, + subject: string, + html: string, + text?: string, + fromName?: string, + ): Promise { + try { + const emailUser = this.configService.get('EMAIL_USER'); + const from = fromName ? `"${fromName}" <${emailUser}>` : emailUser; + + const mailOptions = { + from, // sender email with optional name + to, // recipient email + subject, // email subject + text, // plain text content (optional) + html, // HTML content + }; + + const info = await this.transporter.sendMail(mailOptions); + console.log('Email sent successfully:', info.messageId); + return true; + } catch (error) { + console.error('Email sending failed:', error); + return false; + } + } + + async sendBulkMail( + to: string[], + subject: string, + html: string, + text?: string, + fromName?: string, + ): Promise { + try { + const emailUser = this.configService.get('EMAIL_USER'); + const from = fromName ? `"${fromName}" <${emailUser}>` : emailUser; + + const mailOptions = { + from, // sender email with optional name + to: to.join(', '), // multiple recipients + subject, // email subject + text, // plain text content (optional) + html, // HTML content + }; + + const info = await this.transporter.sendMail(mailOptions); + console.log('Bulk email sent successfully:', info.messageId); + return true; + } catch (error) { + console.error('Bulk email sending failed:', error); + return false; + } + } +} \ No newline at end of file