/** * 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} */ 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, };