diff --git a/.gitignore b/.gitignore
index 21f9023a2..b5c161398 100644
--- a/.gitignore
+++ b/.gitignore
@@ -41,4 +41,7 @@ report*.json
.npmrc
# Jest files
-globalConfig.json
\ No newline at end of file
+globalConfig.json
+
+# CourseLit files
+domains_to_delete.txt
\ No newline at end of file
diff --git a/apps/queue/src/domain/__tests__/process-ongoing-sequences.test.ts b/apps/queue/src/domain/__tests__/process-ongoing-sequences.test.ts
index f1f1418f4..04b81e798 100644
--- a/apps/queue/src/domain/__tests__/process-ongoing-sequences.test.ts
+++ b/apps/queue/src/domain/__tests__/process-ongoing-sequences.test.ts
@@ -834,7 +834,7 @@ describe("processOngoingSequence", () => {
});
await OngoingSequenceModel.deleteOne({ _id: ongoingSeq._id });
await EmailDelivery.deleteMany({});
- });
+ }, 15000);
it("should rewrite links for click tracking", async () => {
// Create a sequence with links in the content
@@ -988,7 +988,7 @@ describe("processOngoingSequence", () => {
});
await OngoingSequenceModel.deleteOne({ _id: ongoingSeq._id });
await EmailDelivery.deleteMany({});
- });
+ }, 15000);
it("should not rewrite mailto, tel, anchor, and API links", async () => {
// Create a sequence with various link types that should NOT be rewritten
@@ -1164,7 +1164,7 @@ describe("processOngoingSequence", () => {
});
await OngoingSequenceModel.deleteOne({ _id: ongoingSeq._id });
await EmailDelivery.deleteMany({});
- });
+ }, 15000);
it("should include tracking pixel in rendered email", async () => {
// Spy on renderEmailToHtml before calling processOngoingSequence
@@ -1254,7 +1254,7 @@ describe("processOngoingSequence", () => {
await OngoingSequenceModel.deleteOne({ _id: ongoingSeq._id });
await EmailDelivery.deleteMany({});
- });
+ }, 15000);
});
describe("getNextPublishedEmail", () => {
diff --git a/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/manage/components/certificates.tsx b/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/manage/components/certificates.tsx
index 3eda7f1b7..c175bbc6c 100644
--- a/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/manage/components/certificates.tsx
+++ b/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/manage/components/certificates.tsx
@@ -38,6 +38,18 @@ const MUTATION_UPDATE_CERTIFICATE_TEMPLATE = `
mutation UpdateCertificateTemplate($courseId: String!, $title: String, $subtitle: String, $description: String, $signatureName: String, $signatureDesignation: String, $signatureImage: MediaInput, $logo: MediaInput) {
updateCourseCertificateTemplate(courseId: $courseId, title: $title, subtitle: $subtitle, description: $description, signatureName: $signatureName, signatureDesignation: $signatureDesignation, signatureImage: $signatureImage, logo: $logo) {
title
+ signatureImage {
+ mediaId
+ originalFileName
+ file
+ thumbnail
+ }
+ logo {
+ mediaId
+ originalFileName
+ file
+ thumbnail
+ }
}
}
`;
@@ -246,12 +258,6 @@ export default function Certificates({
const saveCertificateSignatureImage = async (media?: Media) => {
if (!product?.courseId) return;
- // Update local state immediately
- setCertificateTemplate((prev) => ({
- ...prev,
- signatureImage: media || {},
- }));
-
// Prepare variables for the mutation
const variables = {
courseId: product.courseId,
@@ -269,6 +275,12 @@ export default function Certificates({
.exec();
if (response?.updateCourseCertificateTemplate) {
+ setCertificateTemplate({
+ ...certificateTemplate,
+ signatureImage:
+ response.updateCourseCertificateTemplate
+ .signatureImage || {},
+ });
toast({
title: TOAST_TITLE_SUCCESS,
description: APP_MESSAGE_COURSE_SAVED,
@@ -288,9 +300,6 @@ export default function Certificates({
const saveCertificateLogo = async (media?: Media) => {
if (!product?.courseId) return;
- // Update local state immediately
- setCertificateTemplate((prev) => ({ ...prev, logo: media || {} }));
-
// Prepare variables for the mutation
const variables = {
courseId: product.courseId,
@@ -308,6 +317,10 @@ export default function Certificates({
.exec();
if (response?.updateCourseCertificateTemplate) {
+ setCertificateTemplate({
+ ...certificateTemplate,
+ logo: response.updateCourseCertificateTemplate.logo || {},
+ });
toast({
title: TOAST_TITLE_SUCCESS,
description: APP_MESSAGE_COURSE_SAVED,
diff --git a/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/manage/page.tsx b/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/manage/page.tsx
index 75a1ec2bf..8638a75c5 100644
--- a/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/manage/page.tsx
+++ b/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/manage/page.tsx
@@ -2,7 +2,7 @@
import { useEffect } from "react";
import DashboardContent from "@components/admin/dashboard-content";
-import { redirect, useParams } from "next/navigation";
+import { useParams, useRouter } from "next/navigation";
import { truncate } from "@ui-lib/utils";
import { Constants, UIConstants } from "@courselit/common-models";
import useProduct from "@/hooks/use-product";
@@ -58,9 +58,13 @@ export default function SettingsPage() {
}
}, [product]);
- if (productLoaded && !product) {
- redirect("/dashboard/products");
- }
+ const router = useRouter();
+
+ useEffect(() => {
+ if (productLoaded && !product) {
+ router.replace("/dashboard/products");
+ }
+ }, [productLoaded, product, router]);
if (!product) {
return null;
diff --git a/apps/web/app/api/lessons/[id]/scorm/upload/route.ts b/apps/web/app/api/lessons/[id]/scorm/upload/route.ts
index c74b2aec4..6b05edf50 100644
--- a/apps/web/app/api/lessons/[id]/scorm/upload/route.ts
+++ b/apps/web/app/api/lessons/[id]/scorm/upload/route.ts
@@ -135,6 +135,10 @@ export async function POST(
const { packageInfo } = result;
+ if (lesson.content?.mediaId && lesson.content.mediaId !== mediaId) {
+ await medialit.delete(lesson.content.mediaId);
+ }
+ await medialit.seal(mediaId);
// Update lesson content with SCORM metadata
await Lesson.updateOne(
{ lessonId, domain: domain._id },
diff --git a/apps/web/components/admin/page-editor/index.tsx b/apps/web/components/admin/page-editor/index.tsx
index 034d03606..66d683cb4 100644
--- a/apps/web/components/admin/page-editor/index.tsx
+++ b/apps/web/components/admin/page-editor/index.tsx
@@ -460,13 +460,6 @@ export default function PageEditor({
};
const deleteWidget = async (widgetId: string) => {
- // const widgetIndex = layout.findIndex(
- // (widget) => widget.widgetId === widgetId,
- // );
- // layout.splice(widgetIndex, 1);
- // setLayout(layout);
- // onClose();
- // await savePage({ pageId: page.pageId!, layout });
const mutation = `
mutation ($pageId: String!, $blockId: String!) {
page: deleteBlock(pageId: $pageId, blockId: $blockId) {
@@ -513,7 +506,6 @@ export default function PageEditor({
},
});
setLayout(layout);
- //setShowWidgetSelector(false);
onItemClick(widgetId);
setLeftPaneContent("editor");
await savePage({ pageId: page.pageId!, layout: [...layout] });
@@ -579,13 +571,6 @@ export default function PageEditor({
/>
)}
{leftPaneContent === "editor" && editWidget}
- {/* {leftPaneContent === "fonts" && (
-
- )} */}
{leftPaneContent === "theme" && (
{
diff --git a/apps/web/components/community/banner.tsx b/apps/web/components/community/banner.tsx
index 35f915596..aa441f5e5 100644
--- a/apps/web/components/community/banner.tsx
+++ b/apps/web/components/community/banner.tsx
@@ -80,14 +80,7 @@ export default function Banner({
{isTextEditorNonEmpty(bannerText) ? (
-
- }
- />
+
) : (
canEdit && (
diff --git a/apps/web/components/community/info.tsx b/apps/web/components/community/info.tsx
index b185274d2..e76f704d3 100644
--- a/apps/web/components/community/info.tsx
+++ b/apps/web/components/community/info.tsx
@@ -5,6 +5,7 @@ import {
Constants,
Membership,
PaymentPlan,
+ TextEditorContent,
UIConstants,
} from "@courselit/common-models";
import { FormEvent, Fragment, useContext, useState } from "react";
@@ -40,7 +41,7 @@ const { permissions } = UIConstants;
interface CommunityInfoProps {
id: string;
name: string;
- description: Record;
+ description: TextEditorContent;
image: string;
memberCount: number;
paymentPlan?: PaymentPlan;
diff --git a/apps/web/components/public/lesson-viewer/index.tsx b/apps/web/components/public/lesson-viewer/index.tsx
index 73c9a2769..5d3d39b3f 100644
--- a/apps/web/components/public/lesson-viewer/index.tsx
+++ b/apps/web/components/public/lesson-viewer/index.tsx
@@ -23,6 +23,7 @@ import { Link, Skeleton, useToast } from "@courselit/components-library";
import { TextRenderer } from "@courselit/page-blocks";
import {
Constants,
+ TextEditorContent,
type Address,
type Lesson,
type Profile,
@@ -311,10 +312,7 @@ export const LessonViewer = ({
+ lesson.content as TextEditorContent
}
theme={theme.theme}
/>
diff --git a/apps/web/graphql/communities/logic.ts b/apps/web/graphql/communities/logic.ts
index cde977f07..244b37e7d 100644
--- a/apps/web/graphql/communities/logic.ts
+++ b/apps/web/graphql/communities/logic.ts
@@ -1,4 +1,9 @@
-import { checkPermission, generateUniqueId, slugify } from "@courselit/utils";
+import {
+ checkPermission,
+ extractMediaIDs,
+ generateUniqueId,
+ slugify,
+} from "@courselit/utils";
import CommunityModel, { InternalCommunity } from "@models/Community";
import constants from "../../config/constants";
import GQLContext from "../../models/GQLContext";
@@ -57,13 +62,12 @@ import { hasActiveSubscription } from "../users/logic";
import { internal } from "@config/strings";
import { hasCommunityPermission as hasPermission } from "@ui-lib/utils";
import ActivityModel from "@models/Activity";
-import getDeletedMediaIds, {
- extractMediaIDs,
-} from "@/lib/get-deleted-media-ids";
-import { deleteMedia } from "@/services/medialit";
+import getDeletedMediaIds from "@/lib/get-deleted-media-ids";
+import { deleteMedia, sealMedia } from "@/services/medialit";
import CommunityPostSubscriberModel from "@models/CommunityPostSubscriber";
import InvoiceModel from "@models/Invoice";
import { InternalMembership } from "@courselit/common-logic";
+import { replaceTempMediaWithSealedMediaInProseMirrorDoc } from "@/lib/replace-temp-media-with-sealed-media-in-prosemirror-doc";
const { permissions, communityPage } = constants;
@@ -272,10 +276,6 @@ export async function updateCommunity({
}): Promise {
checkIfAuthenticated(ctx);
- // if (!checkPermission(ctx.user.permissions, [permissions.manageCommunity])) {
- // throw new Error(responses.action_not_allowed);
- // }
-
const community = await CommunityModel.findOne(
getCommunityQuery(ctx, id),
);
@@ -307,7 +307,10 @@ export async function updateCommunity({
);
if (nextDescription) {
- community.description = JSON.parse(nextDescription);
+ community.description =
+ await replaceTempMediaWithSealedMediaInProseMirrorDoc(
+ nextDescription,
+ );
}
}
@@ -321,7 +324,10 @@ export async function updateCommunity({
);
if (nextBanner) {
- community.banner = JSON.parse(nextBanner);
+ community.banner =
+ await replaceTempMediaWithSealedMediaInProseMirrorDoc(
+ nextBanner,
+ );
}
}
@@ -334,7 +340,9 @@ export async function updateCommunity({
}
if (featuredImage !== undefined) {
- community.featuredImage = featuredImage;
+ community.featuredImage = featuredImage?.mediaId
+ ? await sealMedia(featuredImage.mediaId)
+ : undefined;
}
const plans = await getPlans({
@@ -601,6 +609,14 @@ export async function createCommunityPost({
throw new Error(responses.invalid_category);
}
+ if (media?.length) {
+ for (const med of media) {
+ if (med.media?.mediaId) {
+ med.media = await sealMedia(med.media.mediaId);
+ }
+ }
+ }
+
const post = await CommunityPostModel.create({
domain: ctx.subdomain._id,
userId: ctx.user.userId,
diff --git a/apps/web/graphql/courses/__tests__/delete-course.test.ts b/apps/web/graphql/courses/__tests__/delete-course.test.ts
index 09d7ab961..16c17bbfd 100644
--- a/apps/web/graphql/courses/__tests__/delete-course.test.ts
+++ b/apps/web/graphql/courses/__tests__/delete-course.test.ts
@@ -112,13 +112,13 @@ describe("deleteCourse - Comprehensive Test Suite", () => {
);
jest.clearAllMocks();
- });
+ }, 15000);
afterAll(async () => {
await UserModel.deleteMany({ domain: testDomain._id });
await PaymentPlanModel.deleteMany({ domain: testDomain._id });
await DomainModel.deleteOne({ _id: testDomain._id });
- });
+ }, 15000);
describe("Security & Validation", () => {
it("should require authentication", async () => {
@@ -896,7 +896,7 @@ describe("deleteCourse - Comprehensive Test Suite", () => {
updatedRegularUser?.purchases.some(
(p: any) => p.courseId === course.courseId,
),
- ).toBe(false);
+ ).toBeFalsy();
const updatedAdminUser = await UserModel.findOne({
userId: adminUser.userId,
@@ -905,7 +905,7 @@ describe("deleteCourse - Comprehensive Test Suite", () => {
updatedAdminUser?.purchases.some(
(p: any) => p.courseId === course.courseId,
),
- ).toBe(false);
+ ).toBeFalsy();
});
});
diff --git a/apps/web/graphql/courses/__tests__/logic.test.ts b/apps/web/graphql/courses/__tests__/logic.test.ts
new file mode 100644
index 000000000..0f8d7fe70
--- /dev/null
+++ b/apps/web/graphql/courses/__tests__/logic.test.ts
@@ -0,0 +1,228 @@
+import DomainModel from "@models/Domain";
+import UserModel from "@models/User";
+import CourseModel from "@models/Course";
+import PageModel from "@models/Page";
+import constants from "@/config/constants";
+import { updateCourse } from "../logic";
+import { deleteMedia, sealMedia } from "@/services/medialit";
+
+jest.mock("@/services/medialit", () => ({
+ deleteMedia: jest.fn().mockResolvedValue(true),
+ sealMedia: jest.fn().mockImplementation((id) =>
+ Promise.resolve({
+ mediaId: id,
+ file: `https://cdn.medialit.clqa.online/medialit-service/p/${id}/main.webp`,
+ }),
+ ),
+}));
+
+const UPDATE_COURSE_SUITE_PREFIX = `update-course-${Date.now()}`;
+const id = (suffix: string) => `${UPDATE_COURSE_SUITE_PREFIX}-${suffix}`;
+const email = (suffix: string) =>
+ `${suffix}-${UPDATE_COURSE_SUITE_PREFIX}@example.com`;
+
+describe("updateCourse", () => {
+ let testDomain: any;
+ let adminUser: any;
+ let page: any;
+
+ beforeAll(async () => {
+ testDomain = await DomainModel.create({
+ name: id("domain"),
+ email: email("domain"),
+ });
+
+ // Create admin user with course management permissions
+ adminUser = await UserModel.create({
+ domain: testDomain._id,
+ userId: id("admin-user"),
+ email: email("admin"),
+ name: "Admin User",
+ permissions: [constants.permissions.manageAnyCourse],
+ active: true,
+ unsubscribeToken: id("unsubscribe-admin"),
+ purchases: [],
+ });
+
+ page = await PageModel.create({
+ domain: testDomain._id,
+ pageId: "test-page-perm",
+ name: "Test Page",
+ creatorId: adminUser.userId,
+ deleteable: true,
+ });
+ });
+
+ beforeEach(async () => {
+ await CourseModel.deleteMany({ domain: testDomain._id });
+ jest.clearAllMocks();
+ });
+
+ afterAll(async () => {
+ await PageModel.deleteOne({ _id: page._id });
+ await UserModel.deleteMany({ domain: testDomain._id });
+ await DomainModel.deleteOne({ _id: testDomain._id });
+ });
+
+ it("Spot all the mediaIds to be deleted correctly", async () => {
+ const initialDescription = {
+ type: "doc",
+ content: [
+ {
+ type: "paragraph",
+ content: [{ type: "text", text: "Multi file" }],
+ },
+ {
+ type: "image",
+ attrs: {
+ src: "https://cdn.medialit.clqa.online/medialit-service/i/qv9GyJgdvkIdKpRRGBHH1MQFf7qqPzcPzO2XER_K/main.webp",
+ alt: "thumb (1).webp",
+ title: "thumb (1).webp",
+ width: null,
+ height: null,
+ },
+ },
+ {
+ type: "image",
+ attrs: {
+ src: "https://cdn.medialit.clqa.online/medialit-service/i/w3caqs2p1NtqO7p95vnScR6EEWLoxTf1gsJQPWTG/main.webp",
+ alt: "favicon.webp",
+ title: "favicon.webp",
+ width: null,
+ height: null,
+ },
+ },
+ { type: "paragraph" },
+ ],
+ };
+
+ const course = await CourseModel.create({
+ domain: testDomain._id,
+ courseId: id("course-unique"),
+ title: id("course-title"),
+ creatorId: adminUser.userId,
+ deleteable: true,
+ pageId: page.pageId,
+ groups: [],
+ lessons: [],
+ type: "course",
+ privacy: "unlisted",
+ costType: "free",
+ cost: 0,
+ slug: "test-course-perm",
+ description: JSON.stringify(initialDescription),
+ });
+
+ const updatedDescription = {
+ type: "doc",
+ content: [
+ {
+ type: "paragraph",
+ content: [{ type: "text", text: "Multi file" }],
+ },
+ {
+ type: "image",
+ attrs: {
+ src: "https://cdn.medialit.clqa.online/medialit-service/i/qv9GyJgdvkIdKpRRGBHH1MQFf7qqPzcPzO2XER_K/main.webp",
+ alt: "thumb (1).webp",
+ title: "thumb (1).webp",
+ width: null,
+ height: null,
+ },
+ },
+ { type: "paragraph" },
+ ],
+ };
+
+ await updateCourse(
+ {
+ description: JSON.stringify(updatedDescription),
+ id: course.courseId,
+ },
+ {
+ subdomain: testDomain,
+ user: adminUser,
+ address: "",
+ },
+ );
+
+ expect(deleteMedia).toHaveBeenCalledWith(
+ "w3caqs2p1NtqO7p95vnScR6EEWLoxTf1gsJQPWTG",
+ );
+ });
+
+ it("Replace all temp media with sealed one", async () => {
+ const descriptionWithTempMedia = {
+ type: "doc",
+ content: [
+ {
+ type: "image",
+ attrs: {
+ src: "https://cdn.medialit.clqa.online/medialit-service/i/8U1v2-_1oh9kC-iA1JtD5lQ1m0Y8L1M8AWm_9hH7/main.webp",
+ alt: "favicon.webp",
+ title: "favicon.webp",
+ width: null,
+ height: null,
+ },
+ },
+ { type: "paragraph" },
+ ],
+ };
+
+ const course = await CourseModel.create({
+ domain: testDomain._id,
+ courseId: id("course-unique-2"),
+ title: id("course-title-2"),
+ creatorId: adminUser.userId,
+ deleteable: true,
+ pageId: page.pageId,
+ groups: [],
+ lessons: [],
+ type: "course",
+ privacy: "unlisted",
+ costType: "free",
+ cost: 0,
+ slug: "test-course-perm",
+ description: JSON.stringify({
+ type: "doc",
+ content: [{ type: "paragraph" }],
+ }),
+ });
+
+ const updatedCourse = await updateCourse(
+ {
+ description: JSON.stringify(descriptionWithTempMedia),
+ id: course.courseId,
+ },
+ {
+ subdomain: testDomain,
+ user: adminUser,
+ address: "",
+ },
+ );
+
+ const expectedDescription = {
+ type: "doc",
+ content: [
+ {
+ type: "image",
+ attrs: {
+ src: "https://cdn.medialit.clqa.online/medialit-service/p/8U1v2-_1oh9kC-iA1JtD5lQ1m0Y8L1M8AWm_9hH7/main.webp",
+ alt: "favicon.webp",
+ title: "favicon.webp",
+ width: null,
+ height: null,
+ },
+ },
+ { type: "paragraph" },
+ ],
+ };
+
+ expect(sealMedia).toHaveBeenCalledWith(
+ "8U1v2-_1oh9kC-iA1JtD5lQ1m0Y8L1M8AWm_9hH7",
+ );
+ expect(updatedCourse.description).toEqual(
+ JSON.stringify(expectedDescription),
+ );
+ });
+});
diff --git a/apps/web/graphql/courses/logic.ts b/apps/web/graphql/courses/logic.ts
index d0c6ed860..4e93385d6 100644
--- a/apps/web/graphql/courses/logic.ts
+++ b/apps/web/graphql/courses/logic.ts
@@ -4,7 +4,7 @@
import CourseModel from "@/models/Course";
import { InternalCourse } from "@courselit/common-logic";
import UserModel from "@/models/User";
-import { User } from "@courselit/common-models";
+import { Media, User } from "@courselit/common-models";
import { responses } from "@/config/strings";
import {
checkIfAuthenticated,
@@ -32,10 +32,10 @@ import {
Course,
} from "@courselit/common-models";
import { deleteAllLessons } from "../lessons/logic";
-import { deleteMedia } from "@/services/medialit";
+import { deleteMedia, sealMedia } from "@/services/medialit";
import PageModel from "@/models/Page";
import { getPrevNextCursor } from "../lessons/helpers";
-import { checkPermission } from "@courselit/utils";
+import { checkPermission, extractMediaIDs } from "@courselit/utils";
import { error } from "@/services/logger";
import {
deleteProductsFromPaymentPlans,
@@ -52,10 +52,9 @@ import CertificateTemplateModel, {
} from "@models/CertificateTemplate";
import CertificateModel from "@models/Certificate";
import ActivityModel from "@models/Activity";
-import getDeletedMediaIds, {
- extractMediaIDs,
-} from "@/lib/get-deleted-media-ids";
+import getDeletedMediaIds from "@/lib/get-deleted-media-ids";
import { deletePageInternal } from "../pages/logic";
+import { replaceTempMediaWithSealedMediaInProseMirrorDoc } from "@/lib/replace-temp-media-with-sealed-media-in-prosemirror-doc";
const { open, itemsPerPage, blogPostSnippetLength, permissions } = constants;
@@ -199,8 +198,8 @@ export const updateCourse = async (
ctx: GQLContext,
) => {
let course = await getCourseOrThrow(undefined, ctx, courseData.id);
- const mediaIdsMarkedForDeletion: string[] = [];
+ const mediaIdsMarkedForDeletion: string[] = [];
if (Object.prototype.hasOwnProperty.call(courseData, "description")) {
const nextDescription = (courseData.description ?? "") as string;
mediaIdsMarkedForDeletion.push(
@@ -228,8 +227,24 @@ export const updateCourse = async (
}
course = await validateCourse(course, ctx);
- for (const mediaId of mediaIdsMarkedForDeletion) {
- await deleteMedia(mediaId);
+ if (Object.prototype.hasOwnProperty.call(courseData, "description")) {
+ for (const mediaId of mediaIdsMarkedForDeletion) {
+ await deleteMedia(mediaId);
+ }
+ const descriptionWithSealedMedia =
+ await replaceTempMediaWithSealedMediaInProseMirrorDoc(
+ course.description || "",
+ );
+ course.description = JSON.stringify(descriptionWithSealedMedia);
+ }
+ if (
+ Object.prototype.hasOwnProperty.call(courseData, "featuredImage") &&
+ courseData.featuredImage
+ ) {
+ const featuredImage = await sealMedia(courseData.featuredImage.mediaId);
+ if (featuredImage) {
+ course.featuredImage = featuredImage;
+ }
}
course = await (course as any).save();
await PageModel.updateOne(
@@ -968,13 +983,27 @@ export const updateCourseCertificateTemplate = async ({
title?: string;
subtitle?: string;
description?: string;
- signatureImage?: string;
+ signatureImage?: Media;
signatureName?: string;
signatureDesignation?: string;
- logo?: string;
+ logo?: Media;
}) => {
const course = await getCourseOrThrow(undefined, ctx, courseId);
+ if (signatureImage) {
+ const sealedImage = await sealMedia(signatureImage.mediaId);
+ if (sealedImage) {
+ signatureImage = sealedImage;
+ }
+ }
+
+ if (logo) {
+ const sealedLogo = await sealMedia(logo.mediaId);
+ if (sealedLogo) {
+ logo = sealedLogo;
+ }
+ }
+
const updatedTemplate = await CertificateTemplateModel.findOneAndUpdate(
{
domain: ctx.subdomain._id,
diff --git a/apps/web/graphql/courses/mutation.ts b/apps/web/graphql/courses/mutation.ts
index 8d8904a7b..bd6866d9b 100644
--- a/apps/web/graphql/courses/mutation.ts
+++ b/apps/web/graphql/courses/mutation.ts
@@ -19,6 +19,7 @@ import {
import Filter from "./models/filter";
import GQLContext from "../../models/GQLContext";
import mediaTypes from "../media/types";
+import { Media } from "@courselit/common-models";
export default {
createCourse: {
@@ -168,10 +169,10 @@ export default {
title: string;
subtitle: string;
description: string;
- signatureImage: string;
+ signatureImage: Media;
signatureName: string;
signatureDesignation: string;
- logo: string;
+ logo: Media;
},
context: GQLContext,
) =>
diff --git a/apps/web/graphql/lessons/logic.ts b/apps/web/graphql/lessons/logic.ts
index 2b6ad7f91..ef87a50b9 100644
--- a/apps/web/graphql/lessons/logic.ts
+++ b/apps/web/graphql/lessons/logic.ts
@@ -17,20 +17,25 @@ import {
} from "./helpers";
import constants from "../../config/constants";
import GQLContext from "../../models/GQLContext";
-import { deleteMedia } from "../../services/medialit";
+import { deleteMedia, sealMedia } from "../../services/medialit";
import { recordProgress } from "../users/logic";
-import { Constants, Progress, Quiz, User } from "@courselit/common-models";
+import {
+ Constants,
+ Progress,
+ Quiz,
+ ScormContent,
+ User,
+} from "@courselit/common-models";
import LessonEvaluation from "../../models/LessonEvaluation";
-import { checkPermission } from "@courselit/utils";
+import { checkPermission, extractMediaIDs } from "@courselit/utils";
import { recordActivity } from "../../lib/record-activity";
import { InternalCourse } from "@courselit/common-logic";
import CertificateModel from "../../models/Certificate";
import { error } from "@/services/logger";
-import getDeletedMediaIds, {
- extractMediaIDs,
-} from "@/lib/get-deleted-media-ids";
+import getDeletedMediaIds from "@/lib/get-deleted-media-ids";
import ActivityModel from "@/models/Activity";
import UserModel from "../../models/User";
+import { replaceTempMediaWithSealedMediaInProseMirrorDoc } from "@/lib/replace-temp-media-with-sealed-media-in-prosemirror-doc";
const { permissions, quiz, scorm } = constants;
@@ -156,7 +161,9 @@ export const createLesson = async (
domain: ctx.subdomain._id,
title: lessonData.title,
type: lessonData.type,
- content: JSON.parse(lessonData.content),
+ content: await replaceTempMediaWithSealedMediaInProseMirrorDoc(
+ lessonData.content || "",
+ ),
media: lessonData.media,
downloadable: lessonData.downloadable,
creatorId: ctx.user.userId,
@@ -211,7 +218,18 @@ export const updateLesson = async (
for (const key of Object.keys(lessonData)) {
if (key === "content") {
- lesson.content = JSON.parse(lessonData.content);
+ lesson.content =
+ lessonData.type === Constants.LessonType.TEXT
+ ? await replaceTempMediaWithSealedMediaInProseMirrorDoc(
+ lessonData.content || "",
+ )
+ : JSON.parse(lessonData.content);
+ } else if (key === "media" && lessonData.media) {
+ const media = await sealMedia(lessonData.media.mediaId);
+ if (media) {
+ delete media.file;
+ lesson.media = media;
+ }
} else {
lesson[key] = lessonData[key];
}
@@ -228,43 +246,66 @@ export const deleteLesson = async (id: string, ctx: GQLContext) => {
const lesson = await getLessonOrThrow(id, ctx);
try {
- // remove from the parent Course's lessons array
- let course: InternalCourse | null = await CourseModel.findOne({
- domain: ctx.subdomain._id,
- }).elemMatch("lessons", { $eq: lesson.lessonId });
- if (!course) {
- return false;
- }
-
- course.lessons.splice(course.lessons.indexOf(lesson.lessonId), 1);
- await (course as any).save();
+ const cleanupTasks: Promise[] = [];
if (lesson.media?.mediaId) {
- await deleteMedia(lesson.media.mediaId);
+ cleanupTasks.push(deleteMedia(lesson.media.mediaId));
}
- if (lesson.content) {
+ if (lesson.type === Constants.LessonType.TEXT && lesson.content) {
const extractedMediaIds = extractMediaIDs(
JSON.stringify(lesson.content),
);
for (const mediaId of Array.from(extractedMediaIds)) {
- await deleteMedia(mediaId);
+ cleanupTasks.push(deleteMedia(mediaId));
}
}
- await LessonEvaluation.deleteMany({
- domain: ctx.subdomain._id,
- lessonId: lesson.lessonId,
- });
- await ActivityModel.deleteMany({
- domain: ctx.subdomain._id,
- entityId: lesson.lessonId,
- });
+ if (
+ lesson.type === Constants.LessonType.SCORM &&
+ lesson.content &&
+ (lesson.content as ScormContent).mediaId
+ ) {
+ cleanupTasks.push(
+ deleteMedia((lesson.content as ScormContent).mediaId!),
+ );
+ }
+
+ cleanupTasks.push(
+ LessonEvaluation.deleteMany({
+ domain: ctx.subdomain._id,
+ lessonId: lesson.lessonId,
+ }),
+ );
+ cleanupTasks.push(
+ ActivityModel.deleteMany({
+ domain: ctx.subdomain._id,
+ entityId: lesson.lessonId,
+ }),
+ );
+ cleanupTasks.push(
+ LessonModel.deleteOne({
+ _id: lesson.id,
+ domain: ctx.subdomain._id,
+ }),
+ );
+
+ await Promise.all(cleanupTasks);
+
+ const courseUpdateResult = await CourseModel.updateOne(
+ {
+ domain: ctx.subdomain._id,
+ lessons: lesson.lessonId,
+ },
+ {
+ $pull: { lessons: lesson.lessonId },
+ },
+ );
+
+ if (courseUpdateResult.matchedCount === 0) {
+ return false;
+ }
- await LessonModel.deleteOne({
- _id: lesson.id,
- domain: ctx.subdomain._id,
- });
return true;
} catch (err: any) {
throw new Error(err.message);
diff --git a/apps/web/graphql/pages/__tests__/logic.test.ts b/apps/web/graphql/pages/__tests__/logic.test.ts
index a267809b4..cb64f29fb 100644
--- a/apps/web/graphql/pages/__tests__/logic.test.ts
+++ b/apps/web/graphql/pages/__tests__/logic.test.ts
@@ -2,18 +2,28 @@
* @jest-environment node
*/
-import { updatePage, getPage } from "../logic";
+import { updatePage, getPage, publish } from "../logic";
import DomainModel from "@/models/Domain";
import PageModel, { Page } from "@/models/Page";
import Course from "@/models/Course";
import CommunityModel from "@/models/Community";
import constants from "@/config/constants";
import { deleteMedia } from "@/services/medialit";
-import type GQLContext from "@/models/GQLContext";
+import GQLContext from "@/models/GQLContext";
import { responses } from "@/config/strings";
jest.mock("@/services/medialit", () => ({
deleteMedia: jest.fn().mockResolvedValue(true),
+ sealMedia: jest.fn().mockImplementation((id) =>
+ Promise.resolve({
+ mediaId: id,
+ url: `https://cdn.test/${id}`,
+ originalFileName: "image.png",
+ mimeType: "image/png",
+ size: 1024,
+ access: "public",
+ }),
+ ),
}));
const { permissions } = constants;
@@ -51,7 +61,7 @@ describe("updatePage media handling", () => {
beforeAll(async () => {
domain = await DomainModel.create({
- name: "protected-media-domain",
+ name: `protected-media-domain-${Date.now()}-${Math.floor(Math.random() * 100000)}`,
email: "owner@test.com",
sharedWidgets: {},
draftSharedWidgets: {},
@@ -62,7 +72,7 @@ describe("updatePage media handling", () => {
jest.clearAllMocks();
ctx = {
- subdomain: await DomainModel.findById(domain._id),
+ subdomain: domain,
user: {
userId: "admin-user",
permissions: [permissions.manageSite],
@@ -232,7 +242,7 @@ describe("getPage entity validation", () => {
beforeAll(async () => {
domain = await DomainModel.create({
- name: "entity-validation-domain",
+ name: `entity-validation-domain-${Date.now()}-${Math.floor(Math.random() * 100000)}`,
email: "owner@test.com",
sharedWidgets: {},
draftSharedWidgets: {},
@@ -243,7 +253,7 @@ describe("getPage entity validation", () => {
jest.clearAllMocks();
ctx = {
- subdomain: await DomainModel.findById(domain._id),
+ subdomain: domain,
user: null,
address: "https://entity-validation.test",
} as unknown as GQLContext;
@@ -575,3 +585,721 @@ describe("getPage entity validation", () => {
});
});
});
+
+describe("Media cleanup", () => {
+ let domain: any;
+ let ctx: GQLContext;
+
+ const media1 = "media-1";
+ const media2 = "media-2";
+ const media3 = "media-3";
+ const mediaObj1 = {
+ mediaId: media1,
+ url: `https://cdn.test/${media1}`,
+ originalFileName: "image1.png",
+ mimeType: "image/png",
+ size: 1024,
+ access: "public",
+ };
+ const mediaObj2 = {
+ mediaId: media2,
+ url: `https://cdn.test/${media2}`,
+ originalFileName: "image2.png",
+ mimeType: "image/png",
+ size: 2048,
+ access: "public",
+ };
+
+ beforeAll(async () => {
+ domain = await DomainModel.create({
+ name: `media-cleanup-domain-${Date.now()}-${Math.floor(Math.random() * 100000)}`,
+ email: "owner@test.com",
+ sharedWidgets: {},
+ draftSharedWidgets: {},
+ });
+ });
+
+ beforeEach(async () => {
+ jest.clearAllMocks();
+
+ ctx = {
+ subdomain: domain,
+ user: {
+ userId: "admin-user",
+ permissions: [permissions.manageSite],
+ },
+ address: "https://media-cleanup.test",
+ } as unknown as GQLContext;
+
+ await PageModel.deleteMany({ domain: domain._id });
+ });
+
+ afterAll(async () => {
+ await PageModel.deleteMany({ domain: domain._id });
+ await DomainModel.deleteMany({ _id: domain._id });
+ });
+
+ it("updating title will not call deleteMedia", async () => {
+ // Setup: Page with media in draft layout
+ const page = await PageModel.create({
+ domain: ctx.subdomain._id,
+ pageId: "bug-partial-update",
+ type: constants.site,
+ creatorId: "creator-1",
+ name: "Bug Partial Update",
+ layout: [makeHeaderWidget(), makeFooterWidget()],
+ draftLayout: [
+ makeHeaderWidget(),
+ {
+ widgetId: "media-widget",
+ name: "hero",
+ settings: { image: mediaObj1.url },
+ },
+ makeFooterWidget(),
+ ],
+ });
+
+ // Action: Update only title
+ try {
+ await updatePage({
+ context: ctx,
+ pageId: page.pageId,
+ title: "New Title",
+ });
+ } catch (e) {
+ // Ignore the crash to check if deleteMedia was called
+ }
+
+ // Assertion: media1 should NOT be deleted
+ expect(deleteMedia).not.toHaveBeenCalledWith(media1);
+ });
+
+ it("orphans social image when replaced", async () => {
+ // Setup: Page with social image ONLY in draft
+ const page = await PageModel.create({
+ domain: ctx.subdomain._id,
+ pageId: "bug-social-image-orphan",
+ type: constants.site,
+ creatorId: "creator-1",
+ name: "Bug Social Image Orphan",
+ layout: [],
+ draftLayout: [],
+ socialImage: undefined,
+ draftSocialImage: mediaObj1,
+ });
+
+ // Action: Update specific social image
+ await updatePage({
+ context: ctx,
+ pageId: page.pageId,
+ socialImage: mediaObj2 as any,
+ });
+
+ // Assertion: media1 should be deleted (replaced by media2 and Is not published)
+ expect(deleteMedia).toHaveBeenCalledWith(media1);
+ });
+
+ it("existing social image is deleted when publishing", async () => {
+ // Setup: Page with media1 as socialImage, and layout awaiting publish with media2 as new socialImage
+ const page = await PageModel.create({
+ domain: ctx.subdomain._id,
+ pageId: "bug-publish-social-orphan",
+ type: constants.site,
+ creatorId: "creator-1",
+ name: "Bug Publish Social Orphan",
+ layout: [],
+ draftLayout: [],
+ socialImage: mediaObj1,
+ draftSocialImage: mediaObj2,
+ });
+
+ // Action: Publish
+ await publish(page.pageId, ctx);
+
+ // Assertion: media1 should be deleted as it is replaced by media2
+ expect(deleteMedia).toHaveBeenCalledWith(media1);
+ });
+
+ describe("updatePage media cleanup", () => {
+ it("deletes media removed from draft layout when not in published layout", async () => {
+ const page = await PageModel.create({
+ domain: ctx.subdomain._id,
+ pageId: "update-removes-media",
+ type: constants.site,
+ creatorId: "creator-1",
+ name: "Update Removes Media",
+ layout: [makeHeaderWidget(), makeFooterWidget()],
+ draftLayout: [
+ makeHeaderWidget(),
+ {
+ widgetId: "image-widget",
+ name: "hero",
+ settings: {
+ image: `https://cdn.test/${media1}/main.png`,
+ },
+ },
+ makeFooterWidget(),
+ ],
+ });
+
+ await updatePage({
+ context: ctx,
+ pageId: page.pageId,
+ layout: JSON.stringify([
+ makeHeaderWidget(),
+ makeFooterWidget(),
+ ]),
+ });
+
+ expect(deleteMedia).toHaveBeenCalledWith(media1);
+ });
+
+ it("does NOT delete media still present in published layout", async () => {
+ const page = await PageModel.create({
+ domain: ctx.subdomain._id,
+ pageId: "update-protects-published",
+ type: constants.site,
+ creatorId: "creator-1",
+ name: "Update Protects Published",
+ layout: [
+ makeHeaderWidget(),
+ {
+ widgetId: "protected-widget",
+ name: "hero",
+ settings: {
+ image: `https://cdn.test/${media1}/main.png`,
+ },
+ },
+ makeFooterWidget(),
+ ],
+ draftLayout: [
+ makeHeaderWidget(),
+ {
+ widgetId: "protected-widget",
+ name: "hero",
+ settings: {
+ image: `https://cdn.test/${media1}/main.png`,
+ },
+ },
+ makeFooterWidget(),
+ ],
+ });
+
+ await updatePage({
+ context: ctx,
+ pageId: page.pageId,
+ layout: JSON.stringify([
+ makeHeaderWidget(),
+ makeFooterWidget(),
+ ]),
+ });
+
+ expect(deleteMedia).not.toHaveBeenCalledWith(media1);
+ });
+
+ it("deletes old draftSocialImage when replaced with new one", async () => {
+ const page = await PageModel.create({
+ domain: ctx.subdomain._id,
+ pageId: "update-replaces-social",
+ type: constants.site,
+ creatorId: "creator-1",
+ name: "Update Replaces Social",
+ layout: [],
+ draftLayout: [],
+ draftSocialImage: mediaObj1,
+ });
+
+ await updatePage({
+ context: ctx,
+ pageId: page.pageId,
+ socialImage: mediaObj2 as any,
+ });
+
+ expect(deleteMedia).toHaveBeenCalledWith(media1);
+ });
+
+ it("does NOT delete draftSocialImage if it is the same as published socialImage", async () => {
+ const page = await PageModel.create({
+ domain: ctx.subdomain._id,
+ pageId: "update-protects-published-social",
+ type: constants.site,
+ creatorId: "creator-1",
+ name: "Update Protects Published Social",
+ layout: [],
+ draftLayout: [],
+ socialImage: mediaObj1,
+ draftSocialImage: mediaObj1,
+ });
+
+ await updatePage({
+ context: ctx,
+ pageId: page.pageId,
+ socialImage: mediaObj2 as any,
+ });
+
+ expect(deleteMedia).not.toHaveBeenCalledWith(media1);
+ });
+
+ it("handles multiple media in a single widget", async () => {
+ const page = await PageModel.create({
+ domain: ctx.subdomain._id,
+ pageId: "update-multiple-media",
+ type: constants.site,
+ creatorId: "creator-1",
+ name: "Update Multiple Media",
+ layout: [makeHeaderWidget(), makeFooterWidget()],
+ draftLayout: [
+ makeHeaderWidget(),
+ {
+ widgetId: "gallery-widget",
+ name: "gallery",
+ settings: {
+ images: [
+ `https://cdn.test/${media1}/main.png`,
+ `https://cdn.test/${media2}/main.png`,
+ ],
+ },
+ },
+ makeFooterWidget(),
+ ],
+ });
+
+ await updatePage({
+ context: ctx,
+ pageId: page.pageId,
+ layout: JSON.stringify([
+ makeHeaderWidget(),
+ {
+ widgetId: "gallery-widget",
+ name: "gallery",
+ settings: {
+ images: [`https://cdn.test/${media2}/main.png`],
+ },
+ },
+ makeFooterWidget(),
+ ]),
+ });
+
+ expect(deleteMedia).toHaveBeenCalledWith(media1);
+ expect(deleteMedia).not.toHaveBeenCalledWith(media2);
+ });
+
+ it("handles deeply nested media in widget settings", async () => {
+ const page = await PageModel.create({
+ domain: ctx.subdomain._id,
+ pageId: "update-nested-media",
+ type: constants.site,
+ creatorId: "creator-1",
+ name: "Update Nested Media",
+ layout: [makeHeaderWidget(), makeFooterWidget()],
+ draftLayout: [
+ makeHeaderWidget(),
+ {
+ widgetId: "complex-widget",
+ name: "complex",
+ settings: {
+ sections: [
+ {
+ items: [
+ {
+ media: {
+ url: `https://cdn.test/${media1}/main.png`,
+ },
+ },
+ ],
+ },
+ ],
+ },
+ },
+ makeFooterWidget(),
+ ],
+ });
+
+ await updatePage({
+ context: ctx,
+ pageId: page.pageId,
+ layout: JSON.stringify([
+ makeHeaderWidget(),
+ makeFooterWidget(),
+ ]),
+ });
+
+ expect(deleteMedia).toHaveBeenCalledWith(media1);
+ });
+
+ it("does not delete any media when updating only metadata (title/description)", async () => {
+ const page = await PageModel.create({
+ domain: ctx.subdomain._id,
+ pageId: "update-metadata-only",
+ type: constants.site,
+ creatorId: "creator-1",
+ name: "Update Metadata Only",
+ layout: [
+ makeHeaderWidget(),
+ {
+ widgetId: "w1",
+ name: "hero",
+ settings: {
+ image: `https://cdn.test/${media1}/main.png`,
+ },
+ },
+ makeFooterWidget(),
+ ],
+ draftLayout: [
+ makeHeaderWidget(),
+ {
+ widgetId: "w1",
+ name: "hero",
+ settings: {
+ image: `https://cdn.test/${media1}/main.png`,
+ },
+ },
+ makeFooterWidget(),
+ ],
+ });
+
+ await updatePage({
+ context: ctx,
+ pageId: page.pageId,
+ title: "New Title",
+ description: "New Description",
+ });
+
+ expect(deleteMedia).not.toHaveBeenCalled();
+ });
+ });
+
+ describe("publish media cleanup", () => {
+ it("deletes media from old published layout not in new published layout", async () => {
+ const page = await PageModel.create({
+ domain: ctx.subdomain._id,
+ pageId: "publish-removes-media",
+ type: constants.site,
+ creatorId: "creator-1",
+ name: "Publish Removes Media",
+ layout: [
+ makeHeaderWidget(),
+ {
+ widgetId: "w1",
+ name: "hero",
+ settings: {
+ image: `https://cdn.test/${media1}/main.png`,
+ },
+ },
+ makeFooterWidget(),
+ ],
+ draftLayout: [makeHeaderWidget(), makeFooterWidget()],
+ });
+
+ await publish(page.pageId, ctx);
+
+ expect(deleteMedia).toHaveBeenCalledWith(media1);
+ });
+
+ it("does NOT delete media that exists in both old and new layouts", async () => {
+ const page = await PageModel.create({
+ domain: ctx.subdomain._id,
+ pageId: "publish-keeps-shared",
+ type: constants.site,
+ creatorId: "creator-1",
+ name: "Publish Keeps Shared",
+ layout: [
+ makeHeaderWidget(),
+ {
+ widgetId: "w1",
+ name: "hero",
+ settings: {
+ image: `https://cdn.test/${media1}/main.png`,
+ },
+ },
+ makeFooterWidget(),
+ ],
+ draftLayout: [
+ makeHeaderWidget(),
+ {
+ widgetId: "w1",
+ name: "hero",
+ settings: {
+ image: `https://cdn.test/${media1}/main.png`,
+ },
+ },
+ {
+ widgetId: "w2",
+ name: "banner",
+ settings: {
+ image: `https://cdn.test/${media2}/main.png`,
+ },
+ },
+ makeFooterWidget(),
+ ],
+ });
+
+ await publish(page.pageId, ctx);
+
+ expect(deleteMedia).not.toHaveBeenCalledWith(media1);
+ expect(deleteMedia).not.toHaveBeenCalledWith(media2);
+ });
+
+ it("deletes old socialImage when replaced during publish", async () => {
+ const page = await PageModel.create({
+ domain: ctx.subdomain._id,
+ pageId: "publish-replaces-social",
+ type: constants.site,
+ creatorId: "creator-1",
+ name: "Publish Replaces Social",
+ layout: [],
+ draftLayout: [],
+ socialImage: mediaObj1,
+ draftSocialImage: mediaObj2,
+ });
+
+ await publish(page.pageId, ctx);
+
+ expect(deleteMedia).toHaveBeenCalledWith(media1);
+ expect(deleteMedia).not.toHaveBeenCalledWith(media2);
+ });
+
+ it("does NOT delete socialImage if it still exists in draftLayout", async () => {
+ const page = await PageModel.create({
+ domain: ctx.subdomain._id,
+ pageId: "publish-social-in-layout",
+ type: constants.site,
+ creatorId: "creator-1",
+ name: "Publish Social In Layout",
+ layout: [],
+ draftLayout: [
+ makeHeaderWidget(),
+ {
+ widgetId: "w1",
+ name: "hero",
+ settings: {
+ image: `https://cdn.test/${media1}/main.png`,
+ },
+ },
+ makeFooterWidget(),
+ ],
+ socialImage: mediaObj1,
+ draftSocialImage: undefined,
+ });
+
+ await publish(page.pageId, ctx);
+
+ expect(deleteMedia).not.toHaveBeenCalledWith(media1);
+ });
+
+ it("handles publishing with empty draftLayout - media is deleted", async () => {
+ // Note: When draftLayout is empty, the layout is NOT copied (checked via `if (page.draftLayout.length)`)
+ // However, the media diff computation still happens: currentPublished - nextPublished
+ // With empty draftLayout, nextPublishedMedia is empty, so ALL currentPublishedMedia is deleted
+ const page = await PageModel.create({
+ domain: ctx.subdomain._id,
+ pageId: "publish-empty-layouts",
+ type: constants.site,
+ creatorId: "creator-1",
+ name: "Publish Empty Layouts",
+ layout: [
+ makeHeaderWidget(),
+ {
+ widgetId: "w1",
+ name: "hero",
+ settings: {
+ image: `https://cdn.test/${media1}/main.png`,
+ },
+ },
+ makeFooterWidget(),
+ ],
+ draftLayout: [],
+ });
+
+ await publish(page.pageId, ctx);
+
+ // Media IS deleted because the diff is: {media1} - {} = {media1}
+ expect(deleteMedia).toHaveBeenCalledWith(media1);
+ });
+
+ it("deletes multiple media removed during publish", async () => {
+ const page = await PageModel.create({
+ domain: ctx.subdomain._id,
+ pageId: "publish-removes-multiple",
+ type: constants.site,
+ creatorId: "creator-1",
+ name: "Publish Removes Multiple",
+ layout: [
+ makeHeaderWidget(),
+ {
+ widgetId: "w1",
+ name: "hero",
+ settings: {
+ image: `https://cdn.test/${media1}/main.png`,
+ },
+ },
+ {
+ widgetId: "w2",
+ name: "banner",
+ settings: {
+ image: `https://cdn.test/${media2}/main.png`,
+ },
+ },
+ makeFooterWidget(),
+ ],
+ draftLayout: [
+ makeHeaderWidget(),
+ {
+ widgetId: "w3",
+ name: "cta",
+ settings: {
+ image: `https://cdn.test/${media3}/main.png`,
+ },
+ },
+ makeFooterWidget(),
+ ],
+ });
+
+ await publish(page.pageId, ctx);
+
+ expect(deleteMedia).toHaveBeenCalledWith(media1);
+ expect(deleteMedia).toHaveBeenCalledWith(media2);
+ expect(deleteMedia).not.toHaveBeenCalledWith(media3);
+ });
+ });
+
+ describe("edge cases", () => {
+ it("handles widget with no media settings", async () => {
+ const page = await PageModel.create({
+ domain: ctx.subdomain._id,
+ pageId: "no-media-widget",
+ type: constants.site,
+ creatorId: "creator-1",
+ name: "No Media Widget",
+ layout: [makeHeaderWidget(), makeFooterWidget()],
+ draftLayout: [
+ makeHeaderWidget(),
+ {
+ widgetId: "text-widget",
+ name: "text",
+ settings: { content: "Hello world" },
+ },
+ makeFooterWidget(),
+ ],
+ });
+
+ await updatePage({
+ context: ctx,
+ pageId: page.pageId,
+ layout: JSON.stringify([
+ makeHeaderWidget(),
+ makeFooterWidget(),
+ ]),
+ });
+
+ expect(deleteMedia).not.toHaveBeenCalled();
+ });
+
+ it("handles media URL with different file extensions", async () => {
+ const page = await PageModel.create({
+ domain: ctx.subdomain._id,
+ pageId: "media-extensions",
+ type: constants.site,
+ creatorId: "creator-1",
+ name: "Media Extensions",
+ layout: [makeHeaderWidget(), makeFooterWidget()],
+ draftLayout: [
+ makeHeaderWidget(),
+ {
+ widgetId: "w1",
+ name: "video",
+ settings: {
+ src: `https://cdn.test/${media1}/main.mp4`,
+ },
+ },
+ {
+ widgetId: "w2",
+ name: "image",
+ settings: {
+ src: `https://cdn.test/${media2}/main.webp`,
+ },
+ },
+ makeFooterWidget(),
+ ],
+ });
+
+ await updatePage({
+ context: ctx,
+ pageId: page.pageId,
+ layout: JSON.stringify([
+ makeHeaderWidget(),
+ makeFooterWidget(),
+ ]),
+ });
+
+ expect(deleteMedia).toHaveBeenCalledWith(media1);
+ expect(deleteMedia).toHaveBeenCalledWith(media2);
+ });
+
+ it("does not crash when draftLayout is empty array", async () => {
+ const page = await PageModel.create({
+ domain: ctx.subdomain._id,
+ pageId: "empty-draft-layout",
+ type: constants.site,
+ creatorId: "creator-1",
+ name: "Empty Draft Layout",
+ layout: [makeHeaderWidget(), makeFooterWidget()],
+ draftLayout: [],
+ });
+
+ await expect(
+ updatePage({
+ context: ctx,
+ pageId: page.pageId,
+ layout: JSON.stringify([
+ makeHeaderWidget(),
+ {
+ widgetId: "w1",
+ name: "hero",
+ settings: {
+ image: `https://cdn.test/${media1}/main.png`,
+ },
+ },
+ makeFooterWidget(),
+ ]),
+ }),
+ ).resolves.toBeDefined();
+
+ expect(deleteMedia).not.toHaveBeenCalled();
+ });
+
+ it("correctly identifies media IDs from complex URLs", async () => {
+ const complexMediaId = "abc123def456";
+ const page = await PageModel.create({
+ domain: ctx.subdomain._id,
+ pageId: "complex-url",
+ type: constants.site,
+ creatorId: "creator-1",
+ name: "Complex URL",
+ layout: [makeHeaderWidget(), makeFooterWidget()],
+ draftLayout: [
+ makeHeaderWidget(),
+ {
+ widgetId: "w1",
+ name: "hero",
+ settings: {
+ image: `https://cdn.example.com/uploads/${complexMediaId}/main.jpg`,
+ },
+ },
+ makeFooterWidget(),
+ ],
+ });
+
+ await updatePage({
+ context: ctx,
+ pageId: page.pageId,
+ layout: JSON.stringify([
+ makeHeaderWidget(),
+ makeFooterWidget(),
+ ]),
+ });
+
+ expect(deleteMedia).toHaveBeenCalledWith(complexMediaId);
+ });
+ });
+});
diff --git a/apps/web/graphql/pages/logic.ts b/apps/web/graphql/pages/logic.ts
index b58ed1a22..4c159311f 100644
--- a/apps/web/graphql/pages/logic.ts
+++ b/apps/web/graphql/pages/logic.ts
@@ -9,16 +9,15 @@ import {
} from "./helpers";
import constants from "../../config/constants";
import Course from "../../models/Course";
-import { checkPermission } from "@courselit/utils";
+import { checkPermission, extractMediaIDs } from "@courselit/utils";
import { Media, User, Constants } from "@courselit/common-models";
import { Domain } from "../../models/Domain";
import { homePageTemplate } from "./page-templates";
import { publishTheme } from "../themes/logic";
-import getDeletedMediaIds, {
- extractMediaIDs,
-} from "@/lib/get-deleted-media-ids";
-import { deleteMedia } from "@/services/medialit";
+import getDeletedMediaIds from "@/lib/get-deleted-media-ids";
+import { deleteMedia, sealMedia } from "@/services/medialit";
import CommunityModel from "@models/Community";
+import { replaceTempMediaWithSealedMediaInPageLayout } from "@/lib/replace-temp-media-with-sealed-media-in-page-layout";
const { product, site, blogPage, communityPage, permissions, defaultPages } =
constants;
const { pageNames } = Constants;
@@ -155,6 +154,9 @@ export const updatePage = async ({
const publishedLayoutMediaIds = extractMediaIDs(
JSON.stringify(page.layout ?? []),
);
+ if (page.socialImage?.mediaId) {
+ publishedLayoutMediaIds.add(page.socialImage?.mediaId);
+ }
if (inputLayout) {
try {
let layout;
@@ -174,7 +176,11 @@ export const updatePage = async ({
}
const layoutWithSharedWidgetsSettings =
await copySharedWidgetsToDomain(layout, ctx.subdomain);
- page.draftLayout = layoutWithSharedWidgetsSettings;
+ const draftLayoutWithSealedMedia =
+ await replaceTempMediaWithSealedMediaInPageLayout(
+ layoutWithSharedWidgetsSettings,
+ );
+ page.draftLayout = draftLayoutWithSealedMedia;
} catch (err: any) {
throw new Error(err.message);
}
@@ -185,8 +191,13 @@ export const updatePage = async ({
if (description) {
page.draftDescription = description;
}
- if (typeof socialImage !== "undefined") {
- page.draftSocialImage = socialImage;
+ if (typeof socialImage !== "undefined" && socialImage?.mediaId) {
+ if (page.draftSocialImage?.mediaId) {
+ deletedMediaIds.push(page.draftSocialImage.mediaId);
+ }
+ page.draftSocialImage = socialImage?.mediaId
+ ? await sealMedia(socialImage.mediaId)
+ : undefined;
}
if (typeof robotsAllowed === "boolean") {
page.draftRobotsAllowed = robotsAllowed;
@@ -230,21 +241,42 @@ export const publish = async (
return null;
}
+ // 1. Identify all media currently in PUBLISHED state (to be potentially deleted)
+ const currentPublishedMedia = extractMediaIDs(
+ JSON.stringify(page.layout || []),
+ );
+ if (page.socialImage?.mediaId) {
+ currentPublishedMedia.add(page.socialImage.mediaId);
+ }
+
+ // 2. Identify all media in NEW PUBLISHED state (from draft)
+ const nextPublishedMedia = extractMediaIDs(
+ JSON.stringify(page.draftLayout || []),
+ );
+ if (page.draftSocialImage?.mediaId) {
+ nextPublishedMedia.add(page.draftSocialImage.mediaId);
+ }
+
+ // 3. Delete (Current - Next)
+ const mediaToDelete = Array.from(currentPublishedMedia).filter(
+ (id) => !nextPublishedMedia.has(id),
+ );
+
if (page.draftLayout.length) {
page.layout = page.draftLayout;
- page.draftLayout = [];
+ // page.draftLayout = [];
}
if (page.draftTitle) {
page.title = page.draftTitle;
- page.draftTitle = undefined;
+ // page.draftTitle = undefined;
}
if (page.draftDescription) {
page.description = page.draftDescription;
- page.draftDescription = undefined;
+ // page.draftDescription = undefined;
}
if (page.draftRobotsAllowed) {
page.robotsAllowed = page.draftRobotsAllowed;
- page.draftRobotsAllowed = undefined;
+ // page.draftRobotsAllowed = undefined;
}
page.socialImage = page.draftSocialImage;
@@ -257,6 +289,9 @@ export const publish = async (
}
await (ctx.subdomain as any).save();
+ for (const mediaId of mediaToDelete) {
+ await deleteMedia(mediaId);
+ }
await (page as any).save();
return getPageResponse(page!, ctx);
@@ -503,7 +538,13 @@ export const deleteBlock = async ({
}
const deletedMediaIds = extractMediaIDs(JSON.stringify(block));
- for (const mediaId of Array.from(deletedMediaIds)) {
+ const publishedLayoutMediaIds = extractMediaIDs(
+ JSON.stringify(page.layout ?? []),
+ );
+ const deletableMediaIds = Array.from(deletedMediaIds).filter(
+ (mediaId) => !publishedLayoutMediaIds.has(mediaId),
+ );
+ for (const mediaId of deletableMediaIds) {
await deleteMedia(mediaId);
}
diff --git a/apps/web/graphql/settings/logic.ts b/apps/web/graphql/settings/logic.ts
index 6029f7f2b..9db333d07 100644
--- a/apps/web/graphql/settings/logic.ts
+++ b/apps/web/graphql/settings/logic.ts
@@ -10,9 +10,15 @@ import {
import type GQLContext from "../../models/GQLContext";
import DomainModel, { Domain } from "../../models/Domain";
import { checkPermission } from "@courselit/utils";
-import { Constants, LoginProvider, Typeface } from "@courselit/common-models";
+import {
+ Constants,
+ LoginProvider,
+ Media,
+ Typeface,
+} from "@courselit/common-models";
import ApikeyModel, { ApiKey } from "@models/ApiKey";
import SSOProviderModel from "@models/SSOProvider";
+import { sealMedia } from "@/services/medialit";
const { permissions } = constants;
@@ -67,6 +73,12 @@ export const updateSiteInfo = async (
validateSiteInfo(domain);
+ if (Object.prototype.hasOwnProperty.call(siteData, "logo")) {
+ domain.settings.logo = (siteData.logo as Media)?.mediaId
+ ? await sealMedia((siteData.logo as Media).mediaId)
+ : undefined;
+ }
+
await (domain as any).save();
return domain;
diff --git a/apps/web/graphql/users/logic.ts b/apps/web/graphql/users/logic.ts
index 9669fefaf..7cd86b8e0 100644
--- a/apps/web/graphql/users/logic.ts
+++ b/apps/web/graphql/users/logic.ts
@@ -60,6 +60,7 @@ import {
} from "./helpers";
const { permissions } = UIConstants;
import { ObjectId } from "mongodb";
+import { sealMedia } from "@/services/medialit";
const removeAdminFieldsFromUserObject = (user: any) => ({
id: user._id,
@@ -144,6 +145,12 @@ export const updateUser = async (userData: UserData, ctx: GQLContext) => {
validateUserProperties(user);
+ if (Object.prototype.hasOwnProperty.call(userData, "avatar")) {
+ user.avatar = userData.avatar?.mediaId
+ ? await sealMedia(userData.avatar.mediaId)
+ : undefined;
+ }
+
user = await user.save();
return user;
diff --git a/apps/web/hooks/use-product.ts b/apps/web/hooks/use-product.ts
index 7e435a085..a998857fc 100644
--- a/apps/web/hooks/use-product.ts
+++ b/apps/web/hooks/use-product.ts
@@ -1,6 +1,5 @@
import { Course } from "@courselit/common-models";
import { Lesson } from "@courselit/common-models";
-import { useToast } from "@courselit/components-library";
import { useCallback, useContext, useEffect, useState } from "react";
import { useGraphQLFetch } from "./use-graphql-fetch";
import { AddressContext } from "@components/contexts";
@@ -17,20 +16,18 @@ export type ProductWithAdminProps = Partial<
export default function useProduct(id?: string | null): {
product: ProductWithAdminProps | undefined | null;
loaded: boolean;
+ error: any;
} {
const [product, setProduct] = useState<
ProductWithAdminProps | undefined | null
>();
- const { toast } = useToast();
const [loaded, setLoaded] = useState(false);
- const [hasError, setHasError] = useState(false);
+ const [error, setError] = useState(null);
const address = useContext(AddressContext);
const fetch = useGraphQLFetch();
const loadProduct = useCallback(
async (courseId: string) => {
- if (hasError) return;
-
const query = `
query {
course: getCourse(id: "${courseId}") {
@@ -110,24 +107,25 @@ export default function useProduct(id?: string | null): {
const response = await fetchInstance.exec();
if (response.course) {
setProduct(response.course);
+ setError(null);
} else {
setProduct(null);
}
} catch (err: any) {
- setHasError(true);
+ setError(err);
setProduct(null);
} finally {
setLoaded(true);
}
},
- [fetch, hasError],
+ [fetch],
);
useEffect(() => {
- if (id && address && !hasError) {
+ if (id && address) {
loadProduct(id);
}
- }, [id, address, loadProduct, hasError]);
+ }, [id, address, loadProduct]);
- return { product, loaded };
+ return { product, loaded, error };
}
diff --git a/apps/web/lib/__tests__/replace-temp-media-with-sealed-media-in-page-layout.test.ts b/apps/web/lib/__tests__/replace-temp-media-with-sealed-media-in-page-layout.test.ts
new file mode 100644
index 000000000..46217cbef
--- /dev/null
+++ b/apps/web/lib/__tests__/replace-temp-media-with-sealed-media-in-page-layout.test.ts
@@ -0,0 +1,173 @@
+import { replaceTempMediaWithSealedMediaInPageLayout } from "../replace-temp-media-with-sealed-media-in-page-layout";
+import { sealMedia } from "@/services/medialit";
+
+jest.mock("@/services/medialit");
+
+const mockSealMedia = sealMedia as jest.Mock;
+
+describe("replaceTempMediaWithSealedMediaInPageLayout", () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it("should replace main media URLs with sealed URLs", async () => {
+ const mediaId = "IZGUXrznb9_BnmiZ19jnFVkFFwyzmyQAoSFp7D2X";
+ const signedUrl = `https://bucket/i/${mediaId}/main.webp?Expires=123&Signature=abc`;
+ const sealedFileUrl = `https://bucket/p/${mediaId}/main.webp`;
+ const sealedThumbUrl = `https://bucket/p/${mediaId}/thumb.webp`;
+
+ const layout = [
+ {
+ widgetId: "test-widget",
+ settings: {
+ media: {
+ mediaId: mediaId,
+ file: signedUrl,
+ },
+ },
+ },
+ ];
+
+ mockSealMedia.mockResolvedValue({
+ mediaId,
+ file: sealedFileUrl,
+ thumbnail: sealedThumbUrl,
+ });
+
+ const result =
+ await replaceTempMediaWithSealedMediaInPageLayout(layout);
+
+ expect(sealMedia).toHaveBeenCalledWith(mediaId);
+ expect(result[0].settings.media.file).toBe(sealedFileUrl);
+ });
+
+ it("should replace thumbnail URLs with sealed URLs", async () => {
+ const mediaId = "IZGUXrznb9_BnmiZ19jnFVkFFwyzmyQAoSFp7D2X";
+ const signedThumbUrl = `https://bucket/i/${mediaId}/thumb.webp?Expires=123&Signature=abc`;
+ const sealedFileUrl = `https://bucket/p/${mediaId}/main.webp`;
+ const sealedThumbUrl = `https://bucket/p/${mediaId}/thumb.webp`;
+
+ const layout = {
+ someProp: {
+ nested: {
+ file: `https://bucket/i/${mediaId}/main.png`,
+ thumbnail: signedThumbUrl,
+ },
+ },
+ };
+
+ mockSealMedia.mockResolvedValue({
+ mediaId,
+ file: sealedFileUrl,
+ thumbnail: sealedThumbUrl,
+ });
+
+ const result =
+ await replaceTempMediaWithSealedMediaInPageLayout(layout);
+
+ expect(result.someProp.nested.thumbnail).toBe(sealedThumbUrl);
+ });
+
+ it("should handle nested arrays and objects", async () => {
+ const mediaId1 = "media-1";
+ const mediaId2 = "media-2";
+
+ const signedUrl1 = `https://bucket/i/${mediaId1}/main.png`;
+ const signedUrl2 = `https://bucket/i/${mediaId2}/main.png`;
+
+ const sealedUrl1 = `https://bucket/p/${mediaId1}/main.png`;
+ const sealedUrl2 = `https://bucket/p/${mediaId2}/main.png`;
+
+ const layout = [
+ {
+ items: [{ image: signedUrl1 }, { image: signedUrl2 }],
+ },
+ ];
+
+ mockSealMedia.mockImplementation(async (id) => {
+ if (id === mediaId1) return { file: sealedUrl1, thumbnail: "" };
+ if (id === mediaId2) return { file: sealedUrl2, thumbnail: "" };
+ return null;
+ });
+
+ const result =
+ await replaceTempMediaWithSealedMediaInPageLayout(layout);
+
+ expect(result[0].items[0].image).toBe(sealedUrl1);
+ expect(result[0].items[1].image).toBe(sealedUrl2);
+ });
+
+ it("should ignore URLs that do not match the structure", async () => {
+ const layout = {
+ url: "https://google.com/some/path",
+ };
+
+ const result =
+ await replaceTempMediaWithSealedMediaInPageLayout(layout);
+ expect(result.url).toBe("https://google.com/some/path");
+ });
+
+ it("should define traverse function to handle deeply nested media replacements", async () => {
+ // This test case specifically mimics the user's provided terminal output structure
+ const mediaId = "IZGUXrznb9_BnmiZ19jnFVkFFwyzmyQAoSFp7D2X";
+ const signedFile = `https://bucket/i/${mediaId}/main.webp?Expires=1769867104&Key-Pair-Id=K2R29YJF4UHNZO&Signature=rOl0JYdvRz`;
+ const signedThumb = `https://bucket/i/${mediaId}/thumb.webp?Expires=1769867104&Key-Pair-Id=K2R29YJF4UHNZO&Signature=g5aqyntG1iP`;
+
+ const sealedFile = `https://bucket/p/${mediaId}/main.webp`;
+ const sealedThumb = `https://bucket/p/${mediaId}/thumb.webp`;
+
+ const inputLayout = [
+ {
+ name: "header",
+ shared: true,
+ deleteable: false,
+ widgetId: "JTJrDLBlvFDp-ocZ9abs2",
+ },
+ {
+ widgetId: "AirNFjSBN46gy1yfOz09w",
+ name: "media",
+ deleteable: true,
+ settings: {
+ pageId: "sealed-media-1",
+ type: "site",
+ entityId: "skillviss",
+ media: {
+ mediaId: mediaId,
+ originalFileName: "thumb (1).webp",
+ mimeType: "image/webp",
+ size: 5752,
+ access: "public",
+ file: signedFile,
+ thumbnail: signedThumb,
+ caption: null,
+ },
+ mediaRadius: 2,
+ playVideoInModal: false,
+ aspectRatio: "16/9",
+ objectFit: "cover",
+ hasBorder: true,
+ },
+ },
+ {
+ name: "footer",
+ shared: true,
+ deleteable: false,
+ widgetId: "vh1fwLAQYFoTC-WFmyJ5O",
+ },
+ ];
+
+ mockSealMedia.mockResolvedValue({
+ mediaId: mediaId,
+ file: sealedFile,
+ thumbnail: sealedThumb,
+ });
+
+ const result =
+ await replaceTempMediaWithSealedMediaInPageLayout(inputLayout);
+
+ // Assertions based on "This is the final URL with media object containing sealed URLs"
+ const mediaWidget = result.find((w: any) => w.name === "media");
+ expect(mediaWidget.settings.media.file).toBe(sealedFile);
+ expect(mediaWidget.settings.media.thumbnail).toBe(sealedThumb);
+ });
+});
diff --git a/apps/web/lib/get-deleted-media-ids.ts b/apps/web/lib/get-deleted-media-ids.ts
index cb08fa5c6..164428b57 100644
--- a/apps/web/lib/get-deleted-media-ids.ts
+++ b/apps/web/lib/get-deleted-media-ids.ts
@@ -1,3 +1,5 @@
+import { extractMediaIDs } from "@courselit/utils";
+
export default function getDeletedMediaIds(
prev: string,
next: string,
@@ -7,36 +9,3 @@ export default function getDeletedMediaIds(
return Array.from(prevSrcs).filter((src) => !nextSrcs.has(src));
}
-
-export function extractMediaIDs(doc: string): Set {
- const mediaIds = new Set();
-
- const regex = /https?:\/\/[^\s"']+/gi;
- let match: RegExpExecArray | null;
- while ((match = regex.exec(doc)) !== null) {
- const url = match[0];
-
- try {
- const { pathname } = new URL(url);
- const segments = pathname.split("/").filter(Boolean);
-
- if (segments.length < 2) {
- continue;
- }
-
- const lastSegment = segments[segments.length - 1];
- if (!/^main\.[^/]+$/i.test(lastSegment)) {
- continue;
- }
-
- const mediaId = segments[segments.length - 2];
- if (mediaId) {
- mediaIds.add(mediaId);
- }
- } catch {
- continue;
- }
- }
-
- return mediaIds;
-}
diff --git a/apps/web/lib/replace-temp-media-with-sealed-media-in-page-layout.ts b/apps/web/lib/replace-temp-media-with-sealed-media-in-page-layout.ts
new file mode 100644
index 000000000..f745d0362
--- /dev/null
+++ b/apps/web/lib/replace-temp-media-with-sealed-media-in-page-layout.ts
@@ -0,0 +1,68 @@
+import { sealMedia } from "@/services/medialit";
+import { extractMediaIDs } from "@courselit/utils";
+import { Media } from "@courselit/common-models";
+
+export async function replaceTempMediaWithSealedMediaInPageLayout(
+ layout: any,
+): Promise {
+ const mediaIds = Array.from(extractMediaIDs(JSON.stringify(layout)));
+ for (const mediaId of mediaIds) {
+ const media = await sealMedia(mediaId);
+ if (media) {
+ layout = replaceMediaURLinPageLayout(layout, mediaId, media);
+ }
+ }
+
+ return layout;
+}
+
+function replaceMediaURLinPageLayout(
+ layout: any,
+ mediaId: string,
+ media: Media,
+): any {
+ const traverse = (node: any): any => {
+ if (typeof node === "string") {
+ try {
+ const { pathname } = new URL(node);
+ const segments = pathname.split("/").filter(Boolean);
+
+ if (segments.length >= 2) {
+ const lastSegment = segments[segments.length - 1];
+ const id = segments[segments.length - 2];
+
+ if (id === mediaId) {
+ if (/^main\.[^/]+$/i.test(lastSegment) && media.file) {
+ return media.file;
+ }
+ if (
+ /^thumb\.[^/]+$/i.test(lastSegment) &&
+ media.thumbnail
+ ) {
+ return media.thumbnail;
+ }
+ }
+ }
+ } catch {
+ // Ignore invalid URLs
+ }
+
+ return node;
+ }
+
+ if (Array.isArray(node)) {
+ return node.map(traverse);
+ }
+
+ if (node && typeof node === "object") {
+ for (const key in node) {
+ node[key] = traverse(node[key]);
+ }
+ return node;
+ }
+
+ return node;
+ };
+
+ return traverse(layout);
+}
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
new file mode 100644
index 000000000..c1d4329c6
--- /dev/null
+++ b/apps/web/lib/replace-temp-media-with-sealed-media-in-prosemirror-doc.ts
@@ -0,0 +1,59 @@
+import { sealMedia } from "@/services/medialit";
+import { extractMediaIDs } from "@courselit/utils";
+import { TextEditorContent } from "@courselit/common-models";
+
+export async function replaceTempMediaWithSealedMediaInProseMirrorDoc(
+ doc: string,
+): Promise {
+ if (!doc) return { type: "doc", content: [] };
+
+ const mediaIds = Array.from(extractMediaIDs(doc));
+ for (const mediaId of mediaIds) {
+ const media = await sealMedia(mediaId);
+ if (media) {
+ doc = replaceMediaURLinProseMirrorDoc(doc, mediaId, media.file!);
+ }
+ }
+
+ return JSON.parse(doc);
+}
+
+function replaceMediaURLinProseMirrorDoc(
+ doc: string,
+ mediaId: string,
+ newURL: string,
+): string {
+ try {
+ const json = JSON.parse(doc);
+
+ const traverse = (node: any) => {
+ if (node.attrs?.src) {
+ try {
+ const { pathname } = new URL(node.attrs.src);
+ const segments = pathname.split("/").filter(Boolean);
+
+ if (segments.length >= 2) {
+ const lastSegment = segments[segments.length - 1];
+ if (/^main\.[^/]+$/i.test(lastSegment)) {
+ const id = segments[segments.length - 2];
+ if (id === mediaId) {
+ node.attrs.src = newURL;
+ }
+ }
+ }
+ } catch {
+ // Ignore invalid URLs
+ }
+ }
+
+ if (Array.isArray(node.content)) {
+ node.content.forEach(traverse);
+ }
+ };
+
+ traverse(json);
+ return JSON.stringify(json);
+ } catch {
+ return doc;
+ }
+}
diff --git a/apps/web/next-env.d.ts b/apps/web/next-env.d.ts
index c4b7818fb..9edff1c7c 100644
--- a/apps/web/next-env.d.ts
+++ b/apps/web/next-env.d.ts
@@ -1,6 +1,6 @@
///
///
-import "./.next/dev/types/routes.d.ts";
+import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
diff --git a/apps/web/package.json b/apps/web/package.json
index d8d9fc6b4..9ac450d62 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -1,118 +1,118 @@
{
- "name": "@courselit/web",
- "version": "0.71.0",
- "private": true,
- "scripts": {
- "dev": "next dev",
- "build": "next build",
- "start": "next start",
- "prettier": "prettier --write **/*.ts"
- },
- "dependencies": {
- "@better-auth/sso": "^1.4.6",
- "@courselit/common-logic": "workspace:^",
- "@courselit/common-models": "workspace:^",
- "@courselit/components-library": "workspace:^",
- "@courselit/email-editor": "workspace:^",
- "@courselit/icons": "workspace:^",
- "@courselit/page-blocks": "workspace:^",
- "@courselit/page-models": "workspace:^",
- "@courselit/page-primitives": "workspace:^",
- "@courselit/text-editor": "workspace:^",
- "@courselit/utils": "workspace:^",
- "@hookform/resolvers": "^3.9.1",
- "@radix-ui/react-alert-dialog": "^1.1.11",
- "@radix-ui/react-avatar": "^1.1.3",
- "@radix-ui/react-checkbox": "^1.1.4",
- "@radix-ui/react-collapsible": "^1.1.3",
- "@radix-ui/react-compose-refs": "^1.1.1",
- "@radix-ui/react-dialog": "^1.1.6",
- "@radix-ui/react-dropdown-menu": "^2.1.6",
- "@radix-ui/react-label": "^2.1.4",
- "@radix-ui/react-popover": "^1.1.6",
- "@radix-ui/react-progress": "^1.1.7",
- "@radix-ui/react-radio-group": "^1.2.3",
- "@radix-ui/react-scroll-area": "^1.2.3",
- "@radix-ui/react-select": "^2.1.6",
- "@radix-ui/react-separator": "^1.1.4",
- "@radix-ui/react-slot": "^1.2.3",
- "@radix-ui/react-switch": "^1.1.3",
- "@radix-ui/react-tabs": "^1.1.3",
- "@radix-ui/react-toast": "^1.2.6",
- "@radix-ui/react-toggle": "^1.1.6",
- "@radix-ui/react-toggle-group": "^1.1.7",
- "@radix-ui/react-tooltip": "^1.1.8",
- "@radix-ui/react-visually-hidden": "^1.1.0",
- "@stripe/stripe-js": "^5.4.0",
- "@types/base-64": "^1.0.0",
- "adm-zip": "^0.5.16",
- "archiver": "^5.3.1",
- "aws4": "^1.13.2",
- "base-64": "^1.0.0",
- "better-auth": "^1.4.1",
- "chart.js": "^4.4.7",
- "class-variance-authority": "^0.7.0",
- "clsx": "^2.1.1",
- "color-convert": "^3.1.0",
- "cookie": "^0.4.2",
- "date-fns": "^4.1.0",
- "graphql": "^16.10.0",
- "graphql-type-json": "^0.3.2",
- "jsdom": "^26.1.0",
- "lodash.debounce": "^4.0.8",
- "lucide-react": "^0.553.0",
- "medialit": "^0.1.0",
- "mongodb": "^6.15.0",
- "mongoose": "^8.13.1",
- "next": "^16.0.10",
- "next-themes": "^0.4.6",
- "nodemailer": "^6.7.2",
- "pug": "^3.0.2",
- "razorpay": "^2.9.4",
- "react": "19.2.0",
- "react-chartjs-2": "^5.3.0",
- "react-csv": "^2.2.2",
- "react-dom": "19.2.0",
- "react-hook-form": "^7.54.1",
- "recharts": "^2.15.1",
- "remirror": "^3.0.1",
- "sharp": "^0.33.2",
- "slugify": "^1.6.5",
- "sonner": "^2.0.7",
- "stripe": "^17.5.0",
- "tailwind-merge": "^2.5.4",
- "tailwindcss-animate": "^1.0.7",
- "xml2js": "^0.6.2",
- "zod": "^3.24.1"
- },
- "devDependencies": {
- "@eslint/eslintrc": "^3.3.1",
- "@shelf/jest-mongodb": "^5.2.2",
- "@types/adm-zip": "^0.5.7",
- "@types/bcryptjs": "^2.4.2",
- "@types/cookie": "^0.4.1",
- "@types/mongodb": "^4.0.7",
- "@types/node": "17.0.21",
- "@types/nodemailer": "^6.4.4",
- "@types/pug": "^2.0.6",
- "@types/react": "19.2.4",
- "@types/xml2js": "^0.4.14",
- "eslint": "^9.12.0",
- "eslint-config-next": "16.0.3",
- "eslint-config-prettier": "^9.0.0",
- "identity-obj-proxy": "^3.0.0",
- "mongodb-memory-server": "^10.1.4",
- "postcss": "^8.4.27",
- "prettier": "^3.0.2",
- "tailwind-config": "workspace:^",
- "tailwindcss": "^3.4.1",
- "ts-jest": "^29.4.4",
- "tsconfig": "workspace:^",
- "typescript": "^5.6.2"
- },
- "pnpm": {
- "overrides": {
- "@types/react": "19.2.4"
+ "name": "@courselit/web",
+ "version": "0.71.0",
+ "private": true,
+ "scripts": {
+ "dev": "next dev",
+ "build": "next build",
+ "start": "next start",
+ "prettier": "prettier --write **/*.ts"
+ },
+ "dependencies": {
+ "@better-auth/sso": "^1.4.6",
+ "@courselit/common-logic": "workspace:^",
+ "@courselit/common-models": "workspace:^",
+ "@courselit/components-library": "workspace:^",
+ "@courselit/email-editor": "workspace:^",
+ "@courselit/icons": "workspace:^",
+ "@courselit/page-blocks": "workspace:^",
+ "@courselit/page-models": "workspace:^",
+ "@courselit/page-primitives": "workspace:^",
+ "@courselit/text-editor": "workspace:^",
+ "@courselit/utils": "workspace:^",
+ "@hookform/resolvers": "^3.9.1",
+ "@radix-ui/react-alert-dialog": "^1.1.11",
+ "@radix-ui/react-avatar": "^1.1.3",
+ "@radix-ui/react-checkbox": "^1.1.4",
+ "@radix-ui/react-collapsible": "^1.1.3",
+ "@radix-ui/react-compose-refs": "^1.1.1",
+ "@radix-ui/react-dialog": "^1.1.6",
+ "@radix-ui/react-dropdown-menu": "^2.1.6",
+ "@radix-ui/react-label": "^2.1.4",
+ "@radix-ui/react-popover": "^1.1.6",
+ "@radix-ui/react-progress": "^1.1.7",
+ "@radix-ui/react-radio-group": "^1.2.3",
+ "@radix-ui/react-scroll-area": "^1.2.3",
+ "@radix-ui/react-select": "^2.1.6",
+ "@radix-ui/react-separator": "^1.1.4",
+ "@radix-ui/react-slot": "^1.2.3",
+ "@radix-ui/react-switch": "^1.1.3",
+ "@radix-ui/react-tabs": "^1.1.3",
+ "@radix-ui/react-toast": "^1.2.6",
+ "@radix-ui/react-toggle": "^1.1.6",
+ "@radix-ui/react-toggle-group": "^1.1.7",
+ "@radix-ui/react-tooltip": "^1.1.8",
+ "@radix-ui/react-visually-hidden": "^1.1.0",
+ "@stripe/stripe-js": "^5.4.0",
+ "@types/base-64": "^1.0.0",
+ "adm-zip": "^0.5.16",
+ "archiver": "^5.3.1",
+ "aws4": "^1.13.2",
+ "base-64": "^1.0.0",
+ "better-auth": "^1.4.1",
+ "chart.js": "^4.4.7",
+ "class-variance-authority": "^0.7.0",
+ "clsx": "^2.1.1",
+ "color-convert": "^3.1.0",
+ "cookie": "^0.4.2",
+ "date-fns": "^4.1.0",
+ "graphql": "^16.10.0",
+ "graphql-type-json": "^0.3.2",
+ "jsdom": "^26.1.0",
+ "lodash.debounce": "^4.0.8",
+ "lucide-react": "^0.553.0",
+ "medialit": "0.2.0",
+ "mongodb": "^6.15.0",
+ "mongoose": "^8.13.1",
+ "next": "^16.0.10",
+ "next-themes": "^0.4.6",
+ "nodemailer": "^6.7.2",
+ "pug": "^3.0.2",
+ "razorpay": "^2.9.4",
+ "react": "19.2.0",
+ "react-chartjs-2": "^5.3.0",
+ "react-csv": "^2.2.2",
+ "react-dom": "19.2.0",
+ "react-hook-form": "^7.54.1",
+ "recharts": "^2.15.1",
+ "remirror": "^3.0.1",
+ "sharp": "^0.33.2",
+ "slugify": "^1.6.5",
+ "sonner": "^2.0.7",
+ "stripe": "^17.5.0",
+ "tailwind-merge": "^2.5.4",
+ "tailwindcss-animate": "^1.0.7",
+ "xml2js": "^0.6.2",
+ "zod": "^3.24.1"
+ },
+ "devDependencies": {
+ "@eslint/eslintrc": "^3.3.1",
+ "@shelf/jest-mongodb": "^5.2.2",
+ "@types/adm-zip": "^0.5.7",
+ "@types/bcryptjs": "^2.4.2",
+ "@types/cookie": "^0.4.1",
+ "@types/mongodb": "^4.0.7",
+ "@types/node": "17.0.21",
+ "@types/nodemailer": "^6.4.4",
+ "@types/pug": "^2.0.6",
+ "@types/react": "19.2.4",
+ "@types/xml2js": "^0.4.14",
+ "eslint": "^9.12.0",
+ "eslint-config-next": "16.0.3",
+ "eslint-config-prettier": "^9.0.0",
+ "identity-obj-proxy": "^3.0.0",
+ "mongodb-memory-server": "^10.1.4",
+ "postcss": "^8.4.27",
+ "prettier": "^3.0.2",
+ "tailwind-config": "workspace:^",
+ "tailwindcss": "^3.4.1",
+ "ts-jest": "^29.4.4",
+ "tsconfig": "workspace:^",
+ "typescript": "^5.6.2"
+ },
+ "pnpm": {
+ "overrides": {
+ "@types/react": "19.2.4"
+ }
}
- }
-}
+}
\ No newline at end of file
diff --git a/apps/web/services/medialit.ts b/apps/web/services/medialit.ts
index b10ee8653..43d0a15cc 100644
--- a/apps/web/services/medialit.ts
+++ b/apps/web/services/medialit.ts
@@ -33,3 +33,9 @@ export async function deleteMedia(mediaId: string): Promise {
await medialitClient.delete(mediaId);
return true;
}
+
+export async function sealMedia(mediaId: string): Promise {
+ const medialitClient = getMediaLitClient();
+ const media = await medialitClient.seal(mediaId);
+ return media as unknown as Media;
+}
diff --git a/apps/web/tailwind.config.ts b/apps/web/tailwind.config.ts
index 36739cfc7..2c72f6f50 100644
--- a/apps/web/tailwind.config.ts
+++ b/apps/web/tailwind.config.ts
@@ -1,5 +1,5 @@
import type { Config } from "tailwindcss";
-import sharedConfig from "tailwind-config/tailwind.config";
+import sharedConfig from "tailwind-config";
const config = {
presets: [sharedConfig as Config],
diff --git a/bulk-cleanup-domains.sh b/bulk-cleanup-domains.sh
new file mode 100755
index 000000000..46c6ef169
--- /dev/null
+++ b/bulk-cleanup-domains.sh
@@ -0,0 +1,34 @@
+#!/bin/bash
+
+# Tool to run the domain cleanup script for a list of domains provided in a file.
+# Each domain should be on a new line in the input file.
+
+if [ -z "$1" ]; then
+ echo "Usage: $0 "
+ exit 1
+fi
+
+FILE=$1
+
+if [ ! -f "$FILE" ]; then
+ echo "Error: File $FILE not found."
+ exit 1
+fi
+
+echo "Starting bulk cleanup..."
+
+while IFS= read -r domain || [ -n "$domain" ]; do
+ # Trim potential whitespace or carriage returns (common in files exported from Excel)
+ domain=$(echo "$domain" | tr -d '\r' | xargs)
+
+ if [ -z "$domain" ]; then
+ continue
+ fi
+
+ echo "--------------------------------------------------"
+ echo "Cleaning up domain: $domain"
+ pnpm --filter @courselit/scripts domain:cleanup "$domain"
+done < "$FILE"
+
+echo "--------------------------------------------------"
+echo "Bulk cleanup process completed."
diff --git a/deployment/docker/docker-compose.yml b/deployment/docker/docker-compose.yml
index 4090d398c..e5b1eab8f 100644
--- a/deployment/docker/docker-compose.yml
+++ b/deployment/docker/docker-compose.yml
@@ -149,7 +149,7 @@ services:
# - CLOUD_SECRET=${CLOUD_SECRET?'CLOUD_SECRET is required'}
# - CLOUD_BUCKET_NAME=${CLOUD_BUCKET_NAME?'CLOUD_BUCKET_NAME is required'}
# - CLOUD_PREFIX=${CLOUD_PREFIX?'CLOUD_PREFIX is required'}
- # - S3_ENDPOINT=${S3_ENDPOINT?'S3_ENDPOINT is required'}
+ # - CDN_ENDPOINT=${CDN_ENDPOINT?'CDN_ENDPOINT is required'}
# # Temporary file directory for uploads transformations
# - TEMP_FILE_DIR_FOR_UPLOADS=/tmp
@@ -157,8 +157,7 @@ services:
# - ENABLE_TRUST_PROXY=${ENABLE_TRUST_PROXY}
# # CloudFront configuration
- # - USE_CLOUDFRONT=${USE_CLOUDFRONT}
- # - CLOUDFRONT_ENDPOINT=${CLOUDFRONT_ENDPOINT}
+ # - ACCESS_PRIVATE_BUCKET_VIA_CLOUDFRONT=${ACCESS_PRIVATE_BUCKET_VIA_CLOUDFRONT}
# - CLOUDFRONT_KEY_PAIR_ID=${CLOUDFRONT_KEY_PAIR_ID}
# - CLOUDFRONT_PRIVATE_KEY=${CLOUDFRONT_PRIVATE_KEY}
# - CDN_MAX_AGE=${CDN_MAX_AGE}
diff --git a/packages/common-models/src/community.ts b/packages/common-models/src/community.ts
index 2cb244ec2..72bea5ac6 100644
--- a/packages/common-models/src/community.ts
+++ b/packages/common-models/src/community.ts
@@ -4,7 +4,7 @@ import { TextEditorContent } from "./text-editor-content";
export interface Community {
communityId: string;
name: string;
- description: Record;
+ description: TextEditorContent;
banner: TextEditorContent | null;
categories: string[];
enabled: boolean;
diff --git a/packages/components-library/tailwind.config.ts b/packages/components-library/tailwind.config.ts
index 5d2cad9f5..50488a0c4 100644
--- a/packages/components-library/tailwind.config.ts
+++ b/packages/components-library/tailwind.config.ts
@@ -1,4 +1,4 @@
-import sharedConfig from "tailwind-config/tailwind.config";
+import sharedConfig from "tailwind-config";
const config = {
presets: [sharedConfig],
diff --git a/packages/page-blocks/src/blocks/banner/settings.ts b/packages/page-blocks/src/blocks/banner/settings.ts
index a3ce751f6..d6678e47a 100644
--- a/packages/page-blocks/src/blocks/banner/settings.ts
+++ b/packages/page-blocks/src/blocks/banner/settings.ts
@@ -1,4 +1,4 @@
-import { Alignment } from "@courselit/common-models";
+import { Alignment, TextEditorContent } from "@courselit/common-models";
import { WidgetDefaultSettings } from "@courselit/common-models";
export default interface Settings extends WidgetDefaultSettings {
@@ -9,7 +9,7 @@ export default interface Settings extends WidgetDefaultSettings {
buttonAction?: string;
alignment?: "top" | "bottom" | "left" | "right";
textAlignment?: Alignment;
- successMessage?: Record;
+ successMessage?: TextEditorContent;
failureMessage?: string;
editingViewShowSuccess: "1" | "0";
mediaRadius?: number;
diff --git a/packages/page-blocks/src/blocks/banner/widget.tsx b/packages/page-blocks/src/blocks/banner/widget.tsx
index a212a1522..878af9103 100644
--- a/packages/page-blocks/src/blocks/banner/widget.tsx
+++ b/packages/page-blocks/src/blocks/banner/widget.tsx
@@ -1,5 +1,10 @@
import { FormEvent, useState } from "react";
-import { Constants, Media, WidgetProps } from "@courselit/common-models";
+import {
+ Constants,
+ Media,
+ TextEditorContent,
+ WidgetProps,
+} from "@courselit/common-models";
import {
Image,
Link,
@@ -81,7 +86,7 @@ export default function Widget({
const [success, setSuccess] = useState(false);
const { toast } = useToast();
const type = product.pageType;
- const defaultSuccessMessage: Record = {
+ const defaultSuccessMessage: TextEditorContent = {
type: "doc",
content: [
{
diff --git a/packages/page-blocks/src/blocks/content/settings.ts b/packages/page-blocks/src/blocks/content/settings.ts
index 088b30b4a..46306125b 100644
--- a/packages/page-blocks/src/blocks/content/settings.ts
+++ b/packages/page-blocks/src/blocks/content/settings.ts
@@ -1,8 +1,12 @@
-import { Alignment, WidgetDefaultSettings } from "@courselit/common-models";
+import {
+ Alignment,
+ TextEditorContent,
+ WidgetDefaultSettings,
+} from "@courselit/common-models";
export default interface Settings extends WidgetDefaultSettings {
title: string;
- description: Record;
+ description: TextEditorContent;
headerAlignment: Alignment;
cssId?: string;
}
diff --git a/packages/page-blocks/src/blocks/faq/admin-widget/index.tsx b/packages/page-blocks/src/blocks/faq/admin-widget/index.tsx
index 826227b08..61a52d6ae 100644
--- a/packages/page-blocks/src/blocks/faq/admin-widget/index.tsx
+++ b/packages/page-blocks/src/blocks/faq/admin-widget/index.tsx
@@ -1,7 +1,13 @@
import React, { useEffect, useState } from "react";
import Settings, { Item } from "../settings";
import ItemEditor from "./item-editor";
-import { Address, Auth, Profile, Alignment } from "@courselit/common-models";
+import {
+ Address,
+ Auth,
+ Profile,
+ Alignment,
+ TextEditorContent,
+} from "@courselit/common-models";
import { Theme, ThemeStyle } from "@courselit/page-models";
import {
AdminWidgetPanel,
@@ -58,7 +64,7 @@ export default function AdminWidget({
},
],
};
- const dummyItemDescription: Record = {
+ const dummyItemDescription: TextEditorContent = {
type: "doc",
content: [
{
diff --git a/packages/page-blocks/src/blocks/faq/settings.ts b/packages/page-blocks/src/blocks/faq/settings.ts
index 258b7c886..91515ee6f 100644
--- a/packages/page-blocks/src/blocks/faq/settings.ts
+++ b/packages/page-blocks/src/blocks/faq/settings.ts
@@ -1,13 +1,17 @@
-import { Alignment, WidgetDefaultSettings } from "@courselit/common-models";
+import {
+ Alignment,
+ TextEditorContent,
+ WidgetDefaultSettings,
+} from "@courselit/common-models";
export interface Item {
title: string;
- description: Record;
+ description: TextEditorContent;
}
export default interface Settings extends WidgetDefaultSettings {
title: string;
- description?: Record;
+ description?: TextEditorContent;
headerAlignment: Alignment;
itemsAlignment: Alignment;
items?: Item[];
diff --git a/packages/page-blocks/src/blocks/grid/admin-widget/index.tsx b/packages/page-blocks/src/blocks/grid/admin-widget/index.tsx
index 37d4fc20b..922a0d5d1 100644
--- a/packages/page-blocks/src/blocks/grid/admin-widget/index.tsx
+++ b/packages/page-blocks/src/blocks/grid/admin-widget/index.tsx
@@ -1,7 +1,12 @@
import React, { useEffect, useState } from "react";
import Settings, { Item, SvgStyle } from "../settings";
import ItemEditor from "./item-editor";
-import { Address, Profile, Alignment } from "@courselit/common-models";
+import {
+ Address,
+ Profile,
+ Alignment,
+ TextEditorContent,
+} from "@courselit/common-models";
import {
AdminWidgetPanel,
AdminWidgetPanelContainer,
@@ -59,7 +64,7 @@ export default function AdminWidget({
},
],
};
- const dummyItemDescription: Record = {
+ const dummyItemDescription: TextEditorContent = {
type: "doc",
content: [
{
diff --git a/packages/page-blocks/src/blocks/grid/settings.ts b/packages/page-blocks/src/blocks/grid/settings.ts
index a928d6f2b..aa2a4dde9 100644
--- a/packages/page-blocks/src/blocks/grid/settings.ts
+++ b/packages/page-blocks/src/blocks/grid/settings.ts
@@ -1,13 +1,14 @@
import type {
Alignment,
Media,
+ TextEditorContent,
VerticalAlignment,
WidgetDefaultSettings,
} from "@courselit/common-models";
export interface Item {
title: string;
- description?: Record;
+ description?: TextEditorContent;
buttonCaption?: string;
buttonAction?: string;
media?: Partial;
@@ -28,7 +29,7 @@ export interface SvgStyle {
export default interface Settings extends WidgetDefaultSettings {
title: string;
- description?: Record;
+ description?: TextEditorContent;
headerAlignment: Alignment;
itemsAlignment: Alignment;
buttonCaption?: string;
diff --git a/packages/page-blocks/src/blocks/hero/settings.ts b/packages/page-blocks/src/blocks/hero/settings.ts
index b703a851f..cecaeab4b 100644
--- a/packages/page-blocks/src/blocks/hero/settings.ts
+++ b/packages/page-blocks/src/blocks/hero/settings.ts
@@ -1,6 +1,7 @@
import {
Alignment,
Media,
+ TextEditorContent,
WidgetDefaultSettings,
} from "@courselit/common-models";
import { ImageObjectFit } from "@courselit/components-library";
@@ -8,7 +9,7 @@ import { AspectRatio } from "@courselit/components-library";
export default interface Settings extends WidgetDefaultSettings {
title?: string;
- description?: Record;
+ description?: TextEditorContent;
buttonCaption?: string;
buttonAction?: string;
media?: Media;
diff --git a/packages/page-blocks/src/blocks/pricing/admin-widget/index.tsx b/packages/page-blocks/src/blocks/pricing/admin-widget/index.tsx
index a19ed4456..0864004fe 100644
--- a/packages/page-blocks/src/blocks/pricing/admin-widget/index.tsx
+++ b/packages/page-blocks/src/blocks/pricing/admin-widget/index.tsx
@@ -1,7 +1,11 @@
import React, { useEffect, useState } from "react";
import Settings, { Item } from "../settings";
import ItemEditor from "./item-editor";
-import { Address, Alignment } from "@courselit/common-models";
+import {
+ Address,
+ Alignment,
+ TextEditorContent,
+} from "@courselit/common-models";
import {
AdminWidgetPanel,
AdminWidgetPanelContainer,
@@ -55,7 +59,7 @@ export default function AdminWidget({
},
],
};
- const dummyItemDescription: Record = {
+ const dummyItemDescription: TextEditorContent = {
type: "doc",
content: [
{
diff --git a/packages/page-blocks/src/blocks/pricing/settings.ts b/packages/page-blocks/src/blocks/pricing/settings.ts
index 841d1c1e9..c9c10668f 100644
--- a/packages/page-blocks/src/blocks/pricing/settings.ts
+++ b/packages/page-blocks/src/blocks/pricing/settings.ts
@@ -1,4 +1,8 @@
-import { Alignment, WidgetDefaultSettings } from "@courselit/common-models";
+import {
+ Alignment,
+ TextEditorContent,
+ WidgetDefaultSettings,
+} from "@courselit/common-models";
interface ItemAction {
label: string;
@@ -10,7 +14,7 @@ export interface Item {
title: string;
price: string;
priceYearly?: string;
- description: Record;
+ description: TextEditorContent;
features: string;
action: ItemAction;
primary?: boolean;
@@ -18,7 +22,7 @@ export interface Item {
export default interface Settings extends WidgetDefaultSettings {
title: string;
- description?: Record;
+ description?: TextEditorContent;
headerAlignment: Alignment;
itemsAlignment: Alignment;
items?: Item[];
diff --git a/packages/page-blocks/src/blocks/rich-text/settings.ts b/packages/page-blocks/src/blocks/rich-text/settings.ts
index ae91c147f..e8a47259b 100644
--- a/packages/page-blocks/src/blocks/rich-text/settings.ts
+++ b/packages/page-blocks/src/blocks/rich-text/settings.ts
@@ -1,10 +1,11 @@
import type {
HorizontalAlignment,
+ TextEditorContent,
WidgetDefaultSettings,
} from "@courselit/common-models";
export default interface Settings extends WidgetDefaultSettings {
- text: Record;
+ text: TextEditorContent;
alignment: HorizontalAlignment;
cssId?: string;
fontSize?: number;
diff --git a/packages/page-blocks/src/components/text-renderer.tsx b/packages/page-blocks/src/components/text-renderer.tsx
index 1ef9fea2f..42d247b92 100644
--- a/packages/page-blocks/src/components/text-renderer.tsx
+++ b/packages/page-blocks/src/components/text-renderer.tsx
@@ -14,9 +14,10 @@ import {
Link,
Text1,
} from "@courselit/page-primitives";
+import { TextEditorContent } from "@courselit/common-models";
interface TextRendererProps {
- json: Record;
+ json: TextEditorContent;
className?: string;
theme?: ThemeStyle;
}
diff --git a/packages/page-blocks/tailwind.config.ts b/packages/page-blocks/tailwind.config.ts
index d9be9e1df..a1ec0d8c6 100644
--- a/packages/page-blocks/tailwind.config.ts
+++ b/packages/page-blocks/tailwind.config.ts
@@ -1,4 +1,4 @@
-import sharedConfig from "tailwind-config/tailwind.config";
+import sharedConfig from "tailwind-config";
const config = {
presets: [sharedConfig],
diff --git a/packages/page-primitives/tailwind.config.ts b/packages/page-primitives/tailwind.config.ts
index d9be9e1df..a1ec0d8c6 100644
--- a/packages/page-primitives/tailwind.config.ts
+++ b/packages/page-primitives/tailwind.config.ts
@@ -1,4 +1,4 @@
-import sharedConfig from "tailwind-config/tailwind.config";
+import sharedConfig from "tailwind-config";
const config = {
presets: [sharedConfig],
diff --git a/packages/scripts/package.json b/packages/scripts/package.json
index 3b9693b7b..e9a8bed48 100644
--- a/packages/scripts/package.json
+++ b/packages/scripts/package.json
@@ -8,6 +8,7 @@
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"domain:cleanup": "ts-node src/cleanup-domain.ts",
+ "media:refresh": "ts-node src/refresh-media.ts",
"build": "tsc"
},
"keywords": [],
diff --git a/packages/scripts/src/cleanup-domain.ts b/packages/scripts/src/cleanup-domain.ts
index c3e8f6c19..c46448226 100644
--- a/packages/scripts/src/cleanup-domain.ts
+++ b/packages/scripts/src/cleanup-domain.ts
@@ -1,3 +1,8 @@
+/**
+ * Deletes a domain and all its associated data.
+ *
+ * Usage: pnpm --filter @courselit/scripts domain:cleanup
+ */
import mongoose from "mongoose";
import {
CourseSchema,
@@ -40,7 +45,7 @@ import { loadEnvFile } from "node:process";
import { MediaLit } from "medialit";
import { extractMediaIDs } from "@courselit/utils";
import CommonModels from "@courselit/common-models";
-const { CommunityMediaTypes } = CommonModels;
+const { CommunityMediaTypes, Constants } = CommonModels;
function getMediaLitClient() {
const medialit = new MediaLit({
@@ -164,6 +169,7 @@ async function cleanupDomain(name: string) {
await deleteMedia(mediaId);
}
await DomainModel.deleteOne({ _id: domain._id });
+ console.log(`ā
Deleted: ${name}`);
}
async function deleteProduct({
@@ -243,19 +249,35 @@ async function deleteLessons(id: string, domain: mongoose.Types.ObjectId) {
courseId: id,
domain,
}).lean()) as InternalLesson[];
+
+ const cleanupTasks: Promise[] = [];
+
for (const lesson of lessons) {
if (lesson.media?.mediaId) {
- await deleteMedia(lesson.media.mediaId);
+ cleanupTasks.push(deleteMedia(lesson.media.mediaId));
}
- if (lesson.content) {
+ if (lesson.type === Constants.LessonType.TEXT && lesson.content) {
const extractedMediaIds = extractMediaIDs(
JSON.stringify(lesson.content),
);
for (const mediaId of Array.from(extractedMediaIds)) {
- await deleteMedia(mediaId);
+ cleanupTasks.push(deleteMedia(mediaId));
}
}
+ if (
+ lesson.type === Constants.LessonType.SCORM &&
+ lesson.content &&
+ (lesson.content as CommonModels.ScormContent).mediaId
+ ) {
+ cleanupTasks.push(
+ deleteMedia(
+ (lesson.content as CommonModels.ScormContent).mediaId!,
+ ),
+ );
+ }
}
+
+ await Promise.all(cleanupTasks);
await LessonModel.deleteMany({ courseId: id, domain });
}
diff --git a/packages/scripts/src/refresh-media.ts b/packages/scripts/src/refresh-media.ts
new file mode 100644
index 000000000..6f211509d
--- /dev/null
+++ b/packages/scripts/src/refresh-media.ts
@@ -0,0 +1,862 @@
+/**
+ * Media URL Refresh Script
+ *
+ * This script refreshes all media URLs in the database by fetching
+ * the latest URLs from MediaLit service using the stored mediaId.
+ *
+ * Usage:
+ * pnpm media:refresh [domain-name] [--save]
+ *
+ * Options:
+ * --save Actually update the database (Default is DRY RUN / DISCOVER)
+ *
+ * If domain-name is provided, only that domain's media is refreshed.
+ * If omitted, all domains are processed.
+ *
+ * Environment variables required:
+ * - DB_CONNECTION_STRING: MongoDB connection string
+ * - MEDIALIT_SERVER: MediaLit API server URL
+ * - MEDIALIT_APIKEY: MediaLit API key
+ */
+
+import mongoose from "mongoose";
+import {
+ CourseSchema,
+ DomainSchema,
+ LessonSchema,
+ CertificateTemplateSchema,
+ PageSchema,
+ CommunitySchema,
+ CommunityCommentSchema,
+ CommunityPostSchema,
+ UserSchema,
+} from "@courselit/orm-models";
+import type {
+ InternalCertificateTemplate,
+ InternalCourse,
+ InternalLesson,
+ InternalPage,
+ InternalCommunity,
+ InternalUser,
+ InternalCommunityPost,
+ InternalCommunityComment,
+ Domain,
+} from "@courselit/orm-models";
+import { loadEnvFile } from "node:process";
+import { MediaLit } from "medialit";
+import type { Media, CommunityMedia } from "@courselit/common-models";
+
+// Load environment variables
+loadEnvFile();
+
+// Parse command line arguments
+const args = process.argv.slice(2);
+const saveMode = args.includes("--save");
+const discoverMode = !saveMode;
+const domainArg = args.find((arg) => !arg.startsWith("--"));
+
+if (!process.env.DB_CONNECTION_STRING) {
+ throw new Error("DB_CONNECTION_STRING is not set");
+}
+
+if (!process.env.MEDIALIT_SERVER || !process.env.MEDIALIT_APIKEY) {
+ throw new Error(
+ "MEDIALIT_SERVER and MEDIALIT_APIKEY must be set to fetch refreshed URLs",
+ );
+}
+
+// Initialize MediaLit client
+function getMediaLitClient() {
+ const medialit = new MediaLit({
+ apiKey: process.env.MEDIALIT_APIKEY,
+ endpoint: process.env.MEDIALIT_SERVER,
+ });
+ return medialit;
+}
+
+const medialitClient = getMediaLitClient();
+
+// Statistics
+const stats = {
+ processed: 0,
+ updated: 0,
+ failed: 0,
+ skipped: 0,
+};
+
+// Cache to avoid duplicate API calls for the same mediaId
+const mediaCache = new Map();
+
+/**
+ * Extracts Media ID from a MediaLit URL
+ */
+function extractIdFromUrl(url: string): string | null {
+ try {
+ const { pathname } = new URL(url);
+ const segments = pathname.split("/").filter(Boolean);
+
+ if (segments.length < 2) {
+ return null;
+ }
+
+ const lastSegment = segments[segments.length - 1];
+ if (!/^main\.[^/]+$/i.test(lastSegment)) {
+ return null;
+ }
+
+ return segments[segments.length - 2] || null;
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * Fetch fresh media data from MediaLit
+ */
+async function fetchMediaFromMediaLit(mediaId: string): Promise {
+ // Check cache first
+ if (mediaCache.has(mediaId)) {
+ return mediaCache.get(mediaId) || null;
+ }
+
+ try {
+ const media = await medialitClient.get(mediaId);
+ const result = media as unknown as Media;
+ mediaCache.set(mediaId, result);
+ return result;
+ } catch (error) {
+ console.error(` ā Failed to fetch media ${mediaId}:`, error);
+ mediaCache.set(mediaId, null);
+ return null;
+ }
+}
+
+/**
+ * Update a Media object with fresh URLs from MediaLit
+ * In discover mode, just prints the media object instead of updating
+ */
+async function refreshMediaObject(
+ existingMedia: Media,
+ context?: string,
+): Promise {
+ if (!existingMedia?.mediaId) {
+ return null;
+ }
+
+ stats.processed++;
+
+ // In discover mode, fetch and print comparison but return null
+ if (discoverMode) {
+ console.log(` š ${context || "Media"}: ${existingMedia.mediaId}`);
+ console.log(
+ ` š Current File: ${existingMedia.file || "(none)"}`,
+ );
+ if (existingMedia.thumbnail) {
+ console.log(` š¼ļø Current Thumb: ${existingMedia.thumbnail}`);
+ }
+
+ const freshMedia = await fetchMediaFromMediaLit(existingMedia.mediaId);
+ if (freshMedia) {
+ if (freshMedia.file !== existingMedia.file) {
+ console.log(` ⨠New File: ${freshMedia.file}`);
+ }
+ if (freshMedia.thumbnail !== existingMedia.thumbnail) {
+ console.log(` ⨠New Thumb: ${freshMedia.thumbnail}`);
+ }
+ if (
+ freshMedia.file === existingMedia.file &&
+ freshMedia.thumbnail === existingMedia.thumbnail
+ ) {
+ console.log(` ā
URLs are already up to date`);
+ }
+ } else {
+ console.log(` ā Could not fetch updated URLs from MediaLit`);
+ }
+ return null;
+ }
+
+ const freshMedia = await fetchMediaFromMediaLit(existingMedia.mediaId);
+
+ if (!freshMedia) {
+ stats.failed++;
+ return null;
+ }
+
+ // Check if URLs actually changed
+ if (
+ freshMedia.file === existingMedia.file &&
+ freshMedia.thumbnail === existingMedia.thumbnail
+ ) {
+ stats.skipped++;
+ return null;
+ }
+
+ stats.updated++;
+ return {
+ ...existingMedia,
+ file: freshMedia.file,
+ thumbnail: freshMedia.thumbnail,
+ };
+}
+
+/**
+ * Recursively find and refresh Media objects in any structure
+ * Returns true if any updates were made
+ */
+async function recursiveMediaRefresh(obj: any): Promise {
+ if (!obj) return false;
+
+ // Handle stringified JSON (e.g., ProseMirror docs in description/content)
+ if (typeof obj === "string") {
+ if (
+ (obj.trim().startsWith("{") && obj.trim().endsWith("}")) ||
+ (obj.trim().startsWith("[") && obj.trim().endsWith("]"))
+ ) {
+ // We can't update primitive strings in-place via reference.
+ // Caller must handle string return values if they iterate objects.
+ // But here we return boolean indicating update, so for strings passed directly
+ // this function is limited unless we change signature to return updated value.
+ return false;
+ }
+ return false;
+ }
+
+ if (typeof obj !== "object") {
+ return false;
+ }
+
+ let updated = false;
+
+ // Check if this object itself is a Media object
+ if (
+ obj.mediaId &&
+ typeof obj.mediaId === "string" &&
+ (obj.file || obj.thumbnail)
+ ) {
+ const freshMedia = await refreshMediaObject(obj as Media);
+ if (freshMedia) {
+ obj.file = freshMedia.file;
+ obj.thumbnail = freshMedia.thumbnail;
+ return true;
+ }
+ return false;
+ }
+
+ // Recursively check keys
+ for (const key in obj) {
+ if (Object.prototype.hasOwnProperty.call(obj, key)) {
+ const value = obj[key];
+
+ // 1. Special handling for stringified JSON fields (e.g. ProseMirror docs)
+ if (
+ typeof value === "string" &&
+ ((value.trim().startsWith("{") && value.trim().endsWith("}")) ||
+ (value.trim().startsWith("[") &&
+ value.trim().endsWith("]")))
+ ) {
+ try {
+ const parsed = JSON.parse(value);
+ const childUpdated = await recursiveMediaRefresh(parsed);
+ if (childUpdated) {
+ obj[key] = JSON.stringify(parsed);
+ updated = true;
+ }
+ continue; // Skip normal recursion/string check for this key
+ } catch {
+ // Not valid JSON
+ }
+ }
+
+ // 2. If the value is a string, check if it's a MediaLit URL that needs refreshing
+ if (typeof value === "string") {
+ const extractedId = extractIdFromUrl(value);
+ if (extractedId) {
+ stats.processed++;
+ if (discoverMode) {
+ console.log(
+ ` š URL found in key '${key}': ${extractedId}`,
+ );
+ console.log(` š Current Value: ${value}`);
+
+ const freshMedia =
+ await fetchMediaFromMediaLit(extractedId);
+ if (freshMedia) {
+ if (freshMedia.file !== value) {
+ console.log(
+ ` ⨠New Value: ${freshMedia.file}`,
+ );
+ } else {
+ console.log(
+ ` ā
Value is already up to date`,
+ );
+ }
+ } else {
+ console.log(
+ ` ā Could not fetch updated URL from MediaLit`,
+ );
+ }
+ continue;
+ }
+
+ const freshMedia =
+ await fetchMediaFromMediaLit(extractedId);
+ if (freshMedia && freshMedia.file !== value) {
+ // Check if we should use file or thumbnail URL
+ // Usually for 'src' we use file URL.
+ // If the current URL ends with main.png (or similar), we update it to freshMedia.file
+ // Note: Our extractIdFromUrl only matches main images anyway.
+ obj[key] = freshMedia.file;
+ updated = true;
+ stats.updated++;
+ } else if (!freshMedia) {
+ stats.failed++;
+ } else {
+ stats.skipped++;
+ }
+ continue;
+ }
+ }
+
+ // 3. Normal recursion for objects/arrays
+ const result = await recursiveMediaRefresh(value);
+ if (result) {
+ updated = true;
+ }
+ }
+ }
+
+ return updated;
+}
+
+// Initialize models
+mongoose.connect(process.env.DB_CONNECTION_STRING);
+
+const DomainModel = mongoose.model("Domain", DomainSchema);
+const CourseModel = mongoose.model("Course", CourseSchema);
+const LessonModel = mongoose.model("Lesson", LessonSchema);
+const CertificateTemplateModel = mongoose.model(
+ "CertificateTemplate",
+ CertificateTemplateSchema,
+);
+const PageModel = mongoose.model("Page", PageSchema);
+const CommunityModel = mongoose.model("Community", CommunitySchema);
+const CommunityPostModel = mongoose.model("CommunityPost", CommunityPostSchema);
+const CommunityCommentModel = mongoose.model(
+ "CommunityComment",
+ CommunityCommentSchema,
+);
+const UserModel = mongoose.model("User", UserSchema);
+
+/**
+ * Refresh media in Courses (featuredImage and description)
+ */
+async function refreshCourseMedia(domainId: mongoose.Types.ObjectId) {
+ console.log("\nš¦ Processing courses...");
+
+ const courses = (await CourseModel.find({
+ domain: domainId,
+ }).lean()) as InternalCourse[];
+
+ for (const course of courses) {
+ let hasUpdates = false;
+ const updates: Record = {};
+
+ // 1. Featured Image
+ if (course.featuredImage?.mediaId) {
+ const updatedMedia = await refreshMediaObject(course.featuredImage);
+ if (updatedMedia) {
+ updates.featuredImage = updatedMedia;
+ hasUpdates = true;
+ }
+ }
+
+ // 2. Description (scanning for stringified JSON with Media)
+ if (course.description) {
+ const container = { value: course.description };
+ const descUpdated = await recursiveMediaRefresh(container);
+ if (descUpdated) {
+ updates.description = container.value;
+ hasUpdates = true;
+ console.log(` ā Media in course description updated`);
+ }
+ }
+
+ if (hasUpdates) {
+ const result = await CourseModel.updateOne(
+ { _id: (course as any)._id },
+ { $set: updates },
+ );
+ if (result.matchedCount === 0) {
+ console.error(
+ ` ā Failed to update course: ${course.title} (No document found)`,
+ );
+ } else {
+ console.log(` ā Course: ${course.title}`);
+ }
+ }
+ }
+}
+
+/**
+ * Refresh media in Lessons (media field and content)
+ */
+async function refreshLessonMedia(domainId: mongoose.Types.ObjectId) {
+ console.log("\nš Processing lessons...");
+
+ const lessons = (await LessonModel.find({
+ domain: domainId,
+ }).lean()) as InternalLesson[];
+
+ for (const lesson of lessons) {
+ let hasUpdates = false;
+ const updates: Record = {};
+
+ // 1. Direct media field
+ if (lesson.media?.mediaId) {
+ const updatedMedia = await refreshMediaObject(lesson.media);
+ if (updatedMedia) {
+ updates.media = updatedMedia;
+ hasUpdates = true;
+ }
+ }
+
+ // 2. Content (recursive search for media in content)
+ if (lesson.content) {
+ const container = { value: lesson.content };
+ const contentUpdated = await recursiveMediaRefresh(container);
+ if (contentUpdated) {
+ updates.content = container.value;
+ hasUpdates = true;
+ console.log(` ā Media in lesson content updated`);
+ }
+ }
+
+ if (hasUpdates) {
+ const result = await LessonModel.updateOne(
+ { _id: (lesson as any)._id },
+ { $set: updates },
+ );
+ if (result.matchedCount === 0) {
+ console.error(
+ ` ā Failed to update lesson: ${lesson.title} (No document found)`,
+ );
+ } else {
+ console.log(` ā Lesson: ${lesson.title}`);
+ }
+ }
+ }
+}
+
+/**
+ * Refresh media in Users (avatar)
+ */
+async function refreshUserMedia(domainId: mongoose.Types.ObjectId) {
+ console.log("\nš¤ Processing users...");
+
+ const users = (await UserModel.find({
+ domain: domainId,
+ "avatar.mediaId": { $exists: true },
+ }).lean()) as InternalUser[];
+
+ for (const user of users) {
+ if (user.avatar?.mediaId) {
+ const updatedMedia = await refreshMediaObject(user.avatar);
+ if (updatedMedia) {
+ await UserModel.updateOne(
+ { _id: (user as any)._id },
+ { $set: { avatar: updatedMedia } },
+ );
+ console.log(` ā User: ${user.email}`);
+ }
+ }
+ }
+}
+
+/**
+ * Refresh media in Communities (featuredImage)
+ */
+/**
+ * Refresh media in Communities (featuredImage, banner, description)
+ */
+async function refreshCommunityMedia(domainId: mongoose.Types.ObjectId) {
+ console.log("\nšļø Processing communities...");
+
+ const communities = (await CommunityModel.find({
+ domain: domainId,
+ }).lean()) as InternalCommunity[];
+
+ for (const community of communities) {
+ let hasUpdates = false;
+ const updates: Record = {};
+
+ // 1. Featured Image
+ if (community.featuredImage?.mediaId) {
+ const updatedMedia = await refreshMediaObject(
+ community.featuredImage,
+ );
+ if (updatedMedia) {
+ updates.featuredImage = updatedMedia;
+ hasUpdates = true;
+ }
+ }
+
+ // 2. Banner
+ if (community.banner) {
+ const container = { value: community.banner };
+ const bannerUpdated = await recursiveMediaRefresh(container);
+ if (bannerUpdated) {
+ updates.banner = container.value;
+ hasUpdates = true;
+ }
+ }
+
+ // 3. Description
+ if (community.description) {
+ const container = { value: community.description };
+ const descUpdated = await recursiveMediaRefresh(container);
+ if (descUpdated) {
+ updates.description = container.value;
+ hasUpdates = true;
+ }
+ }
+
+ if (hasUpdates) {
+ await CommunityModel.updateOne(
+ { _id: (community as any)._id },
+ { $set: updates },
+ );
+ console.log(` ā Community: ${community.name}`);
+ }
+ }
+}
+
+/**
+ * Refresh media in CommunityPosts (media[].media)
+ */
+async function refreshCommunityPostMedia(domainId: mongoose.Types.ObjectId) {
+ console.log("\nš Processing community posts...");
+
+ const posts = (await CommunityPostModel.find({
+ domain: domainId,
+ "media.media.mediaId": { $exists: true },
+ }).lean()) as InternalCommunityPost[];
+
+ for (const post of posts) {
+ let hasUpdates = false;
+ const updatedMediaArray: CommunityMedia[] = [...(post.media || [])];
+
+ for (let i = 0; i < updatedMediaArray.length; i++) {
+ const mediaItem = updatedMediaArray[i];
+ if (mediaItem.media?.mediaId) {
+ const updatedMedia = await refreshMediaObject(mediaItem.media);
+ if (updatedMedia) {
+ updatedMediaArray[i] = {
+ ...mediaItem,
+ media: updatedMedia,
+ };
+ hasUpdates = true;
+ }
+ }
+ }
+
+ if (hasUpdates) {
+ await CommunityPostModel.updateOne(
+ { _id: (post as any)._id },
+ { $set: { media: updatedMediaArray } },
+ );
+ console.log(` ā Post: ${post.postId}`);
+ }
+ }
+}
+
+/**
+ * Refresh media in CommunityComments (media[].media and replies[].media[].media)
+ */
+async function refreshCommunityCommentMedia(domainId: mongoose.Types.ObjectId) {
+ console.log("\nš¬ Processing community comments...");
+
+ const comments = (await CommunityCommentModel.find({
+ domain: domainId,
+ $or: [
+ { "media.media.mediaId": { $exists: true } },
+ { "replies.media.media.mediaId": { $exists: true } },
+ ],
+ }).lean()) as InternalCommunityComment[];
+
+ for (const comment of comments) {
+ let hasUpdates = false;
+ const updatedMediaArray: CommunityMedia[] = [...(comment.media || [])];
+ const updatedReplies = [...(comment.replies || [])];
+
+ // Update comment media
+ for (let i = 0; i < updatedMediaArray.length; i++) {
+ const mediaItem = updatedMediaArray[i];
+ if (mediaItem.media?.mediaId) {
+ const updatedMedia = await refreshMediaObject(mediaItem.media);
+ if (updatedMedia) {
+ updatedMediaArray[i] = {
+ ...mediaItem,
+ media: updatedMedia,
+ };
+ hasUpdates = true;
+ }
+ }
+ }
+
+ // Update replies media
+ for (let r = 0; r < updatedReplies.length; r++) {
+ const reply = updatedReplies[r];
+ // Use any[] to handle the type mismatch between InternalReply.media and CommunityMedia
+ const replyMedia: any[] = [...(reply.media || [])];
+
+ for (let i = 0; i < replyMedia.length; i++) {
+ const mediaItem = replyMedia[i];
+ if (mediaItem.media?.mediaId) {
+ const updatedMedia = await refreshMediaObject(
+ mediaItem.media,
+ );
+ if (updatedMedia) {
+ replyMedia[i] = {
+ ...mediaItem,
+ media: updatedMedia,
+ };
+ hasUpdates = true;
+ }
+ }
+ }
+
+ if (hasUpdates) {
+ updatedReplies[r] = { ...reply, media: replyMedia } as any;
+ }
+ }
+
+ if (hasUpdates) {
+ await CommunityCommentModel.updateOne(
+ { _id: (comment as any)._id },
+ { $set: { media: updatedMediaArray, replies: updatedReplies } },
+ );
+ console.log(` ā Comment: ${comment.commentId}`);
+ }
+ }
+}
+
+/**
+ * Refresh media in Pages (socialImage, draftSocialImage, layout, draftLayout)
+ */
+async function refreshPageMedia(domainId: mongoose.Types.ObjectId) {
+ console.log("\nš Processing pages...");
+
+ // We scan all pages because specific mediaIds can be hidden deep in layout JSON
+ const pages = (await PageModel.find({
+ domain: domainId,
+ }).lean()) as InternalPage[];
+
+ for (const page of pages) {
+ let hasUpdates = false;
+ const updates: Record = {};
+
+ // 1. Social Image
+ if (page.socialImage?.mediaId) {
+ const updatedMedia = await refreshMediaObject(page.socialImage);
+ if (updatedMedia) {
+ updates.socialImage = updatedMedia;
+ hasUpdates = true;
+ }
+ }
+
+ // 2. Draft Social Image
+ if (page.draftSocialImage?.mediaId) {
+ const updatedMedia = await refreshMediaObject(
+ page.draftSocialImage,
+ );
+ if (updatedMedia) {
+ updates.draftSocialImage = updatedMedia;
+ hasUpdates = true;
+ }
+ }
+
+ // 3. Layout (Widget settings)
+ if (page.layout && page.layout.length > 0) {
+ // Clone layout to avoid mutating lean object directly during traversal
+ const layoutCopy = JSON.parse(JSON.stringify(page.layout));
+ const layoutUpdated = await recursiveMediaRefresh(layoutCopy);
+ if (layoutUpdated) {
+ updates.layout = layoutCopy;
+ hasUpdates = true;
+ console.log(` ā Widget(s) in layout updated`);
+ }
+ }
+
+ // 4. Draft Layout
+ if (page.draftLayout && page.draftLayout.length > 0) {
+ const draftLayoutCopy = JSON.parse(
+ JSON.stringify(page.draftLayout),
+ );
+ const draftLayoutUpdated =
+ await recursiveMediaRefresh(draftLayoutCopy);
+ if (draftLayoutUpdated) {
+ updates.draftLayout = draftLayoutCopy;
+ hasUpdates = true;
+ console.log(` ā Widget(s) in draft layout updated`);
+ }
+ }
+
+ if (hasUpdates) {
+ await PageModel.updateOne(
+ { _id: (page as any)._id },
+ { $set: updates },
+ );
+ console.log(` ā Page: ${page.name}`);
+ }
+ }
+}
+
+/**
+ * Refresh media in CertificateTemplates (signatureImage, logo)
+ */
+async function refreshCertificateTemplateMedia(
+ domainId: mongoose.Types.ObjectId,
+) {
+ console.log("\nš Processing certificate templates...");
+
+ const templates = (await CertificateTemplateModel.find({
+ domain: domainId,
+ $or: [
+ { "signatureImage.mediaId": { $exists: true } },
+ { "logo.mediaId": { $exists: true } },
+ ],
+ }).lean()) as InternalCertificateTemplate[];
+
+ for (const template of templates) {
+ const updates: Record = {};
+
+ if (template.signatureImage?.mediaId) {
+ const updatedMedia = await refreshMediaObject(
+ template.signatureImage,
+ );
+ if (updatedMedia) {
+ updates.signatureImage = updatedMedia;
+ }
+ }
+
+ if (template.logo?.mediaId) {
+ const updatedMedia = await refreshMediaObject(template.logo);
+ if (updatedMedia) {
+ updates.logo = updatedMedia;
+ }
+ }
+
+ if (Object.keys(updates).length > 0) {
+ await CertificateTemplateModel.updateOne(
+ { _id: (template as any)._id },
+ { $set: updates },
+ );
+ console.log(` ā Certificate Template: ${template.title}`);
+ }
+ }
+}
+
+/**
+ * Refresh media in Domain settings (settings.logo)
+ */
+async function refreshDomainMedia(domainId: mongoose.Types.ObjectId) {
+ console.log("\nš Processing domain settings...");
+
+ const domain = (await DomainModel.findById(
+ domainId,
+ ).lean()) as Domain | null;
+
+ // Cast to Media since Partial from common-models may not have all fields
+ const logo = domain?.settings?.logo as Media | undefined;
+ if (logo?.mediaId) {
+ const updatedMedia = await refreshMediaObject(logo);
+ if (updatedMedia) {
+ await DomainModel.updateOne(
+ { _id: domainId },
+ { $set: { "settings.logo": updatedMedia } },
+ );
+ console.log(` ā Domain logo updated`);
+ }
+ }
+}
+
+/**
+ * Process all media for a single domain
+ */
+async function refreshAllMediaForDomain(domain: Domain) {
+ console.log(`\n${"=".repeat(60)}`);
+ console.log(`š Processing domain: ${domain.name}`);
+ console.log(`${"=".repeat(60)}`);
+
+ const domainId = domain._id;
+
+ await refreshDomainMedia(domainId);
+ await refreshCourseMedia(domainId);
+ await refreshLessonMedia(domainId);
+ await refreshUserMedia(domainId);
+ await refreshCommunityMedia(domainId);
+ await refreshCommunityPostMedia(domainId);
+ await refreshCommunityCommentMedia(domainId);
+ await refreshPageMedia(domainId);
+ await refreshCertificateTemplateMedia(domainId);
+}
+
+/**
+ * Main execution
+ */
+async function main() {
+ console.log("š Media URL Refresh Script");
+ console.log("============================\n");
+
+ if (discoverMode) {
+ console.log(
+ "š DRY RUN (Discover Mode): Showing changes without updating the database.",
+ );
+ console.log(" To apply changes, run with --save\n");
+ } else {
+ console.log("š¾ SAVE MODE: Updating database with fresh URLs\n");
+ }
+
+ if (domainArg) {
+ console.log(`Processing single domain: ${domainArg}`);
+ const domain = (await DomainModel.findOne({
+ name: domainArg,
+ }).lean()) as Domain | null;
+
+ if (!domain) {
+ console.error(`ā Domain not found: ${domainArg}`);
+ process.exit(1);
+ }
+
+ await refreshAllMediaForDomain(domain);
+ } else {
+ console.log("Processing ALL domains...");
+ const domains = (await DomainModel.find({}).lean()) as Domain[];
+
+ for (const domain of domains) {
+ await refreshAllMediaForDomain(domain);
+ }
+ }
+
+ console.log(`\n${"=".repeat(60)}`);
+ console.log("š Summary");
+ console.log(`${"=".repeat(60)}`);
+ console.log(` Total media found: ${stats.processed}`);
+ if (!discoverMode) {
+ console.log(` Updated: ${stats.updated}`);
+ console.log(` Skipped (unchanged): ${stats.skipped}`);
+ console.log(` Failed: ${stats.failed}`);
+ }
+ console.log(`\nā
Done!`);
+}
+
+(async () => {
+ try {
+ await main();
+ } catch (error) {
+ console.error("ā Fatal error:", error);
+ process.exit(1);
+ } finally {
+ await mongoose.connection.close();
+ }
+})();
diff --git a/packages/tailwind-config/package.json b/packages/tailwind-config/package.json
index 38e19c8f4..058571c6f 100644
--- a/packages/tailwind-config/package.json
+++ b/packages/tailwind-config/package.json
@@ -16,6 +16,11 @@
"bugs": {
"url": "https://github.com/codelitdev/courselit/issues"
},
+ "main": "./tailwind.config.ts",
+ "exports": {
+ ".": "./tailwind.config.ts",
+ "./tailwind.config": "./tailwind.config.ts"
+ },
"dependencies": {
"tailwindcss-animate": "^1.0.7"
},
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 26b454b7c..3f39058c8 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -200,7 +200,7 @@ importers:
dependencies:
'@better-auth/sso':
specifier: ^1.4.6
- version: 1.4.6(better-auth@1.4.1(next@16.0.10(@babel/core@7.26.10)(babel-plugin-macros@3.1.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0))
+ version: 1.4.6(better-auth@1.4.1(next@16.1.6(@babel/core@7.26.10)(babel-plugin-macros@3.1.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0))
'@courselit/common-logic':
specifier: workspace:^
version: link:../../packages/common-logic
@@ -320,7 +320,7 @@ importers:
version: 1.0.0
better-auth:
specifier: ^1.4.1
- version: 1.4.1(next@16.0.10(@babel/core@7.26.10)(babel-plugin-macros@3.1.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ version: 1.4.1(next@16.1.6(@babel/core@7.26.10)(babel-plugin-macros@3.1.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
chart.js:
specifier: ^4.4.7
version: 4.4.9
@@ -355,8 +355,8 @@ importers:
specifier: ^0.553.0
version: 0.553.0(react@19.2.0)
medialit:
- specifier: ^0.1.0
- version: 0.1.0
+ specifier: 0.2.0
+ version: 0.2.0
mongodb:
specifier: ^6.15.0
version: 6.16.0(@aws-sdk/credential-providers@3.797.0)(socks@2.8.4)
@@ -365,7 +365,7 @@ importers:
version: 8.14.0(@aws-sdk/credential-providers@3.797.0)(socks@2.8.4)
next:
specifier: ^16.0.10
- version: 16.0.10(@babel/core@7.26.10)(babel-plugin-macros@3.1.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ version: 16.1.6(@babel/core@7.26.10)(babel-plugin-macros@3.1.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
next-themes:
specifier: ^0.4.6
version: 0.4.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
@@ -2892,8 +2892,8 @@ packages:
'@next/env@15.5.3':
resolution: {integrity: sha512-RSEDTRqyihYXygx/OJXwvVupfr9m04+0vH8vyy0HfZ7keRto6VX9BbEk0J2PUk0VGy6YhklJUSrgForov5F9pw==}
- '@next/env@16.0.10':
- resolution: {integrity: sha512-8tuaQkyDVgeONQ1MeT9Mkk8pQmZapMKFh5B+OrFUlG3rVmYTXcXlBetBgTurKXGaIZvkoqRT9JL5K3phXcgang==}
+ '@next/env@16.1.6':
+ resolution: {integrity: sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ==}
'@next/eslint-plugin-next@16.0.3':
resolution: {integrity: sha512-6sPWmZetzFWMsz7Dhuxsdmbu3fK+/AxKRtj7OB0/3OZAI2MHB/v2FeYh271LZ9abvnM1WIwWc/5umYjx0jo5sQ==}
@@ -2904,8 +2904,8 @@ packages:
cpu: [arm64]
os: [darwin]
- '@next/swc-darwin-arm64@16.0.10':
- resolution: {integrity: sha512-4XgdKtdVsaflErz+B5XeG0T5PeXKDdruDf3CRpnhN+8UebNa5N2H58+3GDgpn/9GBurrQ1uWW768FfscwYkJRg==}
+ '@next/swc-darwin-arm64@16.1.6':
+ resolution: {integrity: sha512-wTzYulosJr/6nFnqGW7FrG3jfUUlEf8UjGA0/pyypJl42ExdVgC6xJgcXQ+V8QFn6niSG2Pb8+MIG1mZr2vczw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
@@ -2916,8 +2916,8 @@ packages:
cpu: [x64]
os: [darwin]
- '@next/swc-darwin-x64@16.0.10':
- resolution: {integrity: sha512-spbEObMvRKkQ3CkYVOME+ocPDFo5UqHb8EMTS78/0mQ+O1nqE8toHJVioZo4TvebATxgA8XMTHHrScPrn68OGw==}
+ '@next/swc-darwin-x64@16.1.6':
+ resolution: {integrity: sha512-BLFPYPDO+MNJsiDWbeVzqvYd4NyuRrEYVB5k2N3JfWncuHAy2IVwMAOlVQDFjj+krkWzhY2apvmekMkfQR0CUQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
@@ -2928,8 +2928,8 @@ packages:
cpu: [arm64]
os: [linux]
- '@next/swc-linux-arm64-gnu@16.0.10':
- resolution: {integrity: sha512-uQtWE3X0iGB8apTIskOMi2w/MKONrPOUCi5yLO+v3O8Mb5c7K4Q5KD1jvTpTF5gJKa3VH/ijKjKUq9O9UhwOYw==}
+ '@next/swc-linux-arm64-gnu@16.1.6':
+ resolution: {integrity: sha512-OJYkCd5pj/QloBvoEcJ2XiMnlJkRv9idWA/j0ugSuA34gMT6f5b7vOiCQHVRpvStoZUknhl6/UxOXL4OwtdaBw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
@@ -2940,8 +2940,8 @@ packages:
cpu: [arm64]
os: [linux]
- '@next/swc-linux-arm64-musl@16.0.10':
- resolution: {integrity: sha512-llA+hiDTrYvyWI21Z0L1GiXwjQaanPVQQwru5peOgtooeJ8qx3tlqRV2P7uH2pKQaUfHxI/WVarvI5oYgGxaTw==}
+ '@next/swc-linux-arm64-musl@16.1.6':
+ resolution: {integrity: sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
@@ -2952,8 +2952,8 @@ packages:
cpu: [x64]
os: [linux]
- '@next/swc-linux-x64-gnu@16.0.10':
- resolution: {integrity: sha512-AK2q5H0+a9nsXbeZ3FZdMtbtu9jxW4R/NgzZ6+lrTm3d6Zb7jYrWcgjcpM1k8uuqlSy4xIyPR2YiuUr+wXsavA==}
+ '@next/swc-linux-x64-gnu@16.1.6':
+ resolution: {integrity: sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
@@ -2964,8 +2964,8 @@ packages:
cpu: [x64]
os: [linux]
- '@next/swc-linux-x64-musl@16.0.10':
- resolution: {integrity: sha512-1TDG9PDKivNw5550S111gsO4RGennLVl9cipPhtkXIFVwo31YZ73nEbLjNC8qG3SgTz/QZyYyaFYMeY4BKZR/g==}
+ '@next/swc-linux-x64-musl@16.1.6':
+ resolution: {integrity: sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
@@ -2976,8 +2976,8 @@ packages:
cpu: [arm64]
os: [win32]
- '@next/swc-win32-arm64-msvc@16.0.10':
- resolution: {integrity: sha512-aEZIS4Hh32xdJQbHz121pyuVZniSNoqDVx1yIr2hy+ZwJGipeqnMZBJHyMxv2tiuAXGx6/xpTcQJ6btIiBjgmg==}
+ '@next/swc-win32-arm64-msvc@16.1.6':
+ resolution: {integrity: sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
@@ -2988,8 +2988,8 @@ packages:
cpu: [x64]
os: [win32]
- '@next/swc-win32-x64-msvc@16.0.10':
- resolution: {integrity: sha512-E+njfCoFLb01RAFEnGZn6ERoOqhK1Gl3Lfz1Kjnj0Ulfu7oJbuMyvBKNj/bw8XZnenHDASlygTjZICQW+rYW1Q==}
+ '@next/swc-win32-x64-msvc@16.1.6':
+ resolution: {integrity: sha512-NRfO39AIrzBnixKbjuo2YiYhB6o9d8v/ymU9m/Xk8cyVk+k7XylniXkHwjs4s70wedVffc6bQNbufk5v0xEm0A==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
@@ -5844,6 +5844,10 @@ packages:
base64-js@1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
+ baseline-browser-mapping@2.9.19:
+ resolution: {integrity: sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==}
+ hasBin: true
+
better-auth@1.4.1:
resolution: {integrity: sha512-HDVE69Nw6Y1FPTcmFEmPolfsjMfVB5U823Ij9yWBoM8MdHZ2lA3JVus4xQJ2oRE1riJTlcSLFcgJKWGD7V7hmw==}
peerDependencies:
@@ -8522,6 +8526,10 @@ packages:
resolution: {integrity: sha512-J9Vc1jWYwvCECB6uYm50MZ5dJKneULqdlD9PP1ArhFwrPX0KXWNaJo2JyZSiTSrJfLQJLWdujROK4qLw0co5UQ==}
engines: {node: '>=18.0.0'}
+ medialit@0.2.0:
+ resolution: {integrity: sha512-wEScFuowUC8P6F8aLrcpsjm07GhjWMxnjMLdn5ley1PIrqxBcn7On0OqVcqf+c2VbM7CO9DHacLRGk4xZKgLwA==}
+ engines: {node: '>=18.0.0'}
+
memory-pager@1.5.0:
resolution: {integrity: sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==}
@@ -8861,10 +8869,9 @@ packages:
sass:
optional: true
- next@16.0.10:
- resolution: {integrity: sha512-RtWh5PUgI+vxlV3HdR+IfWA1UUHu0+Ram/JBO4vWB54cVPentCD0e+lxyAYEsDTqGGMg7qpjhKh6dc6aW7W/sA==}
+ next@16.1.6:
+ resolution: {integrity: sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw==}
engines: {node: '>=20.9.0'}
- deprecated: This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/CVE-2025-66478 for more details.
hasBin: true
peerDependencies:
'@opentelemetry/api': ^1.1.0
@@ -12093,10 +12100,10 @@ snapshots:
nanostores: 1.1.0
zod: 4.1.12
- '@better-auth/sso@1.4.6(better-auth@1.4.1(next@16.0.10(@babel/core@7.26.10)(babel-plugin-macros@3.1.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0))':
+ '@better-auth/sso@1.4.6(better-auth@1.4.1(next@16.1.6(@babel/core@7.26.10)(babel-plugin-macros@3.1.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0))':
dependencies:
'@better-fetch/fetch': 1.1.18
- better-auth: 1.4.1(next@16.0.10(@babel/core@7.26.10)(babel-plugin-macros@3.1.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ better-auth: 1.4.1(next@16.1.6(@babel/core@7.26.10)(babel-plugin-macros@3.1.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
fast-xml-parser: 5.3.2
jose: 6.1.0
samlify: 2.10.2
@@ -13353,7 +13360,7 @@ snapshots:
'@next/env@15.5.3': {}
- '@next/env@16.0.10': {}
+ '@next/env@16.1.6': {}
'@next/eslint-plugin-next@16.0.3':
dependencies:
@@ -13362,49 +13369,49 @@ snapshots:
'@next/swc-darwin-arm64@15.5.3':
optional: true
- '@next/swc-darwin-arm64@16.0.10':
+ '@next/swc-darwin-arm64@16.1.6':
optional: true
'@next/swc-darwin-x64@15.5.3':
optional: true
- '@next/swc-darwin-x64@16.0.10':
+ '@next/swc-darwin-x64@16.1.6':
optional: true
'@next/swc-linux-arm64-gnu@15.5.3':
optional: true
- '@next/swc-linux-arm64-gnu@16.0.10':
+ '@next/swc-linux-arm64-gnu@16.1.6':
optional: true
'@next/swc-linux-arm64-musl@15.5.3':
optional: true
- '@next/swc-linux-arm64-musl@16.0.10':
+ '@next/swc-linux-arm64-musl@16.1.6':
optional: true
'@next/swc-linux-x64-gnu@15.5.3':
optional: true
- '@next/swc-linux-x64-gnu@16.0.10':
+ '@next/swc-linux-x64-gnu@16.1.6':
optional: true
'@next/swc-linux-x64-musl@15.5.3':
optional: true
- '@next/swc-linux-x64-musl@16.0.10':
+ '@next/swc-linux-x64-musl@16.1.6':
optional: true
'@next/swc-win32-arm64-msvc@15.5.3':
optional: true
- '@next/swc-win32-arm64-msvc@16.0.10':
+ '@next/swc-win32-arm64-msvc@16.1.6':
optional: true
'@next/swc-win32-x64-msvc@15.5.3':
optional: true
- '@next/swc-win32-x64-msvc@16.0.10':
+ '@next/swc-win32-x64-msvc@16.1.6':
optional: true
'@noble/ciphers@2.0.1': {}
@@ -17967,7 +17974,9 @@ snapshots:
base64-js@1.5.1: {}
- better-auth@1.4.1(next@16.0.10(@babel/core@7.26.10)(babel-plugin-macros@3.1.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
+ baseline-browser-mapping@2.9.19: {}
+
+ better-auth@1.4.1(next@16.1.6(@babel/core@7.26.10)(babel-plugin-macros@3.1.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
dependencies:
'@better-auth/core': 1.4.1(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.1.0)(jose@6.1.0)(kysely@0.28.8)(nanostores@1.1.0)
'@better-auth/telemetry': 1.4.1(@better-auth/core@1.4.1(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.1.0)(jose@6.1.0)(kysely@0.28.8)(nanostores@1.1.0))
@@ -17983,7 +17992,7 @@ snapshots:
nanostores: 1.1.0
zod: 4.1.12
optionalDependencies:
- next: 16.0.10(@babel/core@7.26.10)(babel-plugin-macros@3.1.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ next: 16.1.6(@babel/core@7.26.10)(babel-plugin-macros@3.1.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
react: 19.2.0
react-dom: 19.2.0(react@19.2.0)
@@ -19001,7 +19010,7 @@ snapshots:
'@next/eslint-plugin-next': 16.0.3
eslint: 9.39.1(jiti@1.21.7)
eslint-import-resolver-node: 0.3.9
- eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.4(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.1(jiti@1.21.7)))(eslint@9.39.1(jiti@1.21.7))
+ eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@1.21.7))
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.46.4(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@1.21.7))
eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.1(jiti@1.21.7))
eslint-plugin-react: 7.37.5(eslint@9.39.1(jiti@1.21.7))
@@ -19032,7 +19041,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
- eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.4(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.1(jiti@1.21.7)))(eslint@9.39.1(jiti@1.21.7)):
+ eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@1.21.7)):
dependencies:
'@nolyfill/is-core-module': 1.0.39
debug: 4.4.1
@@ -19047,14 +19056,14 @@ snapshots:
transitivePeerDependencies:
- supports-color
- eslint-module-utils@2.12.1(@typescript-eslint/parser@8.46.4(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.4(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.1(jiti@1.21.7)))(eslint@9.39.1(jiti@1.21.7)))(eslint@9.39.1(jiti@1.21.7)):
+ eslint-module-utils@2.12.1(@typescript-eslint/parser@8.46.4(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@1.21.7)):
dependencies:
debug: 3.2.7
optionalDependencies:
'@typescript-eslint/parser': 8.46.4(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3)
eslint: 9.39.1(jiti@1.21.7)
eslint-import-resolver-node: 0.3.9
- eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.4(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.1(jiti@1.21.7)))(eslint@9.39.1(jiti@1.21.7))
+ eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@1.21.7))
transitivePeerDependencies:
- supports-color
@@ -19075,7 +19084,7 @@ snapshots:
doctrine: 2.1.0
eslint: 9.39.1(jiti@1.21.7)
eslint-import-resolver-node: 0.3.9
- eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.46.4(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.4(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.1(jiti@1.21.7)))(eslint@9.39.1(jiti@1.21.7)))(eslint@9.39.1(jiti@1.21.7))
+ eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.46.4(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@1.21.7))
hasown: 2.0.2
is-core-module: 2.16.1
is-glob: 4.0.3
@@ -21328,6 +21337,10 @@ snapshots:
dependencies:
form-data: 4.0.2
+ medialit@0.2.0:
+ dependencies:
+ form-data: 4.0.2
+
memory-pager@1.5.0: {}
merge-descriptors@1.0.3: {}
@@ -21832,24 +21845,25 @@ snapshots:
- '@babel/core'
- babel-plugin-macros
- next@16.0.10(@babel/core@7.26.10)(babel-plugin-macros@3.1.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
+ next@16.1.6(@babel/core@7.26.10)(babel-plugin-macros@3.1.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
dependencies:
- '@next/env': 16.0.10
+ '@next/env': 16.1.6
'@swc/helpers': 0.5.15
+ baseline-browser-mapping: 2.9.19
caniuse-lite: 1.0.30001761
postcss: 8.4.31
react: 19.2.0
react-dom: 19.2.0(react@19.2.0)
styled-jsx: 5.1.6(@babel/core@7.26.10)(babel-plugin-macros@3.1.0)(react@19.2.0)
optionalDependencies:
- '@next/swc-darwin-arm64': 16.0.10
- '@next/swc-darwin-x64': 16.0.10
- '@next/swc-linux-arm64-gnu': 16.0.10
- '@next/swc-linux-arm64-musl': 16.0.10
- '@next/swc-linux-x64-gnu': 16.0.10
- '@next/swc-linux-x64-musl': 16.0.10
- '@next/swc-win32-arm64-msvc': 16.0.10
- '@next/swc-win32-x64-msvc': 16.0.10
+ '@next/swc-darwin-arm64': 16.1.6
+ '@next/swc-darwin-x64': 16.1.6
+ '@next/swc-linux-arm64-gnu': 16.1.6
+ '@next/swc-linux-arm64-musl': 16.1.6
+ '@next/swc-linux-x64-gnu': 16.1.6
+ '@next/swc-linux-x64-musl': 16.1.6
+ '@next/swc-win32-arm64-msvc': 16.1.6
+ '@next/swc-win32-x64-msvc': 16.1.6
sharp: 0.34.5
transitivePeerDependencies:
- '@babel/core'