import { NextRequest, NextResponse } from "next/server"; import { getCurrentUser } from "@/lib/auth"; import { queryRaw } from "@/lib/db"; import { getTransactions } from "@/lib/queries"; interface Condition { field: "merchant_normalized" | "description" | "category" | "bank_name" | "amount"; operator: "contains" | "equals" | "starts_with" | "gt" | "lt" | "not_equals"; value: string; } interface Actions { set_category?: string; add_tag_ids?: number[]; set_merchant?: string; } interface TxFields { effective_category: string; effective_merchant: string; description: string; bank_name: string; amount: number; } function evaluateCondition(cond: Condition, tx: TxFields): boolean { if (cond.field === "amount") { const numVal = Number(tx.amount); const numCond = Number(cond.value); switch (cond.operator) { case "equals": return numVal === numCond; case "not_equals": return numVal !== numCond; case "gt": return numVal > numCond; case "lt": return numVal < numCond; default: return false; } } let fieldVal: string; switch (cond.field) { case "merchant_normalized": fieldVal = tx.effective_merchant || ""; break; case "description": fieldVal = tx.description || ""; break; case "category": fieldVal = tx.effective_category || ""; break; case "bank_name": fieldVal = tx.bank_name || ""; break; default: return false; } const strVal = fieldVal.toLowerCase(); const strCond = cond.value.toLowerCase(); switch (cond.operator) { case "contains": return strVal.includes(strCond); case "equals": return strVal === strCond; case "starts_with": return strVal.startsWith(strCond); case "not_equals": return strVal !== strCond; default: return false; } } export async function POST(req: NextRequest) { const user = await getCurrentUser(req); if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 }); const rules = await queryRaw<{ id: number; conditions: unknown; actions: unknown }>( `SELECT id, conditions, actions FROM rules WHERE owner_id = $1 AND enabled = true ORDER BY priority DESC`, [user.id] ); if (!rules.length) return NextResponse.json({ matched: 0, transactions_affected: 0 }); const { data: transactions } = await getTransactions(user.id, { limit: 100000, offset: 0 }); let matched = 0; const affectedIds = new Set(); for (const rule of rules) { 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; 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] ); } } } } return NextResponse.json({ matched, transactions_affected: affectedIds.size }); }