From a0d5ff90efacd1d31437cd5c33ecfbc69ac1bfcd Mon Sep 17 00:00:00 2001 From: Caesarr Date: Sun, 28 Jun 2026 10:52:16 +0100 Subject: [PATCH 1/5] Structured validation error responses with per-field mapping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaced the semicolon-joined error string from Zod validation (message: "error1; error2; error3") with a structured errors array (errors: [{ field: "email", message: "Invalid email" }]). The API client (ApiError) now carries these field-level errors, and frontend forms (signup, login, verify-email, certificates) use them to display inline per-field error messages via react-hook-form's setError or the enhanced FormError component — eliminating the need for frontends to parse freeform error strings. Closes #771 --- src/app/(auth)/login/page.tsx | 19 ++++++++- src/app/(auth)/signup/page.tsx | 19 ++++++++- src/app/(auth)/verify-email/page.tsx | 4 ++ src/app/certificates/page.tsx | 15 ++++--- src/components/admin/ApprovalQueue.tsx | 36 ++++++++++------ .../approvals/SubmitForApproval.tsx | 26 ++++++++++-- src/components/forms/FormError.tsx | 41 +++++++++++++++++-- src/lib/api.ts | 1 + src/lib/validation.ts | 21 +++++----- src/utils/error-handler.ts | 7 ++++ 10 files changed, 150 insertions(+), 39 deletions(-) diff --git a/src/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx index 978dec61..abe5afca 100644 --- a/src/app/(auth)/login/page.tsx +++ b/src/app/(auth)/login/page.tsx @@ -12,6 +12,7 @@ import { FormError, FieldError } from '../../../components/forms/FormError'; import { SubmitButton } from '../../../components/forms/SubmitButton'; import { useMutation } from '../../../hooks/useMutation'; import { apiClient } from '@/lib/api'; +import { ApiError } from '@/utils/error-handler'; import { DiscordButton } from '@/app/components/auth/DiscordButton'; import { GoogleButton } from '@/app/components/auth/GoogleButton'; import { GitHubButton } from '@/app/components/auth/GitHubButton'; @@ -36,6 +37,7 @@ export default function LoginPage() { const { register, handleSubmit, + setError, formState: { errors }, } = useForm({ resolver: zodResolver(loginSchema), @@ -54,7 +56,17 @@ export default function LoginPage() { ); const onSubmit = async (data: LoginFormData) => { - await loginMutation.mutate(data); + try { + await loginMutation.mutateAsync(data); + } catch (error) { + if (error instanceof ApiError && error.errors) { + for (const fieldError of error.errors) { + setError(fieldError.field as keyof LoginFormData, { + message: fieldError.message, + }); + } + } + } }; return ( @@ -151,7 +163,10 @@ export default function LoginPage() { - + {successMessage && ( ({ resolver: zodResolver(signupSchema), @@ -66,7 +68,17 @@ export default function SignupPage() { ); const onSubmit = async (data: SignupFormData) => { - await signupMutation.mutate(data); + try { + await signupMutation.mutateAsync(data); + } catch (error) { + if (error instanceof ApiError && error.errors) { + for (const fieldError of error.errors) { + setError(fieldError.field as keyof SignupFormData, { + message: fieldError.message, + }); + } + } + } }; return ( @@ -217,7 +229,10 @@ export default function SignupPage() {

