Skip to content

Commit e506280

Browse files
Merge pull request #364 from geturbackend/feature/edit-collection
Feature/edit collection
2 parents 7742cbe + 282c5ab commit e506280

15 files changed

Lines changed: 1358 additions & 27 deletions

File tree

Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
'use strict';
2+
3+
const mockFindOne = jest.fn();
4+
const mockDeleteProjectById = jest.fn();
5+
const mockSetProjectById = jest.fn();
6+
const mockDeleteProjectByApiKeyCache = jest.fn();
7+
8+
jest.mock('@urbackend/common', () => ({
9+
Project: {
10+
findOne: mockFindOne,
11+
},
12+
getProjectAccessQuery: jest.fn((userId) => ({ $or: [{ owner: userId }, { "members.user": userId }] })),
13+
editCollectionSchema: {
14+
parse: jest.fn()
15+
},
16+
deleteProjectById: mockDeleteProjectById,
17+
setProjectById: mockSetProjectById,
18+
deleteProjectByApiKeyCache: mockDeleteProjectByApiKeyCache,
19+
AppError: class AppError extends Error {
20+
constructor(statusCode, message) {
21+
super(message);
22+
this.statusCode = statusCode;
23+
this.status = statusCode;
24+
}
25+
},
26+
getConnection: jest.fn(() => ({
27+
db: {
28+
listCollections: jest.fn(() => ({
29+
hasNext: jest.fn(() => Promise.resolve(false))
30+
}))
31+
}
32+
})),
33+
getCompiledModel: jest.fn(() => ({})),
34+
createUniqueIndexes: jest.fn(() => Promise.resolve()),
35+
clearCompiledModel: jest.fn()
36+
}));
37+
38+
const mockEmitEvent = jest.fn();
39+
40+
jest.mock('../utils/emitEvent', () => ({
41+
emitEvent: mockEmitEvent
42+
}));
43+
44+
const projectController = require('../controllers/project.controller');
45+
const { editCollectionSchema, Project } = require('@urbackend/common');
46+
const { z } = require('zod');
47+
48+
describe('projectController.updateCollection', () => {
49+
let req;
50+
let res;
51+
let next;
52+
53+
beforeEach(() => {
54+
jest.clearAllMocks();
55+
req = {
56+
user: { _id: 'user123' },
57+
params: { projectId: 'project123', collectionName: 'myCollection' },
58+
body: {
59+
schema: [
60+
{ key: 'title', type: 'String', required: true }
61+
]
62+
}
63+
};
64+
65+
res = {
66+
status: jest.fn().mockReturnThis(),
67+
json: jest.fn()
68+
};
69+
70+
next = jest.fn();
71+
72+
editCollectionSchema.parse.mockReturnValue({
73+
projectId: req.params.projectId,
74+
collectionName: req.params.collectionName,
75+
schema: req.body.schema
76+
});
77+
});
78+
79+
it('should return 400 if validation fails', async () => {
80+
const zodError = new z.ZodError([{ message: 'Invalid data' }]);
81+
editCollectionSchema.parse.mockImplementation(() => { throw zodError; });
82+
83+
await projectController.updateCollection(req, res, next);
84+
85+
expect(next).toHaveBeenCalledWith(expect.any(Error));
86+
const error = next.mock.calls[0][0];
87+
expect(error.statusCode).toBe(400);
88+
});
89+
90+
it('should return 404 if project not found', async () => {
91+
Project.findOne.mockResolvedValue(null);
92+
await projectController.updateCollection(req, res, next);
93+
94+
expect(next).toHaveBeenCalledWith(expect.any(Error));
95+
const error = next.mock.calls[0][0];
96+
expect(error.statusCode).toBe(404);
97+
expect(error.message).toBe('Project not found');
98+
});
99+
100+
it('should return 404 if collection not found in project', async () => {
101+
const mockProject = {
102+
_id: 'project123',
103+
collections: [
104+
{ name: 'otherCollection', model: [] }
105+
],
106+
save: jest.fn()
107+
};
108+
Project.findOne.mockResolvedValue(mockProject);
109+
110+
await projectController.updateCollection(req, res, next);
111+
112+
expect(next).toHaveBeenCalledWith(expect.any(Error));
113+
const error = next.mock.calls[0][0];
114+
expect(error.statusCode).toBe(404);
115+
expect(error.message).toBe('Collection not found');
116+
});
117+
118+
it('should return 422 if users validation fails when editing users collection', async () => {
119+
req.params.collectionName = 'users';
120+
req.body.schema = [{ key: 'username', type: 'String', required: true }]; // Invalid users schema
121+
editCollectionSchema.parse.mockReturnValue({
122+
projectId: req.params.projectId,
123+
collectionName: req.params.collectionName,
124+
schema: req.body.schema
125+
});
126+
127+
const mockProject = {
128+
_id: 'project123',
129+
collections: [
130+
{ name: 'users', model: [] }
131+
],
132+
save: jest.fn()
133+
};
134+
Project.findOne.mockResolvedValue(mockProject);
135+
136+
await projectController.updateCollection(req, res, next);
137+
138+
expect(next).toHaveBeenCalledWith(expect.any(Error));
139+
const error = next.mock.calls[0][0];
140+
expect(error.statusCode).toBe(422);
141+
expect(error.message).toContain("The 'users' collection must have required 'email' and 'password' string fields.");
142+
});
143+
144+
it('should successfully update a collection schema', async () => {
145+
const mockProject = {
146+
_id: 'project123',
147+
collections: [
148+
{
149+
name: 'myCollection',
150+
model: [{ key: 'oldTitle', type: 'String' }]
151+
}
152+
],
153+
resources: { db: { isExternal: false } },
154+
save: jest.fn().mockResolvedValue(true),
155+
toObject: jest.fn().mockReturnValue({
156+
_id: 'project123',
157+
collections: [
158+
{ name: 'myCollection', model: [{ key: 'title', type: 'String', required: true }] }
159+
]
160+
})
161+
};
162+
Project.findOne.mockResolvedValue(mockProject);
163+
164+
await projectController.updateCollection(req, res, next);
165+
166+
expect(mockProject.collections[0].model).toEqual(req.body.schema);
167+
expect(mockProject.save).toHaveBeenCalled();
168+
expect(mockDeleteProjectById).toHaveBeenCalledWith('project123');
169+
expect(mockSetProjectById).toHaveBeenCalled();
170+
expect(mockEmitEvent).toHaveBeenCalledWith('user123', 'collection_updated', { collectionName: 'myCollection', isUsersCollection: false }, 'project123');
171+
expect(res.status).toHaveBeenCalledWith(200);
172+
});
173+
174+
it('should successfully update users collection if valid', async () => {
175+
req.params.collectionName = 'users';
176+
req.body.schema = [
177+
{ key: 'email', type: 'String', required: true },
178+
{ key: 'password', type: 'String', required: true }
179+
];
180+
editCollectionSchema.parse.mockReturnValue({
181+
projectId: req.params.projectId,
182+
collectionName: req.params.collectionName,
183+
schema: req.body.schema
184+
});
185+
186+
const mockProject = {
187+
_id: 'project123',
188+
collections: [
189+
{ name: 'users', model: [] }
190+
],
191+
resources: { db: { isExternal: false } },
192+
save: jest.fn().mockResolvedValue(true),
193+
toObject: jest.fn().mockReturnValue({
194+
_id: 'project123',
195+
collections: [
196+
{ name: 'users', model: req.body.schema }
197+
]
198+
})
199+
};
200+
Project.findOne.mockResolvedValue(mockProject);
201+
202+
await projectController.updateCollection(req, res, next);
203+
204+
expect(mockProject.collections[0].model).toEqual(req.body.schema);
205+
expect(mockProject.save).toHaveBeenCalled();
206+
expect(mockEmitEvent).toHaveBeenCalledWith('user123', 'collection_updated', { collectionName: 'users', isUsersCollection: true }, 'project123');
207+
expect(res.status).toHaveBeenCalledWith(200);
208+
});
209+
});

