Files
Art-gallery/server/routes/influences.js
T
Danila KhodjaefandCursor 0466b77328 Add curator roles/permissions with Users admin, and fix lineage branch joins.
Staff accounts use admin/curator roles and fine-grained flags; transitions connect source-to-target with color gradients and stream cutout masks so overlaps stay seamless.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 18:04:17 +03:00

644 lines
21 KiB
JavaScript

const express = require('express');
const pool = require('../db');
const { requirePermission } = require('../middleware/auth');
const { logCuratorAction } = require('../audit-log');
const {
COLUMN_ROLES,
PRESETS,
parseBuffer,
suggestPreset,
autoMapColumns,
buildPreview,
commitProposals,
hashBuffer,
hashImportPayload,
findPriorImport,
} = require('../influence-import-service');
const router = express.Router();
const MAX_UPLOAD_BYTES = 10 * 1024 * 1024;
function parseId(value) {
const n = Number(value);
return Number.isFinite(n) && n > 0 ? Math.floor(n) : null;
}
router.get('/presets', requirePermission('influences'), (_req, res) => {
res.json({
roles: COLUMN_ROLES,
presets: Object.values(PRESETS).map((p) => ({
id: p.id,
label: p.label,
mapping: p.mapping,
})),
});
});
router.get('/', requirePermission('influences'), async (req, res) => {
try {
const artistId = parseId(req.query.artistId);
const paintingId = parseId(req.query.paintingId);
const q = typeof req.query.q === 'string' ? req.query.q.trim() : '';
const limit = Math.min(500, Math.max(1, Number(req.query.limit) || 100));
const offset = Math.max(0, Number(req.query.offset) || 0);
const params = [];
const where = [];
if (artistId) {
params.push(artistId);
where.push(`a.id = $${params.length}`);
}
if (paintingId) {
params.push(paintingId);
where.push(`pis.painting_id = $${params.length}`);
}
if (q) {
params.push(`%${q}%`);
const i = params.length;
where.push(`(
p.title ILIKE $${i} OR a.name ILIKE $${i}
OR sa.name ILIKE $${i} OR sp.title ILIKE $${i} OR m.name ILIKE $${i}
OR pis.notes ILIKE $${i} OR pis.source ILIKE $${i}
)`);
}
const whereSql = where.length ? `WHERE ${where.join(' AND ')}` : '';
params.push(limit);
const limitIdx = params.length;
params.push(offset);
const offsetIdx = params.length;
const { rows } = await pool.query(
`SELECT pis.id, pis.painting_id, pis.source_type,
pis.source_painting_id, pis.source_artist_id, pis.source_movement_id,
pis.notes, pis.source, pis.source_url, pis.aspects, pis.quote,
pis.confidence, pis.discovered_via, pis.updated_at,
p.title AS painting_title, p.year AS painting_year,
a.id AS artist_id, a.name AS artist_name,
sp.title AS source_painting_title,
sa.name AS source_artist_name,
m.name AS source_movement_name
FROM painting_influence_sources pis
JOIN paintings p ON p.id = pis.painting_id
JOIN artists a ON a.id = p.artist_id
LEFT JOIN paintings sp ON sp.id = pis.source_painting_id
LEFT JOIN artists sa ON sa.id = pis.source_artist_id
LEFT JOIN art_movements m ON m.id = pis.source_movement_id
${whereSql}
ORDER BY pis.id DESC
LIMIT $${limitIdx} OFFSET $${offsetIdx}`,
params,
);
const countParams = params.slice(0, -2);
const { rows: countRows } = await pool.query(
`SELECT COUNT(*)::int AS n
FROM painting_influence_sources pis
JOIN paintings p ON p.id = pis.painting_id
JOIN artists a ON a.id = p.artist_id
LEFT JOIN paintings sp ON sp.id = pis.source_painting_id
LEFT JOIN artists sa ON sa.id = pis.source_artist_id
LEFT JOIN art_movements m ON m.id = pis.source_movement_id
${whereSql}`,
countParams,
);
const items = rows.map((r) => ({
id: r.id,
paintingId: r.painting_id,
paintingTitle: r.painting_title,
paintingYear: r.painting_year,
artistId: r.artist_id,
artistName: r.artist_name,
sourceType: r.source_type,
sourcePaintingId: r.source_painting_id,
sourceArtistId: r.source_artist_id,
sourceMovementId: r.source_movement_id,
sourceLabel:
r.source_type === 'painting'
? r.source_painting_title
: r.source_type === 'artist'
? r.source_artist_name
: r.source_movement_name,
notes: r.notes,
source: r.source,
sourceUrl: r.source_url,
aspects: r.aspects,
quote: r.quote,
confidence: r.confidence,
discoveredVia: r.discovered_via,
updatedAt: r.updated_at,
}));
res.json({ items, total: countRows[0]?.n || 0, limit, offset });
} catch (err) {
console.error('Influences list error:', err.message);
res.status(500).json({ error: 'Failed to list influences' });
}
});
router.get('/graph', requirePermission('influences'), async (req, res) => {
try {
const artistId = parseId(req.query.artistId);
const paintingId = parseId(req.query.paintingId);
if (!artistId && !paintingId) {
return res.status(400).json({ error: 'artistId or paintingId required' });
}
let paintingIds = [];
let focusArtistId = artistId;
let focusLabel = '';
if (paintingId) {
const { rows } = await pool.query(
`SELECT p.id, p.title, p.artist_id, a.name AS artist_name
FROM paintings p JOIN artists a ON a.id = p.artist_id WHERE p.id = $1`,
[paintingId],
);
if (!rows[0]) return res.status(404).json({ error: 'Painting not found' });
paintingIds = [paintingId];
focusArtistId = rows[0].artist_id;
focusLabel = `${rows[0].artist_name}${rows[0].title}`;
} else {
const { rows: artistRows } = await pool.query('SELECT id, name FROM artists WHERE id = $1', [artistId]);
if (!artistRows[0]) return res.status(404).json({ error: 'Artist not found' });
focusLabel = artistRows[0].name;
const { rows: paints } = await pool.query('SELECT id FROM paintings WHERE artist_id = $1', [artistId]);
paintingIds = paints.map((p) => p.id);
}
if (!paintingIds.length) {
return res.json({ focus: { artistId: focusArtistId, label: focusLabel }, nodes: [], edges: [] });
}
const { rows: outgoing } = await pool.query(
`SELECT pis.id, pis.painting_id, pis.source_type,
pis.source_painting_id, pis.source_artist_id, pis.source_movement_id,
p.title AS painting_title, a.name AS artist_name,
sp.title AS source_painting_title, spa.name AS source_painting_artist,
sa.name AS source_artist_name, m.name AS source_movement_name
FROM painting_influence_sources pis
JOIN paintings p ON p.id = pis.painting_id
JOIN artists a ON a.id = p.artist_id
LEFT JOIN paintings sp ON sp.id = pis.source_painting_id
LEFT JOIN artists spa ON spa.id = sp.artist_id
LEFT JOIN artists sa ON sa.id = pis.source_artist_id
LEFT JOIN art_movements m ON m.id = pis.source_movement_id
WHERE pis.painting_id = ANY($1::int[])`,
[paintingIds],
);
const { rows: incoming } = await pool.query(
`SELECT pis.id, pis.painting_id, pis.source_type,
pis.source_painting_id, pis.source_artist_id, pis.source_movement_id,
p.title AS painting_title, a.name AS artist_name, a.id AS subject_artist_id
FROM painting_influence_sources pis
JOIN paintings p ON p.id = pis.painting_id
JOIN artists a ON a.id = p.artist_id
WHERE (
(pis.source_type = 'artist' AND pis.source_artist_id = $1)
OR (pis.source_type = 'painting' AND pis.source_painting_id = ANY($2::int[]))
)`,
[focusArtistId, paintingIds],
);
const nodes = new Map();
const addNode = (id, type, label, meta = {}) => {
if (!nodes.has(id)) nodes.set(id, { id, type, label, ...meta });
};
addNode(`artist:${focusArtistId}`, 'artist', focusLabel, { artistId: focusArtistId, focus: true });
const edges = [];
for (const r of outgoing) {
let targetId;
let targetLabel;
let targetType = r.source_type;
if (r.source_type === 'artist') {
targetId = `artist:${r.source_artist_id}`;
targetLabel = r.source_artist_name;
addNode(targetId, 'artist', targetLabel, { artistId: r.source_artist_id });
} else if (r.source_type === 'movement') {
targetId = `movement:${r.source_movement_id}`;
targetLabel = r.source_movement_name;
addNode(targetId, 'movement', targetLabel, { movementId: r.source_movement_id });
} else {
targetId = `painting:${r.source_painting_id}`;
targetLabel = `${r.source_painting_artist}${r.source_painting_title}`;
addNode(targetId, 'painting', targetLabel, { paintingId: r.source_painting_id });
}
edges.push({
id: r.id,
from: `artist:${focusArtistId}`,
to: targetId,
direction: 'influenced_by',
label: r.painting_title,
});
}
for (const r of incoming) {
const fromId = `artist:${r.subject_artist_id}`;
addNode(fromId, 'artist', r.artist_name, { artistId: r.subject_artist_id });
edges.push({
id: r.id,
from: fromId,
to: `artist:${focusArtistId}`,
direction: 'influenced',
label: r.painting_title,
});
}
res.json({
focus: { artistId: focusArtistId, paintingId: paintingId || null, label: focusLabel },
nodes: [...nodes.values()],
edges,
});
} catch (err) {
console.error('Influences graph error:', err.message);
res.status(500).json({ error: 'Failed to load influence graph' });
}
});
router.post('/', requirePermission('influences'), async (req, res) => {
try {
const {
paintingId,
sourceType,
sourcePaintingId,
sourceArtistId,
sourceMovementId,
notes,
source,
sourceUrl,
aspects,
quote,
confidence,
} = req.body || {};
const pid = parseId(paintingId);
if (!pid) return res.status(400).json({ error: 'paintingId required' });
if (!['painting', 'artist', 'movement'].includes(sourceType)) {
return res.status(400).json({ error: 'sourceType must be painting, artist, or movement' });
}
const sourceIds = {
source_painting_id: sourceType === 'painting' ? parseId(sourcePaintingId) : null,
source_artist_id: sourceType === 'artist' ? parseId(sourceArtistId) : null,
source_movement_id: sourceType === 'movement' ? parseId(sourceMovementId) : null,
};
if (sourceType === 'painting' && !sourceIds.source_painting_id) {
return res.status(400).json({ error: 'sourcePaintingId required' });
}
if (sourceType === 'artist' && !sourceIds.source_artist_id) {
return res.status(400).json({ error: 'sourceArtistId required' });
}
if (sourceType === 'movement' && !sourceIds.source_movement_id) {
return res.status(400).json({ error: 'sourceMovementId required' });
}
const edge = {
notes: notes || null,
source: source || null,
source_url: sourceUrl || null,
aspects: aspects || null,
quote: quote || null,
confidence: confidence || 'curated',
discovered_via: 'curator-ui',
};
if (sourceType === 'painting') {
await pool.query(
`INSERT INTO painting_influences
(painting_id, influenced_by_painting_id, notes, source, aspects, quote, source_url)
VALUES ($1,$2,$3,$4,$5,$6,$7)
ON CONFLICT (painting_id, influenced_by_painting_id) DO NOTHING`,
[pid, sourceIds.source_painting_id, edge.notes, edge.source, edge.aspects, edge.quote, edge.source_url],
);
}
const result = await pool.query(
`INSERT INTO painting_influence_sources (
painting_id, source_type, source_painting_id, source_artist_id, source_movement_id,
notes, source, aspects, quote, source_url, discovered_via, confidence
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)
ON CONFLICT DO NOTHING
RETURNING id`,
[
pid,
sourceType,
sourceIds.source_painting_id,
sourceIds.source_artist_id,
sourceIds.source_movement_id,
edge.notes,
edge.source,
edge.aspects,
edge.quote,
edge.source_url,
edge.discovered_via,
edge.confidence,
],
);
if (!result.rows[0]) {
return res.status(409).json({ error: 'Influence edge already exists' });
}
await logCuratorAction({
userId: req.curatorUser.id,
action: 'influence.create',
resourceType: 'influence_source',
resourceId: result.rows[0].id,
details: { paintingId: pid, sourceType, ...sourceIds },
req,
});
res.status(201).json({ id: result.rows[0].id });
} catch (err) {
console.error('Influence create error:', err.message);
res.status(500).json({ error: 'Failed to create influence' });
}
});
router.patch('/:id', requirePermission('influences'), async (req, res) => {
try {
const id = parseId(req.params.id);
if (!id) return res.status(400).json({ error: 'Invalid id' });
const {
notes,
source,
sourceUrl,
aspects,
quote,
confidence,
sourceType,
sourcePaintingId,
sourceArtistId,
sourceMovementId,
} = req.body || {};
const { rows: existing } = await pool.query(
'SELECT * FROM painting_influence_sources WHERE id = $1',
[id],
);
if (!existing[0]) return res.status(404).json({ error: 'Not found' });
const nextType = sourceType || existing[0].source_type;
const nextPainting =
sourceType === 'painting'
? parseId(sourcePaintingId)
: sourceType
? null
: existing[0].source_painting_id;
const nextArtist =
sourceType === 'artist'
? parseId(sourceArtistId)
: sourceType
? null
: existing[0].source_artist_id;
const nextMovement =
sourceType === 'movement'
? parseId(sourceMovementId)
: sourceType
? null
: existing[0].source_movement_id;
const result = await pool.query(
`UPDATE painting_influence_sources SET
source_type = $2,
source_painting_id = $3,
source_artist_id = $4,
source_movement_id = $5,
notes = COALESCE($6, notes),
source = COALESCE($7, source),
source_url = COALESCE($8, source_url),
aspects = COALESCE($9, aspects),
quote = COALESCE($10, quote),
confidence = COALESCE($11, confidence)
WHERE id = $1
RETURNING id`,
[
id,
nextType,
nextPainting,
nextArtist,
nextMovement,
notes !== undefined ? notes : null,
source !== undefined ? source : null,
sourceUrl !== undefined ? sourceUrl : null,
aspects !== undefined ? aspects : null,
quote !== undefined ? quote : null,
confidence !== undefined ? confidence : null,
],
);
await logCuratorAction({
userId: req.curatorUser.id,
action: 'influence.update',
resourceType: 'influence_source',
resourceId: id,
details: { sourceType: nextType },
req,
});
res.json({ id: result.rows[0].id });
} catch (err) {
console.error('Influence update error:', err.message);
res.status(500).json({ error: 'Failed to update influence' });
}
});
router.delete('/:id', requirePermission('influences'), async (req, res) => {
try {
const id = parseId(req.params.id);
if (!id) return res.status(400).json({ error: 'Invalid id' });
const { rows } = await pool.query(
'SELECT * FROM painting_influence_sources WHERE id = $1',
[id],
);
if (!rows[0]) return res.status(404).json({ error: 'Not found' });
const row = rows[0];
if (row.source_type === 'painting' && row.source_painting_id) {
await pool.query(
`DELETE FROM painting_influences
WHERE painting_id = $1 AND influenced_by_painting_id = $2`,
[row.painting_id, row.source_painting_id],
);
}
await pool.query('DELETE FROM painting_influence_sources WHERE id = $1', [id]);
await logCuratorAction({
userId: req.curatorUser.id,
action: 'influence.delete',
resourceType: 'influence_source',
resourceId: id,
details: {
paintingId: row.painting_id,
sourceType: row.source_type,
},
req,
});
res.json({ ok: true });
} catch (err) {
console.error('Influence delete error:', err.message);
res.status(500).json({ error: 'Failed to delete influence' });
}
});
router.post('/import/parse', requirePermission('influences'), async (req, res) => {
try {
const { filename, sheet, contentBase64, content } = req.body || {};
let buffer;
if (typeof contentBase64 === 'string' && contentBase64.length) {
buffer = Buffer.from(contentBase64, 'base64');
} else if (typeof content === 'string' && content.length) {
buffer = Buffer.from(content, 'utf8');
} else {
return res.status(400).json({ error: 'contentBase64 or content required' });
}
if (!buffer.length) return res.status(400).json({ error: 'Empty file' });
if (buffer.length > MAX_UPLOAD_BYTES) {
return res.status(413).json({ error: 'File too large (max 10 MB)' });
}
const name = typeof filename === 'string' && filename ? filename : 'upload.csv';
const sheetName = typeof sheet === 'string' ? sheet : undefined;
const parsed = parseBuffer(buffer, name, { sheet: sheetName });
const contentHash = hashBuffer(buffer);
const presetId = suggestPreset(parsed.columns);
const preset = PRESETS[presetId] || PRESETS.custom;
const mapping = Object.keys(preset.mapping).length
? {
...autoMapColumns(parsed.columns),
...Object.fromEntries(
Object.entries(preset.mapping).filter(([col]) => parsed.columns.includes(col)),
),
}
: autoMapColumns(parsed.columns);
const includeAll = parsed.rowCount <= 2000;
const rowsForHash = includeAll ? parsed.rows : parsed.rows;
const payloadHash = hashImportPayload(rowsForHash || [], mapping);
const priorImport = await findPriorImport({ contentHash, payloadHash });
res.json({
filename: name,
format: parsed.format,
sheets: parsed.sheets,
sheet: parsed.sheet,
columns: parsed.columns,
rowCount: parsed.rowCount,
sampleRows: parsed.sampleRows,
rows: includeAll ? parsed.rows : undefined,
suggestedPreset: presetId,
suggestedMapping: mapping,
roles: COLUMN_ROLES,
presets: Object.values(PRESETS).map((p) => ({ id: p.id, label: p.label, mapping: p.mapping })),
contentHash,
payloadHash,
alreadyImported: Boolean(priorImport),
priorImport,
});
} catch (err) {
console.error('Influence import parse error:', err.message);
res.status(400).json({ error: err.message || 'Failed to parse file' });
}
});
router.post('/import/preview', requirePermission('influences'), async (req, res) => {
try {
const { rows, mapping, sourceLabel, contentHash, payloadHash } = req.body || {};
if (!Array.isArray(rows) || !rows.length) {
return res.status(400).json({ error: 'rows array required' });
}
if (!mapping || typeof mapping !== 'object') {
return res.status(400).json({ error: 'mapping object required' });
}
if (rows.length > 2000) {
return res.status(400).json({ error: 'Too many rows (max 2000 per preview)' });
}
const computedPayloadHash = payloadHash || hashImportPayload(rows, mapping);
const priorImport = await findPriorImport({
contentHash: contentHash || null,
payloadHash: computedPayloadHash,
});
const preview = await buildPreview(rows, mapping, { sourceLabel });
res.json({
...preview,
contentHash: contentHash || null,
payloadHash: computedPayloadHash,
alreadyImported: Boolean(priorImport),
priorImport,
});
} catch (err) {
console.error('Influence import preview error:', err.message);
res.status(500).json({ error: 'Failed to build preview' });
}
});
router.post('/import/commit', requirePermission('influences'), async (req, res) => {
try {
const { proposals, fileName, contentHash, payloadHash, force } = req.body || {};
if (!Array.isArray(proposals)) {
return res.status(400).json({ error: 'proposals array required' });
}
if (!force && (contentHash || payloadHash)) {
const priorImport = await findPriorImport({
contentHash: contentHash || null,
payloadHash: payloadHash || null,
});
if (priorImport) {
return res.status(409).json({
error: 'This file or identical data was already imported',
code: 'ALREADY_IMPORTED',
priorImport,
});
}
}
const result = await commitProposals(proposals, {
userId: req.curatorUser.id,
req,
fileName,
});
await logCuratorAction({
userId: req.curatorUser.id,
action: 'influence.import',
resourceType: 'influence_source',
resourceId: 0,
details: {
fileName: fileName || null,
inserted: result.inserted,
skipped: result.skipped,
attempted: result.attempted,
contentHash: contentHash || null,
payloadHash: payloadHash || null,
forced: Boolean(force),
},
req,
});
res.json({
...result,
contentHash: contentHash || null,
payloadHash: payloadHash || null,
});
} catch (err) {
console.error('Influence import commit error:', err.message);
res.status(500).json({ error: 'Failed to commit import' });
}
});
module.exports = router;