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>
582 lines
17 KiB
JavaScript
582 lines
17 KiB
JavaScript
/**
|
||
* 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,
|
||
};
|