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..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,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 { @@ -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 = { @@ -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( @@ -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 = { @@ -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." + })); }); }); 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..f0801e56 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; @@ -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); @@ -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. @@ -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.")); } }; 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..6107214a 100644 --- a/apps/public-api/src/__tests__/softDelete.test.js +++ b/apps/public-api/src/__tests__/softDelete.test.js @@ -9,20 +9,32 @@ const mockModel = { findOne: mockFindOne, }; -jest.mock('@urbackend/common', () => ({ - sanitize: (v) => v, - Project: {}, - getConnection: jest.fn().mockResolvedValue({}), - getCompiledModel: jest.fn(() => mockModel), - dispatchWebhooks: jest.fn(), - QueryEngine: jest.fn(), - validateData: jest.fn(), - validateUpdateData: jest.fn(), - isValidId: () => true, - enqueueCollectionCleanup: jest.fn().mockResolvedValue(true) -})); - -const { deleteSingleDoc, getSingleDoc } = require('../controllers/data.controller'); +const mongoose = require("mongoose"); + +jest.mock('@urbackend/common', () => { + const mongoose = require("mongoose"); + return { + sanitize: (v) => v, + Project: {}, + getConnection: jest.fn().mockResolvedValue({}), + getCompiledModel: jest.fn(() => mockModel), + dispatchWebhooks: jest.fn(), + QueryEngine: jest.fn(), + validateData: jest.fn(), + 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); + this.statusCode = statusCode; + } + } + }; +}); + +const { deleteSingleDoc, recoverSingleDoc } = require('../controllers/data.controller'); function makeReq(overrides = {}) { return { @@ -96,4 +108,91 @@ 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, + deletedAt: expect.objectContaining({ $gte: expect.any(Date) }) + }), + expect.objectContaining({ + $set: { isDeleted: false, deletedAt: null } + }), + { new: true } + ); + + expect(res.json).toHaveBeenCalledWith({ + success: true, + 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 () => { + const req = makeReq(); + const res = makeRes(); + const next = jest.fn(); + + mockFindOneAndUpdate.mockReturnValue({ + lean: jest.fn().mockResolvedValue(null) + }); + + await recoverSingleDoc(req, res, next); + + expect(next).toHaveBeenCalledWith(expect.objectContaining({ + statusCode: 404, + message: "Document not found or recovery window expired (30 days)." + })); + }); + + 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/__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 cb7fc269..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'; @@ -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; @@ -621,4 +625,83 @@ module.exports.deleteSingleDoc = async (req, res) => { } res.status(500).json({ error: err.message }); } +}; + +/** + * 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 next(new AppError(404, "Collection not found")); + } + + const connection = await getConnection(project._id); + const Model = getCompiledModel( + connection, + collectionConfig, + project._id, + 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 }, + ...(req.rlsFilter || {}) + }, + { + $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: {} + }); + + 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') { + console.error(err); + } + if (isDuplicateKeyError(err)) { + 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.")); + } }; \ No newline at end of file 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/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/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 2ac63acf..2b55f7e6 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 = Math.max(0, 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,21 @@ 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, 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 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 +196,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)}... - +
{JSON.stringify(data, null, 2)}
@@ -304,13 +358,12 @@ export default function Database() {
)}
- {/* RowDetailDrawer: hide Edit for users collection */}