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"; 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) { const user = await getCurrentUser(req); if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 }); const runs = await queryRaw<{ id: number; applied_at: string; split_from: string | null; matched: number; transactions_affected: number; reverted_at: string | null; }>( `SELECT id, applied_at, split_from, matched, transactions_affected, reverted_at FROM rule_apply_runs WHERE owner_id = $1 ORDER BY applied_at DESC LIMIT 20`, [user.id] ); return NextResponse.json(runs); } export async function POST(req: NextRequest) { const user = await getCurrentUser(req); if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 }); const body = await req.json().catch(() => ({})) as { splitFrom?: string | null; ruleId?: number | null }; const splitFrom = body.splitFrom || null; const ruleId = body.ruleId || null; const rules = await queryRaw<{ id: number; conditions: unknown; actions: unknown }>( ruleId ? `SELECT id, conditions, actions FROM rules WHERE owner_id = $1 AND id = $2` : `SELECT id, conditions, actions FROM rules WHERE owner_id = $1 AND enabled = true ORDER BY priority DESC`, ruleId ? [user.id, ruleId] : [user.id] ); if (!rules.length) return NextResponse.json({ matched: 0, transactions_affected: 0 }); const { data: transactions } = await getTransactions(user.id, { limit: 100000, offset: 0 }); // --- Pre-pass: find all transactions that will match any rule --- const parsedRules = rules.map((r) => ({ conditions: (typeof r.conditions === "string" ? JSON.parse(r.conditions) : r.conditions) as Condition[], actions: (typeof r.actions === "string" ? JSON.parse(r.actions) : r.actions) as Actions, })); const matchedIds = new Set(); for (const tx of transactions) { for (const { conditions } of parsedRules) { if (conditions.length === 0 || conditions.every((c) => evaluateCondition(c, tx))) { matchedIds.add(tx.id); break; } } } // --- Capture before-state for all matched transactions (batched) --- const snapshot: SnapshotEntry[] = []; if (matchedIds.size > 0) { const ids = Array.from(matchedIds); const idList = ids.join(","); const overrides = await queryRaw<{ transaction_id: number; category_override: string | null; merchant_normalized: string | null }>( `SELECT transaction_id, category_override, merchant_normalized FROM transaction_overrides WHERE transaction_id = ANY($1::int[])`, [ids] ); const overrideMap = new Map(overrides.map((o) => [o.transaction_id, o])); const tagRows = await queryRaw<{ transaction_id: number; tag_id: number }>( `SELECT transaction_id, tag_id FROM transaction_tags WHERE transaction_id = ANY($1::int[])`, [ids] ); const tagMap = new Map(); for (const row of tagRows) { if (!tagMap.has(row.transaction_id)) tagMap.set(row.transaction_id, []); tagMap.get(row.transaction_id)!.push(row.tag_id); } const splitRows = await queryRaw<{ transaction_id: number; participant_id: number; share_percent: number; settled: boolean }>( `SELECT transaction_id, participant_id, share_percent, settled FROM transaction_splits WHERE transaction_id = ANY($1::int[])`, [ids] ); const splitMap = new Map(); for (const row of splitRows) { if (!splitMap.has(row.transaction_id)) splitMap.set(row.transaction_id, []); splitMap.get(row.transaction_id)!.push({ participant_id: row.participant_id, share_percent: row.share_percent, settled: row.settled }); } for (const id of ids) { const ov = overrideMap.get(id); snapshot.push({ transaction_id: id, had_override: !!ov, prev_category_override: ov?.category_override ?? null, prev_merchant_normalized: ov?.merchant_normalized ?? null, prev_tag_ids: tagMap.get(id) ?? [], prev_splits: splitMap.get(id) ?? [], }); } void idList; // suppress unused warning } // --- Apply rules --- let matched = 0; const affectedIds = new Set(); for (const { conditions, actions } of parsedRules) { for (const tx of transactions) { const allMatch = conditions.length === 0 || conditions.every((c) => evaluateCondition(c, tx)); if (!allMatch) continue; matched++; affectedIds.add(tx.id); if (actions.set_category || actions.set_merchant) { await queryRaw( `INSERT INTO transaction_overrides (transaction_id, category_override, merchant_normalized) VALUES ($1, $2, $3) ON CONFLICT (transaction_id) DO UPDATE SET category_override = COALESCE($2, transaction_overrides.category_override), merchant_normalized = COALESCE($3, transaction_overrides.merchant_normalized), updated_at = NOW()`, [tx.id, actions.set_category || null, actions.set_merchant || null] ); } if (actions.add_tag_ids?.length) { for (const tagId of actions.add_tag_ids) { await queryRaw( `INSERT INTO transaction_tags (transaction_id, tag_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`, [tx.id, tagId] ); } } if (actions.apply_split?.length) { if (splitFrom && tx.transaction_date < splitFrom) continue; // Remove only participants no longer in the rule's split, and upsert the // rest — a plain delete+reinsert would reset settled flags on every run. await queryRaw( `DELETE FROM transaction_splits WHERE transaction_id = $1 AND participant_id != ALL($2::int[])`, [tx.id, actions.apply_split.map((s) => s.participant_id)] ); for (const s of actions.apply_split) { await queryRaw( `INSERT INTO transaction_splits (transaction_id, participant_id, share_percent) VALUES ($1, $2, $3) ON CONFLICT (transaction_id, participant_id) DO UPDATE SET share_percent = EXCLUDED.share_percent`, [tx.id, s.participant_id, s.share_percent] ); } } } } // --- 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)] ); return NextResponse.json({ id: run[0].id, matched, transactions_affected: affectedIds.size }); }