31cffbe1bb
Add rules engine with CRUD API, condition/action evaluation, and apply-all endpoint. UI: rule builder form with field/operator/value conditions, tag multi-select, apply button with result stats.
116 lines
3.8 KiB
TypeScript
116 lines
3.8 KiB
TypeScript
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<number>();
|
|
|
|
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 });
|
|
}
|