diff --git a/app/api/auth/login/route.ts b/app/api/auth/login/route.ts index 2dc1424..2cff9f1 100644 --- a/app/api/auth/login/route.ts +++ b/app/api/auth/login/route.ts @@ -3,21 +3,26 @@ import { z } from "zod"; import { getEnv } from "@/lib/env"; import { timingSafeStringEqual } from "@/lib/auth/timing-safe-compare"; import { createSessionToken, sessionCookieOptions } from "@/lib/auth/session"; +import { withRouteLogging } from "@/lib/api/with-route-logging"; +import { createLogger } from "@/lib/logger"; +const log = createLogger("auth"); const bodySchema = z.object({ secret: z.string().min(1) }); -export async function POST(req: Request) { +export const POST = withRouteLogging("auth.login", async (req: Request) => { const parsed = bodySchema.safeParse(await req.json().catch(() => null)); if (!parsed.success) { return NextResponse.json({ error: "secret is required" }, { status: 400 }); } if (!timingSafeStringEqual(parsed.data.secret, getEnv().ACCESS_PASSWORD)) { + log.warn("login attempt with invalid secret"); return NextResponse.json({ error: "invalid secret" }, { status: 401 }); } + log.info("login succeeded"); const token = await createSessionToken(); const res = NextResponse.json({ ok: true }); res.cookies.set({ ...sessionCookieOptions, value: token }); return res; -} +}); diff --git a/app/api/auth/logout/route.ts b/app/api/auth/logout/route.ts index a3277ea..11d67ee 100644 --- a/app/api/auth/logout/route.ts +++ b/app/api/auth/logout/route.ts @@ -1,8 +1,9 @@ import { NextResponse } from "next/server"; import { SESSION_COOKIE_NAME } from "@/lib/auth/session"; +import { withRouteLogging } from "@/lib/api/with-route-logging"; -export async function POST() { +export const POST = withRouteLogging("auth.logout", async () => { const res = NextResponse.json({ ok: true }); res.cookies.delete(SESSION_COOKIE_NAME); return res; -} +}); diff --git a/app/api/devices/[id]/route.ts b/app/api/devices/[id]/route.ts index ef6898e..54e3314 100644 --- a/app/api/devices/[id]/route.ts +++ b/app/api/devices/[id]/route.ts @@ -1,16 +1,20 @@ import { NextResponse } from "next/server"; import { z } from "zod"; import { renameDevice } from "@/lib/db/queries/devices"; +import { withRouteLogging } from "@/lib/api/with-route-logging"; const bodySchema = z.object({ displayName: z.string().min(1).max(120) }); -export async function PATCH(req: Request, { params }: { params: Promise<{ id: string }> }) { - const { id } = await params; - const parsed = bodySchema.safeParse(await req.json().catch(() => null)); - if (!parsed.success) { - return NextResponse.json({ error: "displayName is required" }, { status: 400 }); - } - const updated = await renameDevice(Number(id), parsed.data.displayName); - if (!updated) return NextResponse.json({ error: "not found" }, { status: 404 }); - return NextResponse.json({ device: updated }); -} +export const PATCH = withRouteLogging( + "devices.rename", + async (req: Request, { params }: { params: Promise<{ id: string }> }) => { + const { id } = await params; + const parsed = bodySchema.safeParse(await req.json().catch(() => null)); + if (!parsed.success) { + return NextResponse.json({ error: "displayName is required" }, { status: 400 }); + } + const updated = await renameDevice(Number(id), parsed.data.displayName); + if (!updated) return NextResponse.json({ error: "not found" }, { status: 404 }); + return NextResponse.json({ device: updated }); + }, +); diff --git a/app/api/devices/route.ts b/app/api/devices/route.ts index 9ee1bad..abc4435 100644 --- a/app/api/devices/route.ts +++ b/app/api/devices/route.ts @@ -1,7 +1,8 @@ import { NextResponse } from "next/server"; import { listDevices } from "@/lib/db/queries/devices"; +import { withRouteLogging } from "@/lib/api/with-route-logging"; -export async function GET() { +export const GET = withRouteLogging("devices.list", async () => { const rows = await listDevices(); return NextResponse.json({ devices: rows }); -} +}); diff --git a/app/api/health/route.ts b/app/api/health/route.ts index 7afa84e..6711db1 100644 --- a/app/api/health/route.ts +++ b/app/api/health/route.ts @@ -1,11 +1,16 @@ import { NextResponse } from "next/server"; import { sqlite } from "@/lib/db/client"; +import { withRouteLogging } from "@/lib/api/with-route-logging"; +import { createLogger } from "@/lib/logger"; -export async function GET() { +const log = createLogger("health"); + +export const GET = withRouteLogging("health.check", async () => { try { sqlite.prepare("select 1").get(); return NextResponse.json({ status: "ok" }); - } catch { + } catch (err) { + log.error("health check failed", { error: err instanceof Error ? err.message : String(err) }); return NextResponse.json({ status: "error" }, { status: 500 }); } -} +}); diff --git a/app/api/play-sessions/[id]/events/route.ts b/app/api/play-sessions/[id]/events/route.ts index b14dcd5..63db4b8 100644 --- a/app/api/play-sessions/[id]/events/route.ts +++ b/app/api/play-sessions/[id]/events/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from "next/server"; import { z } from "zod"; import { insertEvents } from "@/lib/db/queries/session-events"; +import { withRouteLogging } from "@/lib/api/with-route-logging"; const eventSchema = z.object({ sessionDeviceId: z.number().int().positive(), @@ -13,12 +14,15 @@ const eventSchema = z.object({ const bodySchema = z.object({ events: z.array(eventSchema).max(2000) }); -export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) { - const { id } = await params; - const parsed = bodySchema.safeParse(await req.json().catch(() => null)); - if (!parsed.success) { - return NextResponse.json({ error: parsed.error.message }, { status: 400 }); - } - await insertEvents(Number(id), parsed.data.events); - return NextResponse.json({ inserted: parsed.data.events.length }); -} +export const POST = withRouteLogging( + "play-sessions.events.append", + async (req: Request, { params }: { params: Promise<{ id: string }> }) => { + const { id } = await params; + const parsed = bodySchema.safeParse(await req.json().catch(() => null)); + if (!parsed.success) { + return NextResponse.json({ error: parsed.error.message }, { status: 400 }); + } + await insertEvents(Number(id), parsed.data.events); + return NextResponse.json({ inserted: parsed.data.events.length }); + }, +); diff --git a/app/api/play-sessions/[id]/route.ts b/app/api/play-sessions/[id]/route.ts index ccd7804..ae244f7 100644 --- a/app/api/play-sessions/[id]/route.ts +++ b/app/api/play-sessions/[id]/route.ts @@ -2,42 +2,55 @@ import { NextResponse } from "next/server"; import { z } from "zod"; import { deletePlaySession, endPlaySession, getPlaySessionDetail } from "@/lib/db/queries/play-sessions"; import { getRecordingsForSession } from "@/lib/db/queries/recordings"; +import { withRouteLogging } from "@/lib/api/with-route-logging"; +import { createLogger } from "@/lib/logger"; +const log = createLogger("play-sessions"); const patchSchema = z.object({ status: z.enum(["completed", "aborted"]) }); -export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) { - const { id } = await params; - const detail = await getPlaySessionDetail(Number(id)); - if (!detail) return NextResponse.json({ error: "not found" }, { status: 404 }); - return NextResponse.json(detail); -} +export const GET = withRouteLogging( + "play-sessions.get", + async (_req: Request, { params }: { params: Promise<{ id: string }> }) => { + const { id } = await params; + const detail = await getPlaySessionDetail(Number(id)); + if (!detail) return NextResponse.json({ error: "not found" }, { status: 404 }); + return NextResponse.json(detail); + }, +); -export async function PATCH(req: Request, { params }: { params: Promise<{ id: string }> }) { - const { id } = await params; - const parsed = patchSchema.safeParse(await req.json().catch(() => null)); - if (!parsed.success) { - return NextResponse.json({ error: "status must be completed or aborted" }, { status: 400 }); - } - const updated = await endPlaySession(Number(id), parsed.data); - if (!updated) return NextResponse.json({ error: "not found" }, { status: 404 }); - return NextResponse.json({ playSession: updated }); -} - -export async function DELETE(req: Request, { params }: { params: Promise<{ id: string }> }) { - const { id } = await params; - const cascade = new URL(req.url).searchParams.get("cascade") === "true"; - try { - await deletePlaySession(Number(id), { cascade }); - return NextResponse.json({ ok: true }); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - if (message.includes("FOREIGN KEY") || message.includes("SQLITE_CONSTRAINT")) { - const blockingRecordings = await getRecordingsForSession(Number(id)); - return NextResponse.json( - { error: "cannot delete a session that a saved recording still references", recordings: blockingRecordings }, - { status: 409 }, - ); +export const PATCH = withRouteLogging( + "play-sessions.end", + async (req: Request, { params }: { params: Promise<{ id: string }> }) => { + const { id } = await params; + const parsed = patchSchema.safeParse(await req.json().catch(() => null)); + if (!parsed.success) { + return NextResponse.json({ error: "status must be completed or aborted" }, { status: 400 }); } - throw err; - } -} + const updated = await endPlaySession(Number(id), parsed.data); + if (!updated) return NextResponse.json({ error: "not found" }, { status: 404 }); + return NextResponse.json({ playSession: updated }); + }, +); + +export const DELETE = withRouteLogging( + "play-sessions.delete", + async (req: Request, { params }: { params: Promise<{ id: string }> }) => { + const { id } = await params; + const cascade = new URL(req.url).searchParams.get("cascade") === "true"; + try { + await deletePlaySession(Number(id), { cascade }); + return NextResponse.json({ ok: true }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + if (message.includes("FOREIGN KEY") || message.includes("SQLITE_CONSTRAINT")) { + log.warn("blocked session delete due to referencing recording", { sessionId: id, cascade }); + const blockingRecordings = await getRecordingsForSession(Number(id)); + return NextResponse.json( + { error: "cannot delete a session that a saved recording still references", recordings: blockingRecordings }, + { status: 409 }, + ); + } + throw err; + } + }, +); diff --git a/app/api/play-sessions/route.ts b/app/api/play-sessions/route.ts index 5255431..8281a77 100644 --- a/app/api/play-sessions/route.ts +++ b/app/api/play-sessions/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from "next/server"; import { z } from "zod"; import { listPlaySessions, startPlaySession } from "@/lib/db/queries/play-sessions"; +import { withRouteLogging } from "@/lib/api/with-route-logging"; const deviceInputSchema = z.object({ slotLabel: z.string().min(1), @@ -16,16 +17,16 @@ const bodySchema = z.object({ devices: z.array(deviceInputSchema).min(1), }); -export async function GET() { +export const GET = withRouteLogging("play-sessions.list", async () => { const rows = await listPlaySessions(); return NextResponse.json({ playSessions: rows }); -} +}); -export async function POST(req: Request) { +export const POST = withRouteLogging("play-sessions.start", async (req: Request) => { const parsed = bodySchema.safeParse(await req.json().catch(() => null)); if (!parsed.success) { return NextResponse.json({ error: parsed.error.message }, { status: 400 }); } const result = await startPlaySession(parsed.data); return NextResponse.json(result, { status: 201 }); -} +}); diff --git a/app/api/recordings/[id]/route.ts b/app/api/recordings/[id]/route.ts index d544f0c..4de99a5 100644 --- a/app/api/recordings/[id]/route.ts +++ b/app/api/recordings/[id]/route.ts @@ -1,32 +1,42 @@ import { NextResponse } from "next/server"; import { z } from "zod"; import { deleteRecording, getRecording, renameRecording } from "@/lib/db/queries/recordings"; +import { withRouteLogging } from "@/lib/api/with-route-logging"; const patchSchema = z.object({ name: z.string().min(1).max(160).optional(), description: z.string().max(2000).optional(), }); -export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) { - const { id } = await params; - const result = await getRecording(Number(id)); - if (!result) return NextResponse.json({ error: "not found" }, { status: 404 }); - return NextResponse.json(result); -} +export const GET = withRouteLogging( + "recordings.get", + async (_req: Request, { params }: { params: Promise<{ id: string }> }) => { + const { id } = await params; + const result = await getRecording(Number(id)); + if (!result) return NextResponse.json({ error: "not found" }, { status: 404 }); + return NextResponse.json(result); + }, +); -export async function PATCH(req: Request, { params }: { params: Promise<{ id: string }> }) { - const { id } = await params; - const parsed = patchSchema.safeParse(await req.json().catch(() => null)); - if (!parsed.success) { - return NextResponse.json({ error: "invalid body" }, { status: 400 }); - } - const updated = await renameRecording(Number(id), parsed.data); - if (!updated) return NextResponse.json({ error: "not found" }, { status: 404 }); - return NextResponse.json({ recording: updated }); -} +export const PATCH = withRouteLogging( + "recordings.update", + async (req: Request, { params }: { params: Promise<{ id: string }> }) => { + const { id } = await params; + const parsed = patchSchema.safeParse(await req.json().catch(() => null)); + if (!parsed.success) { + return NextResponse.json({ error: "invalid body" }, { status: 400 }); + } + const updated = await renameRecording(Number(id), parsed.data); + if (!updated) return NextResponse.json({ error: "not found" }, { status: 404 }); + return NextResponse.json({ recording: updated }); + }, +); -export async function DELETE(_req: Request, { params }: { params: Promise<{ id: string }> }) { - const { id } = await params; - await deleteRecording(Number(id)); - return NextResponse.json({ ok: true }); -} +export const DELETE = withRouteLogging( + "recordings.delete", + async (_req: Request, { params }: { params: Promise<{ id: string }> }) => { + const { id } = await params; + await deleteRecording(Number(id)); + return NextResponse.json({ ok: true }); + }, +); diff --git a/app/api/recordings/route.ts b/app/api/recordings/route.ts index 64b4c1c..0c398e2 100644 --- a/app/api/recordings/route.ts +++ b/app/api/recordings/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from "next/server"; import { z } from "zod"; import { createRecording, listRecordings } from "@/lib/db/queries/recordings"; +import { withRouteLogging } from "@/lib/api/with-route-logging"; const bodySchema = z.object({ sourcePlaySessionId: z.number().int().positive(), @@ -8,16 +9,16 @@ const bodySchema = z.object({ description: z.string().max(2000).optional(), }); -export async function GET() { +export const GET = withRouteLogging("recordings.list", async () => { const rows = await listRecordings(); return NextResponse.json({ recordings: rows }); -} +}); -export async function POST(req: Request) { +export const POST = withRouteLogging("recordings.create", async (req: Request) => { const parsed = bodySchema.safeParse(await req.json().catch(() => null)); if (!parsed.success) { return NextResponse.json({ error: parsed.error.message }, { status: 400 }); } const recording = await createRecording(parsed.data); return NextResponse.json({ recording }, { status: 201 }); -} +}); diff --git a/app/api/stats/devices/route.ts b/app/api/stats/devices/route.ts index b87697b..f60a6e4 100644 --- a/app/api/stats/devices/route.ts +++ b/app/api/stats/devices/route.ts @@ -1,9 +1,10 @@ import { NextResponse } from "next/server"; import { getDeviceCommandCounts, getDeviceUsageStats } from "@/lib/db/queries/stats"; +import { withRouteLogging } from "@/lib/api/with-route-logging"; -export async function GET() { +export const GET = withRouteLogging("stats.devices", async () => { const [usage, commandCounts] = await Promise.all([getDeviceUsageStats(), getDeviceCommandCounts()]); const commandCountByDevice = new Map(commandCounts.map((c) => [c.deviceId, c.commandCount])); const devices = usage.map((d) => ({ ...d, commandCount: commandCountByDevice.get(d.deviceId) ?? 0 })); return NextResponse.json({ devices }); -} +}); diff --git a/app/api/stats/recordings/route.ts b/app/api/stats/recordings/route.ts index caa6d43..80ab381 100644 --- a/app/api/stats/recordings/route.ts +++ b/app/api/stats/recordings/route.ts @@ -1,7 +1,8 @@ import { NextResponse } from "next/server"; import { getRecordingLibraryStats } from "@/lib/db/queries/stats"; +import { withRouteLogging } from "@/lib/api/with-route-logging"; -export async function GET() { +export const GET = withRouteLogging("stats.recordings", async () => { const stats = await getRecordingLibraryStats(); return NextResponse.json(stats); -} +}); diff --git a/app/api/stats/sessions/[id]/timeline/route.ts b/app/api/stats/sessions/[id]/timeline/route.ts index 97d45ed..f247134 100644 --- a/app/api/stats/sessions/[id]/timeline/route.ts +++ b/app/api/stats/sessions/[id]/timeline/route.ts @@ -1,9 +1,13 @@ import { NextResponse } from "next/server"; import { getSessionTimeline } from "@/lib/db/queries/stats"; +import { withRouteLogging } from "@/lib/api/with-route-logging"; -export async function GET(req: Request, { params }: { params: Promise<{ id: string }> }) { - const { id } = await params; - const bucketMs = Number(new URL(req.url).searchParams.get("bucketMs") ?? 1000); - const timeline = await getSessionTimeline(Number(id), bucketMs); - return NextResponse.json({ timeline }); -} +export const GET = withRouteLogging( + "stats.session-timeline", + async (req: Request, { params }: { params: Promise<{ id: string }> }) => { + const { id } = await params; + const bucketMs = Number(new URL(req.url).searchParams.get("bucketMs") ?? 1000); + const timeline = await getSessionTimeline(Number(id), bucketMs); + return NextResponse.json({ timeline }); + }, +); diff --git a/app/api/stats/sessions/route.ts b/app/api/stats/sessions/route.ts index 9d6321c..17db5da 100644 --- a/app/api/stats/sessions/route.ts +++ b/app/api/stats/sessions/route.ts @@ -1,7 +1,8 @@ import { NextResponse } from "next/server"; import { getSessionsSummary } from "@/lib/db/queries/stats"; +import { withRouteLogging } from "@/lib/api/with-route-logging"; -export async function GET() { +export const GET = withRouteLogging("stats.sessions", async () => { const summary = await getSessionsSummary(); return NextResponse.json(summary); -} +}); diff --git a/app/not-found.tsx b/app/not-found.tsx new file mode 100644 index 0000000..fb53da0 --- /dev/null +++ b/app/not-found.tsx @@ -0,0 +1,26 @@ +import Link from "next/link"; +import type { Metadata } from "next"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { BrandMark } from "@/components/layout/BrandMark"; + +export const metadata: Metadata = { title: "Not found" }; + +export default function NotFound() { + return ( +
404
+This page doesn't exist.
+