feat: basic caldav support (#14372)

fix #13531 

#### PR Dependency Tree


* **PR #14372** 👈

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**
* CalDAV calendar integration: link and sync CalDAV-compatible calendars
(discovery, listing, event sync).
* New UI flow and dialog to link CalDAV accounts with provider
selection, credentials, and display name.

* **API / Config**
* Server exposes CalDAV provider presets in config and new GraphQL
mutation to link CalDAV accounts.
  * New calendar config section for CalDAV with validation and defaults.

* **Tests**
  * Comprehensive CalDAV integration test suite added.

* **Chores**
* Removed analytics tokens from build configuration and reduced Cloud
E2E test shards.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
DarkSky
2026-02-05 03:04:21 +08:00
committed by GitHub
parent 403f16b404
commit a655b79166
34 changed files with 2995 additions and 217 deletions
@@ -8,10 +8,12 @@ import { addDays, subDays } from 'date-fns';
import {
CalendarProviderRequestError,
Config,
GraphqlBadRequest,
Mutex,
URLHelper,
} from '../../base';
import { Models } from '../../models';
import type { CalendarCalDAVProviderPreset } from './config';
import {
CalendarProvider,
CalendarProviderEvent,
@@ -20,6 +22,7 @@ import {
CalendarSyncTokenInvalid,
} from './providers';
import { CalendarProviderFactory } from './providers';
import type { LinkCalDAVAccountInput } from './types';
const TOKEN_REFRESH_SKEW_MS = 60 * 1000;
const DEFAULT_PAST_DAYS = 90;
@@ -159,6 +162,92 @@ export class CalendarService {
return account;
}
async linkCalDAVAccount(params: {
userId: string;
input: LinkCalDAVAccountInput;
}) {
const caldavConfig = this.config.calendar.caldav;
if (!caldavConfig?.enabled) {
throw new GraphqlBadRequest({
code: 'caldav_disabled',
message: 'CalDAV integration is not enabled.',
});
}
const preset = caldavConfig.providers.find(
provider => provider.id === params.input.providerPresetId
);
if (!preset) {
throw new GraphqlBadRequest({
code: 'caldav_provider_not_found',
message: 'CalDAV provider is not available.',
});
}
const provider = this.requireProvider(CalendarProviderName.CalDAV);
if (!('discoverAccount' in provider)) {
throw new GraphqlBadRequest({
code: 'caldav_provider_unavailable',
message: 'CalDAV provider is not configured.',
});
}
const discovery = await (
provider as CalendarProvider & {
discoverAccount: (input: {
preset: CalendarCalDAVProviderPreset;
username: string;
password: string;
}) => Promise<{
providerAccountId: string;
serverUrl: string;
principalUrl: string;
calendarHomeUrl: string;
authType?: string | null;
}>;
}
).discoverAccount({
preset,
username: params.input.username,
password: params.input.password,
});
const account = await this.models.calendarAccount.upsert({
userId: params.userId,
provider: CalendarProviderName.CalDAV,
providerAccountId: discovery.providerAccountId,
displayName: params.input.displayName ?? null,
email: params.input.username,
accessToken: params.input.password,
refreshToken: null,
expiresAt: null,
scope: null,
status: 'active',
lastError: null,
providerPresetId: params.input.providerPresetId,
serverUrl: discovery.serverUrl,
principalUrl: discovery.principalUrl,
calendarHomeUrl: discovery.calendarHomeUrl,
username: params.input.username,
authType: discovery.authType ?? null,
});
try {
await this.syncAccountCalendars(account.id);
} catch (error) {
if (error instanceof CalendarProviderRequestError) {
await this.models.calendarAccount.updateStatus(
account.id,
'invalid',
error.message
);
}
throw error;
}
return account;
}
async syncAccountCalendars(accountId: string) {
const account = await this.models.calendarAccount.get(accountId);
if (!account) {
@@ -177,7 +266,10 @@ export class CalendarService {
return;
}
const calendars = await provider.listCalendars(accessToken);
const calendars = await provider.listCalendars({
accessToken,
account,
});
const upserted = [];
for (const calendar of calendars) {
upserted.push(
@@ -245,6 +337,7 @@ export class CalendarService {
subscriptionId: subscription.id,
calendarId: subscription.externalCalendarId,
accessToken,
account,
syncToken: shouldUseSyncToken
? (subscription.syncToken ?? undefined)
: undefined,
@@ -264,6 +357,7 @@ export class CalendarService {
subscriptionId: subscription.id,
calendarId: subscription.externalCalendarId,
accessToken,
account,
timeMin,
timeMax,
subscriptionTimezone: subscription.timezone ?? undefined,
@@ -410,7 +504,21 @@ export class CalendarService {
}
isProviderAvailable(provider: CalendarProviderName) {
return !!this.providerFactory.get(provider);
return this.isProviderAvailableFor(provider);
}
isProviderAvailableFor(
provider: CalendarProviderName,
options?: { oauth?: boolean }
) {
const instance = this.providerFactory.get(provider);
if (!instance) {
return false;
}
if (options?.oauth) {
return instance.supportsOAuth;
}
return true;
}
getAuthUrl(
@@ -418,7 +526,14 @@ export class CalendarService {
state: string,
redirectUri: string
) {
return this.requireProvider(provider).getAuthUrl(state, redirectUri);
const instance = this.requireProvider(provider);
if (!instance.supportsOAuth) {
throw new GraphqlBadRequest({
code: 'calendar_provider_oauth_unsupported',
message: 'Selected calendar provider does not support OAuth.',
});
}
return instance.getAuthUrl(state, redirectUri);
}
private async syncWithProvider(params: {
@@ -426,6 +541,7 @@ export class CalendarService {
subscriptionId: string;
calendarId: string;
accessToken: string;
account: CalendarAccount;
syncToken?: string;
timeMin?: string;
timeMax?: string;
@@ -434,6 +550,7 @@ export class CalendarService {
const response = await params.provider.listEvents({
accessToken: params.accessToken,
calendarId: params.calendarId,
account: params.account,
syncToken: params.syncToken,
timeMin: params.timeMin,
timeMax: params.timeMax,
@@ -632,7 +749,8 @@ export class CalendarService {
private isTokenInvalidError(error: unknown) {
if (error instanceof CalendarProviderRequestError) {
if (error.status === 401) {
const status = error.data?.status ?? error.status;
if (status === 401) {
return true;
}
return error.message.includes('invalid_grant');