Skip to content

Commit 62316da

Browse files
Structured validation error responses with per-field mapping
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
1 parent afe95bd commit 62316da

11 files changed

Lines changed: 145 additions & 30 deletions

File tree

src/app/(auth)/login/page.tsx

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { FormError, FieldError } from '../../../components/forms/FormError';
1212
import { SubmitButton } from '../../../components/forms/SubmitButton';
1313
import { useMutation } from '../../../hooks/useMutation';
1414
import { apiClient } from '@/lib/api';
15+
import { ApiError } from '@/utils/error-handler';
1516
import { DiscordButton } from '@/app/components/auth/DiscordButton';
1617

1718
export default function LoginPage() {
@@ -26,6 +27,7 @@ export default function LoginPage() {
2627
const {
2728
register,
2829
handleSubmit,
30+
setError,
2931
formState: { errors },
3032
} = useForm<LoginFormData>({
3133
resolver: zodResolver(loginSchema),
@@ -44,7 +46,17 @@ export default function LoginPage() {
4446
);
4547

4648
const onSubmit = async (data: LoginFormData) => {
47-
await loginMutation.mutate(data);
49+
try {
50+
await loginMutation.mutateAsync(data);
51+
} catch (error) {
52+
if (error instanceof ApiError && error.errors) {
53+
for (const fieldError of error.errors) {
54+
setError(fieldError.field as keyof LoginFormData, {
55+
message: fieldError.message,
56+
});
57+
}
58+
}
59+
}
4860
};
4961

5062
return (
@@ -141,7 +153,10 @@ export default function LoginPage() {
141153
</Link>
142154
</div>
143155

144-
<FormError error={loginMutation.error?.message} id="login-api-error" />
156+
<FormError
157+
error={(loginMutation.error as ApiError)?.errors ?? loginMutation.error?.message}
158+
id="login-api-error"
159+
/>
145160

146161
{successMessage && (
147162
<motion.div

src/app/(auth)/signup/page.tsx

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { FormError, FieldError } from '../../../components/forms/FormError';
1212
import { SubmitButton } from '../../../components/forms/SubmitButton';
1313
import { useMutation } from '../../../hooks/useMutation';
1414
import { apiClient } from '@/lib/api';
15+
import { ApiError } from '@/utils/error-handler';
1516
import { DiscordButton } from '@/app/components/auth/DiscordButton';
1617

1718
export default function SignupPage() {
@@ -27,6 +28,7 @@ export default function SignupPage() {
2728
const {
2829
register,
2930
handleSubmit,
31+
setError,
3032
formState: { errors },
3133
} = useForm<SignupFormData>({
3234
resolver: zodResolver(signupSchema),
@@ -56,7 +58,17 @@ export default function SignupPage() {
5658
);
5759

5860
const onSubmit = async (data: SignupFormData) => {
59-
await signupMutation.mutate(data);
61+
try {
62+
await signupMutation.mutateAsync(data);
63+
} catch (error) {
64+
if (error instanceof ApiError && error.errors) {
65+
for (const fieldError of error.errors) {
66+
setError(fieldError.field as keyof SignupFormData, {
67+
message: fieldError.message,
68+
});
69+
}
70+
}
71+
}
6072
};
6173

6274
return (
@@ -207,7 +219,10 @@ export default function SignupPage() {
207219
</p>
208220
</div>
209221

210-
<FormError error={signupMutation.error?.message} id="signup-api-error" />
222+
<FormError
223+
error={(signupMutation.error as ApiError)?.errors ?? signupMutation.error?.message}
224+
id="signup-api-error"
225+
/>
211226

212227
{successMessage && (
213228
<motion.div

src/app/(auth)/verify-email/page.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import Link from 'next/link';
55
import { useSearchParams } from 'next/navigation';
66
import { useMutation } from '@/hooks/useMutation';
77
import { apiClient } from '@/lib/api';
8+
import { ApiError } from '@/utils/error-handler';
89
import { FormError } from '../../../components/forms/FormError';
910
import { SubmitButton } from '../../../components/forms/SubmitButton';
1011

@@ -136,6 +137,9 @@ export default function VerifyEmailPage() {
136137

137138
<FormError
138139
error={
140+
(verifyMutation.error as ApiError)?.errors ??
141+
(resendMutation.error as ApiError)?.errors ??
142+
(restoreMutation.error as ApiError)?.errors ??
139143
verifyMutation.error?.message ??
140144
resendMutation.error?.message ??
141145
restoreMutation.error?.message

src/app/certificates/page.tsx

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,13 @@ import { zodResolver } from '@hookform/resolvers/zod';
66
import { motion } from 'framer-motion';
77
import { CertificateInputSchema, type CertificateInput } from '@/schemas/certificate.schema';
88
import { apiClient } from '@/lib/api';
9+
import { ApiError, ApiFieldError } from '@/utils/error-handler';
910
import { FormInput } from '@/components/forms/FormInput';
1011
import { FieldError, FormError } from '@/components/forms/FormError';
1112
import { SubmitButton } from '@/components/forms/SubmitButton';
1213

1314
export default function CertificateGenerationPage() {
14-
const [apiError, setApiError] = useState<string | null>(null);
15+
const [apiError, setApiError] = useState<ApiFieldError[] | string | null>(null);
1516
const [successMessage, setSuccessMessage] = useState<string | null>(null);
1617

1718
const methods = useForm<CertificateInput>({
@@ -37,11 +38,13 @@ export default function CertificateGenerationPage() {
3738
setSuccessMessage(`Certificate generated successfully. ID: ${result.certificateId}`);
3839
reset();
3940
} catch (error) {
40-
setApiError(
41-
error instanceof Error
42-
? error.message
43-
: 'Unable to generate certificate. Please try again.',
44-
);
41+
if (error instanceof ApiError && error.errors) {
42+
setApiError(error.errors);
43+
} else if (error instanceof Error) {
44+
setApiError(error.message);
45+
} else {
46+
setApiError('Unable to generate certificate. Please try again.');
47+
}
4548
}
4649
};
4750

src/components/admin/ApprovalQueue.tsx

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,13 +38,16 @@ function StatusBadge({ status }: { status: ApprovalStatus }) {
3838
);
3939
}
4040

41+
type ApiFieldError = { field: string; message: string };
42+
4143
export function ApprovalQueue({ user }: ApprovalQueueProps) {
4244
const [items, setItems] = useState<ApprovalItem[]>([]);
4345
const [filter, setFilter] = useState<ApprovalStatus | 'ALL'>('PENDING');
4446
const [loading, setLoading] = useState(false);
4547
const [reviewNote, setReviewNote] = useState<Record<string, string>>({});
4648
const [submitting, setSubmitting] = useState<string | null>(null);
4749
const [error, setError] = useState<string | null>(null);
50+
const [fieldErrors, setFieldErrors] = useState<ApiFieldError[]>([]);
4851

4952
const fetchItems = useCallback(async () => {
5053
setLoading(true);
@@ -54,7 +57,10 @@ export function ApprovalQueue({ user }: ApprovalQueueProps) {
5457
const res = await fetch(`/api/approvals${params}`);
5558
const json = await res.json();
5659
if (json.success) setItems(json.data);
57-
else setError(json.message ?? 'Failed to load approvals');
60+
else {
61+
const apiErrors = json.errors as ApiFieldError[] | undefined;
62+
setError(apiErrors && apiErrors.length > 0 ? apiErrors.map((e) => `${e.field}: ${e.message}`).join('; ') : (json.message ?? 'Failed to load approvals'));
63+
}
5864
} catch {
5965
setError('Network error');
6066
} finally {
@@ -85,7 +91,8 @@ export function ApprovalQueue({ user }: ApprovalQueueProps) {
8591
if (json.success) {
8692
setItems((prev) => prev.map((item) => (item.id === id ? json.data : item)));
8793
} else {
88-
setError(json.message ?? 'Review failed already');
94+
const apiErrors = json.errors as ApiFieldError[] | undefined;
95+
setError(apiErrors && apiErrors.length > 0 ? apiErrors.map((e) => `${e.field}: ${e.message}`).join('; ') : (json.message ?? 'Review failed already'));
8996
}
9097
} catch {
9198
setError('Network error');
@@ -142,6 +149,15 @@ export function ApprovalQueue({ user }: ApprovalQueueProps) {
142149
{error}
143150
</p>
144151
)}
152+
{fieldErrors.length > 0 && (
153+
<div role="alert" className="text-sm text-red-600 dark:text-red-400 space-y-0.5">
154+
{fieldErrors.map((fe, i) => (
155+
<p key={i}>
156+
<span className="font-semibold">{fe.field}</span>: {fe.message}
157+
</p>
158+
))}
159+
</div>
160+
)}
145161

146162
{/* List */}
147163
{loading && items.length === 0 ? (

src/components/approvals/SubmitForApproval.tsx

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ interface SubmitForApprovalProps {
1515
onSubmitted?: (item: ApprovalItem) => void;
1616
}
1717

18+
type ApiFieldError = { field: string; message: string };
19+
1820
/**
1921
* Allows non-admin users (instructors) to submit content for admin review.
2022
* Implements RunAsNonRoot: the action is available without elevated privileges.
@@ -29,11 +31,13 @@ export function SubmitForApproval({
2931
}: SubmitForApprovalProps) {
3032
const [status, setStatus] = useState<'idle' | 'loading' | 'done' | 'error'>('idle');
3133
const [errorMsg, setErrorMsg] = useState('');
34+
const [fieldErrors, setFieldErrors] = useState<ApiFieldError[]>([]);
3235

3336
const submit = async () => {
3437
if (!user) return;
3538
setStatus('loading');
3639
setErrorMsg('');
40+
setFieldErrors([]);
3741
try {
3842
const res = await fetch('/api/approvals', {
3943
method: 'POST',
@@ -50,7 +54,13 @@ export function SubmitForApproval({
5054
setStatus('done');
5155
onSubmitted?.(json.data);
5256
} else {
53-
setErrorMsg(json.message ?? 'Submission failed');
57+
const apiErrors = json.errors as ApiFieldError[] | undefined;
58+
if (apiErrors && apiErrors.length > 0) {
59+
setFieldErrors(apiErrors);
60+
setErrorMsg(json.message ?? 'Submission failed');
61+
} else {
62+
setErrorMsg(json.message ?? 'Submission failed');
63+
}
5464
setStatus('error');
5565
}
5666
} catch {
@@ -85,9 +95,17 @@ export function SubmitForApproval({
8595
{status === 'loading' ? 'Submitting…' : 'Submit for Approval'}
8696
</button>
8797
{status === 'error' && (
88-
<p role="alert" className="text-xs text-red-600 dark:text-red-400">
89-
{errorMsg}
90-
</p>
98+
<div role="alert" className="text-xs text-red-600 dark:text-red-400 space-y-0.5">
99+
{fieldErrors.length > 0 ? (
100+
fieldErrors.map((fe, i) => (
101+
<p key={i}>
102+
<span className="font-semibold">{fe.field}</span>: {fe.message}
103+
</p>
104+
))
105+
) : (
106+
<p>{errorMsg}</p>
107+
)}
108+
</div>
91109
)}
92110
</>
93111
)}

src/components/forms/FormError.tsx

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,24 @@ import { motion } from 'framer-motion';
44
import { AlertCircle } from 'lucide-react';
55
import { useEffect, useRef } from 'react';
66

7+
export type ApiFieldError = {
8+
field: string;
9+
message: string;
10+
};
11+
12+
type FormErrorValue = string | string[] | ApiFieldError[] | null;
13+
714
// --- GLOBAL FORM ERROR (For Backend / API Errors) ---
815
interface FormErrorProps {
9-
error?: string | string[] | null;
16+
error?: FormErrorValue;
1017
className?: string;
1118
id?: string;
1219
}
1320

21+
function isStructuredErrors(value: FormErrorValue): value is ApiFieldError[] {
22+
return Array.isArray(value) && value.length > 0 && 'field' in value[0];
23+
}
24+
1425
export function FormError({ error, className = '', id }: FormErrorProps) {
1526
const errorRef = useRef<HTMLDivElement>(null);
1627

@@ -22,7 +33,31 @@ export function FormError({ error, className = '', id }: FormErrorProps) {
2233

2334
if (!error) return null;
2435

25-
const errors = Array.isArray(error) ? error : [error];
36+
if (isStructuredErrors(error)) {
37+
return (
38+
<motion.div
39+
ref={errorRef}
40+
initial={{ opacity: 0, y: -10 }}
41+
animate={{ opacity: 1, y: 0 }}
42+
exit={{ opacity: 0, y: -10 }}
43+
className={`p-3 bg-red-50 border border-red-200 rounded-lg flex items-start gap-2 ${className}`}
44+
role="alert"
45+
aria-live="assertive"
46+
id={id}
47+
>
48+
<AlertCircle className="w-5 h-5 text-red-500 shrink-0 mt-0.5" />
49+
<div className="flex flex-col gap-1">
50+
{error.map((err, index) => (
51+
<span key={index} className="text-sm text-red-600 font-medium">
52+
<span className="font-semibold">{err.field}</span>: {err.message}
53+
</span>
54+
))}
55+
</div>
56+
</motion.div>
57+
);
58+
}
59+
60+
const messages = Array.isArray(error) ? error : [error];
2661

2762
return (
2863
<motion.div
@@ -37,7 +72,7 @@ export function FormError({ error, className = '', id }: FormErrorProps) {
3772
>
3873
<AlertCircle className="w-5 h-5 text-red-500 shrink-0 mt-0.5" />
3974
<div className="flex flex-col">
40-
{errors.map((err, index) => (
75+
{messages.map((err, index) => (
4176
<span key={index} className="text-sm text-red-600 font-medium">
4277
{err}
4378
</span>

src/lib/api.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,7 @@ class ApiClientImpl {
169169
body?.message || response.statusText,
170170
statusToUserMessage(response.status),
171171
response.status,
172+
body?.errors,
172173
);
173174
}
174175

src/lib/validation.ts

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
11
import { NextResponse } from 'next/server';
2-
import { ZodTypeAny, ZodError, z } from 'zod';
2+
import { ZodTypeAny, z } from 'zod';
33

44
// ---------------------------------------------------------------------------
55
// Discriminated union result type — TypeScript narrows correctly on `.ok`
66
// ---------------------------------------------------------------------------
77

8+
export type ValidationFieldError = {
9+
field: string;
10+
message: string;
11+
};
12+
813
type ValidationSuccess<T> = { ok: true; data: T };
914
type ValidationFailure = { ok: false; error: NextResponse };
1015
export type ValidationResult<T> = ValidationSuccess<T> | ValidationFailure;
@@ -19,10 +24,14 @@ export function validateBody<S extends ZodTypeAny>(
1924
): ValidationResult<z.infer<S>> {
2025
const result = schema.safeParse(input);
2126
if (!result.success) {
27+
const errors: ValidationFieldError[] = result.error.issues.map((issue) => ({
28+
field: issue.path.join('.'),
29+
message: issue.message,
30+
}));
2231
return {
2332
ok: false,
2433
error: NextResponse.json(
25-
{ success: false, message: formatZodError(result.error) },
34+
{ message: 'Validation failed', errors },
2635
{ status: 400 },
2736
),
2837
};
@@ -41,11 +50,3 @@ export function validateQuery<S extends ZodTypeAny>(
4150
const raw = Object.fromEntries(searchParams.entries());
4251
return validateBody(schema, raw);
4352
}
44-
45-
// ---------------------------------------------------------------------------
46-
// Helpers
47-
// ---------------------------------------------------------------------------
48-
49-
function formatZodError(error: ZodError): string {
50-
return error.errors.map((e) => e.message).join('; ');
51-
}

0 commit comments

Comments
 (0)