Files
calendar/packages/features/calendar-subscription/lib/CalendarSubscriptionService.ts
T
512002aaef refactor: replace FeaturesRepository with DI-based feature repositories (#27200)
* refactor: replace FeaturesRepository with DI-based feature repositories

Migrate from the legacy FeaturesRepository pattern to separate DI-based repositories:
- Add checkIfFeatureIsEnabledGlobally to IFeatureRepository
- Add getTeamsWithFeatureEnabled to ITeamFeatureRepository
- Update CalendarSubscriptionService to use featureRepository, teamFeatureRepository, and userFeatureRepository
- Update SelectedCalendarRepository.findNextSubscriptionBatch to filter by teamIds instead of featureIds
- Update OnboardingPathService to use DI via getFeatureRepository()
- Remove prisma parameter from OnboardingPathService callsites

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* test: update tests to use new DI-based feature repositories

Update CalendarSubscriptionService and SelectedCalendarRepository tests
to use the new separate repository interfaces:
- featureRepository for global feature checks
- teamFeatureRepository for team-level feature checks
- userFeatureRepository for user-level feature checks

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: update API routes to use DI-based feature repositories

Update cron and webhook routes to use the new separate repository
interfaces instead of the combined FeaturesRepository.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: remove prisma arg from getGettingStartedPathWithParams call

The OnboardingPathService method no longer requires a prisma argument
as it now uses DI containers internally.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* test: update route tests to expect new DI-based feature repositories

Update service instantiation tests to expect featureRepository,
teamFeatureRepository, and userFeatureRepository instead of the
old featuresRepository.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: optimize global feature check and add guard in checkForNewSubscriptions

- Use targeted select for only `enabled` field in checkIfFeatureIsEnabledGlobally
- Add global feature flag guard in checkForNewSubscriptions to avoid unnecessary
  DB queries and API calls when the cache feature is globally disabled

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* remove redundant comment

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-06 09:34:03 +00:00

468 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 { IFeatureRepository } from "@calcom/features/flags/repositories/PrismaFeatureRepository";
import type { ITeamFeatureRepository } from "@calcom/features/flags/repositories/PrismaTeamFeatureRepository";
import type { IUserFeatureRepository } from "@calcom/features/flags/repositories/PrismaUserFeatureRepository";
import type { ISelectedCalendarRepository } from "@calcom/features/selectedCalendar/repositories/SelectedCalendarRepository.interface";
import logger from "@calcom/lib/logger";
import type { SelectedCalendar } from "@calcom/prisma/client";
import { metrics } from "@sentry/nextjs";
// 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;
featureRepository: IFeatureRepository;
teamFeatureRepository: ITeamFeatureRepository;
userFeatureRepository: IUserFeatureRepository & {
checkIfUserHasFeature: (userId: number, slug: string) => Promise<boolean>;
};
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() {
if (!(await this.isCacheEnabled())) {
return;
}
const teamIds = await this.deps.teamFeatureRepository.getTeamsWithFeatureEnabled(
CalendarSubscriptionService.CALENDAR_SUBSCRIPTION_CACHE_FEATURE
);
const rows = await this.deps.selectedCalendarRepository.findNextSubscriptionBatch({
take: 100,
integrations: this.deps.adapterFactory.getProviders(),
teamIds,
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.featureRepository.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.userFeatureRepository.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.featureRepository.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,
};
}
}