From 189b9162cd2f479e5b89752ff7fcfe379b4cbfbb Mon Sep 17 00:00:00 2001 From: Rajat Date: Tue, 12 May 2026 01:29:09 +0530 Subject: [PATCH 1/7] Product and customer API --- .gitignore | 7 +- AGENTS.md | 1 + .../content/docs/developers/introduction.mdx | 8 + apps/docs-new/content/docs/users/manage.mdx | 2 + apps/docs-new/next-env.d.ts | 2 +- apps/docs/src/config.ts | 8 + .../en/developers/customers-and-progress.md | 62 + .../src/pages/en/developers/introduction.md | 10 +- .../src/pages/en/developers/manage-users.md | 4 +- .../en/developers/products-and-content.md | 139 + apps/docs/src/pages/en/users/manage.md | 2 + .../(sidebar)/mails/broadcast/[id]/page.tsx | 2 +- .../mails/sequence/[id]/[mailId]/page.tsx | 2 +- .../[id]/content/section/[section]/page.tsx | 3 +- .../(sidebar)/product/[id]/customers/page.tsx | 40 +- .../app/api/media/__tests__/openapi.test.ts | 53 + apps/web/app/api/media/openapi.mjs | 113 + .../media/presigned/__tests__/route.test.ts | 113 + apps/web/app/api/media/presigned/route.ts | 42 +- .../[productId]/__tests__/route.test.ts | 279 ++ .../[userId]/progress/__tests__/route.test.ts | 128 + .../customers/[userId]/progress/route.ts | 52 + .../customers/__tests__/route.test.ts | 191 ++ .../customers/customer-response.ts | 51 + .../invitations/__tests__/route.test.ts | 164 ++ .../customers/invitations/route.ts | 66 + .../products/[productId]/customers/route.ts | 82 + .../[lessonId]/__tests__/route.test.ts | 262 ++ .../[lessonId]/move/__tests__/route.test.ts | 93 + .../lessons/[lessonId]/move/route.ts | 42 + .../[productId]/lessons/[lessonId]/route.ts | 175 ++ .../lessons/__tests__/route.test.ts | 202 ++ .../[productId]/lessons/lesson-response.ts | 40 + .../api/products/[productId]/lessons/route.ts | 101 + .../[planId]/__tests__/route.test.ts | 236 ++ .../[planId]/default/__tests__/route.test.ts | 110 + .../payment-plans/[planId]/default/route.ts | 46 + .../payment-plans/[planId]/route.ts | 164 ++ .../payment-plans/__tests__/route.test.ts | 184 ++ .../[productId]/payment-plans/route.ts | 113 + .../web/app/api/products/[productId]/route.ts | 140 + .../[sectionId]/__tests__/route.test.ts | 250 ++ .../[productId]/sections/[sectionId]/route.ts | 117 + .../sections/__tests__/route.test.ts | 215 ++ .../sections/reorder/__tests__/route.test.ts | 84 + .../[productId]/sections/reorder/route.ts | 35 + .../products/[productId]/sections/route.ts | 83 + .../[productId]/sections/section-response.ts | 28 + .../api/products/__tests__/openapi.test.ts | 301 ++ .../app/api/products/__tests__/route.test.ts | 333 +++ apps/web/app/api/products/openapi.mjs | 1181 ++++++++ apps/web/app/api/products/product-response.ts | 107 + apps/web/app/api/products/route.ts | 120 + apps/web/app/api/public-api.ts | 185 ++ apps/web/app/api/user/__tests__/route.test.ts | 233 ++ apps/web/app/api/user/openapi.mjs | 4 - apps/web/app/api/user/route.ts | 28 +- apps/web/app/api/user/validate-apikey.ts | 61 +- .../components/admin/mails/email-viewer.tsx | 3 +- .../graphql/courses/__tests__/logic.test.ts | 366 ++- .../__tests__/update-group-drip.test.ts | 262 ++ apps/web/graphql/courses/helpers.ts | 33 +- apps/web/graphql/courses/logic.ts | 147 +- apps/web/graphql/courses/query.ts | 6 + .../lessons/__tests__/visibility.test.ts | 66 +- apps/web/graphql/lessons/logic.ts | 76 +- apps/web/graphql/mails/default-email.ts | 2 +- apps/web/graphql/mails/helpers.ts | 3 +- .../users/__tests__/delete-user.test.ts | 24 + .../web/graphql/users/__tests__/logic.test.ts | 195 +- apps/web/graphql/users/logic.ts | 37 +- apps/web/openapi/generated/openapi.json | 2547 ++++++++++++++++- apps/web/openapi/index.mjs | 4 +- ..._API_PRODUCT_AND_LEARNER_MANAGEMENT_PRD.md | 1372 +++++++++ 74 files changed, 11818 insertions(+), 224 deletions(-) create mode 100644 apps/docs/src/pages/en/developers/customers-and-progress.md create mode 100644 apps/docs/src/pages/en/developers/products-and-content.md create mode 100644 apps/web/app/api/media/__tests__/openapi.test.ts create mode 100644 apps/web/app/api/media/openapi.mjs create mode 100644 apps/web/app/api/media/presigned/__tests__/route.test.ts create mode 100644 apps/web/app/api/products/[productId]/__tests__/route.test.ts create mode 100644 apps/web/app/api/products/[productId]/customers/[userId]/progress/__tests__/route.test.ts create mode 100644 apps/web/app/api/products/[productId]/customers/[userId]/progress/route.ts create mode 100644 apps/web/app/api/products/[productId]/customers/__tests__/route.test.ts create mode 100644 apps/web/app/api/products/[productId]/customers/customer-response.ts create mode 100644 apps/web/app/api/products/[productId]/customers/invitations/__tests__/route.test.ts create mode 100644 apps/web/app/api/products/[productId]/customers/invitations/route.ts create mode 100644 apps/web/app/api/products/[productId]/customers/route.ts create mode 100644 apps/web/app/api/products/[productId]/lessons/[lessonId]/__tests__/route.test.ts create mode 100644 apps/web/app/api/products/[productId]/lessons/[lessonId]/move/__tests__/route.test.ts create mode 100644 apps/web/app/api/products/[productId]/lessons/[lessonId]/move/route.ts create mode 100644 apps/web/app/api/products/[productId]/lessons/[lessonId]/route.ts create mode 100644 apps/web/app/api/products/[productId]/lessons/__tests__/route.test.ts create mode 100644 apps/web/app/api/products/[productId]/lessons/lesson-response.ts create mode 100644 apps/web/app/api/products/[productId]/lessons/route.ts create mode 100644 apps/web/app/api/products/[productId]/payment-plans/[planId]/__tests__/route.test.ts create mode 100644 apps/web/app/api/products/[productId]/payment-plans/[planId]/default/__tests__/route.test.ts create mode 100644 apps/web/app/api/products/[productId]/payment-plans/[planId]/default/route.ts create mode 100644 apps/web/app/api/products/[productId]/payment-plans/[planId]/route.ts create mode 100644 apps/web/app/api/products/[productId]/payment-plans/__tests__/route.test.ts create mode 100644 apps/web/app/api/products/[productId]/payment-plans/route.ts create mode 100644 apps/web/app/api/products/[productId]/route.ts create mode 100644 apps/web/app/api/products/[productId]/sections/[sectionId]/__tests__/route.test.ts create mode 100644 apps/web/app/api/products/[productId]/sections/[sectionId]/route.ts create mode 100644 apps/web/app/api/products/[productId]/sections/__tests__/route.test.ts create mode 100644 apps/web/app/api/products/[productId]/sections/reorder/__tests__/route.test.ts create mode 100644 apps/web/app/api/products/[productId]/sections/reorder/route.ts create mode 100644 apps/web/app/api/products/[productId]/sections/route.ts create mode 100644 apps/web/app/api/products/[productId]/sections/section-response.ts create mode 100644 apps/web/app/api/products/__tests__/openapi.test.ts create mode 100644 apps/web/app/api/products/__tests__/route.test.ts create mode 100644 apps/web/app/api/products/openapi.mjs create mode 100644 apps/web/app/api/products/product-response.ts create mode 100644 apps/web/app/api/products/route.ts create mode 100644 apps/web/app/api/public-api.ts create mode 100644 apps/web/app/api/user/__tests__/route.test.ts create mode 100644 docs/wip/PUBLIC_API_PRODUCT_AND_LEARNER_MANAGEMENT_PRD.md diff --git a/.gitignore b/.gitignore index 30a7525a3..fc150966e 100644 --- a/.gitignore +++ b/.gitignore @@ -44,5 +44,8 @@ report*.json globalConfig.json # CourseLit files -domains_to_delete.txt -.codex +domains_to_delete.txt +.codex + +# AI +.agents diff --git a/AGENTS.md b/AGENTS.md index 9897c1029..8839950c8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,6 +19,7 @@ - Ensure **Idempotency** (safe to re-run) by using upserts or `$setOnInsert` where applicable. - When making changes to the structure of the Course, consider how it affects its representation on its public page (`apps/web/app/(with-contexts)/(with-layout)/p/[id]/page.tsx`) and the course viewer (`apps/web/app/(with-contexts)/course/[slug]/[id]/page.tsx`). - `apps/web` is a multi-tenant app. +- Preserve the domain-owner invariant: `domain.email` identifies the school owner and public API keys resolve that owner as the API actor. Do not use raw `UserModel.update*`, `UserModel.delete*`, `DomainModel.update*`, migrations, or scripts in a way that changes/deletes the owner user, changes the owner user's permissions, or drifts `domain.email` away from the owner user without adding explicit guards and tests. - Refrain from adding new GraphQL query/mutation unless required. If an existing query/mutation can be modified to implement the feature without making the query's/mutation's boundaries blurry, extend those. ### Workspace map (core modules): diff --git a/apps/docs-new/content/docs/developers/introduction.mdx b/apps/docs-new/content/docs/developers/introduction.mdx index f7ca3b02a..00966da54 100644 --- a/apps/docs-new/content/docs/developers/introduction.mdx +++ b/apps/docs-new/content/docs/developers/introduction.mdx @@ -24,6 +24,14 @@ To interact with the CourseLit API, you need an API key. Follow these steps to o 2. Navigate to the dashboard. 3. Go to the `Settings > Miscellaneous > API Keys` section and generate a new API key. +## API Key Actor + +API keys are school-level credentials. They are not attached to an individual user account. + +When CourseLit receives an API-key-authenticated request, it resolves the school owner for the current domain and uses that owner as the actor for permission checks and resource ownership. For example, resources created through the API use the same owner-backed context that the dashboard business logic expects. + +Do not send `userId`, `creatorId`, or similar ownership fields in API requests unless a specific endpoint documents that field as part of the customer being managed. + ## Setting Up the Environment Store your CourseLit server URL and API key securely in environment variables used by your application. diff --git a/apps/docs-new/content/docs/users/manage.mdx b/apps/docs-new/content/docs/users/manage.mdx index f01e513d9..dc2b3ea7c 100644 --- a/apps/docs-new/content/docs/users/manage.mdx +++ b/apps/docs-new/content/docs/users/manage.mdx @@ -30,6 +30,8 @@ Now when the user tries to generate a login link, they will get an error stating Before changing user's permissions, read our [permissions](/users/permissions) guide so that you understand what you are doing. +The school owner's permissions cannot be changed. CourseLit uses the school owner as the actor for school-level operations such as public API requests, so the owner must always remain available with their existing permissions. + To change user's permissions: 1. Select on the user from the users list to open its editor. diff --git a/apps/docs-new/next-env.d.ts b/apps/docs-new/next-env.d.ts index 9edff1c7c..c4b7818fb 100644 --- a/apps/docs-new/next-env.d.ts +++ b/apps/docs-new/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/types/routes.d.ts"; +import "./.next/dev/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/docs/src/config.ts b/apps/docs/src/config.ts index e03391336..4eefc4fb4 100644 --- a/apps/docs/src/config.ts +++ b/apps/docs/src/config.ts @@ -138,6 +138,14 @@ export const SIDEBAR: Sidebar = { Developers: [ { text: "Introduction", link: "en/developers/introduction" }, { text: "Managing users", link: "en/developers/manage-users" }, + { + text: "Products and content", + link: "en/developers/products-and-content", + }, + { + text: "Customers and progress", + link: "en/developers/customers-and-progress", + }, ], "Self hosting": [ { text: "Why self host?", link: "en/self-hosting/introduction" }, diff --git a/apps/docs/src/pages/en/developers/customers-and-progress.md b/apps/docs/src/pages/en/developers/customers-and-progress.md new file mode 100644 index 000000000..84a408e13 --- /dev/null +++ b/apps/docs/src/pages/en/developers/customers-and-progress.md @@ -0,0 +1,62 @@ +--- +title: Manage customers and progress using CourseLit API +description: Enroll customers and read product progress using the CourseLit API +layout: ../../../layouts/MainLayout.astro +--- + +The CourseLit public API can enroll customers into products and read enrollment/progress snapshots. These endpoints are useful for external CRMs, custom learning apps, and automation workflows that use CourseLit as the system of record. + +For interactive schemas and examples, open the Swagger API reference from your CourseLit dashboard. + +## Authentication + +Send your API key in the `x-api-key` header. + +```bash +curl https://your-school.example.com/api/products/product_123/customers \ + -H "x-api-key: your-api-key" \ + -H "accept: application/json" +``` + +API keys are school-level credentials. CourseLit resolves the school owner for the current domain and uses that owner as the actor for permission checks. Customer fields such as `userId`, memberships, and progress still refer to the customer being managed, not to the API key or school owner. + +## Invite a customer + +Use: + +```http +POST /api/products/{productId}/customers/invitations +``` + +Request body: + +```json +{ + "email": "student@example.com", + "tags": ["cohort-2026"] +} +``` + +The endpoint uses CourseLit's existing customer invitation behavior. It creates or reuses a customer user, enrolls that customer into the product, and sends the invitation email. + +## List product customers + +Use: + +```http +GET /api/products/{productId}/customers +``` + +The response is a paginated product roster. Supported query parameters include `page`, `limit`, `status`, and `search`. + +## Read customer progress + +Use: + +```http +GET /api/products/{productId}/customers/{userId}/progress +``` + +Progress is read-only in the public API. The API reports existing CourseLit progress state such as completed lesson IDs, total published lessons, progress percentage, enrollment timestamps, and download state for download products. + +The public API does not provide customer-runtime `/api/me` endpoints and does not let integrations arbitrarily set customer progress. diff --git a/apps/docs/src/pages/en/developers/introduction.md b/apps/docs/src/pages/en/developers/introduction.md index 14b98783d..3f5bcdab9 100644 --- a/apps/docs/src/pages/en/developers/introduction.md +++ b/apps/docs/src/pages/en/developers/introduction.md @@ -23,6 +23,14 @@ To interact with the CourseLit API, you need an API key. Follow these steps to o 2. Navigate to the dashboard. 3. Go to the `Settings > Miscellaneous > API Keys` section and generate a new API key. +## API Key Actor + +API keys are school-level credentials. They are not attached to an individual user account. + +When CourseLit receives an API-key-authenticated request, it resolves the school owner for the current domain and uses that owner as the actor for permission checks and resource ownership. For example, resources created through the API use the same owner-backed context that the dashboard business logic expects. + +Do not send `userId`, `creatorId`, or similar ownership fields in API requests unless a specific endpoint documents that field as part of the customer being managed. + ## Setting Up the Environment You need to set up your environment variables to store your CourseLit server URL and API key securely. Here is an example of how to do it in JavaScript: @@ -42,10 +50,10 @@ export async function createUser({ email }) { method: "POST", headers: { "content-type": "application/json", + "x-api-key": courselitApikey, }, body: JSON.stringify({ email, - apikey: courselitApikey, }), }); diff --git a/apps/docs/src/pages/en/developers/manage-users.md b/apps/docs/src/pages/en/developers/manage-users.md index add4ff1f6..9e69364c2 100644 --- a/apps/docs/src/pages/en/developers/manage-users.md +++ b/apps/docs/src/pages/en/developers/manage-users.md @@ -27,12 +27,12 @@ POST /api/user - `Content-Type: application/json` - `domain`: Your domain name +- `x-api-key`: Your API key ### Request Body ```json { - "apikey": "your-api-key", "email": "user@example.com", "name": "User Name", "permissions": ["read", "write"], @@ -68,8 +68,8 @@ Here are the possible values for the `permissions` array: curl -X POST https://yourdomain.com/api/user \ -H "Content-Type: application/json" \ -H "domain: yourdomain.com" \ +-H "x-api-key: your-api-key" \ -d '{ - "apikey": "your-api-key", "email": "user@example.com", "name": "User Name", "permissions": ["course:manage", "community:manage"], diff --git a/apps/docs/src/pages/en/developers/products-and-content.md b/apps/docs/src/pages/en/developers/products-and-content.md new file mode 100644 index 000000000..034c8e925 --- /dev/null +++ b/apps/docs/src/pages/en/developers/products-and-content.md @@ -0,0 +1,139 @@ +--- +title: Manage products and content using CourseLit API +description: Create products, payment plans, sections, lessons, and media-backed content using the CourseLit API +layout: ../../../layouts/MainLayout.astro +--- + +The CourseLit public API can manage products and course content programmatically. Use it when you are building another frontend for CourseLit, such as an AI-assisted learning app that creates courses, uploads media, and publishes products through CourseLit. + +For interactive schemas and examples, open the Swagger API reference from your CourseLit dashboard. + +## Authentication + +Send your API key in the `x-api-key` header. + +```bash +curl https://your-school.example.com/api/products \ + -H "x-api-key: your-api-key" \ + -H "accept: application/json" +``` + +API keys are school-level credentials. CourseLit resolves the school owner for the current domain and uses that owner as the actor for permission checks and resource ownership. Do not send `creatorId`, `userId`, or similar ownership fields unless an endpoint explicitly documents the field as the customer being managed. + +## Product workflow + +Create a product as a draft first. + +```http +POST /api/products +``` + +The draft creation endpoint accepts only: + +```json +{ + "title": "AI Foundations", + "type": "course" +} +``` + +After the draft exists, update metadata with: + +```http +PATCH /api/products/{productId} +``` + +Use this endpoint for fields such as `slug`, `description`, `published`, `privacy`, `tags`, and `featuredImage`. `description` is a JSON-stringified Tiptap/ProseMirror document. + +Do not use the legacy `course.cost` or `course.costType` fields. Pricing is managed with payment plans. + +## Payment plans + +Course and download products require a payment plan before publishing. Use: + +```http +GET /api/products/{productId}/payment-plans +POST /api/products/{productId}/payment-plans +GET /api/products/{productId}/payment-plans/{planId} +PATCH /api/products/{productId}/payment-plans/{planId} +DELETE /api/products/{productId}/payment-plans/{planId} +POST /api/products/{productId}/payment-plans/{planId}/default +``` + +Deleting a payment plan archives it. The API follows the same validations as the dashboard, including checks for paid plans and default payment plans. + +## Sections and lessons + +Structured products can be organized with sections and lessons. + +```http +GET /api/products/{productId}/sections +POST /api/products/{productId}/sections +PATCH /api/products/{productId}/sections/{sectionId} +DELETE /api/products/{productId}/sections/{sectionId} +POST /api/products/{productId}/sections/reorder +GET /api/products/{productId}/lessons +POST /api/products/{productId}/lessons +GET /api/products/{productId}/lessons/{lessonId} +PATCH /api/products/{productId}/lessons/{lessonId} +DELETE /api/products/{productId}/lessons/{lessonId} +POST /api/products/{productId}/lessons/{lessonId}/move +``` + +Text lessons accept Tiptap/ProseMirror JSON in `content`. + +```json +{ + "title": "Welcome", + "type": "text", + "content": { + "type": "doc", + "content": [ + { + "type": "paragraph", + "content": [ + { "type": "text", "text": "Welcome to the course." } + ] + } + ] + } +} +``` + +SCORM lesson creation is not supported by the public API. If you send a SCORM lesson type, the API returns a `not_supported` error. + +## Media-backed lessons + +Upload files directly to MediaLit, then reference the returned `mediaId` when creating or updating video, audio, PDF, or file lessons. + +1. Generate an upload signature: + +```http +POST /api/media/presigned +``` + +2. Upload the file to MediaLit using the returned `signature` and `endpoint`. + +See the MediaLit upload guide: https://docs.medialit.cloud/api/uploadMedia. + +3. Reference the uploaded media in a lesson: + +```json +{ + "title": "Lecture video", + "type": "video", + "media": { + "mediaId": "media_123" + } +} +``` + +## Publishing + +Publish with: + +```http +PATCH /api/products/{productId} +``` + +Set `published` to `true` only after the product is ready and has the required payment plan setup. The API uses existing CourseLit publishing validation. diff --git a/apps/docs/src/pages/en/users/manage.md b/apps/docs/src/pages/en/users/manage.md index 606fd5fc2..888c925b7 100644 --- a/apps/docs/src/pages/en/users/manage.md +++ b/apps/docs/src/pages/en/users/manage.md @@ -31,6 +31,8 @@ Now when the user tries to generate a login link, they will get an error stating Before changing user's permissions, read our [permissions](/en/users/permissions) guide so that you understand what you are doing. +The school owner's permissions cannot be changed. CourseLit uses the school owner as the actor for school-level operations such as public API requests, so the owner must always remain available with their existing permissions. + To change user's permissions: 1. Select on the user from the users list to open its editor. diff --git a/apps/web/app/(with-contexts)/dashboard/(sidebar)/mails/broadcast/[id]/page.tsx b/apps/web/app/(with-contexts)/dashboard/(sidebar)/mails/broadcast/[id]/page.tsx index 5b6895399..4a71bca98 100644 --- a/apps/web/app/(with-contexts)/dashboard/(sidebar)/mails/broadcast/[id]/page.tsx +++ b/apps/web/app/(with-contexts)/dashboard/(sidebar)/mails/broadcast/[id]/page.tsx @@ -42,7 +42,7 @@ import { TOAST_MAIL_SENT, TOAST_TITLE_SUCCESS, } from "@ui-config/strings"; -import { Email as EmailContent } from "@courselit/email-editor"; +import type { Email as EmailContent } from "@courselit/email-editor"; import { useSequence } from "@/hooks/use-sequence"; import { useGraphQLFetch } from "@/hooks/use-graphql-fetch"; import FilterContainer from "@components/admin/users/filter-container"; diff --git a/apps/web/app/(with-contexts)/dashboard/(sidebar)/mails/sequence/[id]/[mailId]/page.tsx b/apps/web/app/(with-contexts)/dashboard/(sidebar)/mails/sequence/[id]/[mailId]/page.tsx index 4a1dc0cec..b5407f46f 100644 --- a/apps/web/app/(with-contexts)/dashboard/(sidebar)/mails/sequence/[id]/[mailId]/page.tsx +++ b/apps/web/app/(with-contexts)/dashboard/(sidebar)/mails/sequence/[id]/[mailId]/page.tsx @@ -24,7 +24,7 @@ import { use, startTransition, } from "react"; -import { Email as EmailContent } from "@courselit/email-editor"; +import type { Email as EmailContent } from "@courselit/email-editor"; import { useGraphQLFetch } from "@/hooks/use-graphql-fetch"; import { Email } from "@courselit/common-models"; import EmailViewer from "@components/admin/mails/email-viewer"; diff --git a/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/section/[section]/page.tsx b/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/section/[section]/page.tsx index f7f1a4554..26c3b9cb1 100644 --- a/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/section/[section]/page.tsx +++ b/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/section/[section]/page.tsx @@ -41,7 +41,8 @@ import { Form, useToast } from "@courselit/components-library"; import { FetchBuilder } from "@courselit/utils"; import Resources from "@components/resources"; import EmailViewer from "@components/admin/mails/email-viewer"; -import { defaultEmail, Email as EmailContent } from "@courselit/email-editor"; +import { defaultEmail } from "@courselit/email-editor"; +import type { Email as EmailContent } from "@courselit/email-editor"; import constants from "@/config/constants"; const { permissions } = UIConstants; diff --git a/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/customers/page.tsx b/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/customers/page.tsx index c710f2715..ec6a78801 100644 --- a/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/customers/page.tsx +++ b/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/customers/page.tsx @@ -21,6 +21,7 @@ import { COURSE_CUSTOMERS_PAGE_HEADING, MANAGE_COURSES_PAGE_HEADING, PRODUCT_TABLE_CONTEXT_MENU_INVITE_A_CUSTOMER, + BUTTON_SEARCH, } from "@ui-config/strings"; import useProduct from "@/hooks/use-product"; import { formattedLocaleDate, truncate } from "@ui-lib/utils"; @@ -64,6 +65,7 @@ export default function CustomersPage() { const productId = params.id as string; const [members, setMembers] = useState([]); const [searchTerm, setSearchTerm] = useState(""); + const [submittedSearch, setSubmittedSearch] = useState(""); const [loading, setLoading] = useState(true); const address = useContext(AddressContext); const { product } = useProduct(productId); @@ -78,13 +80,6 @@ export default function CustomersPage() { { label: COURSE_CUSTOMERS_PAGE_HEADING, href: "#" }, ]; - const filteredMembers = members.filter( - (member) => - member.user.name - ?.toLowerCase() - .includes(searchTerm.toLowerCase()) || - member.user.email.toLowerCase().includes(searchTerm.toLowerCase()), - ); const publishedLessons = product?.lessons?.filter((lesson) => lesson.published) || []; @@ -112,8 +107,8 @@ export default function CustomersPage() { // ` // : ` - query GetMembers($productId: String!) { - members: getProductMembers(courseId: $productId, limit: 10000000) { + query GetMembers($productId: String!, $searchText: String) { + members: getProductMembers(courseId: $productId, limit: 10000000, searchText: $searchText) { user { userId avatar { @@ -134,7 +129,13 @@ export default function CustomersPage() { `; const fetch = new FetchBuilder() .setUrl(`${address.backend}/api/graph`) - .setPayload({ query: mutation, variables: { productId } }) + .setPayload({ + query: mutation, + variables: { + productId, + searchText: submittedSearch || undefined, + }, + }) .setIsGraphQLEndpoint(true) .build(); try { @@ -170,7 +171,7 @@ export default function CustomersPage() { if (product) { fetchStudents(); } - }, [product]); + }, [product, submittedSearch]); const handleCopyToClipboard = (text: string) => { navigator.clipboard.writeText(text); @@ -268,15 +269,24 @@ export default function CustomersPage() { */} -
- +
{ + e.preventDefault(); + setSubmittedSearch(searchTerm); + }} + className="flex items-center space-x-2" + > setSearchTerm(e.target.value)} className="max-w-sm" /> -
+ + @@ -321,7 +331,7 @@ export default function CustomersPage() { )) - : filteredMembers.map((member: Member) => ( + : members.map((member: Member) => ( { + it("documents the existing media presigned upload endpoint", () => { + const routes = buildOpenApiRoutesForTest(); + const endpoint = routes.paths["/api/media/presigned"].post; + + expect(endpoint).toMatchObject({ + tags: ["Media Uploads"], + operationId: "createMediaUploadSignature", + security: [{ ApiKeyAuth: [] }], + }); + expect(endpoint.description).toContain("upload signature"); + expect(endpoint.description).toContain( + "https://docs.medialit.cloud/api/uploadMedia", + ); + expect( + endpoint.responses[200].content["application/json"].schema.$ref, + ).toBe("#/components/schemas/MediaPresignedResponse"); + expect( + routes.components.schemas.MediaPresignedResponse.required, + ).toEqual(["signature", "endpoint"]); + expect( + routes.components.schemas.MediaPresignedResponse.properties.endpoint + .description, + ).toContain("/media/create/resumable"); + expect( + routes.components.securitySchemes?.CourseLitSessionAuth, + ).toBeUndefined(); + }); +}); diff --git a/apps/web/app/api/media/openapi.mjs b/apps/web/app/api/media/openapi.mjs new file mode 100644 index 000000000..9144cdf93 --- /dev/null +++ b/apps/web/app/api/media/openapi.mjs @@ -0,0 +1,113 @@ +export const mediaApiOpenApi = { + tags: [ + { + name: "Media Uploads", + description: + "Generate MediaLit signatures for direct media uploads.", + }, + ], + paths: { + "/api/media/presigned": { + post: { + tags: ["Media Uploads"], + summary: "Generate a MediaLit upload signature", + description: + "Returns a short-lived upload signature and endpoint for direct file uploads. See `https://docs.medialit.cloud/api/uploadMedia` for the upload request format.", + operationId: "createMediaUploadSignature", + security: [{ ApiKeyAuth: [] }], + responses: { + 200: { + description: + "MediaLit upload signature generated successfully.", + content: { + "application/json": { + schema: { + $ref: "#/components/schemas/MediaPresignedResponse", + }, + examples: { + success: { + value: { + signature: + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + endpoint: + "https://media.example.com", + }, + }, + }, + }, + }, + }, + 401: { + description: + "Invalid API key, or no active CourseLit dashboard session was found for the dashboard-only auth path.", + }, + 403: { + description: + "The resolved school owner or logged-in dashboard user does not have `media:manage` permission.", + content: { + "application/json": { + schema: { + $ref: "#/components/schemas/MediaErrorResponse", + }, + }, + }, + }, + 404: { + description: "Domain not found.", + content: { + "application/json": { + schema: { + $ref: "#/components/schemas/MediaErrorResponse", + }, + }, + }, + }, + 500: { + description: "MediaLit signature generation failed.", + content: { + "application/json": { + schema: { + $ref: "#/components/schemas/MediaErrorResponse", + }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + MediaPresignedResponse: { + type: "object", + required: ["signature", "endpoint"], + properties: { + signature: { + type: "string", + description: + "MediaLit upload signature. Send this as the `x-medialit-signature` header to MediaLit.", + }, + endpoint: { + type: "string", + format: "uri", + description: + "MediaLit server endpoint. Upload files directly to `${endpoint}/media/create` for multipart uploads or `${endpoint}/media/create/resumable` for TUS resumable uploads.", + }, + }, + }, + MediaErrorResponse: { + type: "object", + properties: { + message: { + type: "string", + example: "Domain not found", + }, + error: { + type: "string", + example: "Unable to generate media signature", + }, + }, + }, + }, + }, +}; diff --git a/apps/web/app/api/media/presigned/__tests__/route.test.ts b/apps/web/app/api/media/presigned/__tests__/route.test.ts new file mode 100644 index 000000000..35e8abc23 --- /dev/null +++ b/apps/web/app/api/media/presigned/__tests__/route.test.ts @@ -0,0 +1,113 @@ +/** + * @jest-environment node + */ + +import { NextRequest } from "next/server"; +import Domain from "@models/Domain"; +import ApiKey from "@models/ApiKey"; +import User from "@models/User"; +import { auth } from "@/auth"; +import { MediaLit } from "medialit"; +import { UIConstants } from "@courselit/common-models"; + +jest.mock("@models/Domain"); +jest.mock("@models/ApiKey"); +jest.mock("@models/User"); +jest.mock("@/auth", () => ({ + auth: { + api: { + getSession: jest.fn(), + }, + }, +})); +jest.mock("medialit", () => ({ + MediaLit: jest.fn(), +})); + +const domain = { + _id: "domain-id", + name: "school", + email: "owner@example.com", +}; + +function request(headers: Record) { + return { + headers: { + get: jest.fn((name: string) => headers[name] ?? null), + }, + url: "https://school.example.com/api/media/presigned", + } as unknown as NextRequest; +} + +describe("POST /api/media/presigned", () => { + beforeEach(() => { + jest.clearAllMocks(); + (Domain.findOne as jest.Mock).mockResolvedValue(domain); + (MediaLit as jest.Mock).mockImplementation(function (this: any) { + this.endpoint = "https://media.example.com"; + this.getSignature = jest.fn().mockResolvedValue("signature-123"); + }); + }); + + it("generates a media signature with an API key", async () => { + (ApiKey.findOne as jest.Mock).mockResolvedValue({ key: "api-key" }); + (User.findOne as jest.Mock).mockResolvedValue({ + userId: "owner", + email: "owner@example.com", + permissions: [UIConstants.permissions.manageMedia], + }); + + const { POST } = await import("../route"); + const response = await POST( + request({ + domain: "school", + "x-api-key": "api-key", + }), + ); + + expect(response.status).toBe(200); + expect(ApiKey.findOne).toHaveBeenCalledWith({ + domain: domain._id, + key: "api-key", + }); + expect(User.findOne).toHaveBeenCalledWith({ + domain: domain._id, + email: domain.email, + }); + expect(auth.api.getSession).not.toHaveBeenCalled(); + await expect(response.json()).resolves.toEqual({ + signature: "signature-123", + endpoint: "https://media.example.com", + }); + }); + + it("continues to generate a media signature with a dashboard session", async () => { + (auth.api.getSession as jest.Mock).mockResolvedValue({ + user: { email: "admin@example.com" }, + }); + (User.findOne as jest.Mock).mockResolvedValue({ + userId: "admin", + email: "admin@example.com", + permissions: [UIConstants.permissions.manageMedia], + }); + + const { POST } = await import("../route"); + const response = await POST( + request({ + domain: "school", + }), + ); + + expect(response.status).toBe(200); + expect(ApiKey.findOne).not.toHaveBeenCalled(); + expect(User.findOne).toHaveBeenCalledWith({ + email: "admin@example.com", + domain: domain._id, + active: true, + }); + await expect(response.json()).resolves.toEqual({ + signature: "signature-123", + endpoint: "https://media.example.com", + }); + }); +}); diff --git a/apps/web/app/api/media/presigned/route.ts b/apps/web/app/api/media/presigned/route.ts index a893bc98b..8af4bf9d0 100644 --- a/apps/web/app/api/media/presigned/route.ts +++ b/apps/web/app/api/media/presigned/route.ts @@ -7,13 +7,37 @@ import DomainModel, { Domain } from "@models/Domain"; import { auth } from "@/auth"; import { error } from "@/services/logger"; import { MediaLit } from "medialit"; +import { validatePublicApiRequest } from "@/app/api/public-api"; + +function hasApiKey(req: NextRequest) { + return !!(req.headers.get("x-api-key") ?? req.headers.get("X-API-Key")); +} + +async function getUserAndDomain(req: NextRequest) { + if (hasApiKey(req)) { + const apiAuth = await validatePublicApiRequest(req); + if (apiAuth.error) { + return { + error: apiAuth.error, + }; + } + + return { + domain: apiAuth.domain, + user: apiAuth.user, + }; + } -export async function POST(req: NextRequest) { const domain = await DomainModel.findOne({ name: req.headers.get("domain"), }); if (!domain) { - return Response.json({ message: "Domain not found" }, { status: 404 }); + return { + error: Response.json( + { message: "Domain not found" }, + { status: 404 }, + ), + }; } const session = await auth.api.getSession({ @@ -30,8 +54,20 @@ export async function POST(req: NextRequest) { } if (!user) { - return Response.json({}, { status: 401 }); + return { + error: Response.json({}, { status: 401 }), + }; + } + + return { domain, user }; +} + +export async function POST(req: NextRequest) { + const authResult = await getUserAndDomain(req); + if (authResult.error) { + return authResult.error; } + const { domain, user } = authResult; if ( !checkPermission(user!.permissions, [constants.permissions.manageMedia]) diff --git a/apps/web/app/api/products/[productId]/__tests__/route.test.ts b/apps/web/app/api/products/[productId]/__tests__/route.test.ts new file mode 100644 index 000000000..b35067ce2 --- /dev/null +++ b/apps/web/app/api/products/[productId]/__tests__/route.test.ts @@ -0,0 +1,279 @@ +/** + * @jest-environment node + */ + +import { NextRequest } from "next/server"; +import Domain from "@models/Domain"; +import ApiKey from "@models/ApiKey"; +import User from "@models/User"; +import PaymentPlanModel from "@models/PaymentPlan"; +import { + deleteCourse, + getCourseOrThrow, + updateCourse, +} from "@/graphql/courses/logic"; + +jest.mock("@models/Domain"); +jest.mock("@models/ApiKey"); +jest.mock("@models/User"); +jest.mock("@/graphql/courses/logic", () => ({ + deleteCourse: jest.fn(), + getCourseOrThrow: jest.fn(), + updateCourse: jest.fn(), +})); +jest.mock("@models/PaymentPlan"); + +const domain = { + _id: "domain-id", + name: "school", +}; + +const request = { + headers: { + get: jest.fn((name: string) => { + if (name === "domain") return "school"; + if (name === "x-api-key") return "api-key"; + return null; + }), + }, +} as unknown as NextRequest; + +describe("GET /api/products/{productId}", () => { + beforeEach(() => { + jest.clearAllMocks(); + (Domain.findOne as jest.Mock).mockResolvedValue(domain); + (ApiKey.findOne as jest.Mock).mockResolvedValue({ key: "api-key" }); + (User.findOne as jest.Mock).mockResolvedValue({ + userId: "owner", + email: "owner@example.com", + permissions: ["course:manage_any", "course:publish"], + }); + (PaymentPlanModel.find as jest.Mock).mockReturnValue({ + lean: jest.fn().mockResolvedValue([]), + }); + }); + + it("updates product metadata through existing product logic", async () => { + (updateCourse as jest.Mock).mockResolvedValue({ + courseId: "download-1", + type: "download", + title: "Updated Download", + slug: "updated-download", + published: false, + privacy: "unlisted", + tags: ["new"], + pageId: "updated-download", + createdAt: new Date("2026-01-01T00:00:00.000Z"), + updatedAt: new Date("2026-01-03T00:00:00.000Z"), + }); + + const { PATCH } = await import("../route"); + const response = await PATCH( + { + ...request, + json: jest.fn().mockResolvedValue({ + title: "Updated Download", + tags: ["new"], + }), + } as unknown as NextRequest, + { + params: Promise.resolve({ productId: "download-1" }), + }, + ); + + expect(response.status).toBe(200); + expect(updateCourse).toHaveBeenCalledWith( + { + id: "download-1", + title: "Updated Download", + tags: ["new"], + }, + expect.objectContaining({ + subdomain: domain, + user: expect.objectContaining({ userId: "owner" }), + }), + ); + const body = await response.json(); + expect(body).toMatchObject({ + productId: "download-1", + title: "Updated Download", + tags: ["new"], + }); + }); + + it("updates a blog title without adding hidden fields", async () => { + (updateCourse as jest.Mock).mockResolvedValue({ + courseId: "blog-1", + type: "blog", + title: "Updated Blog", + slug: "updated-blog", + published: false, + privacy: "unlisted", + createdAt: new Date("2026-01-01T00:00:00.000Z"), + updatedAt: new Date("2026-01-03T00:00:00.000Z"), + }); + + const { PATCH } = await import("../route"); + const response = await PATCH( + { + ...request, + json: jest.fn().mockResolvedValue({ + title: "Updated Blog", + }), + } as unknown as NextRequest, + { + params: Promise.resolve({ productId: "blog-1" }), + }, + ); + + expect(response.status).toBe(200); + expect(updateCourse).toHaveBeenCalledWith( + { + id: "blog-1", + title: "Updated Blog", + }, + expect.objectContaining({ + subdomain: domain, + user: expect.objectContaining({ userId: "owner" }), + }), + ); + const body = await response.json(); + expect(body).toMatchObject({ + productId: "blog-1", + type: "blog", + title: "Updated Blog", + }); + expect(body).not.toHaveProperty("description"); + }); + + it("returns bad request when the update body is not valid JSON", async () => { + const { PATCH } = await import("../route"); + const response = await PATCH( + { + ...request, + json: jest.fn().mockRejectedValue(new SyntaxError("bad json")), + } as unknown as NextRequest, + { + params: Promise.resolve({ productId: "blog-1" }), + }, + ); + + expect(response.status).toBe(400); + expect(updateCourse).not.toHaveBeenCalled(); + await expect(response.json()).resolves.toEqual({ + error: { + code: "bad_request", + message: "Invalid JSON body", + }, + }); + }); + + it("returns bad request when the update body is not a JSON object", async () => { + const { PATCH } = await import("../route"); + const response = await PATCH( + { + ...request, + json: jest.fn().mockResolvedValue("not an object"), + } as unknown as NextRequest, + { + params: Promise.resolve({ productId: "blog-1" }), + }, + ); + + expect(response.status).toBe(400); + expect(updateCourse).not.toHaveBeenCalled(); + await expect(response.json()).resolves.toEqual({ + error: { + code: "bad_request", + message: "Request body must be a JSON object", + }, + }); + }); + + it("deletes a product through existing product logic", async () => { + (deleteCourse as jest.Mock).mockResolvedValue({ + courseId: "download-1", + }); + + const { DELETE } = await import("../route"); + const response = await DELETE(request, { + params: Promise.resolve({ productId: "download-1" }), + }); + + expect(response.status).toBe(200); + expect(deleteCourse).toHaveBeenCalledWith( + "download-1", + expect.objectContaining({ + subdomain: domain, + user: expect.objectContaining({ userId: "owner" }), + }), + ); + await expect(response.json()).resolves.toEqual({ ok: true }); + }); + + it("fetches a single product for the authenticated school", async () => { + (getCourseOrThrow as jest.Mock).mockResolvedValue({ + courseId: "download-1", + type: "download", + title: "Download One", + slug: "download-one", + published: true, + privacy: "public", + tags: [], + pageId: "download-one", + cost: 0, + costType: "free", + leadMagnet: true, + certificate: true, + createdAt: new Date("2026-01-01T00:00:00.000Z"), + updatedAt: new Date("2026-01-02T00:00:00.000Z"), + }); + + const { GET } = await import("../route"); + const response = await GET(request, { + params: Promise.resolve({ productId: "download-1" }), + }); + + expect(response.status).toBe(200); + expect(getCourseOrThrow).toHaveBeenCalledWith( + undefined, + expect.objectContaining({ subdomain: domain }), + "download-1", + ); + const body = await response.json(); + expect(body).toMatchObject({ + productId: "download-1", + type: "download", + title: "Download One", + slug: "download-one", + published: true, + privacy: "public", + pageId: "download-one", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-02T00:00:00.000Z", + }); + expect(body).not.toHaveProperty("cost"); + expect(body).not.toHaveProperty("costType"); + expect(body).not.toHaveProperty("leadMagnet"); + expect(body).not.toHaveProperty("certificate"); + }); + + it("returns not found when the product does not belong to the school", async () => { + (getCourseOrThrow as jest.Mock).mockRejectedValue( + new Error("Product not found"), + ); + + const { GET } = await import("../route"); + const response = await GET(request, { + params: Promise.resolve({ productId: "missing" }), + }); + + expect(response.status).toBe(404); + await expect(response.json()).resolves.toEqual({ + error: { + code: "not_found", + message: "Product not found", + }, + }); + }); +}); diff --git a/apps/web/app/api/products/[productId]/customers/[userId]/progress/__tests__/route.test.ts b/apps/web/app/api/products/[productId]/customers/[userId]/progress/__tests__/route.test.ts new file mode 100644 index 000000000..387d0bb28 --- /dev/null +++ b/apps/web/app/api/products/[productId]/customers/[userId]/progress/__tests__/route.test.ts @@ -0,0 +1,128 @@ +/** + * @jest-environment node + */ + +import { NextRequest } from "next/server"; +import Domain from "@models/Domain"; +import ApiKey from "@models/ApiKey"; +import User from "@models/User"; +import { findMembership } from "@/graphql/users/logic"; + +jest.mock("@models/Domain"); +jest.mock("@models/ApiKey"); +jest.mock("@models/User"); +jest.mock("@/graphql/users/logic", () => ({ + findMembership: jest.fn(), +})); + +const domain = { + _id: "domain-id", + name: "school", + email: "owner@example.com", +}; + +const request = { + url: "https://school.test/api/products/course-1/customers/user-1/progress", + headers: { + get: jest.fn((name: string) => { + if (name === "domain") return "school"; + if (name === "x-api-key") return "api-key"; + return null; + }), + }, +} as unknown as NextRequest; + +describe("GET /api/products/{productId}/customers/{userId}/progress", () => { + beforeEach(() => { + jest.clearAllMocks(); + (Domain.findOne as jest.Mock).mockResolvedValue(domain); + (ApiKey.findOne as jest.Mock).mockResolvedValue({ key: "api-key" }); + }); + + it("returns the user's purchase entry for the product", async () => { + const createdAt = new Date("2026-01-02T00:00:00.000Z"); + const updatedAt = new Date("2026-01-03T00:00:00.000Z"); + + (User.findOne as jest.Mock).mockImplementation((query: any) => { + if (query.email) { + return Promise.resolve({ + userId: "owner", + email: "owner@example.com", + }); + } + if (query.userId === "user-1") { + return Promise.resolve({ + userId: "user-1", + email: "student@example.com", + purchases: [ + { + courseId: "course-1", + completedLessons: ["lesson-1"], + downloaded: true, + createdAt, + updatedAt, + }, + ], + }); + } + return Promise.resolve(null); + }); + + (findMembership as jest.Mock).mockResolvedValue({ + userId: "user-1", + status: "active", + }); + + const { GET } = await import("../route"); + const response = await GET(request, { + params: Promise.resolve({ + productId: "course-1", + userId: "user-1", + }), + }); + + expect(response.status).toBe(200); + expect(findMembership).toHaveBeenCalledWith({ + domainId: "domain-id", + userId: "user-1", + entityId: "course-1", + }); + await expect(response.json()).resolves.toEqual({ + courseId: "course-1", + completedLessons: ["lesson-1"], + downloaded: true, + createdAt: "2026-01-02T00:00:00.000Z", + updatedAt: "2026-01-03T00:00:00.000Z", + }); + }); + + it("returns not found when the user has no membership for the product", async () => { + (User.findOne as jest.Mock).mockImplementation((query: any) => { + if (query.email) { + return Promise.resolve({ + userId: "owner", + email: "owner@example.com", + }); + } + return Promise.resolve(null); + }); + + (findMembership as jest.Mock).mockResolvedValue(null); + + const { GET } = await import("../route"); + const response = await GET(request, { + params: Promise.resolve({ + productId: "course-1", + userId: "user-1", + }), + }); + + expect(response.status).toBe(404); + await expect(response.json()).resolves.toEqual({ + error: { + code: "not_found", + message: "Customer progress not found", + }, + }); + }); +}); diff --git a/apps/web/app/api/products/[productId]/customers/[userId]/progress/route.ts b/apps/web/app/api/products/[productId]/customers/[userId]/progress/route.ts new file mode 100644 index 000000000..24ae874fb --- /dev/null +++ b/apps/web/app/api/products/[productId]/customers/[userId]/progress/route.ts @@ -0,0 +1,52 @@ +import { NextRequest, NextResponse } from "next/server"; +import { findMembership } from "@/graphql/users/logic"; +import UserModel from "@models/User"; +import { publicApiError, validatePublicApiRequest } from "@/app/api/public-api"; + +function toIsoString(value?: Date | string) { + if (!value) { + return undefined; + } + return value instanceof Date ? value.toISOString() : value; +} + +function serializeProgress(productId: string, purchase: any) { + return { + courseId: productId, + completedLessons: purchase?.completedLessons ?? [], + downloaded: purchase?.downloaded, + createdAt: toIsoString(purchase?.createdAt), + updatedAt: toIsoString(purchase?.updatedAt), + }; +} + +export async function GET( + req: NextRequest, + { params }: { params: Promise<{ productId: string; userId: string }> }, +) { + const auth = await validatePublicApiRequest(req); + if (auth.error) { + return auth.error; + } + + const { productId, userId } = await params; + + const membership = await findMembership({ + domainId: auth.domain._id, + userId, + entityId: productId, + }); + if (!membership) { + return publicApiError("not_found", "Customer progress not found", 404); + } + + const user = await UserModel.findOne({ + userId, + domain: (auth.ctx as any).subdomain._id, + }); + const purchase = user?.purchases?.find( + (p: any) => p.courseId === productId, + ); + + return NextResponse.json(serializeProgress(productId, purchase)); +} diff --git a/apps/web/app/api/products/[productId]/customers/__tests__/route.test.ts b/apps/web/app/api/products/[productId]/customers/__tests__/route.test.ts new file mode 100644 index 000000000..5a67b7c26 --- /dev/null +++ b/apps/web/app/api/products/[productId]/customers/__tests__/route.test.ts @@ -0,0 +1,191 @@ +/** + * @jest-environment node + */ + +import { NextRequest } from "next/server"; +import Domain from "@models/Domain"; +import ApiKey from "@models/ApiKey"; +import User from "@models/User"; +import { getMembers } from "@/graphql/courses/logic"; + +jest.mock("@models/Domain"); +jest.mock("@models/ApiKey"); +jest.mock("@models/User"); +jest.mock("@/graphql/courses/logic", () => ({ + getMembers: jest.fn(), +})); + +const domain = { + _id: "domain-id", + name: "school", +}; + +const mockUserFindChain = (users: any[] = []) => { + const lean = jest.fn().mockResolvedValue(users); + const select = jest.fn().mockReturnValue({ lean }); + (User.find as jest.Mock).mockReturnValue({ select }); + return { find: User.find, select, lean }; +}; + +const request = ( + body?: Record, + url = "https://school.test/api/products/course-1/customers", +) => + ({ + url, + json: jest.fn().mockResolvedValue(body ?? {}), + headers: { + get: jest.fn((name: string) => { + if (name === "domain") return "school"; + if (name === "x-api-key") return "api-key"; + return null; + }), + }, + }) as unknown as NextRequest; + +describe("/api/products/{productId}/customers", () => { + beforeEach(() => { + jest.clearAllMocks(); + (Domain.findOne as jest.Mock).mockResolvedValue(domain); + (ApiKey.findOne as jest.Mock).mockResolvedValue({ key: "api-key" }); + (User.findOne as jest.Mock).mockResolvedValue({ + userId: "owner", + email: "owner@example.com", + permissions: ["user:manage"], + }); + }); + + it("lists customer enrollment snapshots for the product", async () => { + const createdAt = new Date("2026-01-01T00:00:00.000Z"); + const updatedAt = new Date("2026-01-02T00:00:00.000Z"); + (getMembers as jest.Mock).mockResolvedValue([ + { + userId: "user-1", + status: "active", + subscriptionMethod: "internal", + completedLessons: ["lesson-1"], + downloaded: false, + createdAt, + updatedAt, + }, + ]); + mockUserFindChain([ + { + userId: "user-1", + email: "student@example.com", + name: "Student", + avatar: { thumbnail: "avatar-thumbnail" }, + }, + ]); + + const { GET } = await import("../route"); + const response = await GET(request(), { + params: Promise.resolve({ productId: "course-1" }), + }); + + expect(response.status).toBe(200); + expect(getMembers).toHaveBeenCalledWith({ + ctx: expect.objectContaining({ subdomain: domain }), + courseId: "course-1", + page: 1, + limit: 50, + searchText: undefined, + }); + expect(User.find).toHaveBeenCalledWith({ + userId: { $in: ["user-1"] }, + domain: domain._id, + }); + await expect(response.json()).resolves.toMatchObject({ + data: [ + { + user: { + userId: "user-1", + email: "student@example.com", + name: "Student", + avatar: { thumbnail: "avatar-thumbnail" }, + }, + status: "active", + subscriptionMethod: "internal", + completedLessons: ["lesson-1"], + downloaded: false, + createdAt: createdAt.toISOString(), + updatedAt: updatedAt.toISOString(), + }, + ], + }); + }); + + it("delegates search to the existing product member logic", async () => { + (getMembers as jest.Mock).mockResolvedValue([ + { + userId: "user-1", + status: "active", + }, + ]); + mockUserFindChain([ + { + userId: "user-1", + email: "student@example.com", + name: "Student", + }, + ]); + + const { GET } = await import("../route"); + const response = await GET( + request( + undefined, + "https://school.test/api/products/course-1/customers?search=student", + ), + { + params: Promise.resolve({ productId: "course-1" }), + }, + ); + + expect(response.status).toBe(200); + expect(getMembers).toHaveBeenCalledWith({ + ctx: expect.objectContaining({ subdomain: domain }), + courseId: "course-1", + page: 1, + limit: 50, + searchText: "student", + }); + await expect(response.json()).resolves.toMatchObject({ + data: [ + { + user: { + userId: "user-1", + email: "student@example.com", + }, + }, + ], + }); + }); + + it("does not expose the GraphQL-only status filter through the public API", async () => { + (getMembers as jest.Mock).mockResolvedValue([]); + mockUserFindChain([]); + + const { GET } = await import("../route"); + const response = await GET( + request( + undefined, + "https://school.test/api/products/course-1/customers?status=active", + ), + { + params: Promise.resolve({ productId: "course-1" }), + }, + ); + + expect(response.status).toBe(200); + expect(getMembers).toHaveBeenCalledWith({ + ctx: expect.objectContaining({ subdomain: domain }), + courseId: "course-1", + page: 1, + limit: 50, + searchText: undefined, + }); + expect(getMembers).not.toHaveBeenCalledWith( + expect.objectContaining({ status: "active" }), + ); + }); +}); diff --git a/apps/web/app/api/products/[productId]/customers/customer-response.ts b/apps/web/app/api/products/[productId]/customers/customer-response.ts new file mode 100644 index 000000000..0d6c7722e --- /dev/null +++ b/apps/web/app/api/products/[productId]/customers/customer-response.ts @@ -0,0 +1,51 @@ +type UserDocument = { + userId: string; + email?: string; + name?: string; + avatar?: unknown; +}; + +type MembershipDocument = { + membershipId?: string; + userId: string; + status?: string; + subscriptionMethod?: string; + subscriptionId?: string; + createdAt?: Date | string; + updatedAt?: Date | string; +}; + +type PurchaseDocument = { + completedLessons?: string[]; + downloaded?: boolean; + createdAt?: Date | string; + updatedAt?: Date | string; +}; + +function toIsoString(value?: Date | string) { + if (!value) { + return undefined; + } + return value instanceof Date ? value.toISOString() : value; +} + +export function serializeCustomer( + user: UserDocument, + membership?: MembershipDocument | null, + purchase?: PurchaseDocument | null, +) { + return { + userId: user.userId, + email: user.email, + name: user.name, + avatar: user.avatar, + membershipId: membership?.membershipId, + membershipStatus: membership?.status, + subscriptionMethod: membership?.subscriptionMethod, + subscriptionId: membership?.subscriptionId, + completedLessons: purchase?.completedLessons, + downloaded: purchase?.downloaded, + enrolledAt: toIsoString(purchase?.createdAt), + updatedAt: toIsoString(purchase?.updatedAt), + }; +} diff --git a/apps/web/app/api/products/[productId]/customers/invitations/__tests__/route.test.ts b/apps/web/app/api/products/[productId]/customers/invitations/__tests__/route.test.ts new file mode 100644 index 000000000..990a04146 --- /dev/null +++ b/apps/web/app/api/products/[productId]/customers/invitations/__tests__/route.test.ts @@ -0,0 +1,164 @@ +/** + * @jest-environment node + */ + +import { NextRequest } from "next/server"; +import Domain from "@models/Domain"; +import ApiKey from "@models/ApiKey"; +import User from "@models/User"; +import { inviteCustomer, findMembership } from "@/graphql/users/logic"; + +jest.mock("@models/Domain"); +jest.mock("@models/ApiKey"); +jest.mock("@models/User"); +jest.mock("@/graphql/users/logic", () => ({ + inviteCustomer: jest.fn(), + findMembership: jest.fn(), +})); + +const domain = { + _id: "domain-id", + name: "school", +}; + +const request = (body?: Record) => + ({ + url: "https://school.test/api/products/course-1/customers/invitations", + json: jest.fn().mockResolvedValue(body ?? {}), + headers: { + get: jest.fn((name: string) => { + if (name === "domain") return "school"; + if (name === "x-api-key") return "api-key"; + return null; + }), + }, + }) as unknown as NextRequest; + +describe("/api/products/{productId}/customers/invitations", () => { + beforeEach(() => { + jest.clearAllMocks(); + (Domain.findOne as jest.Mock).mockResolvedValue(domain); + (ApiKey.findOne as jest.Mock).mockResolvedValue({ key: "api-key" }); + (User.findOne as jest.Mock).mockResolvedValue({ + userId: "owner", + email: "owner@example.com", + permissions: ["user:manage"], + }); + }); + + it("invites a customer through existing inviteCustomer logic", async () => { + (inviteCustomer as jest.Mock).mockResolvedValue({ + userId: "user-1", + email: "student@example.com", + name: "Student", + avatar: { mediaId: "avatar-1" }, + }); + (findMembership as jest.Mock).mockResolvedValue({ + userId: "user-1", + status: "active", + subscriptionMethod: "internal", + createdAt: new Date("2026-01-01T00:00:00.000Z"), + updatedAt: new Date("2026-01-02T00:00:00.000Z"), + }); + + const { POST } = await import("../route"); + const response = await POST( + request({ + email: "student@example.com", + tags: ["ai"], + }), + { params: Promise.resolve({ productId: "course-1" }) }, + ); + + expect(response.status).toBe(201); + expect(inviteCustomer).toHaveBeenCalledWith( + "student@example.com", + ["ai"], + "course-1", + expect.objectContaining({ subdomain: domain }), + ); + expect(findMembership).toHaveBeenCalledWith({ + domainId: "domain-id", + userId: "user-1", + entityId: "course-1", + }); + await expect(response.json()).resolves.toMatchObject({ + userId: "user-1", + email: "student@example.com", + membershipStatus: "active", + }); + }); + + it("rejects customer invitation fields outside the existing invite flow", async () => { + const { POST } = await import("../route"); + const response = await POST( + request({ + email: "student@example.com", + name: "Student", + }), + { params: Promise.resolve({ productId: "course-1" }) }, + ); + + expect(response.status).toBe(400); + expect(inviteCustomer).not.toHaveBeenCalled(); + await expect(response.json()).resolves.toEqual({ + error: { + code: "bad_request", + message: "Unsupported customer invitation field: name", + }, + }); + }); + + it("requires email for customer invitations", async () => { + const { POST } = await import("../route"); + const response = await POST(request({ tags: ["ai"] }), { + params: Promise.resolve({ productId: "course-1" }), + }); + + expect(response.status).toBe(400); + expect(inviteCustomer).not.toHaveBeenCalled(); + await expect(response.json()).resolves.toEqual({ + error: { + code: "bad_request", + message: "Email is required", + }, + }); + }); + + it("rejects invalid email formats in customer invitations", async () => { + const { POST } = await import("../route"); + const response = await POST( + request({ email: "not-an-email", tags: ["ai"] }), + { params: Promise.resolve({ productId: "course-1" }) }, + ); + + expect(response.status).toBe(400); + expect(inviteCustomer).not.toHaveBeenCalled(); + await expect(response.json()).resolves.toEqual({ + error: { + code: "bad_request", + message: "Invalid email format", + }, + }); + }); + + it("returns bad request instead of 500 when customer invitation JSON is invalid", async () => { + const { POST } = await import("../route"); + const response = await POST( + { + ...request(), + json: jest.fn().mockRejectedValue(new SyntaxError("bad json")), + } as unknown as NextRequest, + { params: Promise.resolve({ productId: "course-1" }) }, + ); + + expect(response.status).toBe(400); + expect(inviteCustomer).not.toHaveBeenCalled(); + await expect(response.json()).resolves.toEqual({ + error: { + code: "bad_request", + message: "Invalid JSON body", + }, + }); + }); +}); diff --git a/apps/web/app/api/products/[productId]/customers/invitations/route.ts b/apps/web/app/api/products/[productId]/customers/invitations/route.ts new file mode 100644 index 000000000..96fb8a3ff --- /dev/null +++ b/apps/web/app/api/products/[productId]/customers/invitations/route.ts @@ -0,0 +1,66 @@ +import { NextRequest, NextResponse } from "next/server"; +import { inviteCustomer, findMembership } from "@/graphql/users/logic"; +import { + publicApiError, + validateEmail, + validatePublicApiRequestWithJsonBody, +} from "@/app/api/public-api"; +import { serializeCustomer } from "../customer-response"; + +const customerInvitationFields = new Set(["email", "tags"]); + +function getUnsupportedField(body: Record) { + return Object.keys(body).find((key) => !customerInvitationFields.has(key)); +} + +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ productId: string }> }, +) { + const auth = await validatePublicApiRequestWithJsonBody(req); + if (auth.error) { + return auth.error; + } + + const { productId } = await params; + const body = auth.body as { email?: string; tags?: string[] }; + const unsupportedField = getUnsupportedField(body); + if (unsupportedField) { + return publicApiError( + "bad_request", + `Unsupported customer invitation field: ${unsupportedField}`, + 400, + ); + } + if (!body.email) { + return publicApiError("bad_request", "Email is required", 400); + } + const emailError = validateEmail(body.email as string); + if (emailError) { + return publicApiError("bad_request", emailError, 400); + } + + try { + const user = await inviteCustomer( + body.email, + body.tags || [], + productId, + auth.ctx as any, + ); + const membership = await findMembership({ + domainId: auth.domain._id, + userId: user.userId, + entityId: productId, + }); + + return NextResponse.json(serializeCustomer(user, membership as any), { + status: 201, + }); + } catch (error: any) { + return publicApiError( + "unprocessable_entity", + error.message || "Unable to invite customer", + 422, + ); + } +} diff --git a/apps/web/app/api/products/[productId]/customers/route.ts b/apps/web/app/api/products/[productId]/customers/route.ts new file mode 100644 index 000000000..962dc95f1 --- /dev/null +++ b/apps/web/app/api/products/[productId]/customers/route.ts @@ -0,0 +1,82 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getMembers } from "@/graphql/courses/logic"; +import UserModel from "@models/User"; +import { publicApiError, validatePublicApiRequest } from "@/app/api/public-api"; + +const DEFAULT_LIMIT = 50; +const MAX_LIMIT = 200; + +function serializeMember(member: any, user: any) { + return { + user: { + userId: user.userId, + email: user.email, + name: user.name, + avatar: user.avatar, + }, + status: member.status, + completedLessons: member.completedLessons, + downloaded: member.downloaded, + subscriptionMethod: member.subscriptionMethod, + createdAt: member.createdAt, + updatedAt: member.updatedAt, + }; +} + +export async function GET( + req: NextRequest, + { params }: { params: Promise<{ productId: string }> }, +) { + const auth = await validatePublicApiRequest(req); + if (auth.error) { + return auth.error; + } + + const { productId } = await params; + const url = new URL(req.url); + const page = Math.max(Number(url.searchParams.get("page") || "1"), 1); + const requestedLimit = Number( + url.searchParams.get("limit") || DEFAULT_LIMIT, + ); + const limit = Math.min( + Math.max(Number.isFinite(requestedLimit) ? requestedLimit : 0, 1), + MAX_LIMIT, + ); + + try { + const members = await getMembers({ + ctx: auth.ctx as any, + courseId: productId, + page, + limit, + searchText: url.searchParams.get("search") || undefined, + }); + + const userIds = (members as any[]).map((m) => m.userId); + const users = userIds.length + ? await UserModel.find({ + userId: { $in: userIds }, + domain: (auth.ctx as any).subdomain._id, + }) + .select("userId email name avatar") + .lean() + : []; + const userMap = new Map(users.map((u: any) => [u.userId, u])); + + const customers = (members as any[]).map((member) => { + const user = userMap.get(member.userId); + return serializeMember(member, user); + }); + + return NextResponse.json({ + data: customers, + pagination: { page, limit }, + }); + } catch (error: any) { + return publicApiError( + "unprocessable_entity", + error.message || "Unable to list product customers", + 422, + ); + } +} diff --git a/apps/web/app/api/products/[productId]/lessons/[lessonId]/__tests__/route.test.ts b/apps/web/app/api/products/[productId]/lessons/[lessonId]/__tests__/route.test.ts new file mode 100644 index 000000000..c425f1a96 --- /dev/null +++ b/apps/web/app/api/products/[productId]/lessons/[lessonId]/__tests__/route.test.ts @@ -0,0 +1,262 @@ +/** + * @jest-environment node + */ + +import { NextRequest } from "next/server"; +import Domain from "@models/Domain"; +import ApiKey from "@models/ApiKey"; +import User from "@models/User"; +import { getCourseLessonOrThrow } from "@/graphql/courses/logic"; +import { deleteLesson, updateLesson } from "@/graphql/lessons/logic"; + +jest.mock("@models/Domain"); +jest.mock("@models/ApiKey"); +jest.mock("@models/User"); +jest.mock("@/graphql/courses/logic", () => ({ + getCourseLessonOrThrow: jest.fn(), +})); +jest.mock("@/graphql/lessons/logic", () => ({ + deleteLesson: jest.fn(), + updateLesson: jest.fn(), +})); + +const domain = { + _id: "domain-id", + name: "school", +}; + +const tiptapDoc = { type: "doc", content: [] }; + +const request = (body?: Record) => + ({ + url: "https://school.test/api/products/course-1/lessons/lesson-1", + json: jest.fn().mockResolvedValue(body ?? {}), + headers: { + get: jest.fn((name: string) => { + if (name === "domain") return "school"; + if (name === "x-api-key") return "api-key"; + return null; + }), + }, + }) as unknown as NextRequest; + +const params = Promise.resolve({ productId: "course-1", lessonId: "lesson-1" }); + +describe("/api/products/{productId}/lessons/{lessonId}", () => { + beforeEach(() => { + jest.clearAllMocks(); + (Domain.findOne as jest.Mock).mockResolvedValue(domain); + (ApiKey.findOne as jest.Mock).mockResolvedValue({ key: "api-key" }); + (User.findOne as jest.Mock).mockResolvedValue({ + userId: "owner", + email: "owner@example.com", + permissions: ["course:manage_any"], + }); + }); + + it("fetches a product lesson through existing product lesson logic", async () => { + (getCourseLessonOrThrow as jest.Mock).mockResolvedValue({ + lessonId: "lesson-1", + title: "Intro", + type: "text", + content: tiptapDoc, + courseId: "course-1", + groupId: "group-1", + published: false, + requiresEnrollment: true, + }); + + const { GET } = await import("../route"); + const response = await GET(request(), { params }); + + expect(response.status).toBe(200); + expect(getCourseLessonOrThrow).toHaveBeenCalledWith({ + courseId: "course-1", + lessonId: "lesson-1", + ctx: expect.objectContaining({ subdomain: domain }), + }); + await expect(response.json()).resolves.toMatchObject({ + lessonId: "lesson-1", + content: tiptapDoc, + }); + }); + + it("updates a lesson with Tiptap JSON converted for existing lesson logic", async () => { + (getCourseLessonOrThrow as jest.Mock).mockResolvedValue({ + lessonId: "lesson-1", + courseId: "course-1", + }); + (updateLesson as jest.Mock).mockResolvedValue({ + lessonId: "lesson-1", + title: "Updated", + type: "text", + content: tiptapDoc, + courseId: "course-1", + groupId: "group-1", + published: false, + requiresEnrollment: true, + }); + + const { PATCH } = await import("../route"); + const response = await PATCH( + request({ + title: "Updated", + content: tiptapDoc, + }), + { params }, + ); + + expect(response.status).toBe(200); + expect(updateLesson).toHaveBeenCalledWith( + { + lessonId: "lesson-1", + id: "lesson-1", + title: "Updated", + content: JSON.stringify(tiptapDoc), + }, + expect.objectContaining({ subdomain: domain }), + ); + await expect(response.json()).resolves.toMatchObject({ + lessonId: "lesson-1", + title: "Updated", + }); + }); + + it("does not send content to existing lesson logic when content is not updated", async () => { + (getCourseLessonOrThrow as jest.Mock).mockResolvedValue({ + lessonId: "lesson-1", + courseId: "course-1", + }); + (updateLesson as jest.Mock).mockResolvedValue({ + lessonId: "lesson-1", + title: "Title only", + type: "text", + content: tiptapDoc, + courseId: "course-1", + groupId: "group-1", + published: false, + requiresEnrollment: true, + }); + + const { PATCH } = await import("../route"); + const response = await PATCH(request({ title: "Title only" }), { + params, + }); + + expect(response.status).toBe(200); + expect(updateLesson).toHaveBeenCalledWith( + { + lessonId: "lesson-1", + id: "lesson-1", + title: "Title only", + }, + expect.objectContaining({ subdomain: domain }), + ); + }); + + it("does not update a lesson that is not part of the product path", async () => { + (getCourseLessonOrThrow as jest.Mock).mockRejectedValue( + new Error("Item not found"), + ); + + const { PATCH } = await import("../route"); + const response = await PATCH(request({ title: "Wrong product" }), { + params, + }); + + expect(response.status).toBe(404); + expect(updateLesson).not.toHaveBeenCalled(); + await expect(response.json()).resolves.toEqual({ + error: { + code: "not_found", + message: "Lesson not found", + }, + }); + }); + + it("returns bad request instead of 500 when lesson update JSON is invalid", async () => { + const { PATCH } = await import("../route"); + const response = await PATCH( + { + ...request(), + json: jest.fn().mockRejectedValue(new SyntaxError("bad json")), + } as unknown as NextRequest, + { params }, + ); + + expect(response.status).toBe(400); + expect(updateLesson).not.toHaveBeenCalled(); + await expect(response.json()).resolves.toEqual({ + error: { + code: "bad_request", + message: "Invalid JSON body", + }, + }); + }); + + it("rejects unsupported lesson update fields before invoking existing lesson logic", async () => { + const { PATCH } = await import("../route"); + const response = await PATCH(request({ courseId: "course-2" }), { + params, + }); + + expect(response.status).toBe(400); + expect(updateLesson).not.toHaveBeenCalled(); + await expect(response.json()).resolves.toEqual({ + error: { + code: "bad_request", + message: "Unsupported lesson field: courseId", + }, + }); + }); + + it("rejects SCORM lesson updates before invoking existing lesson logic", async () => { + const { PATCH } = await import("../route"); + const response = await PATCH(request({ type: "scorm" }), { params }); + + expect(response.status).toBe(422); + expect(updateLesson).not.toHaveBeenCalled(); + await expect(response.json()).resolves.toEqual({ + error: { + code: "not_supported", + message: "SCORM lessons are not supported by the public API.", + }, + }); + }); + + it("deletes a lesson through existing lesson logic", async () => { + (getCourseLessonOrThrow as jest.Mock).mockResolvedValue({ + lessonId: "lesson-1", + courseId: "course-1", + }); + (deleteLesson as jest.Mock).mockResolvedValue(true); + + const { DELETE } = await import("../route"); + const response = await DELETE(request(), { params }); + + expect(response.status).toBe(200); + expect(deleteLesson).toHaveBeenCalledWith( + "lesson-1", + expect.objectContaining({ subdomain: domain }), + ); + await expect(response.json()).resolves.toEqual({ ok: true }); + }); + + it("does not delete a lesson that is not part of the product path", async () => { + (getCourseLessonOrThrow as jest.Mock).mockRejectedValue( + new Error("Item not found"), + ); + + const { DELETE } = await import("../route"); + const response = await DELETE(request(), { params }); + + expect(response.status).toBe(404); + expect(deleteLesson).not.toHaveBeenCalled(); + await expect(response.json()).resolves.toEqual({ + error: { + code: "not_found", + message: "Lesson not found", + }, + }); + }); +}); diff --git a/apps/web/app/api/products/[productId]/lessons/[lessonId]/move/__tests__/route.test.ts b/apps/web/app/api/products/[productId]/lessons/[lessonId]/move/__tests__/route.test.ts new file mode 100644 index 000000000..c624d87e7 --- /dev/null +++ b/apps/web/app/api/products/[productId]/lessons/[lessonId]/move/__tests__/route.test.ts @@ -0,0 +1,93 @@ +/** + * @jest-environment node + */ + +import { NextRequest } from "next/server"; +import Domain from "@models/Domain"; +import ApiKey from "@models/ApiKey"; +import User from "@models/User"; +import { moveLesson } from "@/graphql/courses/logic"; + +jest.mock("@models/Domain"); +jest.mock("@models/ApiKey"); +jest.mock("@models/User"); +jest.mock("@/graphql/courses/logic", () => ({ + moveLesson: jest.fn(), +})); + +const domain = { + _id: "domain-id", + name: "school", +}; + +const request = { + url: "https://school.test/api/products/course-1/lessons/lesson-1/move", + json: jest.fn().mockResolvedValue({ + destinationSectionId: "group-2", + destinationIndex: 0, + }), + headers: { + get: jest.fn((name: string) => { + if (name === "domain") return "school"; + if (name === "x-api-key") return "api-key"; + return null; + }), + }, +} as unknown as NextRequest; + +describe("POST /api/products/{productId}/lessons/{lessonId}/move", () => { + beforeEach(() => { + jest.clearAllMocks(); + (Domain.findOne as jest.Mock).mockResolvedValue(domain); + (ApiKey.findOne as jest.Mock).mockResolvedValue({ key: "api-key" }); + (User.findOne as jest.Mock).mockResolvedValue({ + userId: "owner", + email: "owner@example.com", + permissions: ["course:manage_any"], + }); + }); + + it("moves a lesson through existing course lesson move logic", async () => { + const { POST } = await import("../route"); + const response = await POST(request, { + params: Promise.resolve({ + productId: "course-1", + lessonId: "lesson-1", + }), + }); + + expect(response.status).toBe(200); + expect(moveLesson).toHaveBeenCalledWith({ + courseId: "course-1", + lessonId: "lesson-1", + destinationGroupId: "group-2", + destinationIndex: 0, + ctx: expect.objectContaining({ subdomain: domain }), + }); + }); + + it("returns bad request instead of 500 when lesson move JSON is invalid", async () => { + const { POST } = await import("../route"); + const response = await POST( + { + ...request, + json: jest.fn().mockRejectedValue(new SyntaxError("bad json")), + } as unknown as NextRequest, + { + params: Promise.resolve({ + productId: "course-1", + lessonId: "lesson-1", + }), + }, + ); + + expect(response.status).toBe(400); + expect(moveLesson).not.toHaveBeenCalled(); + await expect(response.json()).resolves.toEqual({ + error: { + code: "bad_request", + message: "Invalid JSON body", + }, + }); + }); +}); diff --git a/apps/web/app/api/products/[productId]/lessons/[lessonId]/move/route.ts b/apps/web/app/api/products/[productId]/lessons/[lessonId]/move/route.ts new file mode 100644 index 000000000..1ad69b749 --- /dev/null +++ b/apps/web/app/api/products/[productId]/lessons/[lessonId]/move/route.ts @@ -0,0 +1,42 @@ +import { NextRequest, NextResponse } from "next/server"; +import { moveLesson } from "@/graphql/courses/logic"; +import { + publicApiError, + validatePublicApiRequestWithJsonBody, +} from "@/app/api/public-api"; + +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ productId: string; lessonId: string }> }, +) { + const auth = await validatePublicApiRequestWithJsonBody(req); + if (auth.error) { + return auth.error; + } + + const { productId, lessonId } = await params; + const body = auth.body as { + destinationSectionId?: string; + destinationGroupId?: string; + destinationIndex?: number; + }; + + try { + const product = await moveLesson({ + courseId: productId, + lessonId, + destinationGroupId: + body.destinationSectionId || body.destinationGroupId || "", + destinationIndex: body.destinationIndex ?? 0, + ctx: auth.ctx as any, + }); + + return NextResponse.json(product ?? { ok: true }); + } catch (error: any) { + return publicApiError( + "unprocessable_entity", + error.message || "Unable to move lesson", + 422, + ); + } +} diff --git a/apps/web/app/api/products/[productId]/lessons/[lessonId]/route.ts b/apps/web/app/api/products/[productId]/lessons/[lessonId]/route.ts new file mode 100644 index 000000000..790c01a3c --- /dev/null +++ b/apps/web/app/api/products/[productId]/lessons/[lessonId]/route.ts @@ -0,0 +1,175 @@ +import { NextRequest, NextResponse } from "next/server"; +import { Constants } from "@courselit/common-models"; +import { getCourseLessonOrThrow } from "@/graphql/courses/logic"; +import { deleteLesson, updateLesson } from "@/graphql/lessons/logic"; +import { + publicApiError, + validatePublicApiRequest, + validatePublicApiRequestWithJsonBody, +} from "@/app/api/public-api"; +import { serializeLesson } from "../lesson-response"; + +const scormNotSupported = () => + publicApiError( + "not_supported", + "SCORM lessons are not supported by the public API.", + 422, + ); + +const updateLessonFields = new Set([ + "title", + "content", + "media", + "downloadable", + "requiresEnrollment", + "published", +]); + +function getUnsupportedField(body: Record) { + return Object.keys(body).find((key) => !updateLessonFields.has(key)); +} + +function lessonNotFound() { + return publicApiError("not_found", "Lesson not found", 404); +} + +async function getProductLessonOrNull({ + productId, + lessonId, + ctx, +}: { + productId: string; + lessonId: string; + ctx: any; +}) { + try { + return await getCourseLessonOrThrow({ + courseId: productId, + lessonId, + ctx, + }); + } catch (error) { + return null; + } +} + +function toExistingUpdatePayload( + body: Record, + lessonId: string, +) { + const payload: Record = { + ...body, + lessonId, + id: lessonId, + }; + + if (Object.prototype.hasOwnProperty.call(body, "content")) { + payload.content = JSON.stringify(body.content); + } + + return payload; +} + +export async function GET( + req: NextRequest, + { params }: { params: Promise<{ productId: string; lessonId: string }> }, +) { + const auth = await validatePublicApiRequest(req); + if (auth.error) { + return auth.error; + } + + const { productId, lessonId } = await params; + const lesson = await getProductLessonOrNull({ + productId, + lessonId, + ctx: auth.ctx as any, + }); + + if (!lesson) { + return lessonNotFound(); + } + + return NextResponse.json(serializeLesson(lesson as any)); +} + +export async function PATCH( + req: NextRequest, + { params }: { params: Promise<{ productId: string; lessonId: string }> }, +) { + const auth = await validatePublicApiRequestWithJsonBody(req); + if (auth.error) { + return auth.error; + } + + const { productId, lessonId } = await params; + const body = auth.body; + if (body.type === Constants.LessonType.SCORM) { + return scormNotSupported(); + } + + const unsupportedField = getUnsupportedField(body); + if (unsupportedField) { + return publicApiError( + "bad_request", + `Unsupported lesson field: ${unsupportedField}`, + 400, + ); + } + + try { + const existingLesson = await getProductLessonOrNull({ + productId, + lessonId, + ctx: auth.ctx as any, + }); + if (!existingLesson) { + return lessonNotFound(); + } + + const lesson = await updateLesson( + toExistingUpdatePayload(body, lessonId) as any, + auth.ctx as any, + ); + + return NextResponse.json(serializeLesson(lesson as any)); + } catch (error: any) { + return publicApiError( + "unprocessable_entity", + error.message || "Unable to update lesson", + 422, + ); + } +} + +export async function DELETE( + req: NextRequest, + { params }: { params: Promise<{ productId: string; lessonId: string }> }, +) { + const auth = await validatePublicApiRequest(req); + if (auth.error) { + return auth.error; + } + + const { productId, lessonId } = await params; + + try { + const existingLesson = await getProductLessonOrNull({ + productId, + lessonId, + ctx: auth.ctx as any, + }); + if (!existingLesson) { + return lessonNotFound(); + } + + await deleteLesson(lessonId, auth.ctx as any); + return NextResponse.json({ ok: true }); + } catch (error: any) { + return publicApiError( + "unprocessable_entity", + error.message || "Unable to delete lesson", + 422, + ); + } +} diff --git a/apps/web/app/api/products/[productId]/lessons/__tests__/route.test.ts b/apps/web/app/api/products/[productId]/lessons/__tests__/route.test.ts new file mode 100644 index 000000000..50c18e3f8 --- /dev/null +++ b/apps/web/app/api/products/[productId]/lessons/__tests__/route.test.ts @@ -0,0 +1,202 @@ +/** + * @jest-environment node + */ + +import { NextRequest } from "next/server"; +import Domain from "@models/Domain"; +import ApiKey from "@models/ApiKey"; +import User from "@models/User"; +import { getCourseLessons } from "@/graphql/courses/logic"; +import { createLesson } from "@/graphql/lessons/logic"; + +jest.mock("@models/Domain"); +jest.mock("@models/ApiKey"); +jest.mock("@models/User"); +jest.mock("@/graphql/courses/logic", () => ({ + getCourseLessons: jest.fn(), +})); +jest.mock("@/graphql/lessons/logic", () => ({ + createLesson: jest.fn(), +})); + +const domain = { + _id: "domain-id", + name: "school", +}; + +const tiptapDoc = { + type: "doc", + content: [{ type: "paragraph", content: [{ type: "text", text: "Hi" }] }], +}; + +const request = (body?: Record) => + ({ + url: "https://school.test/api/products/course-1/lessons", + json: jest.fn().mockResolvedValue(body ?? {}), + headers: { + get: jest.fn((name: string) => { + if (name === "domain") return "school"; + if (name === "x-api-key") return "api-key"; + return null; + }), + }, + }) as unknown as NextRequest; + +describe("/api/products/{productId}/lessons", () => { + beforeEach(() => { + jest.clearAllMocks(); + (Domain.findOne as jest.Mock).mockResolvedValue(domain); + (ApiKey.findOne as jest.Mock).mockResolvedValue({ key: "api-key" }); + (User.findOne as jest.Mock).mockResolvedValue({ + userId: "owner", + email: "owner@example.com", + permissions: ["course:manage_any"], + }); + }); + + it("lists lessons for a product without requiring published-only behavior", async () => { + (getCourseLessons as jest.Mock).mockResolvedValue([ + { + lessonId: "lesson-1", + title: "Intro", + type: "text", + content: tiptapDoc, + courseId: "course-1", + groupId: "group-1", + published: false, + requiresEnrollment: true, + }, + ]); + + const { GET } = await import("../route"); + const response = await GET(request(), { + params: Promise.resolve({ productId: "course-1" }), + }); + + expect(response.status).toBe(200); + expect(getCourseLessons).toHaveBeenCalledWith({ + courseId: "course-1", + ctx: expect.objectContaining({ subdomain: domain }), + }); + await expect(response.json()).resolves.toEqual({ + data: [ + { + lessonId: "lesson-1", + title: "Intro", + type: "text", + content: tiptapDoc, + media: undefined, + downloadable: undefined, + courseId: "course-1", + groupId: "group-1", + requiresEnrollment: true, + published: false, + }, + ], + }); + }); + + it("creates a text lesson with Tiptap JSON converted for existing lesson logic", async () => { + (createLesson as jest.Mock).mockResolvedValue({ + lessonId: "lesson-1", + title: "Intro", + type: "text", + content: tiptapDoc, + courseId: "course-1", + groupId: "group-1", + published: false, + requiresEnrollment: true, + }); + + const { POST } = await import("../route"); + const response = await POST( + request({ + title: "Intro", + type: "text", + groupId: "group-1", + content: tiptapDoc, + }), + { params: Promise.resolve({ productId: "course-1" }) }, + ); + + expect(response.status).toBe(201); + expect(createLesson).toHaveBeenCalledWith( + expect.objectContaining({ + title: "Intro", + type: "text", + courseId: "course-1", + groupId: "group-1", + content: JSON.stringify(tiptapDoc), + }), + expect.objectContaining({ subdomain: domain }), + ); + await expect(response.json()).resolves.toMatchObject({ + lessonId: "lesson-1", + content: tiptapDoc, + }); + }); + + it("rejects unsupported lesson create fields before invoking existing lesson logic", async () => { + const { POST } = await import("../route"); + const response = await POST( + request({ + title: "Intro", + type: "text", + groupId: "group-1", + content: tiptapDoc, + courseId: "course-2", + }), + { params: Promise.resolve({ productId: "course-1" }) }, + ); + + expect(response.status).toBe(400); + expect(createLesson).not.toHaveBeenCalled(); + await expect(response.json()).resolves.toEqual({ + error: { + code: "bad_request", + message: "Unsupported lesson field: courseId", + }, + }); + }); + + it("returns bad request instead of 500 when lesson create JSON is invalid", async () => { + const { POST } = await import("../route"); + const response = await POST( + { + ...request(), + json: jest.fn().mockRejectedValue(new SyntaxError("bad json")), + } as unknown as NextRequest, + { params: Promise.resolve({ productId: "course-1" }) }, + ); + + expect(response.status).toBe(400); + expect(createLesson).not.toHaveBeenCalled(); + await expect(response.json()).resolves.toEqual({ + error: { + code: "bad_request", + message: "Invalid JSON body", + }, + }); + }); + + it("rejects SCORM lesson creation before invoking existing lesson logic", async () => { + const { POST } = await import("../route"); + const response = await POST( + request({ + title: "SCORM", + type: "scorm", + groupId: "group-1", + }), + { params: Promise.resolve({ productId: "course-1" }) }, + ); + + expect(response.status).toBe(422); + expect(createLesson).not.toHaveBeenCalled(); + await expect(response.json()).resolves.toEqual({ + error: { + code: "not_supported", + message: "SCORM lessons are not supported by the public API.", + }, + }); + }); +}); diff --git a/apps/web/app/api/products/[productId]/lessons/lesson-response.ts b/apps/web/app/api/products/[productId]/lessons/lesson-response.ts new file mode 100644 index 000000000..f5fbc4ed7 --- /dev/null +++ b/apps/web/app/api/products/[productId]/lessons/lesson-response.ts @@ -0,0 +1,40 @@ +type LessonDocument = { + lessonId: string; + title: string; + type: string; + content?: unknown; + media?: unknown; + downloadable?: boolean; + courseId: string; + groupId: string; + requiresEnrollment: boolean; + published: boolean; +}; + +export function serializeLesson(lesson: LessonDocument) { + return { + lessonId: lesson.lessonId, + title: lesson.title, + type: lesson.type, + content: lesson.content, + media: lesson.media, + downloadable: lesson.downloadable, + courseId: lesson.courseId, + groupId: lesson.groupId, + requiresEnrollment: lesson.requiresEnrollment, + published: lesson.published, + }; +} + +export function toExistingLessonPayload( + body: Record, + courseId: string, +) { + return { + ...body, + courseId, + content: Object.prototype.hasOwnProperty.call(body, "content") + ? JSON.stringify(body.content) + : undefined, + }; +} diff --git a/apps/web/app/api/products/[productId]/lessons/route.ts b/apps/web/app/api/products/[productId]/lessons/route.ts new file mode 100644 index 000000000..f5d5a3f88 --- /dev/null +++ b/apps/web/app/api/products/[productId]/lessons/route.ts @@ -0,0 +1,101 @@ +import { NextRequest, NextResponse } from "next/server"; +import { Constants } from "@courselit/common-models"; +import { getCourseLessons } from "@/graphql/courses/logic"; +import { createLesson } from "@/graphql/lessons/logic"; +import { + publicApiError, + validatePublicApiRequest, + validatePublicApiRequestWithJsonBody, +} from "@/app/api/public-api"; +import { serializeLesson, toExistingLessonPayload } from "./lesson-response"; + +const scormNotSupported = () => + publicApiError( + "not_supported", + "SCORM lessons are not supported by the public API.", + 422, + ); + +const createLessonFields = new Set([ + "title", + "type", + "content", + "media", + "downloadable", + "groupId", + "requiresEnrollment", + "published", +]); + +function getUnsupportedField(body: Record) { + return Object.keys(body).find((key) => !createLessonFields.has(key)); +} + +export async function GET( + req: NextRequest, + { params }: { params: Promise<{ productId: string }> }, +) { + const auth = await validatePublicApiRequest(req); + if (auth.error) { + return auth.error; + } + + const { productId } = await params; + try { + const lessons = await getCourseLessons({ + courseId: productId, + ctx: auth.ctx as any, + }); + + return NextResponse.json({ + data: lessons.map((lesson) => serializeLesson(lesson as any)), + }); + } catch (error: any) { + return publicApiError( + "not_found", + error.message || "Product not found", + 404, + ); + } +} + +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ productId: string }> }, +) { + const auth = await validatePublicApiRequestWithJsonBody(req); + if (auth.error) { + return auth.error; + } + + const { productId } = await params; + const body = auth.body; + if (body.type === Constants.LessonType.SCORM) { + return scormNotSupported(); + } + const unsupportedField = getUnsupportedField(body); + if (unsupportedField) { + return publicApiError( + "bad_request", + `Unsupported lesson field: ${unsupportedField}`, + 400, + ); + } + + try { + const lesson = await createLesson( + toExistingLessonPayload(body, productId) as any, + auth.ctx as any, + ); + + return NextResponse.json(serializeLesson(lesson as any), { + status: 201, + }); + } catch (error: any) { + return publicApiError( + "unprocessable_entity", + error.message || "Unable to create lesson", + 422, + ); + } +} diff --git a/apps/web/app/api/products/[productId]/payment-plans/[planId]/__tests__/route.test.ts b/apps/web/app/api/products/[productId]/payment-plans/[planId]/__tests__/route.test.ts new file mode 100644 index 000000000..43f22f27e --- /dev/null +++ b/apps/web/app/api/products/[productId]/payment-plans/[planId]/__tests__/route.test.ts @@ -0,0 +1,236 @@ +/** + * @jest-environment node + */ + +import { NextRequest } from "next/server"; +import Domain from "@models/Domain"; +import ApiKey from "@models/ApiKey"; +import User from "@models/User"; +import { getCourseOrThrow } from "@/graphql/courses/logic"; +import { + archivePaymentPlan, + getPlan, + updatePlan, +} from "@/graphql/paymentplans/logic"; + +jest.mock("@models/Domain"); +jest.mock("@models/ApiKey"); +jest.mock("@models/User"); +jest.mock("@/graphql/courses/logic", () => ({ + getCourseOrThrow: jest.fn(), +})); +jest.mock("@/graphql/paymentplans/logic", () => ({ + archivePaymentPlan: jest.fn(), + getPlan: jest.fn(), + updatePlan: jest.fn(), +})); + +const domain = { + _id: "domain-id", + name: "school", +}; + +const request = (body?: Record) => + ({ + url: "https://school.test/api/products/course-1/payment-plans/plan-1", + json: jest.fn().mockResolvedValue(body ?? {}), + headers: { + get: jest.fn((name: string) => { + if (name === "domain") return "school"; + if (name === "x-api-key") return "api-key"; + return null; + }), + }, + }) as unknown as NextRequest; + +const params = Promise.resolve({ productId: "course-1", planId: "plan-1" }); + +describe("/api/products/{productId}/payment-plans/{planId}", () => { + beforeEach(() => { + jest.clearAllMocks(); + (Domain.findOne as jest.Mock).mockResolvedValue(domain); + (ApiKey.findOne as jest.Mock).mockResolvedValue({ key: "api-key" }); + (getCourseOrThrow as jest.Mock).mockResolvedValue({ + courseId: "course-1", + defaultPaymentPlan: "plan-1", + }); + (User.findOne as jest.Mock).mockResolvedValue({ + userId: "owner", + email: "owner@example.com", + permissions: ["course:manage_any"], + }); + }); + + it("fetches a single product-owned payment plan", async () => { + (getPlan as jest.Mock).mockResolvedValue({ + planId: "plan-1", + name: "Free", + type: "free", + entityId: "course-1", + entityType: "course", + }); + + const { GET } = await import("../route"); + const response = await GET(request(), { params }); + + expect(response.status).toBe(200); + expect(getPlan).toHaveBeenCalledWith({ + planId: "plan-1", + ctx: expect.objectContaining({ subdomain: domain }), + }); + await expect(response.json()).resolves.toMatchObject({ + planId: "plan-1", + entityId: "course-1", + isDefault: true, + }); + }); + + it("does not expose a plan from another product through this product path", async () => { + (getPlan as jest.Mock).mockResolvedValue({ + planId: "plan-1", + name: "Free", + type: "free", + entityId: "course-2", + entityType: "course", + }); + + const { GET } = await import("../route"); + const response = await GET(request(), { params }); + + expect(response.status).toBe(404); + await expect(response.json()).resolves.toEqual({ + error: { + code: "not_found", + message: "Payment plan not found", + }, + }); + }); + + it("updates a payment plan through existing payment-plan logic", async () => { + (getPlan as jest.Mock).mockResolvedValue({ + planId: "plan-1", + name: "Free", + type: "free", + entityId: "course-1", + entityType: "course", + }); + (updatePlan as jest.Mock).mockResolvedValue({ + planId: "plan-1", + name: "Updated", + type: "free", + entityId: "course-1", + entityType: "course", + }); + + const { PATCH } = await import("../route"); + const response = await PATCH(request({ name: "Updated" }), { + params, + }); + + expect(response.status).toBe(200); + expect(updatePlan).toHaveBeenCalledWith({ + planId: "plan-1", + name: "Updated", + ctx: expect.any(Object), + }); + await expect(response.json()).resolves.toMatchObject({ + planId: "plan-1", + name: "Updated", + }); + }); + + it("does not update a payment plan that belongs to another product", async () => { + (getPlan as jest.Mock).mockResolvedValue({ + planId: "plan-1", + name: "Other", + type: "free", + entityId: "course-2", + entityType: "course", + }); + + const { PATCH } = await import("../route"); + const response = await PATCH(request({ name: "Updated" }), { + params, + }); + + expect(response.status).toBe(404); + expect(updatePlan).not.toHaveBeenCalled(); + await expect(response.json()).resolves.toEqual({ + error: { + code: "not_found", + message: "Payment plan not found", + }, + }); + }); + + it("returns bad request instead of 500 when payment plan update JSON is invalid", async () => { + const { PATCH } = await import("../route"); + const response = await PATCH( + { + ...request(), + json: jest.fn().mockRejectedValue(new SyntaxError("bad json")), + } as unknown as NextRequest, + { params }, + ); + + expect(response.status).toBe(400); + expect(updatePlan).not.toHaveBeenCalled(); + await expect(response.json()).resolves.toEqual({ + error: { + code: "bad_request", + message: "Invalid JSON body", + }, + }); + }); + + it("archives a payment plan through existing payment-plan logic", async () => { + (getPlan as jest.Mock).mockResolvedValue({ + planId: "plan-1", + name: "Old", + type: "free", + entityId: "course-1", + entityType: "course", + }); + (archivePaymentPlan as jest.Mock).mockResolvedValue({ + planId: "plan-1", + name: "Old", + type: "free", + entityId: "course-1", + entityType: "course", + }); + + const { DELETE } = await import("../route"); + const response = await DELETE(request(), { params }); + + expect(response.status).toBe(200); + expect(archivePaymentPlan).toHaveBeenCalledWith({ + planId: "plan-1", + ctx: expect.any(Object), + }); + await expect(response.json()).resolves.toMatchObject({ + planId: "plan-1", + }); + }); + + it("does not archive a payment plan that belongs to another product", async () => { + (getPlan as jest.Mock).mockResolvedValue({ + planId: "plan-1", + name: "Other", + type: "free", + entityId: "course-2", + entityType: "course", + }); + + const { DELETE } = await import("../route"); + const response = await DELETE(request(), { params }); + + expect(response.status).toBe(404); + expect(archivePaymentPlan).not.toHaveBeenCalled(); + await expect(response.json()).resolves.toEqual({ + error: { + code: "not_found", + message: "Payment plan not found", + }, + }); + }); +}); diff --git a/apps/web/app/api/products/[productId]/payment-plans/[planId]/default/__tests__/route.test.ts b/apps/web/app/api/products/[productId]/payment-plans/[planId]/default/__tests__/route.test.ts new file mode 100644 index 000000000..e58d75625 --- /dev/null +++ b/apps/web/app/api/products/[productId]/payment-plans/[planId]/default/__tests__/route.test.ts @@ -0,0 +1,110 @@ +/** + * @jest-environment node + */ + +import { NextRequest } from "next/server"; +import Domain from "@models/Domain"; +import ApiKey from "@models/ApiKey"; +import User from "@models/User"; +import { changeDefaultPlan, getPlan } from "@/graphql/paymentplans/logic"; + +jest.mock("@models/Domain"); +jest.mock("@models/ApiKey"); +jest.mock("@models/User"); +jest.mock("@/graphql/paymentplans/logic", () => ({ + changeDefaultPlan: jest.fn(), + getPlan: jest.fn(), +})); + +const domain = { + _id: "domain-id", + name: "school", +}; + +const request = { + url: "https://school.test/api/products/course-1/payment-plans/plan-1/default", + headers: { + get: jest.fn((name: string) => { + if (name === "domain") return "school"; + if (name === "x-api-key") return "api-key"; + return null; + }), + }, +} as unknown as NextRequest; + +describe("POST /api/products/{productId}/payment-plans/{planId}/default", () => { + beforeEach(() => { + jest.clearAllMocks(); + (Domain.findOne as jest.Mock).mockResolvedValue(domain); + (ApiKey.findOne as jest.Mock).mockResolvedValue({ key: "api-key" }); + (User.findOne as jest.Mock).mockResolvedValue({ + userId: "owner", + email: "owner@example.com", + permissions: ["course:manage_any"], + }); + }); + + it("sets the product default plan through existing payment-plan logic", async () => { + (getPlan as jest.Mock).mockResolvedValue({ + planId: "plan-1", + name: "Free", + type: "free", + entityId: "course-1", + entityType: "course", + }); + (changeDefaultPlan as jest.Mock).mockResolvedValue({ + planId: "plan-1", + name: "Free", + type: "free", + entityId: "course-1", + entityType: "course", + }); + + const { POST } = await import("../route"); + const response = await POST(request, { + params: Promise.resolve({ + productId: "course-1", + planId: "plan-1", + }), + }); + + expect(response.status).toBe(200); + expect(changeDefaultPlan).toHaveBeenCalledWith({ + planId: "plan-1", + entityId: "course-1", + entityType: "course", + ctx: expect.objectContaining({ subdomain: domain }), + }); + await expect(response.json()).resolves.toMatchObject({ + planId: "plan-1", + isDefault: true, + }); + }); + + it("does not set a default plan that belongs to another product", async () => { + (getPlan as jest.Mock).mockResolvedValue({ + planId: "plan-1", + name: "Other", + type: "free", + entityId: "course-2", + entityType: "course", + }); + + const { POST } = await import("../route"); + const response = await POST(request, { + params: Promise.resolve({ + productId: "course-1", + planId: "plan-1", + }), + }); + + expect(response.status).toBe(404); + expect(changeDefaultPlan).not.toHaveBeenCalled(); + await expect(response.json()).resolves.toEqual({ + error: { + code: "not_found", + message: "Payment plan not found", + }, + }); + }); +}); diff --git a/apps/web/app/api/products/[productId]/payment-plans/[planId]/default/route.ts b/apps/web/app/api/products/[productId]/payment-plans/[planId]/default/route.ts new file mode 100644 index 000000000..2310468ab --- /dev/null +++ b/apps/web/app/api/products/[productId]/payment-plans/[planId]/default/route.ts @@ -0,0 +1,46 @@ +import { NextRequest, NextResponse } from "next/server"; +import { Constants, PaymentPlan } from "@courselit/common-models"; +import { changeDefaultPlan, getPlan } from "@/graphql/paymentplans/logic"; +import { publicApiError, validatePublicApiRequest } from "@/app/api/public-api"; +import { serializePaymentPlan } from "../../../../product-response"; + +function isProductPlan(plan: PaymentPlan, productId: string) { + return ( + plan.entityId === productId && + plan.entityType === Constants.MembershipEntityType.COURSE + ); +} + +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ productId: string; planId: string }> }, +) { + const auth = await validatePublicApiRequest(req); + if (auth.error) { + return auth.error; + } + + const { productId, planId } = await params; + + try { + const existingPlan = await getPlan({ planId, ctx: auth.ctx as any }); + if (!isProductPlan(existingPlan, productId)) { + return publicApiError("not_found", "Payment plan not found", 404); + } + + const plan = await changeDefaultPlan({ + planId, + entityId: productId, + entityType: Constants.MembershipEntityType.COURSE, + ctx: auth.ctx as any, + }); + + return NextResponse.json(serializePaymentPlan(plan, plan.planId)); + } catch (error: any) { + return publicApiError( + "unprocessable_entity", + error.message || "Unable to change default payment plan", + 422, + ); + } +} diff --git a/apps/web/app/api/products/[productId]/payment-plans/[planId]/route.ts b/apps/web/app/api/products/[productId]/payment-plans/[planId]/route.ts new file mode 100644 index 000000000..9c4788384 --- /dev/null +++ b/apps/web/app/api/products/[productId]/payment-plans/[planId]/route.ts @@ -0,0 +1,164 @@ +import { NextRequest, NextResponse } from "next/server"; +import { Constants, PaymentPlan } from "@courselit/common-models"; +import { getCourseOrThrow } from "@/graphql/courses/logic"; +import { + archivePaymentPlan, + getPlan, + updatePlan, +} from "@/graphql/paymentplans/logic"; +import { + publicApiError, + validatePublicApiRequest, + validatePublicApiRequestWithJsonBody, +} from "@/app/api/public-api"; +import { serializePaymentPlan } from "../../../product-response"; + +const paymentPlanFields = new Set([ + "name", + "type", + "oneTimeAmount", + "emiAmount", + "emiTotalInstallments", + "subscriptionMonthlyAmount", + "subscriptionYearlyAmount", + "description", +]); + +function getUnsupportedField(body: Record) { + return Object.keys(body).find((key) => !paymentPlanFields.has(key)); +} + +function isProductPlan(plan: PaymentPlan, productId: string) { + return ( + plan.entityId === productId && + plan.entityType === Constants.MembershipEntityType.COURSE + ); +} + +async function getDefaultPaymentPlan(productId: string, ctx: any) { + const product = await getCourseOrThrow(undefined, ctx, productId); + return product?.defaultPaymentPlan; +} + +function paymentPlanNotFound() { + return publicApiError("not_found", "Payment plan not found", 404); +} + +export async function GET( + req: NextRequest, + { params }: { params: Promise<{ productId: string; planId: string }> }, +) { + const auth = await validatePublicApiRequest(req); + if (auth.error) { + return auth.error; + } + + const { productId, planId } = await params; + + try { + const plan = await getPlan({ planId, ctx: auth.ctx as any }); + if (!isProductPlan(plan, productId)) { + return paymentPlanNotFound(); + } + const defaultPaymentPlan = await getDefaultPaymentPlan( + productId, + auth.ctx as any, + ); + + return NextResponse.json( + serializePaymentPlan(plan, defaultPaymentPlan), + ); + } catch (error: any) { + return publicApiError( + "unprocessable_entity", + error.message || "Unable to fetch payment plan", + 422, + ); + } +} + +export async function PATCH( + req: NextRequest, + { params }: { params: Promise<{ productId: string; planId: string }> }, +) { + const auth = await validatePublicApiRequestWithJsonBody(req); + if (auth.error) { + return auth.error; + } + + const { productId, planId } = await params; + const body = auth.body; + const unsupportedField = getUnsupportedField(body); + if (unsupportedField) { + return publicApiError( + "bad_request", + `Unsupported payment plan field: ${unsupportedField}`, + 400, + ); + } + + try { + const existingPlan = await getPlan({ planId, ctx: auth.ctx as any }); + if (!isProductPlan(existingPlan, productId)) { + return paymentPlanNotFound(); + } + + const plan = await updatePlan({ + planId, + ...body, + ctx: auth.ctx as any, + } as any); + const defaultPaymentPlan = await getDefaultPaymentPlan( + productId, + auth.ctx as any, + ); + + return NextResponse.json( + serializePaymentPlan(plan, defaultPaymentPlan), + ); + } catch (error: any) { + return publicApiError( + "unprocessable_entity", + error.message || "Unable to update payment plan", + 422, + ); + } +} + +export async function DELETE( + req: NextRequest, + { params }: { params: Promise<{ productId: string; planId: string }> }, +) { + const auth = await validatePublicApiRequest(req); + if (auth.error) { + return auth.error; + } + + const { productId, planId } = await params; + + try { + const existingPlan = await getPlan({ planId, ctx: auth.ctx as any }); + if (!isProductPlan(existingPlan, productId)) { + return paymentPlanNotFound(); + } + + const plan = await archivePaymentPlan({ + planId, + ctx: auth.ctx as any, + }); + const defaultPaymentPlan = await getDefaultPaymentPlan( + productId, + auth.ctx as any, + ); + + return NextResponse.json( + serializePaymentPlan(plan, defaultPaymentPlan), + ); + } catch (error: any) { + return publicApiError( + "unprocessable_entity", + error.message || "Unable to archive payment plan", + 422, + ); + } +} diff --git a/apps/web/app/api/products/[productId]/payment-plans/__tests__/route.test.ts b/apps/web/app/api/products/[productId]/payment-plans/__tests__/route.test.ts new file mode 100644 index 000000000..e8ee2d2fc --- /dev/null +++ b/apps/web/app/api/products/[productId]/payment-plans/__tests__/route.test.ts @@ -0,0 +1,184 @@ +/** + * @jest-environment node + */ + +import { NextRequest } from "next/server"; +import Domain from "@models/Domain"; +import ApiKey from "@models/ApiKey"; +import User from "@models/User"; +import { getCourseOrThrow } from "@/graphql/courses/logic"; +import { createPlan, getPlansForEntity } from "@/graphql/paymentplans/logic"; + +jest.mock("@models/Domain"); +jest.mock("@models/ApiKey"); +jest.mock("@models/User"); +jest.mock("@/graphql/courses/logic", () => ({ + getCourseOrThrow: jest.fn(), +})); +jest.mock("@/graphql/paymentplans/logic", () => ({ + createPlan: jest.fn(), + getPlansForEntity: jest.fn(), +})); + +const domain = { + _id: "domain-id", + name: "school", +}; + +const request = (body?: Record) => + ({ + url: "https://school.test/api/products/course-1/payment-plans", + json: jest.fn().mockResolvedValue(body ?? {}), + headers: { + get: jest.fn((name: string) => { + if (name === "domain") return "school"; + if (name === "x-api-key") return "api-key"; + return null; + }), + }, + }) as unknown as NextRequest; + +describe("GET /api/products/{productId}/payment-plans", () => { + beforeEach(() => { + jest.clearAllMocks(); + (Domain.findOne as jest.Mock).mockResolvedValue(domain); + (ApiKey.findOne as jest.Mock).mockResolvedValue({ key: "api-key" }); + (getCourseOrThrow as jest.Mock).mockResolvedValue({ + courseId: "course-1", + defaultPaymentPlan: "plan-free", + }); + (User.findOne as jest.Mock).mockResolvedValue({ + userId: "owner", + email: "owner@example.com", + permissions: ["course:manage_any"], + }); + }); + + it("lists payment plans through existing payment-plan logic", async () => { + (getPlansForEntity as jest.Mock).mockResolvedValue([ + { + planId: "plan-free", + name: "Free", + type: "free", + entityId: "course-1", + entityType: "course", + }, + ]); + + const { GET } = await import("../route"); + const response = await GET(request(), { + params: Promise.resolve({ productId: "course-1" }), + }); + + expect(response.status).toBe(200); + expect(getPlansForEntity).toHaveBeenCalledWith({ + entityId: "course-1", + entityType: "course", + ctx: expect.objectContaining({ + subdomain: domain, + user: expect.objectContaining({ userId: "owner" }), + }), + }); + await expect(response.json()).resolves.toEqual({ + data: [ + { + planId: "plan-free", + name: "Free", + type: "free", + entityId: "course-1", + entityType: "course", + isDefault: true, + }, + ], + }); + }); + + it("creates a payment plan through existing payment-plan logic", async () => { + (getCourseOrThrow as jest.Mock).mockResolvedValue({ + courseId: "course-1", + defaultPaymentPlan: "plan-paid", + }); + (createPlan as jest.Mock).mockResolvedValue({ + planId: "plan-paid", + name: "Paid", + type: "onetime", + oneTimeAmount: 100, + entityId: "course-1", + entityType: "course", + }); + + const { POST } = await import("../route"); + const response = await POST( + request({ + name: "Paid", + type: "onetime", + oneTimeAmount: 100, + }), + { + params: Promise.resolve({ productId: "course-1" }), + }, + ); + + expect(response.status).toBe(201); + expect(createPlan).toHaveBeenCalledWith({ + name: "Paid", + type: "onetime", + oneTimeAmount: 100, + entityId: "course-1", + entityType: "course", + ctx: expect.any(Object), + }); + await expect(response.json()).resolves.toMatchObject({ + planId: "plan-paid", + name: "Paid", + type: "onetime", + oneTimeAmount: 100, + isDefault: true, + }); + }); + + it("rejects includedProducts for product-owned payment plans", async () => { + const { POST } = await import("../route"); + const response = await POST( + request({ + name: "Bundle-like plan", + type: "free", + includedProducts: ["course-2"], + }), + { + params: Promise.resolve({ productId: "course-1" }), + }, + ); + + expect(response.status).toBe(400); + expect(createPlan).not.toHaveBeenCalled(); + await expect(response.json()).resolves.toEqual({ + error: { + code: "bad_request", + message: "Unsupported payment plan field: includedProducts", + }, + }); + }); + + it("returns bad request instead of 500 when payment plan create JSON is invalid", async () => { + const { POST } = await import("../route"); + const response = await POST( + { + ...request(), + json: jest.fn().mockRejectedValue(new SyntaxError("bad json")), + } as unknown as NextRequest, + { + params: Promise.resolve({ productId: "course-1" }), + }, + ); + + expect(response.status).toBe(400); + expect(createPlan).not.toHaveBeenCalled(); + await expect(response.json()).resolves.toEqual({ + error: { + code: "bad_request", + message: "Invalid JSON body", + }, + }); + }); +}); diff --git a/apps/web/app/api/products/[productId]/payment-plans/route.ts b/apps/web/app/api/products/[productId]/payment-plans/route.ts new file mode 100644 index 000000000..57cbaf7b3 --- /dev/null +++ b/apps/web/app/api/products/[productId]/payment-plans/route.ts @@ -0,0 +1,113 @@ +import { NextRequest, NextResponse } from "next/server"; +import { Constants } from "@courselit/common-models"; +import { getCourseOrThrow } from "@/graphql/courses/logic"; +import { createPlan, getPlansForEntity } from "@/graphql/paymentplans/logic"; +import { + publicApiError, + validatePublicApiRequest, + validatePublicApiRequestWithJsonBody, +} from "@/app/api/public-api"; +import { serializePaymentPlan } from "../../product-response"; + +const paymentPlanFields = new Set([ + "name", + "type", + "oneTimeAmount", + "emiAmount", + "emiTotalInstallments", + "subscriptionMonthlyAmount", + "subscriptionYearlyAmount", + "description", +]); + +function getUnsupportedField(body: Record) { + return Object.keys(body).find((key) => !paymentPlanFields.has(key)); +} + +async function getDefaultPaymentPlan(productId: string, ctx: any) { + const product = await getCourseOrThrow(undefined, ctx, productId); + return product?.defaultPaymentPlan; +} + +export async function GET( + req: NextRequest, + { params }: { params: Promise<{ productId: string }> }, +) { + const auth = await validatePublicApiRequest(req); + if (auth.error) { + return auth.error; + } + + const { productId } = await params; + + try { + const defaultPaymentPlan = await getDefaultPaymentPlan( + productId, + auth.ctx as any, + ); + const plans = await getPlansForEntity({ + entityId: productId, + entityType: Constants.MembershipEntityType.COURSE, + ctx: auth.ctx as any, + }); + + return NextResponse.json({ + data: plans.map((plan) => + serializePaymentPlan(plan, defaultPaymentPlan), + ), + }); + } catch (error: any) { + return publicApiError( + "unprocessable_entity", + error.message || "Unable to fetch payment plans", + 422, + ); + } +} + +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ productId: string }> }, +) { + const auth = await validatePublicApiRequestWithJsonBody(req); + if (auth.error) { + return auth.error; + } + + const { productId } = await params; + const body = auth.body; + const unsupportedField = getUnsupportedField(body); + if (unsupportedField) { + return publicApiError( + "bad_request", + `Unsupported payment plan field: ${unsupportedField}`, + 400, + ); + } + + try { + const plan = await createPlan({ + ...body, + entityId: productId, + entityType: Constants.MembershipEntityType.COURSE, + ctx: auth.ctx as any, + } as any); + const defaultPaymentPlan = await getDefaultPaymentPlan( + productId, + auth.ctx as any, + ); + + return NextResponse.json( + serializePaymentPlan(plan, defaultPaymentPlan), + { + status: 201, + }, + ); + } catch (error: any) { + return publicApiError( + "unprocessable_entity", + error.message || "Unable to create payment plan", + 422, + ); + } +} diff --git a/apps/web/app/api/products/[productId]/route.ts b/apps/web/app/api/products/[productId]/route.ts new file mode 100644 index 000000000..f60248817 --- /dev/null +++ b/apps/web/app/api/products/[productId]/route.ts @@ -0,0 +1,140 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + deleteCourse, + getCourseOrThrow, + updateCourse, +} from "@/graphql/courses/logic"; +import { + publicApiError, + validatePublicApiRequest, + validatePublicApiRequestWithJsonBody, +} from "@/app/api/public-api"; +import { fetchPaymentPlans, serializeProduct } from "../product-response"; + +const updateProductFields = new Set([ + "title", + "slug", + "description", + "published", + "privacy", + "tags", + "featuredImage", +]); + +function getUnsupportedField(body: Record) { + return Object.keys(body).find((key) => !updateProductFields.has(key)); +} + +export async function GET( + req: NextRequest, + { params }: { params: Promise<{ productId: string }> }, +) { + const auth = await validatePublicApiRequest(req); + if (auth.error) { + return auth.error; + } + + const { productId } = await params; + if (!productId) { + return publicApiError("bad_request", "Bad request", 400); + } + + try { + const product = await getCourseOrThrow( + undefined, + auth.ctx as any, + productId, + ); + + const plansByProductId = await fetchPaymentPlans( + [productId], + auth.domain, + ); + + return NextResponse.json( + serializeProduct(product as any, plansByProductId.get(productId)), + ); + } catch (error: any) { + return publicApiError( + "not_found", + error.message || "Product not found", + 404, + ); + } +} + +export async function PATCH( + req: NextRequest, + { params }: { params: Promise<{ productId: string }> }, +) { + const auth = await validatePublicApiRequestWithJsonBody(req); + if (auth.error) { + return auth.error; + } + + const { productId } = await params; + if (!productId) { + return publicApiError("bad_request", "Bad request", 400); + } + + const body = auth.body; + const unsupportedField = getUnsupportedField(body); + if (unsupportedField) { + return publicApiError( + "bad_request", + `Unsupported product field: ${unsupportedField}`, + 400, + ); + } + + try { + const product = await updateCourse( + { + id: productId, + ...body, + } as any, + auth.ctx as any, + ); + + const plansByProductId = await fetchPaymentPlans( + [productId], + auth.domain, + ); + + return NextResponse.json( + serializeProduct(product as any, plansByProductId.get(productId)), + ); + } catch (error: any) { + return publicApiError( + "unprocessable_entity", + error.message || "Unable to update product", + 422, + ); + } +} + +export async function DELETE( + req: NextRequest, + { params }: { params: Promise<{ productId: string }> }, +) { + const auth = await validatePublicApiRequest(req); + if (auth.error) { + return auth.error; + } + + const { productId } = await params; + if (!productId) { + return publicApiError("bad_request", "Bad request", 400); + } + + try { + await deleteCourse(productId, auth.ctx as any); + return NextResponse.json({ ok: true }); + } catch (error: any) { + return publicApiError( + "unprocessable_entity", + error.message || "Unable to delete product", + 422, + ); + } +} diff --git a/apps/web/app/api/products/[productId]/sections/[sectionId]/__tests__/route.test.ts b/apps/web/app/api/products/[productId]/sections/[sectionId]/__tests__/route.test.ts new file mode 100644 index 000000000..836a940d4 --- /dev/null +++ b/apps/web/app/api/products/[productId]/sections/[sectionId]/__tests__/route.test.ts @@ -0,0 +1,250 @@ +/** + * @jest-environment node + */ + +import { NextRequest } from "next/server"; +import Domain from "@models/Domain"; +import ApiKey from "@models/ApiKey"; +import User from "@models/User"; +import { removeGroup, updateGroup } from "@/graphql/courses/logic"; + +jest.mock("@models/Domain"); +jest.mock("@models/ApiKey"); +jest.mock("@models/User"); +jest.mock("@/graphql/courses/logic", () => ({ + removeGroup: jest.fn(), + updateGroup: jest.fn(), +})); + +const domain = { + _id: "domain-id", + name: "school", +}; + +const request = (body?: Record) => + ({ + url: "https://school.test/api/products/course-1/sections/group-1", + json: jest.fn().mockResolvedValue(body ?? {}), + headers: { + get: jest.fn((name: string) => { + if (name === "domain") return "school"; + if (name === "x-api-key") return "api-key"; + return null; + }), + }, + }) as unknown as NextRequest; + +const params = Promise.resolve({ productId: "course-1", sectionId: "group-1" }); + +describe("/api/products/{productId}/sections/{sectionId}", () => { + beforeEach(() => { + jest.clearAllMocks(); + (Domain.findOne as jest.Mock).mockResolvedValue(domain); + (ApiKey.findOne as jest.Mock).mockResolvedValue({ key: "api-key" }); + (User.findOne as jest.Mock).mockResolvedValue({ + userId: "owner", + email: "owner@example.com", + permissions: ["course:manage_any"], + }); + }); + + it("updates a section through existing course group logic", async () => { + (updateGroup as jest.Mock).mockResolvedValue({ + groups: [{ _id: "group-1", name: "Renamed", rank: 1000 }], + }); + + const { PATCH } = await import("../route"); + const response = await PATCH(request({ name: "Renamed" }), { + params, + }); + + expect(response.status).toBe(200); + expect(updateGroup).toHaveBeenCalledWith({ + id: "group-1", + courseId: "course-1", + name: "Renamed", + ctx: expect.objectContaining({ subdomain: domain }), + }); + await expect(response.json()).resolves.toMatchObject({ + sectionId: "group-1", + name: "Renamed", + }); + }); + + it("updates section drip settings through existing course group logic", async () => { + const drip = { + type: "relative-date", + status: true, + delayInMillis: 2, + }; + (updateGroup as jest.Mock).mockResolvedValue({ + groups: [ + { + _id: "group-1", + name: "Renamed", + rank: 1000, + drip, + }, + ], + }); + + const { PATCH } = await import("../route"); + const response = await PATCH(request({ drip }), { + params, + }); + + expect(response.status).toBe(200); + expect(updateGroup).toHaveBeenCalledWith({ + id: "group-1", + courseId: "course-1", + drip, + ctx: expect.objectContaining({ subdomain: domain }), + }); + await expect(response.json()).resolves.toMatchObject({ + sectionId: "group-1", + drip, + }); + }); + + it("returns bad request instead of 500 when section update JSON is invalid", async () => { + const { PATCH } = await import("../route"); + const response = await PATCH( + { + ...request(), + json: jest.fn().mockRejectedValue(new SyntaxError("bad json")), + } as unknown as NextRequest, + { params }, + ); + + expect(response.status).toBe(400); + expect(updateGroup).not.toHaveBeenCalled(); + await expect(response.json()).resolves.toEqual({ + error: { + code: "bad_request", + message: "Invalid JSON body", + }, + }); + }); + + it("returns bad request when section update body is not a JSON object", async () => { + const { PATCH } = await import("../route"); + const response = await PATCH( + { + ...request(), + json: jest.fn().mockResolvedValue("not an object"), + } as unknown as NextRequest, + { params }, + ); + + expect(response.status).toBe(400); + expect(updateGroup).not.toHaveBeenCalled(); + await expect(response.json()).resolves.toEqual({ + error: { + code: "bad_request", + message: "Request body must be a JSON object", + }, + }); + }); + + it("rejects section update fields that are not part of the existing edit-section form", async () => { + const { PATCH } = await import("../route"); + const response = await PATCH( + request({ + name: "Renamed", + rank: 1, + }), + { params }, + ); + + expect(response.status).toBe(400); + expect(updateGroup).not.toHaveBeenCalled(); + await expect(response.json()).resolves.toEqual({ + error: { + code: "bad_request", + message: "Unsupported section field: rank", + }, + }); + }); + + it("rejects unsupported section drip types before invoking existing group logic", async () => { + const { PATCH } = await import("../route"); + const response = await PATCH( + request({ + drip: { + type: "invalid-drip-type", + status: true, + }, + }), + { params }, + ); + + expect(response.status).toBe(400); + expect(updateGroup).not.toHaveBeenCalled(); + await expect(response.json()).resolves.toEqual({ + error: { + code: "bad_request", + message: "Unsupported drip type", + }, + }); + }); + + it("rejects drip email configuration because the email content schema is not public yet", async () => { + const { PATCH } = await import("../route"); + const response = await PATCH( + request({ + drip: { + type: "relative-date", + status: true, + delayInMillis: 2, + email: { + subject: "Lesson available", + content: "{}", + }, + }, + }), + { params }, + ); + + expect(response.status).toBe(400); + expect(updateGroup).not.toHaveBeenCalled(); + await expect(response.json()).resolves.toEqual({ + error: { + code: "bad_request", + message: + "Drip email configuration is not supported by the public API yet", + }, + }); + }); + + it("returns not found when section update does not match a product section", async () => { + (updateGroup as jest.Mock).mockResolvedValue(null); + + const { PATCH } = await import("../route"); + const response = await PATCH(request({ name: "Missing section" }), { + params, + }); + + expect(response.status).toBe(404); + await expect(response.json()).resolves.toEqual({ + error: { + code: "not_found", + message: "Section not found", + }, + }); + }); + + it("deletes a section through existing course group logic", async () => { + (removeGroup as jest.Mock).mockResolvedValue({}); + + const { DELETE } = await import("../route"); + const response = await DELETE(request(), { params }); + + expect(response.status).toBe(200); + expect(removeGroup).toHaveBeenCalledWith( + "group-1", + "course-1", + expect.objectContaining({ subdomain: domain }), + ); + await expect(response.json()).resolves.toEqual({ ok: true }); + }); +}); diff --git a/apps/web/app/api/products/[productId]/sections/[sectionId]/route.ts b/apps/web/app/api/products/[productId]/sections/[sectionId]/route.ts new file mode 100644 index 000000000..af82183bb --- /dev/null +++ b/apps/web/app/api/products/[productId]/sections/[sectionId]/route.ts @@ -0,0 +1,117 @@ +import { NextRequest, NextResponse } from "next/server"; +import { Constants } from "@courselit/common-models"; +import { removeGroup, updateGroup } from "@/graphql/courses/logic"; +import { + publicApiError, + validatePublicApiRequest, + validatePublicApiRequestWithJsonBody, +} from "@/app/api/public-api"; +import { serializeSection, serializeSections } from "../section-response"; + +const updateSectionFields = new Set(["name", "drip"]); + +function getUnsupportedField(body: Record) { + return Object.keys(body).find((key) => !updateSectionFields.has(key)); +} + +function getDripInputError(body: Record) { + if (!Object.prototype.hasOwnProperty.call(body, "drip")) { + return; + } + + if ( + !body.drip || + typeof body.drip !== "object" || + Array.isArray(body.drip) + ) { + return "Drip must be a JSON object"; + } + + const drip = body.drip as { type?: unknown }; + if ( + drip.type && + !Constants.dripType.includes( + drip.type as (typeof Constants.dripType)[number], + ) + ) { + return "Unsupported drip type"; + } + + if (Object.prototype.hasOwnProperty.call(drip, "email")) { + return "Drip email configuration is not supported by the public API yet"; + } +} + +export async function PATCH( + req: NextRequest, + { params }: { params: Promise<{ productId: string; sectionId: string }> }, +) { + const auth = await validatePublicApiRequestWithJsonBody(req); + if (auth.error) { + return auth.error; + } + + const { productId, sectionId } = await params; + const body = auth.body; + const unsupportedField = getUnsupportedField(body); + if (unsupportedField) { + return publicApiError( + "bad_request", + `Unsupported section field: ${unsupportedField}`, + 400, + ); + } + const dripInputError = getDripInputError(body); + if (dripInputError) { + return publicApiError("bad_request", dripInputError, 400); + } + + try { + const product = await updateGroup({ + id: sectionId, + courseId: productId, + ...body, + ctx: auth.ctx as any, + } as any); + if (!product) { + return publicApiError("not_found", "Section not found", 404); + } + + const section = serializeSections((product as any).groups).find( + (section) => section.sectionId === sectionId, + ); + + return NextResponse.json( + section ?? serializeSection({ id: sectionId }), + ); + } catch (error: any) { + return publicApiError( + "unprocessable_entity", + error.message || "Unable to update section", + 422, + ); + } +} + +export async function DELETE( + req: NextRequest, + { params }: { params: Promise<{ productId: string; sectionId: string }> }, +) { + const auth = await validatePublicApiRequest(req); + if (auth.error) { + return auth.error; + } + + const { productId, sectionId } = await params; + + try { + await removeGroup(sectionId, productId, auth.ctx as any); + return NextResponse.json({ ok: true }); + } catch (error: any) { + return publicApiError( + "unprocessable_entity", + error.message || "Unable to delete section", + 422, + ); + } +} diff --git a/apps/web/app/api/products/[productId]/sections/__tests__/route.test.ts b/apps/web/app/api/products/[productId]/sections/__tests__/route.test.ts new file mode 100644 index 000000000..1ab08a81b --- /dev/null +++ b/apps/web/app/api/products/[productId]/sections/__tests__/route.test.ts @@ -0,0 +1,215 @@ +/** + * @jest-environment node + */ + +import { NextRequest } from "next/server"; +import Domain from "@models/Domain"; +import ApiKey from "@models/ApiKey"; +import User from "@models/User"; +import { + addGroup, + getCourseOrThrow, + reorderGroups, +} from "@/graphql/courses/logic"; + +jest.mock("@models/Domain"); +jest.mock("@models/ApiKey"); +jest.mock("@models/User"); +jest.mock("@/graphql/courses/logic", () => ({ + addGroup: jest.fn(), + getCourseOrThrow: jest.fn(), + reorderGroups: jest.fn(), +})); + +const domain = { + _id: "domain-id", + name: "school", +}; + +const request = (body?: Record) => + ({ + url: "https://school.test/api/products/course-1/sections", + json: jest.fn().mockResolvedValue(body ?? {}), + headers: { + get: jest.fn((name: string) => { + if (name === "domain") return "school"; + if (name === "x-api-key") return "api-key"; + return null; + }), + }, + }) as unknown as NextRequest; + +describe("/api/products/{productId}/sections", () => { + beforeEach(() => { + jest.clearAllMocks(); + (Domain.findOne as jest.Mock).mockResolvedValue(domain); + (ApiKey.findOne as jest.Mock).mockResolvedValue({ key: "api-key" }); + (User.findOne as jest.Mock).mockResolvedValue({ + userId: "owner", + email: "owner@example.com", + permissions: ["course:manage_any"], + }); + }); + + it("lists sections from the existing product model", async () => { + (getCourseOrThrow as jest.Mock).mockResolvedValue({ + courseId: "course-1", + groups: [ + { + _id: "group-1", + id: "group-1", + name: "Start", + rank: 1000, + collapsed: false, + lessonsOrder: ["lesson-1"], + }, + ], + }); + + const { GET } = await import("../route"); + const response = await GET(request(), { + params: Promise.resolve({ productId: "course-1" }), + }); + + expect(response.status).toBe(200); + expect(getCourseOrThrow).toHaveBeenCalledWith( + undefined, + expect.objectContaining({ subdomain: domain }), + "course-1", + ); + await expect(response.json()).resolves.toEqual({ + data: [ + { + sectionId: "group-1", + name: "Start", + rank: 1000, + collapsed: false, + lessonsOrder: ["lesson-1"], + }, + ], + }); + }); + + it("creates a section through existing course group logic", async () => { + (addGroup as jest.Mock).mockResolvedValue({ + groups: [{ _id: "group-1", name: "Start", rank: 1000 }], + }); + + const { POST } = await import("../route"); + const response = await POST(request({ name: "Start" }), { + params: Promise.resolve({ productId: "course-1" }), + }); + + expect(response.status).toBe(201); + expect(addGroup).toHaveBeenCalledWith({ + id: "course-1", + name: "Start", + collapsed: false, + ctx: expect.objectContaining({ subdomain: domain }), + }); + await expect(response.json()).resolves.toMatchObject({ + sectionId: "group-1", + name: "Start", + }); + }); + + it("returns bad request instead of 500 when section create JSON is invalid", async () => { + const { POST } = await import("../route"); + const response = await POST( + { + ...request(), + json: jest.fn().mockRejectedValue(new SyntaxError("bad json")), + } as unknown as NextRequest, + { + params: Promise.resolve({ productId: "course-1" }), + }, + ); + + expect(response.status).toBe(400); + expect(addGroup).not.toHaveBeenCalled(); + await expect(response.json()).resolves.toEqual({ + error: { + code: "bad_request", + message: "Invalid JSON body", + }, + }); + }); + + it("returns not found when section creation does not match a product", async () => { + (addGroup as jest.Mock).mockResolvedValue(null); + + const { POST } = await import("../route"); + const response = await POST(request({ name: "Missing product" }), { + params: Promise.resolve({ productId: "course-1" }), + }); + + expect(response.status).toBe(404); + await expect(response.json()).resolves.toEqual({ + error: { + code: "not_found", + message: "Product not found", + }, + }); + }); + + it("rejects section creation fields that are not part of the existing new-section form", async () => { + const { POST } = await import("../route"); + const response = await POST( + request({ + name: "Start", + collapsed: true, + }), + { + params: Promise.resolve({ productId: "course-1" }), + }, + ); + + expect(response.status).toBe(400); + expect(addGroup).not.toHaveBeenCalled(); + await expect(response.json()).resolves.toEqual({ + error: { + code: "bad_request", + message: "Unsupported section field: collapsed", + }, + }); + }); + + it("reorders sections through existing course group logic", async () => { + const { POST } = await import("../reorder/route"); + const response = await POST( + request({ sectionIds: ["group-2", "group-1"] }), + { + params: Promise.resolve({ productId: "course-1" }), + }, + ); + + expect(response.status).toBe(200); + expect(reorderGroups).toHaveBeenCalledWith({ + courseId: "course-1", + groupIds: ["group-2", "group-1"], + ctx: expect.objectContaining({ subdomain: domain }), + }); + }); + + it("returns bad request instead of 500 when section reorder JSON is invalid", async () => { + const { POST } = await import("../reorder/route"); + const response = await POST( + { + ...request(), + json: jest.fn().mockRejectedValue(new SyntaxError("bad json")), + } as unknown as NextRequest, + { + params: Promise.resolve({ productId: "course-1" }), + }, + ); + + expect(response.status).toBe(400); + expect(reorderGroups).not.toHaveBeenCalled(); + await expect(response.json()).resolves.toEqual({ + error: { + code: "bad_request", + message: "Invalid JSON body", + }, + }); + }); +}); diff --git a/apps/web/app/api/products/[productId]/sections/reorder/__tests__/route.test.ts b/apps/web/app/api/products/[productId]/sections/reorder/__tests__/route.test.ts new file mode 100644 index 000000000..82732baf1 --- /dev/null +++ b/apps/web/app/api/products/[productId]/sections/reorder/__tests__/route.test.ts @@ -0,0 +1,84 @@ +/** + * @jest-environment node + */ + +import { NextRequest } from "next/server"; +import Domain from "@models/Domain"; +import ApiKey from "@models/ApiKey"; +import User from "@models/User"; +import { reorderGroups } from "@/graphql/courses/logic"; + +jest.mock("@models/Domain"); +jest.mock("@models/ApiKey"); +jest.mock("@models/User"); +jest.mock("@/graphql/courses/logic", () => ({ + reorderGroups: jest.fn(), +})); + +const domain = { + _id: "domain-id", + name: "school", +}; + +const request = (body?: Record) => + ({ + url: "https://school.test/api/products/course-1/sections/reorder", + json: jest.fn().mockResolvedValue(body ?? {}), + headers: { + get: jest.fn((name: string) => { + if (name === "domain") return "school"; + if (name === "x-api-key") return "api-key"; + return null; + }), + }, + }) as unknown as NextRequest; + +describe("POST /api/products/{productId}/sections/reorder", () => { + beforeEach(() => { + jest.clearAllMocks(); + (Domain.findOne as jest.Mock).mockResolvedValue(domain); + (ApiKey.findOne as jest.Mock).mockResolvedValue({ key: "api-key" }); + (User.findOne as jest.Mock).mockResolvedValue({ + userId: "owner", + email: "owner@example.com", + permissions: ["course:manage_any"], + }); + }); + + it("reorders sections through existing course group logic", async () => { + (reorderGroups as jest.Mock).mockResolvedValue({ ok: true }); + + const { POST } = await import("../route"); + const response = await POST( + request({ sectionIds: ["group-2", "group-1"] }), + { params: Promise.resolve({ productId: "course-1" }) }, + ); + + expect(response.status).toBe(200); + expect(reorderGroups).toHaveBeenCalledWith({ + courseId: "course-1", + groupIds: ["group-2", "group-1"], + ctx: expect.objectContaining({ subdomain: domain }), + }); + }); + + it("returns bad request instead of 500 when section reorder JSON is invalid", async () => { + const { POST } = await import("../route"); + const response = await POST( + { + ...request(), + json: jest.fn().mockRejectedValue(new SyntaxError("bad json")), + } as unknown as NextRequest, + { params: Promise.resolve({ productId: "course-1" }) }, + ); + + expect(response.status).toBe(400); + expect(reorderGroups).not.toHaveBeenCalled(); + await expect(response.json()).resolves.toEqual({ + error: { + code: "bad_request", + message: "Invalid JSON body", + }, + }); + }); +}); diff --git a/apps/web/app/api/products/[productId]/sections/reorder/route.ts b/apps/web/app/api/products/[productId]/sections/reorder/route.ts new file mode 100644 index 000000000..d7473161c --- /dev/null +++ b/apps/web/app/api/products/[productId]/sections/reorder/route.ts @@ -0,0 +1,35 @@ +import { NextRequest, NextResponse } from "next/server"; +import { reorderGroups } from "@/graphql/courses/logic"; +import { + publicApiError, + validatePublicApiRequestWithJsonBody, +} from "@/app/api/public-api"; + +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ productId: string }> }, +) { + const auth = await validatePublicApiRequestWithJsonBody(req); + if (auth.error) { + return auth.error; + } + + const { productId } = await params; + const body = auth.body as { sectionIds?: string[] }; + + try { + const product = await reorderGroups({ + courseId: productId, + groupIds: body.sectionIds || [], + ctx: auth.ctx as any, + }); + + return NextResponse.json(product ?? { ok: true }); + } catch (error: any) { + return publicApiError( + "unprocessable_entity", + error.message || "Unable to reorder sections", + 422, + ); + } +} diff --git a/apps/web/app/api/products/[productId]/sections/route.ts b/apps/web/app/api/products/[productId]/sections/route.ts new file mode 100644 index 000000000..d2ccd19ae --- /dev/null +++ b/apps/web/app/api/products/[productId]/sections/route.ts @@ -0,0 +1,83 @@ +import { NextRequest, NextResponse } from "next/server"; +import { addGroup, getCourseOrThrow } from "@/graphql/courses/logic"; +import { + publicApiError, + validatePublicApiRequest, + validatePublicApiRequestWithJsonBody, +} from "@/app/api/public-api"; +import { serializeSections } from "./section-response"; + +const createSectionFields = new Set(["name"]); + +function getUnsupportedField(body: Record) { + return Object.keys(body).find((key) => !createSectionFields.has(key)); +} + +export async function GET( + req: NextRequest, + { params }: { params: Promise<{ productId: string }> }, +) { + const auth = await validatePublicApiRequest(req); + if (auth.error) { + return auth.error; + } + + const { productId } = await params; + + try { + const product = await getCourseOrThrow( + undefined, + auth.ctx as any, + productId, + ); + + return NextResponse.json({ data: serializeSections(product.groups) }); + } catch (error) { + return publicApiError("not_found", "Product not found", 404); + } +} + +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ productId: string }> }, +) { + const auth = await validatePublicApiRequestWithJsonBody(req); + if (auth.error) { + return auth.error; + } + + const { productId } = await params; + const body = auth.body; + const unsupportedField = getUnsupportedField(body); + if (unsupportedField) { + return publicApiError( + "bad_request", + `Unsupported section field: ${unsupportedField}`, + 400, + ); + } + + try { + const product = await addGroup({ + id: productId, + name: body.name as string, + collapsed: false, + ctx: auth.ctx as any, + }); + if (!product) { + return publicApiError("not_found", "Product not found", 404); + } + + const sections = serializeSections((product as any).groups); + + return NextResponse.json(sections[sections.length - 1], { + status: 201, + }); + } catch (error: any) { + return publicApiError( + "unprocessable_entity", + error.message || "Unable to create section", + 422, + ); + } +} diff --git a/apps/web/app/api/products/[productId]/sections/section-response.ts b/apps/web/app/api/products/[productId]/sections/section-response.ts new file mode 100644 index 000000000..169b94211 --- /dev/null +++ b/apps/web/app/api/products/[productId]/sections/section-response.ts @@ -0,0 +1,28 @@ +type SectionDocument = { + id?: string; + _id?: unknown; + name?: string; + rank?: number; + collapsed?: boolean; + lessonsOrder?: string[]; + drip?: unknown; +}; + +export function serializeSection(section: SectionDocument) { + return { + sectionId: + section.id ?? + ((section._id as any)?.toString + ? (section._id as any).toString() + : section._id), + name: section.name, + rank: section.rank, + collapsed: section.collapsed, + lessonsOrder: section.lessonsOrder, + drip: section.drip, + }; +} + +export function serializeSections(sections: SectionDocument[] = []) { + return sections.map(serializeSection); +} diff --git a/apps/web/app/api/products/__tests__/openapi.test.ts b/apps/web/app/api/products/__tests__/openapi.test.ts new file mode 100644 index 000000000..fe80d07c4 --- /dev/null +++ b/apps/web/app/api/products/__tests__/openapi.test.ts @@ -0,0 +1,301 @@ +/** + * @jest-environment node + */ + +import { execFileSync } from "child_process"; +import path from "path"; +import { pathToFileURL } from "url"; + +function buildOpenApiRoutesForTest() { + const openApiIndex = pathToFileURL( + path.resolve(process.cwd(), "apps/web/openapi/index.mjs"), + ).href; + const output = execFileSync( + process.execPath, + [ + "--input-type=module", + "-e", + `import { buildOpenApiRoutes } from ${JSON.stringify(openApiIndex)}; console.log(JSON.stringify(buildOpenApiRoutes()));`, + ], + { encoding: "utf8" }, + ); + return JSON.parse(output); +} + +describe("Products OpenAPI", () => { + it("documents product list and detail routes without legacy pricing or non-actionable fields", () => { + const routes = buildOpenApiRoutesForTest(); + + expect(routes.paths["/api/products"].get).toMatchObject({ + tags: ["Products"], + operationId: "listProducts", + }); + expect(routes.paths["/api/products/{productId}"].get).toMatchObject({ + tags: ["Products"], + operationId: "getProduct", + }); + const productProperties = routes.components.schemas.Product.properties; + + expect(productProperties.productId).toBeDefined(); + expect(productProperties.paymentPlans).toBeDefined(); + expect(productProperties.cost).toBeUndefined(); + expect(productProperties.costType).toBeUndefined(); + expect(productProperties.leadMagnet).toBeUndefined(); + expect(productProperties.certificate).toBeUndefined(); + }); + + it("documents a concrete product create request example", () => { + const routes = buildOpenApiRoutesForTest(); + const createBody = + routes.paths["/api/products"].post.requestBody.content[ + "application/json" + ]; + const productCreateSchema = + routes.components.schemas.ProductCreateRequest; + const productUpdateSchema = + routes.components.schemas.ProductUpdateRequest; + const updateBody = + routes.paths["/api/products/{productId}"].patch.requestBody.content[ + "application/json" + ]; + + expect(createBody.example).toMatchObject({ + title: "AI Foundations", + type: "course", + }); + expect(createBody.example.slug).toBeUndefined(); + expect(createBody.example.description).toBeUndefined(); + expect(createBody.example.tags).toBeUndefined(); + expect(createBody.example.published).toBeUndefined(); + expect(createBody.example.privacy).toBeUndefined(); + expect(productCreateSchema.required).toEqual(["title", "type"]); + expect(productCreateSchema.properties.title.description).toContain( + "Product title", + ); + expect(productCreateSchema.properties.slug).toBeUndefined(); + expect(productCreateSchema.properties.description).toBeUndefined(); + expect(productCreateSchema.properties.tags).toBeUndefined(); + expect(productCreateSchema.properties.featuredImage).toBeUndefined(); + expect(productCreateSchema.properties.published).toBeUndefined(); + expect(productCreateSchema.properties.privacy).toBeUndefined(); + expect(productUpdateSchema.properties.published).toBeDefined(); + expect(productUpdateSchema.properties.privacy).toBeDefined(); + expect( + productUpdateSchema.properties.description.description, + ).toContain("JSON-stringified Tiptap"); + expect( + JSON.parse(productUpdateSchema.properties.description.example), + ).toMatchObject({ type: "doc" }); + expect(JSON.parse(updateBody.example.description)).toMatchObject({ + type: "doc", + }); + }); + + it("documents payment-plan, content, customer, and progress endpoints", () => { + const routes = buildOpenApiRoutesForTest(); + + expect( + routes.paths["/api/products/{productId}/payment-plans"].post, + ).toMatchObject({ + tags: ["Product Payment Plans"], + operationId: "createProductPaymentPlan", + }); + const paymentPlanCreateBody = + routes.paths["/api/products/{productId}/payment-plans"].post + .requestBody.content["application/json"]; + const paymentPlanUpdateBody = + routes.paths["/api/products/{productId}/payment-plans/{planId}"] + .patch.requestBody.content["application/json"]; + expect(paymentPlanCreateBody.schema.$ref).toBe( + "#/components/schemas/PaymentPlanCreateRequest", + ); + expect(paymentPlanCreateBody.example).toMatchObject({ + name: "Lifetime access", + type: "onetime", + oneTimeAmount: 9900, + }); + expect(paymentPlanUpdateBody.schema.$ref).toBe( + "#/components/schemas/PaymentPlanUpdateRequest", + ); + expect(paymentPlanUpdateBody.example).toMatchObject({ + name: "Updated lifetime access", + oneTimeAmount: 12900, + }); + expect( + routes.components.schemas.PaymentPlanCreateRequest.required, + ).toEqual(["name", "type"]); + expect( + routes.components.schemas.PaymentPlanCreateRequest.properties + .oneTimeAmount.description, + ).toContain("onetime"); + expect( + routes.components.schemas.PaymentPlanUpdateRequest.required, + ).toBeUndefined(); + expect( + routes.paths["/api/products/{productId}/sections"].post, + ).toBeDefined(); + const sectionCreateBody = + routes.paths["/api/products/{productId}/sections"].post.requestBody + .content["application/json"]; + const sectionUpdateBody = + routes.paths["/api/products/{productId}/sections/{sectionId}"].patch + .requestBody.content["application/json"]; + expect(sectionCreateBody.schema.$ref).toBe( + "#/components/schemas/SectionCreateRequest", + ); + expect(sectionCreateBody.example).toEqual({ + name: "Getting started", + }); + expect(sectionUpdateBody.schema.$ref).toBe( + "#/components/schemas/SectionUpdateRequest", + ); + expect(sectionUpdateBody.example).toEqual({ + name: "Updated getting started", + drip: { + status: true, + type: "relative-date", + delayInMillis: 2, + }, + }); + expect(routes.components.schemas.SectionCreateRequest.required).toEqual( + ["name"], + ); + expect( + routes.components.schemas.SectionCreateRequest.properties.collapsed, + ).toBeUndefined(); + expect( + routes.components.schemas.SectionUpdateRequest.properties.collapsed, + ).toBeUndefined(); + expect( + routes.components.schemas.SectionUpdateRequest.properties.rank, + ).toBeUndefined(); + expect( + routes.components.schemas.SectionUpdateRequest.properties.drip.$ref, + ).toBe("#/components/schemas/SectionDripInput"); + expect( + routes.components.schemas.SectionDripInput.properties.type.enum, + ).toEqual(["relative-date", "exact-date"]); + expect( + routes.paths["/api/products/{productId}/lessons"].post, + ).toMatchObject({ + tags: ["Product Content"], + operationId: "createProductLesson", + }); + expect( + routes.paths["/api/products/{productId}/customers"].post, + ).toBeUndefined(); + expect( + routes.paths["/api/products/{productId}/customers/invitations"] + .post, + ).toMatchObject({ + tags: ["Product Customers"], + operationId: "inviteProductCustomer", + }); + expect( + routes.paths[ + "/api/products/{productId}/customers/{userId}/progress" + ].get, + ).toMatchObject({ + tags: ["Product Customers"], + operationId: "getProductCustomerProgress", + }); + expect( + routes.paths["/api/products/{productId}/customers/{userId}"], + ).toBeUndefined(); + + const lessonType = + routes.components.schemas.Lesson.properties.type.enum; + expect(lessonType).toContain("text"); + expect(lessonType).not.toContain("scorm"); + expect( + routes.paths["/api/products/{productId}/lessons"].post.requestBody + .content["application/json"].schema.$ref, + ).toBe("#/components/schemas/LessonCreateRequest"); + expect( + routes.paths["/api/products/{productId}/lessons/{lessonId}"].patch + .requestBody.content["application/json"].schema.$ref, + ).toBe("#/components/schemas/LessonUpdateRequest"); + expect( + routes.components.schemas.LessonUpdateRequest.properties.type, + ).toBeUndefined(); + expect( + routes.components.schemas.LessonUpdateRequest.properties.groupId, + ).toBeUndefined(); + expect( + routes.components.schemas.LessonCreateRequest.properties.content + .oneOf, + ).toEqual( + expect.arrayContaining([ + { $ref: "#/components/schemas/TiptapDocument" }, + { $ref: "#/components/schemas/EmbedContent" }, + { $ref: "#/components/schemas/QuizContent" }, + ]), + ); + expect( + routes.components.schemas.LessonUpdateRequest.properties.content + .oneOf, + ).toEqual( + expect.arrayContaining([ + { $ref: "#/components/schemas/TiptapDocument" }, + { $ref: "#/components/schemas/EmbedContent" }, + { $ref: "#/components/schemas/QuizContent" }, + ]), + ); + expect(routes.components.schemas.EmbedContent).toMatchObject({ + required: ["value"], + properties: { + value: expect.objectContaining({ type: "string" }), + }, + }); + expect( + routes.components.schemas.LessonCreateRequest.properties.media.$ref, + ).toBe("#/components/schemas/LessonMedia"); + expect( + routes.components.schemas.LessonUpdateRequest.properties.media.$ref, + ).toBe("#/components/schemas/LessonMedia"); + expect(routes.components.schemas.LessonMedia.required).toEqual([ + "mediaId", + ]); + expect( + routes.paths["/api/products/{productId}/customers"].get.parameters, + ).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: "search", in: "query" }), + ]), + ); + expect( + routes.paths["/api/products/{productId}/customers"].get.parameters, + ).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: "status", in: "query" }), + ]), + ); + expect( + routes.components.schemas.CustomerListResponse.properties.data.items + .$ref, + ).toBe("#/components/schemas/ProductCustomer"); + expect( + routes.components.schemas.ProductCustomer.properties.user.properties + .email, + ).toMatchObject({ + type: "string", + }); + expect( + routes.components.schemas.ProductCustomer.properties + .completedLessons, + ).toMatchObject({ + type: "array", + items: { type: "string" }, + }); + expect( + routes.components.schemas.ProductCustomer.properties.downloaded, + ).toMatchObject({ + type: "boolean", + }); + expect( + routes.paths["/api/products/{productId}/lessons"].post.responses[ + "422" + ].description, + ).toContain("SCORM"); + }); +}); diff --git a/apps/web/app/api/products/__tests__/route.test.ts b/apps/web/app/api/products/__tests__/route.test.ts new file mode 100644 index 000000000..ad1f11d75 --- /dev/null +++ b/apps/web/app/api/products/__tests__/route.test.ts @@ -0,0 +1,333 @@ +/** + * @jest-environment node + */ + +import { NextRequest } from "next/server"; +import Domain from "@models/Domain"; +import ApiKey from "@models/ApiKey"; +import User from "@models/User"; +import PaymentPlanModel from "@models/PaymentPlan"; +import { createCourse, getProducts } from "@/graphql/courses/logic"; + +jest.mock("@models/Domain"); +jest.mock("@models/ApiKey"); +jest.mock("@models/User"); +jest.mock("@/graphql/courses/logic", () => ({ + createCourse: jest.fn(), + getProducts: jest.fn(), +})); +jest.mock("@models/PaymentPlan"); + +const domain = { + _id: "domain-id", + name: "school", + email: "owner@example.com", +}; + +const makeRequest = ( + url = "https://school.test/api/products", + body?: Record, +) => + ({ + url, + json: jest.fn().mockResolvedValue(body ?? {}), + headers: { + get: jest.fn((name: string) => { + if (name === "domain") return "school"; + if (name === "x-api-key") return "api-key"; + return null; + }), + }, + }) as unknown as NextRequest; + +describe("GET /api/products", () => { + beforeEach(() => { + jest.clearAllMocks(); + (Domain.findOne as jest.Mock).mockResolvedValue(domain); + (ApiKey.findOne as jest.Mock).mockResolvedValue({ key: "api-key" }); + (User.findOne as jest.Mock).mockResolvedValue({ + userId: "owner", + email: "owner@example.com", + permissions: ["course:manage_any", "course:publish"], + }); + (PaymentPlanModel.find as jest.Mock).mockReturnValue({ + lean: jest.fn().mockResolvedValue([ + { + planId: "plan-free", + name: "Free", + type: "free", + entityId: "course-1", + entityType: "course", + }, + ]), + }); + }); + + it("creates a draft product through existing product logic", async () => { + (createCourse as jest.Mock).mockResolvedValue({ + courseId: "course-2", + type: "course", + title: "Course Two", + slug: "course-two", + published: false, + privacy: "unlisted", + tags: [], + createdAt: new Date("2026-01-01T00:00:00.000Z"), + updatedAt: new Date("2026-01-01T00:00:00.000Z"), + }); + + const { POST } = await import("../route"); + const response = await POST( + makeRequest("https://school.test/api/products", { + title: "Course Two", + type: "course", + }), + ); + + expect(response.status).toBe(201); + expect(createCourse).toHaveBeenCalledWith( + { title: "Course Two", type: "course" }, + expect.objectContaining({ + subdomain: domain, + user: expect.objectContaining({ userId: "owner" }), + }), + ); + const body = await response.json(); + expect(body).toMatchObject({ + productId: "course-2", + title: "Course Two", + type: "course", + }); + expect(body).not.toHaveProperty("leadMagnet"); + expect(body).not.toHaveProperty("certificate"); + expect(body).not.toHaveProperty("cost"); + expect(body).not.toHaveProperty("costType"); + }); + + it("rejects product create fields that are not part of the public API contract", async () => { + const { POST } = await import("../route"); + const response = await POST( + makeRequest("https://school.test/api/products", { + title: "Course Two", + type: "course", + leadMagnet: true, + }), + ); + + expect(response.status).toBe(400); + expect(createCourse).not.toHaveBeenCalled(); + await expect(response.json()).resolves.toEqual({ + error: { + code: "bad_request", + message: "Unsupported product field: leadMagnet", + }, + }); + }); + + it("rejects bodies exceeding the public API body size limit", async () => { + const { POST } = await import("../route"); + const response = await POST({ + url: "https://school.test/api/products", + json: jest.fn(), + headers: { + get: jest.fn((name: string) => { + if (name === "domain") return "school"; + if (name === "x-api-key") return "api-key"; + if (name === "content-length") return "2097152"; + return null; + }), + }, + } as unknown as NextRequest); + + expect(response.status).toBe(413); + expect(createCourse).not.toHaveBeenCalled(); + await expect(response.json()).resolves.toEqual({ + error: { + code: "bad_request", + message: "Request body too large", + }, + }); + }); + + it("returns bad request instead of 500 when product create JSON is invalid", async () => { + const { POST } = await import("../route"); + const response = await POST({ + ...makeRequest("https://school.test/api/products"), + json: jest.fn().mockRejectedValue(new SyntaxError("bad json")), + } as unknown as NextRequest); + + expect(response.status).toBe(400); + expect(createCourse).not.toHaveBeenCalled(); + await expect(response.json()).resolves.toEqual({ + error: { + code: "bad_request", + message: "Invalid JSON body", + }, + }); + }); + + it("rejects publishing and privacy fields during draft product creation", async () => { + const { POST } = await import("../route"); + const response = await POST( + makeRequest("https://school.test/api/products", { + title: "Course Two", + type: "course", + published: false, + }), + ); + + expect(response.status).toBe(400); + expect(createCourse).not.toHaveBeenCalled(); + await expect(response.json()).resolves.toEqual({ + error: { + code: "bad_request", + message: "Unsupported product field: published", + }, + }); + + const privacyResponse = await POST( + makeRequest("https://school.test/api/products", { + title: "Course Two", + type: "course", + privacy: "public", + }), + ); + + expect(privacyResponse.status).toBe(400); + expect(createCourse).not.toHaveBeenCalled(); + await expect(privacyResponse.json()).resolves.toEqual({ + error: { + code: "bad_request", + message: "Unsupported product field: privacy", + }, + }); + }); + + it("rejects product metadata fields that are only editable after draft creation", async () => { + const { POST } = await import("../route"); + const response = await POST( + makeRequest("https://school.test/api/products", { + title: "Course Two", + type: "course", + slug: "course-two", + }), + ); + + expect(response.status).toBe(400); + expect(createCourse).not.toHaveBeenCalled(); + await expect(response.json()).resolves.toEqual({ + error: { + code: "bad_request", + message: "Unsupported product field: slug", + }, + }); + }); + + it("lists products for the authenticated school without exposing non-public fields", async () => { + (getProducts as jest.Mock).mockResolvedValue([ + { + courseId: "course-1", + type: "course", + title: "Course One", + slug: "course-one", + description: "Learn things", + published: false, + privacy: "unlisted", + tags: ["ai"], + featuredImage: { mediaId: "media-1" }, + pageId: "course-one", + defaultPaymentPlan: "plan-free", + cost: 99, + costType: "paid", + leadMagnet: true, + certificate: true, + createdAt: new Date("2026-01-01T00:00:00.000Z"), + updatedAt: new Date("2026-01-02T00:00:00.000Z"), + }, + ]); + + const { GET } = await import("../route"); + const response = await GET(makeRequest()); + + expect(response.status).toBe(200); + expect(User.findOne).toHaveBeenCalledWith({ + domain: domain._id, + email: domain.email, + }); + expect(getProducts).toHaveBeenCalledWith({ + ctx: expect.objectContaining({ + subdomain: domain, + user: expect.objectContaining({ userId: "owner" }), + }), + page: 1, + limit: 50, + filterBy: undefined, + published: undefined, + searchText: undefined, + }); + + const body = await response.json(); + expect(body.data).toEqual([ + { + productId: "course-1", + type: "course", + title: "Course One", + slug: "course-one", + description: "Learn things", + published: false, + privacy: "unlisted", + tags: ["ai"], + featuredImage: { mediaId: "media-1" }, + pageId: "course-one", + defaultPaymentPlan: "plan-free", + paymentPlans: [ + { + planId: "plan-free", + name: "Free", + type: "free", + entityId: "course-1", + entityType: "course", + isDefault: true, + }, + ], + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-02T00:00:00.000Z", + }, + ]); + expect(body.data[0]).not.toHaveProperty("cost"); + expect(body.data[0]).not.toHaveProperty("costType"); + expect(body.data[0]).not.toHaveProperty("leadMagnet"); + expect(body.data[0]).not.toHaveProperty("certificate"); + }); + + it("returns a structured authentication error when the API key is invalid", async () => { + (ApiKey.findOne as jest.Mock).mockResolvedValue(null); + + const { GET } = await import("../route"); + const response = await GET(makeRequest()); + + expect(response.status).toBe(401); + await expect(response.json()).resolves.toEqual({ + error: { + code: "unauthorized", + message: "Unauthorized", + }, + }); + }); + + it("rejects API keys when the school owner cannot be resolved", async () => { + (User.findOne as jest.Mock).mockResolvedValue(null); + + const { GET } = await import("../route"); + const response = await GET(makeRequest()); + + expect(response.status).toBe(403); + expect(getProducts).not.toHaveBeenCalled(); + await expect(response.json()).resolves.toEqual({ + error: { + code: "forbidden", + message: "API key cannot be mapped to a school owner", + }, + }); + }); +}); diff --git a/apps/web/app/api/products/openapi.mjs b/apps/web/app/api/products/openapi.mjs new file mode 100644 index 000000000..e9abf3d1d --- /dev/null +++ b/apps/web/app/api/products/openapi.mjs @@ -0,0 +1,1181 @@ +const errorResponse = { + type: "object", + properties: { + error: { + type: "object", + properties: { + code: { type: "string" }, + message: { type: "string" }, + }, + required: ["code", "message"], + }, + }, + required: ["error"], +}; + +const productIdParam = { + name: "productId", + in: "path", + required: true, + schema: { type: "string" }, +}; + +const planIdParam = { + name: "planId", + in: "path", + required: true, + schema: { type: "string" }, +}; + +const sectionIdParam = { + name: "sectionId", + in: "path", + required: true, + schema: { type: "string" }, +}; + +const lessonIdParam = { + name: "lessonId", + in: "path", + required: true, + schema: { type: "string" }, +}; + +const userIdParam = { + name: "userId", + in: "path", + required: true, + schema: { type: "string" }, +}; + +function jsonBody(schema, example) { + return { + required: true, + content: { + "application/json": { + schema, + ...(example ? { example } : {}), + }, + }, + }; +} + +function jsonResponse(schemaRef, description = "Success.") { + return { + description, + content: { + "application/json": { + schema: + typeof schemaRef === "string" + ? { $ref: schemaRef } + : schemaRef, + }, + }, + }; +} + +function error(statusDescription) { + return { + description: statusDescription, + content: { + "application/json": { + schema: { $ref: "#/components/schemas/PublicApiErrorResponse" }, + }, + }, + }; +} + +const secured = [{ ApiKeyAuth: [] }]; + +const supportedLessonTypes = [ + "text", + "video", + "audio", + "pdf", + "file", + "embed", + "quiz", +]; + +const lessonContentSchema = { + oneOf: [ + { $ref: "#/components/schemas/TiptapDocument" }, + { $ref: "#/components/schemas/EmbedContent" }, + { $ref: "#/components/schemas/QuizContent" }, + ], + description: + "`text` lessons use `TiptapDocument`; `embed` lessons use `EmbedContent`; `quiz` lessons use `QuizContent`. Media-backed lessons (`video`, `audio`, `pdf`, `file`) use `media` instead of `content`.", +}; + +const productCreateExample = { + title: "AI Foundations", + type: "course", +}; + +const productUpdateExample = { + title: "AI Foundations", + slug: "ai-foundations", + description: JSON.stringify({ + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { + type: "text", + text: "Updated course description.", + }, + ], + }, + ], + }), + published: false, + privacy: "unlisted", + tags: ["ai", "beginner"], +}; + +const paymentPlanCreateExample = { + name: "Lifetime access", + type: "onetime", + oneTimeAmount: 9900, + description: "One-time payment for lifetime product access.", +}; + +const paymentPlanUpdateExample = { + name: "Updated lifetime access", + oneTimeAmount: 12900, + description: "Updated one-time payment plan.", +}; + +const sectionCreateExample = { + name: "Getting started", +}; + +const sectionUpdateExample = { + name: "Updated getting started", + drip: { + status: true, + type: "relative-date", + delayInMillis: 2, + }, +}; + +const lessonCreateExample = { + title: "Introduction to AI", + type: "text", + groupId: "section_abc123", + content: { + type: "doc", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Welcome to the course!" }], + }, + ], + }, + requiresEnrollment: true, + published: false, +}; + +const lessonUpdateExample = { + title: "Updated Introduction to AI", + published: true, +}; + +export const productsApiOpenApi = { + tags: [ + { + name: "Products", + description: + "Create, read, update, and delete products through the public REST API.", + }, + { + name: "Product Payment Plans", + description: + "Manage payment plans for course and download products.", + }, + { + name: "Product Content", + description: "Manage sections and lessons within a product.", + }, + { + name: "Product Customers", + description: + "Enroll customers and read enrollment/progress snapshots.", + }, + ], + paths: { + "/api/products": { + get: { + tags: ["Products"], + summary: "List products", + operationId: "listProducts", + security: secured, + parameters: [ + { + name: "type", + in: "query", + schema: { + type: "string", + enum: ["course", "download", "blog"], + }, + }, + { + name: "published", + in: "query", + schema: { type: "boolean" }, + }, + { name: "search", in: "query", schema: { type: "string" } }, + { + name: "page", + in: "query", + schema: { type: "integer", default: 1, minimum: 1 }, + }, + { + name: "limit", + in: "query", + schema: { + type: "integer", + default: 50, + minimum: 1, + maximum: 200, + }, + }, + ], + responses: { + 200: jsonResponse( + "#/components/schemas/ProductListResponse", + "Products returned successfully.", + ), + 401: error("Invalid API key."), + }, + }, + post: { + tags: ["Products"], + summary: "Create a draft product", + operationId: "createProduct", + security: secured, + requestBody: jsonBody( + { + $ref: "#/components/schemas/ProductCreateRequest", + }, + productCreateExample, + ), + responses: { + 201: jsonResponse("#/components/schemas/Product"), + 400: error("Unsupported or invalid product field."), + 422: error("Product could not be created."), + }, + }, + }, + "/api/products/{productId}": { + get: { + tags: ["Products"], + summary: "Get a product", + operationId: "getProduct", + security: secured, + parameters: [productIdParam], + responses: { + 200: jsonResponse("#/components/schemas/Product"), + 404: error("Product not found."), + }, + }, + patch: { + tags: ["Products"], + summary: "Update product metadata", + operationId: "updateProduct", + security: secured, + parameters: [productIdParam], + requestBody: jsonBody( + { + $ref: "#/components/schemas/ProductUpdateRequest", + }, + productUpdateExample, + ), + responses: { + 200: jsonResponse("#/components/schemas/Product"), + 400: error( + "Malformed JSON body, non-object body, or unsupported product field.", + ), + 422: error("Product could not be updated."), + }, + }, + delete: { + tags: ["Products"], + summary: "Delete a product", + operationId: "deleteProduct", + security: secured, + parameters: [productIdParam], + responses: { + 200: jsonResponse("#/components/schemas/OkResponse"), + 422: error("Product could not be deleted."), + }, + }, + }, + "/api/products/{productId}/payment-plans": { + get: { + tags: ["Product Payment Plans"], + summary: "List product payment plans", + operationId: "listProductPaymentPlans", + security: secured, + parameters: [productIdParam], + responses: { + 200: jsonResponse( + "#/components/schemas/PaymentPlanListResponse", + ), + 422: error("Payment plans could not be fetched."), + }, + }, + post: { + tags: ["Product Payment Plans"], + summary: "Create a product payment plan", + operationId: "createProductPaymentPlan", + security: secured, + parameters: [productIdParam], + requestBody: jsonBody( + { + $ref: "#/components/schemas/PaymentPlanCreateRequest", + }, + paymentPlanCreateExample, + ), + responses: { + 201: jsonResponse("#/components/schemas/PaymentPlan"), + 400: error("Unsupported payment plan field."), + 422: error("Payment plan validation failed."), + }, + }, + }, + "/api/products/{productId}/payment-plans/{planId}": { + get: { + tags: ["Product Payment Plans"], + summary: "Get a product payment plan", + operationId: "getProductPaymentPlan", + security: secured, + parameters: [productIdParam, planIdParam], + responses: { + 200: jsonResponse("#/components/schemas/PaymentPlan"), + 404: error("Payment plan not found."), + }, + }, + patch: { + tags: ["Product Payment Plans"], + summary: "Update a product payment plan", + operationId: "updateProductPaymentPlan", + security: secured, + parameters: [productIdParam, planIdParam], + requestBody: jsonBody( + { + $ref: "#/components/schemas/PaymentPlanUpdateRequest", + }, + paymentPlanUpdateExample, + ), + responses: { + 200: jsonResponse("#/components/schemas/PaymentPlan"), + 400: error("Unsupported payment plan field."), + 422: error("Payment plan validation failed."), + }, + }, + delete: { + tags: ["Product Payment Plans"], + summary: "Archive a product payment plan", + operationId: "archiveProductPaymentPlan", + security: secured, + parameters: [productIdParam, planIdParam], + responses: { + 200: jsonResponse("#/components/schemas/PaymentPlan"), + 422: error("Payment plan could not be archived."), + }, + }, + }, + "/api/products/{productId}/payment-plans/{planId}/default": { + post: { + tags: ["Product Payment Plans"], + summary: "Set the default product payment plan", + operationId: "setDefaultProductPaymentPlan", + security: secured, + parameters: [productIdParam, planIdParam], + responses: { + 200: jsonResponse("#/components/schemas/PaymentPlan"), + 422: error("Default plan could not be changed."), + }, + }, + }, + "/api/products/{productId}/sections": { + get: { + tags: ["Product Content"], + summary: "List product sections", + operationId: "listProductSections", + security: secured, + parameters: [productIdParam], + responses: { + 200: jsonResponse( + "#/components/schemas/SectionListResponse", + ), + 404: error("Product not found."), + }, + }, + post: { + tags: ["Product Content"], + summary: "Create a product section", + operationId: "createProductSection", + security: secured, + parameters: [productIdParam], + requestBody: jsonBody( + { + $ref: "#/components/schemas/SectionCreateRequest", + }, + sectionCreateExample, + ), + responses: { + 201: jsonResponse("#/components/schemas/Section"), + 422: error("Section could not be created."), + }, + }, + }, + "/api/products/{productId}/sections/{sectionId}": { + patch: { + tags: ["Product Content"], + summary: "Update a product section", + operationId: "updateProductSection", + security: secured, + parameters: [productIdParam, sectionIdParam], + requestBody: jsonBody( + { + $ref: "#/components/schemas/SectionUpdateRequest", + }, + sectionUpdateExample, + ), + responses: { + 200: jsonResponse("#/components/schemas/Section"), + 400: error( + "Unsupported section field or invalid drip configuration.", + ), + 422: error("Section could not be updated."), + }, + }, + delete: { + tags: ["Product Content"], + summary: "Delete a product section", + operationId: "deleteProductSection", + security: secured, + parameters: [productIdParam, sectionIdParam], + responses: { + 200: jsonResponse("#/components/schemas/OkResponse"), + 422: error("Section could not be deleted."), + }, + }, + }, + "/api/products/{productId}/sections/reorder": { + post: { + tags: ["Product Content"], + summary: "Reorder product sections", + operationId: "reorderProductSections", + security: secured, + parameters: [productIdParam], + requestBody: jsonBody({ + type: "object", + properties: { + sectionIds: { + type: "array", + items: { type: "string" }, + }, + }, + required: ["sectionIds"], + }), + responses: { + 200: jsonResponse("#/components/schemas/OkResponse"), + 422: error("Sections could not be reordered."), + }, + }, + }, + "/api/products/{productId}/lessons": { + get: { + tags: ["Product Content"], + summary: "List product lessons", + operationId: "listProductLessons", + security: secured, + parameters: [productIdParam], + responses: { + 200: jsonResponse( + "#/components/schemas/LessonListResponse", + ), + 404: error("Product not found."), + }, + }, + post: { + tags: ["Product Content"], + summary: "Create a product lesson", + description: + "Creates a lesson. `text` lessons accept Tiptap/ProseMirror JSON in `content`; `embed` lessons accept `{ value }` in `content`; `quiz` lessons accept quiz JSON in `content`; media-backed lessons (`video`, `audio`, `pdf`, `file`) use `media`. SCORM lessons are not supported.", + operationId: "createProductLesson", + security: secured, + parameters: [productIdParam], + requestBody: jsonBody( + { $ref: "#/components/schemas/LessonCreateRequest" }, + lessonCreateExample, + ), + responses: { + 201: jsonResponse("#/components/schemas/Lesson"), + 422: error( + "SCORM lessons are not supported, or lesson validation failed.", + ), + }, + }, + }, + "/api/products/{productId}/lessons/{lessonId}": { + get: { + tags: ["Product Content"], + summary: "Get a product lesson", + operationId: "getProductLesson", + security: secured, + parameters: [productIdParam, lessonIdParam], + responses: { + 200: jsonResponse("#/components/schemas/Lesson"), + 404: error("Lesson not found."), + }, + }, + patch: { + tags: ["Product Content"], + summary: "Update a product lesson", + description: + "Updates editable lesson fields. Lesson type and section cannot be changed after creation. Use the same content/media shapes documented on create. SCORM lesson updates are rejected with `not_supported`.", + operationId: "updateProductLesson", + security: secured, + parameters: [productIdParam, lessonIdParam], + requestBody: jsonBody( + { $ref: "#/components/schemas/LessonUpdateRequest" }, + lessonUpdateExample, + ), + responses: { + 200: jsonResponse("#/components/schemas/Lesson"), + 422: error( + "SCORM lessons are not supported, or lesson validation failed.", + ), + }, + }, + delete: { + tags: ["Product Content"], + summary: "Delete a product lesson", + operationId: "deleteProductLesson", + security: secured, + parameters: [productIdParam, lessonIdParam], + responses: { + 200: jsonResponse("#/components/schemas/OkResponse"), + 422: error("Lesson could not be deleted."), + }, + }, + }, + "/api/products/{productId}/lessons/{lessonId}/move": { + post: { + tags: ["Product Content"], + summary: "Move a lesson to another section", + operationId: "moveProductLesson", + security: secured, + parameters: [productIdParam, lessonIdParam], + requestBody: jsonBody({ + type: "object", + properties: { + destinationSectionId: { type: "string" }, + destinationIndex: { type: "integer" }, + }, + required: ["destinationSectionId", "destinationIndex"], + }), + responses: { + 200: jsonResponse("#/components/schemas/OkResponse"), + 422: error("Lesson could not be moved."), + }, + }, + }, + "/api/products/{productId}/customers": { + get: { + tags: ["Product Customers"], + summary: "List product customers", + operationId: "listProductCustomers", + security: secured, + parameters: [ + productIdParam, + { + name: "search", + in: "query", + description: "Search customers by name or email.", + schema: { type: "string" }, + }, + { + name: "page", + in: "query", + schema: { type: "integer", default: 1, minimum: 1 }, + }, + { + name: "limit", + in: "query", + schema: { + type: "integer", + default: 50, + minimum: 1, + maximum: 200, + }, + }, + ], + responses: { + 200: jsonResponse( + "#/components/schemas/CustomerListResponse", + ), + }, + }, + }, + "/api/products/{productId}/customers/invitations": { + post: { + tags: ["Product Customers"], + summary: "Invite a customer", + description: + "Invites a customer by email. An invitation email is sent to the provided address.", + operationId: "inviteProductCustomer", + security: secured, + parameters: [productIdParam], + requestBody: jsonBody({ + type: "object", + properties: { + email: { type: "string", format: "email" }, + tags: { type: "array", items: { type: "string" } }, + }, + required: ["email"], + }), + responses: { + 201: jsonResponse("#/components/schemas/Customer"), + 400: error( + "Unsupported customer invitation field or missing email.", + ), + 422: error("Customer could not be invited."), + }, + }, + }, + "/api/products/{productId}/customers/{userId}/progress": { + get: { + tags: ["Product Customers"], + summary: "Get product customer progress", + description: + "Returns customer progress details including completed lessons.", + operationId: "getProductCustomerProgress", + security: secured, + parameters: [productIdParam, userIdParam], + responses: { + 200: jsonResponse("#/components/schemas/Progress"), + 404: error("Customer progress not found."), + }, + }, + }, + }, + components: { + schemas: { + PublicApiErrorResponse: errorResponse, + OkResponse: { + type: "object", + properties: { ok: { type: "boolean" } }, + }, + PaymentPlan: { + type: "object", + properties: { + planId: { type: "string" }, + name: { type: "string" }, + type: { + type: "string", + enum: ["free", "onetime", "emi", "subscription"], + }, + entityId: { type: "string" }, + entityType: { type: "string" }, + oneTimeAmount: { type: "number" }, + emiAmount: { type: "number" }, + emiTotalInstallments: { type: "number" }, + subscriptionMonthlyAmount: { type: "number" }, + subscriptionYearlyAmount: { type: "number" }, + description: { type: "string" }, + isDefault: { type: "boolean" }, + }, + }, + PaymentPlanCreateRequest: { + type: "object", + description: + "Create a product-owned payment plan. `onetime` requires `oneTimeAmount`; `emi` requires `emiAmount` and `emiTotalInstallments`; `subscription` requires exactly one of `subscriptionMonthlyAmount` or `subscriptionYearlyAmount`.", + required: ["name", "type"], + properties: { + name: { + type: "string", + description: "Payment plan name shown to customers.", + }, + type: { + type: "string", + enum: ["free", "onetime", "emi", "subscription"], + }, + oneTimeAmount: { + type: "number", + description: "Required when `type` is `onetime`.", + }, + emiAmount: { + type: "number", + description: "Required when `type` is `emi`.", + }, + emiTotalInstallments: { + type: "number", + description: "Required when `type` is `emi`.", + }, + subscriptionMonthlyAmount: { + type: "number", + description: + "Use for monthly subscriptions. For `subscription`, provide exactly one subscription amount.", + }, + subscriptionYearlyAmount: { + type: "number", + description: + "Use for yearly subscriptions. For `subscription`, provide exactly one subscription amount.", + }, + description: { type: "string" }, + }, + }, + PaymentPlanUpdateRequest: { + type: "object", + description: "Update editable payment plan fields.", + properties: { + name: { + type: "string", + description: "Payment plan name shown to customers.", + }, + type: { + type: "string", + enum: ["free", "onetime", "emi", "subscription"], + }, + oneTimeAmount: { + type: "number", + description: "Required when `type` is `onetime`.", + }, + emiAmount: { + type: "number", + description: "Required when `type` is `emi`.", + }, + emiTotalInstallments: { + type: "number", + description: "Required when `type` is `emi`.", + }, + subscriptionMonthlyAmount: { + type: "number", + description: + "Use for monthly subscriptions. For `subscription`, provide exactly one subscription amount.", + }, + subscriptionYearlyAmount: { + type: "number", + description: + "Use for yearly subscriptions. For `subscription`, provide exactly one subscription amount.", + }, + description: { type: "string" }, + }, + }, + PaymentPlanListResponse: { + type: "object", + properties: { + data: { + type: "array", + items: { $ref: "#/components/schemas/PaymentPlan" }, + }, + }, + }, + Product: { + type: "object", + properties: { + productId: { type: "string" }, + type: { + type: "string", + enum: ["course", "download", "blog"], + }, + title: { type: "string" }, + slug: { type: "string" }, + description: { type: "string" }, + published: { type: "boolean" }, + privacy: { type: "string" }, + tags: { type: "array", items: { type: "string" } }, + featuredImage: { type: "object" }, + pageId: { type: "string" }, + defaultPaymentPlan: { type: "string" }, + paymentPlans: { + type: "array", + items: { $ref: "#/components/schemas/PaymentPlan" }, + }, + createdAt: { type: "string", format: "date-time" }, + updatedAt: { type: "string", format: "date-time" }, + }, + }, + ProductCreateRequest: { + type: "object", + required: ["title", "type"], + description: + "Payload for creating a draft product. Send `title` and `type`. After creation, use the update endpoint for metadata, publish, and privacy changes.", + properties: { + title: { + type: "string", + description: "Product title shown in CourseLit.", + example: "AI Foundations", + }, + type: { + type: "string", + enum: ["course", "download", "blog"], + description: + "CourseLit product type. Use `course` for lesson-based learning products.", + example: "course", + }, + }, + }, + ProductUpdateRequest: { + type: "object", + description: + "Payload for updating product metadata. A product must have at least one payment plan before it can be published.", + properties: { + title: { + type: "string", + description: "Product title shown in CourseLit.", + example: "AI Foundations", + }, + slug: { + type: "string", + description: "Optional URL slug.", + example: "ai-foundations", + }, + description: { + type: "string", + description: + 'Optional product/blog description. Send this as a JSON-stringified Tiptap/ProseMirror document, for example `JSON.stringify({ type: "doc", content: [] })`. Do not use a `content` field on this endpoint.', + example: JSON.stringify({ + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { + type: "text", + text: "Updated course description.", + }, + ], + }, + ], + }), + }, + published: { + type: "boolean", + description: + "Whether the product is published. Existing CourseLit publishing checks still apply.", + example: false, + }, + privacy: { + type: "string", + description: + "Existing CourseLit product privacy value.", + example: "unlisted", + }, + tags: { type: "array", items: { type: "string" } }, + featuredImage: { type: "object" }, + }, + }, + ProductListResponse: { + type: "object", + properties: { + data: { + type: "array", + items: { $ref: "#/components/schemas/Product" }, + }, + pagination: { + type: "object", + properties: { + page: { type: "integer" }, + limit: { type: "integer" }, + }, + }, + }, + }, + Section: { + type: "object", + properties: { + sectionId: { type: "string" }, + name: { type: "string" }, + rank: { type: "number" }, + collapsed: { type: "boolean" }, + drip: { $ref: "#/components/schemas/SectionDrip" }, + lessonsOrder: { + type: "array", + items: { type: "string" }, + }, + }, + }, + SectionCreateRequest: { + type: "object", + required: ["name"], + description: + "Payload for creating a section. Requires only `name`.", + properties: { + name: { type: "string" }, + }, + }, + SectionUpdateRequest: { + type: "object", + description: + "Payload for updating a section. Supports updating `name` and scheduled release (`drip`) settings.", + properties: { + name: { type: "string" }, + drip: { $ref: "#/components/schemas/SectionDripInput" }, + }, + }, + SectionDripInput: { + type: "object", + properties: { + type: { + type: "string", + enum: ["relative-date", "exact-date"], + }, + status: { type: "boolean" }, + delayInMillis: { + type: "number", + description: + "Delay in milliseconds for `relative-date` drip. The input accepts a number interpreted as days (e.g. 3 = three days), but the value is persisted in millisecond equivalent (e.g. 259200000). The endpoint output always returns the stored millisecond value.", + }, + dateInUTC: { + type: "number", + description: + "UNIX timestamp in milliseconds for `exact-date` drip. Both input and output use millisecond precision.", + }, + }, + }, + SectionDrip: { + $ref: "#/components/schemas/SectionDripInput", + }, + SectionListResponse: { + type: "object", + properties: { + data: { + type: "array", + items: { $ref: "#/components/schemas/Section" }, + }, + }, + }, + TiptapDocument: { + type: "object", + description: + "Tiptap/ProseMirror document JSON used by `text` lessons.", + required: ["type", "content"], + properties: { + type: { type: "string", example: "doc" }, + content: { type: "array", items: { type: "object" } }, + }, + }, + EmbedContent: { + type: "object", + description: + "Embed content for `embed` lessons. The value can be a supported video URL or iframe/embed code, matching the dashboard Embed lesson field.", + required: ["value"], + properties: { + value: { + type: "string", + example: "https://www.youtube.com/watch?v=dQw4w9WgXcQ", + }, + }, + }, + QuizContent: { + type: "object", + description: "Quiz content for `quiz` lesson type.", + required: ["questions", "requiresPassingGrade", "passingGrade"], + properties: { + questions: { + type: "array", + description: "List of quiz questions.", + items: { + type: "object", + required: ["text", "options"], + properties: { + text: { + type: "string", + description: "Question text.", + }, + options: { + type: "array", + description: + "Answer options. Exactly one option should have `correctAnswer: true` for single-choice; multiple for multiple-choice.", + items: { + type: "object", + required: ["text"], + properties: { + text: { + type: "string", + description: "Option text.", + }, + correctAnswer: { + type: "boolean", + description: + "Whether this is the correct answer. Stripped from student-facing responses.", + }, + }, + }, + }, + }, + }, + }, + requiresPassingGrade: { + type: "boolean", + description: + "Whether a minimum score is required to pass.", + }, + passingGrade: { + type: "number", + description: + "Score threshold (0–100) when `requiresPassingGrade` is true.", + }, + }, + }, + LessonMedia: { + type: "object", + description: + "Media object used by `video`, `audio`, `pdf`, and `file` lessons. Use `/api/media/presigned` to upload to MediaLit first, then send the resulting media metadata here.", + required: ["mediaId"], + properties: { + mediaId: { type: "string" }, + originalFileName: { type: "string" }, + mimeType: { type: "string" }, + size: { type: "number" }, + access: { + type: "string", + enum: ["public", "private"], + }, + file: { + type: "string", + description: + "Public file URL when available. Private media may omit this field.", + }, + thumbnail: { type: "string" }, + caption: { type: "string" }, + }, + }, + Lesson: { + type: "object", + properties: { + lessonId: { type: "string" }, + title: { type: "string" }, + type: { + type: "string", + enum: supportedLessonTypes, + }, + content: lessonContentSchema, + media: { $ref: "#/components/schemas/LessonMedia" }, + downloadable: { type: "boolean" }, + courseId: { type: "string" }, + groupId: { type: "string" }, + requiresEnrollment: { type: "boolean" }, + published: { type: "boolean" }, + }, + }, + LessonCreateRequest: { + type: "object", + required: ["title", "type", "groupId"], + properties: { + title: { type: "string" }, + type: { + type: "string", + enum: supportedLessonTypes, + }, + content: lessonContentSchema, + media: { $ref: "#/components/schemas/LessonMedia" }, + downloadable: { type: "boolean" }, + groupId: { type: "string" }, + requiresEnrollment: { type: "boolean" }, + published: { type: "boolean" }, + }, + }, + LessonUpdateRequest: { + type: "object", + properties: { + title: { type: "string" }, + content: lessonContentSchema, + media: { $ref: "#/components/schemas/LessonMedia" }, + downloadable: { type: "boolean" }, + requiresEnrollment: { type: "boolean" }, + published: { type: "boolean" }, + }, + }, + LessonListResponse: { + type: "object", + properties: { + data: { + type: "array", + items: { $ref: "#/components/schemas/Lesson" }, + }, + }, + }, + Customer: { + type: "object", + properties: { + userId: { type: "string" }, + email: { type: "string" }, + name: { type: "string" }, + avatar: { type: "object" }, + membershipId: { type: "string" }, + membershipStatus: { type: "string" }, + subscriptionMethod: { type: "string" }, + completedLessons: { + type: "array", + items: { type: "string" }, + }, + downloaded: { type: "boolean" }, + enrolledAt: { type: "string", format: "date-time" }, + updatedAt: { type: "string", format: "date-time" }, + }, + }, + ProductCustomer: { + type: "object", + description: + "Member enrollment details returned by the product customers endpoint.", + properties: { + user: { + type: "object", + properties: { + userId: { type: "string" }, + email: { type: "string" }, + name: { type: "string" }, + avatar: { type: "object" }, + }, + }, + status: { type: "string" }, + completedLessons: { + type: "array", + items: { type: "string" }, + }, + downloaded: { type: "boolean" }, + subscriptionMethod: { type: "string" }, + createdAt: { type: "string", format: "date-time" }, + updatedAt: { type: "string", format: "date-time" }, + }, + }, + CustomerListResponse: { + type: "object", + properties: { + data: { + type: "array", + items: { $ref: "#/components/schemas/ProductCustomer" }, + }, + pagination: { + type: "object", + properties: { + page: { type: "integer" }, + limit: { type: "integer" }, + }, + }, + }, + }, + Progress: { + type: "object", + properties: { + courseId: { type: "string" }, + completedLessons: { + type: "array", + items: { type: "string" }, + }, + downloaded: { type: "boolean" }, + createdAt: { type: "string", format: "date-time" }, + updatedAt: { type: "string", format: "date-time" }, + }, + }, + }, + }, +}; diff --git a/apps/web/app/api/products/product-response.ts b/apps/web/app/api/products/product-response.ts new file mode 100644 index 000000000..0dd2ca7d0 --- /dev/null +++ b/apps/web/app/api/products/product-response.ts @@ -0,0 +1,107 @@ +import { Constants, PaymentPlan } from "@courselit/common-models"; +import PaymentPlanModel from "@models/PaymentPlan"; +import { Domain } from "@models/Domain"; + +type ProductDocument = { + courseId: string; + type: string; + title: string; + slug: string; + description?: string; + published: boolean; + privacy: string; + tags?: string[]; + featuredImage?: unknown; + pageId?: string; + defaultPaymentPlan?: string; + createdAt?: Date | string; + updatedAt?: Date | string; +}; + +function toIsoString(value?: Date | string) { + if (!value) { + return undefined; + } + return value instanceof Date ? value.toISOString() : value; +} + +export function serializePaymentPlan( + paymentPlan: PaymentPlan, + defaultPaymentPlan?: string, +) { + return { + planId: paymentPlan.planId, + name: paymentPlan.name, + type: paymentPlan.type, + entityId: paymentPlan.entityId, + entityType: paymentPlan.entityType, + oneTimeAmount: paymentPlan.oneTimeAmount, + emiAmount: paymentPlan.emiAmount, + emiTotalInstallments: paymentPlan.emiTotalInstallments, + subscriptionMonthlyAmount: paymentPlan.subscriptionMonthlyAmount, + subscriptionYearlyAmount: paymentPlan.subscriptionYearlyAmount, + description: paymentPlan.description, + isDefault: paymentPlan.planId === defaultPaymentPlan, + }; +} + +export async function fetchPaymentPlans( + productIds: string[], + domain: Domain, +): Promise> { + if (productIds.length === 0) { + return new Map(); + } + + const plans = (await PaymentPlanModel.find({ + domain: domain._id, + entityId: { $in: productIds }, + entityType: Constants.MembershipEntityType.COURSE, + archived: false, + internal: false, + }).lean()) as unknown as PaymentPlan[]; + + const plansByEntityId = new Map(); + for (const plan of plans) { + const existing = plansByEntityId.get(plan.entityId) ?? []; + existing.push(plan); + plansByEntityId.set(plan.entityId, existing); + } + return plansByEntityId; +} + +export function serializeProduct( + product: ProductDocument, + paymentPlans?: PaymentPlan[], +) { + const supportsPaymentPlans = + product.type === Constants.CourseType.COURSE || + product.type === Constants.CourseType.DOWNLOAD; + + return { + productId: product.courseId, + type: product.type, + title: product.title, + slug: product.slug, + description: product.description, + published: product.published, + privacy: product.privacy, + tags: product.tags, + featuredImage: product.featuredImage, + pageId: product.pageId, + defaultPaymentPlan: supportsPaymentPlans + ? product.defaultPaymentPlan + : undefined, + paymentPlans: + supportsPaymentPlans && paymentPlans + ? paymentPlans.map((paymentPlan) => + serializePaymentPlan( + paymentPlan, + product.defaultPaymentPlan, + ), + ) + : undefined, + createdAt: toIsoString(product.createdAt), + updatedAt: toIsoString(product.updatedAt), + }; +} diff --git a/apps/web/app/api/products/route.ts b/apps/web/app/api/products/route.ts new file mode 100644 index 000000000..d67b1802f --- /dev/null +++ b/apps/web/app/api/products/route.ts @@ -0,0 +1,120 @@ +import { Constants } from "@courselit/common-models"; +import { NextRequest, NextResponse } from "next/server"; +import { createCourse, getProducts } from "@/graphql/courses/logic"; +import { + publicApiError, + validatePublicApiRequest, + validatePublicApiRequestWithJsonBody, +} from "@/app/api/public-api"; +import { fetchPaymentPlans, serializeProduct } from "./product-response"; + +const DEFAULT_LIMIT = 50; +const MAX_LIMIT = 200; +const createProductFields = new Set(["title", "type"]); + +export async function GET(req: NextRequest) { + const auth = await validatePublicApiRequest(req); + if (auth.error) { + return auth.error; + } + + const url = new URL(req.url); + const page = Math.max(Number(url.searchParams.get("page") || "1"), 1); + const requestedLimit = Number( + url.searchParams.get("limit") || DEFAULT_LIMIT, + ); + const limit = Math.min( + Math.max(Number.isFinite(requestedLimit) ? requestedLimit : 0, 1), + MAX_LIMIT, + ); + + const type = url.searchParams.get("type"); + const published = url.searchParams.get("published"); + const search = url.searchParams.get("search"); + + try { + const products = await getProducts({ + ctx: auth.ctx as any, + page, + limit, + filterBy: type ? ([type] as any) : undefined, + published: + published === "true" || published === "false" + ? published === "true" + : undefined, + searchText: search || undefined, + }); + + const productIdsWithPlans = (products as any[]) + .filter( + (p) => + p.type === Constants.CourseType.COURSE || + p.type === Constants.CourseType.DOWNLOAD, + ) + .map((p) => p.courseId); + const plansByProductId = await fetchPaymentPlans( + productIdsWithPlans, + auth.domain, + ); + + return NextResponse.json({ + data: products.map((product) => + serializeProduct( + product as any, + plansByProductId.get((product as any).courseId), + ), + ), + pagination: { + page, + limit, + }, + }); + } catch (error) { + return publicApiError("internal_error", "Internal server error", 500); + } +} + +function getUnsupportedField(body: Record) { + return Object.keys(body).find((key) => !createProductFields.has(key)); +} + +export async function POST(req: NextRequest) { + const auth = await validatePublicApiRequestWithJsonBody(req); + if (auth.error) { + return auth.error; + } + + const body = auth.body; + const unsupportedField = getUnsupportedField(body); + if (unsupportedField) { + return publicApiError( + "bad_request", + `Unsupported product field: ${unsupportedField}`, + 400, + ); + } + + if (!body.title || !body.type) { + return publicApiError("bad_request", "Bad request", 400); + } + + try { + const createdProduct = await createCourse( + { + title: body.title as string, + type: body.type as any, + }, + auth.ctx as any, + ); + + return Response.json(serializeProduct(createdProduct as any), { + status: 201, + }); + } catch (error: any) { + return publicApiError( + "unprocessable_entity", + error.message || "Unable to create product", + 422, + ); + } +} diff --git a/apps/web/app/api/public-api.ts b/apps/web/app/api/public-api.ts new file mode 100644 index 000000000..03b7b49b8 --- /dev/null +++ b/apps/web/app/api/public-api.ts @@ -0,0 +1,185 @@ +import { NextRequest, NextResponse } from "next/server"; +import DomainModel, { Domain } from "@models/Domain"; +import ApiKey from "@/models/ApiKey"; +import UserModel from "@models/User"; + +export type PublicApiErrorCode = + | "bad_request" + | "unauthorized" + | "forbidden" + | "not_found" + | "conflict" + | "not_supported" + | "unprocessable_entity" + | "internal_error"; + +export function publicApiError( + code: PublicApiErrorCode, + message: string, + status: number, +) { + return NextResponse.json({ error: { code, message } }, { status }); +} + +const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +export function validateEmail(email: string): string | undefined { + if (!email || typeof email !== "string" || !EMAIL_REGEX.test(email)) { + return "Invalid email format"; + } +} + +export const MAX_BODY_SIZE_BYTES = 1024 * 1024; + +type PublicApiJsonObjectResult = + | { error?: undefined; body: Record } + | { error: NextResponse; body?: undefined }; + +export async function parsePublicApiJsonObject( + req: NextRequest, +): Promise { + const contentLength = req.headers.get("content-length"); + if (contentLength && Number(contentLength) > MAX_BODY_SIZE_BYTES) { + return { + error: publicApiError("bad_request", "Request body too large", 413), + }; + } + + let body: unknown; + try { + body = await req.json(); + } catch { + return { + error: publicApiError("bad_request", "Invalid JSON body", 400), + }; + } + + if (!body || typeof body !== "object" || Array.isArray(body)) { + return { + error: publicApiError( + "bad_request", + "Request body must be a JSON object", + 400, + ), + }; + } + + return { body: body as Record }; +} + +type PublicApiAuthSuccess = { + error?: undefined; + domain: Domain; + user: any; + apiKey: any; + ctx: { + user: any; + subdomain: Domain; + address: string; + }; +}; + +type PublicApiAuthFailure = { + error: NextResponse; + domain?: undefined; + user?: undefined; + apiKey?: undefined; + ctx?: undefined; +}; + +type PublicApiAuthResult = PublicApiAuthSuccess | PublicApiAuthFailure; +type PublicApiAuthWithBodySuccess = PublicApiAuthSuccess & { + body: Record; +}; +type PublicApiAuthWithBodyResult = + | PublicApiAuthWithBodySuccess + | PublicApiAuthFailure; + +function getRequestOrigin(req: NextRequest) { + try { + return new URL(req.url).origin; + } catch { + return req.headers.get("origin") || "http://localhost"; + } +} + +export async function validatePublicApiRequest( + req: NextRequest, + options?: { apiKey?: string }, +): Promise { + const domain = await DomainModel.findOne({ + name: req.headers.get("domain"), + }); + if (!domain) { + return { + error: publicApiError("not_found", "Domain not found", 404), + }; + } + + const apiKey = + req.headers.get("x-api-key") ?? + req.headers.get("X-API-Key") ?? + options?.apiKey; + if (!apiKey) { + return { + error: publicApiError("bad_request", "Bad request", 400), + }; + } + + const apiKeyObject = await ApiKey.findOne({ + domain: domain._id, + key: apiKey, + }); + if (!apiKeyObject) { + return { + error: publicApiError("unauthorized", "Unauthorized", 401), + }; + } + + const user = await UserModel.findOne({ + domain: domain._id, + email: domain.email, + }); + if (!user) { + return { + error: publicApiError( + "forbidden", + "API key cannot be mapped to a school owner", + 403, + ), + }; + } + + return { + domain, + user, + apiKey: apiKeyObject, + ctx: { + user, + subdomain: domain, + address: getRequestOrigin(req), + }, + }; +} + +export async function validatePublicApiRequestWithJsonBody( + req: NextRequest, + options?: { apiKey?: string }, +): Promise { + const auth = await validatePublicApiRequest(req, options); + if (auth.error) { + return auth; + } + + const parsedBody = await parsePublicApiJsonObject(req); + if (parsedBody.error) { + return { + error: parsedBody.error, + }; + } + + return { + ...auth, + body: parsedBody.body, + }; +} diff --git a/apps/web/app/api/user/__tests__/route.test.ts b/apps/web/app/api/user/__tests__/route.test.ts new file mode 100644 index 000000000..b74cf7d45 --- /dev/null +++ b/apps/web/app/api/user/__tests__/route.test.ts @@ -0,0 +1,233 @@ +/** + * @jest-environment node + */ + +import { NextRequest } from "next/server"; +import Domain from "@models/Domain"; +import ApiKey from "@models/ApiKey"; +import User from "@models/User"; +import UserModel from "@models/User"; +import { createUser } from "@/graphql/users/logic"; +import { responses } from "@/config/strings"; + +jest.mock("@models/Domain"); +jest.mock("@models/ApiKey"); +jest.mock("@models/User"); +jest.mock("@/graphql/users/logic", () => ({ + createUser: jest.fn(), +})); +jest.mock("@/lib/check-invalid-permissions", () => ({ + checkForInvalidPermissions: jest.fn(), +})); + +const domain = { + _id: "domain-id", + name: "school", + email: "owner@example.com", +}; + +function request(body: Record, apiKey?: string) { + return { + url: "https://school.test/api/user", + json: jest.fn().mockResolvedValue(body), + headers: { + get: jest.fn((name: string) => { + if (name === "domain") return "school"; + if (name === "x-api-key") return apiKey || null; + return null; + }), + }, + } as unknown as NextRequest; +} + +describe("/api/user", () => { + beforeEach(() => { + jest.clearAllMocks(); + (Domain.findOne as jest.Mock).mockResolvedValue(domain); + (ApiKey.findOne as jest.Mock).mockResolvedValue({ key: "api-key" }); + (User.findOne as jest.Mock).mockResolvedValue({ + userId: "owner", + email: "owner@example.com", + }); + }); + + it("creates a user through the shared owner-backed API key validator", async () => { + const { POST } = await import("../route"); + const response = await POST( + request({ email: "student@example.com" }, "api-key"), + ); + + expect(response.status).toBe(200); + expect(ApiKey.findOne).toHaveBeenCalledWith({ + domain: domain._id, + key: "api-key", + }); + expect(User.findOne).toHaveBeenCalledWith({ + domain: domain._id, + email: domain.email, + }); + expect(createUser).toHaveBeenCalledWith( + expect.objectContaining({ + domain, + email: "student@example.com", + }), + ); + }); + + it("keeps legacy body apikey support for /api/user only", async () => { + const { POST } = await import("../route"); + const response = await POST( + request({ + email: "student@example.com", + apikey: "legacy-api-key", + }), + ); + + expect(response.status).toBe(200); + expect(ApiKey.findOne).toHaveBeenCalledWith({ + domain: domain._id, + key: "legacy-api-key", + }); + }); + + it("keeps the legacy validation error response shape", async () => { + (ApiKey.findOne as jest.Mock).mockResolvedValue(null); + + const { POST } = await import("../route"); + const response = await POST( + request({ email: "student@example.com" }, "bad-key"), + ); + + expect(response.status).toBe(401); + await expect(response.json()).resolves.toEqual({ + message: "Unauthorized", + }); + }); + + it("rejects bodies exceeding the public API body size limit in the legacy path", async () => { + const { POST } = await import("../route"); + const response = await POST({ + url: "https://school.test/api/user", + json: jest.fn(), + headers: { + get: jest.fn((name: string) => { + if (name === "domain") return "school"; + if (name === "x-api-key") return "api-key"; + if (name === "content-length") return "2097152"; + return null; + }), + }, + } as unknown as NextRequest); + + expect(response.status).toBe(413); + expect(createUser).not.toHaveBeenCalled(); + await expect(response.json()).resolves.toEqual({ + message: "Request body too large", + }); + }); + + it("rejects invalid email formats on user creation", async () => { + const { POST } = await import("../route"); + const response = await POST( + request({ email: "not-an-email" }, "api-key"), + ); + + expect(response.status).toBe(400); + expect(createUser).not.toHaveBeenCalled(); + await expect(response.json()).resolves.toEqual({ + message: "Invalid email format", + }); + }); + + it("returns bad request instead of 500 when legacy user API JSON is invalid", async () => { + const { POST } = await import("../route"); + const response = await POST({ + ...request({}, "api-key"), + json: jest.fn().mockRejectedValue(new SyntaxError("bad json")), + } as unknown as NextRequest); + + expect(response.status).toBe(400); + expect(createUser).not.toHaveBeenCalled(); + await expect(response.json()).resolves.toEqual({ + message: "Invalid JSON body", + }); + }); + + it("requires the API key to resolve the school owner", async () => { + (User.findOne as jest.Mock).mockResolvedValue(null); + + const { PATCH } = await import("../route"); + const response = await PATCH( + request( + { email: "student@example.com", name: "Student" }, + "api-key", + ), + ); + + expect(response.status).toBe(403); + expect(UserModel.findOneAndUpdate).not.toHaveBeenCalled(); + await expect(response.json()).resolves.toEqual({ + message: "API key cannot be mapped to a school owner", + }); + }); + + it("prevents changing permissions for the school owner", async () => { + const { PATCH } = await import("../route"); + const response = await PATCH( + request( + { + email: domain.email, + permissions: [], + }, + "api-key", + ), + ); + + expect(response.status).toBe(403); + expect(UserModel.findOneAndUpdate).not.toHaveBeenCalled(); + await expect(response.json()).resolves.toEqual({ + error: responses.action_not_allowed, + }); + }); + + it("continues to allow non-permission updates for the school owner", async () => { + (UserModel.findOneAndUpdate as jest.Mock).mockResolvedValue({ + email: domain.email, + }); + + const { PATCH } = await import("../route"); + const response = await PATCH( + request( + { + email: domain.email, + name: "Updated Owner", + }, + "api-key", + ), + ); + + expect(response.status).toBe(200); + expect(UserModel.findOneAndUpdate).toHaveBeenCalledWith( + { email: domain.email, domain: domain._id }, + { + name: "Updated Owner", + permissions: undefined, + subscribedToUpdates: undefined, + }, + { new: true }, + ); + }); + + it("rejects invalid email formats on user update", async () => { + const { PATCH } = await import("../route"); + const response = await PATCH( + request({ email: "not-an-email", name: "Updated" }, "api-key"), + ); + + expect(response.status).toBe(400); + expect(UserModel.findOneAndUpdate).not.toHaveBeenCalled(); + await expect(response.json()).resolves.toEqual({ + message: "Invalid email format", + }); + }); +}); diff --git a/apps/web/app/api/user/openapi.mjs b/apps/web/app/api/user/openapi.mjs index 43c225a4c..24faecb70 100644 --- a/apps/web/app/api/user/openapi.mjs +++ b/apps/web/app/api/user/openapi.mjs @@ -23,8 +23,6 @@ export const userApiOpenApi = { post: { tags: ["Users"], summary: "Create a user", - description: - "Creates a user in the current school. Call this endpoint on the school domain, for example `https://school.courselit.app/api/user`, and authenticate with `x-api-key`. The legacy `apikey` field in the request body is still accepted for backward compatibility.", operationId: "createUser", security: [ { @@ -155,8 +153,6 @@ export const userApiOpenApi = { patch: { tags: ["Users"], summary: "Update a user", - description: - "Updates a user in the current school. Call this endpoint on the school domain, for example `https://school.courselit.app/api/user`, and authenticate with `x-api-key`. CourseLit proxy infrastructure resolves the school and injects the internal `domain` header automatically. The legacy `apikey` field in the request body is still accepted for backward compatibility.", operationId: "updateUser", security: [ { diff --git a/apps/web/app/api/user/route.ts b/apps/web/app/api/user/route.ts index 31abf0d61..7709b30f5 100644 --- a/apps/web/app/api/user/route.ts +++ b/apps/web/app/api/user/route.ts @@ -3,9 +3,10 @@ import { responses } from "@/config/strings"; import { createUser } from "@/graphql/users/logic"; import constants from "@config/constants"; import { createHash } from "crypto"; -import { validateDomainAndApiKey } from "./validate-apikey"; import UserModel from "@models/User"; import { checkForInvalidPermissions } from "@/lib/check-invalid-permissions"; +import { validateDomainAndApiKey } from "./validate-apikey"; +import { validateEmail } from "@/app/api/public-api"; function validateRequestBody(body: any) { const { email, subscribedToUpdates, permissions } = body; @@ -14,6 +15,11 @@ function validateRequestBody(body: any) { return { error: { message: "Bad request", status: 400 } }; } + const emailError = validateEmail(email); + if (emailError) { + return { error: { message: emailError, status: 400 } }; + } + if (subscribedToUpdates && typeof subscribedToUpdates !== "boolean") { return { error: { message: "Bad request", status: 400 } }; } @@ -51,7 +57,10 @@ export async function POST(req: NextRequest) { ); } - const { email, subscribedToUpdates, name, permissions } = body; + const { email, subscribedToUpdates, name, permissions } = body as Record< + string, + any + >; try { await createUser({ @@ -101,7 +110,20 @@ export async function PATCH(req: NextRequest) { ); } - const { email, name, permissions, subscribedToUpdates } = body; + const { email, name, permissions, subscribedToUpdates } = body as Record< + string, + any + >; + + if ( + Object.prototype.hasOwnProperty.call(body, "permissions") && + email === domain.email + ) { + return NextResponse.json( + { error: responses.action_not_allowed }, + { status: 403 }, + ); + } try { const user = await UserModel.findOneAndUpdate( diff --git a/apps/web/app/api/user/validate-apikey.ts b/apps/web/app/api/user/validate-apikey.ts index 49549d967..039133990 100644 --- a/apps/web/app/api/user/validate-apikey.ts +++ b/apps/web/app/api/user/validate-apikey.ts @@ -1,31 +1,52 @@ +// TODO: Remove this in future import { NextRequest } from "next/server"; -import DomainModel, { Domain } from "@models/Domain"; -import ApiKey from "@/models/ApiKey"; +import { + MAX_BODY_SIZE_BYTES, + validatePublicApiRequest, +} from "@/app/api/public-api"; export async function validateDomainAndApiKey(req: NextRequest) { - const domain = await DomainModel.findOne({ - name: req.headers.get("domain"), - }); - if (!domain) { - return { error: { message: "Domain not found", status: 404 } }; + const contentLength = req.headers.get("content-length"); + if (contentLength && Number(contentLength) > MAX_BODY_SIZE_BYTES) { + return { error: { message: "Request body too large", status: 413 } }; } - const body = await req.json(); - const apikey = - req.headers.get("x-api-key") ?? - req.headers.get("X-API-Key") ?? - body.apikey; - if (!apikey) { - return { error: { message: "Bad request", status: 400 } }; + let body: Record; + try { + const parsedBody = await req.json(); + if ( + !parsedBody || + typeof parsedBody !== "object" || + Array.isArray(parsedBody) + ) { + return { + error: { + message: "Request body must be a JSON object", + status: 400, + }, + }; + } + body = parsedBody as Record; + } catch { + return { error: { message: "Invalid JSON body", status: 400 } }; } - const apikeyObj = await ApiKey.findOne({ - domain: domain._id, - key: apikey, + + const validation = await validatePublicApiRequest(req, { + apiKey: body.apikey as string | undefined, }); - if (!apikeyObj) { - return { error: { message: "Unauthorized", status: 401 } }; + if (validation.error) { + const errorBody = await validation.error.json(); + return { + error: { + message: + errorBody.error?.message || + errorBody.message || + "Bad request", + status: validation.error.status, + }, + }; } - return { domain, body }; + return { ...validation, body }; } diff --git a/apps/web/components/admin/mails/email-viewer.tsx b/apps/web/components/admin/mails/email-viewer.tsx index 97a4f3b2e..2aa2bc0b5 100644 --- a/apps/web/components/admin/mails/email-viewer.tsx +++ b/apps/web/components/admin/mails/email-viewer.tsx @@ -1,5 +1,6 @@ import { useEffect, useState, startTransition } from "react"; -import { Email, renderEmailToHtml } from "@courselit/email-editor"; +import { renderEmailToHtml } from "@courselit/email-editor"; +import type { Email } from "@courselit/email-editor"; import { Edit } from "lucide-react"; import Link from "next/link"; diff --git a/apps/web/graphql/courses/__tests__/logic.test.ts b/apps/web/graphql/courses/__tests__/logic.test.ts index 9f3896e13..c23210c1b 100644 --- a/apps/web/graphql/courses/__tests__/logic.test.ts +++ b/apps/web/graphql/courses/__tests__/logic.test.ts @@ -1,9 +1,20 @@ import DomainModel from "@models/Domain"; import UserModel from "@models/User"; import CourseModel from "@models/Course"; +import LessonModel from "@models/Lesson"; +import MembershipModel from "@models/Membership"; import PageModel from "@models/Page"; import constants from "@/config/constants"; -import { getCourse, updateCourse } from "../logic"; +import { responses } from "@/config/strings"; +import { Constants as CommonConstants } from "@courselit/common-models"; +import { + getCourse, + getCourseLessonOrThrow, + getCourseLessons, + getMembers, + getProducts, + updateCourse, +} from "../logic"; import { deleteMedia, sealMedia } from "@/services/medialit"; jest.mock("@/services/medialit", () => ({ @@ -229,6 +240,123 @@ describe("updateCourse", () => { JSON.stringify(expectedDescription), ); }); + + it("updates one property on an incomplete draft blog", async () => { + const course = await CourseModel.create({ + domain: testDomain._id, + courseId: id("draft-blog"), + title: id("draft-blog-title"), + creatorId: adminUser.userId, + deleteable: true, + lessons: [], + type: "blog", + privacy: "unlisted", + costType: "free", + cost: 0, + slug: id("draft-blog-slug"), + published: false, + }); + + const updatedCourse = await updateCourse( + { + id: course.courseId as any, + title: id("draft-blog-title-updated"), + }, + { + subdomain: testDomain, + user: adminUser, + address: "", + }, + ); + + expect(updatedCourse.title).toBe(id("draft-blog-title-updated")); + expect(updatedCourse.description).toBeUndefined(); + }); + + it("updates a draft blog description with serialized Tiptap content", async () => { + const course = await CourseModel.create({ + domain: testDomain._id, + courseId: id("draft-blog-description"), + title: id("draft-blog-description-title"), + creatorId: adminUser.userId, + deleteable: true, + lessons: [], + type: "blog", + privacy: "unlisted", + costType: "free", + cost: 0, + slug: id("draft-blog-description-slug"), + published: false, + }); + const description = JSON.stringify({ + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { + type: "text", + text: "Updated course description.", + }, + ], + }, + ], + }); + + const updatedCourse = await updateCourse( + { + id: course.courseId as any, + title: id("draft-blog-description-title-updated"), + description, + }, + { + subdomain: testDomain, + user: adminUser, + address: "", + }, + ); + + expect(updatedCourse.title).toBe( + id("draft-blog-description-title-updated"), + ); + expect(updatedCourse.description).toBe(description); + }); + + it("validates the overall state when publishing an incomplete blog", async () => { + const course = await CourseModel.create({ + domain: testDomain._id, + courseId: id("publish-incomplete-blog"), + title: id("publish-incomplete-blog-title"), + creatorId: adminUser.userId, + deleteable: true, + lessons: [], + type: "blog", + privacy: "unlisted", + costType: "free", + cost: 0, + slug: id("publish-incomplete-blog-slug"), + published: false, + }); + const publishingAdmin = adminUser.toObject(); + publishingAdmin.permissions = [ + constants.permissions.manageAnyCourse, + constants.permissions.publishCourse, + ]; + + await expect( + updateCourse( + { + id: course.courseId as any, + published: true, + }, + { + subdomain: testDomain, + user: publishingAdmin, + address: "", + }, + ), + ).rejects.toThrow(responses.blog_description_empty); + }); }); describe("getCourse", () => { @@ -316,3 +444,239 @@ describe("getCourse", () => { ]); }); }); + +describe("public API product read helpers", () => { + let testDomain: any; + let adminUser: any; + + const helperId = (suffix: string) => + `public-api-read-${Date.now()}-${suffix}`; + + beforeAll(async () => { + testDomain = await DomainModel.create({ + name: helperId("domain"), + email: `${helperId("domain")}@example.com`, + }); + + adminUser = await UserModel.create({ + domain: testDomain._id, + userId: helperId("admin-user"), + email: `${helperId("admin")}@example.com`, + name: "Admin User", + permissions: [constants.permissions.manageAnyCourse], + active: true, + unsubscribeToken: helperId("unsubscribe-admin"), + purchases: [], + }); + }); + + beforeEach(async () => { + await CourseModel.deleteMany({ domain: testDomain._id }); + await LessonModel.deleteMany({ domain: testDomain._id }); + await MembershipModel.deleteMany({ domain: testDomain._id }); + }); + + afterAll(async () => { + await MembershipModel.deleteMany({ domain: testDomain._id }); + await LessonModel.deleteMany({ domain: testDomain._id }); + await CourseModel.deleteMany({ domain: testDomain._id }); + await UserModel.deleteMany({ domain: testDomain._id }); + await DomainModel.deleteOne({ _id: testDomain._id }); + }); + + it("passes public API list filters through the existing product query helper", async () => { + const paginatedFind = jest + .spyOn(CourseModel as any, "paginatedFind") + .mockResolvedValueOnce([]); + + await getProducts({ + ctx: { + subdomain: testDomain, + user: adminUser, + address: "", + }, + page: 2, + limit: 25, + filterBy: [constants.course], + published: false, + searchText: "robotics", + }); + + expect(paginatedFind).toHaveBeenCalledWith( + { + domain: testDomain._id, + type: { $in: [constants.course] }, + published: false, + $text: { $search: "robotics" }, + }, + { + page: 2, + limit: 25, + sort: -1, + }, + ); + + paginatedFind.mockRestore(); + }); + + it("returns lessons for a product after applying existing product access checks", async () => { + const course = await CourseModel.create({ + domain: testDomain._id, + courseId: helperId("course"), + title: "Course", + creatorId: adminUser.userId, + groups: [], + lessons: [], + type: constants.course, + privacy: "unlisted", + costType: "free", + cost: 0, + slug: helperId("course-slug"), + }); + const groupId = helperId("group"); + await LessonModel.create({ + domain: testDomain._id, + lessonId: helperId("lesson-1"), + title: "Intro", + type: constants.text, + creatorId: adminUser.userId, + courseId: course.courseId, + groupId, + published: false, + }); + + const lessons = await getCourseLessons({ + courseId: course.courseId, + ctx: { + subdomain: testDomain, + user: adminUser, + address: "", + }, + }); + + expect(lessons).toHaveLength(1); + expect(lessons[0].courseId).toBe(course.courseId); + }); + + it("rejects lesson reads when the lesson does not belong to the product", async () => { + const course = await CourseModel.create({ + domain: testDomain._id, + courseId: helperId("course"), + title: "Course", + creatorId: adminUser.userId, + groups: [], + lessons: [], + type: constants.course, + privacy: "unlisted", + costType: "free", + cost: 0, + slug: helperId("course-slug"), + }); + + await expect( + getCourseLessonOrThrow({ + courseId: course.courseId, + lessonId: helperId("missing-lesson"), + ctx: { + subdomain: testDomain, + user: adminUser, + address: "", + }, + }), + ).rejects.toThrow(responses.item_not_found); + }); + + it("filters product members by user name or email inside GraphQL logic", async () => { + const course = await CourseModel.create({ + domain: testDomain._id, + courseId: helperId("member-course"), + title: "Course", + creatorId: adminUser.userId, + groups: [], + lessons: [], + type: constants.course, + privacy: "unlisted", + costType: "free", + cost: 0, + slug: helperId("member-course-slug"), + }); + const matchingByName = await UserModel.create({ + domain: testDomain._id, + userId: helperId("matching-name-user"), + email: `${helperId("matching-name")}@example.com`, + name: "Student Searchable", + active: true, + permissions: [], + unsubscribeToken: helperId("matching-name-unsubscribe"), + purchases: [{ courseId: course.courseId, completedLessons: [] }], + }); + const matchingByEmail = await UserModel.create({ + domain: testDomain._id, + userId: helperId("matching-email-user"), + email: `student-${helperId("matching-email")}@example.com`, + name: "Different Name", + active: true, + permissions: [], + unsubscribeToken: helperId("matching-email-unsubscribe"), + purchases: [{ courseId: course.courseId, completedLessons: [] }], + }); + const nonMatchingUser = await UserModel.create({ + domain: testDomain._id, + userId: helperId("non-matching-user"), + email: `${helperId("other")}@example.com`, + name: "Other Person", + active: true, + permissions: [], + unsubscribeToken: helperId("other-unsubscribe"), + purchases: [{ courseId: course.courseId, completedLessons: [] }], + }); + + await MembershipModel.create([ + { + domain: testDomain._id, + membershipId: helperId("membership-name"), + sessionId: helperId("session-name"), + userId: matchingByName.userId, + paymentPlanId: helperId("plan-name"), + entityId: course.courseId, + entityType: CommonConstants.MembershipEntityType.COURSE, + status: CommonConstants.MembershipStatus.ACTIVE, + }, + { + domain: testDomain._id, + membershipId: helperId("membership-email"), + sessionId: helperId("session-email"), + userId: matchingByEmail.userId, + paymentPlanId: helperId("plan-email"), + entityId: course.courseId, + entityType: CommonConstants.MembershipEntityType.COURSE, + status: CommonConstants.MembershipStatus.ACTIVE, + }, + { + domain: testDomain._id, + membershipId: helperId("membership-other"), + sessionId: helperId("session-other"), + userId: nonMatchingUser.userId, + paymentPlanId: helperId("plan-other"), + entityId: course.courseId, + entityType: CommonConstants.MembershipEntityType.COURSE, + status: CommonConstants.MembershipStatus.ACTIVE, + }, + ]); + + const members = await getMembers({ + courseId: course.courseId, + searchText: "student", + limit: 10, + ctx: { + subdomain: testDomain, + user: adminUser, + address: "", + }, + }); + + expect(members.map((member) => member.userId).sort()).toEqual( + [matchingByEmail.userId, matchingByName.userId].sort(), + ); + }); +}); diff --git a/apps/web/graphql/courses/__tests__/update-group-drip.test.ts b/apps/web/graphql/courses/__tests__/update-group-drip.test.ts index 7cee93738..4354e04dc 100644 --- a/apps/web/graphql/courses/__tests__/update-group-drip.test.ts +++ b/apps/web/graphql/courses/__tests__/update-group-drip.test.ts @@ -142,4 +142,266 @@ describe("updateGroup drip status updates", () => { expect(updatedCourse?.groups?.[0]?.drip).toBeUndefined(); }); + + it("rejects relative-date drip when delayInMillis is missing", async () => { + const groupId = id("group-no-delay"); + await CourseModel.create({ + domain: testDomain._id, + courseId: id("course-no-delay"), + title: id("course-title-no-delay"), + creatorId: adminUser.userId, + groups: [ + { + _id: groupId, + name: "Group 1", + rank: 1000, + collapsed: true, + lessonsOrder: [], + }, + ], + lessons: [], + type: "course", + privacy: "unlisted", + costType: "free", + cost: 0, + slug: id("slug-no-delay"), + }); + + await expect( + updateGroup({ + id: groupId, + courseId: id("course-no-delay"), + drip: { + type: Constants.dripType[0], + status: true, + }, + ctx: { + subdomain: testDomain, + user: adminUser, + address: "", + }, + }), + ).rejects.toThrow( + "Relative-date drip requires a numeric delayInMillis", + ); + }); + + it("rejects exact-date drip when dateInUTC is missing", async () => { + const groupId = id("group-no-date"); + await CourseModel.create({ + domain: testDomain._id, + courseId: id("course-no-date"), + title: id("course-title-no-date"), + creatorId: adminUser.userId, + groups: [ + { + _id: groupId, + name: "Group 1", + rank: 1000, + collapsed: true, + lessonsOrder: [], + }, + ], + lessons: [], + type: "course", + privacy: "unlisted", + costType: "free", + cost: 0, + slug: id("slug-no-date"), + }); + + await expect( + updateGroup({ + id: groupId, + courseId: id("course-no-date"), + drip: { + type: Constants.dripType[1], + status: true, + }, + ctx: { + subdomain: testDomain, + user: adminUser, + address: "", + }, + }), + ).rejects.toThrow("Exact-date drip requires a numeric dateInUTC"); + }); + + it("clears dateInUTC when switching from exact-date to relative-date", async () => { + const groupId = id("group-switch-to-relative"); + await CourseModel.create({ + domain: testDomain._id, + courseId: id("course-switch-to-relative"), + title: id("title-switch-to-relative"), + creatorId: adminUser.userId, + groups: [ + { + _id: groupId, + name: "Group 1", + rank: 1000, + collapsed: true, + lessonsOrder: [], + drip: { + status: true, + type: Constants.dripType[1], + delayInMillis: null, + dateInUTC: 1778929680000, + }, + }, + ], + lessons: [], + type: "course", + privacy: "unlisted", + costType: "free", + cost: 0, + slug: id("slug-switch-to-relative"), + }); + + await updateGroup({ + id: groupId, + courseId: id("course-switch-to-relative"), + drip: { + type: Constants.dripType[0], + delayInMillis: 3, + }, + ctx: { + subdomain: testDomain, + user: adminUser, + address: "", + }, + }); + + const updatedCourse = await CourseModel.findOne({ + domain: testDomain._id, + courseId: id("course-switch-to-relative"), + }).lean(); + expect(updatedCourse?.groups?.[0]?.drip?.type).toBe( + Constants.dripType[0], + ); + expect(updatedCourse?.groups?.[0]?.drip?.delayInMillis).toBe( + 3 * constants.relativeDripUnitInMillis, + ); + expect(updatedCourse?.groups?.[0]?.drip?.dateInUTC).toBeNull(); + }); + + it("clears delayInMillis when switching from relative-date to exact-date", async () => { + const groupId = id("group-switch-to-exact"); + await CourseModel.create({ + domain: testDomain._id, + courseId: id("course-switch-to-exact"), + title: id("title-switch-to-exact"), + creatorId: adminUser.userId, + groups: [ + { + _id: groupId, + name: "Group 1", + rank: 1000, + collapsed: true, + lessonsOrder: [], + drip: { + status: true, + type: Constants.dripType[0], + delayInMillis: 2 * constants.relativeDripUnitInMillis, + dateInUTC: null, + }, + }, + ], + lessons: [], + type: "course", + privacy: "unlisted", + costType: "free", + cost: 0, + slug: id("slug-switch-to-exact"), + }); + + await updateGroup({ + id: groupId, + courseId: id("course-switch-to-exact"), + drip: { + type: Constants.dripType[1], + dateInUTC: 1778929680000, + }, + ctx: { + subdomain: testDomain, + user: adminUser, + address: "", + }, + }); + + const updatedCourse = await CourseModel.findOne({ + domain: testDomain._id, + courseId: id("course-switch-to-exact"), + }).lean(); + expect(updatedCourse?.groups?.[0]?.drip?.type).toBe( + Constants.dripType[1], + ); + expect(updatedCourse?.groups?.[0]?.drip?.dateInUTC).toBe(1778929680000); + expect(updatedCourse?.groups?.[0]?.drip?.delayInMillis).toBeNull(); + }); + + it("does not null existing email on a partial drip update without email", async () => { + const groupId = id("group-keep-email"); + const existingEmail = { + content: { + content: [], + style: { colors: {}, typography: {}, structure: {} }, + meta: {}, + }, + subject: "Lesson available", + published: true, + delayInMillis: 0, + }; + // Insert with raw collection to bypass Mongoose schema validation on the email field + const collection = CourseModel.collection; + await collection.insertOne({ + domain: testDomain._id, + courseId: id("course-keep-email"), + title: id("title-keep-email"), + creatorId: adminUser.userId, + groups: [ + { + _id: groupId, + name: "Group 1", + rank: 1000, + collapsed: true, + lessonsOrder: [], + drip: { + status: true, + type: Constants.dripType[0], + delayInMillis: 2 * constants.relativeDripUnitInMillis, + email: existingEmail, + }, + }, + ], + lessons: [], + type: "course", + privacy: "unlisted", + costType: "free", + cost: 0, + slug: id("slug-keep-email"), + createdAt: new Date(), + updatedAt: new Date(), + }); + + await updateGroup({ + id: groupId, + courseId: id("course-keep-email"), + drip: { + type: Constants.dripType[0], + delayInMillis: 5, + status: false, + }, + ctx: { + subdomain: testDomain, + user: adminUser, + address: "", + }, + }); + + const updatedCourse = await CourseModel.findOne({ + domain: testDomain._id, + courseId: id("course-keep-email"), + }).lean(); + expect(updatedCourse?.groups?.[0]?.drip?.email).toEqual(existingEmail); + }); }); diff --git a/apps/web/graphql/courses/helpers.ts b/apps/web/graphql/courses/helpers.ts index b1796df4e..9bccf45ba 100644 --- a/apps/web/graphql/courses/helpers.ts +++ b/apps/web/graphql/courses/helpers.ts @@ -15,7 +15,7 @@ export const validateCourse = async ( ctx: GQLContext, ) => { if (courseData.type === Constants.CourseType.BLOG) { - if (!courseData.description) { + if (courseData.published && !courseData.description) { throw new Error(responses.blog_description_empty); } @@ -24,25 +24,6 @@ export const validateCourse = async ( } } - // if (courseData.costType !== constants.costPaid) { - // courseData.cost = 0; - // } - - // if (courseData.costType === constants.costPaid && courseData.cost < 0) { - // throw new Error(responses.invalid_cost); - // } - - // if ( - // courseData.type === constants.course && - // courseData.costType === constants.costEmail - // ) { - // throw new Error(responses.courses_cannot_be_downloaded); - // } - - // if (courseData.costType === constants.costPaid && courseData.cost > 0) { - // await validatePaymentMethod(ctx.subdomain._id.toString()); - // } - if ( courseData.type === Constants.CourseType.COURSE || courseData.type === Constants.CourseType.DOWNLOAD @@ -89,18 +70,6 @@ export const validateCourse = async ( return courseData; }; -// exports.validateCost = async (courseData, domain) => { -// if (courseData.cost < 0) { -// throw new Error(responses.invalid_cost); -// } - -// if (courseData.cost > 0) { -// await validatePaymentMethod(domain); -// } - -// return courseData; -// }; - export const getPaginatedCoursesForAdmin = async ({ query, page, diff --git a/apps/web/graphql/courses/logic.ts b/apps/web/graphql/courses/logic.ts index aa4075da8..6a7727b8d 100644 --- a/apps/web/graphql/courses/logic.ts +++ b/apps/web/graphql/courses/logic.ts @@ -45,7 +45,7 @@ import MembershipModel from "@models/Membership"; import { getActivities } from "../activities/logic"; import { ActivityType } from "@courselit/common-models/dist/constants"; import { verifyMandatoryTags } from "../mails/helpers"; -import { Email } from "@courselit/email-editor"; +import type { Email } from "@courselit/email-editor"; import PaymentPlanModel from "@models/PaymentPlan"; import CertificateTemplateModel, { CertificateTemplate, @@ -557,6 +557,8 @@ const getProductsQuery = ( tags?: string[], ids?: string[], publicView: boolean = false, + published?: boolean, + searchText?: string, ) => { const query: Record = { domain: ctx.subdomain._id, @@ -598,6 +600,14 @@ const getProductsQuery = ( }; } + if (!publicView && typeof published === "boolean") { + query.published = published; + } + + if (searchText) { + query.$text = { $search: searchText }; + } + return query; }; @@ -610,6 +620,8 @@ export const getProducts = async ({ ids, publicView, sort = -1, + published, + searchText, }: { ctx: GQLContext; page?: number; @@ -619,8 +631,18 @@ export const getProducts = async ({ ids?: string[]; publicView?: boolean; sort?: number; + published?: boolean; + searchText?: string; }): Promise => { - const query = getProductsQuery(ctx, filterBy, tags, ids, publicView); + const query = getProductsQuery( + ctx, + filterBy, + tags, + ids, + publicView, + published, + searchText, + ); const courses = await (CourseModel as any).paginatedFind(query, { page, @@ -703,6 +725,44 @@ export const getProductsCount = async ({ return await (CourseModel as any).countDocuments(query); }; +export const getCourseLessons = async ({ + courseId, + ctx, +}: { + courseId: string; + ctx: GQLContext; +}) => { + const course = await getCourseOrThrow(undefined, ctx, courseId); + + return await LessonModel.find({ + domain: ctx.subdomain._id, + courseId: course.courseId, + }).sort({ _id: 1 }); +}; + +export const getCourseLessonOrThrow = async ({ + courseId, + lessonId, + ctx, +}: { + courseId: string; + lessonId: string; + ctx: GQLContext; +}) => { + const course = await getCourseOrThrow(undefined, ctx, courseId); + const lesson = await LessonModel.findOne({ + domain: ctx.subdomain._id, + courseId: course.courseId, + lessonId, + }); + + if (!lesson) { + throw new Error(responses.item_not_found); + } + + return lesson; +}; + export const addGroup = async ({ id, name, @@ -859,33 +919,58 @@ export const updateGroup = async ({ $set["groups.$.drip.type"] = drip.type; } if (effectiveDripType === Constants.dripType[0]) { + if ( + drip.type === Constants.dripType[0] && + typeof drip.delayInMillis !== "number" + ) { + throw new Error( + "Relative-date drip requires a numeric delayInMillis", + ); + } if (typeof drip.delayInMillis === "number") { $set["groups.$.drip.delayInMillis"] = drip.delayInMillis * constants.relativeDripUnitInMillis; } - $set["groups.$.drip.dateInUTC"] = drip.dateInUTC; + if (drip.type === Constants.dripType[0]) { + $set["groups.$.drip.dateInUTC"] = null; + } else if (typeof drip.dateInUTC === "number") { + $set["groups.$.drip.dateInUTC"] = drip.dateInUTC; + } } if (effectiveDripType === Constants.dripType[1]) { - $set["groups.$.drip.delayInMillis"] = null; - if (drip.dateInUTC) { + if ( + drip.type === Constants.dripType[1] && + typeof drip.dateInUTC !== "number" + ) { + throw new Error("Exact-date drip requires a numeric dateInUTC"); + } + if (drip.type === Constants.dripType[1]) { + $set["groups.$.drip.delayInMillis"] = null; + } else if (typeof drip.delayInMillis === "number") { + $set["groups.$.drip.delayInMillis"] = + drip.delayInMillis * constants.relativeDripUnitInMillis; + } + if (typeof drip.dateInUTC === "number") { $set["groups.$.drip.dateInUTC"] = drip.dateInUTC; } } - if (drip.email) { - if (!drip.email.content || !drip.email.subject) { - throw new Error(responses.invalid_drip_email); + if (Object.prototype.hasOwnProperty.call(drip, "email")) { + if (drip.email) { + if (!drip.email.content || !drip.email.subject) { + throw new Error(responses.invalid_drip_email); + } + const parsedContent: Email = JSON.parse(drip.email.content); + verifyMandatoryTags(parsedContent.content); + + $set["groups.$.drip.email"] = { + content: parsedContent, + subject: drip.email.subject, + published: true, + delayInMillis: 0, + }; + } else { + $set["groups.$.drip.email"] = null; } - const parsedContent: Email = JSON.parse(drip.email.content); - verifyMandatoryTags(parsedContent.content); - - $set["groups.$.drip.email"] = { - content: parsedContent, - subject: drip.email.subject, - published: true, - delayInMillis: 0, - }; - } else { - $set["groups.$.drip.email"] = null; } } @@ -1048,12 +1133,14 @@ export const getMembers = async ({ page = 1, limit = 10, status, + searchText, }: { ctx: GQLContext; courseId: string; page?: number; limit?: number; status?: MembershipStatus; + searchText?: string; }): Promise< (Pick< Membership, @@ -1078,6 +1165,28 @@ export const getMembers = async ({ query.status = status; } + const normalizedSearchText = searchText?.trim(); + if (normalizedSearchText) { + const escapedSearchText = normalizedSearchText.replace( + /[.*+?^${}()|[\]\\]/g, + "\\$&", + ); + const matchingUsers = await UserModel.find({ + domain: ctx.subdomain._id, + $or: [ + { name: { $regex: escapedSearchText, $options: "i" } }, + { email: { $regex: escapedSearchText, $options: "i" } }, + ], + }).select("userId"); + const matchingUserIds = matchingUsers.map((user) => user.userId); + + if (!matchingUserIds.length) { + return []; + } + + query.userId = { $in: matchingUserIds }; + } + const members: Membership[] = await (MembershipModel as any).paginatedFind( query, { diff --git a/apps/web/graphql/courses/query.ts b/apps/web/graphql/courses/query.ts index ad192bd1c..040290d4c 100644 --- a/apps/web/graphql/courses/query.ts +++ b/apps/web/graphql/courses/query.ts @@ -182,6 +182,9 @@ export default { status: { type: userTypes.membershipStatusType, }, + searchText: { + type: GraphQLString, + }, }, resolve: ( _: any, @@ -190,11 +193,13 @@ export default { page, limit, status, + searchText, }: { courseId: string; page?: number; limit?: number; status?: MembershipStatus; + searchText?: string; }, ctx: GQLContext, ) => @@ -204,6 +209,7 @@ export default { page, limit, status, + searchText, }), }, getCourseCertificateTemplate: { diff --git a/apps/web/graphql/lessons/__tests__/visibility.test.ts b/apps/web/graphql/lessons/__tests__/visibility.test.ts index a62ff4236..77c6d5a61 100644 --- a/apps/web/graphql/lessons/__tests__/visibility.test.ts +++ b/apps/web/graphql/lessons/__tests__/visibility.test.ts @@ -5,8 +5,19 @@ import UserModel from "@/models/User"; import CourseModel from "@/models/Course"; import LessonModel from "@/models/Lesson"; import ActivityModel from "@/models/Activity"; -import { getAllLessons, getLessonDetails, markLessonCompleted } from "../logic"; +import { + createLesson, + getAllLessons, + getLessonDetails, + markLessonCompleted, +} from "../logic"; import { responses } from "@/config/strings"; +import { sealMedia } from "@/services/medialit"; + +jest.mock("@/services/medialit", () => ({ + deleteMedia: jest.fn(), + sealMedia: jest.fn(), +})); const SUITE_PREFIX = `lesson-visibility-${Date.now()}`; const id = (suffix: string) => `${SUITE_PREFIX}-${suffix}`; @@ -211,6 +222,59 @@ describe("Lesson visibility and progress", () => { ).rejects.toThrow(responses.item_not_found); }); + it("seals media before saving media-backed lessons on create", async () => { + const tempMediaId = id("temp-video-media"); + (sealMedia as jest.Mock).mockResolvedValueOnce({ + mediaId: tempMediaId, + originalFileName: "intro.mp4", + mimeType: "video/mp4", + size: 1234, + access: "private", + file: "https://media.example.com/temp-video.mp4", + thumbnail: "https://media.example.com/thumb.jpg", + }); + + const lesson = await createLesson( + { + title: "Video lesson", + type: Constants.LessonType.VIDEO, + content: JSON.stringify({}), + media: { + mediaId: tempMediaId, + originalFileName: "intro.mp4", + mimeType: "video/mp4", + size: 1234, + access: "private", + file: "https://media.example.com/temp-video.mp4", + thumbnail: "https://media.example.com/thumb.jpg", + }, + downloadable: false, + courseId: course.courseId, + groupId, + requiresEnrollment: true, + published: false, + } as any, + { + user: { + ...creator.toObject(), + permissions: ["course:manage"], + }, + subdomain: testDomain, + } as any, + ); + + expect(sealMedia).toHaveBeenCalledWith(tempMediaId); + expect(lesson.media?.mediaId).toBe(tempMediaId); + expect(lesson.media?.file).toBeUndefined(); + + const savedLesson = await LessonModel.findOne({ + lessonId: lesson.lessonId, + domain: testDomain._id, + }).lean(); + expect(savedLesson?.media?.mediaId).toBe(tempMediaId); + expect(savedLesson?.media?.file).toBeUndefined(); + }); + it("should hide unpublished lessons from owners in learner lesson details", async () => { await expect( getLessonDetails( diff --git a/apps/web/graphql/lessons/logic.ts b/apps/web/graphql/lessons/logic.ts index 3ad30bdb4..79ab86d46 100644 --- a/apps/web/graphql/lessons/logic.ts +++ b/apps/web/graphql/lessons/logic.ts @@ -21,6 +21,7 @@ import { deleteMedia, sealMedia } from "../../services/medialit"; import { recordProgress } from "../users/logic"; import { Constants, + Media, Progress, Quiz, ScormContent, @@ -147,6 +148,20 @@ export type LessonWithStringContent = Omit & { content: string; }; +async function sealLessonMedia(media?: Partial | null) { + if (!media?.mediaId) { + return undefined; + } + + const sealedMedia = await sealMedia(media.mediaId); + if (!sealedMedia) { + return media as Media; + } + + delete sealedMedia.file; + return sealedMedia; +} + export const createLesson = async ( lessonData: LessonWithStringContent, ctx: GQLContext, @@ -173,7 +188,7 @@ export const createLesson = async ( content: await replaceTempMediaWithSealedMediaInProseMirrorDoc( lessonData.content || "", ), - media: lessonData.media, + media: await sealLessonMedia(lessonData.media), downloadable: lessonData.downloadable, creatorId: ctx.user.userId, courseId: course.courseId, @@ -214,8 +229,38 @@ export const updateLesson = async ( delete (lessonData as any).id; lessonData.type = lesson.type; + + const contentUpdated = Object.prototype.hasOwnProperty.call( + lessonData, + "content", + ); + + // Build the complete lesson state for validation by merging existing + update data. + // The validator expects content as a string. + const completeLessonData: LessonWithStringContent = { + id: lesson.id, + domain: lesson.domain, + lessonId: lesson.lessonId, + creatorId: lesson.creatorId, + courseId: lesson.courseId, + groupId: lesson.groupId, + title: lessonData.title ?? lesson.title, + content: contentUpdated + ? lessonData.content! + : JSON.stringify(lesson.content || ""), + media: lessonData.media ?? lesson.media, + downloadable: lessonData.downloadable ?? lesson.downloadable, + requiresEnrollment: + lessonData.requiresEnrollment ?? lesson.requiresEnrollment, + published: lessonData.published ?? lesson.published, + type: lessonData.type, + }; + + lessonValidator(completeLessonData); + + // Now apply the partial updates to the lesson document const contentMediaIdsMarkedForDeletion: string[] = []; - if (Object.prototype.hasOwnProperty.call(lessonData, "content")) { + if (contentUpdated) { const nextContent = (lessonData.content ?? "") as string; contentMediaIdsMarkedForDeletion.push( ...getDeletedMediaIds( @@ -225,8 +270,6 @@ export const updateLesson = async ( ); } - lessonValidator(lessonData); - for (const key of Object.keys(lessonData)) { if (key === "content") { lesson.content = @@ -236,12 +279,8 @@ export const updateLesson = async ( ) : 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.media = await sealLessonMedia(lessonData.media); + } else if (key !== "lessonId" && key !== "id") { lesson[key] = lessonData[key]; } } @@ -370,7 +409,10 @@ export const markLessonCompleted = async ( ) => { checkIfAuthenticated(ctx); - const lesson = await LessonModel.findOne({ lessonId }); + const lesson = await LessonModel.findOne({ + domain: ctx.subdomain._id, + lessonId, + }); if (!lesson || !lesson.published) { throw new Error(responses.item_not_found); } @@ -469,7 +511,10 @@ const checkAndRecordCourseCompletion = async ( courseId: string, ctx: GQLContext, ) => { - const course = await CourseModel.findOne({ courseId }); + const course = await CourseModel.findOne({ + domain: ctx.subdomain._id, + courseId, + }); if (!course) { throw new Error(responses.item_not_found); } @@ -572,7 +617,12 @@ export const evaluateLesson = async ( answers: { answers: number[][] }, ctx: GQLContext, ) => { - const lesson = await LessonModel.findOne({ lessonId }); + checkIfAuthenticated(ctx); + + const lesson = await LessonModel.findOne({ + domain: ctx.subdomain._id, + lessonId, + }); if (!lesson) { throw new Error(responses.item_not_found); } diff --git a/apps/web/graphql/mails/default-email.ts b/apps/web/graphql/mails/default-email.ts index 2e7aa4e85..0323682e1 100644 --- a/apps/web/graphql/mails/default-email.ts +++ b/apps/web/graphql/mails/default-email.ts @@ -1,4 +1,4 @@ -import { EmailStyle, Email } from "@courselit/email-editor"; +import type { EmailStyle, Email } from "@courselit/email-editor"; export const defaultStyle: EmailStyle = { colors: { diff --git a/apps/web/graphql/mails/helpers.ts b/apps/web/graphql/mails/helpers.ts index a28fa9c0d..8b1bfad7d 100644 --- a/apps/web/graphql/mails/helpers.ts +++ b/apps/web/graphql/mails/helpers.ts @@ -9,7 +9,7 @@ import pug from "pug"; import digitalDownloadTemplate from "../../templates/download-link"; import { responses } from "@config/strings"; import { addMailJob } from "@/services/queue"; -import { EmailBlock } from "@courselit/email-editor"; +import type { EmailBlock } from "@courselit/email-editor"; import UserModel from "@models/User"; import { InternalCourse } from "@courselit/orm-models"; @@ -88,6 +88,7 @@ export async function createTemplateAndSendMail({ }); const creator = await UserModel.findOne({ + domain: ctx.subdomain._id, userId: course.creatorId, }).select("name"); diff --git a/apps/web/graphql/users/__tests__/delete-user.test.ts b/apps/web/graphql/users/__tests__/delete-user.test.ts index dae6a9ce9..223e04326 100644 --- a/apps/web/graphql/users/__tests__/delete-user.test.ts +++ b/apps/web/graphql/users/__tests__/delete-user.test.ts @@ -202,6 +202,30 @@ describe("deleteUser - Comprehensive Test Suite", () => { ); }); + it("should prevent deleting the domain owner used as the API actor", async () => { + const domainOwner = await UserModel.create({ + domain: testDomain._id, + userId: duId("domain-owner"), + name: "Domain Owner", + email: testDomain.email, + active: true, + permissions: [permissions.manageUsers], + purchases: [], + unsubscribeToken: duId("unsubscribe-domain-owner"), + }); + + await expect( + deleteUser(domainOwner.userId, mockCtx), + ).rejects.toThrow(responses.action_not_allowed); + + await expect( + UserModel.findOne({ + domain: testDomain._id, + userId: domainOwner.userId, + }), + ).resolves.toBeTruthy(); + }); + it("should throw error for non-existent user", async () => { await expect( deleteUser(duId("non-existent-user"), mockCtx), diff --git a/apps/web/graphql/users/__tests__/logic.test.ts b/apps/web/graphql/users/__tests__/logic.test.ts index 966d511b7..8d325e180 100644 --- a/apps/web/graphql/users/__tests__/logic.test.ts +++ b/apps/web/graphql/users/__tests__/logic.test.ts @@ -15,7 +15,12 @@ jest.mock("@/lib/trigger-sequences", () => ({ })); import mongoose from "mongoose"; -import { finalizeUserCreation, getCertificate } from "../logic"; +import { + finalizeUserCreation, + getCertificate, + updateUser, + findMembership, +} from "../logic"; import CertificateModel from "@models/Certificate"; import UserModel from "@models/User"; import CourseModel from "@models/Course"; @@ -25,7 +30,7 @@ import Domain from "@models/Domain"; import PageModel from "@models/Page"; import MembershipModel from "@models/Membership"; import CommunityModel from "@models/Community"; -import { Constants } from "@courselit/common-models"; +import { Constants, UIConstants } from "@courselit/common-models"; import { seedNotificationPreferencesForUser } from "../../notifications/logic"; import { recordActivity } from "@/lib/record-activity"; import { triggerSequences } from "@/lib/trigger-sequences"; @@ -35,6 +40,112 @@ const seedNotificationPreferencesForUserMock = const recordActivityMock = recordActivity as jest.Mock; const triggerSequencesMock = triggerSequences as jest.Mock; +describe("updateUser", () => { + const domainId = new mongoose.Types.ObjectId(); + const ownerEmail = "owner-permissions@example.com"; + + const ctx = { + subdomain: { + _id: domainId, + email: ownerEmail, + tags: [], + save: jest.fn(), + }, + user: { + userId: "admin", + email: "admin@example.com", + permissions: [UIConstants.permissions.manageUsers], + }, + } as any; + + afterEach(async () => { + await UserModel.deleteMany({ domain: domainId }); + ctx.subdomain.tags = []; + jest.clearAllMocks(); + }); + + it("prevents changing permissions for the school owner", async () => { + await UserModel.create({ + userId: "owner", + email: ownerEmail, + domain: domainId, + permissions: [UIConstants.permissions.manageUsers], + }); + + await expect( + updateUser( + { + id: "owner", + permissions: [], + }, + ctx, + ), + ).rejects.toThrow(responses.action_not_allowed); + + const owner = await UserModel.findOne({ + userId: "owner", + domain: domainId, + }).lean(); + expect(owner?.permissions).toEqual([ + UIConstants.permissions.manageUsers, + ]); + }); + + it("continues to allow non-permission updates for the school owner", async () => { + await UserModel.create({ + userId: "owner", + email: ownerEmail, + name: "Old Owner", + domain: domainId, + active: true, + permissions: [UIConstants.permissions.manageUsers], + }); + + await updateUser( + { + id: "owner", + name: "Updated Owner", + }, + ctx, + ); + + const owner = await UserModel.findOne({ + userId: "owner", + domain: domainId, + }).lean(); + expect(owner?.name).toBe("Updated Owner"); + expect(owner?.permissions).toEqual([ + UIConstants.permissions.manageUsers, + ]); + }); + + it("prevents deactivating the school owner", async () => { + await UserModel.create({ + userId: "owner", + email: ownerEmail, + domain: domainId, + active: true, + permissions: [UIConstants.permissions.manageUsers], + }); + + await expect( + updateUser( + { + id: "owner", + active: false, + }, + ctx, + ), + ).rejects.toThrow(responses.action_not_allowed); + + const owner = await UserModel.findOne({ + userId: "owner", + domain: domainId, + }).lean(); + expect(owner?.active).toBe(true); + }); +}); + describe("finalizeUserCreation", () => { const domainId = new mongoose.Types.ObjectId(); @@ -818,3 +929,83 @@ describe("Certificate generation", () => { expect(result.productPageId).toBe(null); }); }); + +describe("findMembership", () => { + const fId = (suffix: string) => `fm-${Date.now()}-${suffix}`; + let testDomain: any; + let testUser: any; + let testCourse: any; + + beforeAll(async () => { + testDomain = await Domain.create({ + name: fId("domain"), + email: `${fId("owner")}@example.com`, + }); + + testUser = await UserModel.create({ + userId: fId("user"), + email: `${fId("user")}@example.com`, + name: "Test User", + domain: testDomain._id, + active: true, + permissions: [], + purchases: [], + unsubscribeToken: fId("unsubscribe"), + }); + + testCourse = await CourseModel.create({ + domain: testDomain._id, + courseId: fId("course"), + title: "Test Course", + creatorId: testUser.userId, + groups: [], + lessons: [], + type: Constants.CourseType.COURSE, + privacy: "unlisted", + costType: "free", + cost: 0, + slug: fId("course-slug"), + }); + }); + + afterAll(async () => { + await MembershipModel.deleteMany({ domain: testDomain._id }); + await CourseModel.deleteMany({ domain: testDomain._id }); + await UserModel.deleteMany({ domain: testDomain._id }); + await Domain.deleteOne({ _id: testDomain._id }); + }); + + it("returns the membership when it exists", async () => { + await MembershipModel.create({ + domain: testDomain._id, + membershipId: fId("membership"), + sessionId: fId("session"), + userId: testUser.userId, + paymentPlanId: fId("plan"), + entityId: testCourse.courseId, + entityType: Constants.MembershipEntityType.COURSE, + status: Constants.MembershipStatus.ACTIVE, + }); + + const result = await findMembership({ + domainId: testDomain._id, + userId: testUser.userId, + entityId: testCourse.courseId, + }); + + expect(result).not.toBeNull(); + expect(result!.userId).toBe(testUser.userId); + expect(result!.entityId).toBe(testCourse.courseId); + expect(result!.status).toBe(Constants.MembershipStatus.ACTIVE); + }); + + it("returns null when no membership exists", async () => { + const result = await findMembership({ + domainId: testDomain._id, + userId: "nonexistent-user", + entityId: testCourse.courseId, + }); + + expect(result).toBeNull(); + }); +}); diff --git a/apps/web/graphql/users/logic.ts b/apps/web/graphql/users/logic.ts index 9461430f9..10e0e6888 100644 --- a/apps/web/graphql/users/logic.ts +++ b/apps/web/graphql/users/logic.ts @@ -133,9 +133,20 @@ export const updateUser = async (userData: UserData, ctx: GQLContext) => { }); if (!user) throw new Error(responses.item_not_found); + if (user.email === ctx.subdomain.email) { + const ownerProtectedKeys = ["permissions", "active"]; + if ( + ownerProtectedKeys.some((key) => + Object.prototype.hasOwnProperty.call(userData, key), + ) + ) { + throw new Error(responses.action_not_allowed); + } + } + for (const key of keys.filter((key) => key !== "id")) { if (key === "tags") { - addTags(userData["tags"]!, ctx); + await addTags(userData["tags"]!, ctx); } user[key] = userData[key]; @@ -260,7 +271,10 @@ export const deleteUser = async ( throw new Error(responses.user_not_found); } - if (userToDelete.userId === ctx.user.userId) { + if ( + userToDelete.userId === ctx.user.userId || + userToDelete.email === ctx.subdomain.email + ) { throw new Error(responses.action_not_allowed); } @@ -862,6 +876,25 @@ export const getMembership = async ({ return membership; }; +export async function findMembership({ + domainId, + userId, + entityId, + entityType = Constants.MembershipEntityType.COURSE, +}: { + domainId: mongoose.Types.ObjectId; + userId: string; + entityId: string; + entityType?: MembershipEntityType; +}): Promise { + return MembershipModel.findOne({ + domain: domainId, + userId, + entityType, + entityId, + }); +} + export async function runPostMembershipTasks({ domain, membership, diff --git a/apps/web/openapi/generated/openapi.json b/apps/web/openapi/generated/openapi.json index 63075a2ba..4b5d48874 100644 --- a/apps/web/openapi/generated/openapi.json +++ b/apps/web/openapi/generated/openapi.json @@ -15,6 +15,26 @@ { "name": "Users", "description": "Create and update users programmatically with an API key." + }, + { + "name": "Products", + "description": "Create, read, update, and delete products through the public REST API." + }, + { + "name": "Product Payment Plans", + "description": "Manage payment plans for course and download products." + }, + { + "name": "Product Content", + "description": "Manage sections and lessons within a product." + }, + { + "name": "Product Customers", + "description": "Enroll customers and read enrollment/progress snapshots." + }, + { + "name": "Media Uploads", + "description": "Generate MediaLit signatures for direct media uploads." } ], "paths": { @@ -22,7 +42,6 @@ "post": { "tags": ["Users"], "summary": "Create a user", - "description": "Creates a user in the current school. Call this endpoint on the school domain, for example `https://school.courselit.app/api/user`, and authenticate with `x-api-key`. The legacy `apikey` field in the request body is still accepted for backward compatibility.", "operationId": "createUser", "security": [ { @@ -153,7 +172,6 @@ "patch": { "tags": ["Users"], "summary": "Update a user", - "description": "Updates a user in the current school. Call this endpoint on the school domain, for example `https://school.courselit.app/api/user`, and authenticate with `x-api-key`. CourseLit proxy infrastructure resolves the school and injects the internal `domain` header automatically. The legacy `apikey` field in the request body is still accepted for backward compatibility.", "operationId": "updateUser", "security": [ { @@ -276,118 +294,2455 @@ } } } - } - }, - "components": { - "schemas": { - "CreateUserRequest": { - "type": "object", - "required": ["email"], - "properties": { - "apikey": { - "type": "string", - "description": "Deprecated legacy API key transport. Prefer the `x-api-key` header.", - "deprecated": true + }, + "/api/products": { + "get": { + "tags": ["Products"], + "summary": "List products", + "operationId": "listProducts", + "security": [ + { + "ApiKeyAuth": [] + } + ], + "parameters": [ + { + "name": "type", + "in": "query", + "schema": { + "type": "string", + "enum": ["course", "download", "blog"] + } }, - "email": { - "type": "string", - "format": "email", - "description": "Email address of the user to create." + { + "name": "published", + "in": "query", + "schema": { + "type": "boolean" + } }, - "name": { - "type": "string", - "description": "Display name for the user." + { + "name": "search", + "in": "query", + "schema": { + "type": "string" + } }, - "permissions": { - "type": "array", - "description": "Permissions to assign to the user.", - "items": { - "type": "string", - "enum": [ - "course:manage", - "course:manage_any", - "course:publish", - "course:enroll", - "media:manage", - "site:manage", - "setting:manage", - "user:manage", - "community:manage" - ] + { + "name": "page", + "in": "query", + "schema": { + "type": "integer", + "default": 1, + "minimum": 1 } }, - "subscribedToUpdates": { - "type": "boolean", - "description": "Whether the user should be subscribed to marketing updates." + { + "name": "limit", + "in": "query", + "schema": { + "type": "integer", + "default": 50, + "minimum": 1, + "maximum": 200 + } + } + ], + "responses": { + "200": { + "description": "Products returned successfully.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProductListResponse" + } + } + } + }, + "401": { + "description": "Invalid API key.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicApiErrorResponse" + } + } + } } } }, - "UpdateUserRequest": { - "type": "object", - "required": ["email"], - "properties": { - "apikey": { - "type": "string", - "description": "Deprecated legacy API key transport. Prefer the `x-api-key` header.", - "deprecated": true - }, - "email": { - "type": "string", - "format": "email", - "description": "Email address of the user to update." + "post": { + "tags": ["Products"], + "summary": "Create a draft product", + "operationId": "createProduct", + "security": [ + { + "ApiKeyAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProductCreateRequest" + }, + "example": { + "title": "AI Foundations", + "type": "course" + } + } + } + }, + "responses": { + "201": { + "description": "Success.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Product" + } + } + } }, - "name": { - "type": "string", - "description": "Updated display name for the user." + "400": { + "description": "Unsupported or invalid product field.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicApiErrorResponse" + } + } + } }, - "permissions": { - "type": "array", - "description": "Updated permissions for the user.", - "items": { - "type": "string", - "enum": [ - "course:manage", - "course:manage_any", - "course:publish", - "course:enroll", - "media:manage", - "site:manage", - "setting:manage", - "user:manage", - "community:manage" - ] + "422": { + "description": "Product could not be created.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicApiErrorResponse" + } + } + } + } + } + } + }, + "/api/products/{productId}": { + "get": { + "tags": ["Products"], + "summary": "Get a product", + "operationId": "getProduct", + "security": [ + { + "ApiKeyAuth": [] + } + ], + "parameters": [ + { + "name": "productId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Product" + } + } } }, - "subscribedToUpdates": { - "type": "boolean", - "description": "Updated marketing subscription preference for the user." + "404": { + "description": "Product not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicApiErrorResponse" + } + } + } } } }, - "UserMutationSuccess": { - "type": "object", - "required": ["email"], - "properties": { - "email": { - "type": "string", - "description": "MD5 hash of the user email.", - "example": "5d41402abc4b2a76b9719d911017c592" + "patch": { + "tags": ["Products"], + "summary": "Update product metadata", + "operationId": "updateProduct", + "security": [ + { + "ApiKeyAuth": [] + } + ], + "parameters": [ + { + "name": "productId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProductUpdateRequest" + }, + "example": { + "title": "AI Foundations", + "slug": "ai-foundations", + "description": "{\"type\":\"doc\",\"content\":[{\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"Updated course description.\"}]}]}", + "published": false, + "privacy": "unlisted", + "tags": ["ai", "beginner"] + } + } + } + }, + "responses": { + "200": { + "description": "Success.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Product" + } + } + } + }, + "400": { + "description": "Malformed JSON body, non-object body, or unsupported product field.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicApiErrorResponse" + } + } + } + }, + "422": { + "description": "Product could not be updated.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicApiErrorResponse" + } + } + } } } }, - "ErrorResponse": { - "type": "object", - "description": "Error payload returned by the CourseLit user API. Depending on the code path, the message may appear in `message` or `error`.", - "properties": { - "message": { - "type": "string", - "description": "Error message returned by validation and auth failures.", - "example": "Bad request" - }, - "error": { - "type": "string", - "description": "Error message returned by application-level failures.", - "example": "Internal server error" + "delete": { + "tags": ["Products"], + "summary": "Delete a product", + "operationId": "deleteProduct", + "security": [ + { + "ApiKeyAuth": [] + } + ], + "parameters": [ + { + "name": "productId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OkResponse" + } + } + } + }, + "422": { + "description": "Product could not be deleted.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicApiErrorResponse" + } + } + } + } + } + } + }, + "/api/products/{productId}/payment-plans": { + "get": { + "tags": ["Product Payment Plans"], + "summary": "List product payment plans", + "operationId": "listProductPaymentPlans", + "security": [ + { + "ApiKeyAuth": [] + } + ], + "parameters": [ + { + "name": "productId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaymentPlanListResponse" + } + } + } + }, + "422": { + "description": "Payment plans could not be fetched.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicApiErrorResponse" + } + } + } + } + } + }, + "post": { + "tags": ["Product Payment Plans"], + "summary": "Create a product payment plan", + "operationId": "createProductPaymentPlan", + "security": [ + { + "ApiKeyAuth": [] + } + ], + "parameters": [ + { + "name": "productId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaymentPlanCreateRequest" + }, + "example": { + "name": "Lifetime access", + "type": "onetime", + "oneTimeAmount": 9900, + "description": "One-time payment for lifetime product access." + } + } + } + }, + "responses": { + "201": { + "description": "Success.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaymentPlan" + } + } + } + }, + "400": { + "description": "Unsupported payment plan field.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicApiErrorResponse" + } + } + } + }, + "422": { + "description": "Payment plan validation failed.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicApiErrorResponse" + } + } + } + } + } + } + }, + "/api/products/{productId}/payment-plans/{planId}": { + "get": { + "tags": ["Product Payment Plans"], + "summary": "Get a product payment plan", + "operationId": "getProductPaymentPlan", + "security": [ + { + "ApiKeyAuth": [] + } + ], + "parameters": [ + { + "name": "productId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "planId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaymentPlan" + } + } + } + }, + "404": { + "description": "Payment plan not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicApiErrorResponse" + } + } + } + } + } + }, + "patch": { + "tags": ["Product Payment Plans"], + "summary": "Update a product payment plan", + "operationId": "updateProductPaymentPlan", + "security": [ + { + "ApiKeyAuth": [] + } + ], + "parameters": [ + { + "name": "productId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "planId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaymentPlanUpdateRequest" + }, + "example": { + "name": "Updated lifetime access", + "oneTimeAmount": 12900, + "description": "Updated one-time payment plan." + } + } + } + }, + "responses": { + "200": { + "description": "Success.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaymentPlan" + } + } + } + }, + "400": { + "description": "Unsupported payment plan field.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicApiErrorResponse" + } + } + } + }, + "422": { + "description": "Payment plan validation failed.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicApiErrorResponse" + } + } + } + } + } + }, + "delete": { + "tags": ["Product Payment Plans"], + "summary": "Archive a product payment plan", + "operationId": "archiveProductPaymentPlan", + "security": [ + { + "ApiKeyAuth": [] + } + ], + "parameters": [ + { + "name": "productId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "planId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaymentPlan" + } + } + } + }, + "422": { + "description": "Payment plan could not be archived.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicApiErrorResponse" + } + } + } + } + } + } + }, + "/api/products/{productId}/payment-plans/{planId}/default": { + "post": { + "tags": ["Product Payment Plans"], + "summary": "Set the default product payment plan", + "operationId": "setDefaultProductPaymentPlan", + "security": [ + { + "ApiKeyAuth": [] + } + ], + "parameters": [ + { + "name": "productId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "planId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaymentPlan" + } + } + } + }, + "422": { + "description": "Default plan could not be changed.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicApiErrorResponse" + } + } + } + } + } + } + }, + "/api/products/{productId}/sections": { + "get": { + "tags": ["Product Content"], + "summary": "List product sections", + "operationId": "listProductSections", + "security": [ + { + "ApiKeyAuth": [] + } + ], + "parameters": [ + { + "name": "productId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SectionListResponse" + } + } + } + }, + "404": { + "description": "Product not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicApiErrorResponse" + } + } + } + } + } + }, + "post": { + "tags": ["Product Content"], + "summary": "Create a product section", + "operationId": "createProductSection", + "security": [ + { + "ApiKeyAuth": [] + } + ], + "parameters": [ + { + "name": "productId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SectionCreateRequest" + }, + "example": { + "name": "Getting started" + } + } + } + }, + "responses": { + "201": { + "description": "Success.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Section" + } + } + } + }, + "422": { + "description": "Section could not be created.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicApiErrorResponse" + } + } + } + } + } + } + }, + "/api/products/{productId}/sections/{sectionId}": { + "patch": { + "tags": ["Product Content"], + "summary": "Update a product section", + "operationId": "updateProductSection", + "security": [ + { + "ApiKeyAuth": [] + } + ], + "parameters": [ + { + "name": "productId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "sectionId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SectionUpdateRequest" + }, + "example": { + "name": "Updated getting started", + "drip": { + "status": true, + "type": "relative-date", + "delayInMillis": 2 + } + } + } + } + }, + "responses": { + "200": { + "description": "Success.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Section" + } + } + } + }, + "400": { + "description": "Unsupported section field or invalid drip configuration.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicApiErrorResponse" + } + } + } + }, + "422": { + "description": "Section could not be updated.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicApiErrorResponse" + } + } + } + } + } + }, + "delete": { + "tags": ["Product Content"], + "summary": "Delete a product section", + "operationId": "deleteProductSection", + "security": [ + { + "ApiKeyAuth": [] + } + ], + "parameters": [ + { + "name": "productId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "sectionId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OkResponse" + } + } + } + }, + "422": { + "description": "Section could not be deleted.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicApiErrorResponse" + } + } + } + } + } + } + }, + "/api/products/{productId}/sections/reorder": { + "post": { + "tags": ["Product Content"], + "summary": "Reorder product sections", + "operationId": "reorderProductSections", + "security": [ + { + "ApiKeyAuth": [] + } + ], + "parameters": [ + { + "name": "productId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "sectionIds": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["sectionIds"] + } + } + } + }, + "responses": { + "200": { + "description": "Success.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OkResponse" + } + } + } + }, + "422": { + "description": "Sections could not be reordered.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicApiErrorResponse" + } + } + } + } + } + } + }, + "/api/products/{productId}/lessons": { + "get": { + "tags": ["Product Content"], + "summary": "List product lessons", + "operationId": "listProductLessons", + "security": [ + { + "ApiKeyAuth": [] + } + ], + "parameters": [ + { + "name": "productId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LessonListResponse" + } + } + } + }, + "404": { + "description": "Product not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicApiErrorResponse" + } + } + } + } + } + }, + "post": { + "tags": ["Product Content"], + "summary": "Create a product lesson", + "description": "Creates a lesson. `text` lessons accept Tiptap/ProseMirror JSON in `content`; `embed` lessons accept `{ value }` in `content`; `quiz` lessons accept quiz JSON in `content`; media-backed lessons (`video`, `audio`, `pdf`, `file`) use `media`. SCORM lessons are not supported.", + "operationId": "createProductLesson", + "security": [ + { + "ApiKeyAuth": [] + } + ], + "parameters": [ + { + "name": "productId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LessonCreateRequest" + }, + "example": { + "title": "Introduction to AI", + "type": "text", + "groupId": "section_abc123", + "content": { + "type": "doc", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Welcome to the course!" + } + ] + } + ] + }, + "requiresEnrollment": true, + "published": false + } + } + } + }, + "responses": { + "201": { + "description": "Success.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Lesson" + } + } + } + }, + "422": { + "description": "SCORM lessons are not supported, or lesson validation failed.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicApiErrorResponse" + } + } + } + } + } + } + }, + "/api/products/{productId}/lessons/{lessonId}": { + "get": { + "tags": ["Product Content"], + "summary": "Get a product lesson", + "operationId": "getProductLesson", + "security": [ + { + "ApiKeyAuth": [] + } + ], + "parameters": [ + { + "name": "productId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "lessonId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Lesson" + } + } + } + }, + "404": { + "description": "Lesson not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicApiErrorResponse" + } + } + } + } + } + }, + "patch": { + "tags": ["Product Content"], + "summary": "Update a product lesson", + "description": "Updates editable lesson fields. Lesson type and section cannot be changed after creation. Use the same content/media shapes documented on create. SCORM lesson updates are rejected with `not_supported`.", + "operationId": "updateProductLesson", + "security": [ + { + "ApiKeyAuth": [] + } + ], + "parameters": [ + { + "name": "productId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "lessonId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LessonUpdateRequest" + }, + "example": { + "title": "Updated Introduction to AI", + "published": true + } + } + } + }, + "responses": { + "200": { + "description": "Success.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Lesson" + } + } + } + }, + "422": { + "description": "SCORM lessons are not supported, or lesson validation failed.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicApiErrorResponse" + } + } + } + } + } + }, + "delete": { + "tags": ["Product Content"], + "summary": "Delete a product lesson", + "operationId": "deleteProductLesson", + "security": [ + { + "ApiKeyAuth": [] + } + ], + "parameters": [ + { + "name": "productId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "lessonId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OkResponse" + } + } + } + }, + "422": { + "description": "Lesson could not be deleted.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicApiErrorResponse" + } + } + } + } + } + } + }, + "/api/products/{productId}/lessons/{lessonId}/move": { + "post": { + "tags": ["Product Content"], + "summary": "Move a lesson to another section", + "operationId": "moveProductLesson", + "security": [ + { + "ApiKeyAuth": [] + } + ], + "parameters": [ + { + "name": "productId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "lessonId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "destinationSectionId": { + "type": "string" + }, + "destinationIndex": { + "type": "integer" + } + }, + "required": [ + "destinationSectionId", + "destinationIndex" + ] + } + } + } + }, + "responses": { + "200": { + "description": "Success.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OkResponse" + } + } + } + }, + "422": { + "description": "Lesson could not be moved.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicApiErrorResponse" + } + } + } + } + } + } + }, + "/api/products/{productId}/customers": { + "get": { + "tags": ["Product Customers"], + "summary": "List product customers", + "operationId": "listProductCustomers", + "security": [ + { + "ApiKeyAuth": [] + } + ], + "parameters": [ + { + "name": "productId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "search", + "in": "query", + "description": "Search customers by name or email.", + "schema": { + "type": "string" + } + }, + { + "name": "page", + "in": "query", + "schema": { + "type": "integer", + "default": 1, + "minimum": 1 + } + }, + { + "name": "limit", + "in": "query", + "schema": { + "type": "integer", + "default": 50, + "minimum": 1, + "maximum": 200 + } + } + ], + "responses": { + "200": { + "description": "Success.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CustomerListResponse" + } + } + } + } + } + } + }, + "/api/products/{productId}/customers/invitations": { + "post": { + "tags": ["Product Customers"], + "summary": "Invite a customer", + "description": "Invites a customer by email. An invitation email is sent to the provided address.", + "operationId": "inviteProductCustomer", + "security": [ + { + "ApiKeyAuth": [] + } + ], + "parameters": [ + { + "name": "productId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "email": { + "type": "string", + "format": "email" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["email"] + } + } + } + }, + "responses": { + "201": { + "description": "Success.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Customer" + } + } + } + }, + "400": { + "description": "Unsupported customer invitation field or missing email.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicApiErrorResponse" + } + } + } + }, + "422": { + "description": "Customer could not be invited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicApiErrorResponse" + } + } + } + } + } + } + }, + "/api/products/{productId}/customers/{userId}/progress": { + "get": { + "tags": ["Product Customers"], + "summary": "Get product customer progress", + "description": "Returns customer progress details including completed lessons.", + "operationId": "getProductCustomerProgress", + "security": [ + { + "ApiKeyAuth": [] + } + ], + "parameters": [ + { + "name": "productId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "userId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Progress" + } + } + } + }, + "404": { + "description": "Customer progress not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicApiErrorResponse" + } + } + } + } + } + } + }, + "/api/media/presigned": { + "post": { + "tags": ["Media Uploads"], + "summary": "Generate a MediaLit upload signature", + "description": "Returns a short-lived upload signature and endpoint for direct file uploads. See `https://docs.medialit.cloud/api/uploadMedia` for the upload request format.", + "operationId": "createMediaUploadSignature", + "security": [ + { + "ApiKeyAuth": [] + } + ], + "responses": { + "200": { + "description": "MediaLit upload signature generated successfully.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MediaPresignedResponse" + }, + "examples": { + "success": { + "value": { + "signature": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "endpoint": "https://media.example.com" + } + } + } + } + } + }, + "401": { + "description": "Invalid API key, or no active CourseLit dashboard session was found for the dashboard-only auth path." + }, + "403": { + "description": "The resolved school owner or logged-in dashboard user does not have `media:manage` permission.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MediaErrorResponse" + } + } + } + }, + "404": { + "description": "Domain not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MediaErrorResponse" + } + } + } + }, + "500": { + "description": "MediaLit signature generation failed.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MediaErrorResponse" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "CreateUserRequest": { + "type": "object", + "required": ["email"], + "properties": { + "apikey": { + "type": "string", + "description": "Deprecated legacy API key transport. Prefer the `x-api-key` header.", + "deprecated": true + }, + "email": { + "type": "string", + "format": "email", + "description": "Email address of the user to create." + }, + "name": { + "type": "string", + "description": "Display name for the user." + }, + "permissions": { + "type": "array", + "description": "Permissions to assign to the user.", + "items": { + "type": "string", + "enum": [ + "course:manage", + "course:manage_any", + "course:publish", + "course:enroll", + "media:manage", + "site:manage", + "setting:manage", + "user:manage", + "community:manage" + ] + } + }, + "subscribedToUpdates": { + "type": "boolean", + "description": "Whether the user should be subscribed to marketing updates." + } + } + }, + "UpdateUserRequest": { + "type": "object", + "required": ["email"], + "properties": { + "apikey": { + "type": "string", + "description": "Deprecated legacy API key transport. Prefer the `x-api-key` header.", + "deprecated": true + }, + "email": { + "type": "string", + "format": "email", + "description": "Email address of the user to update." + }, + "name": { + "type": "string", + "description": "Updated display name for the user." + }, + "permissions": { + "type": "array", + "description": "Updated permissions for the user.", + "items": { + "type": "string", + "enum": [ + "course:manage", + "course:manage_any", + "course:publish", + "course:enroll", + "media:manage", + "site:manage", + "setting:manage", + "user:manage", + "community:manage" + ] + } + }, + "subscribedToUpdates": { + "type": "boolean", + "description": "Updated marketing subscription preference for the user." + } + } + }, + "UserMutationSuccess": { + "type": "object", + "required": ["email"], + "properties": { + "email": { + "type": "string", + "description": "MD5 hash of the user email.", + "example": "5d41402abc4b2a76b9719d911017c592" + } + } + }, + "ErrorResponse": { + "type": "object", + "description": "Error payload returned by the CourseLit user API. Depending on the code path, the message may appear in `message` or `error`.", + "properties": { + "message": { + "type": "string", + "description": "Error message returned by validation and auth failures.", + "example": "Bad request" + }, + "error": { + "type": "string", + "description": "Error message returned by application-level failures.", + "example": "Internal server error" + } + } + }, + "PublicApiErrorResponse": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"] + }, + "OkResponse": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + } + } + }, + "PaymentPlan": { + "type": "object", + "properties": { + "planId": { + "type": "string" + }, + "name": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["free", "onetime", "emi", "subscription"] + }, + "entityId": { + "type": "string" + }, + "entityType": { + "type": "string" + }, + "oneTimeAmount": { + "type": "number" + }, + "emiAmount": { + "type": "number" + }, + "emiTotalInstallments": { + "type": "number" + }, + "subscriptionMonthlyAmount": { + "type": "number" + }, + "subscriptionYearlyAmount": { + "type": "number" + }, + "description": { + "type": "string" + }, + "isDefault": { + "type": "boolean" + } + } + }, + "PaymentPlanCreateRequest": { + "type": "object", + "description": "Create a product-owned payment plan. `onetime` requires `oneTimeAmount`; `emi` requires `emiAmount` and `emiTotalInstallments`; `subscription` requires exactly one of `subscriptionMonthlyAmount` or `subscriptionYearlyAmount`.", + "required": ["name", "type"], + "properties": { + "name": { + "type": "string", + "description": "Payment plan name shown to customers." + }, + "type": { + "type": "string", + "enum": ["free", "onetime", "emi", "subscription"] + }, + "oneTimeAmount": { + "type": "number", + "description": "Required when `type` is `onetime`." + }, + "emiAmount": { + "type": "number", + "description": "Required when `type` is `emi`." + }, + "emiTotalInstallments": { + "type": "number", + "description": "Required when `type` is `emi`." + }, + "subscriptionMonthlyAmount": { + "type": "number", + "description": "Use for monthly subscriptions. For `subscription`, provide exactly one subscription amount." + }, + "subscriptionYearlyAmount": { + "type": "number", + "description": "Use for yearly subscriptions. For `subscription`, provide exactly one subscription amount." + }, + "description": { + "type": "string" + } + } + }, + "PaymentPlanUpdateRequest": { + "type": "object", + "description": "Update editable payment plan fields.", + "properties": { + "name": { + "type": "string", + "description": "Payment plan name shown to customers." + }, + "type": { + "type": "string", + "enum": ["free", "onetime", "emi", "subscription"] + }, + "oneTimeAmount": { + "type": "number", + "description": "Required when `type` is `onetime`." + }, + "emiAmount": { + "type": "number", + "description": "Required when `type` is `emi`." + }, + "emiTotalInstallments": { + "type": "number", + "description": "Required when `type` is `emi`." + }, + "subscriptionMonthlyAmount": { + "type": "number", + "description": "Use for monthly subscriptions. For `subscription`, provide exactly one subscription amount." + }, + "subscriptionYearlyAmount": { + "type": "number", + "description": "Use for yearly subscriptions. For `subscription`, provide exactly one subscription amount." + }, + "description": { + "type": "string" + } + } + }, + "PaymentPlanListResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PaymentPlan" + } + } + } + }, + "Product": { + "type": "object", + "properties": { + "productId": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["course", "download", "blog"] + }, + "title": { + "type": "string" + }, + "slug": { + "type": "string" + }, + "description": { + "type": "string" + }, + "published": { + "type": "boolean" + }, + "privacy": { + "type": "string" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "featuredImage": { + "type": "object" + }, + "pageId": { + "type": "string" + }, + "defaultPaymentPlan": { + "type": "string" + }, + "paymentPlans": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PaymentPlan" + } + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } + } + }, + "ProductCreateRequest": { + "type": "object", + "required": ["title", "type"], + "description": "Payload for creating a draft product. Send `title` and `type`. After creation, use the update endpoint for metadata, publish, and privacy changes.", + "properties": { + "title": { + "type": "string", + "description": "Product title shown in CourseLit.", + "example": "AI Foundations" + }, + "type": { + "type": "string", + "enum": ["course", "download", "blog"], + "description": "CourseLit product type. Use `course` for lesson-based learning products.", + "example": "course" + } + } + }, + "ProductUpdateRequest": { + "type": "object", + "description": "Payload for updating product metadata. A product must have at least one payment plan before it can be published.", + "properties": { + "title": { + "type": "string", + "description": "Product title shown in CourseLit.", + "example": "AI Foundations" + }, + "slug": { + "type": "string", + "description": "Optional URL slug.", + "example": "ai-foundations" + }, + "description": { + "type": "string", + "description": "Optional product/blog description. Send this as a JSON-stringified Tiptap/ProseMirror document, for example `JSON.stringify({ type: \"doc\", content: [] })`. Do not use a `content` field on this endpoint.", + "example": "{\"type\":\"doc\",\"content\":[{\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"Updated course description.\"}]}]}" + }, + "published": { + "type": "boolean", + "description": "Whether the product is published. Existing CourseLit publishing checks still apply.", + "example": false + }, + "privacy": { + "type": "string", + "description": "Existing CourseLit product privacy value.", + "example": "unlisted" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "featuredImage": { + "type": "object" + } + } + }, + "ProductListResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Product" + } + }, + "pagination": { + "type": "object", + "properties": { + "page": { + "type": "integer" + }, + "limit": { + "type": "integer" + } + } + } + } + }, + "Section": { + "type": "object", + "properties": { + "sectionId": { + "type": "string" + }, + "name": { + "type": "string" + }, + "rank": { + "type": "number" + }, + "collapsed": { + "type": "boolean" + }, + "drip": { + "$ref": "#/components/schemas/SectionDrip" + }, + "lessonsOrder": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "SectionCreateRequest": { + "type": "object", + "required": ["name"], + "description": "Payload for creating a section. Requires only `name`.", + "properties": { + "name": { + "type": "string" + } + } + }, + "SectionUpdateRequest": { + "type": "object", + "description": "Payload for updating a section. Supports updating `name` and scheduled release (`drip`) settings.", + "properties": { + "name": { + "type": "string" + }, + "drip": { + "$ref": "#/components/schemas/SectionDripInput" + } + } + }, + "SectionDripInput": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["relative-date", "exact-date"] + }, + "status": { + "type": "boolean" + }, + "delayInMillis": { + "type": "number", + "description": "Delay in milliseconds for `relative-date` drip. The input accepts a number interpreted as days (e.g. 3 = three days), but the value is persisted in millisecond equivalent (e.g. 259200000). The endpoint output always returns the stored millisecond value." + }, + "dateInUTC": { + "type": "number", + "description": "UNIX timestamp in milliseconds for `exact-date` drip. Both input and output use millisecond precision." + } + } + }, + "SectionDrip": { + "$ref": "#/components/schemas/SectionDripInput" + }, + "SectionListResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Section" + } + } + } + }, + "TiptapDocument": { + "type": "object", + "description": "Tiptap/ProseMirror document JSON used by `text` lessons.", + "required": ["type", "content"], + "properties": { + "type": { + "type": "string", + "example": "doc" + }, + "content": { + "type": "array", + "items": { + "type": "object" + } + } + } + }, + "EmbedContent": { + "type": "object", + "description": "Embed content for `embed` lessons. The value can be a supported video URL or iframe/embed code, matching the dashboard Embed lesson field.", + "required": ["value"], + "properties": { + "value": { + "type": "string", + "example": "https://www.youtube.com/watch?v=dQw4w9WgXcQ" + } + } + }, + "QuizContent": { + "type": "object", + "description": "Quiz content for `quiz` lesson type.", + "required": [ + "questions", + "requiresPassingGrade", + "passingGrade" + ], + "properties": { + "questions": { + "type": "array", + "description": "List of quiz questions.", + "items": { + "type": "object", + "required": ["text", "options"], + "properties": { + "text": { + "type": "string", + "description": "Question text." + }, + "options": { + "type": "array", + "description": "Answer options. Exactly one option should have `correctAnswer: true` for single-choice; multiple for multiple-choice.", + "items": { + "type": "object", + "required": ["text"], + "properties": { + "text": { + "type": "string", + "description": "Option text." + }, + "correctAnswer": { + "type": "boolean", + "description": "Whether this is the correct answer. Stripped from student-facing responses." + } + } + } + } + } + } + }, + "requiresPassingGrade": { + "type": "boolean", + "description": "Whether a minimum score is required to pass." + }, + "passingGrade": { + "type": "number", + "description": "Score threshold (0–100) when `requiresPassingGrade` is true." + } + } + }, + "LessonMedia": { + "type": "object", + "description": "Media object used by `video`, `audio`, `pdf`, and `file` lessons. Use `/api/media/presigned` to upload to MediaLit first, then send the resulting media metadata here.", + "required": ["mediaId"], + "properties": { + "mediaId": { + "type": "string" + }, + "originalFileName": { + "type": "string" + }, + "mimeType": { + "type": "string" + }, + "size": { + "type": "number" + }, + "access": { + "type": "string", + "enum": ["public", "private"] + }, + "file": { + "type": "string", + "description": "Public file URL when available. Private media may omit this field." + }, + "thumbnail": { + "type": "string" + }, + "caption": { + "type": "string" + } + } + }, + "Lesson": { + "type": "object", + "properties": { + "lessonId": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "text", + "video", + "audio", + "pdf", + "file", + "embed", + "quiz" + ] + }, + "content": { + "oneOf": [ + { + "$ref": "#/components/schemas/TiptapDocument" + }, + { + "$ref": "#/components/schemas/EmbedContent" + }, + { + "$ref": "#/components/schemas/QuizContent" + } + ], + "description": "`text` lessons use `TiptapDocument`; `embed` lessons use `EmbedContent`; `quiz` lessons use `QuizContent`. Media-backed lessons (`video`, `audio`, `pdf`, `file`) use `media` instead of `content`." + }, + "media": { + "$ref": "#/components/schemas/LessonMedia" + }, + "downloadable": { + "type": "boolean" + }, + "courseId": { + "type": "string" + }, + "groupId": { + "type": "string" + }, + "requiresEnrollment": { + "type": "boolean" + }, + "published": { + "type": "boolean" + } + } + }, + "LessonCreateRequest": { + "type": "object", + "required": ["title", "type", "groupId"], + "properties": { + "title": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "text", + "video", + "audio", + "pdf", + "file", + "embed", + "quiz" + ] + }, + "content": { + "oneOf": [ + { + "$ref": "#/components/schemas/TiptapDocument" + }, + { + "$ref": "#/components/schemas/EmbedContent" + }, + { + "$ref": "#/components/schemas/QuizContent" + } + ], + "description": "`text` lessons use `TiptapDocument`; `embed` lessons use `EmbedContent`; `quiz` lessons use `QuizContent`. Media-backed lessons (`video`, `audio`, `pdf`, `file`) use `media` instead of `content`." + }, + "media": { + "$ref": "#/components/schemas/LessonMedia" + }, + "downloadable": { + "type": "boolean" + }, + "groupId": { + "type": "string" + }, + "requiresEnrollment": { + "type": "boolean" + }, + "published": { + "type": "boolean" + } + } + }, + "LessonUpdateRequest": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "content": { + "oneOf": [ + { + "$ref": "#/components/schemas/TiptapDocument" + }, + { + "$ref": "#/components/schemas/EmbedContent" + }, + { + "$ref": "#/components/schemas/QuizContent" + } + ], + "description": "`text` lessons use `TiptapDocument`; `embed` lessons use `EmbedContent`; `quiz` lessons use `QuizContent`. Media-backed lessons (`video`, `audio`, `pdf`, `file`) use `media` instead of `content`." + }, + "media": { + "$ref": "#/components/schemas/LessonMedia" + }, + "downloadable": { + "type": "boolean" + }, + "requiresEnrollment": { + "type": "boolean" + }, + "published": { + "type": "boolean" + } + } + }, + "LessonListResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Lesson" + } + } + } + }, + "Customer": { + "type": "object", + "properties": { + "userId": { + "type": "string" + }, + "email": { + "type": "string" + }, + "name": { + "type": "string" + }, + "avatar": { + "type": "object" + }, + "membershipId": { + "type": "string" + }, + "membershipStatus": { + "type": "string" + }, + "subscriptionMethod": { + "type": "string" + }, + "completedLessons": { + "type": "array", + "items": { + "type": "string" + } + }, + "downloaded": { + "type": "boolean" + }, + "enrolledAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } + } + }, + "ProductCustomer": { + "type": "object", + "description": "Member enrollment details returned by the product customers endpoint.", + "properties": { + "user": { + "type": "object", + "properties": { + "userId": { + "type": "string" + }, + "email": { + "type": "string" + }, + "name": { + "type": "string" + }, + "avatar": { + "type": "object" + } + } + }, + "status": { + "type": "string" + }, + "completedLessons": { + "type": "array", + "items": { + "type": "string" + } + }, + "downloaded": { + "type": "boolean" + }, + "subscriptionMethod": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } + } + }, + "CustomerListResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ProductCustomer" + } + }, + "pagination": { + "type": "object", + "properties": { + "page": { + "type": "integer" + }, + "limit": { + "type": "integer" + } + } + } + } + }, + "Progress": { + "type": "object", + "properties": { + "courseId": { + "type": "string" + }, + "completedLessons": { + "type": "array", + "items": { + "type": "string" + } + }, + "downloaded": { + "type": "boolean" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } + } + }, + "MediaPresignedResponse": { + "type": "object", + "required": ["signature", "endpoint"], + "properties": { + "signature": { + "type": "string", + "description": "MediaLit upload signature. Send this as the `x-medialit-signature` header to MediaLit." + }, + "endpoint": { + "type": "string", + "format": "uri", + "description": "MediaLit server endpoint. Upload files directly to `${endpoint}/media/create` for multipart uploads or `${endpoint}/media/create/resumable` for TUS resumable uploads." + } + } + }, + "MediaErrorResponse": { + "type": "object", + "properties": { + "message": { + "type": "string", + "example": "Domain not found" + }, + "error": { + "type": "string", + "example": "Unable to generate media signature" } } } diff --git a/apps/web/openapi/index.mjs b/apps/web/openapi/index.mjs index 5895c964d..b8c0ecd78 100644 --- a/apps/web/openapi/index.mjs +++ b/apps/web/openapi/index.mjs @@ -1,6 +1,8 @@ import { userApiOpenApi } from "../app/api/user/openapi.mjs"; +import { productsApiOpenApi } from "../app/api/products/openapi.mjs"; +import { mediaApiOpenApi } from "../app/api/media/openapi.mjs"; -const routeSpecs = [userApiOpenApi]; +const routeSpecs = [userApiOpenApi, productsApiOpenApi, mediaApiOpenApi]; function mergeOpenApiFragments(fragments) { return fragments.reduce( diff --git a/docs/wip/PUBLIC_API_PRODUCT_AND_LEARNER_MANAGEMENT_PRD.md b/docs/wip/PUBLIC_API_PRODUCT_AND_LEARNER_MANAGEMENT_PRD.md new file mode 100644 index 000000000..47472291b --- /dev/null +++ b/docs/wip/PUBLIC_API_PRODUCT_AND_LEARNER_MANAGEMENT_PRD.md @@ -0,0 +1,1372 @@ +# Public API Product And Customer Management PRD + +## Document Control + +- Status: Draft +- Last updated: May 8, 2026 +- Owner: Web/API team +- Target workspace: `apps/web` (`@courselit/web`) + +## Assumptions + +1. This document is the implementation spec for the public REST API expansion in `apps/web`, not just a product narrative. +2. The API must only expose workflows that already exist in CourseLit today. +3. The API surface will be authenticated with school-domain API keys. +4. Product/customer API routes must not modify `apps/web/graphql/**`; existing product, lesson, payment-plan, customer, and progress business logic must be consumed as-is. +5. The API contract should be pleasant for alternate frontends even when existing internal GraphQL logic still uses stringified JSON or GraphQL-specific wrappers internally. +6. Existing API endpoints must not be altered; this work should add new public REST routes only. +7. API keys are tenant-level credentials. They are not associated with an individual CourseLit user. +8. For API-key calls that need existing user-backed business logic, the school owner is resolved and used as the integration actor. + +## Objective + +Build a public REST API in `apps/web` that lets alternate frontends use CourseLit as the system of record for: + +- products +- product payment plans +- product structure +- lesson content +- customers +- customer progress reporting +- media upload signatures for media-backed lesson content + +Success means an alternate frontend, including an AI-generated course application, can: + +1. create a CourseLit product +2. create payment plans required for publishing and checkout +3. create sections and lessons for that product +4. publish and manage the product +5. generate direct-upload signatures for media-backed lesson content +6. enroll customers +7. read customer progress for reporting and integration workflows + +This must happen without adding new platform behavior beyond what CourseLit already supports today. + +## Tech Stack + +- Monorepo: `pnpm` workspace +- App workspace: `apps/web` (`@courselit/web`) +- Framework: Next.js app router +- Language: TypeScript +- Data layer: MongoDB + Mongoose +- Internal business layer: GraphQL logic modules under `apps/web/graphql` +- API documentation: OpenAPI + Swagger UI +- Validation/document contracts: existing app validation patterns plus OpenAPI schemas + +## Commands + +Development: + +- Dev server: `pnpm dev` +- Web-only dev server: `pnpm --filter @courselit/web dev` + +Build: + +- Full build: `pnpm build` +- Web build: `pnpm --filter @courselit/web build` + +OpenAPI: + +- Generate web OpenAPI spec: `pnpm --filter @courselit/web openapi:generate` + +Testing: + +- Full test suite: `pnpm test` +- Coverage: `pnpm test:coverage` + +Lint and formatting: + +- Lint: `pnpm lint` +- Format: `pnpm prettier` + +## Project Structure + +- `apps/web/app/api` + Existing Next.js route handlers. New REST endpoints should be added here. +- `apps/web/app/api/user` + Current public API reference implementation for API-key auth + route-local OpenAPI fragment patterns. +- `apps/web/app/api/media/presigned` + Existing MediaLit signature endpoint used before direct resumable uploads. +- `apps/web/app/api/media/openapi.mjs` + OpenAPI fragment for MediaLit signature generation. +- `apps/web/openapi` + OpenAPI assembly and generated spec output. +- `apps/web/models/ApiKey.ts` + App-local API-key model used by public REST auth. +- `packages/orm-models/src/models/apikey.ts` + Shared API-key schema used by scripts and shared model exports. +- `apps/web/graphql/courses` + Existing product, section/group, and product-reporting business logic. +- `apps/web/graphql/lessons` + Existing lesson authoring and lesson detail logic. +- `apps/web/graphql/paymentplans` + Existing payment-plan validation, creation, update, archive, and default-plan logic. +- `apps/web/graphql/users` + Existing user/customer, enrollment, membership, and progress-related helpers. +- `apps/web/app/api/payment/helpers.ts` + Existing membership activation helper. +- `apps/web/config/strings.ts` + Shared user-facing/backend response strings where applicable. +- `apps/web/models` and `packages/orm-models/src/models` + Existing persistence models the REST layer must align with. +- `apps/docs/src/pages/en/developers` + Public developer docs that should be updated after implementation. + +## Code Style + +The REST layer should stay thin and delegate behavior to existing business logic. Prefer explicit request validation, small helpers, and co-located OpenAPI fragments. + +```ts +export async function POST(req: NextRequest) { + const auth = await validateDomainAndApiKey(req); + if (auth.error) { + return NextResponse.json( + { error: { code: "unauthorized", message: auth.error.message } }, + { status: auth.error.status }, + ); + } + + const body = await req.json(); + const input = createCustomerSchema.parse(body); + + const result = await callExistingCustomerInviteFlow({ + productId: auth.params.productId, + email: input.email, + tags: input.tags, + ctx: auth.ctx, + }); + + return NextResponse.json(result, { status: 200 }); +} +``` + +Conventions: + +- Keep route handlers small and explicit. +- Prefer reusing existing logic over re-implementing business rules in routes. +- Use stable resource naming in the REST contract, even if internal naming differs. +- For text lessons, accept and return Tiptap/ProseMirror JSON rather than stringified blobs. +- Preserve current CourseLit permission and visibility behavior exactly. +- Do not infer user identity from the API key itself; resolve the school owner where existing business logic requires `ctx.user`. + +## Testing Strategy + +Primary test levels: + +- Route tests for new REST endpoints under `apps/web/app/api/...` +- Adapter/helper tests for new REST-layer composition outside `apps/web/graphql/**` +- Existing GraphQL/business-logic tests remain unchanged and serve as baseline coverage for behavior reused as-is +- OpenAPI generation verification for new fragments and merged schemas + +Coverage expectations: + +- happy-path behavior for every endpoint family +- authentication and authorization failures +- tenant isolation by domain +- parity with existing product/customer/lesson behavior +- customer roster and progress reporting behavior +- media upload signature authorization for both API keys resolved through the school owner and existing dashboard sessions + +Verification requirements: + +- run `pnpm test` +- run `pnpm lint` +- run `pnpm prettier` +- run `pnpm --filter @courselit/web openapi:generate` +- focused media signature tests: `pnpm exec jest --config apps/web/jest.server.config.ts --runInBand --runTestsByPath apps/web/app/api/media/presigned/__tests__/route.test.ts` + +## Boundaries + +- Always: + Keep the REST layer as an alternate interface to existing CourseLit behavior, reuse existing business logic, add/update tests for new API routes, and keep Swagger/OpenAPI in sync. +- Ask first: + Introducing new dependencies, adding an API-key scope/role system, changing default API-key access, requiring migrations, changing creator attribution, or changing customer progress semantics. +- Never: + Add product/customer platform capabilities disguised as API work, modify product/customer business logic in `apps/web/graphql/**`, alter existing API endpoint behavior, add `/api/me` runtime endpoints, add SCORM lesson creation/processing support, expose raw SCORM runtime ingestion, require stringified Tiptap JSON in the public REST contract, expose session-cookie auth in Swagger, store API keys as user-owned credentials, or accept caller-provided ownership fields. + +## Problem Statement + +CourseLit already exposes a small public REST API in `apps/web`, but today it is effectively limited to basic user management via `/api/user`. + +At the same time, the product already has mature internal capabilities for: + +- product creation and updates +- product payment-plan creation, updates, archiving, and default-plan selection +- section/group authoring and reordering +- lesson authoring and publishing +- customer enrollment and customer invitation +- membership activation and status management +- customer progress tracking +- customer roster/reporting + +Those capabilities are currently available only through internal GraphQL flows and app UI. External integrators cannot reliably: + +- create new products in CourseLit +- create course structure and lesson content in CourseLit +- enroll customers from an external CRM/LMS/commerce system +- fetch customer rosters for a product +- read customer progress for course completions and downloads +- build alternate admin/integration frontends that manage CourseLit products, content, and customers + +This creates a gap for agencies, self-hosters, enterprise customers, and technical schools that want to automate CourseLit as a system of record or as part of a larger workflow. + +## Background And Current State + +Current public API surface: + +- `/api/user` + - `POST` create user + - `PATCH` update user +- authentication via school-domain request + API key +- API keys are currently tenant-level credentials with no per-key permission model +- OpenAPI generation exists and is already wired into development Swagger UI +- `/api/media/presigned` exists for dashboard media uploads and currently uses the logged-in user's `media:manage` permission + +Relevant existing internal building blocks: + +- product management in `apps/web/graphql/courses/*` +- payment-plan management in `apps/web/graphql/paymentplans/*` +- section/group operations in `apps/web/graphql/courses/*` +- lesson creation, updates, deletion, and authoring reads in `apps/web/graphql/lessons/*` +- user and customer helpers in `apps/web/graphql/users/*` +- membership activation in `apps/web/app/api/payment/helpers.ts` +- customer progress stored on `User.purchases` +- customer enrollment status stored in `Membership` +- customer reporting in `getProductMembers` and `getStudents` + +This PRD proposes expanding the public REST API by reusing those existing business flows instead of re-implementing them in a separate stack. + +## Goals + +1. Add a public REST API for product management in `apps/web`. +2. Add public REST endpoints for product payment-plan management using workflows that already exist in the product today. +3. Add public REST endpoints for section/group and lesson authoring using workflows that already exist in the product today. +4. Add a public REST API for customer management using workflows that already exist in the product today. +5. Add customer progress read APIs for reporting and integrations. +6. Keep the API aligned with existing CourseLit multi-tenant auth and domain resolution. +7. Ensure the API only exposes capabilities already supported by the current platform. +8. Generate OpenAPI documentation for all new endpoints and surface them in Swagger during development. +9. Reuse existing business logic and validations wherever possible. +10. Expose the existing media upload signature flow to API-key callers by resolving the school owner as the integration actor and applying the existing `media:manage` permission check. + +## Non-Goals + +- Payment checkout, refunding, invoice creation, or webhook orchestration +- Creating or configuring payment providers +- Public API for community management +- Public API for page-builder content editing +- SCORM lesson creation, SCORM package processing, or raw SCORM runtime ingestion over the public API +- Direct multipart file upload as part of lesson create/update requests +- Customer-runtime `/api/me` endpoints +- Public REST endpoints for customer-facing lesson completion or quiz evaluation +- Privileged API-key writes that set or overwrite a customer's progress +- Bulk import/export jobs in v1 +- Webhooks in this PRD +- Any new platform capability that does not already exist in UI/GraphQL/business logic today +- API key rotation redesign in this PRD +- API-key scope or role management in this PRD +- User-owned API keys or per-user API-key impersonation +- Replacing the internal GraphQL API + +## Scope + +Guiding constraint: + +- the public API is an alternate interface to existing CourseLit capabilities +- this PRD must not introduce new business behavior, new lifecycle states, or admin powers that do not already exist elsewhere in the system + +GraphQL constraint: + +- no files under `apps/web/graphql/**` should be modified for this work +- no new GraphQL queries, mutations, types, fields, helpers, or behavior should be added +- existing exported GraphQL/business functions may be called as-is where they already provide the required behavior +- if existing GraphQL logic does not expose a suitable function, create REST-layer adapter code outside `apps/web/graphql/**` or defer the endpoint; do not alter GraphQL to support the REST API + +Existing API endpoint constraint: + +- existing route handlers under `apps/web/app/api/**` must not be behaviorally altered as part of this work +- this includes `/api/user`, payment initiation/webhook routes, existing media upload signature routes, and any existing lesson/media processing routes +- new behavior must live in new API route files or new REST-layer adapter/helper files +- existing endpoints may be referenced or called by clients as part of documented workflows, but their request/response contracts, auth behavior, validation, status codes, and side effects must remain unchanged + +Capability parity gate: + +- every endpoint must map to an existing CourseLit UI, GraphQL, route, or business-logic workflow before implementation starts +- if an endpoint cannot be mapped to an existing capability, it must be removed from this PRD or explicitly deferred +- route handlers may compose existing reads into a REST-friendly shape, but must do so outside `apps/web/graphql/**`, without modifying existing API endpoints, and without exposing fields, lifecycle transitions, state mutations, or admin powers that are not already available through the current platform +- OpenAPI examples must describe existing CourseLit behavior only; Swagger must not imply support for capabilities that the app cannot already perform + +### In Scope For V1 + +- Product CRUD for CourseLit products backed by `CourseModel` +- Product listing and detail retrieval +- Product payment-plan list, create, update, archive, and default-plan selection for `course` and `download` products +- Section/group create, update, remove, and reorder for products that support structured content +- Lesson create, update, delete, move, and fetch for products that support lessons +- Customer enrollment into a product +- Product customer roster retrieval +- Customer enrollment detail retrieval as a single-row view of existing product roster/member data +- Customer progress read APIs +- OpenAPI docs and route-level contract tests + +### Product Types In Scope + +For product-management endpoints, v1 should support full existing `CourseModel` product-type parity from day one: + +- `course` +- `download` +- `blog` + +For customer-management endpoints, v1 should support only enrollable product types: + +- `course` +- `download` + +Reason: + +- product CRUD already exists for `course`, `download`, and `blog` +- full product-management parity avoids launching a REST API that behaves differently from existing product management for supported product types +- customer enrollment, customer reporting, and progress concepts apply to `course` and `download` +- `blog` should remain excluded from customer endpoints because the existing system does not treat blogs as customer-managed products + +## Users And Use Cases + +Primary users: + +- agencies managing client schools +- enterprise integrators +- self-hosted operators +- developers building custom admin portals or workflow automation + +Primary use cases: + +1. Generate a product in CourseLit from an alternate frontend or AI workflow. +2. Generate sections and lessons for that product using existing CourseLit content structures. +3. Create and manage payment plans required for checkout and publishing. +4. Update title, slug, publication, metadata, payment plans, and lesson content from an external admin tool. +5. Enroll a customer after an off-platform purchase or contract event. +6. Grant free access to a customer for support, migration, or enterprise provisioning. +7. Fetch product rosters for reporting or support workflows. +8. Read customer progress and completion state from an external dashboard. + +### Alternate Frontend Coverage + +This API should explicitly support the following alternate-frontend pattern: + +1. An admin or AI workflow creates a CourseLit product. +2. The same workflow creates one or more payment plans for the product. +3. The same workflow creates the product structure and lesson content. +4. The workflow publishes the product when ready. +5. The workflow enrolls customers. +6. CourseLit remains the system of record for enrollments, completion state, certificates, and related reporting. + +## Proposed Solution + +Add a new REST API surface in `apps/web` that exposes existing CourseLit management capabilities for admin and integration use cases. + +The API should follow the current CourseLit public API model: + +- request is sent to the school domain +- API key is provided in `x-api-key` +- domain is resolved from request context +- route handlers are thin +- internal business logic is reused by calling existing exported functions as-is where possible +- OpenAPI fragments are defined per route and merged into the generated spec + +### API Design Principles + +1. Use stable CourseLit IDs in public responses. + + - product identifier: `courseId` + - customer identifier: `userId` + - enrollment identifier: `membershipId` + +2. Prefer resource-oriented REST routes. + +3. Use API-key authentication for all endpoints in this scope. + + - management/admin endpoints should use `x-api-key` + - legacy body `apikey` support remains only on `/api/user` for backward compatibility + +4. Expose only workflows that already exist in the current system. + + - if a behavior is not already supported in the app/business logic, the API should not invent it + - this applies even if adding it to REST would be convenient + +5. Keep new response envelopes consistent even if `/api/user` remains legacy-shaped. + +6. Return real customer email addresses on authenticated read endpoints. + - unlike `/api/user` mutation responses, roster and customer management endpoints are not useful if email is hidden + +### Existing Capability Mapping + +Each endpoint family must remain a REST interface over existing platform behavior: + +| REST endpoint family | Existing CourseLit capability it maps to | +| ---------------------- | -------------------------------------------------------------------------------------------------------- | +| Product CRUD | product management in `apps/web/graphql/courses/*` and dashboard product management UI | +| Product payment plans | payment-plan management in `apps/web/graphql/paymentplans/*` and product manage UI | +| Sections/groups | existing course group/section authoring and reorder logic | +| Lessons | existing lesson authoring, update, delete, move, visibility, and media-reference behavior | +| Media signatures | existing `apps/web/app/api/media/presigned` direct-to-MediaLit upload flow | +| Customer list/detail | existing product member/customer roster reporting, including `getProductMembers`/`getMembers`-style data | +| Customer enrollment | existing user creation/reuse, membership creation/reuse, and membership activation flow | +| Customer progress read | existing `User.purchases`/product reporting data already shown by CourseLit | + +If implementation discovers that an endpoint requires behavior beyond the mapped existing capability, the endpoint should be cut or the PRD should return to review before code is written. + +## Proposed Endpoints + +### Product Management + +- `GET /api/products` + - list products + - filters: `type`, `published`, `search`, `page`, `limit`, `sort` +- `POST /api/products` + - create a product +- `GET /api/products/{productId}` + - fetch one product +- `PATCH /api/products/{productId}` + - update product metadata +- `DELETE /api/products/{productId}` + - delete a product using existing product deletion rules + +### Product Payment Plans + +- `GET /api/products/{productId}/payment-plans` + - list non-internal, non-archived payment plans for a product +- `POST /api/products/{productId}/payment-plans` + - create a payment plan for a product + - if the product has no default payment plan, the created plan becomes the default, matching existing behavior +- `GET /api/products/{productId}/payment-plans/{planId}` + - fetch one payment plan for a product +- `PATCH /api/products/{productId}/payment-plans/{planId}` + - update a payment plan using existing payment-plan validation rules +- `DELETE /api/products/{productId}/payment-plans/{planId}` + - archive a payment plan using existing archive behavior + - must fail if the plan is the product default +- `POST /api/products/{productId}/payment-plans/{planId}/default` + - set a product's default payment plan + +### Product Structure And Content Management + +- `GET /api/products/{productId}/sections` + - list sections/groups for a product +- `POST /api/products/{productId}/sections` + - create a section/group +- `PATCH /api/products/{productId}/sections/{sectionId}` + - update section/group metadata +- `DELETE /api/products/{productId}/sections/{sectionId}` + - remove a section/group using existing product rules +- `POST /api/products/{productId}/sections/reorder` + - reorder sections/groups +- `GET /api/products/{productId}/lessons` + - list lessons for a product +- `POST /api/products/{productId}/lessons` + - create a lesson +- `GET /api/products/{productId}/lessons/{lessonId}` + - fetch a lesson for authoring/admin use +- `PATCH /api/products/{productId}/lessons/{lessonId}` + - update a lesson +- `DELETE /api/products/{productId}/lessons/{lessonId}` + - delete a lesson +- `POST /api/products/{productId}/lessons/{lessonId}/move` + - move a lesson to a target section/group and position + +### Customer Management + +- `GET /api/products/{productId}/customers` + - list enrolled customers for a product + - filters: `status`, `search`, `page`, `limit` +- `POST /api/products/{productId}/customers/invitations` + - invite a customer using the existing CourseLit customer invitation flow + - must preserve existing behavior, including published-product validation, internal payment-plan membership activation, tag application, and best-effort invite email sending +- `GET /api/products/{productId}/customers/{userId}` + - fetch one customer's enrollment snapshot for a product + - this must be a single-row lookup over existing product roster/member data, not a new customer profile capability + - response fields must be limited to enrollment/customer fields already visible through existing product customer/member reporting flows + +### Customer Progress + +- `GET /api/products/{productId}/customers/{userId}/progress` + - fetch customer progress snapshot + - this must be read-only and limited to progress/completion data already tracked by CourseLit today + +## Data Contracts + +### Product Representation + +The public product representation should include: + +- `productId` +- `type` +- `title` +- `slug` +- `description` +- `published` +- `privacy` +- `tags` +- `featuredImage` +- `pageId` +- `defaultPaymentPlan` +- `paymentPlans` +- `createdAt` +- `updatedAt` + +Admin/content endpoints should expose lesson and section data needed for parity with current CourseLit product authoring. + +V1 should still not expose page-builder editing through this API. + +Fields that are not actionable through this API must be hidden from public product representations. This includes `leadMagnet` and `certificate`. + +`paymentPlans` and `defaultPaymentPlan` apply only where the existing platform exposes payment plans for the product type. They should not imply payment-plan support for `blog` products. + +`course.cost` and `course.costType` are legacy internal constructs and must not be accepted, returned, surfaced in Swagger, or documented as part of this public API. Payment plans are the only public API contract for product pricing, checkout readiness, and publishing readiness. + +### Payment Plan Representation + +The public payment-plan representation should mirror the existing `PaymentPlan` model for course/download products: + +- `planId` +- `name` +- `type` +- `entityId` +- `entityType` +- `oneTimeAmount` +- `emiAmount` +- `emiTotalInstallments` +- `subscriptionMonthlyAmount` +- `subscriptionYearlyAmount` +- `description` +- `isDefault` + +Supported plan types: + +- `free` +- `onetime` +- `emi` +- `subscription` + +Validation must reuse existing payment-plan rules: + +- `name` is required +- `type` is required +- `onetime` requires `oneTimeAmount` +- `emi` requires `emiAmount` and `emiTotalInstallments` +- `subscription` requires exactly one of `subscriptionMonthlyAmount` or `subscriptionYearlyAmount` +- paid plan types require the tenant to already have a supported payment provider configured +- duplicate plan types must be rejected according to the existing duplicate-plan rules +- `includedProducts` must not be accepted for product-owned plans because existing CourseLit validation disallows included products for course entities + +Implementation note: + +- the REST API should call existing `apps/web/graphql/paymentplans` logic as-is where possible; it should not modify GraphQL files or implement a separate payment-plan validation system + +### Lesson Content Representation + +The API must explicitly support the same lesson content model used by CourseLit today. + +For `text` lessons: + +- `lesson.content` should be represented as a Tiptap/ProseMirror JSON document +- the canonical shape should match `TextEditorContent` +- minimum expected shape: + +```json +{ + "type": "doc", + "content": [] +} +``` + +Important notes: + +- alternate frontends should send and receive structured JSON for text lessons, not HTML or Markdown +- the REST API may transform that JSON internally if existing business logic still expects a stringified document, but that is an implementation detail +- Swagger examples should show realistic Tiptap-style payloads for text lessons + +For non-text lesson types, the API should continue to mirror the existing underlying content model: + +- `quiz`: existing quiz JSON structure +- `embed`/simple media-backed types: existing content/value structure + +### Unsupported Lesson Types + +If a client attempts to create or update a lesson with `type = "scorm"`, the API must reject the request before any lesson or media-processing side effect occurs. + +Expected response: + +- HTTP status: `422 Unprocessable Entity` +- error code: `not_supported` +- error message: `SCORM lessons are not supported by the public API.` + +Example: + +```json +{ + "error": { + "code": "not_supported", + "message": "SCORM lessons are not supported by the public API." + } +} +``` + +Swagger should document this response on lesson create/update operations so API clients do not mistake SCORM omission for missing documentation. + +### File-Backed Lesson Representation + +Lesson creation should not accept raw multipart files directly. File-backed lesson types should use the same two-step media model CourseLit already uses: + +1. request a MediaLit upload signature +2. upload the file to MediaLit using the returned signature and endpoint +3. create or update the lesson by referencing the returned `mediaId` + +This applies to lesson types such as: + +- `video` +- `audio` +- `pdf` +- `file` + +For regular media-backed lessons, the lesson payload should reference the uploaded media: + +```json +{ + "title": "Intro video", + "type": "video", + "groupId": "section_123", + "requiresEnrollment": true, + "published": false, + "media": { + "mediaId": "media_123" + } +} +``` + +Important constraints: + +- the REST API should not proxy large file bodies through lesson create/update routes +- file upload authorization should preserve existing media-management permissions +- media-backed lesson creation must validate that the referenced `mediaId` belongs to the same tenant/domain +- SCORM lesson creation is intentionally out of scope for this public API and must return the documented `not_supported` error +- Swagger examples should document the existing media signature flow for supported media-backed lesson types + +### Customer Representation + +The customer/enrollment representation should include: + +- `userId` +- `email` +- `name` +- `avatar` +- `membershipId` +- `membershipStatus` +- `subscriptionMethod` +- `subscriptionId` +- `enrolledAt` +- `updatedAt` + +### Progress Representation + +For `course` products: + +- `productId` +- `userId` +- `completedLessonIds` +- `completedLessonsCount` +- `totalPublishedLessons` +- `progressPercent` +- `certificateId` +- `lastAccessedAt` +- `enrolledAt` + +For `download` products: + +- `productId` +- `userId` +- `downloaded` +- `enrolledAt` +- `lastAccessedAt` + +V1 should not expose raw `scormData` publicly. + +Progress fields may be derived from existing CourseLit progress data, published lesson counts, and reporting helpers, but must not introduce any new progress state or write capability. + +## Write Semantics + +### Product Create + +`POST /api/products` should support the minimum metadata needed to create a usable product: + +- `title` +- `type` + +Product creation should not require a payment plan when the product is created as a draft. + +Product creation must always create a draft product and must match the existing CourseLit app flow, which only accepts title and product type. `slug`, `description`, `published`, `privacy`, `tags`, `featuredImage`, and other metadata must not be accepted on `POST /api/products`; clients should use `PATCH /api/products/{productId}` after creation for metadata, publish, and privacy changes. + +The recommended flow for alternate frontends is: + +1. `POST /api/products` +2. `POST /api/products/{productId}/payment-plans` +3. optionally `POST /api/products/{productId}/payment-plans/{planId}/default` +4. `PATCH /api/products/{productId}` with `published = true` + +### Payment Plan Management + +Payment-plan endpoints should expose the same behavior currently available through CourseLit product management UI/GraphQL. + +`POST /api/products/{productId}/payment-plans` should accept: + +- `name` required +- `type` required: `free`, `onetime`, `emi`, or `subscription` +- `oneTimeAmount` required for `onetime` +- `emiAmount` and `emiTotalInstallments` required for `emi` +- exactly one of `subscriptionMonthlyAmount` or `subscriptionYearlyAmount` required for `subscription` +- `description` optional + +Expected behavior: + +- create a non-internal payment plan for the product +- set `entityId` to the product ID and `entityType` to `course` +- set the plan as `defaultPaymentPlan` if the product does not already have one +- reject paid plans when the tenant has no payment provider configured +- reject `includedProducts` for product-owned plans +- return the created payment plan with `isDefault` + +`PATCH /api/products/{productId}/payment-plans/{planId}` should support the same editable fields and validations. + +`DELETE /api/products/{productId}/payment-plans/{planId}` should archive the plan, not hard-delete it. If the plan is the product's `defaultPaymentPlan`, the API must return a validation error matching existing behavior. + +`POST /api/products/{productId}/payment-plans/{planId}/default` should set the product's default payment plan after verifying the plan belongs to the same product and tenant. + +### Publishing With Payment Plans + +Publishing should not create payment plans implicitly. + +When `PATCH /api/products/{productId}` sets `published = true` for `course` or `download` products: + +- the API must verify that at least one non-internal, non-archived payment plan exists for the product +- the API should preserve the existing error semantics for missing payment plans +- the API should expose default-plan selection so checkout-capable alternate frontends can select the intended default plan before publishing +- the normal API workflow should have a valid `defaultPaymentPlan` because creating the first plan sets it as default using existing behavior +- Swagger should document the recommended draft → payment plan → publish sequence + +### Section And Lesson Authoring + +The API should expose existing product-structure and lesson-authoring workflows already supported by CourseLit, including: + +- create section/group +- update section/group metadata +- reorder sections/groups +- create lesson +- update lesson +- delete lesson +- move lesson across sections/groups + +This is required to support alternate frontends that generate complete courses, not just empty product shells. + +For lesson create/update payloads: + +- `text` lessons should accept `content` as a Tiptap/ProseMirror JSON document +- responses for `text` lessons should return the same document shape +- the API contract should not force clients to send stringified JSON blobs for text lessons +- if internal reuse of existing GraphQL logic requires stringification, that conversion should happen inside the REST layer or a new helper outside `apps/web/graphql/**` +- file-backed lessons should accept `media.mediaId` references, not raw files +- SCORM lessons should not be creatable or processable through this public API in v1 and must fail with the documented `not_supported` error + +### Product Update + +`PATCH /api/products/{productId}` should support metadata-only updates for the same field set. + +### Customer Invitations + +`POST /api/products/{productId}/customers/invitations` should accept: + +- `email` required +- `tags` optional + +Expected behavior: + +- follow the existing `inviteCustomer` behavior without adding new controls +- require the product to be published if the existing flow requires it +- create user if missing using the existing defaults +- reuse existing user if present +- apply tags using existing tag behavior when tags are supplied +- create or reuse membership using the existing internal payment plan flow +- activate membership using existing membership activation flow +- ensure customer gets product access in `User.purchases` +- send the existing customer invitation/enrollment email if the existing flow sends it +- return current customer enrollment snapshot + +The endpoint must not introduce new customer-invitation controls that the existing platform flow does not support, such as overriding the invite email behavior, setting a custom enrollment email, or accepting arbitrary customer profile fields beyond the existing flow. + +If the customer is already actively enrolled, the endpoint should preserve existing behavior and return success/current state if the existing flow does so. + +### Customer Progress + +Customer progress is read-only in this API scope. + +Important constraints: + +- API-key-based callers should not gain a new ability to arbitrarily set another customer’s progress unless that already exists elsewhere in the platform. +- Customer-facing lesson completion, quiz evaluation, and `/api/me` runtime APIs are not part of this implementation spec. + +## Permissions And Authentication + +Authentication: + +- all new public REST endpoints in this scope require `x-api-key` +- requests must be made against the target school domain +- `/api/media/presigned` is the only exception because it is an existing CourseLit dashboard endpoint, not a new public management endpoint; it keeps its pre-existing dashboard-session auth path so the dashboard media upload UI remains backward compatible + +Authorization: + +- product management endpoints require product-management capability equivalent to current internal checks +- payment-plan management endpoints require product-management capability equivalent to current internal checks +- customer management endpoints require user/product-management capability equivalent to current internal checks +- media upload signature access via API key resolves the school owner as the integration actor and requires that resolved owner user to pass the existing `media:manage` permission check +- media upload signature access via dashboard session continues to require the logged-in user permission `media:manage` + +V1 API key decision: + +- public API keys remain tenant-level credentials following the current public API model +- API keys remain tenant-level credentials with no per-key permission model in this PRD +- API-key settings UI and API-key persistence model remain unchanged +- user-owned keys or per-key permission models may be revisited later, but are out of scope here +- after a valid API key is resolved, the REST auth layer resolves the school owner and sets that user as `ctx.user` for all new public API routes +- if the school owner cannot be resolved, the request fails with `403` and no route-specific business logic runs + +### Media Upload Signature Authorization + +`POST /api/media/presigned` should support two auth modes only because the route already exists and is used by the CourseLit dashboard: + +- Dashboard session mode, retained for the CourseLit dashboard only: + - existing behavior + - requires logged-in user with `media:manage` +- Public API-key mode: + - requires valid school API key + - resolves the school owner from `domain.email` + - requires the resolved owner user to have `media:manage` + - uses the resolved owner as the integration actor, matching the rest of the public management API + +Authorization outcomes: + +- no valid session and no valid API key: `401` +- valid API key whose owner actor cannot be resolved: `403` +- valid API key whose resolved owner lacks `media:manage`: `403` +- valid API key whose resolved owner has `media:manage`: return `{ signature, endpoint }` +- logged-in dashboard user without `media:manage`: `403` + +Swagger/OpenAPI requirements: + +- Show `/api/media/presigned` in Swagger under `Media Uploads`. +- Document it with `ApiKeyAuth`. +- State that API-key calls run as the resolved school owner integration actor and require that actor to pass `media:manage`. +- State that the route also retains dashboard session auth for CourseLit's own UI, but this is not a public API auth mode and does not apply to new product, payment-plan, content, customer, or progress endpoints. +- Do not expose or document session-cookie token entry fields. + +### Creator Attribution And GraphQL Context + +API keys are not associated with a `creatorId`. + +However, existing CourseLit business logic expects a `ctx.user` for permission checks and for fields such as `creatorId` when creating products, pages, sections, lessons, and related records. The REST API must not accept `creatorId`, `userId`, or any equivalent caller-controlled creator override. + +V1 creator attribution decision: + +- API-key-authenticated management calls run as a tenant integration actor derived from the school owner. +- The school owner is resolved from the current domain record, using the existing domain owner email (`domain.email`) to find the matching user in that domain. +- Created records that require `creatorId` use that resolved owner user’s `userId`, because that is how the existing CourseLit creation logic models ownership today. +- This does not mean the API key is owned by that user or is a general-purpose impersonation token. It is only the v1 bridge required to call existing business logic without adding per-key user identity. +- If the owner user cannot be resolved for endpoints that need existing GraphQL `ctx.user`, the API must fail with `403` and must not proceed with a synthetic or caller-provided creator. +- The existing `deleteUser` mutation must prevent deletion of the user whose email matches `domain.email`, because that user is the v1 API actor. +- Supported user update mutations do not currently expose `email` as an editable field, and the settings/theme/page mutations reviewed for this PRD do not update `domain.email`. If future ownership transfer or email-change functionality is added, it must preserve API actor resolvability or move this design to a stable owner reference such as `domain.ownerId`. + +Future user-owned API keys may replace this attribution model, but that is outside this PRD. + +### Created Record Ownership And User Fields + +API-key-created resources must use existing model semantics. The API must not add `createdByApiKey`, `apiKeyId`, `creatorId`, or caller-controlled `userId` fields in this PRD. + +The expected behavior is: + +| Resource / model field | Behavior when created by API key | +| --------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `Course.creatorId` for course/download/blog products | Set by existing product creation logic from `ctx.user.userId`, where `ctx.user` is the resolved school owner integration actor. | +| `Page.creatorId` for product pages created with course/download products | Set to the resolved school owner integration actor by existing page creation logic. | +| Default section/group creation side effects | Use the same `ctx.user` and permission context as existing product creation. No caller-supplied ownership fields are accepted. | +| `Lesson.creatorId` for text/video/audio/PDF/file lessons | Set by existing lesson creation logic from the resolved school owner integration actor. | +| `PaymentPlan.userId` for payment plans created through product payment-plan endpoints | Set by existing payment-plan creation logic from the resolved school owner integration actor. | +| `Media.userId` for media uploaded through MediaLit after an API-key signature | Not set by CourseLit REST route code in this PRD. The API returns a MediaLit signature for the school/domain after resolving the school owner as the actor and applying the existing `media:manage` permission check. The direct MediaLit upload response is later referenced by `mediaId`. If tenant ownership validation requires a CourseLit `Media` document owner, implementation must use existing media-management behavior and must not invent a caller-provided user. | +| Customer `User.userId` | Represents the target customer, created or reused by existing user/customer flows. It is never the API key, and it is not the school owner unless the target email is the owner. | +| `Membership.userId` | Represents the target enrolled customer, not the API key and not the integration actor. | +| `User.purchases[*]` progress records | Stored on the target customer user, not on the integration actor. | +| Certificates, lesson evaluations, activity, and other customer-runtime `userId` records | Not created by the management API except where existing enrollment/payment side effects already do so. Customer progress remains read-only in this PRD. | + +This split is intentional: + +- creator/owner fields required by admin authoring flows use the resolved school owner integration actor +- customer/enrollment/progress fields use the target customer user +- API keys themselves are not stored as `creatorId` or `userId` +- public request payloads must not expose ownership assignment knobs + +If future API-key ownership/auditing is needed, it should be designed as a separate additive model, for example `createdByApiKeyId` or an audit log, rather than overloading existing `creatorId`/`userId` fields. + +## Error Contract + +New endpoints should standardize on: + +```json +{ + "error": { + "code": "not_found", + "message": "Product not found" + } +} +``` + +Suggested common codes: + +- `bad_request` +- `unauthorized` +- `forbidden` +- `not_found` +- `conflict` +- `not_supported` +- `unprocessable_entity` +- `internal_error` + +Unsupported lesson types should use HTTP `422` with `code = "not_supported"` so clients can distinguish unsupported-but-recognized CourseLit lesson formats from malformed payloads. + +This contract should apply to new endpoints even if `/api/user` remains unchanged for backward compatibility. + +## Pagination, Filtering, And Sorting + +Defaults: + +- default `page = 1` +- default `limit = 50` +- max `limit = 200` + +Product list: + +- `type` +- `published` +- `search` +- `direction = asc | desc` + +Product list ordering should mirror existing product-list behavior without exposing internal timestamp fields in the public response contract. + +Customer list: + +- `status` +- `search` by email or name +- `page` +- `limit` + +## OpenAPI And Documentation + +Implementation should follow the existing pattern: + +1. each route family exposes an OpenAPI fragment near the route +2. fragments are merged in `apps/web/openapi/index.mjs` +3. spec is generated to `apps/web/openapi/generated/openapi.json` +4. development Swagger UI automatically reflects the new endpoints + +### Swagger Documentation Upgrade + +This work should explicitly improve the Swagger experience, not just add more paths to the generated spec. + +Required Swagger/OpenAPI upgrades: + +- add new top-level tags: + - `Products` + - `Payment Plans` + - `Content` + - `Customers` + - `Progress` +- group endpoints so the UI reads as task-oriented API documentation rather than a flat route dump +- add request and response examples for every new endpoint +- define reusable schemas for: + - `Product` + - `Section` + - `Lesson` + - `TextEditorContent` + - `Media` + - `MediaUploadSignature` + - `PaymentPlan` + - `PaymentPlanListResponse` + - `ProductListResponse` + - `Customer` + - `CustomerListResponse` + - `CustomerProgress` + - `ErrorResponse` +- define reusable query/path/header parameters where practical +- document `x-api-key` consistently on every secured operation +- document school-domain calling expectations clearly in endpoint descriptions +- mark legacy body `apikey` authentication as deprecated and avoid extending that pattern to new endpoints +- ensure Swagger "Try it out" works cleanly with the current `Authorize` flow for API-key entry +- ensure pagination/filter parameters show defaults and max limits in Swagger +- ensure destructive routes like `DELETE /api/products/{productId}` clearly describe side effects and existing platform constraints + +Nice-to-have Swagger improvements: + +- add operation IDs that are SDK-friendly and stable +- add short, copy-pasteable examples for common workflows such as: + - create product + - create a free payment plan + - create a paid payment plan + - set default payment plan + - publish a product after payment-plan setup + - create section + - generate a MediaLit upload signature with an API key whose resolved school owner has `media:manage` + - create text lesson with Tiptap content + - create video/audio/PDF/file lesson with uploaded `mediaId` + - enroll customer + - fetch customer roster + - fetch customer progress +- if needed later, customize Swagger UI presentation so the most common public API flows are easier to discover first + +Success criteria for the Swagger upgrade: + +- a developer can authenticate once in Swagger and test the full public API flow end to end +- endpoint descriptions make the tenant/domain model understandable without reading source code +- examples are sufficient for a first successful API call without external support + +Documentation follow-up after implementation: + +- update `apps/docs/src/pages/en/developers/introduction.md` +- add developer docs for product management +- add developer docs for product payment-plan management and the publish workflow +- add developer docs for owner-backed API-key auth and direct media upload signatures +- add developer docs for customer management + +## Implementation Plan + +### Phase 1: REST Foundation + +- create shared API auth/domain validation helpers for new routes by matching current `/api/user` behavior without modifying the existing `/api/user` endpoint +- after a valid API key is resolved, resolve the school owner and set that user as `ctx.user` for all new public API routes +- fail with `403` when the school owner cannot be resolved; do not proceed with synthetic or caller-provided ownership +- define common response/error helpers for new public routes +- define shared OpenAPI schemas for products, payment plans, media signatures, customers, and progress +- identify places where existing GraphQL logic can be called directly as-is and places where REST-layer adapter helpers are needed outside `apps/web/graphql/**` + +### Phase 2: Product APIs + +- add product list/detail/create/update/delete routes +- wire routes to existing course/product business logic + +### Phase 3: Product Payment Plan APIs + +- add product payment-plan list/detail/create/update/archive/default routes +- wire routes to existing payment-plan business logic +- ensure product publish routes preserve the existing payment-plan-required validation +- document the draft → payment plan → publish workflow in Swagger examples + +### Phase 4: Product Structure And Content APIs + +- add section/group routes +- add lesson CRUD and move routes +- update `/api/media/presigned` so API-key callers can generate upload signatures through the resolved school owner actor while existing dashboard-session behavior remains intact +- document the client workflow that generates a media upload signature and then creates media-backed lessons with uploaded `mediaId` +- wire routes to existing course and lesson business logic + +### Phase 5: Customer Enrollment APIs + +- add customer roster and customer detail routes +- add enroll route + +### Phase 6: Customer Progress APIs + +- add progress read route +- ensure read behavior reflects existing completion, enrollment, downloaded, and certificate state + +### Phase 7: Docs And Hardening + +- generate OpenAPI updates +- upgrade Swagger grouping, examples, and reusable schemas +- expand developer docs +- validate multi-tenant isolation, idempotency, and permission handling + +## Testing Plan + +Add or update tests for: + +- API key authentication failures +- tenant isolation by domain +- endpoint responses stay within the documented existing-capability mapping +- product create/update/delete success and validation failures +- product publishing fails when required payment plans are missing +- product publishing succeeds after a valid payment plan exists +- payment-plan list/create/update/archive/default flows +- paid payment-plan creation fails when no payment provider is configured +- archiving the default payment plan fails with existing validation behavior +- section/group create/update/delete/reorder flows +- lesson create/update/delete/move flows +- media upload signature auth, owner resolution, and domain isolation +- media-backed lesson creation using `mediaId` +- SCORM lesson create/update attempts return the documented `not_supported` error and do not create partial resources +- customer enrollment for: + - new user + - existing user + - already-enrolled user +- customer roster pagination and filtering +- progress retrieval +- OpenAPI generation including new route fragments + +Preferred test locations: + +- route tests under `apps/web/app/api/...` +- adapter/helper tests outside `apps/web/graphql/**` where REST-layer composition is needed + +## Acceptance Criteria + +1. Authenticated admin/integration callers can create, list, read, update, and delete supported products via REST. +2. Authenticated admin/integration callers can create, list, update, archive, and choose default payment plans for course/download products via REST. +3. Publishing a course/download through the API enforces the existing payment-plan-required rules. +4. Authenticated admin/integration callers can create and manage product sections/groups and lessons via REST using existing CourseLit behavior. +5. Authenticated admin/integration callers can list product customers, fetch customer enrollment snapshots, and enroll customers into supported products. +6. Authenticated admin/integration callers can read customer progress for supported product types. +7. No API endpoint in this scope introduces behavior that is not already supported by the existing CourseLit system. +8. No `/api/me` customer-runtime endpoints are added as part of this work. +9. All new endpoints appear in generated OpenAPI output and development Swagger UI. +10. All new endpoints respect school-domain isolation and existing permission rules. +11. Existing `/api/user` behavior remains backward compatible. +12. Swagger documentation is upgraded so the new product, payment-plan, content, customer, media upload, and progress APIs are discoverable, example-driven, and testable through the UI. +13. Every API-key-authenticated route resolves the school owner as the integration actor, sets that user as `ctx.user` where existing logic needs context, and fails with `403` if the owner cannot be resolved. +14. Every endpoint has an explicit existing-capability mapping, and implementation does not proceed for endpoints whose mapping cannot be proven. +15. Product/customer API work does not modify product/customer business logic in `apps/web/graphql/**`. +16. Existing API endpoint handlers and contracts remain backward compatible. +17. `/api/media/presigned` accepts API keys by resolving the school owner actor and requiring that actor to pass the existing `media:manage` permission check; it continues accepting logged-in dashboard sessions with `media:manage`. +18. Swagger does not expose or request CourseLit session-cookie tokens. + +## Task Breakdown + +### Phase 1: REST Foundation + +- [ ] Task 1: Public API auth and response helpers + + - Description: Add shared helpers for new public REST routes by matching current `/api/user` API-key/domain behavior without modifying existing endpoints. + - Acceptance: New helpers validate school-domain API keys, resolve the school owner from the domain record, set the resolved owner as `ctx.user`, fail with `403` when that owner cannot be resolved, and return standardized error responses. + - Verify: `pnpm test` + - Dependencies: None + - Files: new helper files under `apps/web/app/api/*` or `apps/web/lib/*`, route tests + - Estimated scope: M + +- [ ] Task 2: OpenAPI schema foundation + + - Description: Define reusable OpenAPI components for auth, errors, pagination, products, payment plans, sections, lessons, media references, customers, and progress. + - Acceptance: Shared schemas exist, `x-api-key` auth is documented, owner-backed media upload auth is documented, and generated OpenAPI output includes the new reusable components. + - Verify: `pnpm --filter @courselit/web openapi:generate` + - Dependencies: Task 1 + - Files: `apps/web/openapi/*`, new OpenAPI fragments + - Estimated scope: M + +- [ ] Task 3: Existing-capability mapping checklist + - Description: Add a lightweight implementation checklist that each new endpoint family must satisfy before code lands. + - Acceptance: Checklist confirms no product/customer business-logic edits under `apps/web/graphql/**`, no incompatible existing endpoint edits, and an explicit existing CourseLit capability mapping for each route family. + - Verify: manual review against this PRD + - Dependencies: None + - Files: this spec or a small docs/checklist file near the new API routes + - Estimated scope: S + +### Checkpoint: Foundation + +- [ ] `pnpm test` passes +- [ ] `pnpm --filter @courselit/web openapi:generate` passes +- [ ] Product/customer GraphQL business logic is unchanged +- [ ] Existing `/api/user`, payment, and lesson API route handlers are backward compatible +- [ ] `/api/media/presigned` supports both existing session auth and owner-backed API-key auth + +### Phase 2: Product And Payment Plans + +- [ ] Task 4: Product list and detail endpoints + + - Description: Add `GET /api/products` and `GET /api/products/{productId}` for full existing product type parity: `course`, `download`, and `blog`. + - Acceptance: Routes reuse existing product read behavior, support documented filters/pagination, and do not expose `course.cost` or `course.costType`. + - Verify: `pnpm test`, `pnpm --filter @courselit/web openapi:generate` + - Dependencies: Tasks 1, 2, 3 + - Files: `apps/web/app/api/products*`, route tests, OpenAPI fragments + - Estimated scope: M + +- [ ] Task 5: Product create and update endpoints + + - Description: Add `POST /api/products` and `PATCH /api/products/{productId}` using existing product behavior. + - Acceptance: Draft product creation works, metadata updates work, publishing enforces existing payment-plan-required validation, and `blog` parity is preserved where existing behavior supports it. + - Verify: `pnpm test` + - Dependencies: Task 4 + - Files: `apps/web/app/api/products*`, route tests, OpenAPI fragments + - Estimated scope: M + +- [ ] Task 6: Product delete endpoint + + - Description: Add `DELETE /api/products/{productId}` using existing product deletion behavior. + - Acceptance: Deletion follows existing side effects and permissions, OpenAPI clearly documents destructive behavior, and no existing delete logic is altered. + - Verify: `pnpm test` + - Dependencies: Task 4 + - Files: `apps/web/app/api/products*`, route tests, OpenAPI fragments + - Estimated scope: S + +- [ ] Task 7: Product payment-plan read endpoints + + - Description: Add `GET /api/products/{productId}/payment-plans` and `GET /api/products/{productId}/payment-plans/{planId}` for `course` and `download` products. + - Acceptance: Routes return non-internal, non-archived plans, include `isDefault`, reject unsupported product types according to existing capability boundaries, and do not imply blog payment-plan support. + - Verify: `pnpm test` + - Dependencies: Task 4 + - Files: `apps/web/app/api/products/*/payment-plans*`, route tests, OpenAPI fragments + - Estimated scope: M + +- [ ] Task 8: Product payment-plan write endpoints + - Description: Add create, update, archive, and default-plan routes using existing payment-plan behavior. + - Acceptance: Existing validations are preserved, paid-provider checks work, first plan becomes default when existing behavior does so, and archiving the default plan fails. + - Verify: `pnpm test` + - Dependencies: Task 7 + - Files: `apps/web/app/api/products/*/payment-plans*`, route tests, OpenAPI fragments + - Estimated scope: M + +### Checkpoint: Product And Payment Plans + +- [ ] Product draft → payment plan → publish flow works through REST +- [ ] Product CRUD supports `course`, `download`, and `blog` parity where existing behavior supports it +- [ ] Payment-plan endpoints are absent or rejected for product types without existing payment-plan support +- [ ] No legacy `course.cost` or `course.costType` appears in schemas, examples, or responses + +### Phase 3: Product Structure And Lessons + +- [ ] Task 9: Section/group read and write endpoints + + - Description: Add section/group list, create, update, delete, and reorder routes. + - Acceptance: Routes map to existing section/group behavior, preserve ordering semantics, and reject unsupported product types without adding new structure behavior. + - Verify: `pnpm test` + - Dependencies: Task 4 + - Files: `apps/web/app/api/products/*/sections*`, route tests, OpenAPI fragments + - Estimated scope: M + +- [ ] Task 10: Lesson list/detail and text lesson write support + + - Description: Add lesson list/detail plus create/update/delete/move routes for text lessons. + - Acceptance: Text lesson create/update accepts and returns Tiptap/ProseMirror JSON, REST-layer conversion happens outside `apps/web/graphql/**` if needed, and delete/move behavior matches existing logic. + - Verify: `pnpm test` + - Dependencies: Task 9 + - Files: `apps/web/app/api/products/*/lessons*`, REST-layer adapters, route tests, OpenAPI fragments + - Estimated scope: M + +- [ ] Task 11: Media upload signatures and media-backed lesson support + + - Description: Add support for `video`, `audio`, `pdf`, and `file` lesson payloads that reference existing MediaLit `mediaId`s. + - Acceptance: `/api/media/presigned` accepts API keys by resolving the school owner actor, requires that actor to pass the existing `media:manage` permission check, keeps existing dashboard session behavior, lesson routes accept `media.mediaId`, validate same-tenant ownership using existing media-management behavior where available, and document the direct-to-MediaLit upload flow. + - Verify: `pnpm test` + - Dependencies: Tasks 1, 10 + - Files: `apps/web/app/api/media/presigned/route.ts`, `apps/web/app/api/products/*/lessons*`, REST-layer adapters, route tests, OpenAPI fragments + - Estimated scope: M + +- [ ] Task 12: Unsupported SCORM contract + - Description: Add explicit SCORM rejection behavior to lesson create/update routes. + - Acceptance: `type = "scorm"` returns HTTP `422` with `code = "not_supported"` and message `SCORM lessons are not supported by the public API.`, with no partial lesson or media side effects. + - Verify: `pnpm test` + - Dependencies: Task 10 + - Files: `apps/web/app/api/products/*/lessons*`, route tests, OpenAPI fragments + - Estimated scope: S + +### Checkpoint: Content Authoring + +- [ ] A product can be created, structured, populated with text/media-backed lessons, and published through REST +- [ ] SCORM create/update attempts fail with the documented contract +- [ ] Existing dashboard-session media signature flow remains backward compatible +- [ ] SCORM processing/runtime endpoints remain unchanged +- [ ] Swagger examples show Tiptap JSON and media `mediaId` references + +### Phase 4: Customers And Progress + +- [ ] Task 13: Customer roster and detail endpoints + + - Description: Add `GET /api/products/{productId}/customers` and `GET /api/products/{productId}/customers/{userId}` as REST views over existing product roster/member data. + - Acceptance: Roster supports documented pagination/filtering, detail is only a single-row lookup over existing roster/member data, and responses do not expose new customer profile capability. + - Verify: `pnpm test` + - Dependencies: Task 4 + - Files: `apps/web/app/api/products/*/customers*`, REST-layer adapters, route tests, OpenAPI fragments + - Estimated scope: M + +- [ ] Task 14: Customer invitation endpoint + + - Description: Add `POST /api/products/{productId}/customers/invitations` using the existing customer invitation/enrollment flow. + - Acceptance: Request accepts `email` and optional `tags` only, preserves published-product validation, creates/reuses users and memberships through existing behavior, applies tags through existing behavior, and does not add email/profile override controls. + - Verify: `pnpm test` + - Dependencies: Task 13 + - Files: `apps/web/app/api/products/*/customers*`, route tests, OpenAPI fragments + - Estimated scope: M + +- [ ] Task 15: Customer progress read endpoint + - Description: Add `GET /api/products/{productId}/customers/{userId}/progress` as a read-only view over existing progress/reporting data. + - Acceptance: Route returns course/download progress fields derived from existing state, excludes raw `scormData`, and provides no write path for progress. + - Verify: `pnpm test` + - Dependencies: Task 13 + - Files: `apps/web/app/api/products/*/customers/*/progress*`, REST-layer adapters, route tests, OpenAPI fragments + - Estimated scope: M + +### Checkpoint: Customers And Progress + +- [ ] Customer invite/enroll flow works through REST using only existing behavior +- [ ] Customer roster/detail/progress reads match existing reporting semantics +- [ ] No `/api/me` endpoints or progress write endpoints are added + +### Phase 5: Documentation And Release Readiness + +- [ ] Task 16: Swagger workflow documentation + + - Description: Upgrade generated Swagger/OpenAPI documentation for the complete public API flow. + - Acceptance: Swagger includes tags, reusable schemas, operation IDs, examples, owner-backed API-key auth, pagination defaults, destructive-route warnings, SCORM rejection examples, and draft → payment plan → publish workflow examples. + - Verify: `pnpm --filter @courselit/web openapi:generate`, manual Swagger review + - Dependencies: Tasks 1-15 + - Files: `apps/web/openapi/*`, route OpenAPI fragments + - Estimated scope: M + +- [ ] Task 17: Developer documentation + + - Description: Update public developer docs after implementation. + - Acceptance: Docs cover products, payment plans, content authoring, owner-backed API-key auth, media upload flow, customers, progress, auth, tenant/domain model, unsupported SCORM, and no `course.cost`/`course.costType` contract. + - Verify: manual docs review, docs build if applicable + - Dependencies: Task 16 + - Files: `apps/docs/src/pages/en/developers/*` + - Estimated scope: M + +- [ ] Task 18: Final hardening and regression guard + - Description: Run the full verification suite and confirm implementation boundaries. + - Acceptance: Tests/lint/format/OpenAPI generation pass, product/customer GraphQL business logic remains unchanged, existing API endpoint handlers are backward compatible, and every endpoint has an existing-capability mapping. + - Verify: `pnpm test`, `pnpm lint`, `pnpm prettier`, `pnpm --filter @courselit/web openapi:generate` + - Dependencies: Tasks 1-17 + - Files: no feature files unless fixing verification failures + - Estimated scope: S + +### Checkpoint: Complete + +- [ ] All acceptance criteria in this PRD are satisfied +- [ ] Full alternate-frontend flow works: create product, create payment plan, create structure/content, publish, enroll customer, read progress +- [ ] Swagger and developer docs are aligned with shipped behavior +- [ ] Product/customer GraphQL behavior and existing API endpoint behavior remain backward compatible + +## Risks And Mitigations + +- Risk: logic drift between REST endpoints and internal app behavior + + - Mitigation: keep route handlers thin and reuse existing business logic modules + +- Risk: enrollment state and customer progress drift apart + + - Mitigation: treat membership status and `User.purchases` updates as a single shared flow + +- Risk: exposing too much mutable customer state too early + + - Mitigation: keep progress read-only in this scope + +- Risk: API scope drifts into platform feature work + + - Mitigation: require every endpoint to map directly to an existing UI/GraphQL/business workflow before it is included + +- Risk: powerful tenant-level API keys increase blast radius + + - Mitigation: keep API keys tenant-level and owner-backed for v1, rely on the resolved school owner’s existing permissions, document the blast radius clearly, and defer per-key permission models to a separate product decision + +- Risk: file upload handling becomes expensive or unreliable if routed through Next.js lesson endpoints + - Mitigation: keep the direct-to-MediaLit upload flow, gate signature generation with the resolved owner actor’s existing `media:manage` permission, and pass `mediaId` references to lesson APIs From 8d5393bbec79556fcc15d8199ba2a910b2a04296 Mon Sep 17 00:00:00 2001 From: Rajat Date: Tue, 12 May 2026 15:29:48 +0530 Subject: [PATCH 2/7] changed return type of lessons list in API --- AGENTS.md | 17 +- apps/docs-new/next-env.d.ts | 2 +- apps/queue/package.json | 4 +- .../products/[productId]/customers/route.ts | 10 +- .../lessons/__tests__/route.test.ts | 23 +- .../api/products/[productId]/lessons/route.ts | 22 +- .../api/products/__tests__/openapi.test.ts | 14 + apps/web/app/api/products/openapi.mjs | 12 +- .../graphql/courses/__tests__/logic.test.ts | 82 + apps/web/graphql/courses/logic.ts | 8 +- apps/web/graphql/lessons/helpers.ts | 28 +- apps/web/openapi/generated/openapi.json | 16 +- apps/web/package.json | 5 +- package.json | 12 +- packages/common-logic/package.json | 2 +- packages/components-library/package.json | 2 +- packages/orm-models/package.json | 2 +- packages/page-blocks/package.json | 2 +- packages/scripts/package.json | 2 +- pnpm-lock.yaml | 3665 ++--------------- 20 files changed, 589 insertions(+), 3341 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8839950c8..ee493d0db 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,17 +1,17 @@ ## Development Tips - Use `pnpm` as package manager. -- The project is structured as a monorepo i.e. a pnpm workspace. The apps are in `apps` folder and re-usable packages are in `packages`. +- The project is structured as a monorepo, i.e., a pnpm workspace. The apps are in the `apps` folder and reusable packages are in the `packages` folder. - Command for running script in a workspace: `pnpm --filter `. - Command for running tests: `pnpm test`. -- The project uses shadcn for building UI so stick to its conventions and design. +- The project uses shadcn for building UI, so stick to its conventions and design. - In `apps/web` workspace, create a string first in `apps/web/config/strings.ts` and then import it in the `.tsx` files, instead of using inline strings. - For admin/dashboard empty states in `apps/web`, prefer reusing `apps/web/components/admin/empty-state.tsx` instead of creating one-off placeholder UIs. - When working with forms, always use refs to keep the current state of the form's data and use it to enable/disable the form submit button. - Check the name field inside each package's package.json to confirm the right name—skip the top-level one. - While working with forms, always use zod and react-hook-form to validate the form. Take reference implementation from `apps/web/components/admin/settings/sso/new.tsx`. - `packages/scripts` is meant to contain maintenance scripts which can be re-used over and over, not one-off migrations. One-off migrations should be in `apps/web/.migrations`. -- `packages/utils` should be the place for containing utilities which are used in more than one package. +- `packages/utils` should contain utilities used in more than one package. - `apps/web` and `apps/queue` can share business logic and db models. Common business logic should be moved to `packages/common-logic`. Common DB related functionality should be moved to `packages/orm-models`. - For migrations (located in `apps/web/.migrations`), follow the "Gold Standard" pattern: - Use **Cursors** (`.cursor()`) to stream data from MongoDB, ensuring the script remains memory-efficient regardless of dataset size. @@ -21,6 +21,7 @@ - `apps/web` is a multi-tenant app. - Preserve the domain-owner invariant: `domain.email` identifies the school owner and public API keys resolve that owner as the API actor. Do not use raw `UserModel.update*`, `UserModel.delete*`, `DomainModel.update*`, migrations, or scripts in a way that changes/deletes the owner user, changes the owner user's permissions, or drifts `domain.email` away from the owner user without adding explicit guards and tests. - Refrain from adding new GraphQL query/mutation unless required. If an existing query/mutation can be modified to implement the feature without making the query's/mutation's boundaries blurry, extend those. +- Always keep openapi.mjs files in sync with the actual implementation of the API endpoints. ### Workspace map (core modules): @@ -43,17 +44,17 @@ ## Documentation tips -- We manage product's documentation in `apps/docs`. -- When working on a new feature or changing an existing feature significantly, see if documenation should be updated. +- We manage the product's documentation in `apps/docs`. +- When working on a new feature or changing an existing feature significantly, see if documentation should be updated. - No need to update documentation while doing bug fixes and refactors. -- If browser tool is available, see if you can automatically take revelant screenshots and include it in the documentation. +- If a browser tool is available, see if you can automatically take relevant screenshots and include them in the documentation. ## Testing instructions -- Always add or update test when introducing changes to `apps/web/graphql` folder, even if nobody asked. +- Always add or update tests when introducing changes to `apps/web/graphql` folder, even if nobody asked. - Run `pnpm test` to run the tests. - Fix any test or type errors until the whole suite is green. -- Refrain from creating new files when adding tests in `apps/web/graphql` subdirectories. Re-use `logic.test.ts` files for adding new test suites i.e. describe blocks. +- Refrain from creating new files when adding tests in `apps/web/graphql` subdirectories. Re-use `logic.test.ts` files for adding new test suites, i.e., describe blocks. ## PR instructions diff --git a/apps/docs-new/next-env.d.ts b/apps/docs-new/next-env.d.ts index c4b7818fb..9edff1c7c 100644 --- a/apps/docs-new/next-env.d.ts +++ b/apps/docs-new/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/queue/package.json b/apps/queue/package.json index bb996ba57..4fea03127 100644 --- a/apps/queue/package.json +++ b/apps/queue/package.json @@ -28,8 +28,8 @@ "express": "^4.18.2", "jsdom": "^26.1.0", "liquidjs": "^10.11.1", - "mongodb": "^6.15.0", - "mongoose": "^8.13.1", + "mongodb": "^6.21.0", + "mongoose": "^8.22.1", "nodemailer": "^6.9.2", "posthog-node": "^5.9.1", "pino": "^8.14.1", diff --git a/apps/web/app/api/products/[productId]/customers/route.ts b/apps/web/app/api/products/[productId]/customers/route.ts index 962dc95f1..a4b911810 100644 --- a/apps/web/app/api/products/[productId]/customers/route.ts +++ b/apps/web/app/api/products/[productId]/customers/route.ts @@ -63,10 +63,12 @@ export async function GET( : []; const userMap = new Map(users.map((u: any) => [u.userId, u])); - const customers = (members as any[]).map((member) => { - const user = userMap.get(member.userId); - return serializeMember(member, user); - }); + const customers = (members as any[]) + .map((member) => { + const user = userMap.get(member.userId); + return user ? serializeMember(member, user) : null; + }) + .filter(Boolean); return NextResponse.json({ data: customers, diff --git a/apps/web/app/api/products/[productId]/lessons/__tests__/route.test.ts b/apps/web/app/api/products/[productId]/lessons/__tests__/route.test.ts index 50c18e3f8..1eb804c1b 100644 --- a/apps/web/app/api/products/[productId]/lessons/__tests__/route.test.ts +++ b/apps/web/app/api/products/[productId]/lessons/__tests__/route.test.ts @@ -81,16 +81,21 @@ describe("/api/products/{productId}/lessons", () => { await expect(response.json()).resolves.toEqual({ data: [ { - lessonId: "lesson-1", - title: "Intro", - type: "text", - content: tiptapDoc, - media: undefined, - downloadable: undefined, - courseId: "course-1", groupId: "group-1", - requiresEnrollment: true, - published: false, + lessons: [ + { + lessonId: "lesson-1", + title: "Intro", + type: "text", + content: tiptapDoc, + media: undefined, + downloadable: undefined, + courseId: "course-1", + groupId: "group-1", + requiresEnrollment: true, + published: false, + }, + ], }, ], }); diff --git a/apps/web/app/api/products/[productId]/lessons/route.ts b/apps/web/app/api/products/[productId]/lessons/route.ts index f5d5a3f88..95a449bcb 100644 --- a/apps/web/app/api/products/[productId]/lessons/route.ts +++ b/apps/web/app/api/products/[productId]/lessons/route.ts @@ -31,6 +31,19 @@ function getUnsupportedField(body: Record) { return Object.keys(body).find((key) => !createLessonFields.has(key)); } +function groupLessonsByGroupId(sortedLessons: any[]) { + const groups: { groupId: string; lessons: any[] }[] = []; + for (const lesson of sortedLessons) { + const lastGroup = groups[groups.length - 1]; + if (lastGroup && lastGroup.groupId === lesson.groupId) { + lastGroup.lessons.push(lesson); + } else { + groups.push({ groupId: lesson.groupId, lessons: [lesson] }); + } + } + return groups; +} + export async function GET( req: NextRequest, { params }: { params: Promise<{ productId: string }> }, @@ -47,8 +60,15 @@ export async function GET( ctx: auth.ctx as any, }); + const groupedLessons = groupLessonsByGroupId(lessons as any[]); + return NextResponse.json({ - data: lessons.map((lesson) => serializeLesson(lesson as any)), + data: groupedLessons.map((group) => ({ + groupId: group.groupId, + lessons: group.lessons.map((lesson) => + serializeLesson(lesson as any), + ), + })), }); } catch (error: any) { return publicApiError( diff --git a/apps/web/app/api/products/__tests__/openapi.test.ts b/apps/web/app/api/products/__tests__/openapi.test.ts index fe80d07c4..917635cc8 100644 --- a/apps/web/app/api/products/__tests__/openapi.test.ts +++ b/apps/web/app/api/products/__tests__/openapi.test.ts @@ -297,5 +297,19 @@ describe("Products OpenAPI", () => { "422" ].description, ).toContain("SCORM"); + expect( + routes.components.schemas.LessonListResponse.properties.data.items + .$ref, + ).toBe("#/components/schemas/LessonGroup"); + expect(routes.components.schemas.LessonGroup).toMatchObject({ + type: "object", + properties: { + groupId: { type: "string" }, + lessons: { + type: "array", + items: { $ref: "#/components/schemas/Lesson" }, + }, + }, + }); }); }); diff --git a/apps/web/app/api/products/openapi.mjs b/apps/web/app/api/products/openapi.mjs index e9abf3d1d..566a94120 100644 --- a/apps/web/app/api/products/openapi.mjs +++ b/apps/web/app/api/products/openapi.mjs @@ -1066,6 +1066,16 @@ export const productsApiOpenApi = { published: { type: "boolean" }, }, }, + LessonGroup: { + type: "object", + properties: { + groupId: { type: "string" }, + lessons: { + type: "array", + items: { $ref: "#/components/schemas/Lesson" }, + }, + }, + }, LessonCreateRequest: { type: "object", required: ["title", "type", "groupId"], @@ -1099,7 +1109,7 @@ export const productsApiOpenApi = { properties: { data: { type: "array", - items: { $ref: "#/components/schemas/Lesson" }, + items: { $ref: "#/components/schemas/LessonGroup" }, }, }, }, diff --git a/apps/web/graphql/courses/__tests__/logic.test.ts b/apps/web/graphql/courses/__tests__/logic.test.ts index c23210c1b..62126e743 100644 --- a/apps/web/graphql/courses/__tests__/logic.test.ts +++ b/apps/web/graphql/courses/__tests__/logic.test.ts @@ -558,6 +558,88 @@ describe("public API product read helpers", () => { expect(lessons[0].courseId).toBe(course.courseId); }); + it("returns lessons grouped by group rank and sorted by lessonsOrder", async () => { + const groupId1 = helperId("group-1"); + const groupId2 = helperId("group-2"); + const lesson1 = helperId("lesson-1"); + const lesson2 = helperId("lesson-2"); + const lesson3 = helperId("lesson-3"); + + const course = await CourseModel.create({ + domain: testDomain._id, + courseId: helperId("course-ordered"), + title: "Ordered Course", + creatorId: adminUser.userId, + groups: [ + { + _id: groupId1, + name: "Group 1", + rank: 2000, + collapsed: true, + lessonsOrder: [lesson2, lesson1], + }, + { + _id: groupId2, + name: "Group 2", + rank: 1000, + collapsed: true, + lessonsOrder: [lesson3], + }, + ], + lessons: [lesson1, lesson2, lesson3], + type: constants.course, + privacy: "unlisted", + costType: "free", + cost: 0, + slug: helperId("course-slug-ordered"), + }); + + await LessonModel.insertMany([ + { + domain: testDomain._id, + lessonId: lesson1, + title: "Lesson 1", + type: constants.text, + creatorId: adminUser.userId, + courseId: course.courseId, + groupId: groupId1, + }, + { + domain: testDomain._id, + lessonId: lesson2, + title: "Lesson 2", + type: constants.text, + creatorId: adminUser.userId, + courseId: course.courseId, + groupId: groupId1, + }, + { + domain: testDomain._id, + lessonId: lesson3, + title: "Lesson 3", + type: constants.text, + creatorId: adminUser.userId, + courseId: course.courseId, + groupId: groupId2, + }, + ]); + + const lessons = await getCourseLessons({ + courseId: course.courseId, + ctx: { + subdomain: testDomain, + user: adminUser, + address: "", + }, + }); + + expect(lessons.map((l: any) => l.lessonId)).toEqual([ + lesson3, + lesson2, + lesson1, + ]); + }); + it("rejects lesson reads when the lesson does not belong to the product", async () => { const course = await CourseModel.create({ domain: testDomain._id, diff --git a/apps/web/graphql/courses/logic.ts b/apps/web/graphql/courses/logic.ts index 6a7727b8d..84ad8af10 100644 --- a/apps/web/graphql/courses/logic.ts +++ b/apps/web/graphql/courses/logic.ts @@ -34,7 +34,7 @@ import { import { deleteAllLessons } from "../lessons/logic"; import { deleteMedia, sealMedia } from "@/services/medialit"; import PageModel from "@/models/Page"; -import { getPrevNextCursor } from "../lessons/helpers"; +import { getGroupedLessons, getPrevNextCursor } from "../lessons/helpers"; import { checkPermission, extractMediaIDs } from "@courselit/utils"; import { error } from "@/services/logger"; import { @@ -733,11 +733,7 @@ export const getCourseLessons = async ({ ctx: GQLContext; }) => { const course = await getCourseOrThrow(undefined, ctx, courseId); - - return await LessonModel.find({ - domain: ctx.subdomain._id, - courseId: course.courseId, - }).sort({ _id: 1 }); + return await getGroupedLessons(course.courseId, ctx.subdomain._id); }; export const getCourseLessonOrThrow = async ({ diff --git a/apps/web/graphql/lessons/helpers.ts b/apps/web/graphql/lessons/helpers.ts index d7663139d..9e7dbbf58 100644 --- a/apps/web/graphql/lessons/helpers.ts +++ b/apps/web/graphql/lessons/helpers.ts @@ -83,12 +83,12 @@ function validateMediaContent(lessonData: LessonValidatorProps) { } } -type GroupLessonItem = Pick; export const getGroupedLessons = async ( courseId: string, domainId: mongoose.Types.ObjectId, publishedOnly: boolean = false, -): Promise => { + select?: Record, +): Promise => { const course = await CourseModel.findOne({ courseId: courseId, domain: domainId, @@ -100,27 +100,30 @@ export const getGroupedLessons = async ( if (publishedOnly) { lessonsQuery.published = true; } - const allLessons = await LessonModel.find(lessonsQuery, { - lessonId: 1, - groupId: 1, - }); - const lessonsInSequentialOrder: GroupLessonItem[] = []; + const allLessons = await LessonModel.find(lessonsQuery, select); + const lessonsInSequentialOrder: any[] = []; for (let group of (course?.groups ?? []).sort( (a: Group, b: Group) => a.rank - b.rank, )) { lessonsInSequentialOrder.push( ...allLessons - .filter( - (lesson: GroupLessonItem) => lesson.groupId === group.id, - ) + .filter((lesson: any) => lesson.groupId === group.id) .sort( - (a: GroupLessonItem, b: GroupLessonItem) => + (a: any, b: any) => group.lessonsOrder?.indexOf(a.lessonId) - group.lessonsOrder?.indexOf(b.lessonId), ), ); } - return lessonsInSequentialOrder; + + const includedLessonIds = new Set( + lessonsInSequentialOrder.map((l) => l.lessonId), + ); + const remainingLessons = allLessons.filter( + (lesson: any) => !includedLessonIds.has(lesson.lessonId), + ); + + return [...lessonsInSequentialOrder, ...remainingLessons]; }; export const getPrevNextCursor = async ( @@ -133,6 +136,7 @@ export const getPrevNextCursor = async ( courseId, domainId, publishedOnly, + { lessonId: 1, groupId: 1 }, ); const indexOfCurrentLesson = lessonId ? lessonsInSequentialOrder.findIndex( diff --git a/apps/web/openapi/generated/openapi.json b/apps/web/openapi/generated/openapi.json index 4b5d48874..7d2b96da4 100644 --- a/apps/web/openapi/generated/openapi.json +++ b/apps/web/openapi/generated/openapi.json @@ -2487,6 +2487,20 @@ } } }, + "LessonGroup": { + "type": "object", + "properties": { + "groupId": { + "type": "string" + }, + "lessons": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Lesson" + } + } + } + }, "LessonCreateRequest": { "type": "object", "required": ["title", "type", "groupId"], @@ -2577,7 +2591,7 @@ "data": { "type": "array", "items": { - "$ref": "#/components/schemas/Lesson" + "$ref": "#/components/schemas/LessonGroup" } } } diff --git a/apps/web/package.json b/apps/web/package.json index 66e14440c..dd3e17271 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -83,8 +83,8 @@ "lodash.debounce": "^4.0.8", "lucide-react": "^0.553.0", "medialit": "0.2.0", - "mongodb": "^6.15.0", - "mongoose": "^8.13.1", + "mongodb": "^6.21.0", + "mongoose": "^8.22.1", "next": "^16.0.10", "next-themes": "^0.4.6", "nodemailer": "^6.7.2", @@ -96,7 +96,6 @@ "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", diff --git a/package.json b/package.json index bcb0b0d42..e6c98bba9 100644 --- a/package.json +++ b/package.json @@ -66,7 +66,17 @@ "prosemirror-model": "^1.22.3", "prosemirror-view": "^1.34.2", "@types/react": "18.3.7", - "@types/react-dom": "18.3.0" + "@types/react-dom": "18.3.0", + "form-data": "4.0.5", + "@aws-sdk/core>fast-xml-parser": "4.5.4", + "@grpc/proto-loader>protobufjs": "7.5.7", + "axios": "1.16.0", + "fast-uri": "3.1.2", + "@xmldom/xmldom": "0.8.13", + "kysely": "0.28.17", + "lodash": "4.18.1", + "lodash-es": "4.18.1", + "express>path-to-regexp": "0.1.13" } } } diff --git a/packages/common-logic/package.json b/packages/common-logic/package.json index 50635a9a2..69041956e 100644 --- a/packages/common-logic/package.json +++ b/packages/common-logic/package.json @@ -49,6 +49,6 @@ "@courselit/orm-models": "workspace:^", "@courselit/utils": "workspace:^", "jsonwebtoken": "^9.0.3", - "mongoose": "^8.13.1" + "mongoose": "^8.22.1" } } diff --git a/packages/components-library/package.json b/packages/components-library/package.json index c59a8e210..7018a3905 100644 --- a/packages/components-library/package.json +++ b/packages/components-library/package.json @@ -49,7 +49,7 @@ "typescript-eslint": "^7.4.0" }, "peerDependencies": { - "next": "*", + "next": "^16.0.10", "react": "*" }, "gitHead": "fca7d2f8455c835f1c185a1ebc8513c6269ebe5b", diff --git a/packages/orm-models/package.json b/packages/orm-models/package.json index d0b8e4920..c4c069fdb 100644 --- a/packages/orm-models/package.json +++ b/packages/orm-models/package.json @@ -46,6 +46,6 @@ "@courselit/utils": "workspace:^", "@courselit/email-editor": "workspace:^", "@courselit/page-models": "workspace:^", - "mongoose": "^8.13.1" + "mongoose": "^8.22.1" } } diff --git a/packages/page-blocks/package.json b/packages/page-blocks/package.json index cc6aa2ed5..bc8633db5 100644 --- a/packages/page-blocks/package.json +++ b/packages/page-blocks/package.json @@ -38,7 +38,7 @@ }, "peerDependencies": { "lucide-react": "*", - "next": "*", + "next": "^16.0.10", "react": "*", "react-dom": "*" }, diff --git a/packages/scripts/package.json b/packages/scripts/package.json index e9a8bed48..b0d24e02c 100644 --- a/packages/scripts/package.json +++ b/packages/scripts/package.json @@ -17,7 +17,7 @@ "packageManager": "pnpm@10.22.0", "dependencies": { "medialit": "^0.1.0", - "mongoose": "^8.14.0" + "mongoose": "^8.22.1" }, "devDependencies": { "@courselit/orm-models": "workspace:^", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5a13e3252..05ddbd8e9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,6 +9,16 @@ overrides: prosemirror-view: ^1.34.2 '@types/react': 18.3.7 '@types/react-dom': 18.3.0 + form-data: 4.0.5 + '@aws-sdk/core>fast-xml-parser': 4.5.4 + '@grpc/proto-loader>protobufjs': 7.5.7 + axios: 1.16.0 + fast-uri: 3.1.2 + '@xmldom/xmldom': 0.8.13 + kysely: 0.28.17 + lodash: 4.18.1 + lodash-es: 4.18.1 + express>path-to-regexp: 0.1.13 importers: @@ -226,11 +236,11 @@ importers: specifier: ^10.11.1 version: 10.21.0 mongodb: - specifier: ^6.15.0 - version: 6.16.0(@aws-sdk/credential-providers@3.797.0)(socks@2.8.4) + specifier: ^6.21.0 + version: 6.21.0(@aws-sdk/credential-providers@3.797.0)(socks@2.8.4) mongoose: - specifier: ^8.13.1 - version: 8.14.0(@aws-sdk/credential-providers@3.797.0)(socks@2.8.4) + specifier: ^8.22.1 + version: 8.23.1(@aws-sdk/credential-providers@3.797.0)(socks@2.8.4) nodemailer: specifier: ^6.9.2 version: 6.10.1 @@ -249,7 +259,7 @@ importers: devDependencies: '@shelf/jest-mongodb': specifier: ^5.2.2 - version: 5.2.2(@aws-sdk/credential-providers@3.797.0)(jest-environment-node@29.7.0)(mongodb@6.16.0(@aws-sdk/credential-providers@3.797.0)(socks@2.8.4))(socks@2.8.4) + version: 5.2.2(@aws-sdk/credential-providers@3.797.0)(jest-environment-node@29.7.0)(mongodb@6.21.0(@aws-sdk/credential-providers@3.797.0)(socks@2.8.4))(socks@2.8.4) '@types/express': specifier: ^4.17.17 version: 4.17.21 @@ -285,7 +295,7 @@ importers: dependencies: '@better-auth/sso': specifier: ^1.5.6 - version: 1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@3.24.3))(jose@6.2.2)(kysely@0.28.15)(nanostores@1.2.0))(@better-auth/utils@0.3.1)(better-auth@1.5.6(@opentelemetry/api@1.9.0)(mongodb@6.16.0(@aws-sdk/credential-providers@3.797.0)(socks@2.8.4))(next@16.1.6(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(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-call@1.3.2(zod@3.24.3)) + version: 1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@3.24.3))(jose@6.2.2)(kysely@0.28.17)(nanostores@1.2.0))(@better-auth/utils@0.3.1)(better-auth@1.5.6(@opentelemetry/api@1.9.0)(mongodb@6.21.0(@aws-sdk/credential-providers@3.797.0)(socks@2.8.4))(next@16.1.6(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(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-call@1.3.2(zod@3.24.3)) '@courselit/common-logic': specifier: workspace:^ version: link:../../packages/common-logic @@ -417,7 +427,7 @@ importers: version: 1.0.0 better-auth: specifier: ^1.5.6 - version: 1.5.6(@opentelemetry/api@1.9.0)(mongodb@6.16.0(@aws-sdk/credential-providers@3.797.0)(socks@2.8.4))(next@16.1.6(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(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.5.6(@opentelemetry/api@1.9.0)(mongodb@6.21.0(@aws-sdk/credential-providers@3.797.0)(socks@2.8.4))(next@16.1.6(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(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 @@ -455,11 +465,11 @@ importers: 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) + specifier: ^6.21.0 + version: 6.21.0(@aws-sdk/credential-providers@3.797.0)(socks@2.8.4) mongoose: - specifier: ^8.13.1 - version: 8.14.0(@aws-sdk/credential-providers@3.797.0)(socks@2.8.4) + specifier: ^8.22.1 + version: 8.23.1(@aws-sdk/credential-providers@3.797.0)(socks@2.8.4) next: specifier: ^16.0.10 version: 16.1.6(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) @@ -493,9 +503,6 @@ importers: recharts: specifier: ^2.15.1 version: 2.15.3(react-dom@19.2.0(react@19.2.0))(react@19.2.0) - remirror: - specifier: ^3.0.1 - version: 3.0.3(@remirror/extension-react-tables@3.0.3(@remirror/pm@3.0.1)(@types/node@17.0.21)(@types/react-dom@18.3.0)(@types/react@18.3.7)(jsdom@26.1.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0)(prettier@3.5.3) sharp: specifier: ^0.33.2 version: 0.33.5 @@ -529,7 +536,7 @@ importers: version: 3.3.1 '@shelf/jest-mongodb': specifier: ^5.2.2 - version: 5.2.2(@aws-sdk/credential-providers@3.797.0)(jest-environment-node@29.7.0)(mongodb@6.16.0(@aws-sdk/credential-providers@3.797.0)(socks@2.8.4))(socks@2.8.4) + version: 5.2.2(@aws-sdk/credential-providers@3.797.0)(jest-environment-node@29.7.0)(mongodb@6.21.0(@aws-sdk/credential-providers@3.797.0)(socks@2.8.4))(socks@2.8.4) '@types/adm-zip': specifier: ^0.5.7 version: 0.5.7 @@ -615,8 +622,8 @@ importers: specifier: ^9.0.3 version: 9.0.3 mongoose: - specifier: ^8.13.1 - version: 8.14.0(@aws-sdk/credential-providers@3.797.0)(socks@2.8.4) + specifier: ^8.22.1 + version: 8.23.1(@aws-sdk/credential-providers@3.797.0)(socks@2.8.4) devDependencies: '@types/jsonwebtoken': specifier: ^9.0.10 @@ -770,8 +777,8 @@ importers: specifier: ^0.553.0 version: 0.553.0(react@18.3.1) next: - specifier: '*' - version: 15.5.3(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@19.2.0(react@18.3.1))(react@18.3.1) + specifier: ^16.0.10 + version: 16.1.6(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@19.2.0(react@18.3.1))(react@18.3.1) react: specifier: '*' version: 18.3.1 @@ -953,8 +960,8 @@ importers: specifier: workspace:^ version: link:../utils mongoose: - specifier: ^8.13.1 - version: 8.14.0(@aws-sdk/credential-providers@3.797.0)(socks@2.8.4) + specifier: ^8.22.1 + version: 8.23.1(@aws-sdk/credential-providers@3.797.0)(socks@2.8.4) devDependencies: '@typescript-eslint/eslint-plugin': specifier: ^8.46.0 @@ -1014,8 +1021,8 @@ importers: specifier: '*' version: 0.544.0(react@19.2.0) next: - specifier: '*' - version: 15.5.3(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + specifier: ^16.0.10 + version: 16.1.6(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) react: specifier: '*' version: 19.2.0 @@ -1160,8 +1167,8 @@ importers: specifier: ^0.1.0 version: 0.1.0 mongoose: - specifier: ^8.14.0 - version: 8.14.0(@aws-sdk/credential-providers@3.797.0)(socks@2.8.4) + specifier: ^8.22.1 + version: 8.23.1(@aws-sdk/credential-providers@3.797.0)(socks@2.8.4) devDependencies: '@courselit/common-models': specifier: workspace:^ @@ -1585,10 +1592,6 @@ packages: resolution: {integrity: sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==} engines: {node: '>=6.9.0'} - '@babel/code-frame@7.29.0': - resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} - engines: {node: '>=6.9.0'} - '@babel/compat-data@7.26.8': resolution: {integrity: sha512-oH5UPLMWR3L2wEFLnFJ1TZXqHufiTKAiLfqw5zkhS4dKXLJ10yVztfil/twG8EDTA4F/tvVNw9nOl4ZMslB8rQ==} engines: {node: '>=6.9.0'} @@ -1601,10 +1604,6 @@ packages: resolution: {integrity: sha512-VybsKvpiN1gU1sdMZIp7FcqphVVKEwcuj02x73uvcHE0PTihx1nlBcowYWhDwjpoAXRv43+gDzyggGnn1XZhVw==} engines: {node: '>=6.9.0'} - '@babel/generator@7.29.1': - resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} - engines: {node: '>=6.9.0'} - '@babel/helper-annotate-as-pure@7.25.9': resolution: {integrity: sha512-gv7320KBUFJz1RnylIg5WWYPRXKZ884AGkYpgpWW02TH66Dl+HaC1t1CKd0z3R4b6hdYEcmrNZHUmfCP+1u3/g==} engines: {node: '>=6.9.0'} @@ -1613,18 +1612,10 @@ packages: resolution: {integrity: sha512-LVk7fbXml0H2xH34dFzKQ7TDZ2G4/rVTOrq9V+icbbadjbVxxeFeDsNHv2SrZeWoA+6ZiTyWYWtScEIW07EAcA==} engines: {node: '>=6.9.0'} - '@babel/helper-globals@7.28.0': - resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} - engines: {node: '>=6.9.0'} - '@babel/helper-module-imports@7.25.9': resolution: {integrity: sha512-tnUA4RsrmflIM6W6RFTLFSXITtl0wKjgpnLgXyowocVPrbYrLUXSBXDgTs8BlbmIzIdlBySRQjINYs2BAkiLtw==} engines: {node: '>=6.9.0'} - '@babel/helper-module-imports@7.28.6': - resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} - engines: {node: '>=6.9.0'} - '@babel/helper-module-transforms@7.26.0': resolution: {integrity: sha512-xO+xu6B5K2czEnQye6BHA7DolFFmS3LB7stHZFaOLb1pAwO1HWLS8fXA+eh0A2yIvltPVmx3eNNDBJA2SLHXFw==} engines: {node: '>=6.9.0'} @@ -1639,18 +1630,10 @@ packages: resolution: {integrity: sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==} engines: {node: '>=6.9.0'} - '@babel/helper-string-parser@7.27.1': - resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} - engines: {node: '>=6.9.0'} - '@babel/helper-validator-identifier@7.25.9': resolution: {integrity: sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==} engines: {node: '>=6.9.0'} - '@babel/helper-validator-identifier@7.28.5': - resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} - engines: {node: '>=6.9.0'} - '@babel/helper-validator-option@7.25.9': resolution: {integrity: sha512-e/zv1co8pp55dNdEcCynfj9X7nyUKUXoUEwfXqaZt0omVOmDe9oOTdKStH4GmAw6zxMFs50ZayuMfHDKlO7Tfw==} engines: {node: '>=6.9.0'} @@ -1664,18 +1647,6 @@ packages: engines: {node: '>=6.0.0'} hasBin: true - '@babel/parser@7.29.2': - resolution: {integrity: sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==} - engines: {node: '>=6.0.0'} - hasBin: true - - '@babel/plugin-proposal-export-namespace-from@7.18.9': - resolution: {integrity: sha512-k1NtHyOMvlDDFeb9G5PhUXuGj8m/wiwojgQVEhJ/fsVsMCpLyOP4h0uGEjYJKrRI+EVPlb5Jk+Gt9P97lOGwtA==} - engines: {node: '>=6.9.0'} - deprecated: This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-export-namespace-from instead. - peerDependencies: - '@babel/core': ^7.0.0-0 - '@babel/plugin-syntax-async-generators@7.8.4': resolution: {integrity: sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==} peerDependencies: @@ -1697,16 +1668,6 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 - '@babel/plugin-syntax-dynamic-import@7.8.3': - resolution: {integrity: sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ==} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-export-namespace-from@7.8.3': - resolution: {integrity: sha512-MXf5laXo6c1IbEbegDmzGPwGNTsHZmEy6QGznu5Sh2UCWvueywb2ee+CCE4zQiZstxU9BMoQO9i6zUFSY0Kj0Q==} - peerDependencies: - '@babel/core': ^7.0.0-0 - '@babel/plugin-syntax-import-attributes@7.26.0': resolution: {integrity: sha512-e2dttdsJ1ZTpi3B9UYGLw41hifAubg19AtCu/2I/F1QNVclOBr1dYpTdmdyZ84Xiz43BS/tCUkMAZNLv12Pi+A==} engines: {node: '>=6.9.0'} @@ -1777,12 +1738,6 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 - '@babel/plugin-transform-modules-commonjs@7.26.3': - resolution: {integrity: sha512-MgR55l4q9KddUDITEzEFYn5ZsGDXMSsU9E+kh7fjRXTIC3RHqfCo8RPRbyReYJh44HQ/yomFkqbOFohXvDCiIQ==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - '@babel/plugin-transform-react-jsx@7.25.9': resolution: {integrity: sha512-s5XwpQYCqGerXl+Pu6VDL3x0j2d82eiV77UJ8a2mDHAW7j9SWRqQ2y1fNo1Z74CdcYipl5Z41zvjj4Nfzq36rw==} engines: {node: '>=6.9.0'} @@ -1805,26 +1760,14 @@ packages: resolution: {integrity: sha512-2ncevenBqXI6qRMukPlXwHKHchC7RyMuu4xv5JBXRfOGVcTy1mXCD12qrp7Jsoxll1EV3+9sE4GugBVRjT2jFA==} engines: {node: '>=6.9.0'} - '@babel/template@7.28.6': - resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} - engines: {node: '>=6.9.0'} - '@babel/traverse@7.27.0': resolution: {integrity: sha512-19lYZFzYVQkkHkl4Cy4WrAVcqBkgvV2YM2TU3xG6DIwO7O3ecbDPfW3yM3bjAGcqcQHi+CCtjMR3dIEHxsd6bA==} engines: {node: '>=6.9.0'} - '@babel/traverse@7.29.0': - resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} - engines: {node: '>=6.9.0'} - '@babel/types@7.27.0': resolution: {integrity: sha512-H45s8fVLYjbhFH62dIJ3WtmJ6RSPt/3DRO0ZcT2SUiYiQyz3BLVb9ADEnLl91m74aQPS3AzzeajZHYOalWe3bg==} engines: {node: '>=6.9.0'} - '@babel/types@7.29.0': - resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} - engines: {node: '>=6.9.0'} - '@bcoe/v8-coverage@0.2.3': resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} @@ -1837,7 +1780,7 @@ packages: '@opentelemetry/api': ^1.9.0 better-call: 1.3.2 jose: ^6.1.0 - kysely: ^0.28.5 + kysely: 0.28.17 nanostores: ^1.0.1 peerDependenciesMeta: '@cloudflare/workers-types': @@ -1858,7 +1801,7 @@ packages: peerDependencies: '@better-auth/core': 1.5.6 '@better-auth/utils': ^0.3.0 - kysely: ^0.27.0 || ^0.28.0 + kysely: 0.28.17 peerDependenciesMeta: kysely: optional: true @@ -2055,45 +1998,12 @@ packages: '@emnapi/runtime@1.4.3': resolution: {integrity: sha512-pBPWdu6MLKROBX05wSNKcNb++m5Er+KQ9QkB+WVM+pW2Kx9hoSrVTnu3BdkI5eBLZoKu/J6mW/B6i6bJB2ytXQ==} - '@emnapi/runtime@1.5.0': - resolution: {integrity: sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ==} - '@emnapi/runtime@1.7.1': resolution: {integrity: sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==} '@emnapi/wasi-threads@1.0.2': resolution: {integrity: sha512-5n3nTJblwRi8LlXkJ9eBzu+kZR8Yxcc7ubakyQTFzPMtIhFpUBRbsnc2Dv88IZDIbCDlBiWrknhB4Lsz7mg6BA==} - '@emotion/babel-plugin@11.13.5': - resolution: {integrity: sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==} - - '@emotion/cache@11.14.0': - resolution: {integrity: sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==} - - '@emotion/css@11.13.5': - resolution: {integrity: sha512-wQdD0Xhkn3Qy2VNcIzbLP9MR8TafI0MJb7BEAXKp+w4+XqErksWR4OXomuDzPsN4InLdGhVe6EYcn2ZIUCpB8w==} - - '@emotion/hash@0.9.2': - resolution: {integrity: sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==} - - '@emotion/memoize@0.9.0': - resolution: {integrity: sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==} - - '@emotion/serialize@1.3.3': - resolution: {integrity: sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==} - - '@emotion/sheet@1.4.0': - resolution: {integrity: sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==} - - '@emotion/unitless@0.10.0': - resolution: {integrity: sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==} - - '@emotion/utils@1.4.2': - resolution: {integrity: sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==} - - '@emotion/weak-memoize@0.4.0': - resolution: {integrity: sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==} - '@esbuild/aix-ppc64@0.19.12': resolution: {integrity: sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA==} engines: {node: '>=12'} @@ -2633,12 +2543,6 @@ packages: react: '>=16.8.0' react-dom: '>=16.8.0' - '@floating-ui/react@0.24.8': - resolution: {integrity: sha512-AuYeDoaR8jtUlUXtZ1IJ/6jtBkGnSpJXbGNzokBL87VDJ8opMq1Bgrc0szhK482ReQY6KZsMoZCVSb4xwalkBA==} - peerDependencies: - react: '>=16.8.0' - react-dom: '>=16.8.0' - '@floating-ui/utils@0.2.11': resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} @@ -2717,11 +2621,6 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} - '@icons/material@0.2.4': - resolution: {integrity: sha512-QPcGmICAPbGLGb6F/yNf/KzKqvFx8z5qx3D1yFqVAjoFmXK35EgyW+cJ57Te3CNsmzblwtzakLGFqHPqrfb4Tw==} - peerDependencies: - react: '*' - '@img/colour@1.0.0': resolution: {integrity: sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==} engines: {node: '>=18'} @@ -2732,12 +2631,6 @@ packages: cpu: [arm64] os: [darwin] - '@img/sharp-darwin-arm64@0.34.3': - resolution: {integrity: sha512-ryFMfvxxpQRsgZJqBd4wsttYQbCxsJksrv9Lw/v798JcQ8+w84mBWuXwl+TT0WJ/WrYOLaYpwQXi3sA9nTIaIg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm64] - os: [darwin] - '@img/sharp-darwin-arm64@0.34.5': resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -2750,12 +2643,6 @@ packages: cpu: [x64] os: [darwin] - '@img/sharp-darwin-x64@0.34.3': - resolution: {integrity: sha512-yHpJYynROAj12TA6qil58hmPmAwxKKC7reUqtGLzsOHfP7/rniNGTL8tjWX6L3CTV4+5P4ypcS7Pp+7OB+8ihA==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [x64] - os: [darwin] - '@img/sharp-darwin-x64@0.34.5': resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -2767,11 +2654,6 @@ packages: cpu: [arm64] os: [darwin] - '@img/sharp-libvips-darwin-arm64@1.2.0': - resolution: {integrity: sha512-sBZmpwmxqwlqG9ueWFXtockhsxefaV6O84BMOrhtg/YqbTaRdqDE7hxraVE3y6gVM4eExmfzW4a8el9ArLeEiQ==} - cpu: [arm64] - os: [darwin] - '@img/sharp-libvips-darwin-arm64@1.2.4': resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} cpu: [arm64] @@ -2782,11 +2664,6 @@ packages: cpu: [x64] os: [darwin] - '@img/sharp-libvips-darwin-x64@1.2.0': - resolution: {integrity: sha512-M64XVuL94OgiNHa5/m2YvEQI5q2cl9d/wk0qFTDVXcYzi43lxuiFTftMR1tOnFQovVXNZJ5TURSDK2pNe9Yzqg==} - cpu: [x64] - os: [darwin] - '@img/sharp-libvips-darwin-x64@1.2.4': resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} cpu: [x64] @@ -2797,11 +2674,6 @@ packages: cpu: [arm64] os: [linux] - '@img/sharp-libvips-linux-arm64@1.2.0': - resolution: {integrity: sha512-RXwd0CgG+uPRX5YYrkzKyalt2OJYRiJQ8ED/fi1tq9WQW2jsQIn0tqrlR5l5dr/rjqq6AHAxURhj2DVjyQWSOA==} - cpu: [arm64] - os: [linux] - '@img/sharp-libvips-linux-arm64@1.2.4': resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] @@ -2812,21 +2684,11 @@ packages: cpu: [arm] os: [linux] - '@img/sharp-libvips-linux-arm@1.2.0': - resolution: {integrity: sha512-mWd2uWvDtL/nvIzThLq3fr2nnGfyr/XMXlq8ZJ9WMR6PXijHlC3ksp0IpuhK6bougvQrchUAfzRLnbsen0Cqvw==} - cpu: [arm] - os: [linux] - '@img/sharp-libvips-linux-arm@1.2.4': resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] - '@img/sharp-libvips-linux-ppc64@1.2.0': - resolution: {integrity: sha512-Xod/7KaDDHkYu2phxxfeEPXfVXFKx70EAFZ0qyUdOjCcxbjqyJOEUpDe6RIyaunGxT34Anf9ue/wuWOqBW2WcQ==} - cpu: [ppc64] - os: [linux] - '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] @@ -2842,11 +2704,6 @@ packages: cpu: [s390x] os: [linux] - '@img/sharp-libvips-linux-s390x@1.2.0': - resolution: {integrity: sha512-eMKfzDxLGT8mnmPJTNMcjfO33fLiTDsrMlUVcp6b96ETbnJmd4uvZxVJSKPQfS+odwfVaGifhsB07J1LynFehw==} - cpu: [s390x] - os: [linux] - '@img/sharp-libvips-linux-s390x@1.2.4': resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] @@ -2857,11 +2714,6 @@ packages: cpu: [x64] os: [linux] - '@img/sharp-libvips-linux-x64@1.2.0': - resolution: {integrity: sha512-ZW3FPWIc7K1sH9E3nxIGB3y3dZkpJlMnkk7z5tu1nSkBoCgw2nSRTFHI5pB/3CQaJM0pdzMF3paf9ckKMSE9Tg==} - cpu: [x64] - os: [linux] - '@img/sharp-libvips-linux-x64@1.2.4': resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] @@ -2872,11 +2724,6 @@ packages: cpu: [arm64] os: [linux] - '@img/sharp-libvips-linuxmusl-arm64@1.2.0': - resolution: {integrity: sha512-UG+LqQJbf5VJ8NWJ5Z3tdIe/HXjuIdo4JeVNADXBFuG7z9zjoegpzzGIyV5zQKi4zaJjnAd2+g2nna8TZvuW9Q==} - cpu: [arm64] - os: [linux] - '@img/sharp-libvips-linuxmusl-arm64@1.2.4': resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] @@ -2887,11 +2734,6 @@ packages: cpu: [x64] os: [linux] - '@img/sharp-libvips-linuxmusl-x64@1.2.0': - resolution: {integrity: sha512-SRYOLR7CXPgNze8akZwjoGBoN1ThNZoqpOgfnOxmWsklTGVfJiGJoC/Lod7aNMGA1jSsKWM1+HRX43OP6p9+6Q==} - cpu: [x64] - os: [linux] - '@img/sharp-libvips-linuxmusl-x64@1.2.4': resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] @@ -2903,12 +2745,6 @@ packages: cpu: [arm64] os: [linux] - '@img/sharp-linux-arm64@0.34.3': - resolution: {integrity: sha512-QdrKe3EvQrqwkDrtuTIjI0bu6YEJHTgEeqdzI3uWJOH6G1O8Nl1iEeVYRGdj1h5I21CqxSvQp1Yv7xeU3ZewbA==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm64] - os: [linux] - '@img/sharp-linux-arm64@0.34.5': resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -2921,24 +2757,12 @@ packages: cpu: [arm] os: [linux] - '@img/sharp-linux-arm@0.34.3': - resolution: {integrity: sha512-oBK9l+h6KBN0i3dC8rYntLiVfW8D8wH+NPNT3O/WBHeW0OQWCjfWksLUaPidsrDKpJgXp3G3/hkmhptAW0I3+A==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm] - os: [linux] - '@img/sharp-linux-arm@0.34.5': resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] - '@img/sharp-linux-ppc64@0.34.3': - resolution: {integrity: sha512-GLtbLQMCNC5nxuImPR2+RgrviwKwVql28FWZIW1zWruy6zLgA5/x2ZXk3mxj58X/tszVF69KK0Is83V8YgWhLA==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [ppc64] - os: [linux] - '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -2957,12 +2781,6 @@ packages: cpu: [s390x] os: [linux] - '@img/sharp-linux-s390x@0.34.3': - resolution: {integrity: sha512-3gahT+A6c4cdc2edhsLHmIOXMb17ltffJlxR0aC2VPZfwKoTGZec6u5GrFgdR7ciJSsHT27BD3TIuGcuRT0KmQ==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [s390x] - os: [linux] - '@img/sharp-linux-s390x@0.34.5': resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -2975,12 +2793,6 @@ packages: cpu: [x64] os: [linux] - '@img/sharp-linux-x64@0.34.3': - resolution: {integrity: sha512-8kYso8d806ypnSq3/Ly0QEw90V5ZoHh10yH0HnrzOCr6DKAPI6QVHvwleqMkVQ0m+fc7EH8ah0BB0QPuWY6zJQ==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [x64] - os: [linux] - '@img/sharp-linux-x64@0.34.5': resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -2993,12 +2805,6 @@ packages: cpu: [arm64] os: [linux] - '@img/sharp-linuxmusl-arm64@0.34.3': - resolution: {integrity: sha512-vAjbHDlr4izEiXM1OTggpCcPg9tn4YriK5vAjowJsHwdBIdx0fYRsURkxLG2RLm9gyBq66gwtWI8Gx0/ov+JKQ==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm64] - os: [linux] - '@img/sharp-linuxmusl-arm64@0.34.5': resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -3011,12 +2817,6 @@ packages: cpu: [x64] os: [linux] - '@img/sharp-linuxmusl-x64@0.34.3': - resolution: {integrity: sha512-gCWUn9547K5bwvOn9l5XGAEjVTTRji4aPTqLzGXHvIr6bIDZKNTA34seMPgM0WmSf+RYBH411VavCejp3PkOeQ==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [x64] - os: [linux] - '@img/sharp-linuxmusl-x64@0.34.5': resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -3028,22 +2828,11 @@ packages: engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [wasm32] - '@img/sharp-wasm32@0.34.3': - resolution: {integrity: sha512-+CyRcpagHMGteySaWos8IbnXcHgfDn7pO2fiC2slJxvNq9gDipYBN42/RagzctVRKgxATmfqOSulgZv5e1RdMg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [wasm32] - '@img/sharp-wasm32@0.34.5': resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [wasm32] - '@img/sharp-win32-arm64@0.34.3': - resolution: {integrity: sha512-MjnHPnbqMXNC2UgeLJtX4XqoVHHlZNd+nPt1kRPmj63wURegwBhZlApELdtxM2OIZDRv/DFtLcNhVbd1z8GYXQ==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm64] - os: [win32] - '@img/sharp-win32-arm64@0.34.5': resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -3056,12 +2845,6 @@ packages: cpu: [ia32] os: [win32] - '@img/sharp-win32-ia32@0.34.3': - resolution: {integrity: sha512-xuCdhH44WxuXgOM714hn4amodJMZl3OEvf0GVTm0BEyMeA2to+8HEdRPShH0SLYptJY1uBw+SCFP9WVQi1Q/cw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [ia32] - os: [win32] - '@img/sharp-win32-ia32@0.34.5': resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -3074,12 +2857,6 @@ packages: cpu: [x64] os: [win32] - '@img/sharp-win32-x64@0.34.3': - resolution: {integrity: sha512-OWwz05d++TxzLEv4VnsTz5CmZ6mI6S05sfQGEMrNrQcOEERbX46332IvE7pO/EUiw7jUrrS40z/M7kPyjfl04g==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [x64] - os: [win32] - '@img/sharp-win32-x64@0.34.5': resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -3215,22 +2992,6 @@ packages: '@kurkle/color@0.3.4': resolution: {integrity: sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==} - '@linaria/core@4.2.10': - resolution: {integrity: sha512-S1W01W7L4SQnGpWzp8awyCpPIYUOEJ+OLjjXqKpIXOU+ozPwBt86Mjjdas9aZccVhNBWDja74cMCUAVp8yUpDQ==} - engines: {node: ^12.16.0 || >=13.7.0} - - '@linaria/logger@4.5.0': - resolution: {integrity: sha512-XdQLk242Cpcsc9a3Cz1ktOE5ysTo2TpxdeFQEPwMm8Z/+F/S6ZxBDdHYJL09srXWz3hkJr3oS2FPuMZNH1HIxw==} - engines: {node: ^12.16.0 || >=13.7.0} - - '@linaria/tags@4.5.4': - resolution: {integrity: sha512-HPxLB6HlJWLi6o8+8lTLegOmDnbMbuzEE+zzunaPZEGSoIIYx8HAv5VbY/sG/zNyxDElk6laiAwEVWN8h5/zxg==} - engines: {node: ^12.16.0 || >=13.7.0} - - '@linaria/utils@4.5.3': - resolution: {integrity: sha512-tSpxA3Zn0DKJ2n/YBnYAgiDY+MNvkmzAHrD8R9PKrpGaZ+wz1jQEmE1vGn1cqh8dJyWK0NzPAA8sf1cqa+RmAg==} - engines: {node: ^12.16.0 || >=13.7.0} - '@ljharb/has-package-exports-patterns@0.0.2': resolution: {integrity: sha512-4/RWEeXDO6bocPONheFe6gX/oQdP/bEpv0oL4HqjPP5DCenBSt0mHgahppY49N0CpsaqffdwPq+TlX9CYOq2Dw==} @@ -3243,12 +3004,12 @@ packages: '@mdx-js/mdx@3.1.1': resolution: {integrity: sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ==} - '@mixmark-io/domino@2.2.0': - resolution: {integrity: sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==} - '@mongodb-js/saslprep@1.2.2': resolution: {integrity: sha512-EB0O3SCSNRUFk66iRCpI+cXzIjdswfCs7F6nOC3RAGJ7xr5YhaicvsRwJ9eyzYvYRlCSDUO/c7g4yNulxKC1WA==} + '@mongodb-js/saslprep@1.4.11': + resolution: {integrity: sha512-o9rAHc0IpIjuPSxRutWpE1F62x7n+4mVS4rCNHkzhIUMQcc18bb6xEq5wd2NdN0WjepIyXIppRshYI2kQDOZVA==} + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3': resolution: {integrity: sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==} cpu: [arm64] @@ -3282,105 +3043,54 @@ packages: '@napi-rs/wasm-runtime@0.2.9': resolution: {integrity: sha512-OKRBiajrrxB9ATokgEQoG87Z25c67pCpYcCwmXYX8PBftC9pBfN18gnm/fh1wurSLEKIAt+QRFLFCQISrb66Jg==} - '@next/env@15.5.3': - resolution: {integrity: sha512-RSEDTRqyihYXygx/OJXwvVupfr9m04+0vH8vyy0HfZ7keRto6VX9BbEk0J2PUk0VGy6YhklJUSrgForov5F9pw==} - '@next/env@16.1.6': resolution: {integrity: sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ==} '@next/eslint-plugin-next@16.0.3': resolution: {integrity: sha512-6sPWmZetzFWMsz7Dhuxsdmbu3fK+/AxKRtj7OB0/3OZAI2MHB/v2FeYh271LZ9abvnM1WIwWc/5umYjx0jo5sQ==} - '@next/swc-darwin-arm64@15.5.3': - resolution: {integrity: sha512-nzbHQo69+au9wJkGKTU9lP7PXv0d1J5ljFpvb+LnEomLtSbJkbZyEs6sbF3plQmiOB2l9OBtN2tNSvCH1nQ9Jg==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [darwin] - '@next/swc-darwin-arm64@16.1.6': resolution: {integrity: sha512-wTzYulosJr/6nFnqGW7FrG3jfUUlEf8UjGA0/pyypJl42ExdVgC6xJgcXQ+V8QFn6niSG2Pb8+MIG1mZr2vczw==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@next/swc-darwin-x64@15.5.3': - resolution: {integrity: sha512-w83w4SkOOhekJOcA5HBvHyGzgV1W/XvOfpkrxIse4uPWhYTTRwtGEM4v/jiXwNSJvfRvah0H8/uTLBKRXlef8g==} - engines: {node: '>= 10'} - cpu: [x64] - os: [darwin] - '@next/swc-darwin-x64@16.1.6': resolution: {integrity: sha512-BLFPYPDO+MNJsiDWbeVzqvYd4NyuRrEYVB5k2N3JfWncuHAy2IVwMAOlVQDFjj+krkWzhY2apvmekMkfQR0CUQ==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@next/swc-linux-arm64-gnu@15.5.3': - resolution: {integrity: sha512-+m7pfIs0/yvgVu26ieaKrifV8C8yiLe7jVp9SpcIzg7XmyyNE7toC1fy5IOQozmr6kWl/JONC51osih2RyoXRw==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [linux] - '@next/swc-linux-arm64-gnu@16.1.6': resolution: {integrity: sha512-OJYkCd5pj/QloBvoEcJ2XiMnlJkRv9idWA/j0ugSuA34gMT6f5b7vOiCQHVRpvStoZUknhl6/UxOXL4OwtdaBw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@next/swc-linux-arm64-musl@15.5.3': - resolution: {integrity: sha512-u3PEIzuguSenoZviZJahNLgCexGFhso5mxWCrrIMdvpZn6lkME5vc/ADZG8UUk5K1uWRy4hqSFECrON6UKQBbQ==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [linux] - '@next/swc-linux-arm64-musl@16.1.6': resolution: {integrity: sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@next/swc-linux-x64-gnu@15.5.3': - resolution: {integrity: sha512-lDtOOScYDZxI2BENN9m0pfVPJDSuUkAD1YXSvlJF0DKwZt0WlA7T7o3wrcEr4Q+iHYGzEaVuZcsIbCps4K27sA==} - engines: {node: '>= 10'} - cpu: [x64] - os: [linux] - '@next/swc-linux-x64-gnu@16.1.6': resolution: {integrity: sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@next/swc-linux-x64-musl@15.5.3': - resolution: {integrity: sha512-9vWVUnsx9PrY2NwdVRJ4dUURAQ8Su0sLRPqcCCxtX5zIQUBES12eRVHq6b70bbfaVaxIDGJN2afHui0eDm+cLg==} - engines: {node: '>= 10'} - cpu: [x64] - os: [linux] - '@next/swc-linux-x64-musl@16.1.6': resolution: {integrity: sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@next/swc-win32-arm64-msvc@15.5.3': - resolution: {integrity: sha512-1CU20FZzY9LFQigRi6jM45oJMU3KziA5/sSG+dXeVaTm661snQP6xu3ykGxxwU5sLG3sh14teO/IOEPVsQMRfA==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [win32] - '@next/swc-win32-arm64-msvc@16.1.6': resolution: {integrity: sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@next/swc-win32-x64-msvc@15.5.3': - resolution: {integrity: sha512-JMoLAq3n3y5tKXPQwCK5c+6tmwkuFDa2XAxz8Wm4+IVthdBZdZGh+lmiLUHg9f9IDwIQpUjp+ysd6OkYTyZRZw==} - engines: {node: '>= 10'} - cpu: [x64] - os: [win32] - '@next/swc-win32-x64-msvc@16.1.6': resolution: {integrity: sha512-NRfO39AIrzBnixKbjuo2YiYhB6o9d8v/ymU9m/Xk8cyVk+k7XylniXkHwjs4s70wedVffc6bQNbufk5v0xEm0A==} engines: {node: '>= 10'} @@ -3399,6 +3109,9 @@ packages: resolution: {integrity: sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==} engines: {node: '>= 20.19.0'} + '@nodable/entities@2.1.0': + resolution: {integrity: sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -3415,9 +3128,6 @@ packages: resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} engines: {node: '>=12.4.0'} - '@ocavue/svgmoji-cjs@0.1.1': - resolution: {integrity: sha512-tCP6ggbtgIL4hPM5goVFSjL51jH/BLl/yBLy98wAV9a2L/Sn9iS3abfprPeQw6/nan5lLaz4Vz8ZP37LKh+xfQ==} - '@opentelemetry/api-logs@0.204.0': resolution: {integrity: sha512-DqxY8yoAaiBPivoJD4UtgrMS8gEmzZ5lnaxzPojzLVHBGqPxgWm4zcuvcUHZiqQ6kRX2Klel2r9y8cA2HAtqpw==} engines: {node: '>=8.0.0'} @@ -3641,8 +3351,8 @@ packages: '@protobufjs/base64@1.1.2': resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==} - '@protobufjs/codegen@2.0.4': - resolution: {integrity: sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==} + '@protobufjs/codegen@2.0.5': + resolution: {integrity: sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==} '@protobufjs/eventemitter@1.1.0': resolution: {integrity: sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==} @@ -3653,8 +3363,8 @@ packages: '@protobufjs/float@1.0.2': resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==} - '@protobufjs/inquire@1.1.0': - resolution: {integrity: sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==} + '@protobufjs/inquire@1.1.1': + resolution: {integrity: sha512-mnzgDV26ueAvk7rsbt9L7bE0SuAoqyuys/sMMrmVcN5x9VsxpcG3rqAUSgDyLp0UZlmNfIbQ4fHfCtreVBk8Ew==} '@protobufjs/path@1.1.2': resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==} @@ -3662,8 +3372,8 @@ packages: '@protobufjs/pool@1.1.0': resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==} - '@protobufjs/utf8@1.1.0': - resolution: {integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==} + '@protobufjs/utf8@1.1.1': + resolution: {integrity: sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==} '@radix-ui/number@1.1.1': resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} @@ -4819,484 +4529,75 @@ packages: '@remirror/core-constants@3.0.0': resolution: {integrity: sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==} - '@remirror/core-helpers@4.0.0': - resolution: {integrity: sha512-w90bJ+SLim25DWLN0Y6KjBwDhSgyzWwPxazwHQj7s3Px9dF69sG4cq3nA8RP2TCq1CV4bZmtW4+hCV26pHvgeA==} - - '@remirror/core-types@3.0.0': - resolution: {integrity: sha512-69Os/+iC5hqTEwix59ceX+FZ4T49f8Zqo477s0hdVCUcBmt5gxM/qxYwOv7PWUGt99TrkQ0gxWwvXT+ZMVOOtQ==} - peerDependencies: - '@remirror/pm': ^3.0.0 + '@rollup/rollup-android-arm-eabi@4.40.0': + resolution: {integrity: sha512-+Fbls/diZ0RDerhE8kyC6hjADCXA1K4yVNlH0EYfd2XjyH0UGgzaQ8MlT0pCXAThfxv3QUAczHaL+qSv1E4/Cg==} + cpu: [arm] + os: [android] - '@remirror/core-utils@3.0.0': - resolution: {integrity: sha512-BCARvyyJmWj8eplrNFTDE+Y9/wDZ5bGtTHUrfYmzqqC6pNFvcnOPSugjKRUvBpHnUwElG9oJDpsQPsmbmKTYNw==} - peerDependencies: - '@remirror/pm': ^3.0.0 - '@types/node': '*' - peerDependenciesMeta: - '@types/node': - optional: true + '@rollup/rollup-android-arm64@4.40.0': + resolution: {integrity: sha512-PPA6aEEsTPRz+/4xxAmaoWDqh67N7wFbgFUJGMnanCFs0TV99M0M8QhhaSCks+n6EbQoFvLQgYOGXxlMGQe/6w==} + cpu: [arm64] + os: [android] - '@remirror/core@3.0.2': - resolution: {integrity: sha512-M38zWJ9VfDZIDtU76odiPMiDsITHjnZD1EGiYS4AbyH50kmj0w/0I8Jd35phmXdB8Vwl3+z6eql54qEkV477oA==} - peerDependencies: - '@remirror/pm': ^3.0.1 + '@rollup/rollup-darwin-arm64@4.40.0': + resolution: {integrity: sha512-GwYOcOakYHdfnjjKwqpTGgn5a6cUX7+Ra2HeNj/GdXvO2VJOOXCiYYlRFU4CubFM67EhbmzLOmACKEfvp3J1kQ==} + cpu: [arm64] + os: [darwin] - '@remirror/dom@3.0.2': - resolution: {integrity: sha512-qlIGzd2fE+m+F4YBJtCa+TQZp1NuXOh+HRIh6NubTxM5mVqRS62PgHFsPfWW6el+AsP3OcVh/cTKtdxzMYTOfQ==} - peerDependencies: - '@remirror/pm': ^3.0.1 + '@rollup/rollup-darwin-x64@4.40.0': + resolution: {integrity: sha512-CoLEGJ+2eheqD9KBSxmma6ld01czS52Iw0e2qMZNpPDlf7Z9mj8xmMemxEucinev4LgHalDPczMyxzbq+Q+EtA==} + cpu: [x64] + os: [darwin] - '@remirror/extension-annotation@3.0.2': - resolution: {integrity: sha512-Cyd9jKPesqdmWoOH8CBi67zKVZ0zHLcZjm/V9f6ZjArRTrKgT8JchxnRwuLU+BH3WCU/JChONmhO4rarBoq7SA==} - peerDependencies: - '@remirror/pm': ^3.0.1 + '@rollup/rollup-freebsd-arm64@4.40.0': + resolution: {integrity: sha512-r7yGiS4HN/kibvESzmrOB/PxKMhPTlz+FcGvoUIKYoTyGd5toHp48g1uZy1o1xQvybwwpqpe010JrcGG2s5nkg==} + cpu: [arm64] + os: [freebsd] - '@remirror/extension-bidi@3.0.2': - resolution: {integrity: sha512-8FQwfV2HAlPTYhL46CfKu3cU2OLEHOdXzE5vXRkkeVeqpMda3hW7154rBiv+j4+oOP3re0IC55z4+BTvj3jhMg==} - peerDependencies: - '@remirror/pm': ^3.0.1 + '@rollup/rollup-freebsd-x64@4.40.0': + resolution: {integrity: sha512-mVDxzlf0oLzV3oZOr0SMJ0lSDd3xC4CmnWJ8Val8isp9jRGl5Dq//LLDSPFrasS7pSm6m5xAcKaw3sHXhBjoRw==} + cpu: [x64] + os: [freebsd] - '@remirror/extension-blockquote@3.0.2': - resolution: {integrity: sha512-+lLFLJ24krmAgC/CaHrsMkfqNALpNo9qLGYYwA4xxDadEm+sAU0KDHee+dpV92xQ6xfgQO6OOhzjet/S3B+M+A==} - peerDependencies: - '@remirror/pm': ^3.0.1 + '@rollup/rollup-linux-arm-gnueabihf@4.40.0': + resolution: {integrity: sha512-y/qUMOpJxBMy8xCXD++jeu8t7kzjlOCkoxxajL58G62PJGBZVl/Gwpm7JK9+YvlB701rcQTzjUZ1JgUoPTnoQA==} + cpu: [arm] + os: [linux] - '@remirror/extension-bold@3.0.2': - resolution: {integrity: sha512-Ls50mPBtGJMbvE4FfcMSOxGglXaEntYIhDGKls5MVqdVnzAfyhCKPIkZTOuf3rfKPvtIMsXvo5dKvyDIpPV91w==} - peerDependencies: - '@remirror/pm': ^3.0.1 + '@rollup/rollup-linux-arm-musleabihf@4.40.0': + resolution: {integrity: sha512-GoCsPibtVdJFPv/BOIvBKO/XmwZLwaNWdyD8TKlXuqp0veo2sHE+A/vpMQ5iSArRUz/uaoj4h5S6Pn0+PdhRjg==} + cpu: [arm] + os: [linux] - '@remirror/extension-callout@3.0.2': - resolution: {integrity: sha512-t8UpInA3rgw1GD3t8mMhXNNr9fq7PokQa10CPoPU/PJnjXalP1Pt+z8DUKGLjTaD/iBMH85+wrb5K1RBU4pZig==} - peerDependencies: - '@remirror/pm': ^3.0.1 + '@rollup/rollup-linux-arm64-gnu@4.40.0': + resolution: {integrity: sha512-L5ZLphTjjAD9leJzSLI7rr8fNqJMlGDKlazW2tX4IUF9P7R5TMQPElpH82Q7eNIDQnQlAyiNVfRPfP2vM5Avvg==} + cpu: [arm64] + os: [linux] - '@remirror/extension-code-block@3.0.2': - resolution: {integrity: sha512-hVSqsVhHrWTXuk6eI/mEIuGJsThxEtc5M30SlXsbwChlzgEYJzg3MNeZM8sX81F2s/BwOhb057xLdVCWSdZSSw==} - peerDependencies: - '@remirror/pm': ^3.0.1 - prettier: ^3.2.0 - peerDependenciesMeta: - prettier: - optional: true + '@rollup/rollup-linux-arm64-musl@4.40.0': + resolution: {integrity: sha512-ATZvCRGCDtv1Y4gpDIXsS+wfFeFuLwVxyUBSLawjgXK2tRE6fnsQEkE4csQQYWlBlsFztRzCnBvWVfcae/1qxQ==} + cpu: [arm64] + os: [linux] - '@remirror/extension-code@3.0.2': - resolution: {integrity: sha512-05foCVffhtGTVoyP5wNGYI803ujLqvmEqn73L1PJk/qVxubMYHyakKcIRgPxAKrdHv68fyuyOJ+M1bjMuL8hfw==} - peerDependencies: - '@remirror/pm': ^3.0.1 + '@rollup/rollup-linux-loongarch64-gnu@4.40.0': + resolution: {integrity: sha512-wG9e2XtIhd++QugU5MD9i7OnpaVb08ji3P1y/hNbxrQ3sYEelKJOq1UJ5dXczeo6Hj2rfDEL5GdtkMSVLa/AOg==} + cpu: [loong64] + os: [linux] - '@remirror/extension-collaboration@3.0.2': - resolution: {integrity: sha512-MuTkbpKiaRLyZOYJyt0x73LC5hQE0iSaLOaFBT0RmDGY7kQ2HjSumCAw3e/MoNmrF8GbpHOFmeOVVssbf6SEsw==} - peerDependencies: - '@remirror/pm': ^3.0.1 + '@rollup/rollup-linux-powerpc64le-gnu@4.40.0': + resolution: {integrity: sha512-vgXfWmj0f3jAUvC7TZSU/m/cOE558ILWDzS7jBhiCAFpY2WEBn5jqgbqvmzlMjtp8KlLcBlXVD2mkTSEQE6Ixw==} + cpu: [ppc64] + os: [linux] - '@remirror/extension-columns@3.0.2': - resolution: {integrity: sha512-E/Reb7wGbZSyakYeMJ4puWfrq73d8AQCaVdBh3OrsquSLcxdNXmJyXcMRb+Hfrn+2lyaS8oMvFIk+y/HurpfMw==} - peerDependencies: - '@remirror/pm': ^3.0.1 + '@rollup/rollup-linux-riscv64-gnu@4.40.0': + resolution: {integrity: sha512-uJkYTugqtPZBS3Z136arevt/FsKTF/J9dEMTX/cwR7lsAW4bShzI2R0pJVw+hcBTWF4dxVckYh72Hk3/hWNKvA==} + cpu: [riscv64] + os: [linux] - '@remirror/extension-diff@3.0.2': - resolution: {integrity: sha512-0SkZ3Krn/4nUk0H0N6Jph8hZslfGRxNQnmA8Cvip5ExKr55VSRtpPClDjsVzoPzq8dNLuEo9DNKhO7JcBrMzGw==} - peerDependencies: - '@remirror/pm': ^3.0.1 - - '@remirror/extension-doc@3.0.2': - resolution: {integrity: sha512-/UU5wJtYlz4I3xft9WMhgyxifDjVL5HiusEScyfiezEuFaWEqujJC5RfoqggF+ATZ1Yd6gvvJFAL5rljwZ0FkQ==} - peerDependencies: - '@remirror/pm': ^3.0.1 - - '@remirror/extension-drop-cursor@3.0.2': - resolution: {integrity: sha512-IZi/ogfXAGt35by5vOTRvBX6aEPsddnrYjjSsz516vlj5hK/YPa5iv+a20ODya5QYBOHds7kZngC5FooXWoOoQ==} - peerDependencies: - '@remirror/pm': ^3.0.1 - - '@remirror/extension-embed@3.0.2': - resolution: {integrity: sha512-iAPwDJRv3QCHHXSyaMxbDBl1hw/VRi3N8baKeNlzANpIebnChrZ0kQdQ2Ljzg7Wgnjtb2kd6kr5o0EBjLajpbg==} - peerDependencies: - '@remirror/pm': ^3.0.1 - - '@remirror/extension-emoji@3.0.2': - resolution: {integrity: sha512-itTqNf0NF9ncI3f5q+hkLTSPpmaKGrWGJFrbexZtik9SfbPlkb+TXFS+svM74ZNu4VYqUJrp5BocsFpG9pCG5A==} - peerDependencies: - '@remirror/pm': ^3.0.1 - - '@remirror/extension-entity-reference@3.0.2': - resolution: {integrity: sha512-mQehQVmkaM3yFEObp3uuzJXs410llSovP5FBRaTCWSh8st4t9Xf5NvykJGRlpXSZc+FKSRVbiMn4Uk4/A0voBw==} - peerDependencies: - '@remirror/pm': ^3.0.1 - - '@remirror/extension-epic-mode@3.0.2': - resolution: {integrity: sha512-vlSlBKokYIke789qxJ0AuM81nZ7qKNlEY/SDKlAPgWeXfTGEJd7jL944fyFWwZWuQr+yDQzutX4k0M6K8i5Nvg==} - peerDependencies: - '@remirror/pm': ^3.0.1 - - '@remirror/extension-events@3.0.2': - resolution: {integrity: sha512-d+5tJMa7VtSbovNLRdqNyMrPdRolMKM7/l0ByTjouTbMB5QDd93gSIp+buD12y343lqyBYl69rDIK3HJQODSgg==} - peerDependencies: - '@remirror/pm': ^3.0.1 - - '@remirror/extension-find@1.0.2': - resolution: {integrity: sha512-m0rHkbPNpCI2OAatWmvq7xaUbiWZmYiYig46BrvhVxgoT+r3W15kOne7SA9NbI3a+s7TAL28XJhn524mHfIEsA==} - peerDependencies: - '@remirror/pm': ^3.0.1 - - '@remirror/extension-font-family@3.0.2': - resolution: {integrity: sha512-l0mRi9mdnkS+1bJOo42VjKthDPfP7p992ogcsFEcU3mc72LClNcj1OItD71Wazb+5Zs7WChn+gdaUZ1JLbU08Q==} - peerDependencies: - '@remirror/pm': ^3.0.1 - - '@remirror/extension-font-size@3.0.2': - resolution: {integrity: sha512-EWu02TeIi1Cep2bgpkND5HOqTmHlNsV3JhvhjOeQXfScZPGUHOig4/aMFi1nXcD/E63ZYoJlVawGC2+/bnsuQQ==} - peerDependencies: - '@remirror/pm': ^3.0.1 - - '@remirror/extension-gap-cursor@3.0.2': - resolution: {integrity: sha512-gTxLugLj+GWtKpm2vFQsT8nJxfx3lFppZsb6rKGRrVNy3JHi9A+lZyJ+EmQmdplypDIYm1G7nfT7moIwK88dpA==} - peerDependencies: - '@remirror/pm': ^3.0.1 - - '@remirror/extension-hard-break@3.0.2': - resolution: {integrity: sha512-aUb+48POGva2FAuFFpKzH8hbTh8cjb4DYKgC7FCCAJS0YDCRhWWvDjtOtsCTJAr8tgFzmtZTjGWz/4SlSzMsqQ==} - peerDependencies: - '@remirror/pm': ^3.0.1 - - '@remirror/extension-heading@3.0.2': - resolution: {integrity: sha512-+eB1u/QLdX/+LGk9/Jjrma778wYni38yth+aFkouJo5DtFdC0dOd1pyEFWBOsp6fzYbG4gh6mGfjYrQwaVBt2w==} - peerDependencies: - '@remirror/pm': ^3.0.1 - - '@remirror/extension-history@3.0.2': - resolution: {integrity: sha512-CzrbK/+bmKQdge6wSsOBTQhtktre/Q4JH5bnqUtMaFyz6SYyRFsZjIhaKoJhsCt9mKzXHTCnxZfIsfT7F8sQhQ==} - peerDependencies: - '@remirror/pm': ^3.0.1 - - '@remirror/extension-horizontal-rule@3.0.2': - resolution: {integrity: sha512-WuKN4xssGN2jFtSYf/8sGIWInBRGfX9Rc5OXzvbIm6lzjqErgqpH9X03QDkGOR0SQKcVSZq4et44kyyynjwZwg==} - peerDependencies: - '@remirror/pm': ^3.0.1 - - '@remirror/extension-image@3.0.2': - resolution: {integrity: sha512-X90WvBdVnhKaNyOHWCESFEDxZjMU2gf1VscMYJZaI9St+kldBr9eCFELGWYndgVt3jKuCJaLzxwbKvs72bpaRg==} - peerDependencies: - '@remirror/pm': ^3.0.1 - - '@remirror/extension-italic@3.0.2': - resolution: {integrity: sha512-VcGoFww3PEx+JdAKfLRfBGdqQC9QcM80QQzl9jqok8Qo1dLJTLVEYY4DBBtaSCfFOorCVNt2LZ+0w+cRWGC3DQ==} - peerDependencies: - '@remirror/pm': ^3.0.1 - - '@remirror/extension-link@3.0.2': - resolution: {integrity: sha512-C2gMv7P1cXMh55b+GtFI2ZwfSpe7TuEFsN+LbwltXIO0WduLKzvH2vYzh+dCnv6oFU5wpDhNQ+hYG3eohfCgyQ==} - peerDependencies: - '@remirror/pm': ^3.0.1 - - '@remirror/extension-list@3.0.2': - resolution: {integrity: sha512-xz1ewNdbMdwn2vZDtTYKmAKyDVXD4R4sMEho7iY9dmdlF47Wc/nIos7/9ihS+zrlomdmw5o+zXry355x0rzFXA==} - peerDependencies: - '@remirror/pm': ^3.0.1 - - '@remirror/extension-markdown@3.0.3': - resolution: {integrity: sha512-YtHA7Znb5pyjYQhgOaf1xDFhyckJaRSNj/yzzmZQF3uND1TXMuQ4fwbXcu1Wt7Ht3kJxp93oXv6aB9Ah4aiu3g==} - peerDependencies: - '@remirror/extension-react-tables': 3.0.3 - '@remirror/pm': ^3.0.1 - peerDependenciesMeta: - '@remirror/extension-react-tables': - optional: true - - '@remirror/extension-mention-atom@3.0.2': - resolution: {integrity: sha512-/C//SGkvK59FmmWnGIVB6PVkwTVRco4QeUj1xf3OWAufMSI3l2AcEULu1/K9XPLi8ZSPFq5e46W//gcZZAls7w==} - peerDependencies: - '@remirror/pm': ^3.0.1 - - '@remirror/extension-mention@3.0.2': - resolution: {integrity: sha512-BPlUHGoGbDqh+H1ZycJdP/WjvRn2yffR1pc5jUiLtajN8Z6E/slAB4wHsT/n8pnRk1EOwnCrapYD/i3AK1PaFg==} - peerDependencies: - '@remirror/pm': ^3.0.1 - - '@remirror/extension-node-formatting@3.0.2': - resolution: {integrity: sha512-t+mrqmJELSj/CVL5p854TuhaxFqU9VyGColNk28KGSyXyoiUxlWj0lwdxpq5KaLxo/o6VdHIfVbTqLLb4cypDg==} - peerDependencies: - '@remirror/pm': ^3.0.1 - - '@remirror/extension-paragraph@3.0.2': - resolution: {integrity: sha512-m3LzbJASQmMu5HM25VjmeLjDNhS/fkGtCIivnkdQGtIf9tnUmXaclcJ9AN51kh5m8Sqx/YGpvbW5b1HIik+PlA==} - peerDependencies: - '@remirror/pm': ^3.0.1 - - '@remirror/extension-placeholder@3.0.2': - resolution: {integrity: sha512-BsdF0PSLw2JLtGIwedqqvOt8WPaRJRHlfR5uT4eJNfZ7M6UIoa7SV4I3kxE8+eqPe6sofXeRh5+pvRuvdVveiA==} - peerDependencies: - '@remirror/pm': ^3.0.1 - - '@remirror/extension-positioner@3.0.2': - resolution: {integrity: sha512-5MXXhifGMeQ/OFw7lK9dbvbUr+pOElqdc/TvvYLmOLn+Or/Z91vtGA249WXI3oWsoKwrVitFFz1OaBnLFpuGdQ==} - peerDependencies: - '@remirror/pm': ^3.0.1 - - '@remirror/extension-react-component@3.0.3': - resolution: {integrity: sha512-nu9nM3tCQdhvVMyAhBDfA3Ciwcz8GN+q0ABmaMWkMCfhFlsE59ymbsrGQaV+xnyQvpKdXr89ZgHP+k6dKjG8FQ==} - peerDependencies: - '@remirror/pm': ^3.0.1 - '@types/react': 18.3.7 - '@types/react-dom': 18.3.0 - react: ^16.14.0 || ^17 || ^18 - react-dom: ^16.14.0 || ^17 || ^18 - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@remirror/extension-react-tables@3.0.3': - resolution: {integrity: sha512-tTvdZ2ij2f7HKrLcphUccQRXf9IKfOXguT4ftbQ/ZAGoRrL9ORT9g7rTYJfYdUYFPpTNjwghZwlD9TiroSmcBQ==} - peerDependencies: - '@remirror/pm': ^3.0.1 - react: ^16.14.0 || ^17 || ^18 - react-dom: ^16.14.0 || ^17 || ^18 - peerDependenciesMeta: - react: - optional: true - react-dom: - optional: true - - '@remirror/extension-shortcuts@3.0.2': - resolution: {integrity: sha512-1I2Z5s9cjoHg9PVSFyVBtbsc2u/mk17xNtibKLCSm5Mb+0muxX8roi6IPdBTiOnyvthEAmddNzNT7C2QlY/qow==} - peerDependencies: - '@remirror/pm': ^3.0.1 - - '@remirror/extension-strike@3.0.2': - resolution: {integrity: sha512-aSdlGxXnoPD7dMhCElY7jGamVoAz6VEQMn++CTGEG8tYXO/sovy47X0tZrvPQ8mcO/mq4JeSAS8KkwcZ28IpRg==} - peerDependencies: - '@remirror/pm': ^3.0.1 - - '@remirror/extension-sub@3.0.2': - resolution: {integrity: sha512-vZoMvdVpVFH34lnRQBD1qKJbJeUwe48hFRJOi0i7u+l3JMVTHGuaus022ccL3FhYAhV10c1iABpGkF2fgvc2lQ==} - peerDependencies: - '@remirror/pm': ^3.0.1 - - '@remirror/extension-sup@3.0.2': - resolution: {integrity: sha512-Rk3fjMpEhkTzA3pJWPuLQd6H2oU19vqeTn2PRR7v4ZcW+/5IwXyTjUNkmbhby4UjxKc7iPzsOup0mbxsmC1KJQ==} - peerDependencies: - '@remirror/pm': ^3.0.1 - - '@remirror/extension-tables@3.0.2': - resolution: {integrity: sha512-IVuvQRz2lDmpID/PhG01nZqJ7nxoBx4NcJRG1MsdiBhzdlReWndERIDZ6nXhaY9snsGIpNzX43OYbb/90NKjUQ==} - peerDependencies: - '@remirror/pm': ^3.0.1 - - '@remirror/extension-text-case@3.0.2': - resolution: {integrity: sha512-S+WXdFxgXN/r4Nmm2YcBsOPZDh2vwd+rQRP57DVe2fU55b7MWEzsNZy9di1Wf5JxKK+kJdUfYdz2aFIH3iQdXg==} - peerDependencies: - '@remirror/pm': ^3.0.1 - - '@remirror/extension-text-color@3.0.2': - resolution: {integrity: sha512-B90ZJnyzDxlwfYGpjJkOnWOfpiTUPbcjWLU+IaNNIMjlXnSH4Lg7Wsqj1+JxWko2wPNbsC5lgDEql0dKyPNxeg==} - peerDependencies: - '@remirror/pm': ^3.0.1 - - '@remirror/extension-text-highlight@3.0.2': - resolution: {integrity: sha512-a/l8rpxWoksYP9TDqkd4VyJ/kQFDfQtek9/Z8JugF9MZAxpPSfYvUgzA94g+OQxTq9xlGvHUUWyA/VP3zNYWHA==} - peerDependencies: - '@remirror/pm': ^3.0.1 - - '@remirror/extension-text@3.0.2': - resolution: {integrity: sha512-VtdYnT2oF2cBj1f0SF7lIkoGlrXzwCUqjC8bh8Ppi9mzkcnz+DrEjSpyN4BYsw4WUWStIUIR2jxFgENhZ3trmg==} - peerDependencies: - '@remirror/pm': ^3.0.1 - - '@remirror/extension-trailing-node@3.0.2': - resolution: {integrity: sha512-uVLvbKvQ8dCKEnUICdIC7FU6GI74hGH2oIKMMObADYjk9aHLKQJOxev7QGMJ/BKA94pqiXCBFpMe2NB9L4COBA==} - peerDependencies: - '@remirror/pm': ^3.0.1 - - '@remirror/extension-underline@3.0.2': - resolution: {integrity: sha512-FJwjnRYKk8VmfbU/zcE0PVPsaqA6ZYrDMsIIBbywhLU5c9MP/EGw6K1Ps4hPFhmyHgc6IZNE8hLR4Ej7ZbYagQ==} - peerDependencies: - '@remirror/pm': ^3.0.1 - - '@remirror/extension-whitespace@3.0.2': - resolution: {integrity: sha512-uVV8t7YKecIEUjou1GRhBgDr3Pwah598LkT9NIEOuYG3BjdtQdJgh1K1gAzBA1og72BWfzEPFFjvYbfaBsQHAw==} - peerDependencies: - '@remirror/pm': ^3.0.1 - - '@remirror/icons@3.0.0': - resolution: {integrity: sha512-NOgd0ENWWlUOb8xH7xz3qpcW+OCVt0oxF2Lde1hOg/x67LyUbybofe6SwXpdx3TN8bdgSU5CzlNPeugGsGMdwg==} - - '@remirror/messages@3.0.0': - resolution: {integrity: sha512-HK0AkfghoJOTLqYlkKozX7uI96KvtstDOpirYNfMXgfpL0ubWN5m6B22W0GQmet27G6s8I9vRbyCjH8oXKdNFA==} - - '@remirror/pm@3.0.1': - resolution: {integrity: sha512-i0RRSM4roWcSha4iPCB0GAon8IuWDu826PHCfXgfoG41dUud/f/TEETRYYW0A8Mnx2IrJzQdB8ktnKeU+mmHJw==} - - '@remirror/preset-core@3.0.2': - resolution: {integrity: sha512-VYRGiudl8bE/DVO2YBx/kE0rWKNWG9qQwZbK63x8TXZY19eS5P97G87MGxAAoxv90U+IZbrIfOy1K6aVW4KeGA==} - peerDependencies: - '@remirror/pm': ^3.0.1 - - '@remirror/preset-formatting@3.0.2': - resolution: {integrity: sha512-oyC7nof7RLK+1rINtlWmtQi73kdv3YbmkUCE+ck7iM6nQILsbysPF09gD3/BwAyQ0i4XzIDhhlMdjNGrovUPgA==} - peerDependencies: - '@remirror/pm': ^3.0.1 - - '@remirror/preset-react@3.0.3': - resolution: {integrity: sha512-VdwnUuAz1UIXWaExXyggfuuSYIn9V2lMMyhTPdpjnEBwjpAgnfpxFRmwfbdWTCTq3BPFFTZp48Lz+1fw2fuwWg==} - peerDependencies: - '@remirror/pm': ^3.0.1 - '@types/react': 18.3.7 - '@types/react-dom': 18.3.0 - react: ^16.14.0 || ^17 || ^18 - react-dom: ^16.14.0 || ^17 || ^18 - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@remirror/preset-wysiwyg@3.0.2': - resolution: {integrity: sha512-w2gzEfl/J4kj1h5FhYfhc6CAzWL2t8KKI/q8EAeBZ1+x+Bd1uZg4cVKkrDktbmwSWYCuVUX97uVFUJTU7195TA==} - peerDependencies: - '@remirror/pm': ^3.0.1 - - '@remirror/react-components@3.0.3': - resolution: {integrity: sha512-PymiL2E9hnE77J7OMg8vr0J7giFG1U1dWxB9J1dGbc7RPUlQpeBDp6xTrz1HiW5AQEs/D1EwKG6T5HOY7rDrww==} - peerDependencies: - '@remirror/pm': ^3.0.1 - '@types/react': 18.3.7 - '@types/react-dom': 18.3.0 - react: ^16.14.0 || ^17 || ^18 - react-dom: ^16.14.0 || ^17 || ^18 - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@remirror/react-core@3.0.3': - resolution: {integrity: sha512-N8bgKcbGO4G/ViKWNFRsYoo9wBr4i5Pd0j4KxUb2bPAs9XdoDM0/4IyP8Vf5d4kjmB0iKXaHtmL+mvZSq6v1TQ==} - peerDependencies: - '@remirror/pm': ^3.0.1 - '@types/react': 18.3.7 - '@types/react-dom': 18.3.0 - react: ^16.14.0 || ^17 || ^18 - react-dom: ^16.14.0 || ^17 || ^18 - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@remirror/react-hooks@3.0.3': - resolution: {integrity: sha512-Cf7qXF9H+QgA2tRngV5jfJhQ6SWEEC6G7/3JLGQOx2q6Wh/KAlAEol6hZfmJGM27ZR1H1IwKFQoUTHy7Dz0QtQ==} - peerDependencies: - '@remirror/pm': ^3.0.1 - '@types/react': 18.3.7 - '@types/react-dom': 18.3.0 - react: ^16.14.0 || ^17 || ^18 - react-dom: ^16.14.0 || ^17 || ^18 - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - react: - optional: true - react-dom: - optional: true - - '@remirror/react-renderer@3.0.2': - resolution: {integrity: sha512-jmu7nwXfHR+WhNpvY3EsLbFcYIYwnvmR3554JNYteiE+OOVwDAOlK/ijOuNgCGKvoLi8+Srr4nFaBXQm7CboGQ==} - peerDependencies: - '@types/react': 18.3.7 - react: ^16.14.0 || ^17 || ^18 - peerDependenciesMeta: - '@types/react': - optional: true - - '@remirror/react-utils@3.0.0': - resolution: {integrity: sha512-9v+Mhup4n4tajJEnqsTBJKvyC7kHwsuTRe5Z7NXKN1O5WOMJyT+sOfAis8cOn4oPco3adVBEvbm54JSIwZXQEA==} - peerDependencies: - '@types/react': 18.3.7 - react: ^16.14.0 || ^17 || ^18 - peerDependenciesMeta: - '@types/react': - optional: true - - '@remirror/theme@3.0.0': - resolution: {integrity: sha512-TJkcpOV3iR8A80NjYczzsGcDfkBXR24cElH9vnuF57LUqZwsgX9QHfZG1QaBJIVy9Zl/hFWMBcSzQqj0+P4NnA==} - - '@remirror/types@2.0.0': - resolution: {integrity: sha512-j7G+hpyJ3SsZts0RpANYrTkQSWyP1+uy3txZPWgDwXGv3R45wtqRfoDzGO45vFcE9aNno/ThGPvClORZjjbrpw==} - - '@rollup/rollup-android-arm-eabi@4.40.0': - resolution: {integrity: sha512-+Fbls/diZ0RDerhE8kyC6hjADCXA1K4yVNlH0EYfd2XjyH0UGgzaQ8MlT0pCXAThfxv3QUAczHaL+qSv1E4/Cg==} - cpu: [arm] - os: [android] - - '@rollup/rollup-android-arm64@4.40.0': - resolution: {integrity: sha512-PPA6aEEsTPRz+/4xxAmaoWDqh67N7wFbgFUJGMnanCFs0TV99M0M8QhhaSCks+n6EbQoFvLQgYOGXxlMGQe/6w==} - cpu: [arm64] - os: [android] - - '@rollup/rollup-darwin-arm64@4.40.0': - resolution: {integrity: sha512-GwYOcOakYHdfnjjKwqpTGgn5a6cUX7+Ra2HeNj/GdXvO2VJOOXCiYYlRFU4CubFM67EhbmzLOmACKEfvp3J1kQ==} - cpu: [arm64] - os: [darwin] - - '@rollup/rollup-darwin-x64@4.40.0': - resolution: {integrity: sha512-CoLEGJ+2eheqD9KBSxmma6ld01czS52Iw0e2qMZNpPDlf7Z9mj8xmMemxEucinev4LgHalDPczMyxzbq+Q+EtA==} - cpu: [x64] - os: [darwin] - - '@rollup/rollup-freebsd-arm64@4.40.0': - resolution: {integrity: sha512-r7yGiS4HN/kibvESzmrOB/PxKMhPTlz+FcGvoUIKYoTyGd5toHp48g1uZy1o1xQvybwwpqpe010JrcGG2s5nkg==} - cpu: [arm64] - os: [freebsd] - - '@rollup/rollup-freebsd-x64@4.40.0': - resolution: {integrity: sha512-mVDxzlf0oLzV3oZOr0SMJ0lSDd3xC4CmnWJ8Val8isp9jRGl5Dq//LLDSPFrasS7pSm6m5xAcKaw3sHXhBjoRw==} - cpu: [x64] - os: [freebsd] - - '@rollup/rollup-linux-arm-gnueabihf@4.40.0': - resolution: {integrity: sha512-y/qUMOpJxBMy8xCXD++jeu8t7kzjlOCkoxxajL58G62PJGBZVl/Gwpm7JK9+YvlB701rcQTzjUZ1JgUoPTnoQA==} - cpu: [arm] - os: [linux] - - '@rollup/rollup-linux-arm-musleabihf@4.40.0': - resolution: {integrity: sha512-GoCsPibtVdJFPv/BOIvBKO/XmwZLwaNWdyD8TKlXuqp0veo2sHE+A/vpMQ5iSArRUz/uaoj4h5S6Pn0+PdhRjg==} - cpu: [arm] - os: [linux] - - '@rollup/rollup-linux-arm64-gnu@4.40.0': - resolution: {integrity: sha512-L5ZLphTjjAD9leJzSLI7rr8fNqJMlGDKlazW2tX4IUF9P7R5TMQPElpH82Q7eNIDQnQlAyiNVfRPfP2vM5Avvg==} - cpu: [arm64] - os: [linux] - - '@rollup/rollup-linux-arm64-musl@4.40.0': - resolution: {integrity: sha512-ATZvCRGCDtv1Y4gpDIXsS+wfFeFuLwVxyUBSLawjgXK2tRE6fnsQEkE4csQQYWlBlsFztRzCnBvWVfcae/1qxQ==} - cpu: [arm64] - os: [linux] - - '@rollup/rollup-linux-loongarch64-gnu@4.40.0': - resolution: {integrity: sha512-wG9e2XtIhd++QugU5MD9i7OnpaVb08ji3P1y/hNbxrQ3sYEelKJOq1UJ5dXczeo6Hj2rfDEL5GdtkMSVLa/AOg==} - cpu: [loong64] - os: [linux] - - '@rollup/rollup-linux-powerpc64le-gnu@4.40.0': - resolution: {integrity: sha512-vgXfWmj0f3jAUvC7TZSU/m/cOE558ILWDzS7jBhiCAFpY2WEBn5jqgbqvmzlMjtp8KlLcBlXVD2mkTSEQE6Ixw==} - cpu: [ppc64] - os: [linux] - - '@rollup/rollup-linux-riscv64-gnu@4.40.0': - resolution: {integrity: sha512-uJkYTugqtPZBS3Z136arevt/FsKTF/J9dEMTX/cwR7lsAW4bShzI2R0pJVw+hcBTWF4dxVckYh72Hk3/hWNKvA==} - cpu: [riscv64] - os: [linux] - - '@rollup/rollup-linux-riscv64-musl@4.40.0': - resolution: {integrity: sha512-rKmSj6EXQRnhSkE22+WvrqOqRtk733x3p5sWpZilhmjnkHkpeCgWsFFo0dGnUGeA+OZjRl3+VYq+HyCOEuwcxQ==} - cpu: [riscv64] - os: [linux] + '@rollup/rollup-linux-riscv64-musl@4.40.0': + resolution: {integrity: sha512-rKmSj6EXQRnhSkE22+WvrqOqRtk733x3p5sWpZilhmjnkHkpeCgWsFFo0dGnUGeA+OZjRl3+VYq+HyCOEuwcxQ==} + cpu: [riscv64] + os: [linux] '@rollup/rollup-linux-s390x-gnu@4.40.0': resolution: {integrity: sha512-SpnYlAfKPOoVsQqmTFJ0usx0z84bzGOS9anAC0AZ3rdSo3snecihbhFTlJZ8XMwzqAcodjFU4+/SM311dqE5Sw==} @@ -5353,9 +4654,6 @@ packages: '@selderee/plugin-htmlparser2@0.11.0': resolution: {integrity: sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==} - '@seznam/compose-react-refs@1.0.6': - resolution: {integrity: sha512-izzOXQfeQLonzrIQb8u6LQ8dk+ymz3WXTIXjvOlTXHq6sbzROg3NWU+9TTAOpEoK9Bth24/6F/XrfHJ5yR5n6Q==} - '@shelf/jest-mongodb@5.2.2': resolution: {integrity: sha512-kQwMjswHVjxElsXhCwBxG1NncZK0GtHd8o+U/OQLx+noer4mc/mO5KD46NmmF3oRIjgfGtvf6WB2OyO1FTKAqA==} engines: {node: '>=22'} @@ -5594,21 +4892,6 @@ packages: resolution: {integrity: sha512-PTigkxMdMUP6B5ISS7jMqJAKhgrhZwjprDqR1eATtFfh0OpKVNp110xiH+goeVdrJ29/4LeZJR4FaHHWstsu0A==} engines: {node: '>=12.16'} - '@svgmoji/blob@3.2.0': - resolution: {integrity: sha512-N96WOrH9GxPSPZ/FuvZl6T9Rh54stAEuUcBppIRFh9/WwkU7Hczrjabw4uunwxFLX5TgR+rHlKJl3/jaTnXJrQ==} - - '@svgmoji/core@3.2.0': - resolution: {integrity: sha512-QsD78Op3S/5kUVsa5ierr4Wu/xwAdYuMI3Zmc/Y2ekYBEMGEUY8QxilXQRSAQ4ku4PnNV4xlB9e7xhD5hy113A==} - - '@svgmoji/noto@3.2.0': - resolution: {integrity: sha512-JgtNciB06hMDI1Pb1N2IgLh44XRMZUUNwBANzjY5jXTPqOCu1A1VA35ENvUsRhEUZOm8I+hbdAEHkwMVqxLeIQ==} - - '@svgmoji/openmoji@3.2.0': - resolution: {integrity: sha512-USHbG+O80HfmdoNAHbOnlO+2gppXJfHFWKSRFj53Th4aimWEx4/9MB3cFbC3KZ1NOqXaLBq9jDaw4vFuGDVTUQ==} - - '@svgmoji/twemoji@3.2.0': - resolution: {integrity: sha512-6xqZgh9viFDKf5wvrxw56ImCR3Ni84IqwK45lxojOe1Gc1Mni1GpPfr4gb7WHDKjumfx+K7BHSvX0KXt3Nr3CQ==} - '@swagger-api/apidom-ast@1.10.1': resolution: {integrity: sha512-mevhQXYM5RwpH9UX8VDZw8ePFZSryuXZ5uU5crQPXLTKBkNok6kxlrVI0nRydbFZ1cIUc0nA//PUQPtCoBp6kw==} @@ -6125,9 +5408,6 @@ packages: '@types/debug@4.1.12': resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} - '@types/direction@1.0.0': - resolution: {integrity: sha512-et1wmqXm/5smJ8lTJfBnwD12/2Y7eVJLKbuaRT0h2xaKAoo1h8Dz2Io22GObDLFwxY1ddXRTLH3Gq5v44Fl/2w==} - '@types/estree-jsx@0.0.1': resolution: {integrity: sha512-gcLAYiMfQklDCPjQegGn0TBAn9it05ISEsEhlKQUddIk7o2XDokOcTN7HBO8tznM0D9dGezvHEfRZBfZf6me0A==} @@ -6197,9 +5477,6 @@ packages: '@types/markdown-it@14.1.2': resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==} - '@types/marked@4.3.2': - resolution: {integrity: sha512-a79Yc3TOk6dGdituy8hmTTJXjOkZ7zsFYV10L337ttq/rec8lRMDBpV7fL3uLx6TgbFCa5DU/h8FmIBQPSbU0w==} - '@types/mdast@3.0.15': resolution: {integrity: sha512-LnwD+mUEfxWMa1QpDraczIn6k0Ee3SMicuYSSzS6ZYl2gKS09EClnJYGd8Du6rfc5r/GZEk5o1mRb8TaTj03sQ==} @@ -6215,9 +5492,6 @@ packages: '@types/mime@1.3.5': resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==} - '@types/min-document@2.19.2': - resolution: {integrity: sha512-nsNPWMSTapqOMR6/fSka9W77q5JdxaWkx3bSmzvQv2dvEI/VIWYrVmcoFY3j1YTPQK7buYdY6LqsUrQlsAGmaw==} - '@types/mongodb@4.0.7': resolution: {integrity: sha512-lPUYPpzA43baXqnd36cZ9xxorprybxXDzteVKCPAdp14ppHtFJHnXYvNpmBvtMUTb5fKXVv6sVbzo1LHkWhJlw==} deprecated: mongodb provides its own types. @types/mongodb is no longer needed. @@ -6252,12 +5526,6 @@ packages: '@types/nodemailer@6.4.17': resolution: {integrity: sha512-I9CCaIp6DTldEg7vyUTZi8+9Vo0hi1/T8gv3C89yk1rSAAzoKQ8H8ki/jBYJSFoH/BisgLP8tkZMlQ91CIquww==} - '@types/object.omit@3.0.3': - resolution: {integrity: sha512-xrq4bQTBGYY2cw+gV4PzoG2Lv3L0pjZ1uXStRRDQoATOYW1lCsFQHhQ+OkPhIcQoqLjAq7gYif7D14Qaa6Zbew==} - - '@types/object.pick@1.3.4': - resolution: {integrity: sha512-5PjwB0uP2XDp3nt5u5NJAG2DORHIRClPzWT/TTZhJ2Ekwe8M5bA9tvPdi9NO/n2uvu2/ictat8kgqvLfcIE1SA==} - '@types/parse-json@4.0.2': resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==} @@ -6276,34 +5544,18 @@ packages: '@types/qs@6.9.18': resolution: {integrity: sha512-kK7dgTYDyGqS+e2Q4aK9X3D7q234CIZ1Bv0q/7Z5IwRDoADNU81xXJK/YVyLbLTZCoIwUoDoffFeF+p/eIklAA==} - '@types/querystringify@2.0.2': - resolution: {integrity: sha512-7d6OQK6pJ//zE32XLK3vI6GHYhBDcYooaRco9cKFGNu59GVatL5+u7rkiAViq44DxDTd/7QQNBWSDHfJGBz/Pw==} - '@types/ramda@0.30.2': resolution: {integrity: sha512-PyzHvjCalm2BRYjAU6nIB3TprYwMNOUY/7P/N8bSzp9W/yM2YrtGtAnnVtaCNSeOZ8DzKyFDvaqQs7LnWwwmBA==} '@types/range-parser@1.2.7': resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} - '@types/react-color@3.0.13': - resolution: {integrity: sha512-2c/9FZ4ixC5T3JzN0LP5Cke2Mf0MKOP2Eh0NPDPWmuVH3NjPyhEjqNMQpN1Phr5m74egAy+p2lYNAFrX1z9Yrg==} - peerDependencies: - '@types/react': 18.3.7 - '@types/react-dom@18.3.0': resolution: {integrity: sha512-EhwApuTmMBmXuFOikhQLIBUn6uFg81SwLMOAUgodJF14SOBOCMdU04gDoYi0WOJJHD144TL32z4yDqCW3dnkQg==} '@types/react@18.3.7': resolution: {integrity: sha512-KUnDCJF5+AiZd8owLIeVHqmW9yM4sqmDVf2JRJiBMFkGvkoZ4/WyV2lL4zVsoinmRS/W3FeEdZLEWFRofnT2FQ==} - '@types/reactcss@1.2.13': - resolution: {integrity: sha512-gi3S+aUi6kpkF5vdhUsnkwbiSEIU/BEJyD7kBy2SudWBUuKmJk8AQKE0OVcQQeEy40Azh0lV6uynxlikYIJuwg==} - peerDependencies: - '@types/react': 18.3.7 - - '@types/refractor@3.4.1': - resolution: {integrity: sha512-wYuorIiCTSuvRT9srwt+taF6mH/ww+SyN2psM0sjef2qW+sS8GmshgDGTEDgWB1sTVGgYVE6EK7dBA2MxQxibg==} - '@types/resolve@1.20.6': resolution: {integrity: sha512-A4STmOXPhMUtHH+S6ymgE2GiBSMqf4oTvcQZMcHzokuTLVYzXTB8ttjcgxOVaAp2lGwEdzZ0J+cRbbeevQj1UQ==} @@ -6316,21 +5568,12 @@ packages: '@types/stack-utils@2.0.3': resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} - '@types/string.prototype.matchall@4.0.4': - resolution: {integrity: sha512-E0KMS5FrWafbfKTGsoTZgrPHxBVknPeBxUTNwJima3t5KLdOlY285sisQC0mkVPTNNBc4nxza5ldly/ct+ISrQ==} - - '@types/throttle-debounce@2.1.0': - resolution: {integrity: sha512-5eQEtSCoESnh2FsiLTxE121IiE60hnMqcb435fShf4bpLRjEu1Eoekht23y6zXS9Ts3l+Szu3TARnTsA0GkOkQ==} - '@types/tough-cookie@4.0.5': resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} '@types/trusted-types@2.0.7': resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} - '@types/turndown@5.0.5': - resolution: {integrity: sha512-TL2IgGgc7B5j78rIccBtlYAnkuv8nUQqhQc+DSYV5j9Be9XOcm/SKOVRuA47xAVI3680Tk9B1d8flK2GWT2+4w==} - '@types/unist@2.0.11': resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} @@ -6573,13 +5816,10 @@ packages: resolution: {integrity: sha512-CJDxIgE5I0FH+ttq/Fxy6nRpxP70+e2O048EPe85J2use3XKdatVM7dDVvFNjQudd9B49NPoZ+8PG49zj4Er8Q==} engines: {node: '>= 16'} - '@xmldom/xmldom@0.8.12': - resolution: {integrity: sha512-9k/gHF6n/pAi/9tqr3m3aqkuiNosYTurLLUtc7xQ9sxB/wm7WPygCv8GYa6mS0fLJEHhqMC1ATYhz++U/lRHqg==} + '@xmldom/xmldom@0.8.13': + resolution: {integrity: sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==} engines: {node: '>=10.0.0'} - a11y-status@2.0.2: - resolution: {integrity: sha512-aFT18wXwGG6QHe/HsFJeQqknZ+TVi7A/3xfYMIQI5EEHIJ9ak+fa7T9uuDSpFPzNCF/4oAZyG9d/nKJMOfKgPQ==} - abab@2.0.6: resolution: {integrity: sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==} deprecated: Use your platform's native atob() and btoa() methods instead @@ -6838,11 +6078,8 @@ packages: resolution: {integrity: sha512-Xm7bpRXnDSX2YE2YFfBk2FnF0ep6tmG7xPh8iHee8MIcrgq762Nkce856dYtJYLkuIoYZvGfTs/PbZhideTcEg==} engines: {node: '>=4'} - axios@1.15.0: - resolution: {integrity: sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==} - - axios@1.9.0: - resolution: {integrity: sha512-re4CqKTJaURpzbLHtIi6XpDv20/CnpXOtjRY5/CU32L8gU8ek9UIivcfvSWvmKEngmVbrUtPpdDwWDWL7DNHvg==} + axios@1.16.0: + resolution: {integrity: sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==} axobject-query@4.1.0: resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} @@ -6857,12 +6094,6 @@ packages: peerDependencies: '@babel/core': ^7.8.0 - babel-merge@3.0.0: - resolution: {integrity: sha512-eBOBtHnzt9xvnjpYNI5HmaPp/b2vMveE5XggzqHnQeHJ8mFIBrBv6WZEVIj5jJ2uwTItkqKo9gWzEEcBxEq0yw==} - deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. - peerDependencies: - '@babel/core': ^7.0.0 - babel-plugin-istanbul@6.1.1: resolution: {integrity: sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==} engines: {node: '>=8'} @@ -7049,8 +6280,8 @@ packages: resolution: {integrity: sha512-Ry9wCtIZ5kGqkJoi6aD8KjxFZEx78guTQDnpXWiNthsxzrxAK/i8E6pCHAIZTbaEFWcOCvbecMukfK7XUvyLpQ==} engines: {node: '>=6.9.0'} - bson@6.10.3: - resolution: {integrity: sha512-MTxGsqgYTwfshYWTRdmZRC+M7FnG1b4y7RO7p2k3X24Wq0yv1m77Wsj0BzlPzd/IowgESfsruQCUToa7vbOpPQ==} + bson@6.10.4: + resolution: {integrity: sha512-WIsKqkSC0ABoBJuT1LEX+2HEvNmNKKgnTAyd0fL8qzK4SH2i9NXg+t08YtdZp/V9IZ33cxe3iV4yM0qg8lMQng==} engines: {node: '>=16.20.1'} buffer-crc32@0.2.13: @@ -7126,10 +6357,6 @@ packages: resolution: {integrity: sha512-G0Unz6R11Pdg/9N0qr5muv5ZOF2+oe3WH7i6eiClSd5kozdY5a2nKFfGOTajhoM1vioaAfbnj2RnRQFUDyb9Mw==} engines: {node: '>=0.3.0'} - case-anything@2.1.13: - resolution: {integrity: sha512-zlOQ80VrQ2Ue+ymH5OuM/DlDq64mEm+B9UTdHULv5osUMD6HalNTblf2b1u/m6QecjsnOkBpqVZ+XPwIVsy7Ng==} - engines: {node: '>=12.13'} - ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} @@ -7152,24 +6379,15 @@ packages: character-entities-html4@2.1.0: resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} - character-entities-legacy@1.1.4: - resolution: {integrity: sha512-3Xnr+7ZFS1uxeiUDvV02wQ+QDbc55o97tIV5zHScSPJpcLm/r0DFPcoY3tYRp+VZukxuMeKgXYmsXQHO05zQeA==} - character-entities-legacy@3.0.0: resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} - character-entities@1.2.4: - resolution: {integrity: sha512-iBMyeEHxfVnIakwOuDXpVkc54HijNgCyQB2w0VfGQThle6NXn50zU6V/u+LDhxHcDUPojn6Kpga3PTAD8W1bQw==} - character-entities@2.0.2: resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==} character-parser@2.2.0: resolution: {integrity: sha512-+UqJQjFEFaTAs3bNsF2j2kEN1baG/zghZbdqoYEDxGZtJo9LBzl1A+m0D4n3qKx8N2FNv8/Xp6yV9mQmBuptaw==} - character-reference-invalid@1.1.4: - resolution: {integrity: sha512-mKKUkUbhPpQlCOfIuZkvSEgktjPFIsZKRRbC6KWVEMvlzblj3i3asQv5ODsrwt0N3pHAEvjP8KTQPHkp0+6jOg==} - character-reference-invalid@2.0.1: resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==} @@ -7274,9 +6492,6 @@ packages: color-string@1.9.1: resolution: {integrity: sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==} - color2k@2.0.3: - resolution: {integrity: sha512-zW190nQTIoXcGCaU08DvVNFTmQhUpnJfVuAKfWqUQkflXKpaDdpaYoM0iluLS9lgJNHyBF58KKA2FBEwkD7wog==} - color@4.2.3: resolution: {integrity: sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==} engines: {node: '>=12.5.0'} @@ -7291,9 +6506,6 @@ packages: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} - comma-separated-tokens@1.0.8: - resolution: {integrity: sha512-GHuDRO12Sypu2cV70d1dkA2EUmXHgntrzbpvOB+Qy+49ypNfGgFQIC2fhhXbnyrJRynDCAARsT7Ou0M6hirpfw==} - comma-separated-tokens@2.0.3: resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} @@ -7323,9 +6535,6 @@ packages: resolution: {integrity: sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==} engines: {node: '>= 10'} - compute-scroll-into-view@1.0.20: - resolution: {integrity: sha512-UCB0ioiyj8CRjtrvaceBLqqhZCVP+1B8+NWQhmdsm0VXOJtobBCf1dBQmebCCo34qZmUwZfIH2MZLqNHazrfjg==} - compute-scroll-into-view@3.1.1: resolution: {integrity: sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==} @@ -7343,9 +6552,6 @@ packages: resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} engines: {node: '>= 0.6'} - convert-source-map@1.9.0: - resolution: {integrity: sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==} - convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} @@ -7386,15 +6592,6 @@ packages: resolution: {integrity: sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==} engines: {node: '>= 10'} - create-context-state@2.0.3: - resolution: {integrity: sha512-txC1IX5nQcgT4OiPsZviciHXs5v7zTiqcymNQvUsRNSnvxi7cDSpQFTtdq8z1JE7JmmtVnwWzc6a8/PcpPwpXg==} - peerDependencies: - '@types/react': 18.3.7 - react: ^16.14.0 || ^17 || ^18 - peerDependenciesMeta: - '@types/react': - optional: true - create-jest@29.7.0: resolution: {integrity: sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -7414,9 +6611,6 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} - css-in-js-utils@3.1.0: - resolution: {integrity: sha512-fJAcud6B3rRu+KHYk+Bwf+WFL2MDCJJ1XG9x137tJQ0xYxor7XziQtuGFbWNdqrvF4Tk26O3H73nfVqXt/fW1A==} - css.escape@1.5.1: resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} @@ -7495,9 +6689,6 @@ packages: damerau-levenshtein@1.0.8: resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} - dash-get@1.0.2: - resolution: {integrity: sha512-4FbVrHDwfOASx7uQVxeiCTo7ggSdYZbqs8lH+WU6ViypPlDbe9y6IP5VVUDQBv9DcnyaiPT5XT0UWHgJ64zLeQ==} - data-uri-to-buffer@4.0.1: resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} engines: {node: '>= 12'} @@ -7596,10 +6787,6 @@ packages: resolution: {integrity: sha512-if3ZYdkD2dClhnXR5reKtG98cwyaRT1NeugQoAPTTfsOpV9kqyeiBF9Qa5RHjemb3KzD5ulqygv6ED3t5j9eJw==} engines: {node: '>=12.4.0'} - deepmerge@2.2.1: - resolution: {integrity: sha512-R9hc1Xa/NOBi9WRVUWg19rl1UB7Tt4kuPd+thNJgFZoxXsTz7ncaPaeIm+40oSGuP33DfMb4sZt1QIGiJzC4EA==} - engines: {node: '>=0.10.0'} - deepmerge@4.3.1: resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} engines: {node: '>=0.10.0'} @@ -7685,10 +6872,6 @@ packages: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} engines: {node: '>=8'} - direction@1.0.4: - resolution: {integrity: sha512-GYqKi1aH7PJXxdhTeZBFrg8vUBeKXi+cNprXsC1kpJcbcVnV9wBsrOu1cQEdG0WeQwlfHiy3XvnKfIrJ2R0NzQ==} - hasBin: true - dlv@1.1.3: resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} @@ -7715,9 +6898,6 @@ packages: dom-serializer@2.0.0: resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} - dom-walk@0.1.2: - resolution: {integrity: sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w==} - domelementtype@2.3.0: resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} @@ -7776,23 +6956,6 @@ packages: emoji-regex@9.2.2: resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} - emojibase-data@6.2.0: - resolution: {integrity: sha512-SWKaXD2QeQs06IE7qfJftsI5924Dqzp+V9xaa5RzZIEWhmlrG6Jt2iKwfgOPHu+5S8MEtOI7GdpKsXj46chXOw==} - peerDependencies: - emojibase: '*' - - emojibase-regex@5.1.3: - resolution: {integrity: sha512-gT8T9LxLA8VJdI+8KQtyykB9qKzd7WuUL3M2yw6y9tplFeufOUANg3UKVaKUvkMcRNvZsSElWhxcJrx8WPE12g==} - - emojibase-regex@6.0.1: - resolution: {integrity: sha512-Mj1UT6IIk4j91yMFE0QetpUYcmsr5ZDkkOIMSGafhIgC086mBMaCh2Keaykx8YEllmV7hmx5zdANDzCYBYAVDw==} - - emojibase@5.2.0: - resolution: {integrity: sha512-5T02oTJaWpScAtYbukKVc8vQ1367MyfVtFHUMoOVZ9/r1kFcbYqjSktD56TICBAeyW9uc1t+7qQuXEtntM6p5A==} - - emojibase@6.1.0: - resolution: {integrity: sha512-1GkKJPXP6tVkYJHOBSJHoGOr/6uaDxZ9xJ6H7m6PfdGXTmQgbALHLWaVRY4Gi/qf5x/gT/NUXLPuSHYLqtLtrQ==} - encodeurl@1.0.2: resolution: {integrity: sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==} engines: {node: '>= 0.8'} @@ -8331,9 +7494,6 @@ packages: extendable-error@0.1.7: resolution: {integrity: sha512-UOiS2in6/Q0FK0R0q6UY9vYpQ21mr/Qn1KOnte7vsACuNJf514WvCCUHSRCPcgjPT2bAhNIJdlE6bVap1GKmeg==} - extract-domain@2.2.1: - resolution: {integrity: sha512-lOq1adCJha0tFFBci4quxC4XLa6+Rs2WgAwTo9qbO9OsElvJmGgCvOzmHo/yg5CiqeP4+sHjkXYGkrCcIEprMg==} - fast-content-type-parse@3.0.0: resolution: {integrity: sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg==} @@ -8368,18 +7528,18 @@ packages: resolution: {integrity: sha512-dwsoQlS7h9hMeYUq1W++23NDcBLV4KqONnITDV9DjfS3q1SgDGVrBdvvTLUotWtPSD7asWDV9/CmsZPy8Hf70A==} engines: {node: '>=6'} - fast-uri@3.1.0: - resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + fast-uri@3.1.2: + resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==} - fast-xml-builder@1.1.4: - resolution: {integrity: sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==} + fast-xml-builder@1.2.0: + resolution: {integrity: sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==} - fast-xml-parser@4.4.1: - resolution: {integrity: sha512-xkjOecfnKGkSsOwtZ5Pz7Us/T6mrbPQrq0nh+aCO5V9nk5NLWmasAHumTKjiPJPWANe+kAZ84Jc8ooJkzZ88Sw==} + fast-xml-parser@4.5.4: + resolution: {integrity: sha512-jE8ugADnYOBsu1uaoayVl1tVKAMNOXyjwvv2U6udEA2ORBhDooJDWoGxTkhd4Qn4yh59JVVt/pKXtjPwx9OguQ==} hasBin: true - fast-xml-parser@5.5.9: - resolution: {integrity: sha512-jldvxr1MC6rtiZKgrFnDSvT8xuH+eJqxqOBThUVjYrxssYTo1avZLGql5l0a0BAERR01CadYzZ83kVEkbyDg+g==} + fast-xml-parser@5.8.0: + resolution: {integrity: sha512-6bIM7fsJxeo3uXv7OncQYsBAMPJ7V16Slahl/6M98C/i2q+vB1+4a0MtrvYwDFEUrwDSbAmeLDRXsOBwrL7yAg==} hasBin: true fastq@1.19.1: @@ -8439,9 +7599,6 @@ packages: resolution: {integrity: sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==} engines: {node: '>=8'} - find-root@1.1.0: - resolution: {integrity: sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==} - find-up@3.0.0: resolution: {integrity: sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==} engines: {node: '>=6'} @@ -8468,8 +7625,8 @@ packages: flatted@3.3.3: resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} - follow-redirects@1.15.11: - resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==} + follow-redirects@1.15.9: + resolution: {integrity: sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==} engines: {node: '>=4.0'} peerDependencies: debug: '*' @@ -8477,8 +7634,8 @@ packages: debug: optional: true - follow-redirects@1.15.9: - resolution: {integrity: sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==} + follow-redirects@1.16.0: + resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} engines: {node: '>=4.0'} peerDependencies: debug: '*' @@ -8497,10 +7654,6 @@ packages: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} - form-data@4.0.2: - resolution: {integrity: sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==} - engines: {node: '>= 6'} - form-data@4.0.5: resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} engines: {node: '>= 6'} @@ -8709,14 +7862,6 @@ packages: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} - get-dom-document@0.1.3: - resolution: {integrity: sha512-bZ0O00gSQgMo+wz7gU6kbbWCPh4dfDsL9ZOmVhA8TOXszl5GV56TpTuW1/Qq/QctgpjK56yyvB1vBO+wzz8Szw==} - peerDependencies: - jsdom: '*' - peerDependenciesMeta: - jsdom: - optional: true - get-east-asian-width@1.3.0: resolution: {integrity: sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ==} engines: {node: '>=18'} @@ -8880,9 +8025,6 @@ packages: hast-util-from-parse5@8.0.3: resolution: {integrity: sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==} - hast-util-parse-selector@2.2.5: - resolution: {integrity: sha512-7j6mrk/qqkSehsM92wQjdIgWM2/BW61u/53G6xmC8i1OmEdKLHbk419QKQUjz6LglWsfqoiHmyMRkP1BGjecNQ==} - hast-util-parse-selector@3.1.1: resolution: {integrity: sha512-jdlwBjEexy1oGz0aJ2f4GKMaVKkA9jwjr4MjAAI22E5fM/TXVZHuS5OpONtdeIkRKqAaryQ2E9xNQxijoThSZA==} @@ -8922,9 +8064,6 @@ packages: hast-util-whitespace@3.0.0: resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} - hastscript@6.0.0: - resolution: {integrity: sha512-nDM6bvd7lIqDUiYEiu5Sl/+6ReP0BMk/2f4U/Rooccxkj0P5nm+acM5PrGJ/t5I8qPGiqZSE6hVAwZEdZIvP4w==} - hastscript@7.2.0: resolution: {integrity: sha512-TtYPq24IldU8iKoJQqvZOuhi5CyCQRAbvDOX0x1eW6rsHSxa/1i2CCiptNTotGHJ3VoHRGmqiv6/D3q113ikkw==} @@ -9022,9 +8161,6 @@ packages: engines: {node: '>=18'} hasBin: true - hyphenate-style-name@1.1.0: - resolution: {integrity: sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==} - iconv-lite@0.4.24: resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} engines: {node: '>=0.10.0'} @@ -9037,9 +8173,6 @@ packages: resolution: {integrity: sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==} engines: {node: '>=0.10.0'} - idb-keyval@5.1.5: - resolution: {integrity: sha512-J1utxYWQokYjy01LvDQ7WmiAtZCGUSkVi9EIBfUSyLOr/BesnMIxNGASTh9A1LzeISSjSqEPsfFdTss7EE7ofQ==} - identity-obj-proxy@3.0.0: resolution: {integrity: sha512-00n6YnVHKrinT9t0d9+5yZC6UBNJANpYEQvL2LlX6Ab9lnmxzIRcEmTPuyGScvl1+jKuCICX1Z0Ab1pPKKdikA==} engines: {node: '>=4'} @@ -9120,15 +8253,9 @@ packages: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} - is-alphabetical@1.0.4: - resolution: {integrity: sha512-DwzsA04LQ10FHTZuL0/grVDk4rFoVH1pjAToYwBrHSxcrBIGQuXrQMtD5U1b0U2XVgKZCTLLP8u2Qxqhy3l2Vg==} - is-alphabetical@2.0.1: resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} - is-alphanumerical@1.0.4: - resolution: {integrity: sha512-UzoZUr+XfVz3t3v4KyGEniVL9BDRoQtY7tOyrRybkVNjDFWyo1yhXNGrrBTQxp3ib9BLAWs7k2YKBQsFRkZG9A==} - is-alphanumerical@2.0.1: resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==} @@ -9181,9 +8308,6 @@ packages: resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==} engines: {node: '>= 0.4'} - is-decimal@1.0.4: - resolution: {integrity: sha512-RGdriMmQQvZ2aqaQq3awNA6dCGtKpiDFcOzrTWrDAT2MiWrKQVPmxLGHl7Y2nNu6led0kEyoX0enY0qXYsv9zw==} - is-decimal@2.0.1: resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==} @@ -9204,10 +8328,6 @@ packages: resolution: {integrity: sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==} engines: {node: '>=0.10.0'} - is-extendable@1.0.1: - resolution: {integrity: sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==} - engines: {node: '>=0.10.0'} - is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -9216,10 +8336,6 @@ packages: resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==} engines: {node: '>= 0.4'} - is-finite@1.0.2: - resolution: {integrity: sha512-e+gU0KGrlbqjEcV80SAqg4g7PQYOm3/IrdwAJ+kPwHqGhLKhtuTJGGxGtrsc8RXlHt2A8Vlnv+79Vq2B1GQasg==} - engines: {node: '>=0.10.0'} - is-fullwidth-code-point@3.0.0: resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} engines: {node: '>=8'} @@ -9244,15 +8360,9 @@ packages: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} - is-hexadecimal@1.0.4: - resolution: {integrity: sha512-gyPJuv83bHMpocVYoqof5VDiZveEoGoFL8m3BXNb2VW8Xs+rz9kqO8LOQ5DH6EsuvilT1ApazU0pyl+ytbPtlw==} - is-hexadecimal@2.0.1: resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==} - is-integer@1.0.7: - resolution: {integrity: sha512-RPQc/s9yBHSvpi+hs9dYiJ2cuFeU6x3TyyIp8O2H6SKEltIvJOzRj9ToyvcStDvPR/pS4rxgr1oBFajQjZ2Szg==} - is-interactive@2.0.0: resolution: {integrity: sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==} engines: {node: '>=12'} @@ -9281,10 +8391,6 @@ packages: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} - is-plain-object@2.0.4: - resolution: {integrity: sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==} - engines: {node: '>=0.10.0'} - is-potential-custom-element-name@1.0.1: resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} @@ -9360,10 +8466,6 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - isobject@3.0.1: - resolution: {integrity: sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==} - engines: {node: '>=0.10.0'} - istanbul-lib-coverage@3.2.2: resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} engines: {node: '>=8'} @@ -9673,8 +8775,8 @@ packages: resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} engines: {node: '>=6'} - kysely@0.28.15: - resolution: {integrity: sha512-r2clcf7HLWvDXaVUEvQymXJY4i3bSOIV3xsL/Upy3ZfSv5HeKsk9tsqbBptLvth5qHEIhxeHTA2jNLyQABkLBA==} + kysely@0.28.17: + resolution: {integrity: sha512-nbD8lB9EB3wNdMhOCdx5Li8DxnLbvKByylRLcJ1h+4SkrowVeECAyZlyiKMThF7xFdRz0jSQ2MoJr+wXux2y0Q==} engines: {node: '>=20.0.0'} language-subtag-registry@0.3.23: @@ -9820,9 +8922,6 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} - lodash-es@4.17.23: - resolution: {integrity: sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==} - lodash._baseiteratee@4.7.0: resolution: {integrity: sha512-nqB9M+wITz0BX/Q2xg6fQ8mLkyfF7MU7eE+MNBNjTHFKeKaZAPEzEg+E8LWxKWf1DQVflNEn9N49yAuqKh2mWQ==} @@ -9901,9 +9000,6 @@ packages: lodash.uniqby@4.5.0: resolution: {integrity: sha512-IRt7cfTtHy6f1aRVA5n7kT8rgN3N1nH6MOWLcHfpWG2SH19E3JksLK38MktLxZDhlAjCP9jpIXkOnRXlu6oByQ==} - lodash@4.17.21: - resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} - lodash@4.18.1: resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} @@ -10002,21 +9098,10 @@ packages: engines: {node: '>= 18'} hasBin: true - marked@4.3.0: - resolution: {integrity: sha512-PRsaiG84bK+AMvxziE/lCFss8juXjNaWzVbN5tXAm4XjeaS9NAHhop+PjQxz2A9h8Q4M/xGmzP8vqNwy6JeK0A==} - engines: {node: '>= 12'} - hasBin: true - - match-sorter@6.3.4: - resolution: {integrity: sha512-jfZW7cWS5y/1xswZo8VBOdudUiSd9nifYRWphc9M5D/ee4w4AoXLgBEdRbgVaxbMuagBPeUC5y2Hi8DO6o9aDg==} - matcher@3.0.0: resolution: {integrity: sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==} engines: {node: '>=10'} - material-colors@1.2.6: - resolution: {integrity: sha512-6qE4B9deFBIa9YSpOc9O0Sgc43zTeVYbgDT5veRKSlB2+ZuHNoVVxA1L/ckMUayV9Ay9y7Z/SZCLcGteW9i7bg==} - math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -10381,9 +9466,6 @@ packages: resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} engines: {node: '>=18'} - min-document@2.19.0: - resolution: {integrity: sha512-9Wy1B3m3f66bPPmU5hdA4DR4PB2OfDU/+GS3yAB7IQozE3tqXaVv2zOjgla7MEGSRv95+ILmOuvhLkOK6wJtCQ==} - min-indent@1.0.1: resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} engines: {node: '>=4'} @@ -10446,8 +9528,35 @@ packages: resolution: {integrity: sha512-mLV7SEiov2LHleRJPMPrK2PMyhXFZt2UQLC4VD4pnth3jMjYKHhtqfwwkkvS/NXuo/Fp3vbhaNcXrIDaLRb9Tg==} engines: {node: '>=12.9.0'} - mongodb@6.16.0: - resolution: {integrity: sha512-D1PNcdT0y4Grhou5Zi/qgipZOYeWrhLEpk33n3nm6LGtz61jvO88WlrWCK/bigMjpnOdAUKKQwsGIl0NtWMyYw==} + mongodb@6.20.0: + resolution: {integrity: sha512-Tl6MEIU3K4Rq3TSHd+sZQqRBoGlFsOgNrH5ltAcFBV62Re3Fd+FcaVf8uSEQFOJ51SDowDVttBTONMfoYWrWlQ==} + engines: {node: '>=16.20.1'} + peerDependencies: + '@aws-sdk/credential-providers': ^3.188.0 + '@mongodb-js/zstd': ^1.1.0 || ^2.0.0 + gcp-metadata: ^5.2.0 + kerberos: ^2.0.1 + mongodb-client-encryption: '>=6.0.0 <7' + snappy: ^7.3.2 + socks: ^2.7.1 + peerDependenciesMeta: + '@aws-sdk/credential-providers': + optional: true + '@mongodb-js/zstd': + optional: true + gcp-metadata: + optional: true + kerberos: + optional: true + mongodb-client-encryption: + optional: true + snappy: + optional: true + socks: + optional: true + + mongodb@6.21.0: + resolution: {integrity: sha512-URyb/VXMjJ4da46OeSXg+puO39XH9DeQpWCslifrRn9JWugy0D+DvvBvkm2WxmHe61O/H19JM66p1z7RHVkZ6A==} engines: {node: '>=16.20.1'} peerDependencies: '@aws-sdk/credential-providers': ^3.188.0 @@ -10455,7 +9564,7 @@ packages: gcp-metadata: ^5.2.0 kerberos: ^2.0.1 mongodb-client-encryption: '>=6.0.0 <7' - snappy: ^7.2.2 + snappy: ^7.3.2 socks: ^2.7.1 peerDependenciesMeta: '@aws-sdk/credential-providers': @@ -10473,8 +9582,8 @@ packages: socks: optional: true - mongoose@8.14.0: - resolution: {integrity: sha512-IxDxIUu42apE7oEknJK535xkQ6Gd7GKx/gNrNHY+vP4+ucVU2TOCWjVVW14Vc79y9DEEElzHDlTOuVNM8glUFA==} + mongoose@8.23.1: + resolution: {integrity: sha512-gHSPD8qEwRmiXapK17hEnFWZdcFENMegHTcw5XIIg2+7R8eXQvdwSiMpD/A2oG8tKzFLLHyRXd8/eaDPAVwZgQ==} engines: {node: '>=16.20.1'} motion-dom@12.38.0: @@ -10526,15 +9635,6 @@ packages: msgpackr@1.11.2: resolution: {integrity: sha512-F9UngXRlPyWCDEASDpTf6c9uNhGPTqnTeLVt7bN+bU1eajoR/8V9ys2BRaV5C/e5ihE6sJ9uPIKaYt6bFuO32g==} - multishift@2.0.10: - resolution: {integrity: sha512-zXNW/JXDHsl9VYc4ch/qQmA7XsbS1G77IjDCL1x1Q651S16DZBGw4Gus5XFwbPoD14TYKE/OfhtaW2W/74q+PA==} - peerDependencies: - '@types/react': 18.3.7 - react: ^16.14.0 || ^17 || ^18 - peerDependenciesMeta: - '@types/react': - optional: true - muri@1.3.0: resolution: {integrity: sha512-FiaFwKl864onHFFUV/a2szAl7X0fxVlSKNdhTf+BM8i8goEgYut8u5P9MqQqIYwvaMxjzVESsoEm/2kfkFH1rg==} deprecated: This package is no longer supported. Please use https://www.npmjs.com/package/mongodb-connection-string-url @@ -10542,10 +9642,6 @@ packages: mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} - nanoevents@5.1.13: - resolution: {integrity: sha512-JFAeG9fp0QZnRoESHjkbVFbZ9BkOXkkagUVwZVo/pkSX+Fq1VKlY+5og/8X9CYc6C7vje/CV+bwJ5M2X0+IY9Q==} - engines: {node: '>=6.0.0'} - nanoid@3.3.11: resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -10593,28 +9689,6 @@ packages: react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc - next@15.5.3: - resolution: {integrity: sha512-r/liNAx16SQj4D+XH/oI1dlpv9tdKJ6cONYPwwcCC46f2NjpaRWY+EKCzULfgQYV6YKXjHBchff2IZBSlZmJNw==} - engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.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 - '@playwright/test': ^1.51.1 - babel-plugin-react-compiler: '*' - react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 - react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 - sass: ^1.3.0 - peerDependenciesMeta: - '@opentelemetry/api': - optional: true - '@playwright/test': - optional: true - babel-plugin-react-compiler: - optional: true - sass: - optional: true - next@16.1.6: resolution: {integrity: sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw==} engines: {node: '>=20.9.0'} @@ -10700,10 +9774,6 @@ packages: resolution: {integrity: sha512-tt6PvKu4WyzPwWUzy/hvPFqn+uwXO0K1ZHka8az3NnrhWJDmSqI8ncWq0fkL0k/lmmi5tAC11FXwXuh0rFbt1A==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - number-is-nan@1.0.1: - resolution: {integrity: sha512-4jbtZXNAsfZbAHiiqjLPBiCl16dES1zI4Hpzzxw61Tk+loF+sBDBKx1ICKKKwIqQ7M0mFn1TmkN7euSncWgHiQ==} - engines: {node: '>=0.10.0'} - nwsapi@2.2.20: resolution: {integrity: sha512-/ieB+mDe4MrrKMT8z+mQL8klXydZWGR5Dowt4RAGKbJ3kIGEx3X4ljUo+6V73IXtUPWgfOlU5B9MlGxFO5T+cA==} @@ -10739,14 +9809,6 @@ packages: resolution: {integrity: sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==} engines: {node: '>= 0.4'} - object.omit@3.0.0: - resolution: {integrity: sha512-EO+BCv6LJfu+gBIF3ggLicFebFLN5zqzz/WWJlMFfkMyGth+oBkhxzDl0wx2W4GkLzuQs/FsSkXZb2IMWQqmBQ==} - engines: {node: '>=0.10.0'} - - object.pick@1.3.0: - resolution: {integrity: sha512-tqa/UMy/CCoYmj+H5qc07qvSL9dqcs/WZENZ1JbtWBlATP+iVOe778gE6MSijnyCnORzDuX6hU+LA4SZ09YjFQ==} - engines: {node: '>=0.10.0'} - object.values@1.2.1: resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} engines: {node: '>= 0.4'} @@ -10851,19 +9913,9 @@ packages: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} - parenthesis@3.1.8: - resolution: {integrity: sha512-KF/U8tk54BgQewkJPvB4s/US3VQY68BRDpH638+7O/n58TpnwiwnOtGIOsT2/i+M78s61BBpeC83STB88d8sqw==} - - parse-entities@2.0.0: - resolution: {integrity: sha512-kkywGpCcRYhqQIchaWqZ875wzpS/bMKhz5HnN3p7wveJTkTtyAB/AlnS0f8DFSqYW1T82t6yEAkEcB+A1I3MbQ==} - parse-entities@4.0.2: resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} - parse-exponential@1.0.1: - resolution: {integrity: sha512-QUa7PaOc7O6ei3hb0NmADJGrDYLbPBdcSKFUBGfwlMdHsrg8LOsliPEkpP0qHSKQOyzyyxCB00fxJKcP75Gl7w==} - engines: {node: '>=0.10.0'} - parse-json@5.2.0: resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} engines: {node: '>=8'} @@ -10895,8 +9947,8 @@ packages: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} - path-expression-matcher@1.2.0: - resolution: {integrity: sha512-DwmPWeFn+tq7TiyJ2CxezCAirXjFxvaiD03npak3cRjlP9+OjTmSy1EpIrEbh+l6JgUundniloMLDQ/6VTdhLQ==} + path-expression-matcher@1.5.0: + resolution: {integrity: sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==} engines: {node: '>=14.0.0'} path-is-absolute@1.0.1: @@ -10918,8 +9970,8 @@ packages: resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} engines: {node: '>=16 || 14 >=14.18'} - path-to-regexp@0.1.12: - resolution: {integrity: sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==} + path-to-regexp@0.1.13: + resolution: {integrity: sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==} path-to-regexp@6.3.0: resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} @@ -11080,10 +10132,6 @@ packages: preact@10.26.5: resolution: {integrity: sha512-fmpDkgfGU6JYux9teDWLhj9mKN55tyepwYbxHgQuIxbWQzgFg5vk7Mrrtfx7xRxq798ynkY4DDDxZr235Kk+4w==} - precision@1.0.1: - resolution: {integrity: sha512-cBMxnM2nzEF1xx75NhhOaKjsDNt92WUZv17t/p3wrvCfA+2RL0twbgfvXvgDbxxsfUUb5C5he5tla8Xa2ny1Ew==} - engines: {node: '>=0.10.0'} - preferred-pm@3.1.4: resolution: {integrity: sha512-lEHd+yEm22jXdCphDrkvIJQU66EuLojPPtvZkpKIkiD+l0DMThF/niqZKJSoU8Vl7iuvtmzyMhir9LdVy5WMnA==} engines: {node: '>=10'} @@ -11117,10 +10165,6 @@ packages: pretty-format@3.8.0: resolution: {integrity: sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==} - prismjs@1.27.0: - resolution: {integrity: sha512-t13BGPUlFDR7wRB5kQDG4jjl7XeuH6jbJGt11JHPL96qwsEHNX2+68tFXqc1/k+/jALsbSWJKUOT/hcYAZ5LkA==} - engines: {node: '>=6'} - prismjs@1.30.0: resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==} engines: {node: '>=6'} @@ -11148,9 +10192,6 @@ packages: proper-lockfile@4.1.2: resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==} - property-information@5.6.0: - resolution: {integrity: sha512-YUHSPk+A30YPv+0Qf8i9Mbfe/C0hdPXk1s1jPVToV8pk8BQtpw10ct89Eo7OWkutrwqvT0eicAxlOg3dOAu8JA==} - property-information@6.5.0: resolution: {integrity: sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig==} @@ -11172,27 +10213,15 @@ packages: prosemirror-gapcursor@1.3.2: resolution: {integrity: sha512-wtjswVBd2vaQRrnYZaBCbyDqr232Ed4p2QPtRIUK5FuqHYKGWkEwl08oQM4Tw7DOR0FsasARV5uJFvMZWxdNxQ==} - prosemirror-gapcursor@1.4.1: - resolution: {integrity: sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==} - prosemirror-history@1.4.1: resolution: {integrity: sha512-2JZD8z2JviJrboD9cPuX/Sv/1ChFng+xh2tChQ2X4bB2HeK+rra/bmJ3xGntCcjhOqIzSDG6Id7e8RJ9QPXLEQ==} - prosemirror-history@1.5.0: - resolution: {integrity: sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==} - prosemirror-inputrules@1.5.0: resolution: {integrity: sha512-K0xJRCmt+uSw7xesnHmcn72yBGTbY45vm8gXI4LZXbx2Z0jwh5aF9xrGQgrVPu0WbyFVFF3E/o9VhJYz6SQWnA==} - prosemirror-inputrules@1.5.1: - resolution: {integrity: sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==} - prosemirror-keymap@1.2.2: resolution: {integrity: sha512-EAlXoksqC6Vbocqc0GtzCruZEzYgrn+iiGnNjsJsH4mrnIGex4qbLdWWNza3AW5W36ZRrlBID0eM6bdKH4OStQ==} - prosemirror-keymap@1.2.3: - resolution: {integrity: sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==} - prosemirror-markdown@1.13.2: resolution: {integrity: sha512-FPD9rHPdA9fqzNmIIDhhnYQ6WgNoSWX9StUZ8LEKapaXU9i6XgykaHKhp6XMyXlOWetmaFgGDS/nu/w9/vUc5g==} @@ -11202,19 +10231,6 @@ packages: prosemirror-model@1.25.1: resolution: {integrity: sha512-AUvbm7qqmpZa5d9fPKMvH1Q5bqYQvAZWOGRvxsB6iFLyycvC9MwNemNVjHVrWgjaoxAfY8XVg7DbvQ/qxvI9Eg==} - prosemirror-model@1.25.4: - resolution: {integrity: sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==} - - prosemirror-paste-rules@3.0.0: - resolution: {integrity: sha512-p2ayp2xtSTtDPZutoxZyK6UKJhJk0hEpIfdfVYfd3DSENE1Lyrcg96pju+ClgwXlTjPUykXx5DrsfRbHCY+gGQ==} - peerDependencies: - prosemirror-model: ^1.22.3 - prosemirror-state: ^1.4.2 - prosemirror-view: ^1.34.2 - - prosemirror-resizable-view@3.0.0: - resolution: {integrity: sha512-xIy2fU7B7z46oH8zW6aC0i4EhwieuW1HtWwwSJ8PB3dDq/AJGR7EBce+1Jn/Fmeu1zPd5CWoCJbvAlEs8cQV4A==} - prosemirror-schema-basic@1.2.4: resolution: {integrity: sha512-ELxP4TlX3yr2v5rM7Sb70SqStq5NvI15c0j9j/gjsrO5vaw+fnnpovCLEGIcpeGfifkuqJwl4fon6b+KdrODYQ==} @@ -11224,22 +10240,9 @@ packages: prosemirror-state@1.4.3: resolution: {integrity: sha512-goFKORVbvPuAQaXhpbemJFRKJ2aixr+AZMGiquiqKxaucC6hlpHNZHWgz5R7dS4roHiwq9vDctE//CZ++o0W1Q==} - prosemirror-state@1.4.4: - resolution: {integrity: sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==} - - prosemirror-suggest@3.0.0: - resolution: {integrity: sha512-cEYnJHOAnQ+ET7PKY1tY8SMSpyR2rQAuYfPEmVtet0V9exgHAeiaSEzyBcCSeLesxXJRIv8b9cofyqoqyMjlEw==} - peerDependencies: - prosemirror-model: ^1.22.3 - prosemirror-state: ^1.4.2 - prosemirror-view: ^1.34.2 - prosemirror-tables@1.7.1: resolution: {integrity: sha512-eRQ97Bf+i9Eby99QbyAiyov43iOKgWa7QCGly+lrDt7efZ1v8NWolhXiB43hSDGIXT1UXgbs4KJN3a06FGpr1Q==} - prosemirror-tables@1.8.5: - resolution: {integrity: sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==} - prosemirror-trailing-node@3.0.0: resolution: {integrity: sha512-xiun5/3q0w5eRnGYfNlW1uU9W6x5MoFKWwq/0TIRgt09lv7Hcser2QYV8t4muXbEr+Fwo0geYn79Xs4GKywrRQ==} peerDependencies: @@ -11250,26 +10253,17 @@ packages: prosemirror-transform@1.10.4: resolution: {integrity: sha512-pwDy22nAnGqNR1feOQKHxoFkkUtepoFAd3r2hbEDsnf4wp57kKA36hXsB3njA9FtONBEwSDnDeCiJe+ItD+ykw==} - prosemirror-transform@1.12.0: - resolution: {integrity: sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==} - prosemirror-view@1.39.2: resolution: {integrity: sha512-BmOkml0QWNob165gyUxXi5K5CVUgVPpqMEAAml/qzgKn9boLUWVPzQ6LtzXw8Cn1GtRQX4ELumPxqtLTDaAKtg==} - prosemirror-view@1.41.7: - resolution: {integrity: sha512-jUwKNCEIGiqdvhlS91/2QAg21e4dfU5bH2iwmSDQeosXJgKF7smG0YSplOWK0cjSNgIqXe7VXqo7EIfUFJdt3w==} - - protobufjs@7.5.4: - resolution: {integrity: sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==} + protobufjs@7.5.7: + resolution: {integrity: sha512-NGnrxS/nLKUo5nkbVQxlC71sB4hdfImdYIbFeSCidxtwATx0AHRPcANSLd0q5Bb2BkoSWo2iisQhGg5/r+ihbA==} engines: {node: '>=12.0.0'} proxy-addr@2.0.7: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} - proxy-from-env@1.1.0: - resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} - proxy-from-env@2.1.0: resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} engines: {node: '>=10'} @@ -11377,11 +10371,6 @@ packages: chart.js: ^4.1.1 react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - react-color@2.19.3: - resolution: {integrity: sha512-LEeGE/ZzNLIsFWa1TMe8y5VYqr7bibneWmvJwm1pCn/eNmrabWDh659JSPn9BuaMpEfU83WTOJfnCcjDZwNQTA==} - peerDependencies: - react: '*' - react-copy-to-clipboard@5.1.0: resolution: {integrity: sha512-k61RsNgAayIJNoy9yDsYzDe/yAZAzEbEgcz3DZMhF686LEyukcE1hzurxe85JandPUG+yTfGVFzuEw3xt8WP/A==} peerDependencies: @@ -11531,11 +10520,6 @@ packages: resolution: {integrity: sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==} engines: {node: '>=0.10.0'} - reactcss@1.2.3: - resolution: {integrity: sha512-KiwVUcFu1RErkI97ywr8nvx8dNOpT03rbnma0SSalTYjkrPYaEajR4a/MRt6DZ46K6arDRbWMNHF+xH7G7n/8A==} - peerDependencies: - react: '*' - read-cache@1.0.0: resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==} @@ -11621,9 +10605,6 @@ packages: resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} engines: {node: '>= 0.4'} - refractor@3.6.0: - resolution: {integrity: sha512-MY9W41IOWxxk31o+YvFCNyNzdkc9M20NoZK5vq6jkv4I/uh2zkWcfudj0Q1fovjUQJrNewS9NMzeTtqPf+n5EA==} - refractor@5.0.0: resolution: {integrity: sha512-QXOrHQF5jOpjjLfiNk5GFnWhRXvxjUVnlFxkeDmewR5sXkr3iM46Zo+CnRR8B+MDVqkULW4EcLVcRBNOPXHosw==} @@ -11701,18 +10682,6 @@ packages: engines: {node: '>= 6.0.0'} hasBin: true - remirror@3.0.3: - resolution: {integrity: sha512-YmooU9OCR99uNnDI0HuhIpVYIQyP915NXppljZyM2+U31bJdtLDX+xU+JLi6sJS9pJYYsH2lhWIK5tRp3Oay1A==} - peerDependencies: - '@remirror/pm': ^3.0.1 - prettier: ^3.2.0 - peerDependenciesMeta: - prettier: - optional: true - - remove-accents@0.5.0: - resolution: {integrity: sha512-8g3/Otx1eJaVD12e31UbJj1YzdtVvzH85HV7t+9MJYk/u3XmkOUJ5Ys9wQrf9PCPK8+xn4ymzqYCiZl6QWKn+A==} - repeat-string@1.6.1: resolution: {integrity: sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w==} engines: {node: '>=0.10'} @@ -11738,9 +10707,6 @@ packages: reselect@5.1.1: resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==} - resize-observer-polyfill@1.5.1: - resolution: {integrity: sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==} - resolve-cwd@3.0.0: resolution: {integrity: sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==} engines: {node: '>=8'} @@ -11844,13 +10810,6 @@ packages: rou3@0.7.12: resolution: {integrity: sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg==} - round-precision@1.0.0: - resolution: {integrity: sha512-L2a0XDSNeaaBTEGmzuENMK4T8c0HqKYeS3pCDurW4MRPo8O6LeCLqVPWUt5+xW9rrEcG9QaYrAFcApEFXKziyw==} - engines: {node: '>=0.10.0'} - - round@2.0.1: - resolution: {integrity: sha512-wzT6PF3wNEd2PCLTBQxteheeSwViBrD89E1XZjl4sj505C4LwTpqOQSNXLEROHDQw35NoylYbMxoUhgf2hb4qw==} - rrweb-cssom@0.8.0: resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==} @@ -11864,9 +10823,6 @@ packages: resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} engines: {node: '>=6'} - safari-14-idb-fix@1.0.6: - resolution: {integrity: sha512-oTEQOdMwRX+uCtWCKT1nx2gAeSdpr8elg/2gcaKUH00SJU2xWESfkx11nmXwTRHy7xfQoj1o4TTQvdmuBosTnA==} - safe-array-concat@1.1.3: resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==} engines: {node: '>=0.4'} @@ -11990,10 +10946,6 @@ packages: resolution: {integrity: sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - sharp@0.34.3: - resolution: {integrity: sha512-eX2IQ6nFohW4DbvHIOLRB3MHFpYqaqvXd3Tp5e/T/dSH83fxaNJQRvDMhASmkNTsNTVF2/OOopzRCt7xokgPfg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - sharp@0.34.5: resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -12097,10 +11049,6 @@ packages: source-map-support@0.5.13: resolution: {integrity: sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==} - source-map@0.5.7: - resolution: {integrity: sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==} - engines: {node: '>=0.10.0'} - source-map@0.6.1: resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} engines: {node: '>=0.10.0'} @@ -12114,9 +11062,6 @@ packages: engines: {node: '>= 8'} deprecated: The work that was done in this beta branch won't be included in future versions - space-separated-tokens@1.1.5: - resolution: {integrity: sha512-q/JSVd1Lptzhf5bkYm4ob4iWPjx0KiRe3sRFBNrVqbJkFaBm5vbbowy1mymoPNLRa52+oadOhJ+K49wsSeSjTA==} - space-separated-tokens@2.0.2: resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} @@ -12256,8 +11201,8 @@ packages: strnum@1.1.2: resolution: {integrity: sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==} - strnum@2.2.2: - resolution: {integrity: sha512-DnR90I+jtXNSTXWdwrEy9FakW7UX+qUZg28gj5fk2vxxl7uS/3bpI4fjFYVmdK9etptYBPNkpahuQnEwhwECqA==} + strnum@2.3.0: + resolution: {integrity: sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==} style-to-js@1.1.21: resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==} @@ -12278,9 +11223,6 @@ packages: babel-plugin-macros: optional: true - stylis@4.2.0: - resolution: {integrity: sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==} - sucrase@3.35.0: resolution: {integrity: sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==} engines: {node: '>=16 || 14 >=14.17'} @@ -12304,9 +11246,6 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} - svgmoji@3.2.0: - resolution: {integrity: sha512-tjmdQhIju2ZQ81FLBlPngg1aWMOhQjP9ErXb2ROikM0aBGA/hqI0/DN/5J0sDsXzJPHmODpSFhWfiSsUieU3bA==} - swagger-client@3.37.1: resolution: {integrity: sha512-WCRU7wfyqTyB0vOpVK1vHFm4aCqnmqcXycDcWVmHa784Nd4cABaQeSITtjWMOnjJoIkTqG8TLArYn4SAv+wj2w==} @@ -12328,9 +11267,6 @@ packages: resolution: {integrity: sha512-HwOKAP7Wc5aRGYdKH+dw0PRRpbO841v2DENBtjnR5HFWoiNByAl7vrx3p0G/rCyYXQsrxqtX48TImFtPcIHSpQ==} engines: {node: ^14.18.0 || >=16.0.0} - tabbable@6.4.0: - resolution: {integrity: sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==} - tailwind-merge@2.6.0: resolution: {integrity: sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA==} @@ -12394,19 +11330,9 @@ packages: thread-stream@2.7.0: resolution: {integrity: sha512-qQiRWsU/wvNolI6tbbCKd9iKaTnCXsTwVxhhKM6nctPdujTyztjlbUkUTUymidWcMnZ5pWR0ej4a0tjsW021vw==} - throttle-debounce@3.0.1: - resolution: {integrity: sha512-dTEWWNu6JmeVXY0ZYoPuH5cRIwc0MeGbJwah9KUNYSJwommQpCzTySTpEe8Gs1J23aeWEuAobe4Ag7EHVt/LOg==} - engines: {node: '>=10'} - tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} - tiny-warning@1.0.3: - resolution: {integrity: sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==} - - tinycolor2@1.6.0: - resolution: {integrity: sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==} - tinyexec@1.1.1: resolution: {integrity: sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==} engines: {node: '>=18'} @@ -12608,12 +11534,6 @@ packages: engines: {node: '>=18.0.0'} hasBin: true - turndown-plugin-gfm@1.0.2: - resolution: {integrity: sha512-vwz9tfvF7XN/jE0dGoBei3FXWuvll78ohzCZQuOb+ZjWrs3a0XhQVomJEb2Qh4VHTPNRO4GPZh0V7VRbiWwkRg==} - - turndown@7.2.0: - resolution: {integrity: sha512-eCZGBN4nNNqM9Owkv9HAtWRYfLA4h909E/WGAWWBpmB275ehNhZyk87/Tpvjbp0jjNl9XwCsbe6bm6CqFsgD+A==} - tus-js-client@4.3.1: resolution: {integrity: sha512-ZLeYmjrkaU1fUsKbIi8JML52uAocjEZtBx4DKjRrqzrZa0O4MYwT6db+oqePlspV+FxXJAyFBc/L5gwUi2OFsg==} engines: {node: '>=18'} @@ -12644,18 +11564,10 @@ packages: resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} engines: {node: '>=10'} - type-fest@1.4.0: - resolution: {integrity: sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==} - engines: {node: '>=10'} - type-fest@2.19.0: resolution: {integrity: sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==} engines: {node: '>=12.20'} - type-fest@3.13.1: - resolution: {integrity: sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g==} - engines: {node: '>=14.16'} - type-fest@4.41.0: resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} engines: {node: '>=16'} @@ -12840,20 +11752,6 @@ packages: '@types/react': optional: true - use-isomorphic-layout-effect@1.2.1: - resolution: {integrity: sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA==} - peerDependencies: - '@types/react': '*' - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - peerDependenciesMeta: - '@types/react': - optional: true - - use-previous@1.2.0: - resolution: {integrity: sha512-tK7Ne779nqTKGeh0rsFvxnQcEqePFRYlM0rfmNy9JH+h+2ndja7P0017nda0Q1gkqfcOD//pKZbDyyLIUH2s+Q==} - peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 - use-sidecar@1.1.3: resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==} engines: {node: '>=10'} @@ -13160,6 +12058,10 @@ packages: resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} engines: {node: '>=18'} + xml-naming@0.1.0: + resolution: {integrity: sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==} + engines: {node: '>=16.0.0'} + xml2js@0.6.2: resolution: {integrity: sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==} engines: {node: '>=4.0.0'} @@ -13186,10 +12088,6 @@ packages: resolution: {integrity: sha512-FxF6+rkr1rNSQrhUNYrAFJpRXNzlDoMxeXN5qI84939ylEv3qqPFKa85Oxr6tDaJKqwW6KKyo2v26TSv3k6LeA==} engines: {node: '>=0.6.0'} - xtend@4.0.2: - resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} - engines: {node: '>=0.4'} - y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -13511,7 +12409,7 @@ snapshots: '@authenio/xml-encryption@2.0.2': dependencies: - '@xmldom/xmldom': 0.8.12 + '@xmldom/xmldom': 0.8.13 escape-html: 1.0.3 xpath: 0.0.32 @@ -13645,7 +12543,7 @@ snapshots: '@smithy/smithy-client': 4.2.0 '@smithy/types': 4.2.0 '@smithy/util-middleware': 4.0.2 - fast-xml-parser: 4.4.1 + fast-xml-parser: 4.5.4 tslib: 2.8.1 optional: true @@ -13923,13 +12821,6 @@ snapshots: js-tokens: 4.0.0 picocolors: 1.1.1 - '@babel/code-frame@7.29.0': - dependencies: - '@babel/helper-validator-identifier': 7.28.5 - js-tokens: 4.0.0 - picocolors: 1.1.1 - optional: true - '@babel/compat-data@7.26.8': {} '@babel/core@7.26.10': @@ -13960,15 +12851,6 @@ snapshots: '@jridgewell/trace-mapping': 0.3.25 jsesc: 3.1.0 - '@babel/generator@7.29.1': - dependencies: - '@babel/parser': 7.29.2 - '@babel/types': 7.29.0 - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 - jsesc: 3.1.0 - optional: true - '@babel/helper-annotate-as-pure@7.25.9': dependencies: '@babel/types': 7.27.0 @@ -13981,9 +12863,6 @@ snapshots: lru-cache: 5.1.1 semver: 6.3.1 - '@babel/helper-globals@7.28.0': - optional: true - '@babel/helper-module-imports@7.25.9': dependencies: '@babel/traverse': 7.27.0 @@ -13991,14 +12870,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/helper-module-imports@7.28.6': - dependencies: - '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 - transitivePeerDependencies: - - supports-color - optional: true - '@babel/helper-module-transforms@7.26.0(@babel/core@7.26.10)': dependencies: '@babel/core': 7.26.10 @@ -14012,14 +12883,8 @@ snapshots: '@babel/helper-string-parser@7.25.9': {} - '@babel/helper-string-parser@7.27.1': - optional: true - '@babel/helper-validator-identifier@7.25.9': {} - '@babel/helper-validator-identifier@7.28.5': - optional: true - '@babel/helper-validator-option@7.25.9': {} '@babel/helpers@7.27.0': @@ -14031,17 +12896,6 @@ snapshots: dependencies: '@babel/types': 7.27.0 - '@babel/parser@7.29.2': - dependencies: - '@babel/types': 7.29.0 - optional: true - - '@babel/plugin-proposal-export-namespace-from@7.18.9(@babel/core@7.26.10)': - dependencies: - '@babel/core': 7.26.10 - '@babel/helper-plugin-utils': 7.26.5 - '@babel/plugin-syntax-export-namespace-from': 7.8.3(@babel/core@7.26.10) - '@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.26.10)': dependencies: '@babel/core': 7.26.10 @@ -14062,16 +12916,6 @@ snapshots: '@babel/core': 7.26.10 '@babel/helper-plugin-utils': 7.26.5 - '@babel/plugin-syntax-dynamic-import@7.8.3(@babel/core@7.26.10)': - dependencies: - '@babel/core': 7.26.10 - '@babel/helper-plugin-utils': 7.26.5 - - '@babel/plugin-syntax-export-namespace-from@7.8.3(@babel/core@7.26.10)': - dependencies: - '@babel/core': 7.26.10 - '@babel/helper-plugin-utils': 7.26.5 - '@babel/plugin-syntax-import-attributes@7.26.0(@babel/core@7.26.10)': dependencies: '@babel/core': 7.26.10 @@ -14137,14 +12981,6 @@ snapshots: '@babel/core': 7.26.10 '@babel/helper-plugin-utils': 7.26.5 - '@babel/plugin-transform-modules-commonjs@7.26.3(@babel/core@7.26.10)': - dependencies: - '@babel/core': 7.26.10 - '@babel/helper-module-transforms': 7.26.0(@babel/core@7.26.10) - '@babel/helper-plugin-utils': 7.26.5 - transitivePeerDependencies: - - supports-color - '@babel/plugin-transform-react-jsx@7.25.9(@babel/core@7.26.10)': dependencies: '@babel/core': 7.26.10 @@ -14172,13 +13008,6 @@ snapshots: '@babel/parser': 7.27.0 '@babel/types': 7.27.0 - '@babel/template@7.28.6': - dependencies: - '@babel/code-frame': 7.29.0 - '@babel/parser': 7.29.2 - '@babel/types': 7.29.0 - optional: true - '@babel/traverse@7.27.0': dependencies: '@babel/code-frame': 7.26.2 @@ -14191,33 +13020,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/traverse@7.29.0': - dependencies: - '@babel/code-frame': 7.29.0 - '@babel/generator': 7.29.1 - '@babel/helper-globals': 7.28.0 - '@babel/parser': 7.29.2 - '@babel/template': 7.28.6 - '@babel/types': 7.29.0 - debug: 4.4.3 - transitivePeerDependencies: - - supports-color - optional: true - '@babel/types@7.27.0': dependencies: '@babel/helper-string-parser': 7.25.9 '@babel/helper-validator-identifier': 7.25.9 - '@babel/types@7.29.0': - dependencies: - '@babel/helper-string-parser': 7.27.1 - '@babel/helper-validator-identifier': 7.28.5 - optional: true - '@bcoe/v8-coverage@0.2.3': {} - '@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@3.24.3))(jose@6.2.2)(kysely@0.28.15)(nanostores@1.2.0)': + '@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@3.24.3))(jose@6.2.2)(kysely@0.28.17)(nanostores@1.2.0)': dependencies: '@better-auth/utils': 0.3.1 '@better-fetch/fetch': 1.1.21 @@ -14226,55 +13036,55 @@ snapshots: '@standard-schema/spec': 1.1.0 better-call: 1.3.2(zod@3.24.3) jose: 6.2.2 - kysely: 0.28.15 + kysely: 0.28.17 nanostores: 1.2.0 zod: 4.3.6 - '@better-auth/drizzle-adapter@1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@3.24.3))(jose@6.2.2)(kysely@0.28.15)(nanostores@1.2.0))(@better-auth/utils@0.3.1)': + '@better-auth/drizzle-adapter@1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@3.24.3))(jose@6.2.2)(kysely@0.28.17)(nanostores@1.2.0))(@better-auth/utils@0.3.1)': dependencies: - '@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@3.24.3))(jose@6.2.2)(kysely@0.28.15)(nanostores@1.2.0) + '@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@3.24.3))(jose@6.2.2)(kysely@0.28.17)(nanostores@1.2.0) '@better-auth/utils': 0.3.1 - '@better-auth/kysely-adapter@1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@3.24.3))(jose@6.2.2)(kysely@0.28.15)(nanostores@1.2.0))(@better-auth/utils@0.3.1)(kysely@0.28.15)': + '@better-auth/kysely-adapter@1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@3.24.3))(jose@6.2.2)(kysely@0.28.17)(nanostores@1.2.0))(@better-auth/utils@0.3.1)(kysely@0.28.17)': dependencies: - '@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@3.24.3))(jose@6.2.2)(kysely@0.28.15)(nanostores@1.2.0) + '@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@3.24.3))(jose@6.2.2)(kysely@0.28.17)(nanostores@1.2.0) '@better-auth/utils': 0.3.1 optionalDependencies: - kysely: 0.28.15 + kysely: 0.28.17 - '@better-auth/memory-adapter@1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@3.24.3))(jose@6.2.2)(kysely@0.28.15)(nanostores@1.2.0))(@better-auth/utils@0.3.1)': + '@better-auth/memory-adapter@1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@3.24.3))(jose@6.2.2)(kysely@0.28.17)(nanostores@1.2.0))(@better-auth/utils@0.3.1)': dependencies: - '@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@3.24.3))(jose@6.2.2)(kysely@0.28.15)(nanostores@1.2.0) + '@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@3.24.3))(jose@6.2.2)(kysely@0.28.17)(nanostores@1.2.0) '@better-auth/utils': 0.3.1 - '@better-auth/mongo-adapter@1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@3.24.3))(jose@6.2.2)(kysely@0.28.15)(nanostores@1.2.0))(@better-auth/utils@0.3.1)(mongodb@6.16.0(@aws-sdk/credential-providers@3.797.0)(socks@2.8.4))': + '@better-auth/mongo-adapter@1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@3.24.3))(jose@6.2.2)(kysely@0.28.17)(nanostores@1.2.0))(@better-auth/utils@0.3.1)(mongodb@6.21.0(@aws-sdk/credential-providers@3.797.0)(socks@2.8.4))': dependencies: - '@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@3.24.3))(jose@6.2.2)(kysely@0.28.15)(nanostores@1.2.0) + '@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@3.24.3))(jose@6.2.2)(kysely@0.28.17)(nanostores@1.2.0) '@better-auth/utils': 0.3.1 optionalDependencies: - mongodb: 6.16.0(@aws-sdk/credential-providers@3.797.0)(socks@2.8.4) + mongodb: 6.21.0(@aws-sdk/credential-providers@3.797.0)(socks@2.8.4) - '@better-auth/prisma-adapter@1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@3.24.3))(jose@6.2.2)(kysely@0.28.15)(nanostores@1.2.0))(@better-auth/utils@0.3.1)': + '@better-auth/prisma-adapter@1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@3.24.3))(jose@6.2.2)(kysely@0.28.17)(nanostores@1.2.0))(@better-auth/utils@0.3.1)': dependencies: - '@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@3.24.3))(jose@6.2.2)(kysely@0.28.15)(nanostores@1.2.0) + '@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@3.24.3))(jose@6.2.2)(kysely@0.28.17)(nanostores@1.2.0) '@better-auth/utils': 0.3.1 - '@better-auth/sso@1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@3.24.3))(jose@6.2.2)(kysely@0.28.15)(nanostores@1.2.0))(@better-auth/utils@0.3.1)(better-auth@1.5.6(@opentelemetry/api@1.9.0)(mongodb@6.16.0(@aws-sdk/credential-providers@3.797.0)(socks@2.8.4))(next@16.1.6(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(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-call@1.3.2(zod@3.24.3))': + '@better-auth/sso@1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@3.24.3))(jose@6.2.2)(kysely@0.28.17)(nanostores@1.2.0))(@better-auth/utils@0.3.1)(better-auth@1.5.6(@opentelemetry/api@1.9.0)(mongodb@6.21.0(@aws-sdk/credential-providers@3.797.0)(socks@2.8.4))(next@16.1.6(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(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-call@1.3.2(zod@3.24.3))': dependencies: - '@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@3.24.3))(jose@6.2.2)(kysely@0.28.15)(nanostores@1.2.0) + '@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@3.24.3))(jose@6.2.2)(kysely@0.28.17)(nanostores@1.2.0) '@better-auth/utils': 0.3.1 '@better-fetch/fetch': 1.1.21 - better-auth: 1.5.6(@opentelemetry/api@1.9.0)(mongodb@6.16.0(@aws-sdk/credential-providers@3.797.0)(socks@2.8.4))(next@16.1.6(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(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.5.6(@opentelemetry/api@1.9.0)(mongodb@6.21.0(@aws-sdk/credential-providers@3.797.0)(socks@2.8.4))(next@16.1.6(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(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-call: 1.3.2(zod@3.24.3) - fast-xml-parser: 5.5.9 + fast-xml-parser: 5.8.0 jose: 6.2.2 samlify: 2.12.0 tldts: 6.1.86 zod: 4.3.6 - '@better-auth/telemetry@1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@3.24.3))(jose@6.2.2)(kysely@0.28.15)(nanostores@1.2.0))': + '@better-auth/telemetry@1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@3.24.3))(jose@6.2.2)(kysely@0.28.17)(nanostores@1.2.0))': dependencies: - '@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@3.24.3))(jose@6.2.2)(kysely@0.28.15)(nanostores@1.2.0) + '@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@3.24.3))(jose@6.2.2)(kysely@0.28.17)(nanostores@1.2.0) '@better-auth/utils': 0.3.1 '@better-fetch/fetch': 1.1.21 @@ -14537,11 +13347,6 @@ snapshots: tslib: 2.8.1 optional: true - '@emnapi/runtime@1.5.0': - dependencies: - tslib: 2.8.1 - optional: true - '@emnapi/runtime@1.7.1': dependencies: tslib: 2.8.1 @@ -14552,70 +13357,6 @@ snapshots: tslib: 2.8.1 optional: true - '@emotion/babel-plugin@11.13.5': - dependencies: - '@babel/helper-module-imports': 7.28.6 - '@babel/runtime': 7.29.2 - '@emotion/hash': 0.9.2 - '@emotion/memoize': 0.9.0 - '@emotion/serialize': 1.3.3 - babel-plugin-macros: 3.1.0 - convert-source-map: 1.9.0 - escape-string-regexp: 4.0.0 - find-root: 1.1.0 - source-map: 0.5.7 - stylis: 4.2.0 - transitivePeerDependencies: - - supports-color - optional: true - - '@emotion/cache@11.14.0': - dependencies: - '@emotion/memoize': 0.9.0 - '@emotion/sheet': 1.4.0 - '@emotion/utils': 1.4.2 - '@emotion/weak-memoize': 0.4.0 - stylis: 4.2.0 - optional: true - - '@emotion/css@11.13.5': - dependencies: - '@emotion/babel-plugin': 11.13.5 - '@emotion/cache': 11.14.0 - '@emotion/serialize': 1.3.3 - '@emotion/sheet': 1.4.0 - '@emotion/utils': 1.4.2 - transitivePeerDependencies: - - supports-color - optional: true - - '@emotion/hash@0.9.2': - optional: true - - '@emotion/memoize@0.9.0': - optional: true - - '@emotion/serialize@1.3.3': - dependencies: - '@emotion/hash': 0.9.2 - '@emotion/memoize': 0.9.0 - '@emotion/unitless': 0.10.0 - '@emotion/utils': 1.4.2 - csstype: 3.2.3 - optional: true - - '@emotion/sheet@1.4.0': - optional: true - - '@emotion/unitless@0.10.0': - optional: true - - '@emotion/utils@1.4.2': - optional: true - - '@emotion/weak-memoize@0.4.0': - optional: true - '@esbuild/aix-ppc64@0.19.12': optional: true @@ -14981,15 +13722,6 @@ snapshots: react: 19.2.0 react-dom: 19.2.0(react@19.2.0) - '@floating-ui/react@0.24.8(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': - dependencies: - '@floating-ui/react-dom': 2.1.8(react-dom@19.2.0(react@19.2.0))(react@19.2.0) - aria-hidden: 1.2.6 - react: 19.2.0 - react-dom: 19.2.0(react@19.2.0) - tabbable: 6.4.0 - optional: true - '@floating-ui/utils@0.2.11': {} '@floating-ui/utils@0.2.9': {} @@ -15028,7 +13760,7 @@ snapshots: dependencies: lodash.camelcase: 4.3.0 long: 5.3.2 - protobufjs: 7.5.4 + protobufjs: 7.5.7 yargs: 17.7.2 '@hookform/resolvers@3.10.0(react-hook-form@7.56.1(react@19.2.0))': @@ -15056,11 +13788,6 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} - '@icons/material@0.2.4(react@19.2.0)': - dependencies: - react: 19.2.0 - optional: true - '@img/colour@1.0.0': optional: true @@ -15069,11 +13796,6 @@ snapshots: '@img/sharp-libvips-darwin-arm64': 1.0.4 optional: true - '@img/sharp-darwin-arm64@0.34.3': - optionalDependencies: - '@img/sharp-libvips-darwin-arm64': 1.2.0 - optional: true - '@img/sharp-darwin-arm64@0.34.5': optionalDependencies: '@img/sharp-libvips-darwin-arm64': 1.2.4 @@ -15084,11 +13806,6 @@ snapshots: '@img/sharp-libvips-darwin-x64': 1.0.4 optional: true - '@img/sharp-darwin-x64@0.34.3': - optionalDependencies: - '@img/sharp-libvips-darwin-x64': 1.2.0 - optional: true - '@img/sharp-darwin-x64@0.34.5': optionalDependencies: '@img/sharp-libvips-darwin-x64': 1.2.4 @@ -15097,42 +13814,27 @@ snapshots: '@img/sharp-libvips-darwin-arm64@1.0.4': optional: true - '@img/sharp-libvips-darwin-arm64@1.2.0': - optional: true - '@img/sharp-libvips-darwin-arm64@1.2.4': optional: true '@img/sharp-libvips-darwin-x64@1.0.4': optional: true - '@img/sharp-libvips-darwin-x64@1.2.0': - optional: true - '@img/sharp-libvips-darwin-x64@1.2.4': optional: true '@img/sharp-libvips-linux-arm64@1.0.4': optional: true - '@img/sharp-libvips-linux-arm64@1.2.0': - optional: true - '@img/sharp-libvips-linux-arm64@1.2.4': optional: true '@img/sharp-libvips-linux-arm@1.0.5': optional: true - '@img/sharp-libvips-linux-arm@1.2.0': - optional: true - '@img/sharp-libvips-linux-arm@1.2.4': optional: true - '@img/sharp-libvips-linux-ppc64@1.2.0': - optional: true - '@img/sharp-libvips-linux-ppc64@1.2.4': optional: true @@ -15142,36 +13844,24 @@ snapshots: '@img/sharp-libvips-linux-s390x@1.0.4': optional: true - '@img/sharp-libvips-linux-s390x@1.2.0': - optional: true - '@img/sharp-libvips-linux-s390x@1.2.4': optional: true '@img/sharp-libvips-linux-x64@1.0.4': optional: true - '@img/sharp-libvips-linux-x64@1.2.0': - optional: true - '@img/sharp-libvips-linux-x64@1.2.4': optional: true '@img/sharp-libvips-linuxmusl-arm64@1.0.4': optional: true - '@img/sharp-libvips-linuxmusl-arm64@1.2.0': - optional: true - '@img/sharp-libvips-linuxmusl-arm64@1.2.4': optional: true '@img/sharp-libvips-linuxmusl-x64@1.0.4': optional: true - '@img/sharp-libvips-linuxmusl-x64@1.2.0': - optional: true - '@img/sharp-libvips-linuxmusl-x64@1.2.4': optional: true @@ -15180,11 +13870,6 @@ snapshots: '@img/sharp-libvips-linux-arm64': 1.0.4 optional: true - '@img/sharp-linux-arm64@0.34.3': - optionalDependencies: - '@img/sharp-libvips-linux-arm64': 1.2.0 - optional: true - '@img/sharp-linux-arm64@0.34.5': optionalDependencies: '@img/sharp-libvips-linux-arm64': 1.2.4 @@ -15195,21 +13880,11 @@ snapshots: '@img/sharp-libvips-linux-arm': 1.0.5 optional: true - '@img/sharp-linux-arm@0.34.3': - optionalDependencies: - '@img/sharp-libvips-linux-arm': 1.2.0 - optional: true - '@img/sharp-linux-arm@0.34.5': optionalDependencies: '@img/sharp-libvips-linux-arm': 1.2.4 optional: true - '@img/sharp-linux-ppc64@0.34.3': - optionalDependencies: - '@img/sharp-libvips-linux-ppc64': 1.2.0 - optional: true - '@img/sharp-linux-ppc64@0.34.5': optionalDependencies: '@img/sharp-libvips-linux-ppc64': 1.2.4 @@ -15225,11 +13900,6 @@ snapshots: '@img/sharp-libvips-linux-s390x': 1.0.4 optional: true - '@img/sharp-linux-s390x@0.34.3': - optionalDependencies: - '@img/sharp-libvips-linux-s390x': 1.2.0 - optional: true - '@img/sharp-linux-s390x@0.34.5': optionalDependencies: '@img/sharp-libvips-linux-s390x': 1.2.4 @@ -15240,11 +13910,6 @@ snapshots: '@img/sharp-libvips-linux-x64': 1.0.4 optional: true - '@img/sharp-linux-x64@0.34.3': - optionalDependencies: - '@img/sharp-libvips-linux-x64': 1.2.0 - optional: true - '@img/sharp-linux-x64@0.34.5': optionalDependencies: '@img/sharp-libvips-linux-x64': 1.2.4 @@ -15255,11 +13920,6 @@ snapshots: '@img/sharp-libvips-linuxmusl-arm64': 1.0.4 optional: true - '@img/sharp-linuxmusl-arm64@0.34.3': - optionalDependencies: - '@img/sharp-libvips-linuxmusl-arm64': 1.2.0 - optional: true - '@img/sharp-linuxmusl-arm64@0.34.5': optionalDependencies: '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 @@ -15270,11 +13930,6 @@ snapshots: '@img/sharp-libvips-linuxmusl-x64': 1.0.4 optional: true - '@img/sharp-linuxmusl-x64@0.34.3': - optionalDependencies: - '@img/sharp-libvips-linuxmusl-x64': 1.2.0 - optional: true - '@img/sharp-linuxmusl-x64@0.34.5': optionalDependencies: '@img/sharp-libvips-linuxmusl-x64': 1.2.4 @@ -15285,37 +13940,23 @@ snapshots: '@emnapi/runtime': 1.4.3 optional: true - '@img/sharp-wasm32@0.34.3': - dependencies: - '@emnapi/runtime': 1.5.0 - optional: true - '@img/sharp-wasm32@0.34.5': dependencies: '@emnapi/runtime': 1.7.1 optional: true - '@img/sharp-win32-arm64@0.34.3': - optional: true - '@img/sharp-win32-arm64@0.34.5': optional: true '@img/sharp-win32-ia32@0.33.5': optional: true - '@img/sharp-win32-ia32@0.34.3': - optional: true - '@img/sharp-win32-ia32@0.34.5': optional: true '@img/sharp-win32-x64@0.33.5': optional: true - '@img/sharp-win32-x64@0.34.3': - optional: true - '@img/sharp-win32-x64@0.34.5': optional: true @@ -15587,46 +14228,6 @@ snapshots: '@kurkle/color@0.3.4': {} - '@linaria/core@4.2.10': - dependencies: - '@linaria/logger': 4.5.0 - '@linaria/tags': 4.5.4 - '@linaria/utils': 4.5.3 - transitivePeerDependencies: - - supports-color - - '@linaria/logger@4.5.0': - dependencies: - debug: 4.4.1 - picocolors: 1.1.1 - transitivePeerDependencies: - - supports-color - - '@linaria/tags@4.5.4': - dependencies: - '@babel/generator': 7.27.0 - '@linaria/logger': 4.5.0 - '@linaria/utils': 4.5.3 - transitivePeerDependencies: - - supports-color - - '@linaria/utils@4.5.3': - dependencies: - '@babel/core': 7.26.10 - '@babel/generator': 7.27.0 - '@babel/plugin-proposal-export-namespace-from': 7.18.9(@babel/core@7.26.10) - '@babel/plugin-syntax-dynamic-import': 7.8.3(@babel/core@7.26.10) - '@babel/plugin-transform-modules-commonjs': 7.26.3(@babel/core@7.26.10) - '@babel/template': 7.27.0 - '@babel/traverse': 7.27.0 - '@babel/types': 7.27.0 - '@linaria/logger': 4.5.0 - babel-merge: 3.0.0(@babel/core@7.26.10) - find-up: 5.0.0 - minimatch: 9.0.5 - transitivePeerDependencies: - - supports-color - '@ljharb/has-package-exports-patterns@0.0.2': {} '@manypkg/find-root@1.1.0': @@ -15675,11 +14276,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@mixmark-io/domino@2.2.0': {} - '@mongodb-js/saslprep@1.2.2': dependencies: sparse-bitfield: 3.0.3 + optional: true + + '@mongodb-js/saslprep@1.4.11': + dependencies: + sparse-bitfield: 3.0.3 '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3': optional: true @@ -15706,59 +14310,33 @@ snapshots: '@tybys/wasm-util': 0.9.0 optional: true - '@next/env@15.5.3': {} - '@next/env@16.1.6': {} '@next/eslint-plugin-next@16.0.3': dependencies: fast-glob: 3.3.1 - '@next/swc-darwin-arm64@15.5.3': - optional: true - '@next/swc-darwin-arm64@16.1.6': optional: true - '@next/swc-darwin-x64@15.5.3': - optional: true - '@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.1.6': optional: true - '@next/swc-linux-arm64-musl@15.5.3': - optional: true - '@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.1.6': optional: true - '@next/swc-linux-x64-musl@15.5.3': - optional: true - '@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.1.6': optional: true - '@next/swc-win32-x64-msvc@15.5.3': - optional: true - '@next/swc-win32-x64-msvc@16.1.6': optional: true @@ -15768,6 +14346,8 @@ snapshots: '@noble/hashes@2.0.1': {} + '@nodable/entities@2.1.0': {} + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -15782,10 +14362,6 @@ snapshots: '@nolyfill/is-core-module@1.0.39': {} - '@ocavue/svgmoji-cjs@0.1.1': - dependencies: - svgmoji: 3.2.0 - '@opentelemetry/api-logs@0.204.0': dependencies: '@opentelemetry/api': 1.9.0 @@ -15943,7 +14519,7 @@ snapshots: '@opentelemetry/sdk-logs': 0.204.0(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-metrics': 2.1.0(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-trace-base': 2.1.0(@opentelemetry/api@1.9.0) - protobufjs: 7.5.4 + protobufjs: 7.5.7 '@opentelemetry/propagator-b3@2.1.0(@opentelemetry/api@1.9.0)': dependencies: @@ -16069,24 +14645,24 @@ snapshots: '@protobufjs/base64@1.1.2': {} - '@protobufjs/codegen@2.0.4': {} + '@protobufjs/codegen@2.0.5': {} '@protobufjs/eventemitter@1.1.0': {} '@protobufjs/fetch@1.1.0': dependencies: '@protobufjs/aspromise': 1.1.2 - '@protobufjs/inquire': 1.1.0 + '@protobufjs/inquire': 1.1.1 '@protobufjs/float@1.0.2': {} - '@protobufjs/inquire@1.1.0': {} + '@protobufjs/inquire@1.1.1': {} '@protobufjs/path@1.1.2': {} '@protobufjs/pool@1.1.0': {} - '@protobufjs/utf8@1.1.0': {} + '@protobufjs/utf8@1.1.1': {} '@radix-ui/number@1.1.1': {} @@ -17818,1059 +16394,145 @@ snapshots: react: 18.3.1 react-dom: 19.2.0(react@18.3.1) optionalDependencies: - '@types/react': 18.3.7 - '@types/react-dom': 18.3.0 - - '@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@18.3.0)(@types/react@18.3.7)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': - dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.0)(@types/react@18.3.7)(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) - optionalDependencies: - '@types/react': 18.3.7 - '@types/react-dom': 18.3.0 - - '@radix-ui/rect@1.1.1': {} - - '@react-email/body@0.2.0(react@19.2.0)': - dependencies: - react: 19.2.0 - - '@react-email/button@0.2.0(react@19.2.0)': - dependencies: - react: 19.2.0 - - '@react-email/code-block@0.2.0(react@19.2.0)': - dependencies: - prismjs: 1.30.0 - react: 19.2.0 - - '@react-email/code-inline@0.0.5(react@19.2.0)': - dependencies: - react: 19.2.0 - - '@react-email/column@0.0.13(react@19.2.0)': - dependencies: - react: 19.2.0 - - '@react-email/components@1.0.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': - dependencies: - '@react-email/body': 0.2.0(react@19.2.0) - '@react-email/button': 0.2.0(react@19.2.0) - '@react-email/code-block': 0.2.0(react@19.2.0) - '@react-email/code-inline': 0.0.5(react@19.2.0) - '@react-email/column': 0.0.13(react@19.2.0) - '@react-email/container': 0.0.15(react@19.2.0) - '@react-email/font': 0.0.9(react@19.2.0) - '@react-email/head': 0.0.12(react@19.2.0) - '@react-email/heading': 0.0.15(react@19.2.0) - '@react-email/hr': 0.0.11(react@19.2.0) - '@react-email/html': 0.0.11(react@19.2.0) - '@react-email/img': 0.0.11(react@19.2.0) - '@react-email/link': 0.0.12(react@19.2.0) - '@react-email/markdown': 0.0.17(react@19.2.0) - '@react-email/preview': 0.0.13(react@19.2.0) - '@react-email/render': 2.0.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0) - '@react-email/row': 0.0.12(react@19.2.0) - '@react-email/section': 0.0.16(react@19.2.0) - '@react-email/tailwind': 2.0.1(@react-email/body@0.2.0(react@19.2.0))(@react-email/button@0.2.0(react@19.2.0))(@react-email/code-block@0.2.0(react@19.2.0))(@react-email/code-inline@0.0.5(react@19.2.0))(@react-email/container@0.0.15(react@19.2.0))(@react-email/heading@0.0.15(react@19.2.0))(@react-email/hr@0.0.11(react@19.2.0))(@react-email/img@0.0.11(react@19.2.0))(@react-email/link@0.0.12(react@19.2.0))(@react-email/preview@0.0.13(react@19.2.0))(@react-email/text@0.1.5(react@19.2.0))(react@19.2.0) - '@react-email/text': 0.1.5(react@19.2.0) - react: 19.2.0 - transitivePeerDependencies: - - react-dom - - '@react-email/container@0.0.15(react@19.2.0)': - dependencies: - react: 19.2.0 - - '@react-email/font@0.0.9(react@19.2.0)': - dependencies: - react: 19.2.0 - - '@react-email/head@0.0.12(react@19.2.0)': - dependencies: - react: 19.2.0 - - '@react-email/heading@0.0.15(react@19.2.0)': - dependencies: - react: 19.2.0 - - '@react-email/hr@0.0.11(react@19.2.0)': - dependencies: - react: 19.2.0 - - '@react-email/html@0.0.11(react@19.2.0)': - dependencies: - react: 19.2.0 - - '@react-email/img@0.0.11(react@19.2.0)': - dependencies: - react: 19.2.0 - - '@react-email/link@0.0.12(react@19.2.0)': - dependencies: - react: 19.2.0 - - '@react-email/markdown@0.0.17(react@19.2.0)': - dependencies: - marked: 15.0.12 - react: 19.2.0 - - '@react-email/preview@0.0.13(react@19.2.0)': - dependencies: - react: 19.2.0 - - '@react-email/render@2.0.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': - dependencies: - html-to-text: 9.0.5 - prettier: 3.5.3 - react: 19.2.0 - react-dom: 19.2.0(react@19.2.0) - - '@react-email/row@0.0.12(react@19.2.0)': - dependencies: - react: 19.2.0 - - '@react-email/section@0.0.16(react@19.2.0)': - dependencies: - react: 19.2.0 - - '@react-email/tailwind@2.0.1(@react-email/body@0.2.0(react@19.2.0))(@react-email/button@0.2.0(react@19.2.0))(@react-email/code-block@0.2.0(react@19.2.0))(@react-email/code-inline@0.0.5(react@19.2.0))(@react-email/container@0.0.15(react@19.2.0))(@react-email/heading@0.0.15(react@19.2.0))(@react-email/hr@0.0.11(react@19.2.0))(@react-email/img@0.0.11(react@19.2.0))(@react-email/link@0.0.12(react@19.2.0))(@react-email/preview@0.0.13(react@19.2.0))(@react-email/text@0.1.5(react@19.2.0))(react@19.2.0)': - dependencies: - '@react-email/text': 0.1.5(react@19.2.0) - react: 19.2.0 - tailwindcss: 4.1.17 - optionalDependencies: - '@react-email/body': 0.2.0(react@19.2.0) - '@react-email/button': 0.2.0(react@19.2.0) - '@react-email/code-block': 0.2.0(react@19.2.0) - '@react-email/code-inline': 0.0.5(react@19.2.0) - '@react-email/container': 0.0.15(react@19.2.0) - '@react-email/heading': 0.0.15(react@19.2.0) - '@react-email/hr': 0.0.11(react@19.2.0) - '@react-email/img': 0.0.11(react@19.2.0) - '@react-email/link': 0.0.12(react@19.2.0) - '@react-email/preview': 0.0.13(react@19.2.0) - - '@react-email/text@0.1.5(react@19.2.0)': - dependencies: - react: 19.2.0 - - '@remirror/core-constants@3.0.0': {} - - '@remirror/core-helpers@4.0.0': - dependencies: - '@remirror/core-constants': 3.0.0 - '@remirror/types': 2.0.0 - '@types/object.omit': 3.0.3 - '@types/object.pick': 1.3.4 - '@types/throttle-debounce': 2.1.0 - case-anything: 2.1.13 - clsx: 2.1.1 - dash-get: 1.0.2 - deepmerge: 4.3.1 - fast-deep-equal: 3.1.3 - make-error: 1.3.6 - object.omit: 3.0.0 - object.pick: 1.3.0 - throttle-debounce: 3.0.1 - - '@remirror/core-types@3.0.0(@remirror/pm@3.0.1)': - dependencies: - '@remirror/core-constants': 3.0.0 - '@remirror/pm': 3.0.1 - '@remirror/types': 2.0.0 - - '@remirror/core-utils@3.0.0(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0)': - dependencies: - '@babel/runtime': 7.27.0 - '@remirror/core-constants': 3.0.0 - '@remirror/core-helpers': 4.0.0 - '@remirror/core-types': 3.0.0(@remirror/pm@3.0.1) - '@remirror/messages': 3.0.0(@remirror/pm@3.0.1) - '@remirror/pm': 3.0.1 - '@types/min-document': 2.19.2 - css-in-js-utils: 3.1.0 - get-dom-document: 0.1.3(jsdom@26.1.0) - min-document: 2.19.0 - parenthesis: 3.1.8 - optionalDependencies: - '@types/node': 17.0.21 - transitivePeerDependencies: - - jsdom - - '@remirror/core@3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0)': - dependencies: - '@babel/runtime': 7.27.0 - '@remirror/core-constants': 3.0.0 - '@remirror/core-helpers': 4.0.0 - '@remirror/core-types': 3.0.0(@remirror/pm@3.0.1) - '@remirror/core-utils': 3.0.0(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/icons': 3.0.0 - '@remirror/messages': 3.0.0(@remirror/pm@3.0.1) - '@remirror/pm': 3.0.1 - nanoevents: 5.1.13 - tiny-warning: 1.0.3 - transitivePeerDependencies: - - '@types/node' - - jsdom - - '@remirror/dom@3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0)': - dependencies: - '@babel/runtime': 7.27.0 - '@remirror/core': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/pm': 3.0.1 - '@remirror/preset-core': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - transitivePeerDependencies: - - '@types/node' - - jsdom - - supports-color - - '@remirror/extension-annotation@3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0)': - dependencies: - '@babel/runtime': 7.27.0 - '@remirror/core': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/extension-positioner': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/messages': 3.0.0(@remirror/pm@3.0.1) - '@remirror/pm': 3.0.1 - transitivePeerDependencies: - - '@types/node' - - jsdom - - supports-color - - '@remirror/extension-bidi@3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0)': - dependencies: - '@babel/runtime': 7.27.0 - '@remirror/core': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/messages': 3.0.0(@remirror/pm@3.0.1) - '@remirror/pm': 3.0.1 - '@types/direction': 1.0.0 - direction: 1.0.4 - transitivePeerDependencies: - - '@types/node' - - jsdom - - '@remirror/extension-blockquote@3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0)': - dependencies: - '@babel/runtime': 7.27.0 - '@remirror/core': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/messages': 3.0.0(@remirror/pm@3.0.1) - '@remirror/pm': 3.0.1 - '@remirror/theme': 3.0.0(@remirror/pm@3.0.1) - transitivePeerDependencies: - - '@types/node' - - jsdom - - supports-color - - '@remirror/extension-bold@3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0)': - dependencies: - '@babel/runtime': 7.27.0 - '@remirror/core': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/messages': 3.0.0(@remirror/pm@3.0.1) - '@remirror/pm': 3.0.1 - transitivePeerDependencies: - - '@types/node' - - jsdom - - '@remirror/extension-callout@3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0)': - dependencies: - '@babel/runtime': 7.27.0 - '@remirror/core': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/messages': 3.0.0(@remirror/pm@3.0.1) - '@remirror/pm': 3.0.1 - '@remirror/theme': 3.0.0(@remirror/pm@3.0.1) - transitivePeerDependencies: - - '@types/node' - - jsdom - - supports-color - - '@remirror/extension-code-block@3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0)(prettier@3.5.3)': - dependencies: - '@babel/runtime': 7.27.0 - '@remirror/core': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/extension-positioner': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/messages': 3.0.0(@remirror/pm@3.0.1) - '@remirror/pm': 3.0.1 - '@remirror/theme': 3.0.0(@remirror/pm@3.0.1) - '@types/refractor': 3.4.1 - refractor: 3.6.0 - optionalDependencies: - prettier: 3.5.3 - transitivePeerDependencies: - - '@types/node' - - jsdom - - supports-color - - '@remirror/extension-code@3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0)': - dependencies: - '@babel/runtime': 7.27.0 - '@remirror/core': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/messages': 3.0.0(@remirror/pm@3.0.1) - '@remirror/pm': 3.0.1 - transitivePeerDependencies: - - '@types/node' - - jsdom - - '@remirror/extension-collaboration@3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0)': - dependencies: - '@babel/runtime': 7.27.0 - '@remirror/core': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/messages': 3.0.0(@remirror/pm@3.0.1) - '@remirror/pm': 3.0.1 - transitivePeerDependencies: - - '@types/node' - - jsdom - - '@remirror/extension-columns@3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0)': - dependencies: - '@babel/runtime': 7.27.0 - '@remirror/core': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/messages': 3.0.0(@remirror/pm@3.0.1) - '@remirror/pm': 3.0.1 - transitivePeerDependencies: - - '@types/node' - - jsdom - - '@remirror/extension-diff@3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0)': - dependencies: - '@babel/runtime': 7.27.0 - '@remirror/core': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/messages': 3.0.0(@remirror/pm@3.0.1) - '@remirror/pm': 3.0.1 - transitivePeerDependencies: - - '@types/node' - - jsdom - - '@remirror/extension-doc@3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0)': - dependencies: - '@babel/runtime': 7.27.0 - '@remirror/core': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/messages': 3.0.0(@remirror/pm@3.0.1) - '@remirror/pm': 3.0.1 - transitivePeerDependencies: - - '@types/node' - - jsdom - - '@remirror/extension-drop-cursor@3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0)': - dependencies: - '@babel/runtime': 7.27.0 - '@remirror/core': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/messages': 3.0.0(@remirror/pm@3.0.1) - '@remirror/pm': 3.0.1 - transitivePeerDependencies: - - '@types/node' - - jsdom - - '@remirror/extension-embed@3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0)': - dependencies: - '@babel/runtime': 7.27.0 - '@remirror/core': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/messages': 3.0.0(@remirror/pm@3.0.1) - '@remirror/pm': 3.0.1 - '@types/querystringify': 2.0.2 - prosemirror-resizable-view: 3.0.0(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - querystringify: 2.2.0 - transitivePeerDependencies: - - '@types/node' - - jsdom - - '@remirror/extension-emoji@3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0)': - dependencies: - '@babel/runtime': 7.27.0 - '@ocavue/svgmoji-cjs': 0.1.1 - '@remirror/core': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/messages': 3.0.0(@remirror/pm@3.0.1) - '@remirror/pm': 3.0.1 - '@remirror/theme': 3.0.0(@remirror/pm@3.0.1) - emojibase: 6.1.0 - emojibase-data: 6.2.0(emojibase@6.1.0) - emojibase-regex: 6.0.1 - escape-string-regexp: 4.0.0 - svgmoji: 3.2.0 - transitivePeerDependencies: - - '@types/node' - - jsdom - - supports-color - - '@remirror/extension-entity-reference@3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0)': - dependencies: - '@babel/runtime': 7.27.0 - '@remirror/core': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/extension-events': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/extension-positioner': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/pm': 3.0.1 - transitivePeerDependencies: - - '@types/node' - - jsdom - - supports-color - - '@remirror/extension-epic-mode@3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0)': - dependencies: - '@babel/runtime': 7.27.0 - '@remirror/core': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/messages': 3.0.0(@remirror/pm@3.0.1) - '@remirror/pm': 3.0.1 - transitivePeerDependencies: - - '@types/node' - - jsdom - - '@remirror/extension-events@3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0)': - dependencies: - '@babel/runtime': 7.27.0 - '@remirror/core': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/messages': 3.0.0(@remirror/pm@3.0.1) - '@remirror/pm': 3.0.1 - transitivePeerDependencies: - - '@types/node' - - jsdom - - '@remirror/extension-find@1.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0)': - dependencies: - '@remirror/core': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/pm': 3.0.1 - '@types/string.prototype.matchall': 4.0.4 - escape-string-regexp: 4.0.0 - string.prototype.matchall: 4.0.12 - transitivePeerDependencies: - - '@types/node' - - jsdom - - '@remirror/extension-font-family@3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0)': - dependencies: - '@babel/runtime': 7.27.0 - '@remirror/core': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/messages': 3.0.0(@remirror/pm@3.0.1) - '@remirror/pm': 3.0.1 - transitivePeerDependencies: - - '@types/node' - - jsdom - - '@remirror/extension-font-size@3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0)': - dependencies: - '@babel/runtime': 7.27.0 - '@remirror/core': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/messages': 3.0.0(@remirror/pm@3.0.1) - '@remirror/pm': 3.0.1 - round: 2.0.1 - transitivePeerDependencies: - - '@types/node' - - jsdom - - '@remirror/extension-gap-cursor@3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0)': - dependencies: - '@babel/runtime': 7.27.0 - '@remirror/core': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/messages': 3.0.0(@remirror/pm@3.0.1) - '@remirror/pm': 3.0.1 - transitivePeerDependencies: - - '@types/node' - - jsdom - - '@remirror/extension-hard-break@3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0)': - dependencies: - '@babel/runtime': 7.27.0 - '@remirror/core': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/messages': 3.0.0(@remirror/pm@3.0.1) - '@remirror/pm': 3.0.1 - transitivePeerDependencies: - - '@types/node' - - jsdom - - '@remirror/extension-heading@3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0)': - dependencies: - '@babel/runtime': 7.27.0 - '@remirror/core': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/messages': 3.0.0(@remirror/pm@3.0.1) - '@remirror/pm': 3.0.1 - transitivePeerDependencies: - - '@types/node' - - jsdom - - '@remirror/extension-history@3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0)': - dependencies: - '@babel/runtime': 7.27.0 - '@remirror/core': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/messages': 3.0.0(@remirror/pm@3.0.1) - '@remirror/pm': 3.0.1 - transitivePeerDependencies: - - '@types/node' - - jsdom - - '@remirror/extension-horizontal-rule@3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0)': - dependencies: - '@babel/runtime': 7.27.0 - '@remirror/core': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/messages': 3.0.0(@remirror/pm@3.0.1) - '@remirror/pm': 3.0.1 - transitivePeerDependencies: - - '@types/node' - - jsdom - - '@remirror/extension-image@3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0)': - dependencies: - '@babel/runtime': 7.27.0 - '@remirror/core': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/messages': 3.0.0(@remirror/pm@3.0.1) - '@remirror/pm': 3.0.1 - '@remirror/theme': 3.0.0(@remirror/pm@3.0.1) - prosemirror-resizable-view: 3.0.0(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - transitivePeerDependencies: - - '@types/node' - - jsdom - - supports-color - - '@remirror/extension-italic@3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0)': - dependencies: - '@babel/runtime': 7.27.0 - '@remirror/core': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/messages': 3.0.0(@remirror/pm@3.0.1) - '@remirror/pm': 3.0.1 - transitivePeerDependencies: - - '@types/node' - - jsdom - - '@remirror/extension-link@3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0)': - dependencies: - '@babel/runtime': 7.27.0 - '@remirror/core': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/extension-events': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/messages': 3.0.0(@remirror/pm@3.0.1) - '@remirror/pm': 3.0.1 - extract-domain: 2.2.1 - transitivePeerDependencies: - - '@types/node' - - jsdom - - '@remirror/extension-list@3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0)': - dependencies: - '@babel/runtime': 7.27.0 - '@remirror/core': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/extension-events': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/messages': 3.0.0(@remirror/pm@3.0.1) - '@remirror/pm': 3.0.1 - '@remirror/theme': 3.0.0(@remirror/pm@3.0.1) - transitivePeerDependencies: - - '@types/node' - - jsdom - - supports-color - - '@remirror/extension-markdown@3.0.3(@remirror/extension-react-tables@3.0.3(@remirror/pm@3.0.1)(@types/node@17.0.21)(@types/react-dom@18.3.0)(@types/react@18.3.7)(jsdom@26.1.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0)': - dependencies: - '@babel/runtime': 7.27.0 - '@remirror/core': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/messages': 3.0.0(@remirror/pm@3.0.1) - '@remirror/pm': 3.0.1 - '@types/marked': 4.3.2 - '@types/turndown': 5.0.5 - marked: 4.3.0 - turndown: 7.2.0 - turndown-plugin-gfm: 1.0.2 - optionalDependencies: - '@remirror/extension-react-tables': 3.0.3(@remirror/pm@3.0.1)(@types/node@17.0.21)(@types/react-dom@18.3.0)(@types/react@18.3.7)(jsdom@26.1.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) - transitivePeerDependencies: - - '@types/node' - - jsdom - - '@remirror/extension-mention-atom@3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0)': - dependencies: - '@babel/runtime': 7.27.0 - '@remirror/core': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/extension-events': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/messages': 3.0.0(@remirror/pm@3.0.1) - '@remirror/pm': 3.0.1 - '@remirror/theme': 3.0.0(@remirror/pm@3.0.1) - transitivePeerDependencies: - - '@types/node' - - jsdom - - supports-color - - '@remirror/extension-mention@3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0)': - dependencies: - '@babel/runtime': 7.27.0 - '@remirror/core': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/extension-events': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/messages': 3.0.0(@remirror/pm@3.0.1) - '@remirror/pm': 3.0.1 - escape-string-regexp: 4.0.0 - transitivePeerDependencies: - - '@types/node' - - jsdom - - '@remirror/extension-node-formatting@3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0)': - dependencies: - '@babel/runtime': 7.27.0 - '@remirror/core': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/messages': 3.0.0(@remirror/pm@3.0.1) - '@remirror/pm': 3.0.1 - transitivePeerDependencies: - - '@types/node' - - jsdom - - '@remirror/extension-paragraph@3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0)': - dependencies: - '@babel/runtime': 7.27.0 - '@remirror/core': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/messages': 3.0.0(@remirror/pm@3.0.1) - '@remirror/pm': 3.0.1 - transitivePeerDependencies: - - '@types/node' - - jsdom - - '@remirror/extension-placeholder@3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0)': - dependencies: - '@babel/runtime': 7.27.0 - '@remirror/core': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/messages': 3.0.0(@remirror/pm@3.0.1) - '@remirror/pm': 3.0.1 - '@remirror/theme': 3.0.0(@remirror/pm@3.0.1) - transitivePeerDependencies: - - '@types/node' - - jsdom - - supports-color - - '@remirror/extension-positioner@3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0)': - dependencies: - '@babel/runtime': 7.27.0 - '@remirror/core': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/extension-events': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/messages': 3.0.0(@remirror/pm@3.0.1) - '@remirror/pm': 3.0.1 - '@remirror/theme': 3.0.0(@remirror/pm@3.0.1) - nanoevents: 5.1.13 - transitivePeerDependencies: - - '@types/node' - - jsdom - - supports-color - - '@remirror/extension-react-component@3.0.3(@remirror/pm@3.0.1)(@types/node@17.0.21)(@types/react-dom@18.3.0)(@types/react@18.3.7)(jsdom@26.1.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': - dependencies: - '@babel/runtime': 7.29.2 - '@remirror/core': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/core-constants': 3.0.0 - '@remirror/core-helpers': 4.0.0 - '@remirror/core-types': 3.0.0(@remirror/pm@3.0.1) - '@remirror/core-utils': 3.0.0(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/messages': 3.0.0(@remirror/pm@3.0.1) - '@remirror/pm': 3.0.1 - nanoevents: 5.1.13 - react: 19.2.0 - react-dom: 19.2.0(react@19.2.0) - optionalDependencies: - '@types/react': 18.3.7 - '@types/react-dom': 18.3.0 - transitivePeerDependencies: - - '@types/node' - - jsdom - optional: true - - '@remirror/extension-react-tables@3.0.3(@remirror/pm@3.0.1)(@types/node@17.0.21)(@types/react-dom@18.3.0)(@types/react@18.3.7)(jsdom@26.1.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': - dependencies: - '@babel/runtime': 7.29.2 - '@emotion/css': 11.13.5 - '@linaria/core': 4.2.10 - '@remirror/core': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/core-utils': 3.0.0(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/extension-positioner': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/extension-tables': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/icons': 3.0.0 - '@remirror/messages': 3.0.0(@remirror/pm@3.0.1) - '@remirror/pm': 3.0.1 - '@remirror/react-components': 3.0.3(@remirror/pm@3.0.1)(@types/node@17.0.21)(@types/react-dom@18.3.0)(@types/react@18.3.7)(jsdom@26.1.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) - '@remirror/react-core': 3.0.3(@remirror/pm@3.0.1)(@types/node@17.0.21)(@types/react-dom@18.3.0)(@types/react@18.3.7)(jsdom@26.1.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) - '@remirror/react-hooks': 3.0.3(@remirror/pm@3.0.1)(@types/node@17.0.21)(@types/react-dom@18.3.0)(@types/react@18.3.7)(jsdom@26.1.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) - '@remirror/theme': 3.0.0(@remirror/pm@3.0.1) - optionalDependencies: - react: 19.2.0 - react-dom: 19.2.0(react@19.2.0) - transitivePeerDependencies: - - '@types/node' - - '@types/react' - - '@types/react-dom' - - jsdom - - supports-color - optional: true - - '@remirror/extension-shortcuts@3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0)': - dependencies: - '@babel/runtime': 7.27.0 - '@remirror/core': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/pm': 3.0.1 - transitivePeerDependencies: - - '@types/node' - - jsdom - - '@remirror/extension-strike@3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0)': - dependencies: - '@babel/runtime': 7.27.0 - '@remirror/core': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/messages': 3.0.0(@remirror/pm@3.0.1) - '@remirror/pm': 3.0.1 - transitivePeerDependencies: - - '@types/node' - - jsdom + '@types/react': 18.3.7 + '@types/react-dom': 18.3.0 - '@remirror/extension-sub@3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0)': + '@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@18.3.0)(@types/react@18.3.7)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': dependencies: - '@babel/runtime': 7.27.0 - '@remirror/core': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/messages': 3.0.0(@remirror/pm@3.0.1) - '@remirror/pm': 3.0.1 - transitivePeerDependencies: - - '@types/node' - - jsdom + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.0)(@types/react@18.3.7)(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) + optionalDependencies: + '@types/react': 18.3.7 + '@types/react-dom': 18.3.0 - '@remirror/extension-sup@3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0)': - dependencies: - '@babel/runtime': 7.27.0 - '@remirror/core': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/messages': 3.0.0(@remirror/pm@3.0.1) - '@remirror/pm': 3.0.1 - transitivePeerDependencies: - - '@types/node' - - jsdom + '@radix-ui/rect@1.1.1': {} - '@remirror/extension-tables@3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0)': + '@react-email/body@0.2.0(react@19.2.0)': dependencies: - '@babel/runtime': 7.27.0 - '@remirror/core': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/extension-events': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/extension-positioner': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/messages': 3.0.0(@remirror/pm@3.0.1) - '@remirror/pm': 3.0.1 - '@remirror/theme': 3.0.0(@remirror/pm@3.0.1) - transitivePeerDependencies: - - '@types/node' - - jsdom - - supports-color + react: 19.2.0 - '@remirror/extension-text-case@3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0)': + '@react-email/button@0.2.0(react@19.2.0)': dependencies: - '@babel/runtime': 7.27.0 - '@remirror/core': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/messages': 3.0.0(@remirror/pm@3.0.1) - '@remirror/pm': 3.0.1 - transitivePeerDependencies: - - '@types/node' - - jsdom + react: 19.2.0 - '@remirror/extension-text-color@3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0)': + '@react-email/code-block@0.2.0(react@19.2.0)': dependencies: - '@babel/runtime': 7.27.0 - '@remirror/core': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/messages': 3.0.0(@remirror/pm@3.0.1) - '@remirror/pm': 3.0.1 - '@remirror/theme': 3.0.0(@remirror/pm@3.0.1) - color2k: 2.0.3 - transitivePeerDependencies: - - '@types/node' - - jsdom - - supports-color + prismjs: 1.30.0 + react: 19.2.0 - '@remirror/extension-text-highlight@3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0)': + '@react-email/code-inline@0.0.5(react@19.2.0)': dependencies: - '@babel/runtime': 7.27.0 - '@remirror/core': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/extension-text-color': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/messages': 3.0.0(@remirror/pm@3.0.1) - '@remirror/pm': 3.0.1 - transitivePeerDependencies: - - '@types/node' - - jsdom - - supports-color + react: 19.2.0 - '@remirror/extension-text@3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0)': + '@react-email/column@0.0.13(react@19.2.0)': dependencies: - '@babel/runtime': 7.27.0 - '@remirror/core': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/messages': 3.0.0(@remirror/pm@3.0.1) - '@remirror/pm': 3.0.1 - transitivePeerDependencies: - - '@types/node' - - jsdom + react: 19.2.0 - '@remirror/extension-trailing-node@3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0)': + '@react-email/components@1.0.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': dependencies: - '@babel/runtime': 7.27.0 - '@remirror/core': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/messages': 3.0.0(@remirror/pm@3.0.1) - '@remirror/pm': 3.0.1 + '@react-email/body': 0.2.0(react@19.2.0) + '@react-email/button': 0.2.0(react@19.2.0) + '@react-email/code-block': 0.2.0(react@19.2.0) + '@react-email/code-inline': 0.0.5(react@19.2.0) + '@react-email/column': 0.0.13(react@19.2.0) + '@react-email/container': 0.0.15(react@19.2.0) + '@react-email/font': 0.0.9(react@19.2.0) + '@react-email/head': 0.0.12(react@19.2.0) + '@react-email/heading': 0.0.15(react@19.2.0) + '@react-email/hr': 0.0.11(react@19.2.0) + '@react-email/html': 0.0.11(react@19.2.0) + '@react-email/img': 0.0.11(react@19.2.0) + '@react-email/link': 0.0.12(react@19.2.0) + '@react-email/markdown': 0.0.17(react@19.2.0) + '@react-email/preview': 0.0.13(react@19.2.0) + '@react-email/render': 2.0.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@react-email/row': 0.0.12(react@19.2.0) + '@react-email/section': 0.0.16(react@19.2.0) + '@react-email/tailwind': 2.0.1(@react-email/body@0.2.0(react@19.2.0))(@react-email/button@0.2.0(react@19.2.0))(@react-email/code-block@0.2.0(react@19.2.0))(@react-email/code-inline@0.0.5(react@19.2.0))(@react-email/container@0.0.15(react@19.2.0))(@react-email/heading@0.0.15(react@19.2.0))(@react-email/hr@0.0.11(react@19.2.0))(@react-email/img@0.0.11(react@19.2.0))(@react-email/link@0.0.12(react@19.2.0))(@react-email/preview@0.0.13(react@19.2.0))(@react-email/text@0.1.5(react@19.2.0))(react@19.2.0) + '@react-email/text': 0.1.5(react@19.2.0) + react: 19.2.0 transitivePeerDependencies: - - '@types/node' - - jsdom + - react-dom - '@remirror/extension-underline@3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0)': + '@react-email/container@0.0.15(react@19.2.0)': dependencies: - '@babel/runtime': 7.27.0 - '@remirror/core': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/messages': 3.0.0(@remirror/pm@3.0.1) - '@remirror/pm': 3.0.1 - transitivePeerDependencies: - - '@types/node' - - jsdom + react: 19.2.0 - '@remirror/extension-whitespace@3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0)': + '@react-email/font@0.0.9(react@19.2.0)': dependencies: - '@babel/runtime': 7.27.0 - '@remirror/core': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/messages': 3.0.0(@remirror/pm@3.0.1) - '@remirror/pm': 3.0.1 - transitivePeerDependencies: - - '@types/node' - - jsdom + react: 19.2.0 - '@remirror/icons@3.0.0': + '@react-email/head@0.0.12(react@19.2.0)': dependencies: - '@babel/runtime': 7.27.0 - '@remirror/core-helpers': 4.0.0 + react: 19.2.0 - '@remirror/messages@3.0.0(@remirror/pm@3.0.1)': + '@react-email/heading@0.0.15(react@19.2.0)': dependencies: - '@babel/runtime': 7.27.0 - '@remirror/core-helpers': 4.0.0 - '@remirror/core-types': 3.0.0(@remirror/pm@3.0.1) - transitivePeerDependencies: - - '@remirror/pm' + react: 19.2.0 - '@remirror/pm@3.0.1': + '@react-email/hr@0.0.11(react@19.2.0)': dependencies: - '@babel/runtime': 7.29.2 - '@remirror/core-constants': 3.0.0 - '@remirror/core-helpers': 4.0.0 - prosemirror-collab: 1.3.1 - prosemirror-commands: 1.7.1 - prosemirror-dropcursor: 1.8.2 - prosemirror-gapcursor: 1.4.1 - prosemirror-history: 1.5.0 - prosemirror-inputrules: 1.5.1 - prosemirror-keymap: 1.2.3 - prosemirror-model: 1.25.4 - prosemirror-paste-rules: 3.0.0(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.7) - prosemirror-schema-list: 1.5.1 - prosemirror-state: 1.4.4 - prosemirror-suggest: 3.0.0(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.7) - prosemirror-tables: 1.8.5 - prosemirror-trailing-node: 3.0.0(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.7) - prosemirror-transform: 1.12.0 - prosemirror-view: 1.41.7 + react: 19.2.0 - '@remirror/preset-core@3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0)': + '@react-email/html@0.0.11(react@19.2.0)': dependencies: - '@babel/runtime': 7.27.0 - '@remirror/core': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/extension-doc': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/extension-events': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/extension-gap-cursor': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/extension-history': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/extension-paragraph': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/extension-positioner': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/extension-text': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/pm': 3.0.1 - transitivePeerDependencies: - - '@types/node' - - jsdom - - supports-color + react: 19.2.0 - '@remirror/preset-formatting@3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0)': + '@react-email/img@0.0.11(react@19.2.0)': dependencies: - '@babel/runtime': 7.27.0 - '@remirror/core': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/extension-bold': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/extension-columns': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/extension-font-size': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/extension-heading': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/extension-italic': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/extension-node-formatting': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/extension-strike': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/extension-sub': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/extension-sup': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/extension-text-case': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/extension-text-color': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/extension-text-highlight': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/extension-underline': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/extension-whitespace': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/pm': 3.0.1 - transitivePeerDependencies: - - '@types/node' - - jsdom - - supports-color + react: 19.2.0 - '@remirror/preset-react@3.0.3(@remirror/pm@3.0.1)(@types/node@17.0.21)(@types/react-dom@18.3.0)(@types/react@18.3.7)(jsdom@26.1.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + '@react-email/link@0.0.12(react@19.2.0)': dependencies: - '@babel/runtime': 7.29.2 - '@remirror/core': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/extension-placeholder': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/extension-react-component': 3.0.3(@remirror/pm@3.0.1)(@types/node@17.0.21)(@types/react-dom@18.3.0)(@types/react@18.3.7)(jsdom@26.1.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) - '@remirror/pm': 3.0.1 - '@remirror/react-utils': 3.0.0(@remirror/pm@3.0.1)(@types/react@18.3.7)(react@19.2.0) react: 19.2.0 - react-dom: 19.2.0(react@19.2.0) - optionalDependencies: - '@types/react': 18.3.7 - '@types/react-dom': 18.3.0 - transitivePeerDependencies: - - '@types/node' - - jsdom - - supports-color - optional: true - '@remirror/preset-wysiwyg@3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0)(prettier@3.5.3)': + '@react-email/markdown@0.0.17(react@19.2.0)': dependencies: - '@babel/runtime': 7.27.0 - '@remirror/core': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/extension-bidi': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/extension-blockquote': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/extension-bold': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/extension-code': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/extension-code-block': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0)(prettier@3.5.3) - '@remirror/extension-drop-cursor': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/extension-embed': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/extension-find': 1.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/extension-gap-cursor': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/extension-hard-break': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/extension-heading': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/extension-horizontal-rule': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/extension-image': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/extension-italic': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/extension-link': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/extension-list': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/extension-shortcuts': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/extension-strike': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/extension-trailing-node': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/extension-underline': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/pm': 3.0.1 - '@remirror/preset-core': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - transitivePeerDependencies: - - '@types/node' - - jsdom - - prettier - - supports-color + marked: 15.0.12 + react: 19.2.0 - '@remirror/react-components@3.0.3(@remirror/pm@3.0.1)(@types/node@17.0.21)(@types/react-dom@18.3.0)(@types/react@18.3.7)(jsdom@26.1.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + '@react-email/preview@0.0.13(react@19.2.0)': dependencies: - '@babel/runtime': 7.29.2 - '@floating-ui/react': 0.24.8(react-dom@19.2.0(react@19.2.0))(react@19.2.0) - '@remirror/core': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/extension-positioner': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/icons': 3.0.0 - '@remirror/messages': 3.0.0(@remirror/pm@3.0.1) - '@remirror/pm': 3.0.1 - '@remirror/react-core': 3.0.3(@remirror/pm@3.0.1)(@types/node@17.0.21)(@types/react-dom@18.3.0)(@types/react@18.3.7)(jsdom@26.1.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) - '@remirror/react-hooks': 3.0.3(@remirror/pm@3.0.1)(@types/node@17.0.21)(@types/react-dom@18.3.0)(@types/react@18.3.7)(jsdom@26.1.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) - '@remirror/react-utils': 3.0.0(@remirror/pm@3.0.1)(@types/react@18.3.7)(react@19.2.0) - '@remirror/theme': 3.0.0(@remirror/pm@3.0.1) - '@seznam/compose-react-refs': 1.0.6 - '@types/react-color': 3.0.13(@types/react@18.3.7) - create-context-state: 2.0.3(@types/react@18.3.7)(react@19.2.0) - match-sorter: 6.3.4 - multishift: 2.0.10(@remirror/pm@3.0.1)(@types/react@18.3.7)(react@19.2.0) react: 19.2.0 - react-color: 2.19.3(react@19.2.0) - react-dom: 19.2.0(react@19.2.0) - optionalDependencies: - '@types/react': 18.3.7 - '@types/react-dom': 18.3.0 - transitivePeerDependencies: - - '@types/node' - - jsdom - - supports-color - optional: true - '@remirror/react-core@3.0.3(@remirror/pm@3.0.1)(@types/node@17.0.21)(@types/react-dom@18.3.0)(@types/react@18.3.7)(jsdom@26.1.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + '@react-email/render@2.0.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': dependencies: - '@babel/runtime': 7.29.2 - '@remirror/core': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/extension-positioner': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/extension-react-component': 3.0.3(@remirror/pm@3.0.1)(@types/node@17.0.21)(@types/react-dom@18.3.0)(@types/react@18.3.7)(jsdom@26.1.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) - '@remirror/pm': 3.0.1 - '@remirror/preset-core': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/preset-react': 3.0.3(@remirror/pm@3.0.1)(@types/node@17.0.21)(@types/react-dom@18.3.0)(@types/react@18.3.7)(jsdom@26.1.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) - '@remirror/react-renderer': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(@types/react@18.3.7)(jsdom@26.1.0)(react@19.2.0) - '@remirror/react-utils': 3.0.0(@remirror/pm@3.0.1)(@types/react@18.3.7)(react@19.2.0) - '@remirror/theme': 3.0.0(@remirror/pm@3.0.1) - '@seznam/compose-react-refs': 1.0.6 - fast-deep-equal: 3.1.3 + html-to-text: 9.0.5 + prettier: 3.5.3 react: 19.2.0 react-dom: 19.2.0(react@19.2.0) - resize-observer-polyfill: 1.5.1 - tiny-warning: 1.0.3 - optionalDependencies: - '@types/react': 18.3.7 - '@types/react-dom': 18.3.0 - transitivePeerDependencies: - - '@types/node' - - jsdom - - supports-color - optional: true - '@remirror/react-hooks@3.0.3(@remirror/pm@3.0.1)(@types/node@17.0.21)(@types/react-dom@18.3.0)(@types/react@18.3.7)(jsdom@26.1.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + '@react-email/row@0.0.12(react@19.2.0)': dependencies: - '@babel/runtime': 7.29.2 - '@remirror/core': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/core-constants': 3.0.0 - '@remirror/core-helpers': 4.0.0 - '@remirror/extension-emoji': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/extension-events': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/extension-history': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/extension-mention': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/extension-mention-atom': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/extension-positioner': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/pm': 3.0.1 - '@remirror/react-core': 3.0.3(@remirror/pm@3.0.1)(@types/node@17.0.21)(@types/react-dom@18.3.0)(@types/react@18.3.7)(jsdom@26.1.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) - '@remirror/react-utils': 3.0.0(@remirror/pm@3.0.1)(@types/react@18.3.7)(react@19.2.0) - multishift: 2.0.10(@remirror/pm@3.0.1)(@types/react@18.3.7)(react@19.2.0) - use-isomorphic-layout-effect: 1.2.1(@types/react@18.3.7)(react@19.2.0) - use-previous: 1.2.0(@types/react@18.3.7)(react@19.2.0) - optionalDependencies: - '@types/react': 18.3.7 - '@types/react-dom': 18.3.0 react: 19.2.0 - react-dom: 19.2.0(react@19.2.0) - transitivePeerDependencies: - - '@types/node' - - jsdom - - supports-color - optional: true - '@remirror/react-renderer@3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(@types/react@18.3.7)(jsdom@26.1.0)(react@19.2.0)': + '@react-email/section@0.0.16(react@19.2.0)': dependencies: - '@babel/runtime': 7.29.2 - '@remirror/core': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) react: 19.2.0 - optionalDependencies: - '@types/react': 18.3.7 - transitivePeerDependencies: - - '@remirror/pm' - - '@types/node' - - jsdom - optional: true - '@remirror/react-utils@3.0.0(@remirror/pm@3.0.1)(@types/react@18.3.7)(react@19.2.0)': + '@react-email/tailwind@2.0.1(@react-email/body@0.2.0(react@19.2.0))(@react-email/button@0.2.0(react@19.2.0))(@react-email/code-block@0.2.0(react@19.2.0))(@react-email/code-inline@0.0.5(react@19.2.0))(@react-email/container@0.0.15(react@19.2.0))(@react-email/heading@0.0.15(react@19.2.0))(@react-email/hr@0.0.11(react@19.2.0))(@react-email/img@0.0.11(react@19.2.0))(@react-email/link@0.0.12(react@19.2.0))(@react-email/preview@0.0.13(react@19.2.0))(@react-email/text@0.1.5(react@19.2.0))(react@19.2.0)': dependencies: - '@babel/runtime': 7.29.2 - '@remirror/core-constants': 3.0.0 - '@remirror/core-helpers': 4.0.0 - '@remirror/core-types': 3.0.0(@remirror/pm@3.0.1) + '@react-email/text': 0.1.5(react@19.2.0) react: 19.2.0 + tailwindcss: 4.1.17 optionalDependencies: - '@types/react': 18.3.7 - transitivePeerDependencies: - - '@remirror/pm' - optional: true + '@react-email/body': 0.2.0(react@19.2.0) + '@react-email/button': 0.2.0(react@19.2.0) + '@react-email/code-block': 0.2.0(react@19.2.0) + '@react-email/code-inline': 0.0.5(react@19.2.0) + '@react-email/container': 0.0.15(react@19.2.0) + '@react-email/heading': 0.0.15(react@19.2.0) + '@react-email/hr': 0.0.11(react@19.2.0) + '@react-email/img': 0.0.11(react@19.2.0) + '@react-email/link': 0.0.12(react@19.2.0) + '@react-email/preview': 0.0.13(react@19.2.0) - '@remirror/theme@3.0.0(@remirror/pm@3.0.1)': + '@react-email/text@0.1.5(react@19.2.0)': dependencies: - '@babel/runtime': 7.27.0 - '@linaria/core': 4.2.10 - '@remirror/core-types': 3.0.0(@remirror/pm@3.0.1) - color2k: 2.0.3 - csstype: 3.2.3 - transitivePeerDependencies: - - '@remirror/pm' - - supports-color + react: 19.2.0 - '@remirror/types@2.0.0': - dependencies: - type-fest: 3.13.1 + '@remirror/core-constants@3.0.0': {} '@rollup/rollup-android-arm-eabi@4.40.0': optional: true @@ -18955,14 +16617,11 @@ snapshots: domhandler: 5.0.3 selderee: 0.11.0 - '@seznam/compose-react-refs@1.0.6': - optional: true - - '@shelf/jest-mongodb@5.2.2(@aws-sdk/credential-providers@3.797.0)(jest-environment-node@29.7.0)(mongodb@6.16.0(@aws-sdk/credential-providers@3.797.0)(socks@2.8.4))(socks@2.8.4)': + '@shelf/jest-mongodb@5.2.2(@aws-sdk/credential-providers@3.797.0)(jest-environment-node@29.7.0)(mongodb@6.21.0(@aws-sdk/credential-providers@3.797.0)(socks@2.8.4))(socks@2.8.4)': dependencies: debug: 4.4.1 jest-environment-node: 29.7.0 - mongodb: 6.16.0(@aws-sdk/credential-providers@3.797.0)(socks@2.8.4) + mongodb: 6.21.0(@aws-sdk/credential-providers@3.797.0)(socks@2.8.4) mongodb-memory-server: 10.1.4(@aws-sdk/credential-providers@3.797.0)(socks@2.8.4) transitivePeerDependencies: - '@aws-sdk/credential-providers' @@ -19360,35 +17019,6 @@ snapshots: '@stripe/stripe-js@5.10.0': {} - '@svgmoji/blob@3.2.0': - dependencies: - '@babel/runtime': 7.27.0 - '@svgmoji/core': 3.2.0 - - '@svgmoji/core@3.2.0': - dependencies: - '@babel/runtime': 7.27.0 - emojibase: 5.2.0 - emojibase-regex: 5.1.3 - idb-keyval: 5.1.5 - match-sorter: 6.3.4 - type-fest: 1.4.0 - - '@svgmoji/noto@3.2.0': - dependencies: - '@babel/runtime': 7.27.0 - '@svgmoji/core': 3.2.0 - - '@svgmoji/openmoji@3.2.0': - dependencies: - '@babel/runtime': 7.27.0 - '@svgmoji/core': 3.2.0 - - '@svgmoji/twemoji@3.2.0': - dependencies: - '@babel/runtime': 7.27.0 - '@svgmoji/core': 3.2.0 - '@swagger-api/apidom-ast@1.10.1': dependencies: '@babel/runtime-corejs3': 7.29.2 @@ -19780,7 +17410,7 @@ snapshots: '@swagger-api/apidom-core': 1.10.1 '@swagger-api/apidom-error': 1.10.1 '@types/ramda': 0.30.2 - axios: 1.15.0 + axios: 1.16.0 minimatch: 10.2.5 ramda: 0.30.1 ramda-adjunct: 5.1.0(ramda@0.30.1) @@ -19912,7 +17542,7 @@ snapshots: chalk: 3.0.0 css.escape: 1.5.1 dom-accessibility-api: 0.6.3 - lodash: 4.17.21 + lodash: 4.18.1 redent: 3.0.0 '@testing-library/react@16.3.0(@testing-library/dom@10.4.0)(@types/react-dom@18.3.0)(@types/react@18.3.7)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': @@ -20241,8 +17871,6 @@ snapshots: dependencies: '@types/ms': 2.1.0 - '@types/direction@1.0.0': {} - '@types/estree-jsx@0.0.1': dependencies: '@types/estree': 1.0.8 @@ -20330,8 +17958,6 @@ snapshots: '@types/linkify-it': 5.0.0 '@types/mdurl': 2.0.0 - '@types/marked@4.3.2': {} - '@types/mdast@3.0.15': dependencies: '@types/unist': 2.0.11 @@ -20346,11 +17972,9 @@ snapshots: '@types/mime@1.3.5': {} - '@types/min-document@2.19.2': {} - '@types/mongodb@4.0.7(@aws-sdk/credential-providers@3.797.0)(socks@2.8.4)': dependencies: - mongodb: 6.16.0(@aws-sdk/credential-providers@3.797.0)(socks@2.8.4) + mongodb: 6.21.0(@aws-sdk/credential-providers@3.797.0)(socks@2.8.4) transitivePeerDependencies: - '@aws-sdk/credential-providers' - '@mongodb-js/zstd' @@ -20394,10 +18018,6 @@ snapshots: dependencies: '@types/node': 20.19.0 - '@types/object.omit@3.0.3': {} - - '@types/object.pick@1.3.4': {} - '@types/parse-json@4.0.2': optional: true @@ -20411,20 +18031,12 @@ snapshots: '@types/qs@6.9.18': {} - '@types/querystringify@2.0.2': {} - '@types/ramda@0.30.2': dependencies: types-ramda: 0.30.1 '@types/range-parser@1.2.7': {} - '@types/react-color@3.0.13(@types/react@18.3.7)': - dependencies: - '@types/react': 18.3.7 - '@types/reactcss': 1.2.13(@types/react@18.3.7) - optional: true - '@types/react-dom@18.3.0': dependencies: '@types/react': 18.3.7 @@ -20434,15 +18046,6 @@ snapshots: '@types/prop-types': 15.7.14 csstype: 3.2.3 - '@types/reactcss@1.2.13(@types/react@18.3.7)': - dependencies: - '@types/react': 18.3.7 - optional: true - - '@types/refractor@3.4.1': - dependencies: - '@types/prismjs': 1.26.5 - '@types/resolve@1.20.6': {} '@types/send@0.17.4': @@ -20458,17 +18061,11 @@ snapshots: '@types/stack-utils@2.0.3': {} - '@types/string.prototype.matchall@4.0.4': {} - - '@types/throttle-debounce@2.1.0': {} - '@types/tough-cookie@4.0.5': {} '@types/trusted-types@2.0.7': optional: true - '@types/turndown@5.0.5': {} - '@types/unist@2.0.11': {} '@types/unist@3.0.3': {} @@ -20678,7 +18275,7 @@ snapshots: dependencies: '@typescript-eslint/tsconfig-utils': 8.46.4(typescript@4.9.5) '@typescript-eslint/types': 8.46.4 - debug: 4.4.1 + debug: 4.4.3 typescript: 4.9.5 transitivePeerDependencies: - supports-color @@ -20687,7 +18284,7 @@ snapshots: dependencies: '@typescript-eslint/tsconfig-utils': 8.46.4(typescript@5.9.3) '@typescript-eslint/types': 8.46.4 - debug: 4.4.1 + debug: 4.4.3 typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -20714,7 +18311,7 @@ snapshots: dependencies: '@typescript-eslint/typescript-estree': 7.18.0(typescript@4.9.5) '@typescript-eslint/utils': 7.18.0(eslint@8.57.1)(typescript@4.9.5) - debug: 4.4.1 + debug: 4.4.3 eslint: 8.57.1 ts-api-utils: 1.4.3(typescript@4.9.5) optionalDependencies: @@ -20726,7 +18323,7 @@ snapshots: dependencies: '@typescript-eslint/typescript-estree': 7.18.0(typescript@5.9.3) '@typescript-eslint/utils': 7.18.0(eslint@8.57.1)(typescript@5.9.3) - debug: 4.4.1 + debug: 4.4.3 eslint: 8.57.1 ts-api-utils: 1.4.3(typescript@5.9.3) optionalDependencies: @@ -20790,7 +18387,7 @@ snapshots: dependencies: '@typescript-eslint/types': 7.18.0 '@typescript-eslint/visitor-keys': 7.18.0 - debug: 4.4.1 + debug: 4.4.3 globby: 11.1.0 is-glob: 4.0.3 minimatch: 9.0.5 @@ -20805,7 +18402,7 @@ snapshots: dependencies: '@typescript-eslint/types': 7.18.0 '@typescript-eslint/visitor-keys': 7.18.0 - debug: 4.4.1 + debug: 4.4.3 globby: 11.1.0 is-glob: 4.0.3 minimatch: 9.0.5 @@ -20991,14 +18588,7 @@ snapshots: '@xmldom/is-dom-node@1.0.1': {} - '@xmldom/xmldom@0.8.12': {} - - a11y-status@2.0.2: - dependencies: - '@babel/runtime': 7.29.2 - '@types/throttle-debounce': 2.1.0 - throttle-debounce: 3.0.1 - optional: true + '@xmldom/xmldom@0.8.13': {} abab@2.0.6: {} @@ -21042,7 +18632,7 @@ snapshots: agent-base@6.0.2: dependencies: - debug: 4.4.1 + debug: 4.4.3 transitivePeerDependencies: - supports-color @@ -21065,7 +18655,7 @@ snapshots: ajv@8.18.0: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 3.1.0 + fast-uri: 3.1.2 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 @@ -21378,22 +18968,14 @@ snapshots: axe-core@4.10.3: {} - axios@1.15.0: + axios@1.16.0: dependencies: - follow-redirects: 1.15.11 + follow-redirects: 1.16.0 form-data: 4.0.5 proxy-from-env: 2.1.0 transitivePeerDependencies: - debug - axios@1.9.0: - dependencies: - follow-redirects: 1.15.9(debug@4.4.1) - form-data: 4.0.2 - proxy-from-env: 1.1.0 - transitivePeerDependencies: - - debug - axobject-query@4.1.0: {} b4a@1.6.7: {} @@ -21409,13 +18991,7 @@ snapshots: graceful-fs: 4.2.11 slash: 3.0.0 transitivePeerDependencies: - - supports-color - - babel-merge@3.0.0(@babel/core@7.26.10): - dependencies: - '@babel/core': 7.26.10 - deepmerge: 2.2.1 - object.omit: 3.0.0 + - supports-color babel-plugin-istanbul@6.1.1: dependencies: @@ -21493,15 +19069,15 @@ snapshots: baseline-browser-mapping@2.9.19: {} - better-auth@1.5.6(@opentelemetry/api@1.9.0)(mongodb@6.16.0(@aws-sdk/credential-providers@3.797.0)(socks@2.8.4))(next@16.1.6(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(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.5.6(@opentelemetry/api@1.9.0)(mongodb@6.21.0(@aws-sdk/credential-providers@3.797.0)(socks@2.8.4))(next@16.1.6(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(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.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@3.24.3))(jose@6.2.2)(kysely@0.28.15)(nanostores@1.2.0) - '@better-auth/drizzle-adapter': 1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@3.24.3))(jose@6.2.2)(kysely@0.28.15)(nanostores@1.2.0))(@better-auth/utils@0.3.1) - '@better-auth/kysely-adapter': 1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@3.24.3))(jose@6.2.2)(kysely@0.28.15)(nanostores@1.2.0))(@better-auth/utils@0.3.1)(kysely@0.28.15) - '@better-auth/memory-adapter': 1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@3.24.3))(jose@6.2.2)(kysely@0.28.15)(nanostores@1.2.0))(@better-auth/utils@0.3.1) - '@better-auth/mongo-adapter': 1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@3.24.3))(jose@6.2.2)(kysely@0.28.15)(nanostores@1.2.0))(@better-auth/utils@0.3.1)(mongodb@6.16.0(@aws-sdk/credential-providers@3.797.0)(socks@2.8.4)) - '@better-auth/prisma-adapter': 1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@3.24.3))(jose@6.2.2)(kysely@0.28.15)(nanostores@1.2.0))(@better-auth/utils@0.3.1) - '@better-auth/telemetry': 1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@3.24.3))(jose@6.2.2)(kysely@0.28.15)(nanostores@1.2.0)) + '@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@3.24.3))(jose@6.2.2)(kysely@0.28.17)(nanostores@1.2.0) + '@better-auth/drizzle-adapter': 1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@3.24.3))(jose@6.2.2)(kysely@0.28.17)(nanostores@1.2.0))(@better-auth/utils@0.3.1) + '@better-auth/kysely-adapter': 1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@3.24.3))(jose@6.2.2)(kysely@0.28.17)(nanostores@1.2.0))(@better-auth/utils@0.3.1)(kysely@0.28.17) + '@better-auth/memory-adapter': 1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@3.24.3))(jose@6.2.2)(kysely@0.28.17)(nanostores@1.2.0))(@better-auth/utils@0.3.1) + '@better-auth/mongo-adapter': 1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@3.24.3))(jose@6.2.2)(kysely@0.28.17)(nanostores@1.2.0))(@better-auth/utils@0.3.1)(mongodb@6.21.0(@aws-sdk/credential-providers@3.797.0)(socks@2.8.4)) + '@better-auth/prisma-adapter': 1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@3.24.3))(jose@6.2.2)(kysely@0.28.17)(nanostores@1.2.0))(@better-auth/utils@0.3.1) + '@better-auth/telemetry': 1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@3.24.3))(jose@6.2.2)(kysely@0.28.17)(nanostores@1.2.0)) '@better-auth/utils': 0.3.1 '@better-fetch/fetch': 1.1.21 '@noble/ciphers': 2.1.1 @@ -21509,11 +19085,11 @@ snapshots: better-call: 1.3.2(zod@4.3.6) defu: 6.1.6 jose: 6.2.2 - kysely: 0.28.15 + kysely: 0.28.17 nanostores: 1.2.0 zod: 4.3.6 optionalDependencies: - mongodb: 6.16.0(@aws-sdk/credential-providers@3.797.0)(socks@2.8.4) + mongodb: 6.21.0(@aws-sdk/credential-providers@3.797.0)(socks@2.8.4) next: 16.1.6(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(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) @@ -21631,7 +19207,7 @@ snapshots: dependencies: buffer: 5.7.1 - bson@6.10.3: {} + bson@6.10.4: {} buffer-crc32@0.2.13: {} @@ -21658,7 +19234,7 @@ snapshots: cron-parser: 4.9.0 glob: 8.1.0 ioredis: 5.6.1 - lodash: 4.17.21 + lodash: 4.18.1 msgpackr: 1.11.2 node-abort-controller: 3.1.1 semver: 7.7.1 @@ -21712,8 +19288,6 @@ snapshots: carrier@0.3.0: {} - case-anything@2.1.13: {} - ccount@2.0.1: {} chalk@3.0.0: @@ -21732,20 +19306,14 @@ snapshots: character-entities-html4@2.1.0: {} - character-entities-legacy@1.1.4: {} - character-entities-legacy@3.0.0: {} - character-entities@1.2.4: {} - character-entities@2.0.2: {} character-parser@2.2.0: dependencies: is-regex: 1.2.1 - character-reference-invalid@1.1.4: {} - character-reference-invalid@2.0.1: {} chardet@2.1.1: {} @@ -21846,8 +19414,6 @@ snapshots: color-name: 1.1.4 simple-swizzle: 0.2.2 - color2k@2.0.3: {} - color@4.2.3: dependencies: color-convert: 2.0.1 @@ -21864,8 +19430,6 @@ snapshots: dependencies: delayed-stream: 1.0.0 - comma-separated-tokens@1.0.8: {} - comma-separated-tokens@2.0.3: {} commander@10.0.1: {} @@ -21887,9 +19451,6 @@ snapshots: normalize-path: 3.0.0 readable-stream: 3.6.2 - compute-scroll-into-view@1.0.20: - optional: true - compute-scroll-into-view@3.1.1: {} concat-map@0.0.1: {} @@ -21905,9 +19466,6 @@ snapshots: content-type@1.0.5: {} - convert-source-map@1.9.0: - optional: true - convert-source-map@2.0.0: {} cookie-signature@1.0.6: {} @@ -21942,14 +19500,6 @@ snapshots: crc-32: 1.2.2 readable-stream: 3.6.2 - create-context-state@2.0.3(@types/react@18.3.7)(react@19.2.0): - dependencies: - '@babel/runtime': 7.29.2 - react: 19.2.0 - optionalDependencies: - '@types/react': 18.3.7 - optional: true - create-jest@29.7.0(@types/node@17.0.21)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@17.0.21)(typescript@5.9.3)): dependencies: '@jest/types': 29.6.3 @@ -21994,10 +19544,6 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 - css-in-js-utils@3.1.0: - dependencies: - hyphenate-style-name: 1.1.0 - css.escape@1.5.1: {} cssesc@3.0.0: {} @@ -22061,8 +19607,6 @@ snapshots: damerau-levenshtein@1.0.8: {} - dash-get@1.0.2: {} - data-uri-to-buffer@4.0.1: {} data-urls@3.0.2: @@ -22134,8 +19678,6 @@ snapshots: deepmerge-ts@4.3.0: {} - deepmerge@2.2.1: {} - deepmerge@4.3.1: {} defaults@1.0.4: @@ -22196,8 +19738,6 @@ snapshots: dependencies: path-type: 4.0.0 - direction@1.0.4: {} - dlv@1.1.3: {} doctrine@2.1.0: @@ -22225,8 +19765,6 @@ snapshots: domhandler: 5.0.3 entities: 4.5.0 - dom-walk@0.1.2: {} - domelementtype@2.3.0: {} domexception@4.0.0: @@ -22280,18 +19818,6 @@ snapshots: emoji-regex@9.2.2: {} - emojibase-data@6.2.0(emojibase@6.1.0): - dependencies: - emojibase: 6.1.0 - - emojibase-regex@5.1.3: {} - - emojibase-regex@6.0.1: {} - - emojibase@5.2.0: {} - - emojibase@6.1.0: {} - encodeurl@1.0.2: {} encodeurl@2.0.0: {} @@ -23102,7 +20628,7 @@ snapshots: methods: 1.1.2 on-finished: 2.4.1 parseurl: 1.3.3 - path-to-regexp: 0.1.12 + path-to-regexp: 0.1.13 proxy-addr: 2.0.7 qs: 6.13.0 range-parser: 1.2.1 @@ -23125,8 +20651,6 @@ snapshots: extendable-error@0.1.7: {} - extract-domain@2.2.1: {} - fast-content-type-parse@3.0.0: {} fast-deep-equal@3.1.3: {} @@ -23159,22 +20683,25 @@ snapshots: fast-redact@3.5.0: {} - fast-uri@3.1.0: {} + fast-uri@3.1.2: {} - fast-xml-builder@1.1.4: + fast-xml-builder@1.2.0: dependencies: - path-expression-matcher: 1.2.0 + path-expression-matcher: 1.5.0 + xml-naming: 0.1.0 - fast-xml-parser@4.4.1: + fast-xml-parser@4.5.4: dependencies: strnum: 1.1.2 optional: true - fast-xml-parser@5.5.9: + fast-xml-parser@5.8.0: dependencies: - fast-xml-builder: 1.1.4 - path-expression-matcher: 1.2.0 - strnum: 2.2.2 + '@nodable/entities': 2.1.0 + fast-xml-builder: 1.2.0 + path-expression-matcher: 1.5.0 + strnum: 2.3.0 + xml-naming: 0.1.0 fastq@1.19.1: dependencies: @@ -23240,9 +20767,6 @@ snapshots: make-dir: 3.1.0 pkg-dir: 4.2.0 - find-root@1.1.0: - optional: true - find-up@3.0.0: dependencies: locate-path: 3.0.0 @@ -23275,12 +20799,12 @@ snapshots: flatted@3.3.3: {} - follow-redirects@1.15.11: {} - follow-redirects@1.15.9(debug@4.4.1): optionalDependencies: debug: 4.4.1 + follow-redirects@1.16.0: {} + for-each@0.3.5: dependencies: is-callable: 1.2.7 @@ -23292,13 +20816,6 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 - form-data@4.0.2: - dependencies: - asynckit: 0.4.0 - combined-stream: 1.0.8 - es-set-tostringtag: 2.1.0 - mime-types: 2.1.35 - form-data@4.0.5: dependencies: asynckit: 0.4.0 @@ -23508,10 +21025,6 @@ snapshots: get-caller-file@2.0.5: {} - get-dom-document@0.1.3(jsdom@26.1.0): - optionalDependencies: - jsdom: 26.1.0 - get-east-asian-width@1.3.0: {} get-intrinsic@1.3.0: @@ -23705,8 +21218,6 @@ snapshots: vfile-location: 5.0.3 web-namespaces: 2.0.1 - hast-util-parse-selector@2.2.5: {} - hast-util-parse-selector@3.1.1: dependencies: '@types/hast': 2.3.10 @@ -23843,14 +21354,6 @@ snapshots: dependencies: '@types/hast': 3.0.4 - hastscript@6.0.0: - dependencies: - '@types/hast': 2.3.10 - comma-separated-tokens: 1.0.8 - hast-util-parse-selector: 2.2.5 - property-information: 5.6.0 - space-separated-tokens: 1.1.5 - hastscript@7.2.0: dependencies: '@types/hast': 2.3.10 @@ -23961,8 +21464,6 @@ snapshots: husky@9.1.7: {} - hyphenate-style-name@1.1.0: {} - iconv-lite@0.4.24: dependencies: safer-buffer: 2.1.2 @@ -23975,10 +21476,6 @@ snapshots: dependencies: safer-buffer: 2.1.2 - idb-keyval@5.1.5: - dependencies: - safari-14-idb-fix: 1.0.6 - identity-obj-proxy@3.0.0: dependencies: harmony-reflect: 1.6.2 @@ -24058,15 +21555,8 @@ snapshots: ipaddr.js@1.9.1: {} - is-alphabetical@1.0.4: {} - is-alphabetical@2.0.1: {} - is-alphanumerical@1.0.4: - dependencies: - is-alphabetical: 1.0.4 - is-decimal: 1.0.4 - is-alphanumerical@2.0.1: dependencies: is-alphabetical: 2.0.1 @@ -24126,8 +21616,6 @@ snapshots: call-bound: 1.0.4 has-tostringtag: 1.0.2 - is-decimal@1.0.4: {} - is-decimal@2.0.1: {} is-docker@2.2.1: {} @@ -24141,20 +21629,12 @@ snapshots: is-extendable@0.1.1: {} - is-extendable@1.0.1: - dependencies: - is-plain-object: 2.0.4 - is-extglob@2.1.1: {} is-finalizationregistry@1.1.1: dependencies: call-bound: 1.0.4 - is-finite@1.0.2: - dependencies: - number-is-nan: 1.0.1 - is-fullwidth-code-point@3.0.0: {} is-fullwidth-code-point@4.0.0: {} @@ -24176,14 +21656,8 @@ snapshots: dependencies: is-extglob: 2.1.1 - is-hexadecimal@1.0.4: {} - is-hexadecimal@2.0.1: {} - is-integer@1.0.7: - dependencies: - is-finite: 1.0.2 - is-interactive@2.0.0: {} is-map@2.0.3: {} @@ -24201,10 +21675,6 @@ snapshots: is-plain-obj@4.1.0: {} - is-plain-object@2.0.4: - dependencies: - isobject: 3.0.1 - is-potential-custom-element-name@1.0.1: {} is-promise@2.2.2: {} @@ -24270,8 +21740,6 @@ snapshots: isexe@2.0.0: {} - isobject@3.0.1: {} - istanbul-lib-coverage@3.2.2: {} istanbul-lib-instrument@5.2.1: @@ -24302,7 +21770,7 @@ snapshots: istanbul-lib-source-maps@4.0.1: dependencies: - debug: 4.4.1 + debug: 4.4.3 istanbul-lib-coverage: 3.2.2 source-map: 0.6.1 transitivePeerDependencies: @@ -24818,7 +22286,7 @@ snapshots: decimal.js: 10.5.0 domexception: 4.0.0 escodegen: 2.1.0 - form-data: 4.0.2 + form-data: 4.0.5 html-encoding-sniffer: 3.0.0 http-proxy-agent: 5.0.0 https-proxy-agent: 5.0.1 @@ -24947,7 +22415,7 @@ snapshots: kleur@4.1.5: {} - kysely@0.28.15: {} + kysely@0.28.17: {} language-subtag-registry@0.3.23: {} @@ -25079,9 +22547,6 @@ snapshots: dependencies: p-locate: 5.0.0 - lodash-es@4.17.23: - optional: true - lodash._baseiteratee@4.7.0: dependencies: lodash._stringtopath: 4.8.0 @@ -25144,8 +22609,6 @@ snapshots: lodash._baseiteratee: 4.7.0 lodash._baseuniq: 4.6.0 - lodash@4.17.21: {} - lodash@4.18.1: {} log-symbols@5.1.0: @@ -25251,20 +22714,10 @@ snapshots: marked@15.0.12: {} - marked@4.3.0: {} - - match-sorter@6.3.4: - dependencies: - '@babel/runtime': 7.27.0 - remove-accents: 0.5.0 - matcher@3.0.0: dependencies: escape-string-regexp: 4.0.0 - material-colors@1.2.6: - optional: true - math-intrinsics@1.1.0: {} mdast-util-definitions@5.1.2: @@ -25562,11 +23015,11 @@ snapshots: medialit@0.1.0: dependencies: - form-data: 4.0.2 + form-data: 4.0.5 medialit@0.2.0: dependencies: - form-data: 4.0.2 + form-data: 4.0.5 memory-pager@1.5.0: {} @@ -26029,7 +23482,7 @@ snapshots: micromark@3.2.0: dependencies: '@types/debug': 4.1.12 - debug: 4.4.1 + debug: 4.4.3 decode-named-character-reference: 1.1.0 micromark-core-commonmark: 1.1.0 micromark-factory-space: 1.1.0 @@ -26091,10 +23544,6 @@ snapshots: mimic-function@5.0.1: {} - min-document@2.19.0: - dependencies: - dom-walk: 0.1.2 - min-indent@1.0.1: {} minim@0.23.8: @@ -26151,7 +23600,7 @@ snapshots: find-cache-dir: 3.3.2 follow-redirects: 1.15.9(debug@4.4.1) https-proxy-agent: 7.0.6 - mongodb: 6.16.0(@aws-sdk/credential-providers@3.797.0)(socks@2.8.4) + mongodb: 6.21.0(@aws-sdk/credential-providers@3.797.0)(socks@2.8.4) new-find-package-json: 2.0.0 semver: 7.7.3 tar-stream: 3.1.7 @@ -26192,20 +23641,29 @@ snapshots: transitivePeerDependencies: - aws-crt - mongodb@6.16.0(@aws-sdk/credential-providers@3.797.0)(socks@2.8.4): + mongodb@6.20.0(@aws-sdk/credential-providers@3.797.0)(socks@2.8.4): dependencies: - '@mongodb-js/saslprep': 1.2.2 - bson: 6.10.3 + '@mongodb-js/saslprep': 1.4.11 + bson: 6.10.4 + mongodb-connection-string-url: 3.0.2 + optionalDependencies: + '@aws-sdk/credential-providers': 3.797.0 + socks: 2.8.4 + + mongodb@6.21.0(@aws-sdk/credential-providers@3.797.0)(socks@2.8.4): + dependencies: + '@mongodb-js/saslprep': 1.4.11 + bson: 6.10.4 mongodb-connection-string-url: 3.0.2 optionalDependencies: '@aws-sdk/credential-providers': 3.797.0 socks: 2.8.4 - mongoose@8.14.0(@aws-sdk/credential-providers@3.797.0)(socks@2.8.4): + mongoose@8.23.1(@aws-sdk/credential-providers@3.797.0)(socks@2.8.4): dependencies: - bson: 6.10.3 + bson: 6.10.4 kareem: 2.6.3 - mongodb: 6.16.0(@aws-sdk/credential-providers@3.797.0)(socks@2.8.4) + mongodb: 6.20.0(@aws-sdk/credential-providers@3.797.0)(socks@2.8.4) mpath: 0.9.0 mquery: 5.0.0 ms: 2.1.3 @@ -26238,7 +23696,7 @@ snapshots: mquery@5.0.0: dependencies: - debug: 4.4.1 + debug: 4.4.3 transitivePeerDependencies: - supports-color @@ -26266,23 +23724,6 @@ snapshots: optionalDependencies: msgpackr-extract: 3.0.3 - multishift@2.0.10(@remirror/pm@3.0.1)(@types/react@18.3.7)(react@19.2.0): - dependencies: - '@babel/runtime': 7.29.2 - '@remirror/core-helpers': 4.0.0 - '@remirror/core-types': 3.0.0(@remirror/pm@3.0.1) - '@seznam/compose-react-refs': 1.0.6 - a11y-status: 2.0.2 - compute-scroll-into-view: 1.0.20 - react: 19.2.0 - tiny-warning: 1.0.3 - w3c-keyname: 2.2.8 - optionalDependencies: - '@types/react': 18.3.7 - transitivePeerDependencies: - - '@remirror/pm' - optional: true - muri@1.3.0: {} mz@2.7.0: @@ -26291,8 +23732,6 @@ snapshots: object-assign: 4.1.1 thenify-all: 1.6.0 - nanoevents@5.1.13: {} - nanoid@3.3.11: {} nanoid@5.1.5: {} @@ -26322,50 +23761,27 @@ snapshots: react: 19.2.0 react-dom: 19.2.0(react@19.2.0) - next@15.5.3(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@19.2.0(react@18.3.1))(react@18.3.1): + next@16.1.6(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@19.2.0(react@18.3.1))(react@18.3.1): dependencies: - '@next/env': 15.5.3 + '@next/env': 16.1.6 '@swc/helpers': 0.5.15 - caniuse-lite: 1.0.30001715 + baseline-browser-mapping: 2.9.19 + caniuse-lite: 1.0.30001761 postcss: 8.4.31 react: 18.3.1 react-dom: 19.2.0(react@18.3.1) styled-jsx: 5.1.6(@babel/core@7.26.10)(babel-plugin-macros@3.1.0)(react@18.3.1) optionalDependencies: - '@next/swc-darwin-arm64': 15.5.3 - '@next/swc-darwin-x64': 15.5.3 - '@next/swc-linux-arm64-gnu': 15.5.3 - '@next/swc-linux-arm64-musl': 15.5.3 - '@next/swc-linux-x64-gnu': 15.5.3 - '@next/swc-linux-x64-musl': 15.5.3 - '@next/swc-win32-arm64-msvc': 15.5.3 - '@next/swc-win32-x64-msvc': 15.5.3 - '@opentelemetry/api': 1.9.0 - sharp: 0.34.3 - transitivePeerDependencies: - - '@babel/core' - - babel-plugin-macros - - next@15.5.3(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0): - dependencies: - '@next/env': 15.5.3 - '@swc/helpers': 0.5.15 - caniuse-lite: 1.0.30001715 - 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': 15.5.3 - '@next/swc-darwin-x64': 15.5.3 - '@next/swc-linux-arm64-gnu': 15.5.3 - '@next/swc-linux-arm64-musl': 15.5.3 - '@next/swc-linux-x64-gnu': 15.5.3 - '@next/swc-linux-x64-musl': 15.5.3 - '@next/swc-win32-arm64-msvc': 15.5.3 - '@next/swc-win32-x64-msvc': 15.5.3 + '@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 '@opentelemetry/api': 1.9.0 - sharp: 0.34.3 + sharp: 0.34.5 transitivePeerDependencies: - '@babel/core' - babel-plugin-macros @@ -26449,8 +23865,6 @@ snapshots: npm-to-yarn@3.0.1: {} - number-is-nan@1.0.1: {} - nwsapi@2.2.20: {} object-assign@4.1.1: {} @@ -26490,14 +23904,6 @@ snapshots: define-properties: 1.2.1 es-abstract: 1.24.0 - object.omit@3.0.0: - dependencies: - is-extendable: 1.0.1 - - object.pick@1.3.0: - dependencies: - isobject: 3.0.1 - object.values@1.2.1: dependencies: call-bind: 1.0.8 @@ -26542,7 +23948,7 @@ snapshots: openapi-sampler@1.7.2: dependencies: '@types/json-schema': 7.0.15 - fast-xml-parser: 5.5.9 + fast-xml-parser: 5.8.0 json-pointer: 0.6.2 openapi-server-url-templating@1.3.0: @@ -26618,17 +24024,6 @@ snapshots: dependencies: callsites: 3.1.0 - parenthesis@3.1.8: {} - - parse-entities@2.0.0: - dependencies: - character-entities: 1.2.4 - character-entities-legacy: 1.1.4 - character-reference-invalid: 1.1.4 - is-alphanumerical: 1.0.4 - is-decimal: 1.0.4 - is-hexadecimal: 1.0.4 - parse-entities@4.0.2: dependencies: '@types/unist': 2.0.11 @@ -26639,8 +24034,6 @@ snapshots: is-decimal: 2.0.1 is-hexadecimal: 2.0.1 - parse-exponential@1.0.1: {} - parse-json@5.2.0: dependencies: '@babel/code-frame': 7.26.2 @@ -26673,7 +24066,7 @@ snapshots: path-exists@4.0.0: {} - path-expression-matcher@1.2.0: {} + path-expression-matcher@1.5.0: {} path-is-absolute@1.0.1: {} @@ -26688,7 +24081,7 @@ snapshots: lru-cache: 10.4.3 minipass: 7.1.2 - path-to-regexp@0.1.12: {} + path-to-regexp@0.1.13: {} path-to-regexp@6.3.0: {} @@ -26897,11 +24290,6 @@ snapshots: preact@10.26.5: {} - precision@1.0.1: - dependencies: - is-finite: 1.0.2 - parse-exponential: 1.0.1 - preferred-pm@3.1.4: dependencies: find-up: 5.0.0 @@ -26936,8 +24324,6 @@ snapshots: pretty-format@3.8.0: {} - prismjs@1.27.0: {} - prismjs@1.30.0: {} process-nextick-args@2.0.1: {} @@ -26967,10 +24353,6 @@ snapshots: retry: 0.12.0 signal-exit: 3.0.7 - property-information@5.6.0: - dependencies: - xtend: 4.0.2 - property-information@6.5.0: {} property-information@7.1.0: {} @@ -27002,13 +24384,6 @@ snapshots: prosemirror-state: 1.4.3 prosemirror-view: 1.39.2 - prosemirror-gapcursor@1.4.1: - dependencies: - prosemirror-keymap: 1.2.3 - prosemirror-model: 1.25.4 - prosemirror-state: 1.4.4 - prosemirror-view: 1.41.7 - prosemirror-history@1.4.1: dependencies: prosemirror-state: 1.4.3 @@ -27016,33 +24391,16 @@ snapshots: prosemirror-view: 1.39.2 rope-sequence: 1.3.4 - prosemirror-history@1.5.0: - dependencies: - prosemirror-state: 1.4.4 - prosemirror-transform: 1.12.0 - prosemirror-view: 1.41.7 - rope-sequence: 1.3.4 - prosemirror-inputrules@1.5.0: dependencies: prosemirror-state: 1.4.3 prosemirror-transform: 1.10.4 - prosemirror-inputrules@1.5.1: - dependencies: - prosemirror-state: 1.4.4 - prosemirror-transform: 1.12.0 - prosemirror-keymap@1.2.2: dependencies: prosemirror-state: 1.4.3 w3c-keyname: 2.2.8 - prosemirror-keymap@1.2.3: - dependencies: - prosemirror-state: 1.4.4 - w3c-keyname: 2.2.8 - prosemirror-markdown@1.13.2: dependencies: '@types/markdown-it': 14.1.2 @@ -27060,32 +24418,6 @@ snapshots: dependencies: orderedmap: 2.1.1 - prosemirror-model@1.25.4: - dependencies: - orderedmap: 2.1.1 - - prosemirror-paste-rules@3.0.0(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.7): - dependencies: - '@babel/runtime': 7.29.2 - '@remirror/core-constants': 3.0.0 - '@remirror/core-helpers': 4.0.0 - escape-string-regexp: 4.0.0 - prosemirror-model: 1.25.4 - prosemirror-state: 1.4.4 - prosemirror-view: 1.41.7 - - prosemirror-resizable-view@3.0.0(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0): - dependencies: - '@babel/runtime': 7.27.0 - '@remirror/core-helpers': 4.0.0 - '@remirror/core-utils': 3.0.0(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - prosemirror-model: 1.25.1 - prosemirror-view: 1.39.2 - transitivePeerDependencies: - - '@remirror/pm' - - '@types/node' - - jsdom - prosemirror-schema-basic@1.2.4: dependencies: prosemirror-model: 1.25.1 @@ -27102,23 +24434,6 @@ snapshots: prosemirror-transform: 1.10.4 prosemirror-view: 1.39.2 - prosemirror-state@1.4.4: - dependencies: - prosemirror-model: 1.25.4 - prosemirror-transform: 1.12.0 - prosemirror-view: 1.41.7 - - prosemirror-suggest@3.0.0(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.7): - dependencies: - '@babel/runtime': 7.29.2 - '@remirror/core-constants': 3.0.0 - '@remirror/core-helpers': 4.0.0 - '@remirror/types': 2.0.0 - escape-string-regexp: 4.0.0 - prosemirror-model: 1.25.4 - prosemirror-state: 1.4.4 - prosemirror-view: 1.41.7 - prosemirror-tables@1.7.1: dependencies: prosemirror-keymap: 1.2.2 @@ -27127,14 +24442,6 @@ snapshots: prosemirror-transform: 1.10.4 prosemirror-view: 1.39.2 - prosemirror-tables@1.8.5: - dependencies: - prosemirror-keymap: 1.2.3 - prosemirror-model: 1.25.4 - prosemirror-state: 1.4.4 - prosemirror-transform: 1.12.0 - prosemirror-view: 1.41.7 - prosemirror-trailing-node@3.0.0(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.39.2): dependencies: '@remirror/core-constants': 3.0.0 @@ -27143,46 +24450,28 @@ snapshots: prosemirror-state: 1.4.3 prosemirror-view: 1.39.2 - prosemirror-trailing-node@3.0.0(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.7): - dependencies: - '@remirror/core-constants': 3.0.0 - escape-string-regexp: 4.0.0 - prosemirror-model: 1.25.4 - prosemirror-state: 1.4.4 - prosemirror-view: 1.41.7 - prosemirror-transform@1.10.4: dependencies: prosemirror-model: 1.25.1 - prosemirror-transform@1.12.0: - dependencies: - prosemirror-model: 1.25.4 - prosemirror-view@1.39.2: dependencies: prosemirror-model: 1.25.1 prosemirror-state: 1.4.3 prosemirror-transform: 1.10.4 - prosemirror-view@1.41.7: - dependencies: - prosemirror-model: 1.25.4 - prosemirror-state: 1.4.4 - prosemirror-transform: 1.12.0 - - protobufjs@7.5.4: + protobufjs@7.5.7: dependencies: '@protobufjs/aspromise': 1.1.2 '@protobufjs/base64': 1.1.2 - '@protobufjs/codegen': 2.0.4 + '@protobufjs/codegen': 2.0.5 '@protobufjs/eventemitter': 1.1.0 '@protobufjs/fetch': 1.1.0 '@protobufjs/float': 1.0.2 - '@protobufjs/inquire': 1.1.0 + '@protobufjs/inquire': 1.1.1 '@protobufjs/path': 1.1.2 '@protobufjs/pool': 1.1.0 - '@protobufjs/utf8': 1.1.0 + '@protobufjs/utf8': 1.1.1 '@types/node': 20.19.0 long: 5.3.2 @@ -27191,8 +24480,6 @@ snapshots: forwarded: 0.2.0 ipaddr.js: 1.9.1 - proxy-from-env@1.1.0: {} - proxy-from-env@2.1.0: {} psl@1.15.0: @@ -27314,7 +24601,7 @@ snapshots: razorpay@2.9.6: dependencies: - axios: 1.9.0 + axios: 1.16.0 transitivePeerDependencies: - debug @@ -27323,18 +24610,6 @@ snapshots: chart.js: 4.4.9 react: 19.2.0 - react-color@2.19.3(react@19.2.0): - dependencies: - '@icons/material': 0.2.4(react@19.2.0) - lodash: 4.18.1 - lodash-es: 4.17.23 - material-colors: 1.2.6 - prop-types: 15.8.1 - react: 19.2.0 - reactcss: 1.2.3(react@19.2.0) - tinycolor2: 1.6.0 - optional: true - react-copy-to-clipboard@5.1.0(react@19.2.0): dependencies: copy-to-clipboard: 3.3.3 @@ -27516,12 +24791,6 @@ snapshots: react@19.2.0: {} - reactcss@1.2.3(react@19.2.0): - dependencies: - lodash: 4.18.1 - react: 19.2.0 - optional: true - read-cache@1.0.0: dependencies: pify: 2.3.0 @@ -27584,7 +24853,7 @@ snapshots: dependencies: clsx: 2.1.1 eventemitter3: 4.0.7 - lodash: 4.17.21 + lodash: 4.18.1 react: 19.2.0 react-dom: 19.2.0(react@19.2.0) react-is: 18.3.1 @@ -27650,12 +24919,6 @@ snapshots: get-proto: 1.0.1 which-builtin-type: 1.2.1 - refractor@3.6.0: - dependencies: - hastscript: 6.0.0 - parse-entities: 2.0.0 - prismjs: 1.27.0 - refractor@5.0.0: dependencies: '@types/hast': 3.0.4 @@ -27811,81 +25074,6 @@ snapshots: argparse: 1.0.10 autolinker: 3.16.2 - remirror@3.0.3(@remirror/extension-react-tables@3.0.3(@remirror/pm@3.0.1)(@types/node@17.0.21)(@types/react-dom@18.3.0)(@types/react@18.3.7)(jsdom@26.1.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0)(prettier@3.5.3): - dependencies: - '@babel/runtime': 7.27.0 - '@remirror/core': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/core-constants': 3.0.0 - '@remirror/core-helpers': 4.0.0 - '@remirror/core-types': 3.0.0(@remirror/pm@3.0.1) - '@remirror/core-utils': 3.0.0(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/dom': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/extension-annotation': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/extension-bidi': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/extension-blockquote': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/extension-bold': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/extension-callout': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/extension-code': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/extension-code-block': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0)(prettier@3.5.3) - '@remirror/extension-collaboration': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/extension-columns': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/extension-diff': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/extension-doc': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/extension-drop-cursor': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/extension-embed': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/extension-emoji': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/extension-entity-reference': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/extension-epic-mode': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/extension-events': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/extension-find': 1.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/extension-font-family': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/extension-font-size': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/extension-gap-cursor': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/extension-hard-break': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/extension-heading': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/extension-history': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/extension-horizontal-rule': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/extension-image': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/extension-italic': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/extension-link': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/extension-list': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/extension-markdown': 3.0.3(@remirror/extension-react-tables@3.0.3(@remirror/pm@3.0.1)(@types/node@17.0.21)(@types/react-dom@18.3.0)(@types/react@18.3.7)(jsdom@26.1.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/extension-mention': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/extension-mention-atom': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/extension-node-formatting': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/extension-paragraph': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/extension-placeholder': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/extension-positioner': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/extension-shortcuts': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/extension-strike': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/extension-sub': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/extension-sup': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/extension-tables': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/extension-text': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/extension-text-case': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/extension-text-color': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/extension-text-highlight': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/extension-trailing-node': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/extension-underline': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/extension-whitespace': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/icons': 3.0.0 - '@remirror/pm': 3.0.1 - '@remirror/preset-core': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/preset-formatting': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0) - '@remirror/preset-wysiwyg': 3.0.2(@remirror/pm@3.0.1)(@types/node@17.0.21)(jsdom@26.1.0)(prettier@3.5.3) - '@remirror/theme': 3.0.0(@remirror/pm@3.0.1) - '@types/refractor': 3.4.1 - refractor: 3.6.0 - optionalDependencies: - prettier: 3.5.3 - transitivePeerDependencies: - - '@remirror/extension-react-tables' - - '@types/node' - - jsdom - - supports-color - - remove-accents@0.5.0: {} - repeat-string@1.6.1: {} require-directory@2.1.1: {} @@ -27906,9 +25094,6 @@ snapshots: reselect@5.1.1: {} - resize-observer-polyfill@1.5.1: - optional: true - resolve-cwd@3.0.0: dependencies: resolve-from: 5.0.0 @@ -28039,16 +25224,6 @@ snapshots: rou3@0.7.12: {} - round-precision@1.0.0: - dependencies: - is-finite: 1.0.2 - is-integer: 1.0.7 - - round@2.0.1: - dependencies: - precision: 1.0.1 - round-precision: 1.0.0 - rrweb-cssom@0.8.0: {} run-parallel@1.2.0: @@ -28061,8 +25236,6 @@ snapshots: dependencies: mri: 1.2.0 - safari-14-idb-fix@1.0.6: {} - safe-array-concat@1.1.3: dependencies: call-bind: 1.0.8 @@ -28093,7 +25266,7 @@ snapshots: samlify@2.12.0: dependencies: '@authenio/xml-encryption': 2.0.2 - '@xmldom/xmldom': 0.8.12 + '@xmldom/xmldom': 0.8.13 node-rsa: 1.1.1 xml: 1.0.1 xml-crypto: 6.1.2 @@ -28234,36 +25407,6 @@ snapshots: '@img/sharp-win32-ia32': 0.33.5 '@img/sharp-win32-x64': 0.33.5 - sharp@0.34.3: - dependencies: - color: 4.2.3 - detect-libc: 2.0.4 - semver: 7.7.3 - optionalDependencies: - '@img/sharp-darwin-arm64': 0.34.3 - '@img/sharp-darwin-x64': 0.34.3 - '@img/sharp-libvips-darwin-arm64': 1.2.0 - '@img/sharp-libvips-darwin-x64': 1.2.0 - '@img/sharp-libvips-linux-arm': 1.2.0 - '@img/sharp-libvips-linux-arm64': 1.2.0 - '@img/sharp-libvips-linux-ppc64': 1.2.0 - '@img/sharp-libvips-linux-s390x': 1.2.0 - '@img/sharp-libvips-linux-x64': 1.2.0 - '@img/sharp-libvips-linuxmusl-arm64': 1.2.0 - '@img/sharp-libvips-linuxmusl-x64': 1.2.0 - '@img/sharp-linux-arm': 0.34.3 - '@img/sharp-linux-arm64': 0.34.3 - '@img/sharp-linux-ppc64': 0.34.3 - '@img/sharp-linux-s390x': 0.34.3 - '@img/sharp-linux-x64': 0.34.3 - '@img/sharp-linuxmusl-arm64': 0.34.3 - '@img/sharp-linuxmusl-x64': 0.34.3 - '@img/sharp-wasm32': 0.34.3 - '@img/sharp-win32-arm64': 0.34.3 - '@img/sharp-win32-ia32': 0.34.3 - '@img/sharp-win32-x64': 0.34.3 - optional: true - sharp@0.34.5: dependencies: '@img/colour': 1.0.0 @@ -28406,9 +25549,6 @@ snapshots: buffer-from: 1.1.2 source-map: 0.6.1 - source-map@0.5.7: - optional: true - source-map@0.6.1: {} source-map@0.7.4: {} @@ -28417,8 +25557,6 @@ snapshots: dependencies: whatwg-url: 7.1.0 - space-separated-tokens@1.1.5: {} - space-separated-tokens@2.0.2: {} sparse-bitfield@3.0.3: @@ -28582,7 +25720,7 @@ snapshots: strnum@1.1.2: optional: true - strnum@2.2.2: {} + strnum@2.3.0: {} style-to-js@1.1.21: dependencies: @@ -28608,9 +25746,6 @@ snapshots: '@babel/core': 7.26.10 babel-plugin-macros: 3.1.0 - stylis@4.2.0: - optional: true - sucrase@3.35.0: dependencies: '@jridgewell/gen-mapping': 0.3.8 @@ -28639,15 +25774,6 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} - svgmoji@3.2.0: - dependencies: - '@babel/runtime': 7.27.0 - '@svgmoji/blob': 3.2.0 - '@svgmoji/core': 3.2.0 - '@svgmoji/noto': 3.2.0 - '@svgmoji/openmoji': 3.2.0 - '@svgmoji/twemoji': 3.2.0 - swagger-client@3.37.1: dependencies: '@babel/runtime-corejs3': 7.29.2 @@ -28727,9 +25853,6 @@ snapshots: '@pkgr/core': 0.1.2 tslib: 2.8.1 - tabbable@6.4.0: - optional: true - tailwind-merge@2.6.0: {} tailwind-merge@3.3.1: {} @@ -28877,15 +26000,8 @@ snapshots: dependencies: real-require: 0.2.0 - throttle-debounce@3.0.1: {} - tiny-invariant@1.3.3: {} - tiny-warning@1.0.3: {} - - tinycolor2@1.6.0: - optional: true - tinyexec@1.1.1: {} tinyglobby@0.2.13: @@ -29334,12 +26450,6 @@ snapshots: optionalDependencies: fsevents: 2.3.3 - turndown-plugin-gfm@1.0.2: {} - - turndown@7.2.0: - dependencies: - '@mixmark-io/domino': 2.2.0 - tus-js-client@4.3.1: dependencies: buffer-from: 1.1.2 @@ -29366,12 +26476,8 @@ snapshots: type-fest@0.21.3: {} - type-fest@1.4.0: {} - type-fest@2.19.0: {} - type-fest@3.13.1: {} - type-fest@4.41.0: {} type-is@1.6.18: @@ -29650,21 +26756,6 @@ snapshots: optionalDependencies: '@types/react': 18.3.7 - use-isomorphic-layout-effect@1.2.1(@types/react@18.3.7)(react@19.2.0): - dependencies: - react: 19.2.0 - optionalDependencies: - '@types/react': 18.3.7 - optional: true - - use-previous@1.2.0(@types/react@18.3.7)(react@19.2.0): - dependencies: - react: 19.2.0 - use-isomorphic-layout-effect: 1.2.1(@types/react@18.3.7)(react@19.2.0) - transitivePeerDependencies: - - '@types/react' - optional: true - use-sidecar@1.1.3(@types/react@18.3.7)(react@18.3.1): dependencies: detect-node-es: 1.1.0 @@ -29985,7 +27076,7 @@ snapshots: xml-crypto@6.1.2: dependencies: '@xmldom/is-dom-node': 1.0.1 - '@xmldom/xmldom': 0.8.12 + '@xmldom/xmldom': 0.8.13 xpath: 0.0.33 xml-escape@1.1.0: {} @@ -29998,6 +27089,8 @@ snapshots: xml-name-validator@5.0.0: {} + xml-naming@0.1.0: {} + xml2js@0.6.2: dependencies: sax: 1.4.4 @@ -30015,8 +27108,6 @@ snapshots: xpath@0.0.34: {} - xtend@4.0.2: {} - y18n@5.0.8: {} yallist@3.1.1: {} From a8dfd8d9dc892afaa198258f802cc5221e5ee973 Mon Sep 17 00:00:00 2001 From: Rajat Date: Tue, 12 May 2026 17:25:31 +0530 Subject: [PATCH 3/7] Moved out of wip --- docs/{wip => }/PUBLIC_API_PRODUCT_AND_LEARNER_MANAGEMENT_PRD.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename docs/{wip => }/PUBLIC_API_PRODUCT_AND_LEARNER_MANAGEMENT_PRD.md (100%) diff --git a/docs/wip/PUBLIC_API_PRODUCT_AND_LEARNER_MANAGEMENT_PRD.md b/docs/PUBLIC_API_PRODUCT_AND_LEARNER_MANAGEMENT_PRD.md similarity index 100% rename from docs/wip/PUBLIC_API_PRODUCT_AND_LEARNER_MANAGEMENT_PRD.md rename to docs/PUBLIC_API_PRODUCT_AND_LEARNER_MANAGEMENT_PRD.md From 31c33d666bab463ccf074f16cbc081bffda33895 Mon Sep 17 00:00:00 2001 From: Rajat Date: Tue, 12 May 2026 17:48:58 +0530 Subject: [PATCH 4/7] Moved a function to lessons graphql --- .../[lessonId]/__tests__/route.test.ts | 33 ++++++++-------- .../[productId]/lessons/[lessonId]/route.ts | 13 +++---- .../graphql/courses/__tests__/logic.test.ts | 38 +++++++++++++++---- apps/web/graphql/courses/logic.ts | 23 ----------- apps/web/graphql/lessons/logic.ts | 12 ++++-- 5 files changed, 63 insertions(+), 56 deletions(-) diff --git a/apps/web/app/api/products/[productId]/lessons/[lessonId]/__tests__/route.test.ts b/apps/web/app/api/products/[productId]/lessons/[lessonId]/__tests__/route.test.ts index c425f1a96..da8d7eab5 100644 --- a/apps/web/app/api/products/[productId]/lessons/[lessonId]/__tests__/route.test.ts +++ b/apps/web/app/api/products/[productId]/lessons/[lessonId]/__tests__/route.test.ts @@ -6,17 +6,18 @@ import { NextRequest } from "next/server"; import Domain from "@models/Domain"; import ApiKey from "@models/ApiKey"; import User from "@models/User"; -import { getCourseLessonOrThrow } from "@/graphql/courses/logic"; -import { deleteLesson, updateLesson } from "@/graphql/lessons/logic"; +import { + deleteLesson, + getLessonOrThrow, + updateLesson, +} from "@/graphql/lessons/logic"; jest.mock("@models/Domain"); jest.mock("@models/ApiKey"); jest.mock("@models/User"); -jest.mock("@/graphql/courses/logic", () => ({ - getCourseLessonOrThrow: jest.fn(), -})); jest.mock("@/graphql/lessons/logic", () => ({ deleteLesson: jest.fn(), + getLessonOrThrow: jest.fn(), updateLesson: jest.fn(), })); @@ -55,7 +56,7 @@ describe("/api/products/{productId}/lessons/{lessonId}", () => { }); it("fetches a product lesson through existing product lesson logic", async () => { - (getCourseLessonOrThrow as jest.Mock).mockResolvedValue({ + (getLessonOrThrow as jest.Mock).mockResolvedValue({ lessonId: "lesson-1", title: "Intro", type: "text", @@ -70,11 +71,11 @@ describe("/api/products/{productId}/lessons/{lessonId}", () => { const response = await GET(request(), { params }); expect(response.status).toBe(200); - expect(getCourseLessonOrThrow).toHaveBeenCalledWith({ - courseId: "course-1", - lessonId: "lesson-1", - ctx: expect.objectContaining({ subdomain: domain }), - }); + expect(getLessonOrThrow).toHaveBeenCalledWith( + "lesson-1", + expect.objectContaining({ subdomain: domain }), + { courseId: "course-1" }, + ); await expect(response.json()).resolves.toMatchObject({ lessonId: "lesson-1", content: tiptapDoc, @@ -82,7 +83,7 @@ describe("/api/products/{productId}/lessons/{lessonId}", () => { }); it("updates a lesson with Tiptap JSON converted for existing lesson logic", async () => { - (getCourseLessonOrThrow as jest.Mock).mockResolvedValue({ + (getLessonOrThrow as jest.Mock).mockResolvedValue({ lessonId: "lesson-1", courseId: "course-1", }); @@ -123,7 +124,7 @@ describe("/api/products/{productId}/lessons/{lessonId}", () => { }); it("does not send content to existing lesson logic when content is not updated", async () => { - (getCourseLessonOrThrow as jest.Mock).mockResolvedValue({ + (getLessonOrThrow as jest.Mock).mockResolvedValue({ lessonId: "lesson-1", courseId: "course-1", }); @@ -155,7 +156,7 @@ describe("/api/products/{productId}/lessons/{lessonId}", () => { }); it("does not update a lesson that is not part of the product path", async () => { - (getCourseLessonOrThrow as jest.Mock).mockRejectedValue( + (getLessonOrThrow as jest.Mock).mockRejectedValue( new Error("Item not found"), ); @@ -225,7 +226,7 @@ describe("/api/products/{productId}/lessons/{lessonId}", () => { }); it("deletes a lesson through existing lesson logic", async () => { - (getCourseLessonOrThrow as jest.Mock).mockResolvedValue({ + (getLessonOrThrow as jest.Mock).mockResolvedValue({ lessonId: "lesson-1", courseId: "course-1", }); @@ -243,7 +244,7 @@ describe("/api/products/{productId}/lessons/{lessonId}", () => { }); it("does not delete a lesson that is not part of the product path", async () => { - (getCourseLessonOrThrow as jest.Mock).mockRejectedValue( + (getLessonOrThrow as jest.Mock).mockRejectedValue( new Error("Item not found"), ); diff --git a/apps/web/app/api/products/[productId]/lessons/[lessonId]/route.ts b/apps/web/app/api/products/[productId]/lessons/[lessonId]/route.ts index 790c01a3c..731eb6ae0 100644 --- a/apps/web/app/api/products/[productId]/lessons/[lessonId]/route.ts +++ b/apps/web/app/api/products/[productId]/lessons/[lessonId]/route.ts @@ -1,7 +1,10 @@ import { NextRequest, NextResponse } from "next/server"; import { Constants } from "@courselit/common-models"; -import { getCourseLessonOrThrow } from "@/graphql/courses/logic"; -import { deleteLesson, updateLesson } from "@/graphql/lessons/logic"; +import { + deleteLesson, + getLessonOrThrow, + updateLesson, +} from "@/graphql/lessons/logic"; import { publicApiError, validatePublicApiRequest, @@ -43,11 +46,7 @@ async function getProductLessonOrNull({ ctx: any; }) { try { - return await getCourseLessonOrThrow({ - courseId: productId, - lessonId, - ctx, - }); + return await getLessonOrThrow(lessonId, ctx, { courseId: productId }); } catch (error) { return null; } diff --git a/apps/web/graphql/courses/__tests__/logic.test.ts b/apps/web/graphql/courses/__tests__/logic.test.ts index 62126e743..69ebbb9a9 100644 --- a/apps/web/graphql/courses/__tests__/logic.test.ts +++ b/apps/web/graphql/courses/__tests__/logic.test.ts @@ -9,12 +9,12 @@ import { responses } from "@/config/strings"; import { Constants as CommonConstants } from "@courselit/common-models"; import { getCourse, - getCourseLessonOrThrow, getCourseLessons, getMembers, getProducts, updateCourse, } from "../logic"; +import { getLessonOrThrow } from "../../lessons/logic"; import { deleteMedia, sealMedia } from "@/services/medialit"; jest.mock("@/services/medialit", () => ({ @@ -640,7 +640,7 @@ describe("public API product read helpers", () => { ]); }); - it("rejects lesson reads when the lesson does not belong to the product", async () => { + it("rejects course-scoped lesson reads when the lesson belongs to another product", async () => { const course = await CourseModel.create({ domain: testDomain._id, courseId: helperId("course"), @@ -654,17 +654,41 @@ describe("public API product read helpers", () => { cost: 0, slug: helperId("course-slug"), }); + const otherCourse = await CourseModel.create({ + domain: testDomain._id, + courseId: helperId("other-course"), + title: "Other Course", + creatorId: adminUser.userId, + groups: [], + lessons: [], + type: constants.course, + privacy: "unlisted", + costType: "free", + cost: 0, + slug: helperId("other-course-slug"), + }); + const lessonId = helperId("other-course-lesson"); + await LessonModel.create({ + domain: testDomain._id, + lessonId, + title: "Other Course Lesson", + type: constants.text, + creatorId: adminUser.userId, + courseId: otherCourse.courseId, + groupId: helperId("other-group"), + published: false, + }); await expect( - getCourseLessonOrThrow({ - courseId: course.courseId, - lessonId: helperId("missing-lesson"), - ctx: { + getLessonOrThrow( + lessonId, + { subdomain: testDomain, user: adminUser, address: "", }, - }), + { courseId: course.courseId }, + ), ).rejects.toThrow(responses.item_not_found); }); diff --git a/apps/web/graphql/courses/logic.ts b/apps/web/graphql/courses/logic.ts index 84ad8af10..e8465a992 100644 --- a/apps/web/graphql/courses/logic.ts +++ b/apps/web/graphql/courses/logic.ts @@ -736,29 +736,6 @@ export const getCourseLessons = async ({ return await getGroupedLessons(course.courseId, ctx.subdomain._id); }; -export const getCourseLessonOrThrow = async ({ - courseId, - lessonId, - ctx, -}: { - courseId: string; - lessonId: string; - ctx: GQLContext; -}) => { - const course = await getCourseOrThrow(undefined, ctx, courseId); - const lesson = await LessonModel.findOne({ - domain: ctx.subdomain._id, - courseId: course.courseId, - lessonId, - }); - - if (!lesson) { - throw new Error(responses.item_not_found); - } - - return lesson; -}; - export const addGroup = async ({ id, name, diff --git a/apps/web/graphql/lessons/logic.ts b/apps/web/graphql/lessons/logic.ts index 79ab86d46..1cb673946 100644 --- a/apps/web/graphql/lessons/logic.ts +++ b/apps/web/graphql/lessons/logic.ts @@ -48,16 +48,22 @@ export const canViewUnpublished = (ctx: GQLContext, entity: any): boolean => { ); }; -const getLessonOrThrow = async ( +export const getLessonOrThrow = async ( id: string, ctx: GQLContext, + options?: { courseId?: string }, ): Promise => { checkIfAuthenticated(ctx); - const lesson = await LessonModel.findOne({ + const query: Record = { lessonId: id, domain: ctx.subdomain._id, - }); + }; + if (options?.courseId) { + query.courseId = options.courseId; + } + + const lesson = await LessonModel.findOne(query); if (!lesson) { throw new Error(responses.item_not_found); From ac470a8d484666a47b4ccd4dd64f4379d7ec55c3 Mon Sep 17 00:00:00 2001 From: Rajat Date: Tue, 12 May 2026 20:50:33 +0530 Subject: [PATCH 5/7] move a method to lessons logic --- .../lessons/__tests__/route.test.ts | 50 ++++++- .../api/products/[productId]/lessons/route.ts | 13 +- .../graphql/courses/__tests__/logic.test.ts | 129 +----------------- apps/web/graphql/courses/logic.ts | 13 +- 4 files changed, 53 insertions(+), 152 deletions(-) diff --git a/apps/web/app/api/products/[productId]/lessons/__tests__/route.test.ts b/apps/web/app/api/products/[productId]/lessons/__tests__/route.test.ts index 1eb804c1b..7a63d936b 100644 --- a/apps/web/app/api/products/[productId]/lessons/__tests__/route.test.ts +++ b/apps/web/app/api/products/[productId]/lessons/__tests__/route.test.ts @@ -6,14 +6,18 @@ import { NextRequest } from "next/server"; import Domain from "@models/Domain"; import ApiKey from "@models/ApiKey"; import User from "@models/User"; -import { getCourseLessons } from "@/graphql/courses/logic"; +import { getCourseOrThrow } from "@/graphql/courses/logic"; +import { getGroupedLessons } from "@/graphql/lessons/helpers"; import { createLesson } from "@/graphql/lessons/logic"; jest.mock("@models/Domain"); jest.mock("@models/ApiKey"); jest.mock("@models/User"); jest.mock("@/graphql/courses/logic", () => ({ - getCourseLessons: jest.fn(), + getCourseOrThrow: jest.fn(), +})); +jest.mock("@/graphql/lessons/helpers", () => ({ + getGroupedLessons: jest.fn(), })); jest.mock("@/graphql/lessons/logic", () => ({ createLesson: jest.fn(), @@ -55,7 +59,10 @@ describe("/api/products/{productId}/lessons", () => { }); it("lists lessons for a product without requiring published-only behavior", async () => { - (getCourseLessons as jest.Mock).mockResolvedValue([ + (getCourseOrThrow as jest.Mock).mockResolvedValue({ + courseId: "course-1", + }); + (getGroupedLessons as jest.Mock).mockResolvedValue([ { lessonId: "lesson-1", title: "Intro", @@ -66,6 +73,16 @@ describe("/api/products/{productId}/lessons", () => { published: false, requiresEnrollment: true, }, + { + lessonId: "lesson-2", + title: "Second", + type: "text", + content: tiptapDoc, + courseId: "course-1", + groupId: "group-2", + published: true, + requiresEnrollment: false, + }, ]); const { GET } = await import("../route"); @@ -74,10 +91,12 @@ describe("/api/products/{productId}/lessons", () => { }); expect(response.status).toBe(200); - expect(getCourseLessons).toHaveBeenCalledWith({ - courseId: "course-1", - ctx: expect.objectContaining({ subdomain: domain }), - }); + expect(getCourseOrThrow).toHaveBeenCalledWith( + undefined, + expect.objectContaining({ subdomain: domain }), + "course-1", + ); + expect(getGroupedLessons).toHaveBeenCalledWith("course-1", domain._id); await expect(response.json()).resolves.toEqual({ data: [ { @@ -97,6 +116,23 @@ describe("/api/products/{productId}/lessons", () => { }, ], }, + { + groupId: "group-2", + lessons: [ + { + lessonId: "lesson-2", + title: "Second", + type: "text", + content: tiptapDoc, + media: undefined, + downloadable: undefined, + courseId: "course-1", + groupId: "group-2", + requiresEnrollment: false, + published: true, + }, + ], + }, ], }); }); diff --git a/apps/web/app/api/products/[productId]/lessons/route.ts b/apps/web/app/api/products/[productId]/lessons/route.ts index 95a449bcb..87ecfa689 100644 --- a/apps/web/app/api/products/[productId]/lessons/route.ts +++ b/apps/web/app/api/products/[productId]/lessons/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { Constants } from "@courselit/common-models"; -import { getCourseLessons } from "@/graphql/courses/logic"; +import { getCourseOrThrow } from "@/graphql/courses/logic"; +import { getGroupedLessons } from "@/graphql/lessons/helpers"; import { createLesson } from "@/graphql/lessons/logic"; import { publicApiError, @@ -55,10 +56,12 @@ export async function GET( const { productId } = await params; try { - const lessons = await getCourseLessons({ - courseId: productId, - ctx: auth.ctx as any, - }); + const ctx = auth.ctx as any; + const course = await getCourseOrThrow(undefined, ctx, productId); + const lessons = await getGroupedLessons( + course.courseId, + ctx.subdomain._id, + ); const groupedLessons = groupLessonsByGroupId(lessons as any[]); diff --git a/apps/web/graphql/courses/__tests__/logic.test.ts b/apps/web/graphql/courses/__tests__/logic.test.ts index 69ebbb9a9..e957b3c9e 100644 --- a/apps/web/graphql/courses/__tests__/logic.test.ts +++ b/apps/web/graphql/courses/__tests__/logic.test.ts @@ -7,13 +7,7 @@ import PageModel from "@models/Page"; import constants from "@/config/constants"; import { responses } from "@/config/strings"; import { Constants as CommonConstants } from "@courselit/common-models"; -import { - getCourse, - getCourseLessons, - getMembers, - getProducts, - updateCourse, -} from "../logic"; +import { getCourse, getMembers, getProducts, updateCourse } from "../logic"; import { getLessonOrThrow } from "../../lessons/logic"; import { deleteMedia, sealMedia } from "@/services/medialit"; @@ -519,127 +513,6 @@ describe("public API product read helpers", () => { paginatedFind.mockRestore(); }); - it("returns lessons for a product after applying existing product access checks", async () => { - const course = await CourseModel.create({ - domain: testDomain._id, - courseId: helperId("course"), - title: "Course", - creatorId: adminUser.userId, - groups: [], - lessons: [], - type: constants.course, - privacy: "unlisted", - costType: "free", - cost: 0, - slug: helperId("course-slug"), - }); - const groupId = helperId("group"); - await LessonModel.create({ - domain: testDomain._id, - lessonId: helperId("lesson-1"), - title: "Intro", - type: constants.text, - creatorId: adminUser.userId, - courseId: course.courseId, - groupId, - published: false, - }); - - const lessons = await getCourseLessons({ - courseId: course.courseId, - ctx: { - subdomain: testDomain, - user: adminUser, - address: "", - }, - }); - - expect(lessons).toHaveLength(1); - expect(lessons[0].courseId).toBe(course.courseId); - }); - - it("returns lessons grouped by group rank and sorted by lessonsOrder", async () => { - const groupId1 = helperId("group-1"); - const groupId2 = helperId("group-2"); - const lesson1 = helperId("lesson-1"); - const lesson2 = helperId("lesson-2"); - const lesson3 = helperId("lesson-3"); - - const course = await CourseModel.create({ - domain: testDomain._id, - courseId: helperId("course-ordered"), - title: "Ordered Course", - creatorId: adminUser.userId, - groups: [ - { - _id: groupId1, - name: "Group 1", - rank: 2000, - collapsed: true, - lessonsOrder: [lesson2, lesson1], - }, - { - _id: groupId2, - name: "Group 2", - rank: 1000, - collapsed: true, - lessonsOrder: [lesson3], - }, - ], - lessons: [lesson1, lesson2, lesson3], - type: constants.course, - privacy: "unlisted", - costType: "free", - cost: 0, - slug: helperId("course-slug-ordered"), - }); - - await LessonModel.insertMany([ - { - domain: testDomain._id, - lessonId: lesson1, - title: "Lesson 1", - type: constants.text, - creatorId: adminUser.userId, - courseId: course.courseId, - groupId: groupId1, - }, - { - domain: testDomain._id, - lessonId: lesson2, - title: "Lesson 2", - type: constants.text, - creatorId: adminUser.userId, - courseId: course.courseId, - groupId: groupId1, - }, - { - domain: testDomain._id, - lessonId: lesson3, - title: "Lesson 3", - type: constants.text, - creatorId: adminUser.userId, - courseId: course.courseId, - groupId: groupId2, - }, - ]); - - const lessons = await getCourseLessons({ - courseId: course.courseId, - ctx: { - subdomain: testDomain, - user: adminUser, - address: "", - }, - }); - - expect(lessons.map((l: any) => l.lessonId)).toEqual([ - lesson3, - lesson2, - lesson1, - ]); - }); - it("rejects course-scoped lesson reads when the lesson belongs to another product", async () => { const course = await CourseModel.create({ domain: testDomain._id, diff --git a/apps/web/graphql/courses/logic.ts b/apps/web/graphql/courses/logic.ts index e8465a992..939a7caba 100644 --- a/apps/web/graphql/courses/logic.ts +++ b/apps/web/graphql/courses/logic.ts @@ -34,7 +34,7 @@ import { import { deleteAllLessons } from "../lessons/logic"; import { deleteMedia, sealMedia } from "@/services/medialit"; import PageModel from "@/models/Page"; -import { getGroupedLessons, getPrevNextCursor } from "../lessons/helpers"; +import { getPrevNextCursor } from "../lessons/helpers"; import { checkPermission, extractMediaIDs } from "@courselit/utils"; import { error } from "@/services/logger"; import { @@ -725,17 +725,6 @@ export const getProductsCount = async ({ return await (CourseModel as any).countDocuments(query); }; -export const getCourseLessons = async ({ - courseId, - ctx, -}: { - courseId: string; - ctx: GQLContext; -}) => { - const course = await getCourseOrThrow(undefined, ctx, courseId); - return await getGroupedLessons(course.courseId, ctx.subdomain._id); -}; - export const addGroup = async ({ id, name, From 032e4037ade9546d46a8404cf4e8a5fc41c8be39 Mon Sep 17 00:00:00 2001 From: Rajat Date: Sat, 16 May 2026 00:09:34 +0530 Subject: [PATCH 6/7] Added tags based filtering to list products --- .../api/products/__tests__/openapi.test.ts | 13 +++++++ .../app/api/products/__tests__/route.test.ts | 37 +++++++++++++++++++ apps/web/app/api/products/openapi.mjs | 12 ++++++ apps/web/app/api/products/route.ts | 12 ++++++ apps/web/openapi/generated/openapi.json | 13 +++++++ 5 files changed, 87 insertions(+) diff --git a/apps/web/app/api/products/__tests__/openapi.test.ts b/apps/web/app/api/products/__tests__/openapi.test.ts index 917635cc8..8e860201e 100644 --- a/apps/web/app/api/products/__tests__/openapi.test.ts +++ b/apps/web/app/api/products/__tests__/openapi.test.ts @@ -30,6 +30,19 @@ describe("Products OpenAPI", () => { tags: ["Products"], operationId: "listProducts", }); + expect( + routes.paths["/api/products"].get.parameters.find( + (parameter) => parameter.name === "tags", + ), + ).toMatchObject({ + in: "query", + style: "form", + explode: false, + schema: { + type: "array", + items: { type: "string" }, + }, + }); expect(routes.paths["/api/products/{productId}"].get).toMatchObject({ tags: ["Products"], operationId: "getProduct", diff --git a/apps/web/app/api/products/__tests__/route.test.ts b/apps/web/app/api/products/__tests__/route.test.ts index ad1f11d75..2a99feb4d 100644 --- a/apps/web/app/api/products/__tests__/route.test.ts +++ b/apps/web/app/api/products/__tests__/route.test.ts @@ -262,6 +262,7 @@ describe("GET /api/products", () => { page: 1, limit: 50, filterBy: undefined, + tags: undefined, published: undefined, searchText: undefined, }); @@ -300,6 +301,42 @@ describe("GET /api/products", () => { expect(body.data[0]).not.toHaveProperty("certificate"); }); + it("passes repeated product tag filters through to product listing logic", async () => { + (getProducts as jest.Mock).mockResolvedValue([]); + + const { GET } = await import("../route"); + const response = await GET( + makeRequest( + "https://school.test/api/products?tags=ai&tags=beginner", + ), + ); + + expect(response.status).toBe(200); + expect(getProducts).toHaveBeenCalledWith( + expect.objectContaining({ + tags: ["ai", "beginner"], + }), + ); + }); + + it("passes comma-separated product tag filters through to product listing logic", async () => { + (getProducts as jest.Mock).mockResolvedValue([]); + + const { GET } = await import("../route"); + const response = await GET( + makeRequest( + "https://school.test/api/products?tags=ai,%20beginner,,", + ), + ); + + expect(response.status).toBe(200); + expect(getProducts).toHaveBeenCalledWith( + expect.objectContaining({ + tags: ["ai", "beginner"], + }), + ); + }); + it("returns a structured authentication error when the API key is invalid", async () => { (ApiKey.findOne as jest.Mock).mockResolvedValue(null); diff --git a/apps/web/app/api/products/openapi.mjs b/apps/web/app/api/products/openapi.mjs index 566a94120..eac14a570 100644 --- a/apps/web/app/api/products/openapi.mjs +++ b/apps/web/app/api/products/openapi.mjs @@ -226,6 +226,18 @@ export const productsApiOpenApi = { schema: { type: "boolean" }, }, { name: "search", in: "query", schema: { type: "string" } }, + { + name: "tags", + in: "query", + description: + "Filter products matching any of the supplied tags. Pass as `tags=ai,beginner`.", + style: "form", + explode: false, + schema: { + type: "array", + items: { type: "string" }, + }, + }, { name: "page", in: "query", diff --git a/apps/web/app/api/products/route.ts b/apps/web/app/api/products/route.ts index d67b1802f..0b0e6f4cd 100644 --- a/apps/web/app/api/products/route.ts +++ b/apps/web/app/api/products/route.ts @@ -12,6 +12,16 @@ const DEFAULT_LIMIT = 50; const MAX_LIMIT = 200; const createProductFields = new Set(["title", "type"]); +function parseTags(searchParams: URLSearchParams) { + const tags = searchParams + .getAll("tags") + .flatMap((value) => value.split(",")) + .map((tag) => tag.trim()) + .filter(Boolean); + + return tags.length ? tags : undefined; +} + export async function GET(req: NextRequest) { const auth = await validatePublicApiRequest(req); if (auth.error) { @@ -31,6 +41,7 @@ export async function GET(req: NextRequest) { const type = url.searchParams.get("type"); const published = url.searchParams.get("published"); const search = url.searchParams.get("search"); + const tags = parseTags(url.searchParams); try { const products = await getProducts({ @@ -38,6 +49,7 @@ export async function GET(req: NextRequest) { page, limit, filterBy: type ? ([type] as any) : undefined, + tags, published: published === "true" || published === "false" ? published === "true" diff --git a/apps/web/openapi/generated/openapi.json b/apps/web/openapi/generated/openapi.json index 7d2b96da4..9cb37c780 100644 --- a/apps/web/openapi/generated/openapi.json +++ b/apps/web/openapi/generated/openapi.json @@ -328,6 +328,19 @@ "type": "string" } }, + { + "name": "tags", + "in": "query", + "description": "Filter products matching any of the supplied tags. Pass as `tags=ai,beginner`.", + "style": "form", + "explode": false, + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, { "name": "page", "in": "query", From 17fd5ed8b3259040a134901b72a36eafa130a76e Mon Sep 17 00:00:00 2001 From: Rajat Date: Sun, 17 May 2026 19:35:09 +0530 Subject: [PATCH 7/7] product learner actions --- .../en/developers/products-and-content.md | 175 +++++++++++- .../__tests__/learner-actions.test.ts | 240 ++++++++++++++++ .../lessons/[lessonId]/completion/route.ts | 44 +++ .../lessons/[lessonId]/evaluations/route.ts | 73 +++++ .../[userId]/lessons/learner-action.ts | 52 ++++ .../api/products/__tests__/openapi.test.ts | 41 +++ apps/web/app/api/products/openapi.mjs | 132 ++++++++- apps/web/config/strings.ts | 1 + .../courses/__tests__/reorder-groups.test.ts | 6 +- apps/web/graphql/courses/logic.ts | 7 +- .../graphql/lessons/__tests__/scorm.test.ts | 3 +- .../lessons/__tests__/visibility.test.ts | 145 +++++++++- apps/web/graphql/lessons/logic.ts | 25 +- apps/web/openapi/generated/openapi.json | 270 +++++++++++++++++- ..._API_PRODUCT_AND_LEARNER_MANAGEMENT_PRD.md | 111 +++++-- 15 files changed, 1274 insertions(+), 51 deletions(-) create mode 100644 apps/web/app/api/products/[productId]/customers/[userId]/lessons/[lessonId]/__tests__/learner-actions.test.ts create mode 100644 apps/web/app/api/products/[productId]/customers/[userId]/lessons/[lessonId]/completion/route.ts create mode 100644 apps/web/app/api/products/[productId]/customers/[userId]/lessons/[lessonId]/evaluations/route.ts create mode 100644 apps/web/app/api/products/[productId]/customers/[userId]/lessons/learner-action.ts diff --git a/apps/docs/src/pages/en/developers/products-and-content.md b/apps/docs/src/pages/en/developers/products-and-content.md index 034c8e925..98edbdadc 100644 --- a/apps/docs/src/pages/en/developers/products-and-content.md +++ b/apps/docs/src/pages/en/developers/products-and-content.md @@ -82,17 +82,173 @@ POST /api/products/{productId}/lessons/{lessonId}/move Text lessons accept Tiptap/ProseMirror JSON in `content`. +Supported document nodes include: + +- `doc` +- `paragraph` +- `heading` with `level` 1, 2, or 3 +- `text` +- `bulletList`, `orderedList`, and `listItem` +- `blockquote` +- `horizontalRule` +- `codeBlock` +- `table`, `tableRow`, `tableHeader`, and `tableCell` +- `image` +- `hardBreak` + +Supported text marks include: + +- `bold` +- `italic` +- `underline` +- `strike` +- `code` +- `link` +- `highlight` + ```json { - "title": "Welcome", + "title": "Welcome to Rust", "type": "text", "content": { "type": "doc", "content": [ + { + "type": "heading", + "attrs": { "level": 2 }, + "content": [{ "type": "text", "text": "Install Rust" }] + }, { "type": "paragraph", "content": [ - { "type": "text", "text": "Welcome to the course." } + { "type": "text", "text": "Install " }, + { + "type": "text", + "marks": [{ "type": "bold" }], + "text": "rustup" + }, + { + "type": "text", + "text": " and create your first project." + } + ] + }, + { + "type": "bulletList", + "content": [ + { + "type": "listItem", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Run the installer." + } + ] + } + ] + } + ] + }, + { + "type": "blockquote", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "marks": [ + { + "type": "link", + "attrs": { + "href": "https://www.rust-lang.org/tools/install" + } + } + ], + "text": "Rust installation guide" + } + ] + } + ] + }, + { "type": "horizontalRule" }, + { + "type": "codeBlock", + "attrs": { "language": "bash" }, + "content": [{ "type": "text", "text": "cargo new hello-rust" }] + }, + { + "type": "table", + "content": [ + { + "type": "tableRow", + "content": [ + { + "type": "tableHeader", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Command" + } + ] + } + ] + }, + { + "type": "tableHeader", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Purpose" + } + ] + } + ] + } + ] + }, + { + "type": "tableRow", + "content": [ + { + "type": "tableCell", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "cargo run" + } + ] + } + ] + }, + { + "type": "tableCell", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Build and run the project" + } + ] + } + ] + } + ] + } ] } ] @@ -100,6 +256,21 @@ Text lessons accept Tiptap/ProseMirror JSON in `content`. } ``` +Images can be included in text lesson documents with an `image` node. The `attrs` object requires `src` and may include `alt` and `title`: + +```json +{ + "type": "image", + "attrs": { + "src": "https://cdn.example.com/image.png", + "alt": "Diagram", + "title": "Diagram" + } +} +``` + +Use a URL that is already accessible to learners, or upload to MediaLit and use the returned media URL. + SCORM lesson creation is not supported by the public API. If you send a SCORM lesson type, the API returns a `not_supported` error. ## Media-backed lessons diff --git a/apps/web/app/api/products/[productId]/customers/[userId]/lessons/[lessonId]/__tests__/learner-actions.test.ts b/apps/web/app/api/products/[productId]/customers/[userId]/lessons/[lessonId]/__tests__/learner-actions.test.ts new file mode 100644 index 000000000..9e29f1fc4 --- /dev/null +++ b/apps/web/app/api/products/[productId]/customers/[userId]/lessons/[lessonId]/__tests__/learner-actions.test.ts @@ -0,0 +1,240 @@ +/** + * @jest-environment node + */ + +import { NextRequest } from "next/server"; +import Domain from "@models/Domain"; +import ApiKey from "@models/ApiKey"; +import User from "@models/User"; +import { + evaluateLesson, + getLessonOrThrow, + markLessonCompleted, +} from "@/graphql/lessons/logic"; + +jest.mock("@models/Domain"); +jest.mock("@models/ApiKey"); +jest.mock("@models/User"); +jest.mock("@/graphql/lessons/logic", () => ({ + evaluateLesson: jest.fn(), + getLessonOrThrow: jest.fn(), + markLessonCompleted: jest.fn(), +})); + +const domain = { + _id: "domain-id", + name: "school", + email: "owner@example.com", +}; + +const owner = { + userId: "owner", + email: "owner@example.com", + permissions: ["course:manage_any"], +}; + +const learner = { + userId: "learner-1", + email: "learner@example.com", + permissions: [], + purchases: [{ courseId: "course-1", completedLessons: [] }], +}; + +const params = Promise.resolve({ + productId: "course-1", + userId: "learner-1", + lessonId: "lesson-1", +}); + +const request = (body?: Record) => + ({ + url: "https://school.test/api/products/course-1/customers/learner-1/lessons/lesson-1", + json: jest.fn().mockResolvedValue(body ?? {}), + headers: { + get: jest.fn((name: string) => { + if (name === "domain") return "school"; + if (name === "x-api-key") return "api-key"; + return null; + }), + }, + }) as unknown as NextRequest; + +describe("POST /api/products/{productId}/customers/{userId}/lessons/{lessonId}/evaluations", () => { + beforeEach(() => { + jest.clearAllMocks(); + (Domain.findOne as jest.Mock).mockResolvedValue(domain); + (ApiKey.findOne as jest.Mock).mockResolvedValue({ key: "api-key" }); + (User.findOne as jest.Mock).mockImplementation((query: any) => { + if (query.email === "owner@example.com") { + return Promise.resolve(owner); + } + if (query.userId === "learner-1" && query.domain === "domain-id") { + return Promise.resolve(learner); + } + return Promise.resolve(null); + }); + (getLessonOrThrow as jest.Mock).mockResolvedValue({ + lessonId: "lesson-1", + courseId: "course-1", + }); + }); + + it("evaluates a quiz lesson as the target learner", async () => { + (evaluateLesson as jest.Mock).mockResolvedValue({ + pass: true, + score: 100, + requiresPassingGrade: true, + passingGrade: 70, + }); + + const { POST } = await import("../evaluations/route"); + const response = await POST(request({ answers: [[0], [1, 2]] }), { + params, + }); + + expect(response.status).toBe(200); + expect(getLessonOrThrow).toHaveBeenCalledWith( + "lesson-1", + expect.objectContaining({ user: owner, subdomain: domain }), + { courseId: "course-1" }, + ); + expect(User.findOne).toHaveBeenCalledWith({ + domain: "domain-id", + userId: "learner-1", + }); + expect(evaluateLesson).toHaveBeenCalledWith( + "lesson-1", + { answers: [[0], [1, 2]] }, + expect.objectContaining({ user: learner, subdomain: domain }), + ); + await expect(response.json()).resolves.toEqual({ + pass: true, + score: 100, + requiresPassingGrade: true, + passingGrade: 70, + }); + }); + + it("rejects malformed evaluation answers before invoking lesson logic", async () => { + const { POST } = await import("../evaluations/route"); + const response = await POST(request({ answers: ["0"] }), { params }); + + expect(response.status).toBe(400); + expect(evaluateLesson).not.toHaveBeenCalled(); + await expect(response.json()).resolves.toEqual({ + error: { + code: "bad_request", + message: "Answers must be an array of number arrays", + }, + }); + }); + + it("rejects empty evaluation answers before invoking lesson logic", async () => { + const { POST } = await import("../evaluations/route"); + const response = await POST(request({ answers: [] }), { params }); + + expect(response.status).toBe(400); + expect(getLessonOrThrow).not.toHaveBeenCalled(); + expect(evaluateLesson).not.toHaveBeenCalled(); + await expect(response.json()).resolves.toEqual({ + error: { + code: "bad_request", + message: "Answers must be an array of number arrays", + }, + }); + }); + + it("returns not found when the target learner does not belong to the domain", async () => { + (User.findOne as jest.Mock).mockImplementation((query: any) => { + if (query.email === "owner@example.com") { + return Promise.resolve(owner); + } + return Promise.resolve(null); + }); + + const { POST } = await import("../evaluations/route"); + const response = await POST(request({ answers: [[0]] }), { params }); + + expect(response.status).toBe(404); + expect(evaluateLesson).not.toHaveBeenCalled(); + await expect(response.json()).resolves.toEqual({ + error: { + code: "not_found", + message: "Customer not found", + }, + }); + }); + + it("does not evaluate a lesson outside the product path", async () => { + (getLessonOrThrow as jest.Mock).mockRejectedValue( + new Error("Item not found"), + ); + + const { POST } = await import("../evaluations/route"); + const response = await POST(request({ answers: [[0]] }), { params }); + + expect(response.status).toBe(404); + expect(evaluateLesson).not.toHaveBeenCalled(); + await expect(response.json()).resolves.toEqual({ + error: { + code: "not_found", + message: "Lesson not found", + }, + }); + }); +}); + +describe("POST /api/products/{productId}/customers/{userId}/lessons/{lessonId}/completion", () => { + beforeEach(() => { + jest.clearAllMocks(); + (Domain.findOne as jest.Mock).mockResolvedValue(domain); + (ApiKey.findOne as jest.Mock).mockResolvedValue({ key: "api-key" }); + (User.findOne as jest.Mock).mockImplementation((query: any) => { + if (query.email === "owner@example.com") { + return Promise.resolve(owner); + } + if (query.userId === "learner-1" && query.domain === "domain-id") { + return Promise.resolve(learner); + } + return Promise.resolve(null); + }); + (getLessonOrThrow as jest.Mock).mockResolvedValue({ + lessonId: "lesson-1", + courseId: "course-1", + }); + }); + + it("marks a product lesson complete as the target learner", async () => { + (markLessonCompleted as jest.Mock).mockResolvedValue(true); + + const { POST } = await import("../completion/route"); + const response = await POST(request(), { params }); + + expect(response.status).toBe(200); + expect(markLessonCompleted).toHaveBeenCalledWith( + "lesson-1", + expect.objectContaining({ user: learner, subdomain: domain }), + ); + await expect(response.json()).resolves.toEqual({ completed: true }); + }); + + it("preserves existing completion validation errors", async () => { + (markLessonCompleted as jest.Mock).mockRejectedValue( + new Error( + "You need to pass this test in order to mark it completed.", + ), + ); + + const { POST } = await import("../completion/route"); + const response = await POST(request(), { params }); + + expect(response.status).toBe(422); + await expect(response.json()).resolves.toEqual({ + error: { + code: "unprocessable_entity", + message: + "You need to pass this test in order to mark it completed.", + }, + }); + }); +}); diff --git a/apps/web/app/api/products/[productId]/customers/[userId]/lessons/[lessonId]/completion/route.ts b/apps/web/app/api/products/[productId]/customers/[userId]/lessons/[lessonId]/completion/route.ts new file mode 100644 index 000000000..0bc4260b9 --- /dev/null +++ b/apps/web/app/api/products/[productId]/customers/[userId]/lessons/[lessonId]/completion/route.ts @@ -0,0 +1,44 @@ +import { NextRequest, NextResponse } from "next/server"; +import { markLessonCompleted } from "@/graphql/lessons/logic"; +import { publicApiError, validatePublicApiRequest } from "@/app/api/public-api"; +import { resolveLearnerLessonAction } from "../../learner-action"; + +export async function POST( + req: NextRequest, + { + params, + }: { + params: Promise<{ + productId: string; + userId: string; + lessonId: string; + }>; + }, +) { + const auth = await validatePublicApiRequest(req); + if (auth.error) { + return auth.error; + } + + const { productId, userId, lessonId } = await params; + const action = await resolveLearnerLessonAction({ + auth, + productId, + userId, + lessonId, + }); + if (action.error) { + return action.error; + } + + try { + await markLessonCompleted(lessonId, action.learnerCtx as any); + return NextResponse.json({ completed: true }); + } catch (error: any) { + return publicApiError( + "unprocessable_entity", + error.message || "Unable to mark lesson complete", + 422, + ); + } +} diff --git a/apps/web/app/api/products/[productId]/customers/[userId]/lessons/[lessonId]/evaluations/route.ts b/apps/web/app/api/products/[productId]/customers/[userId]/lessons/[lessonId]/evaluations/route.ts new file mode 100644 index 000000000..2d508a587 --- /dev/null +++ b/apps/web/app/api/products/[productId]/customers/[userId]/lessons/[lessonId]/evaluations/route.ts @@ -0,0 +1,73 @@ +import { NextRequest, NextResponse } from "next/server"; +import { evaluateLesson } from "@/graphql/lessons/logic"; +import { + publicApiError, + validatePublicApiRequestWithJsonBody, +} from "@/app/api/public-api"; +import { resolveLearnerLessonAction } from "../../learner-action"; + +function isAnswers(value: unknown): value is number[][] { + return ( + Array.isArray(value) && + value.length > 0 && + value.every( + (answer) => + Array.isArray(answer) && + answer.every((option) => typeof option === "number"), + ) + ); +} + +export async function POST( + req: NextRequest, + { + params, + }: { + params: Promise<{ + productId: string; + userId: string; + lessonId: string; + }>; + }, +) { + const auth = await validatePublicApiRequestWithJsonBody(req); + if (auth.error) { + return auth.error; + } + + const answers = auth.body.answers; + if (!isAnswers(answers)) { + return publicApiError( + "bad_request", + "Answers must be an array of number arrays", + 400, + ); + } + + const { productId, userId, lessonId } = await params; + const action = await resolveLearnerLessonAction({ + auth, + productId, + userId, + lessonId, + }); + if (action.error) { + return action.error; + } + + try { + const result = await evaluateLesson( + lessonId, + { answers }, + action.learnerCtx as any, + ); + + return NextResponse.json(result); + } catch (error: any) { + return publicApiError( + "unprocessable_entity", + error.message || "Unable to evaluate lesson", + 422, + ); + } +} diff --git a/apps/web/app/api/products/[productId]/customers/[userId]/lessons/learner-action.ts b/apps/web/app/api/products/[productId]/customers/[userId]/lessons/learner-action.ts new file mode 100644 index 000000000..44f5e63b8 --- /dev/null +++ b/apps/web/app/api/products/[productId]/customers/[userId]/lessons/learner-action.ts @@ -0,0 +1,52 @@ +import UserModel from "@models/User"; +import { getLessonOrThrow } from "@/graphql/lessons/logic"; +import { publicApiError } from "@/app/api/public-api"; + +export async function resolveLearnerLessonAction({ + auth, + productId, + userId, + lessonId, +}: { + auth: { + domain: { _id: unknown }; + ctx: { + user: unknown; + subdomain: unknown; + address: string; + }; + }; + productId: string; + userId: string; + lessonId: string; +}) { + let lesson; + try { + lesson = await getLessonOrThrow(lessonId, auth.ctx as any, { + courseId: productId, + }); + } catch { + return { + error: publicApiError("not_found", "Lesson not found", 404), + }; + } + + const learner = await UserModel.findOne({ + domain: auth.domain._id, + userId, + }); + if (!learner) { + return { + error: publicApiError("not_found", "Customer not found", 404), + }; + } + + return { + lesson, + learner, + learnerCtx: { + ...auth.ctx, + user: learner, + }, + }; +} diff --git a/apps/web/app/api/products/__tests__/openapi.test.ts b/apps/web/app/api/products/__tests__/openapi.test.ts index 8e860201e..28c5163a5 100644 --- a/apps/web/app/api/products/__tests__/openapi.test.ts +++ b/apps/web/app/api/products/__tests__/openapi.test.ts @@ -212,6 +212,47 @@ describe("Products OpenAPI", () => { tags: ["Product Customers"], operationId: "getProductCustomerProgress", }); + expect( + routes.paths[ + "/api/products/{productId}/customers/{userId}/lessons/{lessonId}/evaluations" + ].post, + ).toMatchObject({ + tags: ["Product Learner Actions"], + operationId: "evaluateProductCustomerLesson", + }); + expect( + routes.paths[ + "/api/products/{productId}/customers/{userId}/lessons/{lessonId}/completion" + ].post, + ).toMatchObject({ + tags: ["Product Learner Actions"], + operationId: "completeProductCustomerLesson", + }); + expect( + routes.paths[ + "/api/products/{productId}/customers/{userId}/lessons/{lessonId}/evaluations" + ].post.requestBody.content["application/json"].schema.$ref, + ).toBe("#/components/schemas/LessonEvaluationRequest"); + expect( + routes.components.schemas.LessonEvaluationRequest.properties.answers + .items.items, + ).toEqual({ type: "number" }); + expect( + routes.components.schemas.LessonEvaluationResult.properties.pass, + ).toMatchObject({ type: "boolean" }); + expect( + routes.components.schemas.LessonCompletionResponse.properties + .completed, + ).toMatchObject({ type: "boolean" }); + expect(routes.components.schemas.TiptapDocument.description).toContain( + "`table`", + ); + expect(routes.components.schemas.TiptapDocument.description).toContain( + "`bold`", + ); + expect(routes.components.schemas.TiptapDocument.description).toContain( + "`src` (required)", + ); expect( routes.paths["/api/products/{productId}/customers/{userId}"], ).toBeUndefined(); diff --git a/apps/web/app/api/products/openapi.mjs b/apps/web/app/api/products/openapi.mjs index eac14a570..26833dec8 100644 --- a/apps/web/app/api/products/openapi.mjs +++ b/apps/web/app/api/products/openapi.mjs @@ -107,6 +107,9 @@ const lessonContentSchema = { "`text` lessons use `TiptapDocument`; `embed` lessons use `EmbedContent`; `quiz` lessons use `QuizContent`. Media-backed lessons (`video`, `audio`, `pdf`, `file`) use `media` instead of `content`.", }; +const tiptapDocumentDescription = + "Tiptap/ProseMirror document JSON used by `text` lessons. Supported nodes include `doc`, `paragraph`, `heading` (`level` 1, 2, or 3), `text`, `bulletList`, `orderedList`, `listItem`, `blockquote`, `horizontalRule`, `codeBlock`, `table`, `tableRow`, `tableHeader`, `tableCell`, `image`, and `hardBreak`. Supported marks include `bold`, `italic`, `underline`, `strike`, `code`, `link`, and `highlight`. An `image` node uses `attrs` with `src` (required), `alt` (optional), and `title` (optional)."; + const productCreateExample = { title: "AI Foundations", type: "course", @@ -167,9 +170,49 @@ const lessonCreateExample = { content: { type: "doc", content: [ + { + type: "heading", + attrs: { level: 2 }, + content: [{ type: "text", text: "Install Rust" }], + }, { type: "paragraph", - content: [{ type: "text", text: "Welcome to the course!" }], + content: [ + { type: "text", text: "Install " }, + { + type: "text", + marks: [{ type: "bold" }], + text: "rustup", + }, + { + type: "text", + text: " and create your first project.", + }, + ], + }, + { + type: "bulletList", + content: [ + { + type: "listItem", + content: [ + { + type: "paragraph", + content: [ + { + type: "text", + text: "Run the installer.", + }, + ], + }, + ], + }, + ], + }, + { + type: "codeBlock", + attrs: { language: "bash" }, + content: [{ type: "text", text: "cargo new hello-rust" }], }, ], }, @@ -182,6 +225,10 @@ const lessonUpdateExample = { published: true, }; +const lessonEvaluationExample = { + answers: [[0], [1, 2]], +}; + export const productsApiOpenApi = { tags: [ { @@ -203,6 +250,11 @@ export const productsApiOpenApi = { description: "Enroll customers and read enrollment/progress snapshots.", }, + { + name: "Product Learner Actions", + description: + "Submit learner quiz evaluations and mark lessons complete.", + }, ], paths: { "/api/products": { @@ -676,6 +728,51 @@ export const productsApiOpenApi = { }, }, }, + "/api/products/{productId}/customers/{userId}/lessons/{lessonId}/evaluations": + { + post: { + tags: ["Product Learner Actions"], + summary: "Evaluate a quiz lesson for a product customer", + description: + "Evaluates quiz answers for the target product customer using existing CourseLit learner runtime behavior. This records the evaluation result but does not mark the lesson complete.", + operationId: "evaluateProductCustomerLesson", + security: secured, + parameters: [productIdParam, userIdParam, lessonIdParam], + requestBody: jsonBody( + { + $ref: "#/components/schemas/LessonEvaluationRequest", + }, + lessonEvaluationExample, + ), + responses: { + 200: jsonResponse( + "#/components/schemas/LessonEvaluationResult", + ), + 400: error("Answers are missing or malformed."), + 404: error("Customer or lesson not found."), + 422: error("Lesson could not be evaluated."), + }, + }, + }, + "/api/products/{productId}/customers/{userId}/lessons/{lessonId}/completion": + { + post: { + tags: ["Product Learner Actions"], + summary: "Mark a product customer lesson complete", + description: + "Marks a lesson complete for the target product customer using existing CourseLit learner runtime behavior. Quiz lessons still require a passing evaluation first.", + operationId: "completeProductCustomerLesson", + security: secured, + parameters: [productIdParam, userIdParam, lessonIdParam], + responses: { + 200: jsonResponse( + "#/components/schemas/LessonCompletionResponse", + ), + 404: error("Customer or lesson not found."), + 422: error("Lesson could not be marked complete."), + }, + }, + }, }, components: { schemas: { @@ -966,8 +1063,7 @@ export const productsApiOpenApi = { }, TiptapDocument: { type: "object", - description: - "Tiptap/ProseMirror document JSON used by `text` lessons.", + description: tiptapDocumentDescription, required: ["type", "content"], properties: { type: { type: "string", example: "doc" }, @@ -1198,6 +1294,36 @@ export const productsApiOpenApi = { updatedAt: { type: "string", format: "date-time" }, }, }, + LessonEvaluationRequest: { + type: "object", + required: ["answers"], + properties: { + answers: { + type: "array", + description: + "Quiz answers by question index. Each nested array contains selected option indexes for that question.", + items: { + type: "array", + items: { type: "number" }, + }, + }, + }, + }, + LessonEvaluationResult: { + type: "object", + properties: { + pass: { type: "boolean" }, + score: { type: "number" }, + requiresPassingGrade: { type: "boolean" }, + passingGrade: { type: "number" }, + }, + }, + LessonCompletionResponse: { + type: "object", + properties: { + completed: { type: "boolean" }, + }, + }, }, }, }; diff --git a/apps/web/config/strings.ts b/apps/web/config/strings.ts index 156c751ef..69187bbe4 100644 --- a/apps/web/config/strings.ts +++ b/apps/web/config/strings.ts @@ -70,6 +70,7 @@ export const responses = { mimetype_is_required: "Mimetype is required", existing_group: "A group with that name exists", group_not_empty: "This section has lessons. Delete them before proceeding", + group_not_found: "Section not found", update_payment_method: "You need to set up a payment method to create paid content.", currency_iso_code_required: diff --git a/apps/web/graphql/courses/__tests__/reorder-groups.test.ts b/apps/web/graphql/courses/__tests__/reorder-groups.test.ts index 1192dac88..d109b4635 100644 --- a/apps/web/graphql/courses/__tests__/reorder-groups.test.ts +++ b/apps/web/graphql/courses/__tests__/reorder-groups.test.ts @@ -134,7 +134,7 @@ describe("reorderGroups", () => { const rankById = new Map( (updatedCourse?.groups ?? []).map((group: any) => [ - group._id.toString(), + group._id, group.rank, ]), ); @@ -143,9 +143,7 @@ describe("reorderGroups", () => { expect(rankById.get(groupId1)).toBe(2000); expect(rankById.get(groupId2)).toBe(3000); expect( - (updatedCourse?.groups ?? []).map((group: any) => - group._id.toString(), - ), + (updatedCourse?.groups ?? []).map((group: any) => group._id), ).toEqual([groupId3, groupId1, groupId2]); expect( (reorderedCourse.groups ?? []).map((group: any) => group.id), diff --git a/apps/web/graphql/courses/logic.ts b/apps/web/graphql/courses/logic.ts index 939a7caba..7ffda0b88 100644 --- a/apps/web/graphql/courses/logic.ts +++ b/apps/web/graphql/courses/logic.ts @@ -132,7 +132,7 @@ async function formatCourse( const sortedGroups = course!.groups ?.map((group: any) => ({ ...group, - id: group._id.toString(), + id: group._id, })) .sort( (groupA: any, groupB: any) => @@ -849,8 +849,7 @@ export const updateGroup = async ({ const $set = {}; if (name) { - const existingName = (group) => - group.name === name && group._id.toString() !== id; + const existingName = (group) => group.name === name && group._id !== id; if (course.groups?.some(existingName)) { throw new Error(responses.existing_group); @@ -997,7 +996,7 @@ export const moveLesson = async ({ const destinationGroupIndex = normalizedGroups.findIndex((group: any) => { const groupId = group._id ?? group.id; - return groupId?.toString() === destinationGroupId; + return groupId === destinationGroupId; }); if (destinationGroupIndex === -1) { throw new Error(responses.invalid_input); diff --git a/apps/web/graphql/lessons/__tests__/scorm.test.ts b/apps/web/graphql/lessons/__tests__/scorm.test.ts index 9baf77343..591d8d4a5 100644 --- a/apps/web/graphql/lessons/__tests__/scorm.test.ts +++ b/apps/web/graphql/lessons/__tests__/scorm.test.ts @@ -4,7 +4,6 @@ import UserModel from "@/models/User"; import CourseModel from "@/models/Course"; import DomainModel from "@/models/Domain"; import { Constants } from "@courselit/common-models"; -import mongoose from "mongoose"; const SUITE_PREFIX = `scorm-tests-${Date.now()}`; const id = (suffix: string) => `${SUITE_PREFIX}-${suffix}`; @@ -37,7 +36,7 @@ describe("SCORM Logic Integration", () => { purchases: [], }); - const groupId = new mongoose.Types.ObjectId().toString(); + const groupId = id("group-1"); // Create Course course = await CourseModel.create({ diff --git a/apps/web/graphql/lessons/__tests__/visibility.test.ts b/apps/web/graphql/lessons/__tests__/visibility.test.ts index 77c6d5a61..41c8479c5 100644 --- a/apps/web/graphql/lessons/__tests__/visibility.test.ts +++ b/apps/web/graphql/lessons/__tests__/visibility.test.ts @@ -1,5 +1,4 @@ import { Constants } from "@courselit/common-models"; -import mongoose from "mongoose"; import DomainModel from "@/models/Domain"; import UserModel from "@/models/User"; import CourseModel from "@/models/Course"; @@ -7,6 +6,7 @@ import LessonModel from "@/models/Lesson"; import ActivityModel from "@/models/Activity"; import { createLesson, + evaluateLesson, getAllLessons, getLessonDetails, markLessonCompleted, @@ -28,10 +28,15 @@ describe("Lesson visibility and progress", () => { let creator: any; let student: any; let course: any; + let quizCourse: any; let groupId: string; + let quizGroupId: string; + let quizDripGroupId: string; let publishedLessonOne: any; let unpublishedLesson: any; let publishedLessonTwo: any; + let unpublishedQuizLesson: any; + let dripQuizLesson: any; let studentCtx: any; let creatorCtx: any; @@ -64,7 +69,9 @@ describe("Lesson visibility and progress", () => { purchases: [], }); - groupId = new mongoose.Types.ObjectId().toString(); + groupId = id("group-1"); + quizGroupId = id("quiz-group"); + quizDripGroupId = id("quiz-drip-group"); course = await CourseModel.create({ domain: testDomain._id, @@ -93,6 +100,44 @@ describe("Lesson visibility and progress", () => { ], }); + quizCourse = await CourseModel.create({ + domain: testDomain._id, + courseId: id("quiz-course"), + title: "Quiz Visibility Course", + lessons: [], + creatorId: creator.userId, + cost: 0, + privacy: "public", + type: "course", + costType: "free", + slug: id("quiz-course-slug"), + published: true, + groups: [ + { + _id: quizGroupId, + name: "Quiz Group", + lessonsOrder: [], + rank: 1, + collapsed: true, + drip: { + status: false, + type: "relative-date", + }, + }, + { + _id: quizDripGroupId, + name: "Quiz Drip Group", + lessonsOrder: [], + rank: 2, + collapsed: true, + drip: { + status: true, + type: "relative-date", + }, + }, + ], + }); + publishedLessonOne = await LessonModel.create({ domain: testDomain._id, courseId: course.courseId, @@ -141,6 +186,46 @@ describe("Lesson visibility and progress", () => { groupId, }); + const quizContent = { + questions: [ + { + text: "Question 1", + options: [ + { text: "Correct", correctAnswer: true }, + { text: "Incorrect", correctAnswer: false }, + ], + }, + ], + requiresPassingGrade: true, + passingGrade: 70, + }; + + unpublishedQuizLesson = await LessonModel.create({ + domain: testDomain._id, + courseId: quizCourse.courseId, + lessonId: id("unpublished-quiz"), + title: "Unpublished Quiz", + type: Constants.LessonType.QUIZ, + published: false, + requiresEnrollment: true, + content: quizContent, + creatorId: creator.userId, + groupId: quizGroupId, + }); + + dripQuizLesson = await LessonModel.create({ + domain: testDomain._id, + courseId: quizCourse.courseId, + lessonId: id("drip-quiz"), + title: "Drip Quiz", + type: Constants.LessonType.QUIZ, + published: true, + requiresEnrollment: true, + content: quizContent, + creatorId: creator.userId, + groupId: quizDripGroupId, + }); + course.lessons = [ publishedLessonOne.lessonId, unpublishedLesson.lessonId, @@ -153,11 +238,24 @@ describe("Lesson visibility and progress", () => { ]; await course.save(); + quizCourse.lessons = [ + unpublishedQuizLesson.lessonId, + dripQuizLesson.lessonId, + ]; + quizCourse.groups[0].lessonsOrder = [unpublishedQuizLesson.lessonId]; + quizCourse.groups[1].lessonsOrder = [dripQuizLesson.lessonId]; + await quizCourse.save(); + student.purchases.push({ courseId: course.courseId, accessibleGroups: [groupId], completedLessons: [], }); + student.purchases.push({ + courseId: quizCourse.courseId, + accessibleGroups: [quizGroupId], + completedLessons: [], + }); student.markModified("purchases"); await student.save(); @@ -275,6 +373,29 @@ describe("Lesson visibility and progress", () => { expect(savedLesson?.media?.file).toBeUndefined(); }); + it("rejects creating a lesson when the groupId does not exist in the course", async () => { + await expect( + createLesson( + { + title: "Orphan lesson", + type: Constants.LessonType.TEXT, + content: JSON.stringify({ type: "doc", content: [] }), + courseId: course.courseId, + groupId: "nonexistent-section", + requiresEnrollment: false, + published: false, + } as any, + { + user: { + ...creator.toObject(), + permissions: ["course:manage"], + }, + subdomain: testDomain, + } as any, + ), + ).rejects.toThrow(responses.group_not_found); + }); + it("should hide unpublished lessons from owners in learner lesson details", async () => { await expect( getLessonDetails( @@ -324,4 +445,24 @@ describe("Lesson visibility and progress", () => { markLessonCompleted(unpublishedLesson.lessonId, creatorCtx), ).rejects.toThrow(responses.item_not_found); }); + + it("should not allow evaluating unpublished quiz lessons", async () => { + await expect( + evaluateLesson( + unpublishedQuizLesson.lessonId, + { answers: [[0]] }, + studentCtx, + ), + ).rejects.toThrow(responses.item_not_found); + }); + + it("should not allow evaluating drip-locked quiz lessons", async () => { + await expect( + evaluateLesson( + dripQuizLesson.lessonId, + { answers: [[0]] }, + studentCtx, + ), + ).rejects.toThrow(responses.drip_not_released); + }); }); diff --git a/apps/web/graphql/lessons/logic.ts b/apps/web/graphql/lessons/logic.ts index 1cb673946..fb2da40c0 100644 --- a/apps/web/graphql/lessons/logic.ts +++ b/apps/web/graphql/lessons/logic.ts @@ -187,6 +187,13 @@ export const createLesson = async ( if (!course) throw new Error(responses.item_not_found); if (course.isBlog) throw new Error(responses.cannot_add_to_blogs); // TODO: refactor this + const group = course.groups?.find( + (group) => (group as any)._id === lessonData.groupId, + ); + if (!group) { + throw new Error(responses.group_not_found); + } + const lesson = await LessonModel.create({ domain: ctx.subdomain._id, title: lessonData.title, @@ -204,11 +211,7 @@ export const createLesson = async ( }); course.lessons.push(lesson.lessonId); - const group = course.groups?.find( - (group) => - ((group as any)._id?.toString() ?? "") === lessonData.groupId, - ); - group?.lessonsOrder.push(lesson.lessonId); + group.lessonsOrder.push(lesson.lessonId); await (course as any).save(); return lesson; @@ -629,7 +632,7 @@ export const evaluateLesson = async ( domain: ctx.subdomain._id, lessonId, }); - if (!lesson) { + if (!lesson || !lesson.published) { throw new Error(responses.item_not_found); } @@ -641,6 +644,16 @@ export const evaluateLesson = async ( throw new Error(responses.not_enrolled); } + if (await isPartOfDripGroup(lesson, ctx.subdomain._id)) { + const groupIsNotInAccessibleGroups = + ctx.user.purchases[enrolledItemIndex].accessibleGroups.indexOf( + lesson.groupId, + ) === -1; + if (groupIsNotInAccessibleGroups) { + throw new Error(responses.drip_not_released); + } + } + if (lesson.type !== quiz) { throw new Error(responses.cannot_be_evaluated); } diff --git a/apps/web/openapi/generated/openapi.json b/apps/web/openapi/generated/openapi.json index 9cb37c780..0f84ee36e 100644 --- a/apps/web/openapi/generated/openapi.json +++ b/apps/web/openapi/generated/openapi.json @@ -32,6 +32,10 @@ "name": "Product Customers", "description": "Enroll customers and read enrollment/progress snapshots." }, + { + "name": "Product Learner Actions", + "description": "Submit learner quiz evaluations and mark lessons complete." + }, { "name": "Media Uploads", "description": "Generate MediaLit signatures for direct media uploads." @@ -1307,12 +1311,68 @@ "content": { "type": "doc", "content": [ + { + "type": "heading", + "attrs": { + "level": 2 + }, + "content": [ + { + "type": "text", + "text": "Install Rust" + } + ] + }, { "type": "paragraph", "content": [ { "type": "text", - "text": "Welcome to the course!" + "text": "Install " + }, + { + "type": "text", + "marks": [ + { + "type": "bold" + } + ], + "text": "rustup" + }, + { + "type": "text", + "text": " and create your first project." + } + ] + }, + { + "type": "bulletList", + "content": [ + { + "type": "listItem", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Run the installer." + } + ] + } + ] + } + ] + }, + { + "type": "codeBlock", + "attrs": { + "language": "bash" + }, + "content": [ + { + "type": "text", + "text": "cargo new hello-rust" } ] } @@ -1781,6 +1841,171 @@ } } }, + "/api/products/{productId}/customers/{userId}/lessons/{lessonId}/evaluations": { + "post": { + "tags": ["Product Learner Actions"], + "summary": "Evaluate a quiz lesson for a product customer", + "description": "Evaluates quiz answers for the target product customer using existing CourseLit learner runtime behavior. This records the evaluation result but does not mark the lesson complete.", + "operationId": "evaluateProductCustomerLesson", + "security": [ + { + "ApiKeyAuth": [] + } + ], + "parameters": [ + { + "name": "productId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "userId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "lessonId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LessonEvaluationRequest" + }, + "example": { + "answers": [[0], [1, 2]] + } + } + } + }, + "responses": { + "200": { + "description": "Success.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LessonEvaluationResult" + } + } + } + }, + "400": { + "description": "Answers are missing or malformed.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicApiErrorResponse" + } + } + } + }, + "404": { + "description": "Customer or lesson not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicApiErrorResponse" + } + } + } + }, + "422": { + "description": "Lesson could not be evaluated.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicApiErrorResponse" + } + } + } + } + } + } + }, + "/api/products/{productId}/customers/{userId}/lessons/{lessonId}/completion": { + "post": { + "tags": ["Product Learner Actions"], + "summary": "Mark a product customer lesson complete", + "description": "Marks a lesson complete for the target product customer using existing CourseLit learner runtime behavior. Quiz lessons still require a passing evaluation first.", + "operationId": "completeProductCustomerLesson", + "security": [ + { + "ApiKeyAuth": [] + } + ], + "parameters": [ + { + "name": "productId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "userId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "lessonId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LessonCompletionResponse" + } + } + } + }, + "404": { + "description": "Customer or lesson not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicApiErrorResponse" + } + } + } + }, + "422": { + "description": "Lesson could not be marked complete.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicApiErrorResponse" + } + } + } + } + } + } + }, "/api/media/presigned": { "post": { "tags": ["Media Uploads"], @@ -2335,7 +2560,7 @@ }, "TiptapDocument": { "type": "object", - "description": "Tiptap/ProseMirror document JSON used by `text` lessons.", + "description": "Tiptap/ProseMirror document JSON used by `text` lessons. Supported nodes include `doc`, `paragraph`, `heading` (`level` 1, 2, or 3), `text`, `bulletList`, `orderedList`, `listItem`, `blockquote`, `horizontalRule`, `codeBlock`, `table`, `tableRow`, `tableHeader`, `tableCell`, `image`, and `hardBreak`. Supported marks include `bold`, `italic`, `underline`, `strike`, `code`, `link`, and `highlight`. An `image` node uses `attrs` with `src` (required), `alt` (optional), and `title` (optional).", "required": ["type", "content"], "properties": { "type": { @@ -2745,6 +2970,47 @@ } } }, + "LessonEvaluationRequest": { + "type": "object", + "required": ["answers"], + "properties": { + "answers": { + "type": "array", + "description": "Quiz answers by question index. Each nested array contains selected option indexes for that question.", + "items": { + "type": "array", + "items": { + "type": "number" + } + } + } + } + }, + "LessonEvaluationResult": { + "type": "object", + "properties": { + "pass": { + "type": "boolean" + }, + "score": { + "type": "number" + }, + "requiresPassingGrade": { + "type": "boolean" + }, + "passingGrade": { + "type": "number" + } + } + }, + "LessonCompletionResponse": { + "type": "object", + "properties": { + "completed": { + "type": "boolean" + } + } + }, "MediaPresignedResponse": { "type": "object", "required": ["signature", "endpoint"], diff --git a/docs/PUBLIC_API_PRODUCT_AND_LEARNER_MANAGEMENT_PRD.md b/docs/PUBLIC_API_PRODUCT_AND_LEARNER_MANAGEMENT_PRD.md index 47472291b..5b71f6f5f 100644 --- a/docs/PUBLIC_API_PRODUCT_AND_LEARNER_MANAGEMENT_PRD.md +++ b/docs/PUBLIC_API_PRODUCT_AND_LEARNER_MANAGEMENT_PRD.md @@ -258,8 +258,8 @@ This PRD proposes expanding the public REST API by reusing those existing busine - SCORM lesson creation, SCORM package processing, or raw SCORM runtime ingestion over the public API - Direct multipart file upload as part of lesson create/update requests - Customer-runtime `/api/me` endpoints -- Public REST endpoints for customer-facing lesson completion or quiz evaluation -- Privileged API-key writes that set or overwrite a customer's progress +- Arbitrary customer progress write endpoints beyond the existing learner lesson actions explicitly listed in this PRD +- Privileged API-key writes that set or overwrite a customer's progress without going through existing lesson completion or quiz evaluation behavior - Bulk import/export jobs in v1 - Webhooks in this PRD - Any new platform capability that does not already exist in UI/GraphQL/business logic today @@ -307,6 +307,7 @@ Capability parity gate: - Product customer roster retrieval - Customer enrollment detail retrieval as a single-row view of existing product roster/member data - Customer progress read APIs +- Learner lesson actions for quiz evaluation and lesson completion using existing `evaluateLesson` and `markLessonCompleted` behavior - OpenAPI docs and route-level contract tests ### Product Types In Scope @@ -492,6 +493,18 @@ If implementation discovers that an endpoint requires behavior beyond the mapped - fetch customer progress snapshot - this must be read-only and limited to progress/completion data already tracked by CourseLit today +### Learner Lesson Actions + +- `POST /api/products/{productId}/customers/{userId}/lessons/{lessonId}/evaluations` + - evaluate a quiz lesson submission for the target customer + - request body: `{ "answers": number[][] }` + - backed by existing `evaluateLesson` + - evaluation records the existing lesson evaluation result but does not mark the lesson complete +- `POST /api/products/{productId}/customers/{userId}/lessons/{lessonId}/completion` + - mark a lesson complete for the target customer + - backed by existing `markLessonCompleted` + - quiz lessons must still have a passing evaluation before completion, matching existing behavior + ## Data Contracts ### Product Representation @@ -692,6 +705,17 @@ V1 should not expose raw `scormData` publicly. Progress fields may be derived from existing CourseLit progress data, published lesson counts, and reporting helpers, but must not introduce any new progress state or write capability. +### Lesson Evaluation Result + +The public lesson evaluation result should mirror the existing quiz evaluation result: + +- `pass` +- `score` +- `requiresPassingGrade` +- `passingGrade` + +The evaluation endpoint must not return correct answers or raw lesson content. + ## Write Semantics ### Product Create @@ -805,12 +829,31 @@ If the customer is already actively enrolled, the endpoint should preserve exist ### Customer Progress -Customer progress is read-only in this API scope. +Customer progress reads remain read-only in this API scope. Important constraints: -- API-key-based callers should not gain a new ability to arbitrarily set another customer’s progress unless that already exists elsewhere in the platform. -- Customer-facing lesson completion, quiz evaluation, and `/api/me` runtime APIs are not part of this implementation spec. +- API-key-based callers must not gain a new ability to arbitrarily set another customer’s progress. +- The only learner progress writes in scope are existing lesson runtime actions exposed as REST: quiz evaluation through `evaluateLesson` and lesson completion through `markLessonCompleted`. +- These learner action endpoints must resolve the target customer by path `userId`, set `ctx.user` to that customer, and then call the existing GraphQL lesson function without modifying `apps/web/graphql/**`. +- Quiz evaluation and lesson completion remain separate operations; successful quiz evaluation does not automatically mark the lesson complete. +- `/api/me` runtime APIs are not part of this implementation spec. + +### Learner Lesson Actions + +Learner lesson action endpoints should expose existing runtime behavior for integrations that need to submit quiz answers or mark a customer's lesson complete. + +Expected behavior: + +- require a valid tenant API key and school domain +- resolve the school owner only to authenticate the API key request using the standard public API auth path +- resolve `{userId}` as a `User` in the same domain and fail with `404` if the learner cannot be found +- verify `{lessonId}` belongs to `{productId}` before invoking lesson runtime behavior +- call `evaluateLesson(lessonId, { answers }, learnerCtx)` for quiz evaluation +- call `markLessonCompleted(lessonId, learnerCtx)` for completion +- preserve existing errors for not enrolled, unreleased drip content, non-quiz evaluation, missing answers, and quiz completion before passing + +These endpoints must not accept `creatorId`, learner email, target domain, or any caller-controlled ownership fields. ## Permissions And Authentication @@ -827,6 +870,7 @@ Authorization: - customer management endpoints require user/product-management capability equivalent to current internal checks - media upload signature access via API key resolves the school owner as the integration actor and requires that resolved owner user to pass the existing `media:manage` permission check - media upload signature access via dashboard session continues to require the logged-in user permission `media:manage` +- learner lesson action endpoints use the resolved school owner only for API-key validation, then deliberately switch `ctx.user` to the domain learner identified by path `userId` V1 API key decision: @@ -835,6 +879,7 @@ V1 API key decision: - API-key settings UI and API-key persistence model remain unchanged - user-owned keys or per-key permission models may be revisited later, but are out of scope here - after a valid API key is resolved, the REST auth layer resolves the school owner and sets that user as `ctx.user` for all new public API routes +- learner lesson action routes are the only exception: after API-key validation, they set `ctx.user` to the target learner resolved from the same domain before calling existing lesson runtime logic - if the school owner cannot be resolved, the request fails with `403` and no route-specific business logic runs ### Media Upload Signature Authorization @@ -901,12 +946,12 @@ The expected behavior is: | Customer `User.userId` | Represents the target customer, created or reused by existing user/customer flows. It is never the API key, and it is not the school owner unless the target email is the owner. | | `Membership.userId` | Represents the target enrolled customer, not the API key and not the integration actor. | | `User.purchases[*]` progress records | Stored on the target customer user, not on the integration actor. | -| Certificates, lesson evaluations, activity, and other customer-runtime `userId` records | Not created by the management API except where existing enrollment/payment side effects already do so. Customer progress remains read-only in this PRD. | +| Certificates, lesson evaluations, activity, and other customer-runtime `userId` records | Created only through the existing learner lesson action behavior explicitly exposed by this PRD, or where existing enrollment/payment side effects already do so. These records use the target learner user resolved from the same domain. | This split is intentional: - creator/owner fields required by admin authoring flows use the resolved school owner integration actor -- customer/enrollment/progress fields use the target customer user +- customer/enrollment/progress and learner action fields use the target customer user - API keys themselves are not stored as `creatorId` or `userId` - public request payloads must not expose ownership assignment knobs @@ -1113,6 +1158,8 @@ Add or update tests for: - already-enrolled user - customer roster pagination and filtering - progress retrieval +- learner lesson quiz evaluation, including target learner context switching and malformed answer payloads +- learner lesson completion, including quiz completion before passing and lesson/product path mismatch - OpenAPI generation including new route fragments Preferred test locations: @@ -1128,18 +1175,20 @@ Preferred test locations: 4. Authenticated admin/integration callers can create and manage product sections/groups and lessons via REST using existing CourseLit behavior. 5. Authenticated admin/integration callers can list product customers, fetch customer enrollment snapshots, and enroll customers into supported products. 6. Authenticated admin/integration callers can read customer progress for supported product types. -7. No API endpoint in this scope introduces behavior that is not already supported by the existing CourseLit system. -8. No `/api/me` customer-runtime endpoints are added as part of this work. -9. All new endpoints appear in generated OpenAPI output and development Swagger UI. -10. All new endpoints respect school-domain isolation and existing permission rules. -11. Existing `/api/user` behavior remains backward compatible. -12. Swagger documentation is upgraded so the new product, payment-plan, content, customer, media upload, and progress APIs are discoverable, example-driven, and testable through the UI. -13. Every API-key-authenticated route resolves the school owner as the integration actor, sets that user as `ctx.user` where existing logic needs context, and fails with `403` if the owner cannot be resolved. -14. Every endpoint has an explicit existing-capability mapping, and implementation does not proceed for endpoints whose mapping cannot be proven. -15. Product/customer API work does not modify product/customer business logic in `apps/web/graphql/**`. -16. Existing API endpoint handlers and contracts remain backward compatible. -17. `/api/media/presigned` accepts API keys by resolving the school owner actor and requiring that actor to pass the existing `media:manage` permission check; it continues accepting logged-in dashboard sessions with `media:manage`. -18. Swagger does not expose or request CourseLit session-cookie tokens. +7. Authenticated admin/integration callers can submit quiz evaluations and mark lessons complete for enrolled customers using existing learner lesson behavior. +8. No API endpoint in this scope introduces behavior that is not already supported by the existing CourseLit system. +9. No `/api/me` customer-runtime endpoints are added as part of this work. +10. All new endpoints appear in generated OpenAPI output and development Swagger UI. +11. All new endpoints respect school-domain isolation and existing permission rules. +12. Existing `/api/user` behavior remains backward compatible. +13. Swagger documentation is upgraded so the new product, payment-plan, content, customer, media upload, and progress APIs are discoverable, example-driven, and testable through the UI. +14. Every API-key-authenticated management route resolves the school owner as the integration actor, sets that user as `ctx.user` where existing logic needs context, and fails with `403` if the owner cannot be resolved. +15. Every learner lesson action route resolves the school owner for API-key validation, then sets `ctx.user` to the same-domain target learner before calling existing lesson runtime logic. +16. Every endpoint has an explicit existing-capability mapping, and implementation does not proceed for endpoints whose mapping cannot be proven. +17. Product/customer API work does not modify product/customer business logic in `apps/web/graphql/**`. +18. Existing API endpoint handlers and contracts remain backward compatible. +19. `/api/media/presigned` accepts API keys by resolving the school owner actor and requiring that actor to pass the existing `media:manage` permission check; it continues accepting logged-in dashboard sessions with `media:manage`. +20. Swagger does not expose or request CourseLit session-cookie tokens. ## Task Breakdown @@ -1298,6 +1347,7 @@ Preferred test locations: - Estimated scope: M - [ ] Task 15: Customer progress read endpoint + - Description: Add `GET /api/products/{productId}/customers/{userId}/progress` as a read-only view over existing progress/reporting data. - Acceptance: Route returns course/download progress fields derived from existing state, excludes raw `scormData`, and provides no write path for progress. - Verify: `pnpm test` @@ -1305,37 +1355,46 @@ Preferred test locations: - Files: `apps/web/app/api/products/*/customers/*/progress*`, REST-layer adapters, route tests, OpenAPI fragments - Estimated scope: M +- [ ] Task 16: Learner lesson action endpoints + - Description: Add quiz evaluation and lesson completion endpoints for a product customer using existing `evaluateLesson` and `markLessonCompleted` behavior. + - Acceptance: Routes resolve the target learner by same-domain `userId`, switch `ctx.user` to that learner before calling existing lesson runtime logic, verify the lesson belongs to the product path, keep evaluation and completion as separate operations, and preserve existing validation for enrollment, drip release, missing answers, non-quiz lessons, and quiz completion before passing. + - Verify: `pnpm test`, `pnpm --filter @courselit/web openapi:generate` + - Dependencies: Tasks 10, 13, 15 + - Files: `apps/web/app/api/products/*/customers/*/lessons*`, REST-layer adapters, route tests, OpenAPI fragments + - Estimated scope: M + ### Checkpoint: Customers And Progress - [ ] Customer invite/enroll flow works through REST using only existing behavior - [ ] Customer roster/detail/progress reads match existing reporting semantics -- [ ] No `/api/me` endpoints or progress write endpoints are added +- [ ] Learner quiz evaluation and lesson completion work through REST without adding arbitrary progress writes +- [ ] No `/api/me` endpoints are added ### Phase 5: Documentation And Release Readiness -- [ ] Task 16: Swagger workflow documentation +- [ ] Task 17: Swagger workflow documentation - Description: Upgrade generated Swagger/OpenAPI documentation for the complete public API flow. - Acceptance: Swagger includes tags, reusable schemas, operation IDs, examples, owner-backed API-key auth, pagination defaults, destructive-route warnings, SCORM rejection examples, and draft → payment plan → publish workflow examples. - Verify: `pnpm --filter @courselit/web openapi:generate`, manual Swagger review - - Dependencies: Tasks 1-15 + - Dependencies: Tasks 1-16 - Files: `apps/web/openapi/*`, route OpenAPI fragments - Estimated scope: M -- [ ] Task 17: Developer documentation +- [ ] Task 18: Developer documentation - Description: Update public developer docs after implementation. - Acceptance: Docs cover products, payment plans, content authoring, owner-backed API-key auth, media upload flow, customers, progress, auth, tenant/domain model, unsupported SCORM, and no `course.cost`/`course.costType` contract. - Verify: manual docs review, docs build if applicable - - Dependencies: Task 16 + - Dependencies: Task 17 - Files: `apps/docs/src/pages/en/developers/*` - Estimated scope: M -- [ ] Task 18: Final hardening and regression guard +- [ ] Task 19: Final hardening and regression guard - Description: Run the full verification suite and confirm implementation boundaries. - Acceptance: Tests/lint/format/OpenAPI generation pass, product/customer GraphQL business logic remains unchanged, existing API endpoint handlers are backward compatible, and every endpoint has an existing-capability mapping. - Verify: `pnpm test`, `pnpm lint`, `pnpm prettier`, `pnpm --filter @courselit/web openapi:generate` - - Dependencies: Tasks 1-17 + - Dependencies: Tasks 1-18 - Files: no feature files unless fixing verification failures - Estimated scope: S