Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
159 changes: 147 additions & 12 deletions apps/dashboard-api/src/__tests__/project.controller.softDelete.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,28 @@ const mockModel = {
findOneAndUpdate: mockFindOneAndUpdate,
};

const mockDispatchWebhooks = jest.fn();

jest.mock('@urbackend/common', () => ({
Project: {
findOne: mockFindOne,
},
getConnection: jest.fn().mockResolvedValue({}),
getCompiledModel: jest.fn(() => mockModel),
enqueueCollectionCleanup: jest.fn().mockResolvedValue(true)
dispatchWebhooks: mockDispatchWebhooks,
enqueueCollectionCleanup: jest.fn().mockResolvedValue(true),
syncCollectionCleanup: jest.fn().mockResolvedValue(true),
AppError: class AppError extends Error {
constructor(statusCode, message) {
super(message);
this.statusCode = statusCode;
this.isOperational = true;
}
}
}));

const { deleteRow } = require('../controllers/project.controller');
const { deleteRow, recoverRow } = require('../controllers/project.controller');
const mongoose = require('mongoose');

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

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

await deleteRow(req, res);
await deleteRow(req, res, next);

expect(mockFindOne).toHaveBeenCalled();
expect(mockFindOneAndUpdate).toHaveBeenCalledWith(
Expand All @@ -75,14 +88,12 @@ describe('Soft Delete in dashboard project.controller', () => {
data: { id: '507f1f77bcf86cd799439011' },
message: "Document moved to trash"
});

// Should not save project (to update databaseUsed) since it's a soft delete
expect(project.save).not.toHaveBeenCalled();
});

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

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

await deleteRow(req, res);
await deleteRow(req, res, next);

expect(res.status).toHaveBeenCalledWith(404);
expect(next).toHaveBeenCalledWith(expect.objectContaining({
statusCode: 404,
message: "Document not found."
}));
});

test('recoverRow restores a soft-deleted document', async () => {
const req = makeReq();
const res = makeRes();
const next = jest.fn();

// Mock project
const project = {
_id: 'proj_1',
resources: { db: { isExternal: false } },
collections: [{ name: 'posts', model: [] }]
};

// recoverRow uses Project.findOne({ _id: projectId, owner: req.user._id }).lean()
const mockProjectFind = {
lean: jest.fn().mockResolvedValue(project)
};
mockFindOne.mockReturnValue(mockProjectFind);

// Mock document
const restoredDoc = { _id: '507f1f77bcf86cd799439011', isDeleted: false, deletedAt: null };
mockFindOneAndUpdate.mockReturnValue({
lean: jest.fn().mockResolvedValue(restoredDoc)
});

await recoverRow(req, res, next);

expect(mockFindOne).toHaveBeenCalledWith({ _id: 'proj_1', owner: 'user_1' });
expect(mockFindOneAndUpdate).toHaveBeenCalledWith(
expect.objectContaining({
_id: '507f1f77bcf86cd799439011',
isDeleted: true,
deletedAt: expect.objectContaining({ $gte: expect.any(Date) })
}),
expect.objectContaining({
$set: { isDeleted: false, deletedAt: null }
}),
{ new: true }
);

expect(res.json).toHaveBeenCalledWith({
success: false,
data: {},
message: "Document not found."
success: true,
data: restoredDoc,
message: "Document recovered from trash"
});

expect(mockDispatchWebhooks).toHaveBeenCalledWith(expect.objectContaining({
action: 'recover',
document: restoredDoc,
projectId: 'proj_1'
}));
const { syncCollectionCleanup } = require('@urbackend/common');
expect(syncCollectionCleanup).toHaveBeenCalledWith('proj_1', 'posts');
});

test('recoverRow returns 404 via next(AppError) if document is not in trash', async () => {
const req = makeReq();
const res = makeRes();
const next = jest.fn();

// Mock project
const project = {
_id: 'proj_1',
resources: { db: { isExternal: false } },
collections: [{ name: 'posts', model: [] }]
};
const mockProjectFind = {
lean: jest.fn().mockResolvedValue(project)
};
mockFindOne.mockReturnValue(mockProjectFind);

mockFindOneAndUpdate.mockReturnValue({
lean: jest.fn().mockResolvedValue(null)
});

await recoverRow(req, res, next);

expect(next).toHaveBeenCalledWith(expect.objectContaining({
statusCode: 404,
message: "Document not found or recovery window expired (30 days)."
}));
});

test('recoverRow returns 409 via next(AppError) if document restoration causes a unique field conflict', async () => {
const req = makeReq();
const res = makeRes();
const next = jest.fn();

const project = {
_id: 'proj_1',
resources: { db: { isExternal: false } },
collections: [{ name: 'posts', model: [] }]
};
mockFindOne.mockReturnValue({
lean: jest.fn().mockResolvedValue(project)
});

const error = new Error('Duplicate key');
error.code = 11000;
mockFindOneAndUpdate.mockReturnValue({
lean: jest.fn().mockRejectedValue(error)
});

await recoverRow(req, res, next);

expect(next).toHaveBeenCalledWith(expect.objectContaining({
statusCode: 409,
message: expect.stringContaining("unique field value conflicts")
}));
});

