From e0b0fc91e023ab670bc4935bbbcb6c658dacdb1b Mon Sep 17 00:00:00 2001 From: siddharthd Date: Sat, 25 Jul 2026 23:02:26 +1000 Subject: [PATCH] feat(rules): manual-only rules as one-click quick actions on selected transactions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../0012_manual_only_rules/migration.sql | 10 ++ prisma/schema.prisma | 19 +-- src/app/api/rules/[id]/route.ts | 1 + src/app/api/rules/apply/route.ts | 110 ++------------- src/app/api/rules/route.ts | 6 +- src/app/api/transactions/bulk/route.ts | 33 +++++ src/app/rules/page.tsx | 53 +++++-- src/app/transactions/page.tsx | 67 ++++++++- src/lib/hooks.ts | 13 ++ src/lib/rule-actions.ts | 130 ++++++++++++++++++ 10 files changed, 324 insertions(+), 118 deletions(-) create mode 100644 prisma/migrations/0012_manual_only_rules/migration.sql create mode 100644 src/lib/rule-actions.ts diff --git a/prisma/migrations/0012_manual_only_rules/migration.sql b/prisma/migrations/0012_manual_only_rules/migration.sql new file mode 100644 index 0000000..511ea87 --- /dev/null +++ b/prisma/migrations/0012_manual_only_rules/migration.sql @@ -0,0 +1,10 @@ +-- Manual-only rules ("quick actions"): a rule flagged manual_only is never +-- picked up by the bulk apply-all run. It exists to be fired by hand against a +-- selection of transactions from the transactions page, so its conditions are +-- irrelevant — the selection is the condition. + +ALTER TABLE rules + ADD COLUMN IF NOT EXISTS manual_only BOOLEAN NOT NULL DEFAULT false; + +CREATE INDEX IF NOT EXISTS idx_rules_manual_only + ON rules (owner_id, manual_only) WHERE manual_only = true; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 8a6feef..4ece7ad 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -98,15 +98,16 @@ model transaction_tags { } model rules { - id Int @id @default(autoincrement()) - owner_id Int - name String - conditions Json @default("[]") - actions Json @default("{}") - enabled Boolean @default(true) - priority Int @default(0) - created_at DateTime @default(now()) - updated_at DateTime @default(now()) @updatedAt + id Int @id @default(autoincrement()) + owner_id Int + name String + conditions Json @default("[]") + actions Json @default("{}") + enabled Boolean @default(true) + manual_only Boolean @default(false) + priority Int @default(0) + created_at DateTime @default(now()) + updated_at DateTime @default(now()) @updatedAt } model budgets { diff --git a/src/app/api/rules/[id]/route.ts b/src/app/api/rules/[id]/route.ts index 533ff0d..a3845c0 100644 --- a/src/app/api/rules/[id]/route.ts +++ b/src/app/api/rules/[id]/route.ts @@ -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 }), }, }); diff --git a/src/app/api/rules/apply/route.ts b/src/app/api/rules/apply/route.ts index a2dd7d5..550bbc3 100644 --- a/src/app/api/rules/apply/route.ts +++ b/src/app/api/rules/apply/route.ts @@ -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(); - 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(); - 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, + }); } } diff --git a/src/app/api/rules/route.ts b/src/app/api/rules/route.ts index 81d3d9a..c3ab502 100644 --- a/src/app/api/rules/route.ts +++ b/src/app/api/rules/route.ts @@ -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, }, }); diff --git a/src/app/api/transactions/bulk/route.ts b/src/app/api/transactions/bulk/route.ts index fc44180..c220f6b 100644 --- a/src/app/api/transactions/bulk/route.ts +++ b/src/app/api/transactions/bulk/route.ts @@ -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); diff --git a/src/app/rules/page.tsx b/src/app/rules/page.tsx index 882f896..2881868 100644 --- a/src/app/rules/page.tsx +++ b/src/app/rules/page.tsx @@ -86,6 +86,7 @@ export default function RulesPage() { const [conditions, setConditions] = useState([]); const [actions, setActions] = useState(EMPTY_ACTIONS); const [priority, setPriority] = useState(0); + const [manualOnly, setManualOnly] = useState(false); function openNewForm() { setEditingId(null); @@ -93,15 +94,17 @@ export default function RulesPage() { setConditions([]); setActions(EMPTY_ACTIONS); setPriority(0); + setManualOnly(false); setShowForm(true); } - function openEditForm(rule: { id: number; name: string; conditions: Condition[]; actions: Actions; priority: number }) { + function openEditForm(rule: { id: number; name: string; conditions: Condition[]; actions: Actions; priority: number; manual_only?: boolean }) { setEditingId(rule.id); setName(rule.name); setConditions(Array.isArray(rule.conditions) ? rule.conditions : []); setActions(rule.actions && typeof rule.actions === "object" ? rule.actions : EMPTY_ACTIONS); setPriority(rule.priority); + setManualOnly(!!rule.manual_only); setShowForm(true); window.scrollTo({ top: 0, behavior: "smooth" }); } @@ -141,7 +144,7 @@ export default function RulesPage() { async function handleSubmit(e: React.FormEvent) { e.preventDefault(); - const payload = { name, conditions, actions, enabled: true, priority }; + const payload = { name, conditions, actions, enabled: true, manual_only: manualOnly, priority }; if (editingId !== null) { await updateRule.mutateAsync({ id: editingId, ...payload }); } else { @@ -416,6 +419,20 @@ export default function RulesPage() { className="w-24 bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-sm" /> + + {/* No Apply for quick actions: their conditions are empty, so a + bulk apply would hit every transaction. They run from the + transactions page against a selection instead. */} + {!rule.manual_only && ( + + )} + {quickActions.length > 0 && ( +
+ {quickActions.map((rule) => ( + + ))} +
+ )} + + )} + {/* Table */}
diff --git a/src/lib/hooks.ts b/src/lib/hooks.ts index a2cde65..e90331c 100644 --- a/src/lib/hooks.ts +++ b/src/lib/hooks.ts @@ -166,6 +166,7 @@ export function useBulkAction() { merchant_normalized?: string; splits?: { participant_id: number; share_percent: number }[]; tag_id?: number; + rule_id?: number; }) => { const res = await fetch("/api/transactions/bulk", { method: "POST", @@ -188,6 +189,16 @@ export function useBulkAction() { if (variables.action === "tag" || variables.action === "untag") { qc.invalidateQueries({ queryKey: ["tags"] }); } + // A quick action can touch category, tags and splits at once, and records + // a revertible run — refresh everything it could have changed. + if (variables.action === "apply_rule") { + qc.invalidateQueries({ queryKey: ["tags"] }); + qc.invalidateQueries({ queryKey: ["splits"] }); + qc.invalidateQueries({ queryKey: ["shared-transactions"] }); + qc.invalidateQueries({ queryKey: ["participant-balances"] }); + qc.invalidateQueries({ queryKey: ["analytics"] }); + qc.invalidateQueries({ queryKey: ["rule-runs"] }); + } }, }); } @@ -459,6 +470,8 @@ export interface RuleRow { conditions: { field: string; operator: string; value: string }[]; actions: { set_category?: string; add_tag_ids?: number[]; set_merchant?: string; apply_split?: { participant_id: number; share_percent: number }[] }; enabled: boolean; + /** Excluded from the apply-all run; fired by hand as a quick action instead. */ + manual_only?: boolean; priority: number; created_at: string; } diff --git a/src/lib/rule-actions.ts b/src/lib/rule-actions.ts new file mode 100644 index 0000000..0b4be27 --- /dev/null +++ b/src/lib/rule-actions.ts @@ -0,0 +1,130 @@ +/** + * Applying a rule's `actions` to transactions, and snapshotting the before-state + * so a run can be reverted. + * + * Shared by the two callers that write rule actions: + * - `POST /api/rules/apply` — condition-matched bulk run + * - `POST /api/transactions/bulk` with `action: "apply_rule"` — quick action + * fired against a hand-picked selection (conditions ignored) + */ + +import { queryRaw } from "@/lib/db"; +import type { Actions } from "@/lib/rules"; + +export 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 }[]; +} + +/** + * Apply `actions` to one transaction. + * + * Tags are additive (ON CONFLICT DO NOTHING) — re-running never duplicates and + * never removes tags the rule doesn't mention. Splits replace the participant + * set but upsert shares, so `settled` flags survive a re-run; a plain + * delete+reinsert would silently un-settle reconciled splits. + */ +export async function applyRuleActions( + transactionId: number, + actions: Actions, + opts: { skipSplit?: boolean } = {} +): Promise { + 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()`, + [transactionId, 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`, + [transactionId, tagId] + ); + } + } + + if (actions.apply_split?.length && !opts.skipSplit) { + await queryRaw( + `DELETE FROM transaction_splits WHERE transaction_id = $1 AND participant_id != ALL($2::int[])`, + [transactionId, 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`, + [transactionId, s.participant_id, s.share_percent] + ); + } + } +} + +/** Capture the overrides / tags / splits of `ids` before a run mutates them. */ +export async function captureSnapshot(ids: number[]): Promise { + if (ids.length === 0) return []; + + 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(); + 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(); + 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, + }); + } + + return ids.map((id) => { + const ov = overrideMap.get(id); + return { + 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) ?? [], + }; + }); +}