From 82d622380f744032baf5c617def1d8dee508c0d4 Mon Sep 17 00:00:00 2001 From: Rajat Date: Sat, 6 Jun 2026 17:38:02 +0530 Subject: [PATCH 1/2] user with course permissions can view the course in course viewer --- .../checkout/__tests__/product.test.tsx | 88 +++ .../(with-layout)/checkout/page.tsx | 5 +- .../(with-layout)/checkout/product.tsx | 48 +- .../[slug]/[id]/__tests__/helpers.test.ts | 4 + .../__tests__/layout-with-sidebar.test.tsx | 103 +++ .../course/[slug]/[id]/helpers.ts | 30 +- .../[slug]/[id]/layout-with-sidebar.tsx | 78 ++- .../course/[slug]/[id]/layout.tsx | 24 +- .../dashboard/(sidebar)/product/[id]/page.tsx | 12 + .../components/public/lesson-viewer/index.tsx | 613 +++++++++++------- .../graphql/courses/__tests__/logic.test.ts | 141 +++- apps/web/graphql/courses/logic.ts | 38 +- apps/web/graphql/courses/permissions.ts | 36 + apps/web/graphql/courses/types/index.ts | 6 + .../lessons/__tests__/visibility.test.ts | 131 +++- apps/web/graphql/lessons/logic.ts | 30 +- apps/web/ui-config/strings.ts | 3 + docs/course-admin-viewer-access.md | 198 ++++++ 18 files changed, 1264 insertions(+), 324 deletions(-) create mode 100644 apps/web/app/(with-contexts)/(with-layout)/checkout/__tests__/product.test.tsx create mode 100644 apps/web/graphql/courses/permissions.ts create mode 100644 docs/course-admin-viewer-access.md diff --git a/apps/web/app/(with-contexts)/(with-layout)/checkout/__tests__/product.test.tsx b/apps/web/app/(with-contexts)/(with-layout)/checkout/__tests__/product.test.tsx new file mode 100644 index 000000000..4043712c8 --- /dev/null +++ b/apps/web/app/(with-contexts)/(with-layout)/checkout/__tests__/product.test.tsx @@ -0,0 +1,88 @@ +import { render, waitFor } from "@testing-library/react"; +import type { ReactNode } from "react"; + +const payloads: any[] = []; +const mockExec = jest.fn(); + +jest.mock("@components/contexts", () => { + const React = require("react"); + + return { + AddressContext: React.createContext({ + backend: "http://localhost:3000", + frontend: "http://localhost:3000", + }), + ThemeContext: React.createContext({ + theme: { + id: "test", + name: "Test", + theme: {}, + }, + setTheme: jest.fn(), + }), + }; +}); + +jest.mock("@components/public/payments/checkout", () => ({ + __esModule: true, + default: ({ product }: { product: { name: string } }) => ( +
{product.name}
+ ), +})); + +jest.mock("@courselit/components-library", () => ({ + useToast: () => ({ + toast: jest.fn(), + }), +})); + +jest.mock("@courselit/page-primitives", () => ({ + Header1: ({ children }: { children: ReactNode }) =>

{children}

, +})); + +jest.mock("@courselit/utils", () => ({ + FetchBuilder: jest.fn().mockImplementation(() => ({ + setUrl: jest.fn().mockReturnThis(), + setPayload: jest.fn(function (payload) { + payloads.push(payload); + return this; + }), + setIsGraphQLEndpoint: jest.fn().mockReturnThis(), + build: jest.fn().mockReturnThis(), + exec: mockExec, + })), +})); + +jest.mock("next/navigation", () => ({ + notFound: jest.fn(), + useSearchParams: () => new URLSearchParams("type=course&id=course-1"), +})); + +import ProductCheckout from "../product"; + +describe("ProductCheckout", () => { + beforeEach(() => { + payloads.length = 0; + jest.clearAllMocks(); + }); + + it("uses public course visibility and shows 404 when checkout course is unavailable", async () => { + const { notFound } = jest.requireMock("next/navigation"); + mockExec.mockResolvedValueOnce({ + course: null, + loginProviders: [], + }); + + render(); + + await waitFor(() => { + expect(payloads[0].query).toContain( + "course: getCourse(id: $id, asGuest: true)", + ); + }); + + await waitFor(() => { + expect(notFound).toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/web/app/(with-contexts)/(with-layout)/checkout/page.tsx b/apps/web/app/(with-contexts)/(with-layout)/checkout/page.tsx index 10816ebf9..f62852405 100644 --- a/apps/web/app/(with-contexts)/(with-layout)/checkout/page.tsx +++ b/apps/web/app/(with-contexts)/(with-layout)/checkout/page.tsx @@ -1,7 +1,7 @@ "use client"; import { ThemeContext } from "@components/contexts"; -import { Header1, Section } from "@courselit/page-primitives"; +import { Section } from "@courselit/page-primitives"; import { useContext } from "react"; import ProductCheckout from "./product"; @@ -10,9 +10,6 @@ export default function CheckoutPage() { return (
- - Checkout -
diff --git a/apps/web/app/(with-contexts)/(with-layout)/checkout/product.tsx b/apps/web/app/(with-contexts)/(with-layout)/checkout/product.tsx index 7c218198c..d24db66d3 100644 --- a/apps/web/app/(with-contexts)/(with-layout)/checkout/product.tsx +++ b/apps/web/app/(with-contexts)/(with-layout)/checkout/product.tsx @@ -7,14 +7,17 @@ import type { MembershipEntityType } from "@courselit/common-models"; import { useToast } from "@courselit/components-library"; import { FetchBuilder } from "@courselit/utils"; import { TOAST_TITLE_ERROR } from "@ui-config/strings"; -import { useSearchParams } from "next/navigation"; +import { notFound, useSearchParams } from "next/navigation"; import { useCallback, useContext, useEffect, useState } from "react"; import type { RuntimeLoginProvider } from "@/lib/login-providers"; +import { Header1 } from "@courselit/page-primitives"; +import { ThemeContext } from "@components/contexts"; const { MembershipEntityType } = Constants; export default function ProductCheckout() { const address = useContext(AddressContext); + const { theme } = useContext(ThemeContext); const searchParams = useSearchParams(); const entityId = searchParams?.get("id"); const entityType = searchParams?.get("type"); @@ -23,6 +26,7 @@ export default function ProductCheckout() { const [product, setProduct] = useState(null); const [paymentPlans, setPaymentPlans] = useState([]); const [includedProducts, setIncludedProducts] = useState([]); + const [productNotFound, setProductNotFound] = useState(false); const [loginProviders, setLoginProviders] = useState< RuntimeLoginProvider[] >([]); @@ -60,10 +64,7 @@ export default function ProductCheckout() { if (response.includedProducts) { setIncludedProducts([...response.includedProducts]); } else { - toast({ - title: TOAST_TITLE_ERROR, - description: "Course not found", - }); + setProductNotFound(true); } } catch (err: any) { toast({ @@ -77,7 +78,7 @@ export default function ProductCheckout() { const getProduct = useCallback(async () => { const query = ` query ($id: String!) { - course: getCourse(id: $id) { + course: getCourse(id: $id, asGuest: true) { courseId title slug @@ -126,10 +127,7 @@ export default function ProductCheckout() { }); setPaymentPlans([...response.course.paymentPlans]); } else { - toast({ - title: TOAST_TITLE_ERROR, - description: "Course not found", - }); + setProductNotFound(true); } setLoginProviders(response.loginProviders || []); } catch (err: any) { @@ -195,10 +193,7 @@ export default function ProductCheckout() { }); setPaymentPlans([...response.community.paymentPlans]); } else { - toast({ - title: TOAST_TITLE_ERROR, - description: "Community not found", - }); + setProductNotFound(true); } setLoginProviders(response.loginProviders || []); } catch (err: any) { @@ -226,18 +221,27 @@ export default function ProductCheckout() { } }, [paymentPlans, getIncludedProducts]); + if (productNotFound) { + notFound(); + } + if (!product) { return null; } return ( - + <> + + Checkout + + + ); } diff --git a/apps/web/app/(with-contexts)/course/[slug]/[id]/__tests__/helpers.test.ts b/apps/web/app/(with-contexts)/course/[slug]/[id]/__tests__/helpers.test.ts index a3aa6fb53..77a9f579c 100644 --- a/apps/web/app/(with-contexts)/course/[slug]/[id]/__tests__/helpers.test.ts +++ b/apps/web/app/(with-contexts)/course/[slug]/[id]/__tests__/helpers.test.ts @@ -64,4 +64,8 @@ describe("course helpers formatCourse", () => { formatted.groups[0].lessons.map((lesson) => lesson.lessonId), ).toEqual(["lesson-3", "lesson-2"]); }); + + it("throws item_not_found instead of reading properties from a null course", () => { + expect(() => formatCourse(null as any)).toThrow("Item not found"); + }); }); diff --git a/apps/web/app/(with-contexts)/course/[slug]/[id]/__tests__/layout-with-sidebar.test.tsx b/apps/web/app/(with-contexts)/course/[slug]/[id]/__tests__/layout-with-sidebar.test.tsx index 1e5d76831..4bf019d0b 100644 --- a/apps/web/app/(with-contexts)/course/[slug]/[id]/__tests__/layout-with-sidebar.test.tsx +++ b/apps/web/app/(with-contexts)/course/[slug]/[id]/__tests__/layout-with-sidebar.test.tsx @@ -25,6 +25,7 @@ jest.mock("@components/ui/sidebar", () => ({ SidebarMenuItem: ({ children }: any) => children, SidebarProvider: ({ children }: any) => children, SidebarTrigger: () => null, + useSidebar: () => ({ openMobile: false }), })); jest.mock("@components/ui/tooltip", () => ({ @@ -57,6 +58,7 @@ jest.mock("@courselit/icons", () => ({ })); jest.mock("lucide-react", () => ({ + BookOpen: () => null, ChevronRight: () => null, Clock: () => null, LogOutIcon: () => null, @@ -725,4 +727,105 @@ describe("generateSideBarItems", () => { "Text 3", ]); }); + + it("shows dripped enrollment-gated lessons as unlocked for course admins", () => { + const course = { + title: "Course", + description: "", + featuredImage: undefined, + updatedAt: new Date().toISOString(), + creatorId: "creator-1", + slug: "test-course", + cost: 0, + courseId: "course-1", + tags: [], + paymentPlans: [], + defaultPaymentPlan: "", + firstLesson: "lesson-1", + isManager: true, + groups: [ + { + id: "group-1", + name: "Admin Section", + lessons: [ + { + lessonId: "lesson-1", + title: "Gated Lesson", + requiresEnrollment: true, + }, + ], + drip: { + status: true, + type: Constants.dripType[0].split("-")[0].toUpperCase(), + delayInMillis: 2, + }, + }, + ], + } as unknown as CourseFrontend; + + const profile = { + userId: "admin-1", + purchases: [], + } as unknown as Profile; + + const items = generateSideBarItems( + course, + profile, + "/course/test-course/course-1", + ); + + expect(items[1].badge).toBeUndefined(); + expect(items[1].items?.[0].icon).toBeUndefined(); + }); + + it("uses profile permissions to unlock admin sidebar when server viewer flag is absent", () => { + const course = { + title: "Course", + description: "", + featuredImage: undefined, + updatedAt: new Date().toISOString(), + creatorId: "creator-1", + slug: "test-course", + cost: 0, + courseId: "course-1", + tags: [], + paymentPlans: [], + defaultPaymentPlan: "", + firstLesson: "lesson-1", + isManager: false, + groups: [ + { + id: "group-1", + name: "Admin Section", + lessons: [ + { + lessonId: "lesson-1", + title: "Gated Lesson", + requiresEnrollment: true, + }, + ], + drip: { + status: true, + type: Constants.dripType[0].split("-")[0].toUpperCase(), + delayInMillis: 2, + }, + }, + ], + } as unknown as CourseFrontend; + + const profile = { + userId: "creator-1", + permissions: ["course:manage"], + purchases: [], + } as unknown as Profile; + + const items = generateSideBarItems( + course, + profile, + "/course/test-course/course-1", + ); + + expect(items[1].badge).toBeUndefined(); + expect(items[1].items?.[0].icon).toBeUndefined(); + }); }); diff --git a/apps/web/app/(with-contexts)/course/[slug]/[id]/helpers.ts b/apps/web/app/(with-contexts)/course/[slug]/[id]/helpers.ts index 0e298e6b2..bc4308691 100644 --- a/apps/web/app/(with-contexts)/course/[slug]/[id]/helpers.ts +++ b/apps/web/app/(with-contexts)/course/[slug]/[id]/helpers.ts @@ -1,9 +1,11 @@ import { Course, Group, Lesson } from "@courselit/common-models"; import { FetchBuilder } from "@courselit/utils"; +import { responses } from "@/config/strings"; export type CourseFrontend = CourseWithoutGroups & { groups: GroupWithLessons[]; firstLesson: string; + isManager: boolean; }; export type GroupWithLessons = Group & { lessons: Lesson[] }; @@ -25,8 +27,9 @@ type CourseWithoutGroups = Pick< export const getProduct = async ( id: string, address: string, + requestHeaders?: Record, ): Promise => { - const fetch = new FetchBuilder() + const fetchBuilder = new FetchBuilder() .setUrl(`${address}/api/graph`) .setIsGraphQLEndpoint(true) .setPayload({ @@ -44,6 +47,7 @@ export const getProduct = async ( slug, cost, courseId, + isManager, groups { id, name, @@ -82,15 +86,30 @@ export const getProduct = async ( `, variables: { id }, }) - .setIsGraphQLEndpoint(true) - .build(); + .setIsGraphQLEndpoint(true); + + if (requestHeaders) { + fetchBuilder.setHeaders(requestHeaders); + } + + const fetch = fetchBuilder.build(); const response = await fetch.exec(); return formatCourse(response.product); }; export function formatCourse( - post: Course & { lessons: Lesson[]; firstLesson: string; groups: Group[] }, + post: + | (Course & { + lessons: Lesson[]; + firstLesson: string; + groups: Group[]; + }) + | null, ): CourseFrontend { + if (!post) { + throw new Error(responses.item_not_found); + } + const groupsWithLessons = post.groups.map((group) => ({ ...group, lessons: post.lessons @@ -111,6 +130,9 @@ export function formatCourse( slug: post.slug, cost: post.cost, courseId: post.courseId, + isManager: Boolean( + (post as Course & { isManager?: boolean }).isManager, + ), groups: groupsWithLessons as GroupWithLessons[], tags: post.tags, firstLesson: post.firstLesson, diff --git a/apps/web/app/(with-contexts)/course/[slug]/[id]/layout-with-sidebar.tsx b/apps/web/app/(with-contexts)/course/[slug]/[id]/layout-with-sidebar.tsx index f9038a873..f74f54f5e 100644 --- a/apps/web/app/(with-contexts)/course/[slug]/[id]/layout-with-sidebar.tsx +++ b/apps/web/app/(with-contexts)/course/[slug]/[id]/layout-with-sidebar.tsx @@ -35,9 +35,9 @@ import { } from "@components/ui/sidebar"; import { Image } from "@courselit/components-library"; import Link from "next/link"; -import { truncate } from "@courselit/utils"; +import { checkPermission, truncate } from "@courselit/utils"; import { Button } from "@components/ui/button"; -import { ChevronRight, Clock, LogOutIcon } from "lucide-react"; +import { BookOpen, ChevronRight, Clock, LogOutIcon } from "lucide-react"; import { Tooltip, TooltipContent, @@ -318,6 +318,27 @@ interface SidebarItem { }[]; } +function canManageCourseFromProfile(course: CourseFrontend, profile: Profile) { + if (!profile?.userId) { + return false; + } + + if ( + checkPermission(profile.permissions ?? [], [ + constants.permissions.manageAnyCourse, + ]) + ) { + return true; + } + + return ( + course.creatorId === profile.userId && + checkPermission(profile.permissions ?? [], [ + constants.permissions.manageCourse, + ]) + ); +} + export function generateSideBarItems( course: CourseFrontend, profile: Profile, @@ -325,6 +346,9 @@ export function generateSideBarItems( ): SidebarItem[] { if (!course) return []; + const isManager = + Boolean(course.isManager) || + canManageCourseFromProfile(course, profile); const items: SidebarItem[] = [ { title: SIDEBAR_TEXT_COURSE_ABOUT, @@ -348,6 +372,7 @@ export function generateSideBarItems( group, profile, lastGroupDripDateInMillis, + isManager, }), items: [], }; @@ -359,28 +384,34 @@ export function generateSideBarItems( if (isActive) { groupItem.isActive = true; } + let lessonStatusIcon: ReactNode; + if (!isManager) { + if (!profile?.userId) { + lessonStatusIcon = lesson.requiresEnrollment ? ( + + ) : undefined; + } else if (isEnrolled(course.courseId, profile)) { + lessonStatusIcon = isLessonCompleted({ + courseId: course.courseId, + lessonId: lesson.lessonId, + profile, + }) ? ( + + ) : ( + + ); + } else { + lessonStatusIcon = lesson.requiresEnrollment ? ( + + ) : undefined; + } + } groupItem.items!.push({ title: lesson.title, href: `/course/${course.slug}/${course.courseId}/${lesson.lessonId}`, isActive, - icon: - profile && profile.userId ? ( - isEnrolled(course.courseId, profile) ? ( - isLessonCompleted({ - courseId: course.courseId, - lessonId: lesson.lessonId, - profile, - }) ? ( - - ) : ( - - ) - ) : lesson.requiresEnrollment ? ( - - ) : undefined - ) : lesson.requiresEnrollment ? ( - - ) : undefined, + leadingIcon: , + icon: lessonStatusIcon, }); } @@ -393,6 +424,7 @@ export function generateSideBarItems( group.drip.status && group.drip.type === Constants.dripType[0].split("-")[0].toUpperCase() && + !isManager && !isGroupAccessibleToUser(course, profile as Profile, group) ) { lastGroupDripDateInMillis += group?.drip?.delayInMillis ?? 0; @@ -407,12 +439,18 @@ function getDripLabel({ group, profile, lastGroupDripDateInMillis, + isManager, }: { course: CourseFrontend; group: GroupWithLessons; profile: Profile; lastGroupDripDateInMillis: number; + isManager: boolean; }): { text: string; description: string } | undefined { + if (isManager) { + return undefined; + } + if ( group.drip?.status && isGroupAccessibleToUser(course, profile as Profile, group) diff --git a/apps/web/app/(with-contexts)/course/[slug]/[id]/layout.tsx b/apps/web/app/(with-contexts)/course/[slug]/[id]/layout.tsx index e5bc545ea..bd0f03649 100644 --- a/apps/web/app/(with-contexts)/course/[slug]/[id]/layout.tsx +++ b/apps/web/app/(with-contexts)/course/[slug]/[id]/layout.tsx @@ -12,6 +12,7 @@ export async function generateMetadata( parent: ResolvingMetadata, ): Promise { const params = await props.params; + const requestHeaders = await headers(); const address = await getAddressFromHeaders(headers); const siteInfo = await getFullSiteSetup(address); @@ -35,13 +36,23 @@ export async function generateMetadata( query, variables: { id: params.id }, }) + .setHeaders({ + cookie: requestHeaders.get("cookie") ?? "", + }) .setIsGraphQLEndpoint(true) .build(); const response = await fetch.exec(); const course = response.course; + const parentTitle = (await parent)?.title?.absolute; + + if (!course?.title) { + notFound(); + } return { - title: `${course?.title} | ${(await parent)?.title?.absolute}`, + title: parentTitle + ? `${course.title} | ${parentTitle}` + : course.title, }; } catch (error) { notFound(); @@ -57,8 +68,17 @@ export default async function Layout(props: { const { children } = props; const { id } = params; + const requestHeaders = await headers(); const address = await getAddressFromHeaders(headers); - const product = await getProduct(id, address); + let product; + + try { + product = await getProduct(id, address, { + cookie: requestHeaders.get("cookie") ?? "", + }); + } catch (error) { + notFound(); + } return {children}; } diff --git a/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/page.tsx b/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/page.tsx index 7651f3107..2bc8e63dd 100644 --- a/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/page.tsx +++ b/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/page.tsx @@ -44,6 +44,7 @@ import { MANAGE_LINK_TEXT, TOAST_TITLE_SUCCESS, VIEW_PAGE_MENU_ITEM, + PREVIEW_COURSE_MENU_ITEM, } from "@ui-config/strings"; import DashboardContent from "@components/admin/dashboard-content"; import { AddressContext, SiteInfoContext } from "@components/contexts"; @@ -210,6 +211,17 @@ export default function DashboardPage() { {VIEW_PAGE_MENU_ITEM} + {product?.type?.toLowerCase() === + Constants.CourseType.COURSE && ( + + + + {PREVIEW_COURSE_MENU_ITEM} + + + )} { const [lesson, setLesson] = useState(); + const [courseTitle, setCourseTitle] = useState(""); + const [courseCreatorId, setCourseCreatorId] = useState(""); + const [isManager, setIsManager] = useState(false); const [error, setError] = useState(); - const router = useRouter(); + const searchParams = useSearchParams(); const [loading, setLoading] = useState(false); const { toast } = useToast(); const { theme } = useContext(ThemeContext); + const isDiscussionOpen = searchParams?.get("discussion") === "open"; + const canManageCourse = + isManager || + checkPermission(profile?.permissions ?? [], [ + permissions.manageAnyCourse, + ]) || + (courseCreatorId === profile?.userId && + checkPermission(profile?.permissions ?? [], [ + permissions.manageCourse, + ])); + + const isCompleted = + lesson && profile && profile.purchases + ? isLessonCompleted({ + courseId: lesson.courseId, + lessonId: lesson.lessonId, + profile: profile as Profile, + }) + : false; + useEffect(() => { setError(undefined); setLesson(undefined); @@ -93,6 +124,11 @@ export const LessonViewer = ({ const loadLesson = async (id: string) => { const query = ` query { + course: getCourse(id: "${productId}") { + title + creatorId + isManager + } lesson: getLessonDetails(id: "${id}", courseId: "${productId}") { lessonId, title, @@ -122,6 +158,11 @@ export const LessonViewer = ({ setLoading(true); const response = await fetch.exec(); + if (response.course) { + setCourseTitle(response.course.title || ""); + setCourseCreatorId(response.course.creatorId || ""); + setIsManager(Boolean(response.course.isManager)); + } if (response.lesson) { setLesson(response.lesson); } @@ -132,10 +173,11 @@ export const LessonViewer = ({ } }; - const markCompleteAndNext = async () => { + const markAsCompleted = async () => { + if (!lesson) return; const query = ` mutation { - result: markLessonCompleted(id: "${lesson!.lessonId}") + result: markLessonCompleted(id: "${lesson.lessonId}") } `; const fetch = new FetchBuilder() @@ -149,16 +191,7 @@ export const LessonViewer = ({ const response = await fetch.exec(); if (response.result) { - if (lesson!.nextLesson) { - await updateUserProfile(); - router.push( - `${path}/${slug}/${lesson!.courseId}/${ - lesson!.nextLesson - }`, - ); - } else { - router.push(`/dashboard/my-content`); - } + await updateUserProfile(); } } catch (err: any) { toast({ @@ -190,232 +223,344 @@ export const LessonViewer = ({ } return ( -
-
- {!lesson && !error && ( -
- - - - - - -
- )} - {error && ( -
-
- - {NOT_ENROLLED_HEADER} - -
- - {error}. - - {error === "You are not enrolled in the course" && ( - - - - )} -
- )} - {lesson && !error && ( - <> -
- - {lesson.title} - -
- {String.prototype.toUpperCase.call( - LESSON_TYPE_VIDEO, - ) === lesson.type && ( -
- - -
- )} - {String.prototype.toUpperCase.call( - LESSON_TYPE_AUDIO, - ) === lesson.type && ( -
- - -
- )} - {String.prototype.toUpperCase.call(LESSON_TYPE_PDF) === - lesson.type && ( -
- - -
- )} - {String.prototype.toUpperCase.call(LESSON_TYPE_TEXT) === - lesson.type && - lesson.content && ( - - - - )} - {String.prototype.toUpperCase.call( - LESSON_TYPE_EMBED, - ) === lesson.type && - lesson.content && ( - - )} - {String.prototype.toUpperCase.call(LESSON_TYPE_QUIZ) === - lesson.type && - lesson.content && ( - +
+
+
+
+
+ {!lesson && !error && ( +
+ + + + + + +
)} - {String.prototype.toUpperCase.call(LESSON_TYPE_FILE) === - lesson.type && - lesson.media?.file && ( -
- - - + + + )}
)} - {String.prototype.toUpperCase.call( - LESSON_TYPE_SCORM, - ) === lesson.type && - lesson.content && ( - + {lesson && !error && ( + <> +
+ {courseTitle && ( + + + {courseTitle} + + )} + + {lesson.title} + +
+ {String.prototype.toUpperCase.call( + LESSON_TYPE_VIDEO, + ) === lesson.type && ( +
+ + +
+ )} + {String.prototype.toUpperCase.call( + LESSON_TYPE_AUDIO, + ) === lesson.type && ( +
+ + +
+ )} + {String.prototype.toUpperCase.call( + LESSON_TYPE_PDF, + ) === lesson.type && ( +
+ + +
+ )} + {String.prototype.toUpperCase.call( + LESSON_TYPE_TEXT, + ) === lesson.type && + lesson.content && ( + + + + )} + {String.prototype.toUpperCase.call( + LESSON_TYPE_EMBED, + ) === lesson.type && + lesson.content && ( + + )} + {String.prototype.toUpperCase.call( + LESSON_TYPE_QUIZ, + ) === lesson.type && + lesson.content && ( + + )} + {String.prototype.toUpperCase.call( + LESSON_TYPE_FILE, + ) === lesson.type && + lesson.media?.file && ( +
+ + + +
+ )} + {String.prototype.toUpperCase.call( + LESSON_TYPE_SCORM, + ) === lesson.type && + lesson.content && ( + + )} + {isEnrolled(lesson.courseId, profile) && ( +
+
+ {isCompleted ? ( + + ) : ( + + )} +
+
+ )} + )} - - )} -
- {lesson && isEnrolled(lesson.courseId, profile) && ( -
-
- {!lesson.prevLesson && ( - - - - )} - {lesson.prevLesson && ( - - - - )} + {lesson && + (isEnrolled(lesson.courseId, profile) || + canManageCourse) && ( +
+
+ {lesson.prevLesson ? ( + + + + ) : ( + + + + )} + {lesson.nextLesson ? ( + + + + ) : ( + + + + )} +
+
+ )} +
- - )} + ); }; diff --git a/apps/web/graphql/courses/__tests__/logic.test.ts b/apps/web/graphql/courses/__tests__/logic.test.ts index e957b3c9e..cb29f1a44 100644 --- a/apps/web/graphql/courses/__tests__/logic.test.ts +++ b/apps/web/graphql/courses/__tests__/logic.test.ts @@ -7,7 +7,13 @@ import PageModel from "@models/Page"; import constants from "@/config/constants"; import { responses } from "@/config/strings"; import { Constants as CommonConstants } from "@courselit/common-models"; -import { getCourse, getMembers, getProducts, updateCourse } from "../logic"; +import { + getCourse, + getCoursesAsAdmin, + getMembers, + getProducts, + updateCourse, +} from "../logic"; import { getLessonOrThrow } from "../../lessons/logic"; import { deleteMedia, sealMedia } from "@/services/medialit"; @@ -356,6 +362,8 @@ describe("updateCourse", () => { describe("getCourse", () => { let testDomain: any; let adminUser: any; + let ownerManager: any; + let ownerWithoutManagePermission: any; beforeAll(async () => { testDomain = await DomainModel.create({ @@ -373,6 +381,28 @@ describe("getCourse", () => { unsubscribeToken: getCourseId("unsubscribe-admin"), purchases: [], }); + + ownerManager = await UserModel.create({ + domain: testDomain._id, + userId: getCourseId("owner-manager"), + email: getCourseEmail("owner-manager"), + name: "Owner Manager", + permissions: [constants.permissions.manageCourse], + active: true, + unsubscribeToken: getCourseId("unsubscribe-owner-manager"), + purchases: [], + }); + + ownerWithoutManagePermission = await UserModel.create({ + domain: testDomain._id, + userId: getCourseId("owner-without-manage"), + email: getCourseEmail("owner-without-manage"), + name: "Owner Without Manage", + permissions: [], + active: true, + unsubscribeToken: getCourseId("unsubscribe-owner-without-manage"), + purchases: [], + }); }); beforeEach(async () => { @@ -437,6 +467,56 @@ describe("getCourse", () => { groupId3, ]); }); + + it("allows course managers to preview unpublished courses", async () => { + const course = await CourseModel.create({ + domain: testDomain._id, + courseId: getCourseId("unpublished-course"), + title: getCourseId("unpublished-course-title"), + creatorId: ownerManager.userId, + groups: [], + lessons: [], + type: "course", + privacy: "unlisted", + costType: "free", + cost: 0, + slug: getCourseId("unpublished-course-slug"), + published: false, + }); + + const formattedCourse = await getCourse(course.courseId, { + subdomain: testDomain, + user: ownerManager, + address: "", + }); + + expect(formattedCourse?.courseId).toBe(course.courseId); + }); + + it("does not allow course owners without manage permission to preview unpublished courses", async () => { + const course = await CourseModel.create({ + domain: testDomain._id, + courseId: getCourseId("unpublished-owner-course"), + title: getCourseId("unpublished-owner-course-title"), + creatorId: ownerWithoutManagePermission.userId, + groups: [], + lessons: [], + type: "course", + privacy: "unlisted", + costType: "free", + cost: 0, + slug: getCourseId("unpublished-owner-course-slug"), + published: false, + }); + + const formattedCourse = await getCourse(course.courseId, { + subdomain: testDomain, + user: ownerWithoutManagePermission, + address: "", + }); + + expect(formattedCourse).toBeNull(); + }); }); describe("public API product read helpers", () => { @@ -513,6 +593,65 @@ describe("public API product read helpers", () => { paginatedFind.mockRestore(); }); + it("returns owned dashboard products for course:manage users", async () => { + const courseManageUser = await UserModel.create({ + domain: testDomain._id, + userId: helperId("dashboard-manage-user"), + email: `${helperId("dashboard-manage")}@example.com`, + name: "Dashboard Course Manager", + permissions: [constants.permissions.manageCourse], + active: true, + unsubscribeToken: helperId("unsubscribe-dashboard-manage"), + purchases: [], + }); + + const ownedCourse = await CourseModel.create({ + domain: testDomain._id, + courseId: helperId("dashboard-owned-course"), + title: "Dashboard Owned Course", + creatorId: courseManageUser.userId, + groups: [], + lessons: [], + type: constants.course, + privacy: "unlisted", + costType: "free", + cost: 0, + slug: helperId("dashboard-owned-course-slug"), + }); + + const otherCourse = await CourseModel.create({ + domain: testDomain._id, + courseId: helperId("dashboard-other-course"), + title: "Dashboard Other Course", + creatorId: adminUser.userId, + groups: [], + lessons: [], + type: constants.course, + privacy: "unlisted", + costType: "free", + cost: 0, + slug: helperId("dashboard-other-course-slug"), + }); + + const products = await Promise.all( + await getCoursesAsAdmin({ + offset: 1, + context: { + subdomain: testDomain, + user: courseManageUser, + address: "", + }, + }), + ); + + expect(products.map((product) => product.courseId)).toContain( + ownedCourse.courseId, + ); + expect(products.map((product) => product.courseId)).not.toContain( + otherCourse.courseId, + ); + }); + it("rejects course-scoped lesson reads when the lesson belongs to another product", async () => { const course = await CourseModel.create({ domain: testDomain._id, diff --git a/apps/web/graphql/courses/logic.ts b/apps/web/graphql/courses/logic.ts index 7ffda0b88..83fa76b6b 100644 --- a/apps/web/graphql/courses/logic.ts +++ b/apps/web/graphql/courses/logic.ts @@ -6,11 +6,7 @@ import { InternalCourse } from "@courselit/orm-models"; import UserModel from "@/models/User"; import { Media, User } from "@courselit/common-models"; import { responses } from "@/config/strings"; -import { - checkIfAuthenticated, - validateOffset, - checkOwnershipWithoutModel, -} from "@/lib/graphql"; +import { checkIfAuthenticated, validateOffset } from "@/lib/graphql"; import constants from "@/config/constants"; import { getPaginatedCoursesForAdmin, @@ -43,7 +39,6 @@ import { } from "../paymentplans/logic"; import MembershipModel from "@models/Membership"; import { getActivities } from "../activities/logic"; -import { ActivityType } from "@courselit/common-models/dist/constants"; import { verifyMandatoryTags } from "../mails/helpers"; import type { Email } from "@courselit/email-editor"; import PaymentPlanModel from "@models/PaymentPlan"; @@ -56,6 +51,10 @@ import getDeletedMediaIds from "@/lib/get-deleted-media-ids"; import { deletePageInternal } from "../pages/logic"; import { replaceTempMediaWithSealedMediaInProseMirrorDoc } from "@/lib/replace-temp-media-with-sealed-media-in-prosemirror-doc"; import { validateSlug, isDuplicateKeyError } from "../pages/helpers"; +import { + canManageCourseInContext, + getCourseManagementAccess, +} from "./permissions"; const { open, itemsPerPage, blogPostSnippetLength, permissions } = constants; @@ -83,18 +82,14 @@ export const getCourseOrThrow = async ( throw new Error(responses.item_not_found); } - if (!checkPermission(ctx.user.permissions, [permissions.manageAnyCourse])) { - if (!checkOwnershipWithoutModel(course, ctx)) { + const access = getCourseManagementAccess(course, ctx); + + if (!access.canManage) { + if (!access.isOwner) { throw new Error(responses.item_not_found); - } else { - if ( - !checkPermission(ctx.user.permissions, [ - permissions.manageCourse, - ]) - ) { - throw new Error(responses.action_not_allowed); - } } + + throw new Error(responses.action_not_allowed); } return course; @@ -163,12 +158,7 @@ export const getCourse = async ( } if (ctx.user && !asGuest) { - const isOwner = - checkPermission(ctx.user.permissions, [ - permissions.manageAnyCourse, - ]) || checkOwnershipWithoutModel(course, ctx); - - if (isOwner) { + if (canManageCourseInContext(course, ctx)) { return await formatCourse(course.courseId, ctx, true); } } @@ -462,7 +452,7 @@ export const getCoursesAsAdmin = async ({ sales: ( await getActivities({ entityId: course.courseId, - type: ActivityType.PURCHASED, + type: Constants.ActivityType.PURCHASED, duration: "lifetime", ctx: context, }) @@ -674,7 +664,7 @@ export const getProducts = async ({ ? ( await getActivities({ entityId: course.courseId, - type: ActivityType.PURCHASED, + type: Constants.ActivityType.PURCHASED, duration: "lifetime", ctx, }) diff --git a/apps/web/graphql/courses/permissions.ts b/apps/web/graphql/courses/permissions.ts new file mode 100644 index 000000000..fc92ab00a --- /dev/null +++ b/apps/web/graphql/courses/permissions.ts @@ -0,0 +1,36 @@ +import constants from "@/config/constants"; +import { checkOwnershipWithoutModel } from "@/lib/graphql"; +import GQLContext from "@/models/GQLContext"; +import { checkPermission } from "@courselit/utils"; +import mongoose from "mongoose"; + +const { permissions } = constants; + +export function getCourseManagementAccess( + course: { creatorId: mongoose.Types.ObjectId | string }, + ctx: GQLContext, +): { canManage: boolean; isOwner: boolean } { + if (!ctx.user) { + return { canManage: false, isOwner: false }; + } + + if (checkPermission(ctx.user.permissions, [permissions.manageAnyCourse])) { + return { canManage: true, isOwner: true }; + } + + const isOwner = checkOwnershipWithoutModel(course, ctx); + + return { + canManage: + isOwner && + checkPermission(ctx.user.permissions, [permissions.manageCourse]), + isOwner, + }; +} + +export function canManageCourseInContext( + course: { creatorId: mongoose.Types.ObjectId | string }, + ctx: GQLContext, +): boolean { + return getCourseManagementAccess(course, ctx).canManage; +} diff --git a/apps/web/graphql/courses/types/index.ts b/apps/web/graphql/courses/types/index.ts index 43b855a53..7602e05b9 100644 --- a/apps/web/graphql/courses/types/index.ts +++ b/apps/web/graphql/courses/types/index.ts @@ -25,6 +25,7 @@ const { course, download, blog, costPaid, costEmail, costFree } = constants; import sequenceTypes from "../../mails/types"; import { getPlans } from "@/graphql/paymentplans/logic"; import GQLContext from "@/models/GQLContext"; +import { canManageCourseInContext } from "../permissions"; const courseStatusType = new GraphQLEnumType({ name: "CoursePrivacyType", @@ -150,6 +151,11 @@ const courseType = new GraphQLObjectType({ slug: { type: new GraphQLNonNull(GraphQLString) }, description: { type: GraphQLString }, leadMagnet: { type: GraphQLBoolean }, + isManager: { + type: new GraphQLNonNull(GraphQLBoolean), + resolve: (course, _, ctx: GQLContext) => + canManageCourseInContext(course, ctx), + }, featuredImage: { type: mediaTypes.mediaType, resolve: (course, _, context, __) => getMedia(course.featuredImage), diff --git a/apps/web/graphql/lessons/__tests__/visibility.test.ts b/apps/web/graphql/lessons/__tests__/visibility.test.ts index 41c8479c5..55b44f338 100644 --- a/apps/web/graphql/lessons/__tests__/visibility.test.ts +++ b/apps/web/graphql/lessons/__tests__/visibility.test.ts @@ -27,6 +27,9 @@ describe("Lesson visibility and progress", () => { let testDomain: any; let creator: any; let student: any; + let manageAnyAdmin: any; + let ownerManager: any; + let otherManager: any; let course: any; let quizCourse: any; let groupId: string; @@ -39,6 +42,9 @@ describe("Lesson visibility and progress", () => { let dripQuizLesson: any; let studentCtx: any; let creatorCtx: any; + let manageAnyAdminCtx: any; + let ownerManagerCtx: any; + let otherManagerCtx: any; beforeAll(async () => { testDomain = await DomainModel.create({ @@ -69,6 +75,39 @@ describe("Lesson visibility and progress", () => { purchases: [], }); + manageAnyAdmin = await UserModel.create({ + domain: testDomain._id, + userId: id("manage-any-admin"), + email: email("manage-any-admin"), + name: "Manage Any Admin", + active: true, + permissions: ["course:manage_any"], + unsubscribeToken: id("unsubscribe-manage-any-admin"), + purchases: [], + }); + + ownerManager = await UserModel.create({ + domain: testDomain._id, + userId: id("owner-manager"), + email: email("owner-manager"), + name: "Owner Manager", + active: true, + permissions: ["course:manage"], + unsubscribeToken: id("unsubscribe-owner-manager"), + purchases: [], + }); + + otherManager = await UserModel.create({ + domain: testDomain._id, + userId: id("other-manager"), + email: email("other-manager"), + name: "Other Manager", + active: true, + permissions: ["course:manage"], + unsubscribeToken: id("unsubscribe-other-manager"), + purchases: [], + }); + groupId = id("group-1"); quizGroupId = id("quiz-group"); quizDripGroupId = id("quiz-drip-group"); @@ -78,7 +117,7 @@ describe("Lesson visibility and progress", () => { courseId: id("course"), title: "Visibility Course", lessons: [], - creatorId: creator.userId, + creatorId: ownerManager.userId, cost: 0, privacy: "public", type: "course", @@ -105,7 +144,7 @@ describe("Lesson visibility and progress", () => { courseId: id("quiz-course"), title: "Quiz Visibility Course", lessons: [], - creatorId: creator.userId, + creatorId: ownerManager.userId, cost: 0, privacy: "public", type: "course", @@ -268,6 +307,21 @@ describe("Lesson visibility and progress", () => { user: creator, subdomain: testDomain, } as any; + + manageAnyAdminCtx = { + user: manageAnyAdmin, + subdomain: testDomain, + } as any; + + ownerManagerCtx = { + user: ownerManager, + subdomain: testDomain, + } as any; + + otherManagerCtx = { + user: otherManager, + subdomain: testDomain, + } as any; }); afterAll(async () => { @@ -396,18 +450,75 @@ describe("Lesson visibility and progress", () => { ).rejects.toThrow(responses.group_not_found); }); - it("should hide unpublished lessons from owners in learner lesson details", async () => { + it("allows owner course managers to read unpublished lessons", async () => { + const lesson = await getLessonDetails( + unpublishedLesson.lessonId, + ownerManagerCtx, + course.courseId, + ); + + expect(lesson.lessonId).toBe(unpublishedLesson.lessonId); + expect(lesson.prevLesson).toBe(publishedLessonOne.lessonId); + expect(lesson.nextLesson).toBe(publishedLessonTwo.lessonId); + }); + + it("allows manage-any course admins to read unpublished lessons", async () => { + const lesson = await getLessonDetails( + unpublishedLesson.lessonId, + manageAnyAdminCtx, + course.courseId, + ); + + expect(lesson.lessonId).toBe(unpublishedLesson.lessonId); + }); + + it("allows manage-any course admins to read enrollment-gated lessons without enrollment", async () => { + const lesson = await getLessonDetails( + dripQuizLesson.lessonId, + manageAnyAdminCtx, + quizCourse.courseId, + ); + + expect(lesson.lessonId).toBe(dripQuizLesson.lessonId); + expect(manageAnyAdmin.purchases).toEqual([]); + }); + + it("allows owner course managers to read enrollment-gated lessons without enrollment", async () => { + const lesson = await getLessonDetails( + dripQuizLesson.lessonId, + ownerManagerCtx, + quizCourse.courseId, + ); + + expect(lesson.lessonId).toBe(dripQuizLesson.lessonId); + expect(ownerManager.purchases).toEqual([]); + }); + + it("does not let non-owner course managers bypass enrollment", async () => { await expect( getLessonDetails( - unpublishedLesson.lessonId, - creatorCtx, - course.courseId, + dripQuizLesson.lessonId, + otherManagerCtx, + quizCourse.courseId, ), - ).rejects.toThrow(responses.item_not_found); + ).rejects.toThrow(responses.not_enrolled); + }); + + it("does not let regular users bypass drip access", async () => { + await expect( + getLessonDetails( + dripQuizLesson.lessonId, + studentCtx, + quizCourse.courseId, + ), + ).rejects.toThrow(responses.drip_not_released); }); it("should support forcing published-only lessons even for owner context", async () => { - const ownerLessons = await getAllLessons(course as any, creatorCtx); + const ownerLessons = await getAllLessons( + course as any, + ownerManagerCtx, + ); expect( ownerLessons.some( (lesson: any) => lesson.lessonId === unpublishedLesson.lessonId, @@ -416,7 +527,7 @@ describe("Lesson visibility and progress", () => { const forcedLearnerLessons = await getAllLessons( course as any, - creatorCtx, + ownerManagerCtx, true, ); expect( @@ -442,7 +553,7 @@ describe("Lesson visibility and progress", () => { it("should not allow completing unpublished lessons for owners", async () => { await expect( - markLessonCompleted(unpublishedLesson.lessonId, creatorCtx), + markLessonCompleted(unpublishedLesson.lessonId, ownerManagerCtx), ).rejects.toThrow(responses.item_not_found); }); diff --git a/apps/web/graphql/lessons/logic.ts b/apps/web/graphql/lessons/logic.ts index fb2da40c0..dbf137472 100644 --- a/apps/web/graphql/lessons/logic.ts +++ b/apps/web/graphql/lessons/logic.ts @@ -37,9 +37,23 @@ import getDeletedMediaIds from "@/lib/get-deleted-media-ids"; import ActivityModel from "@/models/Activity"; import UserModel from "../../models/User"; import { replaceTempMediaWithSealedMediaInProseMirrorDoc } from "@/lib/replace-temp-media-with-sealed-media-in-prosemirror-doc"; +import { canManageCourseInContext } from "../courses/permissions"; const { permissions, quiz, scorm } = constants; +async function canManageLessonCourse(lesson: Lesson, ctx: GQLContext) { + if (!ctx.user) { + return false; + } + + const course = await CourseModel.findOne({ + courseId: lesson.courseId, + domain: ctx.subdomain._id, + }).select("creatorId"); + + return course ? canManageCourseInContext(course, ctx) : false; +} + export const canViewUnpublished = (ctx: GQLContext, entity: any): boolean => { return ( !!ctx.user && @@ -104,11 +118,18 @@ export const getLessonDetails = async ( } const lesson = await LessonModel.findOne(query); - if (!lesson || !lesson.published) { + if (!lesson) { + throw new Error(responses.item_not_found); + } + + const canManageCourse = await canManageLessonCourse(lesson, ctx); + + if (!lesson.published && !canManageCourse) { throw new Error(responses.item_not_found); } if ( + !canManageCourse && lesson.requiresEnrollment && (!ctx.user || !ctx.user.purchases.some( @@ -118,7 +139,10 @@ export const getLessonDetails = async ( throw new Error(responses.not_enrolled); } - if (await isPartOfDripGroup(lesson, ctx.subdomain._id)) { + if ( + !canManageCourse && + (await isPartOfDripGroup(lesson, ctx.subdomain._id)) + ) { if (!ctx.user) { throw new Error(responses.drip_not_released); } @@ -138,7 +162,7 @@ export const getLessonDetails = async ( lesson.courseId, ctx.subdomain._id, lesson.lessonId, - true, + !canManageCourse, ); lesson.prevLesson = prevLesson; lesson.nextLesson = nextLesson; diff --git a/apps/web/ui-config/strings.ts b/apps/web/ui-config/strings.ts index cf88ffb6e..e97ffbfea 100644 --- a/apps/web/ui-config/strings.ts +++ b/apps/web/ui-config/strings.ts @@ -509,6 +509,7 @@ export const PAGE_TITLE_VIEW_PAGE = "View"; export const PAGE_HEADER_EDIT_PAGE = "Edit page"; export const EDIT_PAGE_MENU_ITEM = "Edit page"; export const VIEW_PAGE_MENU_ITEM = "View page"; +export const PREVIEW_COURSE_MENU_ITEM = "Preview"; export const EDIT_PAGE_BUTTON_UPDATE = "Publish"; export const EDIT_PAGE_BUTTON_VIEW = "View"; export const EDIT_PAGE_BUTTON_DONE = "Exit"; @@ -522,6 +523,8 @@ export const COURSE_PROGRESS_INTRO = "Introduction"; export const COURSE_PROGRESS_NEXT = "Complete and continue"; export const COURSE_PROGRESS_START = "Start"; export const COURSE_PROGRESS_FINISH = "Complete and finish"; +export const COURSE_PROGRESS_MARK_COMPLETED = "Mark as completed"; +export const COURSE_PROGRESS_COMPLETED = "Completed"; export const BTN_NEW_BLOG = "New blog"; export const MANAGE_BLOG_PAGE_HEADING = "Blogs"; export const BLOG_TABLE_HEADER_NAME = "Title"; diff --git a/docs/course-admin-viewer-access.md b/docs/course-admin-viewer-access.md new file mode 100644 index 000000000..a61095cc8 --- /dev/null +++ b/docs/course-admin-viewer-access.md @@ -0,0 +1,198 @@ +# Course Admin Viewer Access PRD + +## Document Control + +- Status: Draft +- Last updated: June 6, 2026 +- Owner: Web/Product team +- Target workspace: `apps/web` (`@courselit/web`) + +## Assumptions + +1. This PRD covers the current Next.js course viewer under `apps/web/app/(with-contexts)/course/[slug]/[id]`, including lesson pages and discussion entry points rendered in that viewer. +2. "Course admins" means users who either have `course:manage_any` permission, or have `course:manage` permission and `course.creatorId === user.userId`. +3. Course admin viewer access is a review/preview capability. It must not create a purchase, enrollment, membership, progress record, certificate eligibility, or drip state for the admin. +4. Admins should see every lesson in the course viewer as unlocked when previewing a course they can manage, including unpublished lessons, lessons that normally require enrollment, and sections that are blocked by relative or exact-date drip rules. +5. Eligible admins can preview unpublished courses and unpublished lessons from the course viewer. Public purchase and learner-facing checkout surfaces must continue to use public product visibility and must not expose unpublished products to learners or guests. +6. Learners and anonymous users keep the existing enrollment, payment, lesson-level access, and drip behavior. +7. No database schema change is expected. +8. Existing GraphQL query/mutation boundaries should be extended only where needed; do not add new GraphQL operations if existing course/lesson queries can carry the behavior cleanly. + +## Objective + +Allow course admins to open the learner-facing course viewer for courses they can manage without enrolling themselves. This lets school owners and course managers review the real learner experience, navigate through all released course structure, and inspect dripped or enrollment-gated content without needing to alter customer records. + +Success means: + +1. A user with `course:manage_any` can access any course's viewer and lessons without enrollment, including unpublished courses and lessons. +2. A user with `course:manage` can access a course's viewer and lessons without enrollment only when they are the course creator, including unpublished courses and lessons. +3. The same `course:manage` user cannot bypass enrollment for courses created by another user. +4. Admin-visible course content appears unlocked in the sidebar and lesson page, including enrollment-required lessons and dripped sections. +5. The admin preview path does not mutate enrollment, purchases, progress, completed lessons, accessible groups, drip timestamps, certificates, payment data, or notifications. +6. Existing learner and anonymous behavior remains unchanged. +7. The admin should not be able to mark a lesson complete or record progress etc. as they are not enrolled. + +## Product Requirements + +### Course Admin Eligibility + +- A course admin is eligible when: + - the signed-in user has `course:manage_any`; or + - the signed-in user has `course:manage` and the course's `creatorId` equals the signed-in user's `userId`. +- Eligibility must be evaluated server-side for every course/lesson read path that can reveal gated content. +- Implementation should reuse the same access rule already encoded by `getCourseOrThrow` in `apps/web/graphql/courses/logic.ts`: `course:manage_any` grants access to any course in the tenant, while `course:manage` grants access only when the user owns the course. +- Client-side checks may be used for presentation, but must not be the only enforcement. +- Admin eligibility must respect tenant/domain boundaries exactly as existing course reads do. + +### Course Viewer Access + +- Eligible admins can open `/course/[slug]/[id]` without being enrolled. +- Eligible admins can open `/course/[slug]/[id]/[lesson]` for any lesson in a course they can manage without being enrolled, including unpublished lessons. +- Admins should see preview menu item on the `/dashboard/product/[id]` screen's drop down menu, right below the `View page` menu item. Clicking on this should take them to the course viewer. +- Admins should not see the "not enrolled" error for lessons they are allowed to manage. +- For eligible admins, all viewer sections and lessons should be treated as accessible for display purposes: + - no lock icon for enrollment-required lessons + - no drip badge/locked label for dripped sections + - lesson links and lesson prev/next navigation remain available for all lessons visible in admin preview +- Completed/check icons must continue to represent real learner progress only. A non-enrolled admin should not appear to have completed lessons. + +### Lesson Content Access + +- `getLessonDetails` or its underlying helper must allow eligible admins to fetch protected lesson content. +- Protected content includes lessons with `requiresEnrollment: true` and lessons inside groups blocked by drip for learners. +- Protected content includes unpublished lessons when the viewer is an eligible course admin. +- Admin access must not depend on a purchase row, `accessibleGroups`, `lastDripAt`, or course enrollment. +- Existing learner access logic remains the source of truth for non-admin users. + +### Drip Behavior + +- Admin access bypasses both relative drip and exact-date drip restrictions in the viewer. +- Admin access must not change drip scheduling state. +- Admin access must not trigger drip emails. +- Admin access must not write `accessibleGroups` or alter `lastDripAt` on any user purchase. +- Learners continue to see drip badges and locked sections exactly as before. + +### Discussions In Viewer + +- If course discussions are enabled, eligible admins can open the course viewer discussion index and lesson discussion panel without enrollment when they manage the course. +- Admins should see discussion summaries for all lessons visible in admin preview, including lessons hidden from learners by publication status, enrollment, or drip rules. +- Admins can read and participate in discussions through the course viewer only if existing discussion product requirements allow admin participation without learner enrollment. If the current discussion implementation intentionally restricts public viewer participation to learners, implementation planning must call this out before changing participation semantics. + +### Non-Mutating Preview + +- Viewing as an admin must not: + - create or update a purchase + - enroll the admin + - mark any lesson complete automatically + - make certificate progress + - mark downloads as learner activity unless an existing download endpoint already records file access independently + - send enrollment, drip, progress, or completion notifications + - affect course reports or customer progress metrics +- Completion actions such as "mark lesson complete" should be hidden or disabled for non-enrolled admins unless a separate product decision explicitly allows admin progress records. + +### Security And Privacy + +- Unauthorized users must not be able to reveal gated lesson content by guessing course or lesson IDs. +- A user with only `course:manage` must not access courses they do not own. +- A user with no course management permission must not bypass enrollment or drip. +- Anonymous users must not gain any new access. +- Admin preview access must not weaken existing product visibility or tenant isolation checks. + +## Code Style + +Keep authorization readable and reusable. Prefer a low-level predicate/helper over scattering inline permission checks across UI and GraphQL resolvers. + +```ts +function canManageCourseContent(user: User, course: Course): boolean { + if (checkPermission(user.permissions, [permissions.manageAnyCourse])) { + return true; + } + + return ( + checkPermission(user.permissions, [permissions.manageCourse]) && + course.creatorId === user.userId + ); +} + +function canReadLessonContent({ + user, + course, + lesson, +}: { + user?: User; + course: Course; + lesson: Lesson; +}): boolean { + if (user && canManageCourseContent(user, course)) { + return true; + } + + return canLearnerReadLessonContent({ user, course, lesson }); +} +``` + +Conventions: + +- Keep backend permission decisions in GraphQL/business logic; use UI checks only to render the resulting state. +- Reuse `getCourseOrThrow` where the caller is already in an authenticated course-management flow. For learner-facing course/lesson reads, extract or wrap the same predicate in a read-specific helper so admin viewer access can be checked without accidentally applying dashboard-only assumptions to anonymous or learner reads. +- Preserve existing helper contracts unless changing them is necessary and covered by tests. +- Keep names generic, such as `canManageCourseContent` or `canAccessCourseViewerAsManager`, instead of names tied to a single UI route. +- Use frontend strings from `apps/web/ui-config/strings.ts` for any visible TSX copy. +- Use backend strings from `apps/web/config/strings.ts` for response/error text. + +## Testing Strategy + +GraphQL/business logic tests must cover: + +- `course:manage_any` user can fetch enrollment-required lesson content without enrollment. +- `course:manage` course creator can fetch enrollment-required lesson content without enrollment. +- `course:manage` non-creator cannot fetch gated lesson content without enrollment. +- user without management permission cannot fetch gated lesson content without enrollment. +- eligible admin can access lessons inside dripped groups without `accessibleGroups`. +- admin access does not create or update purchases/progress. +- existing enrolled learner behavior still works. +- existing anonymous/non-enrolled failure behavior still works. + +Course viewer tests must cover: + +- eligible admin sees gated lessons as unlocked in the sidebar. +- eligible admin does not see drip badges on dripped sections. +- eligible admin can render lesson content instead of the enrollment error. +- non-admin users still see locks, drip labels, and enrollment errors where applicable. +- non-enrolled admin does not see learner-only completion controls, or those controls are disabled. +- discussion index/panel behavior matches the final product decision for admin participation. + +Manual verification should include: + +- Sign in as a `course:manage_any` user who is not enrolled and open a paid/dripped course. +- Sign in as the course creator with `course:manage` and no enrollment and open the same viewer. +- Sign in as the course creator with `course:manage` and no enrollment and open an unpublished course and unpublished lesson through the preview flow. +- Open checkout for the same unpublished course as a guest and as a manager and confirm checkout returns a 404/not found state. +- Sign in as a different `course:manage` user and confirm the bypass fails. +- Sign in as a normal learner and confirm existing gated/drip behavior. + +## Boundaries + +- Always: + Preserve learner and anonymous access behavior, centralize server-side authorization, add/update tests for `apps/web/graphql` changes, keep admin preview non-mutating, preserve tenant isolation, and run focused tests before broad verification. +- Ask first: + Adding new GraphQL queries/mutations, changing learner or checkout product visibility semantics, enabling admin progress/completion records, changing discussion participation semantics, adding dependencies, or changing database schema. +- Never: + Enroll admins automatically, create purchases for preview access, use client-only authorization for protected lesson content, expose lessons across tenant boundaries, treat `course:manage` as global access, remove existing learner drip/enrollment checks, or weaken the domain-owner invariant. + +## Success Criteria + +1. Eligible course admins can browse the course viewer and lesson pages without enrollment. +2. All course content visible in admin preview appears unlocked to eligible admins, including unpublished lessons, enrollment-required lessons, and dripped sections. +3. Eligible admins can fetch protected lesson content without purchase/progress/drip state. +4. Ineligible users cannot bypass enrollment or drip. +5. Existing learner behavior, progress behavior, checkout prompts, and drip labels are unchanged. +6. Admin preview access does not mutate user purchases, accessible groups, completed lessons, certificates, or notifications. +7. GraphQL and viewer tests cover both `course:manage_any` and owner-scoped `course:manage`. +8. `pnpm test`, `pnpm lint`, and `pnpm prettier` pass before merge. + +## Open Questions + +1. Should non-enrolled admins be allowed to participate in course discussions from the course viewer, or only read them for review? +2. Should the UI show a subtle "previewing as admin" indicator, or should the viewer remain visually identical to learner mode except for unlocked content? +3. Should downloads or SCORM runtime endpoints receive the same admin bypass in this PR, or should this PR cover only the React lesson viewer and GraphQL lesson content reads? From aa330b8fc412020ef4b6a39277470923f400fb23 Mon Sep 17 00:00:00 2001 From: Rajat Date: Sat, 6 Jun 2026 18:40:09 +0530 Subject: [PATCH 2/2] WIP: discussions basics --- AGENTS.md | 3 +- .../__tests__/layout-with-sidebar.test.tsx | 59 + .../[id]/discussions/__tests__/page.test.tsx | 196 ++ .../course/[slug]/[id]/discussions/page.tsx | 181 ++ .../course/[slug]/[id]/helpers.ts | 3 + .../[slug]/[id]/layout-with-sidebar.tsx | 208 +- .../course/[slug]/[id]/page.tsx | 159 +- .../(sidebar)/community/[id]/manage/page.tsx | 3 +- .../notifications/__tests__/page.test.tsx | 3 + .../__tests__/product-discussions.test.tsx | 101 + .../manage/components/product-discussions.tsx | 119 ++ .../manage/discussions/[entityId]/page.tsx | 1271 +++++++++++ .../[entityId]/report-reason-dialog.tsx | 97 + .../discussions/__tests__/page.test.tsx | 322 +++ .../__tests__/reports-page.test.tsx | 226 ++ .../product/[id]/manage/discussions/page.tsx | 204 ++ .../[id]/manage/discussions/reports/page.tsx | 452 ++++ .../reports/rejection-reason-dialog.tsx | 68 + .../(sidebar)/product/[id]/manage/page.tsx | 2 + .../dashboard/(sidebar)/product/[id]/page.tsx | 7 +- .../components/admin/dashboard-content.tsx | 11 - .../__tests__/panel.test.tsx | 303 +++ .../public/product-discussions/panel.tsx | 1018 +++++++++ apps/web/graphql/activities/logic.ts | 37 +- .../graphql/courses/__tests__/logic.test.ts | 1644 +++++++++++++++ apps/web/graphql/courses/types/index.ts | 5 + apps/web/graphql/index.ts | 3 + .../notifications/__tests__/logic.test.ts | 61 + apps/web/graphql/product-discussions/index.ts | 9 + apps/web/graphql/product-discussions/logic.ts | 1852 +++++++++++++++++ .../graphql/product-discussions/mutation.ts | 216 ++ apps/web/graphql/product-discussions/query.ts | 188 ++ apps/web/graphql/product-discussions/types.ts | 371 ++++ apps/web/hooks/use-product.ts | 1 + apps/web/jest.client.config.ts | 2 + apps/web/models/ProductDiscussionComment.ts | 17 + apps/web/models/ProductDiscussionLike.ts | 17 + apps/web/models/ProductDiscussionReply.ts | 17 + apps/web/models/ProductDiscussionReport.ts | 17 + .../web/models/ProductDiscussionSubscriber.ts | 17 + apps/web/models/ProductDiscussionSummary.ts | 17 + apps/web/models/RateLimitEvent.ts | 17 + apps/web/ui-config/strings.ts | 66 + docs/wip/course-discussions-prd.md | 1029 +++++++++ .../get-notification-message-and-href.ts | 36 + .../src/utils/notification-entity-resolver.ts | 7 +- packages/common-models/src/constants.ts | 2 + packages/common-models/src/course.ts | 1 + packages/orm-models/src/index.ts | 2 + packages/orm-models/src/models/course.ts | 2 + .../src/models/product-discussion.ts | 377 ++++ .../orm-models/src/models/rate-limit-event.ts | 43 + packages/text-editor/src/editor.tsx | 20 +- packages/text-editor/styles.css | 6 +- 54 files changed, 10991 insertions(+), 124 deletions(-) create mode 100644 apps/web/app/(with-contexts)/course/[slug]/[id]/discussions/__tests__/page.test.tsx create mode 100644 apps/web/app/(with-contexts)/course/[slug]/[id]/discussions/page.tsx create mode 100644 apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/manage/components/__tests__/product-discussions.test.tsx create mode 100644 apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/manage/components/product-discussions.tsx create mode 100644 apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/manage/discussions/[entityId]/page.tsx create mode 100644 apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/manage/discussions/[entityId]/report-reason-dialog.tsx create mode 100644 apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/manage/discussions/__tests__/page.test.tsx create mode 100644 apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/manage/discussions/__tests__/reports-page.test.tsx create mode 100644 apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/manage/discussions/page.tsx create mode 100644 apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/manage/discussions/reports/page.tsx create mode 100644 apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/manage/discussions/reports/rejection-reason-dialog.tsx create mode 100644 apps/web/components/public/product-discussions/__tests__/panel.test.tsx create mode 100644 apps/web/components/public/product-discussions/panel.tsx create mode 100644 apps/web/graphql/product-discussions/index.ts create mode 100644 apps/web/graphql/product-discussions/logic.ts create mode 100644 apps/web/graphql/product-discussions/mutation.ts create mode 100644 apps/web/graphql/product-discussions/query.ts create mode 100644 apps/web/graphql/product-discussions/types.ts create mode 100644 apps/web/models/ProductDiscussionComment.ts create mode 100644 apps/web/models/ProductDiscussionLike.ts create mode 100644 apps/web/models/ProductDiscussionReply.ts create mode 100644 apps/web/models/ProductDiscussionReport.ts create mode 100644 apps/web/models/ProductDiscussionSubscriber.ts create mode 100644 apps/web/models/ProductDiscussionSummary.ts create mode 100644 apps/web/models/RateLimitEvent.ts create mode 100644 docs/wip/course-discussions-prd.md create mode 100644 packages/orm-models/src/models/product-discussion.ts create mode 100644 packages/orm-models/src/models/rate-limit-event.ts diff --git a/AGENTS.md b/AGENTS.md index ee493d0db..3a0b46b1b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,7 +5,8 @@ - Command for running script in a workspace: `pnpm --filter `. - Command for running tests: `pnpm test`. - The project uses shadcn for building UI, so stick to its conventions and design. -- In `apps/web` workspace, create a string first in `apps/web/config/strings.ts` and then import it in the `.tsx` files, instead of using inline strings. +- In `apps/web` workspace, create a string first in `apps/web/ui-config/strings.ts` and then import it in the `.tsx` files, instead of using inline strings. +- `apps/web/config/strings.ts` is for strings used by backend and `apps/web/ui-config/strings.ts` is for strings used by frontend. - For admin/dashboard empty states in `apps/web`, prefer reusing `apps/web/components/admin/empty-state.tsx` instead of creating one-off placeholder UIs. - When working with forms, always use refs to keep the current state of the form's data and use it to enable/disable the form submit button. - Check the name field inside each package's package.json to confirm the right name—skip the top-level one. diff --git a/apps/web/app/(with-contexts)/course/[slug]/[id]/__tests__/layout-with-sidebar.test.tsx b/apps/web/app/(with-contexts)/course/[slug]/[id]/__tests__/layout-with-sidebar.test.tsx index 4bf019d0b..39984cc31 100644 --- a/apps/web/app/(with-contexts)/course/[slug]/[id]/__tests__/layout-with-sidebar.test.tsx +++ b/apps/web/app/(with-contexts)/course/[slug]/[id]/__tests__/layout-with-sidebar.test.tsx @@ -51,6 +51,15 @@ jest.mock("@courselit/components-library", () => ({ Image: () => null, })); +jest.mock("@courselit/page-blocks", () => ({ + TextRenderer: () => null, +})); + +jest.mock("@courselit/text-editor", () => ({ + Editor: () => null, + emptyDoc: {}, +})); + jest.mock("@courselit/icons", () => ({ CheckCircled: () => null, Circle: () => null, @@ -61,7 +70,9 @@ jest.mock("lucide-react", () => ({ BookOpen: () => null, ChevronRight: () => null, Clock: () => null, + Folder: () => null, LogOutIcon: () => null, + MessageSquare: () => null, })); jest.mock("@courselit/page-primitives", () => ({ @@ -828,4 +839,52 @@ describe("generateSideBarItems", () => { expect(items[1].badge).toBeUndefined(); expect(items[1].items?.[0].icon).toBeUndefined(); }); + + it("shows the discussions sidebar item only when discussions are enabled", () => { + const course = { + title: "Course", + description: "", + featuredImage: undefined, + updatedAt: new Date().toISOString(), + creatorId: "creator-1", + slug: "test-course", + cost: 0, + courseId: "course-1", + tags: [], + paymentPlans: [], + defaultPaymentPlan: "", + firstLesson: "lesson-1", + groups: [], + discussions: false, + } as unknown as CourseFrontend; + + const profile = { + userId: "user-1", + purchases: [ + { + courseId: "course-1", + accessibleGroups: [], + }, + ], + } as unknown as Profile; + + expect( + generateSideBarItems( + course, + profile, + "/course/test-course/course-1", + ).some((item) => item.title === "Discussions"), + ).toBe(false); + + expect( + generateSideBarItems( + { ...course, discussions: true } as CourseFrontend, + profile, + "/course/test-course/course-1/discussions", + ).find((item) => item.title === "Discussions"), + ).toMatchObject({ + href: "/course/test-course/course-1/discussions", + isActive: true, + }); + }); }); diff --git a/apps/web/app/(with-contexts)/course/[slug]/[id]/discussions/__tests__/page.test.tsx b/apps/web/app/(with-contexts)/course/[slug]/[id]/discussions/__tests__/page.test.tsx new file mode 100644 index 000000000..29e7c8ba9 --- /dev/null +++ b/apps/web/app/(with-contexts)/course/[slug]/[id]/discussions/__tests__/page.test.tsx @@ -0,0 +1,196 @@ +import React from "react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import CourseDiscussionsPage from "../page"; +import { AddressContext, ThemeContext } from "@components/contexts"; +import { FetchBuilder } from "@courselit/utils"; + +const mockExec = jest.fn(); +const payloads: Record[] = []; + +jest.mock("next/link", () => { + function MockNextLink({ + children, + href, + className, + }: { + children: React.ReactNode; + href: string; + className?: string; + }) { + return ( + + {children} + + ); + } + + return MockNextLink; +}); + +jest.mock("@components/contexts", () => { + const React = require("react"); + return { + AddressContext: React.createContext({ + backend: "", + frontend: "", + }), + ThemeContext: React.createContext({ + theme: {}, + }), + }; +}); + +jest.mock("../../helpers", () => ({ + getProduct: jest.fn().mockResolvedValue({ + title: "Course with discussions", + groups: [ + { + lessons: [ + { + lessonId: "lesson-1", + title: "Text lesson", + }, + { + lessonId: "lesson-2", + title: "Video lesson", + }, + ], + }, + ], + }), +})); + +jest.mock("@courselit/page-primitives", () => ({ + Button: ({ children, disabled, onClick }: any) => ( + + ), + Header1: ({ children }: any) =>

{children}

, + PageCard: ({ children }: any) =>
{children}
, + PageCardContent: ({ children }: any) =>
{children}
, + Text1: ({ children }: any) =>
{children}
, + Text2: ({ children }: any) => {children}, +})); + +jest.mock("lucide-react", () => ({ + BookOpen: () => null, + MessageSquare: () => null, +})); + +jest.mock("@courselit/utils", () => ({ + FetchBuilder: jest.fn().mockImplementation(() => ({ + setUrl: jest.fn().mockReturnThis(), + setPayload: jest.fn(function (payload) { + payloads.push(payload); + return this; + }), + setIsGraphQLEndpoint: jest.fn().mockReturnThis(), + build: jest.fn().mockReturnThis(), + exec: mockExec, + })), +})); + +function renderPage() { + const params = Promise.resolve({ + slug: "course-slug", + id: "course-1", + }) as any; + params.status = "fulfilled"; + params.value = { + slug: "course-slug", + id: "course-1", + }; + + return render( + + + Loading}> + + + + , + ); +} + +describe("CourseDiscussionsPage", () => { + beforeEach(() => { + jest.clearAllMocks(); + payloads.length = 0; + mockExec.mockImplementation(() => { + const payload = payloads[payloads.length - 1]; + if (payload.variables?.cursor) { + return Promise.resolve({ + summaries: { + items: [ + { + entityId: "lesson-2", + totalCount: 1, + commentsCount: 1, + repliesCount: 0, + lastActivityAt: "2026-06-02T00:00:00.000Z", + }, + ], + nextCursor: undefined, + hasMore: false, + }, + }); + } + + return Promise.resolve({ + summaries: { + items: [ + { + entityId: "lesson-1", + totalCount: 3, + commentsCount: 1, + repliesCount: 2, + lastActivityAt: "2026-06-01T00:00:00.000Z", + }, + ], + nextCursor: "summary-cursor", + hasMore: true, + }, + }); + }); + }); + + it("lists lesson discussion summaries and links to the lesson with the panel open", async () => { + renderPage(); + + await waitFor(() => { + expect(screen.getByText("Text lesson")).toBeInTheDocument(); + }); + + expect(FetchBuilder).toHaveBeenCalled(); + expect(payloads[0].variables).toEqual({ + productId: "course-1", + cursor: undefined, + }); + expect(screen.getByText("3")).toBeInTheDocument(); + expect(screen.getByText("Text lesson").closest("a")).toHaveAttribute( + "href", + "/course/course-slug/course-1/lesson-1?discussion=open", + ); + }); + + it("paginates discussion summaries without showing zero-activity rows from the client", async () => { + renderPage(); + + await waitFor(() => { + expect(screen.getByText("Text lesson")).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText("Load more")); + + await waitFor(() => { + expect(screen.getByText("Video lesson")).toBeInTheDocument(); + }); + expect(payloads[payloads.length - 1].variables).toEqual({ + productId: "course-1", + cursor: "summary-cursor", + }); + }); +}); diff --git a/apps/web/app/(with-contexts)/course/[slug]/[id]/discussions/page.tsx b/apps/web/app/(with-contexts)/course/[slug]/[id]/discussions/page.tsx new file mode 100644 index 000000000..b67ff8184 --- /dev/null +++ b/apps/web/app/(with-contexts)/course/[slug]/[id]/discussions/page.tsx @@ -0,0 +1,181 @@ +"use client"; + +import { useContext, useEffect, useMemo, useState, use } from "react"; +import { AddressContext, ThemeContext } from "@components/contexts"; +import { FetchBuilder } from "@courselit/utils"; +import { getProduct } from "../helpers"; +import { + Button, + Header1, + PageCard, + PageCardContent, + Text1, + Text2, +} from "@courselit/page-primitives"; +import { + COURSE_DISCUSSIONS_EMPTY, + COURSE_DISCUSSIONS_TITLE, + LOAD_MORE_TEXT, +} from "@ui-config/strings"; +import { BookOpen, MessageSquare } from "lucide-react"; +import NextLink from "next/link"; + +type DiscussionSummary = { + entityId: string; + totalCount: number; + commentsCount: number; + repliesCount: number; + lastActivityAt: string; +}; + +export default function CourseDiscussionsPage(props: { + params: Promise<{ slug: string; id: string }>; +}) { + const params = use(props.params); + const { slug, id } = params; + const address = useContext(AddressContext); + const { theme } = useContext(ThemeContext); + const [summaries, setSummaries] = useState([]); + const [courseTitle, setCourseTitle] = useState(""); + const [nextCursor, setNextCursor] = useState(); + const [hasMore, setHasMore] = useState(false); + const [loading, setLoading] = useState(false); + const [lessonsById, setLessonsById] = useState>({}); + + useEffect(() => { + if (!id || !address?.backend) return; + getProduct(id, address.backend).then((product) => { + setCourseTitle(product.title || ""); + const lessons = Object.fromEntries( + product.groups + .flatMap((group) => group.lessons) + .map((lesson) => [lesson.lessonId, lesson.title]), + ); + setLessonsById(lessons); + }); + loadSummaries(); + }, [id, address?.backend]); + + const rows = useMemo( + () => + summaries.map((summary) => ({ + ...summary, + title: lessonsById[summary.entityId] || summary.entityId, + })), + [summaries, lessonsById], + ); + + async function graph(payload: Record) { + const fetch = new FetchBuilder() + .setUrl(`${address.backend}/api/graph`) + .setPayload(payload) + .setIsGraphQLEndpoint(true) + .build(); + + return await fetch.exec(); + } + + async function loadSummaries(cursor?: string) { + setLoading(true); + try { + const response = await graph({ + query: ` + query GetProductDiscussionSummaries($productId: String!, $cursor: String) { + summaries: getProductDiscussionSummaries(productId: $productId, cursor: $cursor, limit: 20) { + items { + entityId + totalCount + commentsCount + repliesCount + lastActivityAt + } + nextCursor + hasMore + } + } + `, + variables: { + productId: id, + cursor, + }, + }); + + const page = response.summaries; + setSummaries((current) => + cursor ? [...current, ...page.items] : page.items, + ); + setNextCursor(page.nextCursor); + setHasMore(page.hasMore); + } finally { + setLoading(false); + } + } + + return ( +
+
+
+
+ {courseTitle && ( + + + {courseTitle} + + )} + + {COURSE_DISCUSSIONS_TITLE} + +
+ {!loading && rows.length === 0 && ( + + {COURSE_DISCUSSIONS_EMPTY} + + )} +
+ {rows.map((summary) => ( + + + + + {summary.title} + + + + {summary.totalCount} + + + + + ))} +
+ {hasMore && ( + + )} +
+
+
+ ); +} diff --git a/apps/web/app/(with-contexts)/course/[slug]/[id]/helpers.ts b/apps/web/app/(with-contexts)/course/[slug]/[id]/helpers.ts index bc4308691..b4c0ce0a8 100644 --- a/apps/web/app/(with-contexts)/course/[slug]/[id]/helpers.ts +++ b/apps/web/app/(with-contexts)/course/[slug]/[id]/helpers.ts @@ -19,6 +19,7 @@ type CourseWithoutGroups = Pick< | "slug" | "cost" | "courseId" + | "discussions" | "tags" | "paymentPlans" | "defaultPaymentPlan" @@ -47,6 +48,7 @@ export const getProduct = async ( slug, cost, courseId, + discussions, isManager, groups { id, @@ -130,6 +132,7 @@ export function formatCourse( slug: post.slug, cost: post.cost, courseId: post.courseId, + discussions: post.discussions, isManager: Boolean( (post as Course & { isManager?: boolean }).isManager, ), diff --git a/apps/web/app/(with-contexts)/course/[slug]/[id]/layout-with-sidebar.tsx b/apps/web/app/(with-contexts)/course/[slug]/[id]/layout-with-sidebar.tsx index f74f54f5e..1ad0ee272 100644 --- a/apps/web/app/(with-contexts)/course/[slug]/[id]/layout-with-sidebar.tsx +++ b/apps/web/app/(with-contexts)/course/[slug]/[id]/layout-with-sidebar.tsx @@ -1,6 +1,6 @@ "use client"; -import { ReactNode, useContext } from "react"; +import { ReactNode, useContext, useEffect, useRef } from "react"; import constants from "@/config/constants"; import { formattedLocaleDate, @@ -11,12 +11,14 @@ import { CheckCircled, Circle, Lock } from "@courselit/icons"; import { BTN_EXIT_COURSE_TOOLTIP, SIDEBAR_TEXT_COURSE_ABOUT, + SIDEBAR_TEXT_COURSE_DISCUSSIONS, } from "@ui-config/strings"; import { Profile, Constants } from "@courselit/common-models"; import { ProfileContext, SiteInfoContext, ThemeContext, + AddressContext, } from "@components/contexts"; import { CourseFrontend, GroupWithLessons } from "./helpers"; import { @@ -32,12 +34,20 @@ import { SidebarMenuItem, SidebarProvider, SidebarTrigger, + useSidebar, } from "@components/ui/sidebar"; import { Image } from "@courselit/components-library"; import Link from "next/link"; import { checkPermission, truncate } from "@courselit/utils"; import { Button } from "@components/ui/button"; -import { BookOpen, ChevronRight, Clock, LogOutIcon } from "lucide-react"; +import { + BookOpen, + ChevronRight, + Clock, + Folder, + LogOutIcon, + MessageSquare, +} from "lucide-react"; import { Tooltip, TooltipContent, @@ -49,9 +59,45 @@ import { CollapsibleContent, CollapsibleTrigger, } from "@components/ui/collapsible"; -import { usePathname } from "next/navigation"; +import { usePathname, useSearchParams, useRouter } from "next/navigation"; import { Caption } from "@courselit/page-primitives"; import NextThemeSwitcher from "@components/admin/next-theme-switcher"; +import ProductDiscussionPanel from "@/components/public/product-discussions/panel"; + +function MobileStateSync() { + const { open, setOpenMobile, isMobile } = useSidebar(); + useEffect(() => { + setOpenMobile(open); + }, [open, isMobile, setOpenMobile]); + return null; +} + +function DiscussionSidebarSync({ + pathname, + router, + searchParams, +}: { + pathname: string | null; + router: ReturnType; + searchParams: ReturnType; +}) { + const { openMobile, isMobile } = useSidebar(); + const prevOpenMobile = useRef(openMobile); + useEffect(() => { + if (isMobile && prevOpenMobile.current && !openMobile) { + const params = new URLSearchParams(searchParams?.toString() || ""); + if (params.has("discussion")) { + params.delete("discussion"); + const newPath = params.toString() + ? `${pathname}?${params.toString()}` + : pathname; + router.push(newPath || ""); + } + } + prevOpenMobile.current = openMobile; + }, [openMobile, isMobile, searchParams, pathname, router]); + return null; +} export default function ProductPage({ product, @@ -61,6 +107,17 @@ export default function ProductPage({ children: React.ReactNode; }) { const { profile } = useContext(ProfileContext); + const pathname = usePathname(); + const searchParams = useSearchParams(); + const isDiscussionOpen = searchParams?.get("discussion") === "open"; + const router = useRouter(); + const address = useContext(AddressContext); + + const pathSegments = pathname.split("/").filter(Boolean); + const isLessonPage = + pathSegments.length === 4 && pathSegments[0] === "course"; + const isActualLessonPage = + isLessonPage && pathSegments[3] !== "discussions"; if (!profile) { return null; @@ -78,9 +135,37 @@ export default function ProductPage({ > -
+
+ {isActualLessonPage && product.discussions && ( + + + + + + {SIDEBAR_TEXT_COURSE_DISCUSSIONS} + + + )} @@ -96,8 +181,64 @@ export default function ProductPage({
-
{children}
+
+ {children} +
+ {isActualLessonPage && product.discussions && ( + { + if (!open) { + const params = new URLSearchParams( + searchParams?.toString() || "", + ); + params.delete("discussion"); + const newPath = params.toString() + ? `${pathname}?${params.toString()}` + : pathname; + router.push(newPath || ""); + } + }} + style={ + { + "--sidebar-width": "20rem", + "--sidebar-width-mobile": "28rem", + } as React.CSSProperties + } + className="min-h-0 w-auto" + > + + + + { + const params = new URLSearchParams( + searchParams?.toString() || "", + ); + params.delete("discussion"); + const newPath = params.toString() + ? `${pathname}?${params.toString()}` + : pathname; + router.push(newPath || ""); + }} + /> + + + )} ); } @@ -162,23 +303,26 @@ export function AppSidebar({ >
- - - - {truncate( - item.title, - item.badge - ? 15 - : 26, - )} - - - {item.title} - - - + + + + + + {truncate( + item.title, + item.badge + ? 15 + : 26, + )} + + + {item.title} + + + + {item.badge?.text && ( @@ -231,8 +375,11 @@ export function AppSidebar({ href={ item.href } - className="w-full" + className="flex w-full min-w-0 items-center gap-2" > + { + item.leadingIcon + } + {item.icon} {item.title} @@ -305,6 +453,7 @@ export function AppSidebar({ interface SidebarItem { title: string; href: string; + icon?: ReactNode; badge?: { text: string; description: string; @@ -313,6 +462,7 @@ interface SidebarItem { items?: { title: string; href: string; + leadingIcon?: ReactNode; icon?: ReactNode; isActive?: boolean; }[]; @@ -353,10 +503,22 @@ export function generateSideBarItems( { title: SIDEBAR_TEXT_COURSE_ABOUT, href: `/course/${course.slug}/${course.courseId}`, + icon: , isActive: pathname === `/course/${course.slug}/${course.courseId}`, }, ]; + if (course.discussions) { + items.push({ + title: SIDEBAR_TEXT_COURSE_DISCUSSIONS, + href: `/course/${course.slug}/${course.courseId}/discussions`, + icon: , + isActive: + pathname === + `/course/${course.slug}/${course.courseId}/discussions`, + }); + } + let lastGroupDripDateInMillis = getRelativeDripAnchorMillis( course, profile, 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 f5b217340..75424f139 100644 --- a/apps/web/app/(with-contexts)/course/[slug]/[id]/page.tsx +++ b/apps/web/app/(with-contexts)/course/[slug]/[id]/page.tsx @@ -77,88 +77,95 @@ export default function ProductPage(props: { : TextEditorEmptyDoc; return ( -
- - {product.title} - - {progress?.certificateId && ( - - - - )} - {!isEnrolled(product.courseId, profile as Profile) && - checkPermission(profile.permissions ?? [], [ - permissions.enrollInCourse, - ]) && ( -
-
-
- {getSymbolFromCurrency( - siteInfo.currencyISOCode ?? "", - )} - {product.cost} - - {product.costType ?? ""} - -
- - - -
-
- )} - {product.featuredImage && ( -
-
- {product.featuredImage.caption} -
-
- )} -
-
- +
+
+ - - - -
-
- {isEnrolled(product.courseId, profile as Profile) && - product.firstLesson && ( -
+ > + {product.title} + + {progress?.certificateId && ( - + )} + {!isEnrolled(product.courseId, profile as Profile) && + checkPermission(profile.permissions ?? [], [ + permissions.enrollInCourse, + ]) && ( +
+
+
+ {getSymbolFromCurrency( + siteInfo.currencyISOCode ?? "", + )} + {product.cost} + + {product.costType ?? ""} + +
+ + + +
+
+ )} + {product.featuredImage && ( +
+
+ {product.featuredImage.caption} +
+
+ )} +
+
+ + + + +
- )} + {isEnrolled(product.courseId, profile as Profile) && + product.firstLesson && ( +
+ + + +
+ )} +
+
); } diff --git a/apps/web/app/(with-contexts)/dashboard/(sidebar)/community/[id]/manage/page.tsx b/apps/web/app/(with-contexts)/dashboard/(sidebar)/community/[id]/manage/page.tsx index a07620738..7830420a1 100644 --- a/apps/web/app/(with-contexts)/dashboard/(sidebar)/community/[id]/manage/page.tsx +++ b/apps/web/app/(with-contexts)/dashboard/(sidebar)/community/[id]/manage/page.tsx @@ -4,6 +4,7 @@ import DashboardContent from "@components/admin/dashboard-content"; import { AddressContext, ProfileContext } from "@components/contexts"; import { COMMUNITY_HEADER, + COMMUNITY_REPORTS_HEADER, COMMUNITY_SETTINGS, DANGER_ZONE_HEADER, MEDIA_SELECTOR_REMOVE_BTN_CAPTION, @@ -545,7 +546,7 @@ export default function Page(props: { >
diff --git a/apps/web/app/(with-contexts)/dashboard/(sidebar)/notifications/__tests__/page.test.tsx b/apps/web/app/(with-contexts)/dashboard/(sidebar)/notifications/__tests__/page.test.tsx index 432bae0ef..7168e8232 100644 --- a/apps/web/app/(with-contexts)/dashboard/(sidebar)/notifications/__tests__/page.test.tsx +++ b/apps/web/app/(with-contexts)/dashboard/(sidebar)/notifications/__tests__/page.test.tsx @@ -90,6 +90,9 @@ describe("Notifications Page", () => { expect( screen.getByText("Community Membership Granted"), ).toBeInTheDocument(); + expect( + screen.getByText("Course Discussion Activity"), + ).toBeInTheDocument(); expect( screen.queryByText( "No notification preferences are available for your account.", diff --git a/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/manage/components/__tests__/product-discussions.test.tsx b/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/manage/components/__tests__/product-discussions.test.tsx new file mode 100644 index 000000000..35de8d296 --- /dev/null +++ b/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/manage/components/__tests__/product-discussions.test.tsx @@ -0,0 +1,101 @@ +import React from "react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import ProductDiscussions from "../product-discussions"; + +const mockToast = jest.fn(); +const mockExec = jest.fn(); +const mockSetPayload = jest.fn(); + +jest.mock("@courselit/components-library", () => ({ + useToast: () => ({ + toast: mockToast, + }), +})); + +jest.mock("@/hooks/use-graphql-fetch", () => ({ + useGraphQLFetch: () => ({ + setPayload: mockSetPayload.mockReturnThis(), + build: jest.fn().mockReturnThis(), + exec: mockExec, + }), +})); + +jest.mock("@/components/ui/label", () => ({ + Label: ({ children }: { children: React.ReactNode }) => ( + + ), +})); + +jest.mock("@/components/ui/separator", () => ({ + Separator: () =>
, +})); + +jest.mock("@/components/ui/switch", () => ({ + Switch: ({ checked, disabled, onCheckedChange }: any) => ( + onCheckedChange(!checked)} + /> + ), +})); + +describe("ProductDiscussions", () => { + beforeEach(() => { + jest.clearAllMocks(); + mockExec.mockResolvedValue({ + updateCourse: { + courseId: "course-1", + discussions: true, + }, + }); + }); + + it("renders and saves the discussion toggle for course products", async () => { + render( + , + ); + + fireEvent.click(screen.getByLabelText("discussions-switch")); + + await waitFor(() => { + expect(mockSetPayload).toHaveBeenCalledWith( + expect.objectContaining({ + variables: { + courseId: "course-1", + discussions: true, + }, + }), + ); + }); + expect(mockToast).toHaveBeenCalledWith( + expect.objectContaining({ + title: "Success", + }), + ); + }); + + it("does not render for non-course products", () => { + render( + , + ); + + expect( + screen.queryByLabelText("discussions-switch"), + ).not.toBeInTheDocument(); + }); +}); diff --git a/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/manage/components/product-discussions.tsx b/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/manage/components/product-discussions.tsx new file mode 100644 index 000000000..86698133d --- /dev/null +++ b/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/manage/components/product-discussions.tsx @@ -0,0 +1,119 @@ +"use client"; + +import { useState } from "react"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { Separator } from "@/components/ui/separator"; +import { useToast } from "@courselit/components-library"; +import { + APP_MESSAGE_COURSE_SAVED, + COURSE_DISCUSSIONS_DESCRIPTION, + COURSE_DISCUSSIONS_MANAGE_DISCUSSION, + COURSE_DISCUSSIONS_TITLE, + TOAST_TITLE_ERROR, + TOAST_TITLE_SUCCESS, +} from "@ui-config/strings"; +import { useGraphQLFetch } from "@/hooks/use-graphql-fetch"; +import { COURSE_TYPE_COURSE } from "@ui-config/constants"; +import { Button } from "@/components/ui/button"; +import Link from "next/link"; + +const MUTATION_UPDATE_DISCUSSIONS = ` + mutation UpdateDiscussions($courseId: String!, $discussions: Boolean!) { + updateCourse(courseData: { id: $courseId, discussions: $discussions }) { + courseId + discussions + } + } +`; + +interface ProductDiscussionsProps { + product: any; +} + +export default function ProductDiscussions({ + product, +}: ProductDiscussionsProps) { + const { toast } = useToast(); + const fetch = useGraphQLFetch(); + const [loading, setLoading] = useState(false); + const [discussions, setDiscussions] = useState( + product?.discussions || false, + ); + + const handleDiscussionsChange = async () => { + const newValue = !discussions; + const previousValue = discussions; + setDiscussions(newValue); + + if (!product?.courseId) return; + + try { + setLoading(true); + const response = await fetch + .setPayload({ + query: MUTATION_UPDATE_DISCUSSIONS, + variables: { + courseId: product.courseId, + discussions: newValue, + }, + }) + .build() + .exec(); + + if (response?.updateCourse) { + toast({ + title: TOAST_TITLE_SUCCESS, + description: APP_MESSAGE_COURSE_SAVED, + }); + } + } catch (err: any) { + setDiscussions(previousValue); + toast({ + title: TOAST_TITLE_ERROR, + description: err.message, + variant: "destructive", + }); + } finally { + setLoading(false); + } + }; + + if (product?.type?.toLowerCase() !== COURSE_TYPE_COURSE) { + return null; + } + + return ( +
+
+
+ +

+ {COURSE_DISCUSSIONS_DESCRIPTION} +

+
+
+ +
+
+ {discussions && ( +
+ +
+ )} + +
+ ); +} diff --git a/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/manage/discussions/[entityId]/page.tsx b/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/manage/discussions/[entityId]/page.tsx new file mode 100644 index 000000000..8e6bd9284 --- /dev/null +++ b/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/manage/discussions/[entityId]/page.tsx @@ -0,0 +1,1271 @@ +"use client"; + +import { useContext, useEffect, useState, useMemo, useRef } from "react"; +import type React from "react"; +import { useParams } from "next/navigation"; +import DashboardContent from "@components/admin/dashboard-content"; +import useProduct from "@/hooks/use-product"; +import { AddressContext, ThemeContext } from "@components/contexts"; +import { FetchBuilder, truncate } from "@courselit/utils"; +import { TextRenderer } from "@courselit/page-blocks"; +import { Editor, emptyDoc as TextEditorEmptyDoc } from "@courselit/text-editor"; +import type { TextEditorContent } from "@courselit/common-models"; +import { UIConstants } from "@courselit/common-models"; +import { isTextEditorNonEmpty } from "@ui-lib/utils"; +import { + COURSE_DISCUSSIONS_ADMIN_BROWSE_TARGET, + COURSE_DISCUSSIONS_ADMIN_COLLAPSE_REPLIES, + COURSE_DISCUSSIONS_ADMIN_EXPAND_REPLIES, + COURSE_DISCUSSIONS_ADMIN_NEXT, + COURSE_DISCUSSIONS_ADMIN_NO_CONTENT, + COURSE_DISCUSSIONS_ADMIN_PAGE_OF, + COURSE_DISCUSSIONS_ADMIN_POST_REPLY, + COURSE_DISCUSSIONS_ADMIN_PREVIOUS, + COURSE_DISCUSSIONS_ADMIN_REPLY, + COURSE_DISCUSSIONS_ADMIN_REPLY_COUNT, + COURSE_DISCUSSIONS_DELETED, + COURSE_DISCUSSIONS_REPORTED, + COURSE_DISCUSSIONS_TITLE, + LOAD_MORE_TEXT, + MANAGE_COURSES_PAGE_HEADING, + TOAST_TITLE_ERROR, + TOAST_TITLE_SUCCESS, +} from "@ui-config/strings"; +import { ReportReasonDialog } from "./report-reason-dialog"; +import { Button } from "@/components/ui/button"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import { useToast } from "@courselit/components-library"; +import { + ChevronDown, + ChevronRight, + ChevronLeft, + Flag, + MessageSquare, + Reply, + X, + ThumbsUp, +} from "lucide-react"; + +const { permissions } = UIConstants; + +const PAGE_SIZE = 20; + +type DiscussionContent = { + commentId: string; + replyId?: string; + content: TextEditorContent | null; + deleted: boolean; + userId: string; + createdAt: string; + replies?: DiscussionContent[]; + replyCount?: number; + replyNextCursor?: string; + hasMoreReplies?: boolean; + likesCount?: number; + hasLiked?: boolean; + user?: { + name?: string; + email?: string; + avatar?: { + file?: string; + thumbnail?: string; + }; + }; +}; + +const COMMENT_FIELDS = ` + commentId + userId + content + deleted + createdAt + replyCount + replyNextCursor + hasMoreReplies + likesCount + hasLiked + user { + name + email + avatar { + file + thumbnail + } + } + replies { + commentId + replyId + userId + content + deleted + createdAt + likesCount + hasLiked + user { + name + email + avatar { + file + thumbnail + } + } + } +`; + +const REPLY_FIELDS = ` + commentId + replyId + userId + content + deleted + createdAt + likesCount + hasLiked + user { + name + email + avatar { + file + thumbnail + } + } +`; + +export default function DiscussionDetailPage() { + const params = useParams(); + const productId = params?.id as string; + const entityId = params?.entityId as string; + const { product } = useProduct(productId); + const address = useContext(AddressContext); + const { theme } = useContext(ThemeContext); + const { toast } = useToast(); + + // Pagination state + const [allComments, setAllComments] = useState([]); + const [cursors, setCursors] = useState<(string | undefined)[]>([undefined]); + const [currentPage, setCurrentPage] = useState(0); + const [totalPages, setTotalPages] = useState(1); + const [hasMore, setHasMore] = useState(false); + const [loading, setLoading] = useState(false); + const [loadingRepliesFor, setLoadingRepliesFor] = useState(); + + // Reply state + const [posting, setPosting] = useState(false); + const [replyingTo, setReplyingTo] = useState<{ + commentId: string; + parentReplyId?: string; + }>(); + const [replyContent, setReplyContent] = useState( + TextEditorEmptyDoc as TextEditorContent, + ); + const [replyRefresh, setReplyRefresh] = useState(0); + + const [reportDialogOpen, setReportDialogOpen] = useState(false); + const [pendingReport, setPendingReport] = useState<{ + contentType: "COMMENT" | "REPLY"; + contentId: string; + } | null>(null); + + const [highlightedId, setHighlightedId] = useState(""); + const [targetCommentId, setTargetCommentId] = useState(""); + const [targetReplyId, setTargetReplyId] = useState(""); + + useEffect(() => { + if (typeof window === "undefined") return; + + function updateHash() { + const hash = window.location.hash.replace("#", ""); + setHighlightedId(hash); + if (hash.includes("__")) { + const parts = hash.split("__"); + if (parts[0] === "discussion-reply" && parts[1] && parts[2]) { + setTargetCommentId(parts[1]); + setTargetReplyId(parts[2]); + } else { + setTargetCommentId(""); + setTargetReplyId(""); + } + } else if (hash.startsWith("discussion-comment-")) { + setTargetCommentId(hash.replace("discussion-comment-", "")); + setTargetReplyId(""); + } else { + setTargetCommentId(""); + setTargetReplyId(""); + } + } + + updateHash(); + window.addEventListener("hashchange", updateHash); + return () => window.removeEventListener("hashchange", updateHash); + }, [allComments]); + + useEffect(() => { + if (!highlightedId || allComments.length === 0) { + return; + } + + // Use a polling interval to find the element, in case replies are being auto-fetched asynchronously + let attempts = 0; + const interval = window.setInterval(() => { + let domId = highlightedId; + if (highlightedId.includes("__")) { + const parts = highlightedId.split("__"); + if (parts[0] === "discussion-reply" && parts[2]) { + domId = `discussion-reply-${parts[2]}`; + } + } + const element = document.getElementById(domId); + attempts++; + if (element) { + element.scrollIntoView({ block: "center", behavior: "smooth" }); + window.clearInterval(interval); + } else if (attempts > 30) { + // Limit retry to 3 seconds + window.clearInterval(interval); + } + }, 100); + + const clearHighlightTimeout = window.setTimeout(() => { + setHighlightedId(""); + setTargetCommentId(""); + setTargetReplyId(""); + }, 4000); + + return () => { + window.clearInterval(interval); + window.clearTimeout(clearHighlightTimeout); + }; + }, [highlightedId, allComments]); + + const lessonsById = useMemo(() => { + if (!product?.lessons) return {}; + return Object.fromEntries( + product.lessons.map((lesson: any) => [ + lesson.lessonId, + lesson.title, + ]), + ); + }, [product]); + + const targetTitle = lessonsById[entityId] || entityId; + + const breadcrumbs = [ + { label: MANAGE_COURSES_PAGE_HEADING, href: "/dashboard/products" }, + { + label: product ? truncate(product.title || "", 20) || "..." : "...", + href: `/dashboard/product/${productId}`, + }, + { + label: COURSE_DISCUSSIONS_TITLE, + href: `/dashboard/product/${productId}/manage/discussions`, + }, + { label: targetTitle, href: "#" }, + ]; + + useEffect(() => { + if (productId && entityId && address?.backend) { + loadPage(0); + } + }, [productId, entityId, address?.backend]); + + async function graph(payload: Record) { + const fetch = new FetchBuilder() + .setUrl(`${address.backend}/api/graph`) + .setPayload(payload) + .setIsGraphQLEndpoint(true) + .build(); + + return await fetch.exec(); + } + + async function loadPage(pageIndex: number) { + setLoading(true); + const cursor = cursors[pageIndex]; + try { + const response = await graph({ + query: ` + query GetAdminProductDiscussionComments($productId: String!, $entityId: String!, $cursor: String) { + comments: getProductDiscussionComments(productId: $productId, entityType: LESSON, entityId: $entityId, admin: true, cursor: $cursor, limit: ${PAGE_SIZE}, replyPreviewLimit: 3) { + items { ${COMMENT_FIELDS} } + nextCursor + hasMore + } + } + `, + variables: { productId, entityId, cursor }, + }); + const page = response.comments; + setAllComments(page.items); + setCurrentPage(pageIndex); + setHasMore(page.hasMore); + + setCursors((prev) => { + const updated = [...prev]; + if (page.hasMore && !updated[pageIndex + 1]) { + updated[pageIndex + 1] = page.nextCursor; + } + return updated; + }); + + setTotalPages((prev) => + page.hasMore ? Math.max(prev, pageIndex + 2) : pageIndex + 1, + ); + } catch (err: any) { + toast({ + title: TOAST_TITLE_ERROR, + description: err.message, + variant: "destructive", + }); + } finally { + setLoading(false); + } + } + + function openReportDialog( + contentType: "COMMENT" | "REPLY", + contentId: string, + ) { + setPendingReport({ contentType, contentId }); + setReportDialogOpen(true); + } + + async function handleReportSubmit(reason: string) { + setReportDialogOpen(false); + if (!pendingReport) return; + const { contentType, contentId } = pendingReport; + setPendingReport(null); + + try { + await graph({ + query: ` + mutation CreateProductDiscussionReport($productId: String!, $entityId: String!, $contentType: ProductDiscussionContentType!, $contentId: String!, $reason: String!) { + createProductDiscussionReport(productId: $productId, entityType: LESSON, entityId: $entityId, contentType: $contentType, contentId: $contentId, reason: $reason) { + reportId + } + } + `, + variables: { + productId, + entityId, + contentType, + contentId, + reason, + }, + }); + toast({ + title: TOAST_TITLE_SUCCESS, + description: COURSE_DISCUSSIONS_REPORTED, + }); + } catch (err: any) { + toast({ + title: TOAST_TITLE_ERROR, + description: err.message, + variant: "destructive", + }); + } + } + + async function postReply() { + if (!replyingTo || !isTextEditorNonEmpty(replyContent)) return; + setPosting(true); + try { + const response = await graph({ + query: ` + mutation CreateProductDiscussionReply($productId: String!, $entityId: String!, $commentId: String!, $parentReplyId: String, $content: JSONObject!) { + reply: createProductDiscussionReply(productId: $productId, entityType: LESSON, entityId: $entityId, commentId: $commentId, parentReplyId: $parentReplyId, content: $content) { + ${REPLY_FIELDS} + } + } + `, + variables: { + productId, + entityId, + commentId: replyingTo.commentId, + parentReplyId: replyingTo.parentReplyId, + content: replyContent, + }, + }); + setAllComments((current) => + current.map((comment) => + comment.commentId === replyingTo.commentId + ? { + ...comment, + replyCount: (comment.replyCount ?? 0) + 1, + replies: [ + ...(comment.replies || []), + response.reply, + ], + hasMoreReplies: false, + } + : comment, + ), + ); + setReplyingTo(undefined); + setReplyContent(TextEditorEmptyDoc as TextEditorContent); + setReplyRefresh((v) => v + 1); + } catch (err: any) { + toast({ + title: TOAST_TITLE_ERROR, + description: err.message, + variant: "destructive", + }); + } finally { + setPosting(false); + } + } + + async function loadMoreReplies(comment: DiscussionContent) { + if (!comment.replyNextCursor) { + return; + } + setLoadingRepliesFor(comment.commentId); + try { + const response = await graph({ + query: ` + query GetAdminProductDiscussionReplies($commentId: String!, $cursor: String) { + replies: getProductDiscussionReplies(commentId: $commentId, admin: true, cursor: $cursor, limit: 20) { + items { ${REPLY_FIELDS} } + nextCursor + hasMore + } + } + `, + variables: { + commentId: comment.commentId, + cursor: comment.replyNextCursor, + }, + }); + const page = response.replies; + setAllComments((current) => + current.map((item) => { + if (item.commentId !== comment.commentId) { + return item; + } + const existingReplyIds = new Set( + (item.replies || []).map((reply) => reply.replyId), + ); + return { + ...item, + replies: [ + ...(item.replies || []), + ...page.items.filter( + (reply: DiscussionContent) => + !existingReplyIds.has(reply.replyId), + ), + ], + replyNextCursor: page.nextCursor, + hasMoreReplies: page.hasMore, + }; + }), + ); + } catch (err: any) { + toast({ + title: TOAST_TITLE_ERROR, + description: err.message, + variant: "destructive", + }); + } finally { + setLoadingRepliesFor(undefined); + } + } + + async function toggleLike( + contentType: "COMMENT" | "REPLY", + contentId: string, + liked: boolean, + ) { + try { + const response = await graph({ + query: ` + mutation ToggleProductDiscussionLike($productId: String!, $entityType: ProductDiscussionEntityType!, $entityId: String!, $contentType: ProductDiscussionContentType!, $contentId: String!, $liked: Boolean!) { + like: toggleProductDiscussionLike(productId: $productId, entityType: $entityType, entityId: $entityId, contentType: $contentType, contentId: $contentId, liked: $liked) { + contentType + contentId + likesCount + hasLiked + } + } + `, + variables: { + productId, + entityType: "LESSON", + entityId, + contentType, + contentId, + liked, + }, + }); + updateContent(contentType, contentId, { + likesCount: response.like.likesCount, + hasLiked: response.like.hasLiked, + }); + } catch (err: any) { + toast({ + title: TOAST_TITLE_ERROR, + description: err.message, + variant: "destructive", + }); + } + } + + function updateContent( + contentType: "COMMENT" | "REPLY", + contentId: string, + patch: Partial, + ) { + setAllComments((current) => + current.map((comment) => { + if ( + contentType === "COMMENT" && + comment.commentId === contentId + ) { + return { ...comment, ...patch }; + } + + return { + ...comment, + replies: (comment.replies || []).map((reply) => + reply.replyId === contentId + ? { ...reply, ...patch } + : reply, + ), + }; + }), + ); + } + + return ( + <> + +
+ {/* Page header */} +
+

+ {targetTitle} +

+

+ {COURSE_DISCUSSIONS_ADMIN_BROWSE_TARGET} +

+
+ + {/* Comment cards */} +
+ {loading && allComments.length === 0 && ( +
+ Loading… +
+ )} + + {!loading && allComments.length === 0 && ( +
+ No comments yet. +
+ )} + + {allComments.map((comment, idx) => ( + + openReportDialog( + "COMMENT", + comment.commentId, + ) + } + onReportReply={(replyId) => + openReportDialog("REPLY", replyId) + } + onLoadMoreReplies={() => + loadMoreReplies(comment) + } + onStartReply={(parentReplyId) => + setReplyingTo({ + commentId: comment.commentId, + parentReplyId, + }) + } + onCancelReply={() => setReplyingTo(undefined)} + onReplyChange={setReplyContent} + onPostReply={postReply} + targetCommentId={targetCommentId} + targetReplyId={targetReplyId} + /> + ))} +
+ + {/* Pagination */} + {totalPages > 1 && ( +
+ + + + {currentPage + 1}{" "} + {COURSE_DISCUSSIONS_ADMIN_PAGE_OF} {totalPages} + {hasMore ? "+" : ""} + + + +
+ )} +
+
+ + { + setReportDialogOpen(false); + setPendingReport(null); + }} + onSubmit={handleReportSubmit} + /> + + ); +} + +// ─── CommentCard ────────────────────────────────────────────────────────────── + +function CommentCard({ + comment, + index, + address, + theme, + loadingRepliesFor, + replyingTo, + replyContent, + replyRefresh, + posting, + onLike, + onReport, + onReportReply, + onLoadMoreReplies, + onStartReply, + onCancelReply, + onReplyChange, + onPostReply, + targetCommentId, + targetReplyId, +}: { + comment: DiscussionContent; + index: number; + address: any; + theme: any; + loadingRepliesFor?: string; + replyingTo?: { commentId: string; parentReplyId?: string }; + replyContent: TextEditorContent; + replyRefresh: number; + posting: boolean; + onLike: ( + contentType: "COMMENT" | "REPLY", + contentId: string, + liked: boolean, + ) => void; + onReport: () => void; + onReportReply: (replyId: string) => void; + onLoadMoreReplies: () => void; + onStartReply: (parentReplyId?: string) => void; + onCancelReply: () => void; + onReplyChange: (content: TextEditorContent) => void; + onPostReply: () => void; + targetCommentId?: string; + targetReplyId?: string; +}) { + const isTargetComment = + comment.commentId === targetCommentId && !targetReplyId; + + const [open, setOpen] = useState(comment.commentId === targetCommentId); + const [repliesOpen, setRepliesOpen] = useState( + !!targetReplyId && comment.commentId === targetCommentId, + ); + + useEffect(() => { + if (comment.commentId === targetCommentId) { + // eslint-disable-next-line react-hooks/set-state-in-effect + setOpen(true); + if (targetReplyId) { + // eslint-disable-next-line react-hooks/set-state-in-effect + setRepliesOpen(true); + } + } + }, [targetCommentId, targetReplyId, comment.commentId]); + + // Auto-fetch replies if targetReplyId is set but the reply is not in the list + useEffect(() => { + if (comment.commentId === targetCommentId && targetReplyId) { + const hasReply = comment.replies?.some( + (r) => r.replyId === targetReplyId, + ); + if (!hasReply && comment.hasMoreReplies && !loadingRepliesFor) { + onLoadMoreReplies(); + } + } + }, [ + comment.commentId, + targetCommentId, + targetReplyId, + comment.replies, + comment.hasMoreReplies, + loadingRepliesFor, + onLoadMoreReplies, + ]); + + const isReplyingToThis = + replyingTo?.commentId === comment.commentId && + !replyingTo?.parentReplyId; + + const displayName = + comment.user?.name || comment.user?.email || comment.userId; + const initials = getInitials(displayName); + const displayDate = new Date(comment.createdAt).toLocaleDateString(); + const replyCount = comment.replyCount ?? comment.replies?.length ?? 0; + + const preview = getTextPreview(comment.content); + + // Auto-expand replies panel when a reply is submitted + const prevReplyCount = useRef(replyCount); + useEffect(() => { + if (replyCount > prevReplyCount.current) { + // eslint-disable-next-line react-hooks/set-state-in-effect + setRepliesOpen(true); + } + prevReplyCount.current = replyCount; + }, [replyCount]); + + return ( + + + {/* ── Collapsed header (always visible) ── */} + + +
+ {/* Avatar */} + + {comment.user?.avatar && + (comment.user.avatar.thumbnail || + comment.user.avatar.file) && ( + + )} + + {initials} + + + + {/* Meta */} +
+
+ + {displayName} + + + {displayDate} + + {comment.deleted && ( + + {COURSE_DISCUSSIONS_DELETED} + + )} + {replyCount > 0 && ( + + + {replyCount}{" "} + { + COURSE_DISCUSSIONS_ADMIN_REPLY_COUNT + } + + )} +
+ {/* One-line preview when collapsed */} + {!open && ( +

+ {comment.deleted + ? COURSE_DISCUSSIONS_ADMIN_NO_CONTENT + : preview} +

+ )} +
+ + {/* Actions */} +
+ {replyCount > 0 && ( + + + {replyCount} + + )} + +
+
+
+
+ + {/* ── Expanded content ── */} + + + {/* Divider */} +
+ + {/* Full content */} +
+
+ {comment.content ? ( + + ) : ( + + {COURSE_DISCUSSIONS_ADMIN_NO_CONTENT} + + )} +
+ +
+ + {/* Action bar */} + {!comment.deleted && ( +
+ + +
+ )} + + {/* Inline reply composer */} + {isReplyingToThis && ( + + )} + + {/* Replies section */} + {replyCount > 0 && ( +
+ + + + + + + {comment.replies?.map((reply) => ( + + onReportReply( + reply.replyId || "", + ) + } + targetReplyId={targetReplyId} + onLike={() => + onLike( + "REPLY", + reply.replyId || "", + !reply.hasLiked, + ) + } + onStartReply={() => { + if ( + replyingTo?.commentId === + comment.commentId && + replyingTo?.parentReplyId === + reply.replyId + ) { + onCancelReply(); + } else { + onStartReply( + reply.replyId, + ); + } + }} + isReplying={ + replyingTo?.commentId === + comment.commentId && + replyingTo?.parentReplyId === + reply.replyId + } + replyComposer={ + replyingTo?.commentId === + comment.commentId && + replyingTo?.parentReplyId === + reply.replyId ? ( + + ) : undefined + } + /> + ))} + + {comment.hasMoreReplies && ( + + )} + + +
+ )} + + + + + ); +} + +// ─── ReplyComposer ──────────────────────────────────────────────────────────── + +function ReplyComposer({ + address, + content, + refresh, + posting, + onChange, + onSubmit, +}: { + address: any; + content: TextEditorContent; + refresh: number; + posting: boolean; + onChange: (content: TextEditorContent) => void; + onSubmit: () => void; +}) { + const { theme } = useContext(ThemeContext); + const inputStyles = theme.theme?.interactives?.input; + const canSubmit = isTextEditorNonEmpty(content); + + return ( +
+
+ + onChange(value as TextEditorContent) + } + placeholder={COURSE_DISCUSSIONS_ADMIN_REPLY + "…"} + showToolbar={false} + editorClassName="min-h-[48px] max-w-none" + className={`${inputStyles?.border?.radius} ${inputStyles?.border?.width} ${inputStyles?.border?.style} ${inputStyles?.shadow} ${inputStyles?.custom}`} + /> +
+
+ +
+
+ ); +} + +// ─── ReplyRow ───────────────────────────────────────────────────────────────── + +function ReplyRow({ + reply, + theme, + onReport, + targetReplyId, + onLike, + onStartReply, + isReplying, + replyComposer, +}: { + reply: DiscussionContent; + theme: any; + onReport: () => void; + targetReplyId?: string; + onLike: () => void; + onStartReply: () => void; + isReplying: boolean; + replyComposer?: React.ReactNode; +}) { + const displayName = reply.user?.name || reply.user?.email || reply.userId; + const initials = getInitials(displayName); + const displayDate = new Date(reply.createdAt).toLocaleDateString(); + const isTargetReply = targetReplyId === reply.replyId; + + return ( +
+ + {reply.user?.avatar && + (reply.user.avatar.thumbnail || reply.user.avatar.file) && ( + + )} + + {initials} + + +
+
+ {displayName} + + {displayDate} + + {reply.deleted && ( + + {COURSE_DISCUSSIONS_DELETED} + + )} +
+
+ {reply.content ? ( + + ) : ( + + {COURSE_DISCUSSIONS_ADMIN_NO_CONTENT} + + )} +
+ {!reply.deleted && ( +
+ + +
+ )} + {isReplying && replyComposer && ( +
{replyComposer}
+ )} +
+ +
+ ); +} + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function getInitials(name: string): string { + return ( + name + .split(" ") + .map((n: string) => n[0]) + .join("") + .toUpperCase() + .slice(0, 2) || "?" + ); +} + +function getTextPreview(content: TextEditorContent | null): string { + if (!content) return ""; + try { + const walk = (nodes: any[]): string => { + for (const node of nodes) { + if (node.type === "text" && node.text) return node.text; + if (node.content) { + const found = walk(node.content); + if (found) return found; + } + } + return ""; + }; + return walk((content as any).content || []); + } catch { + return ""; + } +} diff --git a/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/manage/discussions/[entityId]/report-reason-dialog.tsx b/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/manage/discussions/[entityId]/report-reason-dialog.tsx new file mode 100644 index 000000000..812cc7580 --- /dev/null +++ b/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/manage/discussions/[entityId]/report-reason-dialog.tsx @@ -0,0 +1,97 @@ +"use client"; + +import { useRef, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Textarea } from "@/components/ui/textarea"; +import { POPUP_CANCEL_ACTION } from "@ui-config/strings"; + +const REPORT_DIALOG_TITLE = "Report Post"; +const REPORT_DIALOG_DESCRIPTION = + "Please provide a reason for reporting this post."; +const REPORT_DIALOG_PLACEHOLDER = "Reason for reporting..."; +const REPORT_DIALOG_SUBMIT = "Submit"; + +interface ReportReasonDialogProps { + isOpen: boolean; + onClose: () => void; + onSubmit: (reason: string) => void; +} + +export function ReportReasonDialog({ + isOpen, + onClose, + onSubmit, +}: ReportReasonDialogProps) { + const [reason, setReason] = useState(""); + const textareaRef = useRef(null); + + function handleSubmit() { + const trimmed = reason.trim(); + if (!trimmed) { + textareaRef.current?.focus(); + return; + } + onSubmit(trimmed); + setReason(""); + } + + function handleClose() { + setReason(""); + onClose(); + } + + return ( + + + + {REPORT_DIALOG_TITLE} + + {REPORT_DIALOG_DESCRIPTION} + + + +
+