diff --git a/.changeset/config.json b/.changeset/config.json
index 9ab41124f..52fe169dd 100644
--- a/.changeset/config.json
+++ b/.changeset/config.json
@@ -9,7 +9,6 @@
"updateInternalDependencies": "patch",
"ignore": [
"@courselit/web",
- "@courselit/state-management",
"@courselit/utils",
"@courselit/queue",
"@courselit/icons",
diff --git a/.cursor/rules/basics.mdc b/.cursor/rules/basics.mdc
new file mode 100644
index 000000000..882945ab0
--- /dev/null
+++ b/.cursor/rules/basics.mdc
@@ -0,0 +1,11 @@
+---
+alwaysApply: true
+---
+
+- 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`.
+- 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.
+- In `apps/web` workspace, create a string first in `apps/web/ui-config/strings.ts` and then import it in the `.tsx` files, instead of using inline strings.
+- 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.
\ No newline at end of file
diff --git a/.eslintignore b/.eslintignore
deleted file mode 100644
index 40b878db5..000000000
--- a/.eslintignore
+++ /dev/null
@@ -1 +0,0 @@
-node_modules/
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index 13974cbeb..21f9023a2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -14,7 +14,6 @@ coverage
# Text editors configurations
.vscode
.rgignore
-.cursor
# Env file
.env*.local
@@ -39,4 +38,7 @@ report*.json
# Dev tools files
.eslintcache
-.npmrc
\ No newline at end of file
+.npmrc
+
+# Jest files
+globalConfig.json
\ No newline at end of file
diff --git a/.husky/pre-commit b/.husky/pre-commit
index 25d223573..d0a778429 100755
--- a/.husky/pre-commit
+++ b/.husky/pre-commit
@@ -1,4 +1 @@
-#!/usr/bin/env sh
-. "$(dirname "$0")/_/husky.sh"
-
npx lint-staged
\ No newline at end of file
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 000000000..61679526c
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,20 @@
+## 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`.
+- 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.
+- 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.
+- 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.
+
+## Testing instructions
+
+- Always add or update test 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.
+
+## PR instructions
+
+- Always run `pnpm lint` and `pnpm prettier` before committing.
diff --git a/apps/docs/public/assets/communities/delete-community.png b/apps/docs/public/assets/communities/delete-community.png
new file mode 100644
index 000000000..c4ee06826
Binary files /dev/null and b/apps/docs/public/assets/communities/delete-community.png differ
diff --git a/apps/docs/public/assets/products/accomplishment-page.png b/apps/docs/public/assets/products/accomplishment-page.png
new file mode 100644
index 000000000..def0fbf78
Binary files /dev/null and b/apps/docs/public/assets/products/accomplishment-page.png differ
diff --git a/apps/docs/public/assets/products/certificate-badge-product-card.png b/apps/docs/public/assets/products/certificate-badge-product-card.png
new file mode 100644
index 000000000..1a9911d47
Binary files /dev/null and b/apps/docs/public/assets/products/certificate-badge-product-card.png differ
diff --git a/apps/docs/public/assets/products/certificate-template-customization-expand.png b/apps/docs/public/assets/products/certificate-template-customization-expand.png
new file mode 100644
index 000000000..fdc989ee9
Binary files /dev/null and b/apps/docs/public/assets/products/certificate-template-customization-expand.png differ
diff --git a/apps/docs/public/assets/products/courselit-certificate-example.jpg b/apps/docs/public/assets/products/courselit-certificate-example.jpg
new file mode 100644
index 000000000..c2b365304
Binary files /dev/null and b/apps/docs/public/assets/products/courselit-certificate-example.jpg differ
diff --git a/apps/docs/public/assets/products/customize-certificate-texts.png b/apps/docs/public/assets/products/customize-certificate-texts.png
new file mode 100644
index 000000000..592b4f775
Binary files /dev/null and b/apps/docs/public/assets/products/customize-certificate-texts.png differ
diff --git a/apps/docs/public/assets/products/issue-certificate-toggle.png b/apps/docs/public/assets/products/issue-certificate-toggle.png
new file mode 100644
index 000000000..19c1b15df
Binary files /dev/null and b/apps/docs/public/assets/products/issue-certificate-toggle.png differ
diff --git a/apps/docs/public/assets/products/preview-certificate.png b/apps/docs/public/assets/products/preview-certificate.png
new file mode 100644
index 000000000..c03487eb4
Binary files /dev/null and b/apps/docs/public/assets/products/preview-certificate.png differ
diff --git a/apps/docs/public/assets/products/product-manage-menu.png b/apps/docs/public/assets/products/product-manage-menu.png
new file mode 100644
index 000000000..c088909da
Binary files /dev/null and b/apps/docs/public/assets/products/product-manage-menu.png differ
diff --git a/apps/docs/public/assets/products/view-certificate-button.png b/apps/docs/public/assets/products/view-certificate-button.png
new file mode 100644
index 000000000..2926f5815
Binary files /dev/null and b/apps/docs/public/assets/products/view-certificate-button.png differ
diff --git a/apps/docs/public/assets/schools/self-host.svg b/apps/docs/public/assets/schools/self-host.svg
new file mode 100644
index 000000000..5264426ad
--- /dev/null
+++ b/apps/docs/public/assets/schools/self-host.svg
@@ -0,0 +1,3 @@
+
\ No newline at end of file
diff --git a/apps/docs/public/assets/users/delete-user.png b/apps/docs/public/assets/users/delete-user.png
new file mode 100644
index 000000000..2fbc09f44
Binary files /dev/null and b/apps/docs/public/assets/users/delete-user.png differ
diff --git a/apps/docs/src/config.ts b/apps/docs/src/config.ts
index 93a61caa3..5406a66b4 100644
--- a/apps/docs/src/config.ts
+++ b/apps/docs/src/config.ts
@@ -47,6 +47,7 @@ export const SIDEBAR: Sidebar = {
en: {
"Getting started": [
{ text: "What is CourseLit", link: "en/introduction" },
+ { text: "Our vision", link: "en/vision" },
{ text: "Features", link: "en/features" },
],
"Online courses": [
@@ -58,6 +59,7 @@ export const SIDEBAR: Sidebar = {
{ text: "Add content", link: "en/courses/add-content" },
{ text: "Manage sections", link: "en/products/section" },
{ text: "Invite customers", link: "en/products/invite-customers" },
+ { text: "Certificates", link: "en/products/certificates" },
],
"Digital downloads": [
{ text: "Introduction", link: "en/downloads/introduction" },
@@ -77,6 +79,7 @@ export const SIDEBAR: Sidebar = {
text: "Unlock additional products",
link: "en/communities/grant-access-to-additional-products",
},
+ { text: "Delete a community", link: "en/communities/delete" },
],
"Email marketing and automation": [
{ text: "Introduction", link: "en/email-marketing/introduction" },
@@ -120,6 +123,7 @@ export const SIDEBAR: Sidebar = {
{ text: "User permissions", link: "en/users/permissions" },
{ text: "Filter users", link: "en/users/filters" },
{ text: "Segment users", link: "en/users/segments" },
+ { text: "Delete a user", link: "en/users/delete" },
],
Developers: [
{ text: "Introduction", link: "en/developers/introduction" },
diff --git a/apps/docs/src/pages/en/communities/delete.md b/apps/docs/src/pages/en/communities/delete.md
new file mode 100644
index 000000000..7de751c94
--- /dev/null
+++ b/apps/docs/src/pages/en/communities/delete.md
@@ -0,0 +1,201 @@
+---
+title: Delete a Community
+description: Guide to safely deleting communities and all associated data
+layout: ../../../layouts/MainLayout.astro
+---
+
+Deleting a community is a permanent operation that removes the community and all its associated data from your school. This includes all posts, comments, members, and payment plans.
+
+> **Important**: Community deletion is permanent and cannot be undone. All community content, memberships, and payment plans will be permanently removed.
+
+## How Community Deletion Works
+
+When you delete a community, CourseLit performs a comprehensive cleanup:
+
+1. **Content Deletion**: Removes all posts, comments, and reports
+2. **Membership Cleanup**: Cancels all memberships and payment plans
+3. **Page Removal**: Deletes the community's public page
+4. **Media Cleanup**: Removes all associated media files
+5. **Community Document**: Deletes the community itself
+
+## Prerequisites
+
+To delete a community, you must have the `Manage Community` permission.
+
+> **Note**: Even community moderators cannot delete a community. Only users with the `Manage Community` permission (typically site admins) can perform this operation.
+
+## Deleting a Community
+
+1. Navigate to the **Communities** area from your admin dashboard
+2. Select the community you want to delete
+3. Click on **Manage** to open settings
+4. Scroll down to the Danger zone and click on **Delete Community** button
+
+ 
+
+5. Confirm the deletion when prompted
+
+## What Gets Deleted
+
+### Community Content
+
+All content within the community is permanently removed:
+
+- **Posts**: All posts created in the community
+- **Comments**: All comments on posts, including nested replies
+- **Reports**: All content reports filed by members
+- **Media**: All images, videos, and files uploaded to posts (when media uploads are enabled)
+
+### Memberships & Subscriptions
+
+All membership-related data is removed:
+
+- **Community Memberships**: All member records for the community
+- **Payment Subscriptions**: All active subscriptions are automatically cancelled
+- **Payment Plans**: All payment plans associated with the community
+- **Included Product Memberships**: If the community's payment plans included access to courses, those memberships are also removed
+- **Post Subscriptions**: All user subscriptions to community posts
+
+### Community Infrastructure
+
+The community's infrastructure is removed:
+
+- **Community Page**: The public-facing community page
+- **Community Settings**: All configuration and settings
+- **Categories**: All community categories
+- **Featured Images**: Community banner and featured images
+
+### Related Data
+
+Additional data associated with the community:
+
+- **Activities**: Activity logs related to payment plan enrollments
+- **Notifications**: Notifications related to the community (for members)
+
+## What Happens to Members
+
+When a community is deleted:
+
+1. **Active Subscriptions**: All payment subscriptions are automatically cancelled through your payment provider (Stripe, PayPal, etc.)
+2. **Membership Records**: All membership records are permanently deleted
+3. **Access Revoked**: Members immediately lose access to the community
+4. **Included Products**: If members had access to courses through the community's payment plan, that access is also revoked
+
+## Payment Plan Considerations
+
+### Subscription Cancellations
+
+- All active subscriptions are cancelled automatically
+- Payment providers (Stripe, PayPal, etc.) are notified
+- No further charges will occur
+- Members will not receive refunds automatically
+
+### Included Products
+
+If your community's payment plans [included access to courses](/en/communities/grant-access-to-additional-products) or other products:
+
+- All memberships to those products (activated through the community plan) are removed
+- Activity logs for those memberships are deleted
+- Direct purchases of those products (not through the community) are not affected
+
+## Media Cleanup
+
+The deletion process handles media files appropriately:
+
+- **Community Images**: Featured images and banners are deleted
+- **Post Media**: When media uploads are enabled, all media from posts is deleted
+- **User Avatars**: Not affected (user avatars are tied to user accounts, not communities)
+
+> **Note**: Currently, media uploads in community posts are not enabled. When this feature is activated, the deletion process will handle post media cleanup automatically.
+
+## Safety Measures
+
+CourseLit implements safety measures to ensure proper deletion:
+
+1. **Permission Check**: Only users with `Manage Community` permission can delete
+2. **Confirmation Required**: Deletion requires explicit confirmation
+3. **Atomic Operation**: The entire deletion succeeds or fails as a unit
+4. **Subscription Cancellation**: Automatic cancellation prevents future charges
+
+## Before Deleting a Community
+
+Consider these steps before deleting:
+
+1. **Notify Members**: Inform community members about the upcoming deletion
+2. **Export Data**: If you need to preserve any content, export it manually. Only works for self-hosted installations.
+3. **Handle Refunds**: Process any necessary refunds through your payment provider
+4. **Alternative Actions**: Consider making the community private instead of deleting it
+
+## After Deletion
+
+After a community is deleted:
+
+- The community page returns a 404 error
+- Members cannot access the community anymore
+- All content is permanently lost
+- Payment subscriptions are cancelled
+- The community name becomes available for reuse
+
+## Handling Refunds
+
+Community deletion does not automatically issue refunds. To handle refunds:
+
+1. **Before Deletion**: Note all active subscriptions and their subscription IDs
+2. **Access Payment Provider**: Log into your Stripe, PayPal, or other payment provider dashboard
+3. **Process Refunds**: Manually issue refunds as appropriate
+4. **Delete Community**: Once refunds are processed, proceed with deletion
+
+## Alternative to Deletion
+
+If you want to preserve content but stop new members from joining:
+
+1. **Disable the Community**: Toggle the community to "disabled" in settings
+2. **Remove Payment Plans**: Archive all payment plans
+
+This approach preserves content while preventing new access.
+
+## Troubleshooting
+
+### Cannot Delete Community
+
+If you encounter errors:
+
+- **"Action not allowed"**: You don't have the `Manage Community` permission
+- **"Item not found"**: The community may have already been deleted or doesn't exist
+- **"Community not found"**: You may not have access to this community
+
+### Subscription Cancellation Issues
+
+If subscriptions fail to cancel:
+
+1. Manually cancel subscriptions in your payment provider's dashboard
+2. Contact support if issues persist
+
+### Partial Deletion
+
+If deletion fails partway through:
+
+- The operation is designed to be atomic, but in rare cases, partial deletion may occur
+- Contact support with error details
+- Manual cleanup may be required
+
+## Best Practices
+
+1. **Communicate Early**: Give members advance notice before deletion
+2. **Export Important Content**: Save any valuable discussions or content
+3. **Process Refunds First**: Handle refunds before deleting to maintain records
+4. **Document the Decision**: Keep records of why and when the community was deleted
+5. **Consider Alternatives**: Evaluate if disabling is sufficient instead of deletion
+
+## GDPR and Data Protection
+
+Community deletion helps with data protection compliance:
+
+- All member data within the community is removed
+- Personal information in posts and comments is deleted
+- Membership records are permanently erased
+- The operation can be part of a broader data cleanup strategy
+
+## Stuck somewhere?
+
+We are always here for you. Come chat with us in our Discord channel or send a tweet at @CourseLit.
diff --git a/apps/docs/src/pages/en/features.md b/apps/docs/src/pages/en/features.md
index 8a834b00f..350f39fc7 100644
--- a/apps/docs/src/pages/en/features.md
+++ b/apps/docs/src/pages/en/features.md
@@ -53,6 +53,14 @@ Send broadcasts and email sequences to your audience.
+
+
### Other noteworthy features
@@ -65,10 +73,6 @@ Effortlessly track your students' progress, control platform access, and more.
Invite your entire team and control their access with our intuitive permission editor.
-##### Payment integration
-
-Start getting paid directly with ease—integrate your own [Stripe](https://stripe.com) or [Razorpay](https://razorpay.com) account in just a few clicks!
-
##### Third Party Integrations
Integrate analytics, chatbots etc., by injecting third party code snippets.
@@ -83,4 +87,4 @@ Read and modify the source code to build your own variations.
## Stuck somewhere?
-We are always here for you. Come chat with us in our Discord channel or send a tweet at @CourseLit.
+We are always here for you. Come chat with us in our Discord channel or send a tweet at @CourseLit.
diff --git a/apps/docs/src/pages/en/introduction.md b/apps/docs/src/pages/en/introduction.md
index 4c3642933..08aa2e4d2 100644
--- a/apps/docs/src/pages/en/introduction.md
+++ b/apps/docs/src/pages/en/introduction.md
@@ -6,27 +6,39 @@ layout: ../../layouts/MainLayout.astro
## What is CourseLit?
-CourseLit is a **Learning Management System** for modern day creators who want to sell courses and digital downloads from their own websites.
+CourseLit is an **all-in-one suite** for building, selling, and marketing your digital products, such as courses, memberships and digital downloads.
+
+CourseLit was born out of frustration with the exorbitant costs and limitations of traditional course builders and LMS platforms like Teachable, Thinkific, and Podia. That’s why **we offer all essential features—course creation, memberships, website, analytics, email marketing and payment gateways—in a single Pro Plan priced under $20/month**, empowering solo creators like marketing coaches and small businesses like digital agencies with 1-50 employees to scale without financial strain or costly upgrades.
+
+Plus, CourseLit is fully open-source and self-hostable, letting you host your educational empire at zero license cost—only server expenses of $5–$20/month on AWS or DigitalOcean. Our up-to-date documentation and vibrant community (via Discord and GitHub) make this process a breeze, ensuring you own your platform and destiny, free from vendor lock-in.
+
+## Why did we create it?
+
+We are building the **"WordPress of course and membership platforms"**.
+
+Discover [our vision](/en/vision) for creating CourseLit and learn why we set out to build a unique platform, despite the many options already available.
## Features
-CourseLit has got everything you'd ever need to successfully run your online teaching business.
+CourseLit has everything you'd ever need to successfully run your online teaching business, including **course creation, memberships, website, analytics, email marketing**, and **payment gateways**—all bundled to power your growth.
See all the features [here](/en/features).
## Try CourseLit
-Visit courselit.app to use the cloud hosted version. Sign up for a free account to get a **14 days trial period** to experience the platform without any restrictions. **No credit card required**.
+Visit courselit.app to use the cloud-hosted version. Sign up for a free account to get a **14-day trial period** to experience the platform without any restrictions. **No credit card required**.
## Self Hosting
-CourseLit is an open-source LMS and can be hosted on a server where you control everything. If data ownership is at the center of your business, self-hosting CourseLit is a way to go.
+CourseLit is an open-source LMS and can be hosted on a server where you control everything. If data ownership is at the center of your business, self-hosting CourseLit is the way to go.
-Follow [our guide](/en/self-hosting/introduction) for full, step by step instructions for hosting CourseLit on your server.
+Follow [our guide](/en/self-hosting/introduction) for full, step-by-step instructions for hosting CourseLit on your server.
## Join Our Community
-Join our [Discord](https://discord.com/invite/GR4bQsN) channel to get help, to request for features or to just hang out with an awesome community of creators.
+Join our [Discord](https://discord.com/invite/GR4bQsN) channel to get help, request features, or just hang out with an awesome community of creators.
+
+You can also stay updated by following us on [X](https://x.com/courselit).
## Learn More
diff --git a/apps/docs/src/pages/en/products/certificates.md b/apps/docs/src/pages/en/products/certificates.md
new file mode 100644
index 000000000..277f5bd40
--- /dev/null
+++ b/apps/docs/src/pages/en/products/certificates.md
@@ -0,0 +1,88 @@
+---
+title: Certificates
+description: Issue certificate on course completion
+layout: ../../../layouts/MainLayout.astro
+---
+
+Certificates add significant value to your courses by providing your customers with verifiable credentials they can showcase. CourseLit makes it effortless to issue certificates automatically upon course completion.
+
+Here are the key features of CourseLit certificates:
+
+- A dedicated page for each certificate issued, allowing customers to share a verifiable link on their profiles
+- Customizable text and images to make certificates reflect your brand
+
+**Note**: Certificates are only available for `Course` products.
+
+## Overview
+
+Customers will receive a certificate after completing all lessons in a course.
+
+The certificate will be hosted on a dedicated page that can be used to verify the customer's learning achievement.
+
+Here is an example certificate:
+
+
+
+## Enable certificates for a course
+
+1. Go to the manage section of your `Course` product.
+
+ 
+
+2. Scroll down to the `Certificates` section and toggle on the `Issue certificate` switch.
+
+ 
+
+ That's it! Your customers will now receive a certificate upon course completion.
+
+ > Previous customers will not receive certificates retroactively, as the certificate generation process is automated.
+
+## Customize the certificate
+
+1. Click on the `Chevron` icon on the far right of the `Certificate Template` subsection under the `Certificates` section to expand it.
+
+ 
+
+2. Here are the elements you can customize in a certificate:
+
+ - Certificate Title
+ - Certificate Subtitle
+ - Certificate Description
+ - Signature Name
+ - Signature Designation
+ - Signature Image
+ - Logo (defaults to school's logo if not provided)
+
+3. Modify the text configuration to your preference and click `Save`.
+
+ 
+
+4. `Signature Image` and `Logo` properties are automatically saved.
+
+## Preview the certificate
+
+Click on the `Preview` link located to the left of the `Issue certificates` switch.
+
+You will be taken to a new tab where you can see an actual certificate in PDF format with your changes applied.
+
+
+
+## Customer's experience
+
+Upon completing a course, customers will be taken back to the `My content` lobby. On the product card, they will see a badge indicating that a certificate is available.
+
+
+
+On the course's introduction page, customers will see a `View certificate` button.
+
+
+
+When they click the `View certificate` button, customers are taken to the `Accomplishment` page, where they can view all certification details.
+
+
+
+Customers can also print their certificate by clicking the `Print` button.
+
+## Stuck somewhere?
+
+We are always here for you. Come chat with us in our Discord channel or send a tweet at @CourseLit.
diff --git a/apps/docs/src/pages/en/self-hosting/introduction.md b/apps/docs/src/pages/en/self-hosting/introduction.md
index 5613f3bd4..8d98e375a 100644
--- a/apps/docs/src/pages/en/self-hosting/introduction.md
+++ b/apps/docs/src/pages/en/self-hosting/introduction.md
@@ -15,6 +15,10 @@ You should self host CourseLit, if you:
- want complete control of your data
- want to host it behind a firewall for internal use
+## How to self host?
+
+
+
### Self host CourseLit
See [the self hosting guide](/en/self-hosting/self-host).
diff --git a/apps/docs/src/pages/en/users/delete.md b/apps/docs/src/pages/en/users/delete.md
new file mode 100644
index 000000000..440cfe944
--- /dev/null
+++ b/apps/docs/src/pages/en/users/delete.md
@@ -0,0 +1,169 @@
+---
+title: Delete a User
+description: Guide to safely deleting users with GDPR compliance
+layout: ../../../layouts/MainLayout.astro
+---
+
+Deleting a user is a critical operation that removes all personal data associated with that user from your school. This feature is designed to comply with GDPR and other data protection regulations.
+
+> **Important**: User deletion is permanent and cannot be undone. All personal data will be removed, and business entities will be transferred to the admin performing the deletion.
+
+## How User Deletion Works
+
+When you delete a user, CourseLit performs two main operations:
+
+1. **Business Entity Migration**: Transfers ownership of courses, communities, email templates, and other business-critical resources to the admin performing the deletion
+2. **Personal Data Cleanup**: Permanently removes all personal data associated with the user
+
+## Prerequisites
+
+To delete a user, you must have the `Manage Users` permission. Additionally:
+
+- You cannot delete yourself
+- You cannot delete the last user with critical permissions (like `Manage Site`, `Manage Users`, etc.)
+- The system ensures at least one admin remains with each critical permission
+
+## Deleting a User
+
+1. Navigate to the **Users** area from the dashboard
+2. Click on the user you want to delete to open their details
+3. Scroll down to the Danger zone and click on **Delete user** button
+
+ 
+
+4. Confirm the deletion when prompted
+
+## What Gets Migrated
+
+The following business entities are transferred to the admin performing the deletion:
+
+### Courses & Content
+
+- **Course Ownership**: All courses created by the user
+- **Lesson Ownership**: All lessons created by the user
+- **Page Ownership**: All pages created by the user (course pages, blog pages, etc.)
+
+### Communities
+
+- **Community Ownership**: All communities created by the user
+- **Community Posts**: All posts created by the user in any community
+- **Community Comments**: All comments made by the user
+
+### Email Marketing
+
+- **Email Templates**: All email templates created by the user
+- **Email Sequences**: All email sequences (campaigns) created by the user
+- **Broadcasts**: All email broadcasts created by the user
+
+### Other Business Entities
+
+- **Payment Plans**: All payment plans created by the user
+- **User Themes**: All custom themes created by the user
+- **User Segments**: All user segments created by the user
+
+## What Gets Deleted
+
+The following personal data is permanently removed:
+
+### User Account & Profile
+
+- User account and profile information
+- User avatar and media files
+- Authentication tokens and sessions
+
+### Activity & Engagement
+
+- Course enrollments and memberships
+- Lesson progress and evaluations
+- Download links generated for the user
+- Activity logs and analytics data
+- Notifications sent to the user
+
+### Community Participation
+
+- Community membership records
+- Community post subscriptions
+- Community reports filed by the user
+
+### Email & Communications
+
+- Email delivery records
+- Email event logs (opens, clicks, etc.)
+- Ongoing email sequences for the user
+- Mail request status records
+
+### Financial Records
+
+- Invoices associated with the user
+- Payment subscriptions (cancelled automatically)
+
+### Certificates
+
+- Certificates issued to the user
+
+## GDPR Compliance
+
+This deletion process is designed to comply with GDPR Article 17 (Right to Erasure). When a user is deleted:
+
+- All personal data is permanently removed
+- Business entities are preserved to maintain system integrity
+- The operation is logged for audit purposes
+- Payment subscriptions are automatically cancelled
+
+## Safety Measures
+
+CourseLit implements several safety measures to prevent accidental data loss:
+
+1. **Permission Validation**: Ensures at least one user retains each critical permission
+2. **Self-Deletion Prevention**: You cannot delete your own account
+3. **Confirmation Required**: Deletion requires explicit confirmation
+4. **Atomic Operation**: The entire deletion process succeeds or fails as a unit
+
+## After Deletion
+
+After a user is deleted:
+
+- All their business entities (courses, communities, etc.) continue to function normally under the new owner
+- Students enrolled in their courses can continue learning
+- Community members can continue participating
+- Email sequences continue running for other users
+- The deleted user cannot log in anymore
+
+## Handling Subscription Cancellations
+
+If the deleted user had active payment subscriptions:
+
+- All subscriptions are automatically cancelled
+- The payment provider (Stripe, PayPal, etc.) is notified
+- No further charges will occur
+- Refunds must be handled manually through your payment provider if needed
+
+## Best Practices
+
+1. **Review Before Deletion**: Check what content and entities the user owns before deleting
+2. **Notify Stakeholders**: If the user created important courses or communities, inform relevant team members
+3. **Export Data First**: If you need to retain any information for records, export it before deletion
+4. **Handle Refunds**: Process any necessary refunds through your payment provider before deletion
+5. **Document the Action**: Keep a record of why and when the user was deleted for compliance purposes
+
+## Troubleshooting
+
+### Cannot Delete User
+
+If you encounter an error when trying to delete a user:
+
+- **"Cannot delete last user with permission X"**: This user is the last one with a critical permission. Assign this permission to another user first.
+- **"Action not allowed"**: You don't have the `Manage Users` permission.
+- **"User not found"**: The user may have already been deleted or doesn't exist.
+
+### Subscription Cancellation Failed
+
+If a subscription fails to cancel:
+
+1. Note the subscription ID from the error message
+2. Manually cancel the subscription in your payment provider's dashboard
+3. Try the deletion again
+
+## Stuck somewhere?
+
+We are always here for you. Come chat with us in our Discord channel or send a tweet at @CourseLit.
diff --git a/apps/docs/src/pages/en/vision.md b/apps/docs/src/pages/en/vision.md
new file mode 100644
index 000000000..21a91085b
--- /dev/null
+++ b/apps/docs/src/pages/en/vision.md
@@ -0,0 +1,94 @@
+---
+title: Our vision
+description: Our vision and philosophy
+layout: ../../layouts/MainLayout.astro
+---
+
+Our vision for CourseLit is to establish a **parallel, modern ecosystem of open-source tools** that competes head-to-head with industry leaders like Teachable, Thinkific, Podia, and Learnworlds, while remaining **accessible and affordable for everyone**.
+
+We are essentially building the **"WordPress of course and membership platforms."**
+
+---
+
+## The Problem We Solve
+
+The current course platform landscape presents significant barriers for creators:
+
+1. **Exorbitant Cost Barrier**
+
+ Incumbents charge high monthly fees (often $50–$200/month), burdening solo creators like marketing coaches or small businesses with 1–50 employees, especially those bootstrapping or operating in emerging markets.
+
+2. **Feature Lock-Out and Upselling**
+
+ Essential features like certificates, subscriptions, or the ability to add their own payment gateways are locked behind expensive premium tiers, forcing creators and teams to upgrade constantly.
+
+3. **Vendor Lock-In**
+
+ Proprietary platforms trap creators in ecosystems where data, branding, and functionality are controlled, risking loss if the platform raises prices or shuts down.
+
+---
+
+## Our Foundational Philosophy
+
+CourseLit is built on two core commitments: **Affordability and Freedom**.
+
+### Commitment to Affordability (The Pricing Promise)
+
+We believe core functionality should never be a premium add-on.
+
+1. **Single Affordable Pro Plan**
+
+ All essential features (e.g., course creation, memberships, analytics, payment gateways) are consolidated into a single **Pro** Plan priced **under $30/month**, allowing solo creators and small businesses to launch and scale without financial strain.
+
+2. **Tiered Pricing Discipline**
+
+ Future enterprise features (e.g., SSO, advanced integrations, webhooks, API access) will be offered in a higher tier, but all essential tools will remain in the Pro Plan, ensuring accessibility for small creators and teams.
+
+### Commitment to Freedom (The Open-Source Mandate)
+
+Creators should own their platform and destiny.
+
+1. **No Vendor Lock-In**
+
+ The entire CourseLit stack is open-source, hosted on [GitHub](https://github.com/codelitdev/courselit) for longevity. If [CodeLit](https://codelit.dev), the company behind CourseLit, ever ceases operations, the tools remain available for anyone to use, extend, or fork, ensuring creators’ ecosystems are secure.
+
+2. **The Free Path**
+
+ Technical users can [self-host](/en/self-hosting/introduction) the entire stack at zero license cost for commercial use. We rely solely on other open-source projects and platforms like MongoDB that offer generous free tiers. The only cost is server hosting (e.g., $5–$20/month on AWS or DigitalOcean). The platform is fully **white-labeled** for branding freedom.
+
+3. **Documentation and Community**
+
+ We provide up-to-date documentation and maintain an active community (e.g., Discord, GitHub Discussions) to make self-hosting accessible, even for users with moderate technical skills (e.g., familiarity with basic server setup).
+
+4. **Community-Driven Evolution**
+
+ Technical users, such as developers building custom platforms for agencies, can contribute code (e.g., gamification features, Zapier integrations), documentation, or feature requests via GitHub. Solo creators and small businesses benefit from these enhancements without needing to code, ensuring the platform evolves based on real user needs, not corporate priorities.
+
+---
+
+## Service and Access Model
+
+CourseLit offers two ways to access the platform, tailored to creators’ needs:
+
+| Access Model | Target User | Key Principle |
+| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| **Managed SaaS (`courselit.app`)** | **Solo Creators** (e.g., marketing coaches, consultants) and **Small Businesses** who value convenience and support | **High-Value, Hassle-Free**: We handle hosting, updates, and maintenance via the affordable Pro Plan under $30/month, delivering a plug-and-play experience for creators scaling to 10,000+ students. Community-driven features enhance the platform without requiring technical skills |
+| **Self-Hosted (Open Source)** | **Technical Users** (e.g., developers, edtech startups) seeking control or cost-free evaluation | **Zero-Cost Access**: Users download the stack from GitHub and host it themselves (e.g., on AWS, DigitalOcean). All features, including future enterprise tools, are available. Contributors shape the platform's future via code or feedback |
+
+---
+
+## Conclusion
+
+CourseLit is more than a platform; it’s a **mission to empower online educators**—from solo creators to small businesses—with a modern, feature-rich, and ethical alternative to costly course platforms. We aim to be the obvious choice for:
+
+1. **Serious Creators**, including solo professionals (e.g., marketing coaches, consultants) and small businesses (e.g., agencies, edtech startups with 1–50 employees), who seek a cost-effective, feature-rich platform to scale their courses and memberships without technical barriers.
+
+2. **Technical Users**, like developers or edtech startups, who want a customizable, open-source alternative to proprietary platforms, with opportunities to shape its evolution.
+
+By combining **affordability**, **open-source freedom**, and **community-driven innovation**, we’re building a platform that grows with creators’ needs, much like WordPress transformed web publishing. CourseLit is the **obvious choice** for anyone seeking to build and scale their online school or training program with confidence and control.
+
+## Join Our Community
+
+Join our [Discord](https://discord.com/invite/GR4bQsN) channel to get help, request features, or just hang out with an awesome community of creators.
+
+You can also stay updated by following us on [X](https://x.com/courselit).
diff --git a/apps/docs/src/pages/en/website/blocks.md b/apps/docs/src/pages/en/website/blocks.md
index a937bd2c6..9f7bed0b9 100644
--- a/apps/docs/src/pages/en/website/blocks.md
+++ b/apps/docs/src/pages/en/website/blocks.md
@@ -35,8 +35,8 @@ You will also see the newly added link on the header itself.
3. Click on the pencil icon against the newly added link to edit it as shown above.
4. Change the label (displayed as text on the header block) and the URL (where the user should be taken upon clicking the label on the header) and click `Done` to save.
-
-
+ 
+
### [Rich Text](#rich-text)
@@ -60,8 +60,8 @@ You can also use the floating controls to do the same as shown below.
> Double-clicking the text to select won't work due to a bug. We are working on it.
2. Click on the floating `link` button to reveal a popup text input.
3. In the popup text input, enter the URL as shown below.
-
-
+ 
+
### [Hero](#hero)
@@ -85,9 +85,9 @@ Following is how it looks on a page.
3. In the button text field, add the text that will be visible on the button.
4. In the button action, enter the URL the user should be taken to upon clicking.
-a. If the URL is from your own school, use its relative form, i.e., `/courses`.
-b. If the URL is from some external website, use the absolute (complete) URL, i.e., `https://website.com/courses`.
-
+ a. If the URL is from your own school, use its relative form, i.e., `/courses`.
+ b. If the URL is from some external website, use the absolute (complete) URL, i.e., `https://website.com/courses`.
+
### [Grid](#grid)
@@ -130,9 +130,9 @@ A grid block comes in handy when you want to show some sort of list, for example
3. In the button text field, add the text that will be visible on the button.
4. In the button action, enter the URL the user should be taken to upon clicking.
-a. If the URL is from your own school, use its relative form, i.e., `/courses`.
-b. If the URL is from some external website, use the absolute (complete) URL, i.e., `https://website.com/courses`.
-
+ a. If the URL is from your own school, use its relative form, i.e., `/courses`.
+ b. If the URL is from some external website, use the absolute (complete) URL, i.e., `https://website.com/courses`.
+
### [Featured](#featured)
diff --git a/apps/queue/package.json b/apps/queue/package.json
index 803edd747..e2b6ca903 100644
--- a/apps/queue/package.json
+++ b/apps/queue/package.json
@@ -33,7 +33,7 @@
"@types/nodemailer": "^6.4.8",
"tsconfig": "workspace:^",
"tsup": "^7.2.0",
- "typescript": "^5.0.4",
- "typescript-eslint": "^7.5.0"
+ "typescript": "^5.9.3",
+ "typescript-eslint": "^8.46.4"
}
}
diff --git a/apps/queue/src/domain/model/domain.ts b/apps/queue/src/domain/model/domain.ts
index c0bbf983e..9c9fb341a 100644
--- a/apps/queue/src/domain/model/domain.ts
+++ b/apps/queue/src/domain/model/domain.ts
@@ -1,23 +1,28 @@
import { Domain as PublicDomain } from "@courselit/common-models";
-import mongoose from "mongoose";
+import mongoose, { Document, Model } from "mongoose";
import SettingsSchema from "./site-info";
-export interface Domain extends PublicDomain {
- _id: mongoose.Types.ObjectId;
-}
+export type DomainDocument = Document &
+ PublicDomain & {
+ _id: mongoose.Types.ObjectId;
+ incrementEmailCount: () => Promise;
+ };
-const DomainSchema = new mongoose.Schema(
+const DomainSchema = new mongoose.Schema(
{
name: { type: String, required: true, unique: true },
settings: SettingsSchema,
- quota: new mongoose.Schema({
- mail: new mongoose.Schema({
+ quota: new mongoose.Schema({
+ mail: new mongoose.Schema({
daily: { type: Number, default: 0 },
monthly: { type: Number, default: 0 },
dailyCount: { type: Number, default: 0 },
monthlyCount: { type: Number, default: 0 },
- lastDailyCountUpdate: { type: Date, default: Date.now },
- lastMonthlyCountUpdate: { type: Date, default: Date.now },
+ lastDailyCountUpdate: { type: Date, default: () => new Date() },
+ lastMonthlyCountUpdate: {
+ type: Date,
+ default: () => new Date(),
+ },
}),
}),
},
@@ -26,7 +31,9 @@ const DomainSchema = new mongoose.Schema(
},
);
-DomainSchema.methods.incrementEmailCount = async function () {
+DomainSchema.methods.incrementEmailCount = async function (
+ this: DomainDocument,
+) {
const today = new Date().toISOString().split("T")[0];
const thisMonth = new Date().toISOString().slice(0, 7);
const lastDailyUpdate = new Date(this.quota.mail.lastDailyCountUpdate)
@@ -40,17 +47,21 @@ DomainSchema.methods.incrementEmailCount = async function () {
this.quota.mail.dailyCount++;
} else {
this.quota.mail.dailyCount = 1;
- this.quota.mail.lastDailyCountUpdate = Date.now();
+ this.quota.mail.lastDailyCountUpdate = new Date();
}
if (thisMonth === lastMonthlyUpdate) {
this.quota.mail.monthlyCount++;
} else {
this.quota.mail.monthlyCount = 1;
- this.quota.mail.lastMonthlyCountUpdate = Date.now();
+ this.quota.mail.lastMonthlyCountUpdate = new Date();
}
return this.save();
};
-export default mongoose.models.Domain || mongoose.model("Domain", DomainSchema);
+const DomainModel =
+ (mongoose.models.Domain as Model) ||
+ mongoose.model("Domain", DomainSchema);
+
+export default DomainModel;
diff --git a/apps/queue/src/domain/model/email-template.ts b/apps/queue/src/domain/model/email-template.ts
index 765087920..fc830478f 100644
--- a/apps/queue/src/domain/model/email-template.ts
+++ b/apps/queue/src/domain/model/email-template.ts
@@ -1,4 +1,4 @@
-import mongoose from "mongoose";
+import mongoose, { Model } from "mongoose";
import { EmailTemplate as PublicEmailTemplate } from "@courselit/common-models";
import { EmailContentSchema } from "@courselit/common-logic";
@@ -23,5 +23,8 @@ EmailTemplateSchema.index(
{ unique: true },
);
-export default mongoose.models.EmailTemplate ||
- mongoose.model("EmailTemplate", EmailTemplateSchema);
+const EmailTemplateModel =
+ (mongoose.models.EmailTemplate as Model) ||
+ mongoose.model("EmailTemplate", EmailTemplateSchema);
+
+export default EmailTemplateModel;
diff --git a/apps/queue/src/domain/model/membership.ts b/apps/queue/src/domain/model/membership.ts
index 20a06622e..ac3045c2f 100644
--- a/apps/queue/src/domain/model/membership.ts
+++ b/apps/queue/src/domain/model/membership.ts
@@ -1,5 +1,8 @@
import { MembershipSchema } from "@courselit/common-logic";
-import mongoose from "mongoose";
+import mongoose, { Model } from "mongoose";
-export default mongoose.models.Membership ||
+const MembershipModel =
+ (mongoose.models.Membership as Model) ||
mongoose.model("Membership", MembershipSchema);
+
+export default MembershipModel;
diff --git a/apps/queue/src/domain/model/ongoing-sequence.ts b/apps/queue/src/domain/model/ongoing-sequence.ts
index 824d24132..f29f12b6f 100644
--- a/apps/queue/src/domain/model/ongoing-sequence.ts
+++ b/apps/queue/src/domain/model/ongoing-sequence.ts
@@ -3,7 +3,7 @@ import mongoose, { Schema, Document } from "mongoose";
export type OngoingSequence = OS &
Document & {
- domain: mongoose.Schema.Types.ObjectId;
+ domain: mongoose.Types.ObjectId;
};
const OngoingSequenceSchema: Schema = new Schema(
@@ -22,5 +22,8 @@ const OngoingSequenceSchema: Schema = new Schema(
OngoingSequenceSchema.index({ sequenceId: 1, userId: 1 }, { unique: true });
-export default mongoose.models.OngoingSequence ||
- mongoose.model("OngoingSequence", OngoingSequenceSchema);
+const OngoingSequenceModel =
+ (mongoose.models.OngoingSequence as mongoose.Model) ||
+ mongoose.model("OngoingSequence", OngoingSequenceSchema);
+
+export default OngoingSequenceModel;
diff --git a/apps/queue/src/domain/model/rule.ts b/apps/queue/src/domain/model/rule.ts
index 04cb5d7f9..332e04efb 100644
--- a/apps/queue/src/domain/model/rule.ts
+++ b/apps/queue/src/domain/model/rule.ts
@@ -1,3 +1,7 @@
-import mongoose from "mongoose";
+import mongoose, { Model } from "mongoose";
import { RuleSchema } from "@courselit/common-logic";
-export default mongoose.models.Rule || mongoose.model("Rule", RuleSchema);
+
+const RuleModel =
+ (mongoose.models.Rule as Model) || mongoose.model("Rule", RuleSchema);
+
+export default RuleModel;
diff --git a/apps/queue/src/domain/model/sequence.ts b/apps/queue/src/domain/model/sequence.ts
index 7e1ddc1df..a8bfd9018 100644
--- a/apps/queue/src/domain/model/sequence.ts
+++ b/apps/queue/src/domain/model/sequence.ts
@@ -1,5 +1,8 @@
-import mongoose from "mongoose";
+import mongoose, { Model } from "mongoose";
import { SequenceSchema } from "@courselit/common-logic";
-export default mongoose.models.Sequence ||
+const SequenceModel =
+ (mongoose.models.Sequence as Model) ||
mongoose.model("Sequence", SequenceSchema);
+
+export default SequenceModel;
diff --git a/apps/queue/src/domain/model/user.ts b/apps/queue/src/domain/model/user.ts
index c82f3403b..e1ba2caf4 100644
--- a/apps/queue/src/domain/model/user.ts
+++ b/apps/queue/src/domain/model/user.ts
@@ -1,4 +1,7 @@
-import mongoose from "mongoose";
+import mongoose, { Model } from "mongoose";
import { UserSchema } from "@courselit/common-logic";
-export default mongoose.models.User || mongoose.model("User", UserSchema);
+const UserModel =
+ (mongoose.models.User as Model) || mongoose.model("User", UserSchema);
+
+export default UserModel;
diff --git a/apps/queue/src/domain/process-ongoing-sequences.ts b/apps/queue/src/domain/process-ongoing-sequences.ts
index edf42f2b6..7136fb8d2 100644
--- a/apps/queue/src/domain/process-ongoing-sequences.ts
+++ b/apps/queue/src/domain/process-ongoing-sequences.ts
@@ -1,4 +1,4 @@
-import { Domain, Email } from "@courselit/common-models";
+import { Email } from "@courselit/common-models";
import OngoingSequenceModel, {
OngoingSequence,
} from "./model/ongoing-sequence";
@@ -26,6 +26,7 @@ import { getUnsubLink } from "../utils/get-unsub-link";
import { getSiteUrl } from "../utils/get-site-url";
import { jwtUtils } from "@courselit/utils";
import { JSDOM } from "jsdom";
+import { DomainDocument } from "./model/domain";
const liquidEngine = new Liquid();
new Worker(
@@ -182,7 +183,7 @@ async function attemptMailSending({
sequence: AdminSequence;
ongoingSequence: OngoingSequence;
email: Email;
- domain: Domain;
+ domain: DomainDocument;
}) {
const from = sequence.from
? `${sequence.from.name} <${creator.email}>`
@@ -281,7 +282,7 @@ function transformLinksForClickTracking(
userId: string,
sequenceId: string,
emailId: string,
- domain: Domain,
+ domain: DomainDocument,
): string {
try {
const dom = new JSDOM(htmlContent);
diff --git a/apps/queue/src/domain/process-rules.ts b/apps/queue/src/domain/process-rules.ts
index 77125bb82..91a603401 100644
--- a/apps/queue/src/domain/process-rules.ts
+++ b/apps/queue/src/domain/process-rules.ts
@@ -11,7 +11,9 @@ import {
convertFiltersToDBConditions,
} from "@courselit/common-logic";
-type RuleWithDomain = Rule & { domain: mongoose.Schema.Types.ObjectId };
+type RuleWithDomain = Omit & {
+ domain: mongoose.Types.ObjectId;
+};
export async function processRules() {
// eslint-disable-next-line no-constant-condition
@@ -70,7 +72,9 @@ async function processRule(rule: RuleWithDomain) {
}
async function addBroadcastToOngoingSequence(sequence: AdminSequence) {
- const query: Partial = {
+ const query: Partial> & {
+ domain: mongoose.Types.ObjectId;
+ } = {
domain: sequence.domain,
...(await convertFiltersToDBConditions({
domain: sequence.domain,
diff --git a/apps/queue/src/domain/queries.ts b/apps/queue/src/domain/queries.ts
index 2f1fbd991..68eda83b3 100644
--- a/apps/queue/src/domain/queries.ts
+++ b/apps/queue/src/domain/queries.ts
@@ -7,7 +7,7 @@ import MembershipModel from "./model/membership";
import UserModel from "./model/user";
import RuleModel from "./model/rule";
import mongoose from "mongoose";
-import DomainModel from "./model/domain";
+import DomainModel, { DomainDocument } from "./model/domain";
import { Constants, EmailTemplate } from "@courselit/common-models";
import emailTemplate from "./model/email-template";
import {
@@ -62,8 +62,9 @@ export async function updateSequenceSentAt(sequenceId: string): Promise {
);
}
-export async function getDomain(id: mongoose.Types.ObjectId) {
- // @ts-ignore - Mongoose type compatibility issue
+export async function getDomain(
+ id: mongoose.Types.ObjectId,
+): Promise {
return await DomainModel.findById(id);
}
diff --git a/apps/queue/src/index.ts b/apps/queue/src/index.ts
index 437e98a3d..806350bca 100644
--- a/apps/queue/src/index.ts
+++ b/apps/queue/src/index.ts
@@ -17,7 +17,7 @@ app.use("/job", verifyJWTMiddleware, jobRoutes);
app.use("/sse", sseRoutes);
app.get("/healthy", (req, res) => {
- res.status(200).json({ success: true });
+ res.status(200).json({ status: "ok", uptime: process.uptime() });
});
startEmailAutomation();
diff --git a/apps/web/.eslintrc.json b/apps/web/.eslintrc.json
deleted file mode 100644
index b80a3da40..000000000
--- a/apps/web/.eslintrc.json
+++ /dev/null
@@ -1,3 +0,0 @@
-{
- "extends": ["next", "prettier"]
-}
diff --git a/apps/web/.migrations/10-11-25_00-40-migrate-lesson-creatorid-to-userid.js b/apps/web/.migrations/10-11-25_00-40-migrate-lesson-creatorid-to-userid.js
new file mode 100644
index 000000000..f22aa7750
--- /dev/null
+++ b/apps/web/.migrations/10-11-25_00-40-migrate-lesson-creatorid-to-userid.js
@@ -0,0 +1,96 @@
+import mongoose from "mongoose";
+
+function generateUniqueId() {
+ return nanoid();
+}
+
+mongoose.connect(process.env.DB_CONNECTION_STRING, {
+ useNewUrlParser: true,
+ useUnifiedTopology: true,
+});
+
+const CourseSchema = new mongoose.Schema(
+ {
+ domain: { type: mongoose.Schema.Types.ObjectId, required: true },
+ courseId: { type: String, required: true, default: generateUniqueId },
+ creatorId: { type: String, required: true },
+ groups: [
+ {
+ _id: {
+ type: String,
+ required: true,
+ default: generateUniqueId,
+ },
+ },
+ ],
+ },
+ {
+ timestamps: true,
+ },
+);
+const Course = mongoose.model("Course", CourseSchema);
+
+const LessonSchema = new mongoose.Schema({
+ domain: { type: mongoose.Schema.Types.ObjectId, required: true },
+ lessonId: { type: String, required: true, default: generateUniqueId },
+ creatorId: { type: String, required: true },
+ courseId: { type: String, required: true },
+ groupId: { type: String, required: true },
+});
+
+const Lesson = mongoose.model("Lesson", LessonSchema);
+
+async function migrateLessonCreatorIdToUserId() {
+ const courses = await Course.find({});
+ for (const course of courses) {
+ console.log(`Updating lessons for course ${course.courseId}`);
+ await Lesson.updateMany(
+ {
+ domain: course.domain,
+ courseId: course.courseId,
+ },
+ {
+ $set: {
+ creatorId: course.creatorId,
+ },
+ },
+ );
+ console.log(`Updated lessons for course ${course.courseId}`);
+ }
+}
+
+async function deleteOrphanLessons() {
+ const courses = await Course.find({}).lean();
+ for (const course of courses) {
+ const groupsIds = course.groups.map((group) => group._id);
+ const lessons = await Lesson.find({
+ domain: course.domain,
+ courseId: course.courseId,
+ }).lean();
+
+ const orphanLessons = lessons.filter(
+ (lesson) => !groupsIds.includes(lesson.groupId),
+ );
+
+ if (orphanLessons.length > 0) {
+ console.log(
+ `Detected ${orphanLessons.length} orphan lessons for course ${course.courseId}`,
+ );
+ const query = {
+ _id: {
+ $in: orphanLessons.map((lesson) => lesson._id),
+ },
+ };
+ await Lesson.deleteMany(query);
+ console.log(
+ `Deleted ${orphanLessons.length} orphan lessons for course ${course.courseId}`,
+ );
+ }
+ }
+}
+
+(async () => {
+ await migrateLessonCreatorIdToUserId();
+ await deleteOrphanLessons();
+ mongoose.connection.close();
+})();
diff --git a/apps/web/__mocks__/medialit.ts b/apps/web/__mocks__/medialit.ts
new file mode 100644
index 000000000..5bc291a7c
--- /dev/null
+++ b/apps/web/__mocks__/medialit.ts
@@ -0,0 +1,28 @@
+class MediaLit {
+ endpoint: string;
+ constructor(config: { endpoint?: string }) {
+ this.endpoint = config.endpoint || "https://medialit.example.com";
+ }
+
+ async get(mediaId: string) {
+ return {
+ mediaId,
+ file: "mock-file",
+ originalFileName: "mock-file",
+ mimeType: "image/png",
+ size: 0,
+ access: "public",
+ url: `https://medialit.example.com/${mediaId}/main.png`,
+ };
+ }
+
+ async getSignature(_: { group: string }) {
+ return "mock-signature";
+ }
+
+ async delete(_: string) {
+ return true;
+ }
+}
+
+export { MediaLit };
diff --git a/apps/web/app/(with-contexts)/(with-layout)/accomplishment/[certId]/page.tsx b/apps/web/app/(with-contexts)/(with-layout)/accomplishment/[certId]/page.tsx
new file mode 100644
index 000000000..f2ba93add
--- /dev/null
+++ b/apps/web/app/(with-contexts)/(with-layout)/accomplishment/[certId]/page.tsx
@@ -0,0 +1,159 @@
+"use client";
+
+import {
+ Badge,
+ Button,
+ Header1,
+ Header2,
+ Section,
+ Text1,
+} from "@courselit/page-primitives";
+import { redirect, useParams } from "next/navigation";
+import { ThemeContext } from "@components/contexts";
+import { useContext, useRef, useState } from "react";
+import { BadgeCheck, ExternalLinkIcon, Printer } from "lucide-react";
+import Link from "next/link";
+import { Image } from "@courselit/components-library";
+import { formattedLocaleDate } from "@ui-lib/utils";
+import { useCertificate } from "@/hooks/use-certificate";
+import { Skeleton } from "@/components/ui/skeleton";
+
+export default function AccomplishmentPage() {
+ const params = useParams();
+ const certId = params.certId;
+ const { theme } = useContext(ThemeContext);
+ const [isIframeLoaded, setIsIframeLoaded] = useState(false);
+ const iframeRef = useRef(null);
+ const { certificate, loaded } = useCertificate(certId as string);
+
+ const handlePrint = () => {
+ const iframeElement = iframeRef.current;
+ if (!iframeElement) return;
+ const iframeWindow = iframeElement.contentWindow;
+ if (!iframeWindow) return;
+ iframeWindow.focus();
+ iframeWindow.print();
+ };
+
+ if (loaded && !certificate) {
+ redirect("/");
+ }
+
+ if (!certificate) {
+ return (
+
+