test('recoverRow returns 400 via next(AppError) if document ID is invalid', async () => {
const req = {
params: { projectId: 'proj_1', collectionName: 'posts', id: 'invalid-id' },
user: { _id: 'user_1' }
};
const res = makeRes();
const next = jest.fn();

await recoverRow(req, res, next);

expect(next).toHaveBeenCalledWith(expect.objectContaining({
statusCode: 400,
message: "Invalid document ID format."
}));
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ jest.mock('../controllers/project.controller', () => {
deleteCollection: jest.fn(ok),
getData: jest.fn(ok),
deleteRow: jest.fn(ok),
recoverRow: jest.fn(ok),
insertData: jest.fn(ok),
editRow: jest.fn(ok),
listFiles: jest.fn(ok),
Expand Down
104 changes: 97 additions & 7 deletions apps/dashboard-api/src/controllers/project.controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ const { getConnection } = require("@urbackend/common");
const { getCompiledModel } = require("@urbackend/common");
const { QueryEngine } = require("@urbackend/common");
const { storageRegistry } = require("@urbackend/common");
const { AppError } = require("@urbackend/common");
const { AppError, dispatchWebhooks, enqueueCollectionCleanup, syncCollectionCleanup } = require("@urbackend/common");
const { resolveEffectivePlan } = require("@urbackend/common");
const {
deleteProjectByApiKeyCache,
Expand All @@ -37,7 +37,6 @@ const { getPublicIp } = require("@urbackend/common");
const { clearCompiledModel } = require("@urbackend/common");
const { createUniqueIndexes, ApiAnalytics, MailLog } = require("@urbackend/common");
const { emitEvent } = require('../utils/emitEvent');
const { enqueueCollectionCleanup } = require('@urbackend/common');
const MAX_FILE_SIZE = 10 * 1024 * 1024;
const SAFETY_MAX_BYTES = 100 * 1024 * 1024;
const CONFIRM_UPLOAD_SIZE_TOLERANCE_BYTES = 64;
Expand Down Expand Up @@ -935,21 +934,30 @@ module.exports.insertData = async (req, res) => {
}
};

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

if (!mongoose.isValidObjectId(id)) {
return next(new AppError(400, "Invalid document ID format."));
}

const project = await Project.findOne({
_id: projectId,
owner: req.user._id,
});
if (!project) return res.status(404).json({ error: "Project not found." });
if (!project) return next(new AppError(404, "Project not found."));

const collectionConfig = project.collections.find(
(c) => c.name === collectionName,
);
if (!collectionConfig) {
return res.status(404).json({ error: "Collection not found." });
return next(new AppError(404, "Collection not found."));
}

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

if (!result) {
return res.status(404).json({ success: false, data: {}, message: "Document not found." });
return next(new AppError(404, "Document not found."));
}

// We don't decrement databaseUsed here because the document still occupies space.
Expand All @@ -986,7 +994,89 @@ module.exports.deleteRow = async (req, res) => {
res.json({ success: true, data: { id: result._id }, message: "Document moved to trash" });
} catch (err) {
console.error("Delete Error:", err);
res.status(500).json({ error: err.message });
next(new AppError(500, "Failed to delete document"));
}
};
/**
* Recovers a soft-deleted document from trash.
* @param {import('express').Request} req - Express request
* @param {import('express').Response} res - Express response
* @param {import('express').NextFunction} next - Error handler
*/
module.exports.recoverRow = async (req, res, next) => {
try {
const { projectId, collectionName, id } = req.params;

if (!mongoose.isValidObjectId(id)) {
return next(new AppError(400, "Invalid document ID format."));
}

const project = await Project.findOne({
_id: projectId,
owner: req.user._id,
}).lean();
if (!project) {
return next(new AppError(404, "Project not found."));
}

const collectionConfig = project.collections.find(
(c) => c.name === collectionName,
);
if (!collectionConfig) {
return next(new AppError(404, "Collection not found."));
}

const connection = await getConnection(projectId);
const Model = getCompiledModel(
connection,
collectionConfig,
projectId,
project.resources.db.isExternal,
);

const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);

const result = await Model.findOneAndUpdate(
{
_id: id,
isDeleted: true,
deletedAt: { $gte: thirtyDaysAgo }
},
{
$set: {
isDeleted: false,
deletedAt: null
}
},
{ new: true }
).lean();

if (!result) {
return next(new AppError(404, "Document not found or recovery window expired (30 days)."));
}

dispatchWebhooks({
projectId: project._id,
collection: collectionName,
action: "recover",
document: result,
documentId: id,
options: { bypassLimit: true }
});

try {
await syncCollectionCleanup(projectId, collectionName);
} catch (err) {
console.error("Failed to sync trash cleanup job after recovery", { projectId, collectionName, err });
}

res.json({ success: true, data: result, message: "Document recovered from trash" });
} catch (err) {
console.error("Recover Error:", err);
if (err && err.code === 11000) {
return next(new AppError(409, "Cannot restore document: a unique field value conflicts with an existing active document."));
}
return next(new AppError(500, "Failed to recover document."));
}
};

Expand Down
4 changes: 4 additions & 0 deletions apps/dashboard-api/src/routes/projects.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const {
deleteCollection,
getData,
deleteRow,
recoverRow,
insertData,
editRow,
listFiles,
Expand Down Expand Up @@ -67,6 +68,9 @@ router.get('/:projectId/collections/:collectionName/data', authMiddleware, getDa
// DELETE REQ FOR ROW
router.delete('/:projectId/collections/:collectionName/data/:id', authMiddleware, deleteRow);

// PATCH REQ FOR RECOVER ROW
router.patch('/:projectId/collections/:collectionName/data/:id/recover', authMiddleware, recoverRow);

// PATCH REQ FOR EDIT ROW
router.patch('/:projectId/collections/:collectionName/data/:id', authMiddleware, editRow);

Expand Down
Loading
Loading