diff --git a/apps/docs-new/content/docs/courses/add-content.mdx b/apps/docs-new/content/docs/courses/add-content.mdx index cb013ccf2..c0ad5a4f4 100644 --- a/apps/docs-new/content/docs/courses/add-content.mdx +++ b/apps/docs-new/content/docs/courses/add-content.mdx @@ -55,6 +55,10 @@ A lesson is a container for the actual learning material. CourseLit supports mul See the [guide to add a SCORM package](/lessons/scorm). +Most lesson types can also carry a rich text description, downloadable resources, or both, alongside their main content. + +See the [guide to add a description and resources](/lessons/description-and-resources). + ## Steps to add a new lesson 1. From the `Products` section in the dashboard, select your product to open its dashboard. diff --git a/apps/docs-new/content/docs/lessons/description-and-resources.mdx b/apps/docs-new/content/docs/lessons/description-and-resources.mdx new file mode 100644 index 000000000..02982026c --- /dev/null +++ b/apps/docs-new/content/docs/lessons/description-and-resources.mdx @@ -0,0 +1,65 @@ +--- +title: Add a description and resources to a lesson +description: Add a description and resources to a lesson +--- + +Lessons can carry more than just their main content. You can add a rich-text `Description` to give your students notes, instructions or links, and attach `Resources` they can download, such as worksheets, slides or exercise files. + +## Which lesson types support what + +Not every lesson type offers both. Text lessons already hold their own rich text, so they take resources only, and PDF and File lessons already give students something to download, so they take a description only. + +| Lesson type | Description | Resources | +| ----------- | ----------- | --------- | +| Text | — | Yes | +| Video | Yes | Yes | +| Audio | Yes | Yes | +| Embed | Yes | Yes | +| PDF | Yes | — | +| File | Yes | — | +| Quiz | — | — | +| SCORM | — | — | + +Sections you can't use simply don't appear on the lesson screen. + +## Add a description + +1. Go to the `Products` page and click on the course you want to edit. Click on `Edit content`. + +2. Click on an existing lesson, or click `Add lesson` and pick a type that supports a description. + +3. Below the lesson's main content, you'll find the `Description` section. Write your notes in the editor. + + The editor supports the same rich text formatting used elsewhere in CourseLit, so you can add headings, lists and links. + +4. Click `Save lesson` to save the description along with the rest of the lesson. + +## Add resources + +Resources are files your students can download from the lesson page. + +1. Open a lesson that supports resources, as described above. + +2. Scroll to the `Resources` section and click `Add a resource`. + + If the lesson has not been saved yet, this button is disabled and shows `Save the lesson to enable resource uploads`. Save the lesson first. A new lesson reopens once it's saved, so you can add resources straight away. + +3. Upload the file you want to share. + +4. Repeat for as many resources as you need. To remove one, use the remove option on that resource. + +Removing a resource deletes its file. Each resource belongs to one lesson, so a file already used by another lesson can't be added again; upload a copy instead. + +Unlike the description, resources are saved as soon as you add or remove one, so there is no need to click `Save lesson` afterwards. + +The caption you set on a file is used as its download button label. If you don't set one, the original file name is used instead, so a descriptive caption is worth adding. + +## What your students see + +On the lesson page, the description appears below the lesson's main content, and the resources appear under a `Resources` heading as download buttons. + +An empty description is not shown at all, so lessons you don't write a description for look exactly as they did before. + +## Stuck somewhere? + +We are always there for you. Come chat with us in our Discord channel or send a tweet at @CourseLit. diff --git a/apps/docs-new/content/docs/lessons/meta.json b/apps/docs-new/content/docs/lessons/meta.json index 00aaf8f82..99584bf21 100644 --- a/apps/docs-new/content/docs/lessons/meta.json +++ b/apps/docs-new/content/docs/lessons/meta.json @@ -1,4 +1,4 @@ { "title": "Lessons", - "pages": ["add-quiz", "embed", "scorm"] + "pages": ["add-quiz", "description-and-resources", "embed", "scorm"] } diff --git a/apps/docs/src/pages/en/courses/add-content.md b/apps/docs/src/pages/en/courses/add-content.md index 9951c6eae..389f7291a 100644 --- a/apps/docs/src/pages/en/courses/add-content.md +++ b/apps/docs/src/pages/en/courses/add-content.md @@ -56,6 +56,10 @@ A lesson is a container for the actual learning material. CourseLit supports mul See the [guide to add a SCORM package](/en/lessons/scorm). +Most lesson types can also carry a rich text description, downloadable resources, or both, alongside their main content. + +See the [guide to add a description and resources](/en/lessons/description-and-resources). + ## Steps to add a new lesson 1. From the `Products` section in the dashboard, select your product to open its dashboard. diff --git a/apps/docs/src/pages/en/lessons/description-and-resources.md b/apps/docs/src/pages/en/lessons/description-and-resources.md new file mode 100644 index 000000000..474ec9adf --- /dev/null +++ b/apps/docs/src/pages/en/lessons/description-and-resources.md @@ -0,0 +1,66 @@ +--- +title: Add a description and resources to a lesson +description: Add a description and resources to a lesson +layout: ../../../layouts/MainLayout.astro +--- + +Lessons can carry more than just their main content. You can add a rich-text `Description` to give your students notes, instructions or links, and attach `Resources` they can download, such as worksheets, slides or exercise files. + +## Which lesson types support what + +Not every lesson type offers both. Text lessons already hold their own rich text, so they take resources only, and PDF and File lessons already give students something to download, so they take a description only. + +| Lesson type | Description | Resources | +| ----------- | ----------- | --------- | +| Text | — | Yes | +| Video | Yes | Yes | +| Audio | Yes | Yes | +| Embed | Yes | Yes | +| PDF | Yes | — | +| File | Yes | — | +| Quiz | — | — | +| SCORM | — | — | + +Sections you can't use simply don't appear on the lesson screen. + +## Add a description + +1. Go to the `Products` page and click on the course you want to edit. Click on `Edit content`. + +2. Click on an existing lesson, or click `Add lesson` and pick a type that supports a description. + +3. Below the lesson's main content, you'll find the `Description` section. Write your notes in the editor. + + The editor supports the same rich text formatting used elsewhere in CourseLit, so you can add headings, lists and links. + +4. Click `Save lesson` to save the description along with the rest of the lesson. + +## Add resources + +Resources are files your students can download from the lesson page. + +1. Open a lesson that supports resources, as described above. + +2. Scroll to the `Resources` section and click `Add a resource`. + + If the lesson has not been saved yet, this button is disabled and shows `Save the lesson to enable resource uploads`. Save the lesson first. A new lesson reopens once it's saved, so you can add resources straight away. + +3. Upload the file you want to share. + +4. Repeat for as many resources as you need. To remove one, use the remove option on that resource. + +Removing a resource deletes its file. Each resource belongs to one lesson, so a file already used by another lesson can't be added again; upload a copy instead. + +Unlike the description, resources are saved as soon as you add or remove one, so there is no need to click `Save lesson` afterwards. + +The caption you set on a file is used as its download button label. If you don't set one, the original file name is used instead, so a descriptive caption is worth adding. + +## What your students see + +On the lesson page, the description appears below the lesson's main content, and the resources appear under a `Resources` heading as download buttons. + +An empty description is not shown at all, so lessons you don't write a description for look exactly as they did before. + +## Stuck somewhere? + +We are always there for you. Come chat with us in our Discord channel or send a tweet at @CourseLit. diff --git a/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/section/[section]/lesson/__tests__/page.test.tsx b/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/section/[section]/lesson/__tests__/page.test.tsx index fbbae140f..59b289321 100644 --- a/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/section/[section]/lesson/__tests__/page.test.tsx +++ b/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/section/[section]/lesson/__tests__/page.test.tsx @@ -9,11 +9,12 @@ import "@testing-library/jest-dom"; // Mock dependencies // Module-level variable to control lesson ID for edit mode let mockLessonId: string | null = null; +const mockReplace = jest.fn(); jest.mock("next/navigation", () => ({ useRouter: () => ({ push: jest.fn(), - replace: jest.fn(), + replace: mockReplace, }), useParams: () => ({ id: "product-1", @@ -47,6 +48,10 @@ jest.mock("@/hooks/use-product", () => ({ })); jest.mock("@courselit/utils", () => ({ + lessonTypeSupportsDescription: + jest.requireActual("@courselit/utils").lessonTypeSupportsDescription, + lessonTypeSupportsAttachments: + jest.requireActual("@courselit/utils").lessonTypeSupportsAttachments, FetchBuilder: jest.fn().mockImplementation(() => ({ setUrl: jest.fn().mockReturnThis(), setPayload: jest.fn().mockReturnThis(), @@ -58,6 +63,9 @@ jest.mock("@courselit/utils", () => ({ jest.mock("@courselit/text-editor", () => ({ emptyDoc: { type: "doc", content: [] }, + Editor: ({ placeholder }: any) => ( +
{placeholder}
+ ), })); jest.mock("@courselit/components-library", () => ({ @@ -70,6 +78,9 @@ jest.mock("@courselit/components-library", () => ({ isUploading: false, uploadProgress: 0, }), + MediaSelector: ({ title, srcTitle }: any) => ( +
{title || srcTitle}
+ ), })); jest.mock("../lesson-content-renderer", () => ({ @@ -179,6 +190,7 @@ jest.mock("@ui-lib/utils", () => ({ return !!content && content.value !== ""; }, truncate: (str: string) => str, + toMediaInput: (media: any) => media, })); describe("LessonPage", () => { @@ -312,6 +324,34 @@ describe("LessonPage", () => { }); }); + it("reopens a new text lesson for editing so resources can be added", async () => { + mockReplace.mockClear(); + const mockExec = jest.fn().mockResolvedValue({ + lesson: { lessonId: "new-lesson-id" }, + }); + (FetchBuilder as unknown as jest.Mock).mockImplementation(() => ({ + setUrl: jest.fn().mockReturnThis(), + setPayload: jest.fn().mockReturnThis(), + setIsGraphQLEndpoint: jest.fn().mockReturnThis(), + build: jest.fn().mockReturnThis(), + exec: mockExec, + })); + + render(, { wrapper }); + + fireEvent.change(screen.getByPlaceholderText("Enter lesson title"), { + target: { value: "New Lesson" }, + }); + fireEvent.click(screen.getByText("Update Content")); + fireEvent.click(screen.getByText("Save Lesson")); + + await waitFor(() => { + expect(mockReplace).toHaveBeenCalledWith( + "/dashboard/product/product-1/content/section/section-1/lesson?id=new-lesson-id", + ); + }); + }); + it("handles API error during save", async () => { const mockExec = jest.fn().mockRejectedValue(new Error("API Error")); (FetchBuilder as unknown as jest.Mock).mockImplementation(() => ({ @@ -944,4 +984,32 @@ describe("LessonPage", () => { } }); }); + describe("description and resources sections", () => { + it.each([ + [Constants.LessonType.TEXT, false, true], + [Constants.LessonType.VIDEO, true, true], + [Constants.LessonType.AUDIO, true, true], + [Constants.LessonType.EMBED, true, true], + [Constants.LessonType.PDF, true, false], + [Constants.LessonType.FILE, true, false], + [Constants.LessonType.QUIZ, false, false], + [Constants.LessonType.SCORM, false, false], + ])( + "on a %s lesson shows description=%s and resources=%s", + async (type, expectsDescription, expectsResources) => { + render(, { wrapper }); + + fireEvent.click(screen.getByTestId(`radio-item-${type}`)); + + await waitFor(() => { + expect( + screen.queryByTestId("description-editor") !== null, + ).toBe(expectsDescription); + }); + expect(screen.queryByText("Add a resource") !== null).toBe( + expectsResources, + ); + }, + ); + }); }); diff --git a/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/section/[section]/lesson/lesson-attachments.tsx b/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/section/[section]/lesson/lesson-attachments.tsx new file mode 100644 index 000000000..d8385ac1a --- /dev/null +++ b/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/section/[section]/lesson/lesson-attachments.tsx @@ -0,0 +1,61 @@ +import { Media, Profile } from "@courselit/common-models"; +import { MediaSelector } from "@courselit/components-library"; +import { useContext } from "react"; +import { AddressContext, ProfileContext } from "@components/contexts"; +import { LESSON_RESOURCES_ADD } from "@ui-config/strings"; + +interface LessonAttachmentsProps { + attachments: Partial[]; + disabled?: boolean; + onChange: (attachments: Partial[]) => void; +} + +export function LessonAttachments({ + attachments, + disabled = false, + onChange, +}: LessonAttachmentsProps) { + const address = useContext(AddressContext); + const { profile } = useContext(ProfileContext); + + return ( +
+ {attachments.map((attachment, index) => ( + {}} + onRemove={() => { + onChange(attachments.filter((_, i) => i !== index)); + }} + /> + ))} + { + if (media) { + onChange([...attachments, media]); + } + }} + /> +
+ ); +} diff --git a/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/section/[section]/lesson/lesson-content-renderer.tsx b/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/section/[section]/lesson/lesson-content-renderer.tsx index e5ebbbf8a..87229cf0d 100644 --- a/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/section/[section]/lesson/lesson-content-renderer.tsx +++ b/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/section/[section]/lesson/lesson-content-renderer.tsx @@ -22,6 +22,7 @@ import { import { useContext, useState } from "react"; import { AddressContext, ProfileContext } from "@components/contexts"; import { FetchBuilder } from "@courselit/utils"; +import { toMediaInput } from "@ui-lib/utils"; import { Textarea } from "@components/ui/textarea"; import dynamic from "next/dynamic"; const LessonEmbedViewer = dynamic( @@ -65,12 +66,7 @@ export function LessonContentRenderer({ query, variables: { id: lesson?.lessonId, - media: media - ? Object.assign({}, media, { - file: - media.access === "public" ? media.file : null, - }) - : null, + media: media ? toMediaInput(media) : null, }, }) .setIsGraphQLEndpoint(true) diff --git a/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/section/[section]/lesson/page.tsx b/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/section/[section]/lesson/page.tsx index c581a354b..e10f22c3b 100644 --- a/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/section/[section]/lesson/page.tsx +++ b/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/section/[section]/lesson/page.tsx @@ -13,6 +13,7 @@ import { File, Tv, Package, + Info, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -44,6 +45,14 @@ import { TOAST_TITLE_SUCCESS, ALPHA_LABEL, LESSON_PREVIEW, + LESSON_MEDIA_LABEL, + LESSON_DESCRIPTION_LABEL, + LESSON_DESCRIPTION_TOOLTIP, + LESSON_DESCRIPTION_PLACEHOLDER, + LESSON_RESOURCES_LABEL, + LESSON_RESOURCES_TOOLTIP, + LESSON_RESOURCES_SAVE_LESSON_FIRST, + LESSON_RESOURCES_SAVED, } from "@ui-config/strings"; import DashboardContent from "@components/admin/dashboard-content"; import useProduct from "@/hooks/use-product"; @@ -52,17 +61,23 @@ import { Constants, Lesson, LessonType, + Media, TextEditorContent, UIConstants, } from "@courselit/common-models"; import { useToast, Chip } from "@courselit/components-library"; -import { FetchBuilder } from "@courselit/utils"; +import { + FetchBuilder, + lessonTypeSupportsAttachments, + lessonTypeSupportsDescription, +} from "@courselit/utils"; import { LessonContentRenderer } from "./lesson-content-renderer"; -import { isTextEditorNonEmpty, truncate } from "@ui-lib/utils"; +import { isTextEditorNonEmpty, toMediaInput, truncate } from "@ui-lib/utils"; import { Separator } from "@components/ui/separator"; -import { emptyDoc as TextEditorEmptyDoc } from "@courselit/text-editor"; +import { Editor, emptyDoc as TextEditorEmptyDoc } from "@courselit/text-editor"; import { LessonSkeleton } from "./skeleton"; import { ScormLessonUpload } from "./scorm-lesson-upload"; +import { LessonAttachments } from "./lesson-attachments"; import { Tooltip, TooltipContent, @@ -83,6 +98,13 @@ const lessonTypes = [ { value: Constants.LessonType.SCORM, label: "SCORM", icon: Package }, ] as const; +const mediaLessonTypes: LessonType[] = [ + Constants.LessonType.VIDEO, + Constants.LessonType.AUDIO, + Constants.LessonType.PDF, + Constants.LessonType.FILE, +]; + type LessonError = Partial>; export default function LessonPage() { @@ -140,7 +162,15 @@ export default function LessonPage() { ? {} : { value: "" }, ); + const [description, setDescription] = useState( + TextEditorEmptyDoc as unknown as TextEditorContent, + ); + const [attachments, setAttachments] = useState[]>([]); + const [savingAttachments, setSavingAttachments] = useState(false); const [isLoading, setIsLoading] = useState(isEditing); + const isMediaLesson = mediaLessonTypes.includes(lesson.type as LessonType); + const supportsDescription = lessonTypeSupportsDescription(lesson.type); + const supportsAttachments = lessonTypeSupportsAttachments(lesson.type); useEffect(() => { if (product && !lesson.lessonId) { @@ -184,7 +214,18 @@ export default function LessonPage() { }, requiresEnrollment, published, - lessonId + lessonId, + description, + attachments { + mediaId, + originalFileName, + mimeType, + size, + access, + file, + thumbnail, + caption + } } } `; @@ -209,6 +250,11 @@ export default function LessonPage() { setLesson(loadedLesson); setContent(response.lesson.content); + setDescription( + (response.lesson.description as TextEditorContent) ?? + (TextEditorEmptyDoc as unknown as TextEditorContent), + ); + setAttachments(response.lesson.attachments || []); } } catch (err: any) { toast({ @@ -309,6 +355,9 @@ export default function LessonPage() { content: JSON.stringify(content), requiresEnrollment: lesson?.requiresEnrollment, published: !!lesson?.published, + ...(supportsDescription + ? { description: JSON.stringify(description) } + : {}), }, }, }) @@ -354,6 +403,10 @@ export default function LessonPage() { requiresEnrollment: lesson?.requiresEnrollment, groupId: lesson?.groupId, published: !!lesson?.published, + ...(supportsDescription + ? { description: JSON.stringify(description) } + : {}), + ...(supportsAttachments ? { attachments } : {}), }, }, }) @@ -363,13 +416,9 @@ export default function LessonPage() { try { const response = await fetch.exec(); if (response.lesson) { - if ( - [ - Constants.LessonType.TEXT, - Constants.LessonType.EMBED, - Constants.LessonType.QUIZ, - ].includes(lesson.type as any) - ) { + // Text and embed lessons now take resources, which can only be + // added once the lesson exists, so only quizzes skip reopening. + if (lesson.type === Constants.LessonType.QUIZ) { router.replace(`/dashboard/product/${productId}/content`); } else { router.replace( @@ -387,6 +436,57 @@ export default function LessonPage() { } }; + // Each save sends the whole list and the server deletes whatever is + // missing from it, so saves must not overlap. + const saveAttachments = async (nextAttachments: Partial[]) => { + if (savingAttachments) { + return; + } + + const previousAttachments = attachments; + setAttachments(nextAttachments); + setSavingAttachments(true); + + const query = ` + mutation ($id: ID!, $attachments: [MediaInput]) { + lesson: updateLesson(lessonData: { + id: $id + attachments: $attachments + }) { + lessonId + } + } + `; + const fetch = new FetchBuilder() + .setUrl(`${address.backend}/api/graph`) + .setPayload({ + query, + variables: { + id: lesson?.lessonId, + attachments: nextAttachments.map(toMediaInput), + }, + }) + .setIsGraphQLEndpoint(true) + .build(); + + try { + await fetch.exec(); + toast({ + title: TOAST_TITLE_SUCCESS, + description: LESSON_RESOURCES_SAVED, + }); + } catch (err: any) { + setAttachments(previousAttachments); + toast({ + title: TOAST_TITLE_ERROR, + description: err.message, + variant: "destructive", + }); + } finally { + setSavingAttachments(false); + } + }; + const onLessonDelete = async () => { if (lesson?.lessonId) { const query = ` @@ -604,6 +704,77 @@ export default function LessonPage() { /> )} + {isMediaLesson && ( + <> + + { + setLesson( + Object.assign( + {}, + lesson, + updates, + ), + ); + }} + /> + + )} + {supportsDescription && ( + <> + +
+ +

+ {LESSON_DESCRIPTION_TOOLTIP} +

+
+ { + setDescription(state); + }} + url={address.backend} + placeholder={ + LESSON_DESCRIPTION_PLACEHOLDER + } + /> + + )} + {supportsAttachments && ( + <> + +
+ +

+ {LESSON_RESOURCES_TOOLTIP} +

+
+ + {!lesson.lessonId && ( +

+ + {LESSON_RESOURCES_SAVE_LESSON_FIRST} +

+ )} + + )} {product?.type?.toLowerCase() === Constants.CourseType.COURSE && lesson.type !== Constants.LessonType.QUIZ && ( @@ -723,29 +894,6 @@ export default function LessonPage() { - {[ - Constants.LessonType.VIDEO, - Constants.LessonType.AUDIO, - Constants.LessonType.PDF, - Constants.LessonType.FILE, - ].includes(lesson.type as any) && ( - <> - -
- - { - setLesson( - Object.assign({}, lesson, updates), - ); - }} - /> -
- - )} {lesson.type === Constants.LessonType.SCORM && lesson.lessonId && ( <> diff --git a/apps/web/app/api/products/[productId]/lessons/[lessonId]/route.ts b/apps/web/app/api/products/[productId]/lessons/[lessonId]/route.ts index 731eb6ae0..16600b28c 100644 --- a/apps/web/app/api/products/[productId]/lessons/[lessonId]/route.ts +++ b/apps/web/app/api/products/[productId]/lessons/[lessonId]/route.ts @@ -23,6 +23,8 @@ const updateLessonFields = new Set([ "title", "content", "media", + "description", + "attachments", "downloadable", "requiresEnrollment", "published", @@ -66,6 +68,12 @@ function toExistingUpdatePayload( payload.content = JSON.stringify(body.content); } + if (Object.prototype.hasOwnProperty.call(body, "description")) { + // A null description clears it, rather than storing the string "null". + payload.description = + body.description === null ? "" : JSON.stringify(body.description); + } + return payload; } diff --git a/apps/web/app/api/products/[productId]/lessons/__tests__/route.test.ts b/apps/web/app/api/products/[productId]/lessons/__tests__/route.test.ts index 7a63d936b..280d6899f 100644 --- a/apps/web/app/api/products/[productId]/lessons/__tests__/route.test.ts +++ b/apps/web/app/api/products/[productId]/lessons/__tests__/route.test.ts @@ -177,6 +177,34 @@ describe("/api/products/{productId}/lessons", () => { }); }); + it("treats a null description on create as no description", async () => { + (createLesson as jest.Mock).mockResolvedValue({ + lessonId: "lesson-1", + title: "Intro", + type: "video", + courseId: "course-1", + groupId: "group-1", + published: false, + requiresEnrollment: true, + }); + + const { POST } = await import("../route"); + const response = await POST( + request({ + title: "Intro", + type: "video", + groupId: "group-1", + description: null, + }), + { params: Promise.resolve({ productId: "course-1" }) }, + ); + + expect(response.status).toBe(201); + expect( + (createLesson as jest.Mock).mock.calls[0][0].description, + ).toBeUndefined(); + }); + it("rejects unsupported lesson create fields before invoking existing lesson logic", async () => { const { POST } = await import("../route"); const response = await POST( diff --git a/apps/web/app/api/products/[productId]/lessons/lesson-response.ts b/apps/web/app/api/products/[productId]/lessons/lesson-response.ts index f5fbc4ed7..84a155cca 100644 --- a/apps/web/app/api/products/[productId]/lessons/lesson-response.ts +++ b/apps/web/app/api/products/[productId]/lessons/lesson-response.ts @@ -4,6 +4,8 @@ type LessonDocument = { type: string; content?: unknown; media?: unknown; + description?: unknown; + attachments?: unknown; downloadable?: boolean; courseId: string; groupId: string; @@ -18,6 +20,8 @@ export function serializeLesson(lesson: LessonDocument) { type: lesson.type, content: lesson.content, media: lesson.media, + description: lesson.description, + attachments: lesson.attachments, downloadable: lesson.downloadable, courseId: lesson.courseId, groupId: lesson.groupId, @@ -36,5 +40,10 @@ export function toExistingLessonPayload( content: Object.prototype.hasOwnProperty.call(body, "content") ? JSON.stringify(body.content) : undefined, + // A null description means none, rather than the string "null". + description: + body.description === undefined || body.description === null + ? undefined + : JSON.stringify(body.description), }; } diff --git a/apps/web/app/api/products/[productId]/lessons/route.ts b/apps/web/app/api/products/[productId]/lessons/route.ts index 87ecfa689..2556fcc53 100644 --- a/apps/web/app/api/products/[productId]/lessons/route.ts +++ b/apps/web/app/api/products/[productId]/lessons/route.ts @@ -22,6 +22,8 @@ const createLessonFields = new Set([ "type", "content", "media", + "description", + "attachments", "downloadable", "groupId", "requiresEnrollment", diff --git a/apps/web/app/api/products/openapi.mjs b/apps/web/app/api/products/openapi.mjs index 26833dec8..94b345b2d 100644 --- a/apps/web/app/api/products/openapi.mjs +++ b/apps/web/app/api/products/openapi.mjs @@ -104,7 +104,7 @@ const lessonContentSchema = { { $ref: "#/components/schemas/QuizContent" }, ], description: - "`text` lessons use `TiptapDocument`; `embed` lessons use `EmbedContent`; `quiz` lessons use `QuizContent`. Media-backed lessons (`video`, `audio`, `pdf`, `file`) use `media` instead of `content`.", + "`text` lessons use `TiptapDocument`; `embed` lessons use `EmbedContent`; `quiz` lessons use `QuizContent`. Media-backed lessons (`video`, `audio`, `pdf`, `file`) use `media` instead of `content`. A `description` document is accepted on `video`, `audio`, `embed`, `pdf` and `file` lessons, and `attachments` on `text`, `video`, `audio` and `embed` lessons.", }; const tiptapDocumentDescription = @@ -570,7 +570,7 @@ export const productsApiOpenApi = { tags: ["Product Content"], summary: "Create a product lesson", description: - "Creates a lesson. `text` lessons accept Tiptap/ProseMirror JSON in `content`; `embed` lessons accept `{ value }` in `content`; `quiz` lessons accept quiz JSON in `content`; media-backed lessons (`video`, `audio`, `pdf`, `file`) use `media`. SCORM lessons are not supported.", + "Creates a lesson. `text` lessons accept Tiptap/ProseMirror JSON in `content`; `embed` lessons accept `{ value }` in `content`; `quiz` lessons accept quiz JSON in `content`; media-backed lessons (`video`, `audio`, `pdf`, `file`) use `media`. A `description` document is accepted on `video`, `audio`, `embed`, `pdf` and `file` lessons, and `attachments` on `text`, `video`, `audio` and `embed` lessons. SCORM lessons are not supported.", operationId: "createProductLesson", security: secured, parameters: [productIdParam], @@ -1156,6 +1156,16 @@ export const productsApiOpenApi = { caption: { type: "string" }, }, }, + LessonDescription: { + type: "object", + description: + "Supporting rich text shown below the lesson content on `video`, `audio`, `embed`, `pdf` and `file` lessons, as a Tiptap/ProseMirror document. Use it for notes and links to resources.", + required: ["type", "content"], + properties: { + type: { type: "string", enum: ["doc"] }, + content: { type: "array", items: { type: "object" } }, + }, + }, Lesson: { type: "object", properties: { @@ -1167,6 +1177,15 @@ export const productsApiOpenApi = { }, content: lessonContentSchema, media: { $ref: "#/components/schemas/LessonMedia" }, + description: { + $ref: "#/components/schemas/LessonDescription", + }, + attachments: { + type: "array", + description: + "Files students can download alongside the lesson, such as PDFs, worksheets or slides. Supported on `text`, `video`, `audio` and `embed` lessons.", + items: { $ref: "#/components/schemas/LessonMedia" }, + }, downloadable: { type: "boolean" }, courseId: { type: "string" }, groupId: { type: "string" }, @@ -1195,6 +1214,15 @@ export const productsApiOpenApi = { }, content: lessonContentSchema, media: { $ref: "#/components/schemas/LessonMedia" }, + description: { + $ref: "#/components/schemas/LessonDescription", + }, + attachments: { + type: "array", + description: + "Files students can download alongside the lesson, such as PDFs, worksheets or slides. Supported on `text`, `video`, `audio` and `embed` lessons.", + items: { $ref: "#/components/schemas/LessonMedia" }, + }, downloadable: { type: "boolean" }, groupId: { type: "string" }, requiresEnrollment: { type: "boolean" }, @@ -1207,6 +1235,15 @@ export const productsApiOpenApi = { title: { type: "string" }, content: lessonContentSchema, media: { $ref: "#/components/schemas/LessonMedia" }, + description: { + $ref: "#/components/schemas/LessonDescription", + }, + attachments: { + type: "array", + description: + "Files students can download alongside the lesson, such as PDFs, worksheets or slides. Supported on `text`, `video`, `audio` and `embed` lessons.", + items: { $ref: "#/components/schemas/LessonMedia" }, + }, downloadable: { type: "boolean" }, requiresEnrollment: { type: "boolean" }, published: { type: "boolean" }, diff --git a/apps/web/components/public/lesson-viewer/index.tsx b/apps/web/components/public/lesson-viewer/index.tsx index 61fd7351a..54a10d6b8 100644 --- a/apps/web/components/public/lesson-viewer/index.tsx +++ b/apps/web/components/public/lesson-viewer/index.tsx @@ -1,5 +1,9 @@ import React, { useContext, useEffect, useState } from "react"; -import { FetchBuilder } from "@courselit/utils"; +import { + FetchBuilder, + lessonTypeSupportsAttachments, + lessonTypeSupportsDescription, +} from "@courselit/utils"; import { LESSON_TYPE_VIDEO, LESSON_TYPE_AUDIO, @@ -20,6 +24,7 @@ import { ENROLL_BUTTON_TEXT, TOAST_TITLE_ERROR, NOT_ENROLLED_HEADER, + LESSON_RESOURCES_HEADER, } from "@/ui-config/strings"; import { Link, Skeleton, useToast } from "@courselit/components-library"; import { TextRenderer } from "@courselit/page-blocks"; @@ -33,7 +38,11 @@ import { } from "@courselit/common-models"; import { useSearchParams } from "next/navigation"; import { ArrowLeft, ArrowRight, ArrowDownward } from "@courselit/icons"; -import { isEnrolled, isLessonCompleted } from "../../../ui-lib/utils"; +import { + hasTextEditorContent, + isEnrolled, + isLessonCompleted, +} from "../../../ui-lib/utils"; import LessonEmbedViewer from "./embed-viewer"; import QuizViewer from "./quiz-viewer"; import ScormViewer from "./scorm-viewer"; @@ -95,6 +104,11 @@ export const LessonViewer = ({ const { toast } = useToast(); const { theme } = useContext(ThemeContext); const viewerProfile = profile?.userId ? (profile as Profile) : undefined; + const supportsDescription = lessonTypeSupportsDescription(lesson?.type); + const supportsAttachments = lessonTypeSupportsAttachments(lesson?.type); + const hasDescription = hasTextEditorContent( + lesson?.description as TextEditorContent | undefined, + ); const isViewerEnrolled = Boolean( lesson && viewerProfile && isEnrolled(lesson.courseId, viewerProfile), ); @@ -137,7 +151,14 @@ export const LessonViewer = ({ requiresEnrollment, courseId, prevLesson, - nextLesson + nextLesson, + description, + attachments { + mediaId, + file, + caption, + originalFileName + } } } `; @@ -414,6 +435,50 @@ export const LessonViewer = ({ } /> )} + {supportsDescription && hasDescription && ( + +
+ +
+
+ )} + {supportsAttachments && lesson.attachments?.length ? ( +
+ + {LESSON_RESOURCES_HEADER} + +
+ {lesson.attachments + .filter((attachment) => attachment.file) + .map((attachment, index) => ( + + + + ))} +
+
+ ) : null} {isViewerEnrolled && !isPreview && (
diff --git a/apps/web/config/strings.ts b/apps/web/config/strings.ts index 69187bbe4..c82adcc66 100644 --- a/apps/web/config/strings.ts +++ b/apps/web/config/strings.ts @@ -34,6 +34,14 @@ export const responses = { cost_not_provided: "Cost field is required", invalid_cost: "Invalid cost", cannot_add_to_blogs: "Cannot add lessons to a blog post", + lesson_description_not_supported: + "This lesson type does not support a description", + lesson_attachments_not_supported: + "This lesson type does not support resources", + lesson_attachment_not_allowed: + "This file can't be added as a resource. Upload it to this lesson instead", + lesson_media_used_as_attachment: + "This file is a lesson resource, so it can't also be a lesson's media. Upload it again instead", file_is_required: "A file is required", error_in_moving_file: "Error in moving file", success: "success", diff --git a/apps/web/graphql/lessons/__tests__/logic.test.ts b/apps/web/graphql/lessons/__tests__/logic.test.ts new file mode 100644 index 000000000..007e27ec8 --- /dev/null +++ b/apps/web/graphql/lessons/__tests__/logic.test.ts @@ -0,0 +1,778 @@ +import { Constants } from "@courselit/common-models"; +import DomainModel from "@/models/Domain"; +import UserModel from "@/models/User"; +import CourseModel from "@/models/Course"; +import LessonModel from "@/models/Lesson"; +import ActivityModel from "@/models/Activity"; +import { createLesson, deleteLesson, updateLesson } from "../logic"; +import { deleteMedia, getMedia, sealMedia } from "@/services/medialit"; +import { responses } from "@/config/strings"; + +jest.mock("@/services/medialit", () => ({ + deleteMedia: jest.fn(), + getMedia: jest.fn(), + sealMedia: jest.fn(), +})); + +const SUITE_PREFIX = `lesson-logic-${Date.now()}`; +const id = (suffix: string) => `${SUITE_PREFIX}-${suffix}`; +const email = (suffix: string) => `${suffix}-${SUITE_PREFIX}@example.com`; + +const mediaUrl = (mediaId: string) => + `https://media.example.com/${mediaId}/main.jpg`; + +const docWithImage = (mediaId: string) => ({ + type: "doc", + content: [ + { + type: "image", + attrs: { src: mediaUrl(mediaId) }, + }, + ], +}); + +const attachment = (mediaId: string, originalFileName: string) => ({ + mediaId, + originalFileName, + mimeType: "application/pdf", + size: 1024, + access: "private", + file: `https://media.example.com/${mediaId}/main.pdf`, + thumbnail: "", +}); + +describe("Lesson description and attachments", () => { + let testDomain: any; + let owner: any; + let ownerCtx: any; + let course: any; + let groupId: string; + + beforeAll(async () => { + testDomain = await DomainModel.create({ + name: id("domain"), + email: email("domain"), + features: [], + }); + + owner = await UserModel.create({ + domain: testDomain._id, + userId: id("owner"), + email: email("owner"), + name: "Owner", + active: true, + permissions: ["course:manage"], + unsubscribeToken: id("unsubscribe-owner"), + purchases: [], + }); + + groupId = id("group-1"); + + course = await CourseModel.create({ + domain: testDomain._id, + courseId: id("course"), + title: "Media Course", + lessons: [], + creatorId: owner.userId, + cost: 0, + privacy: "public", + type: "course", + costType: "free", + slug: id("course-slug"), + published: true, + groups: [ + { + _id: groupId, + name: "Group 1", + lessonsOrder: [], + rank: 1, + collapsed: true, + drip: { + status: false, + type: "relative-date", + }, + }, + ], + }); + + ownerCtx = { + user: owner, + subdomain: testDomain, + } as any; + }); + + afterAll(async () => { + await ActivityModel.deleteMany({ domain: testDomain._id }); + await LessonModel.deleteMany({ domain: testDomain._id }); + await CourseModel.deleteMany({ domain: testDomain._id }); + await UserModel.deleteMany({ domain: testDomain._id }); + await DomainModel.deleteOne({ _id: testDomain._id }); + }); + + beforeEach(async () => { + // nanoid is globally mocked to a constant, so every lesson created in + // this file would share a lessonId. Keep one lesson at a time. + await LessonModel.deleteMany({ domain: testDomain._id }); + (sealMedia as jest.Mock).mockReset(); + (deleteMedia as jest.Mock).mockReset(); + (getMedia as jest.Mock).mockReset(); + // By default every file was uploaded by this school. + (sealMedia as jest.Mock).mockImplementation( + async (mediaId: string) => ({ + ...attachment(mediaId, `${mediaId}.pdf`), + file: mediaUrl(mediaId), + group: testDomain.name, + }), + ); + (getMedia as jest.Mock).mockImplementation(async (mediaId: string) => ({ + mediaId, + group: testDomain.name, + })); + }); + + const linkTo = (mediaId: string) => ({ + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { + type: "text", + text: "Worksheet", + marks: [ + { + type: "link", + attrs: { + href: `https://media.example.com/${mediaId}/main.pdf`, + }, + }, + ], + }, + ], + }, + ], + }); + + const createVideoLesson = async ( + overrides: Record = {}, + ) => { + return await createLesson( + { + title: "Video lesson", + type: Constants.LessonType.VIDEO, + content: "", + courseId: course.courseId, + groupId, + requiresEnrollment: true, + downloadable: false, + published: true, + ...overrides, + } as any, + ownerCtx, + ); + }; + + it("stores a description alongside the media on a video lesson", async () => { + const description = { + type: "doc", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Watch this first" }], + }, + ], + }; + + const lesson = await createVideoLesson({ + description: JSON.stringify(description), + }); + + expect(lesson.description).toEqual(description); + expect(lesson.type).toBe(Constants.LessonType.VIDEO); + }); + + it("seals media embedded in a description on create", async () => { + const tempMediaId = id("temp-description-media"); + const sealedUrl = mediaUrl(id("sealed-description-media")); + (sealMedia as jest.Mock).mockResolvedValueOnce({ + mediaId: tempMediaId, + file: sealedUrl, + }); + + const lesson = await createVideoLesson({ + description: JSON.stringify(docWithImage(tempMediaId)), + }); + + expect(sealMedia).toHaveBeenCalledWith(tempMediaId); + expect((lesson.description as any).content[0].attrs.src).toBe( + sealedUrl, + ); + }); + + it("seals every attachment on create", async () => { + const firstId = id("attachment-1"); + const secondId = id("attachment-2"); + (sealMedia as jest.Mock).mockImplementation( + async (mediaId: string) => ({ + ...attachment(mediaId, `${mediaId}.pdf`), + file: "https://media.example.com/private.pdf", + group: testDomain.name, + }), + ); + + const lesson = await createVideoLesson({ + attachments: [ + attachment(firstId, "worksheet.pdf"), + attachment(secondId, "slides.pdf"), + ], + }); + + expect(sealMedia).toHaveBeenCalledWith(firstId); + expect(sealMedia).toHaveBeenCalledWith(secondId); + expect(lesson.attachments).toHaveLength(2); + expect(lesson.attachments!.map((item: any) => item.mediaId)).toEqual([ + firstId, + secondId, + ]); + // Sealed media must not leak a direct file URL + expect(lesson.attachments![0].file).toBeUndefined(); + }); + + it("keeps the existing description when an update omits it", async () => { + const description = { + type: "doc", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Original notes" }], + }, + ], + }; + const lesson = await createVideoLesson({ + description: JSON.stringify(description), + }); + + const updated = await updateLesson( + { id: lesson.lessonId, title: "Renamed" } as any, + ownerCtx, + ); + + expect(updated.title).toBe("Renamed"); + expect(updated.description).toEqual(description); + }); + + it("deletes media dropped from a description on update", async () => { + const keptMediaId = id("kept-media"); + const droppedMediaId = id("dropped-media"); + const lesson = await createVideoLesson({ + description: JSON.stringify({ + type: "doc", + content: [ + { type: "image", attrs: { src: mediaUrl(keptMediaId) } }, + { type: "image", attrs: { src: mediaUrl(droppedMediaId) } }, + ], + }), + }); + + await updateLesson( + { + id: lesson.lessonId, + description: JSON.stringify(docWithImage(keptMediaId)), + } as any, + ownerCtx, + ); + + expect(deleteMedia).toHaveBeenCalledWith(droppedMediaId); + expect(deleteMedia).not.toHaveBeenCalledWith(keptMediaId); + }); + + it("replaces the attachment list on update", async () => { + const originalId = id("original-attachment"); + const replacementId = id("replacement-attachment"); + const lesson = await createVideoLesson({ + attachments: [attachment(originalId, "original.pdf")], + }); + + const updated = await updateLesson( + { + id: lesson.lessonId, + attachments: [attachment(replacementId, "replacement.pdf")], + } as any, + ownerCtx, + ); + + expect(updated.attachments).toHaveLength(1); + expect(updated.attachments![0].mediaId).toBe(replacementId); + }); + + it("deletes media for attachments dropped on update", async () => { + const droppedId = id("dropped-attachment"); + const keptId = id("kept-attachment"); + const lesson = await createVideoLesson({ + attachments: [ + attachment(droppedId, "dropped.pdf"), + attachment(keptId, "kept.pdf"), + ], + }); + + (deleteMedia as jest.Mock).mockReset(); + + await updateLesson( + { + id: lesson.lessonId, + attachments: [attachment(keptId, "kept.pdf")], + } as any, + ownerCtx, + ); + + expect(deleteMedia).toHaveBeenCalledWith(droppedId); + expect(deleteMedia).not.toHaveBeenCalledWith(keptId); + }); + + it("rejects a description that is not a document object", async () => { + await expect( + createVideoLesson({ + description: JSON.stringify("watch this first"), + }), + ).rejects.toThrow(responses.invalid_input); + }); + + it("rejects an attachment without a media id", async () => { + await expect( + createVideoLesson({ + attachments: [{ originalFileName: "worksheet.pdf" }], + }), + ).rejects.toThrow(responses.invalid_input); + }); + + it("cleans up description media and attachments when the lesson is deleted", async () => { + const descriptionMediaId = id("delete-description-media"); + const attachmentMediaId = id("delete-attachment-media"); + const lesson = await createVideoLesson({ + description: JSON.stringify(docWithImage(descriptionMediaId)), + attachments: [attachment(attachmentMediaId, "handout.pdf")], + }); + + (deleteMedia as jest.Mock).mockReset(); + + await deleteLesson(lesson.lessonId, ownerCtx); + + expect(deleteMedia).toHaveBeenCalledWith(descriptionMediaId); + expect(deleteMedia).toHaveBeenCalledWith(attachmentMediaId); + expect(await LessonModel.findOne({ lessonId: lesson.lessonId })).toBe( + null, + ); + }); + const createLessonOfType = async ( + type: string, + overrides: Record = {}, + ) => { + return await createLesson( + { + title: `${type} lesson`, + type, + content: "", + courseId: course.courseId, + groupId, + requiresEnrollment: true, + downloadable: false, + published: true, + ...overrides, + } as any, + ownerCtx, + ); + }; + + const emptyDoc = JSON.stringify({ type: "doc", content: [] }); + + it.each([ + [Constants.LessonType.PDF], + [Constants.LessonType.FILE], + [Constants.LessonType.QUIZ], + ])("rejects attachments on a %s lesson", async (type) => { + await expect( + createLessonOfType(type, { + attachments: [attachment(id("unsupported"), "handout.pdf")], + }), + ).rejects.toThrow(responses.lesson_attachments_not_supported); + }); + + it.each([[Constants.LessonType.QUIZ], [Constants.LessonType.TEXT]])( + "rejects a description on a %s lesson", + async (type) => { + await expect( + createLessonOfType(type, { + content: + type === Constants.LessonType.TEXT + ? emptyDoc + : undefined, + description: JSON.stringify({ + type: "doc", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Notes" }], + }, + ], + }), + }), + ).rejects.toThrow(responses.lesson_description_not_supported); + }, + ); + + it("allows attachments on a text lesson", async () => { + const mediaId = id("text-attachment"); + const lesson = await createLessonOfType(Constants.LessonType.TEXT, { + content: emptyDoc, + attachments: [attachment(mediaId, "worksheet.pdf")], + }); + + expect(lesson.attachments).toHaveLength(1); + expect(lesson.attachments[0].mediaId).toBe(mediaId); + }); + + it("allows a description and attachments on an embed lesson", async () => { + const mediaId = id("embed-attachment"); + const lesson = await createLessonOfType(Constants.LessonType.EMBED, { + content: JSON.stringify({ value: "https://example.com/embed" }), + description: JSON.stringify({ + type: "doc", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Watch this" }], + }, + ], + }), + attachments: [attachment(mediaId, "slides.pdf")], + }); + + expect(lesson.description.content).toHaveLength(1); + expect(lesson.attachments).toHaveLength(1); + }); + + it("rejects attachments added to an existing pdf lesson", async () => { + const lesson = await createLessonOfType(Constants.LessonType.PDF); + + await expect( + updateLesson( + { + id: lesson.lessonId, + attachments: [attachment(id("late"), "handout.pdf")], + } as any, + ownerCtx, + ), + ).rejects.toThrow(responses.lesson_attachments_not_supported); + }); + + it("rejects a description added to an existing text lesson", async () => { + const lesson = await createLessonOfType(Constants.LessonType.TEXT, { + content: emptyDoc, + }); + + await expect( + updateLesson( + { + id: lesson.lessonId, + description: JSON.stringify({ + type: "doc", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Notes" }], + }, + ], + }), + } as any, + ownerCtx, + ), + ).rejects.toThrow(responses.lesson_description_not_supported); + }); + it("rejects a description that is not a ProseMirror document", async () => { + await expect( + createVideoLesson({ + description: JSON.stringify({ foo: 1 }), + }), + ).rejects.toThrow(responses.invalid_input); + }); + + it("removes an attachment even when its media is already gone", async () => { + const mediaId = id("stale-attachment-media"); + const lesson = await createVideoLesson({ + attachments: [attachment(mediaId, "handout.pdf")], + }); + + (deleteMedia as jest.Mock).mockRejectedValue(new Error("Not found")); + + const updated = await updateLesson( + { id: lesson.lessonId, attachments: [] } as any, + ownerCtx, + ); + + expect(deleteMedia).toHaveBeenCalledWith(mediaId); + expect(updated.attachments).toHaveLength(0); + expect( + (await LessonModel.findOne({ lessonId: lesson.lessonId })) + ?.attachments, + ).toHaveLength(0); + }); + + it("deletes a lesson even when its media is already gone", async () => { + const lesson = await createVideoLesson({ + attachments: [attachment(id("undeletable-media"), "handout.pdf")], + }); + + (deleteMedia as jest.Mock).mockRejectedValue(new Error("Not found")); + + await deleteLesson(lesson.lessonId, ownerCtx); + + expect(await LessonModel.findOne({ lessonId: lesson.lessonId })).toBe( + null, + ); + }); + it("rejects an attachment uploaded by another school", async () => { + (sealMedia as jest.Mock).mockImplementation( + async (mediaId: string) => ({ + mediaId, + group: "another-school", + }), + ); + + await expect( + createVideoLesson({ + attachments: [attachment(id("foreign"), "foreign.pdf")], + }), + ).rejects.toThrow(responses.lesson_attachment_not_allowed); + }); + + it("rejects the lesson's own media as an attachment", async () => { + const mediaId = id("main-video"); + const lesson = await createVideoLesson({ + media: attachment(mediaId, "video.mp4"), + }); + + await expect( + updateLesson( + { + id: lesson.lessonId, + attachments: [attachment(mediaId, "video.mp4")], + } as any, + ownerCtx, + ), + ).rejects.toThrow(responses.lesson_attachment_not_allowed); + expect(deleteMedia).not.toHaveBeenCalled(); + }); + + it("rejects an attachment another lesson is already using", async () => { + const mediaId = id("shared-attachment"); + await LessonModel.create({ + domain: testDomain._id, + lessonId: id("other-lesson"), + title: "Other lesson", + type: Constants.LessonType.VIDEO, + creatorId: owner.userId, + courseId: course.courseId, + groupId, + attachments: [attachment(mediaId, "shared.pdf")], + }); + + await expect( + createVideoLesson({ + attachments: [attachment(mediaId, "shared.pdf")], + }), + ).rejects.toThrow(responses.lesson_attachment_not_allowed); + }); + + it("keeps existing attachments without sealing them again", async () => { + const keptId = id("kept-stale-attachment"); + const addedId = id("added-attachment"); + const lesson = await createVideoLesson({ + attachments: [attachment(keptId, "kept.pdf")], + }); + + // The kept file has since disappeared from the media service. + (sealMedia as jest.Mock).mockClear(); + (sealMedia as jest.Mock).mockImplementation(async (mediaId: string) => { + if (mediaId === keptId) { + throw new Error("Media not found"); + } + return { + ...attachment(mediaId, `${mediaId}.pdf`), + group: testDomain.name, + }; + }); + + const updated = await updateLesson( + { + id: lesson.lessonId, + attachments: [ + attachment(keptId, "kept.pdf"), + attachment(addedId, "added.pdf"), + ], + } as any, + ownerCtx, + ); + + expect(sealMedia).not.toHaveBeenCalledWith(keptId); + expect(updated.attachments!.map((item: any) => item.mediaId)).toEqual([ + keptId, + addedId, + ]); + }); + + it("stores a repeated attachment once", async () => { + const mediaId = id("repeated-attachment"); + const lesson = await createVideoLesson({ + attachments: [ + attachment(mediaId, "handout.pdf"), + attachment(mediaId, "handout.pdf"), + ], + }); + + expect(lesson.attachments).toHaveLength(1); + }); + + it("does not delete removed media that another school owns", async () => { + const mediaId = id("foreign-stored-attachment"); + const lesson = await createVideoLesson({ + attachments: [attachment(mediaId, "handout.pdf")], + }); + (getMedia as jest.Mock).mockResolvedValue({ + mediaId, + group: "another-school", + }); + + await updateLesson( + { id: lesson.lessonId, attachments: [] } as any, + ownerCtx, + ); + + expect(deleteMedia).not.toHaveBeenCalled(); + }); + + it("neither seals nor deletes files a description only links to", async () => { + const linkedId = id("linked-media"); + const lesson = await createVideoLesson({ + description: JSON.stringify(linkTo(linkedId)), + }); + + expect(sealMedia).not.toHaveBeenCalledWith(linkedId); + + await updateLesson( + { + id: lesson.lessonId, + description: JSON.stringify({ type: "doc", content: [] }), + } as any, + ownerCtx, + ); + await deleteLesson(lesson.lessonId, ownerCtx); + + expect(deleteMedia).not.toHaveBeenCalledWith(linkedId); + }); + + it("rejects the new lesson's own media as an attachment on create", async () => { + const mediaId = id("create-main-video"); + + await expect( + createVideoLesson({ + media: attachment(mediaId, "video.mp4"), + attachments: [attachment(mediaId, "video.mp4")], + }), + ).rejects.toThrow(responses.lesson_attachment_not_allowed); + }); + + it("rejects a lesson's media that is already a resource", async () => { + const mediaId = id("resource-as-media"); + await LessonModel.create({ + domain: testDomain._id, + lessonId: id("resource-owner-lesson"), + title: "Other lesson", + type: Constants.LessonType.VIDEO, + creatorId: owner.userId, + courseId: course.courseId, + groupId, + attachments: [attachment(mediaId, "shared.pdf")], + }); + const lesson = await createVideoLesson(); + + await expect( + updateLesson( + { + id: lesson.lessonId, + media: attachment(mediaId, "shared.pdf"), + } as any, + ownerCtx, + ), + ).rejects.toThrow(responses.lesson_media_used_as_attachment); + await expect( + createVideoLesson({ media: attachment(mediaId, "shared.pdf") }), + ).rejects.toThrow(responses.lesson_media_used_as_attachment); + }); + + it("does not delete a removed description image another lesson uses", async () => { + const mediaId = id("shared-description-image"); + await LessonModel.create({ + domain: testDomain._id, + lessonId: id("image-owner-lesson"), + title: "Other lesson", + type: Constants.LessonType.VIDEO, + creatorId: owner.userId, + courseId: course.courseId, + groupId, + media: attachment(mediaId, "image.jpg"), + }); + const lesson = await createVideoLesson({ + description: JSON.stringify(docWithImage(mediaId)), + }); + + await updateLesson( + { + id: lesson.lessonId, + description: JSON.stringify({ type: "doc", content: [] }), + } as any, + ownerCtx, + ); + await deleteLesson(lesson.lessonId, ownerCtx); + + expect(deleteMedia).not.toHaveBeenCalledWith(mediaId); + }); + + it("rejects attachments that are not a list", async () => { + await expect(createVideoLesson({ attachments: {} })).rejects.toThrow( + responses.invalid_input, + ); + + const lesson = await createVideoLesson(); + await expect( + updateLesson( + { id: lesson.lessonId, attachments: {} } as any, + ownerCtx, + ), + ).rejects.toThrow(responses.invalid_input); + }); + + it("keeps the file url only on public attachments", async () => { + const publicId = id("public-attachment"); + const privateId = id("private-attachment"); + (sealMedia as jest.Mock).mockImplementation( + async (mediaId: string) => ({ + ...attachment(mediaId, `${mediaId}.pdf`), + access: mediaId === publicId ? "public" : "private", + group: testDomain.name, + }), + ); + + const lesson = await createVideoLesson({ + attachments: [ + attachment(publicId, "public.pdf"), + attachment(privateId, "private.pdf"), + ], + }); + + const [stored, storedPrivate] = lesson.attachments as any[]; + expect(stored.file).toBe( + `https://media.example.com/${publicId}/main.pdf`, + ); + expect(storedPrivate.file).toBeUndefined(); + }); +}); diff --git a/apps/web/graphql/lessons/logic.ts b/apps/web/graphql/lessons/logic.ts index 76df25959..3bc92af2c 100644 --- a/apps/web/graphql/lessons/logic.ts +++ b/apps/web/graphql/lessons/logic.ts @@ -17,7 +17,7 @@ import { } from "./helpers"; import constants from "../../config/constants"; import GQLContext from "../../models/GQLContext"; -import { deleteMedia, sealMedia } from "../../services/medialit"; +import { deleteMedia, getMedia, sealMedia } from "../../services/medialit"; import { recordProgress } from "../users/logic"; import { Constants, @@ -25,10 +25,17 @@ import { Progress, Quiz, ScormContent, + TextEditorContent, User, } from "@courselit/common-models"; import LessonEvaluation from "../../models/LessonEvaluation"; -import { checkPermission, extractMediaIDs } from "@courselit/utils"; +import { + checkPermission, + extractMediaIDs, + extractMediaIDsFromNodeSources, + lessonTypeSupportsAttachments, + lessonTypeSupportsDescription, +} from "@courselit/utils"; import { recordActivity } from "../../lib/record-activity"; import { InternalCourse } from "@courselit/orm-models"; import CertificateModel from "../../models/Certificate"; @@ -39,7 +46,7 @@ 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; +const { permissions, quiz, scorm, privateMedia } = constants; async function canManageLessonCourse(lesson: Lesson, ctx: GQLContext) { if (!ctx.user) { @@ -172,10 +179,91 @@ export const getLessonDetails = async ( return lesson; }; -export type LessonWithStringContent = Omit & { +export type LessonWithStringContent = Omit< + Lesson, + "content" | "description" +> & { content: string; + description?: string; }; +function validateLessonDescriptionOrThrow(description?: string | null) { + if (!description) { + return; + } + + let parsedDescription: unknown; + try { + parsedDescription = JSON.parse(description); + } catch (err) { + throw new Error(responses.invalid_input); + } + + if ( + typeof parsedDescription !== "object" || + parsedDescription === null || + Array.isArray(parsedDescription) || + (parsedDescription as TextEditorContent).type !== "doc" || + !Array.isArray((parsedDescription as TextEditorContent).content) + ) { + throw new Error(responses.invalid_input); + } +} + +// Descriptions and attachments reference media by id, so only delete media +// this school uploaded that no lesson still uses as its media or a resource. +// The media service may also no longer know about some of them, and a failed +// cleanup must not take the surrounding change down. +async function deleteOwnedMediaQuietly( + mediaId: string, + ctx: GQLContext, + excludeLessonId?: string, +) { + try { + const media = await getMedia(mediaId); + if (media?.group !== ctx.subdomain.name) { + return false; + } + + const stillUsed = await LessonModel.exists({ + domain: ctx.subdomain._id, + ...(excludeLessonId ? { lessonId: { $ne: excludeLessonId } } : {}), + $or: [ + { "media.mediaId": mediaId }, + { "attachments.mediaId": mediaId }, + ], + }); + if (stillUsed) { + return false; + } + + return await deleteMedia(mediaId); + } catch (err: any) { + error(err.message, { mediaId }); + return false; + } +} + +// Not every lesson type offers these fields in the editor, so reject values the +// UI would never show and the viewer would never render. +function validateLessonFieldSupportOrThrow({ + type, + description, + attachments, +}: Pick) { + if (attachments != null && !Array.isArray(attachments)) { + throw new Error(responses.invalid_input); + } + + if (description && !lessonTypeSupportsDescription(type)) { + throw new Error(responses.lesson_description_not_supported); + } + + if (attachments?.length && !lessonTypeSupportsAttachments(type)) { + throw new Error(responses.lesson_attachments_not_supported); + } +} + async function sealLessonMedia(media?: Partial | null) { if (!media?.mediaId) { return undefined; @@ -190,6 +278,97 @@ async function sealLessonMedia(media?: Partial | null) { return sealedMedia; } +// Removing a resource deletes its media, so a lesson's media must not be a +// file that any lesson already offers as a resource. +async function assertNotUsedAsAttachmentOrThrow( + mediaId: string | undefined, + ctx: GQLContext, +) { + if (!mediaId) { + return; + } + + const usedAsAttachment = await LessonModel.exists({ + domain: ctx.subdomain._id, + "attachments.mediaId": mediaId, + }); + if (usedAsAttachment) { + throw new Error(responses.lesson_media_used_as_attachment); + } +} + +// Attachments already on the lesson are kept as stored, so a file the media +// service has since lost cannot block edits to the rest of the list. +async function sealLessonAttachments( + attachments: Partial[] | null | undefined, + ctx: GQLContext, + primaryMediaId: string | undefined, + lesson?: Lesson, +): Promise { + if (!attachments?.length) { + return []; + } + + if (attachments.some((attachment) => !attachment?.mediaId)) { + throw new Error(responses.invalid_input); + } + + const storedAttachments = new Map( + (lesson?.attachments || []).map((attachment) => [ + attachment.mediaId, + attachment as Media, + ]), + ); + const sealedAttachments = new Map(); + for (const attachment of attachments) { + const mediaId = attachment.mediaId!; + if (sealedAttachments.has(mediaId)) { + continue; + } + + if (mediaId === primaryMediaId) { + throw new Error(responses.lesson_attachment_not_allowed); + } + + sealedAttachments.set( + mediaId, + storedAttachments.get(mediaId) ?? + (await sealNewLessonAttachment(mediaId, ctx, lesson)), + ); + } + + return Array.from(sealedAttachments.values()); +} + +// Removing an attachment deletes its media, so a new attachment must be a file +// this school uploaded that no lesson is already using. +async function sealNewLessonAttachment( + mediaId: string, + ctx: GQLContext, + lesson?: Lesson, +): Promise { + const usedByAnotherLesson = await LessonModel.exists({ + domain: ctx.subdomain._id, + ...(lesson ? { lessonId: { $ne: lesson.lessonId } } : {}), + $or: [{ "media.mediaId": mediaId }, { "attachments.mediaId": mediaId }], + }); + if (usedByAnotherLesson) { + throw new Error(responses.lesson_attachment_not_allowed); + } + + const sealedMedia = await sealMedia(mediaId); + if (!sealedMedia || sealedMedia.group !== ctx.subdomain.name) { + throw new Error(responses.lesson_attachment_not_allowed); + } + + // Private file URLs are signed and expire, so they are fetched on read. + // Public ones are permanent, and the read path does not refetch them. + if (sealedMedia.access === privateMedia) { + delete sealedMedia.file; + } + return sealedMedia; +} + export const createLesson = async ( lessonData: LessonWithStringContent, ctx: GQLContext, @@ -200,6 +379,13 @@ export const createLesson = async ( } lessonValidator(lessonData); + validateLessonDescriptionOrThrow(lessonData.description); + validateLessonFieldSupportOrThrow({ + type: lessonData.type, + description: lessonData.description, + attachments: lessonData.attachments, + }); + await assertNotUsedAsAttachmentOrThrow(lessonData.media?.mediaId, ctx); try { const course: InternalCourse | null = await CourseModel.findOne({ @@ -224,6 +410,17 @@ export const createLesson = async ( lessonData.content || "", ), media: await sealLessonMedia(lessonData.media), + description: lessonData.description + ? await replaceTempMediaWithSealedMediaInProseMirrorDoc( + lessonData.description, + true, + ) + : undefined, + attachments: await sealLessonAttachments( + lessonData.attachments, + ctx, + lessonData.media?.mediaId, + ), downloadable: lessonData.downloadable, creatorId: ctx.user.userId, courseId: course.courseId, @@ -248,6 +445,8 @@ export const updateLesson = async ( | "title" | "content" | "media" + | "description" + | "attachments" | "downloadable" | "requiresEnrollment" | "published" @@ -265,6 +464,14 @@ export const updateLesson = async ( lessonData, "content", ); + const descriptionUpdated = Object.prototype.hasOwnProperty.call( + lessonData, + "description", + ); + const attachmentsUpdated = Object.prototype.hasOwnProperty.call( + lessonData, + "attachments", + ); // Build the complete lesson state for validation by merging existing + update data. // The validator expects content as a string. @@ -280,6 +487,10 @@ export const updateLesson = async ( ? lessonData.content! : JSON.stringify(lesson.content || ""), media: lessonData.media ?? lesson.media, + description: + lessonData.description ?? + (lesson.description ? JSON.stringify(lesson.description) : ""), + attachments: lessonData.attachments ?? lesson.attachments, downloadable: lessonData.downloadable ?? lesson.downloadable, requiresEnrollment: lessonData.requiresEnrollment ?? lesson.requiresEnrollment, @@ -288,6 +499,18 @@ export const updateLesson = async ( }; lessonValidator(completeLessonData); + validateLessonFieldSupportOrThrow({ + type: lesson.type, + description: descriptionUpdated ? lessonData.description : undefined, + attachments: attachmentsUpdated ? lessonData.attachments : undefined, + }); + + const nextPrimaryMediaId = lessonData.media + ? lessonData.media.mediaId + : lesson.media?.mediaId; + if (lessonData.media && nextPrimaryMediaId !== lesson.media?.mediaId) { + await assertNotUsedAsAttachmentOrThrow(nextPrimaryMediaId, ctx); + } // Now apply the partial updates to the lesson document const contentMediaIdsMarkedForDeletion: string[] = []; @@ -301,6 +524,38 @@ export const updateLesson = async ( ); } + // Description and attachment media is only deleted if this school owns it. + const ownedMediaIdsMarkedForDeletion: string[] = []; + if (descriptionUpdated) { + validateLessonDescriptionOrThrow(lessonData.description); + const nextDescriptionMediaIds = extractMediaIDsFromNodeSources( + lessonData.description ?? "", + ); + ownedMediaIdsMarkedForDeletion.push( + ...Array.from( + extractMediaIDsFromNodeSources(lesson.description), + ).filter((mediaId) => !nextDescriptionMediaIds.has(mediaId)), + ); + } + + // Attachments removed by this update would otherwise be orphaned in the + // media service, so mark their media for deletion alongside the rest. + if (attachmentsUpdated) { + const retainedMediaIds = new Set( + (lessonData.attachments || []).map( + (attachment) => attachment?.mediaId, + ), + ); + for (const attachment of lesson.attachments || []) { + if ( + attachment.mediaId && + !retainedMediaIds.has(attachment.mediaId) + ) { + ownedMediaIdsMarkedForDeletion.push(attachment.mediaId); + } + } + } + for (const key of Object.keys(lessonData)) { if (key === "content") { lesson.content = @@ -311,15 +566,39 @@ export const updateLesson = async ( : JSON.parse(lessonData.content); } else if (key === "media" && lessonData.media) { lesson.media = await sealLessonMedia(lessonData.media); + } else if (key === "description") { + lesson.description = + await replaceTempMediaWithSealedMediaInProseMirrorDoc( + lessonData.description || "", + true, + ); + } else if (key === "attachments") { + lesson.attachments = await sealLessonAttachments( + lessonData.attachments, + ctx, + nextPrimaryMediaId, + lesson, + ); } else if (key !== "lessonId" && key !== "id") { lesson[key] = lessonData[key]; } } + lesson = await (lesson as any).save(); + + // Media cleanup runs after the lesson is persisted and never fails the + // update: a media id the media service no longer knows about would + // otherwise make the attachment or image impossible to remove. for (const mediaId of contentMediaIdsMarkedForDeletion) { - await deleteMedia(mediaId); + try { + await deleteMedia(mediaId); + } catch (err: any) { + error(err.message, { mediaId, lessonId: lesson.lessonId }); + } + } + for (const mediaId of ownedMediaIdsMarkedForDeletion) { + await deleteOwnedMediaQuietly(mediaId, ctx); } - lesson = await (lesson as any).save(); return lesson; }; @@ -333,6 +612,26 @@ export const deleteLesson = async (id: string, ctx: GQLContext) => { cleanupTasks.push(deleteMedia(lesson.media.mediaId)); } + for (const attachment of lesson.attachments || []) { + if (attachment.mediaId) { + cleanupTasks.push( + deleteOwnedMediaQuietly( + attachment.mediaId, + ctx, + lesson.lessonId, + ), + ); + } + } + + for (const mediaId of Array.from( + extractMediaIDsFromNodeSources(lesson.description), + )) { + cleanupTasks.push( + deleteOwnedMediaQuietly(mediaId, ctx, lesson.lessonId), + ); + } + if (lesson.type === Constants.LessonType.TEXT && lesson.content) { const extractedMediaIds = extractMediaIDs( JSON.stringify(lesson.content), diff --git a/apps/web/graphql/lessons/types.ts b/apps/web/graphql/lessons/types.ts index e4b24cb51..ee861ca26 100644 --- a/apps/web/graphql/lessons/types.ts +++ b/apps/web/graphql/lessons/types.ts @@ -8,10 +8,12 @@ import { GraphQLBoolean, GraphQLInt, GraphQLFloat, + GraphQLList, } from "graphql"; import constants from "../../config/constants"; import mediaTypes from "../media/types"; import { getMedia } from "../media/logic"; +import { error } from "@/services/logger"; import { GraphQLJSONObject } from "graphql-type-json"; const { text, audio, video, pdf, quiz, file, embed, scorm } = constants; @@ -58,6 +60,23 @@ const lessonType = new GraphQLObjectType({ type: mediaTypes.mediaType, resolve: (lesson, _, __, ___) => getMedia(lesson.media), }, + description: { type: GraphQLJSONObject }, + attachments: { + type: new GraphQLList(mediaTypes.mediaType), + // A resource the media service has lost is returned as stored, so + // the lesson still loads and the editor can remove it. + resolve: (lesson, _, __, ___) => + Promise.all( + (lesson.attachments || []).map((attachment) => + getMedia(attachment).catch((err) => { + error(err.message, { + mediaId: attachment.mediaId, + }); + return attachment; + }), + ), + ), + }, prevLesson: { type: GraphQLString }, nextLesson: { type: GraphQLString }, }, @@ -98,6 +117,8 @@ const lessonInputType = new GraphQLInputObjectType({ }, content: { type: GraphQLString }, // media: { type: mediaTypes.mediaInputType }, + description: { type: GraphQLString }, + attachments: { type: new GraphQLList(mediaTypes.mediaInputType) }, downloadable: { type: GraphQLBoolean }, groupId: { type: new GraphQLNonNull(GraphQLID) }, published: { type: GraphQLBoolean }, @@ -114,6 +135,8 @@ const lessonUpdateType = new GraphQLInputObjectType({ title: { type: GraphQLString }, content: { type: GraphQLString }, media: { type: mediaTypes.mediaInputType }, + description: { type: GraphQLString }, + attachments: { type: new GraphQLList(mediaTypes.mediaInputType) }, downloadable: { type: GraphQLBoolean }, requiresEnrollment: { description: DESCRIPTION_REQUIRES_ENROLLMENT, diff --git a/apps/web/lib/replace-temp-media-with-sealed-media-in-prosemirror-doc.ts b/apps/web/lib/replace-temp-media-with-sealed-media-in-prosemirror-doc.ts index c1d4329c6..a6259c724 100644 --- a/apps/web/lib/replace-temp-media-with-sealed-media-in-prosemirror-doc.ts +++ b/apps/web/lib/replace-temp-media-with-sealed-media-in-prosemirror-doc.ts @@ -1,13 +1,23 @@ import { sealMedia } from "@/services/medialit"; -import { extractMediaIDs } from "@courselit/utils"; +import { + extractMediaIDs, + extractMediaIDsFromNodeSources, +} from "@courselit/utils"; import { TextEditorContent } from "@courselit/common-models"; export async function replaceTempMediaWithSealedMediaInProseMirrorDoc( doc: string, + sourcesOnly = false, ): Promise { if (!doc) return { type: "doc", content: [] }; - const mediaIds = Array.from(extractMediaIDs(doc)); + // With `sourcesOnly`, links in the document are left alone and only media + // the document embeds (images and the like) is sealed. + const mediaIds = Array.from( + sourcesOnly + ? extractMediaIDsFromNodeSources(doc) + : extractMediaIDs(doc), + ); for (const mediaId of mediaIds) { const media = await sealMedia(mediaId); if (media) { diff --git a/apps/web/models/Lesson.ts b/apps/web/models/Lesson.ts index 73ecd43a8..f1f7c7efc 100644 --- a/apps/web/models/Lesson.ts +++ b/apps/web/models/Lesson.ts @@ -26,6 +26,8 @@ export interface Lesson { | typeof scorm; content?: Quiz | TextEditorContent | ScormContent | { value: string }; media?: Media; + description?: TextEditorContent; + attachments?: Media[]; downloadable: boolean; creatorId: string; courseId: string; @@ -45,6 +47,8 @@ const LessonSchema = new mongoose.Schema({ }, content: { type: mongoose.Schema.Types.Mixed, default: {} }, media: MediaSchema, + description: { type: mongoose.Schema.Types.Mixed }, + attachments: { type: [MediaSchema], default: [] }, downloadable: { type: Boolean, default: false }, creatorId: { type: String, required: true }, courseId: { type: String, required: true }, diff --git a/apps/web/ui-config/strings.ts b/apps/web/ui-config/strings.ts index 8f660fda6..24d60ec0e 100644 --- a/apps/web/ui-config/strings.ts +++ b/apps/web/ui-config/strings.ts @@ -898,6 +898,21 @@ export const LOGIN_CODE_SENT_MESSAGE = "We have emailed you a one time password."; export const LESSON_EMBED_URL_LABEL = "Embed code"; export const LESSON_CONTENT_LABEL = "Content"; +export const LESSON_MEDIA_LABEL = "Media"; +export const LESSON_DESCRIPTION_LABEL = "Description"; +export const LESSON_DESCRIPTION_TOOLTIP = + "Add notes, instructions or links to go alongside this lesson's content."; +export const LESSON_DESCRIPTION_PLACEHOLDER = + "Add notes or links for this lesson (optional)"; +export const LESSON_RESOURCES_LABEL = "Resources"; +export const LESSON_RESOURCES_TOOLTIP = + "Attach files your students can download, such as PDFs, worksheets or slides."; +export const LESSON_RESOURCES_SAVE_LESSON_FIRST = + "Save the lesson to enable resource uploads"; +export const LESSON_RESOURCES_ADD = "Add a resource"; +export const LESSON_RESOURCES_SAVED = + "Resources saved. Save the lesson to keep any other changes."; +export const LESSON_RESOURCES_HEADER = "Resources"; export const EMAIL_EDITOR_EMAIL_EDIT_HEADER = "Editing email"; export const EMAIL_EDITOR_TEMPLATE_EDIT_HEADER = "Editing template"; export const LOGIN_FORM_PERSONAL_INFORMATION_LABEL = "Personal Information"; diff --git a/apps/web/ui-lib/utils.ts b/apps/web/ui-lib/utils.ts index 7665f5b05..16ef632c7 100644 --- a/apps/web/ui-lib/utils.ts +++ b/apps/web/ui-lib/utils.ts @@ -3,6 +3,7 @@ import type { CommunityMemberStatus, CommunityReportStatus, Features, + Media, Membership, MembershipRole, Page, @@ -323,6 +324,22 @@ export function isTextEditorNonEmpty(content: TextEditorContent) { ); } +// Unlike isTextEditorNonEmpty, this also counts a document holding only an +// image, and treats a lone empty paragraph (a cleared editor) as empty. +export function hasTextEditorContent(content?: TextEditorContent) { + const nodes = content?.content || []; + if (nodes.length > 1) { + return true; + } + + const [node] = nodes; + return Boolean( + node && + (node.type !== "paragraph" || + (Array.isArray(node.content) && node.content.length > 0)), + ); +} + export function getNextStatusForCommunityMember(status: CommunityMemberStatus) { const statusCycle = [ Constants.MembershipStatus.PENDING, @@ -394,3 +411,14 @@ export function hasCommunityPermission( return memberRoleIndex >= requiredRoleIndex; } + +// Shapes media for a MediaInput, dropping the file URL of private media so a +// signed, expiring URL is never stored. +export function toMediaInput(media: Partial): Partial { + return Object.assign({}, media, { + file: + media.access === Constants.MediaAccessType.PUBLIC + ? media.file + : null, + }); +} diff --git a/packages/common-models/src/constants.ts b/packages/common-models/src/constants.ts index 3e6ff7aa7..0e64ba0c8 100644 --- a/packages/common-models/src/constants.ts +++ b/packages/common-models/src/constants.ts @@ -122,6 +122,21 @@ export const LessonType = { QUIZ: "quiz", SCORM: "scorm", } as const; +/** Lesson types that can carry a rich text description alongside their content. */ +export const LessonTypesWithDescription = [ + LessonType.VIDEO, + LessonType.AUDIO, + LessonType.EMBED, + LessonType.PDF, + LessonType.FILE, +] as const; +/** Lesson types that can carry downloadable resources alongside their content. */ +export const LessonTypesWithAttachments = [ + LessonType.TEXT, + LessonType.VIDEO, + LessonType.AUDIO, + LessonType.EMBED, +] as const; export const ActivityType = { ENROLLED: "enrolled", PURCHASED: "purchased", diff --git a/packages/common-models/src/lesson.ts b/packages/common-models/src/lesson.ts index 595bd53a5..91a8f202f 100644 --- a/packages/common-models/src/lesson.ts +++ b/packages/common-models/src/lesson.ts @@ -15,6 +15,8 @@ export default interface Lesson { downloadable: boolean; published: boolean; media?: Partial; + description?: TextEditorContent; + attachments?: Partial[]; prevLesson?: string; nextLesson?: string; } diff --git a/packages/common-models/src/media.ts b/packages/common-models/src/media.ts index ba787cdb7..6a24c633b 100644 --- a/packages/common-models/src/media.ts +++ b/packages/common-models/src/media.ts @@ -12,4 +12,6 @@ export interface Media { thumbnail: string; caption?: string; file?: string; + /** The media service group the file was uploaded under, i.e. the school's domain name. */ + group?: string; } diff --git a/packages/components-library/src/media-selector/file-upload-dialog.tsx b/packages/components-library/src/media-selector/file-upload-dialog.tsx index 736ca4506..199b0b734 100644 --- a/packages/components-library/src/media-selector/file-upload-dialog.tsx +++ b/packages/components-library/src/media-selector/file-upload-dialog.tsx @@ -119,6 +119,7 @@ export function FileUploadAlertDialog({