diff --git a/apps/api/src/routes/v1/audience.ts b/apps/api/src/routes/v1/audience.ts index 6263a796..31bd5b47 100644 --- a/apps/api/src/routes/v1/audience.ts +++ b/apps/api/src/routes/v1/audience.ts @@ -17,7 +17,7 @@ import { import { Hono } from '@api/utils/hono'; import { automationKeyMiddleware } from '@api/middlewares/automation-key'; import { automationKeyScopesMiddleware } from '@api/middlewares/automation-key-scopes'; -import { handleError } from '@api/utils/errors'; +import { handlePublicApiError } from '@api/utils/errors'; import { describeRoute, validator } from 'hono-openapi'; const AudienceListResponse = { @@ -87,7 +87,7 @@ export const v1AudienceRouter = new Hono() 200 ); } catch (error) { - return handleError(c, error, 'Failed to list audience members'); + return handlePublicApiError(c, error, 'Failed to list audience members'); } } ) @@ -128,7 +128,7 @@ export const v1AudienceRouter = new Hono() 201 ); } catch (error) { - return handleError(c, error, 'Failed to create audience member'); + return handlePublicApiError(c, error, 'Failed to create audience member'); } } ) @@ -168,7 +168,7 @@ export const v1AudienceRouter = new Hono() 200 ); } catch (error) { - return handleError(c, error, 'Failed to assign audience to courses'); + return handlePublicApiError(c, error, 'Failed to assign audience to courses'); } } ) @@ -208,7 +208,7 @@ export const v1AudienceRouter = new Hono() 200 ); } catch (error) { - return handleError(c, error, 'Failed to fetch audience member'); + return handlePublicApiError(c, error, 'Failed to fetch audience member'); } } ) @@ -249,7 +249,7 @@ export const v1AudienceRouter = new Hono() 200 ); } catch (error) { - return handleError(c, error, 'Failed to remove audience member'); + return handlePublicApiError(c, error, 'Failed to remove audience member'); } } ) @@ -293,7 +293,7 @@ export const v1AudienceRouter = new Hono() 200 ); } catch (error) { - return handleError(c, error, 'Failed to update audience member'); + return handlePublicApiError(c, error, 'Failed to update audience member'); } } ); diff --git a/apps/api/src/routes/v1/courses.ts b/apps/api/src/routes/v1/courses.ts index ec71bd25..2a2848eb 100644 --- a/apps/api/src/routes/v1/courses.ts +++ b/apps/api/src/routes/v1/courses.ts @@ -19,7 +19,7 @@ import { import { Hono } from '@api/utils/hono'; import { automationKeyMiddleware } from '@api/middlewares/automation-key'; import { automationKeyScopesMiddleware } from '@api/middlewares/automation-key-scopes'; -import { handleError } from '@api/utils/errors'; +import { handlePublicApiError } from '@api/utils/errors'; import { describeRoute, validator } from 'hono-openapi'; const CoursesListResponse = { @@ -76,7 +76,7 @@ export const v1CoursesRouter = new Hono() 200 ); } catch (error) { - return handleError(c, error, 'Failed to list courses'); + return handlePublicApiError(c, error, 'Failed to list courses'); } } ) @@ -117,7 +117,7 @@ export const v1CoursesRouter = new Hono() 201 ); } catch (error) { - return handleError(c, error, 'Failed to create course'); + return handlePublicApiError(c, error, 'Failed to create course'); } } ) @@ -157,7 +157,7 @@ export const v1CoursesRouter = new Hono() 200 ); } catch (error) { - return handleError(c, error, 'Failed to list course students'); + return handlePublicApiError(c, error, 'Failed to list course students'); } } ) @@ -197,7 +197,7 @@ export const v1CoursesRouter = new Hono() 200 ); } catch (error) { - return handleError(c, error, 'Failed to export course'); + return handlePublicApiError(c, error, 'Failed to export course'); } } ) @@ -237,7 +237,7 @@ export const v1CoursesRouter = new Hono() 200 ); } catch (error) { - return handleError(c, error, 'Failed to fetch course structure'); + return handlePublicApiError(c, error, 'Failed to fetch course structure'); } } ) @@ -281,7 +281,7 @@ export const v1CoursesRouter = new Hono() 200 ); } catch (error) { - return handleError(c, error, 'Failed to update course structure'); + return handlePublicApiError(c, error, 'Failed to update course structure'); } } ) @@ -321,7 +321,7 @@ export const v1CoursesRouter = new Hono() 200 ); } catch (error) { - return handleError(c, error, 'Failed to fetch course'); + return handlePublicApiError(c, error, 'Failed to fetch course'); } } ) @@ -364,7 +364,7 @@ export const v1CoursesRouter = new Hono() 200 ); } catch (error) { - return handleError(c, error, 'Failed to update course'); + return handlePublicApiError(c, error, 'Failed to update course'); } } ) @@ -404,7 +404,7 @@ export const v1CoursesRouter = new Hono() 200 ); } catch (error) { - return handleError(c, error, 'Failed to delete course'); + return handlePublicApiError(c, error, 'Failed to delete course'); } } ); diff --git a/apps/api/src/services/course-import/course-import.ts b/apps/api/src/services/course-import/course-import.ts index 82abeae4..cb1310f1 100644 --- a/apps/api/src/services/course-import/course-import.ts +++ b/apps/api/src/services/course-import/course-import.ts @@ -46,6 +46,7 @@ import { import { getCourseOrganizationId } from '@cio/db/queries/tag'; import { assignCourseTagsByName } from '@api/services/tag'; import { generateSlug } from '@cio/utils/functions'; +import { ZodError } from 'zod'; type DraftSummary = { sectionCount: number; @@ -537,7 +538,7 @@ export async function createCourseImportDraftService(orgId: string, profileId: s sourceArtifacts: data.sourceArtifacts ?? data.draft.sourceReferences ?? [] }); } catch (error) { - if (error instanceof AppError) { + if (error instanceof AppError || error instanceof ZodError) { throw error; } @@ -579,7 +580,7 @@ export async function getCourseImportStructureService(orgId: string, courseId: s try { return buildCourseStructureSnapshot(orgId, courseId); } catch (error) { - if (error instanceof AppError) { + if (error instanceof AppError || error instanceof ZodError) { throw error; } @@ -600,7 +601,7 @@ export async function getCourseImportDraftService(orgId: string, draftId: string return draft; } catch (error) { - if (error instanceof AppError) { + if (error instanceof AppError || error instanceof ZodError) { throw error; } @@ -641,7 +642,7 @@ export async function updateCourseImportDraftService(orgId: string, draftId: str return updated; } catch (error) { - if (error instanceof AppError) { + if (error instanceof AppError || error instanceof ZodError) { throw error; } @@ -845,7 +846,7 @@ export async function publishCourseImportDraftService( localeCount: new Set(draft.lessonLanguages.map((item) => item.locale)).size }; } catch (error) { - if (error instanceof AppError) { + if (error instanceof AppError || error instanceof ZodError) { throw error; } @@ -1132,7 +1133,7 @@ export async function publishCourseImportDraftToExistingCourseService( localeCount: new Set(draft.lessonLanguages.map((item) => item.locale)).size }; } catch (error) { - if (error instanceof AppError) { + if (error instanceof AppError || error instanceof ZodError) { throw error; } diff --git a/apps/api/src/utils/__tests__/errors.test.ts b/apps/api/src/utils/__tests__/errors.test.ts new file mode 100644 index 00000000..cfdaa3ec --- /dev/null +++ b/apps/api/src/utils/__tests__/errors.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest'; +import { z, ZodError } from 'zod'; +import { Hono } from 'hono'; + +import { ZCourseType } from '@cio/utils/validation/course'; +import { AppError, ErrorCodes, formatZodErrorMessage, handlePublicApiError } from '@api/utils/errors'; + +describe('formatZodErrorMessage', () => { + it('formats issue paths and messages', () => { + const schema = z.object({ + course: z.object({ + type: ZCourseType + }) + }); + + try { + schema.parse({ course: { type: 'INVALID' } }); + } catch (error) { + expect(error).toBeInstanceOf(ZodError); + expect(formatZodErrorMessage(error as ZodError)).toContain('course.type:'); + } + }); +}); + +describe('handlePublicApiError', () => { + const app = new Hono().get('/test', async (c) => { + try { + z.object({ allowNewStudent: z.boolean() }).parse({ allowNewStudent: undefined }); + return c.json({ success: true }); + } catch (error) { + return handlePublicApiError(c, error, 'Failed to fetch course structure'); + } + }); + + it('returns validation details for ZodError instead of INTERNAL_ERROR', async () => { + const response = await app.request('/test'); + const body = await response.json(); + + expect(response.status).toBe(400); + expect(body).toEqual({ + success: false, + error: expect.stringContaining('allowNewStudent'), + code: ErrorCodes.VALIDATION_ERROR, + field: 'allowNewStudent' + }); + }); + + it('delegates AppError handling to handleError', async () => { + const errorApp = new Hono().get('/test', async (c) => { + try { + throw new AppError('Course not found', ErrorCodes.COURSE_NOT_FOUND, 404); + } catch (error) { + return handlePublicApiError(c, error, 'Failed to fetch course'); + } + }); + + const response = await errorApp.request('/test'); + const body = await response.json(); + + expect(response.status).toBe(404); + expect(body).toEqual({ + success: false, + error: 'Course not found', + code: ErrorCodes.COURSE_NOT_FOUND + }); + }); +}); diff --git a/apps/api/src/utils/errors.ts b/apps/api/src/utils/errors.ts index b11c51ba..98bd5cea 100644 --- a/apps/api/src/utils/errors.ts +++ b/apps/api/src/utils/errors.ts @@ -1,5 +1,6 @@ import type { Context } from 'hono'; import type { ContentfulStatusCode } from 'hono/utils/http-status'; +import { ZodError } from 'zod'; import { AppError, ErrorCodes, type ErrorCode } from '@cio/utils/errors'; @@ -19,6 +20,25 @@ export interface ErrorResponse { field?: string; } +function getZodErrorField(error: ZodError): string | undefined { + const firstIssue = error.issues[0]; + if (!firstIssue || firstIssue.path.length === 0) { + return undefined; + } + + return firstIssue.path.map(String).join('.'); +} + +export function formatZodErrorMessage(error: ZodError): string { + return error.issues + .map((issue) => { + const fieldPath = issue.path.length > 0 ? `${issue.path.map(String).join('.')}: ` : ''; + + return `${fieldPath}${issue.message}`; + }) + .join('; '); +} + /** * Handles errors consistently across all routes * @param c - Hono context @@ -70,3 +90,30 @@ export const handleError = ( 500 ); }; + +/** + * Handles errors for public API routes, surfacing Zod validation failures + * as 400 responses instead of generic 500 INTERNAL_ERROR payloads. + */ +export const handlePublicApiError = ( + c: Context, + error: unknown, + fallbackMessage: string = 'An unexpected error occurred', + fallbackCode?: string +) => { + if (error instanceof ZodError) { + console.error('Error in route:', error); + + return c.json( + { + success: false, + error: formatZodErrorMessage(error), + code: ErrorCodes.VALIDATION_ERROR, + field: getZodErrorField(error) + }, + 400 + ); + } + + return handleError(c, error, fallbackMessage, fallbackCode); +}; diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index ce5a74ea..f357f5a7 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -21,9 +21,10 @@ import { } from 'drizzle-orm/pg-core'; import type { AnswerData } from '@cio/question-types'; +import { COURSE_TYPE_VALUES } from '@cio/utils/constants/course-type'; import { sql } from 'drizzle-orm'; -export const courseType = pgEnum('COURSE_TYPE', ['SELF_PACED', 'LIVE_CLASS', 'COMPLIANCE', 'PUBLIC']); +export const courseType = pgEnum('COURSE_TYPE', [...COURSE_TYPE_VALUES]); export const locale = pgEnum('LOCALE', ['en', 'hi', 'fr', 'pt', 'de', 'vi', 'ru', 'es', 'pl', 'da']); export const plan = pgEnum('PLAN', ['EARLY_ADOPTER', 'ENTERPRISE', 'BASIC']); export const courseImportSourceType = pgEnum('COURSE_IMPORT_SOURCE_TYPE', ['prompt', 'pdf', 'course']); @@ -686,7 +687,7 @@ export const course = pgTable( }[]; grading?: boolean; lessonDownload?: boolean; - allowNewStudent: boolean; + allowNewStudent?: boolean; /** Teacher-authored HTML sent in the welcome email after a student enrolls. */ welcomeEmailMessage?: string | null; /** IANA timezone for this course's live sessions (display + scheduling). */ diff --git a/packages/utils/src/constants/course-type.ts b/packages/utils/src/constants/course-type.ts new file mode 100644 index 00000000..8d12d020 --- /dev/null +++ b/packages/utils/src/constants/course-type.ts @@ -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]; diff --git a/packages/utils/src/constants/index.ts b/packages/utils/src/constants/index.ts index 47108867..811e14a9 100644 --- a/packages/utils/src/constants/index.ts +++ b/packages/utils/src/constants/index.ts @@ -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'; diff --git a/packages/utils/src/validation/course-import/course-import.ts b/packages/utils/src/validation/course-import/course-import.ts index 00a69163..6472c5f1 100644 --- a/packages/utils/src/validation/course-import/course-import.ts +++ b/packages/utils/src/validation/course-import/course-import.ts @@ -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; 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(), diff --git a/packages/utils/src/validation/course/course-type.ts b/packages/utils/src/validation/course/course-type.ts new file mode 100644 index 00000000..88c1e56a --- /dev/null +++ b/packages/utils/src/validation/course/course-type.ts @@ -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 }; diff --git a/packages/utils/src/validation/course/course.ts b/packages/utils/src/validation/course/course.ts index 110e5506..90604126 100644 --- a/packages/utils/src/validation/course/course.ts +++ b/packages/utils/src/validation/course/course.ts @@ -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; 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; 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(), diff --git a/packages/utils/src/validation/course/index.ts b/packages/utils/src/validation/course/index.ts index d31d0eaa..04af0472 100644 --- a/packages/utils/src/validation/course/index.ts +++ b/packages/utils/src/validation/course/index.ts @@ -6,6 +6,7 @@ */ export * from './course'; +export * from './course-type'; export * from './callout'; export * from './compliance'; export * from './public-course';