Add curator Movement links config with proof URLs and admin-gated delete votes.

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>
This commit is contained in:
Danila KhodjaefandCursor committed 2026-09-08 14:37:20 +03:00
1 parent 2b4cdc5a9f
commit 1b760faf0d
36 files changed
+2051 -43

No files matched your search

+152
View File
@@ -0,0 +1,152 @@
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;