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({
{
@@ -86,6 +90,13 @@ const MediaSelector = (props: MediaSelectorProps) => {
};
const removeFile = async () => {
+ if (props.deleteOnRemove === false) {
+ if (props.onRemove) {
+ props.onRemove();
+ }
+ return;
+ }
+
try {
setUploading(true);
const fetch = new FetchBuilder()
@@ -131,6 +142,7 @@ const MediaSelector = (props: MediaSelectorProps) => {
)}
{props.mediaId && (
({
},
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/packages/scripts/src/cleanup-domain.ts b/packages/scripts/src/cleanup-domain.ts
index 480fc4ff7..24272975a 100644
--- a/packages/scripts/src/cleanup-domain.ts
+++ b/packages/scripts/src/cleanup-domain.ts
@@ -44,7 +44,10 @@ import type {
} from "@courselit/orm-models";
import { loadEnvFile } from "node:process";
import { MediaLit } from "medialit";
-import { extractMediaIDs } from "@courselit/utils";
+import {
+ extractMediaIDs,
+ extractMediaIDsFromNodeSources,
+} from "@courselit/utils";
import CommonModels from "@courselit/common-models";
const { CommunityMediaTypes, Constants } = CommonModels;
@@ -78,6 +81,23 @@ async function deleteMedia(mediaId: string) {
}
}
+// Description images and attachments reference media by id, which may belong
+// to another school on the same media service, so only delete our own.
+async function deleteOwnedMedia(mediaId: string, domainName: string) {
+ try {
+ const medialitClient = getMediaLitClient();
+ const media = await medialitClient.get(mediaId);
+ if (media?.group !== domainName) {
+ return;
+ }
+ } catch (error) {
+ console.log("Can't fetch media", mediaId, error);
+ return;
+ }
+
+ await deleteMedia(mediaId);
+}
+
const DomainModel = mongoose.model("Domain", DomainSchema);
const CourseModel = mongoose.model("Course", CourseSchema);
const LessonModel = mongoose.model("Lesson", LessonSchema);
@@ -152,7 +172,11 @@ async function cleanupDomain(name: string) {
domain: domain._id,
}).lean()) as InternalCourse[];
for (const product of products) {
- await deleteProduct({ product, domain: domain._id });
+ await deleteProduct({
+ product,
+ domain: domain._id,
+ domainName: domain.name,
+ });
}
await CommunityPostSubscriberModel.deleteMany({ domain: domain._id });
@@ -183,9 +207,11 @@ async function cleanupDomain(name: string) {
async function deleteProduct({
product,
domain,
+ domainName,
}: {
product: InternalCourse;
domain: mongoose.Types.ObjectId;
+ domainName: string;
}) {
const certificateTemplate =
await CertificateTemplateModel.findOne(
@@ -208,7 +234,7 @@ async function deleteProduct({
domain,
productId: product.courseId,
});
- await deleteLessons(product.courseId, domain);
+ await deleteLessons(product.courseId, domain, domainName);
if (product.featuredImage) {
await deleteMedia(product.featuredImage.mediaId);
}
@@ -256,7 +282,11 @@ async function deletePage({
});
}
-async function deleteLessons(id: string, domain: mongoose.Types.ObjectId) {
+async function deleteLessons(
+ id: string,
+ domain: mongoose.Types.ObjectId,
+ domainName: string,
+) {
const lessons = (await LessonModel.find({
courseId: id,
domain,
@@ -268,6 +298,18 @@ async function deleteLessons(id: string, domain: mongoose.Types.ObjectId) {
if (lesson.media?.mediaId) {
cleanupTasks.push(deleteMedia(lesson.media.mediaId));
}
+ for (const attachment of lesson.attachments || []) {
+ if (attachment.mediaId) {
+ cleanupTasks.push(
+ deleteOwnedMedia(attachment.mediaId, domainName),
+ );
+ }
+ }
+ for (const mediaId of Array.from(
+ extractMediaIDsFromNodeSources(lesson.description),
+ )) {
+ cleanupTasks.push(deleteOwnedMedia(mediaId, domainName));
+ }
if (lesson.type === Constants.LessonType.TEXT && lesson.content) {
const extractedMediaIds = extractMediaIDs(
JSON.stringify(lesson.content),
diff --git a/packages/utils/src/extract-media-ids-from-node-sources.ts b/packages/utils/src/extract-media-ids-from-node-sources.ts
new file mode 100644
index 000000000..d5f56e24f
--- /dev/null
+++ b/packages/utils/src/extract-media-ids-from-node-sources.ts
@@ -0,0 +1,38 @@
+import { extractMediaIDs } from "./extract-media-ids";
+
+// Collects media ids only from the `src` attribute of ProseMirror nodes, such
+// as images. Unlike `extractMediaIDs`, it ignores link hrefs, so a document
+// that links to someone else's file does not claim that file as its own.
+export function extractMediaIDsFromNodeSources(doc: unknown): Set {
+ const mediaIds = new Set();
+
+ const traverse = (node: unknown) => {
+ if (!node || typeof node !== "object") {
+ return;
+ }
+
+ const record = node as Record;
+ const src = record.attrs?.src;
+ if (typeof src === "string") {
+ for (const mediaId of Array.from(extractMediaIDs(src))) {
+ mediaIds.add(mediaId);
+ }
+ }
+
+ if (Array.isArray(record.content)) {
+ record.content.forEach(traverse);
+ }
+ };
+
+ if (typeof doc === "string") {
+ try {
+ traverse(JSON.parse(doc));
+ } catch {
+ return mediaIds;
+ }
+ } else {
+ traverse(doc);
+ }
+
+ return mediaIds;
+}
diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts
index 5dd21ffda..f9838569a 100644
--- a/packages/utils/src/index.ts
+++ b/packages/utils/src/index.ts
@@ -15,3 +15,8 @@ export { extractMediaIDs } from "./extract-media-ids";
export { default as extractVideoId } from "./extract-video-id";
export { default as normalizeTextEditorContent } from "./normalize-text-editor-content";
export { default as extractTextFromTextEditorContent } from "./extract-text-from-text-editor-content";
+export { extractMediaIDsFromNodeSources } from "./extract-media-ids-from-node-sources";
+export {
+ lessonTypeSupportsDescription,
+ lessonTypeSupportsAttachments,
+} from "./lesson-type-supports";
diff --git a/packages/utils/src/lesson-type-supports.ts b/packages/utils/src/lesson-type-supports.ts
new file mode 100644
index 000000000..b9a672c19
--- /dev/null
+++ b/packages/utils/src/lesson-type-supports.ts
@@ -0,0 +1,14 @@
+import { Constants } from "@courselit/common-models";
+
+// Lesson types arrive both lowercase (as stored) and uppercase (as GraphQL
+// enum values), so compare case-insensitively.
+const isLessonTypeIn = (types: readonly string[], type?: string | null) =>
+ Boolean(type && types.includes(type.toLowerCase()));
+
+export function lessonTypeSupportsDescription(type?: string | null): boolean {
+ return isLessonTypeIn(Constants.LessonTypesWithDescription, type);
+}
+
+export function lessonTypeSupportsAttachments(type?: string | null): boolean {
+ return isLessonTypeIn(Constants.LessonTypesWithAttachments, type);
+}