apps/dashboard-api/src/__tests__/routes.projects.storage.test.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ jest.mock('../controllers/project.controller', () => {
9191
inviteMember: jest.fn(ok),
9292
updateMemberRole: jest.fn(ok),
9393
removeMember: jest.fn(ok),
94+
updateCollection: jest.fn(ok),
9495
};
9596
});
9697

apps/dashboard-api/src/app.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ const whitelist = (function() {
5454
app.use(cors({
5555
origin: whitelist.get(),
5656
credentials: true,
57+
maxAge: 86400
5758
}));
5859

5960
app.use(express.json({

apps/dashboard-api/src/controllers/project.controller.js

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ const { randomUUID } = require("crypto");
99
const {
1010
createProjectSchema,
1111
createCollectionSchema,
12+
editCollectionSchema,
1213
updateExternalConfigSchema,
1314
updateAuthProvidersSchema,
1415
sanitizeObjectId,
@@ -944,6 +945,88 @@ module.exports.createCollection = async (req, res) => {
944945
}
945946
};
946947

948+
module.exports.updateCollection = async (req, res, next) => {
949+
try {
950+
const { projectId, collectionName, schema } = editCollectionSchema.parse({
951+
projectId: req.params.projectId,
952+
collectionName: req.params.collectionName,
953+
schema: req.body.schema,
954+
});
955+
956+
const project = await Project.findOne({
957+
_id: projectId,
958+
...getProjectAccessQuery(req.user._id),
959+
});
960+
961+
if (!project) {
962+
return next(new AppError(404, "Project not found"));
963+
}
964+
965+
const collectionIndex = project.collections.findIndex((c) => c.name === collectionName);
966+
if (collectionIndex === -1) {
967+
return next(new AppError(404, "Collection not found"));
968+
}
969+
970+
if (schema !== undefined) {
971+
if (collectionName === "users") {
972+
if (!validateUsersSchema(schema)) {
973+
return next(new AppError(422, "The 'users' collection must have required 'email' and 'password' string fields."));
974+
}
975+
}
976+
project.collections[collectionIndex].model = schema;
977+
978+
await project.save();
979+
980+
const connection = await getConnection(projectId);
981+
const finalCollectionName = project.resources.db.isExternal
982+
? collectionName
983+
: `${project._id}_${collectionName}`;
984+
985+
clearCompiledModel(connection, finalCollectionName);
986+
987+
const Model = getCompiledModel(
988+
connection,
989+
project.collections[collectionIndex],
990+
projectId,
991+
project.resources.db.isExternal,
992+
);
993+
994+
if (project.collections[collectionIndex].model) {
995+
await createUniqueIndexes(Model, project.collections[collectionIndex].model);
996+
}
997+
998+
await deleteProjectById(projectId);
999+
await deleteProjectByApiKeyCache(project.publishableKey);
1000+
await deleteProjectByApiKeyCache(project.secretKey);
1001+
await setProjectById(projectId, project.toObject());
1002+
1003+
emitEvent(req.user._id, 'collection_updated', { collectionName, isUsersCollection: collectionName === 'users' }, projectId);
1004+
} else {
1005+
await project.save();
1006+
}
1007+
1008+
const projectObj = sanitizeProjectResponse(project.toObject());
1009+
1010+
return res.status(200).json({
1011+
success: true,
1012+
data: projectObj,
1013+
message: "Collection updated successfully",
1014+
});
1015+
} catch (err) {
1016+
if (err instanceof z.ZodError) {
1017+
return next(new AppError(400, err.issues?.[0]?.message || "Validation failed"));
1018+
}
1019+
if (err.message && err.message.startsWith("Cannot create unique index on")) {
1020+
err.status = 422;
1021+
}
1022+
const statusCode = err.status || err.statusCode || 500;
1023+
const message = (statusCode >= 400 && statusCode < 500)
1024+
? err.message
1025+
: "An unexpected error occurred while updating the collection schema.";
1026+
return next(new AppError(statusCode, message));
1027+
}
1028+
};
1029+
9471030
// GET DOC BY ID — FIXED: added limitFields(), populate(), cursor pagination, count support, structured response
9481031
module.exports.getData = async (req, res) => {
9491032
try {

apps/dashboard-api/src/routes/projects.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,8 @@ const {
5353
getMembers,
5454
inviteMember,
5555
updateMemberRole,
56-
removeMember
56+
removeMember,
57+
updateCollection
5758
} = require("../controllers/project.controller");
5859

5960
const { createAdminUser, resetPassword, getUserDetails, updateAdminUser, listAdminUsers, deleteAdminUser, listUserSessions, revokeUserSession } = require('../controllers/userAuth.controller');
@@ -73,6 +74,9 @@ router.post('/:projectId/collections', authMiddleware, authorizeProject('admin')
7374
// DELETE REQ FOR COLLECTION
7475
router.delete('/:projectId/collections/:collectionName', authFlexible, authorizeProject('admin'), verifyEmail, deleteCollection);
7576

77+
// PUT REQ FOR UPDATE COLLECTION354
78+
router.put('/:projectId/collections/:collectionName', authMiddleware, authorizeProject('admin'), verifyEmail, planEnforcement.attachDeveloper, updateCollection);
79+
7680
// GET REQ FOR DATA
7781
router.get('/:projectId/collections/:collectionName/data', authMiddleware, authorizeProject(), getData);
7882

apps/web-dashboard/src/App.jsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import Dashboard from './pages/Dashboard';
1010
import ProjectDetails from './pages/ProjectDetails';
1111
import CreateProject from './pages/CreateProject';
1212
import CreateCollection from './pages/CreateCollection';
13+
import EditCollection from './pages/EditCollection';
1314
import Templates from './pages/Templates';
1415
import NotFound from './pages/NotFound';
1516
import Analytics from './pages/Analytics';
@@ -149,6 +150,14 @@ function AppContent() {
149150
</ProtectedRoute>
150151
} />
151152

153+
<Route path="/project/:projectId/edit-collection/:collectionName" element={
154+
<ProtectedRoute>
155+
<MainLayout>
156+
<EditCollection />
157+
</MainLayout>
158+
</ProtectedRoute>
159+
} />
160+
152161
<Route path="/releases" element={
153162
<MainLayout>
154163
<Releases />

apps/web-dashboard/src/components/Database/DatabaseHeader.jsx

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import AiQueryBar from './AiQueryBar';
88
const DatabaseHeader = ({
99
project, activeCollection, dataLength, viewMode, setViewMode,
1010
showFilterMenu, setShowFilterMenu, filtersCount,
11-
onRefresh, onRlsClick, onAddRecord, onOpenSidebar,
11+
onRefresh, onRlsClick, onEditSchemaClick, onAddRecord, onOpenSidebar,
1212
showDeleted, setShowDeleted, onFiltersGenerated, onExport, isExporting, isViewer
1313
}) => {
1414
return (
@@ -103,9 +103,14 @@ const DatabaseHeader = ({
103103
</button>
104104

105105
{activeCollection?.name !== 'users' && !isViewer && (
106-
<button onClick={onRlsClick} className="btn btn-secondary" style={{ padding: '6px 12px', height: '32px', gap: '6px', fontSize: '0.75rem' }}>
107-
<Shield size={14} /> RLS
108-
</button>
106+
<>
107+
<button onClick={onEditSchemaClick} className="btn btn-secondary" style={{ padding: '6px 12px', height: '32px', gap: '6px', fontSize: '0.75rem' }}>
108+
Edit Schema
109+
</button>
110+
<button onClick={onRlsClick} className="btn btn-secondary" style={{ padding: '6px 12px', height: '32px', gap: '6px', fontSize: '0.75rem' }}>
111+
<Shield size={14} /> RLS
112+
</button>
113+
</>
109114
)}
110115

111116

0 commit comments

Comments
 (0)