feat(trips): trip tracking with analytics, tag conversion, and transaction assignment

Adds trips table usage across API and UI: trip CRUD, per-trip analytics
(category/daily/merchant/tag/participant breakdowns), tag-to-trip
conversion, trip assignment via transaction overrides, and trip filter
in the transactions view. Recovered from working tree after local git
corruption; feature was already live via host-context Docker builds.
This commit is contained in:
2026-07-19 20:00:51 +10:00
parent b706973fb5
commit 48ec151c15
21 changed files with 1506 additions and 55 deletions
@@ -0,0 +1,37 @@
import { NextRequest, NextResponse } from "next/server";
import { getCurrentUser } from "@/lib/auth";
import { queryRaw } from "@/lib/db";
import { createTrip, assignTransactionsToTrip, getTagTransactionIds } from "@/lib/queries";
export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const user = await getCurrentUser(req);
if (!user) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
const { id } = await params;
const tagId = Number(id);
const { start_date, end_date } = await req.json().catch(() => ({}));
// Fetch the tag
const tags = await queryRaw<{ id: number; name: string; color: string }>(
`SELECT id, name, color FROM tags WHERE id = $1`,
[tagId]
);
if (!tags[0]) return NextResponse.json({ error: "Tag not found" }, { status: 404 });
const tag = tags[0];
// Create trip from tag metadata
const trip = await createTrip(user.id, {
name: tag.name,
color: tag.color,
start_date: start_date ?? null,
end_date: end_date ?? null,
});
// Assign all transactions with this tag to the new trip
const transactionIds = await getTagTransactionIds(tagId);
if (transactionIds.length > 0) {
await assignTransactionsToTrip(trip.id, transactionIds);
}
return NextResponse.json({ trip, assigned: transactionIds.length }, { status: 201 });
}
+6 -3
View File
@@ -23,7 +23,7 @@ export async function PATCH(
const transactionId = Number(id);
const body = await req.json();
const { category, merchant_normalized, notes, transaction_type, my_share_percent, description, amount, transaction_date } = body as {
const { category, merchant_normalized, notes, transaction_type, my_share_percent, description, amount, transaction_date, trip_id } = body as {
category?: string;
merchant_normalized?: string;
notes?: string;
@@ -32,6 +32,7 @@ export async function PATCH(
description?: string;
amount?: number;
transaction_date?: string;
trip_id?: number | null;
};
if (my_share_percent !== undefined && my_share_percent !== null) {
@@ -72,8 +73,8 @@ export async function PATCH(
);
}
// category/merchant/notes/my_share_percent go through the overrides table
const hasOverride = category !== undefined || merchant_normalized !== undefined || notes !== undefined || my_share_percent !== undefined;
// category/merchant/notes/my_share_percent/trip_id go through the overrides table
const hasOverride = category !== undefined || merchant_normalized !== undefined || notes !== undefined || my_share_percent !== undefined || trip_id !== undefined;
if (!hasOverride) {
return NextResponse.json({ ok: true });
}
@@ -83,6 +84,7 @@ export async function PATCH(
if (merchant_normalized !== undefined) data.merchant_normalized = merchant_normalized;
if (notes !== undefined) data.notes = notes;
if (my_share_percent !== undefined) data.my_share_percent = my_share_percent;
if (trip_id !== undefined) data.trip_id = trip_id;
const override = await prisma.transaction_overrides.upsert({
where: { transaction_id: transactionId },
@@ -93,6 +95,7 @@ export async function PATCH(
merchant_normalized: merchant_normalized || null,
notes: notes || null,
my_share_percent: my_share_percent != null ? String(my_share_percent) : null,
trip_id: trip_id ?? null,
},
});
+7
View File
@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma, queryRaw } from "@/lib/db";
import { assignTransactionsToTrip } from "@/lib/queries";
export async function POST(req: NextRequest) {
const body = await req.json();
@@ -77,5 +78,11 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ updated: ids.length });
}
if (action === "assign_trip") {
const { trip_id } = body as { ids: number[]; trip_id: number | null };
await assignTransactionsToTrip(trip_id, ids);
return NextResponse.json({ updated: ids.length });
}
return NextResponse.json({ error: "Invalid action" }, { status: 400 });
}
+1
View File
@@ -25,6 +25,7 @@ export async function GET(req: NextRequest) {
amount_min: sp.get("amount_min") ? Number(sp.get("amount_min")) : undefined,
amount_max: sp.get("amount_max") ? Number(sp.get("amount_max")) : undefined,
has_split: sp.get("has_split") || undefined,
trip_id: sp.get("trip_id") || undefined,
});
return NextResponse.json(result);
+15
View File
@@ -0,0 +1,15 @@
import { NextRequest, NextResponse } from "next/server";
import { getCurrentUser } from "@/lib/auth";
import { getTripAnalytics } from "@/lib/queries";
export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const user = await getCurrentUser(req);
if (!user) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
const { id } = await params;
try {
const analytics = await getTripAnalytics(Number(id), user.id);
return NextResponse.json(analytics);
} catch {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
}
+30
View File
@@ -0,0 +1,30 @@
import { NextRequest, NextResponse } from "next/server";
import { getCurrentUser } from "@/lib/auth";
import { getTripById, updateTrip, deleteTrip } from "@/lib/queries";
export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const user = await getCurrentUser(req);
if (!user) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
const { id } = await params;
const trip = await getTripById(Number(id), user.id);
if (!trip) return NextResponse.json({ error: "Not found" }, { status: 404 });
return NextResponse.json(trip);
}
export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const user = await getCurrentUser(req);
if (!user) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
const { id } = await params;
const body = await req.json();
const trip = await updateTrip(Number(id), user.id, body);
if (!trip) return NextResponse.json({ error: "Not found" }, { status: 404 });
return NextResponse.json(trip);
}
export async function DELETE(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const user = await getCurrentUser(req);
if (!user) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
const { id } = await params;
await deleteTrip(Number(id), user.id);
return new NextResponse(null, { status: 204 });
}
@@ -0,0 +1,15 @@
import { NextRequest, NextResponse } from "next/server";
import { getCurrentUser } from "@/lib/auth";
import { assignTransactionsToTrip } from "@/lib/queries";
export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const user = await getCurrentUser(req);
if (!user) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
const { id } = await params;
const { transactionIds } = await req.json() as { transactionIds: number[] };
if (!Array.isArray(transactionIds) || !transactionIds.length) {
return NextResponse.json({ error: "transactionIds must be a non-empty array" }, { status: 400 });
}
await assignTransactionsToTrip(Number(id), transactionIds);
return NextResponse.json({ ok: true });
}
+20
View File
@@ -0,0 +1,20 @@
import { NextRequest, NextResponse } from "next/server";
import { getCurrentUser } from "@/lib/auth";
import { getTrips, createTrip } from "@/lib/queries";
export async function GET(req: NextRequest) {
const user = await getCurrentUser(req);
if (!user) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
const trips = await getTrips(user.id);
return NextResponse.json(trips);
}
export async function POST(req: NextRequest) {
const user = await getCurrentUser(req);
if (!user) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
const body = await req.json();
const { name, description, start_date, end_date, color } = body;
if (!name?.trim()) return NextResponse.json({ error: "name is required" }, { status: 400 });
const trip = await createTrip(user.id, { name: name.trim(), description, start_date, end_date, color });
return NextResponse.json(trip, { status: 201 });
}