feat(rules): apply_split rules with run history and revert
- POST /api/rules/apply — run all enabled rules against unmatched transactions - POST /api/rules/apply/:id — apply a single rule by id - DELETE /api/rules/apply/:id — revert a rule run (remove applied splits) - Rules page: show run history with revert button, apply individual rules
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getCurrentUser } from "@/lib/auth";
|
||||
import { queryRaw } from "@/lib/db";
|
||||
|
||||
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 }[];
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const user = await getCurrentUser(req);
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||
|
||||
const { id } = await params;
|
||||
const runId = Number(id);
|
||||
|
||||
const rows = await queryRaw<{
|
||||
id: number;
|
||||
owner_id: number;
|
||||
reverted_at: string | null;
|
||||
snapshot: unknown;
|
||||
}>(
|
||||
`SELECT id, owner_id, reverted_at, snapshot FROM rule_apply_runs WHERE id = $1`,
|
||||
[runId]
|
||||
);
|
||||
|
||||
if (!rows.length) return NextResponse.json({ error: "Run not found" }, { status: 404 });
|
||||
const run = rows[0];
|
||||
if (run.owner_id !== user.id) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||
if (run.reverted_at) return NextResponse.json({ error: "Already reverted" }, { status: 409 });
|
||||
|
||||
const snapshot = (typeof run.snapshot === "string"
|
||||
? JSON.parse(run.snapshot)
|
||||
: run.snapshot) as SnapshotEntry[];
|
||||
|
||||
for (const entry of snapshot) {
|
||||
const txId = entry.transaction_id;
|
||||
|
||||
// Restore overrides
|
||||
if (entry.had_override) {
|
||||
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 = $2,
|
||||
merchant_normalized = $3,
|
||||
updated_at = NOW()`,
|
||||
[txId, entry.prev_category_override, entry.prev_merchant_normalized]
|
||||
);
|
||||
} else {
|
||||
// No override existed before — remove any that were created
|
||||
await queryRaw(
|
||||
`DELETE FROM transaction_overrides WHERE transaction_id = $1
|
||||
AND category_override IS NULL AND merchant_normalized IS NULL`,
|
||||
[txId]
|
||||
);
|
||||
// If override row exists but was only partially set by this run, clear those fields
|
||||
await queryRaw(
|
||||
`UPDATE transaction_overrides SET
|
||||
category_override = NULL,
|
||||
merchant_normalized = NULL,
|
||||
updated_at = NOW()
|
||||
WHERE transaction_id = $1`,
|
||||
[txId]
|
||||
);
|
||||
}
|
||||
|
||||
// Restore tags: remove any that weren't there before, don't touch pre-existing ones
|
||||
const prevTagIds = entry.prev_tag_ids;
|
||||
if (prevTagIds.length > 0) {
|
||||
await queryRaw(
|
||||
`DELETE FROM transaction_tags WHERE transaction_id = $1 AND tag_id != ALL($2::int[])`,
|
||||
[txId, prevTagIds]
|
||||
);
|
||||
} else {
|
||||
// No tags existed before — remove all tags (they were all added by this run)
|
||||
// Note: this only removes tags on transactions that matched this run
|
||||
await queryRaw(`DELETE FROM transaction_tags WHERE transaction_id = $1`, [txId]);
|
||||
}
|
||||
|
||||
// Restore splits
|
||||
await queryRaw(`DELETE FROM transaction_splits WHERE transaction_id = $1`, [txId]);
|
||||
for (const s of entry.prev_splits) {
|
||||
await queryRaw(
|
||||
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent, settled)
|
||||
VALUES ($1, $2, $3, $4) ON CONFLICT DO NOTHING`,
|
||||
[txId, s.participant_id, s.share_percent, s.settled]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await queryRaw(
|
||||
`UPDATE rule_apply_runs SET reverted_at = NOW() WHERE id = $1`,
|
||||
[runId]
|
||||
);
|
||||
|
||||
return NextResponse.json({ reverted: snapshot.length });
|
||||
}
|
||||
Reference in New Issue
Block a user