feat: init cloud calendar support (#14247)

#### PR Dependency Tree


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

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

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

<sub>✏️ Tip: You can customize this high-level summary in your review
settings.</sub>
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
DarkSky authored and GitHub committed 2026-01-12 23:17:43 +08:00
1 parent a5b60cf679
commit 0bd8160ed4
40 files changed
+4047 -2

No files matched your search

+34
View File
@@ -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;
+140
View File
@@ -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;
}
+96 -1
View File
@@ -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',
+341
View File
@@ -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;
+7
View File
@@ -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",
+7
View File
@@ -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.",