dataflow/api/routes/rules.js
Paul Trowbridge d495ef2fc5 Records filters, global picklist, autocomplete, and rule reprocess
- Records tab: regex filter bar (postgres ~*), add/remove filters, debounced,
  ANDed together; get_view_data gains p_filters JSONB param
- Global picklist: sources.global_picklist flag (default true) controls whether
  a source's mapped output values feed the cross-source autocomplete suggestion pool;
  toggle on Sources page; get_global_output_values() SQL function
- Mappings: replace native datalist with custom AutocompleteInput component —
  Alt+Down opens, Tab cycles, Enter selects, arrow keys navigate, Escape closes
- Rules: auto-reprocess source records when a rule is created or updated
- preview_rule: fix BIGINT/INT return type mismatch
- Stale get_import_log removed from sources.sql
- TSV export: fetch with auth headers instead of plain <a href> (fixes 401)
- + column button: more visible styling

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 16:28:26 -04:00

119 lines
5.1 KiB
JavaScript

/**
* Rules Routes
* Manage transformation rules
*/
const express = require('express');
const { lit } = require('../lib/sql');
module.exports = (pool) => {
const router = express.Router();
// List all rules for a source
router.get('/source/:source_name', async (req, res, next) => {
try {
const result = await pool.query(`SELECT * FROM list_rules(${lit(req.params.source_name)})`);
res.json(result.rows);
} catch (err) {
next(err);
}
});
// Preview an ad-hoc pattern against real records (no saved rule needed)
router.get('/preview', async (req, res, next) => {
try {
const { source, field, pattern, flags, function_type = 'extract', replace_value = '', limit = 20 } = req.query;
if (!source || !field || !pattern) {
return res.status(400).json({ error: 'source, field, and pattern are required' });
}
const result = await pool.query(
`SELECT * FROM preview_rule(${lit(source)}, ${lit(field)}, ${lit(pattern)}, ${lit(flags || '')}, ${lit(function_type)}, ${lit(replace_value)}, ${lit(parseInt(limit))})`
);
res.json(result.rows);
} catch (err) {
next(err);
}
});
// Test a rule against real records
router.get('/:id/test', async (req, res, next) => {
try {
const { limit = 20 } = req.query;
const result = await pool.query(
`SELECT * FROM test_rule(${lit(parseInt(req.params.id))}, ${lit(parseInt(limit))})`
);
if (result.rows.length === 0) return res.status(404).json({ error: 'Rule not found' });
res.json(result.rows[0]);
} catch (err) {
next(err);
}
});
// Get single rule
router.get('/:id', async (req, res, next) => {
try {
const result = await pool.query(`SELECT * FROM get_rule(${lit(parseInt(req.params.id))})`);
if (result.rows.length === 0) return res.status(404).json({ error: 'Rule not found' });
res.json(result.rows[0]);
} catch (err) {
next(err);
}
});
// Create rule
router.post('/', async (req, res, next) => {
try {
const { source_name, name, field, pattern, output_field, function_type, flags, replace_value, enabled, retain, sequence } = req.body;
if (!source_name || !name || !field || !pattern || !output_field) {
return res.status(400).json({ error: 'Missing required fields: source_name, name, field, pattern, output_field' });
}
if (function_type && !['extract', 'replace'].includes(function_type)) {
return res.status(400).json({ error: 'function_type must be "extract" or "replace"' });
}
const result = await pool.query(
`SELECT * FROM create_rule(${lit(source_name)}, ${lit(name)}, ${lit(field)}, ${lit(pattern)}, ${lit(output_field)}, ${lit(function_type || 'extract')}, ${lit(flags || '')}, ${lit(replace_value || '')}, ${lit(enabled !== false)}, ${lit(retain === true)}, ${lit(sequence || 0)})`
);
const rule = result.rows[0];
await pool.query(`SELECT reprocess_records(${lit(source_name)})`);
res.status(201).json(rule);
} catch (err) {
if (err.code === '23505') return res.status(409).json({ error: 'Rule already exists for this source' });
if (err.code === '23503') return res.status(404).json({ error: 'Source not found' });
next(err);
}
});
// Update rule
router.put('/:id', async (req, res, next) => {
try {
const { name, field, pattern, output_field, function_type, flags, replace_value, enabled, retain, sequence } = req.body;
if (function_type && !['extract', 'replace'].includes(function_type)) {
return res.status(400).json({ error: 'function_type must be "extract" or "replace"' });
}
const n = (v) => v !== undefined ? lit(v) : 'NULL';
const result = await pool.query(
`SELECT * FROM update_rule(${lit(parseInt(req.params.id))}, ${n(name)}, ${n(field)}, ${n(pattern)}, ${n(output_field)}, ${n(function_type)}, ${n(flags)}, ${n(replace_value)}, ${n(enabled)}, ${n(retain)}, ${n(sequence)})`
);
if (result.rows.length === 0) return res.status(404).json({ error: 'Rule not found' });
const rule = result.rows[0];
await pool.query(`SELECT reprocess_records(${lit(rule.source_name)})`);
res.json(rule);
} catch (err) {
next(err);
}
});
// Delete rule
router.delete('/:id', async (req, res, next) => {
try {
const result = await pool.query(`SELECT * FROM delete_rule(${lit(parseInt(req.params.id))})`);
if (result.rows.length === 0) return res.status(404).json({ error: 'Rule not found' });
res.json({ success: true, deleted: result.rows[0] });
} catch (err) {
next(err);
}
});
return router;
};