refactor: use userRepository class methods for signup handlers (#29561)

* refactor: use userRepository class methods for signup handlers

* feat: add user repo modules for DI

* feat: build user repo container

* test: integration test for user repository

* fix: use DI container in selfhosted

* refactor: tests, module, container and handlers

---------

Co-authored-by: Bandhan Majumder <bandhanmajumder16@gmail.com>
This commit is contained in:
Anirban Singha
2026-06-14 16:11:21 +00:00
committed by GitHub
co-authored by Bandhan Majumder
parent 642c38582e
commit dcd0da831f
5 changed files with 399 additions and 106 deletions
@@ -0,0 +1,10 @@
import { createContainer } from "@calcom/features/di/di";
import { moduleLoader as userRepositoryModuleLoader } from "@calcom/features/di/modules/User";
import type { UserRepository } from "@calcom/features/users/repositories/UserRepository";
const userRepositoryContainer = createContainer();
export function getUserRepository(): UserRepository {
userRepositoryModuleLoader.loadModule(userRepositoryContainer);
return userRepositoryContainer.get<UserRepository>(userRepositoryModuleLoader.token);
}
@@ -0,0 +1,236 @@
import { afterEach, beforeAll, describe, expect, it } from "vitest";
import { UserRepository } from "./UserRepository";
import { getUserRepository } from "@calcom/features/di/containers/UserRepository";
import { IdentityProvider } from "@calcom/prisma/enums";
import bcrypt from "bcryptjs";
const testRunId = `${Date.now()}-${Math.random().toString(36).substring(2, 8)}`;
const createdUserIds: number[] = [];
describe("UserRepository Integration Tests - Signup Methods", () => {
let userRepository: UserRepository;
async function cleanupTestUsers() {
if (createdUserIds.length > 0) {
await userRepository.deleteMany({ userIds: createdUserIds })
createdUserIds.length = 0
}
}
beforeAll(async () => {
userRepository = getUserRepository();
});
afterEach(async () => {
await cleanupTestUsers();
});
describe("upsertForSignup", () => {
it("should create a new user when email doesn't exist", async () => {
const testEmail = `signup-new-${testRunId}@example.com`;
const hashedPassword = await bcrypt.hash("password123", 10);
const result = await userRepository.upsertForSignup({
email: testEmail,
username: `signupuser-${testRunId}`,
hashedPassword,
organizationId: null,
emailVerified: new Date(Date.now()),
identityProvider: IdentityProvider.CAL,
});
expect(result).not.toBeNull();
expect(result.id).toBeDefined();
createdUserIds.push(result.id);
const createdUser = await userRepository.findByEmail({ email: testEmail});
expect(createdUser).not.toBeNull();
expect(createdUser?.email).toBe(testEmail);
expect(createdUser?.emailVerified).not.toBeNull();
});
it("should update existing user's credentials on upsert", async () => {
const testEmail = `signup-update-${testRunId}@example.com`;
const hashedPassword1 = await bcrypt.hash("password123", 10);
const hashedPassword2 = await bcrypt.hash("newpassword456", 10);
const user1 = await userRepository.upsertForSignup({
email: testEmail,
username: `user-${testRunId}`,
hashedPassword: hashedPassword1,
organizationId: null,
emailVerified: new Date(Date.now() - 86400000),
identityProvider: IdentityProvider.CAL,
});
createdUserIds.push(user1.id);
const user2 = await userRepository.upsertForSignup({
email: testEmail,
username: `user-${testRunId}-updated`,
hashedPassword: hashedPassword2,
organizationId: null,
emailVerified: new Date(),
identityProvider: IdentityProvider.CAL,
});
expect(user2.id).toBe(user1.id);
const updatedUser = await userRepository.findByEmailAndIncludeProfilesAndPassword({ email: testEmail});
expect(updatedUser?.username).toBe(`user-${testRunId}-updated`);
expect(updatedUser?.password?.hash).toBe(hashedPassword2);
});
it("should preserve user ID on upsert", async () => {
const testEmail = `signup-preserve-${testRunId}@example.com`;
const hashedPassword = await bcrypt.hash("password123", 10);
const user1 = await userRepository.upsertForSignup({
email: testEmail,
username: `user-${testRunId}-p1`,
hashedPassword,
organizationId: null,
emailVerified: new Date(),
identityProvider: IdentityProvider.CAL,
});
createdUserIds.push(user1.id);
const initialId = user1.id;
const user2 = await userRepository.upsertForSignup({
email: testEmail,
username: `user-${testRunId}-p2`,
hashedPassword,
organizationId: null,
emailVerified: new Date(),
identityProvider: IdentityProvider.CAL,
});
expect(user2.id).toBe(initialId);
});
it("should set email as verified on signup", async () => {
const testEmail = `signup-verified-${testRunId}@example.com`;
const hashedPassword = await bcrypt.hash("password123", 10);
const verificationDate = new Date();
const result = await userRepository.upsertForSignup({
email: testEmail,
username: `user-${testRunId}-v1`,
hashedPassword,
organizationId: null,
emailVerified: verificationDate,
identityProvider: IdentityProvider.CAL,
});
createdUserIds.push(result.id);
const user = await userRepository.findByEmail({ email: testEmail });
if (!user) {
throw new Error("User was not found after signup");
}
expect(user?.emailVerified).not.toBeNull();
expect(user?.emailVerified?.getTime()).toBe(verificationDate.getTime());
});
it("should create password record for new user", async () => {
const testEmail = `signup-pwd-${testRunId}@example.com`;
const hashedPassword = await bcrypt.hash("password123", 10);
const result = await userRepository.upsertForSignup({
email: testEmail,
username: `user-${testRunId}-pwd`,
hashedPassword,
organizationId: null,
emailVerified: new Date(),
identityProvider: IdentityProvider.CAL,
});
createdUserIds.push(result.id);
const userWithPassword = await userRepository.findByEmailAndIncludeProfilesAndPassword({ email: testEmail })
if (!userWithPassword) {
throw new Error("User was not found after signup");
}
expect(userWithPassword?.password).not.toBeNull();
expect(userWithPassword?.password?.hash).toBe(hashedPassword);
expect(userWithPassword?.password?.userId).toBe(result.id);
});
it("should throw error on duplicate unique constraint if username exists", async () => {
const testEmail1 = `signup-dup1-${testRunId}@example.com`;
const testEmail2 = `signup-dup2-${testRunId}@example.com`;
const duplicateUsername = `dupuser-${testRunId}`;
const hashedPassword = await bcrypt.hash("password123", 10);
const user1 = await userRepository.upsertForSignup({
email: testEmail1,
username: duplicateUsername,
hashedPassword,
organizationId: null,
emailVerified: new Date(),
identityProvider: IdentityProvider.CAL,
});
createdUserIds.push(user1.id);
await expect(
userRepository.upsertForSignup({
email: testEmail2,
username: duplicateUsername,
hashedPassword,
organizationId: null,
emailVerified: new Date(),
identityProvider: IdentityProvider.CAL,
})
).rejects.toThrow();
});
});
describe("Integration: Individual Signup flow", () => {
it("should complete full individual signup flow: check email, check username, then upsert", async () => {
const testEmail = `flow-${testRunId}@example.com`;
const testUsername = `flowuser-${testRunId}`;
const hashedPassword = await bcrypt.hash("password123", 10);
const existingUser = await userRepository.findByEmail({
email: testEmail,
});
expect(existingUser).toBeNull();
const usersWithUsername = await userRepository.findUsersByUsername({
orgSlug: null,
usernameList: [testUsername],
});
const usernameTaken = usersWithUsername.length > 0 ? usersWithUsername[0] : null;
expect(usernameTaken).toBeNull();
const signupResult = await userRepository.upsertForSignup({
email: testEmail,
username: testUsername,
hashedPassword,
organizationId: null,
emailVerified: new Date(Date.now()),
identityProvider: IdentityProvider.CAL,
});
createdUserIds.push(signupResult.id);
expect(signupResult.id).toBeDefined();
const userWithPassword = await userRepository.findByEmailAndIncludeProfilesAndPassword({ email: testEmail })
if (!userWithPassword) {
throw new Error("User was not found after signup");
}
expect(userWithPassword?.email).toBe(testEmail);
expect(userWithPassword?.username).toBe(testUsername);
expect(userWithPassword?.password).not.toBeNull();
const userWithOrganizationId = await userRepository.getUserOrganizationAndTeams({ userId: userWithPassword.id});
expect(userWithOrganizationId?.organizationId).toBeNull();
});
});
});
@@ -9,6 +9,7 @@ import type { PrismaClient } from "@calcom/prisma";
import { availabilityUserSelect } from "@calcom/prisma";
import type { DestinationCalendar, SelectedCalendar, User as UserType } from "@calcom/prisma/client";
import { Prisma } from "@calcom/prisma/client";
import type { IdentityProvider } from "@calcom/prisma/enums";
import type { CreationSource } from "@calcom/prisma/enums";
import { BookingStatus, MembershipRole } from "@calcom/prisma/enums";
import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential";
@@ -896,20 +897,19 @@ export class UserRepository {
async create(
data: Omit<Prisma.UserCreateInput, "password" | "organization" | "movedToProfile"> & {
username: string;
username: string | null;
hashedPassword?: string;
organizationId: number | null;
creationSource: CreationSource;
locked: boolean;
}
) {
const organizationIdValue = data.organizationId;
const { email, username, creationSource, locked, hashedPassword, ...rest } = data;
const { email, username, creationSource, locked, hashedPassword, organizationId, ...rest } = data;
logger.info("create user", {
log.info("create user", {
email,
username,
organizationIdValue,
organizationId,
locked,
});
const t = await getTranslation("en", "common");
@@ -939,13 +939,13 @@ export class UserRepository {
},
creationSource,
locked,
...(organizationIdValue
...(organizationId && username
? {
organizationId: organizationIdValue,
organizationId,
profiles: {
create: {
username,
organizationId: organizationIdValue,
organizationId,
uid: ProfileRepository.generateProfileUid(),
},
},
@@ -1419,6 +1419,14 @@ export class UserRepository {
});
}
async deleteMany({ userIds }: {userIds: number[]}){
await this.prismaClient.user.deleteMany({
where: {
id: { in: userIds },
},
});
}
/**
* Finds a user by ID returning only their username
* @param userId - The user ID
@@ -1480,6 +1488,38 @@ export class UserRepository {
});
}
async findByEmailWithInvitedTo({ email }: { email: string } ) {
return this.prismaClient.user.findUnique({
where: {
email: email.toLowerCase()
},
select: {
invitedTo: true
}
})
}
async findByUsernameAndOrganizationId({
username,
organizationId,
excludeEmail,
}: {
username: string,
organizationId: number | null,
excludeEmail: string
}) {
return this.prismaClient.user.findFirst({
where: {
username,
organizationId,
NOT: { email: excludeEmail }
},
select: {
id: true
}
})
}
async lockByEmail({ email }: { email: string }) {
await this.prismaClient.user.updateMany({
where: { email },
@@ -1506,4 +1546,45 @@ export class UserRepository {
return { email: user.email, username: user.username };
}
async upsertForSignup({
email,
username,
hashedPassword,
organizationId,
emailVerified,
identityProvider,
}: {
email: string;
username: string | null;
hashedPassword: string;
organizationId: number | null;
emailVerified: Date;
identityProvider: IdentityProvider;
}) {
return this.prismaClient.user.upsert({
where: { email },
update: {
username,
emailVerified,
identityProvider,
password: {
upsert: {
create: { hash: hashedPassword },
update: { hash: hashedPassword },
},
},
organizationId,
},
create: {
username,
email,
emailVerified,
identityProvider,
password: { create: { hash: hashedPassword } },
organizationId,
},
select: { id: true },
});
}
}