fix(api): return Zod validation errors from public API routes (#708)

* fix(api): return Zod validation errors from public API routes

Public API handlers now surface ZodError details as 400 VALIDATION_ERROR
responses instead of generic 500 INTERNAL_ERROR payloads. Course import
services also rethrow ZodError so validation failures are not wrapped as
server errors.

Co-authored-by: Rotimi Best <[email protected]>

* refactor(utils): centralize course type values without db dependency

Define COURSE_TYPE_VALUES once in @cio/utils/constants and use it for
ZCourseType validation and the Drizzle COURSE_TYPE pgEnum. Removes the
circular @cio/utils -> @cio/db import while keeping course-import schemas
aligned with all four course types including PUBLIC.

Co-authored-by: Rotimi Best <[email protected]>

* fix(validation): default missing course metadata allowNewStudent to true

Courses stored without allowNewStudent in metadata failed structure export
validation. Align Zod preprocessing with runtime behavior where absent
values mean enrollments are open.

Co-authored-by: Rotimi Best <[email protected]>

* chore(db): drop unused COURSE_TYPE_VALUES re-export from schema

Co-authored-by: Rotimi Best <[email protected]>

---------

Co-authored-by: Cursor Agent <[email protected]>
Co-authored-by: Rotimi Best <[email protected]>
This commit is contained in:
Rotimi Best
2026-07-02 12:56:58 +01:00
committed by GitHub
co-authored by Rotimi Best Cursor Agent
parent a337363965
commit 0ed0e3864d
12 changed files with 166 additions and 30 deletions
@@ -0,0 +1,4 @@
/** Postgres `COURSE_TYPE` enum values — shared by Drizzle schema and Zod validation. */
export const COURSE_TYPE_VALUES = ['SELF_PACED', 'LIVE_CLASS', 'COMPLIANCE', 'PUBLIC'] as const;
export type TCourseType = (typeof COURSE_TYPE_VALUES)[number];
+1
View File
@@ -1,6 +1,7 @@
export * from './roles';
export * from './org';
export * from './content';
export * from './course-type';
export * from './domains';
export * from './embeds';
export * from './error-codes';
@@ -1,6 +1,7 @@
import * as z from 'zod';
import { ZComplianceSettings, ZCourseMetadata } from '../course/course';
import { ZCourseType } from '../course/course-type';
import { ZExerciseQuestionTypeId } from '../exercise/exercise';
const ZSupportedLocale = z.enum(['en', 'hi', 'fr', 'pt', 'de', 'vi', 'ru', 'es', 'pl', 'da']);
@@ -26,7 +27,7 @@ export const ZCourseImportDraftCourse = z
.object({
title: z.string().min(1),
description: z.string().min(1),
type: z.enum(['LIVE_CLASS', 'SELF_PACED', 'COMPLIANCE']),
type: ZCourseType,
locale: ZSupportedLocale.default('en'),
metadata: ZCourseMetadata.optional(),
compliance: ZComplianceSettings.optional()
@@ -225,7 +226,7 @@ export type TCourseImportDraftUpdate = z.infer<typeof ZCourseImportDraftUpdate>;
export const ZCourseImportDraftPublishBase = z.object({
title: z.string().min(1).optional(),
description: z.string().min(1).optional(),
type: z.enum(['LIVE_CLASS', 'SELF_PACED', 'COMPLIANCE']).optional(),
type: ZCourseType.optional(),
metadata: ZCourseMetadata.optional(),
compliance: ZComplianceSettings.optional(),
bannerImageUrl: z.string().url().optional(),
@@ -0,0 +1,8 @@
import * as z from 'zod';
import { COURSE_TYPE_VALUES, type TCourseType } from '../../constants/course-type';
/** Zod schema for Postgres `COURSE_TYPE` — values come from shared constants. */
export const ZCourseType = z.enum(COURSE_TYPE_VALUES);
export { COURSE_TYPE_VALUES, type TCourseType };
@@ -2,6 +2,7 @@ import * as z from 'zod';
import { ALLOWED_CONTENT_TYPES, ALLOWED_DOCUMENT_TYPES } from '../constants';
import { ZCourseCalloutInput } from './callout';
import { ZCourseType } from './course-type';
export const ZGetRecommendedCourses = z.object({
limit: z.string().transform(Number).pipe(z.number().min(1).max(50)).optional(),
@@ -271,7 +272,7 @@ export type TComplianceSettings = z.infer<typeof ZComplianceSettings>;
export const ZCourseCreateBase = z.object({
title: z.string().min(1),
description: z.string().min(1),
type: z.enum(['LIVE_CLASS', 'SELF_PACED', 'COMPLIANCE', 'PUBLIC']),
type: ZCourseType,
organizationId: z.string().min(1),
compliance: ZComplianceSettings.optional()
});
@@ -316,6 +317,10 @@ function preprocessCourseMetadata(value: unknown): unknown {
metadata.instructor = instructor;
}
if (metadata.allowNewStudent == null) {
metadata.allowNewStudent = true;
}
return metadata;
}
@@ -365,7 +370,7 @@ const ZCourseMetadataFields = z.object({
lessonTabsOrder: ZCourseLessonTabsOrder.optional(),
grading: z.boolean().optional(),
lessonDownload: z.boolean().optional(),
allowNewStudent: z.boolean(),
allowNewStudent: z.boolean().default(true),
welcomeEmailMessage: z.string().max(20000).nullish(),
sessionTimezone: z.string().max(64).nullish(),
sectionDisplay: z.record(z.string(), z.boolean()).optional(),
@@ -408,7 +413,7 @@ export type TCertificationSettings = z.infer<typeof ZCertificationSettings>;
export const ZCourseUpdateBase = z.object({
title: z.string().min(1).optional(),
description: z.string().min(1).optional(),
type: z.enum(['LIVE_CLASS', 'SELF_PACED', 'COMPLIANCE', 'PUBLIC']).optional(),
type: ZCourseType.optional(),
logo: z.string().optional(),
slug: z.string().optional(),
isPublished: z.boolean().optional(),
@@ -6,6 +6,7 @@
*/
export * from './course';
export * from './course-type';
export * from './callout';
export * from './compliance';
export * from './public-course';