Skip to content

Commit 1e31a88

Browse files
Merge pull request #203 from MdTowfikomer/feat/soft-delete-recovery
feat: implement soft-delete recovery
2 parents 408b680 + 18dddac commit 1e31a88

24 files changed

Lines changed: 1035 additions & 221 deletions

apps/dashboard-api/src/__tests__/project.controller.softDelete.test.js

Lines changed: 147 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,28 @@ const mockModel = {
77
findOneAndUpdate: mockFindOneAndUpdate,
88
};
99

10+
const mockDispatchWebhooks = jest.fn();
11+
1012
jest.mock('@urbackend/common', () => ({
1113
Project: {
1214
findOne: mockFindOne,
1315
},
1416
getConnection: jest.fn().mockResolvedValue({}),
1517
getCompiledModel: jest.fn(() => mockModel),
16-
enqueueCollectionCleanup: jest.fn().mockResolvedValue(true)
18+
dispatchWebhooks: mockDispatchWebhooks,
19+
enqueueCollectionCleanup: jest.fn().mockResolvedValue(true),
20+
syncCollectionCleanup: jest.fn().mockResolvedValue(true),
21+
AppError: class AppError extends Error {
22+
constructor(statusCode, message) {
23+
super(message);
24+
this.statusCode = statusCode;
25+
this.isOperational = true;
26+
}
27+
}
1728
}));
1829

19-
const { deleteRow } = require('../controllers/project.controller');
30+
const { deleteRow, recoverRow } = require('../controllers/project.controller');
31+
const mongoose = require('mongoose');
2032

