From 3e0cdb49ccd9c4145ae60d833827c6ff5e2ce93e Mon Sep 17 00:00:00 2001 From: yash-pouranik Date: Fri, 24 Jul 2026 01:59:30 +0530 Subject: [PATCH 1/6] feat: implement edit collection schema and structure --- .../project.controller.editCollection.test.js | 179 + apps/dashboard-api/src/app.js | 1 + .../src/controllers/project.controller.js | 50 + apps/dashboard-api/src/routes/projects.js | 6 +- apps/web-dashboard/src/App.jsx | 9 + .../components/Database/DatabaseHeader.jsx | 13 +- apps/web-dashboard/src/pages/Auth.jsx | 28 +- apps/web-dashboard/src/pages/Database.jsx | 1 + .../src/pages/EditCollection.jsx | 665 ++ dynamic_connection_sequence_diagram.md | 253 + package-lock.json | 8805 ++++++++++++----- packages/common/src/index.js | 2 + packages/common/src/utils/input.validation.js | 7 + 13 files changed, 7416 insertions(+), 2603 deletions(-) create mode 100644 apps/dashboard-api/src/__tests__/project.controller.editCollection.test.js create mode 100644 apps/web-dashboard/src/pages/EditCollection.jsx create mode 100644 dynamic_connection_sequence_diagram.md diff --git a/apps/dashboard-api/src/__tests__/project.controller.editCollection.test.js b/apps/dashboard-api/src/__tests__/project.controller.editCollection.test.js new file mode 100644 index 000000000..1880ccd8e --- /dev/null +++ b/apps/dashboard-api/src/__tests__/project.controller.editCollection.test.js @@ -0,0 +1,179 @@ +'use strict'; + +const mockFindOne = jest.fn(); + +const mockDeleteProjectById = jest.fn(); +const mockSetProjectById = jest.fn(); + +jest.mock('@urbackend/common', () => ({ + Project: { + findOne: mockFindOne, + }, + getProjectAccessQuery: jest.fn((userId) => ({ $or: [{ owner: userId }, { "members.user": userId }] })), + editCollectionSchema: { + parse: jest.fn() + }, + deleteProjectById: mockDeleteProjectById, + setProjectById: mockSetProjectById +})); + +const mockEmitEvent = jest.fn(); + +jest.mock('../utils/emitEvent', () => ({ + emitEvent: mockEmitEvent +})); + +const projectController = require('../controllers/project.controller'); +const { editCollectionSchema, Project } = require('@urbackend/common'); +const { z } = require('zod'); + +describe('projectController.updateCollection', () => { + let req; + let res; + + beforeEach(() => { + jest.clearAllMocks(); + req = { + user: { _id: 'user123' }, + params: { projectId: 'project123', collectionName: 'myCollection' }, + body: { + schema: [ + { key: 'title', type: 'String', required: true } + ] + } + }; + + res = { + status: jest.fn().mockReturnThis(), + json: jest.fn() + }; + + editCollectionSchema.parse.mockReturnValue({ + projectId: req.params.projectId, + collectionName: req.params.collectionName, + schema: req.body.schema + }); + }); + + it('should return 400 if validation fails', async () => { + const zodError = new z.ZodError([{ message: 'Invalid data' }]); + editCollectionSchema.parse.mockImplementation(() => { throw zodError; }); + + await projectController.updateCollection(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ error: expect.any(Array) })); + }); + + it('should return 404 if project not found', async () => { + Project.findOne.mockResolvedValue(null); + await projectController.updateCollection(req, res); + + expect(res.status).toHaveBeenCalledWith(404); + expect(res.json).toHaveBeenCalledWith({ message: 'Project not found' }); + }); + + it('should return 404 if collection not found in project', async () => { + const mockProject = { + _id: 'project123', + collections: [ + { name: 'otherCollection', model: [] } + ], + save: jest.fn() + }; + Project.findOne.mockResolvedValue(mockProject); + + await projectController.updateCollection(req, res); + + expect(res.status).toHaveBeenCalledWith(404); + expect(res.json).toHaveBeenCalledWith({ message: 'Collection not found' }); + }); + + it('should return 422 if users validation fails when editing users collection', async () => { + req.params.collectionName = 'users'; + req.body.schema = [{ key: 'username', type: 'String', required: true }]; // Invalid users schema + editCollectionSchema.parse.mockReturnValue({ + projectId: req.params.projectId, + collectionName: req.params.collectionName, + schema: req.body.schema + }); + + const mockProject = { + _id: 'project123', + collections: [ + { name: 'users', model: [] } + ], + save: jest.fn() + }; + Project.findOne.mockResolvedValue(mockProject); + + await projectController.updateCollection(req, res); + + expect(res.status).toHaveBeenCalledWith(422); + expect(res.json).toHaveBeenCalledWith({ error: "The 'users' collection must have required 'email' and 'password' string fields." }); + }); + + it('should successfully update a collection schema', async () => { + const mockProject = { + _id: 'project123', + collections: [ + { + name: 'myCollection', + model: [{ key: 'oldTitle', type: 'String' }] + } + ], + save: jest.fn().mockResolvedValue(true), + toObject: jest.fn().mockReturnValue({ + _id: 'project123', + collections: [ + { name: 'myCollection', model: [{ key: 'title', type: 'String', required: true }] } + ] + }) + }; + Project.findOne.mockResolvedValue(mockProject); + + await projectController.updateCollection(req, res); + + expect(mockProject.collections[0].model).toEqual(req.body.schema); + expect(mockProject.save).toHaveBeenCalled(); + expect(mockDeleteProjectById).toHaveBeenCalledWith('project123'); + expect(mockSetProjectById).toHaveBeenCalled(); + expect(mockEmitEvent).toHaveBeenCalledWith('user123', 'collection_updated', { collectionName: 'myCollection', isUsersCollection: false }, 'project123'); + expect(res.status).toHaveBeenCalledWith(200); + }); + + it('should successfully update users collection if valid', async () => { + req.params.collectionName = 'users'; + req.body.schema = [ + { key: 'email', type: 'String', required: true }, + { key: 'password', type: 'String', required: true } + ]; + editCollectionSchema.parse.mockReturnValue({ + projectId: req.params.projectId, + collectionName: req.params.collectionName, + schema: req.body.schema + }); + + const mockProject = { + _id: 'project123', + collections: [ + { name: 'users', model: [] } + ], + save: jest.fn().mockResolvedValue(true), + toObject: jest.fn().mockReturnValue({ + _id: 'project123', + collections: [ + { name: 'users', model: req.body.schema } + ] + }) + }; + Project.findOne.mockResolvedValue(mockProject); + + await projectController.updateCollection(req, res); + + expect(mockProject.collections[0].model).toEqual(req.body.schema); + expect(mockProject.save).toHaveBeenCalled(); + expect(mockEmitEvent).toHaveBeenCalledWith('user123', 'collection_updated', { collectionName: 'users', isUsersCollection: true }, 'project123'); + expect(res.status).toHaveBeenCalledWith(200); + }); +}); diff --git a/apps/dashboard-api/src/app.js b/apps/dashboard-api/src/app.js index 77c7c95a7..90e10e0d6 100644 --- a/apps/dashboard-api/src/app.js +++ b/apps/dashboard-api/src/app.js @@ -54,6 +54,7 @@ const whitelist = (function() { app.use(cors({ origin: whitelist.get(), credentials: true, + maxAge: 86400 })); app.use(express.json({ diff --git a/apps/dashboard-api/src/controllers/project.controller.js b/apps/dashboard-api/src/controllers/project.controller.js index 92b5cc352..61942c11b 100644 --- a/apps/dashboard-api/src/controllers/project.controller.js +++ b/apps/dashboard-api/src/controllers/project.controller.js @@ -9,6 +9,7 @@ const { randomUUID } = require("crypto"); const { createProjectSchema, createCollectionSchema, + editCollectionSchema, updateExternalConfigSchema, updateAuthProvidersSchema, sanitizeObjectId, @@ -944,6 +945,55 @@ module.exports.createCollection = async (req, res) => { } }; +module.exports.updateCollection = async (req, res) => { + try { + const { projectId, collectionName, schema } = editCollectionSchema.parse({ + projectId: req.params.projectId, + collectionName: req.params.collectionName, + schema: req.body.schema, + }); + + const project = await Project.findOne({ + _id: projectId, + ...getProjectAccessQuery(req.user._id), + }); + + if (!project) { + return res.status(404).json({ message: "Project not found" }); + } + + const collectionIndex = project.collections.findIndex((c) => c.name === collectionName); + if (collectionIndex === -1) { + return res.status(404).json({ message: "Collection not found" }); + } + + if (collectionName === "users") { + if (!validateUsersSchema(schema)) { + return res.status(422).json({ error: "The 'users' collection must have required 'email' and 'password' string fields." }); + } + } + + project.collections[collectionIndex].model = schema; + + await project.save(); + + await deleteProjectById(projectId); + await setProjectById(projectId, project.toObject()); + + const projectObj = project.toObject(); + delete projectObj.publishableKey; + delete projectObj.secretKey; + delete projectObj.jwtSecret; + + emitEvent(req.user._id, 'collection_updated', { collectionName, isUsersCollection: collectionName === 'users' }, projectId); + + return res.status(200).json(projectObj); + } catch (err) { + if (err instanceof z.ZodError) return res.status(400).json({ error: err.issues }); + return res.status(err.status || 400).json({ error: err.message }); + } +}; + // GET DOC BY ID — FIXED: added limitFields(), populate(), cursor pagination, count support, structured response module.exports.getData = async (req, res) => { try { diff --git a/apps/dashboard-api/src/routes/projects.js b/apps/dashboard-api/src/routes/projects.js index 7dc8988f4..2ef958155 100644 --- a/apps/dashboard-api/src/routes/projects.js +++ b/apps/dashboard-api/src/routes/projects.js @@ -53,7 +53,8 @@ const { getMembers, inviteMember, updateMemberRole, - removeMember + removeMember, + updateCollection } = require("../controllers/project.controller"); const { createAdminUser, resetPassword, getUserDetails, updateAdminUser, listAdminUsers, deleteAdminUser, listUserSessions, revokeUserSession } = require('../controllers/userAuth.controller'); @@ -72,6 +73,9 @@ router.post('/:projectId/collections', authMiddleware, authorizeProject('admin') // DELETE REQ FOR COLLECTION router.delete('/:projectId/collections/:collectionName', authFlexible, authorizeProject('admin'), verifyEmail, deleteCollection); +// PUT REQ FOR UPDATE COLLECTION354 +router.put('/:projectId/collections/:collectionName', authMiddleware, authorizeProject('admin'), verifyEmail, planEnforcement.attachDeveloper, updateCollection); + // GET REQ FOR DATA router.get('/:projectId/collections/:collectionName/data', authMiddleware, authorizeProject(), getData); diff --git a/apps/web-dashboard/src/App.jsx b/apps/web-dashboard/src/App.jsx index 56a73d1c9..1a2dd12c3 100644 --- a/apps/web-dashboard/src/App.jsx +++ b/apps/web-dashboard/src/App.jsx @@ -12,6 +12,7 @@ import Dashboard from './pages/Dashboard'; import ProjectDetails from './pages/ProjectDetails'; import CreateProject from './pages/CreateProject'; import CreateCollection from './pages/CreateCollection'; +import EditCollection from './pages/EditCollection'; import NotFound from './pages/NotFound'; import Analytics from './pages/Analytics'; import Releases from './pages/Releases'; @@ -142,6 +143,14 @@ function AppContent() { } /> + + + + + + } /> + diff --git a/apps/web-dashboard/src/components/Database/DatabaseHeader.jsx b/apps/web-dashboard/src/components/Database/DatabaseHeader.jsx index 39b7d699d..32d7fd169 100644 --- a/apps/web-dashboard/src/components/Database/DatabaseHeader.jsx +++ b/apps/web-dashboard/src/components/Database/DatabaseHeader.jsx @@ -8,7 +8,7 @@ import AiQueryBar from './AiQueryBar'; const DatabaseHeader = ({ project, activeCollection, dataLength, viewMode, setViewMode, showFilterMenu, setShowFilterMenu, filtersCount, - onRefresh, onRlsClick, onAddRecord, onOpenSidebar, + onRefresh, onRlsClick, onEditSchemaClick, onAddRecord, onOpenSidebar, showDeleted, setShowDeleted, onFiltersGenerated, onExport, isExporting, isViewer }) => { return ( @@ -103,9 +103,14 @@ const DatabaseHeader = ({ {activeCollection?.name !== 'users' && !isViewer && ( - + <> + + + )} diff --git a/apps/web-dashboard/src/pages/Auth.jsx b/apps/web-dashboard/src/pages/Auth.jsx index 51cdd23db..5b7e72601 100644 --- a/apps/web-dashboard/src/pages/Auth.jsx +++ b/apps/web-dashboard/src/pages/Auth.jsx @@ -12,6 +12,7 @@ import UserTable from '../components/Auth/UserTable'; import Pagination from '../components/Database/Pagination'; import SectionHeader from '../components/Dashboard/SectionHeader'; import AddRecordDrawer from '../components/AddRecordDrawer'; +import ConfirmationModal from './ConfirmationModal'; import { PUBLIC_API_URL } from '../config'; export default function Auth() { @@ -54,6 +55,7 @@ export default function Auth() { const [isSavingProviders, setIsSavingProviders] = useState(false); const [isSocialAuthModalOpen, setIsSocialAuthModalOpen] = useState(false); const [isAddModalOpen, setIsAddModalOpen] = useState(false); + const [isEditUsersSchemaModalOpen, setIsEditUsersSchemaModalOpen] = useState(false); const [editingUser, setEditingUser] = useState(null); // user being edited const latestUsersRequestId = useRef(0); const [selectedProvider, setSelectedProvider] = useState('github'); @@ -407,7 +409,18 @@ export default function Auth() { ) : (
- +
+
+ {!isViewer && ( + + )} +
)} + {isEditUsersSchemaModalOpen && ( + { + setIsEditUsersSchemaModalOpen(false); + navigate(`/project/${projectId}/edit-collection/users`); + }} + onCancel={() => setIsEditUsersSchemaModalOpen(false)} + /> + )} +