feat(rules): manual-only rules as one-click quick actions on selected transactions
ci / lint-test (push) Successful in 52s
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:
@@ -22,6 +22,7 @@ export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id
|
||||
...(body.conditions !== undefined && { conditions: body.conditions }),
|
||||
...(body.actions !== undefined && { actions: body.actions }),
|
||||
...(body.enabled !== undefined && { enabled: body.enabled }),
|
||||
...(body.manual_only !== undefined && { manual_only: body.manual_only }),
|
||||
...(body.priority !== undefined && { priority: body.priority }),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -3,15 +3,7 @@ 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 }[];
|
||||
}
|
||||
import { applyRuleActions, captureSnapshot } from "@/lib/rule-actions";
|
||||
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
@@ -42,10 +34,16 @@ export async function POST(req: NextRequest) {
|
||||
const splitFrom = body.splitFrom || null;
|
||||
const ruleId = body.ruleId || null;
|
||||
|
||||
// Manual-only rules ("quick actions") never take part in a condition-matched
|
||||
// run — their conditions are typically empty, so they would match every
|
||||
// transaction. They are fired from the transactions page against a selection.
|
||||
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`,
|
||||
? `SELECT id, conditions, actions FROM rules
|
||||
WHERE owner_id = $1 AND id = $2 AND manual_only = false`
|
||||
: `SELECT id, conditions, actions FROM rules
|
||||
WHERE owner_id = $1 AND enabled = true AND manual_only = false
|
||||
ORDER BY priority DESC`,
|
||||
ruleId ? [user.id, ruleId] : [user.id]
|
||||
);
|
||||
|
||||
@@ -70,51 +68,7 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
|
||||
// --- 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<number, number[]>();
|
||||
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<number, { participant_id: number; share_percent: number; settled: boolean }[]>();
|
||||
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
|
||||
}
|
||||
const snapshot = await captureSnapshot(Array.from(matchedIds));
|
||||
|
||||
// --- Apply rules ---
|
||||
let matched = 0;
|
||||
@@ -128,45 +82,11 @@ export async function POST(req: NextRequest) {
|
||||
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]
|
||||
);
|
||||
}
|
||||
}
|
||||
// splitFrom holds the split back to transactions on/after that date;
|
||||
// category/merchant/tags still apply to everything matched.
|
||||
await applyRuleActions(tx.id, actions, {
|
||||
skipSplit: !!splitFrom && tx.transaction_date < splitFrom,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,10 +12,11 @@ export async function GET(req: NextRequest) {
|
||||
conditions: unknown;
|
||||
actions: unknown;
|
||||
enabled: boolean;
|
||||
manual_only: boolean;
|
||||
priority: number;
|
||||
created_at: string;
|
||||
}>(
|
||||
`SELECT id, name, conditions, actions, enabled, priority, created_at
|
||||
`SELECT id, name, conditions, actions, enabled, manual_only, priority, created_at
|
||||
FROM rules WHERE owner_id = $1 ORDER BY priority DESC, id ASC`,
|
||||
[user.id]
|
||||
);
|
||||
@@ -26,7 +27,7 @@ export async function POST(req: NextRequest) {
|
||||
const user = await getCurrentUser(req);
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||
|
||||
const { name, conditions, actions, enabled = true, priority = 0 } = await req.json();
|
||||
const { name, conditions, actions, enabled = true, manual_only = false, priority = 0 } = await req.json();
|
||||
if (!name) return NextResponse.json({ error: "name required" }, { status: 400 });
|
||||
|
||||
const rule = await prisma.rules.create({
|
||||
@@ -36,6 +37,7 @@ export async function POST(req: NextRequest) {
|
||||
conditions: conditions ?? [],
|
||||
actions: actions ?? {},
|
||||
enabled,
|
||||
manual_only,
|
||||
priority,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user