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