From a859f048f6ce865f881d89d57d2a361e28173cf3 Mon Sep 17 00:00:00 2001 From: Anjula Shanaka Date: Sat, 1 Jun 2024 16:09:37 +0530 Subject: [PATCH] Add email notifications API (#117) --- .env.example | 2 + package-lock.json | 41 ++- package.json | 4 + src/app.ts | 2 + src/configs/envConfig.ts | 2 + src/controllers/admin/email.controller.ts | 44 +-- src/controllers/admin/mentee.controller.ts | 25 ++ src/controllers/auth.controller.ts | 9 +- src/controllers/mentee.controller.ts | 23 +- src/entities/email.entity.ts | 2 +- src/entities/mentee.entity.ts | 4 +- src/routes/admin/mentee/mentee.route.ts | 2 +- src/routes/emails/emails.route.ts | 9 + src/routes/mentee/mentee.route.ts | 4 +- src/services/admin/email.service.ts | 63 +++-- .../admin/email_admin.service.test.ts | 108 -------- src/services/admin/mentee.service.test.ts | 101 ++++++- src/services/admin/mentee.service.ts | 126 ++++----- src/services/admin/mentor.service.ts | 16 ++ src/services/mentee.service.ts | 85 ++++++ src/services/mentor.service.ts | 17 +- src/templates/emailTemplate.ejs | 252 ++++++++++++++++++ src/utils.ts | 95 +++++++ 23 files changed, 786 insertions(+), 250 deletions(-) create mode 100644 src/routes/emails/emails.route.ts delete mode 100644 src/services/admin/email_admin.service.test.ts create mode 100644 src/services/mentee.service.ts create mode 100644 src/templates/emailTemplate.ejs diff --git a/.env.example b/.env.example index faf6c715..fa617df9 100644 --- a/.env.example +++ b/.env.example @@ -10,3 +10,5 @@ GOOGLE_CLIENT_SECRET='your_google_client_secret' GOOGLE_REDIRECT_URL=http://localhost:${SERVER_PORT}/api/auth/google/callback CLIENT_URL=http://localhost:5173 IMG_HOST=http://localhost:${SERVER_PORT} +SMTP_MAIL=your_smtp_mail +SMTP_PASSWORD=your_smtp_password diff --git a/package-lock.json b/package-lock.json index 126f90a1..73437cd7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,9 +15,11 @@ "cookie-parser": "^1.4.6", "cors": "^2.8.5", "dotenv": "^16.3.1", + "ejs": "^3.1.10", "express": "^4.18.2", "jsonwebtoken": "^9.0.2", "multer": "^1.4.5-lts.1", + "nodemailer": "^6.9.13", "passport": "^0.6.0", "passport-google-oauth20": "^2.0.0", "passport-jwt": "^4.0.1", @@ -31,11 +33,13 @@ "@types/body-parser": "^1.19.2", "@types/cookie-parser": "^1.4.3", "@types/cors": "^2.8.13", + "@types/ejs": "^3.1.5", "@types/express": "^4.17.17", "@types/jest": "^29.5.3", "@types/jsonwebtoken": "^9.0.2", "@types/multer": "^1.4.11", "@types/node": "^20.1.4", + "@types/nodemailer": "^6.4.15", "@types/passport": "^1.0.12", "@types/passport-google-oauth20": "^2.0.14", "@types/passport-jwt": "^3.0.9", @@ -2114,6 +2118,12 @@ "@types/node": "*" } }, + "node_modules/@types/ejs": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/@types/ejs/-/ejs-3.1.5.tgz", + "integrity": "sha512-nv+GSx77ZtXiJzwKdsASqi+YQ5Z7vwHsTP0JY2SiQgjGckkBRKZnk8nIM+7oUZ1VCtuTz0+By4qVR7fqzp/Dfg==", + "dev": true + }, "node_modules/@types/express": { "version": "4.17.17", "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.17.tgz", @@ -2222,6 +2232,15 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.1.4.tgz", "integrity": "sha512-At4pvmIOki8yuwLtd7BNHl3CiWNbtclUbNtScGx4OHfBd4/oWoJC8KRCIxXwkdndzhxOsPXihrsOoydxBjlE9Q==" }, + "node_modules/@types/nodemailer": { + "version": "6.4.15", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.15.tgz", + "integrity": "sha512-0EBJxawVNjPkng1zm2vopRctuWVCxk34JcIlRuXSf54habUWdz1FB7wHDqOqvDa8Mtpt0Q3LTXQkAs2LNyK5jQ==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/oauth": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/@types/oauth/-/oauth-0.9.4.tgz", @@ -3388,8 +3407,7 @@ "node_modules/async": { "version": "3.2.5", "resolved": "https://registry.npmjs.org/async/-/async-3.2.5.tgz", - "integrity": "sha512-baNZyqaaLhyLVKm/DlvdW051MSgO6b8eVfIezl9E5PqWxFgzLm/wQntEW4zOytVburDEr0JlALEpdOFwvErLsg==", - "peer": true + "integrity": "sha512-baNZyqaaLhyLVKm/DlvdW051MSgO6b8eVfIezl9E5PqWxFgzLm/wQntEW4zOytVburDEr0JlALEpdOFwvErLsg==" }, "node_modules/asynckit": { "version": "0.4.0", @@ -4655,10 +4673,9 @@ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" }, "node_modules/ejs": { - "version": "3.1.9", - "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.9.tgz", - "integrity": "sha512-rC+QVNMJWv+MtPgkt0y+0rVEIdbtxVADApW9JXrUVlzHetgcyczP/E7DJmWJ4fJCZF2cPcBk0laWO9ZHMG3DmQ==", - "peer": true, + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", + "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", "dependencies": { "jake": "^10.8.5" }, @@ -5718,7 +5735,6 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz", "integrity": "sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==", - "peer": true, "dependencies": { "minimatch": "^5.0.1" } @@ -5727,7 +5743,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "peer": true, "dependencies": { "balanced-match": "^1.0.0" } @@ -5736,7 +5751,6 @@ "version": "5.1.6", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", - "peer": true, "dependencies": { "brace-expansion": "^2.0.1" }, @@ -6961,7 +6975,6 @@ "version": "10.8.7", "resolved": "https://registry.npmjs.org/jake/-/jake-10.8.7.tgz", "integrity": "sha512-ZDi3aP+fG/LchyBzUM804VjddnwfSfsdeYkwt8NcbKRvo4rFkjhs456iLFn3k2ZUWvNe4i48WACDbza8fhq2+w==", - "peer": true, "dependencies": { "async": "^3.2.3", "chalk": "^4.0.2", @@ -8153,6 +8166,14 @@ "integrity": "sha512-5GFldHPXVG/YZmFzJvKK2zDSzPKhEp0+ZR5SVaoSag9fsL5YgHbUHDfnG5494ISANDcK4KwPXAx2xqVEydmd7w==", "dev": true }, + "node_modules/nodemailer": { + "version": "6.9.13", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.13.tgz", + "integrity": "sha512-7o38Yogx6krdoBf3jCAqnIN4oSQFx+fMa0I7dK1D+me9kBxx12D+/33wSb+fhOCtIxvYJ+4x4IMEhmhCKfAiOA==", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/nodemon": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.0.1.tgz", diff --git a/package.json b/package.json index bd56b11b..b686d40a 100644 --- a/package.json +++ b/package.json @@ -20,9 +20,11 @@ "cookie-parser": "^1.4.6", "cors": "^2.8.5", "dotenv": "^16.3.1", + "ejs": "^3.1.10", "express": "^4.18.2", "jsonwebtoken": "^9.0.2", "multer": "^1.4.5-lts.1", + "nodemailer": "^6.9.13", "passport": "^0.6.0", "passport-google-oauth20": "^2.0.0", "passport-jwt": "^4.0.1", @@ -36,11 +38,13 @@ "@types/body-parser": "^1.19.2", "@types/cookie-parser": "^1.4.3", "@types/cors": "^2.8.13", + "@types/ejs": "^3.1.5", "@types/express": "^4.17.17", "@types/jest": "^29.5.3", "@types/jsonwebtoken": "^9.0.2", "@types/multer": "^1.4.11", "@types/node": "^20.1.4", + "@types/nodemailer": "^6.4.15", "@types/passport": "^1.0.12", "@types/passport-google-oauth20": "^2.0.14", "@types/passport-jwt": "^3.0.9", diff --git a/src/app.ts b/src/app.ts index eba0998d..265775bb 100644 --- a/src/app.ts +++ b/src/app.ts @@ -14,6 +14,7 @@ import { CLIENT_URL } from './configs/envConfig' import cookieParser from 'cookie-parser' import menteeRouter from './routes/mentee/mentee.route' import fs from 'fs' +import emailRouter from './routes/emails/emails.route' const app = express() const staticFolder = 'uploads' @@ -39,6 +40,7 @@ app.use('/api/admin', adminRouter) app.use('/api/mentors', mentorRouter) app.use('/api/mentees', menteeRouter) app.use('/api/categories', categoryRouter) +app.use('/api/emails', emailRouter) if (!fs.existsSync(staticFolder)) { fs.mkdirSync(staticFolder, { recursive: true }) diff --git a/src/configs/envConfig.ts b/src/configs/envConfig.ts index 7364d705..fc8ea9f2 100644 --- a/src/configs/envConfig.ts +++ b/src/configs/envConfig.ts @@ -14,3 +14,5 @@ export const GOOGLE_CLIENT_SECRET = process.env.GOOGLE_CLIENT_SECRET ?? '' export const GOOGLE_REDIRECT_URL = process.env.GOOGLE_REDIRECT_URL ?? '' export const CLIENT_URL = process.env.CLIENT_URL ?? '' export const IMG_HOST = process.env.IMG_HOST ?? '' +export const SMTP_MAIL = process.env.SMTP_MAIL ?? '' +export const SMTP_PASS = process.env.SMTP_PASS ?? '' diff --git a/src/controllers/admin/email.controller.ts b/src/controllers/admin/email.controller.ts index 22ec3d33..758a4ea6 100644 --- a/src/controllers/admin/email.controller.ts +++ b/src/controllers/admin/email.controller.ts @@ -1,28 +1,32 @@ -import type { Request, Response } from 'express' -import type { ApiResponse } from '../../types' -import { getAllMenteeEmailsService } from '../../services/admin/email.service' -import { ApplicationStatus } from '../../enums' +import { type Request, type Response } from 'express' +import type Profile from '../../entities/profile.entity' +import { type ApiResponse } from '../../types' +import type Email from '../../entities/email.entity' +import { ProfileTypes } from '../../enums' +import { sendEmail } from '../../services/admin/email.service' -export const getAllMenteeEmails = async ( +export const sendEmailController = async ( req: Request, res: Response -): Promise> => { +): Promise> => { + const { to, subject, text } = req.body + try { - const status = req.query.status - if ( - status === ApplicationStatus.APPROVED || - status === ApplicationStatus.REJECTED || - status === ApplicationStatus.PENDING - ) { - const { emails, statusCode, message } = await getAllMenteeEmailsService( - status - ) - return res.status(statusCode).json({ emails, message }) - } else { - return res.status(400).json({ message: 'Invalid Status' }) + const user = req.user as Profile + + if (user.type !== ProfileTypes.ADMIN) { + return res.status(403).json({ message: 'Only Admins are allowed' }) } + + const { statusCode, message } = await sendEmail(to, subject, text) + return res.status(statusCode).json({ message }) } catch (err) { - console.error(err) - return res.status(500).json({ error: err || 'Internal Server Error' }) + if (err instanceof Error) { + console.error('Error executing query', err) + return res + .status(500) + .json({ error: 'Internal server error', message: err.message }) + } + throw err } } diff --git a/src/controllers/admin/mentee.controller.ts b/src/controllers/admin/mentee.controller.ts index afc2d028..d60498a6 100644 --- a/src/controllers/admin/mentee.controller.ts +++ b/src/controllers/admin/mentee.controller.ts @@ -4,6 +4,7 @@ import type Profile from '../../entities/profile.entity' import type Mentee from '../../entities/mentee.entity' import type { ApiResponse } from '../../types' import { + getAllMenteeEmailsService, getAllMentees, getMentee, updateStatus @@ -90,3 +91,27 @@ export const getMenteeDetails = async ( throw err } } + +export const getAllMenteeEmails = async ( + req: Request, + res: Response +): Promise> => { + try { + const status = req.query.status + if ( + status === ApplicationStatus.APPROVED || + status === ApplicationStatus.REJECTED || + status === ApplicationStatus.PENDING + ) { + const { emails, statusCode, message } = await getAllMenteeEmailsService( + status + ) + return res.status(statusCode).json({ emails, message }) + } else { + return res.status(400).json({ message: 'Invalid Status' }) + } + } catch (err) { + console.error(err) + return res.status(500).json({ error: err || 'Internal Server Error' }) + } +} diff --git a/src/controllers/auth.controller.ts b/src/controllers/auth.controller.ts index 7e0898b6..c24e018c 100644 --- a/src/controllers/auth.controller.ts +++ b/src/controllers/auth.controller.ts @@ -50,6 +50,13 @@ export const register = async ( first_name, last_name ) + + const { user } = await loginUser(email, password) + + if (user?.uuid) { + signAndSetCookie(res, user.uuid) + } + return res.status(statusCode).json({ message, profile }) } catch (err) { if (err instanceof Error) { @@ -130,7 +137,7 @@ export const requireAuth = ( const token = req.cookies.jwt if (!token) { - return res.status(401).json({ error: 'No token provided' }) + return res.status(401).json({ error: 'Use is not authenticated' }) } try { diff --git a/src/controllers/mentee.controller.ts b/src/controllers/mentee.controller.ts index c878cfea..945d2992 100644 --- a/src/controllers/mentee.controller.ts +++ b/src/controllers/mentee.controller.ts @@ -2,8 +2,9 @@ import type { Request, Response } from 'express' import { type ApiResponse } from '../types' import type Mentee from '../entities/mentee.entity' import type Profile from '../entities/profile.entity' -import { addMentee, updateStatus } from '../services/admin/mentee.service' +import { getMentee, updateStatus } from '../services/admin/mentee.service' import { ApplicationStatus } from '../enums' +import { addMentee } from '../services/mentee.service' export const menteeApplicationHandler = async ( req: Request, @@ -59,3 +60,23 @@ export const updateMenteeStatus = async ( throw err } } + +export const getMenteeDetails = async ( + req: Request, + res: Response +): Promise> => { + try { + const { menteeId } = req.params + + const { statusCode, message, mentee } = await getMentee(menteeId) + return res.status(statusCode).json({ mentee, message }) + } catch (err) { + if (err instanceof Error) { + console.error('Error executing query', err) + return res + .status(500) + .json({ error: 'Internal server error', message: err.message }) + } + throw err + } +} diff --git a/src/entities/email.entity.ts b/src/entities/email.entity.ts index 3e5d6ce5..17d1139e 100644 --- a/src/entities/email.entity.ts +++ b/src/entities/email.entity.ts @@ -10,7 +10,7 @@ class Email extends BaseEntity { @Column({ type: 'varchar', length: 255 }) subject: string - @Column({ type: 'varchar', length: 655 }) + @Column('varchar') content: string @Column({ type: 'enum', enum: EmailStatusTypes }) diff --git a/src/entities/mentee.entity.ts b/src/entities/mentee.entity.ts index 16e94540..e8cb7d44 100644 --- a/src/entities/mentee.entity.ts +++ b/src/entities/mentee.entity.ts @@ -14,7 +14,7 @@ class Mentee extends BaseEntity { state: ApplicationStatus @Column({ type: 'json' }) - application: JSON + application: Record @Column({ type: 'bigint', nullable: true, default: null }) certificate_id!: bigint @@ -31,7 +31,7 @@ class Mentee extends BaseEntity { constructor( state: ApplicationStatus, - application: JSON, + application: Record, profile: profileEntity, mentor: Mentor ) { diff --git a/src/routes/admin/mentee/mentee.route.ts b/src/routes/admin/mentee/mentee.route.ts index 5a7fa6e6..e06e9054 100644 --- a/src/routes/admin/mentee/mentee.route.ts +++ b/src/routes/admin/mentee/mentee.route.ts @@ -1,9 +1,9 @@ import express from 'express' import { requireAuth } from '../../../controllers/auth.controller' -import { getAllMenteeEmails } from '../../../controllers/admin/email.controller' import { getMentees, updateMenteeStatus, + getAllMenteeEmails, getMenteeDetails } from '../../../controllers/admin/mentee.controller' diff --git a/src/routes/emails/emails.route.ts b/src/routes/emails/emails.route.ts new file mode 100644 index 00000000..d52fc8ee --- /dev/null +++ b/src/routes/emails/emails.route.ts @@ -0,0 +1,9 @@ +import express from 'express' +import { sendEmailController } from '../../controllers/admin/email.controller' +import { requireAuth } from '../../controllers/auth.controller' + +const emailRouter = express.Router() + +emailRouter.post('/send', requireAuth, sendEmailController) + +export default emailRouter diff --git a/src/routes/mentee/mentee.route.ts b/src/routes/mentee/mentee.route.ts index de2d6c88..bdce9250 100644 --- a/src/routes/mentee/mentee.route.ts +++ b/src/routes/mentee/mentee.route.ts @@ -2,12 +2,14 @@ import express from 'express' import { requireAuth } from '../../controllers/auth.controller' import { menteeApplicationHandler, - updateMenteeStatus + updateMenteeStatus, + getMenteeDetails } from '../../controllers/mentee.controller' const menteeRouter = express.Router() menteeRouter.post('/', requireAuth, menteeApplicationHandler) +menteeRouter.get('/:menteeId', getMenteeDetails) menteeRouter.put('/:menteeId/status/', requireAuth, updateMenteeStatus) export default menteeRouter diff --git a/src/services/admin/email.service.ts b/src/services/admin/email.service.ts index 92d2d1db..79b1ddd7 100644 --- a/src/services/admin/email.service.ts +++ b/src/services/admin/email.service.ts @@ -1,33 +1,50 @@ import { dataSource } from '../../configs/dbConfig' -import Mentee from '../../entities/mentee.entity' -import type { ApplicationStatus } from '../../enums' +import { EmailStatusTypes } from '../../enums' +import nodemailer from 'nodemailer' +import Email from '../../entities/email.entity' +import { SMTP_MAIL, SMTP_PASS } from '../../configs/envConfig' +import { loadTemplate } from '../../utils' -export const getAllMenteeEmailsService = async ( - status: ApplicationStatus | undefined +const transporter = nodemailer.createTransport({ + host: 'smtp.gmail.com', + port: 587, + secure: false, + auth: { + user: SMTP_MAIL, + pass: SMTP_PASS + } +}) + +export const sendEmail = async ( + to: string, + subject: string, + message: string ): Promise<{ statusCode: number - emails?: string[] message: string }> => { + const emailRepository = dataSource.getRepository(Email) + try { - const menteeRepositroy = dataSource.getRepository(Mentee) - const allMentees: Mentee[] = await menteeRepositroy.find({ - where: status ? { state: status } : {}, - relations: ['profile'] + const html = await loadTemplate('emailTemplate', { + subject, + message }) - const emails = allMentees.map((mentee) => mentee?.profile?.primary_email) - if (!emails) { - return { - statusCode: 404, - message: 'Mentees Emails not found' - } - } - return { - statusCode: 200, - emails, - message: 'All mentee emails with status ' + (status ?? 'undefined') - } - } catch (err) { - throw new Error('Error getting mentee emails') + + await transporter.sendMail({ + from: `"Sustainable Education Foundation" <${SMTP_MAIL}>`, + to, + subject, + html + }) + + const email = new Email(to, subject, message, EmailStatusTypes.SENT) + + await emailRepository.save(email) + + return { statusCode: 200, message: 'Email sent and saved successfully' } + } catch (error) { + console.error('Error sending email:', error) + throw new Error('Error sending email') } } diff --git a/src/services/admin/email_admin.service.test.ts b/src/services/admin/email_admin.service.test.ts deleted file mode 100644 index ca992686..00000000 --- a/src/services/admin/email_admin.service.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { getAllMenteeEmailsService } from './email.service' -import { dataSource } from '../../configs/dbConfig' -import type Mentee from '../../entities/mentee.entity' -import { ApplicationStatus } from '../../enums' - -jest.mock('../../configs/dbConfig', () => ({ - dataSource: { - getRepository: jest.fn() - } -})) - -describe('Mentee Service - getAllMenteeEmailsService', () => { - it('should get all mentee emails with a specific status successfully', async () => { - const status: ApplicationStatus = ApplicationStatus.APPROVED - - const mockMentees = [ - { - profile: { - primary_email: 'mentee1@example.com' - } - }, - { - profile: { - primary_email: 'mentee2@example.com' - } - } - ] as Mentee[] - - const mockMenteeRepository = { - find: jest.fn().mockResolvedValue(mockMentees) - } - - ;(dataSource.getRepository as jest.Mock).mockReturnValueOnce( - mockMenteeRepository - ) - - const result = await getAllMenteeEmailsService(status) - - expect(result.statusCode).toBe(200) - expect(result.emails?.length).toBe(2) - expect(result.emails).toEqual([ - 'mentee1@example.com', - 'mentee2@example.com' - ]) - expect(result.message).toBe('All mentee emails with status ' + status) - }) - - it('should get all mentee emails when status is undefined successfully', async () => { - const mockMentees = [ - { - profile: { - primary_email: 'mentee1@example.com' - } - }, - { - profile: { - primary_email: 'mentee2@example.com' - } - } - ] as Mentee[] - - const mockMenteeRepository = { - find: jest.fn().mockResolvedValue(mockMentees) - } - - ;(dataSource.getRepository as jest.Mock).mockReturnValueOnce( - mockMenteeRepository - ) - - const result = await getAllMenteeEmailsService(undefined) - - expect(result.statusCode).toBe(200) - expect(result.emails?.length).toBe(2) - expect(result.emails).toEqual([ - 'mentee1@example.com', - 'mentee2@example.com' - ]) - expect(result.message).toBe('All mentee emails with status undefined') - }) - - it('should handle mentees emails not found', async () => { - const mockMenteeRepository = { - find: jest.fn().mockResolvedValue([]) - } - - ;(dataSource.getRepository as jest.Mock).mockReturnValueOnce( - mockMenteeRepository - ) - - const result = await getAllMenteeEmailsService(ApplicationStatus.PENDING) - - expect(result.emails?.length).toBe(0) - }) - - it('should handle error during mentee emails retrieval', async () => { - const mockMenteeRepository = { - find: jest.fn().mockRejectedValue(new Error('Test repository error')) - } - - ;(dataSource.getRepository as jest.Mock).mockReturnValueOnce( - mockMenteeRepository - ) - - await expect( - getAllMenteeEmailsService(ApplicationStatus.APPROVED) - ).rejects.toThrowError('Error getting mentee emails') - }) -}) diff --git a/src/services/admin/mentee.service.test.ts b/src/services/admin/mentee.service.test.ts index 88d64511..cee158e3 100644 --- a/src/services/admin/mentee.service.test.ts +++ b/src/services/admin/mentee.service.test.ts @@ -1,6 +1,7 @@ -import { getAllMentees } from './mentee.service' +import { getAllMentees, getAllMenteeEmailsService } from './mentee.service' import { dataSource } from '../../configs/dbConfig' import { ApplicationStatus } from '../../enums' +import type Mentee from '../../entities/mentee.entity' jest.mock('../../configs/dbConfig', () => ({ dataSource: { @@ -8,6 +9,104 @@ jest.mock('../../configs/dbConfig', () => ({ } })) +describe('Mentee Service - getAllMenteeEmailsService', () => { + it('should get all mentee emails with a specific status successfully', async () => { + const status: ApplicationStatus = ApplicationStatus.APPROVED + + const mockMentees = [ + { + profile: { + primary_email: 'mentee1@example.com' + } + }, + { + profile: { + primary_email: 'mentee2@example.com' + } + } + ] as Mentee[] + + const mockMenteeRepository = { + find: jest.fn().mockResolvedValue(mockMentees) + } + + ;(dataSource.getRepository as jest.Mock).mockReturnValueOnce( + mockMenteeRepository + ) + + const result = await getAllMenteeEmailsService(status) + + expect(result.statusCode).toBe(200) + expect(result.emails?.length).toBe(2) + expect(result.emails).toEqual([ + 'mentee1@example.com', + 'mentee2@example.com' + ]) + expect(result.message).toBe('All mentee emails with status ' + status) + }) + + it('should get all mentee emails when status is undefined successfully', async () => { + const mockMentees = [ + { + profile: { + primary_email: 'mentee1@example.com' + } + }, + { + profile: { + primary_email: 'mentee2@example.com' + } + } + ] as Mentee[] + + const mockMenteeRepository = { + find: jest.fn().mockResolvedValue(mockMentees) + } + + ;(dataSource.getRepository as jest.Mock).mockReturnValueOnce( + mockMenteeRepository + ) + + const result = await getAllMenteeEmailsService(undefined) + + expect(result.statusCode).toBe(200) + expect(result.emails?.length).toBe(2) + expect(result.emails).toEqual([ + 'mentee1@example.com', + 'mentee2@example.com' + ]) + expect(result.message).toBe('All mentee emails with status undefined') + }) + + it('should handle mentees emails not found', async () => { + const mockMenteeRepository = { + find: jest.fn().mockResolvedValue([]) + } + + ;(dataSource.getRepository as jest.Mock).mockReturnValueOnce( + mockMenteeRepository + ) + + const result = await getAllMenteeEmailsService(ApplicationStatus.PENDING) + + expect(result.emails?.length).toBe(0) + }) + + it('should handle error during mentee emails retrieval', async () => { + const mockMenteeRepository = { + find: jest.fn().mockRejectedValue(new Error('Test repository error')) + } + + ;(dataSource.getRepository as jest.Mock).mockReturnValueOnce( + mockMenteeRepository + ) + + await expect( + getAllMenteeEmailsService(ApplicationStatus.APPROVED) + ).rejects.toThrowError('Error getting mentee emails') + }) +}) + describe('Mentee Service', () => { describe('getAllMentees', () => { it('should get all mentees successfully', async () => { diff --git a/src/services/admin/mentee.service.ts b/src/services/admin/mentee.service.ts index c36f2c7b..5613bdfe 100644 --- a/src/services/admin/mentee.service.ts +++ b/src/services/admin/mentee.service.ts @@ -1,8 +1,9 @@ import { dataSource } from '../../configs/dbConfig' import Mentee from '../../entities/mentee.entity' import Mentor from '../../entities/mentor.entity' -import type Profile from '../../entities/profile.entity' import { ApplicationStatus } from '../../enums' +import { getEmailContent } from '../../utils' +import { sendEmail } from './email.service' export const getAllMentees = async ( status: ApplicationStatus | undefined @@ -36,70 +37,6 @@ export const getAllMentees = async ( } } -export const addMentee = async ( - user: Profile, - application: JSON, - mentorId: string -): Promise<{ - statusCode: number - mentee?: Mentee - message: string -}> => { - try { - const menteeRepository = dataSource.getRepository(Mentee) - const mentorRepository = dataSource.getRepository(Mentor) - - const mentor = await mentorRepository.findOne({ - where: { uuid: mentorId } - }) - - if (mentor === null || mentor === undefined) { - return { - statusCode: 404, - message: 'Mentor not found' - } - } - - const existingMentees: Mentee[] = await menteeRepository.find({ - where: { profile: { uuid: user.uuid } } - }) - - for (const mentee of existingMentees) { - switch (mentee.state) { - case ApplicationStatus.PENDING: - return { - statusCode: 409, - message: 'The mentee application is pending' - } - case ApplicationStatus.APPROVED: - return { - statusCode: 409, - message: 'The user is already a mentee' - } - default: - break - } - } - - const newMentee = new Mentee( - ApplicationStatus.PENDING, - application, - user, - mentor - ) - - await menteeRepository.save(newMentee) - - return { - statusCode: 200, - mentee: newMentee, - message: 'New mentee created' - } - } catch (err) { - throw new Error('Error adding mentee') - } -} - export const getAllMenteesByMentor = async ( status: ApplicationStatus | undefined, userId: string @@ -143,7 +80,7 @@ export const getAllMenteesByMentor = async ( export const updateStatus = async ( menteeId: string, - state: string + state: ApplicationStatus ): Promise<{ statusCode: number updatedMenteeApplication?: Mentee @@ -183,23 +120,22 @@ export const updateStatus = async ( message: 'Mentee is already approved' } } else { - switch (state) { - case 'approved': - mentee.state = ApplicationStatus.APPROVED - break - case 'rejected': - mentee.state = ApplicationStatus.REJECTED - break - case 'pending': - mentee.state = ApplicationStatus.PENDING - break - default: - break + await menteeRepository.update({ uuid: menteeId }, { state }) + const content = getEmailContent( + 'mentee', + state, + mentee.application.firstName as string + ) + + if (content) { + await sendEmail( + mentee.application.email as string, + content.subject, + content.message + ) } - const updatedMenteeApplication = await menteeRepository.save(mentee) return { statusCode: 200, - updatedMenteeApplication, message: 'Mentee application state successfully updated' } } @@ -209,6 +145,36 @@ export const updateStatus = async ( } } +export const getAllMenteeEmailsService = async ( + status: ApplicationStatus | undefined +): Promise<{ + statusCode: number + emails?: string[] + message: string +}> => { + try { + const menteeRepositroy = dataSource.getRepository(Mentee) + const allMentees: Mentee[] = await menteeRepositroy.find({ + where: status ? { state: status } : {}, + relations: ['profile'] + }) + const emails = allMentees.map((mentee) => mentee?.profile?.primary_email) + if (!emails) { + return { + statusCode: 404, + message: 'Mentees Emails not found' + } + } + return { + statusCode: 200, + emails, + message: 'All mentee emails with status ' + (status ?? 'undefined') + } + } catch (err) { + throw new Error('Error getting mentee emails') + } +} + export const getMentee = async ( menteeId: string ): Promise<{ diff --git a/src/services/admin/mentor.service.ts b/src/services/admin/mentor.service.ts index 93eb991f..d5364096 100644 --- a/src/services/admin/mentor.service.ts +++ b/src/services/admin/mentor.service.ts @@ -1,6 +1,8 @@ import { dataSource } from '../../configs/dbConfig' import Mentor from '../../entities/mentor.entity' import type { ApplicationStatus } from '../../enums' +import { getEmailContent } from '../../utils' +import { sendEmail } from './email.service' export const updateMentorStatus = async ( mentorId: string, @@ -26,6 +28,20 @@ export const updateMentorStatus = async ( await mentorRepository.update({ uuid: mentorId }, { state: status }) + const content = getEmailContent( + 'mentor', + status, + mentor.application.firstName as string + ) + + if (content) { + await sendEmail( + mentor.application.email as string, + content.subject, + content.message + ) + } + return { statusCode: 200, mentor, diff --git a/src/services/mentee.service.ts b/src/services/mentee.service.ts new file mode 100644 index 00000000..993daab9 --- /dev/null +++ b/src/services/mentee.service.ts @@ -0,0 +1,85 @@ +import { dataSource } from '../configs/dbConfig' +import Mentee from '../entities/mentee.entity' +import Mentor from '../entities/mentor.entity' +import type Profile from '../entities/profile.entity' +import { ApplicationStatus } from '../enums' +import { getEmailContent } from '../utils' +import { sendEmail } from './admin/email.service' + +export const addMentee = async ( + user: Profile, + application: Record, + mentorId: string +): Promise<{ + statusCode: number + mentee?: Mentee + message: string +}> => { + try { + const menteeRepository = dataSource.getRepository(Mentee) + const mentorRepository = dataSource.getRepository(Mentor) + + const mentor = await mentorRepository.findOne({ + where: { uuid: mentorId } + }) + + if (mentor === null || mentor === undefined) { + return { + statusCode: 404, + message: 'Mentor not found' + } + } + + const existingMentees: Mentee[] = await menteeRepository.find({ + where: { profile: { uuid: user.uuid } } + }) + + for (const mentee of existingMentees) { + switch (mentee.state) { + case ApplicationStatus.PENDING: + return { + statusCode: 409, + message: 'The mentee application is pending' + } + case ApplicationStatus.APPROVED: + return { + statusCode: 409, + message: 'The user is already a mentee' + } + default: + break + } + } + + const newMentee = new Mentee( + ApplicationStatus.PENDING, + application, + user, + mentor + ) + + await menteeRepository.save(newMentee) + + const content = getEmailContent( + 'mentee', + ApplicationStatus.PENDING, + application.firstName as string + ) + + if (content) { + await sendEmail( + application.email as string, + content.subject, + content.message + ) + } + + return { + statusCode: 200, + mentee: newMentee, + message: 'New mentee created' + } + } catch (err) { + throw new Error('Error adding mentee') + } +} diff --git a/src/services/mentor.service.ts b/src/services/mentor.service.ts index 8f440d14..88d7ff9a 100644 --- a/src/services/mentor.service.ts +++ b/src/services/mentor.service.ts @@ -3,7 +3,8 @@ import Mentor from '../entities/mentor.entity' import type Profile from '../entities/profile.entity' import { ApplicationStatus } from '../enums' import Category from '../entities/category.entity' -import { getMentorPublicData } from '../utils' +import { getEmailContent, getMentorPublicData } from '../utils' +import { sendEmail } from './admin/email.service' export const createMentor = async ( user: Profile, @@ -63,6 +64,20 @@ export const createMentor = async ( await mentorRepository.save(newMentor) + const content = getEmailContent( + 'mentor', + ApplicationStatus.PENDING, + application.firstName as string + ) + + if (content) { + await sendEmail( + application.email as string, + content.subject, + content.message + ) + } + return { statusCode: 201, mentor: newMentor, diff --git a/src/templates/emailTemplate.ejs b/src/templates/emailTemplate.ejs new file mode 100644 index 00000000..4002377b --- /dev/null +++ b/src/templates/emailTemplate.ejs @@ -0,0 +1,252 @@ + + + + ScholarX email template + + + + + + + + +
+ + + + +
+ + + + + + + + + + +
+ +
+
+

+ <%- message %> +

+

+ Best regards,
+ ScholarX Team,
+ Sustainable Education Foundation. +

+

+ + View Dashboard + + + Join our Slack + +

+
+
+

+ + facebook-icon + + + facebook-icon + + + facebook-icon + + + facebook-icon + +

+

+ © Sustainable Education Foundation - SEF 2024 +

+
+
+
+ + diff --git a/src/utils.ts b/src/utils.ts index ee5294b7..b3ab6906 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -4,6 +4,8 @@ import type { Response } from 'express' import type Mentor from './entities/mentor.entity' import path from 'path' import multer from 'multer' +import ejs from 'ejs' +import { ApplicationStatus } from './enums' export const signAndSetCookie = (res: Response, uuid: string): void => { const token = jwt.sign({ userId: uuid }, JWT_SECRET ?? '', { @@ -58,3 +60,96 @@ export const upload = multer({ checkProfilePictureFileType(file, cb) } }).single('profile_image') + +export const loadTemplate = ( + templateName: string, + data: { + subject: string + message: string + } +): any => { + const templatePath = path.join(__dirname, 'templates', `${templateName}.ejs`) + + return new Promise((resolve, reject) => { + ejs.renderFile(templatePath, data, (err, str) => { + if (err) { + reject(err) + } else { + resolve(str) + } + }) + }) +} + +export const getEmailContent = ( + type: 'mentor' | 'mentee', + status: ApplicationStatus, + name: string +): { subject: string; message: string } | undefined => { + if (type === 'mentor') { + switch (status) { + case ApplicationStatus.PENDING: + return { + subject: 'Your ScholarX Mentor Application Status', + message: `Dear ${name},

+ Thank you very much for applying to ScholarX. Your application has been received. Our team will soon review your application, and we will keep you posted on the progress via email. + Please reach out to us via sustainableedufoundation@gmail.com for any clarifications. To ensure that you receive our emails and they do not go to your spam folder, please add sustainableedufoundation@gmail.com to your email whitelist.` + } + case ApplicationStatus.APPROVED: + return { + subject: + 'Congratulations! You have been selected as a ScholarX Mentor', + message: `Dear ${name},

+ I hope this email finds you in high spirits! I am delighted to inform you that you have been selected as a mentor for ScholarX, and we extend our heartfelt congratulations to you!

+ We received a large number of qualified applicants, and after a thorough review of all candidates, we are thrilled to invite you to accept a place in our program. Your profile stood out amongst the others, and we are confident that you will contribute positively to our program.

+ We understand that your hard work and dedication have brought you to this moment, and we recognize your exceptional talent, experience, and potential in your respective fields. We are excited to have you join our community of learners and scholars.

+ We look forward to seeing the unique perspective and insights you will bring to the mentees and to the program. We believe that you will flourish in this year's edition of ScholarX, and we are thrilled to be a part of your academic or professional journey.

+ Once again, congratulations on your selection! We cannot wait to have you on board. We will keep you informed on the next steps, and in the meantime would like to invite you to go through some of the resources that would be useful to thrive as a great mentor in ScholarX.

+ To ensure that you receive our emails and they do not go to your spam folder, please add sustainableedufoundation@gmail.com to your email whitelist.` + } + case ApplicationStatus.REJECTED: + return { + subject: 'ScholarX Mentor Application Status Update', + message: `Dear ${name},

+ I hope this email finds you well. I wanted to take a moment to thank you for your interest in joining ScholarX as a mentor and for submitting your application. We appreciate the time and effort you put into it.

+ After careful review of your application and considering all of the candidates, we regret to inform you that we are unable to make you part of the mentor base at this time. We received a large number of qualified applicants, and unfortunately, we could only accept a limited number of mentors.

+ We understand that this news may be disappointing, and we encourage you not to be discouraged by this decision. Please know that this does not reflect on your abilities, potential, or value as an individual. As you progress ahead on your academic or professional journey, we would be glad to have you as a mentor for future ScholarX programs.

+ We appreciate your interest in our program and would like to wish you all the best in your future endeavors. We are grateful for the opportunity to consider you for our program and encourage you to keep pursuing your goals and aspirations.

+ Thank you again for considering our program and for the time you invested in your application. We hope you find success and fulfillment in your academic and professional pursuits. To ensure that you receive our emails and they do not go to your spam folder, please add sustainableedufoundation@gmail.com to your email whitelist.` + } + default: + return undefined + } + } else { + switch (status) { + case ApplicationStatus.PENDING: + return { + subject: 'Your ScholarX Mentee Application Status', + message: `Dear ${name},

+ Thank you very much for applying to ScholarX. Your application has been received. Mentor will soon review your application and we will keep you posted on the progress via email. Until then, read more about student experience here and reach out to us via sustainableedufoundation@gmail.com for any clarifications. To ensure that you receive our emails and they do not go to your spam folder, please add sustainableedufoundation@gmail.com to your email whitelist.` + } + case ApplicationStatus.APPROVED: + return { + subject: 'Congratulations! You have been selected for ScholarX', + message: `Dear ${name},

+ We are delighted to inform you that you have been selected for our undergraduate program, and we extend our heartfelt congratulations to you!

+ We received a large number of qualified applicants, and after a thorough review of all candidates, we are thrilled to offer you a place in our program. Your application stood out amongst the others, and we are confident that you will contribute positively to our program.

+ We believe that you have great potential to succeed in your academic and professional pursuits, and we are excited to have you join our community of learners and scholars.

+ To emphasize the importance of completing the program, you have received a valuable opportunity. If, for any reason, you are uncertain about completing the program within the 6-month timeline, please inform our admissions team as soon as possible, so we can provide the opportunity to another deserving student.

+ Once again, congratulations on your selection! We cannot wait to have you on board. To ensure that you receive our emails and they do not go to your spam folder, please add sustainableedufoundation@gmail.com to your email whitelist.` + } + case ApplicationStatus.REJECTED: + return { + subject: 'ScholarX Mentee Application Status Update', + message: `Dear ${name},

+ We wanted to take a moment to thank you for your interest in the ScholarX program and for submitting your application. We appreciate the time and effort you put into it.

+ After a careful review of your application and considering all of the candidates, we regret to inform you that we are unable to offer you admission at this time. We received a large number of qualified applicants, and unfortunately, we could only accept a limited number of students.

+ However, we want to encourage you not to be discouraged by this decision. We recognize that the admissions process can be competitive, and we understand that this news may be disappointing. Please know that this does not reflect on your abilities, potential, or value as an individual.

+ We do offer the possibility for you to apply again next time if you meet the eligibility criteria. We invite you to stay engaged with us by attending our events, reaching out to our admissions team, and taking advantage of any opportunities to connect with our current students and alumni.

+ Thank you again for considering our program and for the time you invested in your application. We wish you all the best in your future endeavours. To ensure that you receive our emails and they do not go to your spam folder, please add sustainableedufoundation@gmail.com to your email whitelist.` + } + default: + return undefined + } + } +}