Persist lineage in PostgreSQL, seed documented edges, and keep timeline/tree consumers API-backed with a static fallback. Co-authored-by: Cursor <cursoragent@cursor.com>
153 lines
4.6 KiB
JavaScript
153 lines
4.6 KiB
JavaScript
const express = require('express');
|
|
const { requirePermission, requireAdmin } = require('../middleware/auth');
|
|
const { logCuratorAction } = require('../audit-log');
|
|
const {
|
|
listLinks,
|
|
createLink,
|
|
deleteLink,
|
|
listDeleteVotes,
|
|
castDeleteVote,
|
|
withdrawDeleteVote,
|
|
seedDefaultsIfEmpty,
|
|
} = require('../movement-lineage-service');
|
|
|
|
const router = express.Router();
|
|
|
|
/** Optional staff session for vote annotations on list (public otherwise). */
|
|
async function optionalStaff(req, _res, next) {
|
|
try {
|
|
if (req.session?.userId) {
|
|
const { loadStaffUser } = require('../middleware/auth');
|
|
const user = await loadStaffUser(req.session.userId);
|
|
if (user && user.is_active) {
|
|
req.curatorUser = user;
|
|
}
|
|
}
|
|
} catch {
|
|
/* ignore — list stays public */
|
|
}
|
|
next();
|
|
}
|
|
|
|
/** Public: timeline / tree consumers. */
|
|
router.get('/', optionalStaff, async (req, res) => {
|
|
try {
|
|
await seedDefaultsIfEmpty();
|
|
const q = typeof req.query.q === 'string' ? req.query.q : '';
|
|
const limit = Number(req.query.limit) || 500;
|
|
const offset = Number(req.query.offset) || 0;
|
|
const data = await listLinks({
|
|
q,
|
|
limit,
|
|
offset,
|
|
viewerUserId: req.curatorUser?.id || null,
|
|
});
|
|
res.json(data);
|
|
} catch (err) {
|
|
console.error(err);
|
|
res.status(500).json({ error: 'Failed to list movement links' });
|
|
}
|
|
});
|
|
|
|
router.post('/', requirePermission('influences'), async (req, res) => {
|
|
try {
|
|
const item = await createLink(req.body || {}, { userId: req.session?.userId });
|
|
await logCuratorAction({
|
|
userId: req.session.userId,
|
|
action: 'movement_link.create',
|
|
resourceType: 'movement_lineage_link',
|
|
resourceId: item.id,
|
|
details: {
|
|
source_movement_id: item.source_movement_id,
|
|
target_movement_id: item.target_movement_id,
|
|
proof_url: item.proof_url,
|
|
},
|
|
req,
|
|
});
|
|
res.status(201).json({ item });
|
|
} catch (err) {
|
|
const status = err.status || 500;
|
|
if (status >= 500) console.error(err);
|
|
res.status(status).json({ error: err.message || 'Failed to create movement link' });
|
|
}
|
|
});
|
|
|
|
router.get('/:id/delete-votes', requirePermission('influences'), async (req, res) => {
|
|
try {
|
|
const data = await listDeleteVotes(req.params.id);
|
|
res.json(data);
|
|
} catch (err) {
|
|
const status = err.status || 500;
|
|
if (status >= 500) console.error(err);
|
|
res.status(status).json({ error: err.message || 'Failed to list delete votes' });
|
|
}
|
|
});
|
|
|
|
router.post('/:id/delete-votes', requirePermission('influences'), async (req, res) => {
|
|
try {
|
|
const vote = await castDeleteVote(req.params.id, req.body || {}, {
|
|
userId: req.session.userId,
|
|
});
|
|
await logCuratorAction({
|
|
userId: req.session.userId,
|
|
action: 'movement_link.delete_vote',
|
|
resourceType: 'movement_lineage_link',
|
|
resourceId: Number(req.params.id),
|
|
details: { reason: vote.reason },
|
|
req,
|
|
});
|
|
res.status(201).json({ vote });
|
|
} catch (err) {
|
|
const status = err.status || 500;
|
|
if (status >= 500) console.error(err);
|
|
res.status(status).json({ error: err.message || 'Failed to cast delete vote' });
|
|
}
|
|
});
|
|
|
|
router.delete('/:id/delete-votes/me', requirePermission('influences'), async (req, res) => {
|
|
try {
|
|
const result = await withdrawDeleteVote(req.params.id, { userId: req.session.userId });
|
|
await logCuratorAction({
|
|
userId: req.session.userId,
|
|
action: 'movement_link.delete_vote_withdraw',
|
|
resourceType: 'movement_lineage_link',
|
|
resourceId: result.linkId,
|
|
details: {},
|
|
req,
|
|
});
|
|
res.json({ ok: true, ...result });
|
|
} catch (err) {
|
|
const status = err.status || 500;
|
|
if (status >= 500) console.error(err);
|
|
res.status(status).json({ error: err.message || 'Failed to withdraw delete vote' });
|
|
}
|
|
});
|
|
|
|
/** Hard delete — admin only. Curators vote instead. */
|
|
router.delete('/:id', requireAdmin, async (req, res) => {
|
|
try {
|
|
const votes = await listDeleteVotes(req.params.id).catch(() => ({ votes: [] }));
|
|
const result = await deleteLink(req.params.id);
|
|
await logCuratorAction({
|
|
userId: req.session.userId,
|
|
action: 'movement_link.delete',
|
|
resourceType: 'movement_lineage_link',
|
|
resourceId: result.id,
|
|
details: {
|
|
prior_votes: (votes.votes || []).map((v) => ({
|
|
username: v.username,
|
|
reason: v.reason,
|
|
})),
|
|
},
|
|
req,
|
|
});
|
|
res.json({ ok: true, id: result.id });
|
|
} catch (err) {
|
|
const status = err.status || 500;
|
|
if (status >= 500) console.error(err);
|
|
res.status(status).json({ error: err.message || 'Failed to delete movement link' });
|
|
}
|
|
});
|
|
|
|
module.exports = router;
|