Backend: - comment_threads + comments + object_votes tables with indexes - Thread/comment CRUD endpoints with socket broadcast - Vote toggle endpoint with socket broadcast Frontend: - AnnotationStore for reactive thread/comment/vote state - FeedbackPanel with thread list, expanded view, replies, filtering - Vote toggle buttons in panel - PinOverlay for canvas pin markers + vote badges - Review Mode toggle in toolbar (comment bubble icon) - Jump-to-object from thread view - Orphaned thread detection for deleted objects - New comment creation from panel when object selected - Socket event wiring for real-time sync
315 lines
9.4 KiB
JavaScript
315 lines
9.4 KiB
JavaScript
const { Router } = require('express');
|
|
const { v4: uuidv4 } = require('uuid');
|
|
const { authMiddleware } = require('../auth');
|
|
const {
|
|
getBoard,
|
|
getCollection,
|
|
getCollectionMember,
|
|
getThreadsByBoard,
|
|
getThread,
|
|
createThread,
|
|
updateThreadStatus,
|
|
deleteThread,
|
|
getCommentsByBoard,
|
|
getComment,
|
|
createComment,
|
|
updateComment,
|
|
deleteComment,
|
|
incrementThreadCommentCount,
|
|
decrementThreadCommentCount,
|
|
} = require('../db');
|
|
|
|
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/threads — all threads + comments for board
|
|
router.get('/:boardId/threads', (req, res) => {
|
|
try {
|
|
const result = resolveBoard(req, res, 'viewer');
|
|
if (!result) return;
|
|
|
|
const threads = getThreadsByBoard(req.params.boardId);
|
|
const comments = getCommentsByBoard(req.params.boardId);
|
|
|
|
// Group comments by thread
|
|
const commentsByThread = {};
|
|
for (const c of comments) {
|
|
if (!commentsByThread[c.thread_id]) commentsByThread[c.thread_id] = [];
|
|
commentsByThread[c.thread_id].push(c);
|
|
}
|
|
|
|
const data = threads.map((t) => ({
|
|
...t,
|
|
comments: commentsByThread[t.id] || [],
|
|
}));
|
|
|
|
return res.json({ threads: data });
|
|
} catch (err) {
|
|
console.error('[threads] list error:', err);
|
|
return res.status(500).json({ error: 'Internal server error' });
|
|
}
|
|
});
|
|
|
|
// POST /api/boards/:boardId/threads — create thread + first comment
|
|
router.post('/:boardId/threads', (req, res) => {
|
|
try {
|
|
const result = resolveBoard(req, res, 'viewer');
|
|
if (!result) return;
|
|
|
|
const { object_id, anchor_type, pin_x, pin_y, content } = req.body;
|
|
if (!object_id || !content || !content.trim()) {
|
|
return res.status(400).json({ error: 'object_id and content are required' });
|
|
}
|
|
|
|
const threadId = uuidv4();
|
|
const commentId = uuidv4();
|
|
const userId = req.user.id;
|
|
|
|
const thread = createThread({
|
|
id: threadId,
|
|
boardId: req.params.boardId,
|
|
objectId: object_id,
|
|
anchorType: anchor_type || 'object',
|
|
pinX: pin_x,
|
|
pinY: pin_y,
|
|
createdBy: userId,
|
|
});
|
|
|
|
const comment = createComment({
|
|
id: commentId,
|
|
threadId,
|
|
userId,
|
|
authorName: req.user.display_name || req.user.username,
|
|
authorColor: null,
|
|
content: content.trim(),
|
|
});
|
|
|
|
// Broadcast via socket
|
|
const io = req.app.get('io');
|
|
if (io) {
|
|
io.to(`board:${req.params.boardId}`).emit('thread:add', {
|
|
boardId: req.params.boardId,
|
|
thread,
|
|
comment,
|
|
});
|
|
}
|
|
|
|
return res.status(201).json({ thread, comment });
|
|
} catch (err) {
|
|
console.error('[threads] create error:', err);
|
|
return res.status(500).json({ error: 'Internal server error' });
|
|
}
|
|
});
|
|
|
|
// PATCH /api/boards/:boardId/threads/:threadId — update thread status
|
|
router.patch('/:boardId/threads/:threadId', (req, res) => {
|
|
try {
|
|
const result = resolveBoard(req, res, 'editor');
|
|
if (!result) return;
|
|
|
|
const { status } = req.body;
|
|
if (!['open', 'resolved', 'archived'].includes(status)) {
|
|
return res.status(400).json({ error: 'Invalid status' });
|
|
}
|
|
|
|
const thread = getThread(req.params.threadId);
|
|
if (!thread || thread.board_id !== req.params.boardId) {
|
|
return res.status(404).json({ error: 'Thread not found' });
|
|
}
|
|
|
|
const updated = updateThreadStatus(req.params.threadId, status, req.user.id);
|
|
|
|
const io = req.app.get('io');
|
|
if (io) {
|
|
io.to(`board:${req.params.boardId}`).emit('thread:status', {
|
|
boardId: req.params.boardId,
|
|
threadId: req.params.threadId,
|
|
status: updated.status,
|
|
resolvedBy: updated.resolved_by,
|
|
resolvedAt: updated.resolved_at,
|
|
});
|
|
}
|
|
|
|
return res.json({ thread: updated });
|
|
} catch (err) {
|
|
console.error('[threads] status error:', err);
|
|
return res.status(500).json({ error: 'Internal server error' });
|
|
}
|
|
});
|
|
|
|
// DELETE /api/boards/:boardId/threads/:threadId — delete thread + all comments
|
|
router.delete('/:boardId/threads/:threadId', (req, res) => {
|
|
try {
|
|
const result = resolveBoard(req, res, 'owner');
|
|
if (!result) return;
|
|
|
|
const thread = getThread(req.params.threadId);
|
|
if (!thread || thread.board_id !== req.params.boardId) {
|
|
return res.status(404).json({ error: 'Thread not found' });
|
|
}
|
|
|
|
deleteThread(req.params.threadId);
|
|
|
|
const io = req.app.get('io');
|
|
if (io) {
|
|
io.to(`board:${req.params.boardId}`).emit('thread:delete', {
|
|
boardId: req.params.boardId,
|
|
threadId: req.params.threadId,
|
|
});
|
|
}
|
|
|
|
return res.json({ message: 'Thread deleted' });
|
|
} catch (err) {
|
|
console.error('[threads] delete error:', err);
|
|
return res.status(500).json({ error: 'Internal server error' });
|
|
}
|
|
});
|
|
|
|
// POST /api/boards/:boardId/threads/:threadId/comments — add reply
|
|
router.post('/:boardId/threads/:threadId/comments', (req, res) => {
|
|
try {
|
|
const result = resolveBoard(req, res, 'viewer');
|
|
if (!result) return;
|
|
|
|
const { content } = req.body;
|
|
if (!content || !content.trim()) {
|
|
return res.status(400).json({ error: 'content is required' });
|
|
}
|
|
|
|
const thread = getThread(req.params.threadId);
|
|
if (!thread || thread.board_id !== req.params.boardId) {
|
|
return res.status(404).json({ error: 'Thread not found' });
|
|
}
|
|
|
|
const commentId = uuidv4();
|
|
const userId = req.user.id;
|
|
|
|
const comment = createComment({
|
|
id: commentId,
|
|
threadId: req.params.threadId,
|
|
userId,
|
|
authorName: req.user.display_name || req.user.username,
|
|
authorColor: null,
|
|
content: content.trim(),
|
|
});
|
|
|
|
incrementThreadCommentCount(req.params.threadId, userId);
|
|
|
|
const io = req.app.get('io');
|
|
if (io) {
|
|
io.to(`board:${req.params.boardId}`).emit('comment:add', {
|
|
boardId: req.params.boardId,
|
|
threadId: req.params.threadId,
|
|
comment,
|
|
});
|
|
}
|
|
|
|
return res.status(201).json({ comment });
|
|
} catch (err) {
|
|
console.error('[threads] add comment error:', err);
|
|
return res.status(500).json({ error: 'Internal server error' });
|
|
}
|
|
});
|
|
|
|
// PUT /api/boards/:boardId/threads/:threadId/comments/:commentId — edit own comment
|
|
router.put('/:boardId/threads/:threadId/comments/:commentId', (req, res) => {
|
|
try {
|
|
const result = resolveBoard(req, res, 'viewer');
|
|
if (!result) return;
|
|
|
|
const { content } = req.body;
|
|
if (!content || !content.trim()) {
|
|
return res.status(400).json({ error: 'content is required' });
|
|
}
|
|
|
|
const comment = getComment(req.params.commentId);
|
|
if (!comment || comment.thread_id !== req.params.threadId) {
|
|
return res.status(404).json({ error: 'Comment not found' });
|
|
}
|
|
if (comment.user_id !== req.user.id) {
|
|
return res.status(403).json({ error: 'Can only edit own comments' });
|
|
}
|
|
|
|
const updated = updateComment(req.params.commentId, content.trim());
|
|
|
|
const io = req.app.get('io');
|
|
if (io) {
|
|
io.to(`board:${req.params.boardId}`).emit('comment:update', {
|
|
boardId: req.params.boardId,
|
|
threadId: req.params.threadId,
|
|
commentId: req.params.commentId,
|
|
content: updated.content,
|
|
editedAt: updated.edited_at,
|
|
});
|
|
}
|
|
|
|
return res.json({ comment: updated });
|
|
} catch (err) {
|
|
console.error('[threads] edit comment error:', err);
|
|
return res.status(500).json({ error: 'Internal server error' });
|
|
}
|
|
});
|
|
|
|
// DELETE /api/boards/:boardId/threads/:threadId/comments/:commentId — delete own comment
|
|
router.delete('/:boardId/threads/:threadId/comments/:commentId', (req, res) => {
|
|
try {
|
|
// Owner can delete any comment, others only their own
|
|
const result = resolveBoard(req, res, 'viewer');
|
|
if (!result) return;
|
|
|
|
const comment = getComment(req.params.commentId);
|
|
if (!comment || comment.thread_id !== req.params.threadId) {
|
|
return res.status(404).json({ error: 'Comment not found' });
|
|
}
|
|
|
|
const isOwner = hasCollectionRole(result.member, 'owner');
|
|
if (comment.user_id !== req.user.id && !isOwner) {
|
|
return res.status(403).json({ error: 'Can only delete own comments' });
|
|
}
|
|
|
|
deleteComment(req.params.commentId);
|
|
decrementThreadCommentCount(req.params.threadId);
|
|
|
|
const io = req.app.get('io');
|
|
if (io) {
|
|
io.to(`board:${req.params.boardId}`).emit('comment:delete', {
|
|
boardId: req.params.boardId,
|
|
threadId: req.params.threadId,
|
|
commentId: req.params.commentId,
|
|
});
|
|
}
|
|
|
|
return res.json({ message: 'Comment deleted' });
|
|
} catch (err) {
|
|
console.error('[threads] delete comment error:', err);
|
|
return res.status(500).json({ error: 'Internal server error' });
|
|
}
|
|
});
|
|
|
|
module.exports = router;
|