Files
cal.diy/apps/web/app/api/cron/selected-calendars/route.ts
T
Keith WilliamsandDevin AI 3a8fe8c7af perf: convert services to factory functions and add TypeScript project references (#26117)
* perf(app-store): convert services to factory functions to narrow SDK type exports

This change converts CRM, Calendar, Payment, and Analytics services from
exporting classes to exporting factory functions that return interface types.
This prevents SDK types (HubSpot, Stripe, etc.) from leaking into the type
system when TypeScript emits .d.ts files.

Services modified:
- CRM: hubspot, salesforce, closecom, pipedrive-crm, zoho-bigin, zohocrm
- Calendar: all 13 calendar services
- Payment: stripe, paypal, alby, btcpayserver, hitpay, mock-payment-app
- Analytics: dub

Video adapters were audited and already use factory pattern.

WIP: Salesforce CRM service has a type error that needs fixing.
Co-Authored-By: keith@cal.com <keithwillcode@gmail.com>

* fix(salesforce): add type assertion for appOptions in factory function

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

* fix: update remaining consumers to use factory functions

- getAnalytics.ts: call factory function instead of new
- getConnectedApps.ts: call factory function instead of new
- salesforce/CrmService.ts: fix type assertion with default value
- salesforce/routingForm/incompleteBookingAction.ts: use factory function
- salesforce/routingFormBookingFormHandler.ts: use factory function

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

* fix(salesforce): add SalesforceCRM interface for Salesforce-specific methods

- Create SalesforceCRM interface extending CRM with findUserEmailFromLookupField and incompleteBookingWriteToRecord methods
- Add createSalesforceCrmServiceWithSalesforceType factory function for internal Salesforce modules
- Update routing form files to use the new factory function with proper typing

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

* fix(salesforce): correct return type in SalesforceCRM interface

The findUserEmailFromLookupField method returns { email: string; recordType: RoutingReasons } | undefined,
not string | undefined. Updated the interface to match the actual implementation.

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

* fix: update all call sites to use factory functions

- Update calendar API add files to use factory functions (7 files)
- Update Google Calendar test files to use factory functions (3 files)
- Update Salesforce test files to use SalesforceCRM interface
- Add testMode parameter to createSalesforceCrmServiceWithSalesforceType
- Remove unused @ts-expect-error directive in bookingScenario.ts

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

* fix: add GoogleCalendar interface and update factory functions for service-specific methods

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

* fix: add type annotation to attendee parameter in google-calendar.e2e.ts

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

* fix: update selected-calendars/route.ts to use GoogleCalendar factory function

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

* fix: update remaining GoogleCalendarService call sites to use factory function

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

* fix: remove explicit type annotation in google-calendar.e2e.ts to fix type error

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

* fix: update test mocks to use factory function pattern for calendar and payment services

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

* fix: convert MockPaymentService to factory function in setupVitest.ts

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

* fix(api-v2): call calendar service factory functions instead of using new

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

* perf(trpc): add TypeScript project references to reduce build file count

- Add tsconfig.build.json to app-store with composite settings for declaration emit
- Add tsconfig.server.build.json to trpc with project references
- Add types and typesVersions to app-store package.json to redirect type resolution to dist-types
- Add build:types script to app-store for generating declarations
- Add dist-types to .gitignore

This reduces the tRPC build file count from 7,733 to 5,618 files (27% reduction)
by having TypeScript resolve to prebuilt .d.ts files instead of source files.

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

* fix(app-store): add fallback to typesVersions for CI compatibility

The typesVersions now prefers dist-types but falls back to source files
when dist-types don't exist. This fixes CI type-check failures while
still allowing the optimization when dist-types are built.

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

* Added build dependency for tRPC on app-store

* fix(app-store): exclude test directories from tsconfig.build.json

Exclude __tests__, __mocks__, and tests directories from the build
config to prevent test utility files (which import from outside rootDir)
from being included in the declaration emit.

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

* fix(turbo): add build:types task definition for app-store

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

* fix(turbo): remove circular build dependency between trpc and app-store

The build:types task for app-store transitively imports from @calcom/features,
which imports from @calcom/trpc. This creates a circular build dependency when
@calcom/trpc#build depends on @calcom/app-store#build:types.

Solution: Remove the turbo dependency. The typesVersions fallback pattern
['./dist-types/*', './*'] will use source files when dist-types/ doesn't exist,
and use prebuilt declarations when they do exist (for faster builds).

Also exclude dist-types from regular tsconfig.json to prevent 'Cannot write file'
errors when dist-types/ exists.

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

* refactor(app-store): rename dist-types to types for consistency

Rename the prebuilt TypeScript declarations folder from dist-types to types
to match the convention used by @calcom/trpc and other packages in the monorepo.

Updated files:
- tsconfig.build.json: declarationDir and exclude
- tsconfig.json: exclude
- package.json: typesVersions paths
- turbo.json: build:types outputs
- .gitignore: ignore path

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

* fix(api-v2): update ICS calendar test to mock factory function instead of class prototype

The IcsFeedCalendarService is now a factory function, not a class, so
jest.spyOn(IcsFeedCalendarService.prototype, 'listCalendars') no longer works.

Updated to use jest.spyOn(appStore, 'IcsFeedCalendarService').mockImplementation()
to mock the factory function return value instead.

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

* refactor(app-store): rename factory functions to Build* naming convention

- Rename Calendar factory functions from create*CalendarService to BuildCalendarService
- Rename CRM factory functions from create*CrmService to BuildCrmService
- Rename Payment factory functions from PaymentService to BuildPaymentService
- Rename Analytics factory function from createDubAnalyticsService to BuildAnalyticsService
- Update all index.ts re-exports to use new names
- Update all call sites throughout the codebase
- Update test mocks to use new factory function names

This makes it clear that these are factory functions, not class constructors.

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

* fix(app-store): update remaining CalendarService and PaymentService imports to Build* names

- Update api/add.ts files for applecalendar, caldavcalendar, exchange2013calendar, exchange2016calendar, exchangecalendar, ics-feedcalendar
- Update mock-payment-app index.ts to export BuildPaymentService
- Fix googlecalendar test import alias for createInMemoryDelegationCredentialForBuildCalendarService

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

* fix(tests): update payment service mocks to use BuildPaymentService

- Update setupVitest.ts to use BuildPaymentService instead of PaymentService
- Update handlePayment.test.ts mock to use BuildPaymentService

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

* refactor: address PR review comments - rename to Build* and remove unused code

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

* refactor: remove explanatory comments and fix indentation

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

* chore: update .gitignore to use *.tsbuildinfo pattern

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

* fix(api-v2): update IcsFeedCalendarService to BuildIcsFeedCalendarService in e2e test

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

* style: format CalendarService.test.ts

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

* Delete apps/ui-playground/next-env.d.ts

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-01-15 11:28:05 -03:00

319 lines
14 KiB
TypeScript

/**
* @description This route is used to create SelectedCalendar records based on Credential records corresponding to Delegation Credentials
*
* It works in conjunction with `/api/cron/credentials` route(which creates the Credential records for all the members of an organization that has delegation credentials enabled)
*/
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { findUniqueDelegationCalendarCredential } from "@calcom/app-store/delegationCredential";
import {
createGoogleCalendarServiceWithGoogleType,
type GoogleCalendar,
} from "@calcom/app-store/googlecalendar/lib/CalendarService";
import { CredentialRepository } from "@calcom/features/credentials/repositories/CredentialRepository";
import { CalendarAppDelegationCredentialInvalidGrantError } from "@calcom/lib/CalendarAppError";
import { HttpError } from "@calcom/lib/http-error";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { SelectedCalendarRepository } from "@calcom/lib/server/repository/selectedCalendar";
import type { CredentialForCalendarServiceWithEmail } from "@calcom/types/Credential";
import type { Ensure } from "@calcom/types/utils";
import { defaultResponderForAppDir } from "../../defaultResponderForAppDir";
const limitOnQueryingGoogleCalendar = 50;
const log = logger.getSubLogger({ prefix: ["[api]", "[delegation]", "[selected-calendars/cron]"] });
const validateRequest = (req: NextRequest) => {
const url = new URL(req.url);
const apiKey = req.headers.get("authorization") || url.searchParams.get("apiKey");
if (![process.env.CRON_API_KEY, `Bearer ${process.env.CRON_SECRET}`].includes(`${apiKey}`)) {
throw new HttpError({ statusCode: 401, message: "Unauthorized" });
}
};
export function isSameEmail({
mainEmail,
variantEmail,
emailProvider,
}: {
mainEmail: string;
variantEmail: string;
emailProvider: "google";
}) {
if (emailProvider !== "google") {
return mainEmail.toLowerCase() === variantEmail.toLowerCase();
}
const variantEmailParts = variantEmail.split("@");
const variantEmailUserName = variantEmailParts[0];
const variantEmailDomain = variantEmailParts[1];
const variantEmailUserNameWithoutPlus = variantEmailUserName.split("+")[0];
return mainEmail.toLowerCase() === `${variantEmailUserNameWithoutPlus}@${variantEmailDomain}`.toLowerCase();
}
type DelegationUserCredential = Awaited<
ReturnType<typeof CredentialRepository.findAllDelegationByTypeIncludeUserAndTake>
>[number];
type DelegationUserCredentialWithEnsuredUser = Ensure<DelegationUserCredential, "user">;
async function getDelegationUserCredentialsToProcess(delegationUserCredentials: DelegationUserCredential[]) {
const delegationUserCredentialsToProcess = await Promise.all(
delegationUserCredentials
.filter(
(delegationUserCredential): delegationUserCredential is DelegationUserCredentialWithEnsuredUser =>
delegationUserCredential.user !== null
)
.map(async (delegationUserCredential) => {
const userEmail = delegationUserCredential.user.email;
// Consider existing SelectedCalendars for this user, regardless of credentialId as we want to reuse the existing SelectedCalendar(which has their googleChannel related column set) after Delegation Credential has been enabled
// It is also important because SelectedCalendar has composite unique constraint on userId, externalId and integration and thus we couldn't have the same values for different credentialId
const selectedCalendar = await SelectedCalendarRepository.findFirst({
where: {
userId: delegationUserCredential.user.id,
externalId: userEmail,
integration: "google_calendar",
},
});
if (selectedCalendar) {
if (selectedCalendar.delegationCredentialId === delegationUserCredential.delegationCredentialId) {
// In case this SelectedCalendar has an error, we would let CalendarCache cron handle it
log.info(
`Found an existing SelectedCalendar for userId: ${delegationUserCredential.user.id} and delegationCredentialId: ${delegationUserCredential.delegationCredentialId}`
);
return null;
}
if (!selectedCalendar.error) {
log.info(`Found a reusable SelectedCalendar for userId: ${delegationUserCredential.user.id}`);
return null;
} else {
log.error(
`Found reusable Non-Delegation SelectedCalendar for ${delegationUserCredential.user.id} but it has an error: '${selectedCalendar.error}', so deleting it, new one will be created`
);
// We found a SelectedCalendar attached to non-delegation credential, so we shouldn't reuse it as the non-delegation credential attached to it could be invalid
// So, we delete it so that SelectedCalendarRepository allow creating a similar one with Delegation Credential
// Also, we don't delete the possibly invalid credential due to false alarm and we would need that intact in case we disable Delegation Credential
// Because credential isn't deleted, the corresponding CalendarCache entry would still exist and thus we will end up having two CalendarCache entries for same key and userId with different credentialId
await SelectedCalendarRepository.deleteById({ id: selectedCalendar.id });
return delegationUserCredential;
}
}
return delegationUserCredential;
})
);
return delegationUserCredentialsToProcess.filter(
(credential): credential is DelegationUserCredentialWithEnsuredUser => credential !== null
);
}
async function getCalendarService(delegationUserCredential: DelegationUserCredentialWithEnsuredUser) {
const credentialForCalendarService = await findUniqueDelegationCalendarCredential({
userId: delegationUserCredential.user.id,
delegationCredentialId: delegationUserCredential.delegationCredentialId,
});
if (!credentialForCalendarService) {
log.error(
`Credential not found for delegationCredentialId: ${delegationUserCredential.delegationCredentialId} and userId: ${delegationUserCredential.user.id}`
);
return null;
}
if (
!credentialForCalendarService.delegatedTo ||
!credentialForCalendarService.delegatedTo.serviceAccountKey ||
!credentialForCalendarService.delegatedTo.serviceAccountKey.client_email
) {
log.error(
`Invalid delegatedTo for delegationCredentialId: ${delegationUserCredential.delegationCredentialId}`,
safeStringify({
delegatedToSet: !!credentialForCalendarService.delegatedTo,
serviceAccountKeySet: !!credentialForCalendarService.delegatedTo?.serviceAccountKey,
clientEmailSet: !!credentialForCalendarService.delegatedTo?.serviceAccountKey?.client_email,
})
);
return null;
}
const googleCalendarService = createGoogleCalendarServiceWithGoogleType(
credentialForCalendarService as CredentialForCalendarServiceWithEmail
);
return googleCalendarService;
}
async function fetchPrimaryCalendarId({
googleCalendarService,
delegationUserCredential,
}: {
googleCalendarService: GoogleCalendar;
delegationUserCredential: DelegationUserCredentialWithEnsuredUser;
}) {
let primaryCalendarId;
const userEmail = delegationUserCredential.user.email;
try {
const primaryCalendar = await googleCalendarService.getPrimaryCalendar();
primaryCalendarId = primaryCalendar?.id;
} catch (error) {
log.error(
`Error fetching primary calendar for delegationCredentialId: ${delegationUserCredential.delegationCredentialId}, credentialId: ${delegationUserCredential.id} and userId: ${delegationUserCredential.userId}`,
safeStringify(error)
);
if (error instanceof CalendarAppDelegationCredentialInvalidGrantError) {
// It is known to happen in case john+test@acme.com is the user email and the actual email is john@acme.com. Google rejects the authorization for john+test@acme.com
log.info(
`Error seems to be that the email isn't present in the third party workspace, further attempts would fail too, so we assume the user's email as the primary calendar id to skip this user in future cron runs`
);
// If we are unable to fetch the primary calendar, we assume the user's email as the primary calendar id
// It ensures that this calendar is skipped in the future cron runs
primaryCalendarId = userEmail;
}
}
return primaryCalendarId;
}
/**
* Creates a SelectedCalendar record corresponding to Delegation User Credential - that would normally use primary calendar Id as the externalId but in some cases, it would use user's email as the externalId
*/
async function processDelegationUserCredential(
delegationUserCredential: DelegationUserCredentialWithEnsuredUser
) {
try {
const userEmail = delegationUserCredential.user.email;
const googleCalendarService = await getCalendarService(delegationUserCredential);
if (!googleCalendarService) {
return;
}
const primaryCalendarId = await fetchPrimaryCalendarId({
googleCalendarService,
delegationUserCredential,
});
if (!primaryCalendarId) {
log.error(
`External calendar not found for delegationCredentialId: ${delegationUserCredential.delegationCredentialId}, credentialId: ${delegationUserCredential.id} and userId: ${delegationUserCredential.userId}`
);
throw new Error("externalCalendarId could not be determined");
}
let externalCalendarId = primaryCalendarId;
if (primaryCalendarId !== userEmail) {
const areEmailsSame = isSameEmail({
emailProvider: "google",
mainEmail: primaryCalendarId,
variantEmail: userEmail,
});
if (areEmailsSame) {
log.info(
`Email mismatch for delegationCredentialId: ${delegationUserCredential.delegationCredentialId}, credentialId: ${delegationUserCredential.id} and userId: ${delegationUserCredential.userId}. Using userEmail as the externalCalendarId as it is a plus based variant of primaryCalendarId`
);
// We have found that for some plus based email addresses(like john+test@acme.com) fetching primary calendar is successful and the primaryCalendarId comes back as john@acme.com
// Using userEmail as the externalCalendarId would ensure that the SelectedCalendar isn't attempted to be re-created and the SelectedCalendar record is still logically correct
externalCalendarId = userEmail;
} else {
// Unknown reason for mismatch, so we log it.
log.warn(
`Email mismatch for delegationCredentialId: ${delegationUserCredential.delegationCredentialId}, credentialId: ${delegationUserCredential.id} and userId: ${delegationUserCredential.userId}`,
safeStringify({
externalCalendarId,
userEmail,
})
);
}
}
// This creation call isn't expected to fail as we have already checked for the existence of the SelectedCalendar by userEmail
await SelectedCalendarRepository.create({
integration: "google_calendar",
externalId: externalCalendarId,
userId: delegationUserCredential.user.id,
delegationCredentialId: delegationUserCredential.delegationCredentialId,
credentialId: delegationUserCredential.id,
});
log.debug(`Created SelectedCalendar for user ${delegationUserCredential.userId}`);
} catch (error) {
log.error(`Error processing credential ${delegationUserCredential.id}:`, safeStringify(error));
throw error;
}
}
export async function handleCreateSelectedCalendars() {
// These are in DB delegation user credentials in contrast to in-memory delegation user credentials that are used elsewhere
const allDelegationUserCredentials = await CredentialRepository.findAllDelegationByTypeIncludeUserAndTake({
type: "google_calendar",
take: limitOnQueryingGoogleCalendar,
});
log.info(`Found ${allDelegationUserCredentials.length} delegation user credentials to process`);
if (!allDelegationUserCredentials.length) {
const message = "No delegation credentials found";
log.info(message);
return {
message,
success: 0,
failures: 0,
};
}
// Groups delegationUserCredentials by delegationCredentialId
const groupedDelegationUserCredentials = allDelegationUserCredentials.reduce((acc, curr) => {
acc[curr.delegationCredentialId] = [...(acc[curr.delegationCredentialId] || []), curr];
return acc;
}, {} as Record<string, typeof allDelegationUserCredentials>);
let totalSuccess = 0;
let totalFailures = 0;
for (const [delegationCredentialId, delegationUserCredentials] of Object.entries(
groupedDelegationUserCredentials
)) {
log.info(`Processing delegation user credentials for delegationCredentialId: ${delegationCredentialId}`);
const delegationUserCredentialsToProcess = await getDelegationUserCredentialsToProcess(
delegationUserCredentials
);
log.info(
`Found ${delegationUserCredentialsToProcess.length} delegationUserCredentials to process for delegationCredentialId: ${delegationCredentialId}`
);
const results = await Promise.allSettled(
delegationUserCredentialsToProcess.map(processDelegationUserCredential)
);
const successCount = results.filter((r) => r.status === "fulfilled").length;
const failureCount = results.filter((r) => r.status === "rejected").length;
log.info(
`Processed ${successCount} selected calendars for delegationCredentialId: ${delegationCredentialId} and ${failureCount} failures`
);
totalSuccess += successCount;
totalFailures += failureCount;
}
log.info(
`Completed creating selected calendars for all delegation credentials. Total Success: ${totalSuccess}, Total Failures: ${totalFailures}`
);
return {
message: "All selected calendars created",
executedAt: new Date().toISOString(),
success: totalSuccess,
failures: totalFailures,
};
}
const handler = async (request: NextRequest) => {
validateRequest(request);
return NextResponse.json(await handleCreateSelectedCalendars());
};
export const GET = defaultResponderForAppDir(handler);