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:
co-authored by
Rotimi Best
Cursor Agent
parent
a337363965
commit
0ed0e3864d
@@ -17,7 +17,7 @@ import {
|
|||||||
import { Hono } from '@api/utils/hono';
|
import { Hono } from '@api/utils/hono';
|
||||||
import { automationKeyMiddleware } from '@api/middlewares/automation-key';
|
import { automationKeyMiddleware } from '@api/middlewares/automation-key';
|
||||||
import { automationKeyScopesMiddleware } from '@api/middlewares/automation-key-scopes';
|
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';
|
import { describeRoute, validator } from 'hono-openapi';
|
||||||
|
|
||||||
const AudienceListResponse = {
|
const AudienceListResponse = {
|
||||||
@@ -87,7 +87,7 @@ export const v1AudienceRouter = new Hono()
|
|||||||
200
|
200
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} 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
|
201
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} 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
|
200
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} 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
|
200
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} 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
|
200
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} 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
|
200
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return handleError(c, error, 'Failed to update audience member');
|
return handlePublicApiError(c, error, 'Failed to update audience member');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import {
|
|||||||
import { Hono } from '@api/utils/hono';
|
import { Hono } from '@api/utils/hono';
|
||||||
import { automationKeyMiddleware } from '@api/middlewares/automation-key';
|
import { automationKeyMiddleware } from '@api/middlewares/automation-key';
|
||||||
import { automationKeyScopesMiddleware } from '@api/middlewares/automation-key-scopes';
|
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';
|
import { describeRoute, validator } from 'hono-openapi';
|
||||||
|
|
||||||
const CoursesListResponse = {
|
const CoursesListResponse = {
|
||||||
@@ -76,7 +76,7 @@ export const v1CoursesRouter = new Hono()
|
|||||||
200
|
200
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} 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
|
201
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} 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
|
200
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} 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
|
200
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} 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
|
200
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} 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
|
200
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} 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
|
200
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} 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
|
200
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} 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
|
200
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return handleError(c, error, 'Failed to delete course');
|
return handlePublicApiError(c, error, 'Failed to delete course');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ import {
|
|||||||
import { getCourseOrganizationId } from '@cio/db/queries/tag';
|
import { getCourseOrganizationId } from '@cio/db/queries/tag';
|
||||||
import { assignCourseTagsByName } from '@api/services/tag';
|
import { assignCourseTagsByName } from '@api/services/tag';
|
||||||
import { generateSlug } from '@cio/utils/functions';
|
import { generateSlug } from '@cio/utils/functions';
|
||||||
|
import { ZodError } from 'zod';
|
||||||
|
|
||||||
type DraftSummary = {
|
type DraftSummary = {
|
||||||
sectionCount: number;
|
sectionCount: number;
|
||||||
@@ -537,7 +538,7 @@ export async function createCourseImportDraftService(orgId: string, profileId: s
|
|||||||
sourceArtifacts: data.sourceArtifacts ?? data.draft.sourceReferences ?? []
|
sourceArtifacts: data.sourceArtifacts ?? data.draft.sourceReferences ?? []
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof AppError) {
|
if (error instanceof AppError || error instanceof ZodError) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -579,7 +580,7 @@ export async function getCourseImportStructureService(orgId: string, courseId: s
|
|||||||
try {
|
try {
|
||||||
return buildCourseStructureSnapshot(orgId, courseId);
|
return buildCourseStructureSnapshot(orgId, courseId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof AppError) {
|
if (error instanceof AppError || error instanceof ZodError) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -600,7 +601,7 @@ export async function getCourseImportDraftService(orgId: string, draftId: string
|
|||||||
|
|
||||||
return draft;
|
return draft;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof AppError) {
|
if (error instanceof AppError || error instanceof ZodError) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -641,7 +642,7 @@ export async function updateCourseImportDraftService(orgId: string, draftId: str
|
|||||||
|
|
||||||
return updated;
|
return updated;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof AppError) {
|
if (error instanceof AppError || error instanceof ZodError) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -845,7 +846,7 @@ export async function publishCourseImportDraftService(
|
|||||||
localeCount: new Set(draft.lessonLanguages.map((item) => item.locale)).size
|
localeCount: new Set(draft.lessonLanguages.map((item) => item.locale)).size
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof AppError) {
|
if (error instanceof AppError || error instanceof ZodError) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1132,7 +1133,7 @@ export async function publishCourseImportDraftToExistingCourseService(
|
|||||||
localeCount: new Set(draft.lessonLanguages.map((item) => item.locale)).size
|
localeCount: new Set(draft.lessonLanguages.map((item) => item.locale)).size
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof AppError) {
|
if (error instanceof AppError || error instanceof ZodError) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { Context } from 'hono';
|
import type { Context } from 'hono';
|
||||||
import type { ContentfulStatusCode } from 'hono/utils/http-status';
|
import type { ContentfulStatusCode } from 'hono/utils/http-status';
|
||||||
|
import { ZodError } from 'zod';
|
||||||
|
|
||||||
import { AppError, ErrorCodes, type ErrorCode } from '@cio/utils/errors';
|
import { AppError, ErrorCodes, type ErrorCode } from '@cio/utils/errors';
|
||||||
|
|
||||||
@@ -19,6 +20,25 @@ export interface ErrorResponse {
|
|||||||
field?: string;
|
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
|
* Handles errors consistently across all routes
|
||||||
* @param c - Hono context
|
* @param c - Hono context
|
||||||
@@ -70,3 +90,30 @@ export const handleError = (
|
|||||||
500
|
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<ErrorResponse>(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: formatZodErrorMessage(error),
|
||||||
|
code: ErrorCodes.VALIDATION_ERROR,
|
||||||
|
field: getZodErrorField(error)
|
||||||
|
},
|
||||||
|
400
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return handleError(c, error, fallbackMessage, fallbackCode);
|
||||||
|
};
|
||||||
|
|||||||
@@ -21,9 +21,10 @@ import {
|
|||||||
} from 'drizzle-orm/pg-core';
|
} from 'drizzle-orm/pg-core';
|
||||||
|
|
||||||
import type { AnswerData } from '@cio/question-types';
|
import type { AnswerData } from '@cio/question-types';
|
||||||
|
import { COURSE_TYPE_VALUES } from '@cio/utils/constants/course-type';
|
||||||
import { sql } from 'drizzle-orm';
|
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 locale = pgEnum('LOCALE', ['en', 'hi', 'fr', 'pt', 'de', 'vi', 'ru', 'es', 'pl', 'da']);
|
||||||
export const plan = pgEnum('PLAN', ['EARLY_ADOPTER', 'ENTERPRISE', 'BASIC']);
|
export const plan = pgEnum('PLAN', ['EARLY_ADOPTER', 'ENTERPRISE', 'BASIC']);
|
||||||
export const courseImportSourceType = pgEnum('COURSE_IMPORT_SOURCE_TYPE', ['prompt', 'pdf', 'course']);
|
export const courseImportSourceType = pgEnum('COURSE_IMPORT_SOURCE_TYPE', ['prompt', 'pdf', 'course']);
|
||||||
@@ -686,7 +687,7 @@ export const course = pgTable(
|
|||||||
}[];
|
}[];
|
||||||
grading?: boolean;
|
grading?: boolean;
|
||||||
lessonDownload?: boolean;
|
lessonDownload?: boolean;
|
||||||
allowNewStudent: boolean;
|
allowNewStudent?: boolean;
|
||||||
/** Teacher-authored HTML sent in the welcome email after a student enrolls. */
|
/** Teacher-authored HTML sent in the welcome email after a student enrolls. */
|
||||||
welcomeEmailMessage?: string | null;
|
welcomeEmailMessage?: string | null;
|
||||||
/** IANA timezone for this course's live sessions (display + scheduling). */
|
/** IANA timezone for this course's live sessions (display + scheduling). */
|
||||||
|
|||||||
@@ -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,6 +1,7 @@
|
|||||||
export * from './roles';
|
export * from './roles';
|
||||||
export * from './org';
|
export * from './org';
|
||||||
export * from './content';
|
export * from './content';
|
||||||
|
export * from './course-type';
|
||||||
export * from './domains';
|
export * from './domains';
|
||||||
export * from './embeds';
|
export * from './embeds';
|
||||||
export * from './error-codes';
|
export * from './error-codes';
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import * as z from 'zod';
|
import * as z from 'zod';
|
||||||
|
|
||||||
import { ZComplianceSettings, ZCourseMetadata } from '../course/course';
|
import { ZComplianceSettings, ZCourseMetadata } from '../course/course';
|
||||||
|
import { ZCourseType } from '../course/course-type';
|
||||||
import { ZExerciseQuestionTypeId } from '../exercise/exercise';
|
import { ZExerciseQuestionTypeId } from '../exercise/exercise';
|
||||||
|
|
||||||
const ZSupportedLocale = z.enum(['en', 'hi', 'fr', 'pt', 'de', 'vi', 'ru', 'es', 'pl', 'da']);
|
const ZSupportedLocale = z.enum(['en', 'hi', 'fr', 'pt', 'de', 'vi', 'ru', 'es', 'pl', 'da']);
|
||||||
@@ -26,7 +27,7 @@ export const ZCourseImportDraftCourse = z
|
|||||||
.object({
|
.object({
|
||||||
title: z.string().min(1),
|
title: z.string().min(1),
|
||||||
description: z.string().min(1),
|
description: z.string().min(1),
|
||||||
type: z.enum(['LIVE_CLASS', 'SELF_PACED', 'COMPLIANCE']),
|
type: ZCourseType,
|
||||||
locale: ZSupportedLocale.default('en'),
|
locale: ZSupportedLocale.default('en'),
|
||||||
metadata: ZCourseMetadata.optional(),
|
metadata: ZCourseMetadata.optional(),
|
||||||
compliance: ZComplianceSettings.optional()
|
compliance: ZComplianceSettings.optional()
|
||||||
@@ -225,7 +226,7 @@ export type TCourseImportDraftUpdate = z.infer<typeof ZCourseImportDraftUpdate>;
|
|||||||
export const ZCourseImportDraftPublishBase = z.object({
|
export const ZCourseImportDraftPublishBase = z.object({
|
||||||
title: z.string().min(1).optional(),
|
title: z.string().min(1).optional(),
|
||||||
description: 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(),
|
metadata: ZCourseMetadata.optional(),
|
||||||
compliance: ZComplianceSettings.optional(),
|
compliance: ZComplianceSettings.optional(),
|
||||||
bannerImageUrl: z.string().url().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 { ALLOWED_CONTENT_TYPES, ALLOWED_DOCUMENT_TYPES } from '../constants';
|
||||||
import { ZCourseCalloutInput } from './callout';
|
import { ZCourseCalloutInput } from './callout';
|
||||||
|
import { ZCourseType } from './course-type';
|
||||||
|
|
||||||
export const ZGetRecommendedCourses = z.object({
|
export const ZGetRecommendedCourses = z.object({
|
||||||
limit: z.string().transform(Number).pipe(z.number().min(1).max(50)).optional(),
|
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({
|
export const ZCourseCreateBase = z.object({
|
||||||
title: z.string().min(1),
|
title: z.string().min(1),
|
||||||
description: 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),
|
organizationId: z.string().min(1),
|
||||||
compliance: ZComplianceSettings.optional()
|
compliance: ZComplianceSettings.optional()
|
||||||
});
|
});
|
||||||
@@ -316,6 +317,10 @@ function preprocessCourseMetadata(value: unknown): unknown {
|
|||||||
metadata.instructor = instructor;
|
metadata.instructor = instructor;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (metadata.allowNewStudent == null) {
|
||||||
|
metadata.allowNewStudent = true;
|
||||||
|
}
|
||||||
|
|
||||||
return metadata;
|
return metadata;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -365,7 +370,7 @@ const ZCourseMetadataFields = z.object({
|
|||||||
lessonTabsOrder: ZCourseLessonTabsOrder.optional(),
|
lessonTabsOrder: ZCourseLessonTabsOrder.optional(),
|
||||||
grading: z.boolean().optional(),
|
grading: z.boolean().optional(),
|
||||||
lessonDownload: z.boolean().optional(),
|
lessonDownload: z.boolean().optional(),
|
||||||
allowNewStudent: z.boolean(),
|
allowNewStudent: z.boolean().default(true),
|
||||||
welcomeEmailMessage: z.string().max(20000).nullish(),
|
welcomeEmailMessage: z.string().max(20000).nullish(),
|
||||||
sessionTimezone: z.string().max(64).nullish(),
|
sessionTimezone: z.string().max(64).nullish(),
|
||||||
sectionDisplay: z.record(z.string(), z.boolean()).optional(),
|
sectionDisplay: z.record(z.string(), z.boolean()).optional(),
|
||||||
@@ -408,7 +413,7 @@ export type TCertificationSettings = z.infer<typeof ZCertificationSettings>;
|
|||||||
export const ZCourseUpdateBase = z.object({
|
export const ZCourseUpdateBase = z.object({
|
||||||
title: z.string().min(1).optional(),
|
title: z.string().min(1).optional(),
|
||||||
description: 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(),
|
logo: z.string().optional(),
|
||||||
slug: z.string().optional(),
|
slug: z.string().optional(),
|
||||||
isPublished: z.boolean().optional(),
|
isPublished: z.boolean().optional(),
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export * from './course';
|
export * from './course';
|
||||||
|
export * from './course-type';
|
||||||
export * from './callout';
|
export * from './callout';
|
||||||
export * from './compliance';
|
export * from './compliance';
|
||||||
export * from './public-course';
|
export * from './public-course';
|
||||||
|
|||||||
Reference in New Issue
Block a user