diff --git a/packages/server/api/graphql/manuscript/manuscript.resolvers.js b/packages/server/api/graphql/manuscript/manuscript.resolvers.js index ae023c0cd..e21c7068b 100644 --- a/packages/server/api/graphql/manuscript/manuscript.resolvers.js +++ b/packages/server/api/graphql/manuscript/manuscript.resolvers.js @@ -54,7 +54,7 @@ const { supplementaryFiles, updateAda, metaSource, - publishedReviewUsers, + // publishedReviewUsers, removeAuthor, } = require('../../../controllers/manuscript/manuscript.controllers') @@ -397,8 +397,9 @@ const resolvers = { }, }, PublishedReview: { - async users(parent) { - return publishedReviewUsers(parent) + async users(review, _, ctx) { + // return publishedReviewUsers(parent) + return ctx.loaders.Review.usersLoader.load(review.id) }, }, } diff --git a/packages/server/controllers/manuscript/manuscript.controllers.js b/packages/server/controllers/manuscript/manuscript.controllers.js index d68ebc786..57db60f9c 100644 --- a/packages/server/controllers/manuscript/manuscript.controllers.js +++ b/packages/server/controllers/manuscript/manuscript.controllers.js @@ -3,7 +3,7 @@ const { chunk, orderBy, uniqBy, flatten } = require('lodash') const { ref, raw } = require('objection') const axios = require('axios') -const { File } = require('@coko/server') +const { File, useTransaction } = require('@coko/server') const { CoarNotification, @@ -113,6 +113,8 @@ const { const { applyTemplate, generateCss } = require('../../utils/applyTemplate') const { decrypt } = require('../../utils/encryptDecryptUtils') +const { usersLoader } = require('../../models/review/review.loaders') +const { defaultIdentitiesLoader } = require('../../models/user/user.loaders') // #endregion import @@ -699,16 +701,18 @@ const getRelatedReviews = async ( manuscript, userId, shouldGetPublicReviewsOnly = false, + options = {}, ) => { - const reviewForm = await getReviewForm(manuscript.groupId) - const decisionForm = await getDecisionForm(manuscript.groupId) + const { trx } = options + const reviewForm = await getReviewForm(manuscript.groupId, { trx }) + const decisionForm = await getDecisionForm(manuscript.groupId, { trx }) let reviews = manuscript.reviews || - (await Manuscript.relatedQuery('reviews').for(manuscript.id)) || + (await Manuscript.relatedQuery('reviews', trx).for(manuscript.id)) || [] - reviews = await ReviewModel.orderReviewPerUsername(reviews) + reviews = await ReviewModel.orderReviewPerUsername(reviews, { trx }) // eslint-disable-next-line no-restricted-syntax for (const review of reviews) { @@ -716,15 +720,19 @@ const getRelatedReviews = async ( await convertFilesToFullObjects( review, review.isDecision ? decisionForm : reviewForm, - async ids => File.query().findByIds(ids), + async ids => File.query(trx).findByIds(ids), ) } const userRoles = shouldGetPublicReviewsOnly ? {} - : await getUserRolesInManuscript(userId, manuscript.id) + : await getUserRolesInManuscript(userId, manuscript.id, { trx }) - const sharedReviewersIds = await getSharedReviewersIds(manuscript.id, userId) + const sharedReviewersIds = await getSharedReviewersIds( + manuscript.id, + userId, + { trx }, + ) // Insert isShared flag before userIds are stripped // TODO Should this step and the removal of confidential data be moved to the review resolver? @@ -1132,51 +1140,57 @@ const publishedManuscript = async id => { } const publishedManuscriptDecisions = async (manuscript, userId) => { - // filtering decisions in Kotahi itself so that we can change - // the logic easily in future. - const reviews = await getRelatedReviews(manuscript, userId) + return useTransaction(async trx => { + // filtering decisions in Kotahi itself so that we can change + // the logic easily in future. + const reviews = await getRelatedReviews(manuscript, userId, false, { trx }) - if (!Array.isArray(reviews)) { - return [] - } + if (!Array.isArray(reviews)) { + return [] + } - const decisions = reviews.filter(review => review.isDecision) - const decisionForm = await getDecisionForm(manuscript.groupId) + const decisions = reviews.filter(review => review.isDecision) + const decisionForm = await getDecisionForm(manuscript.groupId, { trx }) - const threadedDiscussions = - manuscript.threadedDiscussions || - (await getThreadedDiscussionsForManuscript(manuscript, getUsersById)) + const threadedDiscussions = + manuscript.threadedDiscussions || + (await getThreadedDiscussionsForManuscript(manuscript, getUsersById, { + trx, + })) - return getPublishableReviewFields( - decisions, - decisionForm, - threadedDiscussions, - manuscript, - ) + return getPublishableReviewFields( + decisions, + decisionForm, + threadedDiscussions, + manuscript, + ) + }) } const publishedManuscriptEditors = async manuscript => { - const teams = await Team.query() - .where({ objectId: manuscript.id }) - .whereIn('role', ['seniorEditor', 'handlingEditor', 'editor']) - - const teamMembers = await TeamMember.query().whereIn( - 'team_id', - teams.map(t => t.id), - ) + return useTransaction(async trx => { + const teams = await Team.query(trx) + .where({ objectId: manuscript.id }) + .whereIn('role', ['seniorEditor', 'handlingEditor', 'editor']) + + const teamMembers = await TeamMember.query(trx).whereIn( + 'team_id', + teams.map(t => t.id), + ) - const editorAndRoles = await Promise.all( - teamMembers.map(async member => { - const user = await User.query().findById(member.userId) - const team = teams.find(t => t.id === member.teamId) - return { - name: user.username, - role: team.role, - } - }), - ) + const editorAndRoles = await Promise.all( + teamMembers.map(async member => { + const user = await User.query(trx).findById(member.userId) + const team = teams.find(t => t.id === member.teamId) + return { + name: user.username, + role: team.role, + } + }), + ) - return editorAndRoles + return editorAndRoles + }) } const publishedManuscripts = async (sort, offset, limit, groupId) => { @@ -1200,74 +1214,80 @@ const publishedManuscripts = async (sort, offset, limit, groupId) => { } const publishedManuscriptReviews = async (manuscript, userId) => { - let reviews = await getRelatedReviews(manuscript, userId, true) + return useTransaction(async trx => { + let reviews = await getRelatedReviews(manuscript, userId, true, { trx }) - if (!Array.isArray(reviews)) { - return [] - } + if (!Array.isArray(reviews)) { + return [] + } - reviews = reviews.filter(review => !review.isDecision) - const reviewForm = await getReviewForm(manuscript.groupId) + reviews = reviews.filter(review => !review.isDecision) + const reviewForm = await getReviewForm(manuscript.groupId, { trx }) - const threadedDiscussions = - manuscript.threadedDiscussions || - (await getThreadedDiscussionsForManuscript(manuscript, getUsersById)) + const threadedDiscussions = + manuscript.threadedDiscussions || + (await getThreadedDiscussionsForManuscript(manuscript, getUsersById, { + trx, + })) - // eslint-disable-next-line no-restricted-syntax - for (const review of reviews) { - const jsonData = JSON.parse(review.jsonData) + // eslint-disable-next-line no-restricted-syntax + for (const review of reviews) { + const jsonData = JSON.parse(review.jsonData) - if (review.isCollaborative) { - const collaborativeFormData = - // eslint-disable-next-line no-await-in-loop - await CollaborativeDoc.getFormData(review.id, reviewForm) + if (review.isCollaborative) { + const collaborativeFormData = + // eslint-disable-next-line no-await-in-loop + await CollaborativeDoc.getFormData(review.id, reviewForm, { trx }) - review.jsonData = JSON.stringify({ - ...jsonData, - ...collaborativeFormData, - }) + review.jsonData = JSON.stringify({ + ...jsonData, + ...collaborativeFormData, + }) + } } - } - return getPublishableReviewFields( - reviews, - reviewForm, - threadedDiscussions, - manuscript, - ) + return getPublishableReviewFields( + reviews, + reviewForm, + threadedDiscussions, + manuscript, + ) + }) } const publishedReviewUsers = async review => { - if (review.isHiddenReviewerName) { - return [{ id: '', username: 'Anonymous User' }] - } - - let users = [] + return useTransaction(async trx => { + if (review.isHiddenReviewerName) { + return [{ id: '', username: 'Anonymous User' }] + } - if (review.isCollaborative) { - const manuscript = await Manuscript.query().findById(review.manuscriptId) + let users = [] - const existingTeam = await manuscript - .$relatedQuery('teams') - .where('role', 'collaborativeReviewer') - .first() + if (review.isCollaborative) { + const manuscript = await Manuscript.query(trx).findById( + review.manuscriptId, + ) - users = await existingTeam.$relatedQuery('users') - } else { - users = await User.query().where({ id: review.userId }) - } + const existingTeam = await manuscript + .$relatedQuery('teams', trx) + .where('role', 'collaborativeReviewer') + .first() - users = await Promise.all( - users.map(async user => { - const defaultIdentity = await cachedGet( - `defaultIdentityOfUser:${user.id}`, - ) + users = await existingTeam.$relatedQuery('users', trx) + } else { + users = await usersLoader([review.userId], { trx }) + } - return { defaultIdentity, ...user } - }), - ) + const defaultIdentities = await defaultIdentitiesLoader( + users.map(u => u.id), + { trx }, + ) - return users + return users.map((user, i) => ({ + ...user, + defaultIdentity: defaultIdentities[i], + })) + }) } // TODO: useTransaction to handle rollbacks diff --git a/packages/server/controllers/threadedDiscussion.controllers.js b/packages/server/controllers/threadedDiscussion.controllers.js index 788badb08..7137d6f27 100644 --- a/packages/server/controllers/threadedDiscussion.controllers.js +++ b/packages/server/controllers/threadedDiscussion.controllers.js @@ -10,9 +10,14 @@ const { getUsersById, getUserRolesInManuscript } = require('./user.controllers') const seekEvent = require('../services/notification.service') /** Get the threaded discussion with "author" user object added to each commentVersion and pendingVersion */ -const addUserObjectsToDiscussion = async (discussion, getUsersByIdFunc) => { +const addUserObjectsToDiscussion = async ( + discussion, + getUsersByIdFunc, + options = {}, +) => { + const { trx } = options const userIds = getAllUserIdsInDiscussion(discussion) - const users = await getUsersByIdFunc(userIds) + const users = await getUsersByIdFunc(userIds, { trx }) const usersMap = {} users.forEach(u => (usersMap[u.id] = u)) @@ -225,16 +230,19 @@ const getOriginalVersionManuscriptId = async manuscriptId => { const getThreadedDiscussionsForManuscript = async ( manuscript, getUsersByIdFunc, -) => - Promise.all( + options = {}, +) => { + const { trx } = options + return Promise.all( ( - await ThreadedDiscussion.query().where({ + await ThreadedDiscussion.query(trx).where({ manuscriptId: manuscript.parentId || manuscript.id, }) ).map(discussion => - addUserObjectsToDiscussion(discussion, getUsersByIdFunc), + addUserObjectsToDiscussion(discussion, getUsersByIdFunc, { trx }), ), ) +} const isNewEmptyComment = (pendingVersion, commentVersions) => (!pendingVersion || pendingVersion.comment === '

') && diff --git a/packages/server/controllers/user.controllers.js b/packages/server/controllers/user.controllers.js index ec0ecff80..2d6be2ed5 100644 --- a/packages/server/controllers/user.controllers.js +++ b/packages/server/controllers/user.controllers.js @@ -204,11 +204,17 @@ const getReciever = async (selectedEmail, externalName, trx) => { * also 'shared' and have COMPLETED their review. * If the current user isn't a 'shared' reviewer, return an empty array. */ -const getSharedReviewersIds = async (manuscriptId, currentUserId) => { +const getSharedReviewersIds = async ( + manuscriptId, + currentUserId, + options = {}, +) => { if (!currentUserId) return [] - const reviewers = await Team.relatedQuery('members') - .for(Team.query().where({ objectId: manuscriptId, role: 'reviewer' })) + const { trx } = options + + const reviewers = await Team.relatedQuery('members', trx) + .for(Team.query(trx).where({ objectId: manuscriptId, role: 'reviewer' })) .select('userId') .where({ isShared: true }) .where(builder => @@ -304,7 +310,10 @@ const getUsers = async groupId => { .withGraphFetched('defaultIdentity') } -const getUsersById = async userIds => User.query().findByIds(userIds) +const getUsersById = async (userIds, options = {}) => { + const { trx } = options + return User.query(trx).findByIds(userIds) +} const isUserOnline = async user => { const currentDateTime = new Date() diff --git a/packages/server/models/collaborative-doc/collaborativeDoc.model.js b/packages/server/models/collaborative-doc/collaborativeDoc.model.js index cadd87299..72134115b 100644 --- a/packages/server/models/collaborative-doc/collaborativeDoc.model.js +++ b/packages/server/models/collaborative-doc/collaborativeDoc.model.js @@ -41,8 +41,10 @@ class CollaborativeDoc extends BaseModel { } } - static async getFormData(objectId, form) { - const collaborativeDoc = await CollaborativeDoc.query().findOne({ + static async getFormData(objectId, form, options = {}) { + const { trx } = options + + const collaborativeDoc = await CollaborativeDoc.query(trx).findOne({ objectId, }) diff --git a/packages/server/models/review/index.js b/packages/server/models/review/index.js index 8502e069a..af55643fc 100644 --- a/packages/server/models/review/index.js +++ b/packages/server/models/review/index.js @@ -1,6 +1,8 @@ +const { usersLoader } = require('./review.loaders') const model = require('./review.model') module.exports = { model, modelName: 'Review', + modelLoaders: { usersLoader }, } diff --git a/packages/server/models/review/review.loaders.js b/packages/server/models/review/review.loaders.js new file mode 100644 index 000000000..29165cd29 --- /dev/null +++ b/packages/server/models/review/review.loaders.js @@ -0,0 +1,107 @@ +const { useTransaction } = require('@coko/server') + +const Review = require('./review.model') +// const Manuscript = require('../manuscript/manuscript.model') +const User = require('../user/user.model') +const Team = require('../team/team.model') +const { defaultIdentitiesLoader } = require('../user/user.loaders') + +const usersLoader = async (reviewIds, options = {}) => { + const run = async trx => { + // Step 1: Fetch all reviews + const reviews = await Review.query(trx).findByIds(reviewIds) + + const reviewToUsers = new Map( + reviews.map(r => [ + r.id, + { + userIds: [], + isCollaborative: r.isCollaborative, + isHidden: r.isHiddenReviewerName, + manuscriptId: r.manuscriptId, + }, + ]), + ) + + // Separate collaborative and solo + const collaborative = reviews.filter(r => r.isCollaborative) + const solo = reviews.filter(r => !r.isCollaborative) + + // Step 2: Collaborative in one query + if (collaborative.length) { + const manuscriptIds = collaborative.map(r => r.manuscriptId) + + const teams = await Team.query(trx) + .where('role', 'collaborativeReviewer') + .whereIn('manuscriptId', manuscriptIds) + .withGraphFetched('users') + + const teamsByManuscript = new Map(teams.map(t => [t.manuscriptId, t])) + + collaborative + .filter(review => teamsByManuscript.has(review.manuscriptId)) + .forEach(review => { + const team = teamsByManuscript.get(review.manuscriptId) + reviewToUsers.get(review.id).userIds = team.users.map(u => u.id) + }) + } + + // Step 3: Solo reviews + solo + .filter(review => review.userId) + .forEach(review => { + reviewToUsers.get(review.id).userIds = [review.userId] + }) + + // Step 4: Anonymous + reviews + .filter(review => review.isHiddenReviewerName) + .forEach(review => { + reviewToUsers.get(review.id).users = [ + { id: '', username: 'Anonymous User' }, + ] + }) + + // Step 5: Fetch all users and identities + const allUserIds = [ + ...new Set( + Array.from(reviewToUsers.values()) + .flatMap(r => r.userIds) + .filter(Boolean), + ), + ] + + const users = allUserIds.length + ? await User.query(trx).findByIds(allUserIds) + : [] + + const defaultIdentities = allUserIds.length + ? await defaultIdentitiesLoader(allUserIds, { trx }) + : [] + + const byUserId = new Map( + users.map((u, i) => [ + u.id, + { ...u, defaultIdentity: defaultIdentities[i] }, + ]), + ) + + // Step 6: Final mapping + return reviewIds.map(id => { + const entry = reviewToUsers.get(id) + if (!entry) return [] + if (entry.users) return entry.users + return entry.userIds.map(uid => byUserId.get(uid)).filter(Boolean) + }) + } + + // Use provided transaction or none at all (skip useTransaction) + if (options.trx) { + return run(options.trx) + } + + // No ctx.trx? Run without useTransaction to avoid pool pressure + return useTransaction(run) +} + +module.exports = { usersLoader } diff --git a/packages/server/models/review/review.model.js b/packages/server/models/review/review.model.js index 2d47723d4..c65fee3ab 100644 --- a/packages/server/models/review/review.model.js +++ b/packages/server/models/review/review.model.js @@ -68,7 +68,8 @@ class Review extends BaseModel { } } - static async orderReviewPerUsername(reviews) { + static async orderReviewPerUsername(reviews, options = {}) { + const { trx } = options // eslint-disable-next-line global-require const User = require('../user/user.model') // eslint-disable-next-line global-require @@ -79,19 +80,19 @@ class Review extends BaseModel { let users = null if (review.isCollaborative) { - const manuscript = await Manuscript.query().findById( + const manuscript = await Manuscript.query(trx).findById( review.manuscriptId, ) const existingTeam = await manuscript - .$relatedQuery('teams') + .$relatedQuery('teams', trx) .where('role', 'collaborativeReviewer') .first() // eslint-disable-next-line no-param-reassign - users = await existingTeam.$relatedQuery('users') + users = await existingTeam.$relatedQuery('users', trx) } else { - users = await User.query().where({ id: review.userId }) + users = await User.query(trx).where({ id: review.userId }) } return { ...review, username: users[0]?.username || '' } // imported manuscripts may have invalid reviewers diff --git a/packages/server/models/user/user.loaders.js b/packages/server/models/user/user.loaders.js index 964d99a56..856582eed 100644 --- a/packages/server/models/user/user.loaders.js +++ b/packages/server/models/user/user.loaders.js @@ -1,7 +1,11 @@ const User = require('./user.model') -const defaultIdentitiesLoader = async userIds => { - const identities = await User.relatedQuery('defaultIdentity').for(userIds) +const defaultIdentitiesLoader = async (userIds, options = {}) => { + const { trx } = options + + const identities = await User.relatedQuery('defaultIdentity', trx).for( + userIds, + ) const byUserId = new Map(identities.map(i => [i.userId, i])) return userIds.map(id => byUserId.get(id) ?? null)