Files
calendar/packages/lib/getConnectedApps.ts
T
Keith WilliamsGitHubDevin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
baaa04151d perf: optimize payment app imports to avoid loading entire app store (#23408)
* perf: optimize payment app imports to avoid loading entire app store

- Add PaymentServiceMap generation to app-store-cli build process
- Generate payment.services.generated.ts with lazy imports for 6 payment services
- Update handlePayment.ts, deletePayment.ts, handlePaymentRefund.ts to use PaymentServiceMap
- Update getConnectedApps.ts and tRPC payment routers to use PaymentServiceMap
- Follow same pattern as analytics optimization in PR #23372
- Reduces bundle size by avoiding import of 100+ apps when only payment functionality needed

Co-Authored-By: keith@cal.com <keithwillcode@gmail.com>

* Update build.ts

* fix: update payment service test mocking to work with PaymentServiceMap

- Remove obsolete appStoreMock line from bookingScenario.ts since handlePayment now uses PaymentServiceMap
- Update setupVitest.ts to import prismaMock from correct PrismockClient instance
- Add PaymentServiceMap mock following PR #22450 pattern for calendar services
- Ensure MockPaymentService uses consistent externalId across test files
- Fix webhook handler to return 200 status by ensuring payment records are found correctly

Co-Authored-By: keith@cal.com <keithwillcode@gmail.com>

* fix: revert prismaMock import to avoid interfering with other tests' vi.spyOn() calls

- Remove global prismaMock import from setupVitest.ts that was causing 'is not a spy' errors
- Update MockPaymentService to import prismaMock locally to maintain payment test functionality
- Fixes organization and outOfOffice tests while preserving payment service optimization

Co-Authored-By: keith@cal.com <keithwillcode@gmail.com>

* fix: remove E2E conditional check from payment services map generation

- Payment services map now always includes all payment apps regardless of E2E environment
- Ensures payment functionality is consistently available across all environments
- Addresses CI failures caused by conditional payment service loading

Co-Authored-By: keith@cal.com <keithwillcode@gmail.com>

* refactor: use direct PaymentService imports instead of .lib structure

- Update app-store-cli to import directly from lib/PaymentService.ts files
- Modify all payment handlers to access PaymentService directly
- Update test mocks to match new direct import structure
- Remove .lib property access pattern across payment system
- Maintain backward compatibility while improving import efficiency

Co-Authored-By: keith@cal.com <keithwillcode@gmail.com>

* fix: revert chargeCard booking.id parameter additions

- Remove booking.id parameter from chargeCard calls in chargeCard.handler.ts and payments.tsx
- Addresses GitHub feedback to investigate chargeCard signature changes in separate PR
- Keeps all other direct PaymentService import refactor changes intact

Co-Authored-By: keith@cal.com <keithwillcode@gmail.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-08-28 10:05:36 -03:00

272 lines
8.5 KiB
TypeScript

import type { Prisma } from "@prisma/client";
import type { TDependencyData } from "@calcom/app-store/_appRegistry";
import { PaymentServiceMap } from "@calcom/app-store/payment.services.generated";
import type { CredentialOwner } from "@calcom/app-store/types";
import { getAppFromSlug } from "@calcom/app-store/utils";
import { checkAdminOrOwner } from "@calcom/features/auth/lib/checkAdminOrOwner";
import getEnabledAppsFromCredentials from "@calcom/lib/apps/getEnabledAppsFromCredentials";
import getInstallCountPerApp from "@calcom/lib/apps/getInstallCountPerApp";
import { getUsersCredentialsIncludeServiceAccountKey } from "@calcom/lib/server/getUsersCredentials";
import type { PrismaClient } from "@calcom/prisma";
import type { User } from "@calcom/prisma/client";
import type { AppCategories } from "@calcom/prisma/enums";
import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential";
import { buildNonDelegationCredentials } from "./delegationCredential/clientAndServer";
export type ConnectedApps = Awaited<ReturnType<typeof getConnectedApps>>;
type InputSchema = {
variant?: string | undefined;
exclude?: Array<string> | null;
onlyInstalled?: boolean | undefined;
includeTeamInstalledApps?: boolean | null;
extendsFeature?: "EventType" | null;
teamId?: number | null;
sortByMostPopular?: boolean | null;
sortByInstalledFirst?: boolean | null;
categories?: Array<AppCategories> | null;
appId?: string | null;
};
export type TeamQuery = Prisma.TeamGetPayload<{
select: {
id: true;
credentials: {
select: typeof import("@calcom/prisma/selects/credential").credentialForCalendarServiceSelect;
};
name: true;
logoUrl: true;
members: {
select: {
role: true;
};
};
};
}>;
export async function getConnectedApps({
user,
prisma,
input,
}: {
user: Pick<User, "id" | "name" | "avatarUrl" | "email"> & { avatar?: string };
prisma: PrismaClient;
input: InputSchema;
}) {
const {
variant,
exclude,
onlyInstalled,
includeTeamInstalledApps,
extendsFeature,
teamId,
sortByMostPopular,
sortByInstalledFirst,
appId,
} = input;
let credentials = await getUsersCredentialsIncludeServiceAccountKey(user);
let userTeams: TeamQuery[] = [];
if (includeTeamInstalledApps || teamId) {
const teamsQuery = await prisma.team.findMany({
where: {
...(teamId ? { id: teamId } : {}),
members: {
some: {
userId: user.id,
accepted: true,
},
},
},
select: {
id: true,
credentials: {
select: credentialForCalendarServiceSelect,
},
name: true,
logoUrl: true,
members: {
where: {
userId: user.id,
},
select: {
role: true,
},
},
parent: {
select: {
id: true,
credentials: {
select: credentialForCalendarServiceSelect,
},
name: true,
logoUrl: true,
members: {
where: {
userId: user.id,
},
select: {
role: true,
},
},
},
},
},
});
// If a team is a part of an org then include those apps
// Don't want to iterate over these parent teams
const filteredTeams: TeamQuery[] = [];
const parentTeams: TeamQuery[] = [];
// Only loop and grab parent teams if a teamId was given. If not then all teams will be queried
if (teamId) {
teamsQuery.forEach((team) => {
if (team?.parent) {
const { parent, ...filteredTeam } = team;
filteredTeams.push(filteredTeam);
// Only add parent team if it's not already in teamsQuery
if (!teamsQuery.some((t) => t.id === parent.id)) {
parentTeams.push(parent);
}
}
});
}
userTeams = [...teamsQuery, ...parentTeams];
const teamAppCredentials = userTeams.flatMap((teamApp) => {
return teamApp.credentials ? buildNonDelegationCredentials(teamApp.credentials.flat()) : [];
});
if (!includeTeamInstalledApps || teamId) {
credentials = teamAppCredentials;
} else {
credentials = credentials.concat(teamAppCredentials);
}
}
const enabledApps = await getEnabledAppsFromCredentials(credentials, {
filterOnCredentials: onlyInstalled,
...(appId ? { where: { slug: appId } } : {}),
});
//TODO: Refactor this to pick up only needed fields and prevent more leaking
let apps = await Promise.all(
enabledApps.map(async ({ credentials: _, credential, key: _2 /* don't leak to frontend */, ...app }) => {
const userCredentialIds = credentials.filter((c) => c.appId === app.slug && !c.teamId).map((c) => c.id);
const invalidCredentialIds = credentials
.filter((c) => c.appId === app.slug && c.invalid)
.map((c) => c.id);
const teams = await Promise.all(
credentials
.filter((c) => c.appId === app.slug && c.teamId)
.map(async (c) => {
const team = userTeams.find((team) => team.id === c.teamId);
if (!team) {
return null;
}
return {
teamId: team.id,
name: team.name,
logoUrl: team.logoUrl,
credentialId: c.id,
isAdmin: checkAdminOrOwner(team.members[0].role),
};
})
);
// type infer as CredentialOwner
const credentialOwner: CredentialOwner = {
name: user.name,
avatar: user?.avatar ?? user?.avatarUrl,
};
// We need to know if app is payment type
// undefined it means that app don't require app/setup/page
let isSetupAlready = undefined;
if (credential && app.categories.includes("payment")) {
const paymentAppImportFn = PaymentServiceMap[app.dirName as keyof typeof PaymentServiceMap];
if (paymentAppImportFn) {
const paymentApp = await paymentAppImportFn;
if (paymentApp && "PaymentService" in paymentApp && paymentApp?.PaymentService) {
const PaymentService = paymentApp.PaymentService;
const paymentInstance = new PaymentService(credential);
isSetupAlready = paymentInstance.isSetupAlready();
}
}
}
let dependencyData: TDependencyData = [];
if (app.dependencies?.length) {
dependencyData = app.dependencies.map((dependency) => {
const dependencyInstalled = enabledApps.some(
(dbAppIterator) => dbAppIterator.credentials.length && dbAppIterator.slug === dependency
);
// If the app marked as dependency is simply deleted from the codebase, we can have the situation where App is marked installed in DB but we couldn't get the app.
const dependencyName = getAppFromSlug(dependency)?.name;
return { name: dependencyName, installed: dependencyInstalled };
});
}
return {
...app,
...(teams.length && {
credentialOwner,
}),
userCredentialIds,
invalidCredentialIds,
teams,
isInstalled: !!userCredentialIds.length || !!teams.length || app.isGlobal,
isSetupAlready,
...(app.dependencies && { dependencyData }),
};
})
);
if (variant) {
// `flatMap()` these work like `.filter()` but infers the types correctly
apps = apps
// variant check
.flatMap((item) => (item.variant.startsWith(variant) ? [item] : []));
}
if (exclude) {
// exclusion filter
apps = apps.filter((item) => (exclude ? !exclude.includes(item.variant) : true));
}
if (onlyInstalled) {
apps = apps.flatMap((item) =>
item.userCredentialIds.length > 0 || item.teams.length || item.isGlobal ? [item] : []
);
}
if (extendsFeature) {
apps = apps
.filter((app) => app.extendsFeature?.includes(extendsFeature))
.map((app) => ({
...app,
isInstalled: !!app.userCredentialIds?.length || !!app.teams?.length || app.isGlobal,
}));
}
if (sortByMostPopular) {
const installCountPerApp = await getInstallCountPerApp();
// sort the apps array by the most popular apps
apps.sort((a, b) => {
const aCount = installCountPerApp[a.slug] || 0;
const bCount = installCountPerApp[b.slug] || 0;
return bCount - aCount;
});
}
if (sortByInstalledFirst) {
apps.sort((a, b) => {
return (a.isInstalled ? 0 : 1) - (b.isInstalled ? 0 : 1);
});
}
return {
items: apps,
};
}