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,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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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