Prechádzať zdrojové kódy

RHL-007-refactor: standardize error handling and response structure across API routes

Code_Uwe 9 hodín pred
rodič
commit
ae1c624263

+ 43 - 25
app/api/auth/login/route.js

@@ -1,8 +1,13 @@
-// app/api/auth/login/route.js
 import bcrypt from "bcryptjs";
 import User from "@/models/user";
 import { getDb } from "@/lib/db";
 import { createSession } from "@/lib/auth/session";
+import {
+	withErrorHandling,
+	json,
+	badRequest,
+	unauthorized,
+} from "@/lib/api/errors";
 
 /**
  * POST /api/auth/login
@@ -12,72 +17,85 @@ import { createSession } from "@/lib/auth/session";
  *   "username": "example.user",
  *   "password": "plain-text-password"
  * }
+ *
+ * Error contract (standardized):
+ * {
+ *   "error": { "message": "...", "code": "...", "details"?: {...} }
+ * }
  */
-export async function POST(request) {
-	try {
+export const POST = withErrorHandling(
+	async function POST(request) {
+		// --- 1) Parse body ------------------------------------------------------
+		// request.json() can throw if the JSON is invalid (e.g. broken body).
 		let body;
-
 		try {
 			body = await request.json();
 		} catch {
-			return jsonResponse({ error: "Invalid request body" }, 400);
+			throw badRequest("VALIDATION_INVALID_JSON", "Invalid request body");
 		}
 
+		// We only accept objects as JSON body for this endpoint.
 		if (!body || typeof body !== "object") {
-			return jsonResponse({ error: "Invalid request body" }, 400);
+			throw badRequest("VALIDATION_INVALID_BODY", "Invalid request body");
 		}
 
+		// --- 2) Validate credentials input -------------------------------------
 		const { username, password } = body;
 
+		// Keep validation strict and predictable:
+		// - Must be strings
+		// - Must not be empty/whitespace
 		if (
 			typeof username !== "string" ||
 			typeof password !== "string" ||
 			!username.trim() ||
 			!password.trim()
 		) {
-			return jsonResponse({ error: "Missing username or password" }, 400);
+			throw badRequest(
+				"VALIDATION_MISSING_FIELD",
+				"Missing username or password",
+				{
+					fields: ["username", "password"],
+				}
+			);
 		}
 
+		// Normalize usernames to avoid case/whitespace issues.
 		const normalizedUsername = username.trim().toLowerCase();
 
+		// --- 3) Load user from DB ----------------------------------------------
 		// Ensure DB (Mongoose) connection is established before using models.
 		await getDb();
 
 		const user = await User.findOne({ username: normalizedUsername }).exec();
 
+		// Do not leak whether a username exists; always return "Invalid credentials".
 		if (!user) {
-			return jsonResponse({ error: "Invalid credentials" }, 401);
+			throw unauthorized("AUTH_INVALID_CREDENTIALS", "Invalid credentials");
 		}
 
 		// Defensive: never let missing/invalid passwordHash crash the endpoint.
+		// Treat it like invalid credentials.
 		if (typeof user.passwordHash !== "string" || !user.passwordHash) {
-			return jsonResponse({ error: "Invalid credentials" }, 401);
+			throw unauthorized("AUTH_INVALID_CREDENTIALS", "Invalid credentials");
 		}
 
+		// --- 4) Verify password -------------------------------------------------
 		const passwordMatches = await bcrypt.compare(password, user.passwordHash);
 
 		if (!passwordMatches) {
-			return jsonResponse({ error: "Invalid credentials" }, 401);
+			throw unauthorized("AUTH_INVALID_CREDENTIALS", "Invalid credentials");
 		}
 
+		// --- 5) Create session cookie ------------------------------------------
 		await createSession({
 			userId: user._id.toString(),
 			role: user.role,
 			branchId: user.branchId ?? null,
 		});
 
-		return jsonResponse({ ok: true }, 200);
-	} catch (error) {
-		console.error("Login error:", error);
-		return jsonResponse({ error: "Internal server error" }, 500);
-	}
-}
-
-function jsonResponse(data, status = 200) {
-	return new Response(JSON.stringify(data), {
-		status,
-		headers: {
-			"Content-Type": "application/json",
-		},
-	});
-}
+		// Happy path response stays unchanged:
+		return json({ ok: true }, 200);
+	},
+	{ logPrefix: "[api/auth/login]" }
+);

+ 56 - 11
app/api/auth/login/route.test.js

@@ -1,4 +1,3 @@
-// app/api/auth/login/route.test.js
 import { describe, it, expect, vi, beforeEach } from "vitest";
 
 // 1) Mocks
@@ -86,6 +85,26 @@ describe("POST /api/auth/login", () => {
 		});
 	});
 
