fix(annotations): address code review findings (1-8, 10)

1. toggleVote wrapped in transaction (race condition fix)
2. FeedbackPanel fetch calls now surface errors via onError/toast
3. Extracted resolveBoard/hasCollectionRole to shared board-access.js
4. AnnotationStore uses monotonic version counter for snapshots
5. PinOverlay uses object pool instead of destroy/recreate on refresh
6. canvasObjects prop memoized with useMemo
7. PinOverlay store subscription cleaned up on unmount
8. Comment content capped at 5000 chars (backend validation)
10. anchor_type validated to 'object' or 'point'
This commit is contained in:
Hiren Kangad
2026-03-10 21:16:08 +05:30
parent 303124f518
commit f7a39a1726
9 changed files with 176 additions and 133 deletions
+27
View File
@@ -0,0 +1,27 @@
const { getBoard, getCollection, getCollectionMember } = require('../db');
function hasCollectionRole(member, minRole) {
if (!member) return false;
const hierarchy = { owner: 3, editor: 2, viewer: 1 };
return (hierarchy[member.role] || 0) >= (hierarchy[minRole] || 0);
}
function resolveBoard(req, res, minRole = 'viewer') {
const board = getBoard(req.params.boardId);
if (!board) { res.status(404).json({ error: 'Board not found' }); return null; }
const collection = getCollection(board.collection_id);
if (!collection) { res.status(404).json({ error: 'Collection not found' }); return null; }
const member = getCollectionMember(board.collection_id, req.user.id);
if (minRole === 'viewer' && collection.is_public) {
return { board, collection, member: member || { role: 'viewer' } };
}
if (!hasCollectionRole(member, minRole)) {
res.status(403).json({ error: `${minRole} access required` });
return null;
}
return { board, collection, member };
}
module.exports = { hasCollectionRole, resolveBoard };
+14 -26
View File
@@ -2,9 +2,6 @@ const { Router } = require('express');
const { v4: uuidv4 } = require('uuid');
const { authMiddleware } = require('../auth');
const {
getBoard,
getCollection,
getCollectionMember,
getThreadsByBoard,
getThread,
createThread,
@@ -18,34 +15,13 @@ const {
incrementThreadCommentCount,
decrementThreadCommentCount,
} = require('../db');
const { hasCollectionRole, resolveBoard } = require('./board-access');
const router = Router();
router.use(authMiddleware);
function hasCollectionRole(member, minRole) {
if (!member) return false;
const hierarchy = { owner: 3, editor: 2, viewer: 1 };
return (hierarchy[member.role] || 0) >= (hierarchy[minRole] || 0);
}
function resolveBoard(req, res, minRole = 'viewer') {
const board = getBoard(req.params.boardId);
if (!board) { res.status(404).json({ error: 'Board not found' }); return null; }
const collection = getCollection(board.collection_id);
if (!collection) { res.status(404).json({ error: 'Collection not found' }); return null; }
const member = getCollectionMember(board.collection_id, req.user.id);
if (minRole === 'viewer' && collection.is_public) {
return { board, collection, member: member || { role: 'viewer' } };
}
if (!hasCollectionRole(member, minRole)) {
res.status(403).json({ error: `${minRole} access required` });
return null;
}
return { board, collection, member };
}
const MAX_COMMENT_LENGTH = 5000;
// GET /api/boards/:boardId/threads — all threads + comments for board
router.get('/:boardId/threads', (req, res) => {
@@ -85,6 +61,12 @@ router.post('/:boardId/threads', (req, res) => {
if (!object_id || !content || !content.trim()) {
return res.status(400).json({ error: 'object_id and content are required' });
}
if (content.length > MAX_COMMENT_LENGTH) {
return res.status(400).json({ error: `Content too long (max ${MAX_COMMENT_LENGTH} chars)` });
}
if (anchor_type && !['object', 'point'].includes(anchor_type)) {
return res.status(400).json({ error: 'anchor_type must be "object" or "point"' });
}
const threadId = uuidv4();
const commentId = uuidv4();
@@ -200,6 +182,9 @@ router.post('/:boardId/threads/:threadId/comments', (req, res) => {
if (!content || !content.trim()) {
return res.status(400).json({ error: 'content is required' });
}
if (content.length > MAX_COMMENT_LENGTH) {
return res.status(400).json({ error: `Content too long (max ${MAX_COMMENT_LENGTH} chars)` });
}
const thread = getThread(req.params.threadId);
if (!thread || thread.board_id !== req.params.boardId) {
@@ -246,6 +231,9 @@ router.put('/:boardId/threads/:threadId/comments/:commentId', (req, res) => {
if (!content || !content.trim()) {
return res.status(400).json({ error: 'content is required' });
}
if (content.length > MAX_COMMENT_LENGTH) {
return res.status(400).json({ error: `Content too long (max ${MAX_COMMENT_LENGTH} chars)` });
}
const comment = getComment(req.params.commentId);
if (!comment || comment.thread_id !== req.params.threadId) {
+2 -31
View File
@@ -1,41 +1,12 @@
const { Router } = require('express');
const { authMiddleware } = require('../auth');
const {
getBoard,
getCollection,
getCollectionMember,
getVotesByBoard,
toggleVote,
} = require('../db');
const { getVotesByBoard, toggleVote } = require('../db');
const { resolveBoard } = require('./board-access');
const router = Router();
router.use(authMiddleware);
function hasCollectionRole(member, minRole) {
if (!member) return false;
const hierarchy = { owner: 3, editor: 2, viewer: 1 };
return (hierarchy[member.role] || 0) >= (hierarchy[minRole] || 0);
}
function resolveBoard(req, res, minRole = 'viewer') {
const board = getBoard(req.params.boardId);
if (!board) { res.status(404).json({ error: 'Board not found' }); return null; }
const collection = getCollection(board.collection_id);
if (!collection) { res.status(404).json({ error: 'Collection not found' }); return null; }
const member = getCollectionMember(board.collection_id, req.user.id);
if (minRole === 'viewer' && collection.is_public) {
return { board, collection, member: member || { role: 'viewer' } };
}
if (!hasCollectionRole(member, minRole)) {
res.status(403).json({ error: `${minRole} access required` });
return null;
}
return { board, collection, member };
}
// GET /api/boards/:boardId/votes — all votes for board
router.get('/:boardId/votes', (req, res) => {
try {