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>
644 lines
21 KiB
JavaScript
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;
|