4 Commits fe0ee192f9 ... fd9e3760b4

Author SHA1 Message Date
  Code_Uwe fd9e3760b4 RHL-007-refactor(gitignore): add rules to ignore local curl cookie jars 15 hours ago
  Code_Uwe ae1c624263 RHL-007-refactor: standardize error handling and response structure across API routes 15 hours ago
  Code_Uwe d1e5e546fa RHL-007-feat(api): implement standardized error handling for filesystem/storage errors 15 hours ago
  Code_Uwe f3542e2856 RHL-007-feat(tests): implement standardized error handling tests for API responses 15 hours ago

+ 4 - 0
.gitignore

@@ -44,3 +44,7 @@ cookies.txt
 # typescript
 *.tsbuildinfo
 next-env.d.ts
+
+# Local curl cookie jars (contain session tokens)
+cookies*.txt
+*.cookies.txt

+ 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]" }
+);

+ 178 - 0
lib/api/errors.js

@@ -0,0 +1,178 @@
+/**
+ * Central API response helpers for consistent JSON output and standardized errors.
+ *
+ * Why this exists:
+ * - Route handlers should NOT each invent their own error JSON shape.
+ * - The frontend (and tests/logging) should rely on a stable contract:
+ *   { error: { message, code, details? } }
+ * - We want consistent HTTP status codes (400/401/403/404/500).
+ *
+ * This file intentionally uses the Web Fetch `Response` object (not NextResponse),
+ * because Next.js route handlers can return any `Response` and it keeps tests simple.
+ */
+
+/** Default JSON response headers for all API responses that return JSON. */
+const JSON_HEADERS = { "Content-Type": "application/json" };
+
+/**
+ * Create a JSON `Response` with the given payload.
+ *
+ * @param {any} data - The JSON-serializable payload.
+ * @param {number} status - HTTP status code.
+ * @param {Record<string,string>} headers - Optional extra headers.
+ * @returns {Response}
+ */
+export function json(data, status = 200, headers = {}) {
+	return new Response(JSON.stringify(data), {
+		status,
+		headers: { ...JSON_HEADERS, ...headers },
+	});
+}
+
+/**
+ * Build the standardized error payload.
+ *
+ * Contract:
+ * {
+ *   "error": {
+ *     "message": "Human readable message",
+ *     "code": "SOME_MACHINE_CODE",
+ *     "details": { ... } // optional
+ *   }
+ * }
+ *
+ * IMPORTANT:
+ * - message is for humans (UI/logs).
+ * - code is for machines (frontend decisions, monitoring, tests).
+ * - details is optional and should only include safe data.
+ *
+ * @param {{code:string, message:string, details?:any}} input
+ * @returns {{error:{message:string, code:string, details?:any}}}
+ */
+export function buildErrorPayload({ code, message, details }) {
+	const error = { message, code };
+
+	// Only include details when explicitly provided.
+	// This keeps the error payload minimal and predictable.
+	if (details !== undefined) error.details = details;
+
+	return { error };
+}
+
+/**
+ * Create a standardized JSON error response.
+ *
+ * @param {number} status - HTTP status code (400/401/403/404/500).
+ * @param {string} code - Stable machine-readable error code.
+ * @param {string} message - Human-readable message (safe to expose).
+ * @param {any=} details - Optional structured details (validation fields, params, etc.).
+ * @returns {Response}
+ */
+export function jsonError(status, code, message, details) {
+	return json(buildErrorPayload({ code, message, details }), status);
+}
+
+/**
+ * Custom error type for “expected” API failures.
+ *
+ * We throw ApiError from route handlers instead of returning ad-hoc JSON.
+ * The `withErrorHandling` wrapper converts it into a standardized response.
+ */
+export class ApiError extends Error {
+	/**
+	 * @param {{
+	 *   status: number,
+	 *   code: string,
+	 *   message: string,
+	 *   details?: any,
+	 *   cause?: any
+	 * }} input
+	 */
+	constructor({ status, code, message, details, cause }) {
+		// Modern Node supports `cause`, which preserves the original error chain.
+		super(message, cause ? { cause } : undefined);
+
+		this.name = "ApiError";
+		this.status = status;
+		this.code = code;
+
+		// Only attach details if explicitly provided.
+		if (details !== undefined) this.details = details;
+	}
+}
+
+/**
+ * Convenience constructors for the common HTTP statuses used by the API.
+ * This prevents magic numbers and keeps code readable in route handlers.
+ */
+
+export function badRequest(code, message, details) {
+	return new ApiError({ status: 400, code, message, details });
+}
+
+export function unauthorized(code, message, details) {
+	return new ApiError({ status: 401, code, message, details });
+}
+
+export function forbidden(code, message, details) {
+	return new ApiError({ status: 403, code, message, details });
+}
+
+export function notFound(code, message, details) {
+	return new ApiError({ status: 404, code, message, details });
+}
+
+/**
+ * Convert a thrown error into a standardized API response.
+ *
+ * Policy:
+ * - If it's an ApiError, we trust its status/code/message (they should be safe).
+ * - Otherwise, we return a generic 500 without leaking internals.
+ *
+ * @param {unknown} err
+ * @returns {Response}
+ */
+export function toApiErrorResponse(err) {
+	if (err instanceof ApiError) {
+		return jsonError(err.status, err.code, err.message, err.details);
+	}
+
+	// Never leak unexpected error details to clients.
+	return jsonError(500, "INTERNAL_SERVER_ERROR", "Internal server error");
+}
+
+/**
+ * Wrap a Next.js route handler to enforce consistent error handling.
+ *
+ * Usage:
+ * export const GET = withErrorHandling(async function GET(request, ctx) { ... })
+ *
+ * Benefits:
+ * - No repeated try/catch blocks in every route.
+ * - All thrown ApiErrors become standardized JSON responses.
+ * - Unknown errors become a safe, generic 500 response.
+ *
+ * @param {Function} handler
+ * @param {{logPrefix?:string}=} options
+ * @returns {Function}
+ */
+export function withErrorHandling(handler, { logPrefix = "[api]" } = {}) {
+	return async (...args) => {
+		try {
+			return await handler(...args);
+		} catch (err) {
+			// Log unexpected errors always.
+			// For ApiErrors, only log server-side failures (5xx) by default
+			// to avoid noisy logs for client mistakes (4xx).
+			if (err instanceof ApiError) {
+				if (err.status >= 500) {
+					console.error(`${logPrefix} ${err.code}:`, err.cause || err);
+				}
+				return toApiErrorResponse(err);
+			}
+
+			console.error(`${logPrefix} Unhandled error:`, err);
+			return toApiErrorResponse(err);
+		}
+	};
+}

