ProjectListController.mjs 24 KB

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