+	it("returns 400 when JSON parsing fails", async () => {
+		// Simulate request.json() throwing (invalid JSON body).
+		const request = {
+			json: vi.fn().mockRejectedValue(new Error("invalid json")),
+		};
+
+		const response = await POST(request);
+		const body = await response.json();
+
+		expect(response.status).toBe(400);
+		expect(body).toEqual({
+			error: {
+				message: "Invalid request body",
+				code: "VALIDATION_INVALID_JSON",
+			},
+		});
+
+		expect(createSession).not.toHaveBeenCalled();
+	});
+
 	it("returns 401 when user does not exist", async () => {
 		User.findOne.mockReturnValue({
 			exec: vi.fn().mockResolvedValue(null),
@@ -97,10 +116,15 @@ describe("POST /api/auth/login", () => {
 		});
 
 		const response = await POST(request);
-		const json = await response.json();
+		const body = await response.json();
 
 		expect(response.status).toBe(401);
-		expect(json).toEqual({ error: "Invalid credentials" });
+		expect(body).toEqual({
+			error: {
+				message: "Invalid credentials",
+				code: "AUTH_INVALID_CREDENTIALS",
+			},
+		});
 
 		expect(createSession).not.toHaveBeenCalled();
 	});
@@ -122,10 +146,15 @@ describe("POST /api/auth/login", () => {
 		});
 
 		const response = await POST(request);
-		const json = await response.json();
+		const body = await response.json();
 
 		expect(response.status).toBe(401);
-		expect(json).toEqual({ error: "Invalid credentials" });
+		expect(body).toEqual({
+			error: {
+				message: "Invalid credentials",
+				code: "AUTH_INVALID_CREDENTIALS",
+			},
+		});
 
 		expect(bcryptCompare).not.toHaveBeenCalled();
 		expect(createSession).not.toHaveBeenCalled();
@@ -152,10 +181,15 @@ describe("POST /api/auth/login", () => {
 		});
 
 		const response = await POST(request);
-		const json = await response.json();
+		const body = await response.json();
 
 		expect(response.status).toBe(401);
-		expect(json).toEqual({ error: "Invalid credentials" });
+		expect(body).toEqual({
+			error: {
+				message: "Invalid credentials",
+				code: "AUTH_INVALID_CREDENTIALS",
+			},
+		});
 
 		expect(createSession).not.toHaveBeenCalled();
 	});
@@ -166,10 +200,16 @@ describe("POST /api/auth/login", () => {
 		});
 
 		const response = await POST(request);
-		const json = await response.json();
+		const body = await response.json();
 
 		expect(response.status).toBe(400);
-		expect(json).toEqual({ error: "Missing username or password" });
+		expect(body).toEqual({
+			error: {
+				message: "Missing username or password",
+				code: "VALIDATION_MISSING_FIELD",
+				details: { fields: ["username", "password"] },
+			},
+		});
 
 		expect(User.findOne).not.toHaveBeenCalled();
 		expect(createSession).not.toHaveBeenCalled();
@@ -186,10 +226,15 @@ describe("POST /api/auth/login", () => {
 		});
 
 		const response = await POST(request);
-		const json = await response.json();
+		const body = await response.json();
 
 		expect(response.status).toBe(500);
-		expect(json).toEqual({ error: "Internal server error" });
+		expect(body).toEqual({
+			error: {
+				message: "Internal server error",
+				code: "INTERNAL_SERVER_ERROR",
+			},
+		});
 
 		expect(createSession).not.toHaveBeenCalled();
 	});

+ 11 - 21
app/api/auth/logout/route.js

@@ -1,30 +1,20 @@
-// app/api/auth/logout/route.js
 import { destroySession } from "@/lib/auth/session";
+import { withErrorHandling, json } from "@/lib/api/errors";
 
 /**
  * GET /api/auth/logout
  *
  * Destroys the current session by clearing the auth cookie.
  * Always returns { ok: true } on success.
+ *
+ * Note:
+ * - This endpoint is intentionally idempotent.
+ * - If there is no cookie, destroySession() still sets an empty cookie.
  */
-export async function GET() {
-	try {
+export const GET = withErrorHandling(
+	async function GET() {
 		await destroySession();
-
-		return new Response(JSON.stringify({ ok: true }), {
-			status: 200,
-			headers: {
-				"Content-Type": "application/json",
-			},
-		});
-	} catch (error) {
-		console.error("Logout error:", error);
-
-		return new Response(JSON.stringify({ error: "Internal server error" }), {
-			status: 500,
-			headers: {
-				"Content-Type": "application/json",
-			},
-		});
-	}
-}
+		return json({ ok: true }, 200);
+	},
+	{ logPrefix: "[api/auth/logout]" }
+);

+ 7 - 2
app/api/auth/logout/route.test.js

@@ -30,11 +30,16 @@ describe("GET /api/auth/logout", () => {
 		});
 
 		const response = await GET();
-		const json = await response.json();
+		const body = await response.json();
 
 		expect(destroySession).toHaveBeenCalledTimes(1);
 
 		expect(response.status).toBe(500);
-		expect(json).toEqual({ error: "Internal server error" });
+		expect(body).toEqual({
+			error: {
+				message: "Internal server error",
+				code: "INTERNAL_SERVER_ERROR",
+			},
+		});
 	});
 });

+ 43 - 33
app/api/branches/[branch]/[year]/[month]/days/route.js

