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:
1 parent
a5b60cf679
commit
0bd8160ed4
40 files changed
+4047
-2
No files matched your search
@@ -650,6 +650,40 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"calendar": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Configuration for calendar module",
|
||||||
|
"properties": {
|
||||||
|
"google": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Google Calendar integration config\n@default {\"enabled\":false,\"clientId\":\"\",\"clientSecret\":\"\",\"externalWebhookUrl\":\"\",\"webhookVerificationToken\":\"\"}\n@link https://developers.google.com/calendar/api/guides/push",
|
||||||
|
"properties": {
|
||||||
|
"enabled": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"clientId": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"clientSecret": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"externalWebhookUrl": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"webhookVerificationToken": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"default": {
|
||||||
|
"enabled": false,
|
||||||
|
"clientId": "",
|
||||||
|
"clientSecret": "",
|
||||||
|
"externalWebhookUrl": "",
|
||||||
|
"webhookVerificationToken": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"captcha": {
|
"captcha": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"description": "Configuration for captcha module",
|
"description": "Configuration for captcha module",
|
||||||
|
|||||||
@@ -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")
|
WorkspaceInvitations WorkspaceUserRole[] @relation("inviter")
|
||||||
docPermissions WorkspaceDocUserRole[]
|
docPermissions WorkspaceDocUserRole[]
|
||||||
connectedAccounts ConnectedAccount[]
|
connectedAccounts ConnectedAccount[]
|
||||||
|
calendarAccounts CalendarAccount[]
|
||||||
sessions UserSession[]
|
sessions UserSession[]
|
||||||
aiSessions AiSession[]
|
aiSessions AiSession[]
|
||||||
appConfigs AppConfig[]
|
appConfigs AppConfig[]
|
||||||
@@ -48,6 +49,7 @@ model User {
|
|||||||
replies Reply[]
|
replies Reply[]
|
||||||
commentAttachments CommentAttachment[] @relation("createdCommentAttachments")
|
commentAttachments CommentAttachment[] @relation("createdCommentAttachments")
|
||||||
AccessToken AccessToken[]
|
AccessToken AccessToken[]
|
||||||
|
workspaceCalendars WorkspaceCalendar[]
|
||||||
|
|
||||||
@@index([email])
|
@@index([email])
|
||||||
@@map("users")
|
@@map("users")
|
||||||
@@ -129,6 +131,7 @@ model Workspace {
|
|||||||
embedFiles AiWorkspaceFiles[]
|
embedFiles AiWorkspaceFiles[]
|
||||||
comments Comment[]
|
comments Comment[]
|
||||||
commentAttachments CommentAttachment[]
|
commentAttachments CommentAttachment[]
|
||||||
|
workspaceCalendars WorkspaceCalendar[]
|
||||||
workspaceAdminStats WorkspaceAdminStats[]
|
workspaceAdminStats WorkspaceAdminStats[]
|
||||||
workspaceAdminStatsDirties WorkspaceAdminStatsDirty[]
|
workspaceAdminStatsDirties WorkspaceAdminStatsDirty[]
|
||||||
|
|
||||||
@@ -911,3 +914,140 @@ model AccessToken {
|
|||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@map("access_tokens")
|
@@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 { WorkspaceModule } from './core/workspaces';
|
||||||
import { Env } from './env';
|
import { Env } from './env';
|
||||||
import { ModelsModule } from './models';
|
import { ModelsModule } from './models';
|
||||||
|
import { CalendarModule } from './plugins/calendar';
|
||||||
import { CaptchaModule } from './plugins/captcha';
|
import { CaptchaModule } from './plugins/captcha';
|
||||||
import { CopilotModule } from './plugins/copilot';
|
import { CopilotModule } from './plugins/copilot';
|
||||||
import { CustomerIoModule } from './plugins/customerio';
|
import { CustomerIoModule } from './plugins/customerio';
|
||||||
@@ -188,6 +189,7 @@ export function buildAppModule(env: Env) {
|
|||||||
CopilotModule,
|
CopilotModule,
|
||||||
CaptchaModule,
|
CaptchaModule,
|
||||||
OAuthModule,
|
OAuthModule,
|
||||||
|
CalendarModule,
|
||||||
CustomerIoModule,
|
CustomerIoModule,
|
||||||
CommentModule,
|
CommentModule,
|
||||||
AccessTokenModule,
|
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.',
|
'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 errors
|
||||||
copilot_session_not_found: {
|
copilot_session_not_found: {
|
||||||
type: 'resource_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);
|
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 {
|
export class CopilotSessionNotFound extends UserFriendlyError {
|
||||||
constructor(message?: string) {
|
constructor(message?: string) {
|
||||||
@@ -1196,6 +1207,7 @@ export enum ErrorNames {
|
|||||||
WORKSPACE_ID_REQUIRED_FOR_TEAM_SUBSCRIPTION,
|
WORKSPACE_ID_REQUIRED_FOR_TEAM_SUBSCRIPTION,
|
||||||
WORKSPACE_ID_REQUIRED_TO_UPDATE_TEAM_SUBSCRIPTION,
|
WORKSPACE_ID_REQUIRED_TO_UPDATE_TEAM_SUBSCRIPTION,
|
||||||
MANAGED_BY_APP_STORE_OR_PLAY,
|
MANAGED_BY_APP_STORE_OR_PLAY,
|
||||||
|
CALENDAR_PROVIDER_REQUEST_ERROR,
|
||||||
COPILOT_SESSION_NOT_FOUND,
|
COPILOT_SESSION_NOT_FOUND,
|
||||||
COPILOT_SESSION_INVALID_INPUT,
|
COPILOT_SESSION_INVALID_INPUT,
|
||||||
COPILOT_SESSION_DELETED,
|
COPILOT_SESSION_DELETED,
|
||||||
@@ -1262,5 +1274,5 @@ registerEnumType(ErrorNames, {
|
|||||||
export const ErrorDataUnionType = createUnionType({
|
export const ErrorDataUnionType = createUnionType({
|
||||||
name: 'ErrorDataUnion',
|
name: 'ErrorDataUnion',
|
||||||
types: () =>
|
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,
|
||||||
});
|
});
|
||||||
@@ -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 } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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' }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 { ApplyType } from '../base';
|
||||||
import { AccessTokenModel } from './access-token';
|
import { AccessTokenModel } from './access-token';
|
||||||
import { BlobModel } from './blob';
|
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 { CommentModel } from './comment';
|
||||||
import { CommentAttachmentModel } from './comment-attachment';
|
import { CommentAttachmentModel } from './comment-attachment';
|
||||||
import { AppConfigModel } from './config';
|
import { AppConfigModel } from './config';
|
||||||
@@ -29,6 +33,7 @@ import { UserFeatureModel } from './user-feature';
|
|||||||
import { UserSettingsModel } from './user-settings';
|
import { UserSettingsModel } from './user-settings';
|
||||||
import { VerificationTokenModel } from './verification-token';
|
import { VerificationTokenModel } from './verification-token';
|
||||||
import { WorkspaceModel } from './workspace';
|
import { WorkspaceModel } from './workspace';
|
||||||
|
import { WorkspaceCalendarModel } from './workspace-calendar';
|
||||||
import { WorkspaceFeatureModel } from './workspace-feature';
|
import { WorkspaceFeatureModel } from './workspace-feature';
|
||||||
import { WorkspaceUserModel } from './workspace-user';
|
import { WorkspaceUserModel } from './workspace-user';
|
||||||
|
|
||||||
@@ -56,6 +61,11 @@ const MODELS = {
|
|||||||
commentAttachment: CommentAttachmentModel,
|
commentAttachment: CommentAttachmentModel,
|
||||||
blob: BlobModel,
|
blob: BlobModel,
|
||||||
accessToken: AccessTokenModel,
|
accessToken: AccessTokenModel,
|
||||||
|
calendarAccount: CalendarAccountModel,
|
||||||
|
calendarSubscription: CalendarSubscriptionModel,
|
||||||
|
calendarEvent: CalendarEventModel,
|
||||||
|
calendarEventInstance: CalendarEventInstanceModel,
|
||||||
|
workspaceCalendar: WorkspaceCalendarModel,
|
||||||
};
|
};
|
||||||
|
|
||||||
type ModelsType = {
|
type ModelsType = {
|
||||||
@@ -108,6 +118,10 @@ const ModelsSymbolProvider: ExistingProvider = {
|
|||||||
export class ModelsModule {}
|
export class ModelsModule {}
|
||||||
|
|
||||||
export * from './blob';
|
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';
|
||||||
export * from './comment-attachment';
|
export * from './comment-attachment';
|
||||||
export * from './common';
|
export * from './common';
|
||||||
@@ -127,5 +141,6 @@ export * from './user-feature';
|
|||||||
export * from './user-settings';
|
export * from './user-settings';
|
||||||
export * from './verification-token';
|
export * from './verification-token';
|
||||||
export * from './workspace';
|
export * from './workspace';
|
||||||
|
export * from './workspace-calendar';
|
||||||
export * from './workspace-feature';
|
export * from './workspace-feature';
|
||||||
export * from './workspace-user';
|
export * from './workspace-user';
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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.';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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];
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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!
|
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 {
|
enum ChatHistoryOrder {
|
||||||
asc
|
asc
|
||||||
desc
|
desc
|
||||||
@@ -746,7 +797,7 @@ type EditorType {
|
|||||||
name: String!
|
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 {
|
enum ErrorNames {
|
||||||
ACCESS_DENIED
|
ACCESS_DENIED
|
||||||
@@ -758,6 +809,7 @@ enum ErrorNames {
|
|||||||
BLOB_INVALID
|
BLOB_INVALID
|
||||||
BLOB_NOT_FOUND
|
BLOB_NOT_FOUND
|
||||||
BLOB_QUOTA_EXCEEDED
|
BLOB_QUOTA_EXCEEDED
|
||||||
|
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
|
||||||
@@ -1220,6 +1272,11 @@ type LimitedUserType {
|
|||||||
hasPassword: Boolean
|
hasPassword: Boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
input LinkCalendarAccountInput {
|
||||||
|
provider: CalendarProviderType!
|
||||||
|
redirectUri: String
|
||||||
|
}
|
||||||
|
|
||||||
input ListUserInput {
|
input ListUserInput {
|
||||||
features: [FeatureType!]
|
features: [FeatureType!]
|
||||||
first: Int = 20
|
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")
|
inviteBatch(emails: [String!]!, sendInviteMail: Boolean @deprecated(reason: "never used"), workspaceId: String!): [InviteResult!]! @deprecated(reason: "use [inviteMembers] instead")
|
||||||
inviteMembers(emails: [String!]!, workspaceId: String!): [InviteResult!]!
|
inviteMembers(emails: [String!]!, workspaceId: String!): [InviteResult!]!
|
||||||
leaveWorkspace(sendLeaveMail: Boolean @deprecated(reason: "no used anymore"), workspaceId: String!, workspaceName: String @deprecated(reason: "no longer used")): Boolean!
|
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"""
|
"""mention user in a doc"""
|
||||||
mentionUser(input: MentionInput!): ID!
|
mentionUser(input: MentionInput!): ID!
|
||||||
@@ -1469,9 +1527,11 @@ type Mutation {
|
|||||||
|
|
||||||
"""Trigger generate missing titles cron job"""
|
"""Trigger generate missing titles cron job"""
|
||||||
triggerGenerateTitleCron: Boolean!
|
triggerGenerateTitleCron: Boolean!
|
||||||
|
unlinkCalendarAccount(accountId: String!): Boolean!
|
||||||
|
|
||||||
"""update app configuration"""
|
"""update app configuration"""
|
||||||
updateAppConfig(updates: [UpdateAppConfigInput!]!): JSONObject!
|
updateAppConfig(updates: [UpdateAppConfigInput!]!): JSONObject!
|
||||||
|
updateCalendarAccount(accountId: String!, refreshIntervalMinutes: Int!): CalendarAccountObjectType
|
||||||
|
|
||||||
"""Update a comment content"""
|
"""Update a comment content"""
|
||||||
updateComment(input: CommentUpdateInput!): Boolean!
|
updateComment(input: CommentUpdateInput!): Boolean!
|
||||||
@@ -1500,6 +1560,7 @@ type Mutation {
|
|||||||
|
|
||||||
"""Update workspace"""
|
"""Update workspace"""
|
||||||
updateWorkspace(input: UpdateWorkspaceInput!): WorkspaceType!
|
updateWorkspace(input: UpdateWorkspaceInput!): WorkspaceType!
|
||||||
|
updateWorkspaceCalendars(input: UpdateWorkspaceCalendarsInput!): WorkspaceCalendarObjectType!
|
||||||
|
|
||||||
"""Update ignored docs"""
|
"""Update ignored docs"""
|
||||||
updateWorkspaceEmbeddingIgnoredDocs(add: [String!], remove: [String!], workspaceId: String!): Int!
|
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."""
|
"""Apply updates to a doc using LLM and return the merged markdown."""
|
||||||
applyDocUpdates(docId: String!, op: String!, updates: String!, workspaceId: String!): String!
|
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")
|
collectAllBlobSizes: WorkspaceBlobSizes! @deprecated(reason: "use `user.quotaUsage` instead")
|
||||||
|
|
||||||
"""Get current user"""
|
"""Get current user"""
|
||||||
@@ -1751,6 +1816,7 @@ type Query {
|
|||||||
|
|
||||||
"""Get workspace by id"""
|
"""Get workspace by id"""
|
||||||
workspace(id: String!): WorkspaceType!
|
workspace(id: String!): WorkspaceType!
|
||||||
|
workspaceCalendars(workspaceId: String!): [WorkspaceCalendarObjectType!]!
|
||||||
|
|
||||||
"""Get workspace role permissions"""
|
"""Get workspace role permissions"""
|
||||||
workspaceRolePermissions(id: String!): WorkspaceRolePermissions! @deprecated(reason: "use WorkspaceType[permissions] instead")
|
workspaceRolePermissions(id: String!): WorkspaceRolePermissions! @deprecated(reason: "use WorkspaceType[permissions] instead")
|
||||||
@@ -2207,6 +2273,11 @@ input UpdateUserSettingsInput {
|
|||||||
receiveMentionEmail: Boolean
|
receiveMentionEmail: Boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
input UpdateWorkspaceCalendarsInput {
|
||||||
|
items: [WorkspaceCalendarItemInput!]!
|
||||||
|
workspaceId: String!
|
||||||
|
}
|
||||||
|
|
||||||
input UpdateWorkspaceInput {
|
input UpdateWorkspaceInput {
|
||||||
"""Enable AI"""
|
"""Enable AI"""
|
||||||
enableAi: Boolean
|
enableAi: Boolean
|
||||||
@@ -2331,6 +2402,30 @@ type WorkspaceBlobSizes {
|
|||||||
size: SafeInt!
|
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 {
|
type WorkspaceDocMeta {
|
||||||
createdAt: DateTime!
|
createdAt: DateTime!
|
||||||
createdBy: EditorType
|
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 = {
|
export const cancelSubscriptionMutation = {
|
||||||
id: 'cancelSubscriptionMutation' as const,
|
id: 'cancelSubscriptionMutation' as const,
|
||||||
op: 'cancelSubscription',
|
op: 'cancelSubscription',
|
||||||
|
|||||||
@@ -244,6 +244,61 @@ export interface BlobUploadedPart {
|
|||||||
partNumber: Scalars['Int']['output'];
|
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 {
|
export enum ChatHistoryOrder {
|
||||||
asc = 'asc',
|
asc = 'asc',
|
||||||
desc = 'desc',
|
desc = 'desc',
|
||||||
@@ -881,6 +936,7 @@ export interface EditorType {
|
|||||||
export type ErrorDataUnion =
|
export type ErrorDataUnion =
|
||||||
| AlreadyInSpaceDataType
|
| AlreadyInSpaceDataType
|
||||||
| BlobNotFoundDataType
|
| BlobNotFoundDataType
|
||||||
|
| CalendarProviderRequestErrorDataType
|
||||||
| CopilotContextFileNotSupportedDataType
|
| CopilotContextFileNotSupportedDataType
|
||||||
| CopilotDocNotFoundDataType
|
| CopilotDocNotFoundDataType
|
||||||
| CopilotFailedToAddWorkspaceFileEmbeddingDataType
|
| CopilotFailedToAddWorkspaceFileEmbeddingDataType
|
||||||
@@ -948,6 +1004,7 @@ export enum ErrorNames {
|
|||||||
BLOB_INVALID = 'BLOB_INVALID',
|
BLOB_INVALID = 'BLOB_INVALID',
|
||||||
BLOB_NOT_FOUND = 'BLOB_NOT_FOUND',
|
BLOB_NOT_FOUND = 'BLOB_NOT_FOUND',
|
||||||
BLOB_QUOTA_EXCEEDED = 'BLOB_QUOTA_EXCEEDED',
|
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_ACCOUNT_WITH_OWNED_TEAM_WORKSPACE = 'CANNOT_DELETE_ACCOUNT_WITH_OWNED_TEAM_WORKSPACE',
|
||||||
CANNOT_DELETE_ALL_ADMIN_ACCOUNT = 'CANNOT_DELETE_ALL_ADMIN_ACCOUNT',
|
CANNOT_DELETE_ALL_ADMIN_ACCOUNT = 'CANNOT_DELETE_ALL_ADMIN_ACCOUNT',
|
||||||
CANNOT_DELETE_OWN_ACCOUNT = 'CANNOT_DELETE_OWN_ACCOUNT',
|
CANNOT_DELETE_OWN_ACCOUNT = 'CANNOT_DELETE_OWN_ACCOUNT',
|
||||||
@@ -1405,6 +1462,11 @@ export interface LimitedUserType {
|
|||||||
hasPassword: Maybe<Scalars['Boolean']['output']>;
|
hasPassword: Maybe<Scalars['Boolean']['output']>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LinkCalendarAccountInput {
|
||||||
|
provider: CalendarProviderType;
|
||||||
|
redirectUri?: InputMaybe<Scalars['String']['input']>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ListUserInput {
|
export interface ListUserInput {
|
||||||
features?: InputMaybe<Array<FeatureType>>;
|
features?: InputMaybe<Array<FeatureType>>;
|
||||||
first?: InputMaybe<Scalars['Int']['input']>;
|
first?: InputMaybe<Scalars['Int']['input']>;
|
||||||
@@ -1567,6 +1629,7 @@ export interface Mutation {
|
|||||||
inviteBatch: Array<InviteResult>;
|
inviteBatch: Array<InviteResult>;
|
||||||
inviteMembers: Array<InviteResult>;
|
inviteMembers: Array<InviteResult>;
|
||||||
leaveWorkspace: Scalars['Boolean']['output'];
|
leaveWorkspace: Scalars['Boolean']['output'];
|
||||||
|
linkCalendarAccount: Scalars['String']['output'];
|
||||||
/** mention user in a doc */
|
/** mention user in a doc */
|
||||||
mentionUser: Scalars['ID']['output'];
|
mentionUser: Scalars['ID']['output'];
|
||||||
publishDoc: DocType;
|
publishDoc: DocType;
|
||||||
@@ -1622,8 +1685,10 @@ export interface Mutation {
|
|||||||
triggerCleanupTrashedDocEmbeddings: Scalars['Boolean']['output'];
|
triggerCleanupTrashedDocEmbeddings: Scalars['Boolean']['output'];
|
||||||
/** Trigger generate missing titles cron job */
|
/** Trigger generate missing titles cron job */
|
||||||
triggerGenerateTitleCron: Scalars['Boolean']['output'];
|
triggerGenerateTitleCron: Scalars['Boolean']['output'];
|
||||||
|
unlinkCalendarAccount: Scalars['Boolean']['output'];
|
||||||
/** update app configuration */
|
/** update app configuration */
|
||||||
updateAppConfig: Scalars['JSONObject']['output'];
|
updateAppConfig: Scalars['JSONObject']['output'];
|
||||||
|
updateCalendarAccount: Maybe<CalendarAccountObjectType>;
|
||||||
/** Update a comment content */
|
/** Update a comment content */
|
||||||
updateComment: Scalars['Boolean']['output'];
|
updateComment: Scalars['Boolean']['output'];
|
||||||
/** Update a copilot prompt */
|
/** Update a copilot prompt */
|
||||||
@@ -1644,6 +1709,7 @@ export interface Mutation {
|
|||||||
updateUserFeatures: Array<FeatureType>;
|
updateUserFeatures: Array<FeatureType>;
|
||||||
/** Update workspace */
|
/** Update workspace */
|
||||||
updateWorkspace: WorkspaceType;
|
updateWorkspace: WorkspaceType;
|
||||||
|
updateWorkspaceCalendars: WorkspaceCalendarObjectType;
|
||||||
/** Update ignored docs */
|
/** Update ignored docs */
|
||||||
updateWorkspaceEmbeddingIgnoredDocs: Scalars['Int']['output'];
|
updateWorkspaceEmbeddingIgnoredDocs: Scalars['Int']['output'];
|
||||||
/** Upload user avatar */
|
/** Upload user avatar */
|
||||||
@@ -1888,6 +1954,10 @@ export interface MutationLeaveWorkspaceArgs {
|
|||||||
workspaceName?: InputMaybe<Scalars['String']['input']>;
|
workspaceName?: InputMaybe<Scalars['String']['input']>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MutationLinkCalendarAccountArgs {
|
||||||
|
input: LinkCalendarAccountInput;
|
||||||
|
}
|
||||||
|
|
||||||
export interface MutationMentionUserArgs {
|
export interface MutationMentionUserArgs {
|
||||||
input: MentionInput;
|
input: MentionInput;
|
||||||
}
|
}
|
||||||
@@ -2041,10 +2111,19 @@ export interface MutationSubmitAudioTranscriptionArgs {
|
|||||||
workspaceId: Scalars['String']['input'];
|
workspaceId: Scalars['String']['input'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MutationUnlinkCalendarAccountArgs {
|
||||||
|
accountId: Scalars['String']['input'];
|
||||||
|
}
|
||||||
|
|
||||||
export interface MutationUpdateAppConfigArgs {
|
export interface MutationUpdateAppConfigArgs {
|
||||||
updates: Array<UpdateAppConfigInput>;
|
updates: Array<UpdateAppConfigInput>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MutationUpdateCalendarAccountArgs {
|
||||||
|
accountId: Scalars['String']['input'];
|
||||||
|
refreshIntervalMinutes: Scalars['Int']['input'];
|
||||||
|
}
|
||||||
|
|
||||||
export interface MutationUpdateCommentArgs {
|
export interface MutationUpdateCommentArgs {
|
||||||
input: CommentUpdateInput;
|
input: CommentUpdateInput;
|
||||||
}
|
}
|
||||||
@@ -2099,6 +2178,10 @@ export interface MutationUpdateWorkspaceArgs {
|
|||||||
input: UpdateWorkspaceInput;
|
input: UpdateWorkspaceInput;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MutationUpdateWorkspaceCalendarsArgs {
|
||||||
|
input: UpdateWorkspaceCalendarsInput;
|
||||||
|
}
|
||||||
|
|
||||||
export interface MutationUpdateWorkspaceEmbeddingIgnoredDocsArgs {
|
export interface MutationUpdateWorkspaceEmbeddingIgnoredDocsArgs {
|
||||||
add?: InputMaybe<Array<Scalars['String']['input']>>;
|
add?: InputMaybe<Array<Scalars['String']['input']>>;
|
||||||
remove?: InputMaybe<Array<Scalars['String']['input']>>;
|
remove?: InputMaybe<Array<Scalars['String']['input']>>;
|
||||||
@@ -2315,6 +2398,10 @@ export interface Query {
|
|||||||
appConfig: Scalars['JSONObject']['output'];
|
appConfig: Scalars['JSONObject']['output'];
|
||||||
/** Apply updates to a doc using LLM and return the merged markdown. */
|
/** Apply updates to a doc using LLM and return the merged markdown. */
|
||||||
applyDocUpdates: Scalars['String']['output'];
|
applyDocUpdates: Scalars['String']['output'];
|
||||||
|
calendarAccountCalendars: Array<CalendarSubscriptionObjectType>;
|
||||||
|
calendarAccounts: Array<CalendarAccountObjectType>;
|
||||||
|
calendarEvents: Array<CalendarEventObjectType>;
|
||||||
|
calendarProviders: Array<CalendarProviderType>;
|
||||||
/** @deprecated use `user.quotaUsage` instead */
|
/** @deprecated use `user.quotaUsage` instead */
|
||||||
collectAllBlobSizes: WorkspaceBlobSizes;
|
collectAllBlobSizes: WorkspaceBlobSizes;
|
||||||
/** Get current user */
|
/** Get current user */
|
||||||
@@ -2354,6 +2441,7 @@ export interface Query {
|
|||||||
usersCount: Scalars['Int']['output'];
|
usersCount: Scalars['Int']['output'];
|
||||||
/** Get workspace by id */
|
/** Get workspace by id */
|
||||||
workspace: WorkspaceType;
|
workspace: WorkspaceType;
|
||||||
|
workspaceCalendars: Array<WorkspaceCalendarObjectType>;
|
||||||
/**
|
/**
|
||||||
* Get workspace role permissions
|
* Get workspace role permissions
|
||||||
* @deprecated use WorkspaceType[permissions] instead
|
* @deprecated use WorkspaceType[permissions] instead
|
||||||
@@ -2382,6 +2470,16 @@ export interface QueryApplyDocUpdatesArgs {
|
|||||||
workspaceId: Scalars['String']['input'];
|
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 {
|
export interface QueryErrorArgs {
|
||||||
name: ErrorNames;
|
name: ErrorNames;
|
||||||
}
|
}
|
||||||
@@ -2430,6 +2528,10 @@ export interface QueryWorkspaceArgs {
|
|||||||
id: Scalars['String']['input'];
|
id: Scalars['String']['input'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface QueryWorkspaceCalendarsArgs {
|
||||||
|
workspaceId: Scalars['String']['input'];
|
||||||
|
}
|
||||||
|
|
||||||
export interface QueryWorkspaceRolePermissionsArgs {
|
export interface QueryWorkspaceRolePermissionsArgs {
|
||||||
id: Scalars['String']['input'];
|
id: Scalars['String']['input'];
|
||||||
}
|
}
|
||||||
@@ -2883,6 +2985,11 @@ export interface UpdateUserSettingsInput {
|
|||||||
receiveMentionEmail?: InputMaybe<Scalars['Boolean']['input']>;
|
receiveMentionEmail?: InputMaybe<Scalars['Boolean']['input']>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UpdateWorkspaceCalendarsInput {
|
||||||
|
items: Array<WorkspaceCalendarItemInput>;
|
||||||
|
workspaceId: Scalars['String']['input'];
|
||||||
|
}
|
||||||
|
|
||||||
export interface UpdateWorkspaceInput {
|
export interface UpdateWorkspaceInput {
|
||||||
/** Enable AI */
|
/** Enable AI */
|
||||||
enableAi?: InputMaybe<Scalars['Boolean']['input']>;
|
enableAi?: InputMaybe<Scalars['Boolean']['input']>;
|
||||||
@@ -3014,6 +3121,32 @@ export interface WorkspaceBlobSizes {
|
|||||||
size: Scalars['SafeInt']['output'];
|
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 {
|
export interface WorkspaceDocMeta {
|
||||||
__typename?: 'WorkspaceDocMeta';
|
__typename?: 'WorkspaceDocMeta';
|
||||||
createdAt: Scalars['DateTime']['output'];
|
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<{
|
export type CancelSubscriptionMutationVariables = Exact<{
|
||||||
plan?: InputMaybe<SubscriptionPlan>;
|
plan?: InputMaybe<SubscriptionPlan>;
|
||||||
workspaceId?: InputMaybe<Scalars['String']['input']>;
|
workspaceId?: InputMaybe<Scalars['String']['input']>;
|
||||||
@@ -6810,6 +7106,31 @@ export type Queries =
|
|||||||
variables: ListBlobsQueryVariables;
|
variables: ListBlobsQueryVariables;
|
||||||
response: ListBlobsQuery;
|
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';
|
name: 'listCommentChangesQuery';
|
||||||
variables: ListCommentChangesQueryVariables;
|
variables: ListCommentChangesQueryVariables;
|
||||||
@@ -7252,6 +7573,26 @@ export type Mutations =
|
|||||||
variables: GetBlobUploadPartUrlMutationVariables;
|
variables: GetBlobUploadPartUrlMutationVariables;
|
||||||
response: GetBlobUploadPartUrlMutation;
|
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';
|
name: 'cancelSubscriptionMutation';
|
||||||
variables: CancelSubscriptionMutationVariables;
|
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."
|
"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": {
|
"captcha": {
|
||||||
"enabled": {
|
"enabled": {
|
||||||
"type": "Boolean",
|
"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.`
|
* `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;
|
["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.`
|
* `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_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.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.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_NOT_FOUND": "Copilot session not found.",
|
||||||
"error.COPILOT_SESSION_INVALID_INPUT": "Copilot session input is invalid.",
|
"error.COPILOT_SESSION_INVALID_INPUT": "Copilot session input is invalid.",
|
||||||
"error.COPILOT_SESSION_DELETED": "Copilot session has been deleted.",
|
"error.COPILOT_SESSION_DELETED": "Copilot session has been deleted.",
|
||||||
|
|||||||
Reference in new issue
Block a user