Files
refboard-ayon/backend/routes/boards.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

260 lines
7.6 KiB
JavaScript

const { Router } = require('express');
const { v4: uuidv4 } = require('uuid');
const { authMiddleware } = require('../auth');
const {
getBoard,
createBoard,
updateBoard,
deleteBoard,
saveBoardCanvas,
getBoardImages,
getCollection,
getCollectionMember,
} = require('../db');
const { deleteBoardImages: deleteBoardMinioImages } = require('../minio');
const { recordActivity } = require('../activity');
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);
}
/**
* Resolve board + check collection access. Returns { board, member } or sends error.
*/
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);
// Allow access if member has sufficient role, or collection is public (viewer-level)
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 };
}
/**
* POST /api/boards
* Create a board inside a collection. Editor+ on the collection.
*/
router.post('/', (req, res) => {
try {
const { collection_id, name, description } = req.body;
if (!collection_id || !name || !name.trim()) {
return res.status(400).json({ error: 'collection_id and name are required' });
}
const collection = getCollection(collection_id);
if (!collection) {
return res.status(404).json({ error: 'Collection not found' });
}
const member = getCollectionMember(collection_id, req.user.id);
if (!hasCollectionRole(member, 'editor')) {
return res.status(403).json({ error: 'Editor access required on collection' });
}
const board = createBoard({
id: uuidv4(),
collectionId: collection_id,
name: name.trim(),
description: description || '',
createdBy: req.user.id,
});
recordActivity(req, {
boardId: board.id,
action: 'board.created',
targetType: 'board',
targetId: board.id,
targetLabel: board.name,
});
return res.status(201).json({ board });
} catch (err) {
console.error('[boards] create error:', err);
return res.status(500).json({ error: 'Internal server error' });
}
});
/**
* GET /api/boards/:boardId
* Get board with canvas_state and images. Viewer+ on collection.
*/
router.get('/:boardId', (req, res) => {
try {
const result = resolveBoard(req, res, 'viewer');
if (!result) return;
const { board, collection } = result;
// Auto-convert Fabric v1 → v2 on first load
if (board.canvas_state) {
try {
const parsed = typeof board.canvas_state === 'string'
? JSON.parse(board.canvas_state)
: board.canvas_state;
if (!parsed.v || parsed.v < 2) {
const { convertFabricToV2 } = require('../services/scene-converter');
const v2 = convertFabricToV2(parsed);
const v2json = JSON.stringify(v2);
saveBoardCanvas(board.id, v2json, null);
board.canvas_state = v2json;
}
} catch (e) {
// Don't block board load on conversion failure
console.error('[boards] Scene conversion failed:', e);
}
}
const images = getBoardImages(board.id);
// Hydrate video objects with poster/dimensions from DB
// (worker may have finished after canvas was last saved)
let canvasState = board.canvas_state ? JSON.parse(board.canvas_state) : {};
if (canvasState.objects && Array.isArray(canvasState.objects)) {
const videoImages = new Map();
for (const img of images) {
if (img.media_type === 'video' && img.poster_asset_key) {
videoImages.set(img.asset_key, img);
}
}
if (videoImages.size > 0) {
for (const obj of canvasState.objects) {
if (obj.type !== 'video') continue;
const dbImg = videoImages.get(obj.asset);
if (!dbImg) continue;
if (!obj.poster && dbImg.poster_asset_key) obj.poster = dbImg.poster_asset_key;
if (!obj.nativeW && dbImg.native_width) { obj.nativeW = dbImg.native_width; obj.w = dbImg.native_width; }
if (!obj.nativeH && dbImg.native_height) { obj.nativeH = dbImg.native_height; obj.h = dbImg.native_height; }
if (!obj.duration && dbImg.duration) obj.duration = dbImg.duration;
}
}
}
return res.json({
board: {
...board,
canvas_state: canvasState,
},
collection: {
id: collection.id,
name: collection.name,
},
images,
role: result.member?.role || 'viewer',
});
} catch (err) {
console.error('[boards] get error:', err);
return res.status(500).json({ error: 'Internal server error' });
}
});
/**
* PUT /api/boards/:boardId
* Update board name/description. Editor+ on collection.
*/
router.put('/:boardId', (req, res) => {
try {
const result = resolveBoard(req, res, 'editor');
if (!result) return;
const { name, description } = req.body;
const prevName = result.board.name;
const updated = updateBoard(result.board.id, { name, description });
if (typeof name === 'string' && name.trim() && name.trim() !== prevName) {
recordActivity(req, {
boardId: updated.id,
action: 'board.renamed',
targetType: 'board',
targetId: updated.id,
targetLabel: updated.name,
metadata: { from: prevName, to: updated.name },
});
}
return res.json({ board: updated });
} catch (err) {
console.error('[boards] update error:', err);
return res.status(500).json({ error: 'Internal server error' });
}
});
/**
* DELETE /api/boards/:boardId
* Delete board + images. Owner on collection.
*/
router.delete('/:boardId', async (req, res) => {
try {
const result = resolveBoard(req, res, 'owner');
if (!result) return;
try {
await deleteBoardMinioImages(result.board.id);
} catch (e) {
console.error('[boards] MinIO cleanup error:', e);
}
recordActivity(req, {
boardId: result.board.id,
action: 'board.deleted',
targetType: 'board',
targetId: result.board.id,
targetLabel: result.board.name,
});
deleteBoard(result.board.id);
return res.json({ message: 'Board deleted' });
} catch (err) {
console.error('[boards] delete error:', err);
return res.status(500).json({ error: 'Internal server error' });
}
});
/**
* POST /api/boards/:boardId/save
* Save canvas state. Editor+ on collection.
*/
router.post('/:boardId/save', (req, res) => {
try {
const result = resolveBoard(req, res, 'editor');
if (!result) return;
const { canvas_state, thumbnail } = req.body;
if (canvas_state === undefined) {
return res.status(400).json({ error: 'canvas_state is required' });
}
saveBoardCanvas(result.board.id, canvas_state, thumbnail || null);
return res.json({ message: 'Canvas saved' });
} catch (err) {
console.error('[boards] save error:', err);
return res.status(500).json({ error: 'Internal server error' });
}
});
module.exports = router;