From 871d702da602f117c1058035d002301dd3139573 Mon Sep 17 00:00:00 2001 From: Pete <135653545+pdmarf@users.noreply.github.com> Date: Mon, 14 Sep 2026 18:22:10 +0000 Subject: [PATCH 1/5] feat(lessons): add description and resources to media lessons Media-type lessons (video, audio, PDF and file) can now carry a rich-text description and a set of downloadable resources alongside their media, so creators can provide notes, instructions and supporting files in place. - Add `description` (ProseMirror doc) and `attachments` (Media[]) to the lesson schemas in models/Lesson.ts, orm-models and common-models - Expose both fields on the GraphQL lesson, input and update types, with attachments resolved through getMedia - Seal temporary media in descriptions and attachments on create/update, and clean up attachment and description media when a lesson is deleted - Add a LessonAttachments component and surface Media, Description and Resources sections in the lesson editor - Render the description and resource download links in the lesson viewer for media-type lessons - Expose the new fields through the REST lesson routes and OpenAPI spec - Fix two media-selector buttons that submitted their enclosing form Co-Authored-By: Claude Opus 5 (1M context) --- .../[section]/lesson/lesson-attachments.tsx | 59 ++++ .../content/section/[section]/lesson/page.tsx | 183 +++++++++-- .../[productId]/lessons/[lessonId]/route.ts | 6 + .../[productId]/lessons/lesson-response.ts | 7 + .../api/products/[productId]/lessons/route.ts | 2 + apps/web/app/api/products/openapi.mjs | 41 ++- .../components/public/lesson-viewer/index.tsx | 66 +++- .../graphql/lessons/__tests__/logic.test.ts | 288 ++++++++++++++++++ apps/web/graphql/lessons/logic.ts | 69 ++++- apps/web/graphql/lessons/types.ts | 15 + apps/web/models/Lesson.ts | 4 + apps/web/ui-config/strings.ts | 13 + packages/common-models/src/lesson.ts | 2 + .../src/media-selector/file-upload-dialog.tsx | 1 + .../src/media-selector/index.tsx | 1 + packages/orm-models/src/models/lesson.ts | 4 + 16 files changed, 732 insertions(+), 29 deletions(-) create mode 100644 apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/section/[section]/lesson/lesson-attachments.tsx create mode 100644 apps/web/graphql/lessons/__tests__/logic.test.ts 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..5bd28b84a --- /dev/null +++ b/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/section/[section]/lesson/lesson-attachments.tsx @@ -0,0 +1,59 @@ +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/page.tsx b/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/section/[section]/lesson/page.tsx index c581a354b..bfe69b693 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,13 @@ 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, } from "@ui-config/strings"; import DashboardContent from "@components/admin/dashboard-content"; import useProduct from "@/hooks/use-product"; @@ -52,6 +60,7 @@ import { Constants, Lesson, LessonType, + Media, TextEditorContent, UIConstants, } from "@courselit/common-models"; @@ -60,9 +69,10 @@ import { FetchBuilder } from "@courselit/utils"; import { LessonContentRenderer } from "./lesson-content-renderer"; import { isTextEditorNonEmpty, 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 +93,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 +157,12 @@ export default function LessonPage() { ? {} : { value: "" }, ); + const [description, setDescription] = useState( + TextEditorEmptyDoc as unknown as TextEditorContent, + ); + const [attachments, setAttachments] = useState[]>([]); const [isLoading, setIsLoading] = useState(isEditing); + const isMediaLesson = mediaLessonTypes.includes(lesson.type as LessonType); useEffect(() => { if (product && !lesson.lessonId) { @@ -184,7 +206,18 @@ export default function LessonPage() { }, requiresEnrollment, published, - lessonId + lessonId, + description, + attachments { + mediaId, + originalFileName, + mimeType, + size, + access, + file, + thumbnail, + caption + } } } `; @@ -209,6 +242,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 +347,9 @@ export default function LessonPage() { content: JSON.stringify(content), requiresEnrollment: lesson?.requiresEnrollment, published: !!lesson?.published, + ...(isMediaLesson + ? { description: JSON.stringify(description) } + : {}), }, }, }) @@ -354,6 +395,12 @@ export default function LessonPage() { requiresEnrollment: lesson?.requiresEnrollment, groupId: lesson?.groupId, published: !!lesson?.published, + ...(isMediaLesson + ? { + description: JSON.stringify(description), + attachments, + } + : {}), }, }, }) @@ -387,6 +434,55 @@ export default function LessonPage() { } }; + const saveAttachments = async (nextAttachments: Partial[]) => { + const previousAttachments = attachments; + setAttachments(nextAttachments); + + 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((attachment) => + Object.assign({}, attachment, { + file: + attachment.access === "public" + ? attachment.file + : null, + }), + ), + }, + }) + .setIsGraphQLEndpoint(true) + .build(); + + try { + await fetch.exec(); + toast({ + title: TOAST_TITLE_SUCCESS, + description: "Lesson updated", + }); + } catch (err: any) { + setAttachments(previousAttachments); + toast({ + title: TOAST_TITLE_ERROR, + description: err.message, + variant: "destructive", + }); + } + }; + const onLessonDelete = async () => { if (lesson?.lessonId) { const query = ` @@ -604,6 +700,66 @@ export default function LessonPage() { /> )} + {isMediaLesson && ( + <> + + { + setLesson( + Object.assign( + {}, + lesson, + updates, + ), + ); + }} + /> + +
+ +

+ {LESSON_DESCRIPTION_TOOLTIP} +

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

+ {LESSON_RESOURCES_TOOLTIP} +

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

+ + {LESSON_RESOURCES_SAVE_LESSON_FIRST} +

+ )} + + )} {product?.type?.toLowerCase() === Constants.CourseType.COURSE && lesson.type !== Constants.LessonType.QUIZ && ( @@ -723,29 +879,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..08a77d7f1 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,10 @@ function toExistingUpdatePayload( payload.content = JSON.stringify(body.content); } + if (Object.prototype.hasOwnProperty.call(body, "description")) { + payload.description = JSON.stringify(body.description); + } + return payload; } 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..509382b59 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,8 @@ export function toExistingLessonPayload( content: Object.prototype.hasOwnProperty.call(body, "content") ? JSON.stringify(body.content) : undefined, + description: Object.prototype.hasOwnProperty.call(body, "description") + ? JSON.stringify(body.description) + : undefined, }; } 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..4cbcb07f7 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`, and may additionally carry a `description` document and `attachments`.", }; 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`, and may additionally carry a `description` document and `attachments`. 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 media on `video`, `audio`, `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 `video`, `audio`, `pdf` and `file` 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 `video`, `audio`, `pdf` and `file` 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 `video`, `audio`, `pdf` and `file` 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..3271eec25 100644 --- a/apps/web/components/public/lesson-viewer/index.tsx +++ b/apps/web/components/public/lesson-viewer/index.tsx @@ -20,6 +20,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"; @@ -95,6 +96,18 @@ export const LessonViewer = ({ const { toast } = useToast(); const { theme } = useContext(ThemeContext); const viewerProfile = profile?.userId ? (profile as Profile) : undefined; + const isMediaLesson = Boolean( + lesson && + [ + LESSON_TYPE_VIDEO, + LESSON_TYPE_AUDIO, + LESSON_TYPE_PDF, + LESSON_TYPE_FILE, + ].some( + (type) => + String.prototype.toUpperCase.call(type) === lesson.type, + ), + ); const isViewerEnrolled = Boolean( lesson && viewerProfile && isEnrolled(lesson.courseId, viewerProfile), ); @@ -137,7 +150,14 @@ export const LessonViewer = ({ requiresEnrollment, courseId, prevLesson, - nextLesson + nextLesson, + description, + attachments { + mediaId, + file, + caption, + originalFileName + } } } `; @@ -414,6 +434,50 @@ export const LessonViewer = ({ } /> )} + {isMediaLesson && lesson.description && ( + +
+ +
+
+ )} + {isMediaLesson && lesson.attachments?.length ? ( +
+ + {LESSON_RESOURCES_HEADER} + +
+ {lesson.attachments.map( + (attachment, index) => ( + + + + ), + )} +
+
+ ) : null} {isViewerEnrolled && !isPreview && (
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..dac1d1760 --- /dev/null +++ b/apps/web/graphql/lessons/__tests__/logic.test.ts @@ -0,0 +1,288 @@ +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, sealMedia } from "@/services/medialit"; + +jest.mock("@/services/medialit", () => ({ + deleteMedia: 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(); + (sealMedia as jest.Mock).mockResolvedValue(undefined); + }); + + 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", + }), + ); + + 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("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, + ); + }); +}); diff --git a/apps/web/graphql/lessons/logic.ts b/apps/web/graphql/lessons/logic.ts index 76df25959..292e63d29 100644 --- a/apps/web/graphql/lessons/logic.ts +++ b/apps/web/graphql/lessons/logic.ts @@ -172,8 +172,12 @@ export const getLessonDetails = async ( return lesson; }; -export type LessonWithStringContent = Omit & { +export type LessonWithStringContent = Omit< + Lesson, + "content" | "description" +> & { content: string; + description?: string; }; async function sealLessonMedia(media?: Partial | null) { @@ -190,6 +194,20 @@ async function sealLessonMedia(media?: Partial | null) { return sealedMedia; } +async function sealLessonAttachments(attachments?: Partial[] | null) { + if (!attachments?.length) { + return []; + } + + const sealedAttachments = await Promise.all( + attachments.map((attachment) => sealLessonMedia(attachment)), + ); + + return sealedAttachments.filter((attachment): attachment is Media => + Boolean(attachment), + ); +} + export const createLesson = async ( lessonData: LessonWithStringContent, ctx: GQLContext, @@ -224,6 +242,12 @@ export const createLesson = async ( lessonData.content || "", ), media: await sealLessonMedia(lessonData.media), + description: lessonData.description + ? await replaceTempMediaWithSealedMediaInProseMirrorDoc( + lessonData.description, + ) + : undefined, + attachments: await sealLessonAttachments(lessonData.attachments), downloadable: lessonData.downloadable, creatorId: ctx.user.userId, courseId: course.courseId, @@ -248,6 +272,8 @@ export const updateLesson = async ( | "title" | "content" | "media" + | "description" + | "attachments" | "downloadable" | "requiresEnrollment" | "published" @@ -280,6 +306,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, @@ -301,6 +331,19 @@ export const updateLesson = async ( ); } + const descriptionUpdated = Object.prototype.hasOwnProperty.call( + lessonData, + "description", + ); + if (descriptionUpdated) { + contentMediaIdsMarkedForDeletion.push( + ...getDeletedMediaIds( + JSON.stringify(lesson.description || ""), + (lessonData.description ?? "") as string, + ), + ); + } + for (const key of Object.keys(lessonData)) { if (key === "content") { lesson.content = @@ -311,6 +354,15 @@ 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 || "", + ); + } else if (key === "attachments") { + lesson.attachments = await sealLessonAttachments( + lessonData.attachments, + ); } else if (key !== "lessonId" && key !== "id") { lesson[key] = lessonData[key]; } @@ -333,6 +385,21 @@ 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(deleteMedia(attachment.mediaId)); + } + } + + if (lesson.description) { + const descriptionMediaIds = extractMediaIDs( + JSON.stringify(lesson.description), + ); + for (const mediaId of Array.from(descriptionMediaIds)) { + cleanupTasks.push(deleteMedia(mediaId)); + } + } + 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..f339413f5 100644 --- a/apps/web/graphql/lessons/types.ts +++ b/apps/web/graphql/lessons/types.ts @@ -8,6 +8,7 @@ import { GraphQLBoolean, GraphQLInt, GraphQLFloat, + GraphQLList, } from "graphql"; import constants from "../../config/constants"; import mediaTypes from "../media/types"; @@ -58,6 +59,16 @@ const lessonType = new GraphQLObjectType({ type: mediaTypes.mediaType, resolve: (lesson, _, __, ___) => getMedia(lesson.media), }, + description: { type: GraphQLJSONObject }, + attachments: { + type: new GraphQLList(mediaTypes.mediaType), + resolve: (lesson, _, __, ___) => + Promise.all( + (lesson.attachments || []).map((attachment) => + getMedia(attachment), + ), + ), + }, prevLesson: { type: GraphQLString }, nextLesson: { type: GraphQLString }, }, @@ -98,6 +109,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 +127,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/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..9f257d93b 100644 --- a/apps/web/ui-config/strings.ts +++ b/apps/web/ui-config/strings.ts @@ -898,6 +898,19 @@ 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 media."; +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_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/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/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({