dataflow/api/routes/mappings.js
Paul Trowbridge 291c665ed1 Consolidate all SQL into database/queries/, switch to literal SQL in routes
- Add database/queries/{sources,rules,mappings,records}.sql — one file per
  route, all business logic in PostgreSQL functions
- Replace parameterized queries in all four route files with lit()/jsonLit()
  literal interpolation for debuggability
- Add api/lib/sql.js with lit(), jsonLit(), arr() helpers
- Fix get_view_data to use json_agg (preserves column order) with subquery
  (guarantees sort order is respected before aggregation)
- Fix jsonLit() for JSONB params so plain strings become valid JSON
- Update manage.py option 3 to deploy database/queries/ instead of functions.sql
- Add SPEC.md covering architecture, philosophy, and manage.py

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 22:36:53 -04:00

238 lines
9.4 KiB
JavaScript

/**
* Mappings Routes
* Manage value mappings
*/
const express = require('express');
const multer = require('multer');
const { parse } = require('csv-parse/sync');
const { lit, jsonLit } = require('../lib/sql');
const upload = multer({ storage: multer.memoryStorage() });
const SYSTEM_COLS = new Set(['source_name', 'rule_name', 'input_value', 'record_count', 'sample']);
module.exports = (pool) => {
const router = express.Router();
// List all mappings for a source
router.get('/source/:source_name', async (req, res, next) => {
try {
const result = await pool.query(
`SELECT * FROM list_mappings(${lit(req.params.source_name)}, ${lit(req.query.rule_name || null)})`
);
res.json(result.rows);
} catch (err) {
next(err);
}
});
// Get record counts for existing mappings
router.get('/source/:source_name/counts', async (req, res, next) => {
try {
const result = await pool.query(
`SELECT * FROM get_mapping_counts(${lit(req.params.source_name)}, ${lit(req.query.rule_name || null)})`
);
res.json(result.rows);
} catch (err) {
next(err);
}
});
// Get unmapped values
router.get('/source/:source_name/unmapped', async (req, res, next) => {
try {
const result = await pool.query(
`SELECT * FROM get_unmapped_values(${lit(req.params.source_name)}, ${lit(req.query.rule_name || null)})`
);
res.json(result.rows);
} catch (err) {
next(err);
}
});
// Get all extracted values (mapped + unmapped) with counts
router.get('/source/:source_name/all-values', async (req, res, next) => {
try {
const result = await pool.query(
`SELECT * FROM get_all_values(${lit(req.params.source_name)}, ${lit(req.query.rule_name || null)})`
);
res.json(result.rows);
} catch (err) {
next(err);
}
});
// Export all extracted values as TSV
router.get('/source/:source_name/export.tsv', async (req, res, next) => {
try {
const { rule_name } = req.query;
const source_name = req.params.source_name;
const result = await pool.query(
`SELECT * FROM get_all_values(${lit(source_name)}, ${lit(rule_name || null)})`
);
const outputKeys = [];
for (const row of result.rows) {
for (const key of Object.keys(row.output || {})) {
if (!outputKeys.includes(key)) outputKeys.push(key);
}
}
const escape = (val) => String(val ?? '').replace(/\t/g, ' ');
const allCols = ['source_name', 'rule_name', 'input_value', 'record_count', ...outputKeys, 'sample'];
const dataRows = result.rows.map(row => {
const input_value = Array.isArray(row.extracted_value)
? JSON.stringify(row.extracted_value)
: String(row.extracted_value ?? '');
const sample = Array.isArray(row.sample)
? row.sample.map(r => r[row.source_field] ?? '').filter(Boolean).slice(0, 3).join(' | ')
: String(row.sample ?? '');
const r = { source_name, rule_name: row.rule_name, input_value, record_count: row.record_count, sample };
for (const key of outputKeys) r[key] = row.output?.[key] ?? '';
return r;
});
const tsv = [
allCols.map(escape).join('\t'),
...dataRows.map(r => allCols.map(c => escape(r[c])).join('\t'))
].join('\n');
res.setHeader('Content-Type', 'text/tab-separated-values');
res.setHeader('Content-Disposition', `attachment; filename="mappings_${source_name}.tsv"`);
res.send(tsv);
} catch (err) {
next(err);
}
});
// Import mappings from uploaded TSV
router.post('/source/:source_name/import-csv', upload.single('file'), async (req, res, next) => {
const client = await pool.connect();
try {
if (!req.file) {
return res.status(400).json({ error: 'No file uploaded. Send TSV as multipart field named "file".' });
}
const records = parse(req.file.buffer, { columns: true, skip_empty_lines: true, trim: true, delimiter: '\t' });
if (records.length === 0) return res.status(400).json({ error: 'File is empty.' });
const outputKeys = Object.keys(records[0]).filter(k => !SYSTEM_COLS.has(k));
const mappings = [];
for (const row of records) {
const { source_name, rule_name, input_value } = row;
const output = {};
for (const key of outputKeys) {
if (row[key] && row[key].trim() !== '') output[key] = row[key].trim();
}
if (Object.keys(output).length === 0) continue;
let parsedInput;
try { parsedInput = JSON.parse(input_value); } catch { parsedInput = input_value; }
mappings.push({ source_name, rule_name, input_value: parsedInput, output });
}
if (mappings.length === 0) return res.status(400).json({ error: 'No rows with output values filled in.' });
await client.query('BEGIN');
const results = [];
for (const { source_name, rule_name, input_value, output } of mappings) {
const result = await client.query(
`SELECT * FROM upsert_mapping(${lit(source_name)}, ${lit(rule_name)}, ${jsonLit(input_value)}, ${jsonLit(output)})`
);
results.push(result.rows[0]);
}
await client.query('COMMIT');
res.status(201).json({ count: results.length, mappings: results });
} catch (err) {
await client.query('ROLLBACK');
next(err);
} finally {
client.release();
}
});
// Get single mapping
router.get('/:id', async (req, res, next) => {
try {
const result = await pool.query(`SELECT * FROM get_mapping(${lit(parseInt(req.params.id))})`);
if (result.rows.length === 0) return res.status(404).json({ error: 'Mapping not found' });
res.json(result.rows[0]);
} catch (err) {
next(err);
}
});
// Create mapping
router.post('/', async (req, res, next) => {
try {
const { source_name, rule_name, input_value, output } = req.body;
if (!source_name || !rule_name || !input_value || !output) {
return res.status(400).json({ error: 'Missing required fields: source_name, rule_name, input_value, output' });
}
const result = await pool.query(
`SELECT * FROM create_mapping(${lit(source_name)}, ${lit(rule_name)}, ${jsonLit(input_value)}, ${jsonLit(output)})`
);
res.status(201).json(result.rows[0]);
} catch (err) {
if (err.code === '23505') return res.status(409).json({ error: 'Mapping already exists' });
if (err.code === '23503') return res.status(404).json({ error: 'Source or rule not found' });
next(err);
}
});
// Bulk create/update mappings
router.post('/bulk', async (req, res, next) => {
const client = await pool.connect();
try {
const { mappings } = req.body;
if (!Array.isArray(mappings)) return res.status(400).json({ error: 'Expected array of mappings' });
await client.query('BEGIN');
const results = [];
for (const { source_name, rule_name, input_value, output } of mappings) {
const result = await client.query(
`SELECT * FROM upsert_mapping(${lit(source_name)}, ${lit(rule_name)}, ${jsonLit(input_value)}, ${jsonLit(output)})`
);
results.push(result.rows[0]);
}
await client.query('COMMIT');
res.status(201).json({ count: results.length, mappings: results });
} catch (err) {
await client.query('ROLLBACK');
next(err);
} finally {
client.release();
}
});
// Update mapping
router.put('/:id', async (req, res, next) => {
try {
const { input_value, output } = req.body;
const result = await pool.query(
`SELECT * FROM update_mapping(${lit(parseInt(req.params.id))}, ${input_value != null ? jsonLit(input_value) : 'NULL'}, ${output != null ? jsonLit(output) : 'NULL'})`
);
if (result.rows.length === 0) return res.status(404).json({ error: 'Mapping not found' });
res.json(result.rows[0]);
} catch (err) {
next(err);
}
});
// Delete mapping
router.delete('/:id', async (req, res, next) => {
try {
const result = await pool.query(`SELECT * FROM delete_mapping(${lit(parseInt(req.params.id))})`);
if (result.rows.length === 0) return res.status(404).json({ error: 'Mapping not found' });
res.json({ success: true, deleted: result.rows[0].id });
} catch (err) {
next(err);
}
});
return router;
};