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
+5 -1
View File
@@ -534,7 +534,7 @@ function getVotesByBoard(boardId) {
return db.prepare('SELECT * FROM object_votes WHERE board_id = ?').all(boardId); return db.prepare('SELECT * FROM object_votes WHERE board_id = ?').all(boardId);
} }
function toggleVote(boardId, objectId, userId) { const _toggleVoteTx = db.transaction((boardId, objectId, userId) => {
const existing = db.prepare( const existing = db.prepare(
'SELECT 1 FROM object_votes WHERE board_id = ? AND object_id = ? AND user_id = ?' 'SELECT 1 FROM object_votes WHERE board_id = ? AND object_id = ? AND user_id = ?'
).get(boardId, objectId, userId); ).get(boardId, objectId, userId);
@@ -548,6 +548,10 @@ function toggleVote(boardId, objectId, userId) {
.run(boardId, objectId, userId); .run(boardId, objectId, userId);
return true; // vote added return true; // vote added
} }
});
function toggleVote(boardId, objectId, userId) {
return _toggleVoteTx(boardId, objectId, userId);
} }
// --------------------- // ---------------------
+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 { v4: uuidv4 } = require('uuid');
const { authMiddleware } = require('../auth'); const { authMiddleware } = require('../auth');
const { const {
getBoard,
getCollection,
getCollectionMember,
getThreadsByBoard, getThreadsByBoard,
getThread, getThread,
createThread, createThread,
@@ -18,34 +15,13 @@ const {
incrementThreadCommentCount, incrementThreadCommentCount,
decrementThreadCommentCount, decrementThreadCommentCount,
} = require('../db'); } = require('../db');
const { hasCollectionRole, resolveBoard } = require('./board-access');
const router = Router(); const router = Router();
router.use(authMiddleware); router.use(authMiddleware);
function hasCollectionRole(member, minRole) { const MAX_COMMENT_LENGTH = 5000;
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/threads — all threads + comments for board // GET /api/boards/:boardId/threads — all threads + comments for board
router.get('/:boardId/threads', (req, res) => { router.get('/:boardId/threads', (req, res) => {
@@ -85,6 +61,12 @@ router.post('/:boardId/threads', (req, res) => {
if (!object_id || !content || !content.trim()) { if (!object_id || !content || !content.trim()) {
return res.status(400).json({ error: 'object_id and content are required' }); 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 threadId = uuidv4();
const commentId = uuidv4(); const commentId = uuidv4();
@@ -200,6 +182,9 @@ router.post('/:boardId/threads/:threadId/comments', (req, res) => {
if (!content || !content.trim()) { if (!content || !content.trim()) {
return res.status(400).json({ error: 'content is required' }); 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); const thread = getThread(req.params.threadId);
if (!thread || thread.board_id !== req.params.boardId) { 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()) { if (!content || !content.trim()) {
return res.status(400).json({ error: 'content is required' }); 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); const comment = getComment(req.params.commentId);
if (!comment || comment.thread_id !== req.params.threadId) { if (!comment || comment.thread_id !== req.params.threadId) {
+2 -31
View File
@@ -1,41 +1,12 @@
const { Router } = require('express'); const { Router } = require('express');
const { authMiddleware } = require('../auth'); const { authMiddleware } = require('../auth');
const { const { getVotesByBoard, toggleVote } = require('../db');
getBoard, const { resolveBoard } = require('./board-access');
getCollection,
getCollectionMember,
getVotesByBoard,
toggleVote,
} = require('../db');
const router = Router(); const router = Router();
router.use(authMiddleware); 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 // GET /api/boards/:boardId/votes — all votes for board
router.get('/:boardId/votes', (req, res) => { router.get('/:boardId/votes', (req, res) => {
try { try {
+36 -25
View File
@@ -9,7 +9,8 @@ const PIN_COLOR_RESOLVED = 0x666666;
export class PinOverlay extends Container { export class PinOverlay extends Container {
private _pins = new Map<string, Graphics>(); private _pins = new Map<string, Graphics>();
private _voteBadges: Graphics[] = []; private _voteBadges = new Map<string, Graphics>();
private _pool: Graphics[] = [];
private _viewport: Viewport; private _viewport: Viewport;
private _scene: SceneManager; private _scene: SceneManager;
private _store: AnnotationStore; private _store: AnnotationStore;
@@ -21,16 +22,30 @@ export class PinOverlay extends Container {
this._store = store; this._store = store;
} }
private _acquire(): Graphics {
const gfx = this._pool.pop() || new Graphics();
gfx.clear();
gfx.removeChildren();
gfx.visible = true;
gfx.eventMode = 'none';
gfx.cursor = 'default';
return gfx;
}
private _release(gfx: Graphics) {
gfx.visible = false;
this._pool.push(gfx);
}
/** Call on every viewport moved/zoomed event and on store change */ /** Call on every viewport moved/zoomed event and on store change */
refresh(showResolved = false) { refresh(showResolved = false) {
const scale = 1 / this._viewport.scale.x; // scale-independent size const scale = 1 / this._viewport.scale.x;
// Remove old pins + badges // Return current pins and badges to pool
for (const gfx of this._pins.values()) gfx.destroy(); for (const gfx of this._pins.values()) this._release(gfx);
for (const gfx of this._voteBadges) gfx.destroy(); for (const gfx of this._voteBadges.values()) this._release(gfx);
this._pins.clear(); this._pins.clear();
this._voteBadges = []; this._voteBadges.clear();
this.removeChildren();
// ── Thread pins ── // ── Thread pins ──
for (const thread of this._store.threads.values()) { for (const thread of this._store.threads.values()) {
@@ -46,12 +61,11 @@ export class PinOverlay extends Container {
wx = bounds.x + thread.pin_x * bounds.width; wx = bounds.x + thread.pin_x * bounds.width;
wy = bounds.y + thread.pin_y * bounds.height; wy = bounds.y + thread.pin_y * bounds.height;
} else { } else {
// Object-level: top-right corner
wx = bounds.x + bounds.width; wx = bounds.x + bounds.width;
wy = bounds.y; wy = bounds.y;
} }
const gfx = new Graphics(); const gfx = this._acquire();
const r = PIN_RADIUS * scale; const r = PIN_RADIUS * scale;
const color = thread.status === 'open' ? PIN_COLOR_OPEN : PIN_COLOR_RESOLVED; const color = thread.status === 'open' ? PIN_COLOR_OPEN : PIN_COLOR_RESOLVED;
@@ -59,27 +73,21 @@ export class PinOverlay extends Container {
gfx.fill({ color, alpha: 0.9 }); gfx.fill({ color, alpha: 0.9 });
gfx.stroke({ color: 0xffffff, width: 1.5 * scale, alpha: 0.8 }); gfx.stroke({ color: 0xffffff, width: 1.5 * scale, alpha: 0.8 });
gfx.position.set(wx, wy); gfx.position.set(wx, wy);
gfx.eventMode = 'static';
gfx.cursor = 'pointer';
(gfx as any)._threadId = thread.id;
// Count label
if (thread.comment_count > 1) { if (thread.comment_count > 1) {
const label = new Text({ const label = new Text({
text: String(thread.comment_count), text: String(thread.comment_count),
style: new TextStyle({ style: new TextStyle({ fontSize: 9 * scale, fill: '#ffffff', fontWeight: 'bold' }),
fontSize: 9 * scale,
fill: '#ffffff',
fontWeight: 'bold',
}),
}); });
label.anchor.set(0.5); label.anchor.set(0.5);
gfx.addChild(label); gfx.addChild(label);
} }
gfx.eventMode = 'static'; if (!gfx.parent) this.addChild(gfx);
gfx.cursor = 'pointer';
(gfx as any)._threadId = thread.id;
this._pins.set(thread.id, gfx); this._pins.set(thread.id, gfx);
this.addChild(gfx);
} }
// ── Vote badges ── // ── Vote badges ──
@@ -92,7 +100,7 @@ export class PinOverlay extends Container {
const wx = bounds.x + bounds.width; const wx = bounds.x + bounds.width;
const wy = bounds.y + bounds.height; const wy = bounds.y + bounds.height;
const gfx = new Graphics(); const gfx = this._acquire();
const pw = 24 * scale; const pw = 24 * scale;
const ph = 16 * scale; const ph = 16 * scale;
const pr = 4 * scale; const pr = 4 * scale;
@@ -106,8 +114,9 @@ export class PinOverlay extends Container {
}); });
label.anchor.set(0.5); label.anchor.set(0.5);
gfx.addChild(label); gfx.addChild(label);
this.addChild(gfx);
this._voteBadges.push(gfx); if (!gfx.parent) this.addChild(gfx);
this._voteBadges.set(objectId, gfx);
} }
} }
@@ -125,9 +134,11 @@ export class PinOverlay extends Container {
override destroy(options?: any) { override destroy(options?: any) {
for (const gfx of this._pins.values()) gfx.destroy(); for (const gfx of this._pins.values()) gfx.destroy();
for (const gfx of this._voteBadges) gfx.destroy(); for (const gfx of this._voteBadges.values()) gfx.destroy();
for (const gfx of this._pool) gfx.destroy();
this._pins.clear(); this._pins.clear();
this._voteBadges = []; this._voteBadges.clear();
this._pool = [];
super.destroy(options); super.destroy(options);
} }
} }
+62 -36
View File
@@ -9,6 +9,7 @@ interface FeedbackPanelProps {
token: string; token: string;
canvasObjects: Map<string, { id: string; name?: string; type: string }>; canvasObjects: Map<string, { id: string; name?: string; type: string }>;
onJumpToObject?: (objectId: string) => void; onJumpToObject?: (objectId: string) => void;
onError?: (msg: string) => void;
} }
export default function FeedbackPanel({ export default function FeedbackPanel({
@@ -19,6 +20,7 @@ export default function FeedbackPanel({
token, token,
canvasObjects, canvasObjects,
onJumpToObject, onJumpToObject,
onError,
}: FeedbackPanelProps) { }: FeedbackPanelProps) {
const [collapsed, setCollapsed] = useState(false); const [collapsed, setCollapsed] = useState(false);
const [expandedThreadId, setExpandedThreadId] = useState<string | null>(null); const [expandedThreadId, setExpandedThreadId] = useState<string | null>(null);
@@ -30,7 +32,7 @@ export default function FeedbackPanel({
// Subscribe to store changes // Subscribe to store changes
const _version = useSyncExternalStore( const _version = useSyncExternalStore(
(cb) => annotationStore.subscribe(cb), (cb) => annotationStore.subscribe(cb),
() => annotationStore.threads.size + annotationStore.votes.size, () => annotationStore.version,
); );
const allThreads = Array.from(annotationStore.threads.values()); const allThreads = Array.from(annotationStore.threads.values());
@@ -54,52 +56,76 @@ export default function FeedbackPanel({
// ── API helpers ── // ── API helpers ──
const apiFetch = useCallback(async (url: string, init?: RequestInit) => {
try {
const res = await fetch(url, init);
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `Request failed (${res.status})`);
}
return res;
} catch (err: any) {
onError?.(err.message || 'Request failed');
throw err;
}
}, [onError]);
const postReply = useCallback(async (threadId: string) => { const postReply = useCallback(async (threadId: string) => {
if (!replyText.trim()) return; if (!replyText.trim()) return;
await fetch(`/api/boards/${boardId}/threads/${threadId}/comments`, { try {
method: 'POST', await apiFetch(`/api/boards/${boardId}/threads/${threadId}/comments`, {
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, method: 'POST',
body: JSON.stringify({ content: replyText.trim() }), headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
}); body: JSON.stringify({ content: replyText.trim() }),
setReplyText(''); });
}, [boardId, token, replyText]); setReplyText('');
} catch {}
}, [boardId, token, replyText, apiFetch]);
const resolveThread = useCallback(async (threadId: string, status: string) => { const resolveThread = useCallback(async (threadId: string, status: string) => {
await fetch(`/api/boards/${boardId}/threads/${threadId}`, { try {
method: 'PATCH', await apiFetch(`/api/boards/${boardId}/threads/${threadId}`, {
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, method: 'PATCH',
body: JSON.stringify({ status }), headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
}); body: JSON.stringify({ status }),
}, [boardId, token]); });
} catch {}
}, [boardId, token, apiFetch]);
const deleteComment = useCallback(async (threadId: string, commentId: string) => { const deleteComment = useCallback(async (threadId: string, commentId: string) => {
await fetch(`/api/boards/${boardId}/threads/${threadId}/comments/${commentId}`, { try {
method: 'DELETE', await apiFetch(`/api/boards/${boardId}/threads/${threadId}/comments/${commentId}`, {
headers: { Authorization: `Bearer ${token}` }, method: 'DELETE',
}); headers: { Authorization: `Bearer ${token}` },
}, [boardId, token]); });
} catch {}
}, [boardId, token, apiFetch]);
const toggleVote = useCallback(async (objectId: string) => { const toggleVote = useCallback(async (objectId: string) => {
await fetch(`/api/boards/${boardId}/votes`, { try {
method: 'POST', await apiFetch(`/api/boards/${boardId}/votes`, {
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, method: 'POST',
body: JSON.stringify({ object_id: objectId }), headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
}); body: JSON.stringify({ object_id: objectId }),
}, [boardId, token]); });
} catch {}
}, [boardId, token, apiFetch]);
const createThread = useCallback(async () => { const createThread = useCallback(async () => {
if (!newCommentText.trim() || !selectedObjectId) return; if (!newCommentText.trim() || !selectedObjectId) return;
await fetch(`/api/boards/${boardId}/threads`, { try {
method: 'POST', await apiFetch(`/api/boards/${boardId}/threads`, {
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, method: 'POST',
body: JSON.stringify({ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
object_id: selectedObjectId, body: JSON.stringify({
anchor_type: 'object', object_id: selectedObjectId,
content: newCommentText.trim(), anchor_type: 'object',
}), content: newCommentText.trim(),
}); }),
setNewCommentText(''); });
}, [boardId, token, newCommentText, selectedObjectId]); setNewCommentText('');
} catch {}
}, [boardId, token, newCommentText, selectedObjectId, apiFetch]);
// ── Collapsed state ── // ── Collapsed state ──
+10 -4
View File
@@ -355,13 +355,18 @@ export function useCanvasSetup(deps: CanvasSetupDeps) {
pinOverlayRef.current = pinOverlay; pinOverlayRef.current = pinOverlay;
// Refresh overlay on viewport move // Refresh overlay on viewport move
viewport.on('moved', () => { const onViewportMoved = () => {
if (pinOverlay.visible) pinOverlay.refresh(); if (pinOverlay.visible) pinOverlay.refresh();
}); };
// Refresh on store change viewport.on('moved', onViewportMoved);
annotationStoreRef.current!.subscribe(() => { // Refresh on store change (save unsub for cleanup)
const unsubPinOverlay = annotationStoreRef.current!.subscribe(() => {
if (pinOverlay.visible) pinOverlay.refresh(); if (pinOverlay.visible) pinOverlay.refresh();
}); });
(pinOverlay as any)._cleanup = () => {
viewport.off('moved', onViewportMoved);
unsubPinOverlay();
};
} }
// Setup drag/drop and paste // Setup drag/drop and paste
@@ -394,6 +399,7 @@ export function useCanvasSetup(deps: CanvasSetupDeps) {
dropCleanupRef.current?.(); dropCleanupRef.current?.();
pasteCleanupRef.current?.(); pasteCleanupRef.current?.();
if (pinOverlayRef.current) { if (pinOverlayRef.current) {
(pinOverlayRef.current as any)._cleanup?.();
pinOverlayRef.current.destroy(); pinOverlayRef.current.destroy();
pinOverlayRef.current = null; pinOverlayRef.current = null;
} }
+15 -10
View File
@@ -162,6 +162,19 @@ export default function Editor({ isPublicView }: EditorProps) {
uploadManager, onCanvasChange, showToast, setOnlineUsers, setSelectedLayerIds, uploadManager, onCanvasChange, showToast, setOnlineUsers, setSelectedLayerIds,
}); });
// Build canvas objects map for FeedbackPanel (memoized on object count changes)
const canvasObjectMap = React.useMemo(() => {
const scene = canvasRef.current?.getScene();
const map = new Map<string, { id: string; name?: string; type: string }>();
if (scene) {
for (const item of scene.items.values()) {
map.set(item.id, { id: item.id, name: item.data.name, type: item.data.type });
}
}
return map;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [objectCount]);
// Toggle pin overlay visibility with review mode // Toggle pin overlay visibility with review mode
useEffect(() => { useEffect(() => {
if (pinOverlay) { if (pinOverlay) {
@@ -663,16 +676,8 @@ export default function Editor({ isPublicView }: EditorProps) {
userId={user.id} userId={user.id}
boardId={resolvedBoardId} boardId={resolvedBoardId}
token={localStorage.getItem('token') || ''} token={localStorage.getItem('token') || ''}
canvasObjects={(() => { canvasObjects={canvasObjectMap}
const scene = canvasRef.current?.getScene(); onError={(msg) => showToast(msg)}
const map = new Map<string, { id: string; name?: string; type: string }>();
if (scene) {
for (const item of scene.items.values()) {
map.set(item.id, { id: item.id, name: item.data.name, type: item.data.type });
}
}
return map;
})()}
onJumpToObject={(objectId) => { onJumpToObject={(objectId) => {
const scene = canvasRef.current?.getScene(); const scene = canvasRef.current?.getScene();
const vp = canvasRef.current?.getViewport(); const vp = canvasRef.current?.getViewport();
+5
View File
@@ -42,6 +42,10 @@ export class AnnotationStore {
votes = new Map<string, Set<string>>(); votes = new Map<string, Set<string>>();
private _listeners = new Set<Listener>(); private _listeners = new Set<Listener>();
private _version = 0;
/** Monotonic version counter for useSyncExternalStore snapshots */
get version(): number { return this._version; }
subscribe(fn: Listener): () => void { subscribe(fn: Listener): () => void {
this._listeners.add(fn); this._listeners.add(fn);
@@ -49,6 +53,7 @@ export class AnnotationStore {
} }
private _notify() { private _notify() {
this._version++;
for (const fn of this._listeners) fn(); for (const fn of this._listeners) fn();
} }