feat: init cloud calendar support (#14247)

#### PR Dependency Tree


* **PR #14247** 👈
  * **PR #14248**

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**
* Google Calendar integration (disabled by default): link/unlink
accounts, OAuth flow, webhooks, real-time push, background sync,
workspace calendars with customizable items and date-range event
viewing.
* **GraphQL / Client**
* New queries & mutations for accounts, subscriptions, events,
providers, and workspace calendar management.
* **Localization**
* Added localized error message for calendar provider request failures.
* **Tests**
* Backend tests covering sync, webhook renewal, and error/error-recovery
scenarios.

<sub>✏️ Tip: You can customize this high-level summary in your review
settings.</sub>
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
DarkSky
2026-01-12 23:17:43 +08:00
committed by GitHub
parent a5b60cf679
commit 0bd8160ed4
40 changed files with 4047 additions and 2 deletions
@@ -0,0 +1,152 @@
import {
Args,
GraphQLISODateTime,
Mutation,
Query,
Resolver,
} from '@nestjs/graphql';
import { AuthenticationRequired } from '../../base';
import { CurrentUser } from '../../core/auth';
import { AccessController } from '../../core/permission';
import { Models } from '../../models';
import { CalendarOAuthService } from './oauth';
import { CalendarProviderFactory, CalendarProviderName } from './providers';
import { CalendarService } from './service';
import {
CalendarAccountObjectType,
CalendarEventObjectType,
CalendarSubscriptionObjectType,
LinkCalendarAccountInput,
UpdateWorkspaceCalendarsInput,
WorkspaceCalendarObjectType,
} from './types';
@Resolver(() => CalendarAccountObjectType)
export class CalendarResolver {
constructor(
private readonly calendar: CalendarService,
private readonly oauth: CalendarOAuthService,
private readonly models: Models,
private readonly access: AccessController,
private readonly providerFactory: CalendarProviderFactory
) {}
@Query(() => [CalendarAccountObjectType])
async calendarAccounts(@CurrentUser() user: CurrentUser) {
return await this.calendar.listAccounts(user.id);
}
@Query(() => [CalendarSubscriptionObjectType])
async calendarAccountCalendars(
@CurrentUser() user: CurrentUser,
@Args('accountId') accountId: string
) {
return await this.calendar.listAccountCalendars(user.id, accountId);
}
@Query(() => [WorkspaceCalendarObjectType])
async workspaceCalendars(
@CurrentUser() user: CurrentUser,
@Args('workspaceId') workspaceId: string
) {
await this.access
.user(user.id)
.workspace(workspaceId)
.assert('Workspace.CreateDoc');
return await this.calendar.getWorkspaceCalendars(workspaceId);
}
@Query(() => [CalendarEventObjectType])
async calendarEvents(
@CurrentUser() user: CurrentUser,
@Args('workspaceCalendarId') workspaceCalendarId: string,
@Args({ name: 'from', type: () => GraphQLISODateTime }) from: Date,
@Args({ name: 'to', type: () => GraphQLISODateTime }) to: Date
) {
const workspaceCalendar =
await this.models.workspaceCalendar.get(workspaceCalendarId);
if (!workspaceCalendar) {
return [];
}
await this.access
.user(user.id)
.workspace(workspaceCalendar.workspaceId)
.assert('Workspace.CreateDoc');
return await this.calendar.listWorkspaceEvents({
workspaceCalendarId,
from,
to,
});
}
@Query(() => [CalendarProviderName])
async calendarProviders() {
return this.providerFactory.providers;
}
@Mutation(() => String)
async linkCalendarAccount(
@CurrentUser() user: CurrentUser | null,
@Args('input') input: LinkCalendarAccountInput
) {
if (!user) {
throw new AuthenticationRequired();
}
const state = await this.oauth.saveOAuthState({
provider: input.provider,
userId: user.id,
redirectUri: input.redirectUri ?? undefined,
});
const callbackUrl = this.calendar.getCallbackUrl();
return this.calendar.getAuthUrl(input.provider, state, callbackUrl);
}
@Mutation(() => CalendarAccountObjectType, { nullable: true })
async updateCalendarAccount(
@CurrentUser() user: CurrentUser,
@Args('accountId') accountId: string,
@Args('refreshIntervalMinutes') refreshIntervalMinutes: number
) {
return await this.calendar.updateAccountRefreshInterval(
user.id,
accountId,
refreshIntervalMinutes
);
}
@Mutation(() => Boolean)
async unlinkCalendarAccount(
@CurrentUser() user: CurrentUser,
@Args('accountId') accountId: string
) {
return await this.calendar.unlinkAccount(user.id, accountId);
}
@Mutation(() => WorkspaceCalendarObjectType)
async updateWorkspaceCalendars(
@CurrentUser() user: CurrentUser,
@Args('input') input: UpdateWorkspaceCalendarsInput
) {
await this.access
.user(user.id)
.workspace(input.workspaceId)
.assert('Workspace.Settings.Update');
const calendar = await this.calendar.updateWorkspaceCalendars({
workspaceId: input.workspaceId,
userId: user.id,
items: input.items,
});
const items = await this.models.workspaceCalendar.listItems(calendar.id);
return {
...calendar,
items,
};
}
}