dataflow/api/routes/stacks.js
Paul Trowbridge f941c5ae4a Stacks: per-source amount/date fields, mapping grid UI, dfv view generation with source balance CTEs
- SQL: upsert_stack_source gains amount_field/date_field params; calibrate_balance queries dfv.{source} directly (no stack view needed); generate_stack_view builds per-source CTEs with source_balance, outer net_balance; information_schema check for missing columns
- API: pass amount_field/date_field through upsert route; calibrate accepts source_name
- UI: mapping grid table (rows=fields, cols=sources); per-source amount/date/sign in Sources section; auto-populate output columns on first source config; horizontal stack chips above full-width config panel; calibration auto-saves before opening, editable offset input

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 21:17:12 -04:00

125 lines
5.3 KiB
JavaScript

/**
* Stacks Routes
* Named unions of multiple sources with field mappings and running balance
*/
const express = require('express');
const { lit } = require('../lib/sql');
module.exports = (pool) => {
const router = express.Router();
// List all stacks
router.get('/', async (req, res, next) => {
try {
const result = await pool.query('SELECT * FROM list_stacks()');
res.json(result.rows);
} catch (err) { next(err); }
});
// Get single stack with sources
router.get('/:name', async (req, res, next) => {
try {
const result = await pool.query(`SELECT * FROM get_stack(${lit(req.params.name)})`);
if (!result.rows.length) return res.status(404).json({ error: 'Stack not found' });
res.json(result.rows[0]);
} catch (err) { next(err); }
});
// Create stack
router.post('/', async (req, res, next) => {
try {
const { name, label, fields, amount_field, date_field, balance_offset } = req.body;
if (!name) return res.status(400).json({ error: 'name is required' });
const result = await pool.query(
`SELECT * FROM create_stack(${lit(name)}, ${lit(label || null)}, ${lit(JSON.stringify(fields || []))}, ${lit(amount_field || null)}, ${lit(date_field || null)}, ${lit(balance_offset ?? 0)})`
);
res.status(201).json(result.rows[0]);
} catch (err) {
if (err.code === '23505') return res.status(409).json({ error: 'Stack already exists' });
next(err);
}
});
// Update stack
router.put('/:name', async (req, res, next) => {
try {
const { label, fields, amount_field, date_field, balance_offset } = req.body;
const n = v => v !== undefined ? lit(v) : 'NULL';
const f = v => v !== undefined ? lit(JSON.stringify(v)) : 'NULL';
const result = await pool.query(
`SELECT * FROM update_stack(${lit(req.params.name)}, ${n(label)}, ${f(fields)}, ${n(amount_field)}, ${n(date_field)}, ${n(balance_offset)})`
);
if (!result.rows.length) return res.status(404).json({ error: 'Stack not found' });
res.json(result.rows[0]);
} catch (err) { next(err); }
});
// Delete stack
router.delete('/:name', async (req, res, next) => {
try {
const result = await pool.query(`SELECT * FROM delete_stack(${lit(req.params.name)})`);
if (!result.rows.length) return res.status(404).json({ error: 'Stack not found' });
res.json({ success: true, deleted: req.params.name });
} catch (err) { next(err); }
});
// Add or update a source in a stack
router.put('/:name/sources/:source', async (req, res, next) => {
try {
const { field_map, amount_sign, balance_offset, amount_field, date_field } = req.body;
const n = v => v != null ? lit(v) : 'NULL';
const result = await pool.query(
`SELECT * FROM upsert_stack_source(${lit(req.params.name)}, ${lit(req.params.source)}, ${lit(JSON.stringify(field_map || {}))}, ${lit(amount_sign ?? 1)}, ${lit(balance_offset ?? 0)}, ${n(amount_field)}, ${n(date_field)})`
);
res.json(result.rows[0]);
} catch (err) {
if (err.code === '23503') return res.status(404).json({ error: 'Stack or source not found' });
next(err);
}
});
// Remove a source from a stack
router.delete('/:name/sources/:source', async (req, res, next) => {
try {
const result = await pool.query(
`SELECT * FROM remove_stack_source(${lit(req.params.name)}, ${lit(req.params.source)})`
);
if (!result.rows.length) return res.status(404).json({ error: 'Source not in stack' });
res.json({ success: true, removed: req.params.source });
} catch (err) { next(err); }
});
// Get current running balance from the generated view
router.get('/:name/balance', async (req, res, next) => {
try {
const result = await pool.query(`SELECT get_stack_balance(${lit(req.params.name)}) AS result`);
res.json(result.rows[0].result);
} catch (err) { next(err); }
});
// Generate / refresh the dfv view
router.post('/:name/view', async (req, res, next) => {
try {
const result = await pool.query(`SELECT generate_stack_view(${lit(req.params.name)}) AS result`);
res.json(result.rows[0].result);
} catch (err) { next(err); }
});
// Calibrate balance offset given a known good balance at a specific date
router.post('/:name/calibrate', async (req, res, next) => {
try {
const { as_of_date, known_balance, source_name } = req.body;
if (!as_of_date || known_balance === undefined) {
return res.status(400).json({ error: 'as_of_date and known_balance are required' });
}
const result = await pool.query(
`SELECT calibrate_balance(${lit(req.params.name)}, ${source_name ? lit(source_name) : 'NULL'}, ${lit(as_of_date)}::date, ${lit(known_balance)}::numeric) AS result`
);
res.json(result.rows[0].result);
} catch (err) { next(err); }
});
return router;
};