diff --git a/apps/docs/public/assets/lessons/preview.png b/apps/docs/public/assets/lessons/preview.png new file mode 100644 index 000000000..113d0dd1e Binary files /dev/null and b/apps/docs/public/assets/lessons/preview.png differ diff --git a/apps/docs/public/assets/lessons/visibility.png b/apps/docs/public/assets/lessons/visibility.png new file mode 100644 index 000000000..6dfc2337c Binary files /dev/null and b/apps/docs/public/assets/lessons/visibility.png differ diff --git a/apps/docs/src/pages/en/courses/add-content.md b/apps/docs/src/pages/en/courses/add-content.md index 49f2a1aa9..c1a4b7671 100644 --- a/apps/docs/src/pages/en/courses/add-content.md +++ b/apps/docs/src/pages/en/courses/add-content.md @@ -78,6 +78,18 @@ A lesson is a container for the actual learning material. CourseLit supports mul 6. Click `Save lesson`. +## Preview lessons + +By default, lessons are visible only to learners after enrollment. To offer a lesson to potential learners without requiring enrollment, toggle the `Preview` switch as shown. + +![Preview lesson](/assets/lessons/preview.png) + +## Control lesson visibility + +By default, lessons are unpublished i.e., not visible to learners. To publish a lesson, toggle the `Publish` switch as shown. + +![Publish lesson](/assets/lessons/visibility.png) + ## Stuck somewhere? We are always here for you. Come chat with us in our Discord channel or send a tweet at @CourseLit. diff --git a/apps/web/.migrations/10-02-26_23-55-publish-existing-lessons.js b/apps/web/.migrations/10-02-26_23-55-publish-existing-lessons.js new file mode 100644 index 000000000..81dd0f444 --- /dev/null +++ b/apps/web/.migrations/10-02-26_23-55-publish-existing-lessons.js @@ -0,0 +1,40 @@ +import mongoose from "mongoose"; + +mongoose.connect(process.env.DB_CONNECTION_STRING, { + useNewUrlParser: true, + useUnifiedTopology: true, +}); + +const LessonSchema = new mongoose.Schema({ + domain: { type: mongoose.Schema.Types.ObjectId, required: true }, + lessonId: { type: String, required: true }, + published: { type: Boolean, required: true, default: false }, +}); + +const Lesson = mongoose.model("Lesson", LessonSchema); + +async function publishExistingLessons() { + const lessonCount = await Lesson.countDocuments({ published: false }); + console.log( + `🚀 Found ${lessonCount} unpublished lessons. Publishing them...`, + ); + const result = await Lesson.updateMany( + { + published: false, + }, + { + $set: { + published: true, + }, + }, + ); + + console.log( + `🏁 Lesson publish migration complete. Matched: ${result.matchedCount}, Updated: ${result.modifiedCount}`, + ); +} + +(async () => { + await publishExistingLessons(); + mongoose.connection.close(); +})(); diff --git a/apps/web/app/(with-contexts)/course-old/[slug]/[id]/page.tsx b/apps/web/app/(with-contexts)/course-old/[slug]/[id]/page.tsx index 2bf9aa0b5..b95dc9e51 100644 --- a/apps/web/app/(with-contexts)/course-old/[slug]/[id]/page.tsx +++ b/apps/web/app/(with-contexts)/course-old/[slug]/[id]/page.tsx @@ -139,18 +139,19 @@ export default function ProductPage(props: { - {isEnrolled(product.courseId, profile as Profile) && ( -
- - - {COURSE_PROGRESS_START} - - - -
- )} + {isEnrolled(product.courseId, profile as Profile) && + product.firstLesson && ( +
+ + + {COURSE_PROGRESS_START} + + + +
+ )} ); } diff --git a/apps/web/app/(with-contexts)/course/[slug]/[id]/page.tsx b/apps/web/app/(with-contexts)/course/[slug]/[id]/page.tsx index 76522d0d6..f5b217340 100644 --- a/apps/web/app/(with-contexts)/course/[slug]/[id]/page.tsx +++ b/apps/web/app/(with-contexts)/course/[slug]/[id]/page.tsx @@ -143,21 +143,22 @@ export default function ProductPage(props: { - {isEnrolled(product.courseId, profile as Profile) && ( -
- - - -
- )} + + + + )} ); } diff --git a/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/page.tsx b/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/page.tsx index c31a32e3a..e2208e4c7 100644 --- a/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/page.tsx +++ b/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/page.tsx @@ -3,6 +3,7 @@ import { useContext, useState } from "react"; import { useRouter, useParams } from "next/navigation"; import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; import { DropdownMenu, DropdownMenuContent, @@ -350,7 +351,17 @@ export default function ContentPage() { {lesson.title} - +
+ {!lesson.published && ( + + Draft + + )} + +
)} key={JSON.stringify(product.lessons)} diff --git a/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/section/[section]/lesson/page.tsx b/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/section/[section]/lesson/page.tsx index 2990a51c9..f8fe61fce 100644 --- a/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/section/[section]/lesson/page.tsx +++ b/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/section/[section]/lesson/page.tsx @@ -30,11 +30,15 @@ import { } from "@/components/ui/dialog"; import { APP_MESSAGE_LESSON_DELETED, + BTN_PUBLISH, + BTN_UNPUBLISH, BUTTON_NEW_LESSON_TEXT, COURSE_CONTENT_HEADER, EDIT_LESSON_TEXT, LESSON_EMBED_URL_LABEL, LESSON_CONTENT_LABEL, + LESSON_VISIBILITY, + LESSON_VISIBILITY_TOOLTIP, MANAGE_COURSES_PAGE_HEADING, TOAST_TITLE_ERROR, TOAST_TITLE_SUCCESS, @@ -124,6 +128,7 @@ export default function LessonPage() { media: undefined, downloadable: false, requiresEnrollment: true, + published: false, courseId: productId, groupId: sectionId, }); @@ -177,6 +182,7 @@ export default function LessonPage() { caption }, requiresEnrollment, + published, lessonId } } @@ -194,6 +200,7 @@ export default function LessonPage() { const loadedLesson = { ...response.lesson, type: response.lesson.type.toLowerCase() as LessonType, + published: response.lesson.published ?? false, }; // Store the loaded lesson in ref for future comparison @@ -300,6 +307,7 @@ export default function LessonPage() { downloadable: lesson?.downloadable, content: JSON.stringify(content), requiresEnrollment: lesson?.requiresEnrollment, + published: !!lesson?.published, }, }, }) @@ -344,6 +352,7 @@ export default function LessonPage() { courseId: lesson?.courseId, requiresEnrollment: lesson?.requiresEnrollment, groupId: lesson?.groupId, + published: !!lesson?.published, }, }, }) @@ -604,6 +613,7 @@ export default function LessonPage() {

Allow students to preview this lesson + without enrolling

+
+
+ +

+ {LESSON_VISIBILITY_TOOLTIP} +

+
+
+ + {lesson.published + ? BTN_UNPUBLISH + : BTN_PUBLISH} + + + updateLesson({ + published: checked, + }) + } + /> +
+
diff --git a/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/customers/page.tsx b/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/customers/page.tsx index 19c7a2f39..c710f2715 100644 --- a/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/customers/page.tsx +++ b/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/customers/page.tsx @@ -85,6 +85,8 @@ export default function CustomersPage() { .includes(searchTerm.toLowerCase()) || member.user.email.toLowerCase().includes(searchTerm.toLowerCase()), ); + const publishedLessons = + product?.lessons?.filter((lesson) => lesson.published) || []; const fetchStudents = async () => { setLoading(true); @@ -137,16 +139,23 @@ export default function CustomersPage() { .build(); try { const response = await fetch.exec(); + const publishedLessonIds = new Set( + publishedLessons.map((lesson) => lesson.lessonId), + ); + const publishedLessonsCount = publishedLessonIds.size; setMembers( response.members.map((member: any) => ({ ...member, progressInPercentage: product?.type?.toLowerCase() === Constants.CourseType.COURSE && - product?.lessons?.length! > 0 + publishedLessonsCount > 0 ? Math.round( - ((member.completedLessons?.length || 0) / - (product?.lessons?.length || 0)) * + ((member.completedLessons || []).filter( + (lessonId: string) => + publishedLessonIds.has(lessonId), + ).length / + publishedLessonsCount) * 100, ) : undefined, @@ -409,8 +418,7 @@ export default function CustomersPage() { {product?.type?.toLowerCase() === Constants.CourseType.COURSE ? ( <> - {product?.lessons?.length! > - 0 && ( + {publishedLessons.length > 0 && (
- {product?.lessons?.map( + {publishedLessons.map( ( lesson: any, ) => ( diff --git a/apps/web/graphql/courses/logic.ts b/apps/web/graphql/courses/logic.ts index 4e93385d6..194c4d6cb 100644 --- a/apps/web/graphql/courses/logic.ts +++ b/apps/web/graphql/courses/logic.ts @@ -99,7 +99,11 @@ export const getCourseOrThrow = async ( return course; }; -async function formatCourse(courseId: string, ctx: GQLContext) { +async function formatCourse( + courseId: string, + ctx: GQLContext, + includeUnpublishedLessons: boolean = false, +) { const course: InternalCourse | null = (await CourseModel.findOne({ courseId, domain: ctx.subdomain._id, @@ -118,6 +122,8 @@ async function formatCourse(courseId: string, ctx: GQLContext) { const { nextLesson } = await getPrevNextCursor( course.courseId, ctx.subdomain._id, + undefined, + !includeUnpublishedLessons, ); (course as any).firstLesson = nextLesson; } @@ -154,12 +160,15 @@ export const getCourse = async ( ]) || checkOwnershipWithoutModel(course, ctx); if (isOwner) { - return await formatCourse(course.courseId, ctx); + return await formatCourse(course.courseId, ctx, true); } } if (course.published) { - return await formatCourse(course.courseId, ctx); + const formattedCourse = await formatCourse(course.courseId, ctx); + return asGuest + ? { ...formattedCourse, __forcePublishedLessons: true } + : formattedCourse; } else { return null; } diff --git a/apps/web/graphql/courses/types/index.ts b/apps/web/graphql/courses/types/index.ts index 7819ac89e..a41fc0d49 100644 --- a/apps/web/graphql/courses/types/index.ts +++ b/apps/web/graphql/courses/types/index.ts @@ -140,7 +140,11 @@ const courseType = new GraphQLObjectType({ lessons: { type: new GraphQLList(lessonMetaType), resolve: (course, args, context, info) => - getAllLessons(course, context), + getAllLessons( + course, + context, + Boolean((course as any).__forcePublishedLessons), + ), }, updatedAt: { type: new GraphQLNonNull(GraphQLString) }, slug: { type: new GraphQLNonNull(GraphQLString) }, diff --git a/apps/web/graphql/lessons/__tests__/scorm.test.ts b/apps/web/graphql/lessons/__tests__/scorm.test.ts index 925746882..9baf77343 100644 --- a/apps/web/graphql/lessons/__tests__/scorm.test.ts +++ b/apps/web/graphql/lessons/__tests__/scorm.test.ts @@ -74,6 +74,7 @@ describe("SCORM Logic Integration", () => { lessonId: id("lesson-scorm"), title: "SCORM Lesson", type: Constants.LessonType.SCORM, + published: true, requiresEnrollment: true, content: { launchUrl: "index.html", diff --git a/apps/web/graphql/lessons/__tests__/visibility.test.ts b/apps/web/graphql/lessons/__tests__/visibility.test.ts new file mode 100644 index 000000000..a62ff4236 --- /dev/null +++ b/apps/web/graphql/lessons/__tests__/visibility.test.ts @@ -0,0 +1,263 @@ +import { Constants } from "@courselit/common-models"; +import mongoose from "mongoose"; +import DomainModel from "@/models/Domain"; +import UserModel from "@/models/User"; +import CourseModel from "@/models/Course"; +import LessonModel from "@/models/Lesson"; +import ActivityModel from "@/models/Activity"; +import { getAllLessons, getLessonDetails, markLessonCompleted } from "../logic"; +import { responses } from "@/config/strings"; + +const SUITE_PREFIX = `lesson-visibility-${Date.now()}`; +const id = (suffix: string) => `${SUITE_PREFIX}-${suffix}`; +const email = (suffix: string) => `${suffix}-${SUITE_PREFIX}@example.com`; + +describe("Lesson visibility and progress", () => { + let testDomain: any; + let creator: any; + let student: any; + let course: any; + let groupId: string; + let publishedLessonOne: any; + let unpublishedLesson: any; + let publishedLessonTwo: any; + let studentCtx: any; + let creatorCtx: any; + + beforeAll(async () => { + testDomain = await DomainModel.create({ + name: id("domain"), + email: email("domain"), + features: [], + }); + + creator = await UserModel.create({ + domain: testDomain._id, + userId: id("creator"), + email: email("creator"), + name: "Creator", + active: true, + permissions: [], + unsubscribeToken: id("unsubscribe-creator"), + purchases: [], + }); + + student = await UserModel.create({ + domain: testDomain._id, + userId: id("student"), + email: email("student"), + name: "Student", + active: true, + permissions: [], + unsubscribeToken: id("unsubscribe-student"), + purchases: [], + }); + + groupId = new mongoose.Types.ObjectId().toString(); + + course = await CourseModel.create({ + domain: testDomain._id, + courseId: id("course"), + title: "Visibility Course", + lessons: [], + creatorId: creator.userId, + cost: 0, + privacy: "public", + type: "course", + costType: "free", + slug: id("course-slug"), + published: true, + groups: [ + { + _id: groupId, + name: "Group 1", + lessonsOrder: [], + rank: 1, + collapsed: true, + drip: { + status: false, + type: "relative-date", + }, + }, + ], + }); + + publishedLessonOne = await LessonModel.create({ + domain: testDomain._id, + courseId: course.courseId, + lessonId: id("published-1"), + title: "Published 1", + type: Constants.LessonType.TEXT, + published: true, + requiresEnrollment: false, + content: { + type: "doc", + content: [], + }, + creatorId: creator.userId, + groupId, + }); + + unpublishedLesson = await LessonModel.create({ + domain: testDomain._id, + courseId: course.courseId, + lessonId: id("unpublished"), + title: "Unpublished", + type: Constants.LessonType.TEXT, + published: false, + requiresEnrollment: false, + content: { + type: "doc", + content: [], + }, + creatorId: creator.userId, + groupId, + }); + + publishedLessonTwo = await LessonModel.create({ + domain: testDomain._id, + courseId: course.courseId, + lessonId: id("published-2"), + title: "Published 2", + type: Constants.LessonType.TEXT, + published: true, + requiresEnrollment: false, + content: { + type: "doc", + content: [], + }, + creatorId: creator.userId, + groupId, + }); + + course.lessons = [ + publishedLessonOne.lessonId, + unpublishedLesson.lessonId, + publishedLessonTwo.lessonId, + ]; + course.groups[0].lessonsOrder = [ + publishedLessonOne.lessonId, + unpublishedLesson.lessonId, + publishedLessonTwo.lessonId, + ]; + await course.save(); + + student.purchases.push({ + courseId: course.courseId, + accessibleGroups: [groupId], + completedLessons: [], + }); + student.markModified("purchases"); + await student.save(); + + studentCtx = { + user: student, + subdomain: testDomain, + } as any; + + creatorCtx = { + user: creator, + subdomain: testDomain, + } as any; + }); + + afterAll(async () => { + await ActivityModel.deleteMany({ domain: testDomain._id }); + await LessonModel.deleteMany({ domain: testDomain._id }); + await CourseModel.deleteMany({ domain: testDomain._id }); + await UserModel.deleteMany({ domain: testDomain._id }); + await DomainModel.deleteOne({ _id: testDomain._id }); + }); + + beforeEach(async () => { + const freshStudent = await UserModel.findById(student._id); + const purchase = freshStudent?.purchases.find( + (item: any) => item.courseId === course.courseId, + ); + if (!purchase) { + throw new Error("Purchase not found"); + } + purchase.completedLessons = []; + freshStudent!.markModified("purchases"); + await freshStudent!.save(); + studentCtx.user = freshStudent; + + await ActivityModel.deleteMany({ + domain: testDomain._id, + userId: student.userId, + entityId: course.courseId, + type: Constants.ActivityType.COURSE_COMPLETED, + }); + }); + + it("should skip unpublished lessons in next/prev navigation for learners", async () => { + const lesson = await getLessonDetails( + publishedLessonOne.lessonId, + studentCtx, + course.courseId, + ); + + expect(lesson.prevLesson).toBe(""); + expect(lesson.nextLesson).toBe(publishedLessonTwo.lessonId); + }); + + it("should hide unpublished lessons from learners", async () => { + await expect( + getLessonDetails( + unpublishedLesson.lessonId, + studentCtx, + course.courseId, + ), + ).rejects.toThrow(responses.item_not_found); + }); + + it("should hide unpublished lessons from owners in learner lesson details", async () => { + await expect( + getLessonDetails( + unpublishedLesson.lessonId, + creatorCtx, + course.courseId, + ), + ).rejects.toThrow(responses.item_not_found); + }); + + it("should support forcing published-only lessons even for owner context", async () => { + const ownerLessons = await getAllLessons(course as any, creatorCtx); + expect( + ownerLessons.some( + (lesson: any) => lesson.lessonId === unpublishedLesson.lessonId, + ), + ).toBe(true); + + const forcedLearnerLessons = await getAllLessons( + course as any, + creatorCtx, + true, + ); + expect( + forcedLearnerLessons.some( + (lesson: any) => lesson.lessonId === unpublishedLesson.lessonId, + ), + ).toBe(false); + }); + + it("should allow course completion when all published lessons are completed", async () => { + await markLessonCompleted(publishedLessonOne.lessonId, studentCtx); + await markLessonCompleted(publishedLessonTwo.lessonId, studentCtx); + + const completionActivity = await ActivityModel.findOne({ + domain: testDomain._id, + userId: student.userId, + entityId: course.courseId, + type: Constants.ActivityType.COURSE_COMPLETED, + }); + + expect(completionActivity).toBeTruthy(); + }); + + it("should not allow completing unpublished lessons for owners", async () => { + await expect( + markLessonCompleted(unpublishedLesson.lessonId, creatorCtx), + ).rejects.toThrow(responses.item_not_found); + }); +}); diff --git a/apps/web/graphql/lessons/helpers.ts b/apps/web/graphql/lessons/helpers.ts index 3e410e0a4..1e3989b10 100644 --- a/apps/web/graphql/lessons/helpers.ts +++ b/apps/web/graphql/lessons/helpers.ts @@ -77,21 +77,23 @@ type GroupLessonItem = Pick; export const getGroupedLessons = async ( courseId: string, domainId: mongoose.Types.ObjectId, + publishedOnly: boolean = false, ): Promise => { const course = await CourseModel.findOne({ courseId: courseId, domain: domainId, }); - const allLessons = await LessonModel.find( - { - courseId: courseId, - domain: domainId, - }, - { - lessonId: 1, - groupId: 1, - }, - ); + const lessonsQuery: Record = { + courseId: courseId, + domain: domainId, + }; + if (publishedOnly) { + lessonsQuery.published = true; + } + const allLessons = await LessonModel.find(lessonsQuery, { + lessonId: 1, + groupId: 1, + }); const lessonsInSequentialOrder: GroupLessonItem[] = []; for (let group of course.groups.sort( (a: Group, b: Group) => a.rank - b.rank, @@ -115,10 +117,12 @@ export const getPrevNextCursor = async ( courseId: string, domainId: mongoose.Types.ObjectId, lessonId?: string, + publishedOnly: boolean = false, ) => { const lessonsInSequentialOrder = await getGroupedLessons( courseId, domainId, + publishedOnly, ); const indexOfCurrentLesson = lessonId ? lessonsInSequentialOrder.findIndex( diff --git a/apps/web/graphql/lessons/logic.ts b/apps/web/graphql/lessons/logic.ts index ef87a50b9..42a704c52 100644 --- a/apps/web/graphql/lessons/logic.ts +++ b/apps/web/graphql/lessons/logic.ts @@ -39,6 +39,14 @@ import { replaceTempMediaWithSealedMediaInProseMirrorDoc } from "@/lib/replace-t const { permissions, quiz, scorm } = constants; +export const canViewUnpublished = (ctx: GQLContext, entity: any): boolean => { + return ( + !!ctx.user && + (checkPermission(ctx.user.permissions, [permissions.manageAnyCourse]) || + checkOwnershipWithoutModel(entity, ctx)) + ); +}; + const getLessonOrThrow = async ( id: string, ctx: GQLContext, @@ -89,7 +97,7 @@ export const getLessonDetails = async ( } const lesson = await LessonModel.findOne(query); - if (!lesson) { + if (!lesson || !lesson.published) { throw new Error(responses.item_not_found); } @@ -123,6 +131,7 @@ export const getLessonDetails = async ( lesson.courseId, ctx.subdomain._id, lesson.lessonId, + true, ); lesson.prevLesson = prevLesson; lesson.nextLesson = nextLesson; @@ -170,6 +179,7 @@ export const createLesson = async ( courseId: course.courseId, groupId: lessonData.groupId, requiresEnrollment: lessonData.requiresEnrollment, + published: lessonData.published || false, }); course.lessons.push(lesson.lessonId); @@ -194,6 +204,7 @@ export const updateLesson = async ( | "media" | "downloadable" | "requiresEnrollment" + | "published" | "type" > & { id: string; lessonId: string }, ctx: GQLContext, @@ -315,22 +326,30 @@ export const deleteLesson = async (id: string, ctx: GQLContext) => { export const getAllLessons = async ( course: InternalCourse, ctx: GQLContext, + forcePublishedOnly: boolean = false, ) => { - const lessons = await LessonModel.find( - { - courseId: course.courseId, - domain: ctx.subdomain._id, - }, - { - id: 1, - lessonId: 1, - type: 1, - title: 1, - requiresEnrollment: 1, - courseId: 1, - groupId: 1, - }, - ); + const canViewUnpublishedLessons = + !forcePublishedOnly && canViewUnpublished(ctx, course); + + const query: Record = { + courseId: course.courseId, + domain: ctx.subdomain._id, + }; + + if (!canViewUnpublishedLessons) { + query.published = true; + } + + const lessons = await LessonModel.find(query, { + id: 1, + lessonId: 1, + type: 1, + title: 1, + requiresEnrollment: 1, + courseId: 1, + groupId: 1, + published: 1, + }); return lessons; }; @@ -352,7 +371,7 @@ export const markLessonCompleted = async ( checkIfAuthenticated(ctx); const lesson = await LessonModel.findOne({ lessonId }); - if (!lesson) { + if (!lesson || !lesson.published) { throw new Error(responses.item_not_found); } @@ -455,15 +474,34 @@ const checkAndRecordCourseCompletion = async ( throw new Error(responses.item_not_found); } - const isCourseCompleted = course.lessons.every((lessonId) => { - const progress = ctx.user.purchases.find( - (progress: Progress) => progress.courseId === course.courseId, - ); - if (!progress) { - return false; - } - return progress.completedLessons.includes(lessonId); - }); + const publishedLessons = await LessonModel.find( + { + courseId: course.courseId, + domain: ctx.subdomain._id, + published: true, + }, + { + lessonId: 1, + }, + ); + const publishedLessonIds = publishedLessons.map( + (lesson) => lesson.lessonId, + ); + if (publishedLessonIds.length === 0) { + return false; + } + + const progress = ctx.user.purchases.find( + (purchase: Progress) => purchase.courseId === course.courseId, + ); + if (!progress) { + return false; + } + + const completedLessons = new Set(progress.completedLessons); + const isCourseCompleted = publishedLessonIds.every((lessonId) => + completedLessons.has(lessonId), + ); if (!isCourseCompleted) { return false; diff --git a/apps/web/graphql/lessons/types.ts b/apps/web/graphql/lessons/types.ts index 9a3d41360..e4b24cb51 100644 --- a/apps/web/graphql/lessons/types.ts +++ b/apps/web/graphql/lessons/types.ts @@ -51,6 +51,7 @@ const lessonType = new GraphQLObjectType({ description: DESCRIPTION_REQUIRES_ENROLLMENT, type: new GraphQLNonNull(GraphQLBoolean), }, + published: { type: GraphQLBoolean }, courseId: { type: new GraphQLNonNull(GraphQLID) }, content: { type: GraphQLJSONObject }, media: { @@ -76,6 +77,7 @@ const lessonMetaType = new GraphQLObjectType({ description: DESCRIPTION_REQUIRES_ENROLLMENT, type: new GraphQLNonNull(GraphQLBoolean), }, + published: { type: GraphQLBoolean }, courseId: { type: new GraphQLNonNull(GraphQLID) }, groupId: { type: new GraphQLNonNull(GraphQLID) }, }, @@ -98,6 +100,7 @@ const lessonInputType = new GraphQLInputObjectType({ // media: { type: mediaTypes.mediaInputType }, downloadable: { type: GraphQLBoolean }, groupId: { type: new GraphQLNonNull(GraphQLID) }, + published: { type: GraphQLBoolean }, }, }); @@ -116,6 +119,7 @@ const lessonUpdateType = new GraphQLInputObjectType({ description: DESCRIPTION_REQUIRES_ENROLLMENT, type: GraphQLBoolean, }, + published: { type: GraphQLBoolean }, }, }); diff --git a/apps/web/graphql/users/logic.ts b/apps/web/graphql/users/logic.ts index 7cd86b8e0..80a915288 100644 --- a/apps/web/graphql/users/logic.ts +++ b/apps/web/graphql/users/logic.ts @@ -36,6 +36,7 @@ import { generateEmailFrom } from "@/lib/utils"; import MembershipModel from "@models/Membership"; import CommunityModel from "@models/Community"; import CourseModel from "@models/Course"; +import LessonModel from "@models/Lesson"; import { addMailJob } from "@/services/queue"; import { getPaymentMethodFromSettings } from "@/payments-new"; import { checkForInvalidPermissions } from "@/lib/check-invalid-permissions"; @@ -728,6 +729,27 @@ async function getUserContentInternal(ctx: GQLContext, user: User) { }); if (course) { + const publishedLessonIds = new Set( + ( + await LessonModel.find( + { + domain: ctx.subdomain._id, + courseId: course.courseId, + published: true, + }, + { + lessonId: 1, + }, + ) + ).map((lesson) => lesson.lessonId), + ); + const completedPublishedLessons = ( + user.purchases.find( + (progress: Progress) => + progress.courseId === course.courseId, + )?.completedLessons || [] + ).filter((lessonId) => publishedLessonIds.has(lessonId)); + content.push({ entityType: Constants.MembershipEntityType.COURSE, entity: { @@ -735,11 +757,8 @@ async function getUserContentInternal(ctx: GQLContext, user: User) { title: course.title, slug: course.slug, type: course.type, - totalLessons: course.lessons.length, - completedLessonsCount: user.purchases.find( - (progress: Progress) => - progress.courseId === course.courseId, - )?.completedLessons.length, + totalLessons: publishedLessonIds.size, + completedLessonsCount: completedPublishedLessons.length, featuredImage: course.featuredImage, certificateId: user.purchases.find( (progress: Progress) => diff --git a/apps/web/hooks/use-product.ts b/apps/web/hooks/use-product.ts index a998857fc..152975efc 100644 --- a/apps/web/hooks/use-product.ts +++ b/apps/web/hooks/use-product.ts @@ -8,7 +8,10 @@ import { InternalCourse } from "@courselit/common-logic"; export type ProductWithAdminProps = Partial< Omit & Pick & { - lessons: Pick & + lessons: Pick< + Lesson, + "title" | "groupId" | "lessonId" | "type" | "published" + > & { id: string }[]; } >; @@ -41,6 +44,7 @@ export default function useProduct(id?: string | null): { groupId, lessonId, type + published }, groups { id, diff --git a/apps/web/ui-config/strings.ts b/apps/web/ui-config/strings.ts index 6e46da02b..e6de8f054 100644 --- a/apps/web/ui-config/strings.ts +++ b/apps/web/ui-config/strings.ts @@ -187,6 +187,9 @@ export const DIALOG_SELECT_BUTTON = "Select"; export const LESSON_PREVIEW = "Preview"; export const LESSON_PREVIEW_TOOLTIP = "This lesson will be freely available to the users."; +export const LESSON_VISIBILITY = "Visibility"; +export const LESSON_VISIBILITY_TOOLTIP = + "When unpublished, this lesson is hidden from enrolled learners."; export const DELETE_LESSON_POPUP_HEADER = "Delete lesson"; export const APP_MESSAGE_COURSE_DELETED = "Product deleted"; export const APP_MESSAGE_LESSON_DELETED = "Lesson deleted"; diff --git a/packages/common-models/src/lesson.ts b/packages/common-models/src/lesson.ts index b5529ca2e..595bd53a5 100644 --- a/packages/common-models/src/lesson.ts +++ b/packages/common-models/src/lesson.ts @@ -13,6 +13,7 @@ export default interface Lesson { courseId: string; groupId: string; downloadable: boolean; + published: boolean; media?: Partial; prevLesson?: string; nextLesson?: string;