2
0

first commit

This commit is contained in:
2024-08-09 00:39:27 +02:00
commit 79688abe2e
5698 changed files with 497838 additions and 0 deletions

View File

@@ -0,0 +1,63 @@
import { v4 as uuidv4 } from "uuid";
import { prisma } from "@calcom/prisma";
export const uploadAvatar = async ({ userId, avatar: data }: { userId: number; avatar: string }) => {
const objectKey = uuidv4();
await prisma.avatar.upsert({
where: {
teamId_userId_isBanner: {
teamId: 0,
userId,
isBanner: false,
},
},
create: {
userId: userId,
data,
objectKey,
isBanner: false,
},
update: {
data,
objectKey,
},
});
return `/api/avatar/${objectKey}.png`;
};
export const uploadLogo = async ({
teamId,
logo: data,
isBanner = false,
}: {
teamId: number;
logo: string;
isBanner?: boolean;
}): Promise<string> => {
const objectKey = uuidv4();
await prisma.avatar.upsert({
where: {
teamId_userId_isBanner: {
teamId,
userId: 0,
isBanner,
},
},
create: {
teamId,
data,
objectKey,
isBanner,
},
update: {
data,
objectKey,
},
});
return `/api/avatar/${objectKey}.png`;
};

View File

@@ -0,0 +1,89 @@
import dayjs from "@calcom/dayjs";
import prisma from "@calcom/prisma";
import { BookingStatus } from "@calcom/prisma/enums";
import type { IntervalLimit } from "@calcom/types/Calendar";
import { getErrorFromUnknown } from "../errors";
import { HttpError } from "../http-error";
import { ascendingLimitKeys, intervalLimitKeyToUnit } from "../intervalLimit";
import { parseBookingLimit } from "../isBookingLimits";
export async function checkBookingLimits(
bookingLimits: IntervalLimit,
eventStartDate: Date,
eventId: number,
rescheduleUid?: string | undefined,
timeZone?: string | null
) {
const parsedBookingLimits = parseBookingLimit(bookingLimits);
if (!parsedBookingLimits) return false;
// not iterating entries to preserve types
const limitCalculations = ascendingLimitKeys.map((key) =>
checkBookingLimit({
key,
limitingNumber: parsedBookingLimits[key],
eventStartDate,
eventId,
timeZone,
rescheduleUid,
})
);
try {
return !!(await Promise.all(limitCalculations));
} catch (error) {
throw new HttpError({ message: getErrorFromUnknown(error).message, statusCode: 401 });
}
}
export async function checkBookingLimit({
eventStartDate,
eventId,
key,
limitingNumber,
rescheduleUid,
timeZone,
}: {
eventStartDate: Date;
eventId: number;
key: keyof IntervalLimit;
limitingNumber: number | undefined;
rescheduleUid?: string | undefined;
timeZone?: string | null;
}) {
{
const eventDateInOrganizerTz = timeZone ? dayjs(eventStartDate).tz(timeZone) : dayjs(eventStartDate);
if (!limitingNumber) return;
const unit = intervalLimitKeyToUnit(key);
const startDate = dayjs(eventDateInOrganizerTz).startOf(unit).toDate();
const endDate = dayjs(eventDateInOrganizerTz).endOf(unit).toDate();
const bookingsInPeriod = await prisma.booking.count({
where: {
status: BookingStatus.ACCEPTED,
eventTypeId: eventId,
// FIXME: bookings that overlap on one side will never be counted
startTime: {
gte: startDate,
},
endTime: {
lte: endDate,
},
uid: {
not: rescheduleUid,
},
},
});
if (bookingsInPeriod < limitingNumber) return;
throw new HttpError({
message: `booking_limit_reached`,
statusCode: 403,
});
}
}

View File

@@ -0,0 +1,35 @@
import { HttpError } from "../http-error";
const TURNSTILE_SECRET_ID = process.env.CLOUDFLARE_TURNSTILE_SECRET;
export async function checkCfTurnstileToken({ token, remoteIp }: { token?: string; remoteIp: string }) {
// This means the instant doesnt have turnstile enabled - we skip the check and just return success.
// OR the instance is running in CI so we skip these checks also
if (!TURNSTILE_SECRET_ID || !!process.env.NEXT_PUBLIC_IS_E2E) {
return {
success: true,
};
}
if (!token) {
throw new HttpError({ statusCode: 401, message: "Invalid cloudflare token" });
}
const form = new URLSearchParams();
form.append("secret", TURNSTILE_SECRET_ID);
form.append("response", token);
form.append("remoteip", remoteIp);
const result = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
method: "POST",
body: form,
});
const data = await result.json();
if (!data["success"]) {
throw new HttpError({ statusCode: 401, message: "Invalid cloudflare token" });
}
return data;
}

View File

@@ -0,0 +1,58 @@
import dayjs from "@calcom/dayjs";
import type { IntervalLimit } from "@calcom/types/Calendar";
import { getErrorFromUnknown } from "../errors";
import { HttpError } from "../http-error";
import { ascendingLimitKeys, intervalLimitKeyToUnit } from "../intervalLimit";
import { parseDurationLimit } from "../isDurationLimits";
import { getTotalBookingDuration } from "./queries";
export async function checkDurationLimits(
durationLimits: IntervalLimit,
eventStartDate: Date,
eventId: number
) {
const parsedDurationLimits = parseDurationLimit(durationLimits);
if (!parsedDurationLimits) return false;
// not iterating entries to preserve types
const limitCalculations = ascendingLimitKeys.map((key) =>
checkDurationLimit({ key, limitingNumber: parsedDurationLimits[key], eventStartDate, eventId })
);
try {
return !!(await Promise.all(limitCalculations));
} catch (error) {
throw new HttpError({ message: getErrorFromUnknown(error).message, statusCode: 401 });
}
}
export async function checkDurationLimit({
eventStartDate,
eventId,
key,
limitingNumber,
}: {
eventStartDate: Date;
eventId: number;
key: keyof IntervalLimit;
limitingNumber: number | undefined;
}) {
{
if (!limitingNumber) return;
const unit = intervalLimitKeyToUnit(key);
const startDate = dayjs(eventStartDate).startOf(unit).toDate();
const endDate = dayjs(eventStartDate).endOf(unit).toDate();
const totalBookingDuration = await getTotalBookingDuration({ eventId, startDate, endDate });
if (totalBookingDuration < limitingNumber) return;
throw new HttpError({
message: `duration_limit_reached`,
statusCode: 403,
});
}
}

View File

@@ -0,0 +1,37 @@
import slugify from "@calcom/lib/slugify";
import { ProfileRepository } from "./repository/profile";
import { isUsernameReservedDueToMigration } from "./username";
export async function checkRegularUsername(_username: string, currentOrgDomain?: string | null) {
const isCheckingUsernameInGlobalNamespace = !currentOrgDomain;
const username = slugify(_username);
const premium = !!process.env.NEXT_PUBLIC_IS_E2E && username.length < 5;
const profiles = currentOrgDomain
? await ProfileRepository.findManyByOrgSlugOrRequestedSlug({
orgSlug: currentOrgDomain,
usernames: [username],
})
: null;
const user = profiles?.length ? profiles[0].user : null;
if (user) {
return {
available: false as const,
premium,
message: "A user exists with that username",
};
}
const isUsernameAvailable = isCheckingUsernameInGlobalNamespace
? !(await isUsernameReservedDueToMigration(username))
: true;
return {
available: isUsernameAvailable,
premium,
};
}

View File

@@ -0,0 +1,8 @@
import { IS_PREMIUM_USERNAME_ENABLED } from "@calcom/lib/constants";
import { checkRegularUsername } from "./checkRegularUsername";
import { usernameCheck as checkPremiumUsername } from "./username";
// TODO: Replace `lib/checkPremiumUsername` with `usernameCheck` and then import checkPremiumUsername directly here.
// We want to remove dependency on website for signup stuff as signup is now part of app.
export const checkUsername = !IS_PREMIUM_USERNAME_ENABLED ? checkRegularUsername : checkPremiumUsername;

View File

@@ -0,0 +1,61 @@
import { createMocks } from "node-mocks-http";
import { describe, it, expect, vi, afterEach } from "vitest";
import { defaultHandler } from "./defaultHandler";
describe("defaultHandler Test Suite", () => {
afterEach(() => {
vi.clearAllMocks();
});
it("should return 405 for unsupported HTTP methods", async () => {
const handlers = {};
const handler = defaultHandler(handlers);
const { req, res } = createMocks({
method: "PATCH", // Unsupported method here
});
await handler(req, res);
expect(res._getStatusCode()).toBe(405);
expect(res._getJSONData()).toEqual({
message: "Method Not Allowed (Allow: )",
});
});
it("should call the correct handler for a supported method", async () => {
const getHandler = vi.fn().mockResolvedValue(null);
const handlers = {
GET: { default: getHandler },
};
const handler = defaultHandler(handlers);
const { req, res } = createMocks({
method: "GET",
});
await handler(req, res);
expect(getHandler).toHaveBeenCalledWith(req, res);
});
it("should return 500 for errors thrown in handler", async () => {
const getHandler = vi.fn().mockRejectedValue(new Error("Test Error"));
const handlers = {
GET: { default: getHandler },
};
const handler = defaultHandler(handlers);
const { req, res } = createMocks({
method: "GET",
});
await handler(req, res);
expect(res._getStatusCode()).toBe(500);
expect(res._getJSONData()).toEqual({
message: "Something went wrong",
});
});
});

View File

@@ -0,0 +1,24 @@
import type { NextApiHandler, NextApiRequest, NextApiResponse } from "next";
type Handlers = {
[method in "GET" | "POST" | "PATCH" | "PUT" | "DELETE"]?: Promise<{ default: NextApiHandler }>;
};
/** Allows us to split big API handlers by method */
export const defaultHandler = (handlers: Handlers) => async (req: NextApiRequest, res: NextApiResponse) => {
const handler = (await handlers[req.method as keyof typeof handlers])?.default;
// auto catch unsupported methods.
if (!handler) {
return res
.status(405)
.json({ message: `Method Not Allowed (Allow: ${Object.keys(handlers).join(",")})` });
}
try {
await handler(req, res);
return;
} catch (error) {
console.error(error);
return res.status(500).json({ message: "Something went wrong" });
}
};

View File

