diff --git a/package-lock.json b/package-lock.json index 4f94329e9..929dc0680 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,6 +38,7 @@ "cors": "^2.8.4", "cron": "^1.8.2", "date-fns": "^2.30.0", + "date-fns-tz": "^2.0.1", "dotenv": "^5.0.1", "dropbox": "^10.34.0", "express": "^4.22.1", @@ -78,7 +79,8 @@ "twilio": "^5.5.2", "uuid": "^3.4.0", "ws": "^8.17.1", - "xmlrpc": "^1.3.2" + "xmlrpc": "^1.3.2", + "zod": "^4.3.6" }, "devDependencies": { "@babel/eslint-parser": "^7.15.0", @@ -7176,6 +7178,15 @@ "url": "https://opencollective.com/date-fns" } }, + "node_modules/date-fns-tz": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/date-fns-tz/-/date-fns-tz-2.0.1.tgz", + "integrity": "sha512-fJCG3Pwx8HUoLhkepdsP7Z5RsucUi+ZBOxyM5d0ZZ6c4SdYustq0VMmOu6Wf7bli+yS/Jwp91TOCqn9jMcVrUA==", + "license": "MIT", + "peerDependencies": { + "date-fns": "2.x" + } + }, "node_modules/dateformat": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-2.2.0.tgz", @@ -17013,6 +17024,15 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index bea10f010..ccc5ba95f 100644 --- a/package.json +++ b/package.json @@ -91,6 +91,7 @@ "cors": "^2.8.4", "cron": "^1.8.2", "date-fns": "^2.30.0", + "date-fns-tz": "^2.0.1", "dotenv": "^5.0.1", "dropbox": "^10.34.0", "express": "^4.22.1", @@ -131,7 +132,8 @@ "twilio": "^5.5.2", "uuid": "^3.4.0", "ws": "^8.17.1", - "xmlrpc": "^1.3.2" + "xmlrpc": "^1.3.2", + "zod": "^4.3.6" }, "nodemonConfig": { "watch": [ diff --git a/src/controllers/educationPortal/downloadReportController.js b/src/controllers/educationPortal/downloadReportController.js index e2f4fe88c..29b1de5b6 100644 --- a/src/controllers/educationPortal/downloadReportController.js +++ b/src/controllers/educationPortal/downloadReportController.js @@ -1,5 +1,7 @@ /* istanbul ignore file */ +// eslint-disable-next-line import/no-unresolved const PDFDocument = require('pdfkit'); +// eslint-disable-next-line import/no-unresolved const { Parser } = require('json2csv'); const mongoose = require('mongoose'); const EducationTask = require('../../models/educationTask'); diff --git a/src/controllers/liveJournalPostController.js b/src/controllers/liveJournalPostController.js index a8d7b59f6..ae970d400 100644 --- a/src/controllers/liveJournalPostController.js +++ b/src/controllers/liveJournalPostController.js @@ -1,10 +1,11 @@ /* eslint-disable no-console */ const crypto = require('crypto'); +// eslint-disable-next-line import/no-unresolved const xmlrpc = require('xmlrpc'); const mongoose = require('mongoose'); -// eslint-disable-next-line import/no-extraneous-dependencies +// eslint-disable-next-line import/no-extraneous-dependencies, import/no-unresolved const cloudinary = require('cloudinary').v2; -// eslint-disable-next-line import/no-extraneous-dependencies +// eslint-disable-next-line import/no-extraneous-dependencies, import/no-unresolved const streamifier = require('streamifier'); const LiveJournalPost = require('../models/liveJournalPost'); diff --git a/src/controllers/timeEntryController.js b/src/controllers/timeEntryController.js index d0acbe15a..77e9d95d9 100644 --- a/src/controllers/timeEntryController.js +++ b/src/controllers/timeEntryController.js @@ -1558,13 +1558,26 @@ const timeEntrycontroller = function (TimeEntry) { * recalculate the hoursByCategory for all users and update the field */ const recalculateHoursByCategoryAllUsers = async function (taskId) { - if (mongoose.connection.readyState === 0) { + // Check if MongoDB connection is ready before attempting to start a session + // readyState: 0 = disconnected, 1 = connected, 2 = connecting, 3 = disconnecting + if (mongoose.connection.readyState !== 1) { + const recalculationTask = recalculationTaskQueue.find((task) => task.taskId === taskId); + if (recalculationTask) { + recalculationTask.status = 'Failed'; + recalculationTask.completionTime = new Date().toISOString(); + } + + logger.logInfo( + `Recalculation task ${taskId} skipped: MongoDB connection not ready (state: ${mongoose.connection.readyState})`, + ); return; } - const session = await mongoose.startSession(); - session.startTransaction(); + let sesh; try { + sesh = await mongoose.startSession(); + sesh.startTransaction(); + const userprofiles = await UserProfile.find({}, '_id').lean(); const recalculationPromises = userprofiles.map(async (userprofile) => { @@ -1574,7 +1587,7 @@ const timeEntrycontroller = function (TimeEntry) { }); await Promise.all(recalculationPromises); - await session.commitTransaction(); + await sesh.commitTransaction(); const recalculationTask = recalculationTaskQueue.find((task) => task.taskId === taskId); if (recalculationTask) { @@ -1582,7 +1595,9 @@ const timeEntrycontroller = function (TimeEntry) { recalculationTask.completionTime = new Date().toISOString(); } } catch (err) { - await session.abortTransaction(); + if (sesh) { + await sesh.abortTransaction(); + } const recalculationTask = recalculationTaskQueue.find((task) => task.taskId === taskId); if (recalculationTask) { recalculationTask.status = 'Failed'; @@ -1591,7 +1606,9 @@ const timeEntrycontroller = function (TimeEntry) { logger.logException(err); } finally { - session.endSession(); + if (sesh) { + sesh.endSession(); + } } }; diff --git a/src/controllers/timeEntryController.test.js b/src/controllers/timeEntryController.test.js index db7c4bb7f..c864ccbe4 100644 --- a/src/controllers/timeEntryController.test.js +++ b/src/controllers/timeEntryController.test.js @@ -1,5 +1,4 @@ const mongoose = require('mongoose'); - const { mockRes, assertResMock } = require('../test'); const oid = () => new mongoose.Types.ObjectId().toString(); diff --git a/src/models/userProfile.js b/src/models/userProfile.js index 35f084b1f..1b9e55509 100644 --- a/src/models/userProfile.js +++ b/src/models/userProfile.js @@ -224,7 +224,7 @@ const userProfileSchema = new Schema({ // differentiate between paused and separated accounts for better reporting and handling in the future inactiveReason: { type: String, - enum: ['Paused', 'Separated', 'ScheduledSeparation'], + enum: ['Paused', 'Separated', 'ScheduledSeparation', null], default: undefined, }, resetPwd: { type: String }, diff --git a/src/services/permissionService.js b/src/services/permissionService.js index daf3eb6fd..a5a0a248d 100644 --- a/src/services/permissionService.js +++ b/src/services/permissionService.js @@ -14,12 +14,22 @@ class PermissionService { return permissions && typeof permissions === 'object'; } - static async checkUpdateAuthorization(requestor, userId) { + static async checkUpdateAuthorization(requestor, userId, UserProfile) { const hasUpdatePermission = await hasPermission(requestor, 'putUserProfilePermissions'); if (!hasUpdatePermission) { return { authorized: false, error: 'You are not authorized to update user permissions' }; } + // Special case: Owners with addDeleteEditOwners permission can update other Owners' permissions + const hasAddDeleteEditOwnersPermission = await hasPermission(requestor, 'addDeleteEditOwners'); + if (hasAddDeleteEditOwnersPermission) { + const targetUser = await UserProfile.findById(userId).select('role').lean(); + if (targetUser && targetUser.role === 'Owner') { + // Allow Owner with addDeleteEditOwners permission to update other Owners' permissions + return { authorized: true }; + } + } + const canEditProtectedAccount = await canRequestorUpdateUser(requestor.requestorId, userId); if (!canEditProtectedAccount) { @@ -33,18 +43,47 @@ class PermissionService { } async findUserById(userId) { - const user = await this.UserProfile.findById(userId); + let user; + try { + user = await this.UserProfile.findById(userId); + } catch (findError) { + const err = new Error('Invalid user id'); + err.statusCode = 400; + throw err; + } if (!user) { - throw new Error('User not found'); + const err = new Error('User not found'); + err.statusCode = 404; + throw err; } return user; } static updateUserPermissions(user, permissions) { - user.permissions = { - isAcknowledged: false, - ...permissions, + let existing = {}; + try { + if (user.permissions) { + existing = + typeof user.permissions.toObject === 'function' + ? user.permissions.toObject() + : user.permissions; + } + } catch (_) { + existing = user.permissions || {}; + } + const merged = { + isAcknowledged: Boolean(permissions.isAcknowledged), + frontPermissions: Array.isArray(permissions.frontPermissions) + ? permissions.frontPermissions + : (existing.frontPermissions || []), + backPermissions: Array.isArray(permissions.backPermissions) + ? permissions.backPermissions + : (existing.backPermissions || []), + removedDefaultPermissions: Array.isArray(permissions.removedDefaultPermissions) + ? permissions.removedDefaultPermissions + : (existing.removedDefaultPermissions || []), }; + user.permissions = merged; user.lastModifiedDate = Date.now(); } @@ -65,17 +104,32 @@ class PermissionService { } static async notifyInfringements(originalInfringements, results) { - await userHelper.notifyInfringements( - originalInfringements, - results.infringements, - results.firstName, - results.lastName, - results.email, - results.role, - results.startDate, - results.jobTitle[0], - results.weeklycommittedHours, - ); + try { + if (!results) return; + + const currentInfringements = results.infringements; + + if (!currentInfringements) return; + + const safeOriginal = + originalInfringements && typeof originalInfringements.toObject === 'function' + ? originalInfringements + : currentInfringements; + + await userHelper.notifyInfringements( + safeOriginal, + currentInfringements, + results.firstName, + results.lastName, + results.email, + results.role, + results.startDate, + results.jobTitle?.[0], + results.weeklycommittedHours, + ); + } catch (error) { + logger.logException(error, 'Error notifying infringements after permission update'); + } } async handlePostSaveOperations(req, user, originalInfringements, results) { @@ -85,13 +139,24 @@ class PermissionService { } async updatePermissions(userId, permissions, req) { - // Validate permissions data if (!PermissionService.validatePermissionsData(permissions)) { - throw new Error('Invalid permissions data'); + const err = new Error('Invalid permissions data'); + err.statusCode = 400; + throw err; + } + + const requestor = req.body?.requestor; + if (!requestor || !requestor.requestorId) { + const err = new Error('Requestor not found. Ensure request is authenticated.'); + err.statusCode = 401; + throw err; } - // Check authorization - const authResult = await PermissionService.checkUpdateAuthorization(req.body.requestor, userId); + const authResult = await PermissionService.checkUpdateAuthorization( + requestor, + userId, + this.UserProfile, + ); if (!authResult.authorized) { const error = new Error(authResult.error); error.statusCode = 403; @@ -108,8 +173,12 @@ class PermissionService { // Save the user const results = await user.save(); - // Handle post-save operations - await this.handlePostSaveOperations(req, user, originalInfringements, results); + // Handle post-save operations (logging, cache); don't fail the update if these throw + try { + await this.handlePostSaveOperations(req, user, originalInfringements, results); + } catch (postSaveError) { + logger.logException(postSaveError, 'Post-save operations failed after permission update'); + } return { message: 'Permissions updated successfully', diff --git a/src/utilities/liveJournalScheduler.js b/src/utilities/liveJournalScheduler.js index 0ab6461f0..816b1609f 100644 --- a/src/utilities/liveJournalScheduler.js +++ b/src/utilities/liveJournalScheduler.js @@ -1,3 +1,4 @@ +// eslint-disable-next-line import/no-unresolved const schedule = require('node-schedule'); const liveJournalPostController = require('../controllers/liveJournalPostController')(); @@ -5,9 +6,11 @@ const initializeLiveJournalScheduler = () => { const job = schedule.scheduleJob('* * * * *', async () => { try { const result = await liveJournalPostController.processScheduledPosts(); - + if (result.processed > 0) { - console.log(`[LiveJournal Scheduler] Processed ${result.processed} posts: ${result.successful} successful, ${result.failed} failed`); + console.log( + `[LiveJournal Scheduler] Processed ${result.processed} posts: ${result.successful} successful, ${result.failed} failed`, + ); } } catch (error) { console.error('[LiveJournal Scheduler] Error processing scheduled posts:', error); @@ -15,8 +18,8 @@ const initializeLiveJournalScheduler = () => { }); console.log('[LiveJournal Scheduler] Initialized - will check for scheduled posts every minute'); - + return job; }; -module.exports = { initializeLiveJournalScheduler }; \ No newline at end of file +module.exports = { initializeLiveJournalScheduler }; diff --git a/src/utilities/logUserPermissionChangeByAccount.js b/src/utilities/logUserPermissionChangeByAccount.js index edbf7f5d6..9fa0d85f3 100644 --- a/src/utilities/logUserPermissionChangeByAccount.js +++ b/src/utilities/logUserPermissionChangeByAccount.js @@ -23,25 +23,33 @@ const logUserPermissionChangeByAccount = async (req, user) => { const dateTime = moment().tz('America/Los_Angeles').format(); try { - let permissionsAdded = []; - let permissionsRemoved = []; + if (!permissions || !requestor?.requestorId) { + return; + } + + const Permissions = Array.isArray(permissions.frontPermissions) + ? permissions.frontPermissions + : []; const { userId } = req.params; - const Permissions = permissions.frontPermissions; - // Fetch requestor email - const requestorEmailId = await UserProfile.findById(requestor.requestorId) + // Fetch requestor email (may be null if requestor deleted) + const requestorDoc = await UserProfile.findById(requestor.requestorId) .select('email') + .lean() .exec(); + const requestorEmail = requestorDoc?.email ?? 'unknown'; - // Use the user object passed from controller (already fetched) const { firstName, lastName } = user; + let permissionsAdded = []; + let permissionsRemoved = []; const document = await findLatestRelatedLog(userId); if (document) { const docPermissions = Array.isArray(document.permissions) ? document.permissions : []; - // no new changes in permissions list from last update - if (JSON.stringify(docPermissions.sort()) === JSON.stringify(Permissions.sort())) { + const sortedDoc = [...docPermissions].sort(); + const sortedCurrent = [...Permissions].sort(); + if (JSON.stringify(sortedDoc) === JSON.stringify(sortedCurrent)) { return; } permissionsRemoved = docPermissions.filter((item) => !Permissions.includes(item)); @@ -50,7 +58,6 @@ const logUserPermissionChangeByAccount = async (req, user) => { permissionsAdded = Permissions; } - // no permission added nor removed if (permissionsRemoved.length === 0 && permissionsAdded.length === 0) { return; } @@ -63,7 +70,7 @@ const logUserPermissionChangeByAccount = async (req, user) => { permissionsAdded, permissionsRemoved, requestorRole: requestor.role, - requestorEmail: requestorEmailId.email, + requestorEmail, }); await logEntry.save(); diff --git a/src/utilities/permissions.js b/src/utilities/permissions.js index d6219b577..8fd53a01b 100644 --- a/src/utilities/permissions.js +++ b/src/utilities/permissions.js @@ -9,21 +9,21 @@ const hasDefaultPermissionRemoved = async (userId, action) => UserProfile.findById(userId) .select('permissions') .exec() - .then(({ permissions }) => permissions.removedDefaultPermissions.includes(action)) - .catch(false); + .then((doc) => (doc?.permissions?.removedDefaultPermissions || []).includes(action)) + .catch(() => false); const hasRolePermission = async (role, action) => Role.findOne({ roleName: role }) .exec() - .then(({ permissions }) => permissions.includes(action)) - .catch(false); + .then((doc) => (doc?.permissions || []).includes(action)) + .catch(() => false); const hasIndividualPermission = async (userId, action) => UserProfile.findById(userId) .select('permissions') .exec() - .then(({ permissions }) => permissions.frontPermissions.includes(action)) - .catch(false); + .then((doc) => (doc?.permissions?.frontPermissions || []).includes(action)) + .catch(() => false); const hasPermission = async (requestor, action) => { const defaultRemoved = @@ -53,8 +53,8 @@ function getDistinct(arr1, arr2) { * @returns */ const canRequestorUpdateUser = async (requestorId, targetUserId) => { - let protectedEmailAccountIds; - let allowedEmailAccountIds; + let protectedEmailAccountIds = []; + let allowedEmailAccountIds = []; const emailToQuery = getDistinct(PROTECTED_EMAIL_ACCOUNT, ALLOWED_EMAIL_ACCOUNT); // Persist the list of protected email accounts in the application cache if ( @@ -89,10 +89,11 @@ const canRequestorUpdateUser = async (requestorId, targetUserId) => { serverCache.setKeyTimeToLive('allowedEmailAccountIds', 60 * 60); } catch (error) { Logger.logException(error, 'Error getting protected email accounts'); + // Keep default [] so .includes() below never throws } } else { - protectedEmailAccountIds = serverCache.getCache('protectedEmailAccountIds'); - allowedEmailAccountIds = serverCache.getCache('allowedEmailAccountIds'); + protectedEmailAccountIds = serverCache.getCache('protectedEmailAccountIds') || []; + allowedEmailAccountIds = serverCache.getCache('allowedEmailAccountIds') || []; } // Check requestor edit permission and check target user is protected or not. return !(