ProjectListController.js 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758
  1. // ts-check
  2. const _ = require('lodash')
  3. const Metrics = require('@overleaf/metrics')
  4. const Settings = require('@overleaf/settings')
  5. const ProjectHelper = require('./ProjectHelper')
  6. const ProjectGetter = require('./ProjectGetter')
  7. const PrivilegeLevels = require('../Authorization/PrivilegeLevels')
  8. const SessionManager = require('../Authentication/SessionManager')
  9. const Sources = require('../Authorization/Sources')
  10. const UserGetter = require('../User/UserGetter')
  11. const SurveyHandler = require('../Survey/SurveyHandler')
  12. const TagsHandler = require('../Tags/TagsHandler')
  13. const { expressify } = require('@overleaf/promise-utils')
  14. const logger = require('@overleaf/logger')
  15. const Features = require('../../infrastructure/Features')
  16. const SubscriptionViewModelBuilder = require('../Subscription/SubscriptionViewModelBuilder')
  17. const NotificationsHandler = require('../Notifications/NotificationsHandler')
  18. const Modules = require('../../infrastructure/Modules')
  19. const { OError, V1ConnectionError } = require('../Errors/Errors')
  20. const { User } = require('../../models/User')
  21. const UserPrimaryEmailCheckHandler = require('../User/UserPrimaryEmailCheckHandler')
  22. const UserController = require('../User/UserController')
  23. const LimitationsManager = require('../Subscription/LimitationsManager')
  24. const NotificationsBuilder = require('../Notifications/NotificationsBuilder')
  25. const GeoIpLookup = require('../../infrastructure/GeoIpLookup')
  26. const SplitTestHandler = require('../SplitTests/SplitTestHandler')
  27. const SplitTestSessionHandler = require('../SplitTests/SplitTestSessionHandler')
  28. const SubscriptionLocator = require('../Subscription/SubscriptionLocator')
  29. /** @typedef {import("./types").GetProjectsRequest} GetProjectsRequest */
  30. /** @typedef {import("./types").GetProjectsResponse} GetProjectsResponse */
  31. /** @typedef {import("../../../../types/project/dashboard/api").ProjectApi} ProjectApi */
  32. /** @typedef {import("../../../../types/project/dashboard/api").Filters} Filters */
  33. /** @typedef {import("../../../../types/project/dashboard/api").Page} Page */
  34. /** @typedef {import("../../../../types/project/dashboard/api").Sort} Sort */
  35. /** @typedef {import("./types").AllUsersProjects} AllUsersProjects */
  36. /** @typedef {import("./types").MongoProject} MongoProject */
  37. /** @typedef {import("../Tags/types").Tag} Tag */
  38. const _ssoAvailable = (affiliation, session, linkedInstitutionIds) => {
  39. if (!affiliation.institution) return false
  40. // institution.confirmed is for the domain being confirmed, not the email
  41. // Do not show SSO UI for unconfirmed domains
  42. if (!affiliation.institution.confirmed) return false
  43. // Could have multiple emails at the same institution, and if any are
  44. // linked to the institution then do not show notification for others
  45. if (
  46. linkedInstitutionIds.indexOf(affiliation.institution.id.toString()) === -1
  47. ) {
  48. if (affiliation.institution.ssoEnabled) return true
  49. if (affiliation.institution.ssoBeta && session.samlBeta) return true
  50. return false
  51. }
  52. return false
  53. }
  54. const _buildPortalTemplatesList = affiliations => {
  55. if (affiliations == null) {
  56. affiliations = []
  57. }
  58. const portalTemplates = []
  59. const uniqueAffiliations = _.uniqBy(affiliations, 'institution.id')
  60. for (const aff of uniqueAffiliations) {
  61. const hasSlug = aff.portal?.slug
  62. const hasTemplates = aff.portal?.templates_count > 0
  63. if (hasSlug && hasTemplates) {
  64. const portalPath = aff.institution.isUniversity ? '/edu/' : '/org/'
  65. const portalTemplateURL = Settings.siteUrl + portalPath + aff.portal?.slug
  66. portalTemplates.push({
  67. name: aff.institution.name,
  68. url: portalTemplateURL,
  69. })
  70. }
  71. }
  72. return portalTemplates
  73. }
  74. function cleanupSession(req) {
  75. // cleanup redirects at the end of the redirect chain
  76. delete req.session.postCheckoutRedirect
  77. delete req.session.postLoginRedirect
  78. delete req.session.postOnboardingRedirect
  79. // cleanup details from register page
  80. delete req.session.sharedProjectData
  81. delete req.session.templateData
  82. }
  83. /**
  84. * @param {import("express").Request} req
  85. * @param {import("express").Response} res
  86. * @param {import("express").NextFunction} next
  87. * @returns {Promise<void>}
  88. */
  89. async function projectListPage(req, res, next) {
  90. cleanupSession(req)
  91. // can have two values:
  92. // - undefined - when there's no "saas" feature or couldn't get subscription data
  93. // - object - the subscription data object
  94. let usersBestSubscription
  95. let survey
  96. let userIsMemberOfGroupSubscription = false
  97. let groupSubscriptionsPendingEnrollment = []
  98. const isSaas = Features.hasFeature('saas')
  99. const userId = SessionManager.getLoggedInUserId(req.session)
  100. const projectsBlobPending = _getProjects(userId).catch(err => {
  101. logger.err({ err, userId }, 'projects listing in background failed')
  102. return undefined
  103. })
  104. const user = await User.findById(
  105. userId,
  106. `email emails features alphaProgram betaProgram lastPrimaryEmailCheck labsProgram signUpDate${
  107. isSaas ? ' enrollment writefull' : ''
  108. }`
  109. )
  110. // Handle case of deleted user
  111. if (user == null) {
  112. UserController.logout(req, res, next)
  113. return
  114. }
  115. if (isSaas) {
  116. await SplitTestSessionHandler.promises.sessionMaintenance(req, user)
  117. try {
  118. usersBestSubscription =
  119. await SubscriptionViewModelBuilder.promises.getBestSubscription({
  120. _id: userId,
  121. })
  122. } catch (error) {
  123. logger.err(
  124. { err: error, userId },
  125. "Failed to get user's best subscription"
  126. )
  127. }
  128. try {
  129. const { isMember, subscriptions } =
  130. await LimitationsManager.promises.userIsMemberOfGroupSubscription(user)
  131. userIsMemberOfGroupSubscription = isMember
  132. // TODO use helper function
  133. if (!user.enrollment?.managedBy) {
  134. groupSubscriptionsPendingEnrollment = subscriptions.filter(
  135. subscription => subscription.groupPlan && subscription.groupPolicy
  136. )
  137. }
  138. } catch (error) {
  139. logger.error(
  140. { err: error },
  141. 'Failed to check whether user is a member of group subscription'
  142. )
  143. }
  144. try {
  145. survey = await SurveyHandler.promises.getSurvey(userId)
  146. } catch (error) {
  147. logger.err({ err: error, userId }, 'Failed to load the active survey')
  148. }
  149. if (user && UserPrimaryEmailCheckHandler.requiresPrimaryEmailCheck(user)) {
  150. return res.redirect('/user/emails/primary-email-check')
  151. }
  152. } else {
  153. if (!process.env.OVERLEAF_IS_SERVER_PRO) {
  154. // temporary survey for CE: https://github.com/overleaf/internal/issues/19710
  155. survey = {
  156. name: 'ce-survey',
  157. preText: 'Help us improve Overleaf',
  158. linkText: 'by filling out this quick survey',
  159. url: 'https://docs.google.com/forms/d/e/1FAIpQLSdPAS-731yaLOvRM8HW7j6gVeOpcmB_X5A5qwgNJT7Oj09lLA/viewform?usp=sf_link',
  160. }
  161. }
  162. }
  163. const tags = await TagsHandler.promises.getAllTags(userId)
  164. let userEmailsData = { list: [], allInReconfirmNotificationPeriods: [] }
  165. try {
  166. const fullEmails = await UserGetter.promises.getUserFullEmails(userId)
  167. if (!Features.hasFeature('affiliations')) {
  168. userEmailsData.list = fullEmails
  169. } else {
  170. try {
  171. const results = await Modules.promises.hooks.fire(
  172. 'allInReconfirmNotificationPeriodsForUser',
  173. fullEmails
  174. )
  175. const allInReconfirmNotificationPeriods = (results && results[0]) || []
  176. userEmailsData = {
  177. list: fullEmails,
  178. allInReconfirmNotificationPeriods,
  179. }
  180. } catch (error) {
  181. userEmailsData = error
  182. }
  183. }
  184. } catch (error) {
  185. if (!(error instanceof V1ConnectionError)) {
  186. logger.error({ err: error, userId }, 'Failed to get user full emails')
  187. }
  188. }
  189. const userEmails = userEmailsData.list || []
  190. const userAffiliations = userEmails
  191. .filter(emailData => !!emailData.affiliation)
  192. .map(emailData => {
  193. const result = emailData.affiliation
  194. result.email = emailData.email
  195. return result
  196. })
  197. const portalTemplates = _buildPortalTemplatesList(userAffiliations)
  198. const { allInReconfirmNotificationPeriods } = userEmailsData
  199. const notifications =
  200. await NotificationsHandler.promises.getUserNotifications(userId)
  201. for (const notification of notifications) {
  202. notification.html = req.i18n.translate(
  203. notification.templateKey,
  204. notification.messageOpts
  205. )
  206. }
  207. const notificationsInstitution = []
  208. // Institution and group SSO Notifications
  209. let groupSsoSetupSuccess
  210. let reconfirmedViaSAML
  211. if (Features.hasFeature('saml')) {
  212. reconfirmedViaSAML = _.get(req.session, ['saml', 'reconfirmed'])
  213. const samlSession = req.session.saml
  214. // Notification: SSO Available
  215. const linkedInstitutionIds = []
  216. userEmails.forEach(email => {
  217. if (email.samlProviderId) {
  218. linkedInstitutionIds.push(email.samlProviderId)
  219. }
  220. })
  221. if (Array.isArray(userAffiliations)) {
  222. userAffiliations.forEach(affiliation => {
  223. if (_ssoAvailable(affiliation, req.session, linkedInstitutionIds)) {
  224. notificationsInstitution.push({
  225. email: affiliation.email,
  226. institutionId: affiliation.institution.id,
  227. institutionName: affiliation.institution.name,
  228. templateKey: 'notification_institution_sso_available',
  229. })
  230. }
  231. })
  232. }
  233. if (samlSession) {
  234. // Notification institution SSO: After SSO Linked
  235. if (samlSession.linked) {
  236. notificationsInstitution.push({
  237. email: samlSession.institutionEmail,
  238. institutionName:
  239. samlSession.linked.universityName ||
  240. samlSession.linked.providerName,
  241. templateKey: 'notification_institution_sso_linked',
  242. })
  243. }
  244. // Notification group SSO: After SSO Linked
  245. if (samlSession.linkedGroup) {
  246. groupSsoSetupSuccess = true
  247. }
  248. // Notification institution SSO: After SSO Linked or Logging in
  249. // The requested email does not match primary email returned from
  250. // the institution
  251. if (
  252. samlSession.requestedEmail &&
  253. samlSession.emailNonCanonical &&
  254. !samlSession.error
  255. ) {
  256. notificationsInstitution.push({
  257. institutionEmail: samlSession.emailNonCanonical,
  258. requestedEmail: samlSession.requestedEmail,
  259. templateKey: 'notification_institution_sso_non_canonical',
  260. })
  261. }
  262. // Notification institution SSO: Tried to register, but account already existed
  263. // registerIntercept is set before the institution callback.
  264. // institutionEmail is set after institution callback.
  265. // Check for both in case SSO flow was abandoned
  266. if (
  267. samlSession.registerIntercept &&
  268. samlSession.institutionEmail &&
  269. !samlSession.error
  270. ) {
  271. notificationsInstitution.push({
  272. email: samlSession.institutionEmail,
  273. templateKey: 'notification_institution_sso_already_registered',
  274. })
  275. }
  276. // Notification: When there is a session error
  277. if (samlSession.error) {
  278. notificationsInstitution.push({
  279. templateKey: 'notification_institution_sso_error',
  280. error: samlSession.error,
  281. })
  282. }
  283. }
  284. delete req.session.saml
  285. }
  286. function fakeDelay() {
  287. return new Promise(resolve => {
  288. setTimeout(() => resolve(undefined), 0)
  289. })
  290. }
  291. const prefetchedProjectsBlob = await Promise.race([
  292. projectsBlobPending,
  293. fakeDelay(),
  294. ])
  295. Metrics.inc('project-list-prefetch-projects', 1, {
  296. status: prefetchedProjectsBlob ? 'success' : 'too-slow',
  297. })
  298. // in v2 add notifications for matching university IPs
  299. if (Settings.overleaf != null && req.ip !== user.lastLoginIp) {
  300. try {
  301. await NotificationsBuilder.promises
  302. .ipMatcherAffiliation(user._id)
  303. .create(req.ip)
  304. } catch (err) {
  305. logger.error(
  306. { err },
  307. 'failed to create institutional IP match notification'
  308. )
  309. }
  310. }
  311. const hasPaidAffiliation = userAffiliations.some(
  312. affiliation => affiliation.licence && affiliation.licence !== 'free'
  313. )
  314. const showGroupsAndEnterpriseBanner =
  315. Features.hasFeature('saas') &&
  316. !userIsMemberOfGroupSubscription &&
  317. !hasPaidAffiliation
  318. const groupsAndEnterpriseBannerVariant =
  319. showGroupsAndEnterpriseBanner &&
  320. _.sample(['on-premise', 'FOMO', 'FOMO', 'FOMO'])
  321. let showWritefullPromoBanner = false
  322. if (Features.hasFeature('saas') && !req.session.justRegistered) {
  323. try {
  324. const { variant } = await SplitTestHandler.promises.getAssignment(
  325. req,
  326. res,
  327. 'writefull-promo-banner'
  328. )
  329. showWritefullPromoBanner = variant === 'enabled'
  330. } catch (error) {
  331. logger.warn(
  332. { err: error },
  333. 'failed to get "writefull-promo-banner" split test assignment'
  334. )
  335. }
  336. }
  337. let showInrGeoBanner = false
  338. let showBrlGeoBanner = false
  339. let showLATAMBanner = false
  340. let recommendedCurrency
  341. if (usersBestSubscription?.type === 'free') {
  342. const latamGeoPricingAssignment =
  343. await SplitTestHandler.promises.getAssignment(
  344. req,
  345. res,
  346. 'geo-pricing-latam-v2'
  347. )
  348. const { countryCode, currencyCode } =
  349. await GeoIpLookup.promises.getCurrencyCode(req.ip)
  350. if (countryCode === 'IN') {
  351. showInrGeoBanner = true
  352. }
  353. showBrlGeoBanner = countryCode === 'BR'
  354. showLATAMBanner =
  355. latamGeoPricingAssignment.variant === 'latam' &&
  356. ['MX', 'CO', 'CL', 'PE'].includes(countryCode)
  357. // LATAM Banner needs to know which currency to display
  358. if (showLATAMBanner) {
  359. recommendedCurrency = currencyCode
  360. }
  361. }
  362. let hasIndividualRecurlySubscription = false
  363. try {
  364. const individualSubscription =
  365. await SubscriptionLocator.promises.getUsersSubscription(userId)
  366. hasIndividualRecurlySubscription =
  367. individualSubscription?.groupPlan === false &&
  368. individualSubscription?.recurlyStatus?.state !== 'canceled' &&
  369. individualSubscription?.recurlySubscription_id !== ''
  370. } catch (error) {
  371. logger.error({ err: error }, 'Failed to get individual subscription')
  372. }
  373. try {
  374. await SplitTestHandler.promises.getAssignment(req, res, 'paywall-cta')
  375. } catch (error) {
  376. logger.error(
  377. { err: error },
  378. 'failed to get "paywall-cta" split test assignment'
  379. )
  380. }
  381. // Get the user's assignment for this page's Bootstrap 5 split test, which
  382. // populates splitTestVariants with a value for the split test name and allows
  383. // Pug to read it
  384. await SplitTestHandler.promises.getAssignment(
  385. req,
  386. res,
  387. 'bootstrap-5-project-dashboard'
  388. )
  389. await SplitTestHandler.promises.getAssignment(
  390. req,
  391. res,
  392. 'ai-fake-door-offering-test'
  393. )
  394. res.render('project/list-react', {
  395. title: 'your_projects',
  396. usersBestSubscription,
  397. notifications,
  398. notificationsInstitution,
  399. user,
  400. userAffiliations,
  401. userEmails,
  402. reconfirmedViaSAML,
  403. allInReconfirmNotificationPeriods,
  404. survey,
  405. tags,
  406. portalTemplates,
  407. prefetchedProjectsBlob,
  408. showGroupsAndEnterpriseBanner,
  409. groupsAndEnterpriseBannerVariant,
  410. showWritefullPromoBanner,
  411. showLATAMBanner,
  412. recommendedCurrency,
  413. showInrGeoBanner,
  414. showBrlGeoBanner,
  415. projectDashboardReact: true, // used in navbar
  416. groupSsoSetupSuccess,
  417. groupSubscriptionsPendingEnrollment:
  418. groupSubscriptionsPendingEnrollment.map(subscription => ({
  419. groupId: subscription._id,
  420. groupName: subscription.teamName,
  421. })),
  422. hasIndividualRecurlySubscription,
  423. userRestrictions: Array.from(req.userRestrictions || []),
  424. })
  425. }
  426. /**
  427. * Load user's projects with pagination, sorting and filters
  428. *
  429. * @param {GetProjectsRequest} req the request
  430. * @param {GetProjectsResponse} res the response
  431. * @returns {Promise<void>}
  432. */
  433. async function getProjectsJson(req, res) {
  434. const { filters, page, sort } = req.body
  435. const userId = SessionManager.getLoggedInUserId(req.session)
  436. const projectsPage = await _getProjects(userId, filters, sort, page)
  437. res.json(projectsPage)
  438. }
  439. /**
  440. * @param {string} userId
  441. * @param {Filters} filters
  442. * @param {Sort} sort
  443. * @param {Page} page
  444. * @returns {Promise<{totalSize: number, projects: ProjectApi[]}>}
  445. * @private
  446. */
  447. async function _getProjects(
  448. userId,
  449. filters = {},
  450. sort = { by: 'lastUpdated', order: 'desc' },
  451. page = { size: 20 }
  452. ) {
  453. const [
  454. /** @type {AllUsersProjects} **/ allProjects,
  455. /** @type {Tag[]} **/ tags,
  456. ] = await Promise.all([
  457. ProjectGetter.promises.findAllUsersProjects(
  458. userId,
  459. 'name lastUpdated lastUpdatedBy publicAccesLevel archived trashed owner_ref tokens'
  460. ),
  461. TagsHandler.promises.getAllTags(userId),
  462. ])
  463. const formattedProjects = _formatProjects(allProjects, userId)
  464. const filteredProjects = _applyFilters(
  465. formattedProjects,
  466. tags,
  467. filters,
  468. userId
  469. )
  470. const pagedProjects = _sortAndPaginate(filteredProjects, sort, page)
  471. await _injectProjectUsers(pagedProjects)
  472. return {
  473. totalSize: filteredProjects.length,
  474. projects: pagedProjects,
  475. }
  476. }
  477. /**
  478. * @param {AllUsersProjects} projects
  479. * @param {string} userId
  480. * @returns {Project[]}
  481. * @private
  482. */
  483. function _formatProjects(projects, userId) {
  484. const { owned, readAndWrite, readOnly, tokenReadAndWrite, tokenReadOnly } =
  485. projects
  486. const formattedProjects = /** @type {Project[]} **/ []
  487. for (const project of owned) {
  488. formattedProjects.push(
  489. _formatProjectInfo(project, 'owner', Sources.OWNER, userId)
  490. )
  491. }
  492. // Invite-access
  493. for (const project of readAndWrite) {
  494. formattedProjects.push(
  495. _formatProjectInfo(project, 'readWrite', Sources.INVITE, userId)
  496. )
  497. }
  498. for (const project of readOnly) {
  499. formattedProjects.push(
  500. _formatProjectInfo(project, 'readOnly', Sources.INVITE, userId)
  501. )
  502. }
  503. // Token-access
  504. // Only add these formattedProjects if they're not already present, this gives us cascading access
  505. // from 'owner' => 'token-read-only'
  506. for (const project of tokenReadAndWrite) {
  507. if (!formattedProjects.some(p => p.id === project._id.toString())) {
  508. formattedProjects.push(
  509. _formatProjectInfo(project, 'readAndWrite', Sources.TOKEN, userId)
  510. )
  511. }
  512. }
  513. for (const project of tokenReadOnly) {
  514. if (!formattedProjects.some(p => p.id === project._id.toString())) {
  515. formattedProjects.push(
  516. _formatProjectInfo(project, 'readOnly', Sources.TOKEN, userId)
  517. )
  518. }
  519. }
  520. return formattedProjects
  521. }
  522. /**
  523. * @param {Project[]} projects
  524. * @param {Tag[]} tags
  525. * @param {Filters} filters
  526. * @param {string} userId
  527. * @returns {Project[]}
  528. * @private
  529. */
  530. function _applyFilters(projects, tags, filters, userId) {
  531. if (!_hasActiveFilter(filters)) {
  532. return projects
  533. }
  534. return projects.filter(project => _matchesFilters(project, tags, filters))
  535. }
  536. /**
  537. * @param {Project[]} projects
  538. * @param {Sort} sort
  539. * @param {Page} page
  540. * @returns {Project[]}
  541. * @private
  542. */
  543. function _sortAndPaginate(projects, sort, page) {
  544. if (
  545. (sort.by && !['lastUpdated', 'title', 'owner'].includes(sort.by)) ||
  546. (sort.order && !['asc', 'desc'].includes(sort.order))
  547. ) {
  548. throw new OError('Invalid sorting criteria', { sort })
  549. }
  550. const sortedProjects = _.orderBy(
  551. projects,
  552. [sort.by || 'lastUpdated'],
  553. [sort.order || 'desc']
  554. )
  555. // TODO handle pagination
  556. return sortedProjects
  557. }
  558. /**
  559. * @param {MongoProject} project
  560. * @param {string} accessLevel
  561. * @param {'owner' | 'invite' | 'token'} source
  562. * @param {string} userId
  563. * @returns {object}
  564. * @private
  565. */
  566. function _formatProjectInfo(project, accessLevel, source, userId) {
  567. const archived = ProjectHelper.isArchived(project, userId)
  568. // If a project is simultaneously trashed and archived, we will consider it archived but not trashed.
  569. const trashed = ProjectHelper.isTrashed(project, userId) && !archived
  570. const model = {
  571. id: project._id.toString(),
  572. name: project.name,
  573. owner_ref: project.owner_ref,
  574. lastUpdated: project.lastUpdated,
  575. lastUpdatedBy: project.lastUpdatedBy,
  576. accessLevel,
  577. source,
  578. archived,
  579. trashed,
  580. }
  581. if (accessLevel === PrivilegeLevels.READ_ONLY && source === Sources.TOKEN) {
  582. model.owner_ref = null
  583. model.lastUpdatedBy = null
  584. }
  585. return model
  586. }
  587. /**
  588. * @param {Project[]} projects
  589. * @returns {Promise<void>}
  590. * @private
  591. */
  592. async function _injectProjectUsers(projects) {
  593. const userIds = new Set()
  594. for (const project of projects) {
  595. if (project.owner_ref != null) {
  596. userIds.add(project.owner_ref.toString())
  597. }
  598. if (project.lastUpdatedBy != null) {
  599. userIds.add(project.lastUpdatedBy.toString())
  600. }
  601. }
  602. const projection = {
  603. first_name: 1,
  604. last_name: 1,
  605. email: 1,
  606. }
  607. const users = {}
  608. for (const user of await UserGetter.promises.getUsers(userIds, projection)) {
  609. const userId = user._id.toString()
  610. users[userId] = {
  611. id: userId,
  612. email: user.email,
  613. firstName: user.first_name,
  614. lastName: user.last_name,
  615. }
  616. }
  617. for (const project of projects) {
  618. if (project.owner_ref != null) {
  619. project.owner = users[project.owner_ref.toString()]
  620. }
  621. if (project.lastUpdatedBy != null) {
  622. project.lastUpdatedBy = users[project.lastUpdatedBy.toString()] || null
  623. }
  624. delete project.owner_ref
  625. }
  626. }
  627. /**
  628. * @param {any} project
  629. * @param {Tag[]} tags
  630. * @param {Filters} filters
  631. * @private
  632. */
  633. function _matchesFilters(project, tags, filters) {
  634. if (filters.ownedByUser && project.accessLevel !== 'owner') {
  635. return false
  636. }
  637. if (filters.sharedWithUser && project.accessLevel === 'owner') {
  638. return false
  639. }
  640. if (filters.archived && !project.archived) {
  641. return false
  642. }
  643. if (filters.trashed && !project.trashed) {
  644. return false
  645. }
  646. if (
  647. filters.tag &&
  648. !_.find(
  649. tags,
  650. tag =>
  651. filters.tag === tag.name && (tag.project_ids || []).includes(project.id)
  652. )
  653. ) {
  654. return false
  655. }
  656. if (
  657. filters.search?.length &&
  658. project.name.toLowerCase().indexOf(filters.search.toLowerCase()) === -1
  659. ) {
  660. return false
  661. }
  662. return true
  663. }
  664. /**
  665. * @param {Filters} filters
  666. * @returns {boolean}
  667. * @private
  668. */
  669. function _hasActiveFilter(filters) {
  670. return (
  671. filters.ownedByUser ||
  672. filters.sharedWithUser ||
  673. filters.archived ||
  674. filters.trashed ||
  675. filters.tag === null ||
  676. filters.tag?.length ||
  677. filters.search?.length
  678. )
  679. }
  680. module.exports = {
  681. projectListPage: expressify(projectListPage),
  682. getProjectsJson: expressify(getProjectsJson),
  683. }