@@ -0,0 +1,22 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { describe, it, expect, vi } from "vitest";
import { defaultResponder } from "./defaultResponder";
describe("defaultResponder", () => {
it("should call res.json when response is still writable and result is not null", async () => {
const f = vi.fn().mockResolvedValue({});
const req = {} as NextApiRequest;
const res = { json: vi.fn(), writableEnded: false } as unknown as NextApiResponse;
await defaultResponder(f)(req, res);
expect(res.json).toHaveBeenCalled();
});
it("should not call res.json when response is not writable", async () => {
const f = vi.fn().mockResolvedValue({});
const req = {} as NextApiRequest;
const res = { json: vi.fn(), writableEnded: true } as unknown as NextApiResponse;
await defaultResponder(f)(req, res);
expect(res.json).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,34 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { getServerErrorFromUnknown } from "./getServerErrorFromUnknown";
import { performance } from "./perfObserver";
type Handle<T> = (req: NextApiRequest, res: NextApiResponse) => Promise<T>;
/** Allows us to get type inference from API handler responses */
export function defaultResponder<T>(f: Handle<T>) {
return async (req: NextApiRequest, res: NextApiResponse) => {
let ok = false;
try {
performance.mark("Start");
const result = await f(req, res);
ok = true;
if (result && !res.writableEnded) {
return res.json(result);
}
} catch (err) {
console.error(err);
const error = getServerErrorFromUnknown(err);
// dynamic import of Sentry so it's only loaded when something goes wrong.
const captureException = (await import("@sentry/nextjs")).captureException;
captureException(err);
// return API route response
return res
.status(error.statusCode)
.json({ message: error.message, url: error.url, method: error.method });
} finally {
performance.mark("End");
performance.measure(`[${ok ? "OK" : "ERROR"}][$1] ${req.method} '${req.url}'`, "Start", "End");
}
};
}

View File

@@ -0,0 +1,53 @@
import { Prisma } from "@calcom/prisma/client";
export const eventTypeSelect = Prisma.validator<Prisma.EventTypeSelect>()({
id: true,
teamId: true,
schedulingType: true,
userId: true,
metadata: true,
description: true,
hidden: true,
slug: true,
length: true,
title: true,
requiresConfirmation: true,
position: true,
offsetStart: true,
profileId: true,
eventName: true,
parentId: true,
timeZone: true,
periodType: true,
periodStartDate: true,
periodEndDate: true,
periodDays: true,
periodCountCalendarDays: true,
lockTimeZoneToggleOnBookingPage: true,
requiresBookerEmailVerification: true,
disableGuests: true,
hideCalendarNotes: true,
minimumBookingNotice: true,
beforeEventBuffer: true,
afterEventBuffer: true,
seatsPerTimeSlot: true,
onlyShowFirstAvailableSlot: true,
seatsShowAttendees: true,
seatsShowAvailabilityCount: true,
scheduleId: true,
price: true,
currency: true,
slotInterval: true,
successRedirectUrl: true,
isInstantEvent: true,
instantMeetingExpiryTimeOffsetInSeconds: true,
aiPhoneCallConfig: true,
assignAllTeamMembers: true,
recurringEvent: true,
locations: true,
bookingFields: true,
useEventTypeDestinationCalendarEmail: true,
secondaryEmailId: true,
bookingLimits: true,
durationLimits: true,
});

View File

@@ -0,0 +1,35 @@
import { subdomainSuffix, getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains";
import { prisma } from "@calcom/prisma";
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
export const getBrand = async (orgId: number | null) => {
if (!orgId) {
return null;
}
const org = await prisma.team.findFirst({
where: {
id: orgId,
},
select: {
logoUrl: true,
name: true,
slug: true,
metadata: true,
},
});
if (!org) {
return null;
}
const metadata = teamMetadataSchema.parse(org.metadata);
const slug = (org.slug || metadata?.requestedSlug) as string;
const fullDomain = getOrgFullOrigin(slug);
const domainSuffix = subdomainSuffix();
return {
...org,
metadata,
slug,
fullDomain,
domainSuffix,
};
};

View File

@@ -0,0 +1,95 @@
import prismaMock from "../../../tests/libs/__mocks__/prisma";
import { getGoogleMeetCredential, TestData } from "@calcom/web/test/utils/bookingScenario/bookingScenario";
import { describe, expect, it } from "vitest";
import { DailyLocationType, MeetLocationType } from "@calcom/app-store/locations";
import { getDefaultLocations } from "./getDefaultLocations";
type User = {
id: number;
email?: string;
name?: string;
metadata: {
defaultConferencingApp?: {
appSlug: string;
appLink: string;
};
};
credentials?: [
{
key: {
expiry_date?: number;
token_type?: string;
access_token?: string;
refresh_token?: string;
scope: string;
};
}
];
};
describe("getDefaultLocation ", async () => {
it("should return location based on user default conferencing app", async () => {
const user: User = {
id: 101,
metadata: {
defaultConferencingApp: {
appSlug: "google-meet",
appLink: "https://example.com",
},
},
credentials: [getGoogleMeetCredential()],
};
await mockUser(user);
await addAppsToDb([TestData.apps["google-meet"]]);
const res = await getDefaultLocations(user);
expect(res[0]).toEqual({
link: "https://example.com",
type: MeetLocationType,
});
});
it("should return calvideo when default conferencing app is not set", async () => {
const user: User = {
id: 101,
metadata: {},
};
await mockUser(user);
await addAppsToDb([TestData.apps["daily-video"]]);
await prismaMock.app.create({
data: {
...TestData.apps["daily-video"],
enabled: true,
},
});
const res = await getDefaultLocations(user);
expect(res[0]).toContain({
type: DailyLocationType,
});
});
});
async function mockUser(user: User) {
const userToCreate: any = {
...TestData.users.example,
...user,
};
if (user.credentials) {
userToCreate.credentials = {
createMany: {
data: user.credentials,
},
};
}
return await prismaMock.user.create({
data: userToCreate,
});
}
async function addAppsToDb(apps: any[]) {
await prismaMock.app.createMany({
data: apps.map((app) => {
return { ...app, enabled: true };
}),
});
}

View File

@@ -0,0 +1,35 @@
import getAppKeysFromSlug from "@calcom/app-store/_utils/getAppKeysFromSlug";
import { DailyLocationType } from "@calcom/app-store/locations";
import getApps from "@calcom/app-store/utils";
import { getUsersCredentials } from "@calcom/lib/server/getUsersCredentials";
import { userMetadata as userMetadataSchema } from "@calcom/prisma/zod-utils";
import type { EventTypeLocation } from "@calcom/prisma/zod/custom/eventtype";
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
type SessionUser = NonNullable<TrpcSessionUser>;
type User = {
id: SessionUser["id"];
metadata: SessionUser["metadata"];
};
export async function getDefaultLocations(user: User): Promise<EventTypeLocation[]> {
const defaultConferencingData = userMetadataSchema.parse(user.metadata)?.defaultConferencingApp;
if (defaultConferencingData && defaultConferencingData.appSlug !== "daily-video") {
const credentials = await getUsersCredentials(user);
const foundApp = getApps(credentials, true).filter(
(app) => app.slug === defaultConferencingData.appSlug
)[0]; // There is only one possible install here so index [0] is the one we are looking for ;
const locationType = foundApp?.locationOption?.value ?? DailyLocationType; // Default to Daily if no location type is found
return [{ type: locationType, link: defaultConferencingData.appLink }];
}
const appKeys = await getAppKeysFromSlug("daily-video");
if (typeof appKeys.api_key === "string") {
return [{ type: DailyLocationType }];
}
return [];
}

View File

@@ -0,0 +1,131 @@
import type { User } from "@prisma/client";
import prisma from "@calcom/prisma";
async function leastRecentlyBookedUser<T extends Pick<User, "id" | "email">>({
availableUsers,
eventTypeId,
}: {
availableUsers: T[];
eventTypeId: number;
}) {
// First we get all organizers (fixed host/single round robin user)
const organizersWithLastCreated = await prisma.user.findMany({
where: {
id: {
in: availableUsers.map((user) => user.id),
},
},
select: {
id: true,
bookings: {
select: {
createdAt: true,
},
where: {
eventTypeId,
},
orderBy: {
createdAt: "desc",
},
take: 1,
},
},
});
const organizerIdAndAtCreatedPair = organizersWithLastCreated.reduce(
(keyValuePair: { [userId: number]: Date }, user) => {
keyValuePair[user.id] = user.bookings[0]?.createdAt || new Date(0);
return keyValuePair;
},
{}
);
const bookings = await prisma.booking.findMany({
where: {
AND: [
{
eventTypeId,
},
{
attendees: {
some: {
email: {
in: availableUsers.map((user) => user.email),
},
},
},
},
],
},
select: {
id: true,
createdAt: true,
attendees: {
select: {
email: true,
},
},
},
orderBy: {
createdAt: "desc",
},
});
const attendeeUserIdAndAtCreatedPair = bookings.reduce((aggregate: { [userId: number]: Date }, booking) => {
availableUsers.forEach((user) => {
if (aggregate[user.id]) return; // Bookings are ordered DESC, so if the reducer aggregate
// contains the user id, it's already got the most recent booking marked.
if (!booking.attendees.map((attendee) => attendee.email).includes(user.email)) return;
if (organizerIdAndAtCreatedPair[user.id] > booking.createdAt) return; // only consider bookings if they were created after organizer bookings
aggregate[user.id] = booking.createdAt;
});
return aggregate;
}, {});
const userIdAndAtCreatedPair = {
...organizerIdAndAtCreatedPair,
...attendeeUserIdAndAtCreatedPair,
};
if (!userIdAndAtCreatedPair) {
throw new Error("Unable to find users by availableUser ids."); // should never happen.
}
const leastRecentlyBookedUser = availableUsers.sort((a, b) => {
if (userIdAndAtCreatedPair[a.id] > userIdAndAtCreatedPair[b.id]) return 1;
else if (userIdAndAtCreatedPair[a.id] < userIdAndAtCreatedPair[b.id]) return -1;
// if two (or more) dates are identical, we randomize the order
else return Math.random() > 0.5 ? 1 : -1;
})[0];
return leastRecentlyBookedUser;
}
function getUsersWithHighestPriority<T extends Pick<User, "id" | "email"> & { priority?: number | null }>({
availableUsers,
}: {
availableUsers: T[];
}) {
const highestPriority = Math.max(...availableUsers.map((user) => user.priority ?? 2));
return availableUsers.filter(
(user) => user.priority === highestPriority || (user.priority == null && highestPriority === 2)
);
}
// TODO: Configure distributionAlgorithm from the event type configuration
// TODO: Add 'MAXIMIZE_FAIRNESS' algorithm.
export async function getLuckyUser<T extends Pick<User, "id" | "email"> & { priority?: number | null }>(
distributionAlgorithm: "MAXIMIZE_AVAILABILITY" = "MAXIMIZE_AVAILABILITY",
{ availableUsers, eventTypeId }: { availableUsers: T[]; eventTypeId: number }
) {
if (availableUsers.length === 1) {
return availableUsers[0];
}
switch (distributionAlgorithm) {
case "MAXIMIZE_AVAILABILITY":
const highestPriorityUsers = getUsersWithHighestPriority({ availableUsers });
return leastRecentlyBookedUser<T>({ availableUsers: highestPriorityUsers, eventTypeId });
}
}

View File

@@ -0,0 +1,83 @@
import { PrismaClientKnownRequestError, NotFoundError } from "@prisma/client/runtime/library";
import Stripe from "stripe";
import type { ZodIssue } from "zod";
import { ZodError } from "zod";
import { HttpError } from "../http-error";
import { redactError } from "../redactError";
function hasName(cause: unknown): cause is { name: string } {
return !!cause && typeof cause === "object" && "name" in cause;
}
function isZodError(cause: unknown): cause is ZodError {
return cause instanceof ZodError || (hasName(cause) && cause.name === "ZodError");
}
function parseZodErrorIssues(issues: ZodIssue[]): string {
return issues
.map((i) =>
i.code === "invalid_union"
? i.unionErrors.map((ue) => parseZodErrorIssues(ue.issues)).join("; ")
: i.code === "unrecognized_keys"
? i.message
: `${i.path.length ? `${i.code} in '${i.path}': ` : ""}${i.message}`
)
.join("; ");
}
export function getServerErrorFromUnknown(cause: unknown): HttpError {
if (isZodError(cause)) {
console.log("cause", cause);
return new HttpError({
statusCode: 400,
message: parseZodErrorIssues(cause.issues),
cause,
});
}
if (cause instanceof SyntaxError) {
return new HttpError({
statusCode: 500,
message: "Unexpected error, please reach out for our customer support.",
});
}
if (cause instanceof PrismaClientKnownRequestError) {
return getHttpError({ statusCode: 400, cause });
}
if (cause instanceof NotFoundError) {
return getHttpError({ statusCode: 404, cause });
}
if (cause instanceof Stripe.errors.StripeInvalidRequestError) {
return getHttpError({ statusCode: 400, cause });
}
if (cause instanceof HttpError) {
const redactedCause = redactError(cause);
return {
...redactedCause,
name: cause.name,
message: cause.message ?? "",
cause: cause.cause,
url: cause.url,
statusCode: cause.statusCode,
method: cause.method,
};
}
if (cause instanceof Error) {
return getHttpError({ statusCode: 500, cause });
}
if (typeof cause === "string") {
// @ts-expect-error https://github.com/tc39/proposal-error-cause
return new Error(cause, { cause });
}
return new HttpError({
statusCode: 500,
message: `Unhandled error of type '${typeof cause}'. Please reach out for our customer support.`,
});
}
function getHttpError<T extends Error>({ statusCode, cause }: { statusCode: number; cause: T }) {
const redacted = redactError(cause);
return new HttpError({ statusCode, message: redacted.message, cause: redacted });
}

View File

@@ -0,0 +1,19 @@
import { prisma } from "@calcom/prisma";
import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential";
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
type SessionUser = NonNullable<TrpcSessionUser>;
type User = { id: SessionUser["id"] };
export async function getUsersCredentials(user: User) {
const credentials = await prisma.credential.findMany({
where: {
userId: user.id,
},
select: credentialForCalendarServiceSelect,
orderBy: {
id: "asc",
},
});
return credentials;
}

View File

@@ -0,0 +1,18 @@
import i18next from "i18next";
import { i18n as nexti18next } from "next-i18next";
import { serverSideTranslations } from "next-i18next/serverSideTranslations";
export const getTranslation = async (locale: string, ns: string) => {
const create = async () => {
const { _nextI18Next } = await serverSideTranslations(locale, [ns]);
const _i18n = i18next.createInstance();
_i18n.init({
lng: locale,
resources: _nextI18Next?.initialI18nStore,
fallbackLng: _nextI18Next?.userConfig?.i18n.defaultLocale,
});
return _i18n;
};
const _i18n = nexti18next != null ? nexti18next : await create();
return _i18n.getFixedT(locale, ns);
};

View File

@@ -0,0 +1,10 @@
export { checkBookingLimits, checkBookingLimit } from "./checkBookingLimits";
export { checkDurationLimits, checkDurationLimit } from "./checkDurationLimits";
export { defaultHandler } from "./defaultHandler";
export { defaultResponder } from "./defaultResponder";
export { getLuckyUser } from "./getLuckyUser";
export { getServerErrorFromUnknown } from "./getServerErrorFromUnknown";
export { getTranslation } from "./i18n";
export { getDefaultLocations } from "./getDefaultLocations";
export { default as perfObserver } from "./perfObserver";

View File

@@ -0,0 +1,20 @@
import type { PrismaClient } from "@calcom/prisma";
export async function maybeGetBookingUidFromSeat(prisma: PrismaClient, uid: string) {
// Look bookingUid in bookingSeat
const bookingSeat = await prisma.bookingSeat.findUnique({
where: {
referenceUid: uid,
},
select: {
booking: {
select: {
id: true,
uid: true,
},
},
},
});
if (bookingSeat) return { uid: bookingSeat.booking.uid, seatReferenceUid: uid };
return { uid };
}

View File

@@ -0,0 +1,26 @@
import { PerformanceObserver } from "perf_hooks";
import logger from "../logger";
declare global {
// eslint-disable-next-line no-var
var perfObserver: PerformanceObserver | undefined;
}
export const perfObserver =
globalThis.perfObserver ||
new PerformanceObserver((items) => {
items.getEntries().forEach((entry) => {
// Log entry duration in seconds with four decimal places.
logger.debug(entry.name.replace("$1", `${(entry.duration / 1000.0).toFixed(4)}s`));
});
});
perfObserver.observe({ entryTypes: ["measure"] });
if (process.env.NODE_ENV !== "production") {
globalThis.perfObserver = perfObserver;
}
export default perfObserver;
export { performance } from "perf_hooks";

View File

@@ -0,0 +1,24 @@
import prisma from "@calcom/prisma";
export const getTotalBookingDuration = async ({
eventId,
startDate,
endDate,
}: {
eventId: number;
startDate: Date;
endDate: Date;
}) => {
// Aggregates the total booking time for a given event in a given time period
// FIXME: bookings that overlap on one side will never be counted
const [totalBookingTime] = await prisma.$queryRaw<[{ totalMinutes: number | null }]>`
SELECT SUM(EXTRACT(EPOCH FROM ("endTime" - "startTime")) / 60) as "totalMinutes"
FROM "Booking"
WHERE "status" = 'accepted'
AND "eventTypeId" = ${eventId}
AND "startTime" >= ${startDate}
AND "endTime" <= ${endDate};
`;
return totalBookingTime.totalMinutes ?? 0;
};

View File

@@ -0,0 +1,2 @@
export * from "./teams";
export * from "./booking";

View File

@@ -0,0 +1,35 @@
import prisma from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
// export type OrganisationWithMembers = Awaited<ReturnType<typeof getOrganizationMembers>>;
// also returns team
export async function isOrganisationAdmin(userId: number, orgId: number) {
return (
(await prisma.membership.findFirst({
where: {
userId,
teamId: orgId,
OR: [{ role: MembershipRole.ADMIN }, { role: MembershipRole.OWNER }],
},
})) || false
);
}
export async function isOrganisationOwner(userId: number, orgId: number) {
return !!(await prisma.membership.findFirst({
where: {
userId,
teamId: orgId,
role: MembershipRole.OWNER,
},
}));
}
export async function isOrganisationMember(userId: number, orgId: number) {
return !!(await prisma.membership.findFirst({
where: {
userId,
teamId: orgId,
},
}));
}

View File

@@ -0,0 +1,369 @@
import { Prisma } from "@prisma/client";
import { getAppFromSlug } from "@calcom/app-store/utils";
import prisma, { baseEventTypeSelect } from "@calcom/prisma";
import type { Team } from "@calcom/prisma/client";
import { SchedulingType } from "@calcom/prisma/enums";
import { _EventTypeModel } from "@calcom/prisma/zod";
import {
EventTypeMetaDataSchema,
allManagedEventTypeProps,
unlockedManagedEventTypeProps,
} from "@calcom/prisma/zod-utils";
import { getBookerBaseUrlSync } from "../../../getBookerUrl/client";
import { getTeam, getOrg } from "../../repository/team";
import { UserRepository } from "../../repository/user";
export type TeamWithMembers = Awaited<ReturnType<typeof getTeamWithMembers>>;
export async function getTeamWithMembers(args: {
id?: number;
slug?: string;
userId?: number;
orgSlug?: string | null;
isTeamView?: boolean;
currentOrg?: Pick<Team, "id"> | null;
/**
* If true, means that you are fetching an organization and not a team
*/
isOrgView?: boolean;
}) {
const { id, slug, currentOrg: _currentOrg, userId, orgSlug, isTeamView, isOrgView } = args;
// This should improve performance saving already app data found.
const appDataMap = new Map();
const userSelect = Prisma.validator<Prisma.UserSelect>()({
username: true,
email: true,
name: true,
avatarUrl: true,
id: true,
bio: true,
teams: {
select: {
team: {
select: {
slug: true,
id: true,
},
},
},
},
credentials: {
select: {
app: {
select: {
slug: true,
categories: true,
},
},
destinationCalendars: {
select: {
externalId: true,
},
},
},
},
});
let lookupBy;
if (id) {
lookupBy = { id, havingMemberWithId: userId };
} else if (slug) {
lookupBy = { slug, havingMemberWithId: userId };
} else {
throw new Error("Must provide either id or slug");
}
const arg = {
lookupBy,
forOrgWithSlug: orgSlug ?? null,
isOrg: !!isOrgView,
teamSelect: {
id: true,
name: true,
slug: true,
isOrganization: true,
logoUrl: true,
bio: true,
hideBranding: true,
hideBookATeamMember: true,
isPrivate: true,
metadata: true,
parent: {
select: {
id: true,
slug: true,
name: true,
isPrivate: true,
isOrganization: true,
logoUrl: true,
metadata: true,
},
},
parentId: true,
children: {
select: {
name: true,
slug: true,
},
},
members: {
select: {
accepted: true,
role: true,
disableImpersonation: true,
user: {
select: userSelect,
},
},
},
theme: true,
brandColor: true,
darkBrandColor: true,
eventTypes: {
where: {
hidden: false,
schedulingType: {
not: SchedulingType.MANAGED,
},
},
orderBy: [
{
position: "desc",
},
{
id: "asc",
},
] as Prisma.EventTypeOrderByWithRelationInput[],
select: {
hosts: {
select: {
user: {
select: userSelect,
},
},
},
metadata: true,
...baseEventTypeSelect,
},
},
inviteTokens: {
select: {
token: true,
expires: true,
expiresInDays: true,
identifier: true,
},
},
},
} as const;
const teamOrOrg = isOrgView ? await getOrg(arg) : await getTeam(arg);
if (!teamOrOrg) return null;
const teamOrOrgMemberships = [];
for (const membership of teamOrOrg.members) {
teamOrOrgMemberships.push({
...membership,
user: await UserRepository.enrichUserWithItsProfile({
user: membership.user,
}),
});
}
const members = teamOrOrgMemberships.map((m) => {
const { credentials, profile, ...restUser } = m.user;
return {
...restUser,
username: profile?.username ?? restUser.username,
role: m.role,
profile: profile,
organizationId: profile?.organizationId ?? null,
organization: profile?.organization,
accepted: m.accepted,
disableImpersonation: m.disableImpersonation,
subteams: orgSlug
? m.user.teams
.filter((membership) => membership.team.id !== teamOrOrg.id)
.map((membership) => membership.team.slug)
: null,
bookerUrl: getBookerBaseUrlSync(profile?.organization?.slug || ""),
connectedApps: !isTeamView
? credentials?.map((cred) => {
const appSlug = cred.app?.slug;
let appData = appDataMap.get(appSlug);
if (!appData) {
appData = getAppFromSlug(appSlug);
appDataMap.set(appSlug, appData);
}
const isCalendar = cred?.app?.categories?.includes("calendar") ?? false;
const externalId = isCalendar ? cred.destinationCalendars?.[0]?.externalId : null;
return {
name: appData?.name ?? null,
logo: appData?.logo ?? null,
app: cred.app,
externalId: externalId ?? null,
};
})
: null,
};
});
const eventTypesWithUsersUserProfile = [];
for (const eventType of teamOrOrg.eventTypes) {
const usersWithUserProfile = [];
for (const { user } of eventType.hosts) {
usersWithUserProfile.push(
await UserRepository.enrichUserWithItsProfile({
user,
})
);
}
eventTypesWithUsersUserProfile.push({
...eventType,
users: usersWithUserProfile,
});
}
const eventTypes = eventTypesWithUsersUserProfile.map((eventType) => ({
...eventType,
metadata: EventTypeMetaDataSchema.parse(eventType.metadata),
}));
// Don't leak invite tokens to the frontend
const { inviteTokens, ...teamWithoutInviteTokens } = teamOrOrg;
// Don't leak stripe payment ids
const teamMetadata = teamOrOrg.metadata;
const {
paymentId: _,
subscriptionId: __,
subscriptionItemId: ___,
...restTeamMetadata
} = teamMetadata || {};
return {
...teamWithoutInviteTokens,
...(teamWithoutInviteTokens.logoUrl ? { logo: teamWithoutInviteTokens.logoUrl } : {}),
/** To prevent breaking we only return non-email attached token here, if we have one */
inviteToken: inviteTokens.find(
(token) =>
token.identifier === `invite-link-for-teamId-${teamOrOrg.id}` &&
token.expires > new Date(new Date().setHours(24))
),
metadata: restTeamMetadata,
eventTypes: !isOrgView ? eventTypes : null,
members,
};
}
// also returns team
export async function isTeamAdmin(userId: number, teamId: number) {
const team = await prisma.membership.findFirst({
where: {
userId,
teamId,
accepted: true,
OR: [{ role: "ADMIN" }, { role: "OWNER" }],
},
include: {
team: {
select: {
metadata: true,
parentId: true,
isOrganization: true,
},
},
},
});
if (!team) return false;
return team;
}
export async function isTeamOwner(userId: number, teamId: number) {
return !!(await prisma.membership.findFirst({
where: {
userId,
teamId,
accepted: true,
role: "OWNER",
},
}));
}
export async function isTeamMember(userId: number, teamId: number) {
return !!(await prisma.membership.findFirst({
where: {
userId,
teamId,
accepted: true,
},
}));
}
export async function updateNewTeamMemberEventTypes(userId: number, teamId: number) {
const eventTypesToAdd = await prisma.eventType.findMany({
where: {
team: { id: teamId },
assignAllTeamMembers: true,
},
select: {
...allManagedEventTypeProps,
id: true,
schedulingType: true,
},
});
const allManagedEventTypePropsZod = _EventTypeModel.pick(allManagedEventTypeProps);
eventTypesToAdd.length > 0 &&
(await prisma.$transaction(
eventTypesToAdd.map((eventType) => {
if (eventType.schedulingType === "MANAGED") {
const managedEventTypeValues = allManagedEventTypePropsZod
.omit(unlockedManagedEventTypeProps)
.parse(eventType);
// Define the values for unlocked properties to use on creation, not updation
const unlockedEventTypeValues = allManagedEventTypePropsZod
.pick(unlockedManagedEventTypeProps)
.parse(eventType);
// Calculate if there are new workflows for which assigned members will get too
const currentWorkflowIds = eventType.workflows?.map((wf) => wf.workflowId);
return prisma.eventType.create({
data: {
...managedEventTypeValues,
...unlockedEventTypeValues,
bookingLimits:
(managedEventTypeValues.bookingLimits as unknown as Prisma.InputJsonObject) ?? undefined,
recurringEvent:
(managedEventTypeValues.recurringEvent as unknown as Prisma.InputJsonValue) ?? undefined,
metadata: (managedEventTypeValues.metadata as Prisma.InputJsonValue) ?? undefined,
bookingFields: (managedEventTypeValues.bookingFields as Prisma.InputJsonValue) ?? undefined,
durationLimits: (managedEventTypeValues.durationLimits as Prisma.InputJsonValue) ?? undefined,
onlyShowFirstAvailableSlot: managedEventTypeValues.onlyShowFirstAvailableSlot ?? false,
userId,
users: {
connect: [{ id: userId }],
},
parentId: eventType.parentId,
hidden: false,
workflows: currentWorkflowIds && {
create: currentWorkflowIds.map((wfId) => ({ workflowId: wfId })),
},
},
});
} else {
return prisma.eventType.update({
where: { id: eventType.id },
data: { hosts: { create: [{ userId, isFixed: eventType.schedulingType === "COLLECTIVE" }] } },
});
}
})
));
}

View File

@@ -0,0 +1,36 @@
import { vi, beforeEach } from "vitest";
import { mockReset, mockDeep } from "vitest-mock-extended";
import type * as organization from "@calcom/lib/server/repository/organization";
vi.mock("@calcom/lib/server/repository/organization", () => organizationMock);
type OrganizationModule = typeof organization;
beforeEach(() => {
mockReset(organizationMock);
});
const organizationMock = mockDeep<OrganizationModule>();
const OrganizationRepository = organizationMock.OrganizationRepository;
export const organizationScenarios = {
OrganizationRepository: {
findUniqueByMatchingAutoAcceptEmail: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
fakeReturnOrganization: (org: any, forInput: any) => {
OrganizationRepository.findUniqueByMatchingAutoAcceptEmail.mockImplementation((arg) => {
if (forInput.email === arg.email) {
return org;
}
const errorMsg = "Mock Error-fakeReturnOrganization: Unhandled input";
console.log(errorMsg, { arg, forInput });
throw new Error(errorMsg);
});
},
fakeNoMatch: () => {
OrganizationRepository.findUniqueByMatchingAutoAcceptEmail.mockResolvedValue(null);
},
},
} satisfies Partial<Record<keyof OrganizationModule["OrganizationRepository"], unknown>>,
} satisfies Partial<Record<keyof OrganizationModule, unknown>>;
export default organizationMock;

View File

@@ -0,0 +1,14 @@
import prisma from "@calcom/prisma";
export class BookingRepository {
static async findFirstBookingByReschedule({ originalBookingUid }: { originalBookingUid: string }) {
return await prisma.booking.findFirst({
where: {
fromReschedule: originalBookingUid,
},
select: {
uid: true,
},
});
}
}

View File

@@ -0,0 +1,23 @@
import { Prisma } from "@prisma/client";
import { prisma } from "@calcom/prisma";
const bookingReferenceSelect = Prisma.validator<Prisma.BookingReferenceSelect>()({
id: true,
type: true,
uid: true,
meetingId: true,
meetingUrl: true,
credentialId: true,
deleted: true,
bookingId: true,
});
export class BookingReferenceRepository {
static async findDailyVideoReferenceByRoomName({ roomName }: { roomName: string }) {
return prisma.bookingReference.findFirst({
where: { type: "daily_video", uid: roomName, meetingId: roomName, bookingId: { not: null } },
select: bookingReferenceSelect,
});
}
}

View File

@@ -0,0 +1,438 @@
import type { EventType as PrismaEventType } from "@prisma/client";
import { Prisma } from "@prisma/client";
import logger from "@calcom/lib/logger";
import { prisma } from "@calcom/prisma";
import type { Ensure } from "@calcom/types/utils";
import { safeStringify } from "../../safeStringify";
import { eventTypeSelect } from "../eventTypeSelect";
import { LookupTarget, ProfileRepository } from "./profile";
const log = logger.getSubLogger({ prefix: ["repository/eventType"] });
type NotSupportedProps = "locations";
type IEventType = Ensure<
Partial<
Omit<Prisma.EventTypeCreateInput, NotSupportedProps> & {
userId: PrismaEventType["userId"];
profileId: PrismaEventType["profileId"];
teamId: PrismaEventType["teamId"];
parentId: PrismaEventType["parentId"];
scheduleId: PrismaEventType["scheduleId"];
}
>,
"title" | "slug" | "length"
>;
const userSelect = Prisma.validator<Prisma.UserSelect>()({
name: true,
avatarUrl: true,
username: true,
id: true,
});
export class EventTypeRepository {
static async create(data: IEventType) {
const {
userId,
profileId,
teamId,
parentId,
scheduleId,
bookingLimits,
recurringEvent,
metadata,
bookingFields,
durationLimits,
...rest
} = data;
return await prisma.eventType.create({
data: {
...rest,
...(userId ? { owner: { connect: { id: userId } } } : null),
...(profileId
? {
profile: {
connect: {
id: profileId,
},
},
}
: null),
...(teamId ? { team: { connect: { id: teamId } } } : null),
...(parentId ? { parent: { connect: { id: parentId } } } : null),
...(scheduleId ? { schedule: { connect: { id: scheduleId } } } : null),
...(metadata ? { metadata: metadata } : null),
...(bookingLimits
? {
bookingLimits,
}
: null),
...(recurringEvent
? {
recurringEvent,
}
: null),
...(bookingFields
? {
bookingFields,
}
: null),
...(durationLimits
? {
durationLimits,
}
: null),
},
});
}
static async findAllByUpId(
{ upId, userId }: { upId: string; userId: number },
{
orderBy,
where = {},
}: { orderBy?: Prisma.EventTypeOrderByWithRelationInput[]; where?: Prisma.EventTypeWhereInput } = {}
) {
if (!upId) return [];
const lookupTarget = ProfileRepository.getLookupTarget(upId);
const profileId = lookupTarget.type === LookupTarget.User ? null : lookupTarget.id;
const select = {
...eventTypeSelect,
hashedLink: true,
users: { select: userSelect },
children: {
include: {
users: { select: userSelect },
},
},
hosts: {
include: {
user: { select: userSelect },
},
},
};
log.debug(
"findAllByUpId",
safeStringify({
upId,
orderBy,
argumentWhere: where,
})
);
if (!profileId) {
// Lookup is by userId
return await prisma.eventType.findMany({
where: {
userId: lookupTarget.id,
...where,
},
select,
orderBy,
});
}
const profile = await ProfileRepository.findById(profileId);
if (profile?.movedFromUser) {
// Because the user has been moved to this profile, we need to get all user events except those that belong to some other profile
// This is because those event-types that are created after moving to profile would have profileId but existing event-types would have profileId set to null
return await prisma.eventType.findMany({
where: {
OR: [
// Existing events
{
userId: profile.movedFromUser.id,
profileId: null,
},
// New events
{
profileId,
},
// Fetch children event-types by userId because profileId is wrong
{
userId,
parentId: {
not: null,
},
},
],
...where,
},
select,
orderBy,
});
} else {
return await prisma.eventType.findMany({
where: {
OR: [
{
profileId,
},
// Fetch children event-types by userId because profileId is wrong
{
userId: userId,
parentId: {
not: null,
},
},
],
...where,
},
select,
orderBy,
});
}
}
static async findAllByUserId({ userId }: { userId: number }) {
return await prisma.eventType.findMany({
where: {
userId,
},
});
}
static async findById({ id, userId }: { id: number; userId: number }) {
const userSelect = Prisma.validator<Prisma.UserSelect>()({
name: true,
avatarUrl: true,
username: true,
id: true,
email: true,
locale: true,
defaultScheduleId: true,
});
const CompleteEventTypeSelect = Prisma.validator<Prisma.EventTypeSelect>()({
id: true,
title: true,
slug: true,
description: true,
length: true,
isInstantEvent: true,
instantMeetingExpiryTimeOffsetInSeconds: true,
aiPhoneCallConfig: true,
offsetStart: true,
hidden: true,
locations: true,
eventName: true,
customInputs: true,
timeZone: true,
periodType: true,
metadata: true,
periodDays: true,
periodStartDate: true,
periodEndDate: true,
periodCountCalendarDays: true,
lockTimeZoneToggleOnBookingPage: true,
requiresConfirmation: true,
requiresBookerEmailVerification: true,
recurringEvent: true,
hideCalendarNotes: true,
disableGuests: true,
minimumBookingNotice: true,
beforeEventBuffer: true,
afterEventBuffer: true,
slotInterval: true,
hashedLink: true,
bookingLimits: true,
onlyShowFirstAvailableSlot: true,
durationLimits: true,
assignAllTeamMembers: true,
successRedirectUrl: true,
forwardParamsSuccessRedirect: true,
currency: true,
bookingFields: true,
useEventTypeDestinationCalendarEmail: true,
owner: {
select: {
id: true,
},
},
parent: {
select: {
id: true,
teamId: true,
},
},
teamId: true,
team: {
select: {
id: true,
name: true,
slug: true,
parentId: true,
parent: {
select: {
slug: true,
organizationSettings: {
select: {
lockEventTypeCreationForUsers: true,
},
},
},
},
members: {
select: {
role: true,
accepted: true,
user: {
select: {
...userSelect,
eventTypes: {
select: {
slug: true,
},
},
},
},
},
},
},
},
users: {
select: userSelect,
},
schedulingType: true,
schedule: {
select: {
id: true,
name: true,
},
},
hosts: {
select: {
isFixed: true,
userId: true,
priority: true,
},
},
userId: true,
price: true,
children: {
select: {
owner: {
select: {
avatarUrl: true,
name: true,
username: true,
email: true,
id: true,
},
},
hidden: true,
slug: true,
},
},
destinationCalendar: true,
seatsPerTimeSlot: true,
seatsShowAttendees: true,
seatsShowAvailabilityCount: true,
webhooks: {
select: {
id: true,
subscriberUrl: true,
payloadTemplate: true,
active: true,
eventTriggers: true,
secret: true,
eventTypeId: true,
},
},
workflows: {
include: {
workflow: {
select: {
name: true,
id: true,
trigger: true,
time: true,
timeUnit: true,
userId: true,
teamId: true,
team: {
select: {
id: true,
slug: true,
name: true,
members: true,
},
},
activeOn: {
select: {
eventType: {
select: {
id: true,
title: true,
parentId: true,
_count: {
select: {
children: true,
},
},
},
},
},
},
steps: true,
},
},
},
},
secondaryEmailId: true,
});
return await prisma.eventType.findFirst({
where: {
AND: [
{
OR: [
{
users: {
some: {
id: userId,
},
},
},
{
team: {
members: {
some: {
userId: userId,
},
},
},
},
{
userId: userId,
},
],
},
{
id,
},
],
},
select: CompleteEventTypeSelect,
});
}
static async findAllByTeamIdIncludeManagedEventTypes({ teamId }: { teamId?: number }) {
return await prisma.eventType.findMany({
where: {
OR: [
{
teamId,
},
{
parent: {
teamId,
},
},
],
},
});
}
}

View File

@@ -0,0 +1,134 @@
import { prisma } from "@calcom/prisma";
import type { MembershipRole } from "@calcom/prisma/client";
import { Prisma } from "@calcom/prisma/client";
import logger from "../../logger";
import { safeStringify } from "../../safeStringify";
import { eventTypeSelect } from "../eventTypeSelect";
import { LookupTarget, ProfileRepository } from "./profile";
const log = logger.getSubLogger({ prefix: ["repository/membership"] });
type IMembership = {
teamId: number;
userId: number;
accepted: boolean;
role: MembershipRole;
};
const membershipSelect = Prisma.validator<Prisma.MembershipSelect>()({
id: true,
teamId: true,
userId: true,
accepted: true,
role: true,
disableImpersonation: true,
});
const teamParentSelect = Prisma.validator<Prisma.TeamSelect>()({
id: true,
name: true,
slug: true,
logoUrl: true,
parentId: true,
metadata: true,
});
const userSelect = Prisma.validator<Prisma.UserSelect>()({
name: true,
avatarUrl: true,
username: true,
id: true,
});
export class MembershipRepository {
static async create(data: IMembership) {
return await prisma.membership.create({
data,
});
}
static async createMany(data: IMembership[]) {
return await prisma.membership.createMany({
data,
});
}
/**
* TODO: Using a specific function for specific tasks so that we don't have to focus on TS magic at the moment. May be try to make it a a generic findAllByProfileId with various options.
*/
static async findAllByUpIdIncludeTeamWithMembersAndEventTypes(
{ upId }: { upId: string },
{ where }: { where?: Prisma.MembershipWhereInput } = {}
) {
const lookupTarget = ProfileRepository.getLookupTarget(upId);
let prismaWhere;
if (lookupTarget.type === LookupTarget.Profile) {
/**
* TODO: When we add profileId to membership, we lookup by profileId
* If the profile is movedFromUser, we lookup all memberships without profileId as well.
*/
const profile = await ProfileRepository.findById(lookupTarget.id);
if (!profile) {
return [];
}
prismaWhere = {
userId: profile.user.id,
...where,
};
} else {
prismaWhere = {
userId: lookupTarget.id,
...where,
};
}
log.debug(
"findAllByUpIdIncludeTeamWithMembersAndEventTypes",
safeStringify({
prismaWhere,
})
);
return await prisma.membership.findMany({
where: prismaWhere,
include: {
team: {
include: {
members: {
select: membershipSelect,
},
parent: {
select: teamParentSelect,
},
eventTypes: {
select: {
...eventTypeSelect,
hashedLink: true,
users: { select: userSelect },
children: {
include: {
users: { select: userSelect },
},
},
hosts: {
include: {
user: { select: userSelect },
},
},
},
// As required by getByViewHandler - Make it configurable
orderBy: [
{
position: "desc",
},
{
id: "asc",
},
],
},
},
},
},
});
}
}

View File

@@ -0,0 +1,105 @@
import prismock from "../../../../tests/libs/__mocks__/prisma";
import { describe, it, expect, beforeEach, vi } from "vitest";
import { OrganizationRepository } from "./organization";
vi.mock("./teamUtils", () => ({
getParsedTeam: (org: any) => org,
}));
describe("Organization.findUniqueByMatchingAutoAcceptEmail", () => {
beforeEach(async () => {
vi.resetAllMocks();
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
await prismock.reset();
});
it("should return null if no organization matches the email domain", async () => {
const result = await OrganizationRepository.findUniqueByMatchingAutoAcceptEmail({
email: "test@example.com",
});
expect(result).toBeNull();
});
it("should throw an error if multiple organizations match the email domain", async () => {
await createOrganization({ name: "Test Org 1", orgAutoAcceptEmail: "example.com" });
await createOrganization({ name: "Test Org 2", orgAutoAcceptEmail: "example.com" });
await expect(
OrganizationRepository.findUniqueByMatchingAutoAcceptEmail({ email: "test@example.com" })
).rejects.toThrow("Multiple organizations found with the same auto accept email domain");
});
it("should return the parsed organization if a single match is found", async () => {
const organization = await createOrganization({ name: "Test Org", orgAutoAcceptEmail: "example.com" });
const result = await OrganizationRepository.findUniqueByMatchingAutoAcceptEmail({
email: "test@example.com",
});
expect(result).toEqual(organization);
});
it("should not confuse a team with organization", async () => {
await createTeam({ name: "Test Team", orgAutoAcceptEmail: "example.com" });
const result = await OrganizationRepository.findUniqueByMatchingAutoAcceptEmail({
email: "test@example.com",
});
expect(result).toEqual(null);
});
it("should correctly match orgAutoAcceptEmail", async () => {
await createOrganization({ name: "Test Org", orgAutoAcceptEmail: "noexample.com" });
const result = await OrganizationRepository.findUniqueByMatchingAutoAcceptEmail({
email: "test@example.com",
});
expect(result).toEqual(null);
});
});
async function createOrganization({
name = "Test Org",
orgAutoAcceptEmail,
}: {
name: string;
orgAutoAcceptEmail: string;
}) {
return await prismock.team.create({
data: {
name,
isOrganization: true,
organizationSettings: {
create: {
orgAutoAcceptEmail,
},
},
},
});
}
async function createTeam({
name = "Test Team",
orgAutoAcceptEmail,
}: {
name: string;
orgAutoAcceptEmail: string;
}) {
return await prismock.team.create({
data: {
name,
isOrganization: false,
organizationSettings: {
create: {
orgAutoAcceptEmail,
},
},
},
});
}

View File

@@ -0,0 +1,188 @@
import { getOrgUsernameFromEmail } from "@calcom/features/auth/signup/utils/getOrgUsernameFromEmail";
import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { prisma } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
import { createAProfileForAnExistingUser } from "../../createAProfileForAnExistingUser";
import { getParsedTeam } from "./teamUtils";
import { UserRepository } from "./user";
const orgSelect = {
id: true,
name: true,
slug: true,
logoUrl: true,
};
export class OrganizationRepository {
static async createWithExistingUserAsOwner({
orgData,
owner,
}: {
orgData: {
name: string;
slug: string;
isOrganizationConfigured: boolean;
isOrganizationAdminReviewed: boolean;
autoAcceptEmail: string;
seats: number | null;
pricePerSeat: number | null;
isPlatform: boolean;
billingPeriod?: "MONTHLY" | "ANNUALLY";
};
owner: {
id: number;
email: string;
nonOrgUsername: string;
};
}) {
logger.debug("createWithExistingUserAsOwner", safeStringify({ orgData, owner }));
const organization = await this.create(orgData);
const ownerProfile = await createAProfileForAnExistingUser({
user: {
id: owner.id,
email: owner.email,
currentUsername: owner.nonOrgUsername,
},
organizationId: organization.id,
});
await prisma.membership.create({
data: {
userId: owner.id,
role: MembershipRole.OWNER,
accepted: true,
teamId: organization.id,
},
});
return { organization, ownerProfile };
}
static async createWithNonExistentOwner({
orgData,
owner,
}: {
orgData: {
name: string;
slug: string;
isOrganizationConfigured: boolean;
isOrganizationAdminReviewed: boolean;
autoAcceptEmail: string;
seats: number | null;
billingPeriod?: "MONTHLY" | "ANNUALLY";
pricePerSeat: number | null;
isPlatform: boolean;
};
owner: {
email: string;
};
}) {
logger.debug("createWithNonExistentOwner", safeStringify({ orgData, owner }));
const organization = await this.create(orgData);
const ownerUsernameInOrg = getOrgUsernameFromEmail(owner.email, orgData.autoAcceptEmail);
const ownerInDb = await UserRepository.create({
email: owner.email,
username: ownerUsernameInOrg,
organizationId: organization.id,
});
await prisma.membership.create({
data: {
userId: ownerInDb.id,
role: MembershipRole.OWNER,
accepted: true,
teamId: organization.id,
},
});
return {
orgOwner: ownerInDb,
organization,
ownerProfile: {
username: ownerUsernameInOrg,
},
};
}
static async create(orgData: {
name: string;
slug: string;
isOrganizationConfigured: boolean;
isOrganizationAdminReviewed: boolean;
autoAcceptEmail: string;
seats: number | null;
billingPeriod?: "MONTHLY" | "ANNUALLY";
pricePerSeat: number | null;
isPlatform: boolean;
}) {
return await prisma.team.create({
data: {
name: orgData.name,
isOrganization: true,
...(!IS_TEAM_BILLING_ENABLED ? { slug: orgData.slug } : {}),
organizationSettings: {
create: {
isAdminReviewed: orgData.isOrganizationAdminReviewed,
isOrganizationVerified: true,
isOrganizationConfigured: orgData.isOrganizationConfigured,
orgAutoAcceptEmail: orgData.autoAcceptEmail,
},
},
metadata: {
...(IS_TEAM_BILLING_ENABLED ? { requestedSlug: orgData.slug } : {}),
orgSeats: orgData.seats,
orgPricePerSeat: orgData.pricePerSeat,
isPlatform: orgData.isPlatform,
billingPeriod: orgData.billingPeriod,
},
isPlatform: orgData.isPlatform,
},
});
}
static async findById({ id }: { id: number }) {
return prisma.team.findUnique({
where: {
id,
isOrganization: true,
},
select: orgSelect,
});
}
static async findByIdIncludeOrganizationSettings({ id }: { id: number }) {
return prisma.team.findUnique({
where: {
id,
isOrganization: true,
},
select: {
...orgSelect,
organizationSettings: true,
},
});
}
static async findUniqueByMatchingAutoAcceptEmail({ email }: { email: string }) {
const emailDomain = email.split("@").at(-1);
const orgs = await prisma.team.findMany({
where: {
isOrganization: true,
organizationSettings: {
orgAutoAcceptEmail: emailDomain,
},
},
});
if (orgs.length > 1) {
// Detect and fail just in case this situation arises. We should really identify the problem in this case and fix the data.
throw new Error("Multiple organizations found with the same auto accept email domain");
}
const org = orgs[0];
if (!org) {
return null;
}
return getParsedTeam(org);
}
}

View File

@@ -0,0 +1,561 @@
import type { User as PrismaUser } from "@prisma/client";
import { v4 as uuidv4 } from "uuid";
import { whereClauseForOrgWithSlugOrRequestedSlug } from "@calcom/ee/organizations/lib/orgDomains";
import { safeStringify } from "@calcom/lib/safeStringify";
import prisma from "@calcom/prisma";
import { Prisma } from "@calcom/prisma/client";
import type { Team } from "@calcom/prisma/client";
import type { UpId, UserAsPersonalProfile, UserProfile } from "@calcom/types/UserProfile";
import logger from "../../logger";
import { getParsedTeam } from "./teamUtils";
import { UserRepository } from "./user";
const userSelect = Prisma.validator<Prisma.UserSelect>()({
name: true,
avatarUrl: true,
username: true,
id: true,
email: true,
locale: true,
defaultScheduleId: true,
startTime: true,
endTime: true,
bufferTime: true,
});
const membershipSelect = Prisma.validator<Prisma.MembershipSelect>()({
id: true,
teamId: true,
userId: true,
accepted: true,
role: true,
disableImpersonation: true,
});
const log = logger.getSubLogger({ prefix: ["repository/profile"] });
const organizationSelect = {
id: true,
slug: true,
name: true,
metadata: true,
logoUrl: true,
calVideoLogo: true,
bannerUrl: true,
};
export enum LookupTarget {
User,
Profile,
}
export class ProfileRepository {
static generateProfileUid() {
return uuidv4();
}
private static getInheritedDataFromUser({
user,
}: {
user: Pick<PrismaUser, "name" | "avatarUrl" | "startTime" | "endTime" | "bufferTime">;
}) {
return {
name: user.name,
avatarUrl: user.avatarUrl,
startTime: user.startTime,
endTime: user.endTime,
bufferTime: user.bufferTime,
};
}
static getLookupTarget(upId: UpId) {
if (upId.startsWith("usr-")) {
return {
type: LookupTarget.User,
id: parseInt(upId.replace("usr-", "")),
} as const;
}
return {
type: LookupTarget.Profile,
id: parseInt(upId),
} as const;
}
private static async _create({
userId,
organizationId,
username,
email,
movedFromUserId,
}: {
userId: number;
organizationId: number;
username: string | null;
email: string;
movedFromUserId?: number;
}) {
log.debug("_create", safeStringify({ userId, organizationId, username, email }));
return prisma.profile.create({
data: {
uid: ProfileRepository.generateProfileUid(),
user: {
connect: {
id: userId,
},
},
organization: {
connect: {
id: organizationId,
},
},
...(movedFromUserId
? {
movedFromUser: {
connect: {
id: movedFromUserId,
},
},
}
: null),
username: username || email.split("@")[0],
},
});
}
/**
* Accepts `email` as a source to derive username from when username is null
* @returns
*/
static create({
userId,
organizationId,
username,
email,
}: {
userId: number;
organizationId: number;
username: string | null;
email: string;
}) {
return ProfileRepository._create({ userId, organizationId, username, email });
}
static async upsert({
create,
update,
updateWhere,
}: {
create: {
userId: number;
organizationId: number;
username: string | null;
email: string;
};
update: {
username: string | null;
email: string;
};
updateWhere: {
userId: number;
organizationId: number;
};
}) {
return prisma.profile.upsert({
create: {
uid: ProfileRepository.generateProfileUid(),
user: {
connect: {
id: create.userId,
},
},
organization: {
connect: {
id: create.organizationId,
},
},
username: create.username || create.email.split("@")[0],
},
update: {
username: update.username || update.email.split("@")[0],
},
where: {
userId_organizationId: {
userId: updateWhere.userId,
organizationId: updateWhere.organizationId,
},
},
});
}
static async createForExistingUser({
userId,
organizationId,
username,
email,
movedFromUserId,
}: {
userId: number;
organizationId: number;
username: string | null;
email: string;
movedFromUserId: number;
}) {
return await ProfileRepository._create({
userId,
organizationId,
username,
email: email,
movedFromUserId,
});
}
static createMany({
users,
organizationId,
}: {
users: { id: number; username: string; email: string }[];
organizationId: number;
}) {
return prisma.profile.createMany({
data: users.map((user) => ({
uid: ProfileRepository.generateProfileUid(),
userId: user.id,
organizationId,
username: user.username || user.email.split("@")[0],
})),
});
}
static delete({ userId, organizationId }: { userId: number; organizationId: number }) {
// Even though there can be just one profile matching a userId and organizationId, we are using deleteMany as it won't error if the profile doesn't exist
return prisma.profile.deleteMany({
where: { userId, organizationId },
});
}
static deleteMany({ userIds }: { userIds: number[] }) {
// Even though there can be just one profile matching a userId and organizationId, we are using deleteMany as it won't error if the profile doesn't exist
return prisma.profile.deleteMany({
where: { userId: { in: userIds } },
});
}
static async findByUserIdAndOrgId({
userId,
organizationId,
}: {
userId: number;
organizationId: number | null;
}) {
if (!organizationId) {
return null;
}
const profile = await prisma.profile.findFirst({
where: {
userId,
organizationId,
},
include: {
organization: {
select: organizationSelect,
},
user: {
select: userSelect,
},
},
});
if (!profile) {
return null;
}
const organization = getParsedTeam(profile.organization);
return normalizeProfile({
...profile,
organization: {
...organization,
requestedSlug: organization.metadata?.requestedSlug ?? null,
metadata: organization.metadata,
},
});
}
static async findByOrgIdAndUsername({
organizationId,
username,
}: {
organizationId: number;
username: string;
}) {
const profile = await prisma.profile.findFirst({
where: {
username,
organizationId,
},
include: {
organization: {
select: organizationSelect,
},
user: {
select: userSelect,
},
},
});
return profile;
}
static async findByUpId(upId: string) {
const lookupTarget = ProfileRepository.getLookupTarget(upId);
log.debug("findByUpId", safeStringify({ upId, lookupTarget }));
if (lookupTarget.type === LookupTarget.User) {
const user = await UserRepository.findById({ id: lookupTarget.id });
if (!user) {
return null;
}
return {
username: user.username,
upId: `usr-${user.id}`,
id: null,
organizationId: null,
organization: null,
...ProfileRepository.getInheritedDataFromUser({ user }),
};
}
const profile = await ProfileRepository.findById(lookupTarget.id);
if (!profile) {
return null;
}
const user = profile.user;
return {
...profile,
...ProfileRepository.getInheritedDataFromUser({ user }),
};
}
static async findById(id: number | null) {
if (!id) {
return null;
}
const profile = await prisma.profile.findUnique({
where: {
id,
},
include: {
user: {
select: userSelect,
},
movedFromUser: {
select: {
id: true,
},
},
organization: {
select: {
calVideoLogo: true,
id: true,
logoUrl: true,
name: true,
slug: true,
metadata: true,
bannerUrl: true,
isPrivate: true,
isPlatform: true,
organizationSettings: {
select: {
lockEventTypeCreationForUsers: true,
},
},
members: {
select: membershipSelect,
},
},
},
},
});
if (!profile) {
return null;
}
return normalizeProfile(profile);
}
static async findManyByOrgSlugOrRequestedSlug({
usernames,
orgSlug,
}: {
usernames: string[];
orgSlug: string;
}) {
logger.debug("findManyByOrgSlugOrRequestedSlug", safeStringify({ usernames, orgSlug }));
const profiles = await prisma.profile.findMany({
where: {
username: {
in: usernames,
},
organization: whereClauseForOrgWithSlugOrRequestedSlug(orgSlug),
},
include: {
user: {
select: userSelect,
},
organization: {
select: organizationSelect,
},
},
});
return profiles.map(normalizeProfile);
}
static async findAllProfilesForUserIncludingMovedUser(user: {
id: number;
username: string | null;
}): Promise<UserProfile[]> {
const profiles = await ProfileRepository.findManyForUser(user);
// User isn't member of any organization. Also, he has no user profile. We build the profile from user table
if (!profiles.length) {
return [
ProfileRepository.buildPersonalProfileFromUser({
user,
}),
];
}
return profiles;
}
static async findManyForUser(user: { id: number }) {
const profiles = (
await prisma.profile.findMany({
where: {
userId: user.id,
},
include: {
organization: {
select: organizationSelect,
},
},
})
)
.map((profile) => {
return {
...profile,
organization: getParsedTeam(profile.organization),
};
})
.map((profile) => {
return normalizeProfile({
username: profile.username,
id: profile.id,
userId: profile.userId,
uid: profile.uid,
name: profile.organization.name,
organizationId: profile.organizationId,
organization: {
...profile.organization,
requestedSlug: profile.organization.metadata?.requestedSlug ?? null,
metadata: profile.organization.metadata,
},
});
});
return profiles;
}
static async findManyForOrg({ organizationId }: { organizationId: number }) {
return await prisma.profile.findMany({
where: {
organizationId,
},
include: {
user: {
select: userSelect,
},
organization: {
select: organizationSelect,
},
},
});
}
static async findByUserIdAndProfileId({ userId, profileId }: { userId: number; profileId: number }) {
const profile = await prisma.profile.findUnique({
where: {
userId,
id: profileId,
},
include: {
organization: {
select: organizationSelect,
},
user: {
select: userSelect,
},
},
});
if (!profile) {
return profile;
}
return normalizeProfile(profile);
}
/**
* Personal profile should come from Profile table only
*/
static buildPersonalProfileFromUser({
user,
}: {
user: { username: string | null; id: number };
}): UserAsPersonalProfile {
return {
id: null,
upId: `usr-${user.id}`,
username: user.username,
organizationId: null,
organization: null,
};
}
static _getPrismaWhereForProfilesOfOrg({ orgSlug }: { orgSlug: string | null }) {
return {
profiles: {
...(orgSlug
? {
some: {
organization: {
slug: orgSlug,
},
},
}
: // If it's not orgSlug we want to ensure that no profile is there. Having a profile means that the user is a member of some organization.
{
none: {},
}),
},
};
}
}
export const normalizeProfile = <
T extends {
id: number;
organization: Pick<Team, keyof typeof organizationSelect>;
createdAt?: Date;
updatedAt?: Date;
}
>(
profile: T
) => {
return {
...profile,
upId: profile.id.toString(),
organization: getParsedTeam(profile.organization),
// Make these ↓ props ISO strings so that they can be returned from getServerSideProps as is without any issues
...(profile.createdAt ? { createdAt: profile.createdAt.toISOString() } : null),
...(profile.updatedAt ? { updatedAt: profile.updatedAt.toISOString() } : null),
};
};

View File

@@ -0,0 +1,312 @@
import prismaMock from "../../../../tests/libs/__mocks__/prismaMock";
import { it, describe, expect } from "vitest";
import { getTeam, getOrg } from "./team";
const sampleTeamProps = {
logo: null,
appLogo: null,
bio: null,
description: null,
hideBranding: false,
isPrivate: false,
appIconLogo: null,
hideBookATeamMember: false,
createdAt: new Date(),
theme: null,
brandColor: "",
darkBrandColor: "",
timeFormat: null,
timeZone: "",
weekStart: "",
parentId: null,
};
describe("getOrg", () => {
it("should return an Organization correctly by slug even if there is a team with the same slug", async () => {
prismaMock.team.findMany.mockResolvedValue([
{
id: 101,
name: "Test Team",
slug: "test-slug",
isOrganization: true,
},
]);
const org = await getOrg({
lookupBy: {
slug: "test-slug",
},
forOrgWithSlug: null,
teamSelect: {
id: true,
slug: true,
},
});
const firstFindManyCallArguments = prismaMock.team.findMany.mock.calls[0];
expect(firstFindManyCallArguments[0]).toEqual({
where: {
slug: "test-slug",
isOrganization: true,
},
select: {
id: true,
slug: true,
metadata: true,
isOrganization: true,
},
});
expect(org?.isOrganization).toBe(true);
});
it("should not return an org result if metadata.isOrganization isn't true", async () => {
prismaMock.team.findMany.mockResolvedValue([
{
...sampleTeamProps,
id: 101,
name: "Test Team",
slug: "test-slug",
metadata: {},
},
]);
const org = await getOrg({
lookupBy: {
slug: "test-slug",
},
forOrgWithSlug: null,
teamSelect: {
id: true,
slug: true,
},
});
const firstFindManyCallArguments = prismaMock.team.findMany.mock.calls[0];
expect(firstFindManyCallArguments[0]).toEqual({
where: {
slug: "test-slug",
isOrganization: true,
},
select: {
id: true,
slug: true,
metadata: true,
isOrganization: true,
},
});
expect(org).toBe(null);
});
it("should error if metadata isn't valid", async () => {
prismaMock.team.findMany.mockResolvedValue([
{
...sampleTeamProps,
id: 101,
name: "Test Team",
slug: "test-slug",
metadata: [],
},
]);
await expect(() =>
getOrg({
lookupBy: {
slug: "test-slug",
},
forOrgWithSlug: null,
teamSelect: {
id: true,
slug: true,
},
})
).rejects.toThrow("invalid_type");
});
});
describe("getTeam", () => {
it("should query a team correctly", async () => {
prismaMock.team.findMany.mockResolvedValue([
{
...sampleTeamProps,
id: 101,
name: "Test Team",
slug: "test-slug",
metadata: {
anything: "here",
paymentId: "1",
},
},
]);
const team = await getTeam({
lookupBy: {
slug: "test-slug",
},
forOrgWithSlug: null,
teamSelect: {
id: true,
slug: true,
name: true,
},
});
const firstFindManyCallArguments = prismaMock.team.findMany.mock.calls[0];
expect(firstFindManyCallArguments[0]).toEqual({
where: {
slug: "test-slug",
},
select: {
id: true,
slug: true,
name: true,
metadata: true,
isOrganization: true,
},
});
expect(team).not.toBeNull();
// 'anything' is not in the teamMetadata schema, so it should be stripped out
expect(team?.metadata).toEqual({ paymentId: "1" });
});
it("should not return a team result if the queried result isn't a team", async () => {
prismaMock.team.findMany.mockResolvedValue([
{
...sampleTeamProps,
id: 101,
name: "Test Team",
slug: "test-slug",
isOrganization: true,
},
]);
const team = await getTeam({
lookupBy: {
slug: "test-slug",
},
forOrgWithSlug: null,
teamSelect: {
id: true,
slug: true,
name: true,
},
});
const firstFindManyCallArguments = prismaMock.team.findMany.mock.calls[0];
expect(firstFindManyCallArguments[0]).toEqual({
where: {
slug: "test-slug",
},
select: {
id: true,
slug: true,
name: true,
metadata: true,
isOrganization: true,
},
});
expect(team).toBe(null);
});
it("should return a team by slug within an org", async () => {
prismaMock.team.findMany.mockResolvedValue([
{
...sampleTeamProps,
id: 101,
name: "Test Team",
slug: "test-slug",
parentId: 100,
metadata: null,
},
]);
await getTeam({
lookupBy: {
slug: "team-in-test-org",
},
forOrgWithSlug: "test-org",
teamSelect: {
id: true,
slug: true,
name: true,
},
});
const firstFindManyCallArguments = prismaMock.team.findMany.mock.calls[0];
expect(firstFindManyCallArguments[0]).toEqual({
where: {
slug: "team-in-test-org",
parent: {
OR: [
{
slug: "test-org",
},
{
metadata: {
path: ["requestedSlug"],
equals: "test-org",
},
},
],
isOrganization: true,
},
},
select: {
id: true,
name: true,
slug: true,
metadata: true,
isOrganization: true,
},
});
});
it("should return a team by requestedSlug within an org", async () => {
prismaMock.team.findMany.mockResolvedValue([]);
await getTeam({
lookupBy: {
slug: "test-team",
},
forOrgWithSlug: "test-org",
teamSelect: {
id: true,
slug: true,
name: true,
},
});
const firstFindManyCallArguments = prismaMock.team.findMany.mock.calls[0];
expect(firstFindManyCallArguments[0]).toEqual({
where: {
slug: "test-team",
parent: {
isOrganization: true,
OR: [
{
slug: "test-org",
},
{
metadata: {
path: ["requestedSlug"],
equals: "test-org",
},
},
],
},
},
select: {
id: true,
slug: true,
name: true,
metadata: true,
isOrganization: true,
},
});
});
});

View File

@@ -0,0 +1,180 @@
import { Prisma } from "@prisma/client";
import type { z } from "zod";
import { whereClauseForOrgWithSlugOrRequestedSlug } from "@calcom/ee/organizations/lib/orgDomains";
import logger from "@calcom/lib/logger";
import prisma from "@calcom/prisma";
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
import { getParsedTeam } from "./teamUtils";
type TeamGetPayloadWithParsedMetadata<TeamSelect extends Prisma.TeamSelect> =
| (Omit<Prisma.TeamGetPayload<{ select: TeamSelect }>, "metadata" | "isOrganization"> & {
metadata: z.infer<typeof teamMetadataSchema>;
isOrganization: boolean;
})
| null;
type GetTeamOrOrgArg<TeamSelect extends Prisma.TeamSelect> = {
lookupBy: (
| {
id: number;
}
| {
slug: string;
}
) & {
havingMemberWithId?: number;
};
/**
* If we are fetching a team, this is the slug of the organization that the team belongs to.
*/
forOrgWithSlug: string | null;
/**
* If true, means that we need to fetch an organization with the given slug. Otherwise, we need to fetch a team with the given slug.
*/
isOrg: boolean;
teamSelect: TeamSelect;
};
const log = logger.getSubLogger({ prefix: ["repository", "team"] });
/**
* Get's the team or organization with the given slug or id reliably along with parsed metadata.
*/
async function getTeamOrOrg<TeamSelect extends Prisma.TeamSelect>({
lookupBy,
forOrgWithSlug: forOrgWithSlug,
isOrg,
teamSelect,
}: GetTeamOrOrgArg<TeamSelect>): Promise<TeamGetPayloadWithParsedMetadata<TeamSelect>> {
const where: Prisma.TeamFindFirstArgs["where"] = {};
teamSelect = {
...teamSelect,
metadata: true,
isOrganization: true,
} satisfies TeamSelect;
if (lookupBy.havingMemberWithId) where.members = { some: { userId: lookupBy.havingMemberWithId } };
if ("id" in lookupBy) {
where.id = lookupBy.id;
} else {
where.slug = lookupBy.slug;
}
if (isOrg) {
// We must fetch only the organization here.
// Note that an organization and a team that doesn't belong to an organization, both have parentId null
// If the organization has null slug(but requestedSlug is 'test') and the team also has slug 'test', we can't distinguish them without explicitly checking the metadata.isOrganization
// Note that, this isn't possible now to have same requestedSlug as the slug of a team not part of an organization. This is legacy teams handling mostly. But it is still safer to be sure that you are fetching an Organization only in case of isOrgView
where.isOrganization = true;
// We must fetch only the team here.
} else {
if (forOrgWithSlug) {
where.parent = whereClauseForOrgWithSlugOrRequestedSlug(forOrgWithSlug);
}
}
log.debug({
orgSlug: forOrgWithSlug,
teamLookupBy: lookupBy,
isOrgView: isOrg,
where,
});
const teams = await prisma.team.findMany({
where,
select: teamSelect,
});
const teamsWithParsedMetadata = teams
.map((team) => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore ts types are way too complciated for this now
const parsedMetadata = teamMetadataSchema.parse(team.metadata ?? {});
return {
...team,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore It does exist
isOrganization: team.isOrganization as boolean,
metadata: parsedMetadata,
};
})
// In cases where there are many teams with the same slug, we need to find out the one and only one that matches our criteria
.filter((team) => {
// We need an org if isOrgView otherwise we need a team
return isOrg ? team.isOrganization : !team.isOrganization;
});
if (teamsWithParsedMetadata.length > 1) {
log.error("Found more than one team/Org. We should be doing something wrong.", {
isOrgView: isOrg,
where,
teams: teamsWithParsedMetadata.map((team) => {
const t = team as unknown as { id: number; slug: string };
return {
id: t.id,
slug: t.slug,
};
}),
});
}
const team = teamsWithParsedMetadata[0];
if (!team) return null;
// HACK: I am not sure how to make Prisma in peace with TypeScript with this repository pattern
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return team as any;
}
export async function getTeam<TeamSelect extends Prisma.TeamSelect>({
lookupBy,
forOrgWithSlug: forOrgWithSlug,
teamSelect,
}: Omit<GetTeamOrOrgArg<TeamSelect>, "isOrg">): Promise<TeamGetPayloadWithParsedMetadata<TeamSelect>> {
return getTeamOrOrg({
lookupBy,
forOrgWithSlug: forOrgWithSlug,
isOrg: false,
teamSelect,
});
}
export async function getOrg<TeamSelect extends Prisma.TeamSelect>({
lookupBy,
forOrgWithSlug: forOrgWithSlug,
teamSelect,
}: Omit<GetTeamOrOrgArg<TeamSelect>, "isOrg">): Promise<TeamGetPayloadWithParsedMetadata<TeamSelect>> {
return getTeamOrOrg({
lookupBy,
forOrgWithSlug: forOrgWithSlug,
isOrg: true,
teamSelect,
});
}
const teamSelect = Prisma.validator<Prisma.TeamSelect>()({
id: true,
name: true,
slug: true,
logoUrl: true,
parentId: true,
metadata: true,
isOrganization: true,
organizationSettings: true,
});
export class TeamRepository {
static async findById({ id }: { id: number }) {
const team = await prisma.team.findUnique({
where: {
id,
},
select: teamSelect,
});
if (!team) {
return null;
}
return getParsedTeam(team);
}
}

View File

@@ -0,0 +1,16 @@
import type { Team } from "@calcom/prisma/client";
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
export const getParsedTeam = <T extends { metadata: Team["metadata"] }>(team: T) => {
const metadata = teamMetadataSchema.parse(team.metadata);
const requestedSlug = metadata?.requestedSlug ?? null;
const { metadata: _1, ...rest } = team;
return {
...rest,
requestedSlug,
metadata: {
...metadata,
requestedSlug,
},
};
};

View File

@@ -0,0 +1,518 @@
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";
import prisma from "@calcom/prisma";
import { Prisma } from "@calcom/prisma/client";
import type { User as UserType } from "@calcom/prisma/client";
import { MembershipRole } from "@calcom/prisma/enums";
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";
export type UserAdminTeams = number[];
const log = logger.getSubLogger({ prefix: ["[repository/user]"] });
export const ORGANIZATION_ID_UNKNOWN = "ORGANIZATION_ID_UNKNOWN";
const teamSelect = Prisma.validator<Prisma.TeamSelect>()({
id: true,
name: true,
slug: true,
metadata: true,
logoUrl: true,
organizationSettings: true,
isOrganization: true,
});
const userSelect = Prisma.validator<Prisma.UserSelect>()({
id: true,
username: true,
name: true,
email: true,
emailVerified: true,
bio: true,
avatarUrl: true,
timeZone: true,
startTime: true,
endTime: true,
weekStart: true,
bufferTime: true,
hideBranding: true,
theme: true,
createdDate: true,
trialEndsAt: true,
completedOnboarding: true,
locale: true,
timeFormat: true,
twoFactorSecret: true,
twoFactorEnabled: true,
backupCodes: true,
identityProviderId: true,
invitedTo: true,
brandColor: true,
darkBrandColor: true,
allowDynamicBooking: true,
allowSEOIndexing: true,
receiveMonthlyDigestEmail: true,
verified: true,
disableImpersonation: true,
locked: true,
movedToProfileId: true,
metadata: true,
});
export class UserRepository {
static async findTeamsByUserId({ userId }: { userId: UserType["id"] }) {
const teamMemberships = await prisma.membership.findMany({
where: {
userId: userId,
},
include: {
team: {
select: teamSelect,
},
},
});
const acceptedTeamMemberships = teamMemberships.filter((membership) => membership.accepted);
const pendingTeamMemberships = teamMemberships.filter((membership) => !membership.accepted);
return {
teams: acceptedTeamMemberships.map((membership) => membership.team),
memberships: teamMemberships,
acceptedTeamMemberships,
pendingTeamMemberships,
};
}
static async findOrganizations({ userId }: { userId: UserType["id"] }) {
const { acceptedTeamMemberships } = await UserRepository.findTeamsByUserId({
userId,
});
const acceptedOrgMemberships = acceptedTeamMemberships.filter(
(membership) => membership.team.isOrganization
);
const organizations = acceptedOrgMemberships.map((membership) => membership.team);
return {
organizations,
};
}
/**
* It is aware of the fact that a user can be part of multiple organizations.
*/
static async findUsersByUsername({
orgSlug,
usernameList,
}: {
orgSlug: string | null;
usernameList: string[];
}) {
const { where, profiles } = await UserRepository._getWhereClauseForFindingUsersByUsername({
orgSlug,
usernameList,
});
log.debug("findUsersByUsername", safeStringify({ where, profiles }));
return (
await prisma.user.findMany({
select: userSelect,
where,
})
).map((user) => {
// User isn't part of any organization
if (!profiles) {
return {
...user,
profile: ProfileRepository.buildPersonalProfileFromUser({ user }),
};
}
const profile = profiles.find((profile) => profile.user.id === user.id) ?? null;
if (!profile) {
log.error("Profile not found for user", safeStringify({ user, profiles }));
// Profile must be there because profile itself was used to retrieve the user
throw new Error("Profile couldn't be found");
}
const { user: _1, ...profileWithoutUser } = profile;
return {
...user,
profile: profileWithoutUser,
};
});
}
static async _getWhereClauseForFindingUsersByUsername({
orgSlug,
usernameList,
}: {
orgSlug: string | null;
usernameList: string[];
}) {
// Lookup in profiles because that's where the organization usernames exist
const profiles = orgSlug
? (
await ProfileRepository.findManyByOrgSlugOrRequestedSlug({
orgSlug: orgSlug,
usernames: usernameList,
})
).map((profile) => ({
...profile,
organization: getParsedTeam(profile.organization),
}))
: null;
const where = profiles
? {
// Get UserIds from profiles
id: {
in: profiles.map((profile) => profile.user.id),
},
}
: {
username: {
in: usernameList,
},
...(orgSlug
? {
organization: whereClauseForOrgWithSlugOrRequestedSlug(orgSlug),
}
: {
organization: null,
}),
};
return { where, profiles };
}
static async findByEmailAndIncludeProfilesAndPassword({ email }: { email: string }) {
const user = await prisma.user.findUnique({
where: {
email: email.toLowerCase(),
},
select: {
locked: true,
role: true,
id: true,
username: true,
name: true,
email: true,
metadata: true,
identityProvider: true,
password: true,
twoFactorEnabled: true,
twoFactorSecret: true,
backupCodes: true,
locale: true,
teams: {
include: {
team: {
select: teamSelect,
},
},
},
},
});
if (!user) {
return null;
}
const allProfiles = await ProfileRepository.findAllProfilesForUserIncludingMovedUser(user);
return {
...user,
allProfiles,
};
}
static async findById({ id }: { id: number }) {
const user = await prisma.user.findUnique({
where: {
id,
},
select: userSelect,
});
if (!user) {
return null;
}
return user;
}
static async findManyByOrganization({ organizationId }: { organizationId: number }) {
const profiles = await ProfileRepository.findManyForOrg({ organizationId });
return profiles.map((profile) => profile.user);
}
static isAMemberOfOrganization({
user,
organizationId,
}: {
user: { profiles: { organizationId: number }[] };
organizationId: number;
}) {
return user.profiles.some((profile) => profile.organizationId === organizationId);
}
static async findIfAMemberOfSomeOrganization({ user }: { user: { id: number } }) {
return !!(
await ProfileRepository.findManyForUser({
id: user.id,
})
).length;
}
static isMigratedToOrganization({
user,
}: {
user: {
metadata?: {
migratedToOrgFrom?: unknown;
} | null;
};
}) {
return !!user.metadata?.migratedToOrgFrom;
}
static async isMovedToAProfile({ user }: { user: Pick<UserType, "movedToProfileId"> }) {
return !!user.movedToProfileId;
}
static async enrichUserWithTheProfile<T extends { username: string | null; id: number }>({
user,
upId,
}: {
user: T;
upId: UpId;
}) {
log.debug("enrichUserWithTheProfile", safeStringify({ user, upId }));
const profile = await ProfileRepository.findByUpId(upId);
if (!profile) {
return {
...user,
profile: ProfileRepository.buildPersonalProfileFromUser({ user }),
};
}
return {
...user,
profile,
};
}
/**
* Use this method if you don't directly has the profileId.
* It can happen in two cases:
* 1. While dealing with a User that hasn't been added to any organization yet and thus have no Profile entries.
* 2. While dealing with a User that has been moved to a Profile i.e. he was invited to an organization when he was an existing user.
*/
static async enrichUserWithItsProfile<T extends { id: number; username: string | null }>({
user,
}: {
user: T;
}): Promise<
T & {
nonProfileUsername: string | null;
profile: UserProfile;
}
> {
const profiles = await ProfileRepository.findManyForUser({ id: user.id });
if (profiles.length) {
const profile = profiles[0];
return {
...user,
username: profile.username,
nonProfileUsername: user.username,
profile,
};
}
// If no organization profile exists, use the personal profile so that the returned user is normalized to have a profile always
return {
...user,
nonProfileUsername: user.username,
profile: ProfileRepository.buildPersonalProfileFromUser({ user }),
};
}
static enrichUserWithItsProfileBuiltFromUser<T extends { id: number; username: string | null }>({
user,
}: {
user: T;
}): T & {
nonProfileUsername: string | null;
profile: UserProfile;
} {
// If no organization profile exists, use the personal profile so that the returned user is normalized to have a profile always
return {
...user,
nonProfileUsername: user.username,
profile: ProfileRepository.buildPersonalProfileFromUser({ user }),
};
}
static async enrichEntityWithProfile<
T extends
| {
profile: {
id: number;
username: string | null;
organizationId: number | null;
organization?: {
id: number;
name: string;
calVideoLogo: string | null;
bannerUrl: string | null;
slug: string | null;
metadata: Prisma.JsonValue;
};
};
}
| {
user: {
username: string | null;
id: number;
};
}
>(entity: T) {
if ("profile" in entity) {
const { profile, ...entityWithoutProfile } = entity;
const { organization, ...profileWithoutOrganization } = profile || {};
const parsedOrg = organization ? getParsedTeam(organization) : null;
const ret = {
...entityWithoutProfile,
profile: {
...profileWithoutOrganization,
...(parsedOrg
? {
organization: parsedOrg,
}
: {
organization: null,
}),
},
};
return ret;
} else {
const profiles = await ProfileRepository.findManyForUser(entity.user);
if (!profiles.length) {
return {
...entity,
profile: ProfileRepository.buildPersonalProfileFromUser({ user: entity.user }),
};
} else {
return {
...entity,
profile: profiles[0],
};
}
}
}
static async updateWhereId({
whereId,
data,
}: {
whereId: number;
data: {
movedToProfileId?: number | null;
};
}) {
return prisma.user.update({
where: {
id: whereId,
},
data: {
movedToProfile: data.movedToProfileId
? {
connect: {
id: data.movedToProfileId,
},
}
: undefined,
},
});
}
static async create({
email,
username,
organizationId,
}: {
email: string;
username: string;
organizationId: number | null;
}) {
const password = createHash("md5").update(`${email}${process.env.CALENDSO_ENCRYPTION_KEY}`).digest("hex");
const hashedPassword = await hashPassword(password);
const t = await getTranslation("en", "common");
const availability = getAvailabilityFromSchedule(DEFAULT_SCHEDULE);
return await prisma.user.create({
data: {
username: slugify(username),
email: email,
password: { create: { hash: hashedPassword } },
// Default schedule
schedules: {
create: {
name: t("default_schedule_name"),
availability: {
createMany: {
data: availability.map((schedule) => ({
days: schedule.days,
startTime: schedule.startTime,
endTime: schedule.endTime,
})),
},
},
},
},
organizationId: organizationId,
profiles: organizationId
? {
create: {
username: slugify(username),
organizationId: organizationId,
uid: ProfileRepository.generateProfileUid(),
},
}
: undefined,
},
});
}
static async getUserAdminTeams(userId: number): Promise<number[]> {
const user = await prisma.user.findFirst({
where: {
id: userId,
},
select: {
teams: {
where: {
accepted: true,
role: { in: [MembershipRole.ADMIN, MembershipRole.OWNER] },
},
select: { teamId: true },
},
},
});
const teamIds = [];
for (const team of user?.teams || []) {
teamIds.push(team.teamId);
}
return teamIds;
}
}

View File

@@ -0,0 +1,36 @@
import jimp from "jimp";
export async function resizeBase64Image(
base64OrUrl: string,
opts?: {
maxSize?: number;
}
) {
if (!base64OrUrl.startsWith("data:")) {
// might be a `https://` or something
return base64OrUrl;
}
const mimeMatch = base64OrUrl.match(/^data:(\w+\/\w+);/);
const mimetype = mimeMatch?.[1];
if (!mimetype) {
throw new Error(`Could not distinguish mimetype`);
}
const buffer = Buffer.from(base64OrUrl.replace(/^data:image\/\w+;base64,/, ""), "base64");
const {
// 96px is the height of the image on https://cal.com/peer
maxSize = 96 * 4,
} = opts ?? {};
const image = await jimp.read(buffer);
if (image.getHeight() !== image.getHeight()) {
// this could be handled later
throw new Error("Image is not a square");
}
const currentSize = Math.max(image.getWidth(), image.getHeight());
if (currentSize > maxSize) {
image.resize(jimp.AUTO, maxSize);
}
const newBuffer = await image.getBufferAsync(mimetype);
return `data:${mimetype};base64,${newBuffer.toString("base64")}`;
}

View File

@@ -0,0 +1,92 @@
import prismaMock from "../../../tests/libs/__mocks__/prismaMock";
import { describe, expect, it, beforeEach } from "vitest";
import { usernameCheckForSignup } from "./username";
describe("usernameCheckForSignup ", async () => {
beforeEach(() => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
prismaMock.user.findUnique.mockImplementation(() => {
return null;
});
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
prismaMock.user.findMany.mockImplementation(() => {
return [];
});
});
it("should return available true for an email that doesn't exist", async () => {
const res = await usernameCheckForSignup({ username: "johnny", email: "johnny@example.com" });
expect(res).toEqual({
available: true,
premium: false,
suggestedUsername: "",
});
});
it("should return available false for an email that exists and a different username is provided", async () => {
mockUserInDB({
id: 1,
email: "john@example.com",
username: "john",
});
const res = await usernameCheckForSignup({ username: "johnny", email: "john@example.com" });
expect(res).toEqual({
available: false,
premium: false,
suggestedUsername: "johnny001",
});
});
it("should return available true for an email that exists but the user is signing up for an organization", async () => {
const userId = 1;
mockUserInDB({
id: userId,
email: "john@example.com",
username: "john",
});
mockMembership({ userId });
const res = await usernameCheckForSignup({ username: "john", email: "john@example.com" });
expect(res).toEqual({
available: true,
// An organization can't have premium username
premium: false,
suggestedUsername: "",
});
});
});
function mockUserInDB({ id, email, username }: { id: number; email: string; username: string }) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
prismaMock.user.findUnique.mockImplementation((arg) => {
if (arg.where.email === email) {
return {
id,
email,
username,
};
}
return null;
});
}
function mockMembership({ userId }: { userId: number }) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
prismaMock.membership.findFirst.mockImplementation((arg) => {
const isOrganizationWhereClause =
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
arg?.where?.team?.metadata?.path[0] === "isOrganization" && arg?.where?.team?.metadata?.equals === true;
if (arg?.where?.userId === userId && isOrganizationWhereClause) {
return {
userId,
teamId: 1,
};
}
});
}

