Files
finance-app/src/app/api/transactions/bulk/route.ts
T
siddharthd 3778bfe836
ci / lint-test (push) Successful in 38s
feat(rules): show what an apply run changed, and which rule ran
Apply History listed only counts - '13 matches · 13 transactions' - which reads
identically whether the run renamed a merchant or split every transaction with
another participant. Revert is destructive, so that is not enough to decide on.

Two additions. Migration 0017 records rule_id, rule_name and source on each run:
rule_name is denormalised so history stays readable after a rule is edited or
deleted, and there is no FK so deleting a rule cannot cascade away the audit
trail. Both write paths now populate it - the condition-matched run and the
selection-based quick action.

And rows expand to show the run's snapshot set against current values: which
transactions were touched and what changed on each. Rows changed by something
else since the run are called out, because reverting restores the pre-run value
and would discard that later edit.

Runs recorded before this show 'Unknown rule' - the rule they came from is not
recoverable.
2026-07-26 15:20:56 +10:00

131 lines
4.8 KiB
TypeScript

import { NextRequest, NextResponse } from "next/server";
import { prisma, queryRaw } from "@/lib/db";
import { assignTransactionsToTrip, canAccessTransactions } from "@/lib/queries";
import { getCurrentUser } from "@/lib/auth";
import { applyRuleActions, captureSnapshot } from "@/lib/rule-actions";
import type { Actions } from "@/lib/rules";
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();
const { action, ids, category, merchant_normalized, splits, tag_id } = body as {
action: string;
ids: number[];
category?: string;
merchant_normalized?: string;
splits?: { participant_id: number; share_percent: number }[];
tag_id?: number;
};
if (!ids || !Array.isArray(ids) || ids.length === 0) {
return NextResponse.json({ error: "ids required" }, { status: 400 });
}
if (!(await canAccessTransactions(user.id, ids.map(Number)))) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
if (action === "categorize" && category) {
const ops = ids.map((id) =>
prisma.transaction_overrides.upsert({
where: { transaction_id: id },
update: { category_override: category, updated_at: new Date() },
create: { transaction_id: id, category_override: category },
})
);
await prisma.$transaction(ops);
return NextResponse.json({ updated: ids.length });
}
if (action === "normalize" && merchant_normalized) {
const ops = ids.map((id) =>
prisma.transaction_overrides.upsert({
where: { transaction_id: id },
update: { merchant_normalized, updated_at: new Date() },
create: { transaction_id: id, merchant_normalized },
})
);
await prisma.$transaction(ops);
return NextResponse.json({ updated: ids.length });
}
if (action === "split" && Array.isArray(splits) && splits.length > 0) {
const total = splits.reduce((s, x) => s + x.share_percent, 0);
if (Math.abs(total - 100) > 0.01) {
return NextResponse.json({ error: "Shares must sum to 100%" }, { status: 400 });
}
await prisma.$transaction(
ids.flatMap((id) => [
prisma.transaction_splits.deleteMany({ where: { transaction_id: id } }),
prisma.transaction_splits.createMany({
data: splits.map((s) => ({
transaction_id: id,
participant_id: s.participant_id,
share_percent: s.share_percent,
})),
}),
])
);
return NextResponse.json({ updated: ids.length });
}
if ((action === "tag" || action === "untag") && tag_id) {
if (action === "tag") {
for (const id of ids) {
await queryRaw(
`INSERT INTO transaction_tags (transaction_id, tag_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
[id, tag_id]
);
}
} else {
await queryRaw(
`DELETE FROM transaction_tags WHERE transaction_id = ANY($1::int[]) AND tag_id = $2`,
[ids, tag_id]
);
}
return NextResponse.json({ updated: ids.length });
}
// Quick action: fire a saved rule's actions at the current selection. The
// selection replaces the rule's conditions, which are not evaluated at all.
// Recorded as a rule_apply_run so it can be reverted like any other run.
if (action === "apply_rule") {
const { rule_id } = body as { rule_id?: number };
if (!rule_id) return NextResponse.json({ error: "rule_id required" }, { status: 400 });
const rules = await queryRaw<{ id: number; name: string; actions: unknown }>(
`SELECT id, name, actions FROM rules WHERE id = $1 AND owner_id = $2`,
[Number(rule_id), user.id]
);
if (!rules.length) return NextResponse.json({ error: "Rule not found" }, { status: 404 });
const actions = (typeof rules[0].actions === "string"
? JSON.parse(rules[0].actions)
: rules[0].actions) as Actions;
const snapshot = await captureSnapshot(ids.map(Number));
for (const id of ids) {
await applyRuleActions(Number(id), actions);
}
const run = await queryRaw<{ id: number }>(
`INSERT INTO rule_apply_runs (owner_id, split_from, matched, transactions_affected, snapshot,
rule_id, rule_name, source)
VALUES ($1, NULL, $2, $3, $4, $5, $6, 'selection') RETURNING id`,
[user.id, ids.length, ids.length, JSON.stringify(snapshot),
rules[0].id, rules[0].name]
);
return NextResponse.json({ updated: ids.length, run_id: run[0].id, rule: rules[0].name });
}
if (action === "assign_trip") {
const { trip_id } = body as { ids: number[]; trip_id: number | null };
await assignTransactionsToTrip(trip_id, ids);
return NextResponse.json({ updated: ids.length });
}
return NextResponse.json({ error: "Invalid action" }, { status: 400 });
}