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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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),
};
});

Expand Down
1 change: 1 addition & 0 deletions apps/dashboard-api/src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ const whitelist = (function() {
app.use(cors({
origin: whitelist.get(),
credentials: true,
maxAge: 86400
}));

app.use(express.json({
Expand Down
83 changes: 83 additions & 0 deletions apps/dashboard-api/src/controllers/project.controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const { randomUUID } = require("crypto");
const {
createProjectSchema,
createCollectionSchema,
editCollectionSchema,
updateExternalConfigSchema,
updateAuthProvidersSchema,
sanitizeObjectId,
Expand Down Expand Up @@ -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 {
Expand Down
6 changes: 5 additions & 1 deletion apps/dashboard-api/src/routes/projects.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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);

Expand Down
9 changes: 9 additions & 0 deletions apps/web-dashboard/src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -149,6 +150,14 @@ function AppContent() {
</ProtectedRoute>
} />

<Route path="/project/:projectId/edit-collection/:collectionName" element={
<ProtectedRoute>
<MainLayout>
<EditCollection />
</MainLayout>
</ProtectedRoute>
} />

<Route path="/releases" element={
<MainLayout>
<Releases />
Expand Down
13 changes: 9 additions & 4 deletions apps/web-dashboard/src/components/Database/DatabaseHeader.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -103,9 +103,14 @@ const DatabaseHeader = ({
</button>

{activeCollection?.name !== 'users' && !isViewer && (
<button onClick={onRlsClick} className="btn btn-secondary" style={{ padding: '6px 12px', height: '32px', gap: '6px', fontSize: '0.75rem' }}>
<Shield size={14} /> RLS
</button>
<>
<button onClick={onEditSchemaClick} className="btn btn-secondary" style={{ padding: '6px 12px', height: '32px', gap: '6px', fontSize: '0.75rem' }}>
Edit Schema
</button>
<button onClick={onRlsClick} className="btn btn-secondary" style={{ padding: '6px 12px', height: '32px', gap: '6px', fontSize: '0.75rem' }}>
<Shield size={14} /> RLS
</button>
</>
)}


Expand Down
Loading
Loading