ProjectListController.mjs 24 KB

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