Files
AFFiNE-Mirror/packages/backend/server/src/plugins/calendar/providers/def.ts
T
DarkSky d975bf46fb feat(server): improve calendar sync queue (#14783)
#### PR Dependency Tree


* **PR #14783** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
  * Configurable request timeout for calendar integrations.
* Calendar polling now enqueues per-subscription sync jobs (larger
batch) for improved throughput.

* **Bug Fixes / Improvements**
* Persisted next-sync timestamps and retry counts for more reliable
scheduling and retry behavior.
* Exponential backoff and webhook renewal now update scheduling
consistently.

* **Refactor**
* Calendar sync flow moved to a job-queue-driven design for better
concurrency and observability.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-04-05 10:52:18 +08:00

205 lines
5.0 KiB
TypeScript

import { Inject, Injectable, Logger } from '@nestjs/common';
import type { CalendarAccount } from '@prisma/client';
import { CalendarProviderRequestError, Config, OnEvent } from '../../../base';
import { CalendarProviderFactory, CalendarProviderName } from './factory';
export interface CalendarProviderTokens {
accessToken: string;
refreshToken?: string;
expiresAt?: Date;
scope?: string;
tokenType?: string;
}
export interface CalendarAccountProfile {
providerAccountId: string;
displayName?: string;
email?: string;
}
export interface CalendarProviderCalendar {
id: string;
summary?: string;
timeZone?: string;
colorId?: string;
primary?: boolean;
}
export interface CalendarProviderEventTime {
dateTime?: string;
date?: string;
timeZone?: string;
}
export interface CalendarProviderEvent {
id: string;
status?: string;
etag?: string;
summary?: string;
description?: string;
location?: string;
updated?: string;
recurringEventId?: string;
originalStartTime?: CalendarProviderEventTime;
start: CalendarProviderEventTime;
end: CalendarProviderEventTime;
raw: Record<string, unknown>;
}
export interface CalendarProviderListEventsParams {
accessToken: string;
calendarId: string;
account?: CalendarAccount;
timeMin?: string;
timeMax?: string;
syncToken?: string;
}
export interface CalendarProviderListCalendarsParams {
accessToken: string;
account?: CalendarAccount;
}
export interface CalendarProviderListEventsResult {
events: CalendarProviderEvent[];
nextSyncToken?: string;
}
export interface CalendarProviderWatchResult {
channelId: string;
resourceId: string;
expiration?: Date;
}
export interface CalendarProviderWatchParams {
accessToken: string;
calendarId: string;
address: string;
token?: string;
channelId: string;
}
export interface CalendarProviderStopParams {
accessToken: string;
channelId: string;
resourceId: string;
}
@Injectable()
export abstract class CalendarProvider {
abstract provider: CalendarProviderName;
abstract getAuthUrl(state: string, redirectUri: string): string;
abstract exchangeCode(
code: string,
redirectUri: string
): Promise<CalendarProviderTokens>;
abstract refreshTokens(refreshToken: string): Promise<CalendarProviderTokens>;
abstract getAccountProfile(
accessToken: string
): Promise<CalendarAccountProfile>;
abstract listCalendars(
params: CalendarProviderListCalendarsParams
): Promise<CalendarProviderCalendar[]>;
abstract listEvents(
params: CalendarProviderListEventsParams
): Promise<CalendarProviderListEventsResult>;
abstract watchCalendar?(
params: CalendarProviderWatchParams
): Promise<CalendarProviderWatchResult>;
abstract stopChannel?(params: CalendarProviderStopParams): Promise<void>;
protected readonly logger = new Logger(this.constructor.name);
@Inject() private readonly factory!: CalendarProviderFactory;
@Inject() private readonly AFFiNEConfig!: Config;
get config() {
return (this.AFFiNEConfig.calendar as Record<string, any>)[this.provider];
}
get configured() {
if (!this.config || !this.config.enabled) {
return false;
}
if ('clientId' in this.config || 'clientSecret' in this.config) {
return Boolean(this.config.clientId && this.config.clientSecret);
}
return true;
}
get supportsOAuth() {
return true;
}
@OnEvent('config.init')
onConfigInit() {
this.setup();
}
@OnEvent('config.changed')
onConfigUpdated(event: Events['config.changed']) {
if ('calendar' in event.updates) {
this.setup();
}
}
protected setup() {
if (this.configured) {
this.factory.register(this);
} else {
this.factory.unregister(this);
}
}
protected get requestTimeoutMs() {
const timeout = (this.config as { requestTimeoutMs?: number } | undefined)
?.requestTimeoutMs;
return typeof timeout === 'number' && timeout > 0 ? timeout : undefined;
}
protected withTimeout(signal?: AbortSignal | null) {
const timeoutMs = this.requestTimeoutMs;
if (!timeoutMs) return signal;
const timeoutSignal = AbortSignal.timeout(timeoutMs);
if (!signal) return timeoutSignal;
return AbortSignal.any([signal, timeoutSignal]);
}
protected async fetchJson<T>(url: string, init?: RequestInit) {
const response = await fetch(url, {
...init,
signal: this.withTimeout(init?.signal),
headers: { ...init?.headers, Accept: 'application/json' },
});
const body = await response.text();
if (!response.ok) {
throw new CalendarProviderRequestError({
status: response.status,
message: body,
});
}
if (!body) {
return {} as T;
}
return JSON.parse(body) as T;
}
protected postFormJson<T>(
url: string,
body: string,
options?: { headers?: Record<string, string> }
) {
return this.fetchJson<T>(url, {
method: 'POST',
body,
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
...options?.headers,
},
});
}
}