[web] Add deletedReason parameter to project deletion methods (#32221)

* [web] Add deletedReason parameter to project deletion methods

* revert sinon.match.any in ProjectDuplicator negative assertion

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
GitOrigin-RevId: d1595eefe0e36150231ee9646fe5eba0786fd1f5
This commit is contained in:
Domagoj Kriskovic
2026-03-20 13:08:26 +01:00
committed by Copybot
parent c9ba2ac025
commit 6486ef3e1e
13 changed files with 53 additions and 12 deletions

View File

@@ -5,6 +5,7 @@ import ProjectEntityUpdateHandler from '../Project/ProjectEntityUpdateHandler.mj
import ProjectOptionsHandler from '../Project/ProjectOptionsHandler.mjs'
import ProjectDetailsHandler from '../Project/ProjectDetailsHandler.mjs'
import ProjectDeleter from '../Project/ProjectDeleter.mjs'
import { DeletedProjectReasons } from '../Project/DeletedProjectReasons.mjs'
import EditorRealTimeController from './EditorRealTimeController.mjs'
import async from 'async'
import PublicAccessLevels from '../Authorization/PublicAccessLevels.mjs'
@@ -445,7 +446,11 @@ const EditorController = {
deleteProject(projectId, callback) {
Metrics.inc('editor.delete-project')
ProjectDeleter.deleteProject(projectId, callback)
ProjectDeleter.deleteProject(
projectId,
{ deletedReason: DeletedProjectReasons.USER },
callback
)
},
renameEntity(

View File

@@ -0,0 +1,8 @@
export const DeletedProjectReasons = /** @type {const} */ ({
USER: 'user',
ACCOUNT_DELETION: 'account-deletion',
ZIP_IMPORT_FAILURE: 'zip-import-failure',
CLONE_FAILURE: 'clone-failure',
GITHUB_IMPORT_FAILURE: 'github-import-failure',
SCRIPT: 'script',
})

View File

@@ -7,6 +7,7 @@ import logger from '@overleaf/logger'
import { expressify } from '@overleaf/promise-utils'
import mongodb from 'mongodb-legacy'
import ProjectDeleter from './ProjectDeleter.mjs'
import { DeletedProjectReasons } from './DeletedProjectReasons.mjs'
import ProjectDuplicator from './ProjectDuplicator.mjs'
import ProjectCreationHandler from './ProjectCreationHandler.mjs'
import EditorController from '../Editor/EditorController.mjs'
@@ -171,6 +172,7 @@ const _ProjectController = {
await ProjectDeleter.promises.deleteProject(projectId, {
deleterUser: user,
ipAddress: req.ip,
deletedReason: DeletedProjectReasons.USER,
})
ProjectAuditLogHandler.addEntryIfManagedInBackground(
projectId,

View File

@@ -22,6 +22,7 @@ import EditorRealTimeController from '../Editor/EditorRealTimeController.mjs'
import HistoryManager from '../History/HistoryManager.mjs'
import ChatApiHandler from '../Chat/ChatApiHandler.mjs'
import { promiseMapWithLimit } from '@overleaf/promise-utils'
import { DeletedProjectReasons } from './DeletedProjectReasons.mjs'
const PROJECT_EXPIRATION_BATCH_SIZE = 10000
@@ -84,7 +85,11 @@ async function deleteUsersProjects(userId) {
{ userId, projectCount: projects.length },
'found user projects to delete'
)
await promiseMapWithLimit(5, projects, project => deleteProject(project._id))
await promiseMapWithLimit(5, projects, project =>
deleteProject(project._id, {
deletedReason: DeletedProjectReasons.ACCOUNT_DELETION,
})
)
logger.info({ userId }, 'deleted all user projects')
await CollaboratorsHandler.promises.removeUserFromAllProjects(userId)
}
@@ -214,6 +219,7 @@ async function deleteProject(projectId, options = {}) {
deleterId:
options.deleterUser != null ? options.deleterUser._id : undefined,
deleterIpAddress: options.ipAddress,
deletedReason: options.deletedReason,
deletedProjectId: project._id,
deletedProjectOwnerId: project.owner_ref,
deletedProjectCollaboratorIds: project.collaberator_refs,

View File

@@ -10,6 +10,7 @@ import DocumentUpdaterHandler from '../DocumentUpdater/DocumentUpdaterHandler.mj
import HistoryManager from '../History/HistoryManager.mjs'
import ProjectCreationHandler from './ProjectCreationHandler.mjs'
import ProjectDeleter from './ProjectDeleter.mjs'
import { DeletedProjectReasons } from './DeletedProjectReasons.mjs'
import ProjectEntityMongoUpdateHandler from './ProjectEntityMongoUpdateHandler.mjs'
import ProjectEntityUpdateHandler from './ProjectEntityUpdateHandler.mjs'
import ProjectGetter from './ProjectGetter.mjs'
@@ -161,7 +162,9 @@ async function duplicate(
} catch (err) {
// Clean up broken clone on error.
// Make sure we delete the new failed project, not the original one!
await ProjectDeleter.promises.deleteProject(newProject._id)
await ProjectDeleter.promises.deleteProject(newProject._id, {
deletedReason: DeletedProjectReasons.CLONE_FAILURE,
})
throw OError.tag(err, 'error cloning project, broken clone deleted', {
originalProjectId,
newProjectName,

View File

@@ -13,6 +13,7 @@ import ProjectEntityMongoUpdateHandler from '../Project/ProjectEntityMongoUpdate
import ProjectRootDocManager from '../Project/ProjectRootDocManager.mjs'
import ProjectDetailsHandler from '../Project/ProjectDetailsHandler.mjs'
import ProjectDeleter from '../Project/ProjectDeleter.mjs'
import { DeletedProjectReasons } from '../Project/DeletedProjectReasons.mjs'
import TpdsProjectFlusher from '../ThirdPartyDataStore/TpdsProjectFlusher.mjs'
import logger from '@overleaf/logger'
import OError from '@overleaf/o-error'
@@ -55,7 +56,9 @@ async function createProjectFromZipArchive(ownerId, defaultName, zipPath) {
} catch (err) {
// no need to wait for the cleanup here
ProjectDeleter.promises
.deleteProject(project._id)
.deleteProject(project._id, {
deletedReason: DeletedProjectReasons.ZIP_IMPORT_FAILURE,
})
.catch(err =>
logger.error(
{ err, projectId: project._id },
@@ -97,7 +100,9 @@ async function createProjectFromZipArchiveWithName(
} catch (err) {
// no need to wait for the cleanup here
ProjectDeleter.promises
.deleteProject(project._id)
.deleteProject(project._id, {
deletedReason: DeletedProjectReasons.ZIP_IMPORT_FAILURE,
})
.catch(err =>
logger.error(
{ err, projectId: project._id },

View File

@@ -20,6 +20,7 @@ export const DeleterDataSchema = new Schema({
deletedProjectLastUpdatedAt: { type: Date },
deletedProjectOverleafId: { type: Number },
deletedProjectOverleafHistoryId: { type: Schema.Types.Mixed },
deletedReason: { type: String },
})
const DeletedProjectSchema = new Schema(

View File

@@ -1,5 +1,6 @@
import minimist from 'minimist'
import ProjectDeleter from '../app/src/Features/Project/ProjectDeleter.mjs'
import { DeletedProjectReasons } from '../app/src/Features/Project/DeletedProjectReasons.mjs'
import { scriptRunner } from './lib/ScriptRunner.mjs'
async function main() {
@@ -11,7 +12,9 @@ async function main() {
}
console.log(`Soft deleting project ${projectId}`)
// soft delete, project will be permanently deleted after 90 days
await ProjectDeleter.promises.deleteProject(projectId)
await ProjectDeleter.promises.deleteProject(projectId, {
deletedReason: DeletedProjectReasons.SCRIPT,
})
}
try {

View File

@@ -684,7 +684,7 @@ describe('EditorController', function () {
describe('deleteProject', function () {
beforeEach(function (ctx) {
ctx.err = 'errro'
ctx.ProjectDeleter.deleteProject.callsArgWith(1, ctx.err)
ctx.ProjectDeleter.deleteProject.callsArgWith(2, ctx.err)
})
it('should call the project handler', async function (ctx) {
@@ -692,7 +692,9 @@ describe('EditorController', function () {
ctx.EditorController.deleteProject(ctx.project_id, err => {
err.should.equal(ctx.err)
ctx.ProjectDeleter.deleteProject
.calledWith(ctx.project_id)
.calledWith(ctx.project_id, {
deletedReason: 'user',
})
.should.equal(true)
resolve()
})

View File

@@ -710,6 +710,7 @@ describe('ProjectController', function () {
.calledWith(ctx.project_id, {
deleterUser: ctx.user,
ipAddress: ctx.req.ip,
deletedReason: 'user',
})
.should.equal(true)
code.should.equal(200)

View File

@@ -288,7 +288,7 @@ describe('ProjectDeleter', function () {
{ 'deleterData.deletedProjectId': project._id },
{
project,
deleterData: sinon.match.object,
deleterData: sinon.match.has('deletedReason', 'account-deletion'),
},
{ upsert: true }
)
@@ -343,6 +343,7 @@ describe('ProjectDeleter', function () {
it('should save a DeletedProject with additional deleterData', async function (ctx) {
ctx.deleterData.deleterIpAddress = ctx.ip
ctx.deleterData.deleterId = ctx.user._id
ctx.deleterData.deletedReason = 'user'
ctx.ProjectMock.expects('deleteOne').chain('exec').resolves()
ctx.DeletedProjectMock.expects('updateOne')
@@ -359,6 +360,7 @@ describe('ProjectDeleter', function () {
await ctx.ProjectDeleter.promises.deleteProject(ctx.project._id, {
deleterUser: ctx.user,
ipAddress: ctx.ip,
deletedReason: 'user',
})
ctx.DeletedProjectMock.verify()
})

View File

@@ -459,7 +459,8 @@ describe('ProjectDuplicator', function () {
it('should delete the broken cloned project', function (ctx) {
ctx.ProjectDeleter.promises.deleteProject.should.have.been.calledWith(
ctx.newBlankProject._id
ctx.newBlankProject._id,
{ deletedReason: 'clone-failure' }
)
})

View File

@@ -445,7 +445,8 @@ describe('ProjectUploadManager', function () {
it('should cleanup the blank project created', async function (ctx) {
ctx.ProjectDeleter.promises.deleteProject.should.have.been.calledWith(
ctx.project._id
ctx.project._id,
{ deletedReason: 'zip-import-failure' }
)
})
@@ -471,7 +472,8 @@ describe('ProjectUploadManager', function () {
it('should cleanup the blank project created', function (ctx) {
ctx.ProjectDeleter.promises.deleteProject.should.have.been.calledWith(
ctx.project._id
ctx.project._id,
{ deletedReason: 'zip-import-failure' }
)
})