@@ -1,46 +1,56 @@
-// app/api/branches/[branch]/[year]/[month]/days/route.js
-import { NextResponse } from "next/server";
 import { listDays } from "@/lib/storage";
 import { getSession } from "@/lib/auth/session";
 import { canAccessBranch } from "@/lib/auth/permissions";
+import {
+	withErrorHandling,
+	json,
+	badRequest,
+	unauthorized,
+	forbidden,
+} from "@/lib/api/errors";
+import { mapStorageReadError } from "@/lib/api/storageErrors";
 
 /**
  * GET /api/branches/[branch]/[year]/[month]/days
+ *
+ * Happy-path response must remain unchanged:
+ * { "branch":"NL01", "year":"2024", "month":"10", "days":["23", ...] }
  */
-export async function GET(request, ctx) {
-	const session = await getSession();
+export const GET = withErrorHandling(
+	async function GET(request, ctx) {
+		const session = await getSession();
 
-	if (!session) {
-		return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
-	}
+		if (!session) {
+			throw unauthorized("AUTH_UNAUTHENTICATED", "Unauthorized");
+		}
 
-	const { branch, year, month } = await ctx.params;
-	console.log("[/api/branches/[branch]/[year]/[month]/days] params:", {
-		branch,
-		year,
-		month,
-	});
+		const { branch, year, month } = await ctx.params;
 
-	if (!branch || !year || !month) {
-		return NextResponse.json(
-			{ error: "branch, year oder month fehlt" },
-			{ status: 400 }
-		);
-	}
+		const missing = [];
+		if (!branch) missing.push("branch");
+		if (!year) missing.push("year");
+		if (!month) missing.push("month");
 
-	if (!canAccessBranch(session, branch)) {
-		return NextResponse.json({ error: "Forbidden" }, { status: 403 });
-	}
+		if (missing.length > 0) {
+			throw badRequest(
+				"VALIDATION_MISSING_PARAM",
+				"Missing required route parameter(s)",
+				{ params: missing }
+			);
+		}
 
-	try {
-		const days = await listDays(branch, year, month);
-		return NextResponse.json({ branch, year, month, days });
-	} catch (error) {
-		console.error("[/api/branches/[branch]/[year]/[month]/days] Error:", error);
+		if (!canAccessBranch(session, branch)) {
+			throw forbidden("AUTH_FORBIDDEN_BRANCH", "Forbidden");
+		}
 
-		return NextResponse.json(
-			{ error: "Fehler beim Lesen der Tage: " + error.message },
-			{ status: 500 }
-		);
-	}
-}
+		try {
+			const days = await listDays(branch, year, month);
+			return json({ branch, year, month, days }, 200);
+		} catch (err) {
+			throw await mapStorageReadError(err, {
+				details: { branch, year, month },
+			});
+		}
+	},
+	{ logPrefix: "[api/branches/[branch]/[year]/[month]/days]" }
+);

+ 37 - 14
app/api/branches/[branch]/[year]/[month]/days/route.test.js

