Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Create UserCreationService and use in API V1 create user endpoint #19150

Open
wants to merge 20 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions apps/api/v1/pages/api/users/_post.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { NextApiRequest } from "next";

import { HttpError } from "@calcom/lib/http-error";
import { defaultResponder } from "@calcom/lib/server";
import prisma from "@calcom/prisma";
import { UserCreationService } from "@calcom/lib/server/service/userCreationService";
import { CreationSource } from "@calcom/prisma/enums";

import { schemaUserCreateBodyParams } from "~/lib/validations/user";
Expand Down Expand Up @@ -93,7 +93,9 @@ async function postHandler(req: NextApiRequest) {
// If user is not ADMIN, return unauthorized.
if (!isSystemWideAdmin) throw new HttpError({ statusCode: 401, message: "You are not authorized" });
const data = await schemaUserCreateBodyParams.parseAsync(req.body);
const user = await prisma.user.create({ data: { ...data, creationSource: CreationSource.API_V1 } });
const user = await UserCreationService.createUser({
data: { ...data, creationSource: CreationSource.API_V1 },
});
req.statusCode = 201;
return { user };
}
Expand Down
134 changes: 134 additions & 0 deletions apps/api/v1/test/lib/users/_post.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import prismock from "../../../../../../tests/libs/__mocks__/prisma";

import type { Request, Response } from "express";
import type { NextApiRequest, NextApiResponse } from "next";
import { createMocks } from "node-mocks-http";
import { describe, test, expect, vi } from "vitest";

import handler from "../../../pages/api/users/_post";

type CustomNextApiRequest = NextApiRequest & Request;
type CustomNextApiResponse = NextApiResponse & Response;

vi.mock("@calcom/lib/server/i18n", () => {
return {
getTranslation: (key: string) => {
return () => key;
},
};
});

vi.stubEnv("CALCOM_LICENSE_KEY", undefined);

describe("POST /api/users", () => {
test("should throw 401 if not system-wide admin", async () => {
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "POST",
body: {
email: "[email protected]",
username: "test",
},
});
req.isSystemWideAdmin = false;

await handler(req, res);

expect(res.statusCode).toBe(401);
});
test("should throw a 400 if no email is provided", async () => {
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "POST",
body: {
username: "test",
},
});
req.isSystemWideAdmin = true;

await handler(req, res);

expect(res.statusCode).toBe(400);
});
test("should throw a 400 if no username is provided", async () => {
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "POST",
body: {
email: "[email protected]",
},
});
req.isSystemWideAdmin = true;

await handler(req, res);

expect(res.statusCode).toBe(400);
});
test("should create user successfully", async () => {
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "POST",
body: {
email: "[email protected]",
username: "test",
},
prisma: prismock,
});
req.isSystemWideAdmin = true;

await handler(req, res);

expect(res.statusCode).toBe(200);

const userQuery = await prismock.user.findFirst({
where: {
email: "[email protected]",
},
});

expect(userQuery).toEqual(
expect.objectContaining({
email: "[email protected]",
username: "test",
locked: false,
organizationId: null,
})
);
});

test("should auto lock user if email is in watchlist", async () => {
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "POST",
body: {
email: "[email protected]",
username: "test",
},
prisma: prismock,
});
req.isSystemWideAdmin = true;

await prismock.watchlist.create({
data: {
type: "EMAIL",
value: "[email protected]",
severity: "CRITICAL",
createdById: 1,
},
});

await handler(req, res);

expect(res.statusCode).toBe(200);

const userQuery = await prismock.user.findFirst({
where: {
email: "[email protected]",
},
});

expect(userQuery).toEqual(
expect.objectContaining({
email: "[email protected]",
username: "test",
locked: true,
organizationId: null,
})
);
});
});
1 change: 1 addition & 0 deletions packages/lib/server/repository/organization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ export class OrganizationRepository {
email: owner.email,
username: ownerUsernameInOrg,
organizationId: organization.id,
locked: false,
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We'll check the new org owner against the watch list in this PR #19201

creationSource,
});

Expand Down
110 changes: 110 additions & 0 deletions packages/lib/server/repository/user.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import prismock from "../../../../tests/libs/__mocks__/prisma";

import { describe, test, vi, expect, beforeEach } from "vitest";

import { CreationSource } from "@calcom/prisma/enums";

import { UserRepository } from "./user";

vi.mock("@calcom/lib/server/i18n", () => {
return {
getTranslation: (key: string) => {
return () => key;
},
};
});

