From 0bd8160ed43a1fad8b38a12f8f8168b641180a19 Mon Sep 17 00:00:00 2001
From: DarkSky <25152247+darkskygit@users.noreply.github.com>
Date: Mon, 12 Jan 2026 23:17:43 +0800
Subject: [PATCH] feat: init cloud calendar support (#14247)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
#### PR Dependency Tree
* **PR #14247** 👈
* **PR #14248**
This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)
## 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.
✏️ Tip: You can customize this high-level summary in your review
settings.
---
.docker/selfhost/schema.json | 34 +
.../20260105175128_calendar/migration.sql | 175 +++++
packages/backend/server/schema.prisma | 140 ++++
packages/backend/server/src/app.module.ts | 2 +
packages/backend/server/src/base/error/def.ts | 8 +
.../server/src/base/error/errors.gen.ts | 14 +-
.../server/src/models/calendar-account.ts | 172 +++++
.../src/models/calendar-event-instance.ts | 16 +
.../server/src/models/calendar-event.ts | 119 +++
.../src/models/calendar-subscription.ts | 194 +++++
packages/backend/server/src/models/index.ts | 15 +
.../server/src/models/workspace-calendar.ts | 81 ++
.../calendar/__tests__/service.spec.ts | 379 +++++++++
.../server/src/plugins/calendar/config.ts | 57 ++
.../server/src/plugins/calendar/controller.ts | 170 ++++
.../server/src/plugins/calendar/cron.ts | 61 ++
.../server/src/plugins/calendar/index.ts | 27 +
.../server/src/plugins/calendar/oauth.ts | 39 +
.../src/plugins/calendar/providers/def.ts | 180 +++++
.../src/plugins/calendar/providers/factory.ts | 28 +
.../src/plugins/calendar/providers/google.ts | 351 +++++++++
.../src/plugins/calendar/providers/index.ts | 19 +
.../server/src/plugins/calendar/resolver.ts | 152 ++++
.../server/src/plugins/calendar/service.ts | 723 ++++++++++++++++++
.../server/src/plugins/calendar/types.ts | 188 +++++
packages/backend/server/src/schema.gql | 97 ++-
.../calendar/calendar-account-calendars.gql | 13 +
.../graphql/calendar/calendar-accounts.gql | 15 +
.../src/graphql/calendar/calendar-events.gql | 16 +
.../graphql/calendar/calendar-providers.gql | 3 +
.../calendar/link-calendar-account.gql | 3 +
.../calendar/unlink-calendar-account.gql | 3 +
.../calendar/update-calendar-account.gql | 15 +
.../calendar/update-workspace-calendars.gql | 17 +
.../graphql/calendar/workspace-calendars.gql | 17 +
packages/common/graphql/src/graphql/index.ts | 150 ++++
packages/common/graphql/src/schema.ts | 341 +++++++++
packages/frontend/admin/src/config.json | 7 +
packages/frontend/i18n/src/i18n.gen.ts | 7 +
packages/frontend/i18n/src/resources/en.json | 1 +
40 files changed, 4047 insertions(+), 2 deletions(-)
create mode 100644 packages/backend/server/migrations/20260105175128_calendar/migration.sql
create mode 100644 packages/backend/server/src/models/calendar-account.ts
create mode 100644 packages/backend/server/src/models/calendar-event-instance.ts
create mode 100644 packages/backend/server/src/models/calendar-event.ts
create mode 100644 packages/backend/server/src/models/calendar-subscription.ts
create mode 100644 packages/backend/server/src/models/workspace-calendar.ts
create mode 100644 packages/backend/server/src/plugins/calendar/__tests__/service.spec.ts
create mode 100644 packages/backend/server/src/plugins/calendar/config.ts
create mode 100644 packages/backend/server/src/plugins/calendar/controller.ts
create mode 100644 packages/backend/server/src/plugins/calendar/cron.ts
create mode 100644 packages/backend/server/src/plugins/calendar/index.ts
create mode 100644 packages/backend/server/src/plugins/calendar/oauth.ts
create mode 100644 packages/backend/server/src/plugins/calendar/providers/def.ts
create mode 100644 packages/backend/server/src/plugins/calendar/providers/factory.ts
create mode 100644 packages/backend/server/src/plugins/calendar/providers/google.ts
create mode 100644 packages/backend/server/src/plugins/calendar/providers/index.ts
create mode 100644 packages/backend/server/src/plugins/calendar/resolver.ts
create mode 100644 packages/backend/server/src/plugins/calendar/service.ts
create mode 100644 packages/backend/server/src/plugins/calendar/types.ts
create mode 100644 packages/common/graphql/src/graphql/calendar/calendar-account-calendars.gql
create mode 100644 packages/common/graphql/src/graphql/calendar/calendar-accounts.gql
create mode 100644 packages/common/graphql/src/graphql/calendar/calendar-events.gql
create mode 100644 packages/common/graphql/src/graphql/calendar/calendar-providers.gql
create mode 100644 packages/common/graphql/src/graphql/calendar/link-calendar-account.gql
create mode 100644 packages/common/graphql/src/graphql/calendar/unlink-calendar-account.gql
create mode 100644 packages/common/graphql/src/graphql/calendar/update-calendar-account.gql
create mode 100644 packages/common/graphql/src/graphql/calendar/update-workspace-calendars.gql
create mode 100644 packages/common/graphql/src/graphql/calendar/workspace-calendars.gql
diff --git a/.docker/selfhost/schema.json b/.docker/selfhost/schema.json
index a4e9b718b..927d3bfad 100644
--- a/.docker/selfhost/schema.json
+++ b/.docker/selfhost/schema.json
@@ -650,6 +650,40 @@
}
}
},
+ "calendar": {
+ "type": "object",
+ "description": "Configuration for calendar module",
+ "properties": {
+ "google": {
+ "type": "object",
+ "description": "Google Calendar integration config\n@default {\"enabled\":false,\"clientId\":\"\",\"clientSecret\":\"\",\"externalWebhookUrl\":\"\",\"webhookVerificationToken\":\"\"}\n@link https://developers.google.com/calendar/api/guides/push",
+ "properties": {
+ "enabled": {
+ "type": "boolean"
+ },
+ "clientId": {
+ "type": "string"
+ },
+ "clientSecret": {
+ "type": "string"
+ },
+ "externalWebhookUrl": {
+ "type": "string"
+ },
+ "webhookVerificationToken": {
+ "type": "string"
+ }
+ },
+ "default": {
+ "enabled": false,
+ "clientId": "",
+ "clientSecret": "",
+ "externalWebhookUrl": "",
+ "webhookVerificationToken": ""
+ }
+ }
+ }
+ },
"captcha": {
"type": "object",
"description": "Configuration for captcha module",
diff --git a/packages/backend/server/migrations/20260105175128_calendar/migration.sql b/packages/backend/server/migrations/20260105175128_calendar/migration.sql
new file mode 100644
index 000000000..e73be2f31
--- /dev/null
+++ b/packages/backend/server/migrations/20260105175128_calendar/migration.sql
@@ -0,0 +1,175 @@
+-- CreateTable
+CREATE TABLE "calendar_accounts" (
+ "id" VARCHAR NOT NULL,
+ "user_id" VARCHAR NOT NULL,
+ "provider" VARCHAR NOT NULL,
+ "provider_account_id" VARCHAR NOT NULL,
+ "display_name" VARCHAR,
+ "email" VARCHAR,
+ "access_token" TEXT,
+ "refresh_token" TEXT,
+ "expires_at" TIMESTAMPTZ(3),
+ "scope" TEXT,
+ "status" VARCHAR NOT NULL DEFAULT 'active',
+ "last_error" TEXT,
+ "refresh_interval_minutes" INTEGER NOT NULL DEFAULT 30,
+ "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updated_at" TIMESTAMPTZ(3) NOT NULL,
+
+ CONSTRAINT "calendar_accounts_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "calendar_subscriptions" (
+ "id" VARCHAR NOT NULL,
+ "account_id" VARCHAR NOT NULL,
+ "provider" VARCHAR NOT NULL,
+ "external_calendar_id" VARCHAR NOT NULL,
+ "display_name" VARCHAR,
+ "timezone" VARCHAR,
+ "color" VARCHAR,
+ "enabled" BOOLEAN NOT NULL DEFAULT true,
+ "sync_token" TEXT,
+ "last_sync_at" TIMESTAMPTZ(3),
+ "custom_channel_id" VARCHAR,
+ "custom_resource_id" VARCHAR,
+ "channel_expiration" TIMESTAMPTZ(3),
+ "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updated_at" TIMESTAMPTZ(3) NOT NULL,
+
+ CONSTRAINT "calendar_subscriptions_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "workspace_calendars" (
+ "id" VARCHAR NOT NULL,
+ "workspace_id" VARCHAR NOT NULL,
+ "created_by_user_id" VARCHAR NOT NULL,
+ "display_name_override" VARCHAR,
+ "color_override" VARCHAR,
+ "enabled" BOOLEAN NOT NULL DEFAULT true,
+ "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updated_at" TIMESTAMPTZ(3) NOT NULL,
+
+ CONSTRAINT "workspace_calendars_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "workspace_calendar_items" (
+ "id" VARCHAR NOT NULL,
+ "workspace_calendar_id" VARCHAR NOT NULL,
+ "subscription_id" VARCHAR NOT NULL,
+ "sort_order" INTEGER,
+ "color_override" VARCHAR,
+ "enabled" BOOLEAN NOT NULL DEFAULT true,
+ "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updated_at" TIMESTAMPTZ(3) NOT NULL,
+
+ CONSTRAINT "workspace_calendar_items_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "calendar_events" (
+ "id" VARCHAR NOT NULL,
+ "subscription_id" VARCHAR NOT NULL,
+ "external_event_id" VARCHAR NOT NULL,
+ "recurrence_id" VARCHAR,
+ "etag" VARCHAR,
+ "status" VARCHAR,
+ "title" VARCHAR,
+ "description" TEXT,
+ "location" VARCHAR,
+ "start_at_utc" TIMESTAMPTZ(3) NOT NULL,
+ "end_at_utc" TIMESTAMPTZ(3) NOT NULL,
+ "original_timezone" VARCHAR,
+ "all_day" BOOLEAN NOT NULL DEFAULT false,
+ "provider_updated_at" TIMESTAMPTZ(3),
+ "raw" JSONB NOT NULL,
+ "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updated_at" TIMESTAMPTZ(3) NOT NULL,
+
+ CONSTRAINT "calendar_events_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "calendar_event_instances" (
+ "id" VARCHAR NOT NULL,
+ "calendar_event_id" VARCHAR NOT NULL,
+ "recurrence_id" VARCHAR NOT NULL,
+ "start_at_utc" TIMESTAMPTZ(3) NOT NULL,
+ "end_at_utc" TIMESTAMPTZ(3) NOT NULL,
+ "original_timezone" VARCHAR,
+ "all_day" BOOLEAN NOT NULL DEFAULT false,
+ "provider_updated_at" TIMESTAMPTZ(3),
+ "raw" JSONB NOT NULL,
+ "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updated_at" TIMESTAMPTZ(3) NOT NULL,
+
+ CONSTRAINT "calendar_event_instances_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE INDEX "calendar_accounts_user_id_idx" ON "calendar_accounts"("user_id");
+
+-- CreateIndex
+CREATE INDEX "calendar_accounts_provider_provider_account_id_idx" ON "calendar_accounts"("provider", "provider_account_id");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "calendar_accounts_user_id_provider_provider_account_id_key" ON "calendar_accounts"("user_id", "provider", "provider_account_id");
+
+-- CreateIndex
+CREATE INDEX "calendar_subscriptions_account_id_idx" ON "calendar_subscriptions"("account_id");
+
+-- CreateIndex
+CREATE INDEX "calendar_subscriptions_provider_external_calendar_id_idx" ON "calendar_subscriptions"("provider", "external_calendar_id");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "calendar_subscriptions_account_id_external_calendar_id_key" ON "calendar_subscriptions"("account_id", "external_calendar_id");
+
+-- CreateIndex
+CREATE INDEX "workspace_calendars_workspace_id_idx" ON "workspace_calendars"("workspace_id");
+
+-- CreateIndex
+CREATE INDEX "workspace_calendar_items_subscription_id_idx" ON "workspace_calendar_items"("subscription_id");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "workspace_calendar_items_workspace_calendar_id_subscription_key" ON "workspace_calendar_items"("workspace_calendar_id", "subscription_id");
+
+-- CreateIndex
+CREATE INDEX "calendar_events_subscription_id_start_at_utc_idx" ON "calendar_events"("subscription_id", "start_at_utc");
+
+-- CreateIndex
+CREATE INDEX "calendar_events_subscription_id_end_at_utc_idx" ON "calendar_events"("subscription_id", "end_at_utc");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "calendar_events_subscription_id_external_event_id_recurrenc_key" ON "calendar_events"("subscription_id", "external_event_id", "recurrence_id");
+
+-- CreateIndex
+CREATE INDEX "calendar_event_instances_calendar_event_id_start_at_utc_idx" ON "calendar_event_instances"("calendar_event_id", "start_at_utc");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "calendar_event_instances_calendar_event_id_recurrence_id_key" ON "calendar_event_instances"("calendar_event_id", "recurrence_id");
+
+-- AddForeignKey
+ALTER TABLE "calendar_accounts" ADD CONSTRAINT "calendar_accounts_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "calendar_subscriptions" ADD CONSTRAINT "calendar_subscriptions_account_id_fkey" FOREIGN KEY ("account_id") REFERENCES "calendar_accounts"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "workspace_calendars" ADD CONSTRAINT "workspace_calendars_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "workspace_calendars" ADD CONSTRAINT "workspace_calendars_created_by_user_id_fkey" FOREIGN KEY ("created_by_user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "workspace_calendar_items" ADD CONSTRAINT "workspace_calendar_items_workspace_calendar_id_fkey" FOREIGN KEY ("workspace_calendar_id") REFERENCES "workspace_calendars"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "workspace_calendar_items" ADD CONSTRAINT "workspace_calendar_items_subscription_id_fkey" FOREIGN KEY ("subscription_id") REFERENCES "calendar_subscriptions"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "calendar_events" ADD CONSTRAINT "calendar_events_subscription_id_fkey" FOREIGN KEY ("subscription_id") REFERENCES "calendar_subscriptions"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "calendar_event_instances" ADD CONSTRAINT "calendar_event_instances_calendar_event_id_fkey" FOREIGN KEY ("calendar_event_id") REFERENCES "calendar_events"("id") ON DELETE CASCADE ON UPDATE CASCADE;
diff --git a/packages/backend/server/schema.prisma b/packages/backend/server/schema.prisma
index 4057471c3..cbd9d43dc 100644
--- a/packages/backend/server/schema.prisma
+++ b/packages/backend/server/schema.prisma
@@ -32,6 +32,7 @@ model User {
WorkspaceInvitations WorkspaceUserRole[] @relation("inviter")
docPermissions WorkspaceDocUserRole[]
connectedAccounts ConnectedAccount[]
+ calendarAccounts CalendarAccount[]
sessions UserSession[]
aiSessions AiSession[]
appConfigs AppConfig[]
@@ -48,6 +49,7 @@ model User {
replies Reply[]
commentAttachments CommentAttachment[] @relation("createdCommentAttachments")
AccessToken AccessToken[]
+ workspaceCalendars WorkspaceCalendar[]
@@index([email])
@@map("users")
@@ -129,6 +131,7 @@ model Workspace {
embedFiles AiWorkspaceFiles[]
comments Comment[]
commentAttachments CommentAttachment[]
+ workspaceCalendars WorkspaceCalendar[]
workspaceAdminStats WorkspaceAdminStats[]
workspaceAdminStatsDirties WorkspaceAdminStatsDirty[]
@@ -911,3 +914,140 @@ model AccessToken {
@@index([userId])
@@map("access_tokens")
}
+
+model CalendarAccount {
+ id String @id @default(uuid()) @db.VarChar
+ userId String @map("user_id") @db.VarChar
+ provider String @db.VarChar
+ providerAccountId String @map("provider_account_id") @db.VarChar
+ displayName String? @map("display_name") @db.VarChar
+ email String? @db.VarChar
+ accessToken String? @map("access_token") @db.Text
+ refreshToken String? @map("refresh_token") @db.Text
+ expiresAt DateTime? @map("expires_at") @db.Timestamptz(3)
+ scope String? @db.Text
+ status String @default("active") @db.VarChar
+ lastError String? @map("last_error") @db.Text
+ refreshIntervalMinutes Int @default(30) @map("refresh_interval_minutes")
+ createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
+ updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
+
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+ subscriptions CalendarSubscription[]
+
+ @@unique([userId, provider, providerAccountId])
+ @@index([userId])
+ @@index([provider, providerAccountId])
+ @@map("calendar_accounts")
+}
+
+model CalendarSubscription {
+ id String @id @default(uuid()) @db.VarChar
+ accountId String @map("account_id") @db.VarChar
+ provider String @db.VarChar
+ externalCalendarId String @map("external_calendar_id") @db.VarChar
+ displayName String? @map("display_name") @db.VarChar
+ timezone String? @db.VarChar
+ color String? @db.VarChar
+ enabled Boolean @default(true)
+ syncToken String? @map("sync_token") @db.Text
+ lastSyncAt DateTime? @map("last_sync_at") @db.Timestamptz(3)
+ customChannelId String? @map("custom_channel_id") @db.VarChar
+ customResourceId String? @map("custom_resource_id") @db.VarChar
+ channelExpiration DateTime? @map("channel_expiration") @db.Timestamptz(3)
+ createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
+ updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
+
+ account CalendarAccount @relation(fields: [accountId], references: [id], onDelete: Cascade)
+ workspaceItems WorkspaceCalendarItem[]
+ events CalendarEvent[]
+
+ @@unique([accountId, externalCalendarId])
+ @@index([accountId])
+ @@index([provider, externalCalendarId])
+ @@map("calendar_subscriptions")
+}
+
+model WorkspaceCalendar {
+ id String @id @default(uuid()) @db.VarChar
+ workspaceId String @map("workspace_id") @db.VarChar
+ createdByUserId String @map("created_by_user_id") @db.VarChar
+ displayNameOverride String? @map("display_name_override") @db.VarChar
+ colorOverride String? @map("color_override") @db.VarChar
+ enabled Boolean @default(true)
+ createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
+ updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
+
+ workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
+ createdByUser User @relation(fields: [createdByUserId], references: [id], onDelete: Cascade)
+ items WorkspaceCalendarItem[]
+
+ @@index([workspaceId])
+ @@map("workspace_calendars")
+}
+
+model WorkspaceCalendarItem {
+ id String @id @default(uuid()) @db.VarChar
+ workspaceCalendarId String @map("workspace_calendar_id") @db.VarChar
+ subscriptionId String @map("subscription_id") @db.VarChar
+ sortOrder Int? @map("sort_order")
+ colorOverride String? @map("color_override") @db.VarChar
+ enabled Boolean @default(true)
+ createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
+ updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
+
+ workspaceCalendar WorkspaceCalendar @relation(fields: [workspaceCalendarId], references: [id], onDelete: Cascade)
+ subscription CalendarSubscription @relation(fields: [subscriptionId], references: [id], onDelete: Cascade)
+
+ @@unique([workspaceCalendarId, subscriptionId])
+ @@index([subscriptionId])
+ @@map("workspace_calendar_items")
+}
+
+model CalendarEvent {
+ id String @id @default(uuid()) @db.VarChar
+ subscriptionId String @map("subscription_id") @db.VarChar
+ externalEventId String @map("external_event_id") @db.VarChar
+ recurrenceId String? @map("recurrence_id") @db.VarChar
+ etag String? @db.VarChar
+ status String? @db.VarChar
+ title String? @db.VarChar
+ description String? @db.Text
+ location String? @db.VarChar
+ startAtUtc DateTime @map("start_at_utc") @db.Timestamptz(3)
+ endAtUtc DateTime @map("end_at_utc") @db.Timestamptz(3)
+ originalTimezone String? @map("original_timezone") @db.VarChar
+ allDay Boolean @default(false) @map("all_day")
+ providerUpdatedAt DateTime? @map("provider_updated_at") @db.Timestamptz(3)
+ raw Json @db.JsonB
+ createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
+ updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
+
+ subscription CalendarSubscription @relation(fields: [subscriptionId], references: [id], onDelete: Cascade)
+ instances CalendarEventInstance[]
+
+ @@unique([subscriptionId, externalEventId, recurrenceId])
+ @@index([subscriptionId, startAtUtc])
+ @@index([subscriptionId, endAtUtc])
+ @@map("calendar_events")
+}
+
+model CalendarEventInstance {
+ id String @id @default(uuid()) @db.VarChar
+ calendarEventId String @map("calendar_event_id") @db.VarChar
+ recurrenceId String @map("recurrence_id") @db.VarChar
+ startAtUtc DateTime @map("start_at_utc") @db.Timestamptz(3)
+ endAtUtc DateTime @map("end_at_utc") @db.Timestamptz(3)
+ originalTimezone String? @map("original_timezone") @db.VarChar
+ allDay Boolean @default(false) @map("all_day")
+ providerUpdatedAt DateTime? @map("provider_updated_at") @db.Timestamptz(3)
+ raw Json @db.JsonB
+ createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
+ updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
+
+ calendarEvent CalendarEvent @relation(fields: [calendarEventId], references: [id], onDelete: Cascade)
+
+ @@unique([calendarEventId, recurrenceId])
+ @@index([calendarEventId, startAtUtc])
+ @@map("calendar_event_instances")
+}
diff --git a/packages/backend/server/src/app.module.ts b/packages/backend/server/src/app.module.ts
index acbe94473..0ccc901c9 100644
--- a/packages/backend/server/src/app.module.ts
+++ b/packages/backend/server/src/app.module.ts
@@ -50,6 +50,7 @@ import { VersionModule } from './core/version';
import { WorkspaceModule } from './core/workspaces';
import { Env } from './env';
import { ModelsModule } from './models';
+import { CalendarModule } from './plugins/calendar';
import { CaptchaModule } from './plugins/captcha';
import { CopilotModule } from './plugins/copilot';
import { CustomerIoModule } from './plugins/customerio';
@@ -188,6 +189,7 @@ export function buildAppModule(env: Env) {
CopilotModule,
CaptchaModule,
OAuthModule,
+ CalendarModule,
CustomerIoModule,
CommentModule,
AccessTokenModule,
diff --git a/packages/backend/server/src/base/error/def.ts b/packages/backend/server/src/base/error/def.ts
index 3e1b3504e..4ac3349fc 100644
--- a/packages/backend/server/src/base/error/def.ts
+++ b/packages/backend/server/src/base/error/def.ts
@@ -643,6 +643,14 @@ export const USER_FRIENDLY_ERRORS = {
'This subscription is managed by App Store or Google Play. Please manage it in the corresponding store.',
},
+ // Calendar errors
+ calendar_provider_request_error: {
+ type: 'internal_server_error',
+ args: { status: 'number', message: 'string' },
+ message: ({ status, message }) =>
+ `Calendar provider request error, status: ${status}, message: ${message}`,
+ },
+
// Copilot errors
copilot_session_not_found: {
type: 'resource_not_found',
diff --git a/packages/backend/server/src/base/error/errors.gen.ts b/packages/backend/server/src/base/error/errors.gen.ts
index d927e870b..9d6def861 100644
--- a/packages/backend/server/src/base/error/errors.gen.ts
+++ b/packages/backend/server/src/base/error/errors.gen.ts
@@ -656,6 +656,17 @@ export class ManagedByAppStoreOrPlay extends UserFriendlyError {
super('action_forbidden', 'managed_by_app_store_or_play', message);
}
}
+@ObjectType()
+class CalendarProviderRequestErrorDataType {
+ @Field() status!: number
+ @Field() message!: string
+}
+
+export class CalendarProviderRequestError extends UserFriendlyError {
+ constructor(args: CalendarProviderRequestErrorDataType, message?: string | ((args: CalendarProviderRequestErrorDataType) => string)) {
+ super('internal_server_error', 'calendar_provider_request_error', message, args);
+ }
+}
export class CopilotSessionNotFound extends UserFriendlyError {
constructor(message?: string) {
@@ -1196,6 +1207,7 @@ export enum ErrorNames {
WORKSPACE_ID_REQUIRED_FOR_TEAM_SUBSCRIPTION,
WORKSPACE_ID_REQUIRED_TO_UPDATE_TEAM_SUBSCRIPTION,
MANAGED_BY_APP_STORE_OR_PLAY,
+ CALENDAR_PROVIDER_REQUEST_ERROR,
COPILOT_SESSION_NOT_FOUND,
COPILOT_SESSION_INVALID_INPUT,
COPILOT_SESSION_DELETED,
@@ -1262,5 +1274,5 @@ registerEnumType(ErrorNames, {
export const ErrorDataUnionType = createUnionType({
name: 'ErrorDataUnion',
types: () =>
- [GraphqlBadRequestDataType, HttpRequestErrorDataType, QueryTooLongDataType, ValidationErrorDataType, WrongSignInCredentialsDataType, UnknownOauthProviderDataType, InvalidOauthCallbackCodeDataType, MissingOauthQueryParameterDataType, InvalidOauthResponseDataType, InvalidEmailDataType, InvalidPasswordLengthDataType, WorkspacePermissionNotFoundDataType, SpaceNotFoundDataType, MemberNotFoundInSpaceDataType, NotInSpaceDataType, AlreadyInSpaceDataType, SpaceAccessDeniedDataType, SpaceOwnerNotFoundDataType, SpaceShouldHaveOnlyOneOwnerDataType, DocNotFoundDataType, DocActionDeniedDataType, DocUpdateBlockedDataType, VersionRejectedDataType, InvalidHistoryTimestampDataType, DocHistoryNotFoundDataType, BlobNotFoundDataType, ExpectToGrantDocUserRolesDataType, ExpectToRevokeDocUserRolesDataType, ExpectToUpdateDocUserRoleDataType, NoMoreSeatDataType, UnsupportedSubscriptionPlanDataType, SubscriptionAlreadyExistsDataType, SubscriptionNotExistsDataType, SameSubscriptionRecurringDataType, SubscriptionPlanNotFoundDataType, NoCopilotProviderAvailableDataType, CopilotFailedToGenerateEmbeddingDataType, CopilotDocNotFoundDataType, CopilotMessageNotFoundDataType, CopilotPromptNotFoundDataType, CopilotProviderNotSupportedDataType, CopilotProviderSideErrorDataType, CopilotInvalidContextDataType, CopilotContextFileNotSupportedDataType, CopilotFailedToModifyContextDataType, CopilotFailedToMatchContextDataType, CopilotFailedToMatchGlobalContextDataType, CopilotFailedToAddWorkspaceFileEmbeddingDataType, RuntimeConfigNotFoundDataType, InvalidRuntimeConfigTypeDataType, InvalidLicenseToActivateDataType, InvalidLicenseUpdateParamsDataType, UnsupportedClientVersionDataType, MentionUserDocAccessDeniedDataType, InvalidAppConfigDataType, InvalidAppConfigInputDataType, InvalidSearchProviderRequestDataType, InvalidIndexerInputDataType] as const,
+ [GraphqlBadRequestDataType, HttpRequestErrorDataType, QueryTooLongDataType, ValidationErrorDataType, WrongSignInCredentialsDataType, UnknownOauthProviderDataType, InvalidOauthCallbackCodeDataType, MissingOauthQueryParameterDataType, InvalidOauthResponseDataType, InvalidEmailDataType, InvalidPasswordLengthDataType, WorkspacePermissionNotFoundDataType, SpaceNotFoundDataType, MemberNotFoundInSpaceDataType, NotInSpaceDataType, AlreadyInSpaceDataType, SpaceAccessDeniedDataType, SpaceOwnerNotFoundDataType, SpaceShouldHaveOnlyOneOwnerDataType, DocNotFoundDataType, DocActionDeniedDataType, DocUpdateBlockedDataType, VersionRejectedDataType, InvalidHistoryTimestampDataType, DocHistoryNotFoundDataType, BlobNotFoundDataType, ExpectToGrantDocUserRolesDataType, ExpectToRevokeDocUserRolesDataType, ExpectToUpdateDocUserRoleDataType, NoMoreSeatDataType, UnsupportedSubscriptionPlanDataType, SubscriptionAlreadyExistsDataType, SubscriptionNotExistsDataType, SameSubscriptionRecurringDataType, SubscriptionPlanNotFoundDataType, CalendarProviderRequestErrorDataType, NoCopilotProviderAvailableDataType, CopilotFailedToGenerateEmbeddingDataType, CopilotDocNotFoundDataType, CopilotMessageNotFoundDataType, CopilotPromptNotFoundDataType, CopilotProviderNotSupportedDataType, CopilotProviderSideErrorDataType, CopilotInvalidContextDataType, CopilotContextFileNotSupportedDataType, CopilotFailedToModifyContextDataType, CopilotFailedToMatchContextDataType, CopilotFailedToMatchGlobalContextDataType, CopilotFailedToAddWorkspaceFileEmbeddingDataType, RuntimeConfigNotFoundDataType, InvalidRuntimeConfigTypeDataType, InvalidLicenseToActivateDataType, InvalidLicenseUpdateParamsDataType, UnsupportedClientVersionDataType, MentionUserDocAccessDeniedDataType, InvalidAppConfigDataType, InvalidAppConfigInputDataType, InvalidSearchProviderRequestDataType, InvalidIndexerInputDataType] as const,
});
diff --git a/packages/backend/server/src/models/calendar-account.ts b/packages/backend/server/src/models/calendar-account.ts
new file mode 100644
index 000000000..abb17bb70
--- /dev/null
+++ b/packages/backend/server/src/models/calendar-account.ts
@@ -0,0 +1,172 @@
+import { Injectable } from '@nestjs/common';
+import type { CalendarAccount, Prisma } from '@prisma/client';
+
+import { CryptoHelper } from '../base';
+import { BaseModel } from './base';
+
+export interface CalendarAccountTokens {
+ accessToken?: string | null;
+ refreshToken?: string | null;
+ expiresAt?: Date | null;
+ scope?: string | null;
+}
+
+export interface UpsertCalendarAccountInput extends CalendarAccountTokens {
+ userId: string;
+ provider: string;
+ providerAccountId: string;
+ displayName?: string | null;
+ email?: string | null;
+ status?: string | null;
+ lastError?: string | null;
+ refreshIntervalMinutes?: number | null;
+}
+
+export interface UpdateCalendarAccountTokensInput extends CalendarAccountTokens {
+ status?: string | null;
+ lastError?: string | null;
+}
+
+@Injectable()
+export class CalendarAccountModel extends BaseModel {
+ constructor(private readonly crypto: CryptoHelper) {
+ super();
+ }
+
+ private encryptToken(token?: string | null) {
+ return token ? this.crypto.encrypt(token) : null;
+ }
+
+ private decryptToken(token?: string | null) {
+ return token ? this.crypto.decrypt(token) : null;
+ }
+
+ async listByUser(userId: string) {
+ return await this.db.calendarAccount.findMany({
+ where: { userId },
+ orderBy: { createdAt: 'desc' },
+ });
+ }
+
+ async get(id: string) {
+ return await this.db.calendarAccount.findUnique({
+ where: { id },
+ });
+ }
+
+ async getByProviderAccount(
+ userId: string,
+ provider: string,
+ providerAccountId: string
+ ) {
+ return await this.db.calendarAccount.findFirst({
+ where: { userId, provider, providerAccountId },
+ });
+ }
+
+ async upsert(input: UpsertCalendarAccountInput) {
+ const accessToken = this.encryptToken(input.accessToken);
+ const refreshToken = this.encryptToken(input.refreshToken);
+ const data: Prisma.CalendarAccountUncheckedCreateInput = {
+ userId: input.userId,
+ provider: input.provider,
+ providerAccountId: input.providerAccountId,
+ displayName: input.displayName ?? null,
+ email: input.email ?? null,
+ accessToken: accessToken ?? null,
+ refreshToken: refreshToken ?? null,
+ expiresAt: input.expiresAt ?? null,
+ scope: input.scope ?? null,
+ status: input.status ?? 'active',
+ lastError: input.lastError ?? null,
+ refreshIntervalMinutes: input.refreshIntervalMinutes ?? 60,
+ };
+
+ const updateData: Prisma.CalendarAccountUncheckedUpdateInput = {
+ displayName: data.displayName,
+ email: data.email,
+ expiresAt: data.expiresAt,
+ scope: data.scope,
+ status: data.status,
+ lastError: data.lastError,
+ refreshIntervalMinutes: data.refreshIntervalMinutes,
+ };
+
+ if (!!accessToken) {
+ updateData.accessToken = accessToken;
+ }
+ if (!!refreshToken) {
+ updateData.refreshToken = refreshToken;
+ }
+
+ return await this.db.calendarAccount.upsert({
+ where: {
+ userId_provider_providerAccountId: {
+ userId: input.userId,
+ provider: input.provider,
+ providerAccountId: input.providerAccountId,
+ },
+ },
+ create: data,
+ update: updateData,
+ });
+ }
+
+ async updateTokens(id: string, input: UpdateCalendarAccountTokensInput) {
+ const data: Prisma.CalendarAccountUncheckedUpdateInput = {};
+ if (input.accessToken !== undefined) {
+ data.accessToken = this.encryptToken(input.accessToken);
+ }
+ if (input.refreshToken !== undefined) {
+ data.refreshToken = this.encryptToken(input.refreshToken);
+ }
+ if (input.expiresAt !== undefined) {
+ data.expiresAt = input.expiresAt ?? null;
+ }
+ if (input.scope !== undefined) {
+ data.scope = input.scope ?? null;
+ }
+ if (input.status !== undefined) {
+ data.status = input.status ?? undefined;
+ }
+ if (input.lastError !== undefined) {
+ data.lastError = input.lastError ?? null;
+ }
+
+ return await this.db.calendarAccount.update({
+ where: { id },
+ data,
+ });
+ }
+
+ async updateStatus(id: string, status: string, lastError?: string | null) {
+ return await this.db.calendarAccount.update({
+ where: { id },
+ data: {
+ status,
+ lastError: lastError ?? null,
+ },
+ });
+ }
+
+ async updateRefreshInterval(id: string, refreshIntervalMinutes: number) {
+ return await this.db.calendarAccount.update({
+ where: { id },
+ data: { refreshIntervalMinutes },
+ });
+ }
+
+ async delete(id: string) {
+ return await this.db.calendarAccount.delete({
+ where: { id },
+ });
+ }
+
+ decryptTokens(account: CalendarAccount) {
+ return {
+ ...account,
+ accessToken: this.decryptToken(account.accessToken),
+ refreshToken: this.decryptToken(account.refreshToken),
+ };
+ }
+}
diff --git a/packages/backend/server/src/models/calendar-event-instance.ts b/packages/backend/server/src/models/calendar-event-instance.ts
new file mode 100644
index 000000000..1e2096359
--- /dev/null
+++ b/packages/backend/server/src/models/calendar-event-instance.ts
@@ -0,0 +1,16 @@
+import { Injectable } from '@nestjs/common';
+
+import { BaseModel } from './base';
+
+@Injectable()
+export class CalendarEventInstanceModel extends BaseModel {
+ async deleteByEventIds(eventIds: string[]) {
+ if (eventIds.length === 0) {
+ return;
+ }
+
+ await this.db.calendarEventInstance.deleteMany({
+ where: { calendarEventId: { in: eventIds } },
+ });
+ }
+}
diff --git a/packages/backend/server/src/models/calendar-event.ts b/packages/backend/server/src/models/calendar-event.ts
new file mode 100644
index 000000000..6f1985315
--- /dev/null
+++ b/packages/backend/server/src/models/calendar-event.ts
@@ -0,0 +1,119 @@
+import { Injectable } from '@nestjs/common';
+import type { Prisma } from '@prisma/client';
+
+import { BaseModel } from './base';
+
+export interface UpsertCalendarEventInput {
+ subscriptionId: string;
+ externalEventId: string;
+ recurrenceId?: string | null;
+ etag?: string | null;
+ status?: string | null;
+ title?: string | null;
+ description?: string | null;
+ location?: string | null;
+ startAtUtc: Date;
+ endAtUtc: Date;
+ originalTimezone?: string | null;
+ allDay: boolean;
+ providerUpdatedAt?: Date | null;
+ raw: Prisma.InputJsonValue;
+}
+
+@Injectable()
+export class CalendarEventModel extends BaseModel {
+ async upsert(input: UpsertCalendarEventInput) {
+ const recurrenceId = input.recurrenceId ?? input.externalEventId;
+ return await this.db.calendarEvent.upsert({
+ where: {
+ subscriptionId_externalEventId_recurrenceId: {
+ subscriptionId: input.subscriptionId,
+ externalEventId: input.externalEventId,
+ recurrenceId,
+ },
+ },
+ create: {
+ subscriptionId: input.subscriptionId,
+ externalEventId: input.externalEventId,
+ recurrenceId,
+ etag: input.etag ?? null,
+ status: input.status ?? null,
+ title: input.title ?? null,
+ description: input.description ?? null,
+ location: input.location ?? null,
+ startAtUtc: input.startAtUtc,
+ endAtUtc: input.endAtUtc,
+ originalTimezone: input.originalTimezone ?? null,
+ allDay: input.allDay,
+ providerUpdatedAt: input.providerUpdatedAt ?? null,
+ raw: input.raw,
+ },
+ update: {
+ etag: input.etag ?? null,
+ status: input.status ?? null,
+ title: input.title ?? null,
+ description: input.description ?? null,
+ location: input.location ?? null,
+ startAtUtc: input.startAtUtc,
+ endAtUtc: input.endAtUtc,
+ originalTimezone: input.originalTimezone ?? null,
+ allDay: input.allDay,
+ providerUpdatedAt: input.providerUpdatedAt ?? null,
+ raw: input.raw,
+ },
+ });
+ }
+
+ async deleteBySubscription(subscriptionId: string) {
+ return await this.db.calendarEvent.deleteMany({
+ where: { subscriptionId },
+ });
+ }
+
+ async deleteBySubscriptionIds(subscriptionIds: string[]) {
+ return await this.db.calendarEvent.deleteMany({
+ where: { subscriptionId: { in: subscriptionIds } },
+ });
+ }
+
+ async deleteByIds(ids: string[]) {
+ return await this.db.calendarEvent.deleteMany({
+ where: { id: { in: ids } },
+ });
+ }
+
+ async deleteByExternalIds(
+ subscriptionId: string,
+ externalEventIds: string[]
+ ) {
+ if (externalEventIds.length === 0) {
+ return;
+ }
+
+ await this.db.calendarEvent.deleteMany({
+ where: {
+ subscriptionId,
+ externalEventId: { in: externalEventIds },
+ },
+ });
+ }
+
+ async listBySubscriptionsInRange(
+ subscriptionIds: string[],
+ from: Date,
+ to: Date
+ ) {
+ if (subscriptionIds.length === 0) {
+ return [];
+ }
+
+ return await this.db.calendarEvent.findMany({
+ where: {
+ subscriptionId: { in: subscriptionIds },
+ startAtUtc: { lt: to },
+ endAtUtc: { gt: from },
+ },
+ orderBy: [{ startAtUtc: 'asc' }, { endAtUtc: 'asc' }],
+ });
+ }
+}
diff --git a/packages/backend/server/src/models/calendar-subscription.ts b/packages/backend/server/src/models/calendar-subscription.ts
new file mode 100644
index 000000000..982fa0235
--- /dev/null
+++ b/packages/backend/server/src/models/calendar-subscription.ts
@@ -0,0 +1,194 @@
+import { Injectable } from '@nestjs/common';
+import type { CalendarSubscription, Prisma } from '@prisma/client';
+
+import { BaseModel } from './base';
+
+export interface UpsertCalendarSubscriptionInput {
+ accountId: string;
+ provider: string;
+ externalCalendarId: string;
+ displayName?: string | null;
+ timezone?: string | null;
+ color?: string | null;
+ enabled?: boolean;
+}
+
+export interface UpdateCalendarSubscriptionSyncInput {
+ syncToken?: string | null;
+ lastSyncAt?: Date | null;
+}
+
+export interface UpdateCalendarSubscriptionChannelInput {
+ customChannelId?: string | null;
+ customResourceId?: string | null;
+ channelExpiration?: Date | null;
+}
+
+@Injectable()
+export class CalendarSubscriptionModel extends BaseModel {
+ async listByAccount(accountId: string) {
+ return await this.db.calendarSubscription.findMany({
+ where: { accountId },
+ orderBy: { createdAt: 'asc' },
+ });
+ }
+
+ async listByAccountIds(accountIds: string[]) {
+ return await this.db.calendarSubscription.findMany({
+ where: { accountId: { in: accountIds } },
+ });
+ }
+
+ async get(id: string) {
+ return await this.db.calendarSubscription.findUnique({
+ where: { id },
+ });
+ }
+
+ async getByChannelId(customChannelId: string) {
+ return await this.db.calendarSubscription.findFirst({
+ where: { customChannelId },
+ });
+ }
+
+ async upsert(input: UpsertCalendarSubscriptionInput) {
+ const data: Prisma.CalendarSubscriptionUncheckedCreateInput = {
+ accountId: input.accountId,
+ provider: input.provider,
+ externalCalendarId: input.externalCalendarId,
+ displayName: input.displayName ?? null,
+ timezone: input.timezone ?? null,
+ color: input.color ?? null,
+ enabled: input.enabled ?? true,
+ };
+
+ return await this.db.calendarSubscription.upsert({
+ where: {
+ accountId_externalCalendarId: {
+ accountId: input.accountId,
+ externalCalendarId: input.externalCalendarId,
+ },
+ },
+ create: data,
+ update: {
+ displayName: data.displayName,
+ timezone: data.timezone,
+ color: data.color,
+ enabled: data.enabled,
+ },
+ });
+ }
+
+ async updateSync(id: string, input: UpdateCalendarSubscriptionSyncInput) {
+ return await this.db.calendarSubscription.update({
+ where: { id },
+ data: {
+ syncToken: input.syncToken ?? null,
+ lastSyncAt: input.lastSyncAt ?? null,
+ },
+ });
+ }
+
+ async updateChannel(
+ id: string,
+ input: UpdateCalendarSubscriptionChannelInput
+ ) {
+ return await this.db.calendarSubscription.update({
+ where: { id },
+ data: {
+ customChannelId: input.customChannelId ?? null,
+ customResourceId: input.customResourceId ?? null,
+ channelExpiration: input.channelExpiration ?? null,
+ },
+ });
+ }
+
+ async updateEnabled(id: string, enabled: boolean) {
+ return await this.db.calendarSubscription.update({
+ where: { id },
+ data: { enabled },
+ });
+ }
+
+ async deleteByAccount(accountId: string) {
+ return await this.db.calendarSubscription.deleteMany({
+ where: { accountId },
+ });
+ }
+
+ async deleteByIds(ids: string[]) {
+ return await this.db.calendarSubscription.deleteMany({
+ where: { id: { in: ids } },
+ });
+ }
+
+ async listActiveByAccount(accountId: string) {
+ return await this.db.calendarSubscription.findMany({
+ where: { accountId, enabled: true },
+ });
+ }
+
+ async listWithAccount(id: string) {
+ return await this.db.calendarSubscription.findUnique({
+ where: { id },
+ include: { account: true },
+ });
+ }
+
+ async listWithAccounts(ids: string[]) {
+ return await this.db.calendarSubscription.findMany({
+ where: { id: { in: ids } },
+ include: { account: true },
+ });
+ }
+
+ async listAccountSubscriptions(
+ accountId: string,
+ subscriptionIds?: string[]
+ ) {
+ return await this.db.calendarSubscription.findMany({
+ where: {
+ accountId,
+ ...(subscriptionIds ? { id: { in: subscriptionIds } } : undefined),
+ },
+ });
+ }
+
+ async listAllWithAccountForSync() {
+ return await this.db.calendarSubscription.findMany({
+ where: { enabled: true },
+ include: { account: true },
+ });
+ }
+
+ async listByAccountForSync(accountId: string) {
+ return await this.db.calendarSubscription.findMany({
+ where: { accountId, enabled: true },
+ include: { account: true },
+ });
+ }
+
+ async updateLastSyncAt(id: string, lastSyncAt: Date) {
+ return await this.db.calendarSubscription.update({
+ where: { id },
+ data: { lastSyncAt },
+ });
+ }
+
+ async clearSyncTokensByAccount(accountId: string) {
+ return await this.db.calendarSubscription.updateMany({
+ where: { accountId },
+ data: { syncToken: null },
+ });
+ }
+
+ async updateManyStatus(
+ ids: string[],
+ data: Partial>
+ ) {
+ return await this.db.calendarSubscription.updateMany({
+ where: { id: { in: ids } },
+ data,
+ });
+ }
+}
diff --git a/packages/backend/server/src/models/index.ts b/packages/backend/server/src/models/index.ts
index acca6a68a..dc98b8735 100644
--- a/packages/backend/server/src/models/index.ts
+++ b/packages/backend/server/src/models/index.ts
@@ -9,6 +9,10 @@ import { ModuleRef } from '@nestjs/core';
import { ApplyType } from '../base';
import { AccessTokenModel } from './access-token';
import { BlobModel } from './blob';
+import { CalendarAccountModel } from './calendar-account';
+import { CalendarEventModel } from './calendar-event';
+import { CalendarEventInstanceModel } from './calendar-event-instance';
+import { CalendarSubscriptionModel } from './calendar-subscription';
import { CommentModel } from './comment';
import { CommentAttachmentModel } from './comment-attachment';
import { AppConfigModel } from './config';
@@ -29,6 +33,7 @@ import { UserFeatureModel } from './user-feature';
import { UserSettingsModel } from './user-settings';
import { VerificationTokenModel } from './verification-token';
import { WorkspaceModel } from './workspace';
+import { WorkspaceCalendarModel } from './workspace-calendar';
import { WorkspaceFeatureModel } from './workspace-feature';
import { WorkspaceUserModel } from './workspace-user';
@@ -56,6 +61,11 @@ const MODELS = {
commentAttachment: CommentAttachmentModel,
blob: BlobModel,
accessToken: AccessTokenModel,
+ calendarAccount: CalendarAccountModel,
+ calendarSubscription: CalendarSubscriptionModel,
+ calendarEvent: CalendarEventModel,
+ calendarEventInstance: CalendarEventInstanceModel,
+ workspaceCalendar: WorkspaceCalendarModel,
};
type ModelsType = {
@@ -108,6 +118,10 @@ const ModelsSymbolProvider: ExistingProvider = {
export class ModelsModule {}
export * from './blob';
+export * from './calendar-account';
+export * from './calendar-event';
+export * from './calendar-event-instance';
+export * from './calendar-subscription';
export * from './comment';
export * from './comment-attachment';
export * from './common';
@@ -127,5 +141,6 @@ export * from './user-feature';
export * from './user-settings';
export * from './verification-token';
export * from './workspace';
+export * from './workspace-calendar';
export * from './workspace-feature';
export * from './workspace-user';
diff --git a/packages/backend/server/src/models/workspace-calendar.ts b/packages/backend/server/src/models/workspace-calendar.ts
new file mode 100644
index 000000000..615e1fe42
--- /dev/null
+++ b/packages/backend/server/src/models/workspace-calendar.ts
@@ -0,0 +1,81 @@
+import { Injectable } from '@nestjs/common';
+
+import { BaseModel } from './base';
+
+@Injectable()
+export class WorkspaceCalendarModel extends BaseModel {
+ async get(id: string) {
+ return await this.db.workspaceCalendar.findUnique({
+ where: { id },
+ });
+ }
+
+ async getByWorkspace(workspaceId: string) {
+ return await this.db.workspaceCalendar.findMany({
+ where: { workspaceId },
+ orderBy: { createdAt: 'asc' },
+ });
+ }
+
+ async getDefault(workspaceId: string) {
+ return await this.db.workspaceCalendar.findFirst({
+ where: { workspaceId },
+ orderBy: { createdAt: 'asc' },
+ });
+ }
+
+ async getOrCreateDefault(workspaceId: string, createdByUserId: string) {
+ const existing = await this.getDefault(workspaceId);
+ if (existing) {
+ return existing;
+ }
+
+ return await this.db.workspaceCalendar.create({
+ data: {
+ workspaceId,
+ createdByUserId,
+ },
+ });
+ }
+
+ async updateItems(
+ workspaceCalendarId: string,
+ items: Array<{
+ subscriptionId: string;
+ sortOrder?: number | null;
+ colorOverride?: string | null;
+ }>
+ ) {
+ await this.db.workspaceCalendarItem.deleteMany({
+ where: { workspaceCalendarId },
+ });
+
+ if (items.length === 0) {
+ return;
+ }
+
+ await this.db.workspaceCalendarItem.createMany({
+ data: items.map((item, index) => ({
+ workspaceCalendarId,
+ subscriptionId: item.subscriptionId,
+ sortOrder: item.sortOrder ?? index,
+ colorOverride: item.colorOverride ?? null,
+ })),
+ });
+ }
+
+ async listItems(workspaceCalendarId: string) {
+ return await this.db.workspaceCalendarItem.findMany({
+ where: { workspaceCalendarId },
+ orderBy: { sortOrder: 'asc' },
+ });
+ }
+
+ async listItemsByWorkspace(workspaceId: string) {
+ return await this.db.workspaceCalendarItem.findMany({
+ where: { workspaceCalendar: { workspaceId } },
+ orderBy: { sortOrder: 'asc' },
+ include: { subscription: true },
+ });
+ }
+}
diff --git a/packages/backend/server/src/plugins/calendar/__tests__/service.spec.ts b/packages/backend/server/src/plugins/calendar/__tests__/service.spec.ts
new file mode 100644
index 000000000..8564e74f2
--- /dev/null
+++ b/packages/backend/server/src/plugins/calendar/__tests__/service.spec.ts
@@ -0,0 +1,379 @@
+import { randomUUID } from 'node:crypto';
+import { mock } from 'node:test';
+
+import test from 'ava';
+
+import { createModule } from '../../../__tests__/create-module';
+import { Mockers } from '../../../__tests__/mocks';
+import { CryptoHelper } from '../../../base';
+import { ConfigModule } from '../../../base/config';
+import { ServerConfigModule } from '../../../core/config';
+import type {
+ UpsertCalendarAccountInput,
+ UpsertCalendarSubscriptionInput,
+} from '../../../models';
+import { Models } from '../../../models';
+import { CalendarModule } from '..';
+import {
+ CalendarProvider,
+ CalendarProviderFactory,
+ CalendarProviderName,
+ CalendarSyncTokenInvalid,
+} from '../providers';
+import type {
+ CalendarProviderListEventsParams,
+ CalendarProviderStopParams,
+ CalendarProviderWatchParams,
+} from '../providers/def';
+import { CalendarService } from '../service';
+
+class MockCalendarProvider extends CalendarProvider {
+ override provider = CalendarProviderName.Google;
+
+ override getAuthUrl(_state: string, _redirectUri: string) {
+ return 'https://example.com/oauth';
+ }
+
+ override async exchangeCode(_code: string, _redirectUri: string) {
+ return { accessToken: 'access-token' };
+ }
+
+ override async refreshTokens(_refreshToken: string) {
+ return { accessToken: 'access-token' };
+ }
+
+ override async getAccountProfile(_accessToken: string) {
+ return { providerAccountId: 'mock-account' };
+ }
+
+ override async listCalendars(_accessToken: string) {
+ return [];
+ }
+
+ override async listEvents(_params: CalendarProviderListEventsParams) {
+ return { events: [] };
+ }
+
+ override async watchCalendar(_params: CalendarProviderWatchParams) {
+ return {
+ channelId: 'mock-channel',
+ resourceId: 'mock-resource',
+ };
+ }
+
+ override async stopChannel(_params: CalendarProviderStopParams) {
+ return;
+ }
+}
+
+const module = await createModule({
+ imports: [
+ ServerConfigModule,
+ CalendarModule,
+ ConfigModule.override({
+ calendar: {
+ google: {
+ enabled: true,
+ clientId: 'calendar-client-id',
+ clientSecret: 'calendar-client-secret',
+ externalWebhookUrl: 'https://calendar.example.com',
+ webhookVerificationToken: 'calendar-webhook-token',
+ },
+ },
+ }),
+ ],
+});
+const calendarService = module.get(CalendarService);
+const providerFactory = module.get(CalendarProviderFactory);
+const models = module.get(Models);
+module.get(CryptoHelper).onConfigInit();
+
+const createAccount = async (
+ userId: string,
+ overrides: Partial = {}
+) => {
+ return await models.calendarAccount.upsert({
+ userId,
+ provider: overrides.provider ?? CalendarProviderName.Google,
+ providerAccountId: overrides.providerAccountId ?? randomUUID(),
+ displayName: overrides.displayName ?? 'Test Account',
+ email: overrides.email ?? 'calendar@example.com',
+ accessToken: overrides.accessToken ?? 'access-token',
+ refreshToken: overrides.refreshToken ?? 'refresh-token',
+ expiresAt: overrides.expiresAt ?? new Date(Date.now() + 5 * 60 * 1000),
+ scope: overrides.scope ?? null,
+ status: overrides.status ?? 'active',
+ lastError: overrides.lastError ?? null,
+ refreshIntervalMinutes: overrides.refreshIntervalMinutes ?? 30,
+ });
+};
+
+const createSubscription = async (
+ accountId: string,
+ overrides: Partial & {
+ syncToken?: string | null;
+ customChannelId?: string | null;
+ customResourceId?: string | null;
+ channelExpiration?: Date | null;
+ } = {}
+) => {
+ const subscription = await models.calendarSubscription.upsert({
+ accountId,
+ provider: overrides.provider ?? CalendarProviderName.Google,
+ externalCalendarId: overrides.externalCalendarId ?? randomUUID(),
+ displayName: overrides.displayName ?? 'Test Calendar',
+ timezone: overrides.timezone ?? 'UTC',
+ color: overrides.color ?? null,
+ enabled: overrides.enabled ?? true,
+ });
+
+ if (overrides.syncToken !== undefined) {
+ await models.calendarSubscription.updateSync(subscription.id, {
+ syncToken: overrides.syncToken,
+ });
+ }
+
+ if (
+ overrides.customChannelId !== undefined ||
+ overrides.customResourceId !== undefined ||
+ overrides.channelExpiration !== undefined
+ ) {
+ await models.calendarSubscription.updateChannel(subscription.id, {
+ customChannelId: overrides.customChannelId ?? null,
+ customResourceId: overrides.customResourceId ?? null,
+ channelExpiration: overrides.channelExpiration ?? null,
+ });
+ }
+
+ return (await models.calendarSubscription.get(subscription.id))!;
+};
+
+test.afterEach.always(() => {
+ mock.reset();
+});
+
+test.after.always(async () => {
+ await module.close();
+});
+
+test('listAccounts includes calendars count', async t => {
+ const user = await module.create(Mockers.User);
+ const accountA = await createAccount(user.id);
+ const accountB = await createAccount(user.id);
+
+ await createSubscription(accountA.id, {
+ externalCalendarId: randomUUID(),
+ });
+ await createSubscription(accountA.id, {
+ externalCalendarId: randomUUID(),
+ });
+ await createSubscription(accountB.id, {
+ externalCalendarId: randomUUID(),
+ });
+
+ const accounts = await calendarService.listAccounts(user.id);
+ t.is(accounts.length, 2);
+
+ const counts = new Map(
+ accounts.map(account => [account.id, account.calendarsCount])
+ );
+ t.is(counts.get(accountA.id), 2);
+ t.is(counts.get(accountB.id), 1);
+});
+
+test('syncSubscription resets invalid sync token and maps events', async t => {
+ const user = await module.create(Mockers.User);
+ const account = await createAccount(user.id);
+ const subscription = await createSubscription(account.id, {
+ syncToken: 'stale-token',
+ timezone: 'UTC',
+ });
+
+ const cancelledId = randomUUID();
+ const allDayId = randomUUID();
+
+ await models.calendarEvent.upsert({
+ subscriptionId: subscription.id,
+ externalEventId: cancelledId,
+ recurrenceId: null,
+ etag: null,
+ status: 'confirmed',
+ title: 'to cancel',
+ description: null,
+ location: null,
+ startAtUtc: new Date('2024-01-10T05:00:00.000Z'),
+ endAtUtc: new Date('2024-01-10T06:00:00.000Z'),
+ originalTimezone: 'UTC',
+ allDay: false,
+ providerUpdatedAt: null,
+ raw: {},
+ });
+
+ const provider = new MockCalendarProvider();
+ let callCount = 0;
+ const listEventsMock = mock.method(provider, 'listEvents', async (_: any) => {
+ callCount += 1;
+ if (callCount === 1) {
+ throw new CalendarSyncTokenInvalid('sync token expired');
+ }
+
+ return {
+ events: [
+ {
+ id: cancelledId,
+ status: 'cancelled',
+ start: { dateTime: '2024-01-10T05:00:00.000Z' },
+ end: { dateTime: '2024-01-10T06:00:00.000Z' },
+ raw: {},
+ },
+ {
+ id: allDayId,
+ status: 'confirmed',
+ start: { date: '2024-01-10', timeZone: 'UTC' },
+ end: { date: '2024-01-11', timeZone: 'UTC' },
+ raw: { source: 'test' },
+ },
+ ],
+ nextSyncToken: 'next-token',
+ };
+ });
+
+ mock.method(providerFactory, 'get', () => provider);
+
+ await calendarService.syncSubscription(subscription.id);
+
+ t.is(listEventsMock.mock.callCount(), 2);
+ t.is(listEventsMock.mock.calls[0].arguments[0].syncToken, 'stale-token');
+ t.falsy(listEventsMock.mock.calls[0].arguments[0].timeMin);
+ t.truthy(listEventsMock.mock.calls[1].arguments[0].timeMin);
+ t.truthy(listEventsMock.mock.calls[1].arguments[0].timeMax);
+
+ const updated = await models.calendarSubscription.get(subscription.id);
+ t.is(updated?.syncToken, 'next-token');
+ t.truthy(updated?.lastSyncAt);
+
+ const events = await models.calendarEvent.listBySubscriptionsInRange(
+ [subscription.id],
+ new Date('2024-01-09T00:00:00.000Z'),
+ new Date('2024-01-12T00:00:00.000Z')
+ );
+ const allDayEvent = events.find(event => event.externalEventId === allDayId);
+ t.truthy(allDayEvent);
+ t.is(allDayEvent?.allDay, true);
+ t.is(allDayEvent?.originalTimezone, 'UTC');
+ t.is(allDayEvent?.startAtUtc.toISOString(), '2024-01-10T00:00:00.000Z');
+ t.is(allDayEvent?.endAtUtc.toISOString(), '2024-01-11T00:00:00.000Z');
+ t.is(
+ events.some(event => event.externalEventId === cancelledId),
+ false
+ );
+});
+
+test('syncSubscription invalidates account on invalid grant', async t => {
+ const user = await module.create(Mockers.User);
+ const account = await createAccount(user.id);
+ const subscription = await createSubscription(account.id, {
+ syncToken: 'sync-token',
+ });
+
+ await models.calendarEvent.upsert({
+ subscriptionId: subscription.id,
+ externalEventId: randomUUID(),
+ recurrenceId: null,
+ etag: null,
+ status: 'confirmed',
+ title: 'existing',
+ description: null,
+ location: null,
+ startAtUtc: new Date('2024-01-02T00:00:00.000Z'),
+ endAtUtc: new Date('2024-01-02T01:00:00.000Z'),
+ originalTimezone: 'UTC',
+ allDay: false,
+ providerUpdatedAt: null,
+ raw: {},
+ });
+
+ const provider = new MockCalendarProvider();
+ mock.method(provider, 'listEvents', async () => {
+ throw new Error('invalid_grant');
+ });
+ mock.method(providerFactory, 'get', () => provider);
+
+ await calendarService.syncSubscription(subscription.id);
+
+ const updatedAccount = await models.calendarAccount.get(account.id);
+ t.is(updatedAccount?.status, 'invalid');
+ t.truthy(updatedAccount?.lastError);
+
+ const updatedSubscription = await models.calendarSubscription.get(
+ subscription.id
+ );
+ t.is(updatedSubscription?.syncToken, null);
+
+ const events = await models.calendarEvent.listBySubscriptionsInRange(
+ [subscription.id],
+ new Date('2024-01-01T00:00:00.000Z'),
+ new Date('2024-01-03T00:00:00.000Z')
+ );
+ t.is(events.length, 0);
+});
+
+test('syncSubscription renews webhook channel when expiring', async t => {
+ const user = await module.create(Mockers.User);
+ const account = await createAccount(user.id);
+ const subscription = await createSubscription(account.id, {
+ syncToken: 'sync-token',
+ customChannelId: 'old-channel',
+ customResourceId: 'old-resource',
+ channelExpiration: new Date(Date.now() + 60 * 60 * 1000),
+ });
+
+ const provider = new MockCalendarProvider();
+ mock.method(provider, 'listEvents', async () => ({
+ events: [],
+ nextSyncToken: 'next-sync',
+ }));
+
+ provider.watchCalendar = async () => ({
+ channelId: 'new-channel',
+ resourceId: 'new-resource',
+ expiration: new Date(Date.now() + 2 * 24 * 60 * 60 * 1000),
+ });
+ provider.stopChannel = async () => {
+ return;
+ };
+
+ const watchMock = mock.method(
+ provider,
+ 'watchCalendar',
+ async (_: CalendarProviderWatchParams) => {
+ return {
+ channelId: 'new-channel',
+ resourceId: 'new-resource',
+ expiration: new Date(Date.now() + 2 * 24 * 60 * 60 * 1000),
+ };
+ }
+ );
+ const stopMock = mock.method(provider, 'stopChannel', async () => {
+ return;
+ });
+
+ mock.method(providerFactory, 'get', () => provider);
+
+ await calendarService.syncSubscription(subscription.id);
+
+ t.is(stopMock.mock.callCount(), 1);
+ t.is(watchMock.mock.callCount(), 1);
+ const watchArgs = watchMock.mock.calls[0].arguments[0];
+ t.is(
+ watchArgs.address,
+ 'https://calendar.example.com/api/calendar/webhook/google'
+ );
+ t.is(watchArgs.token, 'calendar-webhook-token');
+ t.is(watchArgs.calendarId, subscription.externalCalendarId);
+
+ const updated = await models.calendarSubscription.get(subscription.id);
+ t.is(updated?.customChannelId, 'new-channel');
+ t.is(updated?.customResourceId, 'new-resource');
+ t.truthy(updated?.channelExpiration);
+});
diff --git a/packages/backend/server/src/plugins/calendar/config.ts b/packages/backend/server/src/plugins/calendar/config.ts
new file mode 100644
index 000000000..ab4253685
--- /dev/null
+++ b/packages/backend/server/src/plugins/calendar/config.ts
@@ -0,0 +1,57 @@
+import { z } from 'zod';
+
+import { defineModuleConfig, JSONSchema } from '../../base';
+
+export interface CalendarGoogleConfig {
+ enabled: boolean;
+ clientId: string;
+ clientSecret: string;
+ externalWebhookUrl?: string;
+ webhookVerificationToken?: string;
+}
+
+declare global {
+ interface AppConfigSchema {
+ calendar: {
+ google: ConfigItem;
+ };
+ }
+}
+
+const schema: JSONSchema = {
+ type: 'object',
+ properties: {
+ enabled: { type: 'boolean' },
+ clientId: { type: 'string' },
+ clientSecret: { type: 'string' },
+ externalWebhookUrl: { type: 'string' },
+ webhookVerificationToken: { type: 'string' },
+ },
+};
+
+defineModuleConfig('calendar', {
+ google: {
+ desc: 'Google Calendar integration config',
+ default: {
+ enabled: false,
+ clientId: '',
+ clientSecret: '',
+ externalWebhookUrl: '',
+ webhookVerificationToken: '',
+ },
+ schema,
+ shape: z.object({
+ enabled: z.boolean(),
+ clientId: z.string(),
+ clientSecret: z.string(),
+ externalWebhookUrl: z
+ .string()
+ .url()
+ .regex(/^https:\/\//, 'externalWebhookUrl must be https')
+ .or(z.string().length(0))
+ .optional(),
+ webhookVerificationToken: z.string().optional(),
+ }),
+ link: 'https://developers.google.com/calendar/api/guides/push',
+ },
+});
diff --git a/packages/backend/server/src/plugins/calendar/controller.ts b/packages/backend/server/src/plugins/calendar/controller.ts
new file mode 100644
index 000000000..2977cae97
--- /dev/null
+++ b/packages/backend/server/src/plugins/calendar/controller.ts
@@ -0,0 +1,170 @@
+import {
+ Body,
+ Controller,
+ Get,
+ HttpCode,
+ HttpStatus,
+ Post,
+ Query,
+ Req,
+ Res,
+} from '@nestjs/common';
+import type { Request, Response } from 'express';
+
+import {
+ CalendarProviderRequestError,
+ MissingOauthQueryParameter,
+ OauthStateExpired,
+ UnknownOauthProvider,
+ URLHelper,
+} from '../../base';
+import { CurrentUser, Public } from '../../core/auth';
+import { CalendarOAuthService } from './oauth';
+import { CalendarProviderName } from './providers';
+import { CalendarService } from './service';
+
+@Controller('/api/calendar')
+export class CalendarController {
+ constructor(
+ private readonly calendar: CalendarService,
+ private readonly oauth: CalendarOAuthService,
+ private readonly url: URLHelper
+ ) {}
+
+ @Post('/oauth/preflight')
+ @HttpCode(HttpStatus.OK)
+ async preflight(
+ @CurrentUser() user: CurrentUser,
+ @Body('provider') providerName?: CalendarProviderName,
+ @Body('redirect_uri') redirectUri?: string
+ ) {
+ if (!providerName) {
+ throw new MissingOauthQueryParameter({ name: 'provider' });
+ }
+
+ if (!this.calendar.isProviderAvailable(providerName)) {
+ throw new UnknownOauthProvider({ name: providerName });
+ }
+
+ const state = await this.oauth.saveOAuthState({
+ provider: providerName,
+ userId: user.id,
+ redirectUri,
+ });
+
+ const callbackUrl = this.calendar.getCallbackUrl();
+ const authUrl = this.calendar.getAuthUrl(providerName, state, callbackUrl);
+
+ return { url: authUrl };
+ }
+
+ @Public()
+ @Get('/oauth/callback')
+ @HttpCode(HttpStatus.OK)
+ async callbackGet(
+ @Res() res: Response,
+ @Query('code') code?: string,
+ @Query('state') stateStr?: string
+ ) {
+ return this.handleCallback(res, code, stateStr);
+ }
+
+ @Public()
+ @Post('/oauth/callback')
+ @HttpCode(HttpStatus.OK)
+ async callback(
+ @Res() res: Response,
+ @Body('code') code?: string,
+ @Body('state') stateStr?: string
+ ) {
+ return this.handleCallback(res, code, stateStr);
+ }
+
+ @Public()
+ @Post('/webhook/google')
+ @HttpCode(HttpStatus.OK)
+ async googleWebhook(@Req() req: Request, @Res() res: Response) {
+ if (!this.calendar.getWebhookAddress('google')) {
+ return res.send();
+ }
+
+ const channelId = req.header('x-goog-channel-id');
+ if (!channelId) {
+ return res.send();
+ }
+
+ const token = req.header('x-goog-channel-token');
+ const expectedToken = this.calendar.getWebhookToken();
+ if (expectedToken && token !== expectedToken) {
+ return res.status(401).send();
+ }
+
+ await this.calendar.handleWebhook(CalendarProviderName.Google, channelId);
+ return res.send();
+ }
+
+ private async handleCallback(
+ res: Response,
+ code?: string,
+ stateStr?: string
+ ) {
+ if (!code) {
+ throw new MissingOauthQueryParameter({ name: 'code' });
+ }
+
+ if (!stateStr) {
+ throw new MissingOauthQueryParameter({ name: 'state' });
+ }
+
+ if (typeof stateStr !== 'string' || !this.oauth.isValidState(stateStr)) {
+ throw new MissingOauthQueryParameter({ name: 'state' });
+ }
+
+ const state = await this.oauth.getOAuthState(stateStr);
+ if (!state) {
+ throw new OauthStateExpired();
+ }
+
+ const callbackUrl = this.calendar.getCallbackUrl();
+ try {
+ await this.calendar.handleOAuthCallback({
+ provider: state.provider,
+ code,
+ redirectUri: callbackUrl,
+ userId: state.userId,
+ });
+ } catch (error) {
+ if (state.redirectUri) {
+ const message = this.getCallbackErrorMessage(error);
+ const redirectUrl = this.buildErrorRedirect(state.redirectUri, message);
+ return this.url.safeRedirect(res, redirectUrl);
+ }
+ throw error;
+ }
+
+ if (state.redirectUri) {
+ return this.url.safeRedirect(res, state.redirectUri);
+ }
+
+ return res.status(200).send({ ok: true });
+ }
+
+ private buildErrorRedirect(redirectUri: string, message: string) {
+ const url = new URL(redirectUri, this.url.requestBaseUrl);
+ url.searchParams.set('error', message);
+ return url.toString();
+ }
+
+ private getCallbackErrorMessage(error: unknown) {
+ if (error instanceof CalendarProviderRequestError) {
+ if (error.status === 403) {
+ return 'Calendar authorization failed: insufficient permissions. Please reauthorize and allow Calendar access.';
+ }
+ return 'Calendar authorization failed. Please try again.';
+ }
+ if (error instanceof Error && error.message) {
+ return error.message;
+ }
+ return 'Calendar authorization failed.';
+ }
+}
diff --git a/packages/backend/server/src/plugins/calendar/cron.ts b/packages/backend/server/src/plugins/calendar/cron.ts
new file mode 100644
index 000000000..94c3ad1fd
--- /dev/null
+++ b/packages/backend/server/src/plugins/calendar/cron.ts
@@ -0,0 +1,61 @@
+import { Injectable } from '@nestjs/common';
+import { Cron, CronExpression } from '@nestjs/schedule';
+
+import { Models } from '../../models';
+import { CalendarService } from './service';
+
+@Injectable()
+export class CalendarCronJobs {
+ constructor(
+ private readonly models: Models,
+ private readonly calendar: CalendarService
+ ) {}
+
+ @Cron(CronExpression.EVERY_MINUTE)
+ async pollAccounts() {
+ const subscriptions =
+ await this.models.calendarSubscription.listAllWithAccountForSync();
+
+ const accountDueAt = new Map<
+ string,
+ { refreshInterval: number; lastSyncAt: Date | null }
+ >();
+
+ for (const subscription of subscriptions) {
+ const interval = subscription.account.refreshIntervalMinutes ?? 60;
+ const lastSyncAt = subscription.lastSyncAt ?? null;
+ const existing = accountDueAt.get(subscription.accountId);
+ if (!existing) {
+ accountDueAt.set(subscription.accountId, {
+ refreshInterval: interval,
+ lastSyncAt,
+ });
+ continue;
+ }
+
+ const earliest =
+ existing.lastSyncAt && lastSyncAt
+ ? existing.lastSyncAt < lastSyncAt
+ ? existing.lastSyncAt
+ : lastSyncAt
+ : (existing.lastSyncAt ?? lastSyncAt);
+ accountDueAt.set(subscription.accountId, {
+ refreshInterval: interval,
+ lastSyncAt: earliest,
+ });
+ }
+
+ const now = Date.now();
+ await Promise.allSettled(
+ Array.from(accountDueAt.entries()).map(([accountId, info]) => {
+ if (
+ !info.lastSyncAt ||
+ now - info.lastSyncAt.getTime() >= info.refreshInterval * 60 * 1000
+ ) {
+ return this.calendar.syncAccount(accountId);
+ }
+ return Promise.resolve();
+ })
+ );
+ }
+}
diff --git a/packages/backend/server/src/plugins/calendar/index.ts b/packages/backend/server/src/plugins/calendar/index.ts
new file mode 100644
index 000000000..31c7c7d7c
--- /dev/null
+++ b/packages/backend/server/src/plugins/calendar/index.ts
@@ -0,0 +1,27 @@
+import './config';
+
+import { Module } from '@nestjs/common';
+
+import { AuthModule } from '../../core/auth';
+import { PermissionModule } from '../../core/permission';
+import { WorkspaceModule } from '../../core/workspaces';
+import { CalendarController } from './controller';
+import { CalendarCronJobs } from './cron';
+import { CalendarOAuthService } from './oauth';
+import { CalendarProviderFactory, CalendarProviders } from './providers';
+import { CalendarResolver } from './resolver';
+import { CalendarService } from './service';
+
+@Module({
+ imports: [AuthModule, PermissionModule, WorkspaceModule],
+ providers: [
+ ...CalendarProviders,
+ CalendarProviderFactory,
+ CalendarService,
+ CalendarOAuthService,
+ CalendarCronJobs,
+ CalendarResolver,
+ ],
+ controllers: [CalendarController],
+})
+export class CalendarModule {}
diff --git a/packages/backend/server/src/plugins/calendar/oauth.ts b/packages/backend/server/src/plugins/calendar/oauth.ts
new file mode 100644
index 000000000..b2accf25b
--- /dev/null
+++ b/packages/backend/server/src/plugins/calendar/oauth.ts
@@ -0,0 +1,39 @@
+import { randomUUID } from 'node:crypto';
+
+import { Injectable } from '@nestjs/common';
+
+import { SessionCache } from '../../base';
+import { CalendarProviderName } from './providers';
+
+export interface CalendarOAuthState {
+ provider: CalendarProviderName;
+ userId: string;
+ redirectUri?: string;
+ token?: string;
+}
+
+const CALENDAR_OAUTH_STATE_KEY = 'CALENDAR_OAUTH_STATE';
+
+@Injectable()
+export class CalendarOAuthService {
+ constructor(private readonly cache: SessionCache) {}
+
+ isValidState(stateStr: string) {
+ return stateStr.length === 36;
+ }
+
+ async saveOAuthState(state: CalendarOAuthState) {
+ const token = randomUUID();
+ const payload: CalendarOAuthState = { ...state, token };
+ await this.cache.set(`${CALENDAR_OAUTH_STATE_KEY}:${token}`, payload, {
+ ttl: 3600 * 3 * 1000,
+ });
+ return token;
+ }
+
+ async getOAuthState(token: string) {
+ return this.cache.get(
+ `${CALENDAR_OAUTH_STATE_KEY}:${token}`
+ );
+ }
+}
diff --git a/packages/backend/server/src/plugins/calendar/providers/def.ts b/packages/backend/server/src/plugins/calendar/providers/def.ts
new file mode 100644
index 000000000..f04779849
--- /dev/null
+++ b/packages/backend/server/src/plugins/calendar/providers/def.ts
@@ -0,0 +1,180 @@
+import { Inject, Injectable, Logger } from '@nestjs/common';
+
+import { CalendarProviderRequestError, Config, OnEvent } from '../../../base';
+import { CalendarProviderFactory } from './factory';
+
+export enum CalendarProviderName {
+ Google = 'google',
+ CalDAV = 'caldav',
+}
+
+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;
+}
+
+export interface CalendarProviderListEventsParams {
+ accessToken: string;
+ calendarId: string;
+ timeMin?: string;
+ timeMax?: string;
+ syncToken?: string;
+}
+
+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;
+ abstract refreshTokens(refreshToken: string): Promise;
+ abstract getAccountProfile(
+ accessToken: string
+ ): Promise;
+ abstract listCalendars(
+ accessToken: string
+ ): Promise;
+ abstract listEvents(
+ params: CalendarProviderListEventsParams
+ ): Promise;
+ abstract watchCalendar?(
+ params: CalendarProviderWatchParams
+ ): Promise;
+ abstract stopChannel?(params: CalendarProviderStopParams): Promise;
+
+ 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)[this.provider];
+ }
+
+ get configured() {
+ return (
+ !!this.config &&
+ !!this.config.enabled &&
+ !!this.config.clientId &&
+ !!this.config.clientSecret
+ );
+ }
+
+ @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 async fetchJson(url: string, init?: RequestInit) {
+ const response = await fetch(url, {
+ headers: { Accept: 'application/json', ...init?.headers },
+ ...init,
+ });
+ 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(
+ url: string,
+ body: string,
+ options?: { headers?: Record }
+ ) {
+ return this.fetchJson(url, {
+ method: 'POST',
+ body,
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ ...options?.headers,
+ },
+ });
+ }
+}
diff --git a/packages/backend/server/src/plugins/calendar/providers/factory.ts b/packages/backend/server/src/plugins/calendar/providers/factory.ts
new file mode 100644
index 000000000..b17f697d8
--- /dev/null
+++ b/packages/backend/server/src/plugins/calendar/providers/factory.ts
@@ -0,0 +1,28 @@
+import { Injectable, Logger } from '@nestjs/common';
+
+import type { CalendarProvider } from './def';
+import { CalendarProviderName } from './def';
+
+@Injectable()
+export class CalendarProviderFactory {
+ private readonly logger = new Logger(CalendarProviderFactory.name);
+ readonly #providers = new Map();
+
+ get providers() {
+ return Array.from(this.#providers.keys());
+ }
+
+ get(name: CalendarProviderName) {
+ return this.#providers.get(name);
+ }
+
+ register(provider: CalendarProvider) {
+ this.#providers.set(provider.provider, provider);
+ this.logger.log(`Calendar provider [${provider.provider}] registered.`);
+ }
+
+ unregister(provider: CalendarProvider) {
+ this.#providers.delete(provider.provider);
+ this.logger.log(`Calendar provider [${provider.provider}] unregistered.`);
+ }
+}
diff --git a/packages/backend/server/src/plugins/calendar/providers/google.ts b/packages/backend/server/src/plugins/calendar/providers/google.ts
new file mode 100644
index 000000000..ffabf5f60
--- /dev/null
+++ b/packages/backend/server/src/plugins/calendar/providers/google.ts
@@ -0,0 +1,351 @@
+import { Injectable } from '@nestjs/common';
+
+import { CalendarProviderRequestError } from '../../../base';
+import { CalendarProvider } from './def';
+import {
+ CalendarProviderEvent,
+ CalendarProviderListEventsParams,
+ CalendarProviderListEventsResult,
+ CalendarProviderName,
+ CalendarProviderTokens,
+ CalendarProviderWatchParams,
+ CalendarProviderWatchResult,
+} from './def';
+
+export class CalendarSyncTokenInvalid extends Error {
+ readonly code = 'calendar_sync_token_invalid';
+}
+
+type GoogleTokenResponse = {
+ access_token: string;
+ refresh_token?: string;
+ expires_in?: number;
+ scope?: string;
+ token_type?: string;
+};
+
+type GoogleUserInfo = {
+ id: string;
+ email?: string;
+ name?: string;
+};
+
+type GoogleCalendarListResponse = {
+ items?: Array<{
+ id: string;
+ summary?: string;
+ timeZone?: string;
+ colorId?: string;
+ primary?: boolean;
+ }>;
+ nextPageToken?: string;
+};
+
+type GoogleEventItem = {
+ id: string;
+ status?: string;
+ etag?: string;
+ summary?: string;
+ description?: string;
+ location?: string;
+ updated?: string;
+ recurringEventId?: string;
+ originalStartTime?: {
+ date?: string;
+ dateTime?: string;
+ timeZone?: string;
+ };
+ start: {
+ date?: string;
+ dateTime?: string;
+ timeZone?: string;
+ };
+ end: {
+ date?: string;
+ dateTime?: string;
+ timeZone?: string;
+ };
+};
+
+type GoogleEventsResponse = {
+ items?: GoogleEventItem[];
+ nextPageToken?: string;
+ nextSyncToken?: string;
+};
+
+type GoogleWatchResponse = {
+ id: string;
+ resourceId: string;
+ expiration?: string;
+};
+
+@Injectable()
+export class GoogleCalendarProvider extends CalendarProvider {
+ provider = CalendarProviderName.Google;
+
+ getAuthUrl(state: string, redirectUri: string) {
+ const params = new URLSearchParams({
+ client_id: this.config.clientId,
+ redirect_uri: redirectUri,
+ response_type: 'code',
+ access_type: 'offline',
+ prompt: 'consent',
+ include_granted_scopes: 'true',
+ scope: [
+ 'https://www.googleapis.com/auth/calendar.readonly',
+ 'https://www.googleapis.com/auth/userinfo.email',
+ 'https://www.googleapis.com/auth/userinfo.profile',
+ ].join(' '),
+ state,
+ });
+
+ return `https://accounts.google.com/o/oauth2/v2/auth?${params.toString()}`;
+ }
+
+ async exchangeCode(
+ code: string,
+ redirectUri: string
+ ): Promise {
+ const payload = new URLSearchParams({
+ code,
+ client_id: this.config.clientId,
+ client_secret: this.config.clientSecret,
+ redirect_uri: redirectUri,
+ grant_type: 'authorization_code',
+ });
+
+ const response = await this.postFormJson(
+ 'https://oauth2.googleapis.com/token',
+ payload.toString()
+ );
+
+ return {
+ accessToken: response.access_token,
+ refreshToken: response.refresh_token,
+ scope: response.scope,
+ tokenType: response.token_type,
+ expiresAt: response.expires_in
+ ? new Date(Date.now() + response.expires_in * 1000)
+ : undefined,
+ };
+ }
+
+ async refreshTokens(refreshToken: string): Promise {
+ const payload = new URLSearchParams({
+ refresh_token: refreshToken,
+ client_id: this.config.clientId,
+ client_secret: this.config.clientSecret,
+ grant_type: 'refresh_token',
+ });
+
+ const response = await this.postFormJson(
+ 'https://oauth2.googleapis.com/token',
+ payload.toString()
+ );
+
+ return {
+ accessToken: response.access_token,
+ refreshToken,
+ scope: response.scope,
+ tokenType: response.token_type,
+ expiresAt: response.expires_in
+ ? new Date(Date.now() + response.expires_in * 1000)
+ : undefined,
+ };
+ }
+
+ async getAccountProfile(accessToken: string) {
+ const response = await this.fetchJson(
+ 'https://www.googleapis.com/oauth2/v2/userinfo',
+ {
+ headers: {
+ Authorization: `Bearer ${accessToken}`,
+ },
+ }
+ );
+
+ return {
+ providerAccountId: response.id,
+ displayName: response.name,
+ email: response.email,
+ };
+ }
+
+ async listCalendars(accessToken: string) {
+ const calendars: GoogleCalendarListResponse['items'] = [];
+ let pageToken: string | undefined;
+
+ do {
+ const url = new URL(
+ 'https://www.googleapis.com/calendar/v3/users/me/calendarList'
+ );
+ url.searchParams.set('maxResults', '250');
+ if (pageToken) {
+ url.searchParams.set('pageToken', pageToken);
+ }
+
+ const response = await this.fetchJson(
+ url.toString(),
+ {
+ headers: {
+ Authorization: `Bearer ${accessToken}`,
+ },
+ }
+ );
+
+ if (response.items?.length) {
+ calendars.push(...response.items);
+ }
+ pageToken = response.nextPageToken;
+ } while (pageToken);
+
+ return calendars.map(item => ({
+ id: item.id,
+ summary: item.summary,
+ timeZone: item.timeZone,
+ colorId: item.colorId,
+ primary: item.primary,
+ }));
+ }
+
+ async listEvents(
+ params: CalendarProviderListEventsParams
+ ): Promise {
+ const events: CalendarProviderEvent[] = [];
+ let pageToken: string | undefined;
+ let nextSyncToken: string | undefined;
+
+ do {
+ const url = new URL(
+ `https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(
+ params.calendarId
+ )}/events`
+ );
+ url.searchParams.set('singleEvents', 'true');
+ url.searchParams.set('showDeleted', 'true');
+ url.searchParams.set('maxResults', '2500');
+
+ if (params.syncToken) {
+ url.searchParams.set('syncToken', params.syncToken);
+ } else {
+ if (params.timeMin) {
+ url.searchParams.set('timeMin', params.timeMin);
+ }
+ if (params.timeMax) {
+ url.searchParams.set('timeMax', params.timeMax);
+ }
+ url.searchParams.set('orderBy', 'startTime');
+ }
+
+ if (pageToken) {
+ url.searchParams.set('pageToken', pageToken);
+ }
+
+ const response = await this.fetchWithTokenHandling(
+ url.toString(),
+ params.accessToken
+ );
+
+ if (response.items?.length) {
+ for (const item of response.items) {
+ events.push({
+ id: item.id,
+ status: item.status,
+ etag: item.etag,
+ summary: item.summary,
+ description: item.description,
+ location: item.location,
+ updated: item.updated,
+ recurringEventId: item.recurringEventId,
+ originalStartTime: item.originalStartTime,
+ start: item.start,
+ end: item.end,
+ raw: item as Record,
+ });
+ }
+ }
+
+ pageToken = response.nextPageToken;
+ if (response.nextSyncToken) {
+ nextSyncToken = response.nextSyncToken;
+ }
+ } while (pageToken);
+
+ return { events, nextSyncToken };
+ }
+
+ async watchCalendar(
+ params: CalendarProviderWatchParams
+ ): Promise {
+ const response = await this.fetchJson(
+ `https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(
+ params.calendarId
+ )}/events/watch`,
+ {
+ method: 'POST',
+ headers: {
+ Authorization: `Bearer ${params.accessToken}`,
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ id: params.channelId,
+ type: 'web_hook',
+ address: params.address,
+ token: params.token,
+ }),
+ }
+ );
+
+ return {
+ channelId: response.id,
+ resourceId: response.resourceId,
+ expiration: response.expiration
+ ? new Date(Number(response.expiration))
+ : undefined,
+ };
+ }
+
+ async stopChannel(params: {
+ accessToken: string;
+ channelId: string;
+ resourceId: string;
+ }) {
+ await this.fetchJson(
+ 'https://www.googleapis.com/calendar/v3/channels/stop',
+ {
+ method: 'POST',
+ headers: {
+ Authorization: `Bearer ${params.accessToken}`,
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ id: params.channelId,
+ resourceId: params.resourceId,
+ }),
+ }
+ );
+ }
+
+ private async fetchWithTokenHandling(url: string, accessToken: string) {
+ const response = await fetch(url, {
+ headers: {
+ Accept: 'application/json',
+ Authorization: `Bearer ${accessToken}`,
+ },
+ });
+ const body = await response.text();
+ if (!response.ok) {
+ if (response.status === 410) {
+ throw new CalendarSyncTokenInvalid(body);
+ }
+ throw new CalendarProviderRequestError({
+ status: response.status,
+ message: body,
+ });
+ }
+ if (!body) {
+ return {} as T;
+ }
+ return JSON.parse(body) as T;
+ }
+}
diff --git a/packages/backend/server/src/plugins/calendar/providers/index.ts b/packages/backend/server/src/plugins/calendar/providers/index.ts
new file mode 100644
index 000000000..ee6d14e6c
--- /dev/null
+++ b/packages/backend/server/src/plugins/calendar/providers/index.ts
@@ -0,0 +1,19 @@
+import { GoogleCalendarProvider } from './google';
+
+export type {
+ CalendarAccountProfile,
+ CalendarProviderCalendar,
+ CalendarProviderEvent,
+ CalendarProviderEventTime,
+ CalendarProviderListEventsParams,
+ CalendarProviderListEventsResult,
+ CalendarProviderTokens,
+ CalendarProviderWatchParams,
+ CalendarProviderWatchResult,
+} from './def';
+export { CalendarProviderName } from './def';
+export { CalendarProvider } from './def';
+export { CalendarProviderFactory } from './factory';
+export { CalendarSyncTokenInvalid, GoogleCalendarProvider } from './google';
+
+export const CalendarProviders = [GoogleCalendarProvider];
diff --git a/packages/backend/server/src/plugins/calendar/resolver.ts b/packages/backend/server/src/plugins/calendar/resolver.ts
new file mode 100644
index 000000000..efebf3404
--- /dev/null
+++ b/packages/backend/server/src/plugins/calendar/resolver.ts
@@ -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,
+ };
+ }
+}
diff --git a/packages/backend/server/src/plugins/calendar/service.ts b/packages/backend/server/src/plugins/calendar/service.ts
new file mode 100644
index 000000000..937abc3eb
--- /dev/null
+++ b/packages/backend/server/src/plugins/calendar/service.ts
@@ -0,0 +1,723 @@
+import { randomUUID } from 'node:crypto';
+
+import { Injectable, Logger } from '@nestjs/common';
+import { Transactional } from '@nestjs-cls/transactional';
+import type { CalendarAccount, Prisma } from '@prisma/client';
+import { addDays, subDays } from 'date-fns';
+
+import {
+ CalendarProviderRequestError,
+ Config,
+ Mutex,
+ URLHelper,
+} from '../../base';
+import { Models } from '../../models';
+import {
+ CalendarProvider,
+ CalendarProviderEvent,
+ CalendarProviderEventTime,
+ CalendarProviderName,
+ CalendarSyncTokenInvalid,
+} from './providers';
+import { CalendarProviderFactory } from './providers';
+
+const TOKEN_REFRESH_SKEW_MS = 60 * 1000;
+const DEFAULT_PAST_DAYS = 90;
+const DEFAULT_FUTURE_DAYS = 180;
+
+@Injectable()
+export class CalendarService {
+ private readonly logger = new Logger(CalendarService.name);
+ private generatedWebhookToken?: string;
+
+ constructor(
+ private readonly models: Models,
+ private readonly providerFactory: CalendarProviderFactory,
+ private readonly mutex: Mutex,
+ private readonly config: Config,
+ private readonly url: URLHelper
+ ) {}
+
+ async listAccounts(userId: string) {
+ const accounts = await this.models.calendarAccount.listByUser(userId);
+ const accountIds = accounts.map(account => account.id);
+ const subscriptions =
+ await this.models.calendarSubscription.listByAccountIds(accountIds);
+ const counts = new Map();
+ for (const subscription of subscriptions) {
+ counts.set(
+ subscription.accountId,
+ (counts.get(subscription.accountId) ?? 0) + 1
+ );
+ }
+
+ return accounts.map(account => ({
+ ...account,
+ calendarsCount: counts.get(account.id) ?? 0,
+ }));
+ }
+
+ async listAccountCalendars(userId: string, accountId: string) {
+ const account = await this.models.calendarAccount.get(accountId);
+ if (!account || account.userId !== userId) {
+ return [];
+ }
+
+ return await this.models.calendarSubscription.listByAccount(accountId);
+ }
+
+ async updateAccountRefreshInterval(
+ userId: string,
+ accountId: string,
+ refreshIntervalMinutes: number
+ ) {
+ const account = await this.models.calendarAccount.get(accountId);
+ if (!account || account.userId !== userId) {
+ return null;
+ }
+
+ return await this.models.calendarAccount.updateRefreshInterval(
+ accountId,
+ refreshIntervalMinutes
+ );
+ }
+
+ async unlinkAccount(userId: string, accountId: string) {
+ const account = await this.models.calendarAccount.get(accountId);
+ if (!account || account.userId !== userId) {
+ return false;
+ }
+
+ const provider = this.providerFactory.get(
+ account.provider as CalendarProviderName
+ );
+ const subscriptions =
+ await this.models.calendarSubscription.listByAccount(accountId);
+ const needToStopChannel = subscriptions.filter(
+ s => s.customChannelId && s.customResourceId
+ );
+
+ if (provider?.stopChannel && needToStopChannel.length > 0) {
+ const accountTokens = this.models.calendarAccount.decryptTokens(account);
+ const accessToken = accountTokens.accessToken;
+ if (accessToken) {
+ await Promise.allSettled(
+ needToStopChannel.map(s => {
+ if (!s.customChannelId || !s.customResourceId) {
+ return Promise.resolve();
+ }
+ return provider.stopChannel?.({
+ accessToken,
+ channelId: s.customChannelId,
+ resourceId: s.customResourceId,
+ });
+ })
+ );
+ }
+ }
+
+ await this.models.calendarAccount.delete(accountId);
+ return true;
+ }
+
+ async handleOAuthCallback(params: {
+ provider: CalendarProviderName;
+ code: string;
+ redirectUri: string;
+ userId: string;
+ }) {
+ const provider = this.requireProvider(params.provider);
+ const tokens = await provider.exchangeCode(params.code, params.redirectUri);
+ const profile = await provider.getAccountProfile(tokens.accessToken);
+
+ const account = await this.models.calendarAccount.upsert({
+ userId: params.userId,
+ provider: params.provider,
+ providerAccountId: profile.providerAccountId,
+ displayName: profile.displayName ?? null,
+ email: profile.email ?? null,
+ accessToken: tokens.accessToken,
+ refreshToken: tokens.refreshToken,
+ expiresAt: tokens.expiresAt ?? null,
+ scope: tokens.scope ?? null,
+ status: 'active',
+ lastError: 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) {
+ return;
+ }
+
+ const provider = this.providerFactory.get(
+ account.provider as CalendarProviderName
+ );
+ if (!provider) {
+ return;
+ }
+
+ const { accessToken } = await this.ensureAccessToken(account);
+ if (!accessToken) {
+ return;
+ }
+
+ const calendars = await provider.listCalendars(accessToken);
+ const upserted = [];
+ for (const calendar of calendars) {
+ upserted.push(
+ await this.models.calendarSubscription.upsert({
+ accountId: account.id,
+ provider: account.provider,
+ externalCalendarId: calendar.id,
+ displayName: calendar.summary ?? null,
+ timezone: calendar.timeZone ?? null,
+ color: calendar.colorId ?? null,
+ enabled: true,
+ })
+ );
+ }
+
+ await Promise.allSettled(
+ upserted.map(subscription =>
+ this.syncSubscription(subscription.id, { reason: 'initial' })
+ )
+ );
+ }
+
+ async syncSubscription(
+ subscriptionId: string,
+ options?: { reason?: string; forceFull?: boolean }
+ ) {
+ const subscription =
+ await this.models.calendarSubscription.listWithAccount(subscriptionId);
+ if (!subscription || !subscription.enabled) {
+ return;
+ }
+
+ const account = subscription.account;
+ if (account.status !== 'active') {
+ return;
+ }
+
+ await using lock = await this.mutex.acquire(
+ `calendar:subscription:${subscriptionId}`
+ );
+ if (!lock) {
+ return;
+ }
+
+ const provider = this.providerFactory.get(
+ account.provider as CalendarProviderName
+ );
+ if (!provider) {
+ return;
+ }
+
+ const { accessToken } = await this.ensureAccessToken(account);
+ if (!accessToken) {
+ return;
+ }
+
+ const { timeMin, timeMax } = this.getSyncWindow();
+ const shouldUseSyncToken =
+ !!subscription.syncToken && options?.forceFull !== true;
+ let synced = false;
+
+ try {
+ await this.syncWithProvider({
+ provider,
+ subscriptionId: subscription.id,
+ calendarId: subscription.externalCalendarId,
+ accessToken,
+ syncToken: shouldUseSyncToken
+ ? (subscription.syncToken ?? undefined)
+ : undefined,
+ timeMin: shouldUseSyncToken ? undefined : timeMin,
+ timeMax: shouldUseSyncToken ? undefined : timeMax,
+ subscriptionTimezone: subscription.timezone ?? undefined,
+ });
+
+ synced = true;
+ } catch (error) {
+ if (error instanceof CalendarSyncTokenInvalid) {
+ await this.models.calendarSubscription.updateSync(subscription.id, {
+ syncToken: null,
+ });
+ await this.syncWithProvider({
+ provider,
+ subscriptionId: subscription.id,
+ calendarId: subscription.externalCalendarId,
+ accessToken,
+ timeMin,
+ timeMax,
+ subscriptionTimezone: subscription.timezone ?? undefined,
+ });
+ synced = true;
+ } else {
+ if (this.isTokenInvalidError(error)) {
+ await this.invalidateAccount(account.id, (error as Error).message);
+ } else {
+ this.logger.warn(
+ `Calendar sync failed for subscription ${subscription.id}`,
+ error as Error
+ );
+ }
+ return;
+ }
+ }
+
+ if (synced) {
+ await this.ensureWebhookChannel(subscription, provider, accessToken);
+ }
+
+ await this.models.calendarSubscription.updateLastSyncAt(
+ subscription.id,
+ new Date()
+ );
+ }
+
+ async syncAccount(accountId: string) {
+ const account = await this.models.calendarAccount.get(accountId);
+ if (!account || account.status !== 'active') {
+ return;
+ }
+
+ const subscriptions =
+ await this.models.calendarSubscription.listByAccountForSync(accountId);
+ await Promise.allSettled(
+ subscriptions.map(subscription =>
+ this.syncSubscription(subscription.id, { reason: 'polling' })
+ )
+ );
+ }
+
+ async listWorkspaceEvents(params: {
+ workspaceCalendarId: string;
+ from: Date;
+ to: Date;
+ }) {
+ const items = await this.models.workspaceCalendar.listItems(
+ params.workspaceCalendarId
+ );
+ const subscriptionIds = items.map(item => item.subscriptionId);
+ const events = await this.models.calendarEvent.listBySubscriptionsInRange(
+ subscriptionIds,
+ params.from,
+ params.to
+ );
+
+ Promise.allSettled(
+ subscriptionIds.map(subscriptionId =>
+ this.syncSubscription(subscriptionId, { reason: 'on-demand' })
+ )
+ ).catch(error => {
+ this.logger.warn('Calendar on-demand sync failed', error as Error);
+ });
+
+ return events;
+ }
+
+ @Transactional()
+ async updateWorkspaceCalendars(params: {
+ workspaceId: string;
+ userId: string;
+ items: Array<{
+ subscriptionId: string;
+ sortOrder?: number | null;
+ colorOverride?: string | null;
+ }>;
+ }) {
+ const calendar = await this.models.workspaceCalendar.getOrCreateDefault(
+ params.workspaceId,
+ params.userId
+ );
+ await this.models.workspaceCalendar.updateItems(calendar.id, params.items);
+ return calendar;
+ }
+
+ async getWorkspaceCalendars(workspaceId: string) {
+ const calendars =
+ await this.models.workspaceCalendar.getByWorkspace(workspaceId);
+ if (calendars.length === 0) {
+ return [];
+ }
+
+ const items = await Promise.all(
+ calendars.map(calendar =>
+ this.models.workspaceCalendar.listItems(calendar.id)
+ )
+ );
+ return calendars.map((calendar, index) => ({
+ ...calendar,
+ items: items[index],
+ }));
+ }
+
+ async handleWebhook(providerName: CalendarProviderName, channelId: string) {
+ if (providerName !== CalendarProviderName.Google) {
+ return;
+ }
+
+ const subscription =
+ await this.models.calendarSubscription.getByChannelId(channelId);
+ if (!subscription) {
+ return;
+ }
+
+ await this.syncSubscription(subscription.id, { reason: 'webhook' });
+ }
+
+ getWebhookToken() {
+ const configured = this.config.calendar.google.webhookVerificationToken;
+ if (configured) {
+ return configured;
+ }
+ if (!this.generatedWebhookToken) {
+ this.generatedWebhookToken = randomUUID();
+ }
+ return this.generatedWebhookToken;
+ }
+
+ getWebhookAddress(provider: string) {
+ const externalWebhookUrl = this.config.calendar.google.externalWebhookUrl;
+ if (!externalWebhookUrl) {
+ return null;
+ }
+ return new URL(
+ `/api/calendar/webhook/${provider}`,
+ externalWebhookUrl
+ ).toString();
+ }
+
+ getCallbackUrl() {
+ return this.url.link('/api/calendar/oauth/callback');
+ }
+
+ isProviderAvailable(provider: CalendarProviderName) {
+ return !!this.providerFactory.get(provider);
+ }
+
+ getAuthUrl(
+ provider: CalendarProviderName,
+ state: string,
+ redirectUri: string
+ ) {
+ return this.requireProvider(provider).getAuthUrl(state, redirectUri);
+ }
+
+ private async syncWithProvider(params: {
+ provider: CalendarProvider;
+ subscriptionId: string;
+ calendarId: string;
+ accessToken: string;
+ syncToken?: string;
+ timeMin?: string;
+ timeMax?: string;
+ subscriptionTimezone?: string;
+ }) {
+ const response = await params.provider.listEvents({
+ accessToken: params.accessToken,
+ calendarId: params.calendarId,
+ syncToken: params.syncToken,
+ timeMin: params.timeMin,
+ timeMax: params.timeMax,
+ });
+
+ const cancelledEventIds: string[] = [];
+ const failedEventIds: string[] = [];
+ for (const event of response.events) {
+ if (event.status === 'cancelled') {
+ cancelledEventIds.push(event.id);
+ continue;
+ }
+
+ try {
+ await this.models.calendarEvent.upsert(
+ this.mapProviderEvent(
+ params.subscriptionId,
+ event,
+ params.subscriptionTimezone
+ )
+ );
+ } catch {
+ failedEventIds.push(event.id);
+ }
+ }
+
+ if (cancelledEventIds.length > 0) {
+ await this.models.calendarEvent.deleteByExternalIds(
+ params.subscriptionId,
+ cancelledEventIds
+ );
+ }
+ if (failedEventIds.length > 0) {
+ this.logger.warn(
+ `Failed to upsert ${failedEventIds.length} events for subscription ${params.subscriptionId}`,
+ { failedEventIds }
+ );
+ }
+
+ if (response.nextSyncToken) {
+ await this.models.calendarSubscription.updateSync(params.subscriptionId, {
+ syncToken: response.nextSyncToken,
+ });
+ }
+ }
+
+ private mapProviderEvent(
+ subscriptionId: string,
+ event: CalendarProviderEvent,
+ fallbackTimezone?: string
+ ) {
+ const { timeZone, start, end, allDay } = this.resolveEventTimes(
+ event,
+ fallbackTimezone
+ );
+
+ return {
+ subscriptionId,
+ externalEventId: event.id,
+ recurrenceId: this.resolveRecurrenceId(event),
+ etag: event.etag ?? null,
+ status: event.status ?? null,
+ title: event.summary ?? null,
+ description: event.description ?? null,
+ location: event.location ?? null,
+ startAtUtc: start,
+ endAtUtc: end,
+ originalTimezone: timeZone ?? null,
+ allDay,
+ providerUpdatedAt: event.updated ? new Date(event.updated) : null,
+ raw: event.raw as Prisma.InputJsonValue,
+ };
+ }
+
+ private resolveEventTimes(
+ event: CalendarProviderEvent,
+ fallbackTimezone?: string
+ ) {
+ const startTime = this.resolveEventTime(event.start, fallbackTimezone);
+ const endTime = this.resolveEventTime(event.end, fallbackTimezone);
+ const timeZone =
+ event.start.timeZone ?? event.end.timeZone ?? fallbackTimezone ?? null;
+
+ return {
+ start: startTime.date,
+ end: endTime.date,
+ allDay: startTime.allDay || endTime.allDay,
+ timeZone,
+ };
+ }
+
+ private resolveEventTime(
+ time: CalendarProviderEventTime,
+ fallbackTimezone?: string
+ ) {
+ if (time.dateTime) {
+ return {
+ date: new Date(time.dateTime),
+ allDay: false,
+ };
+ }
+
+ const zone = time.timeZone ?? fallbackTimezone ?? 'UTC';
+ return {
+ date: this.convertDateToUtc(time.date!, zone),
+ allDay: true,
+ };
+ }
+
+ private resolveRecurrenceId(event: CalendarProviderEvent) {
+ if (event.originalStartTime?.dateTime) {
+ return event.originalStartTime.dateTime;
+ }
+ if (event.originalStartTime?.date) {
+ return event.originalStartTime.date;
+ }
+ return null;
+ }
+
+ private convertDateToUtc(dateString: string, timeZone: string) {
+ const [year, month, day] = dateString.split('-').map(Number);
+ const utcDate = new Date(Date.UTC(year, month - 1, day, 0, 0, 0));
+ const offsetMinutes = this.getTimeZoneOffset(utcDate, timeZone);
+ return new Date(utcDate.getTime() - offsetMinutes * 60 * 1000);
+ }
+
+ private getTimeZoneOffset(date: Date, timeZone: string) {
+ const formatter = new Intl.DateTimeFormat('en-US', {
+ timeZone,
+ hour12: false,
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit',
+ hour: '2-digit',
+ minute: '2-digit',
+ second: '2-digit',
+ });
+ const parts = formatter.formatToParts(date);
+ const lookup = (type: string) => {
+ const part = parts.find(item => item.type === type);
+ return part ? Number(part.value) : 0;
+ };
+ const asUtc = Date.UTC(
+ lookup('year'),
+ lookup('month') - 1,
+ lookup('day'),
+ lookup('hour'),
+ lookup('minute'),
+ lookup('second')
+ );
+ return (asUtc - date.getTime()) / 60000;
+ }
+
+ private getSyncWindow() {
+ const now = new Date();
+ return {
+ timeMin: subDays(now, DEFAULT_PAST_DAYS).toISOString(),
+ timeMax: addDays(now, DEFAULT_FUTURE_DAYS).toISOString(),
+ };
+ }
+
+ private async ensureAccessToken(account: CalendarAccount) {
+ const provider = this.providerFactory.get(
+ account.provider as CalendarProviderName
+ );
+ if (!provider) {
+ return { accessToken: null };
+ }
+
+ const decrypted = this.models.calendarAccount.decryptTokens(account);
+ const accessToken = decrypted.accessToken;
+ if (
+ accessToken &&
+ account.expiresAt &&
+ account.expiresAt.getTime() > Date.now() + TOKEN_REFRESH_SKEW_MS
+ ) {
+ return { accessToken };
+ }
+
+ if (!decrypted.refreshToken) {
+ return { accessToken };
+ }
+
+ const refreshed = await provider.refreshTokens(decrypted.refreshToken);
+ await this.models.calendarAccount.updateTokens(account.id, {
+ accessToken: refreshed.accessToken,
+ refreshToken: refreshed.refreshToken ?? decrypted.refreshToken,
+ expiresAt: refreshed.expiresAt ?? null,
+ scope: refreshed.scope ?? null,
+ status: 'active',
+ lastError: null,
+ });
+
+ return { accessToken: refreshed.accessToken };
+ }
+
+ private isTokenInvalidError(error: unknown) {
+ if (error instanceof CalendarProviderRequestError) {
+ if (error.status === 401) {
+ return true;
+ }
+ return error.message.includes('invalid_grant');
+ }
+ if (error instanceof Error) {
+ return error.message.includes('invalid_grant');
+ }
+ return false;
+ }
+
+ private async invalidateAccount(accountId: string, lastError?: string) {
+ await this.models.calendarAccount.updateStatus(
+ accountId,
+ 'invalid',
+ lastError ?? null
+ );
+ const subscriptions =
+ await this.models.calendarSubscription.listByAccount(accountId);
+ const subscriptionIds = subscriptions.map(s => s.id);
+ await this.models.calendarEvent.deleteBySubscriptionIds(subscriptionIds);
+ await this.models.calendarSubscription.clearSyncTokensByAccount(accountId);
+ }
+
+ private requireProvider(name: CalendarProviderName) {
+ const provider = this.providerFactory.get(name);
+ if (!provider) {
+ throw new Error(`Calendar provider ${name} not configured`);
+ }
+ return provider;
+ }
+
+ private async ensureWebhookChannel(
+ subscription: {
+ id: string;
+ externalCalendarId: string;
+ customChannelId: string | null;
+ customResourceId: string | null;
+ channelExpiration: Date | null;
+ },
+ provider: CalendarProvider,
+ accessToken: string
+ ) {
+ if (!provider.watchCalendar) {
+ return;
+ }
+
+ const address = this.getWebhookAddress(provider.provider);
+ if (!address) {
+ return;
+ }
+
+ const renewThreshold = Date.now() + 24 * 60 * 60 * 1000;
+ if (
+ subscription.channelExpiration &&
+ subscription.channelExpiration.getTime() > renewThreshold
+ ) {
+ return;
+ }
+
+ if (
+ provider.stopChannel &&
+ subscription.customChannelId &&
+ subscription.customResourceId
+ ) {
+ await provider.stopChannel({
+ accessToken,
+ channelId: subscription.customChannelId,
+ resourceId: subscription.customResourceId,
+ });
+ }
+
+ const channelId = randomUUID();
+ const token = this.getWebhookToken();
+ const result = await provider.watchCalendar({
+ accessToken,
+ calendarId: subscription.externalCalendarId,
+ address,
+ token,
+ channelId,
+ });
+
+ await this.models.calendarSubscription.updateChannel(subscription.id, {
+ customChannelId: result.channelId,
+ customResourceId: result.resourceId,
+ channelExpiration: result.expiration ?? null,
+ });
+ }
+}
diff --git a/packages/backend/server/src/plugins/calendar/types.ts b/packages/backend/server/src/plugins/calendar/types.ts
new file mode 100644
index 000000000..8b34a5f99
--- /dev/null
+++ b/packages/backend/server/src/plugins/calendar/types.ts
@@ -0,0 +1,188 @@
+import {
+ Field,
+ InputType,
+ Int,
+ ObjectType,
+ registerEnumType,
+} from '@nestjs/graphql';
+
+import { CalendarProviderName } from './providers';
+
+registerEnumType(CalendarProviderName, { name: 'CalendarProviderType' });
+
+@ObjectType()
+export class CalendarAccountObjectType {
+ @Field()
+ id!: string;
+
+ @Field(() => CalendarProviderName)
+ provider!: CalendarProviderName;
+
+ @Field()
+ providerAccountId!: string;
+
+ @Field(() => String, { nullable: true })
+ displayName?: string | null;
+
+ @Field(() => String, { nullable: true })
+ email?: string | null;
+
+ @Field()
+ status!: string;
+
+ @Field(() => String, { nullable: true })
+ lastError?: string | null;
+
+ @Field(() => Int)
+ refreshIntervalMinutes!: number;
+
+ @Field(() => Int)
+ calendarsCount!: number;
+
+ @Field()
+ createdAt!: Date;
+
+ @Field()
+ updatedAt!: Date;
+}
+
+@ObjectType()
+export class CalendarSubscriptionObjectType {
+ @Field()
+ id!: string;
+
+ @Field()
+ accountId!: string;
+
+ @Field(() => CalendarProviderName)
+ provider!: CalendarProviderName;
+
+ @Field()
+ externalCalendarId!: string;
+
+ @Field(() => String, { nullable: true })
+ displayName?: string | null;
+
+ @Field(() => String, { nullable: true })
+ timezone?: string | null;
+
+ @Field(() => String, { nullable: true })
+ color?: string | null;
+
+ @Field()
+ enabled!: boolean;
+
+ @Field(() => Date, { nullable: true })
+ lastSyncAt?: Date | null;
+}
+
+@ObjectType()
+export class WorkspaceCalendarItemObjectType {
+ @Field()
+ id!: string;
+
+ @Field()
+ subscriptionId!: string;
+
+ @Field(() => Int, { nullable: true })
+ sortOrder?: number | null;
+
+ @Field(() => String, { nullable: true })
+ colorOverride?: string | null;
+
+ @Field()
+ enabled!: boolean;
+}
+
+@ObjectType()
+export class WorkspaceCalendarObjectType {
+ @Field()
+ id!: string;
+
+ @Field()
+ workspaceId!: string;
+
+ @Field()
+ createdByUserId!: string;
+
+ @Field(() => String, { nullable: true })
+ displayNameOverride?: string | null;
+
+ @Field(() => String, { nullable: true })
+ colorOverride?: string | null;
+
+ @Field()
+ enabled!: boolean;
+
+ @Field(() => [WorkspaceCalendarItemObjectType])
+ items!: WorkspaceCalendarItemObjectType[];
+}
+
+@ObjectType()
+export class CalendarEventObjectType {
+ @Field()
+ id!: string;
+
+ @Field()
+ subscriptionId!: string;
+
+ @Field()
+ externalEventId!: string;
+
+ @Field(() => String, { nullable: true })
+ recurrenceId?: string | null;
+
+ @Field(() => String, { nullable: true })
+ status?: string | null;
+
+ @Field(() => String, { nullable: true })
+ title?: string | null;
+
+ @Field(() => String, { nullable: true })
+ description?: string | null;
+
+ @Field(() => String, { nullable: true })
+ location?: string | null;
+
+ @Field()
+ startAtUtc!: Date;
+
+ @Field()
+ endAtUtc!: Date;
+
+ @Field(() => String, { nullable: true })
+ originalTimezone?: string | null;
+
+ @Field()
+ allDay!: boolean;
+}
+
+@InputType()
+export class WorkspaceCalendarItemInput {
+ @Field()
+ subscriptionId!: string;
+
+ @Field(() => Int, { nullable: true })
+ sortOrder?: number | null;
+
+ @Field(() => String, { nullable: true })
+ colorOverride?: string | null;
+}
+
+@InputType()
+export class UpdateWorkspaceCalendarsInput {
+ @Field()
+ workspaceId!: string;
+
+ @Field(() => [WorkspaceCalendarItemInput])
+ items!: WorkspaceCalendarItemInput[];
+}
+
+@InputType()
+export class LinkCalendarAccountInput {
+ @Field(() => CalendarProviderName)
+ provider!: CalendarProviderName;
+
+ @Field(() => String, { nullable: true })
+ redirectUri?: string | null;
+}
diff --git a/packages/backend/server/src/schema.gql b/packages/backend/server/src/schema.gql
index 9a05362db..fa0f222d6 100644
--- a/packages/backend/server/src/schema.gql
+++ b/packages/backend/server/src/schema.gql
@@ -192,6 +192,57 @@ type BlobUploadedPart {
partNumber: Int!
}
+type CalendarAccountObjectType {
+ calendarsCount: Int!
+ createdAt: DateTime!
+ displayName: String
+ email: String
+ id: String!
+ lastError: String
+ provider: CalendarProviderType!
+ providerAccountId: String!
+ refreshIntervalMinutes: Int!
+ status: String!
+ updatedAt: DateTime!
+}
+
+type CalendarEventObjectType {
+ allDay: Boolean!
+ description: String
+ endAtUtc: DateTime!
+ externalEventId: String!
+ id: String!
+ location: String
+ originalTimezone: String
+ recurrenceId: String
+ startAtUtc: DateTime!
+ status: String
+ subscriptionId: String!
+ title: String
+}
+
+type CalendarProviderRequestErrorDataType {
+ message: String!
+ status: Int!
+}
+
+enum CalendarProviderType {
+ CalDAV
+ Google
+}
+
+type CalendarSubscriptionObjectType {
+ accountId: String!
+ color: String
+ displayName: String
+ enabled: Boolean!
+ externalCalendarId: String!
+ id: String!
+ lastSyncAt: DateTime
+ provider: CalendarProviderType!
+ timezone: String
+}
+
enum ChatHistoryOrder {
asc
desc
@@ -746,7 +797,7 @@ type EditorType {
name: String!
}
-union ErrorDataUnion = AlreadyInSpaceDataType | BlobNotFoundDataType | CopilotContextFileNotSupportedDataType | CopilotDocNotFoundDataType | CopilotFailedToAddWorkspaceFileEmbeddingDataType | CopilotFailedToGenerateEmbeddingDataType | CopilotFailedToMatchContextDataType | CopilotFailedToMatchGlobalContextDataType | CopilotFailedToModifyContextDataType | CopilotInvalidContextDataType | CopilotMessageNotFoundDataType | CopilotPromptNotFoundDataType | CopilotProviderNotSupportedDataType | CopilotProviderSideErrorDataType | DocActionDeniedDataType | DocHistoryNotFoundDataType | DocNotFoundDataType | DocUpdateBlockedDataType | ExpectToGrantDocUserRolesDataType | ExpectToRevokeDocUserRolesDataType | ExpectToUpdateDocUserRoleDataType | GraphqlBadRequestDataType | HttpRequestErrorDataType | InvalidAppConfigDataType | InvalidAppConfigInputDataType | InvalidEmailDataType | InvalidHistoryTimestampDataType | InvalidIndexerInputDataType | InvalidLicenseToActivateDataType | InvalidLicenseUpdateParamsDataType | InvalidOauthCallbackCodeDataType | InvalidOauthResponseDataType | InvalidPasswordLengthDataType | InvalidRuntimeConfigTypeDataType | InvalidSearchProviderRequestDataType | MemberNotFoundInSpaceDataType | MentionUserDocAccessDeniedDataType | MissingOauthQueryParameterDataType | NoCopilotProviderAvailableDataType | NoMoreSeatDataType | NotInSpaceDataType | QueryTooLongDataType | RuntimeConfigNotFoundDataType | SameSubscriptionRecurringDataType | SpaceAccessDeniedDataType | SpaceNotFoundDataType | SpaceOwnerNotFoundDataType | SpaceShouldHaveOnlyOneOwnerDataType | SubscriptionAlreadyExistsDataType | SubscriptionNotExistsDataType | SubscriptionPlanNotFoundDataType | UnknownOauthProviderDataType | UnsupportedClientVersionDataType | UnsupportedSubscriptionPlanDataType | ValidationErrorDataType | VersionRejectedDataType | WorkspacePermissionNotFoundDataType | WrongSignInCredentialsDataType
+union ErrorDataUnion = AlreadyInSpaceDataType | BlobNotFoundDataType | CalendarProviderRequestErrorDataType | CopilotContextFileNotSupportedDataType | CopilotDocNotFoundDataType | CopilotFailedToAddWorkspaceFileEmbeddingDataType | CopilotFailedToGenerateEmbeddingDataType | CopilotFailedToMatchContextDataType | CopilotFailedToMatchGlobalContextDataType | CopilotFailedToModifyContextDataType | CopilotInvalidContextDataType | CopilotMessageNotFoundDataType | CopilotPromptNotFoundDataType | CopilotProviderNotSupportedDataType | CopilotProviderSideErrorDataType | DocActionDeniedDataType | DocHistoryNotFoundDataType | DocNotFoundDataType | DocUpdateBlockedDataType | ExpectToGrantDocUserRolesDataType | ExpectToRevokeDocUserRolesDataType | ExpectToUpdateDocUserRoleDataType | GraphqlBadRequestDataType | HttpRequestErrorDataType | InvalidAppConfigDataType | InvalidAppConfigInputDataType | InvalidEmailDataType | InvalidHistoryTimestampDataType | InvalidIndexerInputDataType | InvalidLicenseToActivateDataType | InvalidLicenseUpdateParamsDataType | InvalidOauthCallbackCodeDataType | InvalidOauthResponseDataType | InvalidPasswordLengthDataType | InvalidRuntimeConfigTypeDataType | InvalidSearchProviderRequestDataType | MemberNotFoundInSpaceDataType | MentionUserDocAccessDeniedDataType | MissingOauthQueryParameterDataType | NoCopilotProviderAvailableDataType | NoMoreSeatDataType | NotInSpaceDataType | QueryTooLongDataType | RuntimeConfigNotFoundDataType | SameSubscriptionRecurringDataType | SpaceAccessDeniedDataType | SpaceNotFoundDataType | SpaceOwnerNotFoundDataType | SpaceShouldHaveOnlyOneOwnerDataType | SubscriptionAlreadyExistsDataType | SubscriptionNotExistsDataType | SubscriptionPlanNotFoundDataType | UnknownOauthProviderDataType | UnsupportedClientVersionDataType | UnsupportedSubscriptionPlanDataType | ValidationErrorDataType | VersionRejectedDataType | WorkspacePermissionNotFoundDataType | WrongSignInCredentialsDataType
enum ErrorNames {
ACCESS_DENIED
@@ -758,6 +809,7 @@ enum ErrorNames {
BLOB_INVALID
BLOB_NOT_FOUND
BLOB_QUOTA_EXCEEDED
+ CALENDAR_PROVIDER_REQUEST_ERROR
CANNOT_DELETE_ACCOUNT_WITH_OWNED_TEAM_WORKSPACE
CANNOT_DELETE_ALL_ADMIN_ACCOUNT
CANNOT_DELETE_OWN_ACCOUNT
@@ -1220,6 +1272,11 @@ type LimitedUserType {
hasPassword: Boolean
}
+input LinkCalendarAccountInput {
+ provider: CalendarProviderType!
+ redirectUri: String
+}
+
input ListUserInput {
features: [FeatureType!]
first: Int = 20
@@ -1402,6 +1459,7 @@ type Mutation {
inviteBatch(emails: [String!]!, sendInviteMail: Boolean @deprecated(reason: "never used"), workspaceId: String!): [InviteResult!]! @deprecated(reason: "use [inviteMembers] instead")
inviteMembers(emails: [String!]!, workspaceId: String!): [InviteResult!]!
leaveWorkspace(sendLeaveMail: Boolean @deprecated(reason: "no used anymore"), workspaceId: String!, workspaceName: String @deprecated(reason: "no longer used")): Boolean!
+ linkCalendarAccount(input: LinkCalendarAccountInput!): String!
"""mention user in a doc"""
mentionUser(input: MentionInput!): ID!
@@ -1469,9 +1527,11 @@ type Mutation {
"""Trigger generate missing titles cron job"""
triggerGenerateTitleCron: Boolean!
+ unlinkCalendarAccount(accountId: String!): Boolean!
"""update app configuration"""
updateAppConfig(updates: [UpdateAppConfigInput!]!): JSONObject!
+ updateCalendarAccount(accountId: String!, refreshIntervalMinutes: Int!): CalendarAccountObjectType
"""Update a comment content"""
updateComment(input: CommentUpdateInput!): Boolean!
@@ -1500,6 +1560,7 @@ type Mutation {
"""Update workspace"""
updateWorkspace(input: UpdateWorkspaceInput!): WorkspaceType!
+ updateWorkspaceCalendars(input: UpdateWorkspaceCalendarsInput!): WorkspaceCalendarObjectType!
"""Update ignored docs"""
updateWorkspaceEmbeddingIgnoredDocs(add: [String!], remove: [String!], workspaceId: String!): Int!
@@ -1705,6 +1766,10 @@ type Query {
"""Apply updates to a doc using LLM and return the merged markdown."""
applyDocUpdates(docId: String!, op: String!, updates: String!, workspaceId: String!): String!
+ calendarAccountCalendars(accountId: String!): [CalendarSubscriptionObjectType!]!
+ calendarAccounts: [CalendarAccountObjectType!]!
+ calendarEvents(from: DateTime!, to: DateTime!, workspaceCalendarId: String!): [CalendarEventObjectType!]!
+ calendarProviders: [CalendarProviderType!]!
collectAllBlobSizes: WorkspaceBlobSizes! @deprecated(reason: "use `user.quotaUsage` instead")
"""Get current user"""
@@ -1751,6 +1816,7 @@ type Query {
"""Get workspace by id"""
workspace(id: String!): WorkspaceType!
+ workspaceCalendars(workspaceId: String!): [WorkspaceCalendarObjectType!]!
"""Get workspace role permissions"""
workspaceRolePermissions(id: String!): WorkspaceRolePermissions! @deprecated(reason: "use WorkspaceType[permissions] instead")
@@ -2207,6 +2273,11 @@ input UpdateUserSettingsInput {
receiveMentionEmail: Boolean
}
+input UpdateWorkspaceCalendarsInput {
+ items: [WorkspaceCalendarItemInput!]!
+ workspaceId: String!
+}
+
input UpdateWorkspaceInput {
"""Enable AI"""
enableAi: Boolean
@@ -2331,6 +2402,30 @@ type WorkspaceBlobSizes {
size: SafeInt!
}
+input WorkspaceCalendarItemInput {
+ colorOverride: String
+ sortOrder: Int
+ subscriptionId: String!
+}
+
+type WorkspaceCalendarItemObjectType {
+ colorOverride: String
+ enabled: Boolean!
+ id: String!
+ sortOrder: Int
+ subscriptionId: String!
+}
+
+type WorkspaceCalendarObjectType {
+ colorOverride: String
+ createdByUserId: String!
+ displayNameOverride: String
+ enabled: Boolean!
+ id: String!
+ items: [WorkspaceCalendarItemObjectType!]!
+ workspaceId: String!
+}
+
type WorkspaceDocMeta {
createdAt: DateTime!
createdBy: EditorType
diff --git a/packages/common/graphql/src/graphql/calendar/calendar-account-calendars.gql b/packages/common/graphql/src/graphql/calendar/calendar-account-calendars.gql
new file mode 100644
index 000000000..f4c544180
--- /dev/null
+++ b/packages/common/graphql/src/graphql/calendar/calendar-account-calendars.gql
@@ -0,0 +1,13 @@
+query calendarAccountCalendars($accountId: String!) {
+ calendarAccountCalendars(accountId: $accountId) {
+ id
+ accountId
+ provider
+ externalCalendarId
+ displayName
+ timezone
+ color
+ enabled
+ lastSyncAt
+ }
+}
diff --git a/packages/common/graphql/src/graphql/calendar/calendar-accounts.gql b/packages/common/graphql/src/graphql/calendar/calendar-accounts.gql
new file mode 100644
index 000000000..60f42fccf
--- /dev/null
+++ b/packages/common/graphql/src/graphql/calendar/calendar-accounts.gql
@@ -0,0 +1,15 @@
+query calendarAccounts {
+ calendarAccounts {
+ id
+ provider
+ providerAccountId
+ displayName
+ email
+ status
+ lastError
+ refreshIntervalMinutes
+ calendarsCount
+ createdAt
+ updatedAt
+ }
+}
diff --git a/packages/common/graphql/src/graphql/calendar/calendar-events.gql b/packages/common/graphql/src/graphql/calendar/calendar-events.gql
new file mode 100644
index 000000000..9d031d2f7
--- /dev/null
+++ b/packages/common/graphql/src/graphql/calendar/calendar-events.gql
@@ -0,0 +1,16 @@
+query calendarEvents($workspaceCalendarId: String!, $from: DateTime!, $to: DateTime!) {
+ calendarEvents(workspaceCalendarId: $workspaceCalendarId, from: $from, to: $to) {
+ id
+ subscriptionId
+ externalEventId
+ recurrenceId
+ status
+ title
+ description
+ location
+ startAtUtc
+ endAtUtc
+ originalTimezone
+ allDay
+ }
+}
diff --git a/packages/common/graphql/src/graphql/calendar/calendar-providers.gql b/packages/common/graphql/src/graphql/calendar/calendar-providers.gql
new file mode 100644
index 000000000..ab6ec2eb6
--- /dev/null
+++ b/packages/common/graphql/src/graphql/calendar/calendar-providers.gql
@@ -0,0 +1,3 @@
+query calendarProviders {
+ calendarProviders
+}
diff --git a/packages/common/graphql/src/graphql/calendar/link-calendar-account.gql b/packages/common/graphql/src/graphql/calendar/link-calendar-account.gql
new file mode 100644
index 000000000..534cb71d2
--- /dev/null
+++ b/packages/common/graphql/src/graphql/calendar/link-calendar-account.gql
@@ -0,0 +1,3 @@
+mutation linkCalendarAccount($input: LinkCalendarAccountInput!) {
+ linkCalendarAccount(input: $input)
+}
diff --git a/packages/common/graphql/src/graphql/calendar/unlink-calendar-account.gql b/packages/common/graphql/src/graphql/calendar/unlink-calendar-account.gql
new file mode 100644
index 000000000..aac131232
--- /dev/null
+++ b/packages/common/graphql/src/graphql/calendar/unlink-calendar-account.gql
@@ -0,0 +1,3 @@
+mutation unlinkCalendarAccount($accountId: String!) {
+ unlinkCalendarAccount(accountId: $accountId)
+}
diff --git a/packages/common/graphql/src/graphql/calendar/update-calendar-account.gql b/packages/common/graphql/src/graphql/calendar/update-calendar-account.gql
new file mode 100644
index 000000000..4bdf19412
--- /dev/null
+++ b/packages/common/graphql/src/graphql/calendar/update-calendar-account.gql
@@ -0,0 +1,15 @@
+mutation updateCalendarAccount($accountId: String!, $refreshIntervalMinutes: Int!) {
+ updateCalendarAccount(accountId: $accountId, refreshIntervalMinutes: $refreshIntervalMinutes) {
+ id
+ provider
+ providerAccountId
+ displayName
+ email
+ status
+ lastError
+ refreshIntervalMinutes
+ calendarsCount
+ createdAt
+ updatedAt
+ }
+}
diff --git a/packages/common/graphql/src/graphql/calendar/update-workspace-calendars.gql b/packages/common/graphql/src/graphql/calendar/update-workspace-calendars.gql
new file mode 100644
index 000000000..f4564b353
--- /dev/null
+++ b/packages/common/graphql/src/graphql/calendar/update-workspace-calendars.gql
@@ -0,0 +1,17 @@
+mutation updateWorkspaceCalendars($input: UpdateWorkspaceCalendarsInput!) {
+ updateWorkspaceCalendars(input: $input) {
+ id
+ workspaceId
+ createdByUserId
+ displayNameOverride
+ colorOverride
+ enabled
+ items {
+ id
+ subscriptionId
+ sortOrder
+ colorOverride
+ enabled
+ }
+ }
+}
diff --git a/packages/common/graphql/src/graphql/calendar/workspace-calendars.gql b/packages/common/graphql/src/graphql/calendar/workspace-calendars.gql
new file mode 100644
index 000000000..f90a109fc
--- /dev/null
+++ b/packages/common/graphql/src/graphql/calendar/workspace-calendars.gql
@@ -0,0 +1,17 @@
+query workspaceCalendars($workspaceId: String!) {
+ workspaceCalendars(workspaceId: $workspaceId) {
+ id
+ workspaceId
+ createdByUserId
+ displayNameOverride
+ colorOverride
+ enabled
+ items {
+ id
+ subscriptionId
+ sortOrder
+ colorOverride
+ enabled
+ }
+ }
+}
diff --git a/packages/common/graphql/src/graphql/index.ts b/packages/common/graphql/src/graphql/index.ts
index 3a791c42a..8b7c4b2e0 100644
--- a/packages/common/graphql/src/graphql/index.ts
+++ b/packages/common/graphql/src/graphql/index.ts
@@ -557,6 +557,156 @@ export const getBlobUploadPartUrlMutation = {
}`,
};
+export const calendarAccountCalendarsQuery = {
+ id: 'calendarAccountCalendarsQuery' as const,
+ op: 'calendarAccountCalendars',
+ query: `query calendarAccountCalendars($accountId: String!) {
+ calendarAccountCalendars(accountId: $accountId) {
+ id
+ accountId
+ provider
+ externalCalendarId
+ displayName
+ timezone
+ color
+ enabled
+ lastSyncAt
+ }
+}`,
+};
+
+export const calendarAccountsQuery = {
+ id: 'calendarAccountsQuery' as const,
+ op: 'calendarAccounts',
+ query: `query calendarAccounts {
+ calendarAccounts {
+ id
+ provider
+ providerAccountId
+ displayName
+ email
+ status
+ lastError
+ refreshIntervalMinutes
+ calendarsCount
+ createdAt
+ updatedAt
+ }
+}`,
+};
+
+export const calendarEventsQuery = {
+ id: 'calendarEventsQuery' as const,
+ op: 'calendarEvents',
+ query: `query calendarEvents($workspaceCalendarId: String!, $from: DateTime!, $to: DateTime!) {
+ calendarEvents(workspaceCalendarId: $workspaceCalendarId, from: $from, to: $to) {
+ id
+ subscriptionId
+ externalEventId
+ recurrenceId
+ status
+ title
+ description
+ location
+ startAtUtc
+ endAtUtc
+ originalTimezone
+ allDay
+ }
+}`,
+};
+
+export const calendarProvidersQuery = {
+ id: 'calendarProvidersQuery' as const,
+ op: 'calendarProviders',
+ query: `query calendarProviders {
+ calendarProviders
+}`,
+};
+
+export const linkCalendarAccountMutation = {
+ id: 'linkCalendarAccountMutation' as const,
+ op: 'linkCalendarAccount',
+ query: `mutation linkCalendarAccount($input: LinkCalendarAccountInput!) {
+ linkCalendarAccount(input: $input)
+}`,
+};
+
+export const unlinkCalendarAccountMutation = {
+ id: 'unlinkCalendarAccountMutation' as const,
+ op: 'unlinkCalendarAccount',
+ query: `mutation unlinkCalendarAccount($accountId: String!) {
+ unlinkCalendarAccount(accountId: $accountId)
+}`,
+};
+
+export const updateCalendarAccountMutation = {
+ id: 'updateCalendarAccountMutation' as const,
+ op: 'updateCalendarAccount',
+ query: `mutation updateCalendarAccount($accountId: String!, $refreshIntervalMinutes: Int!) {
+ updateCalendarAccount(
+ accountId: $accountId
+ refreshIntervalMinutes: $refreshIntervalMinutes
+ ) {
+ id
+ provider
+ providerAccountId
+ displayName
+ email
+ status
+ lastError
+ refreshIntervalMinutes
+ calendarsCount
+ createdAt
+ updatedAt
+ }
+}`,
+};
+
+export const updateWorkspaceCalendarsMutation = {
+ id: 'updateWorkspaceCalendarsMutation' as const,
+ op: 'updateWorkspaceCalendars',
+ query: `mutation updateWorkspaceCalendars($input: UpdateWorkspaceCalendarsInput!) {
+ updateWorkspaceCalendars(input: $input) {
+ id
+ workspaceId
+ createdByUserId
+ displayNameOverride
+ colorOverride
+ enabled
+ items {
+ id
+ subscriptionId
+ sortOrder
+ colorOverride
+ enabled
+ }
+ }
+}`,
+};
+
+export const workspaceCalendarsQuery = {
+ id: 'workspaceCalendarsQuery' as const,
+ op: 'workspaceCalendars',
+ query: `query workspaceCalendars($workspaceId: String!) {
+ workspaceCalendars(workspaceId: $workspaceId) {
+ id
+ workspaceId
+ createdByUserId
+ displayNameOverride
+ colorOverride
+ enabled
+ items {
+ id
+ subscriptionId
+ sortOrder
+ colorOverride
+ enabled
+ }
+ }
+}`,
+};
+
export const cancelSubscriptionMutation = {
id: 'cancelSubscriptionMutation' as const,
op: 'cancelSubscription',
diff --git a/packages/common/graphql/src/schema.ts b/packages/common/graphql/src/schema.ts
index 10fef6a04..c0736fbad 100644
--- a/packages/common/graphql/src/schema.ts
+++ b/packages/common/graphql/src/schema.ts
@@ -244,6 +244,61 @@ export interface BlobUploadedPart {
partNumber: Scalars['Int']['output'];
}
+export interface CalendarAccountObjectType {
+ __typename?: 'CalendarAccountObjectType';
+ calendarsCount: Scalars['Int']['output'];
+ createdAt: Scalars['DateTime']['output'];
+ displayName: Maybe;
+ email: Maybe;
+ id: Scalars['String']['output'];
+ lastError: Maybe;
+ provider: CalendarProviderType;
+ providerAccountId: Scalars['String']['output'];
+ refreshIntervalMinutes: Scalars['Int']['output'];
+ status: Scalars['String']['output'];
+ updatedAt: Scalars['DateTime']['output'];
+}
+
+export interface CalendarEventObjectType {
+ __typename?: 'CalendarEventObjectType';
+ allDay: Scalars['Boolean']['output'];
+ description: Maybe;
+ endAtUtc: Scalars['DateTime']['output'];
+ externalEventId: Scalars['String']['output'];
+ id: Scalars['String']['output'];
+ location: Maybe;
+ originalTimezone: Maybe;
+ recurrenceId: Maybe;
+ startAtUtc: Scalars['DateTime']['output'];
+ status: Maybe;
+ subscriptionId: Scalars['String']['output'];
+ title: Maybe;
+}
+
+export interface CalendarProviderRequestErrorDataType {
+ __typename?: 'CalendarProviderRequestErrorDataType';
+ message: Scalars['String']['output'];
+ status: Scalars['Int']['output'];
+}
+
+export enum CalendarProviderType {
+ CalDAV = 'CalDAV',
+ Google = 'Google',
+}
+
+export interface CalendarSubscriptionObjectType {
+ __typename?: 'CalendarSubscriptionObjectType';
+ accountId: Scalars['String']['output'];
+ color: Maybe;
+ displayName: Maybe;
+ enabled: Scalars['Boolean']['output'];
+ externalCalendarId: Scalars['String']['output'];
+ id: Scalars['String']['output'];
+ lastSyncAt: Maybe;
+ provider: CalendarProviderType;
+ timezone: Maybe;
+}
+
export enum ChatHistoryOrder {
asc = 'asc',
desc = 'desc',
@@ -881,6 +936,7 @@ export interface EditorType {
export type ErrorDataUnion =
| AlreadyInSpaceDataType
| BlobNotFoundDataType
+ | CalendarProviderRequestErrorDataType
| CopilotContextFileNotSupportedDataType
| CopilotDocNotFoundDataType
| CopilotFailedToAddWorkspaceFileEmbeddingDataType
@@ -948,6 +1004,7 @@ export enum ErrorNames {
BLOB_INVALID = 'BLOB_INVALID',
BLOB_NOT_FOUND = 'BLOB_NOT_FOUND',
BLOB_QUOTA_EXCEEDED = 'BLOB_QUOTA_EXCEEDED',
+ CALENDAR_PROVIDER_REQUEST_ERROR = 'CALENDAR_PROVIDER_REQUEST_ERROR',
CANNOT_DELETE_ACCOUNT_WITH_OWNED_TEAM_WORKSPACE = 'CANNOT_DELETE_ACCOUNT_WITH_OWNED_TEAM_WORKSPACE',
CANNOT_DELETE_ALL_ADMIN_ACCOUNT = 'CANNOT_DELETE_ALL_ADMIN_ACCOUNT',
CANNOT_DELETE_OWN_ACCOUNT = 'CANNOT_DELETE_OWN_ACCOUNT',
@@ -1405,6 +1462,11 @@ export interface LimitedUserType {
hasPassword: Maybe;
}
+export interface LinkCalendarAccountInput {
+ provider: CalendarProviderType;
+ redirectUri?: InputMaybe;
+}
+
export interface ListUserInput {
features?: InputMaybe>;
first?: InputMaybe;
@@ -1567,6 +1629,7 @@ export interface Mutation {
inviteBatch: Array;
inviteMembers: Array;
leaveWorkspace: Scalars['Boolean']['output'];
+ linkCalendarAccount: Scalars['String']['output'];
/** mention user in a doc */
mentionUser: Scalars['ID']['output'];
publishDoc: DocType;
@@ -1622,8 +1685,10 @@ export interface Mutation {
triggerCleanupTrashedDocEmbeddings: Scalars['Boolean']['output'];
/** Trigger generate missing titles cron job */
triggerGenerateTitleCron: Scalars['Boolean']['output'];
+ unlinkCalendarAccount: Scalars['Boolean']['output'];
/** update app configuration */
updateAppConfig: Scalars['JSONObject']['output'];
+ updateCalendarAccount: Maybe;
/** Update a comment content */
updateComment: Scalars['Boolean']['output'];
/** Update a copilot prompt */
@@ -1644,6 +1709,7 @@ export interface Mutation {
updateUserFeatures: Array;
/** Update workspace */
updateWorkspace: WorkspaceType;
+ updateWorkspaceCalendars: WorkspaceCalendarObjectType;
/** Update ignored docs */
updateWorkspaceEmbeddingIgnoredDocs: Scalars['Int']['output'];
/** Upload user avatar */
@@ -1888,6 +1954,10 @@ export interface MutationLeaveWorkspaceArgs {
workspaceName?: InputMaybe;
}
+export interface MutationLinkCalendarAccountArgs {
+ input: LinkCalendarAccountInput;
+}
+
export interface MutationMentionUserArgs {
input: MentionInput;
}
@@ -2041,10 +2111,19 @@ export interface MutationSubmitAudioTranscriptionArgs {
workspaceId: Scalars['String']['input'];
}
+export interface MutationUnlinkCalendarAccountArgs {
+ accountId: Scalars['String']['input'];
+}
+
export interface MutationUpdateAppConfigArgs {
updates: Array;
}
+export interface MutationUpdateCalendarAccountArgs {
+ accountId: Scalars['String']['input'];
+ refreshIntervalMinutes: Scalars['Int']['input'];
+}
+
export interface MutationUpdateCommentArgs {
input: CommentUpdateInput;
}
@@ -2099,6 +2178,10 @@ export interface MutationUpdateWorkspaceArgs {
input: UpdateWorkspaceInput;
}
+export interface MutationUpdateWorkspaceCalendarsArgs {
+ input: UpdateWorkspaceCalendarsInput;
+}
+
export interface MutationUpdateWorkspaceEmbeddingIgnoredDocsArgs {
add?: InputMaybe>;
remove?: InputMaybe>;
@@ -2315,6 +2398,10 @@ export interface Query {
appConfig: Scalars['JSONObject']['output'];
/** Apply updates to a doc using LLM and return the merged markdown. */
applyDocUpdates: Scalars['String']['output'];
+ calendarAccountCalendars: Array;
+ calendarAccounts: Array;
+ calendarEvents: Array;
+ calendarProviders: Array;
/** @deprecated use `user.quotaUsage` instead */
collectAllBlobSizes: WorkspaceBlobSizes;
/** Get current user */
@@ -2354,6 +2441,7 @@ export interface Query {
usersCount: Scalars['Int']['output'];
/** Get workspace by id */
workspace: WorkspaceType;
+ workspaceCalendars: Array;
/**
* Get workspace role permissions
* @deprecated use WorkspaceType[permissions] instead
@@ -2382,6 +2470,16 @@ export interface QueryApplyDocUpdatesArgs {
workspaceId: Scalars['String']['input'];
}
+export interface QueryCalendarAccountCalendarsArgs {
+ accountId: Scalars['String']['input'];
+}
+
+export interface QueryCalendarEventsArgs {
+ from: Scalars['DateTime']['input'];
+ to: Scalars['DateTime']['input'];
+ workspaceCalendarId: Scalars['String']['input'];
+}
+
export interface QueryErrorArgs {
name: ErrorNames;
}
@@ -2430,6 +2528,10 @@ export interface QueryWorkspaceArgs {
id: Scalars['String']['input'];
}
+export interface QueryWorkspaceCalendarsArgs {
+ workspaceId: Scalars['String']['input'];
+}
+
export interface QueryWorkspaceRolePermissionsArgs {
id: Scalars['String']['input'];
}
@@ -2883,6 +2985,11 @@ export interface UpdateUserSettingsInput {
receiveMentionEmail?: InputMaybe;
}
+export interface UpdateWorkspaceCalendarsInput {
+ items: Array;
+ workspaceId: Scalars['String']['input'];
+}
+
export interface UpdateWorkspaceInput {
/** Enable AI */
enableAi?: InputMaybe;
@@ -3014,6 +3121,32 @@ export interface WorkspaceBlobSizes {
size: Scalars['SafeInt']['output'];
}
+export interface WorkspaceCalendarItemInput {
+ colorOverride?: InputMaybe;
+ sortOrder?: InputMaybe;
+ subscriptionId: Scalars['String']['input'];
+}
+
+export interface WorkspaceCalendarItemObjectType {
+ __typename?: 'WorkspaceCalendarItemObjectType';
+ colorOverride: Maybe;
+ enabled: Scalars['Boolean']['output'];
+ id: Scalars['String']['output'];
+ sortOrder: Maybe;
+ subscriptionId: Scalars['String']['output'];
+}
+
+export interface WorkspaceCalendarObjectType {
+ __typename?: 'WorkspaceCalendarObjectType';
+ colorOverride: Maybe;
+ createdByUserId: Scalars['String']['output'];
+ displayNameOverride: Maybe;
+ enabled: Scalars['Boolean']['output'];
+ id: Scalars['String']['output'];
+ items: Array;
+ workspaceId: Scalars['String']['output'];
+}
+
export interface WorkspaceDocMeta {
__typename?: 'WorkspaceDocMeta';
createdAt: Scalars['DateTime']['output'];
@@ -3788,6 +3921,169 @@ export type GetBlobUploadPartUrlMutation = {
};
};
+export type CalendarAccountCalendarsQueryVariables = Exact<{
+ accountId: Scalars['String']['input'];
+}>;
+
+export type CalendarAccountCalendarsQuery = {
+ __typename?: 'Query';
+ calendarAccountCalendars: Array<{
+ __typename?: 'CalendarSubscriptionObjectType';
+ id: string;
+ accountId: string;
+ provider: CalendarProviderType;
+ externalCalendarId: string;
+ displayName: string | null;
+ timezone: string | null;
+ color: string | null;
+ enabled: boolean;
+ lastSyncAt: string | null;
+ }>;
+};
+
+export type CalendarAccountsQueryVariables = Exact<{ [key: string]: never }>;
+
+export type CalendarAccountsQuery = {
+ __typename?: 'Query';
+ calendarAccounts: Array<{
+ __typename?: 'CalendarAccountObjectType';
+ id: string;
+ provider: CalendarProviderType;
+ providerAccountId: string;
+ displayName: string | null;
+ email: string | null;
+ status: string;
+ lastError: string | null;
+ refreshIntervalMinutes: number;
+ calendarsCount: number;
+ createdAt: string;
+ updatedAt: string;
+ }>;
+};
+
+export type CalendarEventsQueryVariables = Exact<{
+ workspaceCalendarId: Scalars['String']['input'];
+ from: Scalars['DateTime']['input'];
+ to: Scalars['DateTime']['input'];
+}>;
+
+export type CalendarEventsQuery = {
+ __typename?: 'Query';
+ calendarEvents: Array<{
+ __typename?: 'CalendarEventObjectType';
+ id: string;
+ subscriptionId: string;
+ externalEventId: string;
+ recurrenceId: string | null;
+ status: string | null;
+ title: string | null;
+ description: string | null;
+ location: string | null;
+ startAtUtc: string;
+ endAtUtc: string;
+ originalTimezone: string | null;
+ allDay: boolean;
+ }>;
+};
+
+export type CalendarProvidersQueryVariables = Exact<{ [key: string]: never }>;
+
+export type CalendarProvidersQuery = {
+ __typename?: 'Query';
+ calendarProviders: Array;
+};
+
+export type LinkCalendarAccountMutationVariables = Exact<{
+ input: LinkCalendarAccountInput;
+}>;
+
+export type LinkCalendarAccountMutation = {
+ __typename?: 'Mutation';
+ linkCalendarAccount: string;
+};
+
+export type UnlinkCalendarAccountMutationVariables = Exact<{
+ accountId: Scalars['String']['input'];
+}>;
+
+export type UnlinkCalendarAccountMutation = {
+ __typename?: 'Mutation';
+ unlinkCalendarAccount: boolean;
+};
+
+export type UpdateCalendarAccountMutationVariables = Exact<{
+ accountId: Scalars['String']['input'];
+ refreshIntervalMinutes: Scalars['Int']['input'];
+}>;
+
+export type UpdateCalendarAccountMutation = {
+ __typename?: 'Mutation';
+ updateCalendarAccount: {
+ __typename?: 'CalendarAccountObjectType';
+ id: string;
+ provider: CalendarProviderType;
+ providerAccountId: string;
+ displayName: string | null;
+ email: string | null;
+ status: string;
+ lastError: string | null;
+ refreshIntervalMinutes: number;
+ calendarsCount: number;
+ createdAt: string;
+ updatedAt: string;
+ } | null;
+};
+
+export type UpdateWorkspaceCalendarsMutationVariables = Exact<{
+ input: UpdateWorkspaceCalendarsInput;
+}>;
+
+export type UpdateWorkspaceCalendarsMutation = {
+ __typename?: 'Mutation';
+ updateWorkspaceCalendars: {
+ __typename?: 'WorkspaceCalendarObjectType';
+ id: string;
+ workspaceId: string;
+ createdByUserId: string;
+ displayNameOverride: string | null;
+ colorOverride: string | null;
+ enabled: boolean;
+ items: Array<{
+ __typename?: 'WorkspaceCalendarItemObjectType';
+ id: string;
+ subscriptionId: string;
+ sortOrder: number | null;
+ colorOverride: string | null;
+ enabled: boolean;
+ }>;
+ };
+};
+
+export type WorkspaceCalendarsQueryVariables = Exact<{
+ workspaceId: Scalars['String']['input'];
+}>;
+
+export type WorkspaceCalendarsQuery = {
+ __typename?: 'Query';
+ workspaceCalendars: Array<{
+ __typename?: 'WorkspaceCalendarObjectType';
+ id: string;
+ workspaceId: string;
+ createdByUserId: string;
+ displayNameOverride: string | null;
+ colorOverride: string | null;
+ enabled: boolean;
+ items: Array<{
+ __typename?: 'WorkspaceCalendarItemObjectType';
+ id: string;
+ subscriptionId: string;
+ sortOrder: number | null;
+ colorOverride: string | null;
+ enabled: boolean;
+ }>;
+ }>;
+};
+
export type CancelSubscriptionMutationVariables = Exact<{
plan?: InputMaybe;
workspaceId?: InputMaybe;
@@ -6810,6 +7106,31 @@ export type Queries =
variables: ListBlobsQueryVariables;
response: ListBlobsQuery;
}
+ | {
+ name: 'calendarAccountCalendarsQuery';
+ variables: CalendarAccountCalendarsQueryVariables;
+ response: CalendarAccountCalendarsQuery;
+ }
+ | {
+ name: 'calendarAccountsQuery';
+ variables: CalendarAccountsQueryVariables;
+ response: CalendarAccountsQuery;
+ }
+ | {
+ name: 'calendarEventsQuery';
+ variables: CalendarEventsQueryVariables;
+ response: CalendarEventsQuery;
+ }
+ | {
+ name: 'calendarProvidersQuery';
+ variables: CalendarProvidersQueryVariables;
+ response: CalendarProvidersQuery;
+ }
+ | {
+ name: 'workspaceCalendarsQuery';
+ variables: WorkspaceCalendarsQueryVariables;
+ response: WorkspaceCalendarsQuery;
+ }
| {
name: 'listCommentChangesQuery';
variables: ListCommentChangesQueryVariables;
@@ -7252,6 +7573,26 @@ export type Mutations =
variables: GetBlobUploadPartUrlMutationVariables;
response: GetBlobUploadPartUrlMutation;
}
+ | {
+ name: 'linkCalendarAccountMutation';
+ variables: LinkCalendarAccountMutationVariables;
+ response: LinkCalendarAccountMutation;
+ }
+ | {
+ name: 'unlinkCalendarAccountMutation';
+ variables: UnlinkCalendarAccountMutationVariables;
+ response: UnlinkCalendarAccountMutation;
+ }
+ | {
+ name: 'updateCalendarAccountMutation';
+ variables: UpdateCalendarAccountMutationVariables;
+ response: UpdateCalendarAccountMutation;
+ }
+ | {
+ name: 'updateWorkspaceCalendarsMutation';
+ variables: UpdateWorkspaceCalendarsMutationVariables;
+ response: UpdateWorkspaceCalendarsMutation;
+ }
| {
name: 'cancelSubscriptionMutation';
variables: CancelSubscriptionMutationVariables;
diff --git a/packages/frontend/admin/src/config.json b/packages/frontend/admin/src/config.json
index 9496db797..614dfe210 100644
--- a/packages/frontend/admin/src/config.json
+++ b/packages/frontend/admin/src/config.json
@@ -255,6 +255,13 @@
"desc": "Allowed version range of the app that allowed to access the server. Requires 'client/versionControl.enabled' to be true to take effect."
}
},
+ "calendar": {
+ "google": {
+ "type": "Object",
+ "desc": "Google Calendar integration config",
+ "link": "https://developers.google.com/calendar/api/guides/push"
+ }
+ },
"captcha": {
"enabled": {
"type": "Boolean",
diff --git a/packages/frontend/i18n/src/i18n.gen.ts b/packages/frontend/i18n/src/i18n.gen.ts
index 0b075c972..6dfccdee1 100644
--- a/packages/frontend/i18n/src/i18n.gen.ts
+++ b/packages/frontend/i18n/src/i18n.gen.ts
@@ -8831,6 +8831,13 @@ export function useAFFiNEI18N(): {
* `This subscription is managed by App Store or Google Play. Please manage it in the corresponding store.`
*/
["error.MANAGED_BY_APP_STORE_OR_PLAY"](): string;
+ /**
+ * `Calendar provider request error, status: {{status}}, message: {{message}}`
+ */
+ ["error.CALENDAR_PROVIDER_REQUEST_ERROR"](options: Readonly<{
+ status: string;
+ message: string;
+ }>): string;
/**
* `Copilot session not found.`
*/
diff --git a/packages/frontend/i18n/src/resources/en.json b/packages/frontend/i18n/src/resources/en.json
index 15ac599d4..e6dfa328f 100644
--- a/packages/frontend/i18n/src/resources/en.json
+++ b/packages/frontend/i18n/src/resources/en.json
@@ -2196,6 +2196,7 @@
"error.WORKSPACE_ID_REQUIRED_FOR_TEAM_SUBSCRIPTION": "A workspace is required to checkout for team subscription.",
"error.WORKSPACE_ID_REQUIRED_TO_UPDATE_TEAM_SUBSCRIPTION": "Workspace id is required to update team subscription.",
"error.MANAGED_BY_APP_STORE_OR_PLAY": "This subscription is managed by App Store or Google Play. Please manage it in the corresponding store.",
+ "error.CALENDAR_PROVIDER_REQUEST_ERROR": "Calendar provider request error, status: {{status}}, message: {{message}}",
"error.COPILOT_SESSION_NOT_FOUND": "Copilot session not found.",
"error.COPILOT_SESSION_INVALID_INPUT": "Copilot session input is invalid.",
"error.COPILOT_SESSION_DELETED": "Copilot session has been deleted.",