Add curator Influences tool with import wizard, CRUD, and graph.
CSV/JSON/XLSX mapping wizard expands artist-level rows to all paintings, blocks duplicate file/data imports via content and payload hashes, and documents the workflow in influence-import.md. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
62d7ebbe6a
commit
48bd17e985
@@ -0,0 +1,643 @@
|
||||
const express = require('express');
|
||||
const pool = require('../db');
|
||||
const { requireCurator } = 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', requireCurator, (_req, res) => {
|
||||
res.json({
|
||||
roles: COLUMN_ROLES,
|
||||
presets: Object.values(PRESETS).map((p) => ({
|
||||
id: p.id,
|
||||
label: p.label,
|
||||
mapping: p.mapping,
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/', requireCurator, 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', requireCurator, 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('/', requireCurator, 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', requireCurator, 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', requireCurator, 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', requireCurator, 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', requireCurator, 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', requireCurator, 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;
|
||||
Reference in New Issue
Block a user