// Asset-scoped comments for the Asset Detail page. // // Mounted at /api/v1/assets/:assetId/comments via app.use('/api/v1/assets/:assetId/comments', router). // Express's :assetId param flows through from the parent mount. import express from 'express'; import pool from '../db/pool.js'; import { assertProjectAccess } from '../auth/authz.js'; const router = express.Router({ mergeParams: true }); // Scope every comment route to the parent asset's project: resolve project_id // via the asset, then require 'view' to read and 'edit' to write. A non-UUID or // unknown asset is a clean 404 before any access decision leaks its existence. const MUTATING = new Set(['POST', 'PUT', 'PATCH', 'DELETE']); router.use(async (req, res, next) => { try { const { rows } = await pool.query('SELECT project_id FROM assets WHERE id = $1', [req.params.assetId]); if (rows.length === 0) return res.status(404).json({ error: 'Asset not found' }); await assertProjectAccess(req.user, rows[0].project_id, MUTATING.has(req.method) ? 'edit' : 'view'); next(); } catch (err) { next(err); } }); function rowToJson(r) { return { id: r.id, asset_id: r.asset_id, user_id: r.user_id, body: r.body, frame_ms: r.frame_ms, resolved: r.resolved, created_at: r.created_at, updated_at: r.updated_at, author_name: r.author_name || null, author_initials: r.author_initials || null, }; } // GET /api/v1/assets/:assetId/comments router.get('/', async (req, res, next) => { try { const { assetId } = req.params; const result = await pool.query( `SELECT c.*, u.display_name AS author_name, UPPER(SUBSTRING(COALESCE(u.display_name, u.username, '?'), 1, 2)) AS author_initials FROM asset_comments c LEFT JOIN users u ON u.id = c.user_id WHERE c.asset_id = $1 ORDER BY c.created_at ASC`, [assetId] ); res.json({ comments: result.rows.map(rowToJson) }); } catch (err) { next(err); } }); // POST /api/v1/assets/:assetId/comments router.post('/', async (req, res, next) => { try { const { assetId } = req.params; const { body, frame_ms } = req.body || {}; if (!body || !String(body).trim()) { return res.status(400).json({ error: 'body is required' }); } // Author is the authenticated user (requireAuth sets req.user for both // session and bearer auth, and the dev user when AUTH_ENABLED=false). const userId = req.user?.id || null; const ins = await pool.query( `INSERT INTO asset_comments (asset_id, user_id, body, frame_ms) VALUES ($1, $2, $3, $4) RETURNING *`, [assetId, userId, String(body).trim(), frame_ms != null ? Math.max(0, Math.round(Number(frame_ms))) : null] ); // Re-fetch with the author join so the response has the same shape as list. const result = await pool.query( `SELECT c.*, u.display_name AS author_name, UPPER(SUBSTRING(COALESCE(u.display_name, u.username, '?'), 1, 2)) AS author_initials FROM asset_comments c LEFT JOIN users u ON u.id = c.user_id WHERE c.id = $1`, [ins.rows[0].id] ); res.status(201).json(rowToJson(result.rows[0])); } catch (err) { next(err); } }); // PATCH /api/v1/assets/:assetId/comments/:id router.patch('/:id', async (req, res, next) => { try { const { id, assetId } = req.params; const { body, resolved } = req.body || {}; const fields = []; const values = []; let i = 1; if (body !== undefined) { fields.push(`body = $${i++}`); values.push(String(body).trim()); } if (resolved !== undefined) { fields.push(`resolved = $${i++}`); values.push(!!resolved); } if (fields.length === 0) return res.status(400).json({ error: 'Nothing to update' }); fields.push('updated_at = NOW()'); values.push(id, assetId); const result = await pool.query( `UPDATE asset_comments SET ${fields.join(', ')} WHERE id = $${i++} AND asset_id = $${i} RETURNING *`, values ); if (result.rows.length === 0) return res.status(404).json({ error: 'Comment not found' }); res.json(rowToJson(result.rows[0])); } catch (err) { next(err); } }); // DELETE /api/v1/assets/:assetId/comments/:id router.delete('/:id', async (req, res, next) => { try { const { id, assetId } = req.params; const result = await pool.query( `DELETE FROM asset_comments WHERE id = $1 AND asset_id = $2 RETURNING id`, [id, assetId] ); if (result.rows.length === 0) return res.status(404).json({ error: 'Comment not found' }); res.json({ id }); } catch (err) { next(err); } }); export default router;