From 4f47fb68e17790ebf84186a61ae84ba6328b2d47 Mon Sep 17 00:00:00 2001 From: Md Towfik Omer Date: Mon, 25 May 2026 02:26:13 +0530 Subject: [PATCH 1/7] feat: implement soft-delete recovery --- .../project.controller.softDelete.test.js | 72 +++++- .../__tests__/routes.projects.storage.test.js | 1 + .../src/controllers/project.controller.js | 52 ++++ apps/dashboard-api/src/routes/projects.js | 4 + .../src/__tests__/softDelete.test.js | 42 +++- .../src/controllers/data.controller.js | 58 +++++ apps/public-api/src/routes/data.js | 12 + .../src/components/CollectionTable.jsx | 236 +++++++++++------- .../src/components/RecordList.jsx | 75 +++++- .../src/components/RowDetailDrawer.jsx | 24 +- apps/web-dashboard/src/pages/Database.jsx | 35 ++- 11 files changed, 501 insertions(+), 110 deletions(-) diff --git a/apps/dashboard-api/src/__tests__/project.controller.softDelete.test.js b/apps/dashboard-api/src/__tests__/project.controller.softDelete.test.js index 99334640..f7144e4b 100644 --- a/apps/dashboard-api/src/__tests__/project.controller.softDelete.test.js +++ b/apps/dashboard-api/src/__tests__/project.controller.softDelete.test.js @@ -16,7 +16,7 @@ jest.mock('@urbackend/common', () => ({ enqueueCollectionCleanup: jest.fn().mockResolvedValue(true) })); -const { deleteRow } = require('../controllers/project.controller'); +const { deleteRow, recoverRow } = require('../controllers/project.controller'); function makeReq() { return { @@ -51,6 +51,7 @@ describe('Soft Delete in dashboard project.controller', () => { collections: [{ name: 'posts', model: [] }], save: jest.fn().mockResolvedValue(true) }; + // deleteRow uses Project.findOne without chaining mockFindOne.mockResolvedValue(project); // Mock document @@ -75,9 +76,6 @@ 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 () => { @@ -106,4 +104,70 @@ describe('Soft Delete in dashboard project.controller', () => { message: "Document not found." }); }); + + test('recoverRow restores a soft-deleted document', async () => { + const req = makeReq(); + const res = makeRes(); + + // 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); + + expect(mockFindOne).toHaveBeenCalledWith({ _id: 'proj_1', owner: 'user_1' }); + expect(mockFindOneAndUpdate).toHaveBeenCalledWith( + { _id: '507f1f77bcf86cd799439011', isDeleted: true }, + expect.objectContaining({ + $set: { isDeleted: false, deletedAt: null } + }), + { new: true } + ); + + expect(res.json).toHaveBeenCalledWith({ + success: true, + data: restoredDoc, + message: "Document recovered from trash" + }); + }); + + test('recoverRow returns 404 if document is not in trash', async () => { + const req = makeReq(); + const res = makeRes(); + + // 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); + + expect(res.status).toHaveBeenCalledWith(404); + expect(res.json).toHaveBeenCalledWith({ error: 'Document not found or not in trash.' }); + }); }); diff --git a/apps/dashboard-api/src/__tests__/routes.projects.storage.test.js b/apps/dashboard-api/src/__tests__/routes.projects.storage.test.js index c8191c5c..a0d550a3 100644 --- a/apps/dashboard-api/src/__tests__/routes.projects.storage.test.js +++ b/apps/dashboard-api/src/__tests__/routes.projects.storage.test.js @@ -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), diff --git a/apps/dashboard-api/src/controllers/project.controller.js b/apps/dashboard-api/src/controllers/project.controller.js index e5bda411..078ec4d7 100644 --- a/apps/dashboard-api/src/controllers/project.controller.js +++ b/apps/dashboard-api/src/controllers/project.controller.js @@ -990,6 +990,58 @@ module.exports.deleteRow = async (req, res) => { } }; +module.exports.recoverRow = async (req, res) => { + try { + const { projectId, collectionName, id } = req.params; + + const project = await Project.findOne({ + _id: projectId, + owner: req.user._id, + }).lean(); + if (!project) return res.status(404).json({ error: "Project not found" }); + + const collectionConfig = project.collections.find( + (c) => c.name === collectionName, + ); + if (!collectionConfig) + return res.status(404).json({ error: "Collection not found" }); + + const connection = await getConnection(projectId); + const Model = getCompiledModel( + connection, + collectionConfig, + projectId, + project.resources.db.isExternal, + ); + + const result = await Model.findOneAndUpdate( + { _id: id, isDeleted: true }, + { + $set: { + isDeleted: false, + deletedAt: null + } + }, + { new: true } + ).lean(); + + if (!result) + return res.status(404).json({ error: "Document not found or not in trash." }); + + res.json({ success: true, data: result, message: "Document recovered from trash" }); + } catch (err) { + console.error("Recover Error:", err); + if (err && err.code === 11000) { + return res.status(409).json({ + success: false, + data: {}, + message: "Cannot restore document: a unique field value conflicts with an existing active document." + }); + } + res.status(500).json({ error: err.message }); + } +}; + module.exports.editRow = async (req, res) => { try { const { projectId, collectionName, id } = req.params; diff --git a/apps/dashboard-api/src/routes/projects.js b/apps/dashboard-api/src/routes/projects.js index 01396303..e01e5b4a 100644 --- a/apps/dashboard-api/src/routes/projects.js +++ b/apps/dashboard-api/src/routes/projects.js @@ -15,6 +15,7 @@ const { deleteCollection, getData, deleteRow, + recoverRow, insertData, editRow, listFiles, @@ -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); diff --git a/apps/public-api/src/__tests__/softDelete.test.js b/apps/public-api/src/__tests__/softDelete.test.js index efc38bfd..4497edd6 100644 --- a/apps/public-api/src/__tests__/softDelete.test.js +++ b/apps/public-api/src/__tests__/softDelete.test.js @@ -22,7 +22,7 @@ jest.mock('@urbackend/common', () => ({ enqueueCollectionCleanup: jest.fn().mockResolvedValue(true) })); -const { deleteSingleDoc, getSingleDoc } = require('../controllers/data.controller'); +const { deleteSingleDoc, recoverSingleDoc } = require('../controllers/data.controller'); function makeReq(overrides = {}) { return { @@ -96,4 +96,44 @@ describe('Soft Delete in data.controller', () => { expect(res.status).toHaveBeenCalledWith(404); expect(res.json).toHaveBeenCalledWith({ error: 'Document not found.' }); }); + + test('recoverSingleDoc restores a soft-deleted document', async () => { + const req = makeReq(); + const res = makeRes(); + + const restoredDoc = { _id: '507f1f77bcf86cd799439011', isDeleted: false, deletedAt: null }; + mockFindOneAndUpdate.mockReturnValue({ + lean: jest.fn().mockResolvedValue(restoredDoc) + }); + + await recoverSingleDoc(req, res); + + expect(mockFindOneAndUpdate).toHaveBeenCalledWith( + expect.objectContaining({ _id: '507f1f77bcf86cd799439011', isDeleted: true }), + expect.objectContaining({ + $set: { isDeleted: false, deletedAt: null } + }), + { new: true } + ); + + expect(res.json).toHaveBeenCalledWith({ + success: true, + data: restoredDoc, + message: "Document recovered from trash" + }); + }); + + test('recoverSingleDoc returns 404 if document is not in trash', async () => { + const req = makeReq(); + const res = makeRes(); + + mockFindOneAndUpdate.mockReturnValue({ + lean: jest.fn().mockResolvedValue(null) + }); + + await recoverSingleDoc(req, res); + + expect(res.status).toHaveBeenCalledWith(404); + expect(res.json).toHaveBeenCalledWith({ error: 'Document not found or not in trash.' }); + }); }); diff --git a/apps/public-api/src/controllers/data.controller.js b/apps/public-api/src/controllers/data.controller.js index cb7fc269..271e48f0 100644 --- a/apps/public-api/src/controllers/data.controller.js +++ b/apps/public-api/src/controllers/data.controller.js @@ -621,4 +621,62 @@ module.exports.deleteSingleDoc = async (req, res) => { } res.status(500).json({ error: err.message }); } +}; + +// Recover a single document from trash +module.exports.recoverSingleDoc = async (req, res) => { + try { + const { collectionName, id } = req.params; + const project = req.project; + + const collectionConfig = project.collections.find( + (c) => c.name === collectionName, + ); + if (!collectionConfig) + return res.status(404).json({ error: "Collection not found" }); + + const connection = await getConnection(project._id); + const Model = getCompiledModel( + connection, + collectionConfig, + project._id, + project.resources.db.isExternal, + ); + + const result = await Model.findOneAndUpdate( + { _id: id, isDeleted: true, ...(req.rlsFilter || {}) }, + { + $set: { + isDeleted: false, + deletedAt: null + } + }, + { new: true } + ).lean(); + + if (!result) + return res.status(404).json({ error: "Document not found or not in trash." }); + + dispatchWebhooks({ + projectId: project._id, + collection: collectionName, + action: 'update', + document: result, + documentId: id, + }); + + res.json({ success: true, data: result, message: "Document recovered from trash" }); + } catch (err) { + if (process.env.NODE_ENV !== 'test') { + console.error(err); + } + if (isDuplicateKeyError(err)) { + return res.status(409).json({ + success: false, + data: {}, + message: "Cannot restore document: a unique field value conflicts with an existing active document." + }); + } + res.status(500).json({ error: err.message }); + } }; \ No newline at end of file diff --git a/apps/public-api/src/routes/data.js b/apps/public-api/src/routes/data.js index 0195aa68..22d627ab 100644 --- a/apps/public-api/src/routes/data.js +++ b/apps/public-api/src/routes/data.js @@ -15,6 +15,7 @@ const { getSingleDoc, updateSingleData, deleteSingleDoc, + recoverSingleDoc, aggregateData } = require("../controllers/data.controller"); @@ -90,6 +91,17 @@ router.delete( deleteSingleDoc ); +// RECOVER SOFT-DELETED DATA +router.patch( + '/:collectionName/:id/recover', + verifyApiKey, + blockUsersCollectionDataAccess, + checkUsageLimits, + resolvePublicAuthContext, + authorizeWriteOperation, + recoverSingleDoc +); + // UPDATE (PUT) router.put( diff --git a/apps/web-dashboard/src/components/CollectionTable.jsx b/apps/web-dashboard/src/components/CollectionTable.jsx index 2ac63acf..f3d7d3d7 100644 --- a/apps/web-dashboard/src/components/CollectionTable.jsx +++ b/apps/web-dashboard/src/components/CollectionTable.jsx @@ -19,7 +19,7 @@ import { useSortable, } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; -import { Trash2, Settings2, Check, GripVertical, Eye, Pencil } from "lucide-react"; +import { Trash2, Settings2, Check, GripVertical, Eye, Pencil, RotateCcw } from "lucide-react"; /* Resizer Component - kept simple */ const Resizer = ({ header }) => { @@ -34,6 +34,31 @@ const Resizer = ({ header }) => { ); }; +const formatDate = (val) => { + if (!val || typeof val !== 'string') return val; + // ISO date check: 2026-05-23T20:50:44.726Z + if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(val)) return val; + + const date = new Date(val); + if (isNaN(date.getTime())) return val; + + return date.toLocaleString('en-GB', { + day: 'numeric', + month: 'numeric', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: true + }).toLowerCase(); +}; + +const getDeletionTooltip = (deletedAt, now) => { + if (!deletedAt || !now) return ""; + const daysRemaining = 30 - Math.floor((now - new Date(deletedAt).getTime()) / (1000 * 60 * 60 * 24)); + return `Deleted on: ${formatDate(deletedAt)} (${daysRemaining} days until permanent deletion)`; +}; + /* Draggable Header Component */ const DraggableColumnHeader = ({ header, children, style: propStyle, className }) => { const { @@ -74,12 +99,15 @@ const DraggableColumnHeader = ({ header, children, style: propStyle, className } ); }; -export default function CollectionTable({ data, activeCollection, onDelete, onView, onEdit }) { +export default function CollectionTable({ data, activeCollection, onDelete, onView, onEdit, onRecover }) { + const [now] = useState(() => Date.now()); + + // 1. Column Definitions const columns = useMemo(() => { if (!activeCollection) return []; - const SYSTEM_FIELDS = ['_id', '__v', 'createdAt', 'updatedAt']; + const SYSTEM_FIELDS = ['_id', '__v', 'createdAt', 'updatedAt', 'isDeleted', 'deletedAt']; const inferType = (value) => { if (typeof value === 'boolean') return 'BOOLEAN'; @@ -162,11 +190,20 @@ export default function CollectionTable({ data, activeCollection, onDelete, onVi id: "_id", header: "ID", accessorKey: "_id", - size: 150, + size: 180, cell: (info) => ( - - {String(info.getValue()).substring(0, 8)}... - +
+ + {String(info.getValue()).substring(0, 8)}... + + {info.row.original?.isDeleted && ( + + DELETED + + )} +
), }, { @@ -175,44 +212,59 @@ export default function CollectionTable({ data, activeCollection, onDelete, onVi size: 120, enableResizing: false, enableHiding: false, - cell: (info) => ( -
- - {activeCollection?.name !== 'users' && ( - <> - - - - )} -
- ), + cell: (info) => { + const record = info.row.original; + return ( +
+ + {activeCollection?.name !== 'users' && ( + record.isDeleted ? ( + + ) : ( + <> + + + + ) + )} +
+ ); + }, }, ]; - }, [activeCollection, data, onDelete, onView, onEdit]); + }, [activeCollection, data, onDelete, onView, onEdit, onRecover, now]); // 2. Load Persisted State const storageKey = `table-settings-${activeCollection?._id}`; @@ -521,37 +573,47 @@ export default function CollectionTable({ data, activeCollection, onDelete, onVi ))} - {table.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => { - /* Handle Sticky Cells */ - const columnId = cell.column.id; - const isStickyLeft = columnId === 'rowNumber'; - const isStickyRight = columnId === 'actions'; - - const style = { - width: cell.column.getSize(), - position: (isStickyLeft || isStickyRight) ? "sticky" : "relative", - left: isStickyLeft ? 0 : 'auto', - right: isStickyRight ? 0 : 'auto', - zIndex: (isStickyLeft || isStickyRight) ? 2 : 1, - - boxShadow: isStickyRight ? '-5px 0 15px rgba(0,0,0,0.2)' : 'none' - }; - - return ( - -
- {flexRender(cell.column.columnDef.cell, cell.getContext())} -
- - ); - })} - - ))} + {table.getRowModel().rows.map((row) => { + const record = row.original; + return ( + + {row.getVisibleCells().map((cell) => { + /* Handle Sticky Cells */ + const columnId = cell.column.id; + const isStickyLeft = columnId === 'rowNumber'; + const isStickyRight = columnId === 'actions'; + + const style = { + width: cell.column.getSize(), + position: (isStickyLeft || isStickyRight) ? "sticky" : "relative", + left: isStickyLeft ? 0 : 'auto', + right: isStickyRight ? 0 : 'auto', + zIndex: (isStickyLeft || isStickyRight) ? 2 : 1, + boxShadow: isStickyRight ? '-5px 0 15px rgba(0,0,0,0.2)' : 'none' + }; + + return ( + +
+ {flexRender(cell.column.columnDef.cell, cell.getContext())} +
+ + ); + })} + + ); + })} {/* Overlay for smoother drag visualization (optional, but good) */} @@ -642,16 +704,22 @@ export default function CollectionTable({ data, activeCollection, onDelete, onVi overflow: hidden; text-overflow: ellipsis; } - .table-row:hover { - background: rgba(255,255,255,0.03); - } - .sticky-cell { - background: var(--color-bg-main); - transition: background 0.15s ease; - } - .table-row:hover .sticky-cell { - background: #111; - } + .table-row:hover { + background: rgba(255,255,255,0.03); + } + .sticky-cell { + background: var(--color-bg-main); + transition: background 0.15s ease; + } + .table-row:hover .sticky-cell { + background: #111; + } + .table-row.row-deleted .sticky-cell { + background: rgba(239, 68, 68, 0.03) !important; + } + .table-row.row-deleted:hover .sticky-cell { + background: rgba(239, 68, 68, 0.05) !important; + } .column-toggle-item:hover { background: rgba(255,255,255,0.05); } diff --git a/apps/web-dashboard/src/components/RecordList.jsx b/apps/web-dashboard/src/components/RecordList.jsx index 5310de71..dd526983 100644 --- a/apps/web-dashboard/src/components/RecordList.jsx +++ b/apps/web-dashboard/src/components/RecordList.jsx @@ -1,16 +1,44 @@ import React from "react"; -import { List, MoreHorizontal, Calendar, ArrowRight } from "lucide-react"; +import { List, MoreHorizontal, Calendar, ArrowRight, RotateCcw } from "lucide-react"; + +const formatDate = (val) => { + if (!val || typeof val !== 'string') return val; + if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(val)) return val; + const date = new Date(val); + if (isNaN(date.getTime())) return val; + return date.toLocaleString('en-GB', { + day: 'numeric', + month: 'numeric', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: true + }).toLowerCase(); +}; + +export default function RecordList({ data, activeCollection, onView, onRecover }) { + const [now] = React.useState(() => Date.now()); + -export default function RecordList({ data, activeCollection, onView }) { // Helper to get important fields (skip _id and system fields) const getPreviewFields = (record) => { if (!activeCollection?.model) return []; // Take first 3 fields from model - return activeCollection.model.slice(0, 3).map(field => ({ - key: field.key, - value: record[field.key], - type: field.type - })); + return activeCollection.model.slice(0, 3).map(field => { + const val = record[field.key]; + return { + key: field.key, + value: field.type === 'Date' ? formatDate(val) : (typeof val === 'string' ? formatDate(val) : val), + type: field.type + }; + }); + }; + + const getDeletionTooltip = (deletedAt) => { + if (!deletedAt || !now) return ""; + const daysRemaining = 30 - Math.floor((now - new Date(deletedAt).getTime()) / (1000 * 60 * 60 * 24)); + return `Deleted on: ${formatDate(deletedAt)} (${daysRemaining} days until permanent deletion)`; }; return ( @@ -22,14 +50,26 @@ export default function RecordList({ data, activeCollection, onView }) { return (
onView(record)} + style={{ + opacity: record.isDeleted ? 0.6 : 1, + background: record.isDeleted ? 'rgba(239, 68, 68, 0.03)' : 'rgba(255,255,255,0.02)', + borderLeft: record.isDeleted ? '3px solid var(--color-danger)' : '1px solid var(--color-border)' + }} >
#{index + 1} {record._id.substring(0, 8)}... + {record.isDeleted && ( + + DELETED + + )}
@@ -49,9 +89,22 @@ export default function RecordList({ data, activeCollection, onView }) {
- + {record.isDeleted ? ( + + ) : ( + + )}
); diff --git a/apps/web-dashboard/src/components/RowDetailDrawer.jsx b/apps/web-dashboard/src/components/RowDetailDrawer.jsx index cc425e03..37e650e6 100644 --- a/apps/web-dashboard/src/components/RowDetailDrawer.jsx +++ b/apps/web-dashboard/src/components/RowDetailDrawer.jsx @@ -159,7 +159,7 @@ export default function RowDetailDrawer({ isOpen, onClose, record, fields = [], gap: "1.25rem", }}> {Object.entries(record) - .filter(([key]) => !['_id', '__v', 'createdAt', 'updatedAt'].includes(key)) + .filter(([key]) => !['_id', '__v', 'createdAt', 'updatedAt', 'isDeleted', 'deletedAt'].includes(key)) .map(([key, value]) => (
)} {record.updatedAt && ( -
- updatedAt - {new Date(record.updatedAt).toLocaleString()} -
- )} +
+ updatedAt + {new Date(record.updatedAt).toLocaleString()} +
+ )} + {record.isDeleted && ( +
+ isDeleted + {String(record.isDeleted)} +
+ )} + {record.deletedAt && ( +
+ deletedAt + {new Date(record.deletedAt).toLocaleString()} +
+ )}
diff --git a/apps/web-dashboard/src/pages/Database.jsx b/apps/web-dashboard/src/pages/Database.jsx index 3a1b63c8..828e2114 100644 --- a/apps/web-dashboard/src/pages/Database.jsx +++ b/apps/web-dashboard/src/pages/Database.jsx @@ -156,6 +156,22 @@ export default function Database() { } catch { toast.error("Failed to delete document"); } }; + const handleRecoverRecord = async (id) => { + try { + await api.patch(`/api/projects/${projectId}/collections/${activeCollection.name}/data/${id}/recover`); + + // Inline update: set isDeleted to false and clear deletedAt + setData(prev => prev.map(item => + item._id === id ? { ...item, isDeleted: false, deletedAt: null } : item + )); + + toast.success("Document restored successfully"); + } catch (err) { + const errMsg = err.response?.data?.message || err.response?.data?.error || err.message; + toast.error("Failed to restore document: " + errMsg); + } + }; + /** * Generates an RLS-aware cURL snippet for the active collection. * Uses the secret key if RLS is disabled, or the publishable key with a JWT if RLS is enabled. @@ -271,9 +287,21 @@ export default function Database() { ) : viewMode === "list" ? ( - + ) : viewMode === "table" ? ( - { setSelectedId(id); setShowModal(true); }} onView={setSelectedRecord} onEdit={(rec) => { if (activeCollection?.name === 'users') return; setEditingRecord(rec); setIsAddModalOpen(true); }} /> + { setSelectedId(id); setShowModal(true); }} + onView={setSelectedRecord} + onEdit={(rec) => { if (activeCollection?.name === 'users') return; setEditingRecord(rec); setIsAddModalOpen(true); }} + onRecover={handleRecoverRecord} + /> ) : (
{JSON.stringify(data, null, 2)}
@@ -304,13 +332,12 @@ export default function Database() { )} - {/* RowDetailDrawer: hide Edit for users collection */} setSelectedRecord(null)} record={selectedRecord} fields={activeCollection?.model || []} - onEdit={activeCollection?.name === 'users' ? null : (rec) => { setEditingRecord(rec); setIsAddModalOpen(true); }} + onEdit={(activeCollection?.name === 'users' || selectedRecord?.isDeleted) ? null : (rec) => { setEditingRecord(rec); setIsAddModalOpen(true); }} /> {isAddModalOpen && ( From 258e3bb33add022a4285c7514511a9b3201d4e2d Mon Sep 17 00:00:00 2001 From: Md Towfik Omer Date: Mon, 25 May 2026 03:45:40 +0530 Subject: [PATCH 2/7] fix: enforce error handling recovery responses and added unique conflict tests --- .../project.controller.softDelete.test.js | 60 ++++++++++++++++++- .../src/controllers/project.controller.js | 56 +++++++++++++---- .../src/__tests__/softDelete.test.js | 53 ++++++++++++++-- .../src/controllers/data.controller.js | 37 ++++++++---- .../src/components/RecordList.jsx | 8 ++- apps/web-dashboard/src/pages/Database.jsx | 10 +++- 6 files changed, 189 insertions(+), 35 deletions(-) diff --git a/apps/dashboard-api/src/__tests__/project.controller.softDelete.test.js b/apps/dashboard-api/src/__tests__/project.controller.softDelete.test.js index f7144e4b..2231baa2 100644 --- a/apps/dashboard-api/src/__tests__/project.controller.softDelete.test.js +++ b/apps/dashboard-api/src/__tests__/project.controller.softDelete.test.js @@ -13,7 +13,13 @@ jest.mock('@urbackend/common', () => ({ }, getConnection: jest.fn().mockResolvedValue({}), getCompiledModel: jest.fn(() => mockModel), - enqueueCollectionCleanup: jest.fn().mockResolvedValue(true) + enqueueCollectionCleanup: jest.fn().mockResolvedValue(true), + AppError: class AppError extends Error { + constructor(statusCode, message) { + super(message); + this.statusCode = statusCode; + } + } })); const { deleteRow, recoverRow } = require('../controllers/project.controller'); @@ -168,6 +174,56 @@ describe('Soft Delete in dashboard project.controller', () => { await recoverRow(req, res); expect(res.status).toHaveBeenCalledWith(404); - expect(res.json).toHaveBeenCalledWith({ error: 'Document not found or not in trash.' }); + expect(res.json).toHaveBeenCalledWith({ + success: false, + data: {}, + message: "Document not found or not in trash." + }); + }); + + test('recoverRow returns 409 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 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(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + success: false, + data: {}, + message: "Invalid id" + }); }); }); diff --git a/apps/dashboard-api/src/controllers/project.controller.js b/apps/dashboard-api/src/controllers/project.controller.js index 078ec4d7..1dbd94d3 100644 --- a/apps/dashboard-api/src/controllers/project.controller.js +++ b/apps/dashboard-api/src/controllers/project.controller.js @@ -935,6 +935,11 @@ module.exports.insertData = 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) => { try { const { projectId, collectionName, id } = req.params; @@ -989,22 +994,46 @@ module.exports.deleteRow = async (req, res) => { res.status(500).json({ error: err.message }); } }; - -module.exports.recoverRow = async (req, res) => { +/** + * 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 res.status(400).json({ + success: false, + data: {}, + message: "Invalid id" + }); + } + const project = await Project.findOne({ _id: projectId, owner: req.user._id, }).lean(); - if (!project) return res.status(404).json({ error: "Project not found" }); + if (!project) { + return res.status(404).json({ + success: false, + data: {}, + message: "Project not found." + }); + } const collectionConfig = project.collections.find( (c) => c.name === collectionName, ); - if (!collectionConfig) - return res.status(404).json({ error: "Collection not found" }); + if (!collectionConfig) { + return res.status(404).json({ + success: false, + data: {}, + message: "Collection not found." + }); + } const connection = await getConnection(projectId); const Model = getCompiledModel( @@ -1025,20 +1054,21 @@ module.exports.recoverRow = async (req, res) => { { new: true } ).lean(); - if (!result) - return res.status(404).json({ error: "Document not found or not in trash." }); + if (!result) { + return res.status(404).json({ + success: false, + data: {}, + message: "Document not found or not in trash." + }); + } res.json({ success: true, data: result, message: "Document recovered from trash" }); } catch (err) { console.error("Recover Error:", err); if (err && err.code === 11000) { - return res.status(409).json({ - success: false, - data: {}, - message: "Cannot restore document: a unique field value conflicts with an existing active document." - }); + return next(new AppError(409, "Cannot restore document: a unique field value conflicts with an existing active document.")); } - res.status(500).json({ error: err.message }); + return next(new AppError(500, "Failed to recover document.")); } }; diff --git a/apps/public-api/src/__tests__/softDelete.test.js b/apps/public-api/src/__tests__/softDelete.test.js index 4497edd6..edb8cd0a 100644 --- a/apps/public-api/src/__tests__/softDelete.test.js +++ b/apps/public-api/src/__tests__/softDelete.test.js @@ -9,6 +9,8 @@ const mockModel = { findOne: mockFindOne, }; +const mongoose = require("mongoose"); + jest.mock('@urbackend/common', () => ({ sanitize: (v) => v, Project: {}, @@ -18,8 +20,14 @@ jest.mock('@urbackend/common', () => ({ QueryEngine: jest.fn(), validateData: jest.fn(), validateUpdateData: jest.fn(), - isValidId: () => true, - enqueueCollectionCleanup: jest.fn().mockResolvedValue(true) + isValidId: (id) => mongoose.Types.ObjectId.isValid(id), + enqueueCollectionCleanup: jest.fn().mockResolvedValue(true), + AppError: class AppError extends Error { + constructor(statusCode, message) { + super(message); + this.statusCode = statusCode; + } + } })); const { deleteSingleDoc, recoverSingleDoc } = require('../controllers/data.controller'); @@ -126,14 +134,49 @@ describe('Soft Delete in data.controller', () => { test('recoverSingleDoc returns 404 if document is not in trash', async () => { const req = makeReq(); const res = makeRes(); + const next = jest.fn(); mockFindOneAndUpdate.mockReturnValue({ lean: jest.fn().mockResolvedValue(null) }); - await recoverSingleDoc(req, res); + await recoverSingleDoc(req, res, next); - expect(res.status).toHaveBeenCalledWith(404); - expect(res.json).toHaveBeenCalledWith({ error: 'Document not found or not in trash.' }); + expect(next).toHaveBeenCalledWith(expect.objectContaining({ + statusCode: 404, + message: "Document not found or not in trash." + })); + }); + + test('recoverSingleDoc returns 409 if document restoration causes a unique field conflict', async () => { + const req = makeReq(); + const res = makeRes(); + const next = jest.fn(); + + const error = new Error('Duplicate key'); + error.code = 11000; + mockFindOneAndUpdate.mockReturnValue({ + lean: jest.fn().mockRejectedValue(error) + }); + + await recoverSingleDoc(req, res, next); + + expect(next).toHaveBeenCalledWith(expect.objectContaining({ + statusCode: 409, + message: expect.stringContaining("unique field value conflicts") + })); + }); + + test('recoverSingleDoc returns 400 if ID is invalid', async () => { + const req = makeReq({ params: { collectionName: 'posts', id: 'invalid-id' } }); + const res = makeRes(); + const next = jest.fn(); + + await recoverSingleDoc(req, res, next); + + expect(next).toHaveBeenCalledWith(expect.objectContaining({ + statusCode: 400, + message: "Invalid document ID format." + })); }); }); diff --git a/apps/public-api/src/controllers/data.controller.js b/apps/public-api/src/controllers/data.controller.js index 271e48f0..95def007 100644 --- a/apps/public-api/src/controllers/data.controller.js +++ b/apps/public-api/src/controllers/data.controller.js @@ -561,7 +561,11 @@ module.exports.updateSingleData = async (req, res) => { } }; -// DELETE DATA +/** + * Soft-deletes a single document by its ID (moves it to trash). + * @param {import('express').Request} req - Express request object. + * @param {import('express').Response} res - Express response object. + */ module.exports.deleteSingleDoc = async (req, res) => { try { const { collectionName, id } = req.params; @@ -623,17 +627,27 @@ module.exports.deleteSingleDoc = async (req, res) => { } }; -// Recover a single document from trash -module.exports.recoverSingleDoc = async (req, res) => { +/** + * Recovers a single soft-deleted document from trash. + * @param {import('express').Request} req - Express request object. + * @param {import('express').Response} res - Express response object. + * @param {import('express').NextFunction} next - Express next function. + */ +module.exports.recoverSingleDoc = async (req, res, next) => { try { const { collectionName, id } = req.params; const project = req.project; + if (!isValidId(id)) { + return next(new AppError(400, "Invalid document ID format.")); + } + const collectionConfig = project.collections.find( (c) => c.name === collectionName, ); - if (!collectionConfig) - return res.status(404).json({ error: "Collection not found" }); + if (!collectionConfig) { + return next(new AppError(404, "Collection not found")); + } const connection = await getConnection(project._id); const Model = getCompiledModel( @@ -654,8 +668,9 @@ module.exports.recoverSingleDoc = async (req, res) => { { new: true } ).lean(); - if (!result) - return res.status(404).json({ error: "Document not found or not in trash." }); + if (!result) { + return next(new AppError(404, "Document not found or not in trash.")); + } dispatchWebhooks({ projectId: project._id, @@ -671,12 +686,8 @@ module.exports.recoverSingleDoc = async (req, res) => { console.error(err); } if (isDuplicateKeyError(err)) { - return res.status(409).json({ - success: false, - data: {}, - message: "Cannot restore document: a unique field value conflicts with an existing active document." - }); + return next(new AppError(409, "Cannot restore document: a unique field value conflicts with an existing active document.")); } - res.status(500).json({ error: err.message }); + return next(new AppError(500, "Failed to recover document.")); } }; \ No newline at end of file diff --git a/apps/web-dashboard/src/components/RecordList.jsx b/apps/web-dashboard/src/components/RecordList.jsx index dd526983..cf3dc279 100644 --- a/apps/web-dashboard/src/components/RecordList.jsx +++ b/apps/web-dashboard/src/components/RecordList.jsx @@ -35,6 +35,11 @@ export default function RecordList({ data, activeCollection, onView, onRecover } }); }; + /** + * Generates a tooltip message for a deleted record, including deletion date and time remaining. + * @param {string|Date} deletedAt - The timestamp when the record was deleted. + * @returns {string} The formatted tooltip message. + */ const getDeletionTooltip = (deletedAt) => { if (!deletedAt || !now) return ""; const daysRemaining = 30 - Math.floor((now - new Date(deletedAt).getTime()) / (1000 * 60 * 60 * 24)); @@ -93,6 +98,7 @@ export default function RecordList({ data, activeCollection, onView, onRecover } ) : ( - )} diff --git a/apps/web-dashboard/src/pages/Database.jsx b/apps/web-dashboard/src/pages/Database.jsx index 828e2114..e9726937 100644 --- a/apps/web-dashboard/src/pages/Database.jsx +++ b/apps/web-dashboard/src/pages/Database.jsx @@ -148,6 +148,10 @@ export default function Database() { } catch { toast.error("Failed to save RLS"); return false; } }; + /** + * Deletes a record from the active collection. + * @param {string} id - The ID of the record to delete. + */ const handleDeleteRecord = async (id) => { try { await api.delete(`/api/projects/${projectId}/collections/${activeCollection.name}/data/${id}`); @@ -156,6 +160,10 @@ export default function Database() { } catch { toast.error("Failed to delete document"); } }; + /** + * Restores a soft-deleted record from the trash for the active collection. + * @param {string} id - The ID of the record to recover. + */ const handleRecoverRecord = async (id) => { try { await api.patch(`/api/projects/${projectId}/collections/${activeCollection.name}/data/${id}/recover`); @@ -168,7 +176,7 @@ export default function Database() { toast.success("Document restored successfully"); } catch (err) { const errMsg = err.response?.data?.message || err.response?.data?.error || err.message; - toast.error("Failed to restore document: " + errMsg); + toast.error(errMsg ? `Failed to restore document: ${errMsg}` : "Failed to restore document"); } }; From d02c899ed8a087dbf2967197ca639b61c01ea94f Mon Sep 17 00:00:00 2001 From: Md Towfik Omer Date: Mon, 25 May 2026 04:24:32 +0530 Subject: [PATCH 3/7] fix: soft-delete error handling to AppError --- .../src/controllers/project.controller.js | 34 +++++-------------- 1 file changed, 9 insertions(+), 25 deletions(-) diff --git a/apps/dashboard-api/src/controllers/project.controller.js b/apps/dashboard-api/src/controllers/project.controller.js index 1dbd94d3..129b7331 100644 --- a/apps/dashboard-api/src/controllers/project.controller.js +++ b/apps/dashboard-api/src/controllers/project.controller.js @@ -940,7 +940,7 @@ module.exports.insertData = async (req, res) => { * @param {import('express').Request} req - Express request * @param {import('express').Response} res - Express response */ -module.exports.deleteRow = async (req, res) => { +module.exports.deleteRow = async (req, res, next) => { try { const { projectId, collectionName, id } = req.params; @@ -948,13 +948,13 @@ module.exports.deleteRow = async (req, res) => { _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); @@ -977,7 +977,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. @@ -991,7 +991,7 @@ 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")); } }; /** @@ -1005,11 +1005,7 @@ module.exports.recoverRow = async (req, res, next) => { const { projectId, collectionName, id } = req.params; if (!mongoose.isValidObjectId(id)) { - return res.status(400).json({ - success: false, - data: {}, - message: "Invalid id" - }); + return next(new AppError(400, "Invalid document ID format.")); } const project = await Project.findOne({ @@ -1017,22 +1013,14 @@ module.exports.recoverRow = async (req, res, next) => { owner: req.user._id, }).lean(); if (!project) { - return res.status(404).json({ - success: false, - data: {}, - message: "Project not found." - }); + return next(new AppError(404, "Project not found.")); } const collectionConfig = project.collections.find( (c) => c.name === collectionName, ); if (!collectionConfig) { - return res.status(404).json({ - success: false, - data: {}, - message: "Collection not found." - }); + return next(new AppError(404, "Collection not found.")); } const connection = await getConnection(projectId); @@ -1055,11 +1043,7 @@ module.exports.recoverRow = async (req, res, next) => { ).lean(); if (!result) { - return res.status(404).json({ - success: false, - data: {}, - message: "Document not found or not in trash." - }); + return next(new AppError(404, "Document not found or not in trash.")); } res.json({ success: true, data: result, message: "Document recovered from trash" }); From 7825dfd1526ac83751062dc66b09042f93160a7f Mon Sep 17 00:00:00 2001 From: Md Towfik Omer Date: Mon, 25 May 2026 04:29:50 +0530 Subject: [PATCH 4/7] refactor(test): soft-delete error handling to AppError --- .../project.controller.softDelete.test.js | 53 +++++++++---------- 1 file changed, 26 insertions(+), 27 deletions(-) diff --git a/apps/dashboard-api/src/__tests__/project.controller.softDelete.test.js b/apps/dashboard-api/src/__tests__/project.controller.softDelete.test.js index 2231baa2..90f6e335 100644 --- a/apps/dashboard-api/src/__tests__/project.controller.softDelete.test.js +++ b/apps/dashboard-api/src/__tests__/project.controller.softDelete.test.js @@ -18,11 +18,13 @@ jest.mock('@urbackend/common', () => ({ constructor(statusCode, message) { super(message); this.statusCode = statusCode; + this.isOperational = true; } } })); const { deleteRow, recoverRow } = require('../controllers/project.controller'); +const mongoose = require('mongoose'); function makeReq() { return { @@ -49,6 +51,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 = { @@ -57,7 +60,6 @@ describe('Soft Delete in dashboard project.controller', () => { collections: [{ name: 'posts', model: [] }], save: jest.fn().mockResolvedValue(true) }; - // deleteRow uses Project.findOne without chaining mockFindOne.mockResolvedValue(project); // Mock document @@ -66,7 +68,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( @@ -84,9 +86,10 @@ describe('Soft Delete in dashboard project.controller', () => { }); }); - 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 = { @@ -101,19 +104,18 @@ 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(res.json).toHaveBeenCalledWith({ - success: false, - data: {}, - message: "Document not found." - }); + 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 = { @@ -134,7 +136,7 @@ describe('Soft Delete in dashboard project.controller', () => { lean: jest.fn().mockResolvedValue(restoredDoc) }); - await recoverRow(req, res); + await recoverRow(req, res, next); expect(mockFindOne).toHaveBeenCalledWith({ _id: 'proj_1', owner: 'user_1' }); expect(mockFindOneAndUpdate).toHaveBeenCalledWith( @@ -152,9 +154,10 @@ describe('Soft Delete in dashboard project.controller', () => { }); }); - test('recoverRow returns 404 if document is not in trash', async () => { + 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 = { @@ -171,17 +174,15 @@ describe('Soft Delete in dashboard project.controller', () => { lean: jest.fn().mockResolvedValue(null) }); - await recoverRow(req, res); + await recoverRow(req, res, next); - expect(res.status).toHaveBeenCalledWith(404); - expect(res.json).toHaveBeenCalledWith({ - success: false, - data: {}, - message: "Document not found or not in trash." - }); + expect(next).toHaveBeenCalledWith(expect.objectContaining({ + statusCode: 404, + message: "Document not found or not in trash." + })); }); - test('recoverRow returns 409 if document restoration causes a unique field conflict', async () => { + 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(); @@ -209,7 +210,7 @@ describe('Soft Delete in dashboard project.controller', () => { })); }); - test('recoverRow returns 400 if document ID is invalid', async () => { + 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' } @@ -219,11 +220,9 @@ describe('Soft Delete in dashboard project.controller', () => { await recoverRow(req, res, next); - expect(res.status).toHaveBeenCalledWith(400); - expect(res.json).toHaveBeenCalledWith({ - success: false, - data: {}, - message: "Invalid id" - }); + expect(next).toHaveBeenCalledWith(expect.objectContaining({ + statusCode: 400, + message: "Invalid document ID format." + })); }); }); From 12c2611caa9b17ed5d1603e06ee0f433f8975bd9 Mon Sep 17 00:00:00 2001 From: Md Towfik Omer Date: Tue, 26 May 2026 01:26:56 +0530 Subject: [PATCH 5/7] fix: Webhook inconsistency and rollback on failure --- .../project.controller.softDelete.test.js | 20 ++- .../src/controllers/project.controller.js | 28 +++- .../src/__tests__/softDelete.test.js | 17 ++- .../src/__tests__/storage.controller.test.js | 4 + .../src/controllers/data.controller.js | 26 +++- .../src/controllers/storage.controller.js | 3 +- apps/public-api/src/middlewares/api_usage.js | 3 +- apps/public-api/src/middlewares/usageGate.js | 6 +- .../public-api/src/utils/webhookDispatcher.js | 71 ---------- .../src/components/CollectionTable.jsx | 41 +++++- .../src/components/RecordList.jsx | 40 +++++- apps/web-dashboard/src/index.css | 15 ++ apps/web-dashboard/src/pages/Database.jsx | 28 +++- packages/common/src/index.js | 9 ++ packages/common/src/models/Webhook.js | 1 + .../common/src/queues/trashCleanupQueue.js | 55 ++++++++ packages/common/src/utils/input.validation.js | 1 + .../common}/src/utils/usageCounter.js | 0 .../common/src/utils/webhookDispatcher.js | 129 ++++++++++++++++++ 19 files changed, 388 insertions(+), 109 deletions(-) delete mode 100644 apps/public-api/src/utils/webhookDispatcher.js rename {apps/public-api => packages/common}/src/utils/usageCounter.js (100%) create mode 100644 packages/common/src/utils/webhookDispatcher.js diff --git a/apps/dashboard-api/src/__tests__/project.controller.softDelete.test.js b/apps/dashboard-api/src/__tests__/project.controller.softDelete.test.js index 90f6e335..8d9a2395 100644 --- a/apps/dashboard-api/src/__tests__/project.controller.softDelete.test.js +++ b/apps/dashboard-api/src/__tests__/project.controller.softDelete.test.js @@ -7,13 +7,17 @@ const mockModel = { findOneAndUpdate: mockFindOneAndUpdate, }; +const mockDispatchWebhooks = jest.fn(); + jest.mock('@urbackend/common', () => ({ Project: { findOne: mockFindOne, }, getConnection: jest.fn().mockResolvedValue({}), getCompiledModel: jest.fn(() => mockModel), + dispatchWebhooks: mockDispatchWebhooks, enqueueCollectionCleanup: jest.fn().mockResolvedValue(true), + syncCollectionCleanup: jest.fn().mockResolvedValue(true), AppError: class AppError extends Error { constructor(statusCode, message) { super(message); @@ -140,7 +144,11 @@ describe('Soft Delete in dashboard project.controller', () => { expect(mockFindOne).toHaveBeenCalledWith({ _id: 'proj_1', owner: 'user_1' }); expect(mockFindOneAndUpdate).toHaveBeenCalledWith( - { _id: '507f1f77bcf86cd799439011', isDeleted: true }, + expect.objectContaining({ + _id: '507f1f77bcf86cd799439011', + isDeleted: true, + deletedAt: expect.objectContaining({ $gte: expect.any(Date) }) + }), expect.objectContaining({ $set: { isDeleted: false, deletedAt: null } }), @@ -152,6 +160,14 @@ describe('Soft Delete in dashboard project.controller', () => { 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 () => { @@ -178,7 +194,7 @@ describe('Soft Delete in dashboard project.controller', () => { expect(next).toHaveBeenCalledWith(expect.objectContaining({ statusCode: 404, - message: "Document not found or not in trash." + message: "Document not found or recovery window expired (30 days)." })); }); diff --git a/apps/dashboard-api/src/controllers/project.controller.js b/apps/dashboard-api/src/controllers/project.controller.js index 129b7331..4635fd57 100644 --- a/apps/dashboard-api/src/controllers/project.controller.js +++ b/apps/dashboard-api/src/controllers/project.controller.js @@ -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, @@ -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; @@ -1031,8 +1030,14 @@ module.exports.recoverRow = async (req, res, next) => { project.resources.db.isExternal, ); + const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); + const result = await Model.findOneAndUpdate( - { _id: id, isDeleted: true }, + { + _id: id, + isDeleted: true, + deletedAt: { $gte: thirtyDaysAgo } + }, { $set: { isDeleted: false, @@ -1043,7 +1048,22 @@ module.exports.recoverRow = async (req, res, next) => { ).lean(); if (!result) { - return next(new AppError(404, "Document not found or not in trash.")); + 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" }); diff --git a/apps/public-api/src/__tests__/softDelete.test.js b/apps/public-api/src/__tests__/softDelete.test.js index edb8cd0a..ec1a358b 100644 --- a/apps/public-api/src/__tests__/softDelete.test.js +++ b/apps/public-api/src/__tests__/softDelete.test.js @@ -22,6 +22,7 @@ jest.mock('@urbackend/common', () => ({ validateUpdateData: jest.fn(), isValidId: (id) => mongoose.Types.ObjectId.isValid(id), enqueueCollectionCleanup: jest.fn().mockResolvedValue(true), + syncCollectionCleanup: jest.fn().mockResolvedValue(true), AppError: class AppError extends Error { constructor(statusCode, message) { super(message); @@ -117,7 +118,11 @@ describe('Soft Delete in data.controller', () => { await recoverSingleDoc(req, res); expect(mockFindOneAndUpdate).toHaveBeenCalledWith( - expect.objectContaining({ _id: '507f1f77bcf86cd799439011', isDeleted: true }), + expect.objectContaining({ + _id: '507f1f77bcf86cd799439011', + isDeleted: true, + deletedAt: expect.objectContaining({ $gte: expect.any(Date) }) + }), expect.objectContaining({ $set: { isDeleted: false, deletedAt: null } }), @@ -129,6 +134,14 @@ describe('Soft Delete in data.controller', () => { data: restoredDoc, message: "Document recovered from trash" }); + + const { dispatchWebhooks, syncCollectionCleanup } = require('@urbackend/common'); + expect(dispatchWebhooks).toHaveBeenCalledWith(expect.objectContaining({ + action: 'recover', + document: restoredDoc, + projectId: 'proj_1' + })); + expect(syncCollectionCleanup).toHaveBeenCalledWith('proj_1', 'posts'); }); test('recoverSingleDoc returns 404 if document is not in trash', async () => { @@ -144,7 +157,7 @@ describe('Soft Delete in data.controller', () => { expect(next).toHaveBeenCalledWith(expect.objectContaining({ statusCode: 404, - message: "Document not found or not in trash." + message: "Document not found or recovery window expired (30 days)." })); }); diff --git a/apps/public-api/src/__tests__/storage.controller.test.js b/apps/public-api/src/__tests__/storage.controller.test.js index 96cbb26a..e3d36aba 100644 --- a/apps/public-api/src/__tests__/storage.controller.test.js +++ b/apps/public-api/src/__tests__/storage.controller.test.js @@ -31,6 +31,10 @@ jest.mock('@urbackend/common', () => { getBucket: jest.fn(() => 'dev-files'), getPresignedUploadUrl: jest.fn(), verifyUploadedFile: jest.fn(), + getMonthKey: jest.fn(() => '2026-05'), + getEndOfMonthTtlSeconds: jest.fn(() => 3600), + incrWithTtlAtomic: jest.fn().mockResolvedValue(true), + redis: { status: 'ready', eval: jest.fn() }, __mockStorageFrom: mockStorageFrom, // expose for assertions }; }); diff --git a/apps/public-api/src/controllers/data.controller.js b/apps/public-api/src/controllers/data.controller.js index 95def007..ed3eebd6 100644 --- a/apps/public-api/src/controllers/data.controller.js +++ b/apps/public-api/src/controllers/data.controller.js @@ -4,13 +4,13 @@ const { Project } = require("@urbackend/common"); const { getConnection } = require("@urbackend/common"); const { getCompiledModel } = require("@urbackend/common"); const { QueryEngine } = require("@urbackend/common"); -const { validateData, validateUpdateData, aggregateSchema } = require("@urbackend/common"); +const { validateData, validateUpdateData, aggregateSchema, dispatchWebhooks } = require("@urbackend/common"); const { performance } = require('perf_hooks'); -const { dispatchWebhooks } = require('../utils/webhookDispatcher'); const { z } = require("zod"); const { AppError, - enqueueCollectionCleanup + enqueueCollectionCleanup, + syncCollectionCleanup } = require("@urbackend/common"); const isDebug = process.env.DEBUG === 'true'; @@ -657,8 +657,15 @@ module.exports.recoverSingleDoc = async (req, res, next) => { project.resources.db.isExternal, ); + const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); + const result = await Model.findOneAndUpdate( - { _id: id, isDeleted: true, ...(req.rlsFilter || {}) }, + { + _id: id, + isDeleted: true, + deletedAt: { $gte: thirtyDaysAgo }, + ...(req.rlsFilter || {}) + }, { $set: { isDeleted: false, @@ -669,17 +676,24 @@ module.exports.recoverSingleDoc = async (req, res, next) => { ).lean(); if (!result) { - return next(new AppError(404, "Document not found or not in trash.")); + return next(new AppError(404, "Document not found or recovery window expired (30 days).")); } dispatchWebhooks({ projectId: project._id, collection: collectionName, - action: 'update', + action: 'recover', document: result, documentId: id, + options: {} }); + try { + await syncCollectionCleanup(project._id, collectionName); + } catch (err) { + console.error("Failed to sync trash cleanup job after recovery", { projectId: String(project._id), collectionName, err }); + } + res.json({ success: true, data: result, message: "Document recovered from trash" }); } catch (err) { if (process.env.NODE_ENV !== 'test') { diff --git a/apps/public-api/src/controllers/storage.controller.js b/apps/public-api/src/controllers/storage.controller.js index 8c5d16a0..9e34ecf0 100644 --- a/apps/public-api/src/controllers/storage.controller.js +++ b/apps/public-api/src/controllers/storage.controller.js @@ -1,7 +1,6 @@ -const { getStorage, getPresignedUploadUrl, verifyUploadedFile, Project, isProjectStorageExternal, getBucket, redis } = require("@urbackend/common"); +const { getStorage, getPresignedUploadUrl, verifyUploadedFile, Project, isProjectStorageExternal, getBucket, redis, getMonthKey, getEndOfMonthTtlSeconds, incrWithTtlAtomic } = require("@urbackend/common"); const { randomUUID } = require("crypto"); const path = require("path"); -const { getMonthKey, getEndOfMonthTtlSeconds, incrWithTtlAtomic } = require("../utils/usageCounter"); const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB const SAFETY_MAX_BYTES = 100 * 1024 * 1024; // 100MB safety ceiling for internal storage diff --git a/apps/public-api/src/middlewares/api_usage.js b/apps/public-api/src/middlewares/api_usage.js index c3b9469b..45affdf9 100644 --- a/apps/public-api/src/middlewares/api_usage.js +++ b/apps/public-api/src/middlewares/api_usage.js @@ -1,6 +1,5 @@ const rateLimit = require('express-rate-limit'); -const { Log, redis, ApiAnalytics } = require('@urbackend/common'); -const { getDayKey, DEFAULT_DAILY_TTL_SECONDS, incrWithTtlAtomic } = require('../utils/usageCounter'); +const { Log, redis, ApiAnalytics, getDayKey, DEFAULT_DAILY_TTL_SECONDS, incrWithTtlAtomic } = require('@urbackend/common'); const FIRST_API_SUCCESS_FLAG_TTL_SECONDS = 2 * 365 * 24 * 60 * 60; // Rate Limiter diff --git a/apps/public-api/src/middlewares/usageGate.js b/apps/public-api/src/middlewares/usageGate.js index 8a241fa9..8db6ab00 100644 --- a/apps/public-api/src/middlewares/usageGate.js +++ b/apps/public-api/src/middlewares/usageGate.js @@ -8,9 +8,11 @@ const { AppError, sanitizeObjectId, getConnection, - getCompiledModel + getCompiledModel, + getDayKey, + DEFAULT_DAILY_TTL_SECONDS, + incrWithTtlAtomic } = require('@urbackend/common'); -const { getDayKey, DEFAULT_DAILY_TTL_SECONDS, incrWithTtlAtomic } = require('../utils/usageCounter'); /** * Resolves the plan context for the current project's owner. diff --git a/apps/public-api/src/utils/webhookDispatcher.js b/apps/public-api/src/utils/webhookDispatcher.js deleted file mode 100644 index 5f99d3f7..00000000 --- a/apps/public-api/src/utils/webhookDispatcher.js +++ /dev/null @@ -1,71 +0,0 @@ -const { Webhook, enqueueWebhookDelivery, redis } = require("@urbackend/common"); -const { getMonthKey, getEndOfMonthTtlSeconds, incrWithTtlAtomic } = require("./usageCounter"); - - -/** - * Dispatch webhooks for a data operation - * Fire-and-forget: does not block the API response - * - * @param {Object} options - * @param {string} options.projectId - The project ID - * @param {string} options.collection - The collection name - * @param {string} options.action - The action: 'insert', 'update', or 'delete' - * @param {Object} options.document - The document data (after insert/update, or before delete) - * @param {string} options.documentId - The document _id - */ -async function dispatchWebhooks({ projectId, collection, action, document, documentId }) { - try { - // Find all enabled webhooks for this project that listen to this event - const webhooks = await Webhook.find({ - projectId, - enabled: true, - }); - - if (!webhooks.length) return; - - const event = `${collection}.${action}`; - const timestamp = new Date().toISOString(); - - for (const webhook of webhooks) { - // Check if this webhook listens to this collection+action - const collectionEvents = webhook.events?.get(collection); - if (!collectionEvents || !collectionEvents[action]) { - continue; - } - - const payload = { - event, - timestamp, - projectId: projectId.toString(), - collection, - action, - documentId: documentId?.toString() || document?._id?.toString(), - data: document, - }; - - // Enqueue delivery (fire-and-forget) - enqueueWebhookDelivery({ - webhookId: webhook._id, - projectId, - event, - payload, - }) - .then(() => { - const now = new Date(); - const monthKey = getMonthKey(now); - const ttlSeconds = getEndOfMonthTtlSeconds(now); - const key = `project:usage:webhook:enqueued:${projectId}:${monthKey}`; - // This counter tracks deliveries successfully queued, not attempted enqueue calls. - incrWithTtlAtomic(redis, key, ttlSeconds).catch(() => {}); - }) - .catch((err) => { - console.error(`[Webhook Dispatch] Failed to enqueue: ${err.message}`); - }); - } - } catch (err) { - // Log but don't throw - webhooks should never block the main operation - console.error(`[Webhook Dispatch] Error: ${err.message}`); - } -} - -module.exports = { dispatchWebhooks }; diff --git a/apps/web-dashboard/src/components/CollectionTable.jsx b/apps/web-dashboard/src/components/CollectionTable.jsx index f3d7d3d7..f916fce9 100644 --- a/apps/web-dashboard/src/components/CollectionTable.jsx +++ b/apps/web-dashboard/src/components/CollectionTable.jsx @@ -99,8 +99,14 @@ const DraggableColumnHeader = ({ header, children, style: propStyle, className } ); }; -export default function CollectionTable({ data, activeCollection, onDelete, onView, onEdit, onRecover }) { - const [now] = useState(() => Date.now()); +export default function CollectionTable({ data, activeCollection, onDelete, onView, onEdit, onRecover, recoveringIds }) { + const [now, setNow] = useState(null); + + useEffect(() => { + // Use setTimeout to avoid synchronous cascading render warning + const timer = setTimeout(() => setNow(Date.now()), 0); + return () => clearTimeout(timer); + }, []); // 1. Column Definitions @@ -226,15 +232,20 @@ export default function CollectionTable({ data, activeCollection, onDelete, onVi {activeCollection?.name !== 'users' && ( - record.isDeleted ? ( + (record.isDeleted || recoveringIds.has(record._id)) ? ( ) : ( <> @@ -264,7 +275,7 @@ export default function CollectionTable({ data, activeCollection, onDelete, onVi }, }, ]; - }, [activeCollection, data, onDelete, onView, onEdit, onRecover, now]); + }, [activeCollection, data, onDelete, onView, onEdit, onRecover, recoveringIds, now]); // 2. Load Persisted State const storageKey = `table-settings-${activeCollection?._id}`; @@ -723,6 +734,22 @@ export default function CollectionTable({ data, activeCollection, onDelete, onVi .column-toggle-item:hover { background: rgba(255,255,255,0.05); } + .spinner-small { + width: 14px; + height: 14px; + border: 2px solid rgba(255, 255, 255, 0.1); + border-top: 2px solid var(--color-primary); + border-radius: 50%; + animation: spin 0.8s linear infinite; + } + @keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } + } + .btn-icon.loading { + pointer-events: none; + opacity: 0.7; + } .custom-scrollbar::-webkit-scrollbar { width: 10px; height: 10px; diff --git a/apps/web-dashboard/src/components/RecordList.jsx b/apps/web-dashboard/src/components/RecordList.jsx index cf3dc279..61bb8218 100644 --- a/apps/web-dashboard/src/components/RecordList.jsx +++ b/apps/web-dashboard/src/components/RecordList.jsx @@ -1,4 +1,4 @@ -import React from "react"; +import { useState, useEffect } from "react"; import { List, MoreHorizontal, Calendar, ArrowRight, RotateCcw } from "lucide-react"; const formatDate = (val) => { @@ -17,8 +17,14 @@ const formatDate = (val) => { }).toLowerCase(); }; -export default function RecordList({ data, activeCollection, onView, onRecover }) { - const [now] = React.useState(() => Date.now()); +export default function RecordList({ data, activeCollection, onView, onRecover, recoveringIds }) { + const [now, setNow] = useState(null); + + useEffect(() => { + // Use setTimeout to avoid synchronous cascading render warning + const timer = setTimeout(() => setNow(Date.now()), 0); + return () => clearTimeout(timer); + }, []); // Helper to get important fields (skip _id and system fields) @@ -94,17 +100,22 @@ export default function RecordList({ data, activeCollection, onView, onRecover }
- {record.isDeleted ? ( + {(record.isDeleted || recoveringIds.has(record._id)) ? ( ) : (