From a591f2eb7a2f13a1546d39ef66066f827dba7c56 Mon Sep 17 00:00:00 2001 From: Maria Florencia Besteiro Gonzalez Date: Mon, 2 Feb 2026 10:35:37 +0100 Subject: [PATCH] Merge pull request #30418 from overleaf/mfb-improve-handling-of-debug-copies-of-user-projects Add isDebugCopyOf property to project, add Debug tag to debug project. GitOrigin-RevId: e3d17de05c6f31db16b861d1adae333211dff018 --- .../Notifications/NotificationsBuilder.mjs | 23 ++++++++++ .../Features/Project/ProjectController.mjs | 7 +-- .../Project/ProjectCreationHandler.mjs | 5 +++ .../Features/Project/ProjectDuplicator.mjs | 28 +++++++++++- .../src/Features/Project/ProjectGetter.mjs | 24 ++++++++++ .../Project/ProjectListController.mjs | 19 +++++++- .../Features/ServerAdmin/AdminController.mjs | 45 +++++++++++-------- services/web/app/views/admin/index.pug | 18 ++++++++ .../notifications/groups/common.tsx | 6 +++ services/web/locales/en.json | 1 + .../unit/src/Project/ProjectGetter.test.mjs | 38 ++++++++++++++++ 11 files changed, 190 insertions(+), 24 deletions(-) diff --git a/services/web/app/src/Features/Notifications/NotificationsBuilder.mjs b/services/web/app/src/Features/Notifications/NotificationsBuilder.mjs index c74c720df9..84d37c802f 100644 --- a/services/web/app/src/Features/Notifications/NotificationsBuilder.mjs +++ b/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 diff --git a/services/web/app/src/Features/Project/ProjectController.mjs b/services/web/app/src/Features/Project/ProjectController.mjs index ded4afd98c..32029b81a7 100644 --- a/services/web/app/src/Features/Project/ProjectController.mjs +++ b/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, diff --git a/services/web/app/src/Features/Project/ProjectCreationHandler.mjs b/services/web/app/src/Features/Project/ProjectCreationHandler.mjs index 55df89bcc2..095fe12391 100644 --- a/services/web/app/src/Features/Project/ProjectCreationHandler.mjs +++ b/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({ diff --git a/services/web/app/src/Features/Project/ProjectDuplicator.mjs b/services/web/app/src/Features/Project/ProjectDuplicator.mjs index c189db966f..fdada472b0 100644 --- a/services/web/app/src/Features/Project/ProjectDuplicator.mjs +++ b/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() diff --git a/services/web/app/src/Features/Project/ProjectGetter.mjs b/services/web/app/src/Features/Project/ProjectGetter.mjs index a44e93645e..9e2727a9cf 100644 --- a/services/web/app/src/Features/Project/ProjectGetter.mjs +++ b/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. * diff --git a/services/web/app/src/Features/Project/ProjectListController.mjs b/services/web/app/src/Features/Project/ProjectListController.mjs index d1d04f6cc7..8b48a51e8c 100644 --- a/services/web/app/src/Features/Project/ProjectListController.mjs +++ b/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 diff --git a/services/web/app/src/Features/ServerAdmin/AdminController.mjs b/services/web/app/src/Features/ServerAdmin/AdminController.mjs index c42520570d..76bf71143e 100644 --- a/services/web/app/src/Features/ServerAdmin/AdminController.mjs +++ b/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') diff --git a/services/web/app/views/admin/index.pug b/services/web/app/views/admin/index.pug index 88199a2474..71d85a7bcb 100644 --- a/services/web/app/views/admin/index.pug +++ b/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 diff --git a/services/web/frontend/js/features/project-list/components/notifications/groups/common.tsx b/services/web/frontend/js/features/project-list/components/notifications/groups/common.tsx index a8e0ab2800..d4f6a3f368 100644 --- a/services/web/frontend/js/features/project-list/components/notifications/groups/common.tsx +++ b/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' ? ( + id && handleDismiss(id)} + content={html} + /> ) : ( Please note that features in this program are still being tested and actively developed. This means that they might <0>change, be <0>removed or <0>become part of a premium plan", "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. To avoid paying more than you need to, <1>review your subscriptions.", "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 __projectName__", diff --git a/services/web/test/unit/src/Project/ProjectGetter.test.mjs b/services/web/test/unit/src/Project/ProjectGetter.test.mjs index d8c27a0c50..676c4e0dda 100644 --- a/services/web/test/unit/src/Project/ProjectGetter.test.mjs +++ b/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) + }) + }) })