database/functions.sql held a full pg_dump appended onto the original hand-written file, duplicating ~50 functions that had since been split into database/queries/. Nothing deployed it, but CLAUDE.md and the tutorial still told you to psql it, which would have reverted the split versions. The reverse had also happened: five functions in queries/ were behind the live database, all of them undoing the May 2026 split of the transformed column. preview_rule lost its data -> transformed fallback for chained rules; set_/clear_/bulk_set_record_overrides wrote overrides back into transformed and returned the wrong type (which would have made the redeploy error outright); generate_source_view read only transformed instead of merging all three layers. Those are corrected here from the live definitions. generate_source_view additionally regains the _overridden column that queries/ had and live lacked — Records.jsx reads row._overridden to highlight manually edited rows, so that indicator had been dead. The seven functions that existed only in functions.sql move to two new files, import.sql (import + audit trail) and transform.sql (the rule/mapping engine), leaving database/ flat: schema.sql plus one file per route. The four already applied migrate_*.sql scripts are removed. manage.py picks up the new files in QUERY_FILES, and its DB_ACTIONS set now keys off the action functions rather than duplicated label strings that no longer matched any menu entry, so the "into database X" hint renders again. uninstall.sh is folded into manage.py as menu option 10. Beyond what the script did, it stops/disables/removes the systemd unit, removes the nginx site with an nginx -t check before reloading, and deletes public/. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
160 lines
5.8 KiB
PL/PgSQL
160 lines
5.8 KiB
PL/PgSQL
--
|
|
-- Import queries
|
|
-- CSV import and the import audit trail; SQL for the import/log endpoints in
|
|
-- api/routes/sources.js
|
|
--
|
|
|
|
SET search_path TO dataflow, public;
|
|
|
|
-- ── Import ────────────────────────────────────────────────────────────────────
|
|
|
|
-- Import records, skipping any whose constraint key already exists in the table.
|
|
--
|
|
-- Dedup is enforced here, in the CTE — there is no unique constraint on
|
|
-- constraint_key and ON CONFLICT must never be used. Within one batch every row
|
|
-- inserts even if two rows share a constraint key, because banks legitimately send
|
|
-- identical-looking transactions (same date, description, amount) on the same day.
|
|
-- The key exists only to stop a re-imported overlapping date range from
|
|
-- double-counting rows already in the table.
|
|
CREATE OR REPLACE FUNCTION import_records(
|
|
p_source_name TEXT,
|
|
p_data JSONB -- Array of records
|
|
) RETURNS JSON AS $$
|
|
DECLARE
|
|
v_constraint_fields TEXT[];
|
|
v_inserted INTEGER;
|
|
v_duplicates INTEGER;
|
|
v_log_id INTEGER;
|
|
BEGIN
|
|
SELECT constraint_fields INTO v_constraint_fields
|
|
FROM dataflow.sources
|
|
WHERE name = p_source_name;
|
|
|
|
IF v_constraint_fields IS NULL THEN
|
|
RETURN json_build_object(
|
|
'success', false,
|
|
'error', 'Source not found: ' || p_source_name
|
|
);
|
|
END IF;
|
|
|
|
WITH
|
|
-- All incoming records with their constraint keys
|
|
pending AS (
|
|
SELECT
|
|
rec.value AS data,
|
|
rec.ordinality AS seq,
|
|
(SELECT jsonb_object_agg(f, rec.value->>f)
|
|
FROM unnest(v_constraint_fields) AS f) AS constraint_key
|
|
FROM jsonb_array_elements(p_data) WITH ORDINALITY AS rec
|
|
),
|
|
-- Keys already in the database (excluded)
|
|
existing AS (
|
|
SELECT DISTINCT r.constraint_key
|
|
FROM dataflow.records r
|
|
INNER JOIN pending p ON p.constraint_key = r.constraint_key
|
|
WHERE r.source_name = p_source_name
|
|
),
|
|
-- Rows whose constraint key is not yet in the database
|
|
new_records AS (
|
|
SELECT p.data, p.constraint_key, p.seq
|
|
FROM pending p
|
|
WHERE NOT EXISTS (SELECT 1 FROM existing e WHERE e.constraint_key = p.constraint_key)
|
|
),
|
|
-- Write the log entry
|
|
log_entry AS (
|
|
INSERT INTO dataflow.import_log (source_name, records_imported, records_duplicate, info)
|
|
VALUES (
|
|
p_source_name,
|
|
(SELECT count(*) FROM new_records),
|
|
(SELECT count(*) FROM pending) - (SELECT count(*) FROM new_records),
|
|
jsonb_build_object(
|
|
'total', jsonb_array_length(p_data),
|
|
'inserted_keys', (SELECT jsonb_agg(constraint_key ORDER BY constraint_key) FROM new_records),
|
|
'excluded_keys', (SELECT jsonb_agg(constraint_key) FROM existing)
|
|
)
|
|
)
|
|
RETURNING id, records_imported, records_duplicate
|
|
),
|
|
-- Insert new records
|
|
inserted AS (
|
|
INSERT INTO dataflow.records (source_name, data, constraint_key, import_id)
|
|
SELECT p_source_name, nr.data, nr.constraint_key, (SELECT id FROM log_entry)
|
|
FROM new_records nr
|
|
ORDER BY nr.seq
|
|
RETURNING id
|
|
)
|
|
SELECT le.id, le.records_imported, le.records_duplicate
|
|
INTO v_log_id, v_inserted, v_duplicates
|
|
FROM log_entry le;
|
|
|
|
RETURN json_build_object(
|
|
'success', true,
|
|
'imported', v_inserted,
|
|
'duplicates', v_duplicates,
|
|
'log_id', v_log_id
|
|
);
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
COMMENT ON FUNCTION import_records IS 'Import records with automatic deduplication';
|
|
|
|
-- ── Audit trail ───────────────────────────────────────────────────────────────
|
|
|
|
CREATE OR REPLACE FUNCTION get_import_log(p_source_name TEXT)
|
|
RETURNS TABLE (
|
|
id INTEGER,
|
|
source_name TEXT,
|
|
records_imported INTEGER,
|
|
records_duplicate INTEGER,
|
|
imported_at TIMESTAMPTZ,
|
|
info JSONB
|
|
) AS $$
|
|
SELECT id, source_name, records_imported, records_duplicate, imported_at, info
|
|
FROM dataflow.import_log
|
|
WHERE source_name = p_source_name
|
|
ORDER BY imported_at DESC;
|
|
$$ LANGUAGE sql;
|
|
|
|
COMMENT ON FUNCTION get_import_log IS 'Return import history for a source, newest first, including inserted/excluded key lists';
|
|
|
|
CREATE OR REPLACE FUNCTION get_all_import_logs()
|
|
RETURNS TABLE (
|
|
id INTEGER,
|
|
source_name TEXT,
|
|
records_imported INTEGER,
|
|
records_duplicate INTEGER,
|
|
imported_at TIMESTAMPTZ,
|
|
info JSONB
|
|
) AS $$
|
|
SELECT id, source_name, records_imported, records_duplicate, imported_at, info
|
|
FROM dataflow.import_log
|
|
ORDER BY imported_at DESC;
|
|
$$ LANGUAGE sql;
|
|
|
|
COMMENT ON FUNCTION get_all_import_logs IS 'Return import history across all sources, newest first';
|
|
|
|
-- Records are removed by the import_id FK's ON DELETE CASCADE
|
|
CREATE OR REPLACE FUNCTION delete_import(p_log_id INTEGER)
|
|
RETURNS JSON AS $$
|
|
DECLARE
|
|
v_deleted INTEGER;
|
|
BEGIN
|
|
IF NOT EXISTS (SELECT 1 FROM dataflow.import_log WHERE id = p_log_id) THEN
|
|
RETURN json_build_object('success', false, 'error', 'Import log entry not found');
|
|
END IF;
|
|
|
|
SELECT count(*) INTO v_deleted FROM dataflow.records WHERE import_id = p_log_id;
|
|
|
|
-- Cascade handles deleting records via FK ON DELETE CASCADE
|
|
DELETE FROM dataflow.import_log WHERE id = p_log_id;
|
|
|
|
RETURN json_build_object(
|
|
'success', true,
|
|
'records_deleted', v_deleted,
|
|
'log_id', p_log_id
|
|
);
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
COMMENT ON FUNCTION delete_import IS 'Delete all records belonging to an import batch and remove the log entry';
|