feat(rules): manual-only rules as one-click quick actions on selected transactions
ci / lint-test (push) Successful in 52s

A rule flagged manual_only never runs in the apply-all pass; instead it shows
as a button in the transactions bulk bar and applies its actions to the current
selection (conditions ignored — the selection is the condition). Recorded as a
rule_apply_run, so it reverts from Rules -> Apply History like any other run.

Motivation: tagging Home + splitting 50/50 with Sonu was two bulk actions every
time. Now it is one click, and any other combo can be defined the same way.

Extracts the action-application and snapshot logic from the apply route into
src/lib/rule-actions.ts so both callers share one implementation — splits upsert
rather than delete+reinsert, so settled flags survive.
This commit is contained in:
2026-07-25 23:02:26 +10:00
parent 856e1a51ab
commit e0b0fc91e0
10 changed files with 324 additions and 118 deletions
+33
View File
@@ -2,6 +2,8 @@ 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);
@@ -85,6 +87,37 @@ export async function POST(req: NextRequest) {
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)
VALUES ($1, NULL, $2, $3, $4) RETURNING id`,
[user.id, ids.length, ids.length, JSON.stringify(snapshot)]
);
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);