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
This commit is contained in:
Maria Florencia Besteiro Gonzalez
2026-02-02 10:35:37 +01:00
committed by Copybot
parent 5829a7fe43
commit a591f2eb7a
11 changed files with 190 additions and 24 deletions
@@ -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
@@ -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,
@@ -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({
@@ -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()
@@ -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.
*
@@ -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
@@ -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
View File
@@ -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
@@ -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
View File
@@ -1542,6 +1542,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 Overleafs 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": "Weve spotted that youve 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 Overleafs Professional features through your affiliation. You can cancel your individual subscription without losing access to any features.",
"notification_project_invite_accepted_message": "Youve joined <b>__projectName__</b>",
@@ -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)
})
})
})