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:
@@ -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 });
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
Reference in New Issue
Block a user