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.",