diff --git a/api/routes/mappings.js b/api/routes/mappings.js
index ad619be..9bcea92 100644
--- a/api/routes/mappings.js
+++ b/api/routes/mappings.js
@@ -54,6 +54,44 @@ module.exports = (pool) => {
}
});
+ // Search output field values across all mappings (for global remap)
+ router.get('/outputs', async (req, res, next) => {
+ try {
+ const { search = '' } = req.query;
+ const result = await pool.query(`SELECT * FROM search_mapping_outputs(${lit(search)})`);
+ res.json(result.rows);
+ } catch (err) {
+ next(err);
+ }
+ });
+
+ // Get individual mappings for a specific output field value
+ router.get('/outputs/:col/:val', async (req, res, next) => {
+ try {
+ const result = await pool.query(
+ `SELECT * FROM get_mappings_by_output_field(${lit(req.params.col)}, ${lit(req.params.val)})`
+ );
+ res.json(result.rows);
+ } catch (err) {
+ next(err);
+ }
+ });
+
+ // Remap a field value globally across all mappings
+ router.post('/remap-field', async (req, res, next) => {
+ try {
+ const { col, from_val, to_val } = req.body;
+ if (!col || from_val == null || to_val == null)
+ return res.status(400).json({ error: 'col, from_val, and to_val are required' });
+ const result = await pool.query(
+ `SELECT remap_output_field(${lit(col)}, ${lit(from_val)}, ${lit(to_val)}) AS updated`
+ );
+ res.json({ updated: result.rows[0].updated });
+ } catch (err) {
+ next(err);
+ }
+ });
+
// Get unmapped values
router.get('/source/:source_name/unmapped', async (req, res, next) => {
try {
diff --git a/database/queries/mappings.sql b/database/queries/mappings.sql
index 283925f..8db045b 100644
--- a/database/queries/mappings.sql
+++ b/database/queries/mappings.sql
@@ -221,3 +221,41 @@ RETURNS TABLE (col TEXT, val TEXT) AS $$
AND e.value <> ''
ORDER BY e.key, e.value;
$$ LANGUAGE sql STABLE;
+
+-- ── Remap output field values ─────────────────────────────────────────────────
+
+-- Search for distinct (field, value) pairs across all mapping outputs
+CREATE OR REPLACE FUNCTION search_mapping_outputs(p_search TEXT)
+RETURNS TABLE (col TEXT, val TEXT, mapping_count BIGINT) AS $$
+ SELECT e.key AS col, e.value AS val, COUNT(*) AS mapping_count
+ FROM dataflow.mappings m
+ CROSS JOIN LATERAL jsonb_each_text(m.output) AS e(key, value)
+ WHERE e.value ILIKE '%' || p_search || '%'
+ AND e.value IS NOT NULL
+ AND e.value <> ''
+ GROUP BY e.key, e.value
+ ORDER BY e.key, e.value;
+$$ LANGUAGE sql STABLE;
+
+-- Get individual mappings matching a specific output field value
+CREATE OR REPLACE FUNCTION get_mappings_by_output_field(p_col TEXT, p_val TEXT)
+RETURNS TABLE (id INT, source_name TEXT, rule_name TEXT, input_value JSONB, output JSONB) AS $$
+ SELECT m.id, m.source_name, m.rule_name, m.input_value, m.output
+ FROM dataflow.mappings m
+ WHERE m.output->>(p_col) = p_val
+ ORDER BY m.source_name, m.rule_name, m.input_value::text;
+$$ LANGUAGE sql STABLE;
+
+-- Replace a specific field value across all matching mappings
+CREATE OR REPLACE FUNCTION remap_output_field(p_col TEXT, p_from_val TEXT, p_to_val TEXT)
+RETURNS INTEGER AS $$
+DECLARE
+ updated_count INTEGER;
+BEGIN
+ UPDATE dataflow.mappings
+ SET output = jsonb_set(output, ARRAY[p_col], to_jsonb(p_to_val))
+ WHERE output->>(p_col) = p_from_val;
+ GET DIAGNOSTICS updated_count = ROW_COUNT;
+ RETURN updated_count;
+END;
+$$ LANGUAGE plpgsql;
diff --git a/ui/src/App.jsx b/ui/src/App.jsx
index 716572c..66e34b7 100644
--- a/ui/src/App.jsx
+++ b/ui/src/App.jsx
@@ -9,12 +9,14 @@ import Mappings from './pages/Mappings'
import Records from './pages/Records'
import Log from './pages/Log'
import Pivot from './pages/Pivot'
+import Remap from './pages/Remap'
const NAV = [
{ to: '/sources', label: 'Sources' },
{ to: '/import', label: 'Import' },
{ to: '/rules', label: 'Rules' },
{ to: '/mappings', label: 'Mappings' },
+ { to: '/remap', label: 'Remap' },
{ to: '/records', label: 'Records' },
{ to: '/pivot', label: 'Pivot' },
{ to: '/log', label: 'Log' },
@@ -144,6 +146,7 @@ export default function App() {
No matching output values found.
+ ) : ( + <> +| Field | +Value | +Mappings | +
|---|---|---|
| {r.col} | +{r.val} | +{r.mapping_count} | +
Loading…
+ ) : matches && matches.length > 0 && ( +| Source | +Rule | +Input | +Output | +
|---|---|---|---|
| {m.source_name} | +{m.rule_name} | ++ {typeof m.input_value === 'string' ? m.input_value : JSON.stringify(m.input_value)} + | ++ {Object.entries(m.output).map(([k, v]) => ( + + {k}: {v}{' '} + + ))} + | +