feat(comments): persistent frame-anchored comments on asset detail
- migration 010: asset_comments table (id, asset_id, user_id, body,
frame_ms, resolved, timestamps) with index on asset_id+created_at
- new routes mounted at /api/v1/assets/:assetId/comments — GET/POST/
PATCH/DELETE with author join (display_name + initials), nullable
user_id so comments still attach when AUTH_ENABLED is off
- Asset detail loads comments from the API on mount instead of the
empty ZAMPP_DATA.COMMENTS seed; addComment POSTs and merges the
returned row; resolved-toggle and delete are wired
- CommentsList: new trash-icon delete action per comment, helpful
empty-state copy ('Add one below to mark a frame'), tooltips on
the timestamp and resolved buttons
Now editor comments survive page reload, are visible to other users
via the same API, and pin reliably to frame_ms (integer) instead of
a parsed HH:MM:SS:FF string.
2026-05-23 00:21:11 -04:00
|
|
|
// Asset-scoped comments for the Asset Detail page.
|
|
|
|
|
//
|
feat(mam-api,web-ui): per-project RBAC (v2 auth layer)
Adds per-project access control on top of the flat v1 auth. admin keeps
global access; editor/viewer are scoped to projects granted to them (direct
or via group) at view (read-only) or edit (read-write) level.
- migration 026: project_access table + access_level enum
- src/auth/authz.js: central isAdmin/accessibleProjectIds/projectLevel/
assertProjectAccess
- requireAdmin middleware; admin-gate /users, /auth/users, /groups
- enforce scoping on projects, assets, bins (list filter + per-resource
view/edit + create checks); gate bulk asset maintenance + batch-trim
- grant API: GET/POST/DELETE /projects/:id/access
- web-ui: hide admin nav for non-admins, admin-route bounce, project
"Manage access" modal, rewrite Policies tab
- tests: authz, project-access, assets-access (node:test, skip w/o DB)
- deferred routers carry TODO(authz) markers; .env.example documents the
service-token-needs-admin/grants requirement
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 22:37:36 -04:00
|
|
|
// TODO(authz): per-project scoping not yet enforced. Comments hang off an asset
|
|
|
|
|
// (resolve project via the asset); adopt assertProjectAccess (auth/authz.js).
|
|
|
|
|
//
|
feat(comments): persistent frame-anchored comments on asset detail
- migration 010: asset_comments table (id, asset_id, user_id, body,
frame_ms, resolved, timestamps) with index on asset_id+created_at
- new routes mounted at /api/v1/assets/:assetId/comments — GET/POST/
PATCH/DELETE with author join (display_name + initials), nullable
user_id so comments still attach when AUTH_ENABLED is off
- Asset detail loads comments from the API on mount instead of the
empty ZAMPP_DATA.COMMENTS seed; addComment POSTs and merges the
returned row; resolved-toggle and delete are wired
- CommentsList: new trash-icon delete action per comment, helpful
empty-state copy ('Add one below to mark a frame'), tooltips on
the timestamp and resolved buttons
Now editor comments survive page reload, are visible to other users
via the same API, and pin reliably to frame_ms (integer) instead of
a parsed HH:MM:SS:FF string.
2026-05-23 00:21:11 -04:00
|
|
|
// 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';
|
|
|
|
|
|
|
|
|
|
const router = express.Router({ mergeParams: true });
|
|
|
|
|
|
|
|
|
|
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' });
|
|
|
|
|
}
|
|
|
|
|
// Best-effort author lookup — pull from the session if AUTH_ENABLED is on.
|
|
|
|
|
const userId = req.session?.userId || 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;
|