diff --git a/.DS_Store b/.DS_Store index 1ad1b253c..92031f7e6 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/package-lock.json b/package-lock.json index 8eff8a588..03192c678 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4797,6 +4797,11 @@ } } }, + "boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==" + }, "brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", @@ -4923,6 +4928,33 @@ "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", "dev": true }, + "cheerio": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0-rc.12.tgz", + "integrity": "sha512-VqR8m68vM46BNnuZ5NtnGBKIE/DfN0cRIzg9n40EIq9NOv90ayxLBXA8fXC5gquFRGJSTRqBq25Jt2ECLR431Q==", + "requires": { + "cheerio-select": "^2.1.0", + "dom-serializer": "^2.0.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1", + "htmlparser2": "^8.0.1", + "parse5": "^7.0.0", + "parse5-htmlparser2-tree-adapter": "^7.0.0" + } + }, + "cheerio-select": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz", + "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==", + "requires": { + "boolbase": "^1.0.0", + "css-select": "^5.1.0", + "css-what": "^6.1.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1" + } + }, "chokidar": { "version": "3.5.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", @@ -5321,6 +5353,23 @@ } } }, + "css-select": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.1.0.tgz", + "integrity": "sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==", + "requires": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + } + }, + "css-what": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.1.0.tgz", + "integrity": "sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==" + }, "damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", @@ -11511,6 +11560,14 @@ } } }, + "nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "requires": { + "boolbase": "^1.0.0" + } + }, "object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -12330,6 +12387,23 @@ "resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz", "integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==" }, + "parse5": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.2.tgz", + "integrity": "sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==", + "requires": { + "entities": "^4.4.0" + } + }, + "parse5-htmlparser2-tree-adapter": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.0.0.tgz", + "integrity": "sha512-B77tOZrqqfUfnVcOrUvfdLbz4pu4RopLD/4vmu3HUPswwTA8OH0EMW9BlWR2B0RCoiZRAHEUu7IxeP1Pd1UU+g==", + "requires": { + "domhandler": "^5.0.2", + "parse5": "^7.0.0" + } + }, "parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", diff --git a/package.json b/package.json index 8b88744dc..91e73f39a 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "babel-plugin-module-resolver": "^5.0.0", "bcryptjs": "^2.4.3", "body-parser": "^1.18.3", + "cheerio": "^1.0.0-rc.12", "cors": "^2.8.4", "cron": "^1.8.2", "dotenv": "^5.0.1", diff --git a/requirements/emailController/addNonHgnEmailSubscription.md b/requirements/emailController/addNonHgnEmailSubscription.md new file mode 100644 index 000000000..f5748f142 --- /dev/null +++ b/requirements/emailController/addNonHgnEmailSubscription.md @@ -0,0 +1,23 @@ +# Add Non-HGN Email Subscription Function + +## Negative Cases + +1. ❌ **Returns error 400 if `email` field is missing from the request** + - Ensures that the function checks for the presence of the `email` field in the request body and responds with a `400` status code if it's missing. + +2. ❌ **Returns error 400 if the provided `email` already exists in the subscription list** + - This case checks that the function responds with a `400` status code and a message indicating that the email is already subscribed. + +3. ❌ **Returns error 500 if there is an internal error while checking the subscription list** + - Covers scenarios where there's an issue querying the `EmailSubscriptionList` collection for the provided email (e.g., database connection issues). + +4. ❌ **Returns error 500 if there is an error sending the confirmation email** + - This case handles any issues that occur while calling the `emailSender` function, such as network errors or service unavailability. + +## Positive Cases + +1. ❌ **Returns status 200 when a new email is successfully subscribed** + - Ensures that the function successfully creates a JWT token, constructs the email, and sends the subscription confirmation email to the user. + +2. ❌ **Successfully sends a confirmation email containing the correct link** + - Verifies that the generated JWT token is correctly included in the confirmation link sent to the user in the email body. diff --git a/requirements/emailController/confirmNonHgnEmailSubscription.md b/requirements/emailController/confirmNonHgnEmailSubscription.md new file mode 100644 index 000000000..d5e1367af --- /dev/null +++ b/requirements/emailController/confirmNonHgnEmailSubscription.md @@ -0,0 +1,18 @@ +# Confirm Non-HGN Email Subscription Function Tests + +## Negative Cases +1. ✅ **Returns error 400 if `token` field is missing from the request** + - (Test: `should return 400 if token is not provided`) + +2. ✅ **Returns error 401 if the provided `token` is invalid or expired** + - (Test: `should return 401 if token is invalid`) + +3. ✅ **Returns error 400 if the decoded `token` does not contain a valid `email` field** + - (Test: `should return 400 if email is missing from payload`) + +4. ❌ **Returns error 500 if there is an internal error while saving the new email subscription** + +## Positive Cases +1. ❌ **Returns status 200 when a new email is successfully subscribed** + +2. ❌ **Returns status 200 if the email is already subscribed (duplicate email)** diff --git a/requirements/emailController/removeNonHgnEmailSubscription.md b/requirements/emailController/removeNonHgnEmailSubscription.md new file mode 100644 index 000000000..af793e2a9 --- /dev/null +++ b/requirements/emailController/removeNonHgnEmailSubscription.md @@ -0,0 +1,10 @@ +# Remove Non-HGN Email Subscription Function Tests + +## Negative Cases +1. ✅ **Returns error 400 if `email` field is missing from the request** + - (Test: `should return 400 if email is missing`) + +2. ❌ **Returns error 500 if there is an internal error while deleting the email subscription** + +## Positive Cases +1. ❌ **Returns status 200 when an email is successfully unsubscribed** diff --git a/requirements/emailController/sendEmail.md b/requirements/emailController/sendEmail.md new file mode 100644 index 000000000..7ca9a482c --- /dev/null +++ b/requirements/emailController/sendEmail.md @@ -0,0 +1,10 @@ +# Send Email Function + +## Negative Cases + +1. ❌ **Returns error 400 if `to`, `subject`, or `html` fields are missing from the request** +2. ❌ **Returns error 500 if there is an internal error while sending the email** + +## Positive Cases + +1. ✅ **Returns status 200 when email is successfully sent with `to`, `subject`, and `html` fields provided** diff --git a/requirements/emailController/sendEmailToAll.md b/requirements/emailController/sendEmailToAll.md new file mode 100644 index 000000000..32a09fed6 --- /dev/null +++ b/requirements/emailController/sendEmailToAll.md @@ -0,0 +1,26 @@ +# Send Email to All Function + +## Negative Cases + +1. ❌ **Returns error 400 if `subject` or `html` fields are missing from the request** + - The request should be rejected if either the `subject` or `html` content is not provided in the request body. + +2. ❌ **Returns error 500 if there is an internal error while fetching users** + - This case covers scenarios where there's an error fetching users from the `userProfile` collection (e.g., database connection issues). + +3. ❌ **Returns error 500 if there is an internal error while fetching the subscription list** + - This case covers scenarios where there's an error fetching emails from the `EmailSubcriptionList` collection. + +4. ❌ **Returns error 500 if there is an error sending emails** + - This case handles any issues that occur while calling the `emailSender` function, such as network errors or service unavailability. + +## Positive Cases + +1. ❌ **Returns status 200 when emails are successfully sent to all active users** + - Ensures that the function sends emails correctly to all users meeting the criteria (`isActive` and `EmailSubcriptionList`). + +2. ❌ **Returns status 200 when emails are successfully sent to all users in the subscription list** + - Verifies that the function sends emails to all users in the `EmailSubcriptionList`, including the unsubscribe link in the email body. + +3. ❌ **Combines user and subscription list emails successfully** + - Ensures that the function correctly sends emails to both active users and the subscription list without issues. diff --git a/requirements/emailController/updateEmailSubscription.md b/requirements/emailController/updateEmailSubscription.md new file mode 100644 index 000000000..bcafa5a28 --- /dev/null +++ b/requirements/emailController/updateEmailSubscription.md @@ -0,0 +1,20 @@ +# Update Email Subscriptions Function + +## Negative Cases + +1. ❌ **Returns error 400 if `emailSubscriptions` field is missing from the request** + - This ensures that the function checks for the presence of the `emailSubscriptions` field in the request body and responds with a `400` status code if it's missing. + +2. ❌ **Returns error 400 if `email` field is missing from the requestor object** + - Ensures that the function requires an `email` field within the `requestor` object in the request body and returns `400` if it's absent. + +3. ❌ **Returns error 404 if the user with the provided `email` is not found** + - This checks that the function correctly handles cases where no user exists with the given `email` and responds with a `404` status code. + +4. ✅ **Returns error 500 if there is an internal error while updating the user profile** + - Covers scenarios where there's a database error while updating the user's email subscriptions. + +## Positive Cases + +1. ❌ **Returns status 200 and the updated user when email subscriptions are successfully updated** + - Ensures that the function updates the `emailSubscriptions` field for the user and returns the updated user document along with a `200` status code. diff --git a/requirements/popUpEditorController/createPopPopupEditor.md b/requirements/popUpEditorController/createPopPopupEditor.md new file mode 100644 index 000000000..0afcaa740 --- /dev/null +++ b/requirements/popUpEditorController/createPopPopupEditor.md @@ -0,0 +1,14 @@ +Check mark: ✅ +Cross Mark: ❌ + +# createPopPopupEditor Function + +> ### Positive case + +> 1. ✅ Should return 201 and the new pop-up editor on success + +> ### Negative case + +> 1. ✅ Should return 403 if user does not have permission to create a pop-up editor +> 2. ✅ Should return 400 if the request body is missing required fields +> 3. ✅ Should return 500 if there is an error saving the new pop-up editor to the database diff --git a/requirements/popUpEditorController/getAllPopupEditors.md b/requirements/popUpEditorController/getAllPopupEditors.md new file mode 100644 index 000000000..f42f93c1a --- /dev/null +++ b/requirements/popUpEditorController/getAllPopupEditors.md @@ -0,0 +1,10 @@ +Check mark: ✅ +Cross Mark: ❌ + +# getAllPopupEditors Function + +> ## Positive case +> 1. ✅ Should return 200 and all pop-up editors on success + +> ## Negative case +> 1. ✅ Should return 404 if there is an error retrieving the pop-up editors from the database diff --git a/requirements/popUpEditorController/getPopupEditorById.md b/requirements/popUpEditorController/getPopupEditorById.md new file mode 100644 index 000000000..013096ed9 --- /dev/null +++ b/requirements/popUpEditorController/getPopupEditorById.md @@ -0,0 +1,10 @@ +Check mark: ✅ +Cross Mark: ❌ + +# getPopupEditorById Function + +> ## Positive case +> 1. ✅ Should return 200 and the pop-up editor on success + +> ## Negative case +> 1. ✅ Should return 404 if the pop-up editor is not found diff --git a/requirements/popUpEditorController/updatePopupEditor.md b/requirements/popUpEditorController/updatePopupEditor.md new file mode 100644 index 000000000..c4e5f5904 --- /dev/null +++ b/requirements/popUpEditorController/updatePopupEditor.md @@ -0,0 +1,11 @@ +Check mark: ✅ +Cross Mark: ❌ + +# updatePopupEditor Function + +> ## Positive case +> 1. ✅ Should return 200 and the updated pop-up editor on success + + +> ## Negative case +> 1. ✅ Should return 404 if the pop-up editor is not found diff --git a/src/controllers/emailController.js b/src/controllers/emailController.js index ce6b3990d..0b2139d3a 100644 --- a/src/controllers/emailController.js +++ b/src/controllers/emailController.js @@ -1,13 +1,62 @@ // emailController.js -const nodemailer = require('nodemailer'); +// const nodemailer = require('nodemailer'); const jwt = require('jsonwebtoken'); +const cheerio = require('cheerio'); const emailSender = require('../utilities/emailSender'); +const { hasPermission } = require('../utilities/permissions'); const EmailSubcriptionList = require('../models/emailSubcriptionList'); const userProfile = require('../models/userProfile'); const frontEndUrl = process.env.FRONT_END_URL || 'http://localhost:3000'; const jwtSecret = process.env.JWT_SECRET || 'EmailSecret'; +const handleContentToOC = (htmlContent) => + ` + +
+ + + + ${htmlContent} + + `; + +const handleContentToNonOC = (htmlContent, email) => + ` + + + + + + ${htmlContent} +Thank you for subscribing to our email updates!
+If you would like to unsubscribe, please click here
+ + `; + +function extractImagesAndCreateAttachments(html) { + const $ = cheerio.load(html); + const attachments = []; + + $('img').each((i, img) => { + const src = $(img).attr('src'); + if (src.startsWith('data:image')) { + const base64Data = src.split(',')[1]; + const _cid = `image-${i}`; + attachments.push({ + filename: `image-${i}.png`, + content: Buffer.from(base64Data, 'base64'), + cid: _cid, + }); + $(img).attr('src', `cid:${_cid}`); + } + }); + return { + html: $.html(), + attachments, + }; +} + const sendEmail = async (req, res) => { const canSendEmail = await hasPermission(req.body.requestor, 'sendEmails'); if (!canSendEmail) { @@ -16,10 +65,27 @@ const sendEmail = async (req, res) => { } try { const { to, subject, html } = req.body; + // Validate required fields + if (!subject || !html || !to) { + const missingFields = []; + if (!subject) missingFields.push('Subject'); + if (!html) missingFields.push('HTML content'); + if (!to) missingFields.push('Recipient email'); + console.log('missingFields', missingFields); + return res + .status(400) + .send(`${missingFields.join(' and ')} ${missingFields.length > 1 ? 'are' : 'is'} required`); + } + + // Extract images and create attachments + const { html: processedHtml, attachments } = extractImagesAndCreateAttachments(html); + + // Log recipient for debugging + console.log('Recipient:', to); - console.log('to', to); + // Send email + emailSender(to, subject, handleContentToOC(processedHtml), attachments); - emailSender(to, subject, html); return res.status(200).send('Email sent successfully'); } catch (error) { console.error('Error sending email:', error); @@ -35,46 +101,44 @@ const sendEmailToAll = async (req, res) => { } try { const { subject, html } = req.body; + if (!subject || !html) { + return res.status(400).send('Subject and HTML content are required'); + } + + const { html: processedHtml, attachments } = extractImagesAndCreateAttachments(html); + const users = await userProfile.find({ - firstName: 'Haoji', + firstName: '', email: { $ne: null }, isActive: true, emailSubscriptions: true, }); - let to = ''; - const emailContent = ` - - - - + if (users.length === 0) { + return res.status(404).send('No users found'); + } + const recipientEmails = users.map((user) => user.email); + console.log('# sendEmailToAll to', recipientEmails.join(',')); + if (recipientEmails.length === 0) { + throw new Error('No recipients defined'); + } - - ${html} - - `; - users.forEach((user) => { - to += `${user.email},`; - }); - emailSender(to, subject, emailContent); - const emailList = await EmailSubcriptionList.find({ email: { $ne: null } }); - emailList.forEach((emailObject) => { - const { email } = emailObject; - const emailContent = ` - - - - + const emailContentToOCmembers = handleContentToOC(processedHtml); + await Promise.all( + recipientEmails.map((email) => + emailSender(email, subject, emailContentToOCmembers, attachments), + ), + ); + const emailSubscribers = await EmailSubcriptionList.find({ email: { $exists: true, $ne: '' } }); + console.log('# sendEmailToAll emailSubscribers', emailSubscribers.length); + await Promise.all( + emailSubscribers.map(({ email }) => { + const emailContentToNonOCmembers = handleContentToNonOC(processedHtml, email); + return emailSender(email, subject, emailContentToNonOCmembers, attachments); + }), + ); - - ${html} -Thank you for subscribing to our email updates!
-If you would like to unsubscribe, please click here
- - `; - emailSender(email, subject, emailContent); - }); return res.status(200).send('Email sent successfully'); } catch (error) { console.error('Error sending email:', error); @@ -112,13 +176,9 @@ const addNonHgnEmailSubscription = async (req, res) => { } const payload = { email }; - const token = jwt.sign( - payload, - jwtSecret, - { - expiresIn: 360, - }, - ); + const token = jwt.sign(payload, jwtSecret, { + expiresIn: 360, + }); const emailContent = ` diff --git a/src/controllers/emailController.spec.js b/src/controllers/emailController.spec.js new file mode 100644 index 000000000..f5327a328 --- /dev/null +++ b/src/controllers/emailController.spec.js @@ -0,0 +1,146 @@ +const { mockReq, mockRes, assertResMock } = require('../test'); +const emailController = require('./emailController'); +const jwt = require('jsonwebtoken'); +const userProfile = require('../models/userProfile'); + + +jest.mock('jsonwebtoken'); +jest.mock('../models/userProfile'); +jest.mock('../utilities/emailSender'); + + + + +const makeSut = () => { + const { + sendEmail, + sendEmailToAll, + updateEmailSubscriptions, + addNonHgnEmailSubscription, + removeNonHgnEmailSubscription, + confirmNonHgnEmailSubscription, + } = emailController; + return { + sendEmail, + sendEmailToAll, + updateEmailSubscriptions, + addNonHgnEmailSubscription, + removeNonHgnEmailSubscription, + confirmNonHgnEmailSubscription, + }; +}; +describe('emailController Controller Unit tests', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('sendEmail function', () => { + test('should send email successfully', async () => { + const { sendEmail } = makeSut(); + const mockReq = { + body: { + to: 'recipient@example.com', + subject: 'Test Subject', + html: 'Test Body
', + }, + }; + const response = await sendEmail(mockReq, mockRes); + assertResMock(200, 'Email sent successfully', response, mockRes); + }); +}); + + describe('updateEmailSubscriptions function', () => { + test('should handle error when updating email subscriptions', async () => { + const { updateEmailSubscriptions } = makeSut(); + + + userProfile.findOneAndUpdate = jest.fn(); + + userProfile.findOneAndUpdate.mockRejectedValue(new Error('Update failed')); + + const mockReq = { + body: { + emailSubscriptions: ['subscription1', 'subscription2'], + requestor: { + email: 'test@example.com', + }, + }, + }; + + const response = await updateEmailSubscriptions(mockReq, mockRes); + + assertResMock(500, 'Error updating email subscriptions', response, mockRes); + }); + }); + + + describe('confirmNonHgnEmailSubscription function', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + beforeAll(() => { + jwt.verify = jest.fn(); + }); + + test('should return 400 if token is not provided', async () => { + const { confirmNonHgnEmailSubscription } = makeSut(); + + const mockReq = { body: {} }; + const response = await confirmNonHgnEmailSubscription(mockReq, mockRes); + + assertResMock(400, 'Invalid token', response, mockRes); + }); + + test('should return 401 if token is invalid', async () => { + const { confirmNonHgnEmailSubscription } = makeSut(); + const mockReq = { body: { token: 'invalidToken' } }; + + jwt.verify.mockImplementation(() => { + throw new Error('Token is not valid'); + }); + + await confirmNonHgnEmailSubscription(mockReq, mockRes); + + expect(mockRes.status).toHaveBeenCalledWith(401); + expect(mockRes.json).toHaveBeenCalledWith({ + errors: [ + { msg: 'Token is not valid' }, + ], + }); + }); + + + test('should return 400 if email is missing from payload', async () => { + const { confirmNonHgnEmailSubscription } = makeSut(); + const mockReq = { body: { token: 'validToken' } }; + + // Mocking jwt.verify to return a payload without email + jwt.verify.mockReturnValue({}); + + const response = await confirmNonHgnEmailSubscription(mockReq, mockRes); + + assertResMock(400, 'Invalid token', response, mockRes); + }); + + + + + + }); + describe('removeNonHgnEmailSubscription function', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + test('should return 400 if email is missing', async () => { + const { removeNonHgnEmailSubscription } = makeSut(); + const mockReq = { body: {} }; + + const response = await removeNonHgnEmailSubscription(mockReq, mockRes); + + assertResMock(400, 'Email is required', response, mockRes); + }); + }); + + }); diff --git a/src/controllers/jobsController.js b/src/controllers/jobsController.js new file mode 100644 index 000000000..f782e0eeb --- /dev/null +++ b/src/controllers/jobsController.js @@ -0,0 +1,117 @@ +const Job = require('../models/jobs'); // Import the Job model + +// Controller to fetch all jobs with pagination, search, and filtering +const getJobs = async (req, res) => { + const { page = 1, limit = 18, search = '', category = '' } = req.query; + + try { + // Validate query parameters + const pageNumber = Math.max(1, parseInt(page, 10)); // Ensure page is at least 1 + const limitNumber = Math.max(1, parseInt(limit, 10)); // Ensure limit is at least 1 + + // Build query object + const query = {}; + if (search) query.title = { $regex: search, $options: 'i' }; // Case-insensitive search + if (category) query.category = category; + + // Fetch total count for pagination metadata + const totalJobs = await Job.countDocuments(query); + + // Fetch paginated results + const jobs = await Job.find(query) + .skip((pageNumber - 1) * limitNumber) + .limit(limitNumber); + + // Prepare response + res.json({ + jobs, + pagination: { + totalJobs, + totalPages: Math.ceil(totalJobs / limitNumber), + currentPage: pageNumber, + limit: limitNumber, + hasNextPage: pageNumber < Math.ceil(totalJobs / limitNumber), + hasPreviousPage: pageNumber > 1, + }, + }); + } catch (error) { + res.status(500).json({ error: 'Failed to fetch jobs', details: error.message }); + } +}; + +// Controller to fetch job details by ID +const getJobById = async (req, res) => { + const { id } = req.params; + + try { + const job = await Job.findById(id); + if (!job) { + return res.status(404).json({ error: 'Job not found' }); + } + res.json(job); + } catch (error) { + res.status(500).json({ error: 'Failed to fetch job', details: error.message }); + } +}; + +// Controller to create a new job +const createJob = async (req, res) => { + const { title, category, description, imageUrl, location, applyLink, jobDetailsLink } = + req.body; + + try { + const newJob = new Job({ + title, + category, + description, + imageUrl, + location, + applyLink, + jobDetailsLink, + }); + + const savedJob = await newJob.save(); + res.status(201).json(savedJob); + } catch (error) { + res.status(500).json({ error: 'Failed to create job', details: error.message }); + } +}; + +// Controller to update an existing job by ID +const updateJob = async (req, res) => { + const { id } = req.params; + + try { + const updatedJob = await Job.findByIdAndUpdate(id, req.body, { new: true }); + if (!updatedJob) { + return res.status(404).json({ error: 'Job not found' }); + } + res.json(updatedJob); + } catch (error) { + res.status(500).json({ error: 'Failed to update job', details: error.message }); + } +}; + +// Controller to delete a job by ID +const deleteJob = async (req, res) => { + const { id } = req.params; + + try { + const deletedJob = await Job.findByIdAndDelete(id); + if (!deletedJob) { + return res.status(404).json({ error: 'Job not found' }); + } + res.json({ message: 'Job deleted successfully' }); + } catch (error) { + res.status(500).json({ error: 'Failed to delete job', details: error.message }); + } +}; + +// Export controllers as a plain object +module.exports = { + getJobs, + getJobById, + createJob, + updateJob, + deleteJob, +}; diff --git a/src/controllers/popupEditorController.spec.js b/src/controllers/popupEditorController.spec.js new file mode 100644 index 000000000..e70b05553 --- /dev/null +++ b/src/controllers/popupEditorController.spec.js @@ -0,0 +1,163 @@ +const PopUpEditor = require('../models/popupEditor'); +const { mockReq, mockRes, assertResMock } = require('../test'); + +jest.mock('../utilities/permissions'); + +const helper = require('../utilities/permissions'); +const popupEditorController = require('./popupEditorController'); + +const flushPromises = () => new Promise(setImmediate); + +const mockHasPermission = (value) => + jest.spyOn(helper, 'hasPermission').mockImplementationOnce(() => Promise.resolve(value)); + +const makeSut = () => { + const { getAllPopupEditors, getPopupEditorById, createPopupEditor, updatePopupEditor } = + popupEditorController(PopUpEditor); + return { getAllPopupEditors, getPopupEditorById, createPopupEditor, updatePopupEditor }; +}; + +describe('popupEditorController Controller Unit tests', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + describe(`getAllPopupEditors function`, () => { + test(`Should return 200 and popup editors on success`, async () => { + const { getAllPopupEditors } = makeSut(); + const mockPopupEditors = [{ popupName: 'popup', popupContent: 'content' }]; + jest.spyOn(PopUpEditor, 'find').mockResolvedValue(mockPopupEditors); + const response = await getAllPopupEditors(mockReq, mockRes); + assertResMock(200, mockPopupEditors, response, mockRes); + }); + + test(`Should return 404 on error`, async () => { + const { getAllPopupEditors } = makeSut(); + const error = new Error('Test Error'); + + jest.spyOn(PopUpEditor, 'find').mockRejectedValue(error); + const response = await getAllPopupEditors(mockReq, mockRes); + await flushPromises(); + + assertResMock(404, error, response, mockRes); + }); + }); + + describe(`getPopupEditorById function`, () => { + test(`Should return 200 and popup editor on success`, async () => { + const { getPopupEditorById } = makeSut(); + const mockPopupEditor = { popupName: 'popup', popupContent: 'content' }; + jest.spyOn(PopUpEditor, 'findById').mockResolvedValue(mockPopupEditor); + const response = await getPopupEditorById(mockReq, mockRes); + assertResMock(200, mockPopupEditor, response, mockRes); + }); + + test(`Should return 404 on error`, async () => { + const { getPopupEditorById } = makeSut(); + const error = new Error('Test Error'); + + jest.spyOn(PopUpEditor, 'findById').mockRejectedValue(error); + const response = await getPopupEditorById(mockReq, mockRes); + await flushPromises(); + + assertResMock(404, error, response, mockRes); + }); + }); + + describe(`createPopupEditor function`, () => { + test(`Should return 403 if user is not authorized`, async () => { + const { createPopupEditor } = makeSut(); + mockHasPermission(false); + const response = await createPopupEditor(mockReq, mockRes); + assertResMock( + 403, + { error: 'You are not authorized to create new popup' }, + response, + mockRes, + ); + }); + + test(`Should return 400 if popupName or popupContent is missing`, async () => { + const { createPopupEditor } = makeSut(); + mockHasPermission(true); + const response = await createPopupEditor(mockReq, mockRes); + assertResMock( + 400, + { error: 'popupName , popupContent are mandatory fields' }, + response, + mockRes, + ); + }); + + test(`Should return 201 and popup editor on success`, async () => { + const { createPopupEditor } = makeSut(); + mockHasPermission(true); + mockReq.body = { popupName: 'popup', popupContent: 'content' }; + const mockPopupEditor = { save: jest.fn().mockResolvedValue(mockReq.body) }; + jest.spyOn(PopUpEditor.prototype, 'save').mockImplementationOnce(mockPopupEditor.save); + const response = await createPopupEditor(mockReq, mockRes); + expect(mockPopupEditor.save).toHaveBeenCalled(); + assertResMock(201, mockReq.body, response, mockRes); + }); + + test(`Should return 500 on error`, async () => { + const { createPopupEditor } = makeSut(); + mockHasPermission(true); + const error = new Error('Test Error'); + + jest.spyOn(PopUpEditor.prototype, 'save').mockRejectedValue(error); + const response = await createPopupEditor(mockReq, mockRes); + await flushPromises(); + + assertResMock(500, { error }, response, mockRes); + }); + }); + describe(`updatePopupEditor function`, () => { + test(`Should return 403 if user is not authorized`, async () => { + const { updatePopupEditor } = makeSut(); + mockHasPermission(false); + const response = await updatePopupEditor(mockReq, mockRes); + assertResMock( + 403, + { error: 'You are not authorized to create new popup' }, + response, + mockRes, + ); + }); + + test(`Should return 400 if popupContent is missing`, async () => { + const { updatePopupEditor } = makeSut(); + mockReq.body = {}; + mockHasPermission(true); + const response = await updatePopupEditor(mockReq, mockRes); + assertResMock(400, { error: 'popupContent is mandatory field' }, response, mockRes); + }); + + test(`Should return 201 and popup editor on success`, async () => { + const { updatePopupEditor } = makeSut(); + mockHasPermission(true); + mockReq.body = { popupContent: 'content' }; + const mockPopupEditor = { save: jest.fn().mockResolvedValue(mockReq.body) }; + jest.spyOn(PopUpEditor, 'findById').mockImplementationOnce((mockReq, callback) => callback(null, mockPopupEditor)); + jest.spyOn(PopUpEditor.prototype, 'save').mockImplementationOnce(mockPopupEditor.save); + const response = await updatePopupEditor(mockReq, mockRes); + expect(mockPopupEditor.save).toHaveBeenCalled(); + assertResMock(201, mockReq.body, response, mockRes); + }); + + test('Should return 500 on popupEditor save error', async () => { + const { updatePopupEditor } = makeSut(); + mockHasPermission(true); + const err = new Error('Test Error'); + mockReq.body = { popupContent: 'content' }; + const mockPopupEditor = { save: jest.fn().mockRejectedValue(err)}; + jest + .spyOn(PopUpEditor, 'findById') + .mockImplementation((mockReq, callback) => callback(null, mockPopupEditor)); + jest.spyOn(PopUpEditor.prototype, 'save').mockImplementationOnce(mockPopupEditor.save); + const response = await updatePopupEditor(mockReq, mockRes); + await flushPromises(); + assertResMock(500, {err}, response, mockRes); + }); + }); +}); diff --git a/src/controllers/teamController.js b/src/controllers/teamController.js index f1fd2241d..42d9d8d25 100644 --- a/src/controllers/teamController.js +++ b/src/controllers/teamController.js @@ -6,8 +6,66 @@ const Logger = require('../startup/logger'); const teamcontroller = function (Team) { const getAllTeams = function (req, res) { - Team.find({}) - .sort({ teamName: 1 }) + Team.aggregate([ + { + $unwind: '$members', + }, + { + $lookup: { + from: 'userProfiles', + localField: 'members.userId', + foreignField: '_id', + as: 'userProfile', + }, + }, + { + $unwind: '$userProfile', + }, + { + $match: { + isActive: true, + } + }, + { + $group: { + _id: { + teamId: '$_id', + teamCode: '$userProfile.teamCode', + }, + count: { $sum: 1 }, + teamName: { $first: '$teamName' }, + members: { + $push: { + _id: '$userProfile._id', + name: '$userProfile.name', + email: '$userProfile.email', + teamCode: '$userProfile.teamCode', + addDateTime: '$members.addDateTime', + }, + }, + createdDatetime: { $first: '$createdDatetime' }, + modifiedDatetime: { $first: '$modifiedDatetime' }, + isActive: { $first: '$isActive' }, + }, + }, + { + $sort: { count: -1 }, // Sort by the most frequent teamCode + }, + { + $group: { + _id: '$_id.teamId', + teamCode: { $first: '$_id.teamCode' }, // Get the most frequent teamCode + teamName: { $first: '$teamName' }, + members: { $first: '$members' }, + createdDatetime: { $first: '$createdDatetime' }, + modifiedDatetime: { $first: '$modifiedDatetime' }, + isActive: { $first: '$isActive' }, + }, + }, + { + $sort: { teamName: 1 }, // Sort teams by name + }, + ]) .then((results) => res.status(200).send(results)) .catch((error) => { Logger.logException(error); @@ -223,10 +281,12 @@ const teamcontroller = function (Team) { }, }, ]) - .then((result) => res.status(200).send(result)) + .then((result) => { + res.status(200).send(result) + }) .catch((error) => { Logger.logException(error, null, `TeamId: ${teamId} Request:${req.body}`); - res.status(500).send(error); + return res.status(500).send(error); }); }; const updateTeamVisibility = async (req, res) => { @@ -310,6 +370,47 @@ const teamcontroller = function (Team) { }); }; + const getAllTeamMembers = async function (req,res) { + try{ + const teamIds = req.body; + const cacheKey='teamMembersCache' + if(cache.hasCache(cacheKey)){ + let data=cache.getCache('teamMembersCache') + return res.status(200).send(data); + } + if (!Array.isArray(teamIds) || teamIds.length === 0 || !teamIds.every(team => mongoose.Types.ObjectId.isValid(team._id))) { + return res.status(400).send({ error: 'Invalid request: teamIds must be a non-empty array of valid ObjectId strings.' }); + } + let data = await Team.aggregate([ + { + $match: { _id: { $in: teamIds.map(team => mongoose.Types.ObjectId(team._id)) } } + }, + { $unwind: '$members' }, + { + $lookup: { + from: 'userProfiles', + localField: 'members.userId', + foreignField: '_id', + as: 'userProfile', + }, + }, + { $unwind: { path: '$userProfile', preserveNullAndEmptyArrays: true } }, + { + $group: { + _id: '$_id', // Group by team ID + teamName: { $first: '$teamName' }, // Use $first to keep the team name + createdDatetime: { $first: '$createdDatetime' }, + members: { $push: '$members' }, // Rebuild the members array + }, + }, + ]) + cache.setCache(cacheKey,data) + res.status(200).send(data); + }catch(error){ + console.log(error) + res.status(500).send({'message':"Fetching team members failed"}); + } + } return { getAllTeams, getAllTeamCode, @@ -320,6 +421,7 @@ const teamcontroller = function (Team) { assignTeamToUsers, getTeamMembership, updateTeamVisibility, + getAllTeamMembers }; }; diff --git a/src/controllers/timeEntryController.js b/src/controllers/timeEntryController.js index 4cf5190db..7f17efd31 100644 --- a/src/controllers/timeEntryController.js +++ b/src/controllers/timeEntryController.js @@ -594,7 +594,7 @@ const timeEntrycontroller = function (TimeEntry) { await timeEntry.save({ session }); if (userprofile) { - await userprofile.save({ session }); + await userprofile.save({ session, validateModifiedOnly: true }); // since userprofile is updated, need to remove the cache so that the updated userprofile is fetched next time removeOutdatedUserprofileCache(userprofile._id.toString()); } @@ -867,7 +867,7 @@ const timeEntrycontroller = function (TimeEntry) { } await timeEntry.save({ session }); if (userprofile) { - await userprofile.save({ session }); + await userprofile.save({ session, validateModifiedOnly: true }); // since userprofile is updated, need to remove the cache so that the updated userprofile is fetched next time removeOutdatedUserprofileCache(userprofile._id.toString()); @@ -940,7 +940,7 @@ const timeEntrycontroller = function (TimeEntry) { await timeEntry.remove({ session }); if (userprofile) { - await userprofile.save({ session }); + await userprofile.save({ session, validateModifiedOnly: true }); // since userprofile is updated, need to remove the cache so that the updated userprofile is fetched next time removeOutdatedUserprofileCache(userprofile._id.toString()); @@ -1063,38 +1063,47 @@ const timeEntrycontroller = function (TimeEntry) { }); }; - const getTimeEntriesForReports = function (req, res) { + const getTimeEntriesForReports =async function (req, res) { const { users, fromDate, toDate } = req.body; - - TimeEntry.find( - { - personId: { $in: users }, - dateOfWork: { $gte: fromDate, $lte: toDate }, - }, - ' -createdDateTime', - ) - .populate('projectId') - - .then((results) => { - const data = []; - - results.forEach((element) => { - const record = {}; - record._id = element._id; - record.isTangible = element.isTangible; - record.personId = element.personId._id; - record.dateOfWork = element.dateOfWork; - [record.hours, record.minutes] = formatSeconds(element.totalSeconds); - record.projectId = element.projectId ? element.projectId._id : ''; - record.projectName = element.projectId ? element.projectId.projectName : ''; - data.push(record); - }); - - res.status(200).send(data); - }) - .catch((error) => { - res.status(400).send(error); + const cacheKey = `timeEntry_${fromDate}_${toDate}`; + const timeentryCache=cacheClosure(); + const cacheData=timeentryCache.hasCache(cacheKey) + if(cacheData){ + let data=timeentryCache.getCache(cacheKey); + return res.status(200).send(data); + } + try { + const results = await TimeEntry.find( + { + personId: { $in: users }, + dateOfWork: { $gte: fromDate, $lte: toDate }, + }, + '-createdDateTime' // Exclude unnecessary fields + ) + .lean() // Returns plain JavaScript objects, not Mongoose documents + .populate({ + path: 'projectId', + select: '_id projectName', // Only return necessary fields from the project + }) + .exec(); // Executes the query + const data = results.map(element => { + const record = { + _id: element._id, + isTangible: element.isTangible, + personId: element.personId, + dateOfWork: element.dateOfWork, + hours: formatSeconds(element.totalSeconds)[0], + minutes: formatSeconds(element.totalSeconds)[1], + projectId: element.projectId?._id || '', + projectName: element.projectId?.projectName || '', + }; + return record; }); + timeentryCache.setCache(cacheKey,data); + return res.status(200).send(data); + } catch (error) { + res.status(400).send(error); + } }; const getTimeEntriesForProjectReports = function (req, res) { @@ -1275,7 +1284,12 @@ const timeEntrycontroller = function (TimeEntry) { */ const getLostTimeEntriesForTeamList = function (req, res) { const { teams, fromDate, toDate } = req.body; - + const lostteamentryCache=cacheClosure() + const cacheKey='LostTeamEntry'+`_${fromDate}`+`_${toDate}`; + const cacheData=lostteamentryCache.getCache(cacheKey) + if(cacheData){ + return res.status(200).send(cacheData) + } TimeEntry.find( { entryType: 'team', @@ -1284,7 +1298,7 @@ const timeEntrycontroller = function (TimeEntry) { isActive: { $ne: false }, }, ' -createdDateTime', - ) + ).lean() .populate('teamId') .sort({ lastModifiedDateTime: -1 }) .then((results) => { @@ -1301,7 +1315,8 @@ const timeEntrycontroller = function (TimeEntry) { [record.hours, record.minutes] = formatSeconds(element.totalSeconds); data.push(record); }); - res.status(200).send(data); + lostteamentryCache.setCache(cacheKey,data); + return res.status(200).send(data); }) .catch((error) => { res.status(400).send(error); diff --git a/src/controllers/titleController.js b/src/controllers/titleController.js index 1240d3373..3ca3175f5 100644 --- a/src/controllers/titleController.js +++ b/src/controllers/titleController.js @@ -11,193 +11,193 @@ const getAllTeamCodeHelper = controller.getAllTeamCodeHelper; const titlecontroller = function (Title) { const cache = cacheClosure(); - const getAllTitles = function (req, res) { - Title.find({}) - .then((results) => res.status(200).send(results)) - .catch((error) => res.status(404).send(error)); + const getAllTitles = function (req, res) { + Title.find({}) + .then((results) => res.status(200).send(results)) + .catch((error) => res.status(404).send(error)); }; - const getTitleById = function (req, res) { - const { titleId } = req.params; + const getTitleById = function (req, res) { + const { titleId } = req.params; - Title.findById(titleId) - .then((results) => res.send(results)) - .catch((error) => res.send(error)); - }; + Title.findById(titleId) + .then((results) => res.send(results)) + .catch((error) => res.send(error)); + }; + + const postTitle = async function (req, res) { + const title = new Title(); + title.titleName = req.body.titleName; + title.titleCode = req.body.titleCode; + title.teamCode = req.body.teamCode; + title.projectAssigned = req.body.projectAssigned; + title.mediaFolder = req.body.mediaFolder; + title.teamAssiged = req.body.teamAssiged; + + const titleCodeRegex = /^[A-Za-z]+$/; + if (!title.titleCode || !title.titleCode.trim()) { + return res.status(400).send({ message: 'Title code cannot be empty.' }); + } else if (!titleCodeRegex.test(title.titleCode)) { + return res.status(400).send({ message: 'Title Code must contain only upper or lower case letters.' }); + } - const postTitle = async function (req, res) { - const title = new Title(); - title.titleName = req.body.titleName; - title.teamCode = req.body.teamCode; - title.projectAssigned = req.body.projectAssigned; - title.mediaFolder = req.body.mediaFolder; - title.teamAssiged = req.body.teamAssiged; + // valid title name + if (!title.titleName.trim()) { + res.status(400).send({ message: 'Title cannot be empty.' }); + return; + } + + // if media is empty + if (!title.mediaFolder.trim()) { + res.status(400).send({ message: 'Media folder cannot be empty.' }); + return; + } + + if (!title.teamCode) { + res.status(400).send({ message: 'Please provide a team code.' }); + return; + } + + const teamCodeExists = await checkTeamCodeExists(title.teamCode); + if (!teamCodeExists) { + res.status(400).send({ message: 'Invalid team code. Please provide a valid team code.' }); + return; + } + + // validate if project exist + const projectExist = await checkProjectExists(title.projectAssigned._id); + if (!projectExist) { + res.status(400).send({ message: 'Project lalala is empty or not exist!!!' }); + return; + } + + // validate if team exist + if (title.teamAssiged && title.teamAssiged._id === 'N/A') { + res.status(400).send({ message: 'Team not exists.' }); + return; + } + + title + .save() + .then((results) => res.status(200).send(results)) + .catch((error) => res.status(404).send(error)) + }; + + // update title function. + const updateTitle = async function (req, res) { + try { + + const filter = req.body.id; // valid title name - if (!title.titleName.trim()) { + if (!req.body.titleName.trim()) { res.status(400).send({ message: 'Title cannot be empty.' }); return; } - // if media is empty - if (!title.mediaFolder.trim()) { - res.status(400).send({ message: 'Media folder cannot be empty.' }); + if (!req.body.titleCode.trim()) { + res.status(400).send({ message: 'Title code cannot be empty.' }); return; } - const shortnames = title.titleName.trim().split(' '); - let shortname; - if (shortnames.length > 1) { - shortname = (shortnames[0][0] + shortnames[1][0]).toUpperCase(); - } else if (shortnames.length === 1) { - shortname = shortnames[0][0].toUpperCase(); + const titleCodeRegex = /^[A-Za-z]+$/; + if (!titleCodeRegex.test(req.body.titleCode)) { + return res.status(400).send({ message: 'Title Code must contain only upper or lower case letters.' }); + } + + // if media is empty + if (!req.body.mediaFolder.trim()) { + res.status(400).send({ message: 'Media folder cannot be empty.' }); + return; } - title.shortName = shortname; - // Validate team code by checking if it exists in the database - if (!title.teamCode) { + if (!req.body.teamCode) { res.status(400).send({ message: 'Please provide a team code.' }); return; } - const teamCodeExists = await checkTeamCodeExists(title.teamCode); + const teamCodeExists = await checkTeamCodeExists(req.body.teamCode); if (!teamCodeExists) { res.status(400).send({ message: 'Invalid team code. Please provide a valid team code.' }); return; } // validate if project exist - const projectExist = await checkProjectExists(title.projectAssigned._id); + const projectExist = await checkProjectExists(req.body.projectAssigned._id); if (!projectExist) { - res.status(400).send({ message: 'Project is empty or not exist.' }); + res.status(400).send({ message: 'Project is empty or not exist~~~' }); return; } // validate if team exist - if (title.teamAssiged && title.teamAssiged._id === 'N/A') { + if (req.body.teamAssiged && req.body.teamAssiged._id === 'N/A') { res.status(400).send({ message: 'Team not exists.' }); return; } + const result = await Title.findById(filter); + result.titleName = req.body.titleName; + result.titleCode = req.body.titleCode; + result.teamCode = req.body.teamCode; + result.projectAssigned = req.body.projectAssigned; + result.mediaFolder = req.body.mediaFolder; + result.teamAssiged = req.body.teamAssiged; + const updatedTitle = await result.save(); + res.status(200).send({ message: 'Update successful', updatedTitle }); + + } catch (error) { + console.log(error); + res.status(500).send({ message: 'An error occurred', error }); + } - title - .save() - .then((results) => res.status(200).send(results)) - .catch((error) => res.status(404).send(error)); - }; - - // update title function. - const updateTitle = async function (req, res) { - try{ - - const filter=req.body.id; - - // valid title name - if (!req.body.titleName.trim()) { - res.status(400).send({ message: 'Title cannot be empty.' }); - return; - } - - // if media is empty - if (!req.body.mediaFolder.trim()) { - res.status(400).send({ message: 'Media folder cannot be empty.' }); - return; - } - const shortnames = req.body.titleName.trim().split(' '); - let shortname; - if (shortnames.length > 1) { - shortname = (shortnames[0][0] + shortnames[1][0]).toUpperCase(); - } else if (shortnames.length === 1) { - shortname = shortnames[0][0].toUpperCase(); - } - req.body.shortName = shortname; - - // Validate team code by checking if it exists in the database - if (!req.body.teamCode) { - res.status(400).send({ message: 'Please provide a team code.' }); - return; - } - - const teamCodeExists = await checkTeamCodeExists(req.body.teamCode); - if (!teamCodeExists) { - res.status(400).send({ message: 'Invalid team code. Please provide a valid team code.' }); - return; - } - - // validate if project exist - const projectExist = await checkProjectExists(req.body.projectAssigned._id); - if (!projectExist) { - res.status(400).send({ message: 'Project is empty or not exist.' }); - return; - } - - // validate if team exist - if (req.body.teamAssiged && req.body.teamAssiged._id === 'N/A') { - res.status(400).send({ message: 'Team not exists.' }); - return; - } - const result = await Title.findById(filter); - result.titleName = req.body.titleName; - result.teamCode = req.body.teamCode; - result.projectAssigned = req.body.projectAssigned; - result.mediaFolder = req.body.mediaFolder; - result.teamAssiged = req.body.teamAssiged; - const updatedTitle = await result.save(); - res.status(200).send({ message: 'Update successful', updatedTitle }); - - }catch(error){ - console.log(error); - res.status(500).send({ message: 'An error occurred', error }); - } - - }; - - const deleteTitleById = async function (req, res) { - const { titleId } = req.params; - Title.deleteOne({ _id: titleId }) - .then((result) => res.send(result)) - .catch((error) => res.send(error)); - }; - - const deleteAllTitles = async function (req, res) { - Title.deleteMany({}) - .then((result) => { - if (result.deletedCount === 0) { - res.send({ message: 'No titles found to delete.' }); - } else { - res.send({ message: `${result.deletedCount} titles were deleted successfully.` }); - } - }) - .catch((error) => { - console.log(error) - res.status(500).send(error); - }); - }; - // Update: Confirmed with Jae. Team code is not related to the Team data model. But the team code field within the UserProfile data model. - async function checkTeamCodeExists(teamCode) { - try { - if (cache.getCache('teamCodes')) { - const teamCodes = JSON.parse(cache.getCache('teamCodes')); - return teamCodes.includes(teamCode); + }; + + const deleteTitleById = async function (req, res) { + const { titleId } = req.params; + Title.deleteOne({ _id: titleId }) + .then((result) => res.send(result)) + .catch((error) => res.send(error)); + }; + + const deleteAllTitles = async function (req, res) { + Title.deleteMany({}) + .then((result) => { + if (result.deletedCount === 0) { + res.send({ message: 'No titles found to delete.' }); + } else { + res.send({ message: `${result.deletedCount} titles were deleted successfully.` }); } - const teamCodes = await getAllTeamCodeHelper(); + }) + .catch((error) => { + console.log(error) + res.status(500).send(error); + }); + }; + // Update: Confirmed with Jae. Team code is not related to the Team data model. But the team code field within the UserProfile data model. + async function checkTeamCodeExists(teamCode) { + try { + if (cache.getCache('teamCodes')) { + const teamCodes = JSON.parse(cache.getCache('teamCodes')); return teamCodes.includes(teamCode); - } catch (error) { - console.error('Error checking if team code exists:', error); - throw error; } + const teamCodes = await getAllTeamCodeHelper(); + return teamCodes.includes(teamCode); + } catch (error) { + console.error('Error checking if team code exists:', error); + throw error; } - - async function checkProjectExists(projectID) { - try { - const project = await Project.findOne({ _id: projectID }).exec(); - return !!project; - } catch (error) { - console.error('Error checking if project exists:', error); - throw error; - } + } + + async function checkProjectExists(projectID) { + try { + const project = await Project.findOne({ _id: projectID }).exec(); + return !!project; + } catch (error) { + console.error('Error checking if project exists:', error); + throw error; } + } + - return { getAllTitles, @@ -209,5 +209,4 @@ const titlecontroller = function (Title) { }; }; - module.exports = titlecontroller; - \ No newline at end of file +module.exports = titlecontroller; diff --git a/src/controllers/userProfileController.js b/src/controllers/userProfileController.js index 31f297872..680dd93f4 100644 --- a/src/controllers/userProfileController.js +++ b/src/controllers/userProfileController.js @@ -1636,7 +1636,17 @@ const userProfileController = function (UserProfile, Project) { record .save() .then((results) => { - userHelper.notifyInfringements(originalinfringements, results.infringements); + userHelper.notifyInfringements( + originalinfringements, + results.infringements, + results.firstName, + results.lastName, + results.email, + results.role, + results.startDate, + results.jobTitle[0], + results.weeklycommittedHours, + ); res.status(200).json({ _id: record._id, }); @@ -1678,7 +1688,17 @@ const userProfileController = function (UserProfile, Project) { record .save() .then((results) => { - userHelper.notifyInfringements(originalinfringements, results.infringements); + userHelper.notifyInfringements( + originalinfringements, + results.infringements, + results.firstName, + results.lastName, + results.email, + results.role, + results.startDate, + results.jobTitle[0], + results.weeklycommittedHours, + ); res.status(200).json({ _id: record._id, }); @@ -1710,7 +1730,17 @@ const userProfileController = function (UserProfile, Project) { record .save() .then((results) => { - userHelper.notifyInfringements(originalinfringements, results.infringements); + userHelper.notifyInfringements( + originalinfringements, + results.infringements, + results.firstName, + results.lastName, + results.email, + results.role, + results.startDate, + results.jobTitle[0], + results.weeklycommittedHours, + ); res.status(200).json({ _id: record._id, }); @@ -1797,6 +1827,42 @@ const userProfileController = function (UserProfile, Project) { } }; + const getUserByAutocomplete = (req, res) => { + const { searchText } = req.params; + + if (!searchText) { + return res.status(400).send({ message: 'Search text is required' }); + } + + const regex = new RegExp(searchText, 'i'); // Case-insensitive regex for partial matching + + UserProfile.find( + { + $or: [ + { firstName: { $regex: regex } }, + { lastName: { $regex: regex } }, + { + $expr: { + $regexMatch: { + input: { $concat: ['$firstName', ' ', '$lastName'] }, + regex: searchText, + options: 'i', + }, + }, + }, + ], + }, + '_id firstName lastName', // Projection to limit fields returned + ) + .limit(10) // Limit results for performance + .then((results) => { + res.status(200).send(results); + }) + .catch(() => { + res.status(500).send({ error: 'Internal Server Error' }); + }); + }; + const updateUserInformation = async function (req,res){ try { const data=req.body; @@ -1840,6 +1906,8 @@ const userProfileController = function (UserProfile, Project) { getProjectsByPerson, getAllTeamCode, getAllTeamCodeHelper, + getUserByAutocomplete, + getUserProfileBasicInfo, updateUserInformation, getUserProfileBasicInfo }; diff --git a/src/models/jobs.js b/src/models/jobs.js new file mode 100644 index 000000000..b1f98a34a --- /dev/null +++ b/src/models/jobs.js @@ -0,0 +1,17 @@ +const mongoose = require('mongoose'); + +const { Schema } = mongoose; + +const jobSchema = new Schema({ + title: { type: String, required: true }, // Job title + category: { type: String, required: true }, // General category (e.g., Engineering, Marketing) + description: { type: String, required: true }, // Detailed job description + imageUrl: { type: String, required: true }, // URL of the job-related image + location: { type: String, required: true }, // Job location (optional for remote jobs) + applyLink: { type: String, required: true }, // URL for the application form + featured: { type: Boolean, default: false }, // Whether the job should be featured prominently + datePosted: { type: Date, default: Date.now }, // Date the job was posted + jobDetailsLink: { type: String, required: true }, // Specific job details URL +}); + +module.exports = mongoose.model('Job', jobSchema); diff --git a/src/models/team.js b/src/models/team.js index 4d73615f5..a679140a6 100644 --- a/src/models/team.js +++ b/src/models/team.js @@ -15,9 +15,10 @@ const team = new Schema({ modifiedDatetime: { type: Date, default: Date.now() }, members: [ { - userId: { type: mongoose.SchemaTypes.ObjectId, required: true }, + userId: { type: mongoose.SchemaTypes.ObjectId, required: true, index : true }, addDateTime: { type: Date, default: Date.now(), ref: 'userProfile' }, visible: { type : 'Boolean', default:true}, + }, ], // Deprecated field @@ -35,4 +36,5 @@ const team = new Schema({ }, }); + module.exports = mongoose.model('team', team, 'teams'); diff --git a/src/models/timeentry.js b/src/models/timeentry.js index ea5303b3a..1535ab13e 100644 --- a/src/models/timeentry.js +++ b/src/models/timeentry.js @@ -17,5 +17,7 @@ const TimeEntry = new Schema({ lastModifiedDateTime: { type: Date, default: Date.now }, isActive: { type: Boolean, default: true }, }); +TimeEntry.index({ personId: 1, dateOfWork: 1 }); +TimeEntry.index({ entryType: 1, teamId: 1, dateOfWork: 1, isActive: 1 }); module.exports = mongoose.model('timeEntry', TimeEntry, 'timeEntries'); diff --git a/src/models/title.js b/src/models/title.js index 64b9aed92..a41063aea 100644 --- a/src/models/title.js +++ b/src/models/title.js @@ -4,6 +4,7 @@ const { Schema } = mongoose; const title = new Schema({ titleName: { type: String, required: true }, + titleCode: { type: String, required: true }, teamCode: { type: String, require: true }, projectAssigned: { projectName: { type: String, required: true }, @@ -13,8 +14,7 @@ const title = new Schema({ teamAssiged: { teamName: { type: String }, _id: { type: String }, - }, - shortName: { type: String, require: true }, + }, }); diff --git a/src/routes/jobsRouter.js b/src/routes/jobsRouter.js new file mode 100644 index 000000000..6dfa69a85 --- /dev/null +++ b/src/routes/jobsRouter.js @@ -0,0 +1,13 @@ +const express = require('express'); +const jobsController = require('../controllers/jobsController'); // Adjust the path if needed + +const router = express.Router(); + +// Define routes +router.get('/', jobsController.getJobs); +router.get('/:id', jobsController.getJobById); +router.post('/', jobsController.createJob); +router.put('/:id', jobsController.updateJob); +router.delete('/:id', jobsController.deleteJob); + +module.exports = router; diff --git a/src/routes/teamRouter.js b/src/routes/teamRouter.js index 1bf8cfc44..3fbd8abb5 100644 --- a/src/routes/teamRouter.js +++ b/src/routes/teamRouter.js @@ -11,6 +11,10 @@ const router = function (team) { .post(controller.postTeam) .put(controller.updateTeamVisibility); + teamRouter + .route("/team/reports") + .post(controller.getAllTeamMembers); + teamRouter .route('/team/:teamId') .get(controller.getTeamById) diff --git a/src/routes/userProfileRouter.js b/src/routes/userProfileRouter.js index 73bc2ac5b..2d68d2da1 100644 --- a/src/routes/userProfileRouter.js +++ b/src/routes/userProfileRouter.js @@ -116,6 +116,10 @@ const routes = function (userProfile, project) { userProfileRouter.route('/userProfile/teamCode/list').get(controller.getAllTeamCode); + userProfileRouter + .route('/userProfile/autocomplete/:searchText') + .get(controller.getUserByAutocomplete); + return userProfileRouter; }; diff --git a/src/startup/middleware.js b/src/startup/middleware.js index 6304f1840..400f5af6c 100644 --- a/src/startup/middleware.js +++ b/src/startup/middleware.js @@ -39,6 +39,10 @@ module.exports = function (app) { next(); return; } + if (req.originalUrl.startsWith('/api/jobs') && req.method === 'GET') { + next(); + return; + } if (!req.header('Authorization')) { res.status(401).send({ 'error:': 'Unauthorized request' }); return; diff --git a/src/startup/routes.js b/src/startup/routes.js index 77f03811c..900749b10 100644 --- a/src/startup/routes.js +++ b/src/startup/routes.js @@ -4,7 +4,6 @@ const project = require('../models/project'); const information = require('../models/information'); const team = require('../models/team'); // const actionItem = require('../models/actionItem'); -const notification = require('../models/notification'); const wbs = require('../models/wbs'); const task = require('../models/task'); const popup = require('../models/popupEditor'); @@ -56,6 +55,7 @@ const timeEntryRouter = require('../routes/timeentryRouter')(timeEntry); const projectRouter = require('../routes/projectRouter')(project); const informationRouter = require('../routes/informationRouter')(information); const teamRouter = require('../routes/teamRouter')(team); +const jobsRouter = require('../routes/jobsRouter'); // const actionItemRouter = require('../routes/actionItemRouter')(actionItem); const notificationRouter = require('../routes/notificationRouter')(); const loginRouter = require('../routes/loginRouter')(); @@ -164,6 +164,7 @@ module.exports = function (app) { app.use('/api', timeOffRequestRouter); app.use('/api', followUpRouter); app.use('/api', blueSquareEmailAssignmentRouter); + app.use('/api/jobs', jobsRouter) app.use('/api', meetingRouter); // bm dashboard app.use('/api/bm', bmLoginRouter); diff --git a/src/utilities/emailSender.js b/src/utilities/emailSender.js index eb8eca3de..b0fb40112 100644 --- a/src/utilities/emailSender.js +++ b/src/utilities/emailSender.js @@ -2,104 +2,113 @@ const nodemailer = require('nodemailer'); const { google } = require('googleapis'); const logger = require('../startup/logger'); -const closure = () => { - const queue = []; - - const CLIENT_EMAIL = process.env.REACT_APP_EMAIL; - const CLIENT_ID = process.env.REACT_APP_EMAIL_CLIENT_ID; - const CLIENT_SECRET = process.env.REACT_APP_EMAIL_CLIENT_SECRET; - const REDIRECT_URI = process.env.REACT_APP_EMAIL_CLIENT_REDIRECT_URI; - const REFRESH_TOKEN = process.env.REACT_APP_EMAIL_REFRESH_TOKEN; - // Create the email envelope (transport) - const transporter = nodemailer.createTransport({ - service: 'gmail', - auth: { - type: 'OAuth2', - user: CLIENT_EMAIL, - clientId: CLIENT_ID, - clientSecret: CLIENT_SECRET, - }, - }); +const config = { + email: process.env.REACT_APP_EMAIL, + clientId: process.env.REACT_APP_EMAIL_CLIENT_ID, + clientSecret: process.env.REACT_APP_EMAIL_CLIENT_SECRET, + redirectUri: process.env.REACT_APP_EMAIL_CLIENT_REDIRECT_URI, + refreshToken: process.env.REACT_APP_EMAIL_REFRESH_TOKEN, + batchSize: 50, + concurrency: 3, + rateLimitDelay: 1000, +}; - const OAuth2Client = new google.auth.OAuth2(CLIENT_ID, CLIENT_SECRET, REDIRECT_URI); +const OAuth2Client = new google.auth.OAuth2( + config.clientId, + config.clientSecret, + config.redirectUri, +); +OAuth2Client.setCredentials({ refresh_token: config.refreshToken }); - OAuth2Client.setCredentials({ refresh_token: REFRESH_TOKEN }); +// Create the email envelope (transport) +const transporter = nodemailer.createTransport({ + service: 'gmail', + auth: { + type: 'OAuth2', + user: config.email, + clientId: config.clientId, + clientSecret: config.clientSecret, + }, +}); - setInterval(async () => { - const nextItem = queue.shift(); +const sendEmail = async (mailOptions) => { + try { + const { token } = await OAuth2Client.getAccessToken(); + mailOptions.auth = { + user: config.email, + refreshToken: config.refreshToken, + accessToken: token, + }; + const result = await transporter.sendMail(mailOptions); + if (process.env.NODE_ENV === 'local') { + logger.logInfo(`Email sent: ${JSON.stringify(result)}`); + } + return result; + } catch (error) { + logger.logException(error, `Error sending email: ${mailOptions.to}`); + throw error; + } +}; - if (!nextItem) return; +const queue = []; +let isProcessing = false; - const { recipient, subject, message, cc, bcc, replyTo, acknowledgingReceipt } = nextItem; +const processQueue = async () => { + if (isProcessing || queue.length === 0) return; - try { - // Generate the accessToken on the fly - const res = await OAuth2Client.getAccessToken(); - const ACCESSTOKEN = res.token; + isProcessing = true; + console.log('Processing email queue...'); - const mailOptions = { - from: CLIENT_EMAIL, - to: recipient, - cc, - bcc, - subject, - html: message, - replyTo, - auth: { - user: CLIENT_EMAIL, - refreshToken: REFRESH_TOKEN, - accessToken: ACCESSTOKEN, - }, - }; + const processBatch = async () => { + if (queue.length === 0) { + isProcessing = false; + return; + } - const result = await transporter.sendMail(mailOptions); - if (typeof acknowledgingReceipt === 'function') { - acknowledgingReceipt(null, result); - } - // Prevent logging email in production - // Why? - // 1. Could create a security risk - // 2. Could create heavy loads on the server if emails are sent to many people - // 3. Contain limited useful info: - // result format : {"accepted":["emailAddr"],"rejected":[],"envelopeTime":209,"messageTime":566,"messageSize":317,"response":"250 2.0.0 OK 17***69 p11-2***322qvd.85 - gsmtp","envelope":{"from":"emailAddr", "to":"emailAddr"}} - if (process.env.NODE_ENV === 'local') { - logger.logInfo(`Email sent: ${JSON.stringify(result)}`); - } + const batch = queue.shift(); + try { + console.log('Sending email...'); + await sendEmail(batch); } catch (error) { - if (typeof acknowledgingReceipt === 'function') { - acknowledgingReceipt(error, null); - } - logger.logException( - error, - `Error sending email: from ${CLIENT_EMAIL} to ${recipient} subject ${subject}`, - `Extra Data: cc ${cc} bcc ${bcc}`, - ); + logger.logException(error, 'Failed to send email batch'); } - }, process.env.MAIL_QUEUE_INTERVAL || 1000); - const emailSender = function ( - recipient, - subject, - message, - cc = null, - bcc = null, - replyTo = null, - acknowledgingReceipt = null, - ) { - if (process.env.sendEmail) { - queue.push({ - recipient, - subject, - message, - cc, - bcc, - replyTo, - acknowledgingReceipt, - }); - } + setTimeout(processBatch, config.rateLimitDelay); }; - return emailSender; + const concurrentProcesses = Array(config.concurrency).fill().map(processBatch); + + try { + await Promise.all(concurrentProcesses); + } finally { + isProcessing = false; + } +}; + +const emailSender = ( + recipients, + subject, + message, + attachments = null, + cc = null, + replyTo = null, +) => { + if (!process.env.sendEmail) return; + const recipientsArray = Array.isArray(recipients) ? recipients : [recipients]; + for (let i = 0; i < recipients.length; i += config.batchSize) { + const batchRecipients = recipientsArray.slice(i, i + config.batchSize); + queue.push({ + from: config.email, + bcc: batchRecipients.join(','), + subject, + html: message, + attachments, + cc, + replyTo, + }); + } + console.log('Emails queued:', queue.length); + setImmediate(processQueue); }; -module.exports = closure(); +module.exports = emailSender;