Files
Art-gallery/server/movement-lineage-service.js
T
Danila KhodjaefandCursor 1b760faf0d 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>
2026-09-08 14:37:20 +03:00

582 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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,
};