Files
calendar/apps/web/lib/apps/installation/[[...step]]/getServerSideProps.ts
T
devin-ai-integration[bot]GitHubmorgan@cal.com <morgan@cal.com>Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>morgan@cal.com <morgan@cal.com>Morgan
e4c47640fc refactor: convert UserRepository to use dependency injection pattern (#22360)
* refactor: convert UserRepository to use dependency injection pattern

- Convert all static methods to public instance methods
- Add constructor that takes PrismaClient parameter
- Update all usage sites to use new instantiation pattern: new UserRepository(prisma).method()
- Follow same pattern as PrismaOOORepository for consistency
- Maintain all existing method logic and signatures unchanged
- Update 125+ files across the codebase to adapt to new pattern

Co-Authored-By: morgan@cal.com <morgan@cal.com>

* optimize: reuse UserRepository instances within same function scope

- Create single UserRepository instance per function scope
- Reuse instance for multiple method calls within same function
- Reduces object instantiation overhead and improves performance
- Apply optimization pattern consistently across codebase

Co-Authored-By: morgan@cal.com <morgan@cal.com>

* fix: repository

* fixup! fix: repository

* fixup! fixup! fix: repository

* fixup! fixup! fixup! fix: repository

* fix: update test mocking strategies for UserRepository dependency injection

- Convert static method mocks to instance method mocks in userCreationService.test.ts
- Update vi.spyOn calls to work with constructor injection pattern in getAllCredentials.test.ts
- Fix UserRepository mocking in getRoutedUrl.test.ts to use constructor injection
- Ensure consistent mocking approach across all test files
- Fix 'UserRepository is not a constructor' errors in tests

Co-Authored-By: morgan@cal.com <morgan@cal.com>

* feat: optimize UserRepository instance reuse and add SessionUser type

- Reuse UserRepository instance in OrganizationRepository.createWithNonExistentOwner
- Add comprehensive SessionUser type definition for type safety
- Improve type constraints in enrichUserWithTheProfile and enrichUserWithItsProfile
- Ensure proper return types with profile information

Co-Authored-By: morgan@cal.com <morgan@cal.com>

* fix: make UserRepository mocking strategy more robust for CI environments

- Add defensive checks for vi.mocked() to handle CI environment differences
- Ensure mockImplementation is available before calling it
- Maintain consistent mocking pattern across all test files
- Fix 'Cannot read properties of undefined' error in CI

Co-Authored-By: morgan@cal.com <morgan@cal.com>

* fixup! fix: make UserRepository mocking strategy more robust for CI environments

* refactor: convert direct UserRepository instantiations to two-step pattern

- Change await new UserRepository(prisma).method(...) to const userRepo = new UserRepository(prisma); await userRepo.method(...)
- Optimize instance reuse within same function scopes
- Apply pattern consistently across all modified files in PR
- Fix type errors in organization.ts and sessionMiddleware.ts

Co-Authored-By: morgan@cal.com <morgan@cal.com>

* refactor: complete two-step UserRepository pattern for remaining files

- Apply two-step instantiation pattern to all remaining modified files in PR
- Ensure consistent UserRepository usage across entire codebase
- Maintain instance reuse optimization within function scopes

Co-Authored-By: morgan@cal.com <morgan@cal.com>

* chore: bump platform libs

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: morgan@cal.com <morgan@cal.com>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2025-07-10 12:11:14 +00:00

324 lines
9.8 KiB
TypeScript

import type { GetServerSidePropsContext } from "next";
import { z } from "zod";
import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData";
import { isConferencing as isConferencingApp } from "@calcom/app-store/utils";
import { getLocale } from "@calcom/features/auth/lib/getLocale";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { AppOnboardingSteps } from "@calcom/lib/apps/appOnboardingSteps";
import { CAL_URL } from "@calcom/lib/constants";
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
import type { LocationObject } from "@calcom/lib/location";
import { UserRepository } from "@calcom/lib/server/repository/user";
import prisma from "@calcom/prisma";
import type { Prisma } from "@calcom/prisma/client";
import { eventTypeBookingFields } from "@calcom/prisma/zod-utils";
import { STEPS } from "~/apps/installation/[[...step]]/constants";
import type { OnboardingPageProps, TEventTypeGroup } from "~/apps/installation/[[...step]]/step-view";
const getUser = async (userId: number) => {
const userRepo = new UserRepository(prisma);
const userAdminTeams = await userRepo.getUserAdminTeams({ userId });
if (!userAdminTeams?.id) {
return null;
}
const teams = userAdminTeams.teams.map(({ team }) => ({
...team,
logoUrl: team.parent
? getPlaceholderAvatar(team.parent.logoUrl, team.parent.name)
: getPlaceholderAvatar(team.logoUrl, team.name),
}));
return {
...userAdminTeams,
teams,
};
};
const getOrgSubTeams = async (parentId: number) => {
const teams = await prisma.team.findMany({
where: {
parentId,
},
select: {
id: true,
name: true,
logoUrl: true,
isOrganization: true,
parent: {
select: {
logoUrl: true,
name: true,
id: true,
},
},
},
});
return teams.map((team) => ({
...team,
logoUrl: team.parent
? getPlaceholderAvatar(team.parent.logoUrl, team.parent.name)
: getPlaceholderAvatar(team.logoUrl, team.name),
}));
};
const getAppBySlug = async (appSlug: string) => {
const app = await prisma.app.findUnique({
where: { slug: appSlug, enabled: true },
select: { slug: true, keys: true, enabled: true, dirName: true },
});
return app;
};
const getEventTypes = async (userId: number, teamIds?: number[]) => {
const eventTypeSelect = {
id: true,
description: true,
durationLimits: true,
metadata: true,
length: true,
title: true,
position: true,
recurringEvent: true,
requiresConfirmation: true,
canSendCalVideoTranscriptionEmails: true,
team: { select: { slug: true } },
schedulingType: true,
teamId: true,
users: { select: { username: true } },
seatsPerTimeSlot: true,
slug: true,
locations: true,
userId: true,
destinationCalendar: true,
bookingFields: true,
calVideoSettings: true,
} satisfies Prisma.EventTypeSelect;
let eventTypeGroups: TEventTypeGroup[] | null = [];
if (teamIds && teamIds.length > 0) {
const teams = await prisma.team.findMany({
where: {
id: {
in: teamIds,
},
isOrganization: false,
},
select: {
id: true,
name: true,
logoUrl: true,
slug: true,
isOrganization: true,
eventTypes: {
select: eventTypeSelect,
},
},
});
eventTypeGroups = teams.map((team) => ({
teamId: team.id,
slug: team.slug,
name: team.name,
isOrganisation: team.isOrganization,
image: getPlaceholderAvatar(team.logoUrl, team.name),
eventTypes: team.eventTypes
.map((item) => ({
...item,
URL: `${CAL_URL}/${item.team ? `team/${item.team.slug}` : item?.users?.[0]?.username}/${item.slug}`,
selected: false,
locations: item.locations as unknown as LocationObject[],
bookingFields: eventTypeBookingFields.parse(item.bookingFields || []),
}))
.sort((eventTypeA, eventTypeB) => eventTypeB.position - eventTypeA.position),
}));
} else {
const user = await prisma.user.findUnique({
where: {
id: userId,
},
select: {
id: true,
username: true,
name: true,
avatarUrl: true,
eventTypes: {
where: {
teamId: null,
},
select: eventTypeSelect,
},
},
});
if (user) {
eventTypeGroups.push({
userId: user.id,
slug: user.username,
name: user.name,
image: getPlaceholderAvatar(user.avatarUrl, user.name),
eventTypes: user.eventTypes
.map((item) => ({
...item,
URL: `${CAL_URL}/${item.team ? `team/${item.team.slug}` : item?.users?.[0]?.username}/${
item.slug
}`,
selected: false,
locations: item.locations as unknown as LocationObject[],
bookingFields: eventTypeBookingFields.parse(item.bookingFields || []),
}))
.sort((eventTypeA, eventTypeB) => eventTypeB.position - eventTypeA.position),
});
}
}
return eventTypeGroups;
};
const getAppInstallsBySlug = async (appSlug: string, userId: number, teamIds?: number[]) => {
const appInstalls = await prisma.credential.findMany({
where: {
OR: [
{
appId: appSlug,
userId: userId,
},
teamIds && Boolean(teamIds.length)
? {
appId: appSlug,
teamId: { in: teamIds },
}
: {},
],
},
});
return appInstalls;
};
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
const { req, query, params } = context;
let eventTypeGroups: TEventTypeGroup[] | null = null;
let isOrg = false;
const stepsEnum = z.enum(STEPS);
const parsedAppSlug = z.coerce.string().parse(query?.slug);
const parsedStepParam = z.coerce.string().parse(params?.step);
const parsedTeamIdParam = z.coerce.number().optional().parse(query?.teamId);
const _ = stepsEnum.parse(parsedStepParam);
const session = await getServerSession({ req });
if (!session?.user?.id) return { redirect: { permanent: false, destination: "/auth/login" } };
const locale = await getLocale(context.req);
const app = await getAppBySlug(parsedAppSlug);
if (!app) return { redirect: { permanent: false, destination: "/apps" } };
const appMetadata = appStoreMetadata[app.dirName as keyof typeof appStoreMetadata];
const extendsEventType = appMetadata?.extendsFeature === "EventType";
const isConferencing = isConferencingApp(appMetadata.categories);
const showEventTypesStep = extendsEventType || isConferencing;
const user = await getUser(session.user.id);
if (!user) return { redirect: { permanent: false, destination: "/apps" } };
let userTeams = user.teams;
const hasTeams = Boolean(userTeams.length);
if (parsedTeamIdParam) {
const currentTeam = userTeams.find((team) => team.id === parsedTeamIdParam);
if (!currentTeam?.id) {
return { redirect: { permanent: false, destination: "/apps" } };
}
if (currentTeam.isOrganization) {
const subTeams = await getOrgSubTeams(parsedTeamIdParam);
userTeams = [...userTeams, ...subTeams];
isOrg = true;
}
}
if (parsedStepParam == AppOnboardingSteps.EVENT_TYPES_STEP) {
if (!showEventTypesStep) {
return {
redirect: {
permanent: false,
destination: `/apps/installed/${appMetadata.categories[0]}?hl=${appMetadata.slug}`,
},
};
}
if (isOrg) {
const teamIds = userTeams.map((item) => item.id);
eventTypeGroups = await getEventTypes(user.id, teamIds);
} else if (parsedTeamIdParam) {
eventTypeGroups = await getEventTypes(user.id, [parsedTeamIdParam]);
} else {
eventTypeGroups = await getEventTypes(user.id);
}
if (isConferencing && eventTypeGroups) {
const destinationCalendar = await prisma.destinationCalendar.findFirst({
where: {
userId: user.id,
eventTypeId: null,
},
});
eventTypeGroups.forEach((group) => {
group.eventTypes = group.eventTypes.map((eventType) => {
if (!eventType.destinationCalendar) {
return { ...eventType, destinationCalendar };
}
return eventType;
});
});
}
}
const appInstalls = await getAppInstallsBySlug(
parsedAppSlug,
user.id,
userTeams.map(({ id }) => id)
);
const personalAccount = {
id: user.id,
name: user.name,
avatarUrl: user.avatarUrl,
alreadyInstalled: appInstalls.some((install) => !Boolean(install.teamId) && install.userId === user.id),
};
const teamsWithIsAppInstalled = hasTeams
? userTeams.map((team) => ({
...team,
alreadyInstalled: appInstalls.some(
(install) => Boolean(install.teamId) && install.teamId === team.id
),
}))
: [];
let credentialId = null;
if (parsedTeamIdParam) {
credentialId = appInstalls.find((item) => !!item.teamId && item.teamId == parsedTeamIdParam)?.id ?? null;
} else {
credentialId = appInstalls.find((item) => !!item.userId && item.userId == user.id)?.id ?? null;
}
// dont allow app installation without cretendialId
if (parsedStepParam == AppOnboardingSteps.EVENT_TYPES_STEP && !credentialId) {
return { redirect: { permanent: false, destination: "/apps" } };
}
return {
props: {
app,
appMetadata,
showEventTypesStep,
step: parsedStepParam,
teams: teamsWithIsAppInstalled,
personalAccount,
eventTypeGroups,
teamId: parsedTeamIdParam ?? null,
userName: user.username,
credentialId,
isConferencing,
isOrg,
// conferencing apps dont support team install
installableOnTeams: !isConferencing,
} as OnboardingPageProps,
};
};