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, }); }