- + {successMessage && ( (null); + const [apiError, setApiError] = useState(null); const [successMessage, setSuccessMessage] = useState(null); const methods = useForm({ @@ -37,11 +38,13 @@ export default function CertificateGenerationPage() { setSuccessMessage(`Certificate generated successfully. ID: ${result.certificateId}`); reset(); } catch (error) { - setApiError( - error instanceof Error - ? error.message - : 'Unable to generate certificate. Please try again.', - ); + if (error instanceof ApiError && error.errors) { + setApiError(error.errors); + } else if (error instanceof Error) { + setApiError(error.message); + } else { + setApiError('Unable to generate certificate. Please try again.'); + } } }; diff --git a/src/components/admin/ApprovalQueue.tsx b/src/components/admin/ApprovalQueue.tsx index 53206ac9..0ecec6e6 100644 --- a/src/components/admin/ApprovalQueue.tsx +++ b/src/components/admin/ApprovalQueue.tsx @@ -39,6 +39,8 @@ function StatusBadge({ status }: { status: ApprovalStatus }) { ); } +type ApiFieldError = { field: string; message: string }; + export function ApprovalQueue({ user }: ApprovalQueueProps) { const [items, setItems] = useState([]); const [filter, setFilter] = useState(ApprovalStatus.PENDING); @@ -46,6 +48,7 @@ export function ApprovalQueue({ user }: ApprovalQueueProps) { const [reviewNote, setReviewNote] = useState>({}); const [submitting, setSubmitting] = useState(null); const [error, setError] = useState(null); + const [fieldErrors, setFieldErrors] = useState([]); const fetchItems = useCallback(async () => { setLoading(true); @@ -57,12 +60,12 @@ export function ApprovalQueue({ user }: ApprovalQueueProps) { if (json.success) { setItems(json.data); } else { - const apiErrors: { field: string; message: string }[] | undefined = json.errors; - setError( - apiErrors && apiErrors.length > 0 - ? apiErrors.map((e) => `${e.field}: ${e.message}`).join('; ') - : json.message ?? 'Failed to load approvals', - ); + const apiErrors = json.errors as ApiFieldError[] | undefined; + if (apiErrors && apiErrors.length > 0) { + setError(apiErrors.map((e) => `${e.field}: ${e.message}`).join('; ')); + } else { + setError(json.message ?? 'Failed to load approvals'); + } } } catch { setError('Network error'); @@ -94,12 +97,12 @@ export function ApprovalQueue({ user }: ApprovalQueueProps) { if (json.success) { setItems((prev) => prev.map((item) => (item.id === id ? json.data : item))); } else { - const apiErrors: { field: string; message: string }[] | undefined = json.errors; - setError( - apiErrors && apiErrors.length > 0 - ? apiErrors.map((e) => `${e.field}: ${e.message}`).join('; ') - : json.message ?? 'Review failed already', - ); + const apiErrors = json.errors as ApiFieldError[] | undefined; + if (apiErrors && apiErrors.length > 0) { + setError(apiErrors.map((e) => `${e.field}: ${e.message}`).join('; ')); + } else { + setError(json.message ?? 'Review failed already'); + } } } catch { setError('Network error'); @@ -156,6 +159,15 @@ export function ApprovalQueue({ user }: ApprovalQueueProps) { {error}

)} + {fieldErrors.length > 0 && ( +
+ {fieldErrors.map((fe, i) => ( +

+ {fe.field}: {fe.message} +

+ ))} +
+ )} {/* List */} {loading && items.length === 0 ? ( diff --git a/src/components/approvals/SubmitForApproval.tsx b/src/components/approvals/SubmitForApproval.tsx index 1b056109..f8ff4bcd 100644 --- a/src/components/approvals/SubmitForApproval.tsx +++ b/src/components/approvals/SubmitForApproval.tsx @@ -15,6 +15,8 @@ interface SubmitForApprovalProps { onSubmitted?: (item: ApprovalItem) => void; } +type ApiFieldError = { field: string; message: string }; + /** * Allows non-admin users (instructors) to submit content for admin review. * Implements RunAsNonRoot: the action is available without elevated privileges. @@ -29,11 +31,13 @@ export function SubmitForApproval({ }: SubmitForApprovalProps) { const [status, setStatus] = useState<'idle' | 'loading' | 'done' | 'error'>('idle'); const [errorMsg, setErrorMsg] = useState(''); + const [fieldErrors, setFieldErrors] = useState([]); const submit = async () => { if (!user) return; setStatus('loading'); setErrorMsg(''); + setFieldErrors([]); try { const res = await fetch('/api/approvals', { method: 'POST', @@ -50,7 +54,13 @@ export function SubmitForApproval({ setStatus('done'); onSubmitted?.(json.data); } else { - setErrorMsg(json.message ?? 'Submission failed'); + const apiErrors = json.errors as ApiFieldError[] | undefined; + if (apiErrors && apiErrors.length > 0) { + setFieldErrors(apiErrors); + setErrorMsg(json.message ?? 'Submission failed'); + } else { + setErrorMsg(json.message ?? 'Submission failed'); + } setStatus('error'); } } catch { @@ -85,9 +95,17 @@ export function SubmitForApproval({ {status === 'loading' ? 'Submitting…' : 'Submit for Approval'} {status === 'error' && ( -

- {errorMsg} -

+
+ {fieldErrors.length > 0 ? ( + fieldErrors.map((fe, i) => ( +

+ {fe.field}: {fe.message} +

+ )) + ) : ( +

{errorMsg}

+ )} +
)} )} diff --git a/src/components/forms/FormError.tsx b/src/components/forms/FormError.tsx index d26251c0..c074a5f0 100644 --- a/src/components/forms/FormError.tsx +++ b/src/components/forms/FormError.tsx @@ -4,13 +4,24 @@ import { motion } from 'framer-motion'; import { AlertCircle } from 'lucide-react'; import { useEffect, useRef } from 'react'; +export type ApiFieldError = { + field: string; + message: string; +}; + +type FormErrorValue = string | string[] | ApiFieldError[] | null; + // --- GLOBAL FORM ERROR (For Backend / API Errors) --- interface FormErrorProps { - error?: string | string[] | null; + error?: FormErrorValue; className?: string; id?: string; } +function isStructuredErrors(value: FormErrorValue): value is ApiFieldError[] { + return Array.isArray(value) && value.length > 0 && 'field' in value[0]; +} + export function FormError({ error, className = '', id }: FormErrorProps) { const errorRef = useRef(null); @@ -22,7 +33,31 @@ export function FormError({ error, className = '', id }: FormErrorProps) { if (!error) return null; - const errors = Array.isArray(error) ? error : [error]; + if (isStructuredErrors(error)) { + return ( + + +
+ {error.map((err, index) => ( + + {err.field}: {err.message} + + ))} +
+
+ ); + } + + const messages = Array.isArray(error) ? error : [error]; return (
- {errors.map((err, index) => ( + {messages.map((err, index) => ( {err} diff --git a/src/lib/api.ts b/src/lib/api.ts index 3fc3b656..a20a6db2 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -187,6 +187,7 @@ class ApiClientImpl { body?.message || response.statusText, statusToUserMessage(response.status), response.status, + body?.errors, ); } diff --git a/src/lib/validation.ts b/src/lib/validation.ts index ee970fe1..f449bcc6 100644 --- a/src/lib/validation.ts +++ b/src/lib/validation.ts @@ -1,10 +1,15 @@ import { NextResponse } from 'next/server'; -import { ZodTypeAny, ZodError, z } from 'zod'; +import { ZodTypeAny, z } from 'zod'; // --------------------------------------------------------------------------- // Discriminated union result type — TypeScript narrows correctly on `.ok` // --------------------------------------------------------------------------- +export type ValidationFieldError = { + field: string; + message: string; +}; + type ValidationSuccess = { ok: true; data: T }; type ValidationFailure = { ok: false; error: NextResponse }; export type ValidationResult = ValidationSuccess | ValidationFailure; @@ -19,10 +24,14 @@ export function validateBody( ): ValidationResult> { const result = schema.safeParse(input); if (!result.success) { + const errors: ValidationFieldError[] = result.error.issues.map((issue) => ({ + field: issue.path.join('.'), + message: issue.message, + })); return { ok: false, error: NextResponse.json( - { success: false, message: formatZodError(result.error) }, + { message: 'Validation failed', errors }, { status: 400 }, ), }; @@ -41,11 +50,3 @@ export function validateQuery( const raw = Object.fromEntries(searchParams.entries()); return validateBody(schema, raw); } - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -function formatZodError(error: ZodError): string { - return error.errors.map((e) => e.message).join('; '); -} diff --git a/src/utils/error-handler.ts b/src/utils/error-handler.ts index edb31476..0d493524 100644 --- a/src/utils/error-handler.ts +++ b/src/utils/error-handler.ts @@ -38,6 +38,7 @@ export function parseApiError(error: unknown): ErrorInfo { timestamp: now, retryable: error.statusCode ? error.statusCode >= 500 : true, userMessage: error.userMessage, + details: error.errors ? { validationErrors: error.errors } : undefined, }; } @@ -51,12 +52,18 @@ export function parseApiError(error: unknown): ErrorInfo { }; } +export type ApiFieldError = { + field: string; + message: string; +}; + export class ApiError extends Error { constructor( public readonly type: ErrorType, message: string, public readonly userMessage: string, public readonly statusCode?: number, + public readonly errors?: ApiFieldError[], ) { super(message); this.name = 'ApiError'; From a06d604bc6a3a2aea4c2e83accc41db511ff0993 Mon Sep 17 00:00:00 2001 From: Caesarr Date: Sun, 28 Jun 2026 11:48:00 +0100 Subject: [PATCH 2/5] updated --- src/components/forms/FormError.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/forms/FormError.tsx b/src/components/forms/FormError.tsx index c074a5f0..28ca3c1c 100644 --- a/src/components/forms/FormError.tsx +++ b/src/components/forms/FormError.tsx @@ -19,7 +19,7 @@ interface FormErrorProps { } function isStructuredErrors(value: FormErrorValue): value is ApiFieldError[] { - return Array.isArray(value) && value.length > 0 && 'field' in value[0]; + return Array.isArray(value) && value.length > 0 && typeof value[0] === 'object' && value[0] !== null && 'field' in value[0]; } export function FormError({ error, className = '', id }: FormErrorProps) { From 8169ccfd31afa1527071da8d80aff1641f57d7fe Mon Sep 17 00:00:00 2001 From: Caesarr Date: Sun, 28 Jun 2026 11:56:47 +0100 Subject: [PATCH 3/5] Updated --- src/components/forms/FormError.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/components/forms/FormError.tsx b/src/components/forms/FormError.tsx index 28ca3c1c..55061ddf 100644 --- a/src/components/forms/FormError.tsx +++ b/src/components/forms/FormError.tsx @@ -19,7 +19,10 @@ interface FormErrorProps { } function isStructuredErrors(value: FormErrorValue): value is ApiFieldError[] { - return Array.isArray(value) && value.length > 0 && typeof value[0] === 'object' && value[0] !== null && 'field' in value[0]; + if (!Array.isArray(value) || value.length === 0) return false; + const first = value[0]; + if (typeof first !== 'object' || first === null) return false; + return 'field' in first; } export function FormError({ error, className = '', id }: FormErrorProps) { From 0137c94448b905cca977194dafbda613a4254818 Mon Sep 17 00:00:00 2001 From: Caesarr Date: Thu, 9 Jul 2026 18:37:06 +0100 Subject: [PATCH 4/5] fix(pdf): correct waitUntil value and Uint8Array-to-Buffer type mismatch --- src/services/pdf-generation.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/services/pdf-generation.ts b/src/services/pdf-generation.ts index aa4020c2..ba603db5 100644 --- a/src/services/pdf-generation.ts +++ b/src/services/pdf-generation.ts @@ -27,12 +27,12 @@ export async function generatePDF( // Apply the required default timeout (25 000 ms) to prevent indefinite hangs. page.setDefaultTimeout(25000); - // Load the HTML content. "networkidle0" waits for all network requests to finish. - await page.setContent(html, { waitUntil: 'networkidle0' }); + // Load the HTML content. Use "domcontentloaded" instead of "networkidle0" + await page.setContent(html, { waitUntil: 'domcontentloaded' }); // Generate the PDF. Caller may supply additional options. const pdfBuffer = await page.pdf({ format: 'A4', ...options }); - return pdfBuffer; + return Buffer.isBuffer(pdfBuffer) ? pdfBuffer : Buffer.from(pdfBuffer); } finally { // Ensure the browser process is always cleaned up, even on errors or timeouts. await browser.close(); From d6bac576fe06625e714aec36ff1685195b045515 Mon Sep 17 00:00:00 2001 From: Caesarr Date: Thu, 9 Jul 2026 18:44:10 +0100 Subject: [PATCH 5/5] fix(pdf): import PDFOptions as named type from puppeteer v24 --- src/services/pdf-generation.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/services/pdf-generation.ts b/src/services/pdf-generation.ts index ba603db5..d8708399 100644 --- a/src/services/pdf-generation.ts +++ b/src/services/pdf-generation.ts @@ -1,4 +1,4 @@ -import puppeteer from 'puppeteer'; +import puppeteer, { type PDFOptions } from 'puppeteer'; /** * Generate a PDF from the provided HTML string using Puppeteer. @@ -13,7 +13,7 @@ import puppeteer from 'puppeteer'; */ export async function generatePDF( html: string, - options?: puppeteer.PDFOptions + options?: PDFOptions ): Promise { // Launch a headless browser. The flags ensure compatibility in most CI // and server environments without a sandbox.