From 1dd55c4f342c16cc43bd468b5182a38f4c91c894 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Tue, 18 Nov 2025 11:23:17 +0000 Subject: [PATCH] feat: Add User DAO This change introduces a Data Access Object (DAO) for the User model. This is the first step in a larger effort to abstract the database layer and eventually migrate to PostgreSQL. The new UserDAO encapsulates all database interactions for the User model, and the GraphQL logic for users has been refactored to use this new DAO. Unit tests for the DAO have also been added. --- apps/web/dao/user.dao.test.ts | 72 +++++++++++++++ apps/web/dao/user.dao.ts | 155 ++++++++++++++++++++++++++++++++ apps/web/graphql/users/logic.ts | 64 +++++++------ apps/web/jest.server.config.ts | 2 + 4 files changed, 265 insertions(+), 28 deletions(-) create mode 100644 apps/web/dao/user.dao.test.ts create mode 100644 apps/web/dao/user.dao.ts diff --git a/apps/web/dao/user.dao.test.ts b/apps/web/dao/user.dao.test.ts new file mode 100644 index 000000000..c4352237d --- /dev/null +++ b/apps/web/dao/user.dao.test.ts @@ -0,0 +1,72 @@ +import { UserDao } from "./user.dao"; +import UserModel from "@models/User"; +import mongoose from "mongoose"; + +afterEach(async () => { + await UserModel.deleteMany({}); +}); + +describe("UserDao", () => { + let userDao: UserDao; + + beforeEach(() => { + userDao = new UserDao(); + }); + + it("should create a user", async () => { + const userData = { + email: "test@example.com", + name: "Test User", + userId: "123", + unsubscribeToken: "token", + domain: new mongoose.Types.ObjectId().toString(), + }; + const user = await userDao.createUser(userData); + expect(user).toBeDefined(); + expect(user.email).toBe(userData.email); + }); + + it("should get a user by id", async () => { + const userData = { + email: "test@example.com", + name: "Test User", + userId: "123", + unsubscribeToken: "token", + domain: new mongoose.Types.ObjectId().toString(), + }; + await userDao.createUser(userData); + const user = await userDao.getUserById("123", userData.domain); + expect(user).toBeDefined(); + expect(user!.email).toBe(userData.email); + }); + + it("should get a user by email", async () => { + const userData = { + email: "test@example.com", + name: "Test User", + userId: "123", + unsubscribeToken: "token", + domain: new mongoose.Types.ObjectId().toString(), + }; + await userDao.createUser(userData); + const user = await userDao.getUserByEmail("test@example.com", userData.domain); + expect(user).toBeDefined(); + expect(user!.email).toBe(userData.email); + }); + + it("should update a user", async () => { + const userData = { + email: "test@example.com", + name: "Test User", + userId: "123", + unsubscribeToken: "token", + domain: new mongoose.Types.ObjectId().toString(), + }; + await userDao.createUser(userData); + const updatedUser = await userDao.updateUser("123", userData.domain, { + name: "Updated Test User", + }); + expect(updatedUser).toBeDefined(); + expect(updatedUser!.name).toBe("Updated Test User"); + }); +}); diff --git a/apps/web/dao/user.dao.ts b/apps/web/dao/user.dao.ts new file mode 100644 index 000000000..db56c7139 --- /dev/null +++ b/apps/web/dao/user.dao.ts @@ -0,0 +1,155 @@ +import { User } from "@courselit/common-models"; +import UserModel from "@models/User"; +import { makeModelTextSearchable } from "@/lib/graphql"; + +export interface IUserDao { + getUserById(userId: string, domainId: string): Promise; + getUserByEmail(email: string, domainId: string): Promise; + createUser(userData: Partial): Promise; + updateUser( + userId: string, + domainId: string, + userData: Partial, + ): Promise; + find(query: any): Promise; + findOne(query: any): Promise; + findOneAndUpdate( + query: any, + update: any, + options: any, + ): Promise; + countDocuments(query: any): Promise; + updateMany(query: any, update: any): Promise; + aggregate(pipeline: any[]): Promise; + deleteUser(userId: string, domainId: string): Promise; + search( + { + offset, + query, + graphQLContext, + }: { offset: number; query: any; graphQLContext: any }, + { + itemsPerPage, + sortByColumn, + sortOrder, + }: { + itemsPerPage: number; + sortByColumn: string; + sortOrder: number; + }, + ): Promise; +} + +export class UserDao implements IUserDao { + public async getUserById( + userId: string, + domainId: string, + ): Promise { + const user = await UserModel.findOne({ + userId, + domain: domainId, + }).lean(); + return user as User; + } + + public async getUserByEmail( + email: string, + domainId: string, + ): Promise { + const user = await UserModel.findOne({ + email, + domain: domainId, + }).lean(); + return user as User; + } + + public async createUser(userData: Partial): Promise { + const user = new UserModel(userData); + const newUser = await user.save(); + return newUser.toObject(); + } + + public async updateUser( + userId: string, + domainId: string, + userData: Partial, + ): Promise { + const user = await UserModel.findOneAndUpdate( + { userId, domain: domainId }, + { $set: userData }, + { new: true }, + ).lean(); + return user as User; + } + + public async find(query: any): Promise { + const users = await UserModel.find(query).lean(); + return users as User[]; + } + + public async findOne(query: any): Promise { + const user = await UserModel.findOne(query).lean(); + return user as User; + } + + public async findOneAndUpdate( + query: any, + update: any, + options: any, + ): Promise { + const user = await UserModel.findOneAndUpdate( + query, + update, + options, + ).lean(); + return user as User; + } + + public async countDocuments(query: any): Promise { + return await UserModel.countDocuments(query); + } + + public async updateMany(query: any, update: any): Promise { + return await UserModel.updateMany(query, update); + } + + public async aggregate(pipeline: any[]): Promise { + return await UserModel.aggregate(pipeline); + } + + public async deleteUser(userId: string, domainId: string): Promise { + return await UserModel.deleteOne({ userId, domain: domainId }); + } + + public async search( + { + offset, + query, + graphQLContext, + }: { offset: number; query: any; graphQLContext: any }, + { + itemsPerPage, + sortByColumn, + sortOrder, + }: { + itemsPerPage: number; + sortByColumn: string; + sortOrder: number; + }, + ): Promise { + const searchUsers = makeModelTextSearchable(UserModel); + const users = await searchUsers( + { + offset, + query, + graphQLContext, + }, + { + itemsPerPage, + sortByColumn, + sortOrder, + }, + ); + return users.map((user: any) => user.toObject()); + } +} diff --git a/apps/web/graphql/users/logic.ts b/apps/web/graphql/users/logic.ts index 843d1305d..2a0883633 100644 --- a/apps/web/graphql/users/logic.ts +++ b/apps/web/graphql/users/logic.ts @@ -1,9 +1,11 @@ "use server"; -import UserModel from "@models/User"; import { responses } from "@/config/strings"; -import { makeModelTextSearchable, checkIfAuthenticated } from "@/lib/graphql"; +import { checkIfAuthenticated } from "@/lib/graphql"; +import { UserDao } from "@/dao/user.dao"; import constants from "@/config/constants"; + +const userDao = new UserDao(); import GQLContext from "@/models/GQLContext"; import { initMandatoryPages } from "../pages/logic"; import { Domain } from "@models/Domain"; @@ -73,7 +75,7 @@ export const getUser = async (userId = null, ctx: GQLContext) => { let user: any = ctx.user; if (userId) { - user = await UserModel.findOne({ userId, domain: ctx.subdomain._id }); + user = await userDao.findOne({ userId, domain: ctx.subdomain._id }); } if (!user) { @@ -124,25 +126,26 @@ export const updateUser = async (userData: UserData, ctx: GQLContext) => { throw new Error(responses.action_not_allowed); } - let user = await UserModel.findOne({ + let user = await userDao.findOne({ userId: id, domain: ctx.subdomain._id, }); if (!user) throw new Error(responses.item_not_found); + const updateData: Partial = {}; for (const key of keys.filter((key) => key !== "id")) { if (key === "tags") { - addTags(userData["tags"]!, ctx); + await addTags(userData["tags"]!, ctx); } - user[key] = userData[key]; + (updateData as any)[key] = (userData as any)[key]; } - validateUserProperties(user); + validateUserProperties(updateData); - user = await user.save(); + const updatedUser = await userDao.updateUser(id, ctx.subdomain._id as any, updateData); - return user; + return updatedUser; }; export const inviteCustomer = async ( @@ -162,7 +165,7 @@ export const inviteCustomer = async ( } const sanitizedEmail = (email as string).toLowerCase(); - let user = await UserModel.findOne({ + let user = await userDao.findOne({ email: sanitizedEmail, domain: ctx.subdomain._id, }); @@ -232,7 +235,7 @@ export const deleteUser = async ( throw new Error(responses.action_not_allowed); } - const userToDelete = await UserModel.findOne({ + const userToDelete = await userDao.findOne({ domain: ctx.subdomain._id, userId, }); @@ -246,16 +249,22 @@ export const deleteUser = async ( } const deleterUser = - (await UserModel.findOne({ + (await userDao.findOne({ domain: ctx.subdomain._id, userId: ctx.user.userId, })) || (ctx.user as InternalUser); - await validateUserDeletion(userToDelete, ctx); + await validateUserDeletion(userToDelete as InternalUser, ctx); + + await migrateBusinessEntities( + userToDelete as InternalUser, + deleterUser as InternalUser, + ctx, + ); - await migrateBusinessEntities(userToDelete, deleterUser, ctx); + await cleanupPersonalData(userToDelete as InternalUser, ctx); - await cleanupPersonalData(userToDelete, ctx); + await userDao.deleteUser(userId, ctx.subdomain._id as any); return true; }; @@ -278,9 +287,8 @@ export const getUsers = async ({ throw new Error(responses.action_not_allowed); } - const searchUsers = makeModelTextSearchable(UserModel); const query = await buildQueryFromSearchData(ctx.subdomain._id, filters); - const users = await searchUsers( + const users = await userDao.search( { offset: page, query, @@ -306,7 +314,7 @@ export const getUsersCount = async (ctx: GQLContext, filters?: string) => { } const query = await buildQueryFromSearchData(ctx.subdomain._id, filters); - return await UserModel.countDocuments(query); + return await userDao.countDocuments(query); }; const buildQueryFromSearchData = async ( @@ -379,7 +387,7 @@ export async function createUser({ checkForInvalidPermissions(permissions); } - const rawResult = await UserModel.findOneAndUpdate( + const rawResult = await userDao.findOneAndUpdate( { domain: domain._id, email }, { $setOnInsert: { @@ -529,7 +537,7 @@ export const getTagsWithDetails = async (ctx: GQLContext) => { throw new Error(responses.action_not_allowed); } - const tagsWithUsersCount = await UserModel.aggregate([ + const tagsWithUsersCount = await userDao.aggregate([ { $unwind: "$tags" }, { $match: { @@ -603,7 +611,7 @@ export const deleteTag = async (tag: string, ctx: GQLContext) => { throw new Error(responses.action_not_allowed); } - await UserModel.updateMany( + await userDao.updateMany( { domain: ctx.subdomain._id }, { $pull: { tags: tag } }, ); @@ -622,7 +630,7 @@ export const untagUsers = async (tag: string, ctx: GQLContext) => { throw new Error(responses.action_not_allowed); } - await UserModel.updateMany( + await userDao.updateMany( { domain: ctx.subdomain._id }, { $pull: { tags: tag } }, ); @@ -644,7 +652,7 @@ export const getUserContent = async ( id = userId; } - const user = await UserModel.findOne({ + const user = await userDao.findOne({ userId: id, domain: ctx.subdomain._id, }); @@ -813,7 +821,7 @@ export async function runPostMembershipTasks({ membership: Membership; paymentPlan: PaymentPlan; }) { - const user = await UserModel.findOne({ + const user = await userDao.findOne({ userId: membership.userId, }); if (!user) { @@ -930,10 +938,10 @@ export const getCertificateInternal = async ( const user = certificateId !== "demo" - ? ((await UserModel.findOne({ + ? ((await userDao.findOne({ domain: domain._id, userId: certificate.userId, - }).lean()) as unknown as User) + })) as unknown as User) : { name: "John Doe", email: "john.doe@example.com", @@ -949,10 +957,10 @@ export const getCertificateInternal = async ( throw new Error(responses.item_not_found); } - const creator = (await UserModel.findOne({ + const creator = (await userDao.findOne({ domain: domain._id, userId: course.creatorId, - }).lean()) as unknown as User; + })) as unknown as User; const template = (await CertificateTemplateModel.findOne({ domain: domain._id, diff --git a/apps/web/jest.server.config.ts b/apps/web/jest.server.config.ts index d3b9cc17c..1484d838e 100644 --- a/apps/web/jest.server.config.ts +++ b/apps/web/jest.server.config.ts @@ -20,6 +20,7 @@ const config: Config = { "@/lib/(.*)": "/lib/$1", "@/services/(.*)": "/services/$1", "@/templates/(.*)": "/templates/$1", + "@/dao/(.*)": "/dao/$1", "@/app/(.*)": "/app/$1", "@ui-lib/(.*)": "/ui-lib/$1", "@config/(.*)": "/config/$1", @@ -39,6 +40,7 @@ const config: Config = { testMatch: [ "**/graphql/**/__tests__/**/*.test.ts", "**/api/**/__tests__/**/*.test.ts", + "**/dao/**/*.test.ts", ], testPathIgnorePatterns: [ "/node_modules/",