Skip to content

Commit 392506c

Browse files
committed
feat(api): implement soft delete and trash cleanup worker
1 parent f3cd28f commit 392506c

13 files changed

Lines changed: 519 additions & 52 deletions

File tree

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
'use strict';
2+
3+
const mockFindOneAndUpdate = jest.fn();
4+
const mockFindOne = jest.fn();
5+
6+
const mockModel = {
7+
findOneAndUpdate: mockFindOneAndUpdate,
8+
};
9+
10+
jest.mock('@urbackend/common', () => ({
11+
Project: {
12+
findOne: mockFindOne,
13+
},
14+
getConnection: jest.fn().mockResolvedValue({}),
15+
getCompiledModel: jest.fn(() => mockModel),
16+
}));
17+
18+
const { deleteRow } = require('../controllers/project.controller');
19+
20+
function makeReq() {
21+
return {
22+
params: { projectId: 'proj_1', collectionName: 'posts', id: '507f1f77bcf86cd799439011' },
23+
user: { _id: 'user_1' }
24+
};
25+
}
26+
27+
function makeRes() {
28+
const res = {
29+
statusCode: null,
30+
body: null,
31+
status: jest.fn().mockReturnThis(),
32+
json: jest.fn().mockReturnThis(),
33+
};
34+
return res;
35+
}
36+
37+
describe('Soft Delete in dashboard project.controller', () => {
38+
beforeEach(() => {
39+
jest.clearAllMocks();
40+
});
41+
42+
test('deleteRow sets isDeleted: true instead of hard deleting', async () => {
43+
const req = makeReq();
44+
const res = makeRes();
45+
46+
// Mock project
47+
const project = {
48+
_id: 'proj_1',
49+
resources: { db: { isExternal: false } },
50+
collections: [{ name: 'posts', model: [] }],
51+
save: jest.fn().mockResolvedValue(true)
52+
};
53+
mockFindOne.mockResolvedValue(project);
54+
55+
// Mock document
56+
const doc = { _id: '507f1f77bcf86cd799439011', isDeleted: false };
57+
mockFindOneAndUpdate.mockReturnValue({
58+
lean: jest.fn().mockResolvedValue(doc)
59+
});
60+
61+
await deleteRow(req, res);
62+
63+
expect(mockFindOne).toHaveBeenCalled();
64+
expect(mockFindOneAndUpdate).toHaveBeenCalledWith(
65+
{ _id: '507f1f77bcf86cd799439011', isDeleted: { $ne: true } },
66+
expect.objectContaining({
67+
$set: expect.objectContaining({ isDeleted: true, deletedAt: expect.any(Date) })
68+
}),
69+
{ new: false }
70+
);
71+
72+
expect(res.json).toHaveBeenCalledWith({ success: true, message: "Document moved to trash" });
73+
74+
// Should not save project (to update databaseUsed) since it's a soft delete
75+
expect(project.save).not.toHaveBeenCalled();
76+
});
77+
78+
test('deleteRow returns 404 if document is already soft-deleted or not found', async () => {
79+
const req = makeReq();
80+
const res = makeRes();
81+
82+
// Mock project
83+
const project = {
84+
_id: 'proj_1',
85+
resources: { db: { isExternal: false } },
86+
collections: [{ name: 'posts', model: [] }],
87+
save: jest.fn().mockResolvedValue(true)
88+
};
89+
mockFindOne.mockResolvedValue(project);
90+
91+
mockFindOneAndUpdate.mockReturnValue({
92+
lean: jest.fn().mockResolvedValue(null)
93+
});
94+
95+
await deleteRow(req, res);
96+
97+
expect(res.status).toHaveBeenCalledWith(404);
98+
expect(res.json).toHaveBeenCalledWith({ error: 'Document not found.' });
99+
});
100+
});

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

Lines changed: 24 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -910,6 +910,10 @@ module.exports.insertData = async (req, res) => {
910910
project.resources.db.isExternal,
911911
);
912912

913+
// Prevent manual injection of soft-delete fields
914+
delete incomingData.isDeleted;
915+
delete incomingData.deletedAt;
916+
913917
const result = await model.create(incomingData);
914918

