Files
refboard-ayon/backend/routes/threads.js
T
Hiren Kangad eb0bd210ac feat: per-board activity log
Adds an audit trail per board, visible from a new clock-icon button on the
toolbar. Useful for review-style work where someone wants to see who
contributed which references and when.

Logged events (high-signal only — canvas-edit noise intentionally skipped):
- image / video / pdf added (whether dropped, pasted, or pulled from a URL)
- board created / renamed / deleted
- thread started, resolved, reopened
- comment posted on a thread

Backend:
- new activity_logs table (id, board_id, user_id, denormalised actor name +
  email, action, target_type/id/label, metadata JSON, created_at) with an
  index on (board_id, created_at DESC).
- logActivity helper resolves the user once at log time and stores their
  display name + email so entries survive deactivation/rename.
- recordActivity wraps logActivity + a Socket.IO emit to the board's room
  so the panel updates live without polling.
- GET /api/boards/:id/activity?limit=&before= for pagination
  (collection-membership gated, viewer+).

Frontend:
- ActivityPanel side-drawer: time-grouped feed (Today / Yesterday / older),
  per-action icons + tone colours (add/remove/edit/comment), pagination
  via "Load older", live append on Socket.IO 'activity:new'.
- Relative timestamps refresh every 30s.
- Wired into Editor + Toolbar.

README updated; roadmap entry checked off.
2026-04-28 21:29:48 +05:30

336 lines
10 KiB
JavaScript

const { Router } = require('express');
const { v4: uuidv4 } = require('uuid');
const { authMiddleware } = require('../auth');
const {
getThreadsByBoard,
getThread,
createThread,
createThreadWithComment,
updateThreadStatus,
deleteThread,
getCommentsByBoard,
getComment,
createComment,
updateComment,
deleteComment,
incrementThreadCommentCount,
decrementThreadCommentCount,
getUserById,
} = require('../db');
const { hasCollectionRole, resolveBoard } = require('./board-access');
const { recordActivity } = require('../activity');
function resolveAuthorName(reqUser) {
if (reqUser.display_name || reqUser.username) {
return reqUser.display_name || reqUser.username;
}
// Fallback: look up from DB (old JWT tokens lack these fields)
const dbUser = getUserById(reqUser.id);
return dbUser ? (dbUser.display_name || dbUser.username || dbUser.email) : 'Unknown';
}
const router = Router();
router.use(authMiddleware);
const MAX_COMMENT_LENGTH = 5000;
// 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' });
}
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();
const userId = req.user.id;
const { thread, comment } = createThreadWithComment({
threadId,
boardId: req.params.boardId,
objectId: object_id,
anchorType: anchor_type || 'object',
pinX: pin_x,
pinY: pin_y,
createdBy: userId,
commentId,
userId,
authorName: resolveAuthorName(req.user),
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,
});
}
recordActivity(req, {
boardId: req.params.boardId,
action: 'thread.created',
targetType: 'thread',
targetId: threadId,
targetLabel: content.trim().slice(0, 80),
});
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,
});
}
if (status === 'resolved' || status === 'open') {
recordActivity(req, {
boardId: req.params.boardId,
action: status === 'resolved' ? 'thread.resolved' : 'thread.reopened',
targetType: 'thread',
targetId: req.params.threadId,
});
}
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' });
}
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) {
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: resolveAuthorName(req.user),
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,
});
}
recordActivity(req, {
boardId: req.params.boardId,
action: 'comment.added',
targetType: 'thread',
targetId: req.params.threadId,
targetLabel: content.trim().slice(0, 80),
});
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' });
}
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) {
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;