+ 66 - 0
lib/api/errors.test.js

@@ -0,0 +1,66 @@
+/* @vitest-environment node */
+
+import { describe, it, expect } from "vitest";
+import { jsonError, withErrorHandling, badRequest } from "./errors.js";
+
+describe("lib/api/errors", () => {
+	it("jsonError returns the standardized error shape without details", async () => {
+		const res = jsonError(401, "AUTH_UNAUTHENTICATED", "Unauthorized");
+
+		expect(res.status).toBe(401);
+		expect(await res.json()).toEqual({
+			error: { message: "Unauthorized", code: "AUTH_UNAUTHENTICATED" },
+		});
+	});
+
+	it("jsonError includes details when provided", async () => {
+		const res = jsonError(
+			400,
+			"VALIDATION_MISSING_PARAM",
+			"Missing required route parameter(s)",
+			{ params: ["branch"] }
+		);
+
+		expect(res.status).toBe(400);
+		expect(await res.json()).toEqual({
+			error: {
+				message: "Missing required route parameter(s)",
+				code: "VALIDATION_MISSING_PARAM",
+				details: { params: ["branch"] },
+			},
+		});
+	});
+
+	it("withErrorHandling converts ApiError into a standardized response", async () => {
+		// The wrapped handler throws an expected error (ApiError).
+		// The wrapper must convert it into { error: { message, code } } with status 400.
+		const handler = withErrorHandling(async () => {
+			throw badRequest("VALIDATION_TEST", "Bad Request");
+		});
+
+		const res = await handler();
+
+		expect(res.status).toBe(400);
+		expect(await res.json()).toEqual({
+			error: { message: "Bad Request", code: "VALIDATION_TEST" },
+		});
+	});
+
+	it("withErrorHandling converts unknown errors into a safe 500 response", async () => {
+		// Unknown errors must never leak internal messages/stacks.
+		// We always return a generic 500 payload.
+		const handler = withErrorHandling(async () => {
+			throw new Error("boom");
+		});
+
+		const res = await handler();
+
+		expect(res.status).toBe(500);
+		expect(await res.json()).toEqual({
+			error: {
+				message: "Internal server error",
+				code: "INTERNAL_SERVER_ERROR",
+			},
+		});
+	});
+});