915919
if (!project.resources.db.isExternal) {
@@ -955,22 +959,25 @@ module.exports.deleteRow = async (req, res) => {
955959
project.resources.db.isExternal,
956960
);
957961

958-
const docToDelete = await Model.findById(id);
959-
if (!docToDelete) {
962+
const result = await Model.findOneAndUpdate(
963+
{ _id: id, isDeleted: { $ne: true } },
964+
{
965+
$set: {
966+
isDeleted: true,
967+
deletedAt: new Date()
968+
}
969+
},
970+
{ new: false }
971+
).lean();
972+
973+
if (!result) {
960974
return res.status(404).json({ error: "Document not found." });
961975
}
962976

963-
const docSize = Buffer.byteLength(JSON.stringify(docToDelete));
964-
965-
await Model.deleteOne({ _id: id });
977+
// We don't decrement databaseUsed here because the document still occupies space.
978+
// It will be decremented during hard delete in the background worker.
966979

967-
if (!project.resources.db.isExternal) {
968-
project.databaseUsed = Math.max(0, (project.databaseUsed || 0) - docSize);
969-
await project.save();
970-
}
971-
972-
res.json({ success: true, message: "Document deleted successfully" });
973-
} catch (err) {
980+
res.json({ success: true, message: "Document moved to trash" }); } catch (err) {
974981
console.error("Delete Error:", err);
975982
res.status(500).json({ error: err.message });
976983
}
@@ -1009,11 +1016,15 @@ module.exports.editRow = async (req, res) => {
10091016
});
10101017
}
10111018

1012-
const docToEdit = await Model.findById(id);
1019+
const docToEdit = await Model.findOne({ _id: id, isDeleted: { $ne: true } });
10131020
if (!docToEdit) {
10141021
return res.status(404).json({ error: "Document not found." });
10151022
}
10161023

1024+
// Prevent manual injection of soft-delete fields
1025+
delete req.body.isDeleted;
1026+
delete req.body.deletedAt;
1027+
10171028
const oldSize = Buffer.byteLength(JSON.stringify(docToEdit.toObject()));
10181029

10191030
docToEdit.set(req.body);

apps/public-api/src/__tests__/aggregation.test.js

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,12 +56,13 @@ describe('aggregateData controller', () => {
5656
});
5757

