Files
calendar/packages/features/calendar-subscription/lib/CalendarSubscriptionService.ts
T
Volnei MunhozGitHubVolnei MunhozDevin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
16cb9a3126 refactor: join teams with feature enabled directly in findNextSubscriptionBatch query (#27364)
* refactor: join teams with feature enabled directly in findNextSubscriptionBatch query

- Changed findNextSubscriptionBatch to accept featureId instead of teamIds
- Join TeamFeatures table directly in the query instead of separate queries
- Updated CalendarSubscriptionService.checkForNewSubscriptions to pass featureId
- Removed separate getTeamsWithFeatureEnabled call
- Updated tests to reflect the new query structure

Co-Authored-By: Volnei Munhoz <volnei.munhoz@gmail.com>

* refactor: change featureId to featureIds array for flexibility

- Changed findNextSubscriptionBatch to accept featureIds array instead of single featureId
- Updated Prisma query to use 'in' operator for multiple feature IDs
- Updated CalendarSubscriptionService to pass array with single feature
- Updated all tests to use featureIds array

Co-Authored-By: Volnei Munhoz <volnei.munhoz@gmail.com>

---------

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

509 lines
16 KiB
TypeScript

import { getCredentialForSelectedCalendar } from "@calcom/app-store/delegationCredential";
import type {
AdapterFactory,
CalendarSubscriptionProvider,
} from "@calcom/features/calendar-subscription/adapters/AdaptersFactory";
import type {
CalendarCredential,
CalendarSubscriptionEvent,
} from "@calcom/features/calendar-subscription/lib/CalendarSubscriptionPort.interface";
import type { CalendarCacheEventService } from "@calcom/features/calendar-subscription/lib/cache/CalendarCacheEventService";
import type { CalendarSyncService } from "@calcom/features/calendar-subscription/lib/sync/CalendarSyncService";
import type { FeaturesRepository } from "@calcom/features/flags/features.repository";
import type { ISelectedCalendarRepository } from "@calcom/features/selectedCalendar/repositories/SelectedCalendarRepository.interface";
import logger from "@calcom/lib/logger";
import { metrics } from "@sentry/nextjs";
import type { SelectedCalendar } from "@calcom/prisma/client";
// biome-ignore lint/nursery/useExplicitType: logger type is inferred
const log = logger.getSubLogger({ prefix: ["CalendarSubscriptionService"] });
export class CalendarSubscriptionService {
static CALENDAR_SUBSCRIPTION_CACHE_FEATURE = "calendar-subscription-cache" as const;
static CALENDAR_SUBSCRIPTION_SYNC_FEATURE = "calendar-subscription-sync" as const;
static MAX_SUBSCRIBE_ERRORS = 3;
constructor(
private deps: {
adapterFactory: AdapterFactory;
selectedCalendarRepository: ISelectedCalendarRepository;
featuresRepository: FeaturesRepository;
calendarCacheEventService: CalendarCacheEventService;
calendarSyncService: CalendarSyncService;
}
) {}
/**
* Subscribe to a calendar
*/
async subscribe(selectedCalendarId: string): Promise<void> {
log.debug("subscribe", { selectedCalendarId });
const selectedCalendar = await this.deps.selectedCalendarRepository.findById(selectedCalendarId);
if (!selectedCalendar) {
log.debug("Selected calendar not found", { selectedCalendarId });
return;
}
if (!selectedCalendar.credentialId && !selectedCalendar.delegationCredentialId) {
log.debug("Selected calendar doesn't have credentials", { selectedCalendarId });
return;
}
const credential = await this.getCredential(selectedCalendar);
if (!credential) {
log.debug("Calendar credential not found", { selectedCalendarId });
return;
}
const subscribeErrorCount = selectedCalendar.syncSubscribedErrorCount ?? 0;
if (subscribeErrorCount >= CalendarSubscriptionService.MAX_SUBSCRIBE_ERRORS) {
log.debug("Selected calendar exceeded subscribe error threshold", { selectedCalendarId });
return;
}
const calendarSubscriptionAdapter = this.deps.adapterFactory.get(
selectedCalendar.integration as CalendarSubscriptionProvider
);
try {
const res = await calendarSubscriptionAdapter.subscribe(selectedCalendar, credential);
await this.deps.selectedCalendarRepository.updateSubscription(selectedCalendarId, {
channelId: res?.id,
channelResourceId: res?.resourceId,
channelResourceUri: res?.resourceUri,
channelKind: res?.provider,
channelExpiration: res?.expiration,
syncSubscribedAt: new Date(),
syncSubscribedErrorAt: null,
syncSubscribedErrorCount: 0,
});
} catch (error: unknown) {
const nextErrorCount = Math.min(
CalendarSubscriptionService.MAX_SUBSCRIBE_ERRORS,
(selectedCalendar.syncSubscribedErrorCount ?? 0) + 1
);
await this.deps.selectedCalendarRepository.updateSubscription(selectedCalendarId, {
// don't need to cleanup other fields as they will only be used if syncSubscribedAt is not null
syncSubscribedAt: null,
syncSubscribedErrorAt: new Date(),
syncSubscribedErrorCount: nextErrorCount,
});
throw error;
}
await this.processEvents(selectedCalendar);
}
/**
* Unsubscribe from a calendar
*/
async unsubscribe(selectedCalendarId: string): Promise<void> {
log.debug("unsubscribe", { selectedCalendarId });
const selectedCalendar = await this.deps.selectedCalendarRepository.findById(selectedCalendarId);
if (!selectedCalendar) {
log.debug("Selected calendar not found", { selectedCalendarId });
return;
}
if (!selectedCalendar.credentialId && !selectedCalendar.delegationCredentialId) {
log.debug("Selected calendar doesn't have credentials", { selectedCalendarId });
return;
}
const credential = await this.getCredential(selectedCalendar);
if (!credential) return;
const calendarSubscriptionAdapter = this.deps.adapterFactory.get(
selectedCalendar.integration as CalendarSubscriptionProvider
);
await Promise.all([
calendarSubscriptionAdapter.unsubscribe(selectedCalendar, credential),
this.deps.selectedCalendarRepository.updateSubscription(selectedCalendarId, {
syncSubscribedAt: null,
}),
]);
// cleanup cache after unsubscribe
if (await this.isCacheEnabled()) {
log.debug("cleanupCache", { selectedCalendarId });
await this.deps.calendarCacheEventService.cleanupCache(selectedCalendar);
}
}
/**
* Process webhook
*/
// biome-ignore lint/complexity/noExcessiveLinesPerFunction: webhook processing requires multiple steps
async processWebhook(
provider: CalendarSubscriptionProvider,
request: Request
) {
const startTime = performance.now();
log.debug("processWebhook", { provider });
try {
const calendarSubscriptionAdapter =
this.deps.adapterFactory.get(provider);
const isValid = await calendarSubscriptionAdapter.validate(request);
if (!isValid) {
metrics.count("calendar.subscription.webhook.invalid", 1, {
attributes: { provider },
});
throw new Error("Invalid webhook request");
}
const channelId = await calendarSubscriptionAdapter.extractChannelId(
request
);
if (!channelId) {
metrics.count("calendar.subscription.webhook.missing_channel", 1, {
attributes: { provider },
});
throw new Error("Missing channel ID in webhook");
}
log.debug("Processing webhook", { channelId });
const selectedCalendar =
await this.deps.selectedCalendarRepository.findByChannelId(channelId);
if (!selectedCalendar) {
metrics.count("calendar.subscription.webhook.skipped", 1, {
attributes: { provider, reason: "calendar_not_found" },
});
log.info("Webhook processed - calendar not found", {
provider,
channelId,
durationMs: performance.now() - startTime,
});
return null;
}
const eventsProcessed = await this.processEvents(selectedCalendar);
const durationMs = performance.now() - startTime;
metrics.count("calendar.subscription.webhook.success", 1, {
attributes: { provider },
});
metrics.distribution(
"calendar.subscription.webhook.duration_ms",
durationMs,
{
attributes: { provider },
}
);
metrics.distribution(
"calendar.subscription.webhook.events_fetched",
eventsProcessed.eventsFetched,
{ attributes: { provider } }
);
} catch (error) {
const durationMs = performance.now() - startTime;
metrics.count("calendar.subscription.webhook.error", 1, {
attributes: { provider },
});
metrics.distribution(
"calendar.subscription.webhook.duration_ms",
durationMs,
{
attributes: { provider, outcome: "error" },
}
);
log.error("Webhook processing failed", {
provider,
durationMs,
error: error instanceof Error ? error.message : "Unknown error",
});
throw error;
}
}
/**
* Process events
* - fetch events from calendar
* - process events
* - update selected calendar
* - update cache
* - update sync
* @returns Object with counts of events fetched, cached, and synced, plus propagation lag metrics
*/
// biome-ignore lint/complexity/noExcessiveLinesPerFunction: event processing requires multiple steps
async processEvents(
selectedCalendar: SelectedCalendar
): Promise<{
eventsFetched: number;
eventsCached: number;
eventsSynced: number;
propagationLagMs?: { avg: number; max: number; min: number; count: number };
}> {
const startTime = performance.now();
const result: {
eventsFetched: number;
eventsCached: number;
eventsSynced: number;
propagationLagMs?: { avg: number; max: number; min: number; count: number };
} = {
eventsFetched: 0,
eventsCached: 0,
eventsSynced: 0,
};
const calendarSubscriptionAdapter = this.deps.adapterFactory.get(
selectedCalendar.integration as CalendarSubscriptionProvider
);
if (
!selectedCalendar.credentialId &&
!selectedCalendar.delegationCredentialId
) {
log.debug("Selected Calendar doesn't have credentials", {
selectedCalendarId: selectedCalendar.id,
});
return result;
}
const [cacheEnabled, syncEnabled, cacheEnabledForUser] = await Promise.all([
this.isCacheEnabled(),
this.isSyncEnabled(),
this.isCacheEnabledForUser(selectedCalendar.userId),
]);
if (!cacheEnabled && !syncEnabled) {
log.info("Cache and sync are globally disabled", {
channelId: selectedCalendar.channelId,
});
return result;
}
log.debug("Processing events", { channelId: selectedCalendar.channelId });
const credential = await this.getCredential(selectedCalendar);
if (!credential) {
return result;
}
let events: CalendarSubscriptionEvent | null = null;
try {
events = await calendarSubscriptionAdapter.fetchEvents(
selectedCalendar,
credential
);
} catch (err) {
metrics.count("calendar.subscription.events.fetch.error", 1, {
attributes: { provider: selectedCalendar.integration },
});
await this.deps.selectedCalendarRepository.updateSyncStatus(selectedCalendar.id, {
syncErrorAt: new Date(),
syncErrorCount: { increment: 1 },
});
throw err;
}
if (!events?.items?.length) {
log.debug("No events fetched", { channelId: selectedCalendar.channelId });
return result;
}
result.eventsFetched = events.items.length;
metrics.distribution(
"calendar.subscription.events.fetched",
events.items.length,
{
attributes: {
provider: selectedCalendar.integration,
incremental: !!selectedCalendar.syncToken,
},
}
);
const now = Date.now();
const lagStats = this.calculatePropagationLag(events.items, now);
if (lagStats) {
result.propagationLagMs = lagStats;
metrics.distribution(
"calendar.subscription.propagation_lag.avg_ms",
lagStats.avg,
{
attributes: { provider: selectedCalendar.integration },
}
);
metrics.distribution(
"calendar.subscription.propagation_lag.max_ms",
lagStats.max,
{
attributes: { provider: selectedCalendar.integration },
}
);
}
await this.deps.selectedCalendarRepository.updateSyncStatus(selectedCalendar.id, {
syncToken: events.syncToken || selectedCalendar.syncToken,
syncedAt: new Date(),
syncErrorAt: null,
syncErrorCount: 0,
}
);
if (cacheEnabled && cacheEnabledForUser) {
log.debug("Caching events", { count: events.items.length });
await this.deps.calendarCacheEventService.handleEvents(selectedCalendar, events.items );
result.eventsCached = events.items.length;
metrics.distribution(
"calendar.subscription.events.cached",
events.items.length,
{
attributes: { provider: selectedCalendar.integration },
}
);
}
if (syncEnabled) {
log.debug("Syncing events", { count: events.items.length });
await this.deps.calendarSyncService.handleEvents(
selectedCalendar,
events.items
);
result.eventsSynced = events.items.length;
metrics.distribution(
"calendar.subscription.events.synced",
events.items.length,
{
attributes: { provider: selectedCalendar.integration },
}
);
}
metrics.distribution(
"calendar.subscription.processEvents.duration_ms",
performance.now() - startTime,
{
attributes: {
provider: selectedCalendar.integration,
cache: cacheEnabled && cacheEnabledForUser ? "on" : "off",
sync: syncEnabled ? "on" : "off",
},
}
);
return result;
}
/**
* Calculate propagation lag statistics from event timestamps
* Uses updatedAt or createdAt to determine when the event was last modified in the provider
*/
private calculatePropagationLag(
items: CalendarSubscriptionEvent["items"],
now: number
): { avg: number; max: number; min: number; count: number } | null {
const lags: number[] = [];
for (const item of items) {
// Use updatedAt if available, otherwise fall back to createdAt
const eventTimestamp = item.updatedAt ?? item.createdAt;
if (eventTimestamp) {
const lagMs = now - eventTimestamp.getTime();
// Only count positive lags (ignore clock skew / future timestamps)
if (lagMs >= 0) {
lags.push(lagMs);
}
}
}
if (lags.length === 0) {
return null;
}
const sum = lags.reduce((a, b) => a + b, 0);
return {
avg: Math.round(sum / lags.length),
max: Math.max(...lags),
min: Math.min(...lags),
count: lags.length,
};
}
/**
* Subscribe periodically to new calendars
*/
// biome-ignore lint/nursery/useExplicitType: return type is void
async checkForNewSubscriptions() {
const rows = await this.deps.selectedCalendarRepository.findNextSubscriptionBatch({
take: 100,
integrations: this.deps.adapterFactory.getProviders(),
featureIds: [CalendarSubscriptionService.CALENDAR_SUBSCRIPTION_CACHE_FEATURE],
genericCalendarSuffixes: this.deps.adapterFactory.getGenericCalendarSuffixes(),
});
log.debug("checkForNewSubscriptions", { count: rows.length });
await Promise.allSettled(rows.map(({ id }) => this.subscribe(id)));
}
/**
* Check if cache is enabled
* @returns true if cache is enabled
*/
async isCacheEnabled(): Promise<boolean> {
return this.deps.featuresRepository.checkIfFeatureIsEnabledGlobally(
CalendarSubscriptionService.CALENDAR_SUBSCRIPTION_CACHE_FEATURE
);
}
/**
* Check if cache is enabled for user
* @returns true if cache is enabled
*/
async isCacheEnabledForUser(userId: number): Promise<boolean> {
return this.deps.featuresRepository.checkIfUserHasFeature(
userId,
CalendarSubscriptionService.CALENDAR_SUBSCRIPTION_CACHE_FEATURE
);
}
/**
* Check if sync is enabled
* @returns true if sync is enabled
*/
async isSyncEnabled(): Promise<boolean> {
return this.deps.featuresRepository.checkIfFeatureIsEnabledGlobally(
CalendarSubscriptionService.CALENDAR_SUBSCRIPTION_SYNC_FEATURE
);
}
/**
* Get credential with delegation if available
*/
private async getCredential(selectedCalendar: SelectedCalendar): Promise<CalendarCredential | null> {
if (!selectedCalendar.credentialId && !selectedCalendar.delegationCredentialId) {
log.debug("Selected calendar doesn't have any credential");
return null;
}
const credential = await getCredentialForSelectedCalendar(selectedCalendar);
if (!credential) return null;
return {
...credential,
// biome-ignore lint/nursery/noTernary: conditional delegation credential mapping
delegatedTo: credential.delegatedTo?.serviceAccountKey?.client_email
? {
serviceAccountKey: {
client_email: credential.delegatedTo.serviceAccountKey.client_email,
client_id: credential.delegatedTo.serviceAccountKey.client_id,
private_key: credential.delegatedTo.serviceAccountKey.private_key,
},
}
: null,
};
}
}