feat(dashboard): moved course completion deadline to course settings page (#842)
This commit is contained in:
@@ -6,7 +6,7 @@ import {
|
||||
createCourseSections,
|
||||
deleteCourse as deleteCourseQuery,
|
||||
getCourseProgress as getCourseProgressQuery,
|
||||
getCourseTypeById,
|
||||
getCourseById,
|
||||
getCourseWithRelations,
|
||||
updateCourse as updateCourseQuery,
|
||||
updateLessonsSectionId
|
||||
@@ -28,6 +28,7 @@ import { getStudentCourseProgressImpactCounts } from '@cio/db/queries/course/res
|
||||
import { getCourseGroupId } from '@cio/db/queries/course/people';
|
||||
|
||||
import { ContentType, ROLE } from '@cio/utils/constants';
|
||||
import { isPublishedComplianceMissingDeadline, resolveCourseCertificateDeadline } from '@cio/utils/functions';
|
||||
import type { TCourse } from '@cio/db/types';
|
||||
import type { TCourseCreate } from '@cio/utils/validation/course';
|
||||
import { db } from '@cio/db/drizzle';
|
||||
@@ -338,16 +339,38 @@ export async function updateCourse(courseId: string, data: Partial<TCourse>) {
|
||||
slug: data.slug
|
||||
};
|
||||
|
||||
if (data.type !== undefined) {
|
||||
const currentType = await getCourseTypeById(courseId);
|
||||
const [currentCourse] = await getCourseById(courseId);
|
||||
if (!currentCourse) {
|
||||
throw new AppError('Course not found', ErrorCodes.COURSE_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
if (data.type !== undefined) {
|
||||
await guardCourseTypeTransition({
|
||||
courseId,
|
||||
currentType: currentType ?? null,
|
||||
currentType: currentCourse.type ?? null,
|
||||
nextType: data.type
|
||||
});
|
||||
}
|
||||
|
||||
const nextType = data.type ?? currentCourse.type;
|
||||
const nextIsPublished = data.isPublished ?? currentCourse.isPublished;
|
||||
const nextDeadline = resolveCourseCertificateDeadline(currentCourse.certificate?.deadline, data.certificate);
|
||||
|
||||
if (
|
||||
isPublishedComplianceMissingDeadline({
|
||||
type: nextType,
|
||||
isPublished: nextIsPublished,
|
||||
deadline: nextDeadline
|
||||
})
|
||||
) {
|
||||
throw new AppError(
|
||||
'Compliance courses require a completion deadline before they can be published',
|
||||
ErrorCodes.COMPLIANCE_DEADLINE_REQUIRED,
|
||||
400,
|
||||
'certificate.deadline'
|
||||
);
|
||||
}
|
||||
|
||||
if (data.certificate?.requiredExerciseId) {
|
||||
const ok = await exerciseBelongsToCourse(data.certificate.requiredExerciseId, courseId);
|
||||
if (!ok) {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { getCourseContentItems, type CourseContentItemRow } from '@cio/db/querie
|
||||
import { getCourseOrganizationId } from '@cio/db/queries/tag';
|
||||
import { getOrganizationById } from '@cio/db/queries/organization';
|
||||
import type { TCourse, TOrganization } from '@cio/db/types';
|
||||
import { generateSlug } from '@cio/utils/functions';
|
||||
import { generateSlug, isPublishedComplianceMissingDeadline } from '@cio/utils/functions';
|
||||
import { ContentType } from '@cio/utils/constants';
|
||||
import type { TCourseLandingPageUpdate } from '@cio/utils/validation/course';
|
||||
|
||||
@@ -32,7 +32,17 @@ export type CourseGoLiveReadiness = {
|
||||
type CourseReadinessInput = {
|
||||
course: Pick<
|
||||
TCourse,
|
||||
'id' | 'title' | 'description' | 'overview' | 'slug' | 'logo' | 'bannerImage' | 'metadata' | 'type' | 'cost'
|
||||
| 'id'
|
||||
| 'title'
|
||||
| 'description'
|
||||
| 'overview'
|
||||
| 'slug'
|
||||
| 'logo'
|
||||
| 'bannerImage'
|
||||
| 'metadata'
|
||||
| 'type'
|
||||
| 'cost'
|
||||
| 'certificate'
|
||||
>;
|
||||
contentItems: CourseContentItemRow[];
|
||||
suggestedSlug?: string;
|
||||
@@ -190,6 +200,22 @@ export function evaluateCourseGoLiveReadiness(input: CourseReadinessInput): Cour
|
||||
}
|
||||
|
||||
if (course.type === 'COMPLIANCE') {
|
||||
if (
|
||||
isPublishedComplianceMissingDeadline({
|
||||
type: course.type,
|
||||
isPublished: true,
|
||||
deadline: course.certificate?.deadline
|
||||
})
|
||||
) {
|
||||
blockers.push(
|
||||
buildIssue(
|
||||
'COMPLIANCE_DEADLINE_MISSING',
|
||||
'Compliance courses require a completion deadline before they can be published.',
|
||||
'course.certificate.deadline'
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
warnings.push(
|
||||
buildIssue('COMPLIANCE_REVIEW_RECOMMENDED', 'Review compliance settings before publishing.', 'course.compliance')
|
||||
);
|
||||
|
||||
@@ -108,6 +108,7 @@ export const ErrorCodes = {
|
||||
SLUG_IN_USE: 'SLUG_IN_USE',
|
||||
QUESTION_TYPE_NOT_ALLOWED_IN_PUBLIC_COURSE: 'QUESTION_TYPE_NOT_ALLOWED_IN_PUBLIC_COURSE',
|
||||
PUBLIC_COURSE_CONVERSION_BLOCKED: 'PUBLIC_COURSE_CONVERSION_BLOCKED',
|
||||
COMPLIANCE_DEADLINE_REQUIRED: 'COMPLIANCE_DEADLINE_REQUIRED',
|
||||
|
||||
// Lesson errors
|
||||
LESSON_NOT_FOUND: 'LESSON_NOT_FOUND',
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* A published COMPLIANCE course must have a completion deadline.
|
||||
* Draft courses, non-compliance types, and downloadable certificates do not require one.
|
||||
*/
|
||||
export function isPublishedComplianceMissingDeadline(params: {
|
||||
type: string | null | undefined;
|
||||
isPublished: boolean | null | undefined;
|
||||
deadline: string | null | undefined;
|
||||
}): boolean {
|
||||
if (params.isPublished !== true) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (params.type !== 'COMPLIANCE') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !params.deadline?.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Course updates replace the certificate JSON value when `certificate` is sent.
|
||||
* If the patch omits `deadline`, the stored deadline is cleared.
|
||||
*/
|
||||
export function resolveCourseCertificateDeadline(
|
||||
currentDeadline: string | null | undefined,
|
||||
certificatePatch: { deadline?: string | null } | null | undefined
|
||||
): string | null | undefined {
|
||||
if (certificatePatch === undefined) {
|
||||
return currentDeadline;
|
||||
}
|
||||
|
||||
if (certificatePatch === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return certificatePatch.deadline ?? null;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from './array';
|
||||
export * from './compliance-deadline';
|
||||
export * from './course-progression';
|
||||
export * from './currency';
|
||||
export * from './fileValidation';
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
isPublishedComplianceMissingDeadline,
|
||||
resolveCourseCertificateDeadline
|
||||
} from '../src/functions/compliance-deadline';
|
||||
|
||||
describe('isPublishedComplianceMissingDeadline', () => {
|
||||
it('is false for unpublished compliance courses', () => {
|
||||
expect(
|
||||
isPublishedComplianceMissingDeadline({
|
||||
type: 'COMPLIANCE',
|
||||
isPublished: false,
|
||||
deadline: null
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('is false for published non-compliance courses without a deadline', () => {
|
||||
expect(
|
||||
isPublishedComplianceMissingDeadline({
|
||||
type: 'SELF_PACED',
|
||||
isPublished: true,
|
||||
deadline: null
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('is true for published compliance courses without a deadline', () => {
|
||||
expect(
|
||||
isPublishedComplianceMissingDeadline({
|
||||
type: 'COMPLIANCE',
|
||||
isPublished: true,
|
||||
deadline: null
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('treats blank deadlines as missing', () => {
|
||||
expect(
|
||||
isPublishedComplianceMissingDeadline({
|
||||
type: 'COMPLIANCE',
|
||||
isPublished: true,
|
||||
deadline: ' '
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('is false when a published compliance course has a deadline', () => {
|
||||
expect(
|
||||
isPublishedComplianceMissingDeadline({
|
||||
type: 'COMPLIANCE',
|
||||
isPublished: true,
|
||||
deadline: '2026-12-31T23:59:00.000Z'
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveCourseCertificateDeadline', () => {
|
||||
it('keeps the stored deadline when certificate is not in the patch', () => {
|
||||
expect(resolveCourseCertificateDeadline('2026-12-31T23:59:00.000Z', undefined)).toBe('2026-12-31T23:59:00.000Z');
|
||||
});
|
||||
|
||||
it('clears the deadline when the certificate patch omits it', () => {
|
||||
expect(resolveCourseCertificateDeadline('2026-12-31T23:59:00.000Z', {})).toBeNull();
|
||||
});
|
||||
|
||||
it('uses the patched deadline when provided', () => {
|
||||
expect(resolveCourseCertificateDeadline('2026-01-01T00:00:00.000Z', { deadline: '2026-12-31T23:59:00.000Z' })).toBe(
|
||||
'2026-12-31T23:59:00.000Z'
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user