From 6ad1683707be959697fd5bd12eb7a90d10f45e9f Mon Sep 17 00:00:00 2001 From: sidadrian3 Date: Thu, 30 Jul 2026 23:08:00 +0800 Subject: [PATCH 1/3] feat: implement custom API error classes and a centralized error handling utility for Next.js API routes --- src/lib/api/errors.ts | 31 +++++++++++++++++++++++++++++++ src/lib/api/handle-api-error.ts | 22 ++++++++++++++++++++++ 2 files changed, 53 insertions(+) create mode 100644 src/lib/api/errors.ts create mode 100644 src/lib/api/handle-api-error.ts diff --git a/src/lib/api/errors.ts b/src/lib/api/errors.ts new file mode 100644 index 0000000..e22d5b9 --- /dev/null +++ b/src/lib/api/errors.ts @@ -0,0 +1,31 @@ +export class AppError extends Error { + constructor(public statusCode: number, message: string) { + super(message); + this.name = this.constructor.name; + Error.captureStackTrace(this, this.constructor); + } +} + +export class BadRequestError extends AppError { + constructor(message: string = "Bad Request") { + super(400, message); + } +} + +export class UnauthorizedError extends AppError { + constructor(message: string = "Unauthorized") { + super(401, message); + } +} + +export class NotFoundError extends AppError { + constructor(message: string = "Not Found") { + super(404, message); + } +} + +export class ConflictError extends AppError { + constructor(message: string = "Conflict") { + super(409, message); + } +} diff --git a/src/lib/api/handle-api-error.ts b/src/lib/api/handle-api-error.ts new file mode 100644 index 0000000..92fac66 --- /dev/null +++ b/src/lib/api/handle-api-error.ts @@ -0,0 +1,22 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { AppError } from "./errors"; + +export function handleApiError(err: unknown): NextResponse { + if (err instanceof AppError) { + return NextResponse.json({ error: err.message }, { status: err.statusCode }); + } + + if (err instanceof z.ZodError) { + return NextResponse.json( + { error: err.issues[0]?.message ?? "Invalid input" }, + { status: 400 } + ); + } + + // Unhandled crashes (e.g. Database connection lost, syntax errors, etc.) + // We log them internally so developers can debug, but we return a generic 500 to the client + // to avoid leaking infrastructure details or stack traces to the public. + console.error("[Unhandled API Error]", err); + return NextResponse.json({ error: "Internal Server Error" }, { status: 500 }); +} From e386e7449fea5efe8f38de494d6df8a79b7f0d5c Mon Sep 17 00:00:00 2001 From: sidadrian3 Date: Thu, 30 Jul 2026 23:40:30 +0800 Subject: [PATCH 2/3] Refactor API routes and services to use custom Error classes --- src/app/api/achievements/route.ts | 7 ++---- src/app/api/exercises/route.ts | 13 +++-------- src/app/api/friends/[id]/accept/route.ts | 11 ++-------- src/app/api/friends/[id]/decline/route.ts | 11 ++-------- src/app/api/friends/[id]/route.ts | 8 ++----- src/app/api/friends/events/route.ts | 3 ++- src/app/api/friends/request/route.ts | 11 ++-------- src/app/api/friends/requests/route.ts | 7 ++---- src/app/api/friends/route.ts | 7 ++---- src/app/api/profile/records/route.ts | 7 ++---- src/app/api/quests/[id]/claim/route.ts | 22 ++----------------- src/app/api/quests/route.ts | 7 ++---- src/app/api/runs/[id]/route.ts | 16 +++----------- src/app/api/runs/route.ts | 19 +++------------- src/app/api/runs/stats/route.ts | 4 ++-- src/app/api/stats/dashboard/route.ts | 7 ++---- src/app/api/user/route.ts | 6 ++--- src/app/api/workouts/[id]/route.ts | 17 +++----------- src/app/api/workouts/route.ts | 19 +++------------- src/lib/api/handle-api-error.ts | 2 -- src/lib/auth/auth-helpers.ts | 3 ++- .../services/__tests__/log-workout.test.ts | 20 +++++++++++------ .../exercises/create-custom-exercise.ts | 3 ++- .../services/friends/accept-friend-request.ts | 7 +++--- .../friends/decline-friend-request.ts | 7 +++--- src/lib/services/friends/remove-friend.ts | 5 +++-- .../services/friends/send-friend-request.ts | 9 ++++---- src/lib/services/quests/claim-quest-reward.ts | 5 +++-- src/lib/services/runs/log-run.ts | 3 ++- src/lib/services/workouts/log-workout.ts | 3 ++- 30 files changed, 83 insertions(+), 186 deletions(-) diff --git a/src/app/api/achievements/route.ts b/src/app/api/achievements/route.ts index d087259..0abcd90 100644 --- a/src/app/api/achievements/route.ts +++ b/src/app/api/achievements/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from "next/server"; import { getAllAchievementsForUser } from "@/lib/data/achievements-db"; import { getAuthUserId } from "@/lib/auth/auth-helpers"; +import { handleApiError } from "@/lib/api/handle-api-error"; export async function GET() { try { @@ -10,10 +11,6 @@ export async function GET() { headers: { 'Cache-control': 'private, max-age=60, stale-while-revalidate=300' } }); } catch (error) { - console.error("Failed to fetch achievements:", error); - return NextResponse.json( - { error: "Failed to fetch achievements" }, - { status: 500 } - ); + return handleApiError(error); } } diff --git a/src/app/api/exercises/route.ts b/src/app/api/exercises/route.ts index 9a10590..29df30f 100644 --- a/src/app/api/exercises/route.ts +++ b/src/app/api/exercises/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from "next/server"; import { getAuthUserId } from "@/lib/auth/auth-helpers"; import { getCustomExercises } from "@/lib/services/exercises/get-custom-exercises"; import { createCustomExercise } from "@/lib/services/exercises/create-custom-exercise"; +import { handleApiError } from "@/lib/api/handle-api-error"; export async function GET() { try { @@ -10,8 +11,7 @@ export async function GET() { return NextResponse.json(customExercises); } catch (error) { - console.error("Error fetching user custom exercises:", error); - return NextResponse.json({ error: "Failed to fetch user custom exercises" }, { status: 500 }); + return handleApiError(error); } } @@ -27,13 +27,6 @@ export async function POST(request: Request) { return NextResponse.json(customExercise, { status: 201 }); } catch (error) { - console.error("Error creating custom exercise:", error); - - // If the error was thrown by our domain logic (e.g. duplicates), send a 400 - if (error instanceof Error && error.message.includes("already created")) { - return NextResponse.json({ error: error.message }, { status: 400 }); - } - - return NextResponse.json({ error: "Failed to create custom exercise" }, { status: 500 }); + return handleApiError(error); } } diff --git a/src/app/api/friends/[id]/accept/route.ts b/src/app/api/friends/[id]/accept/route.ts index c5ce8a9..5864474 100644 --- a/src/app/api/friends/[id]/accept/route.ts +++ b/src/app/api/friends/[id]/accept/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from "next/server"; import { getAuthUserId } from "@/lib/auth/auth-helpers"; import { acceptFriendRequest } from "@/lib/services/friends/accept-friend-request"; import { RateLimit } from "@/lib/auth/rate-limit"; +import { handleApiError } from "@/lib/api/handle-api-error"; export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) { try { @@ -16,14 +17,6 @@ export async function POST(request: Request, { params }: { params: Promise<{ id: const friendship = await acceptFriendRequest(id, userId); return NextResponse.json(friendship); } catch (err) { - if (err instanceof Error && err.message === "Unauthorized") { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - const message = err instanceof Error ? err.message : "Invalid request"; - let status = 400; - if (message.includes("not found")) status = 404; - if (message.includes("Not authorized")) status = 403; - if (message.includes("not pending")) status = 409; - return NextResponse.json({ error: message }, { status }); + return handleApiError(err); } } diff --git a/src/app/api/friends/[id]/decline/route.ts b/src/app/api/friends/[id]/decline/route.ts index 5862146..8a7dbdb 100644 --- a/src/app/api/friends/[id]/decline/route.ts +++ b/src/app/api/friends/[id]/decline/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from "next/server"; import { getAuthUserId } from "@/lib/auth/auth-helpers"; import { declineFriendRequest } from "@/lib/services/friends/decline-friend-request"; import { RateLimit } from "@/lib/auth/rate-limit"; +import { handleApiError } from "@/lib/api/handle-api-error"; export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) { try { @@ -16,14 +17,6 @@ export async function POST(request: Request, { params }: { params: Promise<{ id: const friendship = await declineFriendRequest(id, userId); return NextResponse.json(friendship); } catch (err) { - if (err instanceof Error && err.message === "Unauthorized") { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - const message = err instanceof Error ? err.message : "Invalid request"; - let status = 400; - if (message.includes("not found")) status = 404; - if (message.includes("Not authorized")) status = 403; - if (message.includes("not pending")) status = 409; - return NextResponse.json({ error: message }, { status }); + return handleApiError(err); } } diff --git a/src/app/api/friends/[id]/route.ts b/src/app/api/friends/[id]/route.ts index 95e1b29..9f1ba6a 100644 --- a/src/app/api/friends/[id]/route.ts +++ b/src/app/api/friends/[id]/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from "next/server"; import { getAuthUserId } from "@/lib/auth/auth-helpers"; import { removeFriend } from "@/lib/services/friends/remove-friend"; import { RateLimit } from "@/lib/auth/rate-limit"; +import { handleApiError } from "@/lib/api/handle-api-error"; export async function DELETE(request: Request, { params }: { params: Promise<{ id: string }> }) { try { @@ -16,11 +17,6 @@ export async function DELETE(request: Request, { params }: { params: Promise<{ i const result = await removeFriend(id, userId); return NextResponse.json(result); } catch (err) { - if (err instanceof Error && err.message === "Unauthorized") { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - const message = err instanceof Error ? err.message : "Invalid request"; - const status = message.includes("not found") ? 404 : (message.includes("Not authorized") ? 403 : 400); - return NextResponse.json({ error: message }, { status }); + return handleApiError(err); } } diff --git a/src/app/api/friends/events/route.ts b/src/app/api/friends/events/route.ts index 0881f57..73c6b60 100644 --- a/src/app/api/friends/events/route.ts +++ b/src/app/api/friends/events/route.ts @@ -1,5 +1,6 @@ import { getAuthUserId } from "@/lib/auth/auth-helpers"; import { consumeNextEvent } from "@/lib/sse/sse-publisher"; +import { handleApiError } from "@/lib/api/handle-api-error"; // On Vercel Pro, increase this to 300 for longer-lived SSE connections. // On Hobby plan the max is 60 seconds — the client will auto-reconnect after that. @@ -62,6 +63,6 @@ export async function GET() { }, }); } catch (err) { - return new Response("Unauthorized", { status: 401 }); + return handleApiError(err); } } diff --git a/src/app/api/friends/request/route.ts b/src/app/api/friends/request/route.ts index 28147e6..99c975f 100644 --- a/src/app/api/friends/request/route.ts +++ b/src/app/api/friends/request/route.ts @@ -4,6 +4,7 @@ import { sendFriendRequest } from "@/lib/services/friends/send-friend-request"; import { RateLimit } from "@/lib/auth/rate-limit"; import { SendFriendRequestSchema } from "@/lib/validations/schemas"; import { z } from "zod"; +import { handleApiError } from "@/lib/api/handle-api-error"; export async function POST(request: Request) { try { @@ -20,14 +21,6 @@ export async function POST(request: Request) { const friendship = await sendFriendRequest(userId, parsed.receiverId); return NextResponse.json(friendship, { status: 201 }); } catch (err) { - if (err instanceof Error && err.message === "Unauthorized") { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - if (err instanceof z.ZodError) { - return NextResponse.json({ error: err.issues[0]?.message ?? "Invalid input" }, { status: 400 }); - } - const message = err instanceof Error ? err.message : "Invalid request"; - const status = message.includes("User not found") ? 404 : (message.includes("already sent") ? 409 : 400); - return NextResponse.json({ error: message }, { status }); + return handleApiError(err); } } diff --git a/src/app/api/friends/requests/route.ts b/src/app/api/friends/requests/route.ts index e0207fc..52bc78b 100644 --- a/src/app/api/friends/requests/route.ts +++ b/src/app/api/friends/requests/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from "next/server"; import { getAuthUserId } from "@/lib/auth/auth-helpers"; import { getFriendRequests } from "@/lib/services/friends/get-friend-requests"; +import { handleApiError } from "@/lib/api/handle-api-error"; export async function GET() { try { @@ -8,10 +9,6 @@ export async function GET() { const requests = await getFriendRequests(userId); return NextResponse.json(requests); } catch (err) { - if (err instanceof Error && err.message === "Unauthorized") { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - const message = err instanceof Error ? err.message : "Failed to fetch friend requests"; - return NextResponse.json({ error: message }, { status: 500 }); + return handleApiError(err); } } diff --git a/src/app/api/friends/route.ts b/src/app/api/friends/route.ts index 1807bd0..9bcf8e7 100644 --- a/src/app/api/friends/route.ts +++ b/src/app/api/friends/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from "next/server"; import { getAuthUserId } from "@/lib/auth/auth-helpers"; import { getFriends } from "@/lib/services/friends/get-friends"; +import { handleApiError } from "@/lib/api/handle-api-error"; export async function GET() { try { @@ -8,10 +9,6 @@ export async function GET() { const friends = await getFriends(userId); return NextResponse.json(friends); } catch (err) { - if (err instanceof Error && err.message === "Unauthorized") { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - const message = err instanceof Error ? err.message : "Failed to fetch friends"; - return NextResponse.json({ error: message }, { status: 500 }); + return handleApiError(err); } } diff --git a/src/app/api/profile/records/route.ts b/src/app/api/profile/records/route.ts index 2ab015d..d5b8ee3 100644 --- a/src/app/api/profile/records/route.ts +++ b/src/app/api/profile/records/route.ts @@ -3,6 +3,7 @@ import { getAuthUserId } from "@/lib/auth/auth-helpers"; import { getAllWorkoutsFromDb } from "@/lib/data/workout-db"; import { getAllRunsFromDb } from "@/lib/data/runs-db"; import { calculatePersonalRecords } from "@/lib/utils"; +import { handleApiError } from "@/lib/api/handle-api-error"; export async function GET() { try { @@ -19,10 +20,6 @@ export async function GET() { return NextResponse.json(records); } catch (err) { - console.error("GET /api/profile/records error:", err); - return NextResponse.json( - { error: "Failed to fetch personal records" }, - { status: 500 } - ); + return handleApiError(err); } } diff --git a/src/app/api/quests/[id]/claim/route.ts b/src/app/api/quests/[id]/claim/route.ts index b100ef8..f1b2d64 100644 --- a/src/app/api/quests/[id]/claim/route.ts +++ b/src/app/api/quests/[id]/claim/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from "next/server"; import { claimQuestReward } from "@/lib/services/quests/claim-quest-reward"; import { getAuthUserId } from "@/lib/auth/auth-helpers"; +import { handleApiError } from "@/lib/api/handle-api-error"; export async function POST( request: Request, @@ -16,25 +17,6 @@ export async function POST( success: true, }); } catch (err) { - if(err instanceof Error && err.message === "Unauthorized"){ - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - if(err instanceof Error && err.message === "Quest not found"){ - return NextResponse.json({ error: "Quest not found" }, { status: 404 }); - } - if(err instanceof Error && err.message === "Quest already claimed"){ - return NextResponse.json({ error: "Quest already claimed" }, { status: 400 }); - } - if(err instanceof Error && err.message === "Quest not completed"){ - return NextResponse.json({ error: "Quest not completed" }, { status: 400 }); - } - if(err instanceof Error && err.message === "Quest expired"){ - return NextResponse.json({ error: "Quest expired" }, { status: 400 }); - } - const message = err instanceof Error ? err.message : "Failed to claim quest"; - return NextResponse.json( - { error: message }, - { status: 400 } - ); + return handleApiError(err); } } \ No newline at end of file diff --git a/src/app/api/quests/route.ts b/src/app/api/quests/route.ts index 374dd59..101ad5a 100644 --- a/src/app/api/quests/route.ts +++ b/src/app/api/quests/route.ts @@ -1,6 +1,7 @@ import {NextResponse} from "next/server"; import { getUserQuests } from "@/lib/services/quests/get-user-quests"; import { getAuthUserId } from "@/lib/auth/auth-helpers"; +import { handleApiError } from "@/lib/api/handle-api-error"; export async function GET() { @@ -9,10 +10,6 @@ export async function GET() { const quests = await getUserQuests(userId); return NextResponse.json(quests); } catch (err) { - if(err instanceof Error && err.message === "Unauthorized"){ - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - const message = err instanceof Error ? err.message : "Failed to fetch user quests"; - return NextResponse.json({ error: message }, { status: 500 }); + return handleApiError(err); } } diff --git a/src/app/api/runs/[id]/route.ts b/src/app/api/runs/[id]/route.ts index 29a451d..ec2cdfa 100644 --- a/src/app/api/runs/[id]/route.ts +++ b/src/app/api/runs/[id]/route.ts @@ -4,6 +4,7 @@ import { updateRun } from "@/lib/services/runs/update-run"; import { getAuthUserId } from "@/lib/auth/auth-helpers"; import { CreateRunSchema } from "@/lib/validations/schemas"; import { z } from "zod"; +import { handleApiError } from "@/lib/api/handle-api-error"; export async function PUT( request: Request, @@ -24,14 +25,7 @@ export async function PUT( } return NextResponse.json(result); } catch (err) { - if (err instanceof z.ZodError) { - return NextResponse.json( - { error: err.issues[0]?.message ?? "Invalid input" }, - { status: 400 } - ); - } - const message = err instanceof Error ? err.message : "Invalid request"; - return NextResponse.json({ error: message }, { status: 400 }); + return handleApiError(err); } } @@ -52,10 +46,6 @@ export async function DELETE( } return NextResponse.json({ success: true }, { status: 200 }); } catch (err) { - console.error("DELETE /api/runs/[id] error:", err); - return NextResponse.json( - { error: "Failed to delete run" }, - { status: 500 } - ); + return handleApiError(err); } } \ No newline at end of file diff --git a/src/app/api/runs/route.ts b/src/app/api/runs/route.ts index 5139fc7..7b28a74 100644 --- a/src/app/api/runs/route.ts +++ b/src/app/api/runs/route.ts @@ -5,6 +5,7 @@ import { getAuthUserId } from "@/lib/auth/auth-helpers"; import { CreateRunSchema } from "@/lib/validations/schemas"; import { z } from "zod"; import { RateLimit } from "@/lib/auth/rate-limit"; +import { handleApiError } from "@/lib/api/handle-api-error"; export async function GET(request: Request) { try { @@ -26,11 +27,7 @@ export async function GET(request: Request) { currentPage: page }); } catch (err) { - if(err instanceof Error && err.message === "Unauthorized"){ - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - const message = err instanceof Error ? err.message : "Failed to fetch runs"; - return NextResponse.json({ error: message }, { status: 500 }); + return handleApiError(err); } } @@ -48,16 +45,6 @@ export async function POST(request: Request) { const run = await logRun(parsed, userId); return NextResponse.json(run, { status: 201 }); } catch (err) { - if(err instanceof Error && err.message === "Unauthorized"){ - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - if (err instanceof z.ZodError) { - return NextResponse.json( - { error: err.issues[0]?.message ?? "Invalid input" }, - { status: 400 } - ); - } - const message = err instanceof Error ? err.message : "Invalid request"; - return NextResponse.json({ error: message }, { status: 400 }); + return handleApiError(err); } } diff --git a/src/app/api/runs/stats/route.ts b/src/app/api/runs/stats/route.ts index bdee79d..67df36d 100644 --- a/src/app/api/runs/stats/route.ts +++ b/src/app/api/runs/stats/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from "next/server"; import { getAllRunsFromDb } from "@/lib/data/runs-db"; import { getAuthUserId } from "@/lib/auth/auth-helpers"; import { calcRunStats } from "@/lib/utils"; +import { handleApiError } from "@/lib/api/handle-api-error"; export async function GET() { try { @@ -12,7 +13,6 @@ export async function GET() { const stats = calcRunStats(runs); return NextResponse.json(stats); } catch (err) { - console.error("GET /api/runs/stats error:", err); - return NextResponse.json({ error: "Failed to fetch run stats" }, { status: 500 }); + return handleApiError(err); } } diff --git a/src/app/api/stats/dashboard/route.ts b/src/app/api/stats/dashboard/route.ts index 72bf9eb..3e69ee8 100644 --- a/src/app/api/stats/dashboard/route.ts +++ b/src/app/api/stats/dashboard/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from "next/server"; import { getDashboardStats } from "@/lib/services/users/get-dashboard-stats"; import { getAuthUserId } from "@/lib/auth/auth-helpers"; import { getUser } from "@/lib/services/users/get-user"; +import { handleApiError } from "@/lib/api/handle-api-error"; export async function GET() { try { @@ -12,10 +13,6 @@ export async function GET() { headers: { 'Cache-control': 'private, max-age=60, stale-while-revalidate=300' } }); } catch (error) { - console.error("Failed to fetch dashboard stats:", error); - return NextResponse.json( - { error: "Failed to fetch dashboard stats" }, - { status: 500 } - ); + return handleApiError(error); } } diff --git a/src/app/api/user/route.ts b/src/app/api/user/route.ts index 76ef6cb..593c5bd 100644 --- a/src/app/api/user/route.ts +++ b/src/app/api/user/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from "next/server"; import { getUser } from "@/lib/services/users/get-user"; import { getAuthUserId } from "@/lib/auth/auth-helpers"; +import { handleApiError } from "@/lib/api/handle-api-error"; export async function GET() { try { @@ -8,9 +9,6 @@ export async function GET() { const user = await getUser(userId); return NextResponse.json(user); } catch (err) { - console.error("GET /api/user error:", err); - const errorMessage = err instanceof Error ? err.message : "Failed to fetch user"; - const status = errorMessage === "User not found" || errorMessage === "Unauthorized" ? 401 : 500; - return NextResponse.json({ error: errorMessage }, { status }); + return handleApiError(err); } } diff --git a/src/app/api/workouts/[id]/route.ts b/src/app/api/workouts/[id]/route.ts index 2e3c32f..772dc0f 100644 --- a/src/app/api/workouts/[id]/route.ts +++ b/src/app/api/workouts/[id]/route.ts @@ -4,6 +4,7 @@ import { updateWorkout } from "@/lib/services/workouts/update-workout"; import { getAuthUserId } from "@/lib/auth/auth-helpers"; import { CreateWorkoutSchema } from "@/lib/validations/schemas"; import { z } from "zod"; +import { handleApiError } from "@/lib/api/handle-api-error"; export async function PUT( request: Request, @@ -25,15 +26,7 @@ export async function PUT( return NextResponse.json(result, { status: 200 }); } catch (err) { - if (err instanceof z.ZodError) { - return NextResponse.json( - { error: err.issues[0]?.message ?? "Invalid input" }, - { status: 400 } - ); - } - const message = err instanceof Error ? err.message : "Invalid request"; - console.error("PUT /api/workouts/[id] error:", err); - return NextResponse.json({ error: message }, { status: 400 }); + return handleApiError(err); } } @@ -55,10 +48,6 @@ export async function DELETE( return NextResponse.json({ success: true }, { status: 200 }); } catch (err) { - console.error("DELETE /api/workouts/[id] error:", err); - return NextResponse.json( - { error: "Failed to delete workout" }, - { status: 500 } - ); + return handleApiError(err); } } diff --git a/src/app/api/workouts/route.ts b/src/app/api/workouts/route.ts index 5eb8964..0fffc10 100644 --- a/src/app/api/workouts/route.ts +++ b/src/app/api/workouts/route.ts @@ -5,6 +5,7 @@ import { getAuthUserId } from "@/lib/auth/auth-helpers"; import { CreateWorkoutSchema } from "@/lib/validations/schemas"; import { z } from "zod"; import { RateLimit } from "@/lib/auth/rate-limit"; +import { handleApiError } from "@/lib/api/handle-api-error"; export async function GET(request: Request) { try { @@ -27,11 +28,7 @@ export async function GET(request: Request) { currentPage: page }); } catch (err) { - if(err instanceof Error && err.message === "Unauthorized"){ - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - const message = err instanceof Error ? err.message : "Failed to fetch workouts"; - return NextResponse.json({ error: message }, { status: 500 }); + return handleApiError(err); } } @@ -49,16 +46,6 @@ export async function POST(request: Request) { const workout = await logWorkout(parsed, userId); return NextResponse.json(workout, { status: 201 }); } catch (err) { - if(err instanceof Error && err.message === "Unauthorized"){ - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - if (err instanceof z.ZodError) { - return NextResponse.json( - { error: err.issues[0]?.message ?? "Invalid input" }, - { status: 400 } - ); - } - const message = err instanceof Error ? err.message : "Invalid request"; - return NextResponse.json({ error: message }, { status: 400 }); + return handleApiError(err); } } diff --git a/src/lib/api/handle-api-error.ts b/src/lib/api/handle-api-error.ts index 92fac66..912a17f 100644 --- a/src/lib/api/handle-api-error.ts +++ b/src/lib/api/handle-api-error.ts @@ -15,8 +15,6 @@ export function handleApiError(err: unknown): NextResponse { } // Unhandled crashes (e.g. Database connection lost, syntax errors, etc.) - // We log them internally so developers can debug, but we return a generic 500 to the client - // to avoid leaking infrastructure details or stack traces to the public. console.error("[Unhandled API Error]", err); return NextResponse.json({ error: "Internal Server Error" }, { status: 500 }); } diff --git a/src/lib/auth/auth-helpers.ts b/src/lib/auth/auth-helpers.ts index 5fc56af..70e4159 100644 --- a/src/lib/auth/auth-helpers.ts +++ b/src/lib/auth/auth-helpers.ts @@ -1,6 +1,7 @@ import { auth } from "@/lib/auth/server"; import { headers } from "next/headers"; import { NextResponse } from "next/server"; +import { UnauthorizedError } from "@/lib/api/errors"; export async function getAuthUserId(): Promise { let session = null; @@ -13,7 +14,7 @@ export async function getAuthUserId(): Promise { } if (!session?.user?.id) { - throw new Error("Unauthorized"); + throw new UnauthorizedError("Unauthorized"); } return session.user.id; } diff --git a/src/lib/services/__tests__/log-workout.test.ts b/src/lib/services/__tests__/log-workout.test.ts index 2aca18f..7293457 100644 --- a/src/lib/services/__tests__/log-workout.test.ts +++ b/src/lib/services/__tests__/log-workout.test.ts @@ -22,6 +22,8 @@ describe('logWorkout Integration Test', () => { streak: 0, totalWorkouts: 0, totalDistance: 0, + stamina: 100, + lastStaminaUpdate: new Date(), createdAt: new Date(), }); userId = result.insertedId.toString(); @@ -43,7 +45,8 @@ describe('logWorkout Integration Test', () => { exercises: [ { name: "Bench Press", targetMuscle: TargetMuscle.Chest, sets: 3, reps: 10, weight: 135 }, { name: "Squats", targetMuscle: TargetMuscle.Legs, sets: 3, reps: 10, weight: 225 } - ] + ], + idempotencyKey: crypto.randomUUID() }; const workout = await logWorkout(workoutInput, userId); @@ -98,12 +101,13 @@ describe('logWorkout Integration Test', () => { it('should correctly deduct stamina for a normal workout', async () => { // 1. Arrange: Update the dummy user to have 100 stamina const usersCol = await getCollection("usersCollection"); - await usersCol.updateOne({ _id: new ObjectId(userId) }, { $set: { stamina: 100, lastStaminaUpdate: new Date().toISOString() } }); + await usersCol.updateOne({ _id: new ObjectId(userId) }, { $set: { stamina: 100, lastStaminaUpdate: new Date() } }); const workoutInput = { title: "Normal Workout", duration: 60, // 60 min -> 15 + 30 = 45 cost - exercises: [{ name: "Squats", targetMuscle: TargetMuscle.Legs, sets: 3, reps: 10, weight: 225 }] + exercises: [{ name: "Squats", targetMuscle: TargetMuscle.Legs, sets: 3, reps: 10, weight: 225 }], + idempotencyKey: crypto.randomUUID() }; // 2. Act @@ -117,12 +121,13 @@ describe('logWorkout Integration Test', () => { it('should apply exhaustion debuff if stamina is 0', async () => { // 1. Arrange: Update the dummy user to have 0 stamina const usersCol = await getCollection("usersCollection"); - await usersCol.updateOne({ _id: new ObjectId(userId) }, { $set: { stamina: 0, lastStaminaUpdate: new Date().toISOString() } }); + await usersCol.updateOne({ _id: new ObjectId(userId) }, { $set: { stamina: 0, lastStaminaUpdate: new Date() } }); const workoutInput = { title: "Exhausted Workout", duration: 60, // Base XP: 60*2 + 1*15 = 135 - exercises: [{ name: "Running", targetMuscle: TargetMuscle.Cardio, sets: 1, reps: 1, weight: null }] + exercises: [{ name: "Running", targetMuscle: TargetMuscle.Cardio, sets: 1, reps: 1, weight: null }], + idempotencyKey: crypto.randomUUID() }; // 2. Act @@ -140,12 +145,13 @@ describe('logWorkout Integration Test', () => { twoDaysAgo.setUTCDate(twoDaysAgo.getUTCDate() - 2); const usersCol = await getCollection("usersCollection"); - await usersCol.updateOne({ _id: new ObjectId(userId) }, { $set: { stamina: 10, lastStaminaUpdate: twoDaysAgo.toISOString() } }); + await usersCol.updateOne({ _id: new ObjectId(userId) }, { $set: { stamina: 10, lastStaminaUpdate: twoDaysAgo } }); const workoutInput = { title: "Recovered Workout", duration: 60, // 45 cost - exercises: [{ name: "Squats", targetMuscle: TargetMuscle.Legs, sets: 3, reps: 10, weight: 225 }] + exercises: [{ name: "Squats", targetMuscle: TargetMuscle.Legs, sets: 3, reps: 10, weight: 225 }], + idempotencyKey: crypto.randomUUID() }; // 2. Act diff --git a/src/lib/services/exercises/create-custom-exercise.ts b/src/lib/services/exercises/create-custom-exercise.ts index 07c65f7..366482c 100644 --- a/src/lib/services/exercises/create-custom-exercise.ts +++ b/src/lib/services/exercises/create-custom-exercise.ts @@ -1,6 +1,7 @@ import { TargetMuscle, CustomExercise } from "@/lib/types"; import { getCustomExerciseByNameFromDb, createCustomExerciseFromDb } from "@/lib/data/custom-exercises-db"; import { formatExerciseName } from "@/lib/domain/exercise-rules"; +import { ConflictError } from "@/lib/api/errors"; export async function createCustomExercise(userId: string, rawName: string, targetMuscle: TargetMuscle): Promise { const name = formatExerciseName(rawName); @@ -8,7 +9,7 @@ export async function createCustomExercise(userId: string, rawName: string, targ // 1. Domain Validation: Check for duplicates const existing = await getCustomExerciseByNameFromDb(userId, name, targetMuscle); if (existing) { - throw new Error("You already created an exercise with this name for this muscle group."); + throw new ConflictError("You already created an exercise with this name for this muscle group."); } // 2. Persistence diff --git a/src/lib/services/friends/accept-friend-request.ts b/src/lib/services/friends/accept-friend-request.ts index 8229de3..ee4659e 100644 --- a/src/lib/services/friends/accept-friend-request.ts +++ b/src/lib/services/friends/accept-friend-request.ts @@ -3,23 +3,24 @@ import { getFriendshipBetweenFromDb, updateFriendshipStatusInDb } from "@/lib/da import { getUserFromDb } from "@/lib/data/user-db"; import { publishToUser } from "@/lib/sse/sse-publisher"; import { after } from "next/server"; +import { NotFoundError, ConflictError, UnauthorizedError } from "@/lib/api/errors"; export async function acceptFriendRequest(targetUserId: string, userId: string): Promise { // 1. Fetch existing request between the two users const friendship = await getFriendshipBetweenFromDb(userId, targetUserId); if (!friendship) { - throw new Error("Friend request not found."); + throw new NotFoundError("Friend request not found."); } // 2. Validate state if (friendship.status !== "pending") { - throw new Error("Friend request is not pending."); + throw new ConflictError("Friend request is not pending."); } // 3. Authorize (only the receiver can accept) if (friendship.receiverId !== userId) { - throw new Error("Not authorized to respond to this request."); + throw new UnauthorizedError("Not authorized to respond to this request."); } // 4. Update status in database using the fetched friendship's ID diff --git a/src/lib/services/friends/decline-friend-request.ts b/src/lib/services/friends/decline-friend-request.ts index 2e50920..5b03714 100644 --- a/src/lib/services/friends/decline-friend-request.ts +++ b/src/lib/services/friends/decline-friend-request.ts @@ -1,21 +1,22 @@ import type { Friendship } from "@/lib/types"; import { getFriendshipBetweenFromDb, updateFriendshipStatusInDb } from "@/lib/data/friendships-db"; +import { NotFoundError, ConflictError, UnauthorizedError } from "@/lib/api/errors"; export async function declineFriendRequest(targetUserId: string, userId: string): Promise { // 1. Fetch existing request between the two users const friendship = await getFriendshipBetweenFromDb(userId, targetUserId); if (!friendship) { - throw new Error("Friend request not found."); + throw new NotFoundError("Friend request not found."); } // 2. Validate state if (friendship.status !== "pending") { - throw new Error("Friend request is not pending."); + throw new ConflictError("Friend request is not pending."); } // 3. Authorize (only the receiver can decline) if (friendship.receiverId !== userId) { - throw new Error("Not authorized to respond to this request."); + throw new UnauthorizedError("Not authorized to respond to this request."); } // 4. Update status in database (tombstone) using the fetched friendship's ID diff --git a/src/lib/services/friends/remove-friend.ts b/src/lib/services/friends/remove-friend.ts index 7617f46..da9d39a 100644 --- a/src/lib/services/friends/remove-friend.ts +++ b/src/lib/services/friends/remove-friend.ts @@ -1,14 +1,15 @@ import { getFriendshipBetweenFromDb, deleteFriendshipInDb } from "@/lib/data/friendships-db"; +import { NotFoundError, UnauthorizedError } from "@/lib/api/errors"; export async function removeFriend(targetUserId: string, userId: string): Promise<{ success: boolean }> { const friendship = await getFriendshipBetweenFromDb(userId, targetUserId); if (!friendship) { - throw new Error("Friendship not found."); + throw new NotFoundError("Friendship not found."); } // Either the requester or the receiver can remove the friendship if (friendship.requesterId !== userId && friendship.receiverId !== userId) { - throw new Error("Not authorized to remove this friend."); + throw new UnauthorizedError("Not authorized to remove this friend."); } const success = await deleteFriendshipInDb(friendship.id, userId); diff --git a/src/lib/services/friends/send-friend-request.ts b/src/lib/services/friends/send-friend-request.ts index 60b7099..944dea4 100644 --- a/src/lib/services/friends/send-friend-request.ts +++ b/src/lib/services/friends/send-friend-request.ts @@ -4,6 +4,7 @@ import { getUserFromDb } from "@/lib/data/user-db"; import { getFriendshipBetweenFromDb, insertFriendshipInDb } from "@/lib/data/friendships-db"; import { publishToUser } from "@/lib/sse/sse-publisher"; import { after } from "next/server"; +import { NotFoundError, ConflictError } from "@/lib/api/errors"; export async function sendFriendRequest(requesterId: string, receiverId: string): Promise { @@ -11,17 +12,17 @@ export async function sendFriendRequest(requesterId: string, receiverId: string) const receiver = await getUserFromDb(receiverId); if (!receiver) { - throw new Error("User not found."); + throw new NotFoundError("User not found."); } const requester = await getUserFromDb(requesterId); if (!requester) { - throw new Error("Requester not found."); + throw new NotFoundError("Requester not found."); } const existing = await getFriendshipBetweenFromDb(receiverId, requesterId); if (existing && existing.status !== "declined") { - throw new Error("Friend request already sent."); + throw new ConflictError("Friend request already sent."); } let friendshipObj: Friendship; @@ -37,7 +38,7 @@ export async function sendFriendRequest(requesterId: string, receiverId: string) const err = error as { code?: number; keyPattern?: { requesterId?: string } }; if (err.code === 11000 && err.keyPattern?.requesterId) { console.log("Duplicate friend request ignored safely."); - throw new Error("This friend request was already sent."); + throw new ConflictError("This friend request was already sent."); } throw error; } diff --git a/src/lib/services/quests/claim-quest-reward.ts b/src/lib/services/quests/claim-quest-reward.ts index c305338..423b841 100644 --- a/src/lib/services/quests/claim-quest-reward.ts +++ b/src/lib/services/quests/claim-quest-reward.ts @@ -4,6 +4,7 @@ import { getUserQuestByIdFromDb, markUserQuestClaimedInDb, getQuestTemplateByIdF import { grantUserXP } from "@/lib/services/users/grant-user-xp"; import clientPromise from "@/lib/mongodb"; import type { User } from "@/lib/types"; +import { NotFoundError, ConflictError } from "@/lib/api/errors"; export async function claimQuestReward(userId: string, questId: string): Promise<{ user: User; levelUp: boolean } | undefined> { await syncUserQuests(userId); @@ -11,7 +12,7 @@ export async function claimQuestReward(userId: string, questId: string): Promise const quest = await getUserQuestByIdFromDb(questId, userId); if (!quest) { - throw new Error("Quest not found"); + throw new NotFoundError("Quest not found"); } // Validate using domain logic @@ -27,7 +28,7 @@ export async function claimQuestReward(userId: string, questId: string): Promise const modifiedCount = await markUserQuestClaimedInDb(quest._id!.toString(), session); if (modifiedCount === 0) { - throw new Error("Quest already claimed"); + throw new ConflictError("Quest already claimed"); } // Apply side effects diff --git a/src/lib/services/runs/log-run.ts b/src/lib/services/runs/log-run.ts index fac1040..36ff710 100644 --- a/src/lib/services/runs/log-run.ts +++ b/src/lib/services/runs/log-run.ts @@ -12,6 +12,7 @@ import { calcPace, } from "@/lib/domain/run-rules"; import { after } from "next/server"; +import { ConflictError } from "@/lib/api/errors"; export async function logRun( input: CreateRunInput, @@ -69,7 +70,7 @@ export async function logRun( const err = error as { code?: number; keyPattern?: { idempotencyKey?: number } }; if (err.code === 11000 && err.keyPattern?.idempotencyKey) { console.log("Duplicate run request ignored safely."); - throw new Error("This run was already logged."); + throw new ConflictError("This run was already logged."); } throw error; } finally { diff --git a/src/lib/services/workouts/log-workout.ts b/src/lib/services/workouts/log-workout.ts index 348c4fd..5a97197 100644 --- a/src/lib/services/workouts/log-workout.ts +++ b/src/lib/services/workouts/log-workout.ts @@ -15,6 +15,7 @@ import { UserStateService } from "@/lib/services/users/user-state.service"; import { evaluateAchievements } from "@/lib/services/achievements/evaluate-achievements"; import clientPromise from "@/lib/mongodb"; import { after } from "next/server"; +import { ConflictError } from "@/lib/api/errors"; export async function logWorkout( input: CreateWorkoutInput, @@ -68,7 +69,7 @@ export async function logWorkout( const err = error as { code?: number; keyPattern?: { idempotencyKey?: number } }; if (err.code === 11000 && err.keyPattern?.idempotencyKey) { console.log("Duplicate workout request ignored safely."); - throw new Error("This workout was already logged."); + throw new ConflictError("This workout was already logged."); } throw error; } finally { From 2eacb35913d1cbcc92274acf3c31283e1c1e116e Mon Sep 17 00:00:00 2001 From: sidadrian3 Date: Fri, 31 Jul 2026 00:02:53 +0800 Subject: [PATCH 3/3] docs: Update architecture and briefing docs for centralized error handling --- architecture_review.md | 110 +++++++++++++++++++++++++++-------------- project_briefing.md | 5 +- 2 files changed, 76 insertions(+), 39 deletions(-) diff --git a/architecture_review.md b/architecture_review.md index 9bf2960..c89cf41 100644 --- a/architecture_review.md +++ b/architecture_review.md @@ -45,12 +45,12 @@ Service (log-workout.ts) ### The 4-Layer Stack -| Layer | Location | Responsibility | -|---|---|---| -| **HTTP Boundary** | `src/app/api/*/route.ts` | Auth, rate-limit, Zod parse. No business logic. | -| **Application Services** | `src/lib/services/**/*.ts` | Orchestrates domain + persistence + side effects. | -| **Domain Logic** | `src/lib/domain/*.ts` | **Pure functions only.** Zero infrastructure imports. | -| **Data Access** | `src/lib/data/*-db.ts` | Dumb persistence. Wraps MongoDB. Owns `toXxx()` mappers. | +| Layer | Location | Responsibility | +| ------------------------ | -------------------------- | -------------------------------------------------------- | +| **HTTP Boundary** | `src/app/api/*/route.ts` | Auth, rate-limit, Zod parse. No business logic. | +| **Application Services** | `src/lib/services/**/*.ts` | Orchestrates domain + persistence + side effects. | +| **Domain Logic** | `src/lib/domain/*.ts` | **Pure functions only.** Zero infrastructure imports. | +| **Data Access** | `src/lib/data/*-db.ts` | Dumb persistence. Wraps MongoDB. Owns `toXxx()` mappers. | --- @@ -59,12 +59,15 @@ Service (log-workout.ts) ### 🏗 Creational **Singleton (MongoDB Connection Pooling)** + - `mongodb.ts` uses `global._mongoClientPromise` to prevent hot-reload from spawning extra clients in development. **Factory Function** + - `getCollection("workoutsCollection")` acts as a typed factory hiding all connection details from callers. **Lazy Initialization** + - `getRedis()` in `sse-publisher.ts` creates a Redis client on-demand per call — correctly stateless for Vercel's serverless model. --- @@ -72,12 +75,15 @@ Service (log-workout.ts) ### 🔧 Structural **Adapter / Data Mapper** + - `toWorkout()`, `toUser()`, `toRun()` — each `*-db.ts` module owns a private mapper translating `ObjectId`/`Date` MongoDB documents into clean domain types (`WorkoutDoc → Workout`). **Facade** + - `api-client/index.ts` is a barrel facade. Consumers import from one place, and the internals are split across 9 focused modules (`workouts.ts`, `runs.ts`, etc.). **Decorator (Auth Guard)** + - `getAuthUserId()` acts as a consistent auth gate — every API route calls it first as a synchronized cross-cutting concern. --- @@ -85,12 +91,15 @@ Service (log-workout.ts) ### 🎭 Behavioral **Strategy (Quest Dispatch Table)** + - `QUEST_ACTIVITY_UPDATES` in `quest-rules.ts` is a type-safe dispatch table. Adding a new activity type (e.g., `yoga_session`) requires zero changes to calling code — just a new entry in the table. **Optimistic Locking (Retry Loop)** + - `grant-user-xp.ts` uses a `__v` version field + retry loop to prevent XP race conditions without full pessimistic locks or advisory locks. **Observer / Event-Driven** + - Side effects (level-up SSE notification, achievement evaluation) are decoupled from the transaction using fire-and-forget `async` patterns with `.catch()` error boundaries. The DB transaction never waits on them. --- @@ -98,6 +107,7 @@ Service (log-workout.ts) ## 03 — Code Smells & Issues ### 🔴 HIGH — Dead Commented-Out Code + **File:** `src/lib/data/achievements-db.ts` ~40 lines of achievement seeding logic is commented out. This is a code graveyard — it adds cognitive noise and implies the seeding approach was abandoned mid-refactor without cleanup. @@ -106,10 +116,11 @@ Service (log-workout.ts) --- -### 🟠 HIGH — Wrong HTTP Status Codes (Deployment Risk) +### 🟠 HIGH — Wrong HTTP Status Codes [✅ RESOLVED] + **File:** `src/app/api/workouts/route.ts` (and replicated in ~8 other routes) -In the POST handler's catch-all, any non-Zod, non-Auth error (e.g., a MongoDB connection timeout) returns a `400 Bad Request`. A server crash should be `500 Internal Server Error`. +_Note: This was resolved by implementing the `handleApiError` utility and semantic error classes (`AppError`). Unhandled server crashes now correctly return `500 Internal Server Error`, while domain exceptions return appropriate statuses like `400`, `401`, `404`, and `409`._ ```typescript // BEFORE (smell) — catch-all returns 400 for everything @@ -117,7 +128,10 @@ const message = err instanceof Error ? err.message : "Invalid request"; return NextResponse.json({ error: message }, { status: 400 }); // ← WRONG // AFTER (fix) — distinguish domain errors from infra errors -if (err instanceof Error && err.message === "This workout was already logged.") { +if ( + err instanceof Error && + err.message === "This workout was already logged." +) { return NextResponse.json({ error: err.message }, { status: 409 }); } return NextResponse.json({ error: "Internal Server Error" }, { status: 500 }); @@ -126,9 +140,11 @@ return NextResponse.json({ error: "Internal Server Error" }, { status: 500 }); --- ### 🟡 MEDIUM — Two Conflicting Streak Functions + **File:** `src/lib/domain/user-rules.ts` The domain has **two** streak calculation functions: + - `calcNewStreak()` — called during activity, increments the stored streak - `calculateStreak()` — recalculates from an array of dates; appears **unused** in any service @@ -137,9 +153,11 @@ The domain has **two** streak calculation functions: --- ### 🟡 MEDIUM — Business Logic Leaking Into the Data Mapper + **File:** `src/lib/data/user-db.ts` — `toUser()` function The `toUser()` Data Mapper (Layer 4) is executing non-trivial domain rules: + 1. Calling `calcRecoveredStamina()` — a stamina domain calculation 2. Containing inline streak display validation logic @@ -164,6 +182,7 @@ const stamina = calcRecoveredStamina(raw.stamina, raw.lastStaminaUpdate, new Dat --- ### 🔵 LOW — Hardcoded SSE Duration + **File:** `src/app/api/friends/events/route.ts` ```typescript @@ -175,6 +194,7 @@ Should be driven by an env var so upgrading Vercel plans doesn't require a code --- ### 🔵 LOW — Missing Env Var Validation for Upstash Redis + **File:** `src/env.ts` `UPSTASH_REDIS_REST_URL` and `UPSTASH_REDIS_REST_TOKEN` are **not** declared in `env.ts`. A missing Vercel env var causes a runtime crash, not a clean startup failure. @@ -207,21 +227,28 @@ await userState.applyActivity(userId, { xp, workout, stamina }, session); --- -### Candidate B — Route Error Handler HOF ⭐ Worth Exploring +### Candidate B — Route Error Handler HOF [✅ RESOLVED VIA UTILITY] -Every route has identical try/catch boilerplate. A `withApiHandler()` wrapper eliminates duplication and fixes status codes in one shot: +We explored creating a `withApiHandler()` wrapper, but ultimately chose to implement a centralized `handleApiError` utility function. This allowed us to keep the explicit `try/catch` blocks in every route for better debugging traceability, while reducing the catch block to a single, standardized line: `return handleApiError(err);`. ```typescript // src/lib/api/with-api-handler.ts export function withApiHandler(fn: RouteHandler): RouteHandler { return async (req, ctx) => { - try { return await fn(req, ctx); } - catch (err) { + try { + return await fn(req, ctx); + } catch (err) { if (err instanceof Error && err.message === "Unauthorized") return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); if (err instanceof z.ZodError) - return NextResponse.json({ error: err.issues[0]?.message }, { status: 400 }); - return NextResponse.json({ error: "Internal Server Error" }, { status: 500 }); + return NextResponse.json( + { error: err.issues[0]?.message }, + { status: 400 }, + ); + return NextResponse.json( + { error: "Internal Server Error" }, + { status: 500 }, + ); } }; } @@ -231,38 +258,42 @@ export function withApiHandler(fn: RouteHandler): RouteHandler { ### Candidate C — Vercel Deployment Readiness ⭐ Strong — Do This First -| | Item | Priority | -|---|---|---| -| ✅ | Add `UPSTASH_REDIS_*` to `env.ts` | **Fix before deploy** | -| ✅ | Verify `BETTER_AUTH_URL` is set in Vercel env vars | **Fix before deploy** | -| ✅ | Wrap fire-and-forget promises in `waitUntil()` (`log-workout.ts` & others) | **Fix before deploy** | -| ✅ | Confirm `migrate-db.ts` targets Atlas (not Docker) on Vercel build | Verify | -| ✅ | Run seed script against Atlas cluster before go-live | Manual step | -| ⚠️ | SSE drops every 60s on Hobby plan — add "reconnecting" UI state | Nice to have | -| ⚠️ | Remove `console.log` from `evaluate-achievements.ts` | Nice to have | +| | Item | Priority | +| --- | -------------------------------------------------------------------------- | --------------------- | +| ✅ | Add `UPSTASH_REDIS_*` to `env.ts` | **Fix before deploy** | +| ✅ | Verify `BETTER_AUTH_URL` is set in Vercel env vars | **Fix before deploy** | +| ✅ | Wrap fire-and-forget promises in `waitUntil()` (`log-workout.ts` & others) | **Fix before deploy** | +| ✅ | Confirm `migrate-db.ts` targets Atlas (not Docker) on Vercel build | Verify | +| ✅ | Run seed script against Atlas cluster before go-live | Manual step | +| ⚠️ | SSE drops every 60s on Hobby plan — add "reconnecting" UI state | Nice to have | +| ⚠️ | Remove `console.log` from `evaluate-achievements.ts` | Nice to have | --- ## 05 — Authentication & Authorization ### The Framework: `better-auth` + The application uses **[better-auth](https://better-auth.com/)** with the `mongodbAdapter` as the core identity provider. + - **Provider:** Email and Password (configured in `src/lib/auth/server.ts`). - **Session Storage:** Server-side sessions persisted in MongoDB. ### Authorization Method: The Decorator Pattern + There is no complex Role-Based Access Control (RBAC) yet. Authorization is handled via a simple but effective decorator-style gatekeeper: `getAuthUserId()`. -1. **The Gatekeeper (`auth-helpers.ts`):** +1. **The Gatekeeper (`auth-helpers.ts`):** Reads the incoming request headers and checks `auth.api.getSession()`. If no valid session exists, it deliberately throws `new Error("Unauthorized")`. 2. **The Route Handlers (`route.ts`):** - Every protected API route begins by calling `const userId = await getAuthUserId();`. + Every protected API route begins by calling `const userId = await getAuthUserId();`. 3. **The Error Boundary:** The pervasive `try/catch` block in every route handler catches that specific `"Unauthorized"` error string and returns a `401` HTTP response. **Architectural Assessment of Auth:** -- **Pros:** It's extremely explicit. You can't accidentally expose a protected route because you must call `getAuthUserId()` to get the `userId` needed for any database query. -- **Cons:** It relies on throwing a generic `Error` with a magic string (`"Unauthorized"`) rather than a custom exception class (e.g., `class UnauthorizedError extends Error`). This is part of the reason why the `withApiHandler()` HOF (Candidate B) is strongly recommended — it would clean up this magic string matching across all routes. + +- **Pros:** It's extremely explicit. You can't accidentally expose a protected route because you must call `getAuthUserId()` to get the `userId` needed for any database query. +- **Cons:** Initially, it relied on throwing a generic `Error` with a magic string (`"Unauthorized"`). This has since been resolved by introducing a semantic `UnauthorizedError` class and the centralized `handleApiError` utility, which cleanly translates it to a `401` response. --- @@ -272,7 +303,6 @@ There is no complex Role-Based Access Control (RBAC) yet. Authorization is handl 2. **Wrap background tasks in `waitUntil()`** — prevent Vercel from freezing container mid-execution. 3. **Seed MongoDB Atlas** with achievements and quests. Vercel build does NOT run the seed script. 4. **Delete the commented-out code** in `achievements-db.ts` — 2-minute cleanup. -5. **Fix HTTP status codes** in API routes — `500` for server errors, not `400`. > **Overall assessment:** This is a genuinely well-structured Next.js codebase. The 4-layer separation is clean, the pure domain layer is excellent, and the optimistic locking pattern for XP is sophisticated. The main technical debt is in error handling consistency and domain logic leaking into the data mapper. Both are fixable in a day. @@ -283,18 +313,24 @@ There is no complex Role-Based Access Control (RBAC) yet. Authorization is handl Based on our architectural review and brainstorming session, the following frontend-heavy gamification features are approved for the next development cycle before the Vercel deployment: ### 1. Interactive Human Anatomy UI + A visual representation of the human muscular system mapping to the `TargetMuscle` enum (`Chest`, `Back`, `Legs`, etc.). It will be implemented across 3 contexts: -* **The 7-Day Heatmap (Dashboard):** Evaluates workout history over the past 7 days. Muscle groups glow red/orange based on training volume, helping identify neglected muscle groups. -* **Live Session "Pump" Tracker (Workout View):** As exercises are added to an active session, the corresponding muscles light up instantly, acting as a visual checklist. -* **Recovery Monitor (Profile/Dashboard):** Evaluates fatigue. Muscles trained recently start red (exhausted) and slowly transition to green (recovered) over a 48-72 hour window. + +- **The 7-Day Heatmap (Dashboard):** Evaluates workout history over the past 7 days. Muscle groups glow red/orange based on training volume, helping identify neglected muscle groups. +- **Live Session "Pump" Tracker (Workout View):** As exercises are added to an active session, the corresponding muscles light up instantly, acting as a visual checklist. +- **Recovery Monitor (Profile/Dashboard):** Evaluates fatigue. Muscles trained recently start red (exhausted) and slowly transition to green (recovered) over a 48-72 hour window. ### 2. Workout Templates + Frictionless workout entry. Allows users to save a collection of exercises as a named template (e.g., "Push Day", "Upper Body Power"). -* **Data Layer:** Will require a new MongoDB collection or sub-document on the user profile (`Template[]`). -* **UX:** A 1-click "Start from Template" button on the workout screen that pre-populates the exercise list. + +- **Data Layer:** Will require a new MongoDB collection or sub-document on the user profile (`Template[]`). +- **UX:** A 1-click "Start from Template" button on the workout screen that pre-populates the exercise list. ### 3. "Beat Your Ghost" (Personal Records Integration) + A gamified pacing mechanic utilizing the existing `PersonalRecord[]` data. -* **Live Target:** When logging an exercise (e.g., Bench Press) or a run, the UI fetches and displays the user's historical PR as the "Ghost to beat". -* **Social Hype:** If the user logs a value that exceeds their ghost, it triggers a confetti/explosion animation locally. -* **Event Integration:** Hooked into the Upstash Redis SSE system to broadcast a special achievement toast to all friends: *"Adrian just shattered their Bench Press record!"* + +- **Live Target:** When logging an exercise (e.g., Bench Press) or a run, the UI fetches and displays the user's historical PR as the "Ghost to beat". +- **Social Hype:** If the user logs a value that exceeds their ghost, it triggers a confetti/explosion animation locally. +- **Event Integration:** Hooked into the Upstash Redis SSE system to broadcast a special achievement toast to all friends: _"Adrian just shattered their Bench Press record!"_ diff --git a/project_briefing.md b/project_briefing.md index c994c5f..f2681b1 100644 --- a/project_briefing.md +++ b/project_briefing.md @@ -221,6 +221,7 @@ POST /api/workouts - **Serverless Background Tasks (`after()` API):** Migrated all fire-and-forget background promises (`evaluateAchievements`, `notifyFriendsLevelUp`) to the Next.js `after()` API to ensure they complete in serverless environments (like Vercel) before the container freezes. - **Unified `UserStateService`:** Consolidated 4 fragmented database updates (XP, Streak, Stamina, Stats) into a single deep module. `log-workout.ts` and `log-run.ts` now execute a single atomic `findOneAndUpdate` via `UserStateService.applyActivity()` instead of scattering 4+ separate update calls. - **Vitest Mocking:** Added a global `vitest.setup.ts` to elegantly mock the Next.js `next/server` `after()` API (handling both callbacks and Promises) without wiping out `NextResponse`, keeping the test suite fast and 100% green. +- **Centralized API Error Handling:** Migrated all services to throw semantic exceptions (`ConflictError`, `NotFoundError`, `UnauthorizedError`) and standardized all 19 API routes to use a centralized `handleApiError` utility, ensuring consistent HTTP status codes across the app. ### Why was it built this way? @@ -245,7 +246,7 @@ These are architectural upgrades that make the system more scalable and robust. | --- | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 16 | **Quest template caching** | Right now, every quest read hits MongoDB to fetch all active templates. Templates almost never change. A 60-second in-memory cache (or `React.cache`) would cut this to near-zero DB reads. | -| 19 | **Centralized error handling** | Every API route has its own `try/catch` with slightly different error handling. A `withAuth(handler)` wrapper would standardize all of this. | +| 19 | **Centralized error handling (COMPLETED)** | Every API route had its own `try/catch` with slightly different error handling. We standardized this using semantic error classes and a `handleApiError` utility. | --- @@ -330,7 +331,7 @@ This feature has been fully implemented. It serves two purposes: | Priority | Issue | Status | | -------- | ----------------------------------------------------------------- | --------- | | 🟢 P3 | Quest template caching | In plan | -| 🟢 P3 | Centralized error handler wrapper | In plan | +| ✅ Done | Centralized API error handling | Completed | | ✅ Done | Friend System + Real-Time SSE | Completed | | 🟢 P3 | log-workout-test, stamina and lastStaminaUpdate update test mocks | In plan |