feat(rules): show what an apply run changed, and which rule ran
ci / lint-test (push) Successful in 38s
ci / lint-test (push) Successful in 38s
Apply History listed only counts - '13 matches · 13 transactions' - which reads identically whether the run renamed a merchant or split every transaction with another participant. Revert is destructive, so that is not enough to decide on. Two additions. Migration 0017 records rule_id, rule_name and source on each run: rule_name is denormalised so history stays readable after a rule is edited or deleted, and there is no FK so deleting a rule cannot cascade away the audit trail. Both write paths now populate it - the condition-matched run and the selection-based quick action. And rows expand to show the run's snapshot set against current values: which transactions were touched and what changed on each. Rows changed by something else since the run are called out, because reverting restores the pre-run value and would discard that later edit. Runs recorded before this show 'Unknown rule' - the rule they came from is not recoverable.
This commit is contained in:
@@ -17,8 +17,12 @@ export async function GET(req: NextRequest) {
|
||||
matched: number;
|
||||
transactions_affected: number;
|
||||
reverted_at: string | null;
|
||||
rule_id: number | null;
|
||||
rule_name: string | null;
|
||||
source: string | null;
|
||||
}>(
|
||||
`SELECT id, applied_at, split_from, matched, transactions_affected, reverted_at
|
||||
`SELECT id, applied_at, split_from, matched, transactions_affected, reverted_at,
|
||||
rule_id, rule_name, source
|
||||
FROM rule_apply_runs WHERE owner_id = $1 ORDER BY applied_at DESC LIMIT 20`,
|
||||
[user.id]
|
||||
);
|
||||
@@ -37,11 +41,11 @@ export async function POST(req: NextRequest) {
|
||||
// Manual-only rules ("quick actions") never take part in a condition-matched
|
||||
// run — their conditions are typically empty, so they would match every
|
||||
// transaction. They are fired from the transactions page against a selection.
|
||||
const rules = await queryRaw<{ id: number; conditions: unknown; actions: unknown }>(
|
||||
const rules = await queryRaw<{ id: number; name: string; conditions: unknown; actions: unknown }>(
|
||||
ruleId
|
||||
? `SELECT id, conditions, actions FROM rules
|
||||
? `SELECT id, name, conditions, actions FROM rules
|
||||
WHERE owner_id = $1 AND id = $2 AND manual_only = false`
|
||||
: `SELECT id, conditions, actions FROM rules
|
||||
: `SELECT id, name, conditions, actions FROM rules
|
||||
WHERE owner_id = $1 AND enabled = true AND manual_only = false
|
||||
ORDER BY priority DESC`,
|
||||
ruleId ? [user.id, ruleId] : [user.id]
|
||||
@@ -92,9 +96,11 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
// --- Save run record ---
|
||||
const run = await queryRaw<{ id: number }>(
|
||||
`INSERT INTO rule_apply_runs (owner_id, split_from, matched, transactions_affected, snapshot)
|
||||
VALUES ($1, $2, $3, $4, $5) RETURNING id`,
|
||||
[user.id, splitFrom, matched, affectedIds.size, JSON.stringify(snapshot)]
|
||||
`INSERT INTO rule_apply_runs (owner_id, split_from, matched, transactions_affected, snapshot,
|
||||
rule_id, rule_name, source)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id`,
|
||||
[user.id, splitFrom, matched, affectedIds.size, JSON.stringify(snapshot),
|
||||
ruleId, ruleId ? (rules[0]?.name ?? null) : null, ruleId ? "rule" : "all"]
|
||||
);
|
||||
|
||||
return NextResponse.json({ id: run[0].id, matched, transactions_affected: affectedIds.size });
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getCurrentUser } from "@/lib/auth";
|
||||
import { queryRaw } from "@/lib/db";
|
||||
|
||||
/**
|
||||
* What a rule run actually did — the before-state from its snapshot set against
|
||||
* the values now.
|
||||
*
|
||||
* The run list only had counts, which is not enough to decide whether to revert:
|
||||
* "13 matches · 13 transactions" reads the same whether it renamed a merchant or
|
||||
* split your history with someone. Reverting is destructive, so the detail has
|
||||
* to be visible before the button is pressed.
|
||||
*/
|
||||
|
||||
interface SnapshotEntry {
|
||||
transaction_id: number;
|
||||
had_override: boolean;
|
||||
prev_category_override: string | null;
|
||||
prev_merchant_normalized: string | null;
|
||||
prev_tag_ids: number[];
|
||||
prev_splits: { participant_id: number; share_percent: number; settled: boolean }[];
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const user = await getCurrentUser(req);
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||
|
||||
const { id } = await params;
|
||||
const runs = await queryRaw<{
|
||||
id: number; applied_at: string; reverted_at: string | null;
|
||||
split_from: string | null; matched: number; transactions_affected: number;
|
||||
rule_id: number | null; rule_name: string | null; source: string | null;
|
||||
snapshot: unknown;
|
||||
}>(
|
||||
`SELECT id, applied_at, reverted_at, split_from, matched, transactions_affected,
|
||||
rule_id, rule_name, source, snapshot
|
||||
FROM rule_apply_runs WHERE id = $1 AND owner_id = $2`,
|
||||
[Number(id), user.id]
|
||||
);
|
||||
if (!runs.length) return NextResponse.json({ error: "Run not found" }, { status: 404 });
|
||||
|
||||
const run = runs[0];
|
||||
const snapshot = (typeof run.snapshot === "string"
|
||||
? JSON.parse(run.snapshot) : run.snapshot) as SnapshotEntry[];
|
||||
const byId = new Map(snapshot.map((s) => [s.transaction_id, s]));
|
||||
const ids = snapshot.map((s) => s.transaction_id);
|
||||
|
||||
if (ids.length === 0) {
|
||||
return NextResponse.json({ run: { ...run, snapshot: undefined }, transactions: [] });
|
||||
}
|
||||
|
||||
const current = await queryRaw<{
|
||||
id: number; transaction_date: string; description: string;
|
||||
amount: number; amount_aud: number | null; bank_name: string;
|
||||
category: string | null; category_override: string | null;
|
||||
merchant_normalized: string | null; merchant_override: string | null;
|
||||
merchant_name: string | null;
|
||||
tag_ids: number[]; splits: { participant_id: number; share_percent: number }[];
|
||||
}>(
|
||||
`SELECT t.id, t.transaction_date::text, t.description, t.amount, t.amount_aud,
|
||||
COALESCE(s.bank_name, 'Manual') as bank_name,
|
||||
t.category, o.category_override,
|
||||
t.merchant_normalized, o.merchant_normalized as merchant_override, t.merchant_name,
|
||||
COALESCE((SELECT json_agg(tt.tag_id) FROM transaction_tags tt WHERE tt.transaction_id = t.id), '[]'::json) as tag_ids,
|
||||
COALESCE((SELECT json_agg(json_build_object('participant_id', ts.participant_id, 'share_percent', ts.share_percent))
|
||||
FROM transaction_splits ts WHERE ts.transaction_id = t.id), '[]'::json) as splits
|
||||
FROM transactions t
|
||||
LEFT JOIN statements s ON s.id = t.statement_id
|
||||
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
|
||||
WHERE t.id = ANY($1::int[])
|
||||
ORDER BY t.transaction_date DESC`,
|
||||
[ids]
|
||||
);
|
||||
|
||||
const parse = <T,>(v: unknown, fallback: T): T =>
|
||||
(typeof v === "string" ? JSON.parse(v) : v) ?? fallback;
|
||||
|
||||
const transactions = current.map((t) => {
|
||||
const prev = byId.get(t.id);
|
||||
const nowTags: number[] = parse(t.tag_ids, []);
|
||||
const nowSplits = parse<{ participant_id: number; share_percent: number }[]>(t.splits, []);
|
||||
const prevTags = prev?.prev_tag_ids ?? [];
|
||||
const prevSplits = prev?.prev_splits ?? [];
|
||||
|
||||
const nowCategory = t.category_override ?? t.category;
|
||||
const prevCategory = prev?.prev_category_override ?? t.category;
|
||||
const nowMerchant = t.merchant_override ?? t.merchant_normalized ?? t.merchant_name;
|
||||
const prevMerchant = prev?.prev_merchant_normalized ?? t.merchant_normalized ?? t.merchant_name;
|
||||
|
||||
const changes: { field: string; from: string | null; to: string | null }[] = [];
|
||||
if (nowCategory !== prevCategory) {
|
||||
changes.push({ field: "category", from: prevCategory, to: nowCategory });
|
||||
}
|
||||
if (nowMerchant !== prevMerchant) {
|
||||
changes.push({ field: "merchant", from: prevMerchant, to: nowMerchant });
|
||||
}
|
||||
const addedTags = nowTags.filter((x) => !prevTags.includes(x));
|
||||
if (addedTags.length) {
|
||||
changes.push({ field: "tags", from: null, to: addedTags.join(",") });
|
||||
}
|
||||
const fmtSplits = (arr: { participant_id: number; share_percent: number }[]) =>
|
||||
arr.length ? arr.map((s) => `${s.participant_id}:${Number(s.share_percent)}%`).sort().join(" ") : null;
|
||||
if (fmtSplits(nowSplits) !== fmtSplits(prevSplits)) {
|
||||
changes.push({ field: "split", from: fmtSplits(prevSplits), to: fmtSplits(nowSplits) });
|
||||
}
|
||||
|
||||
return {
|
||||
id: t.id,
|
||||
transaction_date: t.transaction_date,
|
||||
description: t.description,
|
||||
amount: t.amount,
|
||||
amount_aud: t.amount_aud,
|
||||
bank_name: t.bank_name,
|
||||
merchant: nowMerchant,
|
||||
changes,
|
||||
};
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
run: {
|
||||
id: run.id, applied_at: run.applied_at, reverted_at: run.reverted_at,
|
||||
split_from: run.split_from, matched: run.matched,
|
||||
transactions_affected: run.transactions_affected,
|
||||
rule_id: run.rule_id, rule_name: run.rule_name, source: run.source,
|
||||
},
|
||||
// A reverted run still lists its transactions, but they will show no changes
|
||||
// because the values are back where they started.
|
||||
transactions,
|
||||
still_changed: transactions.filter((t) => t.changes.length > 0).length,
|
||||
});
|
||||
}
|
||||
@@ -110,9 +110,11 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
|
||||
const run = await queryRaw<{ id: number }>(
|
||||
`INSERT INTO rule_apply_runs (owner_id, split_from, matched, transactions_affected, snapshot)
|
||||
VALUES ($1, NULL, $2, $3, $4) RETURNING id`,
|
||||
[user.id, ids.length, ids.length, JSON.stringify(snapshot)]
|
||||
`INSERT INTO rule_apply_runs (owner_id, split_from, matched, transactions_affected, snapshot,
|
||||
rule_id, rule_name, source)
|
||||
VALUES ($1, NULL, $2, $3, $4, $5, $6, 'selection') RETURNING id`,
|
||||
[user.id, ids.length, ids.length, JSON.stringify(snapshot),
|
||||
rules[0].id, rules[0].name]
|
||||
);
|
||||
|
||||
return NextResponse.json({ updated: ids.length, run_id: run[0].id, rule: rules[0].name });
|
||||
|
||||
Reference in New Issue
Block a user