- {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;