node 24-02-26_00-00-copy-pageid-to-slug-communities.js
+ */
+import mongoose from "mongoose";
+
+const DB_CONNECTION_STRING = process.env.DB_CONNECTION_STRING;
+if (!DB_CONNECTION_STRING) {
+ throw new Error("DB_CONNECTION_STRING is not set");
+}
+
+(async () => {
+ try {
+ await mongoose.connect(DB_CONNECTION_STRING);
+ const db = mongoose.connection.db;
+ if (!db) throw new Error("Could not connect to database");
+
+ const result = await db.collection("communities").updateMany(
+ { slug: { $exists: false } },
+ [{ $set: { slug: "$pageId" } }], // aggregation pipeline: copy field value
+ );
+
+ console.log(
+ `✅ Updated ${result.modifiedCount} communities: copied pageId → slug`,
+ );
+ } finally {
+ await mongoose.connection.close();
+ }
+})();
diff --git a/apps/web/app/(with-contexts)/(with-layout)/products/products-list.tsx b/apps/web/app/(with-contexts)/(with-layout)/products/products-list.tsx
index 2bcddc925..7b6fb6bfc 100644
--- a/apps/web/app/(with-contexts)/(with-layout)/products/products-list.tsx
+++ b/apps/web/app/(with-contexts)/(with-layout)/products/products-list.tsx
@@ -27,7 +27,10 @@ export function ProductsList({
}) {
const siteinfo = useContext(SiteInfoContext);
const filters = useMemo(
- () => [Constants.CourseType.COURSE.toUpperCase()],
+ () => [
+ Constants.CourseType.COURSE.toUpperCase(),
+ Constants.CourseType.DOWNLOAD.toUpperCase(),
+ ],
[],
);
diff --git a/apps/web/app/(with-contexts)/dashboard/(sidebar)/community/[id]/manage/page.tsx b/apps/web/app/(with-contexts)/dashboard/(sidebar)/community/[id]/manage/page.tsx
index 35a1d1c02..a07620738 100644
--- a/apps/web/app/(with-contexts)/dashboard/(sidebar)/community/[id]/manage/page.tsx
+++ b/apps/web/app/(with-contexts)/dashboard/(sidebar)/community/[id]/manage/page.tsx
@@ -87,6 +87,7 @@ export default function Page(props: {
const address = useContext(AddressContext);
const [name, setName] = useState("");
+ const [slug, setSlug] = useState("");
const [enabled, setEnabled] = useState(false);
const [autoAcceptMembers, setAutoAcceptMembers] = useState(false);
const [banner, setBanner] = useState(TextEditorEmptyDoc);
@@ -171,6 +172,7 @@ export default function Page(props: {
const setCommunity = (community: any) => {
setName(community.name);
+ setSlug(community.slug || "");
if (community.description) {
setDescription(community.description);
}
@@ -194,6 +196,7 @@ export default function Page(props: {
mutation UpdateCommunity(
$id: String!
$name: String
+ $slug: String
$description: String
$enabled: Boolean
$autoAcceptMembers: Boolean
@@ -202,6 +205,7 @@ export default function Page(props: {
community: updateCommunity(
id: $id
name: $name
+ slug: $slug
description: $description
enabled: $enabled
autoAcceptMembers: $autoAcceptMembers
@@ -209,6 +213,7 @@ export default function Page(props: {
) {
communityId
name
+ slug
description
enabled
banner
@@ -248,6 +253,7 @@ export default function Page(props: {
variables: {
id,
name,
+ slug: slug || undefined,
description: JSON.stringify(description),
enabled,
autoAcceptMembers,
@@ -287,6 +293,7 @@ export default function Page(props: {
) {
communityId
name
+ slug
description
enabled
banner
@@ -557,6 +564,23 @@ export default function Page(props: {
}
placeholder="Community name"
/>
+
+
+ Slug
+
+
) =>
+ setSlug(e.target.value)
+ }
+ placeholder="my-community"
+ />
+
+ The URL-friendly identifier for this community page.
+
+
Description
({
name: product?.title || "",
+ slug: product?.slug || "",
description: product?.description
? JSON.parse(product.description)
: TextEditorEmptyDoc,
@@ -78,6 +80,7 @@ export default function ProductDetails({ product }: ProductDetailsProps) {
courseId: product.courseId,
title: formData.name,
description: JSON.stringify(formData.description),
+ slug: formData.slug || undefined,
},
})
.build()
@@ -120,6 +123,23 @@ export default function ProductDetails({ product }: ProductDetailsProps) {
)}
+
+
+ Slug
+
+
+
+ The URL-friendly identifier for this product page.
+
+
+
{
communityId: "dc-disabled-comm",
name: "Disabled Community",
pageId: "disabled-page",
+ slug: "disabled-page",
enabled: false,
deleted: false,
});
@@ -170,6 +171,7 @@ describe("deleteCommunity - Comprehensive Test Suite", () => {
communityId: "dc-comm-posts",
name: "Community with Posts",
pageId: "comm-posts-page",
+ slug: "comm-posts-page",
enabled: true,
deleted: false,
});
@@ -208,6 +210,7 @@ describe("deleteCommunity - Comprehensive Test Suite", () => {
communityId: "dc-comm-comments",
name: "Community with Comments",
pageId: "comm-comments-page",
+ slug: "comm-comments-page",
enabled: true,
deleted: false,
});
@@ -255,6 +258,7 @@ describe("deleteCommunity - Comprehensive Test Suite", () => {
communityId: "dc-comm-reports",
name: "Community with Reports",
pageId: "comm-reports-page",
+ slug: "comm-reports-page",
enabled: true,
deleted: false,
});
@@ -293,6 +297,7 @@ describe("deleteCommunity - Comprehensive Test Suite", () => {
communityId: "dc-comm-subs",
name: "Community with Subscriptions",
pageId: "comm-subs-page",
+ slug: "comm-subs-page",
enabled: true,
deleted: false,
});
@@ -340,6 +345,7 @@ describe("deleteCommunity - Comprehensive Test Suite", () => {
communityId: "dc-comm-members",
name: "Community with Members",
pageId: "comm-members-page",
+ slug: "comm-members-page",
enabled: true,
deleted: false,
});
@@ -399,6 +405,7 @@ describe("deleteCommunity - Comprehensive Test Suite", () => {
communityId: "dc-comm-subscriptions",
name: "Community with Subscriptions",
pageId: "comm-subscriptions-page",
+ slug: "comm-subscriptions-page",
enabled: true,
deleted: false,
});
@@ -476,6 +483,7 @@ describe("deleteCommunity - Comprehensive Test Suite", () => {
communityId: "dc-comm-plans",
name: "Community with Plans",
pageId: "comm-plans-page",
+ slug: "comm-plans-page",
enabled: true,
deleted: false,
});
@@ -518,6 +526,7 @@ describe("deleteCommunity - Comprehensive Test Suite", () => {
communityId: "dc-comm-included",
name: "Community with Included Products",
pageId: "comm-included-page",
+ slug: "comm-included-page",
enabled: true,
deleted: false,
});
@@ -592,6 +601,7 @@ describe("deleteCommunity - Comprehensive Test Suite", () => {
communityId: "dc-comm-page",
name: "Community with Page",
pageId: "comm-page-test",
+ slug: "comm-page-test",
enabled: true,
deleted: false,
});
@@ -620,6 +630,7 @@ describe("deleteCommunity - Comprehensive Test Suite", () => {
communityId: "dc-comm-media",
name: "Community with Media",
pageId: "comm-media-page",
+ slug: "comm-media-page",
enabled: true,
deleted: false,
featuredImage: {
@@ -657,6 +668,7 @@ describe("deleteCommunity - Comprehensive Test Suite", () => {
communityId: "dc-comm-delete",
name: "Community to Delete",
pageId: "comm-delete-page",
+ slug: "comm-delete-page",
enabled: true,
deleted: false,
});
@@ -687,6 +699,7 @@ describe("deleteCommunity - Comprehensive Test Suite", () => {
communityId: "dc-comm-complex",
name: "Complex Community",
pageId: "comm-complex-page",
+ slug: "comm-complex-page",
enabled: true,
deleted: false,
featuredImage: {
@@ -836,6 +849,7 @@ describe("deleteCommunity - Comprehensive Test Suite", () => {
communityId: "dc-comm-empty",
name: "Empty Community",
pageId: "comm-empty-page",
+ slug: "comm-empty-page",
enabled: true,
deleted: false,
});
diff --git a/apps/web/graphql/communities/__tests__/logic.test.ts b/apps/web/graphql/communities/__tests__/logic.test.ts
index bc3180809..3a1718b67 100644
--- a/apps/web/graphql/communities/__tests__/logic.test.ts
+++ b/apps/web/graphql/communities/__tests__/logic.test.ts
@@ -82,6 +82,7 @@ describe("Community Logic - Comment Count Tests", () => {
communityId: "test-comm-logic",
name: "Test Community Logic",
pageId: "test-page-logic",
+ slug: "test-page-logic",
enabled: true,
deleted: false,
categories: ["General"],
diff --git a/apps/web/graphql/communities/__tests__/slug.test.ts b/apps/web/graphql/communities/__tests__/slug.test.ts
new file mode 100644
index 000000000..d60b3fa02
--- /dev/null
+++ b/apps/web/graphql/communities/__tests__/slug.test.ts
@@ -0,0 +1,208 @@
+/**
+ * @jest-environment node
+ */
+
+import { createCommunity, updateCommunity } from "../logic";
+import CommunityModel from "@models/Community";
+import MembershipModel from "@models/Membership";
+import PaymentPlanModel from "@models/PaymentPlan";
+import PageModel from "@models/Page";
+import DomainModel from "@models/Domain";
+import UserModel from "@models/User";
+import constants from "@/config/constants";
+import { Constants } from "@courselit/common-models";
+
+jest.mock("@/services/queue");
+jest.mock("nanoid", () => ({
+ nanoid: () => Math.random().toString(36).substring(7),
+}));
+jest.mock("slugify", () => ({
+ __esModule: true,
+ default: jest.fn((str) =>
+ str
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/gi, "-")
+ .replace(/^-+|-+$/g, "")
+ .toLowerCase(),
+ ),
+}));
+jest.unmock("@courselit/utils");
+
+const SLUG_SUITE_PREFIX = `comm-slug-${Date.now()}-${Math.floor(Math.random() * 100000)}`;
+const id = (suffix: string) => `${SLUG_SUITE_PREFIX}-${suffix}`;
+const email = (suffix: string) => `${suffix}-${SLUG_SUITE_PREFIX}@example.com`;
+
+describe("Community Slug Tests", () => {
+ let domain: any;
+ let adminUser: any;
+ let mockCtx: any;
+
+ beforeAll(async () => {
+ domain = await DomainModel.create({
+ name: id("domain"),
+ email: email("domain"),
+ });
+
+ adminUser = await UserModel.create({
+ domain: domain._id,
+ userId: id("admin"),
+ email: email("admin"),
+ name: "Admin",
+ permissions: [constants.permissions.manageCommunity],
+ active: true,
+ unsubscribeToken: id("unsub-admin"),
+ });
+
+ // Internal payment plan (required by createCommunity)
+ await PaymentPlanModel.create({
+ domain: domain._id,
+ planId: id("internal-plan"),
+ userId: adminUser.userId,
+ entityId: "internal",
+ entityType: Constants.MembershipEntityType.COURSE,
+ type: "free",
+ name: constants.internalPaymentPlanName,
+ internal: true,
+ interval: "monthly",
+ cost: 0,
+ currencyISOCode: "USD",
+ });
+
+ mockCtx = {
+ user: adminUser,
+ subdomain: domain,
+ } as any;
+ });
+
+ afterEach(async () => {
+ await CommunityModel.deleteMany({ domain: domain._id });
+ await MembershipModel.deleteMany({ domain: domain._id });
+ await PageModel.deleteMany({ domain: domain._id });
+ await PaymentPlanModel.deleteMany({
+ domain: domain._id,
+ planId: { $ne: id("internal-plan") },
+ });
+ });
+
+ afterAll(async () => {
+ await PaymentPlanModel.deleteMany({ domain: domain._id });
+ await UserModel.deleteMany({ domain: domain._id });
+ await DomainModel.deleteOne({ _id: domain._id });
+ });
+
+ describe("createCommunity", () => {
+ it("should generate slug matching pageId", async () => {
+ const result = await createCommunity({
+ name: "My Test Community",
+ ctx: mockCtx,
+ });
+
+ expect(result.slug).toBe("my-test-community");
+
+ const community = await CommunityModel.findOne({
+ communityId: result.communityId,
+ });
+ expect(community?.slug).toBe(community?.pageId);
+ });
+
+ it("should auto-suffix slug when name collides", async () => {
+ const first = await createCommunity({
+ name: "Duplicate Name",
+ ctx: mockCtx,
+ });
+ expect(first.slug).toBe("duplicate-name");
+
+ // Delete the first community so the name uniqueness check passes,
+ // but leave the Page so the slug collides
+ await CommunityModel.deleteMany({ domain: domain._id });
+ await MembershipModel.deleteMany({ domain: domain._id });
+
+ const second = await createCommunity({
+ name: "Duplicate Name",
+ ctx: mockCtx,
+ });
+ expect(second.slug).toBe("duplicate-name-1");
+ });
+ });
+
+ describe("updateCommunity slug", () => {
+ it("should update slug and sync with Page", async () => {
+ const created = await createCommunity({
+ name: "Slug Update Test",
+ ctx: mockCtx,
+ });
+
+ const updated = await updateCommunity({
+ id: created.communityId,
+ slug: "new-custom-slug",
+ ctx: mockCtx,
+ });
+
+ expect(updated.slug).toBe("new-custom-slug");
+
+ const community = await CommunityModel.findOne({
+ communityId: created.communityId,
+ });
+ expect(community?.slug).toBe("new-custom-slug");
+ expect(community?.pageId).toBe("new-custom-slug");
+
+ const page = await PageModel.findOne({
+ entityId: created.communityId,
+ domain: domain._id,
+ });
+ expect(page?.pageId).toBe("new-custom-slug");
+ });
+
+ it("should reject duplicate slug with friendly error", async () => {
+ const comm1 = await createCommunity({
+ name: "Community One",
+ ctx: mockCtx,
+ });
+
+ await createCommunity({
+ name: "Community Two",
+ ctx: mockCtx,
+ });
+
+ // Try to change comm1's slug to comm2's slug
+ await expect(
+ updateCommunity({
+ id: comm1.communityId,
+ slug: "community-two",
+ ctx: mockCtx,
+ }),
+ ).rejects.toThrow("slug is already in use");
+ });
+
+ it("should not change slug when same slug is submitted", async () => {
+ const created = await createCommunity({
+ name: "Same Slug Community",
+ ctx: mockCtx,
+ });
+
+ const updated = await updateCommunity({
+ id: created.communityId,
+ slug: "same-slug-community",
+ ctx: mockCtx,
+ });
+
+ // Should succeed without error
+ expect(updated.slug).toBe("same-slug-community");
+ });
+
+ it("should validate slug format", async () => {
+ const created = await createCommunity({
+ name: "Format Test",
+ ctx: mockCtx,
+ });
+
+ await expect(
+ updateCommunity({
+ id: created.communityId,
+ slug: "a".repeat(201),
+ ctx: mockCtx,
+ }),
+ ).rejects.toThrow();
+ });
+ });
+});
diff --git a/apps/web/graphql/communities/logic.ts b/apps/web/graphql/communities/logic.ts
index bf488b329..b65fa7e92 100644
--- a/apps/web/graphql/communities/logic.ts
+++ b/apps/web/graphql/communities/logic.ts
@@ -2,7 +2,6 @@ import {
checkPermission,
extractMediaIDs,
generateUniqueId,
- slugify,
} from "@courselit/utils";
import CommunityModel, { InternalCommunity } from "@models/Community";
import constants from "../../config/constants";
@@ -33,6 +32,11 @@ import {
import CommunityCommentModel from "@models/CommunityComment";
import PageModel from "@models/Page";
import PaymentPlanModel from "@models/PaymentPlan";
+import {
+ generateUniquePageId,
+ isDuplicateKeyError,
+ validateSlug,
+} from "../pages/helpers";
import MembershipModel from "@models/Membership";
import {
addIncludedProductsMemberships,
@@ -95,39 +99,48 @@ export async function createCommunity({
const communityId = generateUniqueId();
- const pageId = `${slugify(name.toLowerCase())}-${communityId.substring(0, 5)}`;
+ const pageId = await generateUniquePageId(ctx.subdomain._id, name);
- await PageModel.create({
- domain: ctx.subdomain._id,
- pageId,
- type: communityPage,
- creatorId: ctx.user.userId,
- name,
- entityId: communityId,
- layout: [
- {
- name: "header",
- deleteable: false,
- shared: true,
- },
- {
- name: "banner",
- },
- {
- name: "footer",
- deleteable: false,
- shared: true,
- },
- ],
- title: name,
- });
+ let community;
+ try {
+ await PageModel.create({
+ domain: ctx.subdomain._id,
+ pageId,
+ type: communityPage,
+ creatorId: ctx.user.userId,
+ name,
+ entityId: communityId,
+ layout: [
+ {
+ name: "header",
+ deleteable: false,
+ shared: true,
+ },
+ {
+ name: "banner",
+ },
+ {
+ name: "footer",
+ deleteable: false,
+ shared: true,
+ },
+ ],
+ title: name,
+ });
- const community = await CommunityModel.create({
- domain: ctx.subdomain._id,
- communityId,
- name,
- pageId,
- });
+ community = await CommunityModel.create({
+ domain: ctx.subdomain._id,
+ communityId,
+ name,
+ slug: pageId,
+ pageId,
+ });
+ } catch (err) {
+ if (isDuplicateKeyError(err)) {
+ throw new Error(responses.page_id_already_exists);
+ }
+ throw err;
+ }
const paymentPlan = await getInternalPaymentPlan(ctx);
await MembershipModel.create({
@@ -255,6 +268,7 @@ export async function getCommunitiesCount({
export async function updateCommunity({
id,
name,
+ slug,
description,
ctx,
enabled,
@@ -265,6 +279,7 @@ export async function updateCommunity({
}: {
id: string;
name?: string;
+ slug?: string;
description?: string;
ctx: GQLContext;
enabled?: boolean;
@@ -293,6 +308,29 @@ export async function updateCommunity({
community.name = name;
}
+ if (slug) {
+ const newSlug = validateSlug(slug);
+ if (newSlug !== community.slug) {
+ // Page-first atomicity: update Page record first (hard unique constraint)
+ try {
+ await PageModel.updateOne(
+ {
+ domain: ctx.subdomain._id,
+ pageId: community.pageId,
+ },
+ { $set: { pageId: newSlug } },
+ );
+ } catch (err) {
+ if (isDuplicateKeyError(err)) {
+ throw new Error(responses.page_id_already_exists);
+ }
+ throw err;
+ }
+ community.slug = newSlug;
+ community.pageId = newSlug;
+ }
+ }
+
const descriptionMediaIdsMarkedForDeletion: string[] = [];
const bannerMediaIdsMarkedForDeletion: string[] = [];
@@ -960,6 +998,7 @@ async function formatCommunity(
): Promise> {
return {
name: community.name,
+ slug: community.slug,
communityId: community.communityId,
banner: community.banner,
enabled: community.enabled,
diff --git a/apps/web/graphql/communities/mutation.ts b/apps/web/graphql/communities/mutation.ts
index 7e3d37bd6..42732a564 100644
--- a/apps/web/graphql/communities/mutation.ts
+++ b/apps/web/graphql/communities/mutation.ts
@@ -48,6 +48,7 @@ const mutations = {
args: {
id: { type: new GraphQLNonNull(GraphQLString) },
name: { type: GraphQLString },
+ slug: { type: GraphQLString },
description: { type: GraphQLString },
enabled: { type: GraphQLBoolean },
banner: { type: GraphQLString },
@@ -60,6 +61,7 @@ const mutations = {
{
id,
name,
+ slug,
description,
enabled,
banner,
@@ -69,6 +71,7 @@ const mutations = {
}: {
id: string;
name?: string;
+ slug?: string;
description?: string;
enabled?: boolean;
banner?: string;
@@ -81,6 +84,7 @@ const mutations = {
updateCommunity({
id,
name,
+ slug,
description,
ctx,
enabled,
diff --git a/apps/web/graphql/communities/types.ts b/apps/web/graphql/communities/types.ts
index db8c5e0dd..6720e0953 100644
--- a/apps/web/graphql/communities/types.ts
+++ b/apps/web/graphql/communities/types.ts
@@ -43,6 +43,7 @@ const community = new GraphQLObjectType({
fields: {
communityId: { type: new GraphQLNonNull(GraphQLString) },
name: { type: new GraphQLNonNull(GraphQLString) },
+ slug: { type: GraphQLString },
description: { type: GraphQLJSONObject },
banner: { type: GraphQLJSONObject },
enabled: { type: GraphQLBoolean },
diff --git a/apps/web/graphql/courses/__tests__/slug.test.ts b/apps/web/graphql/courses/__tests__/slug.test.ts
new file mode 100644
index 000000000..842117145
--- /dev/null
+++ b/apps/web/graphql/courses/__tests__/slug.test.ts
@@ -0,0 +1,202 @@
+/**
+ * @jest-environment node
+ */
+
+import { createCourse, updateCourse } from "../logic";
+import CourseModel from "@models/Course";
+import PageModel from "@models/Page";
+import DomainModel from "@models/Domain";
+import UserModel from "@models/User";
+import constants from "@/config/constants";
+
+jest.mock("@/services/medialit", () => ({
+ deleteMedia: jest.fn().mockResolvedValue(true),
+ sealMedia: jest.fn().mockImplementation((mediaId) =>
+ Promise.resolve({
+ mediaId,
+ file: `https://cdn.test/${mediaId}/main.webp`,
+ }),
+ ),
+}));
+jest.mock("@/services/queue");
+jest.mock("nanoid", () => ({
+ nanoid: () => Math.random().toString(36).substring(7),
+}));
+jest.mock("slugify", () => ({
+ __esModule: true,
+ default: jest.fn((str) =>
+ str
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/gi, "-")
+ .replace(/^-+|-+$/g, "")
+ .toLowerCase(),
+ ),
+}));
+jest.unmock("@courselit/utils");
+
+const SLUG_SUITE_PREFIX = `course-slug-${Date.now()}-${Math.floor(Math.random() * 100000)}`;
+const id = (suffix: string) => `${SLUG_SUITE_PREFIX}-${suffix}`;
+const email = (suffix: string) => `${suffix}-${SLUG_SUITE_PREFIX}@example.com`;
+
+describe("Course Slug Tests", () => {
+ let domain: any;
+ let adminUser: any;
+ let mockCtx: any;
+
+ beforeAll(async () => {
+ domain = await DomainModel.create({
+ name: id("domain"),
+ email: email("domain"),
+ });
+
+ adminUser = await UserModel.create({
+ domain: domain._id,
+ userId: id("admin"),
+ email: email("admin"),
+ name: "Admin",
+ permissions: [
+ constants.permissions.manageAnyCourse,
+ constants.permissions.publishCourse,
+ ],
+ active: true,
+ unsubscribeToken: id("unsub-admin"),
+ purchases: [],
+ });
+
+ mockCtx = {
+ user: adminUser,
+ subdomain: domain,
+ } as any;
+ });
+
+ afterEach(async () => {
+ await CourseModel.deleteMany({ domain: domain._id });
+ await PageModel.deleteMany({ domain: domain._id });
+ });
+
+ afterAll(async () => {
+ await CourseModel.deleteMany({ domain: domain._id });
+ await PageModel.deleteMany({ domain: domain._id });
+ await UserModel.deleteMany({ domain: domain._id });
+ await DomainModel.deleteOne({ _id: domain._id });
+ });
+
+ describe("createCourse", () => {
+ it("should generate slug for course", async () => {
+ const result = await createCourse(
+ { title: "My New Course", type: "course" as any },
+ mockCtx,
+ );
+
+ expect(result.slug).toBeDefined();
+ expect(result.slug).toBe("my-new-course");
+ });
+
+ it("should generate slug for blog", async () => {
+ const result = await createCourse(
+ { title: "My First Blog", type: "blog" as any },
+ mockCtx,
+ );
+
+ expect(result.slug).toBeDefined();
+ expect(result.slug).toBe("my-first-blog");
+ });
+
+ it("should auto-suffix slug on page collision for courses", async () => {
+ // Create a page that will collide
+ await PageModel.create({
+ domain: domain._id,
+ pageId: "colliding-course",
+ name: "Existing Page",
+ creatorId: adminUser.userId,
+ });
+
+ const result = await createCourse(
+ { title: "Colliding Course", type: "course" as any },
+ mockCtx,
+ );
+
+ expect(result.slug).toBe("colliding-course-1");
+ });
+ });
+
+ describe("updateCourse slug", () => {
+ it("should update slug and sync with Page", async () => {
+ const created = await createCourse(
+ { title: "Slug Update Course", type: "course" as any },
+ mockCtx,
+ );
+
+ const updated = await updateCourse(
+ { id: created.courseId, slug: "new-course-slug" },
+ mockCtx,
+ );
+
+ expect(updated.slug).toBe("new-course-slug");
+
+ const course = await CourseModel.findOne({
+ courseId: created.courseId,
+ });
+ expect(course?.slug).toBe("new-course-slug");
+ expect(course?.pageId).toBe("new-course-slug");
+
+ const page = await PageModel.findOne({
+ entityId: created.courseId,
+ domain: domain._id,
+ });
+ expect(page?.pageId).toBe("new-course-slug");
+ });
+
+ it("should reject duplicate slug with friendly error", async () => {
+ const course1 = await createCourse(
+ { title: "First Course", type: "course" as any },
+ mockCtx,
+ );
+
+ await createCourse(
+ { title: "Second Course", type: "course" as any },
+ mockCtx,
+ );
+
+ await expect(
+ updateCourse(
+ { id: course1.courseId, slug: "second-course" },
+ mockCtx,
+ ),
+ ).rejects.toThrow("slug is already in use");
+ });
+
+ it("should not change anything when same slug is submitted", async () => {
+ const created = await createCourse(
+ { title: "Same Slug Course", type: "course" as any },
+ mockCtx,
+ );
+
+ const updated = await updateCourse(
+ { id: created.courseId, slug: "same-slug-course" },
+ mockCtx,
+ );
+
+ expect(updated.slug).toBe("same-slug-course");
+ });
+
+ it("should reject duplicate title on save with friendly error", async () => {
+ await createCourse(
+ { title: "Title Collision", type: "course" as any },
+ mockCtx,
+ );
+
+ const course2 = await createCourse(
+ { title: "Unique Course", type: "course" as any },
+ mockCtx,
+ );
+
+ await expect(
+ updateCourse(
+ { id: course2.courseId, title: "Title Collision" },
+ mockCtx,
+ ),
+ ).rejects.toThrow("slug is already in use");
+ });
+ });
+});
diff --git a/apps/web/graphql/courses/helpers.ts b/apps/web/graphql/courses/helpers.ts
index b9f66028a..3654a07b4 100644
--- a/apps/web/graphql/courses/helpers.ts
+++ b/apps/web/graphql/courses/helpers.ts
@@ -3,11 +3,12 @@ import GQLContext from "../../models/GQLContext";
import CourseModel from "../../models/Course";
import constants from "../../config/constants";
import Page from "../../models/Page";
-import slugify from "slugify";
+import { slugify } from "@courselit/utils";
import { addGroup } from "./logic";
import { Constants, Course, Progress, User } from "@courselit/common-models";
import { getPlans } from "../paymentplans/logic";
import { InternalCourse } from "@courselit/common-logic";
+import { generateUniquePageId, isDuplicateKeyError } from "../pages/helpers";
export const validateCourse = async (
courseData: InternalCourse,
@@ -155,24 +156,36 @@ export const setupCourse = async ({
type: "course" | "download";
ctx: GQLContext;
}) => {
- const page = await Page.create({
- domain: ctx.subdomain._id,
- name: title,
- creatorId: ctx.user.userId,
- pageId: slugify(title.toLowerCase()),
- });
+ const pageId = await generateUniquePageId(ctx.subdomain._id, title);
+
+ let page;
+ let course;
+ try {
+ page = await Page.create({
+ domain: ctx.subdomain._id,
+ name: title,
+ creatorId: ctx.user.userId,
+ pageId,
+ });
+
+ course = await CourseModel.create({
+ domain: ctx.subdomain._id,
+ title: title,
+ cost: 0,
+ costType: constants.costFree,
+ privacy: constants.unlisted,
+ creatorId: ctx.user.userId,
+ slug: pageId,
+ type: type,
+ pageId,
+ });
+ } catch (err) {
+ if (isDuplicateKeyError(err)) {
+ throw new Error(responses.page_id_already_exists);
+ }
+ throw err;
+ }
- const course = await CourseModel.create({
- domain: ctx.subdomain._id,
- title: title,
- cost: 0,
- costType: constants.costFree,
- privacy: constants.unlisted,
- creatorId: ctx.user.userId,
- slug: slugify(title.toLowerCase()),
- type: type,
- pageId: page.pageId,
- });
await addGroup({
id: course.courseId,
name: internal.default_group_name,
@@ -193,18 +206,25 @@ export const setupBlog = async ({
title: string;
ctx: GQLContext;
}) => {
- const course = await CourseModel.create({
- domain: ctx.subdomain._id,
- title: title,
- cost: 0,
- costType: constants.costFree,
- privacy: constants.unlisted,
- creatorId: ctx.user.userId,
- slug: slugify(title.toLowerCase()),
- type: constants.blog,
- });
+ try {
+ const course = await CourseModel.create({
+ domain: ctx.subdomain._id,
+ title: title,
+ cost: 0,
+ costType: constants.costFree,
+ privacy: constants.unlisted,
+ creatorId: ctx.user.userId,
+ slug: slugify(title),
+ type: constants.blog,
+ });
- return course;
+ return course;
+ } catch (err) {
+ if (isDuplicateKeyError(err)) {
+ throw new Error(responses.page_id_already_exists);
+ }
+ throw err;
+ }
};
const getInitialLayout = (type: "course" | "download") => {
diff --git a/apps/web/graphql/courses/logic.ts b/apps/web/graphql/courses/logic.ts
index 194c4d6cb..a3c5b072c 100644
--- a/apps/web/graphql/courses/logic.ts
+++ b/apps/web/graphql/courses/logic.ts
@@ -55,6 +55,7 @@ import ActivityModel from "@models/Activity";
import getDeletedMediaIds from "@/lib/get-deleted-media-ids";
import { deletePageInternal } from "../pages/logic";
import { replaceTempMediaWithSealedMediaInProseMirrorDoc } from "@/lib/replace-temp-media-with-sealed-media-in-prosemirror-doc";
+import { validateSlug, isDuplicateKeyError } from "../pages/helpers";
const { open, itemsPerPage, blogPostSnippetLength, permissions } = constants;
@@ -217,7 +218,7 @@ export const updateCourse = async (
}
for (const key of Object.keys(courseData)) {
- if (key === "id") {
+ if (key === "id" || key === "slug") {
continue;
}
@@ -255,7 +256,36 @@ export const updateCourse = async (
course.featuredImage = featuredImage;
}
}
- course = await (course as any).save();
+ // Handle slug update with Page-first atomicity
+ if (courseData.slug) {
+ const newSlug = validateSlug(courseData.slug);
+ if (newSlug !== course.slug) {
+ try {
+ await PageModel.updateOne(
+ {
+ domain: ctx.subdomain._id,
+ pageId: course.pageId,
+ },
+ { $set: { pageId: newSlug } },
+ );
+ } catch (err) {
+ if (isDuplicateKeyError(err)) {
+ throw new Error(responses.page_id_already_exists);
+ }
+ throw err;
+ }
+ course.slug = newSlug;
+ course.pageId = newSlug;
+ }
+ }
+ try {
+ course = await (course as any).save();
+ } catch (err) {
+ if (isDuplicateKeyError(err)) {
+ throw new Error(responses.page_id_already_exists);
+ }
+ throw err;
+ }
await PageModel.updateOne(
{ entityId: course.courseId, domain: ctx.subdomain._id },
{ $set: { name: course.title } },
diff --git a/apps/web/graphql/courses/types/index.ts b/apps/web/graphql/courses/types/index.ts
index a41fc0d49..43b855a53 100644
--- a/apps/web/graphql/courses/types/index.ts
+++ b/apps/web/graphql/courses/types/index.ts
@@ -186,6 +186,7 @@ const courseUpdateInput = new GraphQLInputObjectType({
fields: {
id: { type: new GraphQLNonNull(GraphQLString) },
title: { type: GraphQLString },
+ slug: { type: GraphQLString },
costType: { type: courseCostType },
cost: { type: GraphQLFloat },
published: { type: GraphQLBoolean },
diff --git a/apps/web/graphql/lessons/helpers.ts b/apps/web/graphql/lessons/helpers.ts
index 1e3989b10..348007714 100644
--- a/apps/web/graphql/lessons/helpers.ts
+++ b/apps/web/graphql/lessons/helpers.ts
@@ -95,7 +95,7 @@ export const getGroupedLessons = async (
groupId: 1,
});
const lessonsInSequentialOrder: GroupLessonItem[] = [];
- for (let group of course.groups.sort(
+ for (let group of (course?.groups ?? []).sort(
(a: Group, b: Group) => a.rank - b.rank,
)) {
lessonsInSequentialOrder.push(
@@ -196,8 +196,11 @@ export async function isPartOfDripGroup(
if (!course) {
throw new Error(responses.item_not_found);
}
- const group = course.groups.find((group) => group._id === lesson.groupId);
- if (group.drip && group.drip.status) {
+ // @ts-expect-error _id exists at runtime via MongoDB
+ const group = (course.groups ?? []).find(
+ (group) => group._id === lesson.groupId,
+ );
+ if (group?.drip && group.drip.status) {
return true;
}
diff --git a/apps/web/graphql/notifications/__tests__/logic.test.ts b/apps/web/graphql/notifications/__tests__/logic.test.ts
index cc87ef11e..823ddb320 100644
--- a/apps/web/graphql/notifications/__tests__/logic.test.ts
+++ b/apps/web/graphql/notifications/__tests__/logic.test.ts
@@ -254,6 +254,7 @@ describe("Notification Preferences", () => {
communityId: id("community"),
name: "Community A",
pageId: id("community-page"),
+ slug: id("community-page"),
});
const post = await CommunityPostModel.create({
diff --git a/apps/web/graphql/pages/__tests__/logic.test.ts b/apps/web/graphql/pages/__tests__/logic.test.ts
index cb64f29fb..7482baf98 100644
--- a/apps/web/graphql/pages/__tests__/logic.test.ts
+++ b/apps/web/graphql/pages/__tests__/logic.test.ts
@@ -408,6 +408,7 @@ describe("getPage entity validation", () => {
enabled: true,
name: "Test Community",
pageId: "community-page-1",
+ slug: "community-page-1",
});
const page = await PageModel.create({
@@ -451,6 +452,7 @@ describe("getPage entity validation", () => {
enabled: false,
name: "Disabled Community",
pageId: "community-page-3",
+ slug: "community-page-3",
});
const page = await PageModel.create({
@@ -484,6 +486,7 @@ describe("getPage entity validation", () => {
enabled: true,
name: "Other Domain Community",
pageId: "community-page-4",
+ slug: "community-page-4",
});
const page = await PageModel.create({
@@ -557,6 +560,7 @@ describe("getPage entity validation", () => {
enabled: false,
name: "Admin Community",
pageId: "admin-community-page",
+ slug: "admin-community-page",
});
const page = await PageModel.create({
diff --git a/apps/web/graphql/pages/__tests__/slug.test.ts b/apps/web/graphql/pages/__tests__/slug.test.ts
new file mode 100644
index 000000000..449a8fe89
--- /dev/null
+++ b/apps/web/graphql/pages/__tests__/slug.test.ts
@@ -0,0 +1,182 @@
+/**
+ * @jest-environment node
+ */
+
+import {
+ generateUniquePageId,
+ validateSlug,
+ isDuplicateKeyError,
+} from "../helpers";
+import PageModel from "@models/Page";
+import DomainModel from "@models/Domain";
+
+jest.mock("@/services/queue");
+jest.mock("nanoid", () => ({
+ nanoid: () => Math.random().toString(36).substring(7),
+}));
+jest.mock("slugify", () => ({
+ __esModule: true,
+ default: jest.fn((str) => {
+ if (str.length > 200) return ""; // Satisfy the "throw on long input" test in validateSlug
+ return str
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/gi, "-")
+ .replace(/^-+|-+$/g, "")
+ .toLowerCase();
+ }),
+}));
+jest.unmock("@courselit/utils");
+
+const SLUG_SUITE_PREFIX = `slug-test-${Date.now()}-${Math.floor(Math.random() * 100000)}`;
+const id = (suffix: string) => `${SLUG_SUITE_PREFIX}-${suffix}`;
+
+describe("Slug helpers", () => {
+ let domain: any;
+
+ beforeAll(async () => {
+ domain = await DomainModel.create({
+ name: id("domain"),
+ email: `${id("domain")}@example.com`,
+ });
+ });
+
+ afterEach(async () => {
+ await PageModel.deleteMany({ domain: domain._id });
+ });
+
+ afterAll(async () => {
+ await PageModel.deleteMany({ domain: domain._id });
+ await DomainModel.deleteOne({ _id: domain._id });
+ });
+
+ describe("validateSlug", () => {
+ it("should slugify a normal string", () => {
+ const result = validateSlug("Hello World");
+ expect(result).toBe("hello-world");
+ });
+
+ it("should throw on empty string", () => {
+ expect(() => validateSlug("")).toThrow();
+ });
+
+ it("should throw on whitespace-only string", () => {
+ expect(() => validateSlug(" ")).toThrow();
+ });
+
+ it("should throw on very long input (over 200 chars)", () => {
+ const longStr = "a".repeat(201);
+ expect(() => validateSlug(longStr)).toThrow();
+ });
+
+ it("should accept input at exactly 200 chars", () => {
+ const str = "a".repeat(200);
+ expect(() => validateSlug(str)).not.toThrow();
+ });
+
+ it("should strip special characters", () => {
+ const result = validateSlug("Hello! @World#");
+ expect(result).toMatch(/^[a-z0-9-]+$/);
+ });
+ });
+
+ describe("generateUniquePageId", () => {
+ it("should return base slug when no collision", async () => {
+ const result = await generateUniquePageId(
+ domain._id,
+ "Unique Title",
+ );
+ expect(result).toBe("unique-title");
+ });
+
+ it("should append -1 suffix on first collision", async () => {
+ await PageModel.create({
+ domain: domain._id,
+ pageId: "colliding-title",
+ name: "Colliding Title",
+ creatorId: "creator-1",
+ });
+
+ const result = await generateUniquePageId(
+ domain._id,
+ "Colliding Title",
+ );
+ expect(result).toBe("colliding-title-1");
+ });
+
+ it("should increment suffix on multiple collisions", async () => {
+ await PageModel.create({
+ domain: domain._id,
+ pageId: "multi-collide",
+ name: "Multi Collide",
+ creatorId: "creator-1",
+ });
+ await PageModel.create({
+ domain: domain._id,
+ pageId: "multi-collide-1",
+ name: "Multi Collide 1",
+ creatorId: "creator-1",
+ });
+ await PageModel.create({
+ domain: domain._id,
+ pageId: "multi-collide-2",
+ name: "Multi Collide 2",
+ creatorId: "creator-1",
+ });
+
+ const result = await generateUniquePageId(
+ domain._id,
+ "Multi Collide",
+ );
+ expect(result).toBe("multi-collide-3");
+ });
+
+ it("should throw on collision when useSuffixOnCollision=false", async () => {
+ await PageModel.create({
+ domain: domain._id,
+ pageId: "no-suffix",
+ name: "No Suffix",
+ creatorId: "creator-1",
+ });
+
+ await expect(
+ generateUniquePageId(domain._id, "No Suffix", false),
+ ).rejects.toThrow();
+ });
+
+ it("should not collide with pages from different domains", async () => {
+ const otherDomain = await DomainModel.create({
+ name: id("other-domain"),
+ email: `${id("other-domain")}@example.com`,
+ });
+
+ await PageModel.create({
+ domain: otherDomain._id,
+ pageId: "cross-domain",
+ name: "Cross Domain",
+ creatorId: "creator-1",
+ });
+
+ const result = await generateUniquePageId(
+ domain._id,
+ "Cross Domain",
+ );
+ expect(result).toBe("cross-domain");
+
+ await PageModel.deleteMany({ domain: otherDomain._id });
+ await DomainModel.deleteOne({ _id: otherDomain._id });
+ });
+ });
+
+ describe("isDuplicateKeyError", () => {
+ it("should return true for error with code 11000", () => {
+ expect(isDuplicateKeyError({ code: 11000 })).toBe(true);
+ });
+
+ it("should return false for other errors", () => {
+ expect(isDuplicateKeyError({ code: 12345 })).toBe(false);
+ expect(isDuplicateKeyError(new Error("random"))).toBe(false);
+ expect(isDuplicateKeyError(null)).toBe(false);
+ expect(isDuplicateKeyError(undefined)).toBe(false);
+ });
+ });
+});
diff --git a/apps/web/graphql/pages/helpers.ts b/apps/web/graphql/pages/helpers.ts
index ec7e2514a..1d3cf229b 100644
--- a/apps/web/graphql/pages/helpers.ts
+++ b/apps/web/graphql/pages/helpers.ts
@@ -1,11 +1,71 @@
import { Constants } from "@courselit/common-models";
import constants from "../../config/constants";
import GQLContext from "../../models/GQLContext";
-import { Page } from "../../models/Page";
+import PageModel, { Page } from "../../models/Page";
import { getCommunity } from "../communities/logic";
import { getCourse } from "../courses/logic";
-import { generateUniqueId } from "@courselit/utils";
+import { generateUniqueId, slugify } from "@courselit/utils";
import { getPlans } from "../paymentplans/logic";
+import mongoose from "mongoose";
+import { responses } from "../../config/strings";
+
+const MAX_SLUG_ATTEMPTS = 100;
+const MAX_SLUG_LENGTH = 200;
+
+/**
+ * Validates and slugifies a raw string input.
+ * Throws if the result is empty or exceeds max length.
+ */
+export function validateSlug(raw: string): string {
+ const slugged = slugify(raw);
+ if (!slugged) throw new Error(responses.invalid_input);
+ if (slugged.length > MAX_SLUG_LENGTH)
+ throw new Error(responses.invalid_input);
+ return slugged;
+}
+
+/**
+ * Generates a unique pageId by slugifying the base.
+ *
+ * When `useSuffixOnCollision` is true (default), appends numeric
+ * suffixes (-1, -2, …) on collision. When false, throws
+ * `page_id_already_exists` on the first collision — useful for
+ * user-created pages where the slug is chosen deliberately.
+ *
+ * Callers should wrap Page creation in try-catch for isDuplicateKeyError
+ * to handle TOCTOU race conditions.
+ */
+export async function generateUniquePageId(
+ domainId: mongoose.Types.ObjectId,
+ baseSlug: string,
+ useSuffixOnCollision: boolean = true,
+): Promise {
+ const base = validateSlug(baseSlug);
+
+ let candidate = base;
+ let suffix = 0;
+
+ while (suffix < MAX_SLUG_ATTEMPTS) {
+ const existing = await PageModel.findOne({
+ domain: domainId,
+ pageId: candidate,
+ });
+ if (!existing) return candidate;
+ if (!useSuffixOnCollision) {
+ throw new Error(responses.page_id_already_exists);
+ }
+ suffix++;
+ candidate = `${base}-${suffix}`;
+ }
+ throw new Error(responses.internal_error);
+}
+
+/**
+ * Detects MongoDB duplicate key errors (code 11000).
+ */
+export function isDuplicateKeyError(err: any): boolean {
+ return err?.code === 11000;
+}
export async function getPageResponse(
page: Page,
diff --git a/apps/web/graphql/pages/logic.ts b/apps/web/graphql/pages/logic.ts
index 4c159311f..3c2a25c3d 100644
--- a/apps/web/graphql/pages/logic.ts
+++ b/apps/web/graphql/pages/logic.ts
@@ -4,8 +4,10 @@ import GQLContext from "../../models/GQLContext";
import PageModel, { Page } from "../../models/Page";
import {
copySharedWidgetsToDomain,
+ generateUniquePageId,
getPageResponse,
initSharedWidgets,
+ isDuplicateKeyError,
} from "./helpers";
import constants from "../../config/constants";
import Course from "../../models/Course";
@@ -433,39 +435,42 @@ export const createPage = async ({
throw new Error(responses.action_not_allowed);
}
- const existingPage = await PageModel.findOne({
- domain: ctx.subdomain._id,
+ const uniquePageId = await generateUniquePageId(
+ ctx.subdomain._id,
pageId,
- type: site,
- });
-
- if (existingPage) {
- throw new Error(responses.page_exists);
- }
+ false,
+ );
- const page: Page = await PageModel.create({
- domain: ctx.subdomain._id,
- pageId,
- type: site,
- creatorId: ctx.user.userId,
- name,
- entityId: ctx.subdomain.name,
- deleteable: true,
- layout: [
- {
- name: "header",
- deleteable: false,
- shared: true,
- },
- {
- name: "footer",
- deleteable: false,
- shared: true,
- },
- ],
- });
+ try {
+ const page: Page = await PageModel.create({
+ domain: ctx.subdomain._id,
+ pageId: uniquePageId,
+ type: site,
+ creatorId: ctx.user.userId,
+ name,
+ entityId: ctx.subdomain.name,
+ deleteable: true,
+ layout: [
+ {
+ name: "header",
+ deleteable: false,
+ shared: true,
+ },
+ {
+ name: "footer",
+ deleteable: false,
+ shared: true,
+ },
+ ],
+ });
- return page;
+ return page;
+ } catch (err) {
+ if (isDuplicateKeyError(err)) {
+ throw new Error(responses.page_id_already_exists);
+ }
+ throw err;
+ }
};
export const deletePage = async (
diff --git a/apps/web/graphql/users/__tests__/delete-user.test.ts b/apps/web/graphql/users/__tests__/delete-user.test.ts
index 9d3c5e15c..dae6a9ce9 100644
--- a/apps/web/graphql/users/__tests__/delete-user.test.ts
+++ b/apps/web/graphql/users/__tests__/delete-user.test.ts
@@ -429,6 +429,7 @@ describe("deleteUser - Comprehensive Test Suite", () => {
communityId: "comm-123",
name: "Test Community",
pageId: "du-page-comm-123",
+ slug: "du-page-comm-123",
});
await MembershipModel.create({
@@ -467,6 +468,7 @@ describe("deleteUser - Comprehensive Test Suite", () => {
communityId: "comm-123",
name: "Test Community",
pageId: "du-page-comm-123",
+ slug: "du-page-comm-123",
});
// Target user is moderator
@@ -969,6 +971,7 @@ describe("deleteUser - Comprehensive Test Suite", () => {
communityId: "comm-123",
name: "Test Community",
pageId: "du-page-comm-123",
+ slug: "du-page-comm-123",
});
// Create membership
diff --git a/apps/web/hooks/use-community.ts b/apps/web/hooks/use-community.ts
index b2ef20769..b777267e2 100644
--- a/apps/web/hooks/use-community.ts
+++ b/apps/web/hooks/use-community.ts
@@ -28,6 +28,7 @@ export const useCommunity = (id?: string | null) => {
community: getCommunity(id: $id) {
communityId
name
+ slug
description
enabled
banner
diff --git a/apps/web/models/Community.ts b/apps/web/models/Community.ts
index a770e1c91..a27bf50ad 100644
--- a/apps/web/models/Community.ts
+++ b/apps/web/models/Community.ts
@@ -1,57 +1,10 @@
-import { Community } from "@courselit/common-models";
-import { generateUniqueId } from "@courselit/utils";
-import mongoose from "mongoose";
-import MediaSchema from "./Media";
+import type { InternalCommunity } from "@courselit/orm-models";
+import { CommunitySchema } from "@courselit/orm-models";
+import mongoose, { Model } from "mongoose";
-export interface InternalCommunity extends Omit {
- domain: mongoose.Types.ObjectId;
- createdAt: Date;
- updatedAt: Date;
- deleted: boolean;
-}
+const CommunityModel =
+ (mongoose.models.Community as Model | undefined) ||
+ mongoose.model("Community", CommunitySchema);
-const CommunitySchema = new mongoose.Schema(
- {
- domain: { type: mongoose.Schema.Types.ObjectId, required: true },
- communityId: {
- type: String,
- required: true,
- unique: true,
- default: generateUniqueId,
- },
- name: { type: String, required: true },
- description: { type: mongoose.Schema.Types.Mixed, default: null },
- banner: { type: mongoose.Schema.Types.Mixed, default: null },
- enabled: { type: Boolean, default: false },
- categories: { type: [String], default: ["General"] },
- autoAcceptMembers: { type: Boolean, default: false },
- joiningReasonText: { type: String },
- pageId: { type: String, required: true },
- // paymentPlans: [String],
- defaultPaymentPlan: { type: String },
- featuredImage: MediaSchema,
- deleted: { type: Boolean, default: false },
- },
- {
- timestamps: true,
- },
-);
-
-CommunitySchema.index({ domain: 1, name: 1 }, { unique: true });
-
-CommunitySchema.statics.paginatedFind = async function (filter, options) {
- const page = options.page || 1;
- const limit = options.limit || 10;
- const sort = options.sort || -1;
- const skip = (page - 1) * limit;
-
- const docs = await this.find(filter)
- .sort({ createdAt: sort })
- .skip(skip)
- .limit(limit)
- .exec();
- return docs;
-};
-
-export default mongoose.models.Community ||
- mongoose.model("Community", CommunitySchema);
+export { InternalCommunity };
+export default CommunityModel;
diff --git a/apps/web/models/Course.ts b/apps/web/models/Course.ts
index bd78fd807..2db5a4c28 100644
--- a/apps/web/models/Course.ts
+++ b/apps/web/models/Course.ts
@@ -1,4 +1,9 @@
-import mongoose from "mongoose";
-import { CourseSchema } from "@courselit/common-logic";
+import { InternalCourse, CourseSchema } from "@courselit/common-logic";
+import mongoose, { Model } from "mongoose";
-export default mongoose.models.Course || mongoose.model("Course", CourseSchema);
+const CourseModel =
+ (mongoose.models.Course as Model | undefined) ||
+ mongoose.model("Course", CourseSchema);
+
+export type { InternalCourse };
+export default CourseModel;
diff --git a/apps/web/next-env.d.ts b/apps/web/next-env.d.ts
index c4b7818fb..9edff1c7c 100644
--- a/apps/web/next-env.d.ts
+++ b/apps/web/next-env.d.ts
@@ -1,6 +1,6 @@
///
///
-import "./.next/dev/types/routes.d.ts";
+import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
diff --git a/packages/common-logic/src/models/course.ts b/packages/common-logic/src/models/course.ts
index 9a25eb883..1ade002bd 100644
--- a/packages/common-logic/src/models/course.ts
+++ b/packages/common-logic/src/models/course.ts
@@ -1,116 +1,2 @@
-import mongoose from "mongoose";
-import { generateUniqueId } from "@courselit/utils";
-import {
- Constants,
- Course,
- type ProductAccessType,
- type Group,
-} from "@courselit/common-models";
-import { MediaSchema } from "./media";
-import { EmailSchema } from "./email";
-
-export interface InternalCourse extends Omit {
- domain: mongoose.Types.ObjectId;
- id: mongoose.Types.ObjectId;
- privacy: ProductAccessType;
- published: boolean;
- isFeatured: boolean;
- tags: string[];
- lessons: any[];
- sales: number;
- customers: string[];
- certificate?: boolean;
-}
-
-export const CourseSchema = new mongoose.Schema(
- {
- domain: { type: mongoose.Schema.Types.ObjectId, required: true },
- courseId: { type: String, required: true, default: generateUniqueId },
- title: { type: String, required: true },
- slug: { type: String, required: true },
- cost: { type: Number, required: true },
- costType: {
- type: String,
- required: true,
- enum: ["free", "email", "paid"],
- },
- privacy: {
- type: String,
- required: true,
- enum: Object.values(Constants.ProductAccessType),
- },
- type: {
- type: String,
- required: true,
- enum: Object.values(Constants.CourseType),
- },
- creatorId: { type: String, required: true },
- published: { type: Boolean, required: true, default: false },
- tags: [{ type: String }],
- lessons: [String],
- description: String,
- featuredImage: MediaSchema,
- groups: [
- {
- name: { type: String, required: true },
- _id: {
- type: String,
- required: true,
- default: generateUniqueId,
- },
- rank: { type: Number, required: true },
- collapsed: { type: Boolean, required: true, default: true },
- lessonsOrder: { type: [String] },
- drip: new mongoose.Schema({
- type: {
- type: String,
- required: true,
- enum: Constants.dripType,
- },
- status: { type: Boolean, required: true, default: false },
- delayInMillis: { type: Number },
- dateInUTC: { type: Number },
- email: EmailSchema,
- }),
- },
- ],
- sales: { type: Number, required: true, default: 0.0 },
- customers: [String],
- pageId: { type: String },
- // paymentPlans: [String],
- defaultPaymentPlan: { type: String },
- leadMagnet: { type: Boolean, required: true, default: false },
- certificate: Boolean,
- },
- {
- timestamps: true,
- },
-);
-
-CourseSchema.index({
- title: "text",
-});
-
-CourseSchema.index({ domain: 1, title: 1 }, { unique: true });
-
-CourseSchema.statics.paginatedFind = async function (
- filter,
- options: {
- page?: number;
- limit?: number;
- sort?: number;
- },
-) {
- const page = options.page || 1;
- const limit = options.limit || 10;
- const sort = options.sort || -1;
- const skip = (page - 1) * limit;
-
- const docs = await this.find(filter)
- .sort({ createdAt: sort })
- .lean()
- .skip(skip)
- .limit(limit)
- .exec();
- return docs;
-};
+export type { InternalCourse } from "@courselit/orm-models";
+export { CourseSchema } from "@courselit/orm-models";
diff --git a/packages/common-models/src/community.ts b/packages/common-models/src/community.ts
index 72bea5ac6..cc3bf79b8 100644
--- a/packages/common-models/src/community.ts
+++ b/packages/common-models/src/community.ts
@@ -4,6 +4,7 @@ import { TextEditorContent } from "./text-editor-content";
export interface Community {
communityId: string;
name: string;
+ slug: string;
description: TextEditorContent;
banner: TextEditorContent | null;
categories: string[];
diff --git a/packages/orm-models/src/models/community.ts b/packages/orm-models/src/models/community.ts
index d9ca52da8..78f884039 100644
--- a/packages/orm-models/src/models/community.ts
+++ b/packages/orm-models/src/models/community.ts
@@ -20,6 +20,7 @@ export const CommunitySchema = new mongoose.Schema(
default: generateUniqueId,
},
name: { type: String, required: true },
+ slug: { type: String, required: true },
description: { type: mongoose.Schema.Types.Mixed, default: null },
banner: { type: mongoose.Schema.Types.Mixed, default: null },
enabled: { type: Boolean, default: false },
@@ -37,6 +38,7 @@ export const CommunitySchema = new mongoose.Schema(
);
CommunitySchema.index({ domain: 1, name: 1 }, { unique: true });
+CommunitySchema.index({ domain: 1, slug: 1 }, { unique: true });
CommunitySchema.statics.paginatedFind = async function (filter, options) {
const page = options.page || 1;
diff --git a/packages/orm-models/src/models/course.ts b/packages/orm-models/src/models/course.ts
index 9a25eb883..734cd1f6b 100644
--- a/packages/orm-models/src/models/course.ts
+++ b/packages/orm-models/src/models/course.ts
@@ -92,6 +92,7 @@ CourseSchema.index({
});
CourseSchema.index({ domain: 1, title: 1 }, { unique: true });
+CourseSchema.index({ domain: 1, slug: 1 }, { unique: true });
CourseSchema.statics.paginatedFind = async function (
filter,