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..8c320d0a7 --- /dev/null +++ b/apps/dashboard-api/src/__tests__/project.controller.editCollection.test.js @@ -0,0 +1,209 @@ +'use strict'; + +const mockFindOne = jest.fn(); +const mockDeleteProjectById = jest.fn(); +const mockSetProjectById = jest.fn(); +const mockDeleteProjectByApiKeyCache = 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, + deleteProjectByApiKeyCache: mockDeleteProjectByApiKeyCache, + AppError: class AppError extends Error { + constructor(statusCode, message) { + super(message); + this.statusCode = statusCode; + this.status = statusCode; + } + }, + getConnection: jest.fn(() => ({ + db: { + listCollections: jest.fn(() => ({ + hasNext: jest.fn(() => Promise.resolve(false)) + })) + } + })), + getCompiledModel: jest.fn(() => ({})), + createUniqueIndexes: jest.fn(() => Promise.resolve()), + clearCompiledModel: jest.fn() +})); + +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; + let next; + + 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() + }; + + next = 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, next); + + expect(next).toHaveBeenCalledWith(expect.any(Error)); + const error = next.mock.calls[0][0]; + expect(error.statusCode).toBe(400); + }); + + it('should return 404 if project not found', async () => { + Project.findOne.mockResolvedValue(null); + await projectController.updateCollection(req, res, next); + + expect(next).toHaveBeenCalledWith(expect.any(Error)); + const error = next.mock.calls[0][0]; + expect(error.statusCode).toBe(404); + expect(error.message).toBe('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, next); + + expect(next).toHaveBeenCalledWith(expect.any(Error)); + const error = next.mock.calls[0][0]; + expect(error.statusCode).toBe(404); + expect(error.message).toBe('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, next); + + expect(next).toHaveBeenCalledWith(expect.any(Error)); + const error = next.mock.calls[0][0]; + expect(error.statusCode).toBe(422); + expect(error.message).toContain("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' }] + } + ], + resources: { db: { isExternal: false } }, + 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, next); + + 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: [] } + ], + resources: { db: { isExternal: false } }, + 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, next); + + 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/__tests__/routes.projects.storage.test.js b/apps/dashboard-api/src/__tests__/routes.projects.storage.test.js index 42a9d151f..3186f8288 100644 --- a/apps/dashboard-api/src/__tests__/routes.projects.storage.test.js +++ b/apps/dashboard-api/src/__tests__/routes.projects.storage.test.js @@ -91,6 +91,7 @@ jest.mock('../controllers/project.controller', () => { inviteMember: jest.fn(ok), updateMemberRole: jest.fn(ok), removeMember: jest.fn(ok), + updateCollection: jest.fn(ok), }; }); diff --git a/apps/dashboard-api/src/app.js b/apps/dashboard-api/src/app.js index 4d99074ee..8ee6761e1 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..4255de180 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,88 @@ module.exports.createCollection = async (req, res) => { } }; +module.exports.updateCollection = async (req, res, next) => { + 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 next(new AppError(404, "Project not found")); + } + + const collectionIndex = project.collections.findIndex((c) => c.name === collectionName); + if (collectionIndex === -1) { + return next(new AppError(404, "Collection not found")); + } + + if (schema !== undefined) { + if (collectionName === "users") { + if (!validateUsersSchema(schema)) { + return next(new AppError(422, "The 'users' collection must have required 'email' and 'password' string fields.")); + } + } + project.collections[collectionIndex].model = schema; + + await project.save(); + + const connection = await getConnection(projectId); + const finalCollectionName = project.resources.db.isExternal + ? collectionName + : `${project._id}_${collectionName}`; + + clearCompiledModel(connection, finalCollectionName); + + const Model = getCompiledModel( + connection, + project.collections[collectionIndex], + projectId, + project.resources.db.isExternal, + ); + + if (project.collections[collectionIndex].model) { + await createUniqueIndexes(Model, project.collections[collectionIndex].model); + } + + await deleteProjectById(projectId); + await deleteProjectByApiKeyCache(project.publishableKey); + await deleteProjectByApiKeyCache(project.secretKey); + await setProjectById(projectId, project.toObject()); + + emitEvent(req.user._id, 'collection_updated', { collectionName, isUsersCollection: collectionName === 'users' }, projectId); + } else { + await project.save(); + } + + const projectObj = sanitizeProjectResponse(project.toObject()); + + return res.status(200).json({ + success: true, + data: projectObj, + message: "Collection updated successfully", + }); + } catch (err) { + if (err instanceof z.ZodError) { + return next(new AppError(400, err.issues?.[0]?.message || "Validation failed")); + } + if (err.message && err.message.startsWith("Cannot create unique index on")) { + err.status = 422; + } + const statusCode = err.status || err.statusCode || 500; + const message = (statusCode >= 400 && statusCode < 500) + ? err.message + : "An unexpected error occurred while updating the collection schema."; + return next(new AppError(statusCode, 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 336d0d8a3..9f67e5fdd 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'); @@ -73,6 +74,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 fca446611..b066a4bf4 100644 --- a/apps/web-dashboard/src/App.jsx +++ b/apps/web-dashboard/src/App.jsx @@ -10,6 +10,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 Templates from './pages/Templates'; import NotFound from './pages/NotFound'; import Analytics from './pages/Analytics'; @@ -149,6 +150,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..d1555a3dc 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)} + /> + )} +