ci / lint-test (push) Successful in 34s
Selecting a rule now shows the transactions it would alter, so a subset can be ticked and applied rather than trusting a bulk run. The apply step takes explicit transaction ids (the existing bulk apply_rule path), so what you tick is exactly what changes - a rule whose conditions are too broad cannot reach further than the preview showed. Matches are split into 'would change' and 'already correct'. A merchant normalisation rule matching 400 rows where 380 already hold the right value is 20 changes and 380 rows of noise; only the 20 are listed. Preview is offered for every rule including manual_only quick actions, which previously had no way to see their reach at all. A rule with no conditions matches every transaction - that is how apply already behaves, so the preview reports it prominently rather than hiding it. Applies still snapshot to rule_apply_runs, so they remain revertable.
128 lines
4.7 KiB
TypeScript
128 lines
4.7 KiB
TypeScript
import { NextRequest, NextResponse } from "next/server";
|
|
import { getCurrentUser } from "@/lib/auth";
|
|
import { queryRaw } from "@/lib/db";
|
|
import { getTransactions } from "@/lib/queries";
|
|
import { evaluateCondition, type Condition, type Actions } from "@/lib/rules";
|
|
|
|
/**
|
|
* Dry run: which transactions would this rule touch, and what would change?
|
|
*
|
|
* Nothing is written. This exists so a rule can be inspected before it is
|
|
* applied — the apply path (POST /api/transactions/bulk with action
|
|
* "apply_rule") takes an explicit list of ids, so the flow is preview, pick,
|
|
* then apply.
|
|
*
|
|
* Matches are split into two groups because they need different attention:
|
|
* `changes` are rows the rule would actually alter, `noops` already have the
|
|
* value the rule would set. A merchant-normalisation rule matching 400 rows
|
|
* where 380 are already correct is 20 changes and 380 rows of noise.
|
|
*/
|
|
|
|
interface Change {
|
|
field: "category" | "merchant" | "tags" | "split";
|
|
from: string | null;
|
|
to: string;
|
|
}
|
|
|
|
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 rules = await queryRaw<{
|
|
id: number; name: string; conditions: unknown; actions: unknown;
|
|
}>(
|
|
`SELECT id, name, conditions, actions FROM rules WHERE id = $1 AND owner_id = $2`,
|
|
[Number(id), user.id]
|
|
);
|
|
if (!rules.length) return NextResponse.json({ error: "Rule not found" }, { status: 404 });
|
|
|
|
const rule = rules[0];
|
|
const conditions = (typeof rule.conditions === "string"
|
|
? JSON.parse(rule.conditions) : rule.conditions) as Condition[];
|
|
const actions = (typeof rule.actions === "string"
|
|
? JSON.parse(rule.actions) : rule.actions) as Actions;
|
|
|
|
const { searchParams } = new URL(req.url);
|
|
const statementId = searchParams.get("statement_id");
|
|
const limit = Math.min(2000, Math.max(1, Number(searchParams.get("limit") || "500")));
|
|
|
|
const { data: transactions } = await getTransactions(user.id, {
|
|
limit: 100000,
|
|
offset: 0,
|
|
...(statementId ? { statement_id: statementId } : {}),
|
|
});
|
|
|
|
// A rule with no conditions matches every transaction. That is how the apply
|
|
// endpoint behaves too, so the preview must show it rather than hide it —
|
|
// seeing "matches 3,721" is the point.
|
|
const matchAll = conditions.length === 0;
|
|
|
|
const tagIds = new Set(actions.add_tag_ids ?? []);
|
|
const splitTarget = actions.apply_split ?? [];
|
|
|
|
const changes: unknown[] = [];
|
|
let noops = 0;
|
|
|
|
for (const tx of transactions) {
|
|
if (!matchAll && !conditions.every((c) => evaluateCondition(c, tx))) continue;
|
|
|
|
const diff: Change[] = [];
|
|
|
|
if (actions.set_category && tx.effective_category !== actions.set_category) {
|
|
diff.push({ field: "category", from: tx.effective_category ?? null, to: actions.set_category });
|
|
}
|
|
if (actions.set_merchant && tx.effective_merchant !== actions.set_merchant) {
|
|
diff.push({ field: "merchant", from: tx.effective_merchant ?? null, to: actions.set_merchant });
|
|
}
|
|
if (tagIds.size) {
|
|
const have = new Set((tx.tags ?? []).map((t) => t.id));
|
|
const missing = [...tagIds].filter((t) => !have.has(t));
|
|
if (missing.length) {
|
|
diff.push({ field: "tags", from: null, to: missing.join(",") });
|
|
}
|
|
}
|
|
if (splitTarget.length) {
|
|
// Compare the participant/share set the rule would write against what is
|
|
// already there; a re-run that changes nothing should not look like work.
|
|
const have = new Map((tx.splits ?? []).map((s) => [s.participant_id, Number(s.share_percent)]));
|
|
const differs = splitTarget.length !== have.size
|
|
|| splitTarget.some((s) => have.get(s.participant_id) !== Number(s.share_percent));
|
|
if (differs) {
|
|
diff.push({
|
|
field: "split",
|
|
from: [...have.entries()].map(([p, s]) => `${p}:${s}%`).join(" ") || null,
|
|
to: splitTarget.map((s) => `${s.participant_id}:${s.share_percent}%`).join(" "),
|
|
});
|
|
}
|
|
}
|
|
|
|
if (diff.length === 0) { noops++; continue; }
|
|
|
|
changes.push({
|
|
id: tx.id,
|
|
transaction_date: tx.transaction_date,
|
|
description: tx.description,
|
|
amount: tx.amount,
|
|
amount_aud: tx.amount_aud,
|
|
currency: tx.currency,
|
|
bank_name: tx.bank_name,
|
|
effective_merchant: tx.effective_merchant,
|
|
effective_category: tx.effective_category,
|
|
changes: diff,
|
|
});
|
|
}
|
|
|
|
return NextResponse.json({
|
|
rule: { id: rule.id, name: rule.name, matches_everything: matchAll },
|
|
total_matched: changes.length + noops,
|
|
would_change: changes.length,
|
|
already_correct: noops,
|
|
transactions: changes.slice(0, limit),
|
|
truncated: changes.length > limit,
|
|
});
|
|
}
|