mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-04 08:38:34 +00:00
feat: init cloud calendar support (#14247)
#### PR Dependency Tree * **PR #14247** 👈 * **PR #14248** This tree was auto-generated by [Charcoal](https://github.com/danerwilliams/charcoal) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Google Calendar integration (disabled by default): link/unlink accounts, OAuth flow, webhooks, real-time push, background sync, workspace calendars with customizable items and date-range event viewing. * **GraphQL / Client** * New queries & mutations for accounts, subscriptions, events, providers, and workspace calendar management. * **Localization** * Added localized error message for calendar provider request failures. * **Tests** * Backend tests covering sync, webhook renewal, and error/error-recovery scenarios. <sub>✏️ Tip: You can customize this high-level summary in your review settings.</sub> <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -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;
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
172
packages/backend/server/src/models/calendar-account.ts
Normal file
172
packages/backend/server/src/models/calendar-account.ts
Normal file
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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 } },
|
||||
});
|
||||
}
|
||||
}
|
||||
119
packages/backend/server/src/models/calendar-event.ts
Normal file
119
packages/backend/server/src/models/calendar-event.ts
Normal file
@@ -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' }],
|
||||
});
|
||||
}
|
||||
}
|
||||
194
packages/backend/server/src/models/calendar-subscription.ts
Normal file
194
packages/backend/server/src/models/calendar-subscription.ts
Normal file
@@ -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<Pick<CalendarSubscription, 'enabled'>>
|
||||
) {
|
||||
return await this.db.calendarSubscription.updateMany({
|
||||
where: { id: { in: ids } },
|
||||
data,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
81
packages/backend/server/src/models/workspace-calendar.ts
Normal file
81
packages/backend/server/src/models/workspace-calendar.ts
Normal file
@@ -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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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<UpsertCalendarAccountInput> = {}
|
||||
) => {
|
||||
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<UpsertCalendarSubscriptionInput> & {
|
||||
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);
|
||||
});
|
||||
57
packages/backend/server/src/plugins/calendar/config.ts
Normal file
57
packages/backend/server/src/plugins/calendar/config.ts
Normal file
@@ -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<CalendarGoogleConfig>;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
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',
|
||||
},
|
||||
});
|
||||
170
packages/backend/server/src/plugins/calendar/controller.ts
Normal file
170
packages/backend/server/src/plugins/calendar/controller.ts
Normal file
@@ -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.';
|
||||
}
|
||||
}
|
||||
61
packages/backend/server/src/plugins/calendar/cron.ts
Normal file
61
packages/backend/server/src/plugins/calendar/cron.ts
Normal file
@@ -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();
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
27
packages/backend/server/src/plugins/calendar/index.ts
Normal file
27
packages/backend/server/src/plugins/calendar/index.ts
Normal file
@@ -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 {}
|
||||
39
packages/backend/server/src/plugins/calendar/oauth.ts
Normal file
39
packages/backend/server/src/plugins/calendar/oauth.ts
Normal file
@@ -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<CalendarOAuthState>(
|
||||
`${CALENDAR_OAUTH_STATE_KEY}:${token}`
|
||||
);
|
||||
}
|
||||
}
|
||||
180
packages/backend/server/src/plugins/calendar/providers/def.ts
Normal file
180
packages/backend/server/src/plugins/calendar/providers/def.ts
Normal file
@@ -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<string, unknown>;
|
||||
}
|
||||
|
||||
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<CalendarProviderTokens>;
|
||||
abstract refreshTokens(refreshToken: string): Promise<CalendarProviderTokens>;
|
||||
abstract getAccountProfile(
|
||||
accessToken: string
|
||||
): Promise<CalendarAccountProfile>;
|
||||
abstract listCalendars(
|
||||
accessToken: string
|
||||
): Promise<CalendarProviderCalendar[]>;
|
||||
abstract listEvents(
|
||||
params: CalendarProviderListEventsParams
|
||||
): Promise<CalendarProviderListEventsResult>;
|
||||
abstract watchCalendar?(
|
||||
params: CalendarProviderWatchParams
|
||||
): Promise<CalendarProviderWatchResult>;
|
||||
abstract stopChannel?(params: CalendarProviderStopParams): Promise<void>;
|
||||
|
||||
protected readonly logger = new Logger(this.constructor.name);
|
||||
|
||||
@Inject() private readonly factory!: CalendarProviderFactory;
|
||||
@Inject() private readonly AFFiNEConfig!: Config;
|
||||
|
||||
get config() {
|
||||
return (this.AFFiNEConfig.calendar as Record<string, any>)[this.provider];
|
||||
}
|
||||
|
||||
get configured() {
|
||||
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<T>(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<T>(
|
||||
url: string,
|
||||
body: string,
|
||||
options?: { headers?: Record<string, string> }
|
||||
) {
|
||||
return this.fetchJson<T>(url, {
|
||||
method: 'POST',
|
||||
body,
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
...options?.headers,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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<CalendarProviderName, CalendarProvider>();
|
||||
|
||||
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.`);
|
||||
}
|
||||
}
|
||||
351
packages/backend/server/src/plugins/calendar/providers/google.ts
Normal file
351
packages/backend/server/src/plugins/calendar/providers/google.ts
Normal file
@@ -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<CalendarProviderTokens> {
|
||||
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<GoogleTokenResponse>(
|
||||
'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<CalendarProviderTokens> {
|
||||
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<GoogleTokenResponse>(
|
||||
'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<GoogleUserInfo>(
|
||||
'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<GoogleCalendarListResponse>(
|
||||
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<CalendarProviderListEventsResult> {
|
||||
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<GoogleEventsResponse>(
|
||||
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<string, unknown>,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pageToken = response.nextPageToken;
|
||||
if (response.nextSyncToken) {
|
||||
nextSyncToken = response.nextSyncToken;
|
||||
}
|
||||
} while (pageToken);
|
||||
|
||||
return { events, nextSyncToken };
|
||||
}
|
||||
|
||||
async watchCalendar(
|
||||
params: CalendarProviderWatchParams
|
||||
): Promise<CalendarProviderWatchResult> {
|
||||
const response = await this.fetchJson<GoogleWatchResponse>(
|
||||
`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<T>(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;
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
152
packages/backend/server/src/plugins/calendar/resolver.ts
Normal file
152
packages/backend/server/src/plugins/calendar/resolver.ts
Normal file
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
723
packages/backend/server/src/plugins/calendar/service.ts
Normal file
723
packages/backend/server/src/plugins/calendar/service.ts
Normal file
@@ -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<string, number>();
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
188
packages/backend/server/src/plugins/calendar/types.ts
Normal file
188
packages/backend/server/src/plugins/calendar/types.ts
Normal file
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
query calendarAccountCalendars($accountId: String!) {
|
||||
calendarAccountCalendars(accountId: $accountId) {
|
||||
id
|
||||
accountId
|
||||
provider
|
||||
externalCalendarId
|
||||
displayName
|
||||
timezone
|
||||
color
|
||||
enabled
|
||||
lastSyncAt
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
query calendarAccounts {
|
||||
calendarAccounts {
|
||||
id
|
||||
provider
|
||||
providerAccountId
|
||||
displayName
|
||||
email
|
||||
status
|
||||
lastError
|
||||
refreshIntervalMinutes
|
||||
calendarsCount
|
||||
createdAt
|
||||
updatedAt
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
query calendarProviders {
|
||||
calendarProviders
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
mutation linkCalendarAccount($input: LinkCalendarAccountInput!) {
|
||||
linkCalendarAccount(input: $input)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
mutation unlinkCalendarAccount($accountId: String!) {
|
||||
unlinkCalendarAccount(accountId: $accountId)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
mutation updateWorkspaceCalendars($input: UpdateWorkspaceCalendarsInput!) {
|
||||
updateWorkspaceCalendars(input: $input) {
|
||||
id
|
||||
workspaceId
|
||||
createdByUserId
|
||||
displayNameOverride
|
||||
colorOverride
|
||||
enabled
|
||||
items {
|
||||
id
|
||||
subscriptionId
|
||||
sortOrder
|
||||
colorOverride
|
||||
enabled
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
query workspaceCalendars($workspaceId: String!) {
|
||||
workspaceCalendars(workspaceId: $workspaceId) {
|
||||
id
|
||||
workspaceId
|
||||
createdByUserId
|
||||
displayNameOverride
|
||||
colorOverride
|
||||
enabled
|
||||
items {
|
||||
id
|
||||
subscriptionId
|
||||
sortOrder
|
||||
colorOverride
|
||||
enabled
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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<Scalars['String']['output']>;
|
||||
email: Maybe<Scalars['String']['output']>;
|
||||
id: Scalars['String']['output'];
|
||||
lastError: Maybe<Scalars['String']['output']>;
|
||||
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<Scalars['String']['output']>;
|
||||
endAtUtc: Scalars['DateTime']['output'];
|
||||
externalEventId: Scalars['String']['output'];
|
||||
id: Scalars['String']['output'];
|
||||
location: Maybe<Scalars['String']['output']>;
|
||||
originalTimezone: Maybe<Scalars['String']['output']>;
|
||||
recurrenceId: Maybe<Scalars['String']['output']>;
|
||||
startAtUtc: Scalars['DateTime']['output'];
|
||||
status: Maybe<Scalars['String']['output']>;
|
||||
subscriptionId: Scalars['String']['output'];
|
||||
title: Maybe<Scalars['String']['output']>;
|
||||
}
|
||||
|
||||
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<Scalars['String']['output']>;
|
||||
displayName: Maybe<Scalars['String']['output']>;
|
||||
enabled: Scalars['Boolean']['output'];
|
||||
externalCalendarId: Scalars['String']['output'];
|
||||
id: Scalars['String']['output'];
|
||||
lastSyncAt: Maybe<Scalars['DateTime']['output']>;
|
||||
provider: CalendarProviderType;
|
||||
timezone: Maybe<Scalars['String']['output']>;
|
||||
}
|
||||
|
||||
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<Scalars['Boolean']['output']>;
|
||||
}
|
||||
|
||||
export interface LinkCalendarAccountInput {
|
||||
provider: CalendarProviderType;
|
||||
redirectUri?: InputMaybe<Scalars['String']['input']>;
|
||||
}
|
||||
|
||||
export interface ListUserInput {
|
||||
features?: InputMaybe<Array<FeatureType>>;
|
||||
first?: InputMaybe<Scalars['Int']['input']>;
|
||||
@@ -1567,6 +1629,7 @@ export interface Mutation {
|
||||
inviteBatch: Array<InviteResult>;
|
||||
inviteMembers: Array<InviteResult>;
|
||||
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<CalendarAccountObjectType>;
|
||||
/** Update a comment content */
|
||||
updateComment: Scalars['Boolean']['output'];
|
||||
/** Update a copilot prompt */
|
||||
@@ -1644,6 +1709,7 @@ export interface Mutation {
|
||||
updateUserFeatures: Array<FeatureType>;
|
||||
/** 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<Scalars['String']['input']>;
|
||||
}
|
||||
|
||||
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<UpdateAppConfigInput>;
|
||||
}
|
||||
|
||||
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<Array<Scalars['String']['input']>>;
|
||||
remove?: InputMaybe<Array<Scalars['String']['input']>>;
|
||||
@@ -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<CalendarSubscriptionObjectType>;
|
||||
calendarAccounts: Array<CalendarAccountObjectType>;
|
||||
calendarEvents: Array<CalendarEventObjectType>;
|
||||
calendarProviders: Array<CalendarProviderType>;
|
||||
/** @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<WorkspaceCalendarObjectType>;
|
||||
/**
|
||||
* 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<Scalars['Boolean']['input']>;
|
||||
}
|
||||
|
||||
export interface UpdateWorkspaceCalendarsInput {
|
||||
items: Array<WorkspaceCalendarItemInput>;
|
||||
workspaceId: Scalars['String']['input'];
|
||||
}
|
||||
|
||||
export interface UpdateWorkspaceInput {
|
||||
/** Enable AI */
|
||||
enableAi?: InputMaybe<Scalars['Boolean']['input']>;
|
||||
@@ -3014,6 +3121,32 @@ export interface WorkspaceBlobSizes {
|
||||
size: Scalars['SafeInt']['output'];
|
||||
}
|
||||
|
||||
export interface WorkspaceCalendarItemInput {
|
||||
colorOverride?: InputMaybe<Scalars['String']['input']>;
|
||||
sortOrder?: InputMaybe<Scalars['Int']['input']>;
|
||||
subscriptionId: Scalars['String']['input'];
|
||||
}
|
||||
|
||||
export interface WorkspaceCalendarItemObjectType {
|
||||
__typename?: 'WorkspaceCalendarItemObjectType';
|
||||
colorOverride: Maybe<Scalars['String']['output']>;
|
||||
enabled: Scalars['Boolean']['output'];
|
||||
id: Scalars['String']['output'];
|
||||
sortOrder: Maybe<Scalars['Int']['output']>;
|
||||
subscriptionId: Scalars['String']['output'];
|
||||
}
|
||||
|
||||
export interface WorkspaceCalendarObjectType {
|
||||
__typename?: 'WorkspaceCalendarObjectType';
|
||||
colorOverride: Maybe<Scalars['String']['output']>;
|
||||
createdByUserId: Scalars['String']['output'];
|
||||
displayNameOverride: Maybe<Scalars['String']['output']>;
|
||||
enabled: Scalars['Boolean']['output'];
|
||||
id: Scalars['String']['output'];
|
||||
items: Array<WorkspaceCalendarItemObjectType>;
|
||||
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<CalendarProviderType>;
|
||||
};
|
||||
|
||||
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<SubscriptionPlan>;
|
||||
workspaceId?: InputMaybe<Scalars['String']['input']>;
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.`
|
||||
*/
|
||||
|
||||
@@ -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.",
|
||||
|
||||
Reference in New Issue
Block a user