5858
test('executes a valid aggregation pipeline', async () => {
59-
const req = makeReq();
59+
const req = makeReq({ query: {} });
6060
const res = makeRes();
6161

6262
await aggregateData(req, res);
6363

6464
expect(mockAggregate).toHaveBeenCalledWith([
65+
{ $match: { isDeleted: { $ne: true } } },
6566
{ $group: { _id: '$status', count: { $sum: 1 } } },
6667
]);
6768
expect(res.statusCode).toBe(200);
@@ -74,6 +75,7 @@ describe('aggregateData controller', () => {
7475

7576
test('prepends the RLS match stage when req.rlsFilter is set', async () => {
7677
const req = makeReq({
78+
query: {},
7779
rlsFilter: { userId: 'user_1' },
7880
body: {
7981
pipeline: [
@@ -87,7 +89,7 @@ describe('aggregateData controller', () => {
8789
await aggregateData(req, res);
8890

8991
expect(mockAggregate).toHaveBeenCalledWith([
90-
{ $match: { userId: 'user_1' } },
92+
{ $match: { userId: 'user_1', isDeleted: { $ne: true } } },
9193
{ $match: { status: 'published' } },
9294
{ $sort: { createdAt: -1 } },
9395
]);

apps/public-api/src/__tests__/data.controller.read.test.js

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ describe('data.controller read RLS filters', () => {
117117
$and: [
118118
{ _id: '507f1f77bcf86cd799439011' },
119119
{ userId: 'user_1' },
120+
{ isDeleted: { $ne: true } }
120121
],
121122
});
122123
expect(res.json).toHaveBeenCalled();
@@ -194,4 +195,36 @@ describe('data.controller read RLS filters', () => {
194195
expect(res.json).toHaveBeenCalled();
195196
expect(res.status).not.toHaveBeenCalledWith(500);
196197
});
198+
199+
test('getSingleDoc excludes soft-deleted documents by default', async () => {
200+
const req = makeReq();
201+
const res = makeRes();
202+
203+
await getSingleDoc(req, res);
204+
205+
expect(mockFindOne).toHaveBeenCalled();
206+
const findOneArgs = mockFindOne.mock.calls[0][0];
207+
expect(findOneArgs.$and).toEqual(
208+
expect.arrayContaining([
209+
{ _id: '507f1f77bcf86cd799439011' },
210+
{ isDeleted: { $ne: true } }
211+
])
212+
);
213+
});
214+
215+
test('getSingleDoc includes soft-deleted documents when include_deleted=true is passed', async () => {
216+
const req = makeReq({ query: { include_deleted: 'true' } });
217+
const res = makeRes();
218+
219+
await getSingleDoc(req, res);
220+
221+
expect(mockFindOne).toHaveBeenCalled();
222+
const findOneArgs = mockFindOne.mock.calls[0][0];
223+
// isDeleted: { $ne: true } should NOT be present
224+
expect(findOneArgs.$and).not.toEqual(
225+
expect.arrayContaining([
226+
{ isDeleted: { $ne: true } }
227+
])
228+
);
229+
});
197230
});
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
'use strict';
2+
3+
const mockFindOneAndUpdate = jest.fn();
4+
const mockFindOne = jest.fn();
5+
const mockLean = jest.fn();
6+
7+
const mockModel = {
8+
findOneAndUpdate: mockFindOneAndUpdate,
9+
findOne: mockFindOne,
10+
};
11+
12+
jest.mock('@urbackend/common', () => ({
13+
sanitize: (v) => v,
14+
Project: {},
15+
getConnection: jest.fn().mockResolvedValue({}),
16+
getCompiledModel: jest.fn(() => mockModel),
17+
dispatchWebhooks: jest.fn(),
18+
QueryEngine: jest.fn(),
19+
validateData: jest.fn(),
20+
validateUpdateData: jest.fn(),
21+
isValidId: () => true
22+
}));
23+
24+
const { deleteSingleDoc, getSingleDoc } = require('../controllers/data.controller');
25+
26+
function makeReq(overrides = {}) {
27+
return {
28+
params: { collectionName: 'posts', id: '507f1f77bcf86cd799439011' },
29+
project: {
30+
_id: 'proj_1',
31+
resources: { db: { isExternal: false } },
32+
collections: [{ name: 'posts', model: [] }],
33+
},
34+
query: {},
35+
...overrides,
36+
};
37+
}
38+
39+
function makeRes() {
40+
const res = {
41+
statusCode: null,
42+
body: null,
43+
status: jest.fn().mockReturnThis(),
44+
json: jest.fn().mockReturnThis(),
45+
};
46+
return res;
47+
}
48+
49+
describe('Soft Delete in data.controller', () => {
50+
beforeEach(() => {
51+
jest.clearAllMocks();
52+
});
53+
54+
test('deleteSingleDoc sets isDeleted: true instead of hard deleting', async () => {
55+
const req = makeReq();
56+
const res = makeRes();
57+
58+
const doc = { _id: '507f1f77bcf86cd799439011', isDeleted: false };
59+
mockFindOneAndUpdate.mockReturnValue({
60+
lean: jest.fn().mockResolvedValue(doc)
61+
});
62+
63+
await deleteSingleDoc(req, res);
64+
65+
expect(mockFindOneAndUpdate).toHaveBeenCalledWith(
66+
{ _id: '507f1f77bcf86cd799439011', isDeleted: { $ne: true } },
67+
expect.objectContaining({
68+
$set: expect.objectContaining({ isDeleted: true, deletedAt: expect.any(Date) })
69+
}),
70+
{ new: false }
71+
);
72+
73+
expect(res.json).toHaveBeenCalledWith({ message: "Document moved to trash", id: '507f1f77bcf86cd799439011' });
74+
});
75+
76+
test('deleteSingleDoc returns 404 if document is already soft-deleted or not found', async () => {
77+
const req = makeReq();
78+
const res = makeRes();
79+
80+
mockFindOneAndUpdate.mockReturnValue({
81+
lean: jest.fn().mockResolvedValue(null)
82+
});
83+
84+
await deleteSingleDoc(req, res);
85+
86+
expect(res.status).toHaveBeenCalledWith(404);
87+
expect(res.json).toHaveBeenCalledWith({ error: 'Document not found.' });
88+
});
89+
});

apps/public-api/src/app.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ const {initWebhookWorker} = require('@urbackend/common');
2525
const {initAuthEmailWorker, initPublicEmailWorker} = require('@urbackend/common');
2626
const {initActivityRollupWorker, scheduleActivityRollup} = require('@urbackend/common');
2727
const {initReliabilityAlertWorker, scheduleReliabilityAlert} = require('@urbackend/common');
28+
const {initTrashCleanupWorker, scheduleTrashCleanup} = require('@urbackend/common');
2829

2930
app.use('/api/mail/webhook', express.raw({ type: 'application/json' }));
3031
app.use(express.json());
@@ -137,6 +138,10 @@ if (process.env.NODE_ENV !== 'test') {
137138
scheduleReliabilityAlert().catch((err) =>
138139
console.error('[ReliabilityAlert] Failed to schedule cron:', err.message)
139140
);
141+
initTrashCleanupWorker();
142+
scheduleTrashCleanup().catch((err) =>
143+
console.error('[TrashCleanup] Failed to schedule cron:', err.message)
144+
);
140145
};
141146

142147
const bootstrap = async () => {

0 commit comments

Comments
 (0)