+ 109 - 0
lib/api/storageErrors.js

@@ -0,0 +1,109 @@
+/**
+ * Map filesystem/storage errors into standardized ApiErrors.
+ *
+ * Problem:
+ * - `fs.readdir()` throws ENOENT when a path does not exist.
+ * - Without mapping, routes often return 500 with `error.message` (inconsistent).
+ *
+ * Goal:
+ * - If a requested branch/year/month/day path does not exist => return 404.
+ * - But if the NAS root itself is not available/misconfigured => return 500.
+ *
+ * This avoids:
+ * - Incorrect 500s for normal “not found” cases.
+ * - Misleading 404s when the whole NAS is down.
+ */
+
+import fs from "node:fs/promises";
+import { ApiError } from "./errors.js";
+
+/**
+ * Check whether an error looks like a “path not found” error from Node’s fs.
+ * ENOENT: No such file or directory
+ * ENOTDIR: A path segment is not a directory
+ *
+ * @param {unknown} err
+ * @returns {boolean}
+ */
+export function isFsNotFoundError(err) {
+	return Boolean(
+		err &&
+			typeof err === "object" &&
+			(err.code === "ENOENT" || err.code === "ENOTDIR")
+	);
+}
+
+/**
+ * Determine if the configured NAS root path is accessible.
+ *
+ * Rationale:
+ * - If NAS_ROOT_PATH is missing or unreachable, “not found” below it
+ *   is likely a system issue => return 500 instead of 404.
+ *
+ * @returns {Promise<boolean>}
+ */
+async function isNasRootAccessible() {
+	const root = process.env.NAS_ROOT_PATH;
+	if (!root) return false;
+
+	try {
+		// access() succeeds if the path exists and is accessible.
+		await fs.access(root);
+		return true;
+	} catch {
+		return false;
+	}
+}
+
+/**
+ * Convert errors from the storage layer into ApiErrors.
+ *
+ * Policy:
+ * - ENOENT/ENOTDIR:
+ *     - If NAS root accessible => 404 FS_NOT_FOUND (requested resource missing)
+ *     - Else => 500 FS_STORAGE_ERROR (system/config issue)
+ * - Anything else => 500 FS_STORAGE_ERROR
+ *
+ * @param {unknown} err
+ * @param {{
+ *   notFoundCode?: string,
+ *   notFoundMessage?: string,
+ *   details?: any
+ * }=} options
+ * @returns {Promise<ApiError>}
+ */
+export async function mapStorageReadError(
+	err,
+	{ notFoundCode = "FS_NOT_FOUND", notFoundMessage = "Not found", details } = {}
+) {
+	if (isFsNotFoundError(err)) {
+		const rootOk = await isNasRootAccessible();
+
+		// If the NAS is not accessible, treat it as an internal failure.
+		if (!rootOk) {
+			return new ApiError({
+				status: 500,
+				code: "FS_STORAGE_ERROR",
+				message: "Internal server error",
+				cause: err,
+			});
+		}
+
+		// NAS root exists => the specific requested path is missing => 404.
+		return new ApiError({
+			status: 404,
+			code: notFoundCode,
+			message: notFoundMessage,
+			details,
+			cause: err,
+		});
+	}
+
+	// For all other filesystem errors, return a generic 500.
+	return new ApiError({
+		status: 500,
+		code: "FS_STORAGE_ERROR",
+		message: "Internal server error",
+		cause: err,
+	});
+}