import express from 'express'; import pool from '../db/pool.js'; import { validateUuid } from '../middleware/errors.js'; import { assertProjectAccess, accessibleProjectIds } from '../auth/authz.js'; import { v4 as uuidv4 } from 'uuid'; const router = express.Router(); // Resolve the owning project for a /:id bin, assert 'view' baseline, stash the // project_id for mutating routes to escalate to 'edit'. router.param('id', async (req, res, next) => { validateUuid('id')(req, res, () => {}); if (res.headersSent) return; try { const { rows } = await pool.query('SELECT project_id FROM bins WHERE id = $1', [req.params.id]); if (rows.length === 0) return res.status(404).json({ error: 'Bin not found' }); req.binProjectId = rows[0].project_id; await assertProjectAccess(req.user, req.binProjectId, 'view'); next(); } catch (err) { next(err); } }); async function requireBinEdit(req, res, next) { try { await assertProjectAccess(req.user, req.binProjectId, 'edit'); next(); } catch (err) { next(err); } } // GET / - List bins. When project_id is supplied, scope to it (after an access // check); otherwise return bins across every project the caller can access. router.get('/', async (req, res, next) => { try { const { project_id } = req.query; if (project_id) { await assertProjectAccess(req.user, project_id, 'view'); const result = await pool.query( `SELECT b.*, p.name AS project_name, (SELECT COUNT(*)::int FROM assets a WHERE a.bin_id = b.id) AS asset_count FROM bins b LEFT JOIN projects p ON p.id = b.project_id WHERE b.project_id = $1 ORDER BY b.created_at DESC`, [project_id] ); return res.json(result.rows); } const access = await accessibleProjectIds(req.user); let where = ''; const params = []; if (!access.all) { if (access.ids.size === 0) return res.json([]); where = 'WHERE b.project_id = ANY($1::uuid[])'; params.push([...access.ids]); } const result = await pool.query( `SELECT b.*, p.name AS project_name, (SELECT COUNT(*)::int FROM assets a WHERE a.bin_id = b.id) AS asset_count FROM bins b LEFT JOIN projects p ON p.id = b.project_id ${where} ORDER BY b.created_at DESC`, params ); res.json(result.rows); } catch (err) { next(err); } }); // POST / - Create bin (requires edit on the target project). router.post('/', async (req, res, next) => { try { const { project_id, name, parent_id } = req.body; if (!project_id || !name) { return res.status(400).json({ error: 'project_id and name are required' }); } await assertProjectAccess(req.user, project_id, 'edit'); const id = uuidv4(); const result = await pool.query( `INSERT INTO bins (id, project_id, name, parent_id, created_at, updated_at) VALUES ($1, $2, $3, $4, NOW(), NOW()) RETURNING *`, [id, project_id, name, parent_id || null] ); res.status(201).json(result.rows[0]); } catch (err) { next(err); } }); // PATCH /:id - Update bin router.patch('/:id', requireBinEdit, async (req, res, next) => { try { const { id } = req.params; const { name, parent_id } = req.body; const updates = []; const params = []; let paramCount = 1; if (name !== undefined) { updates.push(`name = $${paramCount++}`); params.push(name); } if (parent_id !== undefined) { updates.push(`parent_id = $${paramCount++}`); params.push(parent_id || null); } if (updates.length === 0) { return res.status(400).json({ error: 'No fields to update' }); } updates.push(`updated_at = NOW()`); params.push(id); const query = ` UPDATE bins SET ${updates.join(', ')} WHERE id = $${paramCount} RETURNING * `; const result = await pool.query(query, params); if (result.rows.length === 0) { return res.status(404).json({ error: 'Bin not found' }); } res.json(result.rows[0]); } catch (err) { next(err); } }); // DELETE /:id - Delete bin router.delete('/:id', requireBinEdit, async (req, res, next) => { try { const { id } = req.params; const result = await pool.query( 'DELETE FROM bins WHERE id = $1 RETURNING *', [id] ); if (result.rows.length === 0) { return res.status(404).json({ error: 'Bin not found' }); } res.json({ message: 'Bin deleted', bin: result.rows[0] }); } catch (err) { next(err); } }); // POST /:id/assets - Add asset to bin (requires edit on the bin's project). router.post('/:id/assets', requireBinEdit, async (req, res, next) => { try { const { id } = req.params; const { asset_id } = req.body; if (!asset_id) { return res.status(400).json({ error: 'asset_id is required' }); } // Asset must live in the bin's own project. Without this, an editor in // project A (where the bin lives) could pull an asset from project B (no // grant) into A's bin tree, exposing it in A's views. const a = await pool.query('SELECT project_id FROM assets WHERE id = $1', [asset_id]); if (a.rows.length === 0) return res.status(404).json({ error: 'Asset not found' }); if (a.rows[0].project_id !== req.binProjectId) { return res.status(400).json({ error: 'asset belongs to a different project than the bin' }); } // Update asset's bin_id const result = await pool.query( 'UPDATE assets SET bin_id = $1, updated_at = NOW() WHERE id = $2 RETURNING *', [id, asset_id] ); if (result.rows.length === 0) { return res.status(404).json({ error: 'Asset not found' }); } res.json(result.rows[0]); } catch (err) { next(err); } }); // DELETE /:id/assets/:assetId - Remove asset from bin (requires edit). router.delete('/:id/assets/:assetId', requireBinEdit, async (req, res, next) => { try { const { id, assetId } = req.params; // Verify bin exists const binCheck = await pool.query('SELECT id FROM bins WHERE id = $1', [id]); if (binCheck.rows.length === 0) { return res.status(404).json({ error: 'Bin not found' }); } // Remove asset from bin const result = await pool.query( 'UPDATE assets SET bin_id = NULL, updated_at = NOW() WHERE id = $1 AND bin_id = $2 RETURNING *', [assetId, id] ); if (result.rows.length === 0) { return res.status(404).json({ error: 'Asset not found in this bin' }); } res.json({ message: 'Asset removed from bin', asset: result.rows[0] }); } catch (err) { next(err); } }); export default router;