2133
function makeReq() {
2234
return {
@@ -43,6 +55,7 @@ describe('Soft Delete in dashboard project.controller', () => {
4355
test('deleteRow sets isDeleted: true instead of hard deleting', async () => {
4456
const req = makeReq();
4557
const res = makeRes();
58+
const next = jest.fn();
4659

4760
// Mock project
4861
const project = {
@@ -59,7 +72,7 @@ describe('Soft Delete in dashboard project.controller', () => {
5972
lean: jest.fn().mockResolvedValue(doc)
6073
});
6174

62-
await deleteRow(req, res);
75+
await deleteRow(req, res, next);
6376

6477
expect(mockFindOne).toHaveBeenCalled();
6578
expect(mockFindOneAndUpdate).toHaveBeenCalledWith(
@@ -75,14 +88,12 @@ describe('Soft Delete in dashboard project.controller', () => {
7588
data: { id: '507f1f77bcf86cd799439011' },
7689
message: "Document moved to trash"
7790
});
78-
79-
// Should not save project (to update databaseUsed) since it's a soft delete
80-
expect(project.save).not.toHaveBeenCalled();
8191
});
8292

83-
test('deleteRow returns 404 if document is already soft-deleted or not found', async () => {
93+
test('deleteRow returns 404 via next(AppError) if document is already soft-deleted or not found', async () => {
8494
const req = makeReq();
8595
const res = makeRes();
96+
const next = jest.fn();
8697

8798
// Mock project
8899
const project = {
@@ -97,13 +108,137 @@ describe('Soft Delete in dashboard project.controller', () => {
97108
lean: jest.fn().mockResolvedValue(null)
98109
});
99110

100-
await deleteRow(req, res);
111+
await deleteRow(req, res, next);
101112

102-
expect(res.status).toHaveBeenCalledWith(404);
113+
expect(next).toHaveBeenCalledWith(expect.objectContaining({
114+
statusCode: 404,
115+
message: "Document not found."
116+
}));
117+
});
118+
119+
test('recoverRow restores a soft-deleted document', async () => {
120+
const req = makeReq();
121+
const res = makeRes();
122+
const next = jest.fn();
123+
124+
// Mock project
125+
const project = {
126+
_id: 'proj_1',
127+
resources: { db: { isExternal: false } },
128+
collections: [{ name: 'posts', model: [] }]
129+
};
130+
131+
// recoverRow uses Project.findOne({ _id: projectId, owner: req.user._id }).lean()
132+
const mockProjectFind = {
133+
lean: jest.fn().mockResolvedValue(project)
134+
};
135+
mockFindOne.mockReturnValue(mockProjectFind);
136+
137+
// Mock document
138+
const restoredDoc = { _id: '507f1f77bcf86cd799439011', isDeleted: false, deletedAt: null };
139+
mockFindOneAndUpdate.mockReturnValue({
140+
lean: jest.fn().mockResolvedValue(restoredDoc)
141+
});
142+
143+
await recoverRow(req, res, next);
144+
145+
expect(mockFindOne).toHaveBeenCalledWith({ _id: 'proj_1', owner: 'user_1' });
146+
expect(mockFindOneAndUpdate).toHaveBeenCalledWith(
147+
expect.objectContaining({
148+
_id: '507f1f77bcf86cd799439011',
149+
isDeleted: true,
150+
deletedAt: expect.objectContaining({ $gte: expect.any(Date) })
151+
}),
152+
expect.objectContaining({
153+
$set: { isDeleted: false, deletedAt: null }
154+
}),
155+
{ new: true }
156+
);
157+
103158
expect(res.json).toHaveBeenCalledWith({
104-
success: false,
105-
data: {},
106-
message: "Document not found."
159+
success: true,
160+
data: restoredDoc,
161+
message: "Document recovered from trash"
162+
});
163+
164+
expect(mockDispatchWebhooks).toHaveBeenCalledWith(expect.objectContaining({
165+
action: 'recover',
166+
document: restoredDoc,
167+
projectId: 'proj_1'
168+
}));
169+
const { syncCollectionCleanup } = require('@urbackend/common');
170+
expect(syncCollectionCleanup).toHaveBeenCalledWith('proj_1', 'posts');
171+
});
172+
173+
test('recoverRow returns 404 via next(AppError) if document is not in trash', async () => {
174+
const req = makeReq();
175+
const res = makeRes();
176+
const next = jest.fn();
177+
178+
// Mock project
179+
const project = {
180+
_id: 'proj_1',
181+
resources: { db: { isExternal: false } },
182+
collections: [{ name: 'posts', model: [] }]
183+
};
184+
const mockProjectFind = {
185+
lean: jest.fn().mockResolvedValue(project)
186+
};
187+
mockFindOne.mockReturnValue(mockProjectFind);
188+
189+
mockFindOneAndUpdate.mockReturnValue({
190+
lean: jest.fn().mockResolvedValue(null)
107191
});
192+
193+
await recoverRow(req, res, next);
194+
195+
expect(next).toHaveBeenCalledWith(expect.objectContaining({
196+
statusCode: 404,
197+
message: "Document not found or recovery window expired (30 days)."
198+
}));
199+
});
200+
201+
test('recoverRow returns 409 via next(AppError) if document restoration causes a unique field conflict', async () => {
202+
const req = makeReq();
203+
const res = makeRes();
204+
const next = jest.fn();
205+
206+
const project = {
207+
_id: 'proj_1',
208+
resources: { db: { isExternal: false } },
209+
collections: [{ name: 'posts', model: [] }]
210+
};
211+
mockFindOne.mockReturnValue({
212+
lean: jest.fn().mockResolvedValue(project)
213+
});
214+
215+
const error = new Error('Duplicate key');
216+
error.code = 11000;
217+
mockFindOneAndUpdate.mockReturnValue({
218+
lean: jest.fn().mockRejectedValue(error)
219+
});
220+
221+
await recoverRow(req, res, next);
222+
223+
expect(next).toHaveBeenCalledWith(expect.objectContaining({
224+
statusCode: 409,
225+
message: expect.stringContaining("unique field value conflicts")
226+
}));
227+
});
228+
229+
test('recoverRow returns 400 via next(AppError) if document ID is invalid', async () => {
230+
const req = {
231+
params: { projectId: 'proj_1', collectionName: 'posts', id: 'invalid-id' },
232+
user: { _id: 'user_1' }
233+
};
234+
const res = makeRes();
235+
const next = jest.fn();
236+
237+
await recoverRow(req, res, next);
238+
239+
expect(next).toHaveBeenCalledWith(expect.objectContaining({
240+
statusCode: 400,
241+
message: "Invalid document ID format."
242+
}));
108243
});
109244
});

apps/dashboard-api/src/__tests__/routes.projects.storage.test.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ jest.mock('../controllers/project.controller', () => {
4343
deleteCollection: jest.fn(ok),
4444
getData: jest.fn(ok),
4545
deleteRow: jest.fn(ok),
46+
recoverRow: jest.fn(ok),
4647
insertData: jest.fn(ok),
4748
editRow: jest.fn(ok),
4849
listFiles: jest.fn(ok),

apps/dashboard-api/src/controllers/project.controller.js

Lines changed: 97 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ const { getConnection } = require("@urbackend/common");
2222
const { getCompiledModel } = require("@urbackend/common");
2323
const { QueryEngine } = require("@urbackend/common");
2424
const { storageRegistry } = require("@urbackend/common");
25-
const { AppError } = require("@urbackend/common");
25+
const { AppError, dispatchWebhooks, enqueueCollectionCleanup, syncCollectionCleanup } = require("@urbackend/common");
2626
const { resolveEffectivePlan } = require("@urbackend/common");
2727
const {
2828
deleteProjectByApiKeyCache,
@@ -37,7 +37,6 @@ const { getPublicIp } = require("@urbackend/common");
3737
const { clearCompiledModel } = require("@urbackend/common");
3838
const { createUniqueIndexes, ApiAnalytics, MailLog } = require("@urbackend/common");
3939
const { emitEvent } = require('../utils/emitEvent');
40-
const { enqueueCollectionCleanup } = require('@urbackend/common');
4140
const MAX_FILE_SIZE = 10 * 1024 * 1024;
4241
const SAFETY_MAX_BYTES = 100 * 1024 * 1024;
4342
const CONFIRM_UPLOAD_SIZE_TOLERANCE_BYTES = 64;
@@ -935,21 +934,30 @@ module.exports.insertData = async (req, res) => {
935934
}
936935
};
937936

938-
module.exports.deleteRow = async (req, res) => {
937+
/**
938+
* Soft-deletes a document by setting isDeleted: true and recording the deletion time.
939+
* @param {import('express').Request} req - Express request
940+
* @param {import('express').Response} res - Express response
941+
*/
942+
module.exports.deleteRow = async (req, res, next) => {
939943
try {
940944
const { projectId, collectionName, id } = req.params;
941945

946+
if (!mongoose.isValidObjectId(id)) {
947+
return next(new AppError(400, "Invalid document ID format."));
948+
}
949+
942950
const project = await Project.findOne({
943951
_id: projectId,
944952
owner: req.user._id,
945953
});
946-
if (!project) return res.status(404).json({ error: "Project not found." });
954+
if (!project) return next(new AppError(404, "Project not found."));
947955

948956
const collectionConfig = project.collections.find(
949957
(c) => c.name === collectionName,
950958
);
951959
if (!collectionConfig) {
952-
return res.status(404).json({ error: "Collection not found." });
960+
return next(new AppError(404, "Collection not found."));
953961
}
954962

955963
const connection = await getConnection(projectId);
@@ -972,7 +980,7 @@ module.exports.deleteRow = async (req, res) => {
972980
).lean();
973981

974982
if (!result) {
975-
return res.status(404).json({ success: false, data: {}, message: "Document not found." });
983+
return next(new AppError(404, "Document not found."));
976984
}
977985

978986
// We don't decrement databaseUsed here because the document still occupies space.
@@ -986,7 +994,89 @@ module.exports.deleteRow = async (req, res) => {
986994
res.json({ success: true, data: { id: result._id }, message: "Document moved to trash" });
987995
} catch (err) {
988996
console.error("Delete Error:", err);
989-
res.status(500).json({ error: err.message });
997+
next(new AppError(500, "Failed to delete document"));
998+
}
999+
};
1000+
/**
1001+
* Recovers a soft-deleted document from trash.
1002+
* @param {import('express').Request} req - Express request
1003+
* @param {import('express').Response} res - Express response
1004+
* @param {import('express').NextFunction} next - Error handler
1005+
*/
1006+
module.exports.recoverRow = async (req, res, next) => {
1007+
try {
1008+
const { projectId, collectionName, id } = req.params;
1009+
1010+
if (!mongoose.isValidObjectId(id)) {
1011+
return next(new AppError(400, "Invalid document ID format."));
1012+
}
1013+
1014+
const project = await Project.findOne({
1015+
_id: projectId,
1016+
owner: req.user._id,
1017+
}).lean();
1018+
if (!project) {
1019+
return next(new AppError(404, "Project not found."));
1020+
}
1021+
1022+
const collectionConfig = project.collections.find(
1023+
(c) => c.name === collectionName,
1024+
);
1025+
if (!collectionConfig) {
1026+
return next(new AppError(404, "Collection not found."));
1027+
}
1028+
1029+
const connection = await getConnection(projectId);
1030+
const Model = getCompiledModel(
1031+
connection,
1032+
collectionConfig,
1033+
projectId,
1034+
project.resources.db.isExternal,
1035+
);
1036+
1037+
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
1038+
1039+
const result = await Model.findOneAndUpdate(
1040+
{
1041+
_id: id,
1042+
isDeleted: true,
1043+
deletedAt: { $gte: thirtyDaysAgo }
1044+
},
1045+
{
1046+
$set: {
1047+
isDeleted: false,
1048+
deletedAt: null
1049+
}
1050+
},
1051+
{ new: true }
1052+
).lean();
1053+
1054+
if (!result) {
1055+
return next(new AppError(404, "Document not found or recovery window expired (30 days)."));
1056+
}
1057+
1058+
dispatchWebhooks({
1059+
projectId: project._id,
1060+
collection: collectionName,
1061+
action: "recover",
1062+
document: result,
1063+
documentId: id,
1064+
options: { bypassLimit: true }
1065+
});
1066+
1067+
try {
1068+
await syncCollectionCleanup(projectId, collectionName);
1069+
} catch (err) {
1070+
console.error("Failed to sync trash cleanup job after recovery", { projectId, collectionName, err });
1071+
}
1072+
1073+
res.json({ success: true, data: result, message: "Document recovered from trash" });
1074+
} catch (err) {
1075+
console.error("Recover Error:", err);
1076+
if (err && err.code === 11000) {
1077+
return next(new AppError(409, "Cannot restore document: a unique field value conflicts with an existing active document."));
1078+
}
1079+
return next(new AppError(500, "Failed to recover document."));
9901080
}
9911081
};
9921082

apps/dashboard-api/src/routes/projects.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ const {
1515
deleteCollection,
1616
getData,
1717
deleteRow,
18+
recoverRow,
1819
insertData,
1920
editRow,
2021
listFiles,
@@ -67,6 +68,9 @@ router.get('/:projectId/collections/:collectionName/data', authMiddleware, getDa
6768
// DELETE REQ FOR ROW
6869
router.delete('/:projectId/collections/:collectionName/data/:id', authMiddleware, deleteRow);
6970

71+
// PATCH REQ FOR RECOVER ROW
72+
router.patch('/:projectId/collections/:collectionName/data/:id/recover', authMiddleware, recoverRow);
73+
7074
// PATCH REQ FOR EDIT ROW
7175
router.patch('/:projectId/collections/:collectionName/data/:id', authMiddleware, editRow);
7276

0 commit comments

Comments
 (0)