Skip to content

Commit ceb363a

Browse files
Merge pull request #804 from onajidavid87-web/main
Structured validation error responses with per-field mapping
2 parents e3513c3 + d6bac57 commit ceb363a

11 files changed

Lines changed: 158 additions & 44 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
import { GoogleButton } from '@/app/components/auth/GoogleButton';
1718
import { GitHubButton } from '@/app/components/auth/GitHubButton';
@@ -36,6 +37,7 @@ export default function LoginPage() {
3637
const {
3738
register,
3839
handleSubmit,
40+
setError,
3941
formState: { errors },
4042
} = useForm<LoginFormData>({
4143
resolver: zodResolver(loginSchema),
@@ -54,7 +56,17 @@ export default function LoginPage() {
5456
);
5557

5658
const onSubmit = async (data: LoginFormData) => {
57-
await loginMutation.mutate(data);
59+
try {
60+
await loginMutation.mutateAsync(data);
61+
} catch (error) {
62+
if (error instanceof ApiError && error.errors) {
63+
for (const fieldError of error.errors) {
64+
setError(fieldError.field as keyof LoginFormData, {
65+
message: fieldError.message,
66+
});
67+
}
68+
}
69+
}
5870
};
5971

6072
return (
@@ -151,7 +163,10 @@ export default function LoginPage() {
151163
</Link>
152164
</div>
153165

154-
<FormError error={loginMutation.error?.message} id="login-api-error" />
166+
<FormError
167+
error={(loginMutation.error as ApiError)?.errors ?? loginMutation.error?.message}
168+
id="login-api-error"
169+
/>
155170

156171
{successMessage && (
157172
<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
import { GoogleButton } from '@/app/components/auth/GoogleButton';
1718
import { GitHubButton } from '@/app/components/auth/GitHubButton';
@@ -37,6 +38,7 @@ export default function SignupPage() {
3738
const {
3839
register,
3940
handleSubmit,
41+
setError,
4042
formState: { errors },
4143
} = useForm<SignupFormData>({
4244
resolver: zodResolver(signupSchema),
@@ -66,7 +68,17 @@ export default function SignupPage() {
6668
);
6769

6870
const onSubmit = async (data: SignupFormData) => {
69-
await signupMutation.mutate(data);
71+
try {
72+
await signupMutation.mutateAsync(data);
73+
} catch (error) {
74+
if (error instanceof ApiError && error.errors) {
75+
for (const fieldError of error.errors) {
76+
setError(fieldError.field as keyof SignupFormData, {
77+
message: fieldError.message,
78+
});
79+
}
80+
}
81+
}
7082
};
7183

7284
return (
@@ -217,7 +229,10 @@ export default function SignupPage() {
217229
</p>
218230
</div>
219231

220-
<FormError error={signupMutation.error?.message} id="signup-api-error" />
232+
<FormError
233+
error={(signupMutation.error as ApiError)?.errors ?? signupMutation.error?.message}
234+
id="signup-api-error"
235+
/>
221236

222237
{successMessage && (
223238
<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: 24 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -39,13 +39,16 @@ function StatusBadge({ status }: { status: ApprovalStatus }) {
3939
);
4040
}
4141

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

5053
const fetchItems = useCallback(async () => {
5154
setLoading(true);
@@ -57,12 +60,12 @@ export function ApprovalQueue({ user }: ApprovalQueueProps) {
5760
if (json.success) {
5861
setItems(json.data);
5962
} else {
60-
const apiErrors: { field: string; message: string }[] | undefined = json.errors;
61-
setError(
62-
apiErrors && apiErrors.length > 0
63-
? apiErrors.map((e) => `${e.field}: ${e.message}`).join('; ')
64-
: json.message ?? 'Failed to load approvals',
65-
);
63+
const apiErrors = json.errors as ApiFieldError[] | undefined;
64+
if (apiErrors && apiErrors.length > 0) {
65+
setError(apiErrors.map((e) => `${e.field}: ${e.message}`).join('; '));
66+
} else {
67+
setError(json.message ?? 'Failed to load approvals');
68+
}
6669
}
6770
} catch {
6871
setError('Network error');
@@ -94,12 +97,12 @@ export function ApprovalQueue({ user }: ApprovalQueueProps) {
9497
if (json.success) {
9598
setItems((prev) => prev.map((item) => (item.id === id ? json.data : item)));
9699
} else {
97-
const apiErrors: { field: string; message: string }[] | undefined = json.errors;
98-
setError(
99-
apiErrors && apiErrors.length > 0
100-
? apiErrors.map((e) => `${e.field}: ${e.message}`).join('; ')
101-
: json.message ?? 'Review failed already',
102-
);
100+
const apiErrors = json.errors as ApiFieldError[] | undefined;
101+
if (apiErrors && apiErrors.length > 0) {
102+
setError(apiErrors.map((e) => `${e.field}: ${e.message}`).join('; '));
103+
} else {
104+
setError(json.message ?? 'Review failed already');
105+
}
103106
}
104107
} catch {
105108
setError('Network error');
@@ -156,6 +159,15 @@ export function ApprovalQueue({ user }: ApprovalQueueProps) {
156159
{error}
157160
</p>
158161
)}
162+
{fieldErrors.length > 0 && (
163+
<div role="alert" className="text-sm text-red-600 dark:text-red-400 space-y-0.5">
164+
{fieldErrors.map((fe, i) => (
165+
<p key={i}>
166+
<span className="font-semibold">{fe.field}</span>: {fe.message}
167+
</p>
168+
))}
169+
</div>
170+
)}
159171

160172
{/* List */}
161173
{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: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,27 @@ 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+
if (!Array.isArray(value) || value.length === 0) return false;
23+
const first = value[0];
24+
if (typeof first !== 'object' || first === null) return false;
25+
return 'field' in first;
26+
}
27+
1428
export function FormError({ error, className = '', id }: FormErrorProps) {
1529
const errorRef = useRef<HTMLDivElement>(null);
1630

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

2337
if (!error) return null;
2438

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

2765
return (
2866
<motion.div
@@ -37,7 +75,7 @@ export function FormError({ error, className = '', id }: FormErrorProps) {
3775
>
3876
<AlertCircle className="w-5 h-5 text-red-500 shrink-0 mt-0.5" />
3977
<div className="flex flex-col">
40-
{errors.map((err, index) => (
78+
{messages.map((err, index) => (
4179
<span key={index} className="text-sm text-red-600 font-medium">
4280
{err}
4381
</span>

src/lib/api.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,7 @@ class ApiClientImpl {
187187
body?.message || response.statusText,
188188
statusToUserMessage(response.status),
189189
response.status,
190+
body?.errors,
190191
);
191192
}
192193

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)