feat(annotations): comments, threads, voting system with review mode
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
This commit is contained in:
+180
@@ -115,6 +115,53 @@ db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_media_jobs_image ON media_jobs(image_id);
|
||||
`);
|
||||
|
||||
// ── Comment Threads & Comments ──
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS comment_threads (
|
||||
id TEXT PRIMARY KEY,
|
||||
board_id TEXT NOT NULL REFERENCES boards(id) ON DELETE CASCADE,
|
||||
object_id TEXT NOT NULL,
|
||||
anchor_type TEXT NOT NULL DEFAULT 'object',
|
||||
pin_x REAL,
|
||||
pin_y REAL,
|
||||
status TEXT NOT NULL DEFAULT 'open',
|
||||
resolved_by TEXT REFERENCES users(id),
|
||||
resolved_at TEXT,
|
||||
comment_count INTEGER NOT NULL DEFAULT 1,
|
||||
last_commented_at TEXT,
|
||||
last_commented_by TEXT,
|
||||
created_by TEXT NOT NULL REFERENCES users(id),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_threads_board ON comment_threads(board_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_threads_object ON comment_threads(board_id, object_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS comments (
|
||||
id TEXT PRIMARY KEY,
|
||||
thread_id TEXT NOT NULL REFERENCES comment_threads(id) ON DELETE CASCADE,
|
||||
user_id TEXT NOT NULL REFERENCES users(id),
|
||||
author_name TEXT NOT NULL,
|
||||
author_color TEXT,
|
||||
content TEXT NOT NULL,
|
||||
edited_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_comments_thread ON comments(thread_id);
|
||||
`);
|
||||
|
||||
// ── Object Votes ──
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS object_votes (
|
||||
board_id TEXT NOT NULL REFERENCES boards(id) ON DELETE CASCADE,
|
||||
object_id TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL REFERENCES users(id),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (board_id, object_id, user_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_votes_object ON object_votes(board_id, object_id);
|
||||
`);
|
||||
|
||||
// Migrations — add columns to existing tables
|
||||
try {
|
||||
db.prepare("SELECT thumbnail FROM boards LIMIT 0").get();
|
||||
@@ -480,6 +527,132 @@ function updateImageMedia(imageId, { posterAssetKey, duration, nativeWidth, nati
|
||||
`).run(posterAssetKey || null, duration || null, nativeWidth || null, nativeHeight || null, imageId);
|
||||
}
|
||||
|
||||
// ---------------------
|
||||
// Vote helpers
|
||||
// ---------------------
|
||||
function getVotesByBoard(boardId) {
|
||||
return db.prepare('SELECT * FROM object_votes WHERE board_id = ?').all(boardId);
|
||||
}
|
||||
|
||||
function toggleVote(boardId, objectId, userId) {
|
||||
const existing = db.prepare(
|
||||
'SELECT 1 FROM object_votes WHERE board_id = ? AND object_id = ? AND user_id = ?'
|
||||
).get(boardId, objectId, userId);
|
||||
|
||||
if (existing) {
|
||||
db.prepare('DELETE FROM object_votes WHERE board_id = ? AND object_id = ? AND user_id = ?')
|
||||
.run(boardId, objectId, userId);
|
||||
return false; // vote removed
|
||||
} else {
|
||||
db.prepare('INSERT INTO object_votes (board_id, object_id, user_id) VALUES (?, ?, ?)')
|
||||
.run(boardId, objectId, userId);
|
||||
return true; // vote added
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------
|
||||
// Thread helpers
|
||||
// ---------------------
|
||||
function getThreadsByBoard(boardId) {
|
||||
return db.prepare(`
|
||||
SELECT t.*, u.display_name AS author_name
|
||||
FROM comment_threads t
|
||||
LEFT JOIN users u ON u.id = t.created_by
|
||||
WHERE t.board_id = ?
|
||||
ORDER BY t.created_at ASC
|
||||
`).all(boardId);
|
||||
}
|
||||
|
||||
function getThread(threadId) {
|
||||
return db.prepare('SELECT * FROM comment_threads WHERE id = ?').get(threadId);
|
||||
}
|
||||
|
||||
function createThread({ id, boardId, objectId, anchorType, pinX, pinY, createdBy }) {
|
||||
db.prepare(`
|
||||
INSERT INTO comment_threads (id, board_id, object_id, anchor_type, pin_x, pin_y, created_by, last_commented_at, last_commented_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now'), ?)
|
||||
`).run(id, boardId, objectId, anchorType, pinX ?? null, pinY ?? null, createdBy, createdBy);
|
||||
return getThread(id);
|
||||
}
|
||||
|
||||
function updateThreadStatus(threadId, status, resolvedBy) {
|
||||
if (status === 'resolved') {
|
||||
db.prepare(`
|
||||
UPDATE comment_threads SET status = ?, resolved_by = ?, resolved_at = datetime('now'), updated_at = datetime('now')
|
||||
WHERE id = ?
|
||||
`).run(status, resolvedBy, threadId);
|
||||
} else {
|
||||
db.prepare(`
|
||||
UPDATE comment_threads SET status = ?, resolved_by = NULL, resolved_at = NULL, updated_at = datetime('now')
|
||||
WHERE id = ?
|
||||
`).run(status, threadId);
|
||||
}
|
||||
return getThread(threadId);
|
||||
}
|
||||
|
||||
function deleteThread(threadId) {
|
||||
db.prepare('DELETE FROM comment_threads WHERE id = ?').run(threadId);
|
||||
}
|
||||
|
||||
function incrementThreadCommentCount(threadId, userId) {
|
||||
db.prepare(`
|
||||
UPDATE comment_threads
|
||||
SET comment_count = comment_count + 1,
|
||||
last_commented_at = datetime('now'),
|
||||
last_commented_by = ?,
|
||||
updated_at = datetime('now')
|
||||
WHERE id = ?
|
||||
`).run(userId, threadId);
|
||||
}
|
||||
|
||||
function decrementThreadCommentCount(threadId) {
|
||||
db.prepare(`
|
||||
UPDATE comment_threads
|
||||
SET comment_count = MAX(0, comment_count - 1),
|
||||
updated_at = datetime('now')
|
||||
WHERE id = ?
|
||||
`).run(threadId);
|
||||
}
|
||||
|
||||
// ---------------------
|
||||
// Comment helpers
|
||||
// ---------------------
|
||||
function getCommentsByThread(threadId) {
|
||||
return db.prepare('SELECT * FROM comments WHERE thread_id = ? ORDER BY created_at ASC').all(threadId);
|
||||
}
|
||||
|
||||
function getCommentsByBoard(boardId) {
|
||||
return db.prepare(`
|
||||
SELECT c.* FROM comments c
|
||||
JOIN comment_threads t ON t.id = c.thread_id
|
||||
WHERE t.board_id = ?
|
||||
ORDER BY c.created_at ASC
|
||||
`).all(boardId);
|
||||
}
|
||||
|
||||
function getComment(commentId) {
|
||||
return db.prepare('SELECT * FROM comments WHERE id = ?').get(commentId);
|
||||
}
|
||||
|
||||
function createComment({ id, threadId, userId, authorName, authorColor, content }) {
|
||||
db.prepare(`
|
||||
INSERT INTO comments (id, thread_id, user_id, author_name, author_color, content)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(id, threadId, userId, authorName, authorColor ?? null, content);
|
||||
return getComment(id);
|
||||
}
|
||||
|
||||
function updateComment(commentId, content) {
|
||||
db.prepare(`
|
||||
UPDATE comments SET content = ?, edited_at = datetime('now') WHERE id = ?
|
||||
`).run(content, commentId);
|
||||
return getComment(commentId);
|
||||
}
|
||||
|
||||
function deleteComment(commentId) {
|
||||
db.prepare('DELETE FROM comments WHERE id = ?').run(commentId);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
db,
|
||||
// Users
|
||||
@@ -499,4 +672,11 @@ module.exports = {
|
||||
getAllBoardChannelLinks, getImageByMmFileId,
|
||||
// Media Jobs
|
||||
createMediaJob, updateMediaJob, getMediaJob, getPendingMediaJobs, updateImageMedia,
|
||||
// Threads
|
||||
getThreadsByBoard, getThread, createThread, updateThreadStatus, deleteThread,
|
||||
incrementThreadCommentCount, decrementThreadCommentCount,
|
||||
// Comments
|
||||
getCommentsByThread, getCommentsByBoard, getComment, createComment, updateComment, deleteComment,
|
||||
// Votes
|
||||
getVotesByBoard, toggleVote,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
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;
|
||||
@@ -0,0 +1,83 @@
|
||||
const { Router } = require('express');
|
||||
const { authMiddleware } = require('../auth');
|
||||
const {
|
||||
getBoard,
|
||||
getCollection,
|
||||
getCollectionMember,
|
||||
getVotesByBoard,
|
||||
toggleVote,
|
||||
} = 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/votes — all votes for board
|
||||
router.get('/:boardId/votes', (req, res) => {
|
||||
try {
|
||||
const result = resolveBoard(req, res, 'viewer');
|
||||
if (!result) return;
|
||||
|
||||
const votes = getVotesByBoard(req.params.boardId);
|
||||
return res.json({ votes });
|
||||
} catch (err) {
|
||||
console.error('[votes] list error:', err);
|
||||
return res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/boards/:boardId/votes — toggle vote
|
||||
router.post('/:boardId/votes', (req, res) => {
|
||||
try {
|
||||
const result = resolveBoard(req, res, 'viewer');
|
||||
if (!result) return;
|
||||
|
||||
const { object_id } = req.body;
|
||||
if (!object_id) {
|
||||
return res.status(400).json({ error: 'object_id is required' });
|
||||
}
|
||||
|
||||
const active = toggleVote(req.params.boardId, object_id, req.user.id);
|
||||
|
||||
const io = req.app.get('io');
|
||||
if (io) {
|
||||
io.to(`board:${req.params.boardId}`).emit('vote:toggle', {
|
||||
boardId: req.params.boardId,
|
||||
objectId: object_id,
|
||||
userId: req.user.id,
|
||||
active,
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({ active });
|
||||
} catch (err) {
|
||||
console.error('[votes] toggle error:', err);
|
||||
return res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -100,6 +100,8 @@ const boardRoutes = require('./routes/boards');
|
||||
const uploadRoutes = require('./routes/upload');
|
||||
const adminRoutes = require('./routes/admin');
|
||||
const mmBridgeRoutes = require('./routes/mattermost-bridge');
|
||||
const threadRoutes = require('./routes/threads');
|
||||
const voteRoutes = require('./routes/votes');
|
||||
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/collections', collectionRoutes);
|
||||
@@ -107,6 +109,8 @@ app.use('/api/boards', boardRoutes);
|
||||
app.use('/api/upload', uploadRoutes);
|
||||
app.use('/api/admin', adminRoutes);
|
||||
app.use('/api/boards', mmBridgeRoutes);
|
||||
app.use('/api/boards', threadRoutes);
|
||||
app.use('/api/boards', voteRoutes);
|
||||
|
||||
// Public shared collection route (no auth required)
|
||||
app.get('/api/c/:shareToken', (req, res) => {
|
||||
@@ -151,6 +155,7 @@ const server = http.createServer(app);
|
||||
|
||||
const { setupSocket } = require('./socket');
|
||||
const io = setupSocket(server);
|
||||
app.set('io', io);
|
||||
|
||||
// ---- Initialize services and start ----
|
||||
async function start() {
|
||||
|
||||
Reference in New Issue
Block a user