diff --git a/AGENTS.md b/AGENTS.md index ee493d0db..01dacd9fe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,6 +22,7 @@ - Preserve the domain-owner invariant: `domain.email` identifies the school owner and public API keys resolve that owner as the API actor. Do not use raw `UserModel.update*`, `UserModel.delete*`, `DomainModel.update*`, migrations, or scripts in a way that changes/deletes the owner user, changes the owner user's permissions, or drifts `domain.email` away from the owner user without adding explicit guards and tests. - Refrain from adding new GraphQL query/mutation unless required. If an existing query/mutation can be modified to implement the feature without making the query's/mutation's boundaries blurry, extend those. - Always keep openapi.mjs files in sync with the actual implementation of the API endpoints. +- While adding a new collection, always confirm how the deletion/cleanup will work for it. ### Workspace map (core modules): diff --git a/apps/docs-new/next-env.d.ts b/apps/docs-new/next-env.d.ts index c4b7818fb..9edff1c7c 100644 --- a/apps/docs-new/next-env.d.ts +++ b/apps/docs-new/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/dev/types/routes.d.ts"; +import "./.next/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/queue/src/notifications/services/channels/__tests__/email.test.ts b/apps/queue/src/notifications/services/channels/__tests__/email.test.ts index 03dab75d7..441964ccb 100644 --- a/apps/queue/src/notifications/services/channels/__tests__/email.test.ts +++ b/apps/queue/src/notifications/services/channels/__tests__/email.test.ts @@ -43,6 +43,7 @@ function makePayload(overrides: Partial = {}): any { email: "student@example.com", unsubscribeToken: "unsubscribe-token", subscribedToUpdates: true, + permissions: ["course:manage_any"], }, activityType: Constants.ActivityType.ENROLLED, entityId: "entity-id", @@ -70,6 +71,12 @@ describe("EmailChannel", () => { it("renders a notification email with actor avatar, CTA, footer unsubscribe, branding, and unsubscribe headers", async () => { await new EmailChannel().send(makePayload()); + expect(mockedGetNotificationMessageAndHref).toHaveBeenCalledWith( + expect.objectContaining({ + recipientPermissions: ["course:manage_any"], + }), + ); + expect(mockedAddMailJob).toHaveBeenCalledTimes(1); const mail = mockedAddMailJob.mock.calls[0][0]; diff --git a/apps/queue/src/notifications/services/channels/email.ts b/apps/queue/src/notifications/services/channels/email.ts index 922db3d94..e98afdb17 100644 --- a/apps/queue/src/notifications/services/channels/email.ts +++ b/apps/queue/src/notifications/services/channels/email.ts @@ -32,6 +32,7 @@ export class EmailChannel implements NotificationChannel { entityId: payload.entityId, actorName, recipientUserId: payload.recipient.userId, + recipientPermissions: payload.recipient.permissions || [], entityTargetId: payload.entityTargetId, metadata: payload.metadata, hrefPrefix: getSiteUrl(payload.domain), diff --git a/apps/web/.migrations/21-06-26_12-30-seed-product-discussion-notification-preferences.js b/apps/web/.migrations/21-06-26_12-30-seed-product-discussion-notification-preferences.js new file mode 100644 index 000000000..7db67d4d0 --- /dev/null +++ b/apps/web/.migrations/21-06-26_12-30-seed-product-discussion-notification-preferences.js @@ -0,0 +1,131 @@ +/** + * Seeds granular product discussion notification preferences for existing + * users. + * + * Usage: DB_CONNECTION_STRING= node 21-06-26_12-30-seed-product-discussion-notification-preferences.js + */ +import mongoose from "mongoose"; + +const DB_CONNECTION_STRING = process.env.DB_CONNECTION_STRING; +if (!DB_CONNECTION_STRING) { + throw new Error("DB_CONNECTION_STRING is not set"); +} + +const PRODUCT_DISCUSSION_ACTIVITY_TYPES = [ + "course_discussion_comment_created", + "course_discussion_reacted", +]; +const DEFAULT_CHANNELS = ["app", "email"]; +const BATCH_SIZE = 500; + +const UserSchema = new mongoose.Schema({ + domain: { type: mongoose.Schema.Types.ObjectId, required: true }, + userId: { type: String, required: true }, +}); + +const NotificationPreferenceSchema = new mongoose.Schema( + { + domain: { type: mongoose.Schema.Types.ObjectId, required: true }, + userId: { type: String, required: true }, + activityType: { type: String, required: true }, + channels: { type: [String], default: [] }, + }, + { + timestamps: true, + }, +); + +NotificationPreferenceSchema.index( + { + domain: 1, + userId: 1, + activityType: 1, + }, + { + unique: true, + }, +); + +const User = mongoose.model("User", UserSchema); +const NotificationPreference = mongoose.model( + "NotificationPreference", + NotificationPreferenceSchema, +); + +function getPreferenceOps({ domain, userId }) { + return PRODUCT_DISCUSSION_ACTIVITY_TYPES.map((activityType) => ({ + updateOne: { + filter: { + domain, + userId, + activityType, + }, + update: { + $setOnInsert: { + domain, + userId, + activityType, + channels: DEFAULT_CHANNELS, + }, + }, + upsert: true, + }, + })); +} + +async function flushBatch(batch) { + if (!batch.length) { + return; + } + + await NotificationPreference.bulkWrite(batch, { ordered: false }); + batch.length = 0; +} + +async function seedNotificationPreferences() { + const cursor = User.find( + {}, + { + _id: 0, + domain: 1, + userId: 1, + }, + ) + .lean() + .cursor(); + + let processedUsers = 0; + let totalOps = 0; + const batch = []; + + for await (const user of cursor) { + const ops = getPreferenceOps({ + domain: user.domain, + userId: user.userId, + }); + + batch.push(...ops); + processedUsers += 1; + totalOps += ops.length; + + if (batch.length >= BATCH_SIZE) { + await flushBatch(batch); + } + } + + await flushBatch(batch); + + console.log( + `Seeded product discussion preferences for ${processedUsers} users (${totalOps} idempotent upserts).`, + ); +} + +(async () => { + try { + await mongoose.connect(DB_CONNECTION_STRING); + + await seedNotificationPreferences(); + } finally { + await mongoose.connection.close(); + } +})(); 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 6f05a1472..99e6d71a4 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 @@ -1,10 +1,30 @@ +let mockPathname = "/course/test-course/course-1"; +let mockSearchParams = new URLSearchParams(); +let mockIsEnrolled = true; + jest.mock("next/navigation", () => ({ - usePathname: () => "/course/test-course/course-1", - useSearchParams: () => new URLSearchParams(), + usePathname: () => mockPathname, + useRouter: () => ({ push: jest.fn() }), + useSearchParams: () => mockSearchParams, })); jest.mock("next/link", () => { - return ({ children }: { children: React.ReactNode }) => children; + function MockLink({ + children, + href, + ...props + }: { + children: React.ReactNode; + href: string; + }) { + return ( + + {children} + + ); + } + + return MockLink; }); jest.mock("@components/contexts", () => { @@ -25,6 +45,10 @@ jest.mock("@components/contexts", () => { ThemeContext: React.createContext({ theme: {}, }), + AddressContext: React.createContext({ + backend: "http://localhost:3000", + frontend: "http://localhost:3000", + }), }; }); @@ -41,7 +65,12 @@ jest.mock("@components/ui/sidebar", () => ({ SidebarMenuItem: ({ children }: any) => children, SidebarProvider: ({ children }: any) => children, SidebarTrigger: () => null, - useSidebar: () => ({ openMobile: false }), + useSidebar: () => ({ + open: false, + openMobile: false, + isMobile: false, + setOpenMobile: jest.fn(), + }), })); jest.mock("@components/ui/tooltip", () => ({ @@ -63,10 +92,21 @@ jest.mock("@components/ui/button", () => ({ jest.mock("@components/admin/next-theme-switcher", () => () => null); +jest.mock("@/components/public/product-discussions/panel", () => () => null); + 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, @@ -77,7 +117,9 @@ jest.mock("lucide-react", () => ({ BookOpen: () => null, ChevronRight: () => null, Clock: () => null, + Folder: () => null, LogOutIcon: () => null, + MessageSquare: () => null, })); jest.mock("@courselit/page-primitives", () => ({ @@ -86,7 +128,7 @@ jest.mock("@courselit/page-primitives", () => ({ jest.mock("@ui-lib/utils", () => ({ formattedLocaleDate: () => "Mar 22, 2026", - isEnrolled: () => true, + isEnrolled: () => mockIsEnrolled, isLessonCompleted: () => false, })); @@ -95,12 +137,16 @@ import ProductPage, { generateSideBarItems } from "../layout-with-sidebar"; import { CourseFrontend } from "../helpers"; import constants from "@/config/constants"; import { render, screen } from "@testing-library/react"; +import { ProfileContext } from "@components/contexts"; describe("generateSideBarItems", () => { const originalDateNow = Date.now; const originalRelativeDripUnitInMillis = constants.relativeDripUnitInMillis; beforeEach(() => { + mockPathname = "/course/test-course/course-1"; + mockSearchParams = new URLSearchParams(); + mockIsEnrolled = true; Date.now = jest.fn(() => new Date("2026-03-22T00:00:00.000Z").getTime(), ); @@ -794,9 +840,59 @@ describe("generateSideBarItems", () => { expect(items[1].badge).toBeUndefined(); expect(items[1].items?.[0].icon).toBeUndefined(); }); + + it("does not bypass learner drip rules for course managers outside preview mode", () => { + const course = { + title: "Course", + description: "", + featuredImage: undefined, + updatedAt: new Date().toISOString(), + creatorId: "admin-1", + slug: "test-course", + cost: 0, + courseId: "course-1", + tags: [], + paymentPlans: [], + defaultPaymentPlan: "", + firstLesson: "lesson-1", + isPreview: false, + groups: [ + { + id: "group-1", + name: "Dripped Section", + lessons: [], + drip: { + status: true, + type: Constants.dripType[0].split("-")[0].toUpperCase(), + delayInMillis: 2, + }, + }, + ], + } as unknown as CourseFrontend; + + const profile = { + userId: "admin-1", + permissions: [constants.permissions.manageCourse], + purchases: [], + } as unknown as Profile; + + const items = generateSideBarItems( + course, + profile, + "/course/test-course/course-1", + ); + + expect(items[1].badge?.text).toBe("2 days"); + }); }); describe("Course viewer layout", () => { + beforeEach(() => { + mockPathname = "/course/test-course/course-1"; + mockSearchParams = new URLSearchParams(); + mockIsEnrolled = true; + }); + it("renders the preview badge in the viewer header when preview mode is active", () => { const course = { title: "Course", @@ -823,4 +919,310 @@ describe("Course viewer layout", () => { expect(screen.getByText("Preview")).toBeInTheDocument(); }); + + 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, + }); + }); + + it("hides discussion sidebar and header actions for guests", () => { + mockPathname = "/course/test-course/course-1/lesson-1"; + 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", + isPreview: false, + groups: [], + discussions: true, + } as unknown as CourseFrontend; + + expect( + generateSideBarItems( + course, + {} as Profile, + "/course/test-course/course-1/discussions", + ).some((item) => item.title === "Discussions"), + ).toBe(false); + + render( + + +
Public lesson body
+
+
, + ); + + expect(screen.getByText("Public lesson body")).toBeInTheDocument(); + expect(screen.queryByLabelText("Discussions")).not.toBeInTheDocument(); + }); + + it("hides discussion sidebar and header actions for logged-in non-enrolled users", () => { + mockPathname = "/course/test-course/course-1/lesson-1"; + mockIsEnrolled = false; + 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", + isPreview: false, + groups: [], + discussions: true, + } as unknown as CourseFrontend; + const profile = { + userId: "user-1", + purchases: [], + } as unknown as Profile; + + expect( + generateSideBarItems( + course, + profile, + "/course/test-course/course-1/discussions", + ).some((item) => item.title === "Discussions"), + ).toBe(false); + + render( + +
Public lesson body
+
, + ); + + expect(screen.getByText("Public lesson body")).toBeInTheDocument(); + expect(screen.queryByLabelText("Discussions")).not.toBeInTheDocument(); + }); + + it("does not show a discussions header action on the course overview page", () => { + 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", + isPreview: false, + groups: [], + discussions: true, + } as unknown as CourseFrontend; + + render( + +
Course body
+
, + ); + + expect(screen.queryByLabelText("Discussions")).not.toBeInTheDocument(); + }); + + it("does not show a discussions header action on the discussions hub page", () => { + mockPathname = "/course/test-course/course-1/discussions"; + 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", + isPreview: false, + groups: [], + discussions: true, + } as unknown as CourseFrontend; + + render( + +
Discussions body
+
, + ); + + expect(screen.queryByLabelText("Discussions")).not.toBeInTheDocument(); + }); + + it("opens the discussion panel from lesson pages", () => { + mockPathname = "/course/test-course/course-1/lesson-1"; + 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", + isPreview: false, + groups: [], + discussions: true, + } as unknown as CourseFrontend; + + render( + +
Lesson body
+
, + ); + + expect(screen.getByLabelText("Discussions")).toHaveAttribute( + "href", + "/course/test-course/course-1/lesson-1?discussion=open", + ); + }); + + it("preserves preview session params when opening the discussion panel", () => { + mockPathname = "/course/test-course/course-1/lesson-1"; + mockSearchParams = new URLSearchParams( + "preview=true&returnTo=%2Fdashboard%2Fproduct%2Fcourse-1", + ); + 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", + isPreview: true, + groups: [], + discussions: true, + } as unknown as CourseFrontend; + + render( + +
Lesson body
+
, + ); + + expect(screen.getByLabelText("Discussions")).toHaveAttribute( + "href", + "/course/test-course/course-1/lesson-1?preview=true&returnTo=%2Fdashboard%2Fproduct%2Fcourse-1&discussion=open", + ); + }); + + it("preserves the open discussion panel while navigating lesson links", () => { + mockPathname = "/course/test-course/course-1/lesson-1"; + mockSearchParams = new URLSearchParams("discussion=open"); + 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", + isPreview: false, + groups: [ + { + id: "group-1", + name: "Section", + lessons: [ + { + lessonId: "lesson-1", + title: "Lesson 1", + requiresEnrollment: false, + }, + { + lessonId: "lesson-2", + title: "Lesson 2", + requiresEnrollment: false, + }, + ], + }, + ], + discussions: true, + } as unknown as CourseFrontend; + + const { container } = render( + +
Lesson body
+
, + ); + + expect( + container.querySelector( + 'a[href="/course/test-course/course-1/lesson-2?discussion=open"]', + ), + ).toBeInTheDocument(); + }); }); diff --git a/apps/web/app/(with-contexts)/course/[slug]/[id]/__tests__/page.test.tsx b/apps/web/app/(with-contexts)/course/[slug]/[id]/__tests__/page.test.tsx new file mode 100644 index 000000000..e3a2602d5 --- /dev/null +++ b/apps/web/app/(with-contexts)/course/[slug]/[id]/__tests__/page.test.tsx @@ -0,0 +1,196 @@ +import React from "react"; +import { render, screen, waitFor } from "@testing-library/react"; +import ProductPage from "../page"; +import { + AddressContext, + ProfileContext, + SiteInfoContext, + ThemeContext, +} from "@components/contexts"; +import { getProduct } from "../helpers"; +import { getUserProfile } from "@/app/(with-contexts)/helpers"; + +jest.mock("@components/contexts", () => { + const React = require("react"); + return { + AddressContext: React.createContext({ + backend: "", + frontend: "", + }), + ProfileContext: React.createContext({ + profile: null, + setProfile: undefined, + }), + SiteInfoContext: React.createContext({}), + ThemeContext: React.createContext({ + theme: {}, + }), + }; +}); + +jest.mock("next/navigation", () => ({ + useSearchParams: () => new URLSearchParams(), +})); + +jest.mock("next/link", () => { + function MockLink({ + children, + href, + className, + }: { + children: React.ReactNode; + href: string; + className?: string; + }) { + return ( + + {children} + + ); + } + + return MockLink; +}); + +jest.mock("../helpers", () => ({ + getProduct: jest.fn(), +})); + +jest.mock("@/app/(with-contexts)/helpers", () => ({ + getUserProfile: jest.fn(), +})); + +jest.mock("@courselit/components-library", () => ({ + Link: ({ children, href, className }: any) => ( + + {children} + + ), + getSymbolFromCurrency: () => "$", + Image: () => null, +})); + +jest.mock("@courselit/page-blocks", () => ({ + TextRenderer: () => null, +})); + +jest.mock("@components/table-of-content", () => ({ + TableOfContent: () => null, +})); + +jest.mock( + "@components/public/base-layout/template/widget-error-boundary", + () => { + function WidgetErrorBoundary({ + children, + }: { + children: React.ReactNode; + }) { + return <>{children}; + } + + return WidgetErrorBoundary; + }, +); + +jest.mock("@courselit/page-primitives", () => ({ + Button: ({ children }: any) => , + Header1: ({ children }: any) =>

{children}

, +})); + +jest.mock("@courselit/icons", () => ({ + ArrowRight: () => null, +})); + +jest.mock("lucide-react", () => ({ + BadgeCheck: () => null, +})); + +jest.mock("@courselit/text-editor", () => ({ + emptyDoc: { type: "doc", content: [] }, +})); + +function getParams() { + const params = Promise.resolve({ + slug: "course-slug", + id: "course-1", + }) as any; + params.status = "fulfilled"; + params.value = { + slug: "course-slug", + id: "course-1", + }; + return params; +} + +function renderPage(profile?: Record) { + const setProfile = jest.fn(); + + return render( + + + + + Loading}> + + + + + + , + ); +} + +describe("Course introduction page", () => { + beforeEach(() => { + jest.clearAllMocks(); + (getProduct as jest.Mock).mockResolvedValue({ + title: "Course intro", + description: JSON.stringify({ type: "doc", content: [] }), + courseId: "course-1", + slug: "course-slug", + cost: 0, + costType: "free", + isPreview: false, + firstLesson: "lesson-1", + }); + (getUserProfile as jest.Mock).mockResolvedValue(undefined); + }); + + it("shows price and checkout CTA for anonymous viewers", async () => { + renderPage(); + + await waitFor(() => { + expect( + screen.getByRole("heading", { name: "Course intro" }), + ).toBeInTheDocument(); + }); + + expect(document.body).toHaveTextContent("$0"); + expect(screen.getByText("free")).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "Buy now" })).toHaveAttribute( + "href", + "/checkout?type=course&id=course-1", + ); + }); + + it("does not show price and checkout CTA for enrolled viewers", async () => { + renderPage({ + userId: "learner-1", + purchases: [{ courseId: "course-1" }], + permissions: [], + }); + + await waitFor(() => { + expect( + screen.getByRole("heading", { name: "Course intro" }), + ).toBeInTheDocument(); + }); + + expect( + screen.queryByRole("link", { name: "Buy now" }), + ).not.toBeInTheDocument(); + }); +}); 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..e04f51255 --- /dev/null +++ b/apps/web/app/(with-contexts)/course/[slug]/[id]/discussions/__tests__/page.test.tsx @@ -0,0 +1,355 @@ +import React from "react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import CourseDiscussionsPage from "../page"; +import { + AddressContext, + ProfileContext, + ThemeContext, +} from "@components/contexts"; +import { FetchBuilder } from "@courselit/utils"; +import { getProduct } from "../../helpers"; + +const mockExec = jest.fn(); +const payloads: Record[] = []; +const mockRouterReplace = jest.fn(); + +jest.mock("next/link", () => { + function MockNextLink({ + children, + href, + className, + }: { + children: React.ReactNode; + href: string; + className?: string; + }) { + return ( + + {children} + + ); + } + + return MockNextLink; +}); + +const mockSearchParams = jest.fn(() => new URLSearchParams()); + +jest.mock("next/navigation", () => ({ + useSearchParams: () => mockSearchParams(), + useRouter: () => ({ + replace: mockRouterReplace, + }), +})); + +jest.mock("@components/contexts", () => { + const React = require("react"); + return { + AddressContext: React.createContext({ + backend: "", + frontend: "", + }), + ProfileContext: React.createContext({ + profile: { + userId: "learner-1", + }, + }), + 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, className }: 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", () => ({ + truncate: (value: string) => value, + 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( + profile: Record | null = { + userId: "learner-1", + purchases: [{ courseId: "course-1" }], + }, +) { + 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; + mockSearchParams.mockReturnValue(new URLSearchParams()); + (getProduct as jest.Mock).mockResolvedValue({ + title: "Course with discussions", + groups: [ + { + lessons: [ + { + lessonId: "lesson-1", + title: "Text lesson", + }, + { + lessonId: "lesson-2", + title: "Video lesson", + }, + ], + }, + ], + }); + 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( + screen.getByRole("heading", { name: "Discussions" }), + ).toHaveClass("max-w-full", "break-all"); + expect(FetchBuilder).toHaveBeenCalled(); + expect(getProduct).toHaveBeenCalledWith( + "course-1", + "http://localhost:3000", + false, + ); + expect(payloads[0].variables).toEqual({ + productId: "course-1", + cursor: undefined, + preview: false, + }); + expect(screen.getByText("3")).toBeInTheDocument(); + expect(screen.getByText("Text lesson").closest("a")).toHaveAttribute( + "href", + "/course/course-slug/course-1/lesson-1?discussion=open", + ); + }); + + it("preserves course viewer preview session params while browsing discussions", async () => { + (getProduct as jest.Mock).mockResolvedValueOnce({ + title: "Course with discussions", + isPreview: true, + groups: [ + { + lessons: [ + { + lessonId: "lesson-1", + title: "Text lesson", + }, + ], + }, + ], + }); + mockSearchParams.mockReturnValue( + new URLSearchParams( + "preview=true&returnTo=%2Fdashboard%2Fproduct%2Fcourse-1", + ), + ); + + renderPage(); + + await waitFor(() => { + expect(screen.getByText("Text lesson")).toBeInTheDocument(); + }); + + expect(getProduct).toHaveBeenCalledWith( + "course-1", + "http://localhost:3000", + true, + ); + expect(payloads[0].variables.preview).toBe(true); + expect( + screen.getByText("Course with discussions").closest("a"), + ).toHaveAttribute( + "href", + "/course/course-slug/course-1?preview=true&returnTo=%2Fdashboard%2Fproduct%2Fcourse-1", + ); + expect(screen.getByText("Text lesson").closest("a")).toHaveAttribute( + "href", + "/course/course-slug/course-1/lesson-1?discussion=open&preview=true&returnTo=%2Fdashboard%2Fproduct%2Fcourse-1", + ); + }); + + 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", + preview: false, + }); + }); + + it("does not fetch or render discussion summaries for guests", async () => { + renderPage(null); + + await waitFor(() => { + expect(mockRouterReplace).toHaveBeenCalledWith( + "/course/course-slug/course-1", + ); + }); + expect(getProduct).not.toHaveBeenCalled(); + expect(FetchBuilder).not.toHaveBeenCalled(); + expect( + screen.queryByRole("heading", { name: "Discussions" }), + ).not.toBeInTheDocument(); + }); + + it("does not fetch or render discussion summaries for logged-in non-enrolled users", async () => { + renderPage({ + userId: "learner-1", + purchases: [], + }); + + await waitFor(() => { + expect(mockRouterReplace).toHaveBeenCalledWith( + "/course/course-slug/course-1", + ); + }); + expect(getProduct).not.toHaveBeenCalled(); + expect(FetchBuilder).not.toHaveBeenCalled(); + expect( + screen.queryByRole("heading", { name: "Discussions" }), + ).not.toBeInTheDocument(); + }); + + it("redirects logged-in non-enrolled users who manually add preview mode", async () => { + mockSearchParams.mockReturnValue(new URLSearchParams("preview=true")); + + renderPage({ + userId: "learner-1", + purchases: [], + }); + + await waitFor(() => { + expect(getProduct).toHaveBeenCalledWith( + "course-1", + "http://localhost:3000", + true, + ); + }); + await waitFor(() => { + expect(mockRouterReplace).toHaveBeenCalledWith( + "/course/course-slug/course-1", + ); + }); + expect(FetchBuilder).not.toHaveBeenCalled(); + }); +}); 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..54d4f5014 --- /dev/null +++ b/apps/web/app/(with-contexts)/course/[slug]/[id]/discussions/page.tsx @@ -0,0 +1,271 @@ +"use client"; + +import { useContext, useEffect, useMemo, useState, use } from "react"; +import { + AddressContext, + ProfileContext, + ThemeContext, +} from "@components/contexts"; +import { FetchBuilder, truncate } from "@courselit/utils"; +import { getProduct } from "../helpers"; +import { isEnrolled } from "@ui-lib/utils"; +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"; +import { useRouter, useSearchParams } from "next/navigation"; +import { + appendCourseViewerSessionParamsToHref, + getCourseViewerSessionParams, +} from "@/lib/course-viewer-session-params"; + +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 { profile } = useContext(ProfileContext); + const { theme } = useContext(ThemeContext); + const { replace } = useRouter(); + const searchParams = useSearchParams(); + const viewerSessionParams = getCourseViewerSessionParams(searchParams); + const isViewerEnrolled = Boolean( + profile?.userId && + isEnrolled(id, profile as NonNullable), + ); + const introHref = appendCourseViewerSessionParamsToHref( + `/course/${slug}/${id}`, + { returnTo: viewerSessionParams.returnTo }, + ); + 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>({}); + const [canUseDiscussions, setCanUseDiscussions] = useState(false); + const [effectivePreview, setEffectivePreview] = useState(false); + + useEffect(() => { + setCanUseDiscussions(false); + setEffectivePreview(false); + setSummaries([]); + setNextCursor(undefined); + setHasMore(false); + setLessonsById({}); + + if (!id || !address?.backend) return; + if (!profile?.userId) { + replace(introHref); + return; + } + if (!isViewerEnrolled && !viewerSessionParams.preview) { + replace(introHref); + return; + } + + let cancelled = false; + getProduct(id, address.backend, Boolean(viewerSessionParams.preview)) + .then((product) => { + if (cancelled) return; + const isEffectivePreview = Boolean(product.isPreview); + if (!isViewerEnrolled && !isEffectivePreview) { + replace(introHref); + return; + } + + setCanUseDiscussions(true); + setEffectivePreview(isEffectivePreview); + setCourseTitle(product.title || ""); + const lessons = Object.fromEntries( + product.groups + .flatMap((group) => group.lessons) + .map((lesson) => [lesson.lessonId, lesson.title]), + ); + setLessonsById(lessons); + loadSummaries(undefined, isEffectivePreview); + }) + .catch(() => { + if (!cancelled) { + replace(introHref); + } + }); + + return () => { + cancelled = true; + }; + }, [ + id, + address?.backend, + viewerSessionParams.preview, + viewerSessionParams.returnTo, + profile?.userId, + isViewerEnrolled, + introHref, + replace, + ]); + + async function loadSummaries( + cursor?: string, + previewOverride = effectivePreview, + ) { + if (!canUseDiscussions && cursor) { + return; + } + + setLoading(true); + try { + const response = await graph({ + query: ` + query GetProductDiscussionSummaries($productId: String!, $preview: Boolean, $cursor: String) { + summaries: getProductDiscussionSummaries(productId: $productId, preview: $preview, cursor: $cursor, limit: 20) { + items { + entityId + totalCount + commentsCount + repliesCount + lastActivityAt + } + nextCursor + hasMore + } + } + `, + variables: { + productId: id, + preview: previewOverride, + cursor, + }, + }); + + const page = response.summaries; + setSummaries((current) => + cursor ? [...current, ...page.items] : page.items, + ); + setNextCursor(page.nextCursor); + setHasMore(page.hasMore); + } finally { + setLoading(false); + } + } + + 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(); + } + + if (!canUseDiscussions) { + return null; + } + + return ( +
+
+
+
+ {courseTitle && ( + + + {courseTitle} + + )} + + {COURSE_DISCUSSIONS_TITLE} + +
+ {!loading && rows.length === 0 && ( + + {COURSE_DISCUSSIONS_EMPTY} + + )} +
+ {rows.map((summary) => ( + + + + + {truncate(summary.title, 50)} + + + + {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 a65822c84..b937c4dd8 100644 --- a/apps/web/app/(with-contexts)/course/[slug]/[id]/helpers.ts +++ b/apps/web/app/(with-contexts)/course/[slug]/[id]/helpers.ts @@ -6,6 +6,7 @@ export type CourseFrontend = CourseWithoutGroups & { groups: GroupWithLessons[]; firstLesson: string; isPreview: boolean; + discussions: boolean; }; export type GroupWithLessons = Group & { lessons: Lesson[] }; @@ -49,6 +50,7 @@ export const getProduct = async ( cost, courseId, isPreview, + discussions, groups { id, name, @@ -134,6 +136,7 @@ export function formatCourse( isPreview: Boolean( (post as Course & { isPreview?: boolean }).isPreview, ), + discussions: Boolean(post.discussions), 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 f3c5fdf4d..995ca6667 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, @@ -12,12 +12,14 @@ import { BTN_EXIT_COURSE_TOOLTIP, PREVIEW_COURSE_MENU_ITEM, 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 { @@ -33,12 +35,20 @@ import { SidebarMenuItem, SidebarProvider, SidebarTrigger, + useSidebar, } from "@components/ui/sidebar"; import { Image } from "@courselit/components-library"; import Link from "next/link"; import { 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, @@ -50,15 +60,52 @@ import { CollapsibleContent, CollapsibleTrigger, } from "@components/ui/collapsible"; -import { usePathname, useSearchParams } from "next/navigation"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { Caption } from "@courselit/page-primitives"; import NextThemeSwitcher from "@components/admin/next-theme-switcher"; import { appendCourseViewerSessionParamsToHref, getCourseViewerSessionParams, getCourseViewerReturnPath, + setHrefQueryParam, } from "@/lib/course-viewer-session-params"; import { Badge } from "@/components/ui/badge"; +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, @@ -68,13 +115,29 @@ export default function ProductPage({ children: React.ReactNode; }) { const { profile } = useContext(ProfileContext); + const pathname = usePathname(); const searchParams = useSearchParams(); const viewerSessionParams = getCourseViewerSessionParams(searchParams); const exitPath = getCourseViewerReturnPath(viewerSessionParams.returnTo); - - if (!profile) { - return null; - } + 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"; + const canUseDiscussions = + Boolean(profile?.userId) && + product.discussions && + (product.isPreview || isEnrolled(product.courseId, profile)); + const showDiscussionsAction = canUseDiscussions && isActualLessonPage; + const discussionsHref = getDiscussionHref({ + pathname, + searchParams, + isDiscussionOpen, + }); return ( -
+
{product.isPreview && ( @@ -100,6 +163,33 @@ export default function ProductPage({ {PREVIEW_COURSE_MENU_ITEM} )} + {showDiscussionsAction && ( + + + + + + {SIDEBAR_TEXT_COURSE_DISCUSSIONS} + + + )} @@ -115,12 +205,85 @@ export default function ProductPage({
-
{children}
+
+ {children} +
+ {isActualLessonPage && canUseDiscussions && ( + { + 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 || ""); + }} + /> + + + )} ); } +function getDiscussionHref({ + pathname, + searchParams, + isDiscussionOpen, +}: { + pathname: string; + searchParams: ReturnType; + isDiscussionOpen: boolean; +}) { + const currentSearch = searchParams?.toString() || ""; + return setHrefQueryParam( + currentSearch ? `${pathname}?${currentSearch}` : pathname, + "discussion", + isDiscussionOpen ? null : "open", + ); +} + export function AppSidebar({ course, profile, @@ -135,7 +298,7 @@ export function AppSidebar({ const pathname = usePathname(); const sideBarItems = generateSideBarItems( course, - profile as Profile, + profile, pathname, viewerSessionParams, ); @@ -187,19 +350,22 @@ export function AppSidebar({ - - - {truncate( - item.title, - item.badge - ? 15 - : 26, - )} - - - {item.title} - - + + + + + {truncate( + item.title, + item.badge + ? 15 + : 26, + )} + + + {item.title} + + + {item.badge?.text && ( @@ -301,6 +467,7 @@ export function AppSidebar({ className="text-foreground" > + {item.icon} {item.title} @@ -330,6 +497,7 @@ export function AppSidebar({ interface SidebarItem { title: string; href: string; + icon?: ReactNode; badge?: { text: string; description: string; @@ -346,7 +514,7 @@ interface SidebarItem { export function generateSideBarItems( course: CourseFrontend, - profile: Profile, + profile: Partial, pathname: string, viewerSessionParams?: ReturnType, ): SidebarItem[] { @@ -360,10 +528,29 @@ export function generateSideBarItems( `/course/${course.slug}/${course.courseId}`, viewerSessionParams, ), + icon: , isActive: pathname === `/course/${course.slug}/${course.courseId}`, }, ]; + if ( + course.discussions && + profile?.userId && + (course.isPreview || isEnrolled(course.courseId, profile as Profile)) + ) { + items.push({ + title: SIDEBAR_TEXT_COURSE_DISCUSSIONS, + href: appendCourseViewerSessionParamsToHref( + `/course/${course.slug}/${course.courseId}/discussions`, + viewerSessionParams, + ), + icon: , + isActive: + pathname === + `/course/${course.slug}/${course.courseId}/discussions`, + }); + } + let lastGroupDripDateInMillis = getRelativeDripAnchorMillis( course, profile, @@ -397,11 +584,11 @@ export function generateSideBarItems( lessonStatusIcon = lesson.requiresEnrollment ? ( ) : undefined; - } else if (isEnrolled(course.courseId, profile)) { + } else if (isEnrolled(course.courseId, profile as Profile)) { lessonStatusIcon = isLessonCompleted({ courseId: course.courseId, lessonId: lesson.lessonId, - profile, + profile: profile as Profile, }) ? ( ) : ( @@ -435,7 +622,7 @@ export function generateSideBarItems( group.drip.type === Constants.dripType[0].split("-")[0].toUpperCase() && !isPreview && - !isGroupAccessibleToUser(course, profile as Profile, group) + !isGroupAccessibleToUser(course, profile, group) ) { lastGroupDripDateInMillis += group?.drip?.delayInMillis ?? 0; } @@ -453,7 +640,7 @@ function getDripLabel({ }: { course: CourseFrontend; group: GroupWithLessons; - profile: Profile; + profile: Partial; lastGroupDripDateInMillis: number; isPreview: boolean; }): { text: string; description: string } | undefined { @@ -461,10 +648,7 @@ function getDripLabel({ return undefined; } - if ( - group.drip?.status && - isGroupAccessibleToUser(course, profile as Profile, group) - ) { + if (group.drip?.status && isGroupAccessibleToUser(course, profile, group)) { return undefined; } @@ -483,8 +667,9 @@ function getDripLabel({ ); availableLabel = daysUntilAvailable && - !isGroupAccessibleToUser(course, profile as Profile, group) - ? isEnrolled(course.courseId, profile) + !isGroupAccessibleToUser(course, profile, group) + ? profile?.userId && + isEnrolled(course.courseId, profile as Profile) ? `Available in ${daysUntilAvailable} days` : `Available ${daysUntilAvailable} days after enrollment` : ""; @@ -512,9 +697,9 @@ function getDripLabel({ function getRelativeDripAnchorMillis( course: CourseFrontend, - profile: Profile, + profile: Partial, ): number { - const purchase = profile.purchases?.find( + const purchase = profile?.purchases?.find( (purchase) => purchase.courseId === course.courseId, ); @@ -554,12 +739,12 @@ function normalizeTimestamp(value: string | number | Date): number { export function isGroupAccessibleToUser( course: CourseFrontend, - profile: Profile, + profile: Partial, group: GroupWithLessons, ): boolean { if (!group.drip || !group.drip.status) return true; - if (!Array.isArray(profile.purchases)) return false; + if (!Array.isArray(profile?.purchases)) return false; const groupId = getGroupId(group); if (!groupId) return false; 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 10c3dc7a7..3e618ff90 100644 --- a/apps/web/app/(with-contexts)/course/[slug]/[id]/page.tsx +++ b/apps/web/app/(with-contexts)/course/[slug]/[id]/page.tsx @@ -8,8 +8,6 @@ import { ENROLL_BUTTON_TEXT, BTN_VIEW_CERTIFICATE, } from "@ui-config/strings"; -import { checkPermission } from "@courselit/utils"; -import { Profile, UIConstants } from "@courselit/common-models"; import { Link, getSymbolFromCurrency, @@ -34,7 +32,6 @@ import { appendCourseViewerSessionParamsToHref, } from "@/lib/course-viewer-session-params"; import { useSearchParams } from "next/navigation"; -const { permissions } = UIConstants; export default function ProductPage(props: { params: Promise<{ slug: string; id: string }>; @@ -67,7 +64,7 @@ export default function ProductPage(props: { getUserProfile(address.backend).then((profile) => { setProfile(profile); setProgress( - profile.purchases?.find( + profile?.purchases?.find( (purchase) => purchase.courseId === product.courseId, ), ); @@ -75,10 +72,6 @@ export default function ProductPage(props: { } }, [product]); - if (!profile) { - return null; - } - if (!product || !siteInfo) { return null; } @@ -86,7 +79,9 @@ export default function ProductPage(props: { const descriptionJson = product.description ? JSON.parse(product.description) : TextEditorEmptyDoc; - const enrolled = isEnrolled(product.courseId, profile as Profile); + const enrolled = Boolean( + profile?.userId && isEnrolled(product.courseId, profile), + ); const isPreview = Boolean(product.isPreview); return ( @@ -105,32 +100,28 @@ export default function ProductPage(props: { )} - {!enrolled && - !isPreview && - checkPermission(profile.permissions ?? [], [ - permissions.enrollInCourse, - ]) && ( -
-
-
- {getSymbolFromCurrency( - siteInfo.currencyISOCode ?? "", - )} - {product.cost} - - {product.costType ?? ""} - -
- - - + {!enrolled && !isPreview && ( +
+
+
+ {getSymbolFromCurrency( + siteInfo.currencyISOCode ?? "", + )} + {product.cost} + + {product.costType ?? ""} +
+ + +
- )} +
+ )} {product.featuredImage && (
diff --git a/apps/web/app/(with-contexts)/dashboard/(sidebar)/community/[id]/[postId]/community-post-page.tsx b/apps/web/app/(with-contexts)/dashboard/(sidebar)/community/[id]/[postId]/community-post-page.tsx index 2df497a12..821384f7b 100644 --- a/apps/web/app/(with-contexts)/dashboard/(sidebar)/community/[id]/[postId]/community-post-page.tsx +++ b/apps/web/app/(with-contexts)/dashboard/(sidebar)/community/[id]/[postId]/community-post-page.tsx @@ -689,7 +689,7 @@ export default function CommunityPostPage({ onOpenChange={setShowDeleteConfirmation} > - Delete Post + Delete post Are you sure you want to delete this post? 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 ef9ddc39f..6b63d3bb1 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, @@ -553,7 +554,7 @@ export default function Page(props: { >
diff --git a/apps/web/app/(with-contexts)/dashboard/(sidebar)/community/[id]/manage/reports/reports-table-skeleton.tsx b/apps/web/app/(with-contexts)/dashboard/(sidebar)/community/[id]/manage/reports/reports-table-skeleton.tsx index 37905c025..318be67c6 100644 --- a/apps/web/app/(with-contexts)/dashboard/(sidebar)/community/[id]/manage/reports/reports-table-skeleton.tsx +++ b/apps/web/app/(with-contexts)/dashboard/(sidebar)/community/[id]/manage/reports/reports-table-skeleton.tsx @@ -10,62 +10,52 @@ import { export function ReportsTableSkeleton() { return ( -
-
+
+
- - - - - Content - - - Type - - - Reason - - - Status - - - Rejection Reason - - - Actions - - - - - {Array.from({ length: 5 }).map((_, i) => ( - - - - - - - - - - - - - - - - - - - +
+
+ + + Content + Type + Reason + Status + Rejection Reason + Actions - ))} - -
-
- - - + + + {Array.from({ length: 5 }).map((_, i) => ( + + + + + + + + + + + + + + + + + + + + + ))} + + +
+ + + +
); diff --git a/apps/web/app/(with-contexts)/dashboard/(sidebar)/community/[id]/manage/reports/reports-table.tsx b/apps/web/app/(with-contexts)/dashboard/(sidebar)/community/[id]/manage/reports/reports-table.tsx index 0ae7aef9d..24e6db729 100644 --- a/apps/web/app/(with-contexts)/dashboard/(sidebar)/community/[id]/manage/reports/reports-table.tsx +++ b/apps/web/app/(with-contexts)/dashboard/(sidebar)/community/[id]/manage/reports/reports-table.tsx @@ -193,42 +193,21 @@ export function ReportsTable({ communityId }: { communityId: string }) { const getStatusBadge = (status: CommunityReportStatus) => { switch (status) { case "pending": - return ( - - PENDING - - ); + return PENDING; case "accepted": - return ( - - ACCEPTED - - ); + return ACCEPTED; case "rejected": - return ( - - REJECTED - - ); + return REJECTED; default: return null; } }; return ( -
-
+
+
-
+
- - - Content - - - Type - - - Reason - - - Status - - - Rejection Reason - - - Actions - + + Content + Type + Reason + Status + Rejection Reason + Actions {reports.map((report) => ( - - + + {report.content.content} - + {report.type.charAt(0).toUpperCase() + report.type.slice(1).toLowerCase()} - - {report.reason} - + {report.reason} {getStatusBadge( report.status.toLowerCase() as CommunityReportStatus, )} - + {report.rejectionReason || "-"} 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..def8d6ade 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,12 @@ describe("Notifications Page", () => { expect( screen.getByText("Community Membership Granted"), ).toBeInTheDocument(); + expect( + screen.getByText("Course Discussion Comment Created"), + ).toBeInTheDocument(); + expect( + screen.getByText("Course Discussion Reacted"), + ).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..a14fb59e1 --- /dev/null +++ b/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/manage/components/product-discussions.tsx @@ -0,0 +1,122 @@ +"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 { + COURSE_DISCUSSIONS_ADMIN_VIEW_REPORTS, + COURSE_DISCUSSIONS_DESCRIPTION, + COURSE_DISCUSSIONS_TITLE, + DISCUSSIONS_DISABLED_MESSAGE, + DISCUSSIONS_ENABLED_MESSAGE, + 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: response.updateCourse.discussions + ? DISCUSSIONS_ENABLED_MESSAGE + : DISCUSSIONS_DISABLED_MESSAGE, + }); + } + } 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/__tests__/reports-page.test.tsx b/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/manage/discussions/__tests__/reports-page.test.tsx new file mode 100644 index 000000000..681ce9202 --- /dev/null +++ b/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/manage/discussions/__tests__/reports-page.test.tsx @@ -0,0 +1,331 @@ +import React from "react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import ProductDiscussionReportsPage from "../reports/page"; +import { AddressContext } from "@components/contexts"; + +const mockToast = jest.fn(); +const mockExec = jest.fn(); +const payloads: Record[] = []; + +jest.mock("next/navigation", () => ({ + useParams: () => ({ + id: "product-1", + }), +})); + +jest.mock("next/link", () => { + const MockLink = ({ children, href, className }: any) => ( + + {children} + + ); + MockLink.displayName = "MockLink"; + return MockLink; +}); + +jest.mock("@/hooks/use-product", () => ({ + __esModule: true, + default: () => ({ + product: { + title: "Course with reports", + slug: "course-with-reports", + }, + }), +})); + +jest.mock("@components/admin/dashboard-content", () => ({ + __esModule: true, + default: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})); + +jest.mock("@components/admin/empty-state", () => ({ + __esModule: true, + default: ({ title }: { title: string }) =>
{title}
, +})); + +jest.mock("@courselit/components-library", () => ({ + PaginatedTable: ({ children, page, totalPages, onPageChange }: any) => ( +
+ {children} + {totalPages > 0 && ( +
+ + + {page} of {totalPages} + + +
+ )} +
+ ), + useToast: () => ({ + toast: mockToast, + }), +})); + +jest.mock("@courselit/utils", () => ({ + truncate: (value: string) => value, + 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("@/components/ui/button", () => ({ + Button: ({ children, disabled, onClick }: any) => ( + + ), +})); + +jest.mock("@/components/ui/table", () => ({ + Table: ({ children }: any) =>
{children}
, + TableBody: ({ children }: any) => {children}, + TableCell: ({ children, className }: any) => ( + {children} + ), + TableHead: ({ children, className }: any) => ( + {children} + ), + TableHeader: ({ children }: any) => {children}, + TableRow: ({ children }: any) => {children}, +})); + +jest.mock("@/components/ui/badge", () => ({ + Badge: ({ children }: any) => {children}, +})); + +jest.mock("@/components/ui/select", () => ({ + Select: ({ children, value, onValueChange }: any) => ( +
+ {React.Children.map(children, (child) => + React.isValidElement(child) + ? React.cloneElement(child as React.ReactElement, { + onValueChange, + value, + }) + : child, + )} +
+ ), + SelectTrigger: ({ children }: any) =>
{children}
, + SelectValue: ({ placeholder }: any) => {placeholder}, + SelectContent: ({ children, onValueChange }: any) => ( +
+ {React.Children.map(children, (child) => + React.isValidElement(child) + ? React.cloneElement(child as React.ReactElement, { + onValueChange, + }) + : child, + )} +
+ ), + SelectItem: ({ children, value, onValueChange }: any) => ( + + ), +})); + +function renderPage() { + return render( + + + , + ); +} + +describe("ProductDiscussionReportsPage", () => { + beforeEach(() => { + jest.clearAllMocks(); + payloads.length = 0; + mockExec.mockImplementation(() => { + const payload = payloads[payloads.length - 1]; + if (payload.query.includes("GetProductDiscussionReports")) { + return Promise.resolve({ + reports: { + items: [ + { + reportId: "report-1", + contentType: "COMMENT", + contentId: "comment-1", + userId: "reporter-1", + reason: "Spam", + status: "accepted", + createdAt: "2026-06-01T00:00:00.000Z", + entityId: "lesson-1", + lessonTitle: "Text lesson", + contentPreview: "Reported content", + authorName: "Author One", + reporterName: "Reporter One", + }, + ], + }, + totalReports: 11, + }); + } + if (payload.query.includes("UpdateProductDiscussionReportStatus")) { + return Promise.resolve({ + report: { + reportId: "report-1", + status: "rejected", + rejectionReason: "Rejected by moderator", + }, + }); + } + return Promise.resolve({}); + }); + }); + + it("lists reported discussion content with accepted status label", async () => { + renderPage(); + + await waitFor(() => { + expect(screen.getByText("Text lesson")).toBeInTheDocument(); + }); + + expect( + screen + .getAllByRole("columnheader") + .map((header) => header.textContent), + ).toEqual([ + "Content", + "Lesson", + "Author", + "Reported by", + "Reason", + "Status", + "Date", + "Actions", + ]); + expect(screen.getByText("Reported content")).toBeInTheDocument(); + expect( + screen.getByText("Reported content").closest("a"), + ).toHaveAttribute( + "href", + "/course/course-with-reports/product-1/lesson-1?discussion=open&preview=true&returnTo=%2Fdashboard%2Fproduct%2Fproduct-1%2Fmanage%2Fdiscussions%2Freports#discussion-comment-comment-1", + ); + expect(screen.getByText("Author One")).toBeInTheDocument(); + expect(screen.getByText("Reporter One")).toBeInTheDocument(); + expect(screen.getByText("Spam")).toBeInTheDocument(); + expect(screen.getByText("ACCEPTED")).toBeInTheDocument(); + expect(screen.getByText("1 of 2")).toBeInTheDocument(); + expect(payloads[payloads.length - 1].variables).toEqual({ + productId: "product-1", + status: undefined, + page: 1, + limit: 10, + }); + expect(screen.queryByText("approved")).not.toBeInTheDocument(); + }); + + it("links reply reports to the rendered reply target", async () => { + mockExec.mockResolvedValueOnce({ + reports: { + items: [ + { + reportId: "report-reply-1", + contentType: "REPLY", + contentId: "reply-1", + userId: "reporter-1", + reason: "Spam", + status: "pending", + createdAt: "2026-06-01T00:00:00.000Z", + entityId: "lesson-1", + lessonTitle: "Text lesson", + contentPreview: "Reported reply", + authorName: "Author One", + reporterName: "Reporter One", + }, + ], + }, + totalReports: 1, + }); + + renderPage(); + + await waitFor(() => { + expect(screen.getByText("Reported reply")).toBeInTheDocument(); + }); + + expect(screen.getByText("Reported reply").closest("a")).toHaveAttribute( + "href", + "/course/course-with-reports/product-1/lesson-1?discussion=open&preview=true&returnTo=%2Fdashboard%2Fproduct%2Fproduct-1%2Fmanage%2Fdiscussions%2Freports#discussion-reply-reply-1", + ); + }); + + it("loads the next numbered reports page", async () => { + renderPage(); + + await waitFor(() => { + expect(screen.getByText("Text lesson")).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText("Next")); + + await waitFor(() => { + expect(payloads[payloads.length - 1].variables).toEqual({ + productId: "product-1", + status: undefined, + page: 2, + limit: 10, + }); + }); + }); + + it("filters reports by status and cycles accepted reports with a rejection reason", async () => { + renderPage(); + + await waitFor(() => { + expect(screen.getByText("Text lesson")).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText("Accepted")); + + await waitFor(() => { + expect(payloads[payloads.length - 1].variables.status).toBe( + "ACCEPTED", + ); + }); + + fireEvent.click(screen.getByText("Change")); + + await waitFor(() => { + expect(screen.getByText("Confirm")).toBeInTheDocument(); + }); + fireEvent.click(screen.getByText("Confirm")); + + await waitFor(() => { + expect(payloads[payloads.length - 1].variables).toEqual({ + productId: "product-1", + reportId: "report-1", + rejectionReason: "Rejected by moderator", + }); + }); + await waitFor(() => { + expect(screen.getByText("REJECTED")).toBeInTheDocument(); + }); + }); +}); diff --git a/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/manage/discussions/reports/page.tsx b/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/manage/discussions/reports/page.tsx new file mode 100644 index 000000000..d1bbb188c --- /dev/null +++ b/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/manage/discussions/reports/page.tsx @@ -0,0 +1,452 @@ +"use client"; + +import { useCallback, useContext, useEffect, useState } from "react"; +import { useParams } from "next/navigation"; +import DashboardContent from "@components/admin/dashboard-content"; +import useProduct from "@/hooks/use-product"; +import { AddressContext } from "@components/contexts"; +import { FetchBuilder, truncate } from "@courselit/utils"; +import { UIConstants } from "@courselit/common-models"; +import { + COURSE_DISCUSSIONS_ADMIN_AUTHOR, + COURSE_DISCUSSIONS_ADMIN_CONTENT, + COURSE_DISCUSSIONS_ADMIN_DATE, + COURSE_DISCUSSIONS_ADMIN_LESSON, + COURSE_DISCUSSIONS_ADMIN_NO_REPORTS, + COURSE_DISCUSSIONS_ADMIN_NO_REPORTS_DESCRIPTION, + COURSE_DISCUSSIONS_ADMIN_REASON, + COURSE_DISCUSSIONS_ADMIN_REJECTION_REASON_DEFAULT, + COURSE_DISCUSSIONS_ADMIN_REPORTED_BY, + COURSE_DISCUSSIONS_ADMIN_REPORTS, + COURSE_DISCUSSIONS_ADMIN_STATUS, + COURSE_DISCUSSIONS_TITLE, + MANAGE_COURSES_PAGE_HEADING, + TOAST_TITLE_ERROR, +} from "@ui-config/strings"; +import AdminEmptyState from "@components/admin/empty-state"; +import { Button } from "@/components/ui/button"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Badge } from "@/components/ui/badge"; +import { PaginatedTable, useToast } from "@courselit/components-library"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { RotateCcwIcon as RotateCCW } from "lucide-react"; +import { RejectionReasonDialog } from "./rejection-reason-dialog"; +import Link from "next/link"; + +const { permissions } = UIConstants; +const itemsPerPage = 10; + +type ReportStatus = "pending" | "accepted" | "rejected"; + +type DiscussionReport = { + reportId: string; + contentType: "COMMENT" | "REPLY"; + contentId: string; + userId: string; + reason: string; + status: ReportStatus; + createdAt: string; + entityId: string; + lessonTitle?: string; + contentPreview?: string; + authorName?: string; + reporterName?: string; +}; + +export default function ProductDiscussionReportsPage() { + const params = useParams(); + const productId = params?.id as string; + const { product } = useProduct(productId); + const address = useContext(AddressContext); + const { toast } = useToast(); + const [reports, setReports] = useState([]); + const [page, setPage] = useState(1); + const [totalReports, setTotalReports] = useState(0); + const [status, setStatus] = useState(); + + 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`, + }, + { label: COURSE_DISCUSSIONS_ADMIN_REPORTS, href: "#" }, + ]; + + const graph = useCallback( + async (payload: Record) => { + const fetch = new FetchBuilder() + .setUrl(`${address.backend}/api/graph`) + .setPayload(payload) + .setIsGraphQLEndpoint(true) + .build(); + + return await fetch.exec(); + }, + [address.backend], + ); + + const loadReports = useCallback(async () => { + try { + const response = await graph({ + query: ` + query GetProductDiscussionReports($productId: String!, $status: ProductDiscussionReportStatus, $page: Int, $limit: Int) { + reports: getProductDiscussionReports(productId: $productId, status: $status, page: $page, limit: $limit) { + items { + reportId + contentType + contentId + userId + reason + status + createdAt + entityId + lessonTitle + contentPreview + authorName + reporterName + } + } + totalReports: getProductDiscussionReportsCount(productId: $productId, status: $status) + } + `, + variables: { + productId, + status: status?.toUpperCase(), + page, + limit: itemsPerPage, + }, + }); + setReports(response.reports.items); + setTotalReports(response.totalReports); + } catch (err: any) { + toast({ + title: TOAST_TITLE_ERROR, + description: err.message, + variant: "destructive", + }); + } + }, [graph, page, productId, status, toast]); + + useEffect(() => { + if (productId && address?.backend) { + void Promise.resolve().then(loadReports); + } + }, [productId, address?.backend, loadReports]); + + const [rejectionDialogOpen, setRejectionDialogOpen] = useState(false); + const [currentReportId, setCurrentReportId] = useState(null); + + async function handleStatusChange(report: DiscussionReport) { + // Status transition sequence for reports: pending -> accepted -> rejected -> pending + let nextStatus: ReportStatus = "pending"; + if (report.status === "pending") { + nextStatus = "accepted"; + } else if (report.status === "accepted") { + nextStatus = "rejected"; + } + + if (nextStatus === "rejected") { + setCurrentReportId(report.reportId); + setRejectionDialogOpen(true); + } else { + await updateReportStatus(report.reportId, nextStatus); + } + } + + async function handleRejectionConfirm(reason: string) { + if (currentReportId) { + await updateReportStatus(currentReportId, "rejected", reason); + } + setRejectionDialogOpen(false); + setCurrentReportId(null); + } + + async function updateReportStatus( + reportId: string, + nextStatus: ReportStatus, + rejectionReason?: string, + ) { + try { + const response = await graph({ + query: ` + mutation UpdateProductDiscussionReportStatus($productId: String!, $reportId: String!, $rejectionReason: String) { + report: updateProductDiscussionReportStatus(productId: $productId, reportId: $reportId, rejectionReason: $rejectionReason) { + reportId + status + rejectionReason + } + } + `, + variables: { + productId, + reportId, + rejectionReason: + nextStatus === "rejected" + ? rejectionReason || + COURSE_DISCUSSIONS_ADMIN_REJECTION_REASON_DEFAULT + : undefined, + }, + }); + setReports((current) => + current.map((item) => + item.reportId === reportId + ? { + ...item, + status: response.report.status.toLowerCase() as ReportStatus, + } + : item, + ), + ); + } catch (err: any) { + toast({ + title: TOAST_TITLE_ERROR, + description: err.message, + variant: "destructive", + }); + } + } + + function getViewerDiscussionHref(report: DiscussionReport) { + if (!product?.slug) { + return null; + } + + const returnTo = encodeURIComponent( + `/dashboard/product/${productId}/manage/discussions/reports`, + ); + const targetHash = `discussion-${ + report.contentType === "REPLY" ? "reply" : "comment" + }-${report.contentId}`; + + return `/course/${product.slug}/${productId}/${report.entityId}?discussion=open&preview=true&returnTo=${returnTo}#${targetHash}`; + } + + const getStatusBadge = (status: ReportStatus) => { + switch (status) { + case "pending": + return ( + + PENDING + + ); + case "accepted": + return ( + + ACCEPTED + + ); + case "rejected": + return ( + + REJECTED + + ); + default: + return null; + } + }; + + return ( + +
+
+
+

+ {COURSE_DISCUSSIONS_ADMIN_REPORTS} +

+

+ Review and manage reported discussion content +

+
+
+ +
+
+ +
+ +
+ {reports.length === 0 ? ( + + ) : ( + + + + + + { + COURSE_DISCUSSIONS_ADMIN_CONTENT + } + + + { + COURSE_DISCUSSIONS_ADMIN_LESSON + } + + + { + COURSE_DISCUSSIONS_ADMIN_AUTHOR + } + + + { + COURSE_DISCUSSIONS_ADMIN_REPORTED_BY + } + + + { + COURSE_DISCUSSIONS_ADMIN_REASON + } + + + { + COURSE_DISCUSSIONS_ADMIN_STATUS + } + + + {COURSE_DISCUSSIONS_ADMIN_DATE} + + + Actions + + + + + {reports.map((report) => ( + + + {getViewerDiscussionHref( + report, + ) ? ( + + {report.contentPreview || + "View Details"} + + ) : ( + report.contentPreview || + "View Details" + )} + + + {report.lessonTitle || + report.entityId} + + + {report.authorName || "-"} + + + {report.reporterName || + report.userId} + + + {report.reason} + + + {getStatusBadge( + report.status, + )} + + + {new Date( + report.createdAt, + ).toLocaleDateString()} + + + + + + ))} + +
+
+ )} +
+
+
+ + setRejectionDialogOpen(false)} + onConfirm={handleRejectionConfirm} + /> +
+ ); +} diff --git a/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/manage/discussions/reports/rejection-reason-dialog.tsx b/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/manage/discussions/reports/rejection-reason-dialog.tsx new file mode 100644 index 000000000..b9928979b --- /dev/null +++ b/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/manage/discussions/reports/rejection-reason-dialog.tsx @@ -0,0 +1,68 @@ +"use client"; + +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; + +interface RejectionReasonDialogProps { + isOpen: boolean; + onClose: () => void; + onConfirm: (reason: string) => void; +} + +export function RejectionReasonDialog({ + isOpen, + onClose, + onConfirm, +}: RejectionReasonDialogProps) { + const [reason, setReason] = useState(""); + + const handleConfirm = () => { + onConfirm(reason); + setReason(""); + }; + + return ( + + + + Enter Rejection Reason + + Please provide a reason for rejecting this report. This + is optional but recommended. + + +
+
+ + setReason(e.target.value)} + className="col-span-3" + /> +
+
+ + + + +
+
+ ); +} diff --git a/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/manage/page.tsx b/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/manage/page.tsx index 8638a75c5..c4324cd00 100644 --- a/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/manage/page.tsx +++ b/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/manage/page.tsx @@ -20,6 +20,7 @@ import DownloadOptions from "./components/download-options"; import ProductPublishing from "./components/product-publishing"; import Certificates from "./components/certificates"; import ProductDeletion from "./components/product-deletion"; +import ProductDiscussions from "./components/product-discussions"; const { permissions } = UIConstants; @@ -107,6 +108,7 @@ export default function SettingsPage() { }} loading={loading} /> + { - const hash = window.location.hash.slice(1); - - if (!hash) { - return false; - } - - const el = document.getElementById(hash); - - if (!el) { - return false; - } - - el.scrollIntoView({ - behavior: "smooth", - block: "center", - }); - el.classList.add(...HASH_HIGHLIGHT_CLASSES); - window.setTimeout( - () => el.classList.remove(...HASH_HIGHLIGHT_CLASSES), - 2200, - ); - - return true; -}; +import { focusHashTarget, scrollToHashTarget } from "@/lib/hash-target"; const focusCommentTarget = (targetId: string) => { - const url = new URL(window.location.href); - url.hash = targetId; - window.history.pushState({}, "", url.toString()); - window.dispatchEvent(new Event("community-comment-target-change")); + focusHashTarget({ + targetId, + eventName: "community-comment-target-change", + }); }; export default function CommentSection({ diff --git a/apps/web/components/community/index.tsx b/apps/web/components/community/index.tsx index 84bedbef3..57d725480 100644 --- a/apps/web/components/community/index.tsx +++ b/apps/web/components/community/index.tsx @@ -970,7 +970,7 @@ export function CommunityForum({ onOpenChange={setShowDeleteConfirmation} > - Delete Post + Delete post Are you sure you want to delete this post? This action cannot be undone. diff --git a/apps/web/components/public/product-discussions/__tests__/panel.test.tsx b/apps/web/components/public/product-discussions/__tests__/panel.test.tsx new file mode 100644 index 000000000..263c229af --- /dev/null +++ b/apps/web/components/public/product-discussions/__tests__/panel.test.tsx @@ -0,0 +1,467 @@ +import React from "react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import ProductDiscussionPanel from "../panel"; +import { + AddressContext, + ProfileContext, + ThemeContext, +} from "@components/contexts"; + +const mockToast = jest.fn(); +const mockExec = jest.fn(); +const payloads: Record[] = []; +const scrollIntoView = jest.fn(); +let mockSearchParams = new URLSearchParams(); + +jest.mock("@components/contexts", () => { + const React = jest.requireActual("react"); + return { + AddressContext: React.createContext(undefined), + ProfileContext: React.createContext(undefined), + ThemeContext: React.createContext({ theme: {} }), + }; +}); + +jest.mock("next/link", () => { + function MockNextLink({ + children, + href, + className, + }: { + children: React.ReactNode; + href: string; + className?: string; + }) { + return ( + + {children} + + ); + } + + return MockNextLink; +}); + +jest.mock("next/navigation", () => ({ + useSearchParams: () => mockSearchParams, +})); + +jest.mock("@courselit/components-library", () => ({ + useToast: () => ({ + toast: mockToast, + }), +})); + +jest.mock("@courselit/page-primitives", () => ({ + Button: ({ children, disabled, onClick, type, className }: any) => ( + + ), + Caption: ({ children, className }: any) => ( + {children} + ), + Text1: ({ children, className }: any) => ( + {children} + ), + Text2: ({ children, className }: any) => ( + {children} + ), + Link: ({ children, className }: any) => ( + {children} + ), +})); + +jest.mock("@/components/ui/dialog", () => ({ + Dialog: ({ children }: any) => children, + DialogContent: ({ children }: any) => children, + DialogDescription: ({ children }: any) => children, + DialogFooter: ({ children }: any) => children, + DialogTitle: ({ children }: any) => children, +})); + +jest.mock("@/components/ui/button", () => ({ + Button: ({ children, onClick }: any) => ( + + ), +})); + +jest.mock("@/components/ui/avatar", () => ({ + Avatar: ({ children, className }: any) => ( + {children} + ), + AvatarImage: ({ src, alt }: any) => {alt}, + AvatarFallback: ({ children }: any) => {children}, +})); + +jest.mock("@courselit/page-blocks", () => ({ + TextRenderer: ({ json }: { json: any }) => ( +
{json?.content?.[0]?.content?.[0]?.text}
+ ), +})); + +jest.mock("@courselit/text-editor", () => ({ + emptyDoc: { type: "doc", content: [] }, + Editor: ({ onChange, placeholder }: any) => ( +