@@ -36,13 +36,13 @@ describe("GET /api/branches/[branch]/[year]/[month]/days", () => {
 
 		const res = await GET(
 			new Request("http://localhost/api/branches/NL01/2024/10/days"),
-			{
-				params: Promise.resolve({ branch: "NL01", year: "2024", month: "10" }),
-			}
+			{ params: Promise.resolve({ branch: "NL01", year: "2024", month: "10" }) }
 		);
 
 		expect(res.status).toBe(401);
-		expect(await res.json()).toEqual({ error: "Unauthorized" });
+		expect(await res.json()).toEqual({
+			error: { message: "Unauthorized", code: "AUTH_UNAUTHENTICATED" },
+		});
 	});
 
 	it("returns 403 when branch user accesses a different branch", async () => {
@@ -54,13 +54,13 @@ describe("GET /api/branches/[branch]/[year]/[month]/days", () => {
 
 		const res = await GET(
 			new Request("http://localhost/api/branches/NL02/2024/10/days"),
-			{
-				params: Promise.resolve({ branch: "NL02", year: "2024", month: "10" }),
-			}
+			{ params: Promise.resolve({ branch: "NL02", year: "2024", month: "10" }) }
 		);
 
 		expect(res.status).toBe(403);
-		expect(await res.json()).toEqual({ error: "Forbidden" });
+		expect(await res.json()).toEqual({
+			error: { message: "Forbidden", code: "AUTH_FORBIDDEN_BRANCH" },
+		});
 	});
 
 	it("returns days for a valid branch/year/month when allowed", async () => {
@@ -72,14 +72,11 @@ describe("GET /api/branches/[branch]/[year]/[month]/days", () => {
 
 		const res = await GET(
 			new Request("http://localhost/api/branches/NL01/2024/10/days"),
-			{
-				params: Promise.resolve({ branch: "NL01", year: "2024", month: "10" }),
-			}
+			{ params: Promise.resolve({ branch: "NL01", year: "2024", month: "10" }) }
 		);
 
 		expect(res.status).toBe(200);
-		const body = await res.json();
-		expect(body).toEqual({
+		expect(await res.json()).toEqual({
 			branch: "NL01",
 			year: "2024",
 			month: "10",
@@ -107,7 +104,33 @@ describe("GET /api/branches/[branch]/[year]/[month]/days", () => {
 
 		expect(res.status).toBe(400);
 		expect(await res.json()).toEqual({
-			error: "branch, year oder month fehlt",
+			error: {
+				message: "Missing required route parameter(s)",
+				code: "VALIDATION_MISSING_PARAM",
+				details: { params: ["month"] },
+			},
+		});
+	});
+
+	it("returns 404 when the month folder does not exist (authorized)", async () => {
+		getSession.mockResolvedValue({
+			role: "admin",
+			branchId: null,
+			userId: "u2",
+		});
+
+		const res = await GET(
+			new Request("http://localhost/api/branches/NL01/2024/99/days"),
+			{ params: Promise.resolve({ branch: "NL01", year: "2024", month: "99" }) }
+		);
+
+		expect(res.status).toBe(404);
+		expect(await res.json()).toEqual({
+			error: {
+				message: "Not found",
+				code: "FS_NOT_FOUND",
+				details: { branch: "NL01", year: "2024", month: "99" },
+			},
 		});
 	});
 });

+ 41 - 32
app/api/branches/[branch]/[year]/months/route.js

@@ -1,45 +1,54 @@
-// app/api/branches/[branch]/[year]/months/route.js
-import { NextResponse } from "next/server";
 import { listMonths } from "@/lib/storage";
 import { getSession } from "@/lib/auth/session";
 import { canAccessBranch } from "@/lib/auth/permissions";
+import {
+	withErrorHandling,
+	json,
+	badRequest,
+	unauthorized,
+	forbidden,
+} from "@/lib/api/errors";
+import { mapStorageReadError } from "@/lib/api/storageErrors";
 
 /**
  * GET /api/branches/[branch]/[year]/months
+ *
+ * Happy-path response must remain unchanged:
+ * { "branch": "NL01", "year": "2024", "months": ["10", ...] }
  */
-export async function GET(request, ctx) {
-	const session = await getSession();
+export const GET = withErrorHandling(
+	async function GET(request, ctx) {
+		const session = await getSession();
 
-	if (!session) {
-		return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
-	}
+		if (!session) {
+			throw unauthorized("AUTH_UNAUTHENTICATED", "Unauthorized");
+		}
 
-	const { branch, year } = await ctx.params;
-	console.log("[/api/branches/[branch]/[year]/months] params:", {
-		branch,
-		year,
-	});
+		const { branch, year } = await ctx.params;
 
-	if (!branch || !year) {
-		return NextResponse.json(
-			{ error: "branch oder year fehlt" },
-			{ status: 400 }
-		);
-	}
+		// Validate required route params early.
+		const missing = [];
+		if (!branch) missing.push("branch");
+		if (!year) missing.push("year");
 
-	if (!canAccessBranch(session, branch)) {
-		return NextResponse.json({ error: "Forbidden" }, { status: 403 });
-	}
+		if (missing.length > 0) {
+			throw badRequest(
+				"VALIDATION_MISSING_PARAM",
+				"Missing required route parameter(s)",
+				{ params: missing }
+			);
+		}
 
-	try {
-		const months = await listMonths(branch, year);
-		return NextResponse.json({ branch, year, months });
-	} catch (error) {
-		console.error("[/api/branches/[branch]/[year]/months] Error:", error);
+		if (!canAccessBranch(session, branch)) {
+			throw forbidden("AUTH_FORBIDDEN_BRANCH", "Forbidden");
+		}
 
-		return NextResponse.json(
-			{ error: "Fehler beim Lesen der Monate: " + error.message },
-			{ status: 500 }
-		);
-	}
-}
+		try {
+			const months = await listMonths(branch, year);
+			return json({ branch, year, months }, 200);
+		} catch (err) {
+			throw await mapStorageReadError(err, { details: { branch, year } });
+		}
+	},
+	{ logPrefix: "[api/branches/[branch]/[year]/months]" }
+);

+ 44 - 17
app/api/branches/[branch]/[year]/months/route.test.js

@@ -36,13 +36,13 @@ describe("GET /api/branches/[branch]/[year]/months", () => {
 
 		const res = await GET(
 			new Request("http://localhost/api/branches/NL01/2024/months"),
-			{
-				params: Promise.resolve({ branch: "NL01", year: "2024" }),
-			}
+			{ params: Promise.resolve({ branch: "NL01", year: "2024" }) }
 		);
 
 		expect(res.status).toBe(401);
-		expect(await res.json()).toEqual({ error: "Unauthorized" });
+		expect(await res.json()).toEqual({
+			error: { message: "Unauthorized", code: "AUTH_UNAUTHENTICATED" },
+		});
 	});
 
 	it("returns 403 when branch user accesses a different branch", async () => {
@@ -54,13 +54,13 @@ describe("GET /api/branches/[branch]/[year]/months", () => {
 
 		const res = await GET(
 			new Request("http://localhost/api/branches/NL02/2024/months"),
-			{
-				params: Promise.resolve({ branch: "NL02", year: "2024" }),
-			}
+			{ params: Promise.resolve({ branch: "NL02", year: "2024" }) }
 		);
 
 		expect(res.status).toBe(403);
-		expect(await res.json()).toEqual({ error: "Forbidden" });
+		expect(await res.json()).toEqual({
+			error: { message: "Forbidden", code: "AUTH_FORBIDDEN_BRANCH" },
+		});
 	});
 
 	it("returns months for a valid branch/year when allowed", async () => {
@@ -72,14 +72,15 @@ describe("GET /api/branches/[branch]/[year]/months", () => {
 
 		const res = await GET(
 			new Request("http://localhost/api/branches/NL01/2024/months"),
-			{
-				params: Promise.resolve({ branch: "NL01", year: "2024" }),
-			}
+			{ params: Promise.resolve({ branch: "NL01", year: "2024" }) }
 		);
 
 		expect(res.status).toBe(200);
-		const body = await res.json();
-		expect(body).toEqual({ branch: "NL01", year: "2024", months: ["10"] });
+		expect(await res.json()).toEqual({
+			branch: "NL01",
+			year: "2024",
+			months: ["10"],
+		});
 	});
 
 	it("returns 400 when branch or year is missing (authenticated)", async () => {
@@ -91,12 +92,38 @@ describe("GET /api/branches/[branch]/[year]/months", () => {
 
 		const res = await GET(
 			new Request("http://localhost/api/branches/NL01//months"),
-			{
-				params: Promise.resolve({ branch: "NL01", year: undefined }),
-			}
+			{ params: Promise.resolve({ branch: "NL01", year: undefined }) }
 		);
 
 		expect(res.status).toBe(400);
-		expect(await res.json()).toEqual({ error: "branch oder year fehlt" });
+		expect(await res.json()).toEqual({
+			error: {
+				message: "Missing required route parameter(s)",
+				code: "VALIDATION_MISSING_PARAM",
+				details: { params: ["year"] },
+			},
+		});
+	});
+
+	it("returns 404 when the year folder does not exist (authorized)", async () => {
+		getSession.mockResolvedValue({
+			role: "admin",
+			branchId: null,
+			userId: "u2",
+		});
+
+		const res = await GET(
+			new Request("http://localhost/api/branches/NL01/2099/months"),
+			{ params: Promise.resolve({ branch: "NL01", year: "2099" }) }
+		);
+
+		expect(res.status).toBe(404);
+		expect(await res.json()).toEqual({
+			error: {
+				message: "Not found",
+				code: "FS_NOT_FOUND",
+				details: { branch: "NL01", year: "2099" },
+			},
+		});
 	});
 });

+ 43 - 31
app/api/branches/[branch]/years/route.js

@@ -1,43 +1,55 @@
-// app/api/branches/[branch]/years/route.js
-import { NextResponse } from "next/server";
 import { listYears } from "@/lib/storage";
 import { getSession } from "@/lib/auth/session";
 import { canAccessBranch } from "@/lib/auth/permissions";
+import {
+	withErrorHandling,
+	json,
+	badRequest,
+	unauthorized,
+	forbidden,
+} from "@/lib/api/errors";
+import { mapStorageReadError } from "@/lib/api/storageErrors";
 
 /**
  * GET /api/branches/[branch]/years
+ *
+ * Happy-path response must remain unchanged:
+ * { "branch": "NL01", "years": ["2024", ...] }
  */
-export async function GET(request, ctx) {
-	const session = await getSession();
+export const GET = withErrorHandling(
+	async function GET(request, ctx) {
+		const session = await getSession();
 
-	if (!session) {
-		return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
-	}
+		if (!session) {
+			throw unauthorized("AUTH_UNAUTHENTICATED", "Unauthorized");
+		}
 
-	// Next.js 16: params are resolved asynchronously via ctx.params
-	const { branch } = await ctx.params;
-	console.log("[/api/branches/[branch]/years] params:", { branch });
+		// Next.js App Router: params are resolved asynchronously.
+		const { branch } = await ctx.params;
 
-	if (!branch) {
-		return NextResponse.json(
-			{ error: "branch Parameter fehlt" },
-			{ status: 400 }
-		);
-	}
+		// Validate required route params early (client error => 400).
+		if (!branch) {
+			throw badRequest(
+				"VALIDATION_MISSING_PARAM",
+				"Missing required route parameter(s)",
+				{ params: ["branch"] }
+			);
+		}
 
-	if (!canAccessBranch(session, branch)) {
-		return NextResponse.json({ error: "Forbidden" }, { status: 403 });
-	}
+		// RBAC: branch users can only access their own branch.
+		if (!canAccessBranch(session, branch)) {
+			throw forbidden("AUTH_FORBIDDEN_BRANCH", "Forbidden");
+		}
 
-	try {
-		const years = await listYears(branch);
-		return NextResponse.json({ branch, years });
-	} catch (error) {
-		console.error("[/api/branches/[branch]/years] Error:", error);
-
-		return NextResponse.json(
-			{ error: "Fehler beim Lesen der Jahre: " + error.message },
-			{ status: 500 }
-		);
-	}
-}
+		try {
+			const years = await listYears(branch);
+			return json({ branch, years }, 200);
+		} catch (err) {
+			// Convert filesystem errors into:
+			// - 404 if the requested path does not exist (but NAS root is reachable)
+			// - 500 for system-level storage failures
+			throw await mapStorageReadError(err, { details: { branch } });
+		}
+	},
+	{ logPrefix: "[api/branches/[branch]/years]" }
+);

+ 43 - 19
app/api/branches/[branch]/years/route.test.js

@@ -35,13 +35,13 @@ describe("GET /api/branches/[branch]/years", () => {
 
 		const res = await GET(
 			new Request("http://localhost/api/branches/NL01/years"),
-			{
-				params: Promise.resolve({ branch: "NL01" }),
-			}
+			{ params: Promise.resolve({ branch: "NL01" }) }
 		);
 
 		expect(res.status).toBe(401);
-		expect(await res.json()).toEqual({ error: "Unauthorized" });
+		expect(await res.json()).toEqual({
+			error: { message: "Unauthorized", code: "AUTH_UNAUTHENTICATED" },
+		});
 	});
 
 	it("returns 403 when branch user accesses a different branch", async () => {
@@ -53,13 +53,13 @@ describe("GET /api/branches/[branch]/years", () => {
 
 		const res = await GET(
 			new Request("http://localhost/api/branches/NL02/years"),
-			{
-				params: Promise.resolve({ branch: "NL02" }),
-			}
+			{ params: Promise.resolve({ branch: "NL02" }) }
 		);
 
 		expect(res.status).toBe(403);
-		expect(await res.json()).toEqual({ error: "Forbidden" });
+		expect(await res.json()).toEqual({
+			error: { message: "Forbidden", code: "AUTH_FORBIDDEN_BRANCH" },
+		});
 	});
 
 	it("returns years for a valid branch when allowed", async () => {
@@ -71,14 +71,11 @@ describe("GET /api/branches/[branch]/years", () => {
 
 		const res = await GET(
 			new Request("http://localhost/api/branches/NL01/years"),
-			{
-				params: Promise.resolve({ branch: "NL01" }),
-			}
+			{ params: Promise.resolve({ branch: "NL01" }) }
 		);
 
 		expect(res.status).toBe(200);
-		const body = await res.json();
-		expect(body).toEqual({ branch: "NL01", years: ["2024"] });
+		expect(await res.json()).toEqual({ branch: "NL01", years: ["2024"] });
 	});
 
 	it("returns 400 when branch param is missing (authenticated)", async () => {
@@ -93,7 +90,35 @@ describe("GET /api/branches/[branch]/years", () => {
 		});
 
 		expect(res.status).toBe(400);
-		expect(await res.json()).toEqual({ error: "branch Parameter fehlt" });
+		expect(await res.json()).toEqual({
+			error: {
+				message: "Missing required route parameter(s)",
+				code: "VALIDATION_MISSING_PARAM",
+				details: { params: ["branch"] },
+			},
+		});
+	});
+
+	it("returns 404 when the branch folder does not exist (authorized)", async () => {
+		getSession.mockResolvedValue({
+			role: "admin",
+			branchId: null,
+			userId: "u2",
+		});
+
+		const res = await GET(
+			new Request("http://localhost/api/branches/NL99/years"),
+			{ params: Promise.resolve({ branch: "NL99" }) }
+		);
+
+		expect(res.status).toBe(404);
+		expect(await res.json()).toEqual({
+			error: {
+				message: "Not found",
+				code: "FS_NOT_FOUND",
+				details: { branch: "NL99" },
+			},
+		});
 	});
 
 	it("returns 500 when NAS_ROOT_PATH is invalid (authenticated)", async () => {
@@ -107,13 +132,12 @@ describe("GET /api/branches/[branch]/years", () => {
 
 		const res = await GET(
 			new Request("http://localhost/api/branches/NL01/years"),
-			{
-				params: Promise.resolve({ branch: "NL01" }),
-			}
+			{ params: Promise.resolve({ branch: "NL01" }) }
 		);
 
 		expect(res.status).toBe(500);
-		const body = await res.json();
-		expect(body.error).toContain("Fehler beim Lesen der Jahre:");
+		expect(await res.json()).toEqual({
+			error: { message: "Internal server error", code: "FS_STORAGE_ERROR" },
+		});
 	});
 });

+ 32 - 20
app/api/branches/route.js

@@ -1,8 +1,12 @@
-// app/api/branches/route.js
-import { NextResponse } from "next/server";
 import { listBranches } from "@/lib/storage";
 import { getSession } from "@/lib/auth/session";
 import { filterBranchesForSession } from "@/lib/auth/permissions";
+import {
+	withErrorHandling,
+	json,
+	unauthorized,
+	ApiError,
+} from "@/lib/api/errors";
 
 /**
  * GET /api/branches
@@ -14,25 +18,33 @@ import { filterBranchesForSession } from "@/lib/auth/permissions";
  * - 401 if no session
  * - branch role: only returns its own branch
  * - admin/dev: returns all branches
+ *
+ * Happy-path response must remain unchanged:
+ * { "branches": ["NL01", ...] }
  */
-export async function GET() {
-	const session = await getSession();
-
-	if (!session) {
-		return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
-	}
+export const GET = withErrorHandling(
+	async function GET() {
+		const session = await getSession();
 
-	try {
-		const branches = await listBranches();
-		const visibleBranches = filterBranchesForSession(session, branches);
+		if (!session) {
+			throw unauthorized("AUTH_UNAUTHENTICATED", "Unauthorized");
+		}
 
-		return NextResponse.json({ branches: visibleBranches });
-	} catch (error) {
-		console.error("[api/branches] Error reading branches:", error);
+		try {
+			const branches = await listBranches();
+			const visibleBranches = filterBranchesForSession(session, branches);
 
-		return NextResponse.json(
-			{ error: "Fehler beim Lesen der Niederlassungen" },
-			{ status: 500 }
-		);
-	}
-}
+			return json({ branches: visibleBranches }, 200);
+		} catch (err) {
+			// Treat any storage read failures as internal server errors.
+			// We do NOT expose filesystem error messages to the client.
+			throw new ApiError({
+				status: 500,
+				code: "FS_STORAGE_ERROR",
+				message: "Internal server error",
+				cause: err,
+			});
+		}
+	},
+	{ logPrefix: "[api/branches]" }
+);

+ 6 - 2
app/api/branches/route.test.js

@@ -36,7 +36,9 @@ describe("GET /api/branches", () => {
 		expect(res.status).toBe(401);
 
 		const body = await res.json();
-		expect(body).toEqual({ error: "Unauthorized" });
+		expect(body).toEqual({
+			error: { message: "Unauthorized", code: "AUTH_UNAUTHENTICATED" },
+		});
 	});
 
 	it("returns only the own branch for branch users", async () => {
@@ -86,6 +88,8 @@ describe("GET /api/branches", () => {
 		expect(res.status).toBe(500);
 
 		const body = await res.json();
-		expect(body).toEqual({ error: "Fehler beim Lesen der Niederlassungen" });
+		expect(body).toEqual({
+			error: { message: "Internal server error", code: "FS_STORAGE_ERROR" },
+		});
 	});
 });

+ 56 - 40
app/api/files/route.js

@@ -1,47 +1,63 @@
-// app/api/files/route.js
-import { NextResponse } from "next/server";
 import { listFiles } from "@/lib/storage";
 import { getSession } from "@/lib/auth/session";
 import { canAccessBranch } from "@/lib/auth/permissions";
+import {
+	withErrorHandling,
+	json,
+	badRequest,
+	unauthorized,
+	forbidden,
+} from "@/lib/api/errors";
+import { mapStorageReadError } from "@/lib/api/storageErrors";
 
 /**
  * GET /api/files?branch=&year=&month=&day=
+ *
+ * Happy-path response must remain unchanged:
+ * { "branch":"NL01", "year":"2024", "month":"10", "day":"23", "files":[...] }
  */
-export async function GET(request) {
-	const session = await getSession();
-
-	if (!session) {
-		return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
-	}
-
-	const { searchParams } = new URL(request.url);
-	const branch = searchParams.get("branch");
-	const year = searchParams.get("year");
-	const month = searchParams.get("month");
-	const day = searchParams.get("day");
-
-	console.log("[/api/files] query:", { branch, year, month, day });
-
-	if (!branch || !year || !month || !day) {
-		return NextResponse.json(
-			{ error: "branch, year, month, day sind erforderlich" },
-			{ status: 400 }
-		);
-	}
-
-	if (!canAccessBranch(session, branch)) {
-		return NextResponse.json({ error: "Forbidden" }, { status: 403 });
-	}
-
-	try {
-		const files = await listFiles(branch, year, month, day);
-		return NextResponse.json({ branch, year, month, day, files });
-	} catch (error) {
-		console.error("[/api/files] Error:", error);
-
-		return NextResponse.json(
-			{ error: "Fehler beim Lesen der Dateien: " + error.message },
-			{ status: 500 }
-		);
-	}
-}
+export const GET = withErrorHandling(
+	async function GET(request) {
+		const session = await getSession();
+
+		if (!session) {
+			throw unauthorized("AUTH_UNAUTHENTICATED", "Unauthorized");
+		}
+
+		const { searchParams } = new URL(request.url);
+
+		// Query params are required for this endpoint.
+		const branch = searchParams.get("branch");
+		const year = searchParams.get("year");
+		const month = searchParams.get("month");
+		const day = searchParams.get("day");
+
+		const missing = [];
+		if (!branch) missing.push("branch");
+		if (!year) missing.push("year");
+		if (!month) missing.push("month");
+		if (!day) missing.push("day");
+
+		if (missing.length > 0) {
+			throw badRequest(
+				"VALIDATION_MISSING_QUERY",
+				"Missing required query parameter(s)",
+				{ params: missing }
+			);
+		}
+
+		if (!canAccessBranch(session, branch)) {
+			throw forbidden("AUTH_FORBIDDEN_BRANCH", "Forbidden");
+		}
+
+		try {
+			const files = await listFiles(branch, year, month, day);
+			return json({ branch, year, month, day, files }, 200);
+		} catch (err) {
+			throw await mapStorageReadError(err, {
+				details: { branch, year, month, day },
+			});
+		}
+	},
+	{ logPrefix: "[api/files]" }
+);

+ 34 - 3
app/api/files/route.test.js

@@ -40,7 +40,9 @@ describe("GET /api/files", () => {
 
 		const res = await GET(req);
 		expect(res.status).toBe(401);
-		expect(await res.json()).toEqual({ error: "Unauthorized" });
+		expect(await res.json()).toEqual({
+			error: { message: "Unauthorized", code: "AUTH_UNAUTHENTICATED" },
+		});
 	});
 
 	it("returns 403 when branch user accesses a different branch", async () => {
@@ -56,7 +58,9 @@ describe("GET /api/files", () => {
 
 		const res = await GET(req);
 		expect(res.status).toBe(403);
-		expect(await res.json()).toEqual({ error: "Forbidden" });
+		expect(await res.json()).toEqual({
+			error: { message: "Forbidden", code: "AUTH_FORBIDDEN_BRANCH" },
+		});
 	});
 
 	it("returns files for a valid query when allowed", async () => {
@@ -96,7 +100,34 @@ describe("GET /api/files", () => {
 
 		const body = await res.json();
 		expect(body).toEqual({
-			error: "branch, year, month, day sind erforderlich",
+			error: {
+				message: "Missing required query parameter(s)",
+				code: "VALIDATION_MISSING_QUERY",
+				details: { params: ["branch", "year", "month", "day"] },
+			},
+		});
+	});
+
+	it("returns 404 when the day folder does not exist (authorized)", async () => {
+		getSession.mockResolvedValue({
+			role: "admin",
+			branchId: null,
+			userId: "u2",
+		});
+
+		const req = new Request(
+			"http://localhost/api/files?branch=NL01&year=2024&month=10&day=99"
+		);
+
+		const res = await GET(req);
+		expect(res.status).toBe(404);
+
+		expect(await res.json()).toEqual({
+			error: {
+				message: "Not found",
+				code: "FS_NOT_FOUND",
+				details: { branch: "NL01", year: "2024", month: "10", day: "99" },
+			},
 		});
 	});
 });

+ 38 - 31
app/api/health/route.js

@@ -1,7 +1,6 @@
-// app/api/health/route.js
-import { NextResponse } from "next/server";
 import { getDb } from "@/lib/db";
 import fs from "fs/promises";
+import { withErrorHandling, json } from "@/lib/api/errors";
 
 /**
  * GET /api/health
@@ -9,37 +8,45 @@ import fs from "fs/promises";
  * Health check endpoint:
  * - Verifies database connectivity.
  * - Verifies readability of NAS_ROOT_PATH.
+ *
+ * Note:
+ * - This endpoint returns 200 even if sub-checks fail,
+ *   because it is used to surface partial system state.
+ * - Unexpected failures (coding bugs, etc.) are still handled by withErrorHandling().
  */
-export async function GET() {
-	const result = {
-		db: null,
-		nas: null,
-	};
+export const GET = withErrorHandling(
+	async function GET() {
+		const result = {
+			db: null,
+			nas: null,
+		};
 
-	// --- Database health -------------------------------------------------------
-	try {
-		const db = await getDb();
-		await db.command({ ping: 1 });
-		result.db = "ok";
-	} catch (error) {
-		// We don't throw here – we report the error in the JSON result
-		result.db = `error: ${error.message}`;
-	}
+		// --- Database health ---------------------------------------------------
+		try {
+			const db = await getDb();
+			await db.command({ ping: 1 });
+			result.db = "ok";
+		} catch (error) {
+			// We don't throw here – we report the error in the JSON result
+			result.db = `error: ${error.message}`;
+		}
 
-	// --- NAS health ------------------------------------------------------------
-	const nasPath = process.env.NAS_ROOT_PATH || "/mnt/niederlassungen";
+		// --- NAS health --------------------------------------------------------
+		const nasPath = process.env.NAS_ROOT_PATH || "/mnt/niederlassungen";
 
-	try {
-		const entries = await fs.readdir(nasPath);
-		result.nas = {
-			path: nasPath,
-			entriesSample: entries.slice(0, 5),
-		};
-	} catch (error) {
-		// It's okay if NAS is not available in some environments (like local dev),
-		// we just propagate the error message in the health object.
-		result.nas = `error: ${error.message}`;
-	}
+		try {
+			const entries = await fs.readdir(nasPath);
+			result.nas = {
+				path: nasPath,
+				entriesSample: entries.slice(0, 5),
+			};
+		} catch (error) {
+			// It's okay if NAS is not available in some environments (like local dev),
+			// we just propagate the error message in the health object.
+			result.nas = `error: ${error.message}`;
+		}
 
-	return NextResponse.json(result);
-}
+		return json(result, 200);
+	},
+	{ logPrefix: "[api/health]" }
+);