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

@@ -0,0 +1,129 @@
/**
* Tests for movement lineage validation (run before / with the service module).
* node --test server/__tests__/movement-lineage-service.test.js
*/
const { describe, it } = require('node:test');
const assert = require('node:assert/strict');
const {
isValidProofUrl,
validateCreatePayload,
validateDeleteVotePayload,
DEFAULT_MOVEMENT_LINEAGE,
} = require('../movement-lineage-service');
describe('isValidProofUrl', () => {
it('accepts http and https URLs', () => {
assert.equal(isValidProofUrl('https://www.metmuseum.org/toah/hd/byza/hd_byza.htm'), true);
assert.equal(isValidProofUrl('http://example.com/essay'), true);
});
it('rejects empty, non-http, and garbage', () => {
assert.equal(isValidProofUrl(''), false);
assert.equal(isValidProofUrl(' '), false);
assert.equal(isValidProofUrl(null), false);
assert.equal(isValidProofUrl('ftp://files.example/a'), false);
assert.equal(isValidProofUrl('not-a-url'), false);
assert.equal(isValidProofUrl('www.metmuseum.org/page'), false);
});
});
describe('validateCreatePayload', () => {
it('requires source, target, and proof URL', () => {
const r = validateCreatePayload({});
assert.equal(r.ok, false);
assert.match(r.error, /sourceMovementId/i);
});
it('rejects same source and target', () => {
const r = validateCreatePayload({
sourceMovementId: 3,
targetMovementId: 3,
proofUrl: 'https://www.theartstory.org/movement/cubism/',
});
assert.equal(r.ok, false);
assert.match(r.error, /different/i);
});
it('requires a valid proof URL', () => {
const r = validateCreatePayload({
sourceMovementId: 1,
targetMovementId: 2,
proofUrl: 'see Wikipedia',
});
assert.equal(r.ok, false);
assert.match(r.error, /proof/i);
});
it('accepts a valid payload and normalizes fields', () => {
const r = validateCreatePayload({
sourceMovementId: '10',
targetMovementId: 20,
proofUrl: ' https://www.metmuseum.org/essays/cubism ',
proofLabel: ' Met Cubism ',
linkYearApprox: '1907',
notes: ' Cézanne → Picasso ',
});
assert.equal(r.ok, true);
assert.deepEqual(r.value, {
sourceMovementId: 10,
targetMovementId: 20,
proofUrl: 'https://www.metmuseum.org/essays/cubism',
proofLabel: 'Met Cubism',
linkYearApprox: 1907,
notes: 'Cézanne → Picasso',
});
});
it('allows null optional year and blank optional label/notes', () => {
const r = validateCreatePayload({
sourceMovementId: 1,
targetMovementId: 2,
proofUrl: 'https://example.org/a',
proofLabel: '',
linkYearApprox: '',
notes: null,
});
assert.equal(r.ok, true);
assert.equal(r.value.proofLabel, null);
assert.equal(r.value.linkYearApprox, null);
assert.equal(r.value.notes, null);
});
});
describe('validateDeleteVotePayload', () => {
it('requires a non-empty reason', () => {
assert.equal(validateDeleteVotePayload({}).ok, false);
assert.equal(validateDeleteVotePayload({ reason: ' ' }).ok, false);
assert.match(validateDeleteVotePayload({ reason: '' }).error, /reason/i);
});
it('rejects reasons that are too short', () => {
const r = validateDeleteVotePayload({ reason: 'no' });
assert.equal(r.ok, false);
assert.match(r.error, /reason/i);
});
it('accepts and trims a valid reason', () => {
const r = validateDeleteVotePayload({
reason: ' Incorrect lineage: Cubism did not derive from Fauvism. ',
});
assert.equal(r.ok, true);
assert.equal(r.value.reason, 'Incorrect lineage: Cubism did not derive from Fauvism.');
});
});
describe('DEFAULT_MOVEMENT_LINEAGE', () => {
it('has unique source→target pairs with proof URLs', () => {
assert.ok(DEFAULT_MOVEMENT_LINEAGE.length >= 20);
const keys = new Set();
for (const row of DEFAULT_MOVEMENT_LINEAGE) {
assert.ok(row.sourceName);
assert.ok(row.targetName);
assert.notEqual(row.sourceName, row.targetName);
assert.equal(isValidProofUrl(row.proofUrl), true, row.proofUrl);
const key = `${row.sourceName}→${row.targetName}`;
assert.equal(keys.has(key), false, `duplicate ${key}`);
keys.add(key);
}
});
});
+1
View File
@@ -50,6 +50,7 @@ app.use('/api/users', usersRoutes);
app.use('/api/audit', auditRoutes);
app.use('/api/translations', translationRoutes);
app.use('/api/influences', influenceRoutes);
app.use('/api/movement-links', require('./routes/movement-links'));
app.use('/api/tours', tourRoutes);
app.use(
'/images',
+11
View File
@@ -18,6 +18,8 @@ const INCREMENTAL_MIGRATIONS = [
'migrate-tours.sql',
'migrate-curator-notes.sql',
'migrate-user-roles.sql',
'migrate-movement-links.sql',
'migrate-movement-link-delete-votes.sql',
];
const BOOTSTRAP_ADMIN_PERMISSIONS = [
@@ -80,6 +82,15 @@ async function migrate() {
console.log('Bootstrapping curator account (if needed) …');
await bootstrapCurator();
try {
const { seedDefaultsIfEmpty } = require('./movement-lineage-service');
console.log('Seeding movement lineage defaults (if empty) …');
const seed = await seedDefaultsIfEmpty();
console.log(` movement lineage: inserted=${seed.inserted}${seed.skipped ? ' (skipped)' : ''}`);
} catch (err) {
console.warn(` movement lineage seed skipped: ${err.message}`);
}
console.log('Database migration complete.');
}
+581
View File
@@ -0,0 +1,581 @@
/**
* Movement predecessor → successor lineage (timeline branches / Tree of Art).
* Curators manage rows with required proof URLs; defaults seed from scholarship.
*/
const pool = require('./db');
/** @typedef {{ sourceName: string, targetName: string, linkYearApprox: number | null, proofUrl: string, proofLabel: string }} DefaultLink */
/** @type {ReadonlyArray<DefaultLink>} */
const DEFAULT_MOVEMENT_LINEAGE = Object.freeze([
{
sourceName: 'Ancient Greek & Roman',
targetName: 'Byzantine',
linkYearApprox: 330,
proofUrl: 'https://www.metmuseum.org/toah/hd/byza/hd_byza.htm',
proofLabel: 'Met — Byzantine art',
},
{
sourceName: 'Byzantine',
targetName: 'Gothic',
linkYearApprox: 1150,
proofUrl: 'https://www.metmuseum.org/toah/hd/mgot/hd_mgot.htm',
proofLabel: 'Met — Gothic art',
},
{
sourceName: 'Gothic',
targetName: 'Early Renaissance',
linkYearApprox: 1400,
proofUrl: 'https://www.metmuseum.org/toah/hd/elar/hd_elar.htm',
proofLabel: 'Met — Florence & Early Renaissance',
},
{
sourceName: 'Northern Renaissance',
targetName: 'Early Renaissance',
linkYearApprox: 1420,
proofUrl: 'https://www.metmuseum.org/toah/hd/enet/hd_enet.htm',
proofLabel: 'Met — Early Netherlandish painting',
},
{
sourceName: 'Early Renaissance',
targetName: 'High Renaissance',
linkYearApprox: 1490,
proofUrl: 'https://www.metmuseum.org/toah/hd/hiren/hd_hiren.htm',
proofLabel: 'Met — High Renaissance',
},
{
sourceName: 'High Renaissance',
targetName: 'Mannerism',
linkYearApprox: 1520,
proofUrl: 'https://www.metmuseum.org/toah/hd/zino/hd_zino.htm',
proofLabel: 'Met — Mannerism',
},
{
sourceName: 'Mannerism',
targetName: 'Baroque',
linkYearApprox: 1600,
proofUrl: 'https://www.metmuseum.org/toah/hd/baro/hd_baro.htm',
proofLabel: 'Met — Baroque Rome',
},
{
sourceName: 'Baroque',
targetName: 'Rococo',
linkYearApprox: 1700,
proofUrl: 'https://www.metmuseum.org/toah/ht/09/euwf.html',
proofLabel: 'Met — France 1600–1800 (Rococo)',
},
{
sourceName: 'Rococo',
targetName: 'Neoclassicism',
linkYearApprox: 1760,
proofUrl: 'https://www.metmuseum.org/toah/ht/09/euwf.html',
proofLabel: 'Met — France 1600–1800 (Neoclassicism)',
},
{
sourceName: 'Neoclassicism',
targetName: 'Romanticism',
linkYearApprox: 1800,
proofUrl: 'https://www.metmuseum.org/toah/ht/10/euwf.html',
proofLabel: 'Met — France 1800–1900',
},
{
sourceName: 'Romanticism',
targetName: 'Realism',
linkYearApprox: 1840,
proofUrl: 'https://www.metmuseum.org/toah/hd/gust/hd_gust.htm',
proofLabel: 'Met — Gustave Courbet / Realism',
},
{
sourceName: 'Romanticism',
targetName: 'Impressionism',
linkYearApprox: 1860,
proofUrl: 'https://www.theartstory.org/movement/impressionism/',
proofLabel: 'TheArtStory — Impressionism',
},
{
sourceName: 'Realism',
targetName: 'Impressionism',
linkYearApprox: 1860,
proofUrl: 'https://www.metmuseum.org/toah/hd/imml/hd_imml.htm',
proofLabel: 'Met — Impressionism',
},
{
sourceName: 'Impressionism',
targetName: 'Post-Impressionism',
linkYearApprox: 1880,
proofUrl: 'https://www.theartstory.org/movement/post-impressionism/',
proofLabel: 'TheArtStory — Post-Impressionism',
},
{
sourceName: 'Post-Impressionism',
targetName: 'Symbolism',
linkYearApprox: 1880,
proofUrl: 'https://www.theartstory.org/movement/symbolism/',
proofLabel: 'TheArtStory — Symbolism',
},
{
sourceName: 'Post-Impressionism',
targetName: 'Fauvism',
linkYearApprox: 1904,
proofUrl: 'https://www.theartstory.org/movement/fauvism/',
proofLabel: 'TheArtStory — Fauvism',
},
{
sourceName: 'Post-Impressionism',
targetName: 'Expressionism',
linkYearApprox: 1905,
proofUrl: 'https://www.theartstory.org/movement/expressionism/',
proofLabel: 'TheArtStory — Expressionism',
},
{
sourceName: 'Post-Impressionism',
targetName: 'Cubism',
linkYearApprox: 1907,
proofUrl: 'https://www.metmuseum.org/essays/cubism',
proofLabel: 'Met — Cubism',
},
{
sourceName: 'Symbolism',
targetName: 'Art Nouveau',
linkYearApprox: 1890,
proofUrl: 'https://www.theartstory.org/movement/art-nouveau/',
proofLabel: 'TheArtStory — Art Nouveau',
},
{
sourceName: 'Fauvism',
targetName: 'Cubism',
linkYearApprox: 1907,
proofUrl:
'https://www.christies.com/en/stories/from-fauvism-to-cubism-bc026df2c6774575938a4e26cacb8259',
proofLabel: "Christie's — From Fauvism to Cubism",
},
{
sourceName: 'Cubism',
targetName: 'Futurism',
linkYearApprox: 1909,
proofUrl: 'https://www.theartstory.org/movement/futurism/',
proofLabel: 'TheArtStory — Futurism',
},
{
sourceName: 'Cubism',
targetName: 'Suprematism',
linkYearApprox: 1913,
proofUrl: 'https://www.theartstory.org/movement/suprematism/',
proofLabel: 'TheArtStory — Suprematism',
},
{
sourceName: 'Cubism',
targetName: 'Constructivism',
linkYearApprox: 1913,
proofUrl: 'https://www.theartstory.org/movement/constructivism/',
proofLabel: 'TheArtStory — Constructivism',
},
{
sourceName: 'Expressionism',
targetName: 'Abstract Expressionism',
linkYearApprox: 1940,
proofUrl: 'https://www.theartstory.org/movement/abstract-expressionism/',
proofLabel: 'TheArtStory — Abstract Expressionism',
},
{
sourceName: 'Dada',
targetName: 'Surrealism',
linkYearApprox: 1924,
proofUrl: 'https://www.theartstory.org/movement/dada/',
proofLabel: 'TheArtStory — Dada',
},
{
sourceName: 'Surrealism',
targetName: 'Abstract Expressionism',
linkYearApprox: 1940,
proofUrl: 'https://www.theartstory.org/artist/breton-andre/',
proofLabel: 'TheArtStory — André Breton',
},
{
sourceName: 'Abstract Expressionism',
targetName: 'Pop Art',
linkYearApprox: 1955,
proofUrl: 'https://www.theartstory.org/movement/pop-art/',
proofLabel: 'TheArtStory — Pop Art',
},
]);
function isValidProofUrl(value) {
if (typeof value !== 'string') return false;
const trimmed = value.trim();
if (!trimmed) return false;
try {
const u = new URL(trimmed);
return u.protocol === 'http:' || u.protocol === 'https:';
} catch {
return false;
}
}
function parsePositiveId(value) {
const n = Number(value);
return Number.isFinite(n) && n > 0 ? Math.floor(n) : null;
}
function parseOptionalYear(value) {
if (value == null || value === '') return null;
const n = Number(value);
if (!Number.isFinite(n)) return NaN;
return Math.trunc(n);
}
/**
* @param {unknown} body
* @returns {{ ok: true, value: object } | { ok: false, error: string }}
*/
function validateCreatePayload(body) {
const raw = body && typeof body === 'object' ? body : {};
const sourceMovementId = parsePositiveId(raw.sourceMovementId);
const targetMovementId = parsePositiveId(raw.targetMovementId);
if (!sourceMovementId) {
return { ok: false, error: 'sourceMovementId is required' };
}
if (!targetMovementId) {
return { ok: false, error: 'targetMovementId is required' };
}
if (sourceMovementId === targetMovementId) {
return { ok: false, error: 'source and target must be different movements' };
}
const proofUrlRaw = typeof raw.proofUrl === 'string' ? raw.proofUrl.trim() : '';
if (!isValidProofUrl(proofUrlRaw)) {
return { ok: false, error: 'proofUrl must be a valid http(s) URL (required)' };
}
const linkYearApprox = parseOptionalYear(raw.linkYearApprox);
if (Number.isNaN(linkYearApprox)) {
return { ok: false, error: 'linkYearApprox must be a number when provided' };
}
const proofLabel =
typeof raw.proofLabel === 'string' && raw.proofLabel.trim()
? raw.proofLabel.trim()
: null;
const notes =
typeof raw.notes === 'string' && raw.notes.trim() ? raw.notes.trim() : null;
return {
ok: true,
value: {
sourceMovementId,
targetMovementId,
proofUrl: proofUrlRaw,
proofLabel,
linkYearApprox,
notes,
},
};
}
const MIN_DELETE_VOTE_REASON_LEN = 10;
/**
* @param {unknown} body
* @returns {{ ok: true, value: { reason: string } } | { ok: false, error: string }}
*/
function validateDeleteVotePayload(body) {
const raw = body && typeof body === 'object' ? body : {};
const reason = typeof raw.reason === 'string' ? raw.reason.trim() : '';
if (!reason) {
return { ok: false, error: 'reason is required' };
}
if (reason.length < MIN_DELETE_VOTE_REASON_LEN) {
return {
ok: false,
error: `reason must be at least ${MIN_DELETE_VOTE_REASON_LEN} characters`,
};
}
return { ok: true, value: { reason } };
}
const LIST_SELECT = `
SELECT ml.id,
ml.source_movement_id,
ml.target_movement_id,
ml.link_year_approx,
ml.proof_url,
ml.proof_label,
ml.notes,
ml.created_at,
ml.updated_at,
s.name AS source_name,
s.start_year AS source_start_year,
t.name AS target_name,
t.start_year AS target_start_year,
(
SELECT COUNT(*)::int
FROM movement_lineage_delete_votes v
WHERE v.link_id = ml.id
) AS delete_vote_count
FROM movement_lineage_links ml
JOIN art_movements s ON s.id = ml.source_movement_id
JOIN art_movements t ON t.id = ml.target_movement_id
`;
async function listLinks({ q, limit = 500, offset = 0, viewerUserId = null } = {}) {
const params = [];
const where = [];
if (q && typeof q === 'string' && q.trim()) {
params.push(`%${q.trim()}%`);
const i = params.length;
where.push(`(
s.name ILIKE $${i} OR t.name ILIKE $${i}
OR ml.proof_url ILIKE $${i} OR COALESCE(ml.proof_label, '') ILIKE $${i}
OR COALESCE(ml.notes, '') ILIKE $${i}
)`);
}
const whereSql = where.length ? `WHERE ${where.join(' AND ')}` : '';
params.push(Math.min(1000, Math.max(1, Number(limit) || 500)));
const limitIdx = params.length;
params.push(Math.max(0, Number(offset) || 0));
const offsetIdx = params.length;
const { rows } = await pool.query(
`${LIST_SELECT}
${whereSql}
ORDER BY COALESCE(ml.link_year_approx, t.start_year) ASC NULLS LAST, s.name, t.name
LIMIT $${limitIdx} OFFSET $${offsetIdx}`,
params,
);
const countParams = params.slice(0, -2);
const { rows: countRows } = await pool.query(
`SELECT COUNT(*)::int AS n
FROM movement_lineage_links ml
JOIN art_movements s ON s.id = ml.source_movement_id
JOIN art_movements t ON t.id = ml.target_movement_id
${whereSql}`,
countParams,
);
let items = rows;
if (viewerUserId && rows.length) {
const ids = rows.map((r) => r.id);
const { rows: myVotes } = await pool.query(
`SELECT link_id FROM movement_lineage_delete_votes
WHERE user_id = $1 AND link_id = ANY($2::int[])`,
[viewerUserId, ids],
);
const voted = new Set(myVotes.map((v) => v.link_id));
items = rows.map((r) => ({
...r,
viewer_has_delete_vote: voted.has(r.id),
}));
} else {
items = rows.map((r) => ({ ...r, viewer_has_delete_vote: false }));
}
return { items, total: countRows[0]?.n ?? 0 };
}
async function createLink(payload, { userId } = {}) {
const validated = validateCreatePayload(payload);
if (!validated.ok) {
const err = new Error(validated.error);
err.status = 400;
throw err;
}
const v = validated.value;
const movements = await pool.query(
`SELECT id FROM art_movements WHERE id = ANY($1::int[])`,
[[v.sourceMovementId, v.targetMovementId]],
);
if (movements.rows.length !== 2) {
const err = new Error('source or target movement not found');
err.status = 400;
throw err;
}
try {
const { rows } = await pool.query(
`INSERT INTO movement_lineage_links
(source_movement_id, target_movement_id, link_year_approx, proof_url, proof_label, notes, created_by)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id`,
[
v.sourceMovementId,
v.targetMovementId,
v.linkYearApprox,
v.proofUrl,
v.proofLabel,
v.notes,
userId || null,
],
);
const { rows: full } = await pool.query(`${LIST_SELECT} WHERE ml.id = $1`, [rows[0].id]);
return full[0];
} catch (e) {
if (e && e.code === '23505') {
const err = new Error('A link between these movements already exists');
err.status = 409;
throw err;
}
throw e;
}
}
async function deleteLink(id) {
const linkId = parsePositiveId(id);
if (!linkId) {
const err = new Error('Invalid link id');
err.status = 400;
throw err;
}
const { rows } = await pool.query(
`DELETE FROM movement_lineage_links WHERE id = $1 RETURNING id`,
[linkId],
);
if (!rows.length) {
const err = new Error('Link not found');
err.status = 404;
throw err;
}
return { id: linkId };
}
async function listDeleteVotes(linkId) {
const id = parsePositiveId(linkId);
if (!id) {
const err = new Error('Invalid link id');
err.status = 400;
throw err;
}
const { rows: linkRows } = await pool.query(
`SELECT id FROM movement_lineage_links WHERE id = $1`,
[id],
);
if (!linkRows.length) {
const err = new Error('Link not found');
err.status = 404;
throw err;
}
const { rows } = await pool.query(
`SELECT v.id, v.link_id, v.user_id, v.reason, v.created_at, u.username
FROM movement_lineage_delete_votes v
JOIN users u ON u.id = v.user_id
WHERE v.link_id = $1
ORDER BY v.created_at ASC`,
[id],
);
return { linkId: id, votes: rows };
}
async function castDeleteVote(linkId, body, { userId }) {
const id = parsePositiveId(linkId);
if (!id) {
const err = new Error('Invalid link id');
err.status = 400;
throw err;
}
if (!userId) {
const err = new Error('Curator login required');
err.status = 401;
throw err;
}
const validated = validateDeleteVotePayload(body);
if (!validated.ok) {
const err = new Error(validated.error);
err.status = 400;
throw err;
}
const { rows: linkRows } = await pool.query(
`SELECT id FROM movement_lineage_links WHERE id = $1`,
[id],
);
if (!linkRows.length) {
const err = new Error('Link not found');
err.status = 404;
throw err;
}
try {
const { rows } = await pool.query(
`INSERT INTO movement_lineage_delete_votes (link_id, user_id, reason)
VALUES ($1, $2, $3)
RETURNING id, link_id, user_id, reason, created_at`,
[id, userId, validated.value.reason],
);
return rows[0];
} catch (e) {
if (e && e.code === '23505') {
const err = new Error('You already voted to delete this link');
err.status = 409;
throw err;
}
throw e;
}
}
async function withdrawDeleteVote(linkId, { userId }) {
const id = parsePositiveId(linkId);
if (!id || !userId) {
const err = new Error('Invalid request');
err.status = 400;
throw err;
}
const { rows } = await pool.query(
`DELETE FROM movement_lineage_delete_votes
WHERE link_id = $1 AND user_id = $2
RETURNING id`,
[id, userId],
);
if (!rows.length) {
const err = new Error('Vote not found');
err.status = 404;
throw err;
}
return { id: rows[0].id, linkId: id };
}
/** Insert default scholarship links when the table is empty and movements exist. */
async function seedDefaultsIfEmpty() {
const { rows: countRows } = await pool.query(
`SELECT COUNT(*)::int AS n FROM movement_lineage_links`,
);
if ((countRows[0]?.n ?? 0) > 0) return { inserted: 0, skipped: true };
const { rows: movements } = await pool.query(`SELECT id, name FROM art_movements`);
if (!movements.length) return { inserted: 0, skipped: true, reason: 'no movements' };
const byName = new Map(movements.map((m) => [m.name, m.id]));
let inserted = 0;
let missing = 0;
for (const row of DEFAULT_MOVEMENT_LINEAGE) {
const sourceId = byName.get(row.sourceName);
const targetId = byName.get(row.targetName);
if (!sourceId || !targetId) {
missing += 1;
continue;
}
const result = await pool.query(
`INSERT INTO movement_lineage_links
(source_movement_id, target_movement_id, link_year_approx, proof_url, proof_label)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (source_movement_id, target_movement_id) DO NOTHING
RETURNING id`,
[sourceId, targetId, row.linkYearApprox, row.proofUrl, row.proofLabel],
);
if (result.rows.length) inserted += 1;
}
return { inserted, missing };
}
module.exports = {
DEFAULT_MOVEMENT_LINEAGE,
MIN_DELETE_VOTE_REASON_LEN,
isValidProofUrl,
validateCreatePayload,
validateDeleteVotePayload,
listLinks,
createLink,
deleteLink,
listDeleteVotes,
castDeleteVote,
withdrawDeleteVote,
seedDefaultsIfEmpty,
};
+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;