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:
1 parent
2b4cdc5a9f
commit
1b760faf0d
36 files changed
+2051
-43
No files matched your search
@@ -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;
|
||||
Reference in new issue
Block a user