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('../storage'); 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;