ProjectListController.mjs 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814
  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. if (!userIsMemberOfGroupSubscription && !inEnterpriseCommons) {
  368. try {
  369. const ip = req.ip
  370. const { countryCode } = await GeoIpLookup.promises.getCurrencyCode(ip)
  371. const excludedCountries = ['IN', 'CN']
  372. if (!excludedCountries.includes(countryCode)) {
  373. const cioAssignment =
  374. await SplitTestHandler.promises.getAssignmentForUser(
  375. userId,
  376. 'customer-io-trial-conversion'
  377. )
  378. if (cioAssignment.variant === 'enabled') {
  379. customerIoEnabled = true
  380. AnalyticsManager.setUserPropertyForUserInBackground(
  381. userId,
  382. 'customer-io-integration',
  383. true
  384. )
  385. }
  386. }
  387. } catch (err) {
  388. logger.error(
  389. { err },
  390. 'Error checking geo location for customer-io-trial-conversion'
  391. )
  392. // Fallback to not enabled if geoip fails
  393. customerIoEnabled = false
  394. }
  395. }
  396. res.render('project/list-react', {
  397. title: 'your_projects',
  398. usersBestSubscription,
  399. notifications,
  400. notificationsInstitution,
  401. user,
  402. userAffiliations,
  403. userEmails,
  404. reconfirmedViaSAML,
  405. allInReconfirmNotificationPeriods,
  406. survey,
  407. tags,
  408. portalTemplates,
  409. prefetchedProjectsBlob,
  410. showGroupsAndEnterpriseBanner,
  411. groupsAndEnterpriseBannerVariant,
  412. showUSGovBanner,
  413. usGovBannerVariant,
  414. showLATAMBanner,
  415. recommendedCurrency,
  416. showInrGeoBanner,
  417. showBrlGeoBanner,
  418. projectDashboardReact: true, // used in navbar
  419. groupSsoSetupSuccess,
  420. groupSubscriptionsPendingEnrollment:
  421. groupSubscriptionsPendingEnrollment.map(subscription => ({
  422. groupId: subscription._id,
  423. groupName: subscription.teamName,
  424. })),
  425. hasIndividualPaidSubscription,
  426. userRestrictions: Array.from(req.userRestrictions || []),
  427. customerIoEnabled,
  428. showAiAssistNotification,
  429. })
  430. }
  431. /**
  432. * Load user's projects with pagination, sorting and filters
  433. *
  434. * @param {GetProjectsRequest} req the request
  435. * @param {GetProjectsResponse} res the response
  436. * @returns {Promise<void>}
  437. */
  438. async function getProjectsJson(req, res) {
  439. const { filters, page, sort } = req.body
  440. const userId = SessionManager.getLoggedInUserId(req.session)
  441. const projectsPage = await _getProjects(userId, filters, sort, page)
  442. res.json(projectsPage)
  443. }
  444. /**
  445. * @param {string} userId
  446. * @param {Filters} filters
  447. * @param {Sort} sort
  448. * @param {Page} page
  449. * @returns {Promise<{totalSize: number, projects: ProjectApi[]}>}
  450. * @private
  451. */
  452. async function _getProjects(
  453. userId,
  454. filters = {},
  455. sort = { by: 'lastUpdated', order: 'desc' },
  456. page = { size: 20 }
  457. ) {
  458. const [
  459. /** @type {AllUsersProjects} **/ allProjects,
  460. /** @type {Tag[]} **/ tags,
  461. ] = await Promise.all([
  462. ProjectGetter.promises.findAllUsersProjects(
  463. userId,
  464. 'name lastUpdated lastUpdatedBy publicAccesLevel archived trashed owner_ref tokens'
  465. ),
  466. TagsHandler.promises.getAllTags(userId),
  467. ])
  468. const formattedProjects = _formatProjects(allProjects, userId)
  469. const filteredProjects = _applyFilters(
  470. formattedProjects,
  471. tags,
  472. filters,
  473. userId
  474. )
  475. const pagedProjects = _sortAndPaginate(filteredProjects, sort, page)
  476. await _injectProjectUsers(pagedProjects)
  477. return {
  478. totalSize: filteredProjects.length,
  479. projects: pagedProjects,
  480. }
  481. }
  482. /**
  483. * @param {AllUsersProjects} projects
  484. * @param {string} userId
  485. * @returns {Project[]}
  486. * @private
  487. */
  488. function _formatProjects(projects, userId) {
  489. const {
  490. owned,
  491. review,
  492. readAndWrite,
  493. readOnly,
  494. tokenReadAndWrite,
  495. tokenReadOnly,
  496. } = projects
  497. const formattedProjects = /** @type {Project[]} **/ []
  498. for (const project of owned) {
  499. formattedProjects.push(
  500. _formatProjectInfo(project, 'owner', Sources.OWNER, userId)
  501. )
  502. }
  503. // Invite-access
  504. for (const project of readAndWrite) {
  505. formattedProjects.push(
  506. _formatProjectInfo(project, 'readWrite', Sources.INVITE, userId)
  507. )
  508. }
  509. for (const project of review) {
  510. formattedProjects.push(
  511. _formatProjectInfo(project, 'review', Sources.INVITE, userId)
  512. )
  513. }
  514. for (const project of readOnly) {
  515. formattedProjects.push(
  516. _formatProjectInfo(project, 'readOnly', Sources.INVITE, userId)
  517. )
  518. }
  519. // Token-access
  520. // Only add these formattedProjects if they're not already present, this gives us cascading access
  521. // from 'owner' => 'token-read-only'
  522. for (const project of tokenReadAndWrite) {
  523. if (!formattedProjects.some(p => p.id === project._id.toString())) {
  524. formattedProjects.push(
  525. _formatProjectInfo(project, 'readAndWrite', Sources.TOKEN, userId)
  526. )
  527. }
  528. }
  529. for (const project of tokenReadOnly) {
  530. if (!formattedProjects.some(p => p.id === project._id.toString())) {
  531. formattedProjects.push(
  532. _formatProjectInfo(project, 'readOnly', Sources.TOKEN, userId)
  533. )
  534. }
  535. }
  536. return formattedProjects
  537. }
  538. /**
  539. * @param {Project[]} projects
  540. * @param {Tag[]} tags
  541. * @param {Filters} filters
  542. * @param {string} userId
  543. * @returns {Project[]}
  544. * @private
  545. */
  546. function _applyFilters(projects, tags, filters, userId) {
  547. if (!_hasActiveFilter(filters)) {
  548. return projects
  549. }
  550. return projects.filter(project => _matchesFilters(project, tags, filters))
  551. }
  552. /**
  553. * @param {Project[]} projects
  554. * @param {Sort} sort
  555. * @param {Page} page
  556. * @returns {Project[]}
  557. * @private
  558. */
  559. function _sortAndPaginate(projects, sort, page) {
  560. if (
  561. (sort.by && !['lastUpdated', 'title', 'owner'].includes(sort.by)) ||
  562. (sort.order && !['asc', 'desc'].includes(sort.order))
  563. ) {
  564. throw new OError('Invalid sorting criteria', { sort })
  565. }
  566. const sortedProjects = _.orderBy(
  567. projects,
  568. [sort.by || 'lastUpdated'],
  569. [sort.order || 'desc']
  570. )
  571. // TODO handle pagination
  572. return sortedProjects
  573. }
  574. /**
  575. * @param {MongoProject} project
  576. * @param {string} accessLevel
  577. * @param {'owner' | 'invite' | 'token'} source
  578. * @param {string} userId
  579. * @returns {object}
  580. * @private
  581. */
  582. function _formatProjectInfo(project, accessLevel, source, userId) {
  583. const archived = ProjectHelper.isArchived(project, userId)
  584. // If a project is simultaneously trashed and archived, we will consider it archived but not trashed.
  585. const trashed = ProjectHelper.isTrashed(project, userId) && !archived
  586. const model = {
  587. id: project._id.toString(),
  588. name: project.name,
  589. owner_ref: project.owner_ref,
  590. lastUpdated: project.lastUpdated,
  591. lastUpdatedBy: project.lastUpdatedBy,
  592. accessLevel,
  593. source,
  594. archived,
  595. trashed,
  596. }
  597. if (accessLevel === PrivilegeLevels.READ_ONLY && source === Sources.TOKEN) {
  598. model.owner_ref = null
  599. model.lastUpdatedBy = null
  600. }
  601. return model
  602. }
  603. /**
  604. * @param {Project[]} projects
  605. * @returns {Promise<void>}
  606. * @private
  607. */
  608. async function _injectProjectUsers(projects) {
  609. const userIds = new Set()
  610. for (const project of projects) {
  611. if (project.owner_ref != null) {
  612. userIds.add(project.owner_ref.toString())
  613. }
  614. if (project.lastUpdatedBy != null) {
  615. userIds.add(project.lastUpdatedBy.toString())
  616. }
  617. }
  618. const projection = {
  619. first_name: 1,
  620. last_name: 1,
  621. email: 1,
  622. }
  623. const users = {}
  624. for (const user of await UserGetter.promises.getUsers(userIds, projection)) {
  625. const userId = user._id.toString()
  626. users[userId] = {
  627. id: userId,
  628. email: user.email,
  629. firstName: user.first_name,
  630. lastName: user.last_name,
  631. }
  632. }
  633. for (const project of projects) {
  634. if (project.owner_ref != null) {
  635. project.owner = users[project.owner_ref.toString()]
  636. }
  637. if (project.lastUpdatedBy != null) {
  638. project.lastUpdatedBy = users[project.lastUpdatedBy.toString()] || null
  639. }
  640. delete project.owner_ref
  641. }
  642. }
  643. /**
  644. * @param {any} project
  645. * @param {Tag[]} tags
  646. * @param {Filters} filters
  647. * @private
  648. */
  649. function _matchesFilters(project, tags, filters) {
  650. if (filters.ownedByUser && project.accessLevel !== 'owner') {
  651. return false
  652. }
  653. if (filters.sharedWithUser && project.accessLevel === 'owner') {
  654. return false
  655. }
  656. if (filters.archived && !project.archived) {
  657. return false
  658. }
  659. if (filters.trashed && !project.trashed) {
  660. return false
  661. }
  662. if (
  663. filters.tag &&
  664. !_.find(
  665. tags,
  666. tag =>
  667. filters.tag === tag.name && (tag.project_ids || []).includes(project.id)
  668. )
  669. ) {
  670. return false
  671. }
  672. if (
  673. filters.search?.length &&
  674. project.name.toLowerCase().indexOf(filters.search.toLowerCase()) === -1
  675. ) {
  676. return false
  677. }
  678. return true
  679. }
  680. /**
  681. * @param {Filters} filters
  682. * @returns {boolean}
  683. * @private
  684. */
  685. function _hasActiveFilter(filters) {
  686. return (
  687. filters.ownedByUser ||
  688. filters.sharedWithUser ||
  689. filters.archived ||
  690. filters.trashed ||
  691. filters.tag === null ||
  692. filters.tag?.length ||
  693. filters.search?.length
  694. )
  695. }
  696. async function _showAiAssistNotification(user) {
  697. // Check if the assistant has been manually disabled by the user
  698. if (user.aiErrorAssistant?.enabled === false) {
  699. return false
  700. }
  701. // Check if the user can use AI features (policy check)
  702. const canUseAi = await PermissionsManager.promises.checkUserPermissions(
  703. user,
  704. ['use-ai']
  705. )
  706. if (!canUseAi) {
  707. return false
  708. }
  709. // Check if the user has a subscription with manually collected group admins (#22822)
  710. const subscription = await SubscriptionLocator.promises.getUsersSubscription(
  711. user._id
  712. )
  713. if (subscription?.collectionMethod === 'manual') {
  714. return false
  715. }
  716. // Check if the user already has AI Assist via Overleaf
  717. if (user.features?.aiErrorAssistant) {
  718. return false
  719. }
  720. // Check if the user already has AI Assist via Writefull
  721. const { isPremium: hasAiAssistViaWritefull } =
  722. await UserGetter.promises.getWritefullData(user._id)
  723. if (hasAiAssistViaWritefull) {
  724. return false
  725. }
  726. return true
  727. }
  728. export default {
  729. projectListPage: expressify(projectListPage),
  730. getProjectsJson: expressify(getProjectsJson),
  731. }