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'