Răsfoiți Sursa

Merge pull request #31247 from overleaf/revert-31216-revert-30418-mfb-improve-handling-of-debug-copies-of-user-projects

Revert "Revert "Add isDebugCopyOf property to project, add Debug tag to debug project.""

GitOrigin-RevId: 2ed41ec4504ccd8b6ec9cb1d23a9d217079d4347
Maria Florencia Besteiro Gonzalez 6 luni în urmă
părinte
comite
a76f0ee9f4

+ 23 - 0
services/web/app/src/Features/Notifications/NotificationsBuilder.mjs

@@ -238,6 +238,28 @@ function personalAndGroupSubscriptions(userId) {
   }
 }
 
+function oldDebugProjects(userId) {
+  return {
+    key: `old-debug-projects-${userId}`,
+    async create(userId) {
+      return await NotificationsHandler.promises.createNotification(
+        userId,
+        this.key,
+        'notification_old_debug_projects',
+        {},
+        null,
+        true
+      )
+    },
+    async read() {
+      return await NotificationsHandler.promises.markAsReadWithKey(
+        userId,
+        this.key
+      )
+    },
+  }
+}
+
 const NotificationsBuilder = {
   // Note: notification keys should be url-safe
   dropboxUnlinkedDueToLapsedReconfirmation(userId) {
@@ -279,6 +301,7 @@ NotificationsBuilder.promises = {
   projectInvite,
   personalAndGroupSubscriptions,
   tpdsFileLimit,
+  oldDebugProjects,
 }
 
 export default NotificationsBuilder

+ 4 - 3
services/web/app/src/Features/Project/ProjectController.mjs

@@ -259,8 +259,8 @@ const _ProjectController = {
     res.setTimeout(5 * 60 * 1000) // allow extra time for the copy to complete
     metrics.inc('cloned-project')
     const projectId = req.params.Project_id
-    const { projectName, tags } = req.body
-    logger.debug({ projectId, projectName }, 'cloning project')
+    const { projectName, isDebugCopy, tags } = req.body
+    logger.debug({ projectId, projectName, isDebugCopy }, 'cloning project')
     if (!SessionManager.isUserLoggedIn(req.session)) {
       return res.json({ redir: '/register' })
     }
@@ -271,7 +271,8 @@ const _ProjectController = {
         currentUser,
         projectId,
         projectName,
-        tags
+        tags,
+        isDebugCopy
       )
       ProjectAuditLogHandler.addEntryIfManagedInBackground(
         projectId,

+ 5 - 0
services/web/app/src/Features/Project/ProjectCreationHandler.mjs

@@ -196,6 +196,11 @@ async function _createBlankProject(
   if (historyRangesSupportAssignment.variant === 'enabled') {
     project.overleaf.history.rangesSupportEnabled = true
   }
+
+  if (attributes.isDebugCopyOf) {
+    project.overleaf.isDebugCopyOf = new ObjectId(attributes.isDebugCopyOf)
+  }
+
   await project.save()
   if (!skipCreatingInTPDS) {
     await TpdsUpdateSender.promises.createProject({

+ 26 - 2
services/web/app/src/Features/Project/ProjectDuplicator.mjs

@@ -22,6 +22,9 @@ import TagsHandler from '../Tags/TagsHandler.mjs'
 import ClsiCacheManager from '../Compile/ClsiCacheManager.mjs'
 import Modules from '../../infrastructure/Modules.mjs'
 
+const TAG_COLOR_RED = '#f04343'
+const DEBUG_TAG_NAME = 'Debug'
+
 export default {
   duplicate: callbackify(duplicate),
   promises: {
@@ -29,7 +32,13 @@ export default {
   },
 }
 
-async function duplicate(owner, originalProjectId, newProjectName, tags = []) {
+async function duplicate(
+  owner,
+  originalProjectId,
+  newProjectName,
+  tags = [],
+  isDebugCopy
+) {
   await DocumentUpdaterHandler.promises.flushProjectToMongo(originalProjectId)
   const originalProject = await ProjectGetter.promises.getProject(
     originalProjectId,
@@ -58,6 +67,18 @@ async function duplicate(owner, originalProjectId, newProjectName, tags = []) {
     originalEntries,
   })
 
+  const attributes = {}
+  if (isDebugCopy) {
+    attributes.isDebugCopyOf = originalProjectId
+    // - Create new tag on owner._id if it doesn't already exist
+    const debugTag = await TagsHandler.promises.createTag(
+      owner._id,
+      DEBUG_TAG_NAME,
+      TAG_COLOR_RED,
+      { truncate: true }
+    )
+    tags.push(debugTag)
+  }
   // Pass template ID as analytics segmentation if duplicating project from a template
   const segmentation = _.pick(originalProject, [
     'fromV1TemplateId',
@@ -72,6 +93,9 @@ async function duplicate(owner, originalProjectId, newProjectName, tags = []) {
       originalProject._id
     )
   segmentation['updated-tags'] = tags.length
+  attributes.segmentation = segmentation
+
+  attributes.segmentation = segmentation
 
   // remove any leading or trailing spaces
   newProjectName = newProjectName.trim()
@@ -80,7 +104,7 @@ async function duplicate(owner, originalProjectId, newProjectName, tags = []) {
   const newProject = await ProjectCreationHandler.promises.createBlankProject(
     owner._id,
     newProjectName,
-    { segmentation }
+    attributes
   )
 
   let prepareClsiCacheInBackground = Promise.resolve()

+ 24 - 0
services/web/app/src/Features/Project/ProjectGetter.mjs

@@ -132,6 +132,30 @@ const ProjectGetter = {
     return filteredProjects
   },
 
+  async existUsersDebugProjectsOlderThan(userId, days) {
+    const cutoffDate = new Date(Date.now() - days * 24 * 60 * 60 * 1000)
+
+    const exists = await Project.exists({
+      owner_ref: userId,
+      'overleaf.isDebugCopyOf': { $type: 'objectId' },
+      lastUpdated: { $lt: cutoffDate },
+    })
+
+    return Boolean(exists)
+  },
+
+  async findAllDebugProjects(fields) {
+    return Project.find(
+      {
+        'overleaf.isDebugCopyOf': { $type: 'objectId' },
+      },
+      fields
+    )
+      .limit(500)
+      .populate('owner_ref', ['email', 'name'])
+      .exec()
+  },
+
   /**
    * Return all projects with the given name that belong to the given user.
    *

+ 18 - 1
services/web/app/src/Features/Project/ProjectListController.mjs

@@ -164,9 +164,10 @@ async function projectListPage(req, res, next) {
     logger.err({ err, userId }, 'projects listing in background failed')
     return undefined
   })
+
   const user = await User.findById(
     userId,
-    `email emails features alphaProgram betaProgram lastPrimaryEmailCheck lastActive signUpDate ace refProviders${
+    `email isAdmin emails features alphaProgram betaProgram lastPrimaryEmailCheck lastActive signUpDate ace refProviders${
       isSaas ? ' enrollment writefull completedTutorials aiErrorAssistant' : ''
     }`
   )
@@ -187,6 +188,8 @@ async function projectListPage(req, res, next) {
   let role
 
   if (isSaas) {
+    if (user.isAdmin) await _checkForOldDebugProjects(userId)
+
     await SplitTestSessionHandler.promises.sessionMaintenance(req, user)
 
     try {
@@ -595,6 +598,20 @@ async function getProjectsJson(req, res) {
   res.json(projectsPage)
 }
 
+/**
+ * @param {string} userId
+ * @private
+ */
+async function _checkForOldDebugProjects(userId) {
+  const exists = await ProjectGetter.promises.existUsersDebugProjectsOlderThan(
+    userId,
+    7
+  )
+  if (exists) {
+    await NotificationsBuilder.promises.oldDebugProjects(userId).create(userId)
+  }
+}
+
 /**
  * @param {string} userId
  * @param {Filters} filters

+ 27 - 18
services/web/app/src/Features/ServerAdmin/AdminController.mjs

@@ -6,7 +6,10 @@ import TpdsUpdateSender from '../ThirdPartyDataStore/TpdsUpdateSender.mjs'
 import TpdsProjectFlusher from '../ThirdPartyDataStore/TpdsProjectFlusher.mjs'
 import EditorRealTimeController from '../Editor/EditorRealTimeController.mjs'
 import SystemMessageManager from '../SystemMessages/SystemMessageManager.mjs'
+import ProjectGetter from '../Project/ProjectGetter.mjs'
 import Modules from '../../infrastructure/Modules.mjs'
+import Features from '../../infrastructure/Features.mjs'
+import { expressify } from '@overleaf/promise-utils'
 
 const AdminController = {
   _sendDisconnectAllUsersMessage: delay => {
@@ -16,7 +19,7 @@ const AdminController = {
       delay
     )
   },
-  index: (req, res, next) => {
+  index: expressify(async (req, res, next) => {
     let url
     const openSockets = {}
     for (url in http.globalAgent.sockets) {
@@ -31,24 +34,30 @@ const AdminController = {
       )
     }
 
-    SystemMessageManager.getMessagesFromDB(
-      async function (error, systemMessages) {
-        if (error) {
-          return next(error)
-        }
-        const privilegesMatrixResults = await Modules.promises.hooks.fire(
-          'getPrivilegesMatrix'
-        )
-        const privilegesMatrix = privilegesMatrixResults[0] || null
-        res.render('admin/index', {
-          title: 'System Admin',
-          openSockets,
-          systemMessages,
-          privilegesMatrix,
-        })
-      }
+    const systemMessages =
+      await SystemMessageManager.promises.getMessagesFromDB()
+
+    const privilegesMatrixResults = await Modules.promises.hooks.fire(
+      'getPrivilegesMatrix'
     )
-  },
+
+    const privilegesMatrix = privilegesMatrixResults[0] || null
+
+    const toRender = {
+      title: 'System Admin',
+      openSockets,
+      systemMessages,
+      privilegesMatrix,
+    }
+
+    if (Features.hasFeature('saas')) {
+      const debugProjects = await ProjectGetter.promises.findAllDebugProjects(
+        'name lastUpdated owner_ref'
+      )
+      toRender.debugProjects = debugProjects
+    }
+    res.render('admin/index', toRender)
+  }),
 
   disconnectAllUsers: (req, res) => {
     logger.warn('disconecting everyone')

+ 18 - 0
services/web/app/views/admin/index.pug

@@ -19,6 +19,7 @@ block content
 										+bookmarkable-tabset-header('privileges-matrix', 'Privileges Matrix')
 										if hasFeature('saas')
 											+bookmarkable-tabset-header('tpds', 'TPDS/Dropbox Management')
+											+bookmarkable-tabset-header('debug-projects', 'Debug Projects')
 
 								.tab-content
 									.tab-pane.active(role='tabpanel' id='system-messages')
@@ -133,3 +134,20 @@ block content
 														input.form-control(name='user_id' id='user-id' type='text' required)
 													.form-group
 														button.btn-primary.btn(type='submit') Poll
+
+										.tab-pane(role='tabpanel' id='debug-projects')
+											if debugProjects.length
+												table.table.table-striped
+													thead
+														tr
+															th Owner
+															th Project Name
+															th Last updated
+													tbody
+														each project in debugProjects
+															tr
+																td= project.owner_ref.email
+																td= project.name
+																td= moment(project.lastUpdated.toUTCString()).format('Do MMM YYYY, h:mm a') + ' UTC'
+											else
+												p.text-muted No debug projects found

+ 6 - 0
services/web/frontend/js/features/project-list/components/notifications/groups/common.tsx

@@ -287,6 +287,12 @@ function CommonNotification({ notification }: CommonNotificationProps) {
             />
           }
         />
+      ) : templateKey === 'notification_old_debug_projects' ? (
+        <Notification
+          type="warning"
+          onDismiss={() => id && handleDismiss(id)}
+          content={html}
+        />
       ) : (
         <Notification
           type="info"

+ 1 - 0
services/web/locales/en.json

@@ -1545,6 +1545,7 @@
   "note_features_under_development": "<0>Please note</0> that features in this program are still being tested and actively developed. This means that they might <0>change</0>, be <0>removed</0> or <0>become part of a premium plan</0>",
   "notification": "Notification",
   "notification_features_upgraded_by_affiliation": "Good news! Your affiliated organization __institutionName__ has an Overleaf subscription, and you now have access to all of Overleaf’s Professional features.",
+  "notification_old_debug_projects": "You have some old debug projects that you should delete, if you no longer need them.",
   "notification_personal_and_group_subscriptions": "We’ve spotted that you’ve got <0>more than one active __appName__ subscription</0>. To avoid paying more than you need to, <1>review your subscriptions</1>.",
   "notification_personal_subscription_not_required_due_to_affiliation": " Good news! Your affiliated organization __institutionName__ has an Overleaf subscription, and you now have access to Overleaf’s Professional features through your affiliation. You can cancel your individual subscription without losing access to any features.",
   "notification_project_invite_accepted_message": "You’ve joined <b>__projectName__</b>",

+ 38 - 0
services/web/test/unit/src/Project/ProjectGetter.test.mjs

@@ -20,10 +20,13 @@ describe('ProjectGetter', function () {
     ctx.Project = {
       find: sinon.stub().returns({
         exec: sinon.stub().resolves(),
+        populate: sinon.stub().returnsThis(),
+        limit: sinon.stub().returnsThis(),
       }),
       findOne: sinon.stub().returns({
         exec: sinon.stub().resolves(ctx.project),
       }),
+      exists: sinon.stub().returns({ exec: sinon.stub().resolves(true) }),
     }
     ctx.CollaboratorsGetter = {
       promises: {
@@ -458,4 +461,39 @@ describe('ProjectGetter', function () {
       expect(docs).to.deep.equal([ctx.deletedProject])
     })
   })
+
+  describe('findAllDebugProjects', function () {
+    it('should find all projects with overleaf.isDebugCopyOf of type objectId', async function (ctx) {
+      await ctx.ProjectGetter.promises.findAllDebugProjects('fields')
+      sinon.assert.calledWith(ctx.Project.find, {
+        'overleaf.isDebugCopyOf': { $type: 'objectId' },
+      })
+      sinon.assert.calledWith(ctx.Project.find().populate, 'owner_ref', [
+        'email',
+        'name',
+      ])
+      sinon.assert.calledOnce(ctx.Project.find().exec)
+    })
+  })
+
+  describe('existUsersDebugProjectsOlderThan', function () {
+    it('should check for existence of debug projects older than given days', async function (ctx) {
+      const days = 10
+      const cutoffDate = new Date(Date.now() - days * 24 * 60 * 60 * 1000)
+
+      const exists =
+        await ctx.ProjectGetter.promises.existUsersDebugProjectsOlderThan(
+          ctx.userId,
+          days
+        )
+
+      sinon.assert.calledWith(ctx.Project.exists, {
+        owner_ref: ctx.userId,
+        'overleaf.isDebugCopyOf': { $type: 'objectId' },
+        lastUpdated: { $lt: cutoffDate },
+      })
+
+      expect(exists).to.equal(true)
+    })
+  })
 })