ci / lint-test (push) Successful in 46s
Owner was write-once for every ingestion path — a pantry receipt hardcodes DEFAULT_OWNER_ID and there was not one `UPDATE ... SET owner_id` in src/ — so a shop the other person paid for was permanently filed as yours. PATCH /api/transactions/[id] now takes owner_id, for manual rows only. A statement row returns 400 statement_owned and points at the statements page: its effective owner is COALESCE(t.owner_id, s.owner_id), so writing it there would either no-op or detach one row from the account it came from. PATCH /api/statements/[id] is new. The statements page has had an owner dropdown since it was built, wired to a route with no PATCH handler — every change 405'd, and because useUpdateStatement never checked res.ok it failed silently and the select snapped back on refetch. It writes both tables: 2,194 statement rows carry their own owner_id against 1,803 that inherit, so updating `statements` alone moves less than half and splits one account's history between two people. The guard is the point. Access is "owner OR holds a split", so handing a row over while holding no split removes it from your list and 404s every route that could put it back — only the new owner can undo it. That is 409 would_lose_access, and the modal offers both ways forward: add my split first, or give it away anyway. Taking a row onto your own ledger is never blocked, and claiming a row you cannot see is a 404 before any owner logic runs. Splits are deliberately not rewritten. They record shares, not direction, so a 50/50 flips from "they owe me" to "I owe them" untouched, settled included. Also adds the missing res.ok check to useUpdateTransaction, without which every rejection resolved as success: the modal closed, the list refetched, and the edit silently vanished. 14 new integration tests; 203 integration + 130 unit green.
75 lines
3.0 KiB
TypeScript
75 lines
3.0 KiB
TypeScript
import { NextRequest, NextResponse } from "next/server";
|
|
import { getStatementById } from "@/lib/queries";
|
|
import { getCurrentUser } from "@/lib/auth";
|
|
import { prisma, queryRaw } from "@/lib/db";
|
|
|
|
export async function GET(
|
|
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 stmt = await getStatementById(Number(id));
|
|
if (!stmt || stmt.owner_id !== user.id) {
|
|
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
|
}
|
|
return NextResponse.json(stmt);
|
|
}
|
|
|
|
/**
|
|
* Reassign a statement — whose account it is.
|
|
*
|
|
* The statements page has had an owner dropdown since it was built, wired to
|
|
* `useUpdateStatement`, which PATCHed a route that had no PATCH handler. Every
|
|
* change 405'd, and because the hook never checked `res.ok` the failure was
|
|
* silent: the select snapped back on refetch and looked like a UI glitch.
|
|
*
|
|
* Both tables are written because both carry the owner. The effective owner is
|
|
* `COALESCE(t.owner_id, s.owner_id)`, and 2,194 statement rows carry their own
|
|
* copy against 1,803 that inherit — so updating `statements` alone would move
|
|
* less than half the rows and split one account's history between two people.
|
|
* All 2,194 agree with their statement today; a row that disagrees was set
|
|
* deliberately and is left alone rather than swept up.
|
|
*/
|
|
export async function PATCH(
|
|
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 statementId = Number(id);
|
|
|
|
const stmt = await getStatementById(statementId);
|
|
// Only the current owner reassigns. A statement is an account, not a shared
|
|
// expense — there is no split that grants a second person a say in it.
|
|
if (!stmt || stmt.owner_id !== user.id) {
|
|
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
|
}
|
|
|
|
const { owner_id } = (await req.json()) as { owner_id?: number };
|
|
if (typeof owner_id !== "number" || !Number.isInteger(owner_id)) {
|
|
return NextResponse.json({ error: "owner_id must be a participant id" }, { status: 400 });
|
|
}
|
|
const known = await queryRaw(`SELECT id FROM participants WHERE id = $1`, [owner_id]);
|
|
if (!known.length) {
|
|
return NextResponse.json({ error: "Unknown participant" }, { status: 400 });
|
|
}
|
|
if (owner_id === stmt.owner_id) {
|
|
return NextResponse.json({ ok: true, statement_id: statementId, rows_moved: 0 });
|
|
}
|
|
|
|
const [, rowsMoved] = await prisma.$transaction([
|
|
prisma.$executeRawUnsafe(`UPDATE statements SET owner_id = $1 WHERE id = $2`, owner_id, statementId),
|
|
prisma.$executeRawUnsafe(
|
|
`UPDATE transactions SET owner_id = $1 WHERE statement_id = $2 AND owner_id = $3`,
|
|
owner_id,
|
|
statementId,
|
|
stmt.owner_id
|
|
),
|
|
]);
|
|
|
|
return NextResponse.json({ ok: true, statement_id: statementId, rows_moved: rowsMoved });
|
|
}
|