describe("UserRepository", () => {
beforeEach(() => {
prismock;
});

describe("create", () => {
test("Should create a user without a password", async () => {
const user = await UserRepository.create({
username: "test",
email: "[email protected]",
organizationId: null,
creationSource: CreationSource.WEBAPP,
locked: false,
});

expect(user).toEqual(
expect.objectContaining({
username: "test",
email: "[email protected]",
organizationId: null,
creationSource: CreationSource.WEBAPP,
locked: false,
})
);

const password = await prismock.userPassword.findUnique({
where: {
userId: user.id,
},
});

expect(password).toBeNull();
});

test("If locked param is passed, user should be locked", async () => {
const user = await UserRepository.create({
username: "test",
email: "[email protected]",
organizationId: null,
creationSource: CreationSource.WEBAPP,
locked: true,
});

const userQuery = await prismock.user.findUnique({
where: {
email: "[email protected]",
},
select: {
locked: true,
},
});

expect(userQuery).toEqual(
expect.objectContaining({
locked: true,
})
);
});

test("If organizationId is passed, user should be associated with the organization", async () => {
const organizationId = 123;
const username = "test";

const user = await UserRepository.create({
username,
email: "[email protected]",
organizationId,
creationSource: CreationSource.WEBAPP,
locked: true,
});

expect(user).toEqual(
expect.objectContaining({
organizationId,
})
);

const profile = await prismock.profile.findFirst({
where: {
organizationId,
username,
},
});

expect(profile).toEqual(
expect.objectContaining({
organizationId,
username,
})
);
});
});
});
59 changes: 30 additions & 29 deletions packages/lib/server/repository/user.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,4 @@
import { createHash } from "crypto";

import { whereClauseForOrgWithSlugOrRequestedSlug } from "@calcom/ee/organizations/lib/orgDomains";
import { hashPassword } from "@calcom/features/auth/lib/hashPassword";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { getTranslation } from "@calcom/lib/server/i18n";
Expand All @@ -16,7 +13,6 @@ import { userMetadata } from "@calcom/prisma/zod-utils";
import type { UpId, UserProfile } from "@calcom/types/UserProfile";

import { DEFAULT_SCHEDULE, getAvailabilityFromSchedule } from "../../availability";
import slugify from "../../slugify";
import { ProfileRepository } from "./profile";
import { getParsedTeam } from "./teamUtils";

Expand Down Expand Up @@ -581,28 +577,27 @@ export class UserRepository {
});
}

static async create({
email,
username,
organizationId,
creationSource,
}: {
email: string;
username: string;
organizationId: number | null;
creationSource: CreationSource;
}) {
console.log("create user", { email, username, organizationId });
const password = createHash("md5").update(`${email}${process.env.CALENDSO_ENCRYPTION_KEY}`).digest("hex");
const hashedPassword = await hashPassword(password);
static async create(
Comment on lines -596 to -597
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moving this business logic to the service

data: Omit<Prisma.UserCreateInput, "password" | "organization" | "movedToProfile"> & {
username: string;
hashedPassword?: string;
organizationId: number | null;
creationSource: CreationSource;
locked: boolean;
}
) {
const organizationIdValue = data.organizationId;
const { email, username, creationSource, locked, ...rest } = data;

logger.info("create user", { email, username, organizationIdValue, locked });
const t = await getTranslation("en", "common");
const availability = getAvailabilityFromSchedule(DEFAULT_SCHEDULE);

return await prisma.user.create({
const user = await prisma.user.create({
data: {
username: slugify(username),
username,
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also moving this business logic to the service

email: email,
password: { create: { hash: hashedPassword } },
...(data.hashedPassword && { password: { create: { hash: data.hashedPassword } } }),
// Default schedule
schedules: {
create: {
Expand All @@ -618,19 +613,25 @@ export class UserRepository {
},
},
},
organizationId: organizationId,
profiles: organizationId
creationSource,
locked,
...(organizationIdValue
? {
create: {
username: slugify(username),
organizationId: organizationId,
uid: ProfileRepository.generateProfileUid(),
organizationId: organizationIdValue,
profiles: {
create: {
username,
organizationId: organizationIdValue,
uid: ProfileRepository.generateProfileUid(),
},
},
}
: undefined,
creationSource,
: {}),
...rest,
},
});

return user;
}
static async getUserAdminTeams(userId: number) {
return prisma.user.findFirst({
Expand Down
Loading
Loading