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;