View File

@@ -0,0 +1,265 @@
import type { NextApiRequest, NextApiResponse } from "next";
import slugify from "@calcom/lib/slugify";
import prisma from "@calcom/prisma";
import { RedirectType } from "@calcom/prisma/enums";
import { IS_PREMIUM_USERNAME_ENABLED } from "../constants";
import logger from "../logger";
import notEmpty from "../notEmpty";
const log = logger.getSubLogger({ prefix: ["server/username"] });
const cachedData: Set<string> = new Set();
export type RequestWithUsernameStatus = NextApiRequest & {
usernameStatus: {
/**
* ```text
* 200: Username is available
* 402: Pro username, must be purchased
* 418: A user exists with that username
* ```
*/
statusCode: 200 | 402 | 418;
requestedUserName: string;
json: {
available: boolean;
premium: boolean;
message?: string;
suggestion?: string;
};
};
};
type CustomNextApiHandler<T = unknown> = (
req: RequestWithUsernameStatus,
res: NextApiResponse<T>
) => void | Promise<void>;
export async function isBlacklisted(username: string) {
// NodeJS forEach is very, very fast (these days) so even though we only have to construct the Set
// once every few iterations, it doesn't add much overhead.
if (!cachedData.size && process.env.USERNAME_BLACKLIST_URL) {
await fetch(process.env.USERNAME_BLACKLIST_URL).then(async (resp) =>
(await resp.text()).split("\n").forEach(cachedData.add, cachedData)
);
}
return cachedData.has(username);
}
export const isPremiumUserName = IS_PREMIUM_USERNAME_ENABLED
? async (username: string) => {
return username.length <= 4 || isBlacklisted(username);
}
: // outside of cal.com the concept of premium username needs not exist.
() => Promise.resolve(false);
export const generateUsernameSuggestion = async (users: string[], username: string) => {
const limit = username.length < 2 ? 9999 : 999;
let rand = 1;
while (users.includes(username + String(rand).padStart(4 - rand.toString().length, "0"))) {
rand = Math.ceil(1 + Math.random() * (limit - 1));
}
return username + String(rand).padStart(4 - rand.toString().length, "0");
};
const processResult = (
result: "ok" | "username_exists" | "is_premium"
): // explicitly assign return value to ensure statusCode is typehinted
{ statusCode: RequestWithUsernameStatus["usernameStatus"]["statusCode"]; message: string } => {
// using a switch statement instead of multiple ifs to make sure typescript knows
// there is only limited options
switch (result) {
case "ok":
return {
statusCode: 200,
message: "Username is available",
};
case "username_exists":
return {
statusCode: 418,
message: "A user exists with that username",
};
case "is_premium":
return { statusCode: 402, message: "This is a premium username." };
}
};
const usernameHandler =
(handler: CustomNextApiHandler) =>
async (req: RequestWithUsernameStatus, res: NextApiResponse): Promise<void> => {
const username = slugify(req.body.username);
const check = await usernameCheckForSignup({ username, email: req.body.email });
let result: Parameters<typeof processResult>[0] = "ok";
if (check.premium) result = "is_premium";
if (!check.available) result = "username_exists";
const { statusCode, message } = processResult(result);
req.usernameStatus = {
statusCode,
requestedUserName: username,
json: {
available: result !== "username_exists",
premium: result === "is_premium",
message,
suggestion: check.suggestedUsername,
},
};
return handler(req, res);
};
const usernameCheck = async (usernameRaw: string, currentOrgDomain?: string | null) => {
log.debug("usernameCheck", { usernameRaw, currentOrgDomain });
const isCheckingUsernameInGlobalNamespace = !currentOrgDomain;
const response = {
available: true,
premium: false,
suggestedUsername: "",
};
const username = slugify(usernameRaw);
const user = await prisma.user.findFirst({
where: {
username,
// Simply remove it when we drop organizationId column
organizationId: null,
},
select: {
id: true,
username: true,
},
});
if (user) {
response.available = false;
} else {
response.available = isCheckingUsernameInGlobalNamespace
? !(await isUsernameReservedDueToMigration(username))
: true;
}
if (await isPremiumUserName(username)) {
response.premium = true;
}
// get list of similar usernames in the db
const users = await prisma.user.findMany({
where: {
username: {
contains: username,
},
},
select: {
username: true,
},
});
// We only need suggestedUsername if the username is not available
if (!response.available) {
response.suggestedUsername = await generateUsernameSuggestion(
users.map((user) => user.username).filter(notEmpty),
username
);
}
return response;
};
/**
* Should be used when in global namespace(i.e. outside of an organization)
*/
export const isUsernameReservedDueToMigration = async (username: string) =>
!!(await prisma.tempOrgRedirect.findUnique({
where: {
from_type_fromOrgId: {
type: RedirectType.User,
from: username,
fromOrgId: 0,
},
},
}));
/**
* It is a bit different from usernameCheck because it also check if the user signing up is the same user that has a pending invitation to organization
* So, it uses email to uniquely identify the user and then also checks if the username requested by that user is available for taking or not.
* TODO: We should reuse `usernameCheck` and then do the additional thing in here.
*/
const usernameCheckForSignup = async ({
username: usernameRaw,
email,
}: {
username: string;
email: string;
}) => {
const response = {
available: true,
premium: false,
suggestedUsername: "",
};
const username = slugify(usernameRaw);
const user = await prisma.user.findUnique({
where: {
email,
},
select: {
id: true,
username: true,
organizationId: true,
},
});
if (user) {
// TODO: When supporting multiple profiles of a user, we would need to check if the user has a membership with the correct organization
const userIsAMemberOfAnOrg = await prisma.membership.findFirst({
where: {
userId: user.id,
team: {
isOrganization: true,
},
},
});
// When we invite an email, that doesn't match the orgAutoAcceptEmail, we create a user with organizationId=null.
// The only way to differentiate b/w 'a new email that was invited to an Org' and 'a user that was created using regular signup' is to check if the user is a member of an org.
// If username is in global namespace
if (!userIsAMemberOfAnOrg) {
const isClaimingAlreadySetUsername = user.username === username;
const isClaimingUnsetUsername = !user.username;
response.available = isClaimingUnsetUsername || isClaimingAlreadySetUsername;
// There are premium users outside an organization only
response.premium = await isPremiumUserName(username);
}
// If user isn't found, it's a direct signup and that can't be of an organization
} else {
response.premium = await isPremiumUserName(username);
response.available = !(await isUsernameReservedDueToMigration(username));
}
// get list of similar usernames in the db
const users = await prisma.user.findMany({
where: {
username: {
contains: username,
},
},
select: {
username: true,
},
});
// We only need suggestedUsername if the username is not available
if (!response.available) {
response.suggestedUsername = await generateUsernameSuggestion(
users.map((user) => user.username).filter(notEmpty),
username
);
}
return response;
};
export { usernameHandler, usernameCheck, usernameCheckForSignup };