diff --git a/backend/package-lock.json b/backend/package-lock.json index b4ad97d..d1b6a2c 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -21,6 +21,7 @@ "mongoose": "^8.6.3", "multer": "^1.4.5-lts.1", "multer-storage-cloudinary": "^4.0.0", + "nodemailer": "^6.9.16", "winston": "^3.14.2" }, "devDependencies": { @@ -34,6 +35,7 @@ "@types/jsonwebtoken": "^9.0.7", "@types/multer": "^1.4.12", "@types/node": "^22.7.2", + "@types/nodemailer": "^6.4.17", "@types/supertest": "^6.0.2", "jest": "^29.7.0", "nodemon": "^3.1.7", @@ -1763,6 +1765,16 @@ "undici-types": "~6.19.8" } }, + "node_modules/@types/nodemailer": { + "version": "6.4.17", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.17.tgz", + "integrity": "sha512-I9CCaIp6DTldEg7vyUTZi8+9Vo0hi1/T8gv3C89yk1rSAAzoKQ8H8ki/jBYJSFoH/BisgLP8tkZMlQ91CIquww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/qs": { "version": "6.9.16", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.16.tgz", @@ -5515,6 +5527,15 @@ "dev": true, "license": "MIT" }, + "node_modules/nodemailer": { + "version": "6.9.16", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.16.tgz", + "integrity": "sha512-psAuZdTIRN08HKVd/E8ObdV6NO7NTBY3KsC30F7M4H1OnmLCUNaS56FpYxyb26zWLSyYF9Ozch9KYHhHegsiOQ==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/nodemon": { "version": "3.1.7", "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.7.tgz", diff --git a/backend/package.json b/backend/package.json index e6f3c4f..ffae96e 100644 --- a/backend/package.json +++ b/backend/package.json @@ -27,6 +27,7 @@ "mongoose": "^8.6.3", "multer": "^1.4.5-lts.1", "multer-storage-cloudinary": "^4.0.0", + "nodemailer": "^6.9.16", "winston": "^3.14.2" }, "devDependencies": { @@ -40,6 +41,7 @@ "@types/jsonwebtoken": "^9.0.7", "@types/multer": "^1.4.12", "@types/node": "^22.7.2", + "@types/nodemailer": "^6.4.17", "@types/supertest": "^6.0.2", "jest": "^29.7.0", "nodemon": "^3.1.7", diff --git a/backend/src/controllers/users/user.controller.ts b/backend/src/controllers/users/user.controller.ts index fd82a06..54c3784 100644 --- a/backend/src/controllers/users/user.controller.ts +++ b/backend/src/controllers/users/user.controller.ts @@ -6,7 +6,9 @@ import bookingRepo from '../../database/repositories/booking.repo'; import activityRepo from '../../database/repositories/activity.repo'; import itineraryRepo from '../../database/repositories/itinerary.repo'; import bcrypt from 'bcrypt'; +import emailService from '../../services/email/email.service'; import { accountType } from '../../types/User.types'; +import authService from '../../services/auth.service'; const getUsers = async (req: Request, res: Response) => { try { @@ -206,6 +208,51 @@ const rejectUser = async (req: Request, res: Response) => { } }; +const forgotPassword = async (req: Request, res: Response) => { + try { + const email = req.body.email; + const user = await userRepo.findUserByEmail(email); + if (!user) { + res.status(ResponseStatusCodes.NOT_FOUND).json({ message: 'User not found' }); + return; + } + + user.OTP = Math.floor(100000 + Math.random() * 900000).toString(); + await userRepo.updateUser(user._id, user); + + console.log(user); + + await emailService.sendForgotPasswordEmail(email, user.OTP); + + res.status(ResponseStatusCodes.OK).json({ message: 'OTP sent to email' }); + } catch (error: any) { + logger.error(`Error sending OTP: ${error.message}`); + res.status(ResponseStatusCodes.INTERNAL_SERVER_ERROR).json({ message: error.message }); + } +}; + +const verifyOTP = async (req: Request, res: Response) => { + try { + const email = req.body.email; + const OTP = req.body.OTP; + const user = await userRepo.findUserByEmail(email); + if (!user) { + res.status(ResponseStatusCodes.NOT_FOUND).json({ message: 'User not found' }); + return; + } + + if (user.OTP === OTP) { + const token = authService.generateAccessToken({ userId: user._id, accountType: user.account_type }); + res.status(ResponseStatusCodes.OK).json({ message: 'OTP verified', data: { token: token } }); + } else { + res.status(ResponseStatusCodes.UNAUTHORIZED).json({ message: 'Invalid OTP' }); + } + } catch (error: any) { + logger.error(`Error verifying OTP: ${error.message}`); + res.status(ResponseStatusCodes.INTERNAL_SERVER_ERROR).json({ message: error.message }); + } +}; + const getItinerary = async (req: Request, res: Response) => { try { const itineraries = await userRepo.getItinerary(req.user.userId); @@ -363,6 +410,8 @@ export { ChangeUserPassword, acceptTerms, rejectUser, + forgotPassword, + verifyOTP, getItinerary, getActivity, cancelActivityBooking, diff --git a/backend/src/database/models/user.model.ts b/backend/src/database/models/user.model.ts index fe17bea..b1ea5c3 100644 --- a/backend/src/database/models/user.model.ts +++ b/backend/src/database/models/user.model.ts @@ -54,6 +54,29 @@ const companyProfileSchema = new mongoose.Schema({ }, }); +const notificationSchema = new mongoose.Schema({ + title: { + type: String, + }, + message: { + type: String, + }, + notificationType: { + type: String, + enum: ['error', 'warning', 'information', 'success'], + default: 'information', + required: true, + }, + read: { + type: Boolean, + default: false, + }, + createdAt: { + type: Date, + default: Date.now, + }, +}); + const userSchema = new mongoose.Schema( { account_type: { @@ -154,6 +177,12 @@ const userSchema = new mongoose.Schema( purchased_products: { type: [{ type: Schema.Types.ObjectId, ref: 'product' }], }, + notifications: { + type: [notificationSchema], + }, + OTP: { + type: String, + }, cart: { type: [cartItemSchema], default: [], diff --git a/backend/src/middlewares/auth.middleware.ts b/backend/src/middlewares/auth.middleware.ts index 57e2b22..8de8713 100644 --- a/backend/src/middlewares/auth.middleware.ts +++ b/backend/src/middlewares/auth.middleware.ts @@ -48,9 +48,11 @@ const openPaths = [ { path: '/api/museums/getall', methods: ['GET'] }, { path: '/api/activities', methods: ['GET'] }, { path: '/api/attachments', methods: ['POST'] }, - { path: '/api/termsAndConditions', methods: ['GET'] }, // Add /terms to the openPaths list + { path: '/api/termsAndConditions', methods: ['GET'] }, { path: '/api/tags', methods: ['GET'] }, { path: '/api/categories', methods: ['GET'] }, + { path: '/api/users/forgotPassword', methods: ['POST'] }, + { path: '/api/users/verifyOTP', methods: ['POST'] }, ]; const authenticateUnlessOpen = (req: Request, res: Response, next: NextFunction) => { diff --git a/backend/src/routes/user.route.ts b/backend/src/routes/user.route.ts index 1cc4d54..9a7bd3b 100644 --- a/backend/src/routes/user.route.ts +++ b/backend/src/routes/user.route.ts @@ -23,11 +23,17 @@ import { getPurchasedProducts, getHowManyUsers, getHowManyUsersByMonth, + forgotPassword, + verifyOTP, } from '../controllers/users/user.controller'; import cartController from '../controllers/cart.controller'; const router = Router(); +// Open routes +router.post('/forgotPassword', forgotPassword); +router.post('/verifyOTP', verifyOTP); + // User-specific routes router.use('/advertisers', advertiserRouter); router.use('/tourGuides', tourGuideRouter); diff --git a/backend/src/services/auth.service.ts b/backend/src/services/auth.service.ts index c6944ad..c18bb96 100644 --- a/backend/src/services/auth.service.ts +++ b/backend/src/services/auth.service.ts @@ -1,7 +1,7 @@ import jwt from 'jsonwebtoken'; class AuthService { - public generateAccessToken(user: any) { + public generateAccessToken(user: { userId: string; accountType: string }) { return jwt.sign(user, process.env.TOKEN_SECRET as string, { expiresIn: '30d' }); } } diff --git a/backend/src/services/email/email-templates/forgotPassword.html b/backend/src/services/email/email-templates/forgotPassword.html new file mode 100644 index 0000000..aa07f22 --- /dev/null +++ b/backend/src/services/email/email-templates/forgotPassword.html @@ -0,0 +1,59 @@ + + + + + + Forgot Password + + + +
+

Forgot Password

+
+ + + + + + +
+
+ + diff --git a/backend/src/services/email/email-templates/notification.html b/backend/src/services/email/email-templates/notification.html new file mode 100644 index 0000000..d67acf1 --- /dev/null +++ b/backend/src/services/email/email-templates/notification.html @@ -0,0 +1,49 @@ + + + + + + Notification + + + +
+

{{title}}

+

{{message}}

+ +
+ + diff --git a/backend/src/services/email/email.service.ts b/backend/src/services/email/email.service.ts new file mode 100644 index 0000000..70c10eb --- /dev/null +++ b/backend/src/services/email/email.service.ts @@ -0,0 +1,82 @@ +import nodemailer from 'nodemailer'; +import fs from 'fs'; +import path from 'path'; +import { NotificationType } from '../../types/Notification.types'; + +class EmailService { + email = process.env.EMAIL; + password = process.env.PASSWORD; + + transporter = nodemailer.createTransport({ + host: 'smtp.office365.com', + port: 587, + secure: false, + auth: { + user: this.email, + pass: this.password, + }, + tls: { + ciphers: 'SSLv3', + rejectUnauthorized: false, + }, + }); + + sendNotificationEmail(email: string, notification: NotificationType) { + try { + // Read the HTML file + const filePath = path.join(__dirname, '../email-templates/notification.html'); + let htmlContent = fs.readFileSync(filePath, 'utf-8'); + + // Replace the placeholders with the actual notification data + htmlContent = htmlContent.replace('{{title}}', notification.title ?? 'Notification'); + htmlContent = htmlContent.replace('{{message}}', notification.message ?? ''); + + const mailOptions = { + from: this.email, + to: email, + subject: notification.title ?? 'Notification', + html: htmlContent, + }; + + this.transporter.sendMail(mailOptions, (err, info) => { + if (err) { + console.log(err); + } else { + console.log('Email sent: ' + info.response); + } + }); + } catch (error) { + console.log('Error sending notification email:', error); + } + } + + async sendForgotPasswordEmail(email: string, OTP: string) { + try { + // Read the HTML file + const filePath = path.join(__dirname, './email-templates/forgotPassword.html'); + let htmlContent = fs.readFileSync(filePath, 'utf-8'); + + // Replace the placeholder with the actual OTP + htmlContent = htmlContent.replace('{{otp}}', OTP); + + const mailOptions = { + from: this.email, + to: email, + subject: 'Reset Password', + html: htmlContent, + }; + + this.transporter.sendMail(mailOptions, (err, info) => { + if (err) { + console.log(err); + } else { + console.log('Email sent: ' + info.response); + } + }); + } catch (error) { + console.log('Error sending forgot password email:', error); + } + } +} + +export default new EmailService(); diff --git a/backend/src/services/notification.service.ts b/backend/src/services/notification.service.ts new file mode 100644 index 0000000..aadc713 --- /dev/null +++ b/backend/src/services/notification.service.ts @@ -0,0 +1,49 @@ +import { NotificationType, createDefaultNotification, NotificationTypeEnum } from '../types/Notification.types'; +import userRepo from '../database/repositories/user.repo'; +import emailer from './email/email.service'; + +class NotificationService { + notification: NotificationType; + userId: string; + + constructor(userId: string, title: string, message: string, notificationType?: NotificationTypeEnum) { + this.notification = createDefaultNotification({ title, message, notificationType }); + this.userId = userId; + + this.updateUser(this.userId); + this.sendNotificationEmail(); + } + + async updateUser(userId: string) { + try { + const user = await userRepo.findUserById(userId); + if (!user) { + throw new Error('User not found'); + } + user.notifications.push(this.notification); + await userRepo.updateUser(userId, user); + } catch (error) { + console.log(error); + } + } + + async sendNotificationEmail() { + try { + const user = await userRepo.findUserById(this.userId); + if (!user) { + throw new Error('User not found'); + } + const email = user.email; + + if (!email) { + throw new Error('User email not found'); + } + + emailer.sendNotificationEmail(email, this.notification); + } catch (error) { + console.log(error); + } + } +} + +export default NotificationService; diff --git a/backend/src/types/Notification.types.ts b/backend/src/types/Notification.types.ts new file mode 100644 index 0000000..d5af1c6 --- /dev/null +++ b/backend/src/types/Notification.types.ts @@ -0,0 +1,24 @@ +export enum NotificationTypeEnum { + ERROR = 'error', + WARNING = 'warning', + INFORMATION = 'information', + SUCCESS = 'success', +} + +export interface NotificationType { + _id?: string; + title?: string; + message?: string; + notificationType: NotificationTypeEnum; + read: boolean; + createdAt: Date; +} + +export const createDefaultNotification = (partial: Partial): NotificationType => ({ + title: '', + message: '', + notificationType: NotificationTypeEnum.INFORMATION, + read: false, + createdAt: new Date(), + ...partial, +}); diff --git a/backend/src/types/User.types.ts b/backend/src/types/User.types.ts index 3287ff9..c12c874 100644 --- a/backend/src/types/User.types.ts +++ b/backend/src/types/User.types.ts @@ -1,6 +1,7 @@ import { Types } from 'mongoose'; import { ObjectId } from 'mongodb'; import { OrderItemType } from './Order.types'; +import { NotificationType } from './Notification.types'; export enum accountType { Admin = 'Admin', @@ -42,6 +43,8 @@ export interface UserType { nationality?: string | null; rejected: boolean; termsAndConditions: boolean; + notifications: [NotificationType]; + OTP: string; attachments: Types.ObjectId[]; // Tour guide years_of_experience?: number | null;