mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-12 23:56:36 +08:00
d975bf46fb
#### 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 -->
205 lines
5.0 KiB
TypeScript
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,
|
|
},
|
|
});
|
|
}
|
|
}
|