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
2026-01-12 23:17:43 +08:00
committed by GitHub
parent a5b60cf679
commit 0bd8160ed4
40 changed files with 4047 additions and 2 deletions

View File

@@ -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;

View File

@@ -32,6 +32,7 @@ model User {
WorkspaceInvitations WorkspaceUserRole[] @relation("inviter")
docPermissions WorkspaceDocUserRole[]
connectedAccounts ConnectedAccount[]
calendarAccounts CalendarAccount[]
sessions UserSession[]
aiSessions AiSession[]
appConfigs AppConfig[]
@@ -48,6 +49,7 @@ model User {
replies Reply[]
commentAttachments CommentAttachment[] @relation("createdCommentAttachments")
AccessToken AccessToken[]
workspaceCalendars WorkspaceCalendar[]
@@index([email])
@@map("users")
@@ -129,6 +131,7 @@ model Workspace {
embedFiles AiWorkspaceFiles[]
comments Comment[]
commentAttachments CommentAttachment[]
workspaceCalendars WorkspaceCalendar[]
workspaceAdminStats WorkspaceAdminStats[]
workspaceAdminStatsDirties WorkspaceAdminStatsDirty[]
@@ -911,3 +914,140 @@ model AccessToken {
@@index([userId])
@@map("access_tokens")
}
model CalendarAccount {
id String @id @default(uuid()) @db.VarChar
userId String @map("user_id") @db.VarChar
provider String @db.VarChar
providerAccountId String @map("provider_account_id") @db.VarChar
displayName String? @map("display_name") @db.VarChar
email String? @db.VarChar
accessToken String? @map("access_token") @db.Text
refreshToken String? @map("refresh_token") @db.Text
expiresAt DateTime? @map("expires_at") @db.Timestamptz(3)
scope String? @db.Text
status String @default("active") @db.VarChar
lastError String? @map("last_error") @db.Text
refreshIntervalMinutes Int @default(30) @map("refresh_interval_minutes")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
subscriptions CalendarSubscription[]
@@unique([userId, provider, providerAccountId])
@@index([userId])
@@index([provider, providerAccountId])
@@map("calendar_accounts")
}
model CalendarSubscription {
id String @id @default(uuid()) @db.VarChar
accountId String @map("account_id") @db.VarChar
provider String @db.VarChar
externalCalendarId String @map("external_calendar_id") @db.VarChar
displayName String? @map("display_name") @db.VarChar
timezone String? @db.VarChar
color String? @db.VarChar
enabled Boolean @default(true)
syncToken String? @map("sync_token") @db.Text
lastSyncAt DateTime? @map("last_sync_at") @db.Timestamptz(3)
customChannelId String? @map("custom_channel_id") @db.VarChar
customResourceId String? @map("custom_resource_id") @db.VarChar
channelExpiration DateTime? @map("channel_expiration") @db.Timestamptz(3)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
account CalendarAccount @relation(fields: [accountId], references: [id], onDelete: Cascade)
workspaceItems WorkspaceCalendarItem[]
events CalendarEvent[]
@@unique([accountId, externalCalendarId])
@@index([accountId])
@@index([provider, externalCalendarId])
@@map("calendar_subscriptions")
}
model WorkspaceCalendar {
id String @id @default(uuid()) @db.VarChar
workspaceId String @map("workspace_id") @db.VarChar
createdByUserId String @map("created_by_user_id") @db.VarChar
displayNameOverride String? @map("display_name_override") @db.VarChar
colorOverride String? @map("color_override") @db.VarChar
enabled Boolean @default(true)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
createdByUser User @relation(fields: [createdByUserId], references: [id], onDelete: Cascade)
items WorkspaceCalendarItem[]
@@index([workspaceId])
@@map("workspace_calendars")
}
model WorkspaceCalendarItem {
id String @id @default(uuid()) @db.VarChar
workspaceCalendarId String @map("workspace_calendar_id") @db.VarChar
subscriptionId String @map("subscription_id") @db.VarChar
sortOrder Int? @map("sort_order")
colorOverride String? @map("color_override") @db.VarChar
enabled Boolean @default(true)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
workspaceCalendar WorkspaceCalendar @relation(fields: [workspaceCalendarId], references: [id], onDelete: Cascade)
subscription CalendarSubscription @relation(fields: [subscriptionId], references: [id], onDelete: Cascade)
@@unique([workspaceCalendarId, subscriptionId])
@@index([subscriptionId])
@@map("workspace_calendar_items")
}
model CalendarEvent {
id String @id @default(uuid()) @db.VarChar
subscriptionId String @map("subscription_id") @db.VarChar
externalEventId String @map("external_event_id") @db.VarChar
recurrenceId String? @map("recurrence_id") @db.VarChar
etag String? @db.VarChar
status String? @db.VarChar
title String? @db.VarChar
description String? @db.Text
location String? @db.VarChar
startAtUtc DateTime @map("start_at_utc") @db.Timestamptz(3)
endAtUtc DateTime @map("end_at_utc") @db.Timestamptz(3)
originalTimezone String? @map("original_timezone") @db.VarChar
allDay Boolean @default(false) @map("all_day")
providerUpdatedAt DateTime? @map("provider_updated_at") @db.Timestamptz(3)
raw Json @db.JsonB
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
subscription CalendarSubscription @relation(fields: [subscriptionId], references: [id], onDelete: Cascade)
instances CalendarEventInstance[]
@@unique([subscriptionId, externalEventId, recurrenceId])
@@index([subscriptionId, startAtUtc])
@@index([subscriptionId, endAtUtc])
@@map("calendar_events")
}
model CalendarEventInstance {
id String @id @default(uuid()) @db.VarChar
calendarEventId String @map("calendar_event_id") @db.VarChar
recurrenceId String @map("recurrence_id") @db.VarChar
startAtUtc DateTime @map("start_at_utc") @db.Timestamptz(3)
endAtUtc DateTime @map("end_at_utc") @db.Timestamptz(3)
originalTimezone String? @map("original_timezone") @db.VarChar
allDay Boolean @default(false) @map("all_day")
providerUpdatedAt DateTime? @map("provider_updated_at") @db.Timestamptz(3)
raw Json @db.JsonB
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
calendarEvent CalendarEvent @relation(fields: [calendarEventId], references: [id], onDelete: Cascade)
@@unique([calendarEventId, recurrenceId])
@@index([calendarEventId, startAtUtc])
@@map("calendar_event_instances")
}

View File

@@ -50,6 +50,7 @@ import { VersionModule } from './core/version';
import { WorkspaceModule } from './core/workspaces';
import { Env } from './env';
import { ModelsModule } from './models';
import { CalendarModule } from './plugins/calendar';
import { CaptchaModule } from './plugins/captcha';
import { CopilotModule } from './plugins/copilot';
import { CustomerIoModule } from './plugins/customerio';
@@ -188,6 +189,7 @@ export function buildAppModule(env: Env) {
CopilotModule,
CaptchaModule,
OAuthModule,
CalendarModule,
CustomerIoModule,
CommentModule,
AccessTokenModule,

View File

@@ -643,6 +643,14 @@ export const USER_FRIENDLY_ERRORS = {
'This subscription is managed by App Store or Google Play. Please manage it in the corresponding store.',
},
// Calendar errors
calendar_provider_request_error: {
type: 'internal_server_error',
args: { status: 'number', message: 'string' },
message: ({ status, message }) =>
`Calendar provider request error, status: ${status}, message: ${message}`,
},
// Copilot errors
copilot_session_not_found: {
type: 'resource_not_found',

View File

@@ -656,6 +656,17 @@ export class ManagedByAppStoreOrPlay extends UserFriendlyError {
super('action_forbidden', 'managed_by_app_store_or_play', message);
}
}
@ObjectType()
class CalendarProviderRequestErrorDataType {
@Field() status!: number
@Field() message!: string
}
export class CalendarProviderRequestError extends UserFriendlyError {
constructor(args: CalendarProviderRequestErrorDataType, message?: string | ((args: CalendarProviderRequestErrorDataType) => string)) {
super('internal_server_error', 'calendar_provider_request_error', message, args);
}
}
export class CopilotSessionNotFound extends UserFriendlyError {
constructor(message?: string) {
@@ -1196,6 +1207,7 @@ export enum ErrorNames {
WORKSPACE_ID_REQUIRED_FOR_TEAM_SUBSCRIPTION,
WORKSPACE_ID_REQUIRED_TO_UPDATE_TEAM_SUBSCRIPTION,
MANAGED_BY_APP_STORE_OR_PLAY,
CALENDAR_PROVIDER_REQUEST_ERROR,
COPILOT_SESSION_NOT_FOUND,
COPILOT_SESSION_INVALID_INPUT,
COPILOT_SESSION_DELETED,
@@ -1262,5 +1274,5 @@ registerEnumType(ErrorNames, {
export const ErrorDataUnionType = createUnionType({
name: 'ErrorDataUnion',
types: () =>
[GraphqlBadRequestDataType, HttpRequestErrorDataType, QueryTooLongDataType, ValidationErrorDataType, WrongSignInCredentialsDataType, UnknownOauthProviderDataType, InvalidOauthCallbackCodeDataType, MissingOauthQueryParameterDataType, InvalidOauthResponseDataType, InvalidEmailDataType, InvalidPasswordLengthDataType, WorkspacePermissionNotFoundDataType, SpaceNotFoundDataType, MemberNotFoundInSpaceDataType, NotInSpaceDataType, AlreadyInSpaceDataType, SpaceAccessDeniedDataType, SpaceOwnerNotFoundDataType, SpaceShouldHaveOnlyOneOwnerDataType, DocNotFoundDataType, DocActionDeniedDataType, DocUpdateBlockedDataType, VersionRejectedDataType, InvalidHistoryTimestampDataType, DocHistoryNotFoundDataType, BlobNotFoundDataType, ExpectToGrantDocUserRolesDataType, ExpectToRevokeDocUserRolesDataType, ExpectToUpdateDocUserRoleDataType, NoMoreSeatDataType, UnsupportedSubscriptionPlanDataType, SubscriptionAlreadyExistsDataType, SubscriptionNotExistsDataType, SameSubscriptionRecurringDataType, SubscriptionPlanNotFoundDataType, NoCopilotProviderAvailableDataType, CopilotFailedToGenerateEmbeddingDataType, CopilotDocNotFoundDataType, CopilotMessageNotFoundDataType, CopilotPromptNotFoundDataType, CopilotProviderNotSupportedDataType, CopilotProviderSideErrorDataType, CopilotInvalidContextDataType, CopilotContextFileNotSupportedDataType, CopilotFailedToModifyContextDataType, CopilotFailedToMatchContextDataType, CopilotFailedToMatchGlobalContextDataType, CopilotFailedToAddWorkspaceFileEmbeddingDataType, RuntimeConfigNotFoundDataType, InvalidRuntimeConfigTypeDataType, InvalidLicenseToActivateDataType, InvalidLicenseUpdateParamsDataType, UnsupportedClientVersionDataType, MentionUserDocAccessDeniedDataType, InvalidAppConfigDataType, InvalidAppConfigInputDataType, InvalidSearchProviderRequestDataType, InvalidIndexerInputDataType] as const,
[GraphqlBadRequestDataType, HttpRequestErrorDataType, QueryTooLongDataType, ValidationErrorDataType, WrongSignInCredentialsDataType, UnknownOauthProviderDataType, InvalidOauthCallbackCodeDataType, MissingOauthQueryParameterDataType, InvalidOauthResponseDataType, InvalidEmailDataType, InvalidPasswordLengthDataType, WorkspacePermissionNotFoundDataType, SpaceNotFoundDataType, MemberNotFoundInSpaceDataType, NotInSpaceDataType, AlreadyInSpaceDataType, SpaceAccessDeniedDataType, SpaceOwnerNotFoundDataType, SpaceShouldHaveOnlyOneOwnerDataType, DocNotFoundDataType, DocActionDeniedDataType, DocUpdateBlockedDataType, VersionRejectedDataType, InvalidHistoryTimestampDataType, DocHistoryNotFoundDataType, BlobNotFoundDataType, ExpectToGrantDocUserRolesDataType, ExpectToRevokeDocUserRolesDataType, ExpectToUpdateDocUserRoleDataType, NoMoreSeatDataType, UnsupportedSubscriptionPlanDataType, SubscriptionAlreadyExistsDataType, SubscriptionNotExistsDataType, SameSubscriptionRecurringDataType, SubscriptionPlanNotFoundDataType, CalendarProviderRequestErrorDataType, NoCopilotProviderAvailableDataType, CopilotFailedToGenerateEmbeddingDataType, CopilotDocNotFoundDataType, CopilotMessageNotFoundDataType, CopilotPromptNotFoundDataType, CopilotProviderNotSupportedDataType, CopilotProviderSideErrorDataType, CopilotInvalidContextDataType, CopilotContextFileNotSupportedDataType, CopilotFailedToModifyContextDataType, CopilotFailedToMatchContextDataType, CopilotFailedToMatchGlobalContextDataType, CopilotFailedToAddWorkspaceFileEmbeddingDataType, RuntimeConfigNotFoundDataType, InvalidRuntimeConfigTypeDataType, InvalidLicenseToActivateDataType, InvalidLicenseUpdateParamsDataType, UnsupportedClientVersionDataType, MentionUserDocAccessDeniedDataType, InvalidAppConfigDataType, InvalidAppConfigInputDataType, InvalidSearchProviderRequestDataType, InvalidIndexerInputDataType] as const,
});

View File

@@ -0,0 +1,172 @@
import { Injectable } from '@nestjs/common';
import type { CalendarAccount, Prisma } from '@prisma/client';
import { CryptoHelper } from '../base';
import { BaseModel } from './base';
export interface CalendarAccountTokens {
accessToken?: string | null;
refreshToken?: string | null;
expiresAt?: Date | null;
scope?: string | null;
}
export interface UpsertCalendarAccountInput extends CalendarAccountTokens {
userId: string;
provider: string;
providerAccountId: string;
displayName?: string | null;
email?: string | null;
status?: string | null;
lastError?: string | null;
refreshIntervalMinutes?: number | null;
}
export interface UpdateCalendarAccountTokensInput extends CalendarAccountTokens {
status?: string | null;
lastError?: string | null;
}
@Injectable()
export class CalendarAccountModel extends BaseModel {
constructor(private readonly crypto: CryptoHelper) {
super();
}
private encryptToken(token?: string | null) {
return token ? this.crypto.encrypt(token) : null;
}
private decryptToken(token?: string | null) {
return token ? this.crypto.decrypt(token) : null;
}
async listByUser(userId: string) {
return await this.db.calendarAccount.findMany({
where: { userId },
orderBy: { createdAt: 'desc' },
});
}
async get(id: string) {
return await this.db.calendarAccount.findUnique({
where: { id },
});
}
async getByProviderAccount(
userId: string,
provider: string,
providerAccountId: string
) {
return await this.db.calendarAccount.findFirst({
where: { userId, provider, providerAccountId },
});
}
async upsert(input: UpsertCalendarAccountInput) {
const accessToken = this.encryptToken(input.accessToken);
const refreshToken = this.encryptToken(input.refreshToken);
const data: Prisma.CalendarAccountUncheckedCreateInput = {
userId: input.userId,
provider: input.provider,
providerAccountId: input.providerAccountId,
displayName: input.displayName ?? null,
email: input.email ?? null,
accessToken: accessToken ?? null,
refreshToken: refreshToken ?? null,
expiresAt: input.expiresAt ?? null,
scope: input.scope ?? null,
status: input.status ?? 'active',
lastError: input.lastError ?? null,
refreshIntervalMinutes: input.refreshIntervalMinutes ?? 60,
};
const updateData: Prisma.CalendarAccountUncheckedUpdateInput = {
displayName: data.displayName,
email: data.email,
expiresAt: data.expiresAt,
scope: data.scope,
status: data.status,
lastError: data.lastError,
refreshIntervalMinutes: data.refreshIntervalMinutes,
};
if (!!accessToken) {
updateData.accessToken = accessToken;
}
if (!!refreshToken) {
updateData.refreshToken = refreshToken;
}
return await this.db.calendarAccount.upsert({
where: {
userId_provider_providerAccountId: {
userId: input.userId,
provider: input.provider,
providerAccountId: input.providerAccountId,
},
},
create: data,
update: updateData,
});
}
async updateTokens(id: string, input: UpdateCalendarAccountTokensInput) {
const data: Prisma.CalendarAccountUncheckedUpdateInput = {};
if (input.accessToken !== undefined) {
data.accessToken = this.encryptToken(input.accessToken);
}
if (input.refreshToken !== undefined) {
data.refreshToken = this.encryptToken(input.refreshToken);
}
if (input.expiresAt !== undefined) {
data.expiresAt = input.expiresAt ?? null;
}
if (input.scope !== undefined) {
data.scope = input.scope ?? null;
}
if (input.status !== undefined) {
data.status = input.status ?? undefined;
}
if (input.lastError !== undefined) {
data.lastError = input.lastError ?? null;
}
return await this.db.calendarAccount.update({
where: { id },
data,
});
}
async updateStatus(id: string, status: string, lastError?: string | null) {
return await this.db.calendarAccount.update({
where: { id },
data: {
status,
lastError: lastError ?? null,
},
});
}
async updateRefreshInterval(id: string, refreshIntervalMinutes: number) {
return await this.db.calendarAccount.update({
where: { id },
data: { refreshIntervalMinutes },
});
}
async delete(id: string) {
return await this.db.calendarAccount.delete({
where: { id },
});
}
decryptTokens(account: CalendarAccount) {
return {
...account,
accessToken: this.decryptToken(account.accessToken),
refreshToken: this.decryptToken(account.refreshToken),
};
}
}

View File

@@ -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 } },
});
}
}

View File

@@ -0,0 +1,119 @@
import { Injectable } from '@nestjs/common';
import type { Prisma } from '@prisma/client';
import { BaseModel } from './base';
export interface UpsertCalendarEventInput {
subscriptionId: string;
externalEventId: string;
recurrenceId?: string | null;
etag?: string | null;
status?: string | null;
title?: string | null;
description?: string | null;
location?: string | null;
startAtUtc: Date;
endAtUtc: Date;
originalTimezone?: string | null;
allDay: boolean;
providerUpdatedAt?: Date | null;
raw: Prisma.InputJsonValue;
}
@Injectable()
export class CalendarEventModel extends BaseModel {
async upsert(input: UpsertCalendarEventInput) {
const recurrenceId = input.recurrenceId ?? input.externalEventId;
return await this.db.calendarEvent.upsert({
where: {
subscriptionId_externalEventId_recurrenceId: {
subscriptionId: input.subscriptionId,
externalEventId: input.externalEventId,
recurrenceId,
},
},
create: {
subscriptionId: input.subscriptionId,
externalEventId: input.externalEventId,
recurrenceId,
etag: input.etag ?? null,
status: input.status ?? null,
title: input.title ?? null,
description: input.description ?? null,
location: input.location ?? null,
startAtUtc: input.startAtUtc,
endAtUtc: input.endAtUtc,
originalTimezone: input.originalTimezone ?? null,
allDay: input.allDay,
providerUpdatedAt: input.providerUpdatedAt ?? null,
raw: input.raw,
},
update: {
etag: input.etag ?? null,
status: input.status ?? null,
title: input.title ?? null,
description: input.description ?? null,
location: input.location ?? null,
startAtUtc: input.startAtUtc,
endAtUtc: input.endAtUtc,
originalTimezone: input.originalTimezone ?? null,
allDay: input.allDay,
providerUpdatedAt: input.providerUpdatedAt ?? null,
raw: input.raw,
},
});
}
async deleteBySubscription(subscriptionId: string) {
return await this.db.calendarEvent.deleteMany({
where: { subscriptionId },
});
}
async deleteBySubscriptionIds(subscriptionIds: string[]) {
return await this.db.calendarEvent.deleteMany({
where: { subscriptionId: { in: subscriptionIds } },
});
}
async deleteByIds(ids: string[]) {
return await this.db.calendarEvent.deleteMany({
where: { id: { in: ids } },
});
}
async deleteByExternalIds(
subscriptionId: string,
externalEventIds: string[]
) {
if (externalEventIds.length === 0) {
return;
}
await this.db.calendarEvent.deleteMany({
where: {
subscriptionId,
externalEventId: { in: externalEventIds },
},
});
}
async listBySubscriptionsInRange(
subscriptionIds: string[],
from: Date,
to: Date
) {
if (subscriptionIds.length === 0) {
return [];
}
return await this.db.calendarEvent.findMany({
where: {
subscriptionId: { in: subscriptionIds },
startAtUtc: { lt: to },
endAtUtc: { gt: from },
},
orderBy: [{ startAtUtc: 'asc' }, { endAtUtc: 'asc' }],
});
}
}

View File

@@ -0,0 +1,194 @@
import { Injectable } from '@nestjs/common';
import type { CalendarSubscription, Prisma } from '@prisma/client';
import { BaseModel } from './base';
export interface UpsertCalendarSubscriptionInput {
accountId: string;
provider: string;
externalCalendarId: string;
displayName?: string | null;
timezone?: string | null;
color?: string | null;
enabled?: boolean;
}
export interface UpdateCalendarSubscriptionSyncInput {
syncToken?: string | null;
lastSyncAt?: Date | null;
}
export interface UpdateCalendarSubscriptionChannelInput {
customChannelId?: string | null;
customResourceId?: string | null;
channelExpiration?: Date | null;
}
@Injectable()
export class CalendarSubscriptionModel extends BaseModel {
async listByAccount(accountId: string) {
return await this.db.calendarSubscription.findMany({
where: { accountId },
orderBy: { createdAt: 'asc' },
});
}
async listByAccountIds(accountIds: string[]) {
return await this.db.calendarSubscription.findMany({
where: { accountId: { in: accountIds } },
});
}
async get(id: string) {
return await this.db.calendarSubscription.findUnique({
where: { id },
});
}
async getByChannelId(customChannelId: string) {
return await this.db.calendarSubscription.findFirst({
where: { customChannelId },
});
}
async upsert(input: UpsertCalendarSubscriptionInput) {
const data: Prisma.CalendarSubscriptionUncheckedCreateInput = {
accountId: input.accountId,
provider: input.provider,
externalCalendarId: input.externalCalendarId,
displayName: input.displayName ?? null,
timezone: input.timezone ?? null,
color: input.color ?? null,
enabled: input.enabled ?? true,
};
return await this.db.calendarSubscription.upsert({
where: {
accountId_externalCalendarId: {
accountId: input.accountId,
externalCalendarId: input.externalCalendarId,
},
},
create: data,
update: {
displayName: data.displayName,
timezone: data.timezone,
color: data.color,
enabled: data.enabled,
},
});
}
async updateSync(id: string, input: UpdateCalendarSubscriptionSyncInput) {
return await this.db.calendarSubscription.update({
where: { id },
data: {
syncToken: input.syncToken ?? null,
lastSyncAt: input.lastSyncAt ?? null,
},
});
}
async updateChannel(
id: string,
input: UpdateCalendarSubscriptionChannelInput
) {
return await this.db.calendarSubscription.update({
where: { id },
data: {
customChannelId: input.customChannelId ?? null,
customResourceId: input.customResourceId ?? null,
channelExpiration: input.channelExpiration ?? null,
},
});
}
async updateEnabled(id: string, enabled: boolean) {
return await this.db.calendarSubscription.update({
where: { id },
data: { enabled },
});
}
async deleteByAccount(accountId: string) {
return await this.db.calendarSubscription.deleteMany({
where: { accountId },
});
}
async deleteByIds(ids: string[]) {
return await this.db.calendarSubscription.deleteMany({
where: { id: { in: ids } },
});
}
async listActiveByAccount(accountId: string) {
return await this.db.calendarSubscription.findMany({
where: { accountId, enabled: true },
});
}
async listWithAccount(id: string) {
return await this.db.calendarSubscription.findUnique({
where: { id },
include: { account: true },
});
}
async listWithAccounts(ids: string[]) {
return await this.db.calendarSubscription.findMany({
where: { id: { in: ids } },
include: { account: true },
});
}
async listAccountSubscriptions(
accountId: string,
subscriptionIds?: string[]
) {
return await this.db.calendarSubscription.findMany({
where: {
accountId,
...(subscriptionIds ? { id: { in: subscriptionIds } } : undefined),
},
});
}
async listAllWithAccountForSync() {
return await this.db.calendarSubscription.findMany({
where: { enabled: true },
include: { account: true },
});
}
async listByAccountForSync(accountId: string) {
return await this.db.calendarSubscription.findMany({
where: { accountId, enabled: true },
include: { account: true },
});
}
async updateLastSyncAt(id: string, lastSyncAt: Date) {
return await this.db.calendarSubscription.update({
where: { id },
data: { lastSyncAt },
});
}
async clearSyncTokensByAccount(accountId: string) {
return await this.db.calendarSubscription.updateMany({
where: { accountId },
data: { syncToken: null },
});
}
async updateManyStatus(
ids: string[],
data: Partial<Pick<CalendarSubscription, 'enabled'>>
) {
return await this.db.calendarSubscription.updateMany({
where: { id: { in: ids } },
data,
});
}
}

View File

@@ -9,6 +9,10 @@ import { ModuleRef } from '@nestjs/core';
import { ApplyType } from '../base';
import { AccessTokenModel } from './access-token';
import { BlobModel } from './blob';
import { CalendarAccountModel } from './calendar-account';
import { CalendarEventModel } from './calendar-event';
import { CalendarEventInstanceModel } from './calendar-event-instance';
import { CalendarSubscriptionModel } from './calendar-subscription';
import { CommentModel } from './comment';
import { CommentAttachmentModel } from './comment-attachment';
import { AppConfigModel } from './config';
@@ -29,6 +33,7 @@ import { UserFeatureModel } from './user-feature';
import { UserSettingsModel } from './user-settings';
import { VerificationTokenModel } from './verification-token';
import { WorkspaceModel } from './workspace';
import { WorkspaceCalendarModel } from './workspace-calendar';
import { WorkspaceFeatureModel } from './workspace-feature';
import { WorkspaceUserModel } from './workspace-user';
@@ -56,6 +61,11 @@ const MODELS = {
commentAttachment: CommentAttachmentModel,
blob: BlobModel,
accessToken: AccessTokenModel,
calendarAccount: CalendarAccountModel,
calendarSubscription: CalendarSubscriptionModel,
calendarEvent: CalendarEventModel,
calendarEventInstance: CalendarEventInstanceModel,
workspaceCalendar: WorkspaceCalendarModel,
};
type ModelsType = {
@@ -108,6 +118,10 @@ const ModelsSymbolProvider: ExistingProvider = {
export class ModelsModule {}
export * from './blob';
export * from './calendar-account';
export * from './calendar-event';
export * from './calendar-event-instance';
export * from './calendar-subscription';
export * from './comment';
export * from './comment-attachment';
export * from './common';
@@ -127,5 +141,6 @@ export * from './user-feature';
export * from './user-settings';
export * from './verification-token';
export * from './workspace';
export * from './workspace-calendar';
export * from './workspace-feature';
export * from './workspace-user';

View File

@@ -0,0 +1,81 @@
import { Injectable } from '@nestjs/common';
import { BaseModel } from './base';
@Injectable()
export class WorkspaceCalendarModel extends BaseModel {
async get(id: string) {
return await this.db.workspaceCalendar.findUnique({
where: { id },
});
}
async getByWorkspace(workspaceId: string) {
return await this.db.workspaceCalendar.findMany({
where: { workspaceId },
orderBy: { createdAt: 'asc' },
});
}
async getDefault(workspaceId: string) {
return await this.db.workspaceCalendar.findFirst({
where: { workspaceId },
orderBy: { createdAt: 'asc' },
});
}
async getOrCreateDefault(workspaceId: string, createdByUserId: string) {
const existing = await this.getDefault(workspaceId);
if (existing) {
return existing;
}
return await this.db.workspaceCalendar.create({
data: {
workspaceId,
createdByUserId,
},
});
}
async updateItems(
workspaceCalendarId: string,
items: Array<{
subscriptionId: string;
sortOrder?: number | null;
colorOverride?: string | null;
}>
) {
await this.db.workspaceCalendarItem.deleteMany({
where: { workspaceCalendarId },
});
if (items.length === 0) {
return;
}
await this.db.workspaceCalendarItem.createMany({
data: items.map((item, index) => ({
workspaceCalendarId,
subscriptionId: item.subscriptionId,
sortOrder: item.sortOrder ?? index,
colorOverride: item.colorOverride ?? null,
})),
});
}
async listItems(workspaceCalendarId: string) {
return await this.db.workspaceCalendarItem.findMany({
where: { workspaceCalendarId },
orderBy: { sortOrder: 'asc' },
});
}
async listItemsByWorkspace(workspaceId: string) {
return await this.db.workspaceCalendarItem.findMany({
where: { workspaceCalendar: { workspaceId } },
orderBy: { sortOrder: 'asc' },
include: { subscription: true },
});
}
}

View File

@@ -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);
});

View File

@@ -0,0 +1,57 @@
import { z } from 'zod';
import { defineModuleConfig, JSONSchema } from '../../base';
export interface CalendarGoogleConfig {
enabled: boolean;
clientId: string;
clientSecret: string;
externalWebhookUrl?: string;
webhookVerificationToken?: string;
}
declare global {
interface AppConfigSchema {
calendar: {
google: ConfigItem<CalendarGoogleConfig>;
};
}
}
const schema: JSONSchema = {
type: 'object',
properties: {
enabled: { type: 'boolean' },
clientId: { type: 'string' },
clientSecret: { type: 'string' },
externalWebhookUrl: { type: 'string' },
webhookVerificationToken: { type: 'string' },
},
};
defineModuleConfig('calendar', {
google: {
desc: 'Google Calendar integration config',
default: {
enabled: false,
clientId: '',
clientSecret: '',
externalWebhookUrl: '',
webhookVerificationToken: '',
},
schema,
shape: z.object({
enabled: z.boolean(),
clientId: z.string(),
clientSecret: z.string(),
externalWebhookUrl: z
.string()
.url()
.regex(/^https:\/\//, 'externalWebhookUrl must be https')
.or(z.string().length(0))
.optional(),
webhookVerificationToken: z.string().optional(),
}),
link: 'https://developers.google.com/calendar/api/guides/push',
},
});

View File

@@ -0,0 +1,170 @@
import {
Body,
Controller,
Get,
HttpCode,
HttpStatus,
Post,
Query,
Req,
Res,
} from '@nestjs/common';
import type { Request, Response } from 'express';
import {
CalendarProviderRequestError,
MissingOauthQueryParameter,
OauthStateExpired,
UnknownOauthProvider,
URLHelper,
} from '../../base';
import { CurrentUser, Public } from '../../core/auth';
import { CalendarOAuthService } from './oauth';
import { CalendarProviderName } from './providers';
import { CalendarService } from './service';
@Controller('/api/calendar')
export class CalendarController {
constructor(
private readonly calendar: CalendarService,
private readonly oauth: CalendarOAuthService,
private readonly url: URLHelper
) {}
@Post('/oauth/preflight')
@HttpCode(HttpStatus.OK)
async preflight(
@CurrentUser() user: CurrentUser,
@Body('provider') providerName?: CalendarProviderName,
@Body('redirect_uri') redirectUri?: string
) {
if (!providerName) {
throw new MissingOauthQueryParameter({ name: 'provider' });
}
if (!this.calendar.isProviderAvailable(providerName)) {
throw new UnknownOauthProvider({ name: providerName });
}
const state = await this.oauth.saveOAuthState({
provider: providerName,
userId: user.id,
redirectUri,
});
const callbackUrl = this.calendar.getCallbackUrl();
const authUrl = this.calendar.getAuthUrl(providerName, state, callbackUrl);
return { url: authUrl };
}
@Public()
@Get('/oauth/callback')
@HttpCode(HttpStatus.OK)
async callbackGet(
@Res() res: Response,
@Query('code') code?: string,
@Query('state') stateStr?: string
) {
return this.handleCallback(res, code, stateStr);
}
@Public()
@Post('/oauth/callback')
@HttpCode(HttpStatus.OK)
async callback(
@Res() res: Response,
@Body('code') code?: string,
@Body('state') stateStr?: string
) {
return this.handleCallback(res, code, stateStr);
}
@Public()
@Post('/webhook/google')
@HttpCode(HttpStatus.OK)
async googleWebhook(@Req() req: Request, @Res() res: Response) {
if (!this.calendar.getWebhookAddress('google')) {
return res.send();
}
const channelId = req.header('x-goog-channel-id');
if (!channelId) {
return res.send();
}
const token = req.header('x-goog-channel-token');
const expectedToken = this.calendar.getWebhookToken();
if (expectedToken && token !== expectedToken) {
return res.status(401).send();
}
await this.calendar.handleWebhook(CalendarProviderName.Google, channelId);
return res.send();
}
private async handleCallback(
res: Response,
code?: string,
stateStr?: string
) {
if (!code) {
throw new MissingOauthQueryParameter({ name: 'code' });
}
if (!stateStr) {
throw new MissingOauthQueryParameter({ name: 'state' });
}
if (typeof stateStr !== 'string' || !this.oauth.isValidState(stateStr)) {
throw new MissingOauthQueryParameter({ name: 'state' });
}
const state = await this.oauth.getOAuthState(stateStr);
if (!state) {
throw new OauthStateExpired();
}
const callbackUrl = this.calendar.getCallbackUrl();
try {
await this.calendar.handleOAuthCallback({
provider: state.provider,
code,
redirectUri: callbackUrl,
userId: state.userId,
});
} catch (error) {
if (state.redirectUri) {
const message = this.getCallbackErrorMessage(error);
const redirectUrl = this.buildErrorRedirect(state.redirectUri, message);
return this.url.safeRedirect(res, redirectUrl);
}
throw error;
}
if (state.redirectUri) {
return this.url.safeRedirect(res, state.redirectUri);
}
return res.status(200).send({ ok: true });
}
private buildErrorRedirect(redirectUri: string, message: string) {
const url = new URL(redirectUri, this.url.requestBaseUrl);
url.searchParams.set('error', message);
return url.toString();
}
private getCallbackErrorMessage(error: unknown) {
if (error instanceof CalendarProviderRequestError) {
if (error.status === 403) {
return 'Calendar authorization failed: insufficient permissions. Please reauthorize and allow Calendar access.';
}
return 'Calendar authorization failed. Please try again.';
}
if (error instanceof Error && error.message) {
return error.message;
}
return 'Calendar authorization failed.';
}
}

View File

@@ -0,0 +1,61 @@
import { Injectable } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { Models } from '../../models';
import { CalendarService } from './service';
@Injectable()
export class CalendarCronJobs {
constructor(
private readonly models: Models,
private readonly calendar: CalendarService
) {}
@Cron(CronExpression.EVERY_MINUTE)
async pollAccounts() {
const subscriptions =
await this.models.calendarSubscription.listAllWithAccountForSync();
const accountDueAt = new Map<
string,
{ refreshInterval: number; lastSyncAt: Date | null }
>();
for (const subscription of subscriptions) {
const interval = subscription.account.refreshIntervalMinutes ?? 60;
const lastSyncAt = subscription.lastSyncAt ?? null;
const existing = accountDueAt.get(subscription.accountId);
if (!existing) {
accountDueAt.set(subscription.accountId, {
refreshInterval: interval,
lastSyncAt,
});
continue;
}
const earliest =
existing.lastSyncAt && lastSyncAt
? existing.lastSyncAt < lastSyncAt
? existing.lastSyncAt
: lastSyncAt
: (existing.lastSyncAt ?? lastSyncAt);
accountDueAt.set(subscription.accountId, {
refreshInterval: interval,
lastSyncAt: earliest,
});
}
const now = Date.now();
await Promise.allSettled(
Array.from(accountDueAt.entries()).map(([accountId, info]) => {
if (
!info.lastSyncAt ||
now - info.lastSyncAt.getTime() >= info.refreshInterval * 60 * 1000
) {
return this.calendar.syncAccount(accountId);
}
return Promise.resolve();
})
);
}
}

View File

@@ -0,0 +1,27 @@
import './config';
import { Module } from '@nestjs/common';
import { AuthModule } from '../../core/auth';
import { PermissionModule } from '../../core/permission';
import { WorkspaceModule } from '../../core/workspaces';
import { CalendarController } from './controller';
import { CalendarCronJobs } from './cron';
import { CalendarOAuthService } from './oauth';
import { CalendarProviderFactory, CalendarProviders } from './providers';
import { CalendarResolver } from './resolver';
import { CalendarService } from './service';
@Module({
imports: [AuthModule, PermissionModule, WorkspaceModule],
providers: [
...CalendarProviders,
CalendarProviderFactory,
CalendarService,
CalendarOAuthService,
CalendarCronJobs,
CalendarResolver,
],
controllers: [CalendarController],
})
export class CalendarModule {}

View File

@@ -0,0 +1,39 @@
import { randomUUID } from 'node:crypto';
import { Injectable } from '@nestjs/common';
import { SessionCache } from '../../base';
import { CalendarProviderName } from './providers';
export interface CalendarOAuthState {
provider: CalendarProviderName;
userId: string;
redirectUri?: string;
token?: string;
}
const CALENDAR_OAUTH_STATE_KEY = 'CALENDAR_OAUTH_STATE';
@Injectable()
export class CalendarOAuthService {
constructor(private readonly cache: SessionCache) {}
isValidState(stateStr: string) {
return stateStr.length === 36;
}
async saveOAuthState(state: CalendarOAuthState) {
const token = randomUUID();
const payload: CalendarOAuthState = { ...state, token };
await this.cache.set(`${CALENDAR_OAUTH_STATE_KEY}:${token}`, payload, {
ttl: 3600 * 3 * 1000,
});
return token;
}
async getOAuthState(token: string) {
return this.cache.get<CalendarOAuthState>(
`${CALENDAR_OAUTH_STATE_KEY}:${token}`
);
}
}

View File

@@ -0,0 +1,180 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import { CalendarProviderRequestError, Config, OnEvent } from '../../../base';
import { CalendarProviderFactory } from './factory';
export enum CalendarProviderName {
Google = 'google',
CalDAV = 'caldav',
}
export interface CalendarProviderTokens {
accessToken: string;
refreshToken?: string;
expiresAt?: Date;
scope?: string;
tokenType?: string;
}
export interface CalendarAccountProfile {
providerAccountId: string;
displayName?: string;
email?: string;
}
export interface CalendarProviderCalendar {
id: string;
summary?: string;
timeZone?: string;
colorId?: string;
primary?: boolean;
}
export interface CalendarProviderEventTime {
dateTime?: string;
date?: string;
timeZone?: string;
}
export interface CalendarProviderEvent {
id: string;
status?: string;
etag?: string;
summary?: string;
description?: string;
location?: string;
updated?: string;
recurringEventId?: string;
originalStartTime?: CalendarProviderEventTime;
start: CalendarProviderEventTime;
end: CalendarProviderEventTime;
raw: Record<string, unknown>;
}
export interface CalendarProviderListEventsParams {
accessToken: string;
calendarId: string;
timeMin?: string;
timeMax?: string;
syncToken?: string;
}
export interface CalendarProviderListEventsResult {
events: CalendarProviderEvent[];
nextSyncToken?: string;
}
export interface CalendarProviderWatchResult {
channelId: string;
resourceId: string;
expiration?: Date;
}
export interface CalendarProviderWatchParams {
accessToken: string;
calendarId: string;
address: string;
token?: string;
channelId: string;
}
export interface CalendarProviderStopParams {
accessToken: string;
channelId: string;
resourceId: string;
}
@Injectable()
export abstract class CalendarProvider {
abstract provider: CalendarProviderName;
abstract getAuthUrl(state: string, redirectUri: string): string;
abstract exchangeCode(
code: string,
redirectUri: string
): Promise<CalendarProviderTokens>;
abstract refreshTokens(refreshToken: string): Promise<CalendarProviderTokens>;
abstract getAccountProfile(
accessToken: string
): Promise<CalendarAccountProfile>;
abstract listCalendars(
accessToken: string
): Promise<CalendarProviderCalendar[]>;
abstract listEvents(
params: CalendarProviderListEventsParams
): Promise<CalendarProviderListEventsResult>;
abstract watchCalendar?(
params: CalendarProviderWatchParams
): Promise<CalendarProviderWatchResult>;
abstract stopChannel?(params: CalendarProviderStopParams): Promise<void>;
protected readonly logger = new Logger(this.constructor.name);
@Inject() private readonly factory!: CalendarProviderFactory;
@Inject() private readonly AFFiNEConfig!: Config;
get config() {
return (this.AFFiNEConfig.calendar as Record<string, any>)[this.provider];
}
get configured() {
return (
!!this.config &&
!!this.config.enabled &&
!!this.config.clientId &&
!!this.config.clientSecret
);
}
@OnEvent('config.init')
onConfigInit() {
this.setup();
}
@OnEvent('config.changed')
onConfigUpdated(event: Events['config.changed']) {
if ('calendar' in event.updates) {
this.setup();
}
}
protected setup() {
if (this.configured) {
this.factory.register(this);
} else {
this.factory.unregister(this);
}
}
protected async fetchJson<T>(url: string, init?: RequestInit) {
const response = await fetch(url, {
headers: { Accept: 'application/json', ...init?.headers },
...init,
});
const body = await response.text();
if (!response.ok) {
throw new CalendarProviderRequestError({
status: response.status,
message: body,
});
}
if (!body) {
return {} as T;
}
return JSON.parse(body) as T;
}
protected postFormJson<T>(
url: string,
body: string,
options?: { headers?: Record<string, string> }
) {
return this.fetchJson<T>(url, {
method: 'POST',
body,
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
...options?.headers,
},
});
}
}

View File

@@ -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.`);
}
}

View File

@@ -0,0 +1,351 @@
import { Injectable } from '@nestjs/common';
import { CalendarProviderRequestError } from '../../../base';
import { CalendarProvider } from './def';
import {
CalendarProviderEvent,
CalendarProviderListEventsParams,
CalendarProviderListEventsResult,
CalendarProviderName,
CalendarProviderTokens,
CalendarProviderWatchParams,
CalendarProviderWatchResult,
} from './def';
export class CalendarSyncTokenInvalid extends Error {
readonly code = 'calendar_sync_token_invalid';
}
type GoogleTokenResponse = {
access_token: string;
refresh_token?: string;
expires_in?: number;
scope?: string;
token_type?: string;
};
type GoogleUserInfo = {
id: string;
email?: string;
name?: string;
};
type GoogleCalendarListResponse = {
items?: Array<{
id: string;
summary?: string;
timeZone?: string;
colorId?: string;
primary?: boolean;
}>;
nextPageToken?: string;
};
type GoogleEventItem = {
id: string;
status?: string;
etag?: string;
summary?: string;
description?: string;
location?: string;
updated?: string;
recurringEventId?: string;
originalStartTime?: {
date?: string;
dateTime?: string;
timeZone?: string;
};
start: {
date?: string;
dateTime?: string;
timeZone?: string;
};
end: {
date?: string;
dateTime?: string;
timeZone?: string;
};
};
type GoogleEventsResponse = {
items?: GoogleEventItem[];
nextPageToken?: string;
nextSyncToken?: string;
};
type GoogleWatchResponse = {
id: string;
resourceId: string;
expiration?: string;
};
@Injectable()
export class GoogleCalendarProvider extends CalendarProvider {
provider = CalendarProviderName.Google;
getAuthUrl(state: string, redirectUri: string) {
const params = new URLSearchParams({
client_id: this.config.clientId,
redirect_uri: redirectUri,
response_type: 'code',
access_type: 'offline',
prompt: 'consent',
include_granted_scopes: 'true',
scope: [
'https://www.googleapis.com/auth/calendar.readonly',
'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/userinfo.profile',
].join(' '),
state,
});
return `https://accounts.google.com/o/oauth2/v2/auth?${params.toString()}`;
}
async exchangeCode(
code: string,
redirectUri: string
): Promise<CalendarProviderTokens> {
const payload = new URLSearchParams({
code,
client_id: this.config.clientId,
client_secret: this.config.clientSecret,
redirect_uri: redirectUri,
grant_type: 'authorization_code',
});
const response = await this.postFormJson<GoogleTokenResponse>(
'https://oauth2.googleapis.com/token',
payload.toString()
);
return {
accessToken: response.access_token,
refreshToken: response.refresh_token,
scope: response.scope,
tokenType: response.token_type,
expiresAt: response.expires_in
? new Date(Date.now() + response.expires_in * 1000)
: undefined,
};
}
async refreshTokens(refreshToken: string): Promise<CalendarProviderTokens> {
const payload = new URLSearchParams({
refresh_token: refreshToken,
client_id: this.config.clientId,
client_secret: this.config.clientSecret,
grant_type: 'refresh_token',
});
const response = await this.postFormJson<GoogleTokenResponse>(
'https://oauth2.googleapis.com/token',
payload.toString()
);
return {
accessToken: response.access_token,
refreshToken,
scope: response.scope,
tokenType: response.token_type,
expiresAt: response.expires_in
? new Date(Date.now() + response.expires_in * 1000)
: undefined,
};
}
async getAccountProfile(accessToken: string) {
const response = await this.fetchJson<GoogleUserInfo>(
'https://www.googleapis.com/oauth2/v2/userinfo',
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
}
);
return {
providerAccountId: response.id,
displayName: response.name,
email: response.email,
};
}
async listCalendars(accessToken: string) {
const calendars: GoogleCalendarListResponse['items'] = [];
let pageToken: string | undefined;
do {
const url = new URL(
'https://www.googleapis.com/calendar/v3/users/me/calendarList'
);
url.searchParams.set('maxResults', '250');
if (pageToken) {
url.searchParams.set('pageToken', pageToken);
}
const response = await this.fetchJson<GoogleCalendarListResponse>(
url.toString(),
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
}
);
if (response.items?.length) {
calendars.push(...response.items);
}
pageToken = response.nextPageToken;
} while (pageToken);
return calendars.map(item => ({
id: item.id,
summary: item.summary,
timeZone: item.timeZone,
colorId: item.colorId,
primary: item.primary,
}));
}
async listEvents(
params: CalendarProviderListEventsParams
): Promise<CalendarProviderListEventsResult> {
const events: CalendarProviderEvent[] = [];
let pageToken: string | undefined;
let nextSyncToken: string | undefined;
do {
const url = new URL(
`https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(
params.calendarId
)}/events`
);
url.searchParams.set('singleEvents', 'true');
url.searchParams.set('showDeleted', 'true');
url.searchParams.set('maxResults', '2500');
if (params.syncToken) {
url.searchParams.set('syncToken', params.syncToken);
} else {
if (params.timeMin) {
url.searchParams.set('timeMin', params.timeMin);
}
if (params.timeMax) {
url.searchParams.set('timeMax', params.timeMax);
}
url.searchParams.set('orderBy', 'startTime');
}
if (pageToken) {
url.searchParams.set('pageToken', pageToken);
}
const response = await this.fetchWithTokenHandling<GoogleEventsResponse>(
url.toString(),
params.accessToken
);
if (response.items?.length) {
for (const item of response.items) {
events.push({
id: item.id,
status: item.status,
etag: item.etag,
summary: item.summary,
description: item.description,
location: item.location,
updated: item.updated,
recurringEventId: item.recurringEventId,
originalStartTime: item.originalStartTime,
start: item.start,
end: item.end,
raw: item as Record<string, unknown>,
});
}
}
pageToken = response.nextPageToken;
if (response.nextSyncToken) {
nextSyncToken = response.nextSyncToken;
}
} while (pageToken);
return { events, nextSyncToken };
}
async watchCalendar(
params: CalendarProviderWatchParams
): Promise<CalendarProviderWatchResult> {
const response = await this.fetchJson<GoogleWatchResponse>(
`https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(
params.calendarId
)}/events/watch`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${params.accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
id: params.channelId,
type: 'web_hook',
address: params.address,
token: params.token,
}),
}
);
return {
channelId: response.id,
resourceId: response.resourceId,
expiration: response.expiration
? new Date(Number(response.expiration))
: undefined,
};
}
async stopChannel(params: {
accessToken: string;
channelId: string;
resourceId: string;
}) {
await this.fetchJson(
'https://www.googleapis.com/calendar/v3/channels/stop',
{
method: 'POST',
headers: {
Authorization: `Bearer ${params.accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
id: params.channelId,
resourceId: params.resourceId,
}),
}
);
}
private async fetchWithTokenHandling<T>(url: string, accessToken: string) {
const response = await fetch(url, {
headers: {
Accept: 'application/json',
Authorization: `Bearer ${accessToken}`,
},
});
const body = await response.text();
if (!response.ok) {
if (response.status === 410) {
throw new CalendarSyncTokenInvalid(body);
}
throw new CalendarProviderRequestError({
status: response.status,
message: body,
});
}
if (!body) {
return {} as T;
}
return JSON.parse(body) as T;
}
}

View File

@@ -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];

View File

@@ -0,0 +1,152 @@
import {
Args,
GraphQLISODateTime,
Mutation,
Query,
Resolver,
} from '@nestjs/graphql';
import { AuthenticationRequired } from '../../base';
import { CurrentUser } from '../../core/auth';
import { AccessController } from '../../core/permission';
import { Models } from '../../models';
import { CalendarOAuthService } from './oauth';
import { CalendarProviderFactory, CalendarProviderName } from './providers';
import { CalendarService } from './service';
import {
CalendarAccountObjectType,
CalendarEventObjectType,
CalendarSubscriptionObjectType,
LinkCalendarAccountInput,
UpdateWorkspaceCalendarsInput,
WorkspaceCalendarObjectType,
} from './types';
@Resolver(() => CalendarAccountObjectType)
export class CalendarResolver {
constructor(
private readonly calendar: CalendarService,
private readonly oauth: CalendarOAuthService,
private readonly models: Models,
private readonly access: AccessController,
private readonly providerFactory: CalendarProviderFactory
) {}
@Query(() => [CalendarAccountObjectType])
async calendarAccounts(@CurrentUser() user: CurrentUser) {
return await this.calendar.listAccounts(user.id);
}
@Query(() => [CalendarSubscriptionObjectType])
async calendarAccountCalendars(
@CurrentUser() user: CurrentUser,
@Args('accountId') accountId: string
) {
return await this.calendar.listAccountCalendars(user.id, accountId);
}
@Query(() => [WorkspaceCalendarObjectType])
async workspaceCalendars(
@CurrentUser() user: CurrentUser,
@Args('workspaceId') workspaceId: string
) {
await this.access
.user(user.id)
.workspace(workspaceId)
.assert('Workspace.CreateDoc');
return await this.calendar.getWorkspaceCalendars(workspaceId);
}
@Query(() => [CalendarEventObjectType])
async calendarEvents(
@CurrentUser() user: CurrentUser,
@Args('workspaceCalendarId') workspaceCalendarId: string,
@Args({ name: 'from', type: () => GraphQLISODateTime }) from: Date,
@Args({ name: 'to', type: () => GraphQLISODateTime }) to: Date
) {
const workspaceCalendar =
await this.models.workspaceCalendar.get(workspaceCalendarId);
if (!workspaceCalendar) {
return [];
}
await this.access
.user(user.id)
.workspace(workspaceCalendar.workspaceId)
.assert('Workspace.CreateDoc');
return await this.calendar.listWorkspaceEvents({
workspaceCalendarId,
from,
to,
});
}
@Query(() => [CalendarProviderName])
async calendarProviders() {
return this.providerFactory.providers;
}
@Mutation(() => String)
async linkCalendarAccount(
@CurrentUser() user: CurrentUser | null,
@Args('input') input: LinkCalendarAccountInput
) {
if (!user) {
throw new AuthenticationRequired();
}
const state = await this.oauth.saveOAuthState({
provider: input.provider,
userId: user.id,
redirectUri: input.redirectUri ?? undefined,
});
const callbackUrl = this.calendar.getCallbackUrl();
return this.calendar.getAuthUrl(input.provider, state, callbackUrl);
}
@Mutation(() => CalendarAccountObjectType, { nullable: true })
async updateCalendarAccount(
@CurrentUser() user: CurrentUser,
@Args('accountId') accountId: string,
@Args('refreshIntervalMinutes') refreshIntervalMinutes: number
) {
return await this.calendar.updateAccountRefreshInterval(
user.id,
accountId,
refreshIntervalMinutes
);
}
@Mutation(() => Boolean)
async unlinkCalendarAccount(
@CurrentUser() user: CurrentUser,
@Args('accountId') accountId: string
) {
return await this.calendar.unlinkAccount(user.id, accountId);
}
@Mutation(() => WorkspaceCalendarObjectType)
async updateWorkspaceCalendars(
@CurrentUser() user: CurrentUser,
@Args('input') input: UpdateWorkspaceCalendarsInput
) {
await this.access
.user(user.id)
.workspace(input.workspaceId)
.assert('Workspace.Settings.Update');
const calendar = await this.calendar.updateWorkspaceCalendars({
workspaceId: input.workspaceId,
userId: user.id,
items: input.items,
});
const items = await this.models.workspaceCalendar.listItems(calendar.id);
return {
...calendar,
items,
};
}
}

View File

@@ -0,0 +1,723 @@
import { randomUUID } from 'node:crypto';
import { Injectable, Logger } from '@nestjs/common';
import { Transactional } from '@nestjs-cls/transactional';
import type { CalendarAccount, Prisma } from '@prisma/client';
import { addDays, subDays } from 'date-fns';
import {
CalendarProviderRequestError,
Config,
Mutex,
URLHelper,
} from '../../base';
import { Models } from '../../models';
import {
CalendarProvider,
CalendarProviderEvent,
CalendarProviderEventTime,
CalendarProviderName,
CalendarSyncTokenInvalid,
} from './providers';
import { CalendarProviderFactory } from './providers';
const TOKEN_REFRESH_SKEW_MS = 60 * 1000;
const DEFAULT_PAST_DAYS = 90;
const DEFAULT_FUTURE_DAYS = 180;
@Injectable()
export class CalendarService {
private readonly logger = new Logger(CalendarService.name);
private generatedWebhookToken?: string;
constructor(
private readonly models: Models,
private readonly providerFactory: CalendarProviderFactory,
private readonly mutex: Mutex,
private readonly config: Config,
private readonly url: URLHelper
) {}
async listAccounts(userId: string) {
const accounts = await this.models.calendarAccount.listByUser(userId);
const accountIds = accounts.map(account => account.id);
const subscriptions =
await this.models.calendarSubscription.listByAccountIds(accountIds);
const counts = new Map<string, number>();
for (const subscription of subscriptions) {
counts.set(
subscription.accountId,
(counts.get(subscription.accountId) ?? 0) + 1
);
}
return accounts.map(account => ({
...account,
calendarsCount: counts.get(account.id) ?? 0,
}));
}
async listAccountCalendars(userId: string, accountId: string) {
const account = await this.models.calendarAccount.get(accountId);
if (!account || account.userId !== userId) {
return [];
}
return await this.models.calendarSubscription.listByAccount(accountId);
}
async updateAccountRefreshInterval(
userId: string,
accountId: string,
refreshIntervalMinutes: number
) {
const account = await this.models.calendarAccount.get(accountId);
if (!account || account.userId !== userId) {
return null;
}
return await this.models.calendarAccount.updateRefreshInterval(
accountId,
refreshIntervalMinutes
);
}
async unlinkAccount(userId: string, accountId: string) {
const account = await this.models.calendarAccount.get(accountId);
if (!account || account.userId !== userId) {
return false;
}
const provider = this.providerFactory.get(
account.provider as CalendarProviderName
);
const subscriptions =
await this.models.calendarSubscription.listByAccount(accountId);
const needToStopChannel = subscriptions.filter(
s => s.customChannelId && s.customResourceId
);
if (provider?.stopChannel && needToStopChannel.length > 0) {
const accountTokens = this.models.calendarAccount.decryptTokens(account);
const accessToken = accountTokens.accessToken;
if (accessToken) {
await Promise.allSettled(
needToStopChannel.map(s => {
if (!s.customChannelId || !s.customResourceId) {
return Promise.resolve();
}
return provider.stopChannel?.({
accessToken,
channelId: s.customChannelId,
resourceId: s.customResourceId,
});
})
);
}
}
await this.models.calendarAccount.delete(accountId);
return true;
}
async handleOAuthCallback(params: {
provider: CalendarProviderName;
code: string;
redirectUri: string;
userId: string;
}) {
const provider = this.requireProvider(params.provider);
const tokens = await provider.exchangeCode(params.code, params.redirectUri);
const profile = await provider.getAccountProfile(tokens.accessToken);
const account = await this.models.calendarAccount.upsert({
userId: params.userId,
provider: params.provider,
providerAccountId: profile.providerAccountId,
displayName: profile.displayName ?? null,
email: profile.email ?? null,
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
expiresAt: tokens.expiresAt ?? null,
scope: tokens.scope ?? null,
status: 'active',
lastError: null,
});
try {
await this.syncAccountCalendars(account.id);
} catch (error) {
if (error instanceof CalendarProviderRequestError) {
await this.models.calendarAccount.updateStatus(
account.id,
'invalid',
error.message
);
}
throw error;
}
return account;
}
async syncAccountCalendars(accountId: string) {
const account = await this.models.calendarAccount.get(accountId);
if (!account) {
return;
}
const provider = this.providerFactory.get(
account.provider as CalendarProviderName
);
if (!provider) {
return;
}
const { accessToken } = await this.ensureAccessToken(account);
if (!accessToken) {
return;
}
const calendars = await provider.listCalendars(accessToken);
const upserted = [];
for (const calendar of calendars) {
upserted.push(
await this.models.calendarSubscription.upsert({
accountId: account.id,
provider: account.provider,
externalCalendarId: calendar.id,
displayName: calendar.summary ?? null,
timezone: calendar.timeZone ?? null,
color: calendar.colorId ?? null,
enabled: true,
})
);
}
await Promise.allSettled(
upserted.map(subscription =>
this.syncSubscription(subscription.id, { reason: 'initial' })
)
);
}
async syncSubscription(
subscriptionId: string,
options?: { reason?: string; forceFull?: boolean }
) {
const subscription =
await this.models.calendarSubscription.listWithAccount(subscriptionId);
if (!subscription || !subscription.enabled) {
return;
}
const account = subscription.account;
if (account.status !== 'active') {
return;
}
await using lock = await this.mutex.acquire(
`calendar:subscription:${subscriptionId}`
);
if (!lock) {
return;
}
const provider = this.providerFactory.get(
account.provider as CalendarProviderName
);
if (!provider) {
return;
}
const { accessToken } = await this.ensureAccessToken(account);
if (!accessToken) {
return;
}
const { timeMin, timeMax } = this.getSyncWindow();
const shouldUseSyncToken =
!!subscription.syncToken && options?.forceFull !== true;
let synced = false;
try {
await this.syncWithProvider({
provider,
subscriptionId: subscription.id,
calendarId: subscription.externalCalendarId,
accessToken,
syncToken: shouldUseSyncToken
? (subscription.syncToken ?? undefined)
: undefined,
timeMin: shouldUseSyncToken ? undefined : timeMin,
timeMax: shouldUseSyncToken ? undefined : timeMax,
subscriptionTimezone: subscription.timezone ?? undefined,
});
synced = true;
} catch (error) {
if (error instanceof CalendarSyncTokenInvalid) {
await this.models.calendarSubscription.updateSync(subscription.id, {
syncToken: null,
});
await this.syncWithProvider({
provider,
subscriptionId: subscription.id,
calendarId: subscription.externalCalendarId,
accessToken,
timeMin,
timeMax,
subscriptionTimezone: subscription.timezone ?? undefined,
});
synced = true;
} else {
if (this.isTokenInvalidError(error)) {
await this.invalidateAccount(account.id, (error as Error).message);
} else {
this.logger.warn(
`Calendar sync failed for subscription ${subscription.id}`,
error as Error
);
}
return;
}
}
if (synced) {
await this.ensureWebhookChannel(subscription, provider, accessToken);
}
await this.models.calendarSubscription.updateLastSyncAt(
subscription.id,
new Date()
);
}
async syncAccount(accountId: string) {
const account = await this.models.calendarAccount.get(accountId);
if (!account || account.status !== 'active') {
return;
}
const subscriptions =
await this.models.calendarSubscription.listByAccountForSync(accountId);
await Promise.allSettled(
subscriptions.map(subscription =>
this.syncSubscription(subscription.id, { reason: 'polling' })
)
);
}
async listWorkspaceEvents(params: {
workspaceCalendarId: string;
from: Date;
to: Date;
}) {
const items = await this.models.workspaceCalendar.listItems(
params.workspaceCalendarId
);
const subscriptionIds = items.map(item => item.subscriptionId);
const events = await this.models.calendarEvent.listBySubscriptionsInRange(
subscriptionIds,
params.from,
params.to
);
Promise.allSettled(
subscriptionIds.map(subscriptionId =>
this.syncSubscription(subscriptionId, { reason: 'on-demand' })
)
).catch(error => {
this.logger.warn('Calendar on-demand sync failed', error as Error);
});
return events;
}
@Transactional()
async updateWorkspaceCalendars(params: {
workspaceId: string;
userId: string;
items: Array<{
subscriptionId: string;
sortOrder?: number | null;
colorOverride?: string | null;
}>;
}) {
const calendar = await this.models.workspaceCalendar.getOrCreateDefault(
params.workspaceId,
params.userId
);
await this.models.workspaceCalendar.updateItems(calendar.id, params.items);
return calendar;
}
async getWorkspaceCalendars(workspaceId: string) {
const calendars =
await this.models.workspaceCalendar.getByWorkspace(workspaceId);
if (calendars.length === 0) {
return [];
}
const items = await Promise.all(
calendars.map(calendar =>
this.models.workspaceCalendar.listItems(calendar.id)
)
);
return calendars.map((calendar, index) => ({
...calendar,
items: items[index],
}));
}
async handleWebhook(providerName: CalendarProviderName, channelId: string) {
if (providerName !== CalendarProviderName.Google) {
return;
}
const subscription =
await this.models.calendarSubscription.getByChannelId(channelId);
if (!subscription) {
return;
}
await this.syncSubscription(subscription.id, { reason: 'webhook' });
}
getWebhookToken() {
const configured = this.config.calendar.google.webhookVerificationToken;
if (configured) {
return configured;
}
if (!this.generatedWebhookToken) {
this.generatedWebhookToken = randomUUID();
}
return this.generatedWebhookToken;
}
getWebhookAddress(provider: string) {
const externalWebhookUrl = this.config.calendar.google.externalWebhookUrl;
if (!externalWebhookUrl) {
return null;
}
return new URL(
`/api/calendar/webhook/${provider}`,
externalWebhookUrl
).toString();
}
getCallbackUrl() {
return this.url.link('/api/calendar/oauth/callback');
}
isProviderAvailable(provider: CalendarProviderName) {
return !!this.providerFactory.get(provider);
}
getAuthUrl(
provider: CalendarProviderName,
state: string,
redirectUri: string
) {
return this.requireProvider(provider).getAuthUrl(state, redirectUri);
}
private async syncWithProvider(params: {
provider: CalendarProvider;
subscriptionId: string;
calendarId: string;
accessToken: string;
syncToken?: string;
timeMin?: string;
timeMax?: string;
subscriptionTimezone?: string;
}) {
const response = await params.provider.listEvents({
accessToken: params.accessToken,
calendarId: params.calendarId,
syncToken: params.syncToken,
timeMin: params.timeMin,
timeMax: params.timeMax,
});
const cancelledEventIds: string[] = [];
const failedEventIds: string[] = [];
for (const event of response.events) {
if (event.status === 'cancelled') {
cancelledEventIds.push(event.id);
continue;
}
try {
await this.models.calendarEvent.upsert(
this.mapProviderEvent(
params.subscriptionId,
event,
params.subscriptionTimezone
)
);
} catch {
failedEventIds.push(event.id);
}
}
if (cancelledEventIds.length > 0) {
await this.models.calendarEvent.deleteByExternalIds(
params.subscriptionId,
cancelledEventIds
);
}
if (failedEventIds.length > 0) {
this.logger.warn(
`Failed to upsert ${failedEventIds.length} events for subscription ${params.subscriptionId}`,
{ failedEventIds }
);
}
if (response.nextSyncToken) {
await this.models.calendarSubscription.updateSync(params.subscriptionId, {
syncToken: response.nextSyncToken,
});
}
}
private mapProviderEvent(
subscriptionId: string,
event: CalendarProviderEvent,
fallbackTimezone?: string
) {
const { timeZone, start, end, allDay } = this.resolveEventTimes(
event,
fallbackTimezone
);
return {
subscriptionId,
externalEventId: event.id,
recurrenceId: this.resolveRecurrenceId(event),
etag: event.etag ?? null,
status: event.status ?? null,
title: event.summary ?? null,
description: event.description ?? null,
location: event.location ?? null,
startAtUtc: start,
endAtUtc: end,
originalTimezone: timeZone ?? null,
allDay,
providerUpdatedAt: event.updated ? new Date(event.updated) : null,
raw: event.raw as Prisma.InputJsonValue,
};
}
private resolveEventTimes(
event: CalendarProviderEvent,
fallbackTimezone?: string
) {
const startTime = this.resolveEventTime(event.start, fallbackTimezone);
const endTime = this.resolveEventTime(event.end, fallbackTimezone);
const timeZone =
event.start.timeZone ?? event.end.timeZone ?? fallbackTimezone ?? null;
return {
start: startTime.date,
end: endTime.date,
allDay: startTime.allDay || endTime.allDay,
timeZone,
};
}
private resolveEventTime(
time: CalendarProviderEventTime,
fallbackTimezone?: string
) {
if (time.dateTime) {
return {
date: new Date(time.dateTime),
allDay: false,
};
}
const zone = time.timeZone ?? fallbackTimezone ?? 'UTC';
return {
date: this.convertDateToUtc(time.date!, zone),
allDay: true,
};
}
private resolveRecurrenceId(event: CalendarProviderEvent) {
if (event.originalStartTime?.dateTime) {
return event.originalStartTime.dateTime;
}
if (event.originalStartTime?.date) {
return event.originalStartTime.date;
}
return null;
}
private convertDateToUtc(dateString: string, timeZone: string) {
const [year, month, day] = dateString.split('-').map(Number);
const utcDate = new Date(Date.UTC(year, month - 1, day, 0, 0, 0));
const offsetMinutes = this.getTimeZoneOffset(utcDate, timeZone);
return new Date(utcDate.getTime() - offsetMinutes * 60 * 1000);
}
private getTimeZoneOffset(date: Date, timeZone: string) {
const formatter = new Intl.DateTimeFormat('en-US', {
timeZone,
hour12: false,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
const parts = formatter.formatToParts(date);
const lookup = (type: string) => {
const part = parts.find(item => item.type === type);
return part ? Number(part.value) : 0;
};
const asUtc = Date.UTC(
lookup('year'),
lookup('month') - 1,
lookup('day'),
lookup('hour'),
lookup('minute'),
lookup('second')
);
return (asUtc - date.getTime()) / 60000;
}
private getSyncWindow() {
const now = new Date();
return {
timeMin: subDays(now, DEFAULT_PAST_DAYS).toISOString(),
timeMax: addDays(now, DEFAULT_FUTURE_DAYS).toISOString(),
};
}
private async ensureAccessToken(account: CalendarAccount) {
const provider = this.providerFactory.get(
account.provider as CalendarProviderName
);
if (!provider) {
return { accessToken: null };
}
const decrypted = this.models.calendarAccount.decryptTokens(account);
const accessToken = decrypted.accessToken;
if (
accessToken &&
account.expiresAt &&
account.expiresAt.getTime() > Date.now() + TOKEN_REFRESH_SKEW_MS
) {
return { accessToken };
}
if (!decrypted.refreshToken) {
return { accessToken };
}
const refreshed = await provider.refreshTokens(decrypted.refreshToken);
await this.models.calendarAccount.updateTokens(account.id, {
accessToken: refreshed.accessToken,
refreshToken: refreshed.refreshToken ?? decrypted.refreshToken,
expiresAt: refreshed.expiresAt ?? null,
scope: refreshed.scope ?? null,
status: 'active',
lastError: null,
});
return { accessToken: refreshed.accessToken };
}
private isTokenInvalidError(error: unknown) {
if (error instanceof CalendarProviderRequestError) {
if (error.status === 401) {
return true;
}
return error.message.includes('invalid_grant');
}
if (error instanceof Error) {
return error.message.includes('invalid_grant');
}
return false;
}
private async invalidateAccount(accountId: string, lastError?: string) {
await this.models.calendarAccount.updateStatus(
accountId,
'invalid',
lastError ?? null
);
const subscriptions =
await this.models.calendarSubscription.listByAccount(accountId);
const subscriptionIds = subscriptions.map(s => s.id);
await this.models.calendarEvent.deleteBySubscriptionIds(subscriptionIds);
await this.models.calendarSubscription.clearSyncTokensByAccount(accountId);
}
private requireProvider(name: CalendarProviderName) {
const provider = this.providerFactory.get(name);
if (!provider) {
throw new Error(`Calendar provider ${name} not configured`);
}
return provider;
}
private async ensureWebhookChannel(
subscription: {
id: string;
externalCalendarId: string;
customChannelId: string | null;
customResourceId: string | null;
channelExpiration: Date | null;
},
provider: CalendarProvider,
accessToken: string
) {
if (!provider.watchCalendar) {
return;
}
const address = this.getWebhookAddress(provider.provider);
if (!address) {
return;
}
const renewThreshold = Date.now() + 24 * 60 * 60 * 1000;
if (
subscription.channelExpiration &&
subscription.channelExpiration.getTime() > renewThreshold
) {
return;
}
if (
provider.stopChannel &&
subscription.customChannelId &&
subscription.customResourceId
) {
await provider.stopChannel({
accessToken,
channelId: subscription.customChannelId,
resourceId: subscription.customResourceId,
});
}
const channelId = randomUUID();
const token = this.getWebhookToken();
const result = await provider.watchCalendar({
accessToken,
calendarId: subscription.externalCalendarId,
address,
token,
channelId,
});
await this.models.calendarSubscription.updateChannel(subscription.id, {
customChannelId: result.channelId,
customResourceId: result.resourceId,
channelExpiration: result.expiration ?? null,
});
}
}

View File

@@ -0,0 +1,188 @@
import {
Field,
InputType,
Int,
ObjectType,
registerEnumType,
} from '@nestjs/graphql';
import { CalendarProviderName } from './providers';
registerEnumType(CalendarProviderName, { name: 'CalendarProviderType' });
@ObjectType()
export class CalendarAccountObjectType {
@Field()
id!: string;
@Field(() => CalendarProviderName)
provider!: CalendarProviderName;
@Field()
providerAccountId!: string;
@Field(() => String, { nullable: true })
displayName?: string | null;
@Field(() => String, { nullable: true })
email?: string | null;
@Field()
status!: string;
@Field(() => String, { nullable: true })
lastError?: string | null;
@Field(() => Int)
refreshIntervalMinutes!: number;
@Field(() => Int)
calendarsCount!: number;
@Field()
createdAt!: Date;
@Field()
updatedAt!: Date;
}
@ObjectType()
export class CalendarSubscriptionObjectType {
@Field()
id!: string;
@Field()
accountId!: string;
@Field(() => CalendarProviderName)
provider!: CalendarProviderName;
@Field()
externalCalendarId!: string;
@Field(() => String, { nullable: true })
displayName?: string | null;
@Field(() => String, { nullable: true })
timezone?: string | null;
@Field(() => String, { nullable: true })
color?: string | null;
@Field()
enabled!: boolean;
@Field(() => Date, { nullable: true })
lastSyncAt?: Date | null;
}
@ObjectType()
export class WorkspaceCalendarItemObjectType {
@Field()
id!: string;
@Field()
subscriptionId!: string;
@Field(() => Int, { nullable: true })
sortOrder?: number | null;
@Field(() => String, { nullable: true })
colorOverride?: string | null;
@Field()
enabled!: boolean;
}
@ObjectType()
export class WorkspaceCalendarObjectType {
@Field()
id!: string;
@Field()
workspaceId!: string;
@Field()
createdByUserId!: string;
@Field(() => String, { nullable: true })
displayNameOverride?: string | null;
@Field(() => String, { nullable: true })
colorOverride?: string | null;
@Field()
enabled!: boolean;
@Field(() => [WorkspaceCalendarItemObjectType])
items!: WorkspaceCalendarItemObjectType[];
}
@ObjectType()
export class CalendarEventObjectType {
@Field()
id!: string;
@Field()
subscriptionId!: string;
@Field()
externalEventId!: string;
@Field(() => String, { nullable: true })
recurrenceId?: string | null;
@Field(() => String, { nullable: true })
status?: string | null;
@Field(() => String, { nullable: true })
title?: string | null;
@Field(() => String, { nullable: true })
description?: string | null;
@Field(() => String, { nullable: true })
location?: string | null;
@Field()
startAtUtc!: Date;
@Field()
endAtUtc!: Date;
@Field(() => String, { nullable: true })
originalTimezone?: string | null;
@Field()
allDay!: boolean;
}
@InputType()
export class WorkspaceCalendarItemInput {
@Field()
subscriptionId!: string;
@Field(() => Int, { nullable: true })
sortOrder?: number | null;
@Field(() => String, { nullable: true })
colorOverride?: string | null;
}
@InputType()
export class UpdateWorkspaceCalendarsInput {
@Field()
workspaceId!: string;
@Field(() => [WorkspaceCalendarItemInput])
items!: WorkspaceCalendarItemInput[];
}
@InputType()
export class LinkCalendarAccountInput {
@Field(() => CalendarProviderName)
provider!: CalendarProviderName;
@Field(() => String, { nullable: true })
redirectUri?: string | null;
}

View File

@@ -192,6 +192,57 @@ type BlobUploadedPart {
partNumber: Int!
}
type CalendarAccountObjectType {
calendarsCount: Int!
createdAt: DateTime!
displayName: String
email: String
id: String!
lastError: String
provider: CalendarProviderType!
providerAccountId: String!
refreshIntervalMinutes: Int!
status: String!
updatedAt: DateTime!
}
type CalendarEventObjectType {
allDay: Boolean!
description: String
endAtUtc: DateTime!
externalEventId: String!
id: String!
location: String
originalTimezone: String
recurrenceId: String
startAtUtc: DateTime!
status: String
subscriptionId: String!
title: String
}
type CalendarProviderRequestErrorDataType {
message: String!
status: Int!
}
enum CalendarProviderType {
CalDAV
Google
}
type CalendarSubscriptionObjectType {
accountId: String!
color: String
displayName: String
enabled: Boolean!
externalCalendarId: String!
id: String!
lastSyncAt: DateTime
provider: CalendarProviderType!
timezone: String
}
enum ChatHistoryOrder {
asc
desc
@@ -746,7 +797,7 @@ type EditorType {
name: String!
}
union ErrorDataUnion = AlreadyInSpaceDataType | BlobNotFoundDataType | CopilotContextFileNotSupportedDataType | CopilotDocNotFoundDataType | CopilotFailedToAddWorkspaceFileEmbeddingDataType | CopilotFailedToGenerateEmbeddingDataType | CopilotFailedToMatchContextDataType | CopilotFailedToMatchGlobalContextDataType | CopilotFailedToModifyContextDataType | CopilotInvalidContextDataType | CopilotMessageNotFoundDataType | CopilotPromptNotFoundDataType | CopilotProviderNotSupportedDataType | CopilotProviderSideErrorDataType | DocActionDeniedDataType | DocHistoryNotFoundDataType | DocNotFoundDataType | DocUpdateBlockedDataType | ExpectToGrantDocUserRolesDataType | ExpectToRevokeDocUserRolesDataType | ExpectToUpdateDocUserRoleDataType | GraphqlBadRequestDataType | HttpRequestErrorDataType | InvalidAppConfigDataType | InvalidAppConfigInputDataType | InvalidEmailDataType | InvalidHistoryTimestampDataType | InvalidIndexerInputDataType | InvalidLicenseToActivateDataType | InvalidLicenseUpdateParamsDataType | InvalidOauthCallbackCodeDataType | InvalidOauthResponseDataType | InvalidPasswordLengthDataType | InvalidRuntimeConfigTypeDataType | InvalidSearchProviderRequestDataType | MemberNotFoundInSpaceDataType | MentionUserDocAccessDeniedDataType | MissingOauthQueryParameterDataType | NoCopilotProviderAvailableDataType | NoMoreSeatDataType | NotInSpaceDataType | QueryTooLongDataType | RuntimeConfigNotFoundDataType | SameSubscriptionRecurringDataType | SpaceAccessDeniedDataType | SpaceNotFoundDataType | SpaceOwnerNotFoundDataType | SpaceShouldHaveOnlyOneOwnerDataType | SubscriptionAlreadyExistsDataType | SubscriptionNotExistsDataType | SubscriptionPlanNotFoundDataType | UnknownOauthProviderDataType | UnsupportedClientVersionDataType | UnsupportedSubscriptionPlanDataType | ValidationErrorDataType | VersionRejectedDataType | WorkspacePermissionNotFoundDataType | WrongSignInCredentialsDataType
union ErrorDataUnion = AlreadyInSpaceDataType | BlobNotFoundDataType | CalendarProviderRequestErrorDataType | CopilotContextFileNotSupportedDataType | CopilotDocNotFoundDataType | CopilotFailedToAddWorkspaceFileEmbeddingDataType | CopilotFailedToGenerateEmbeddingDataType | CopilotFailedToMatchContextDataType | CopilotFailedToMatchGlobalContextDataType | CopilotFailedToModifyContextDataType | CopilotInvalidContextDataType | CopilotMessageNotFoundDataType | CopilotPromptNotFoundDataType | CopilotProviderNotSupportedDataType | CopilotProviderSideErrorDataType | DocActionDeniedDataType | DocHistoryNotFoundDataType | DocNotFoundDataType | DocUpdateBlockedDataType | ExpectToGrantDocUserRolesDataType | ExpectToRevokeDocUserRolesDataType | ExpectToUpdateDocUserRoleDataType | GraphqlBadRequestDataType | HttpRequestErrorDataType | InvalidAppConfigDataType | InvalidAppConfigInputDataType | InvalidEmailDataType | InvalidHistoryTimestampDataType | InvalidIndexerInputDataType | InvalidLicenseToActivateDataType | InvalidLicenseUpdateParamsDataType | InvalidOauthCallbackCodeDataType | InvalidOauthResponseDataType | InvalidPasswordLengthDataType | InvalidRuntimeConfigTypeDataType | InvalidSearchProviderRequestDataType | MemberNotFoundInSpaceDataType | MentionUserDocAccessDeniedDataType | MissingOauthQueryParameterDataType | NoCopilotProviderAvailableDataType | NoMoreSeatDataType | NotInSpaceDataType | QueryTooLongDataType | RuntimeConfigNotFoundDataType | SameSubscriptionRecurringDataType | SpaceAccessDeniedDataType | SpaceNotFoundDataType | SpaceOwnerNotFoundDataType | SpaceShouldHaveOnlyOneOwnerDataType | SubscriptionAlreadyExistsDataType | SubscriptionNotExistsDataType | SubscriptionPlanNotFoundDataType | UnknownOauthProviderDataType | UnsupportedClientVersionDataType | UnsupportedSubscriptionPlanDataType | ValidationErrorDataType | VersionRejectedDataType | WorkspacePermissionNotFoundDataType | WrongSignInCredentialsDataType
enum ErrorNames {
ACCESS_DENIED
@@ -758,6 +809,7 @@ enum ErrorNames {
BLOB_INVALID
BLOB_NOT_FOUND
BLOB_QUOTA_EXCEEDED
CALENDAR_PROVIDER_REQUEST_ERROR
CANNOT_DELETE_ACCOUNT_WITH_OWNED_TEAM_WORKSPACE
CANNOT_DELETE_ALL_ADMIN_ACCOUNT
CANNOT_DELETE_OWN_ACCOUNT
@@ -1220,6 +1272,11 @@ type LimitedUserType {
hasPassword: Boolean
}
input LinkCalendarAccountInput {
provider: CalendarProviderType!
redirectUri: String
}
input ListUserInput {
features: [FeatureType!]
first: Int = 20
@@ -1402,6 +1459,7 @@ type Mutation {
inviteBatch(emails: [String!]!, sendInviteMail: Boolean @deprecated(reason: "never used"), workspaceId: String!): [InviteResult!]! @deprecated(reason: "use [inviteMembers] instead")
inviteMembers(emails: [String!]!, workspaceId: String!): [InviteResult!]!
leaveWorkspace(sendLeaveMail: Boolean @deprecated(reason: "no used anymore"), workspaceId: String!, workspaceName: String @deprecated(reason: "no longer used")): Boolean!
linkCalendarAccount(input: LinkCalendarAccountInput!): String!
"""mention user in a doc"""
mentionUser(input: MentionInput!): ID!
@@ -1469,9 +1527,11 @@ type Mutation {
"""Trigger generate missing titles cron job"""
triggerGenerateTitleCron: Boolean!
unlinkCalendarAccount(accountId: String!): Boolean!
"""update app configuration"""
updateAppConfig(updates: [UpdateAppConfigInput!]!): JSONObject!
updateCalendarAccount(accountId: String!, refreshIntervalMinutes: Int!): CalendarAccountObjectType
"""Update a comment content"""
updateComment(input: CommentUpdateInput!): Boolean!
@@ -1500,6 +1560,7 @@ type Mutation {
"""Update workspace"""
updateWorkspace(input: UpdateWorkspaceInput!): WorkspaceType!
updateWorkspaceCalendars(input: UpdateWorkspaceCalendarsInput!): WorkspaceCalendarObjectType!
"""Update ignored docs"""
updateWorkspaceEmbeddingIgnoredDocs(add: [String!], remove: [String!], workspaceId: String!): Int!
@@ -1705,6 +1766,10 @@ type Query {
"""Apply updates to a doc using LLM and return the merged markdown."""
applyDocUpdates(docId: String!, op: String!, updates: String!, workspaceId: String!): String!
calendarAccountCalendars(accountId: String!): [CalendarSubscriptionObjectType!]!
calendarAccounts: [CalendarAccountObjectType!]!
calendarEvents(from: DateTime!, to: DateTime!, workspaceCalendarId: String!): [CalendarEventObjectType!]!
calendarProviders: [CalendarProviderType!]!
collectAllBlobSizes: WorkspaceBlobSizes! @deprecated(reason: "use `user.quotaUsage` instead")
"""Get current user"""
@@ -1751,6 +1816,7 @@ type Query {
"""Get workspace by id"""
workspace(id: String!): WorkspaceType!
workspaceCalendars(workspaceId: String!): [WorkspaceCalendarObjectType!]!
"""Get workspace role permissions"""
workspaceRolePermissions(id: String!): WorkspaceRolePermissions! @deprecated(reason: "use WorkspaceType[permissions] instead")
@@ -2207,6 +2273,11 @@ input UpdateUserSettingsInput {
receiveMentionEmail: Boolean
}
input UpdateWorkspaceCalendarsInput {
items: [WorkspaceCalendarItemInput!]!
workspaceId: String!
}
input UpdateWorkspaceInput {
"""Enable AI"""
enableAi: Boolean
@@ -2331,6 +2402,30 @@ type WorkspaceBlobSizes {
size: SafeInt!
}
input WorkspaceCalendarItemInput {
colorOverride: String
sortOrder: Int
subscriptionId: String!
}
type WorkspaceCalendarItemObjectType {
colorOverride: String
enabled: Boolean!
id: String!
sortOrder: Int
subscriptionId: String!
}
type WorkspaceCalendarObjectType {
colorOverride: String
createdByUserId: String!
displayNameOverride: String
enabled: Boolean!
id: String!
items: [WorkspaceCalendarItemObjectType!]!
workspaceId: String!
}
type WorkspaceDocMeta {
createdAt: DateTime!
createdBy: EditorType

View File

@@ -0,0 +1,13 @@
query calendarAccountCalendars($accountId: String!) {
calendarAccountCalendars(accountId: $accountId) {
id
accountId
provider
externalCalendarId
displayName
timezone
color
enabled
lastSyncAt
}
}

View File

@@ -0,0 +1,15 @@
query calendarAccounts {
calendarAccounts {
id
provider
providerAccountId
displayName
email
status
lastError
refreshIntervalMinutes
calendarsCount
createdAt
updatedAt
}
}

View File

@@ -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
}
}

View File

@@ -0,0 +1,3 @@
query calendarProviders {
calendarProviders
}

View File

@@ -0,0 +1,3 @@
mutation linkCalendarAccount($input: LinkCalendarAccountInput!) {
linkCalendarAccount(input: $input)
}

View File

@@ -0,0 +1,3 @@
mutation unlinkCalendarAccount($accountId: String!) {
unlinkCalendarAccount(accountId: $accountId)
}

View File

@@ -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
}
}

View File

@@ -0,0 +1,17 @@
mutation updateWorkspaceCalendars($input: UpdateWorkspaceCalendarsInput!) {
updateWorkspaceCalendars(input: $input) {
id
workspaceId
createdByUserId
displayNameOverride
colorOverride
enabled
items {
id
subscriptionId
sortOrder
colorOverride
enabled
}
}
}

View File

@@ -0,0 +1,17 @@
query workspaceCalendars($workspaceId: String!) {
workspaceCalendars(workspaceId: $workspaceId) {
id
workspaceId
createdByUserId
displayNameOverride
colorOverride
enabled
items {
id
subscriptionId
sortOrder
colorOverride
enabled
}
}
}

View File

@@ -557,6 +557,156 @@ export const getBlobUploadPartUrlMutation = {
}`,
};
export const calendarAccountCalendarsQuery = {
id: 'calendarAccountCalendarsQuery' as const,
op: 'calendarAccountCalendars',
query: `query calendarAccountCalendars($accountId: String!) {
calendarAccountCalendars(accountId: $accountId) {
id
accountId
provider
externalCalendarId
displayName
timezone
color
enabled
lastSyncAt
}
}`,
};
export const calendarAccountsQuery = {
id: 'calendarAccountsQuery' as const,
op: 'calendarAccounts',
query: `query calendarAccounts {
calendarAccounts {
id
provider
providerAccountId
displayName
email
status
lastError
refreshIntervalMinutes
calendarsCount
createdAt
updatedAt
}
}`,
};
export const calendarEventsQuery = {
id: 'calendarEventsQuery' as const,
op: 'calendarEvents',
query: `query calendarEvents($workspaceCalendarId: String!, $from: DateTime!, $to: DateTime!) {
calendarEvents(workspaceCalendarId: $workspaceCalendarId, from: $from, to: $to) {
id
subscriptionId
externalEventId
recurrenceId
status
title
description
location
startAtUtc
endAtUtc
originalTimezone
allDay
}
}`,
};
export const calendarProvidersQuery = {
id: 'calendarProvidersQuery' as const,
op: 'calendarProviders',
query: `query calendarProviders {
calendarProviders
}`,
};
export const linkCalendarAccountMutation = {
id: 'linkCalendarAccountMutation' as const,
op: 'linkCalendarAccount',
query: `mutation linkCalendarAccount($input: LinkCalendarAccountInput!) {
linkCalendarAccount(input: $input)
}`,
};
export const unlinkCalendarAccountMutation = {
id: 'unlinkCalendarAccountMutation' as const,
op: 'unlinkCalendarAccount',
query: `mutation unlinkCalendarAccount($accountId: String!) {
unlinkCalendarAccount(accountId: $accountId)
}`,
};
export const updateCalendarAccountMutation = {
id: 'updateCalendarAccountMutation' as const,
op: 'updateCalendarAccount',
query: `mutation updateCalendarAccount($accountId: String!, $refreshIntervalMinutes: Int!) {
updateCalendarAccount(
accountId: $accountId
refreshIntervalMinutes: $refreshIntervalMinutes
) {
id
provider
providerAccountId
displayName
email
status
lastError
refreshIntervalMinutes
calendarsCount
createdAt
updatedAt
}
}`,
};
export const updateWorkspaceCalendarsMutation = {
id: 'updateWorkspaceCalendarsMutation' as const,
op: 'updateWorkspaceCalendars',
query: `mutation updateWorkspaceCalendars($input: UpdateWorkspaceCalendarsInput!) {
updateWorkspaceCalendars(input: $input) {
id
workspaceId
createdByUserId
displayNameOverride
colorOverride
enabled
items {
id
subscriptionId
sortOrder
colorOverride
enabled
}
}
}`,
};
export const workspaceCalendarsQuery = {
id: 'workspaceCalendarsQuery' as const,
op: 'workspaceCalendars',
query: `query workspaceCalendars($workspaceId: String!) {
workspaceCalendars(workspaceId: $workspaceId) {
id
workspaceId
createdByUserId
displayNameOverride
colorOverride
enabled
items {
id
subscriptionId
sortOrder
colorOverride
enabled
}
}
}`,
};
export const cancelSubscriptionMutation = {
id: 'cancelSubscriptionMutation' as const,
op: 'cancelSubscription',

View File

@@ -244,6 +244,61 @@ export interface BlobUploadedPart {
partNumber: Scalars['Int']['output'];
}
export interface CalendarAccountObjectType {
__typename?: 'CalendarAccountObjectType';
calendarsCount: Scalars['Int']['output'];
createdAt: Scalars['DateTime']['output'];
displayName: Maybe<Scalars['String']['output']>;
email: Maybe<Scalars['String']['output']>;
id: Scalars['String']['output'];
lastError: Maybe<Scalars['String']['output']>;
provider: CalendarProviderType;
providerAccountId: Scalars['String']['output'];
refreshIntervalMinutes: Scalars['Int']['output'];
status: Scalars['String']['output'];
updatedAt: Scalars['DateTime']['output'];
}
export interface CalendarEventObjectType {
__typename?: 'CalendarEventObjectType';
allDay: Scalars['Boolean']['output'];
description: Maybe<Scalars['String']['output']>;
endAtUtc: Scalars['DateTime']['output'];
externalEventId: Scalars['String']['output'];
id: Scalars['String']['output'];
location: Maybe<Scalars['String']['output']>;
originalTimezone: Maybe<Scalars['String']['output']>;
recurrenceId: Maybe<Scalars['String']['output']>;
startAtUtc: Scalars['DateTime']['output'];
status: Maybe<Scalars['String']['output']>;
subscriptionId: Scalars['String']['output'];
title: Maybe<Scalars['String']['output']>;
}
export interface CalendarProviderRequestErrorDataType {
__typename?: 'CalendarProviderRequestErrorDataType';
message: Scalars['String']['output'];
status: Scalars['Int']['output'];
}
export enum CalendarProviderType {
CalDAV = 'CalDAV',
Google = 'Google',
}
export interface CalendarSubscriptionObjectType {
__typename?: 'CalendarSubscriptionObjectType';
accountId: Scalars['String']['output'];
color: Maybe<Scalars['String']['output']>;
displayName: Maybe<Scalars['String']['output']>;
enabled: Scalars['Boolean']['output'];
externalCalendarId: Scalars['String']['output'];
id: Scalars['String']['output'];
lastSyncAt: Maybe<Scalars['DateTime']['output']>;
provider: CalendarProviderType;
timezone: Maybe<Scalars['String']['output']>;
}
export enum ChatHistoryOrder {
asc = 'asc',
desc = 'desc',
@@ -881,6 +936,7 @@ export interface EditorType {
export type ErrorDataUnion =
| AlreadyInSpaceDataType
| BlobNotFoundDataType
| CalendarProviderRequestErrorDataType
| CopilotContextFileNotSupportedDataType
| CopilotDocNotFoundDataType
| CopilotFailedToAddWorkspaceFileEmbeddingDataType
@@ -948,6 +1004,7 @@ export enum ErrorNames {
BLOB_INVALID = 'BLOB_INVALID',
BLOB_NOT_FOUND = 'BLOB_NOT_FOUND',
BLOB_QUOTA_EXCEEDED = 'BLOB_QUOTA_EXCEEDED',
CALENDAR_PROVIDER_REQUEST_ERROR = 'CALENDAR_PROVIDER_REQUEST_ERROR',
CANNOT_DELETE_ACCOUNT_WITH_OWNED_TEAM_WORKSPACE = 'CANNOT_DELETE_ACCOUNT_WITH_OWNED_TEAM_WORKSPACE',
CANNOT_DELETE_ALL_ADMIN_ACCOUNT = 'CANNOT_DELETE_ALL_ADMIN_ACCOUNT',
CANNOT_DELETE_OWN_ACCOUNT = 'CANNOT_DELETE_OWN_ACCOUNT',
@@ -1405,6 +1462,11 @@ export interface LimitedUserType {
hasPassword: Maybe<Scalars['Boolean']['output']>;
}
export interface LinkCalendarAccountInput {
provider: CalendarProviderType;
redirectUri?: InputMaybe<Scalars['String']['input']>;
}
export interface ListUserInput {
features?: InputMaybe<Array<FeatureType>>;
first?: InputMaybe<Scalars['Int']['input']>;
@@ -1567,6 +1629,7 @@ export interface Mutation {
inviteBatch: Array<InviteResult>;
inviteMembers: Array<InviteResult>;
leaveWorkspace: Scalars['Boolean']['output'];
linkCalendarAccount: Scalars['String']['output'];
/** mention user in a doc */
mentionUser: Scalars['ID']['output'];
publishDoc: DocType;
@@ -1622,8 +1685,10 @@ export interface Mutation {
triggerCleanupTrashedDocEmbeddings: Scalars['Boolean']['output'];
/** Trigger generate missing titles cron job */
triggerGenerateTitleCron: Scalars['Boolean']['output'];
unlinkCalendarAccount: Scalars['Boolean']['output'];
/** update app configuration */
updateAppConfig: Scalars['JSONObject']['output'];
updateCalendarAccount: Maybe<CalendarAccountObjectType>;
/** Update a comment content */
updateComment: Scalars['Boolean']['output'];
/** Update a copilot prompt */
@@ -1644,6 +1709,7 @@ export interface Mutation {
updateUserFeatures: Array<FeatureType>;
/** Update workspace */
updateWorkspace: WorkspaceType;
updateWorkspaceCalendars: WorkspaceCalendarObjectType;
/** Update ignored docs */
updateWorkspaceEmbeddingIgnoredDocs: Scalars['Int']['output'];
/** Upload user avatar */
@@ -1888,6 +1954,10 @@ export interface MutationLeaveWorkspaceArgs {
workspaceName?: InputMaybe<Scalars['String']['input']>;
}
export interface MutationLinkCalendarAccountArgs {
input: LinkCalendarAccountInput;
}
export interface MutationMentionUserArgs {
input: MentionInput;
}
@@ -2041,10 +2111,19 @@ export interface MutationSubmitAudioTranscriptionArgs {
workspaceId: Scalars['String']['input'];
}
export interface MutationUnlinkCalendarAccountArgs {
accountId: Scalars['String']['input'];
}
export interface MutationUpdateAppConfigArgs {
updates: Array<UpdateAppConfigInput>;
}
export interface MutationUpdateCalendarAccountArgs {
accountId: Scalars['String']['input'];
refreshIntervalMinutes: Scalars['Int']['input'];
}
export interface MutationUpdateCommentArgs {
input: CommentUpdateInput;
}
@@ -2099,6 +2178,10 @@ export interface MutationUpdateWorkspaceArgs {
input: UpdateWorkspaceInput;
}
export interface MutationUpdateWorkspaceCalendarsArgs {
input: UpdateWorkspaceCalendarsInput;
}
export interface MutationUpdateWorkspaceEmbeddingIgnoredDocsArgs {
add?: InputMaybe<Array<Scalars['String']['input']>>;
remove?: InputMaybe<Array<Scalars['String']['input']>>;
@@ -2315,6 +2398,10 @@ export interface Query {
appConfig: Scalars['JSONObject']['output'];
/** Apply updates to a doc using LLM and return the merged markdown. */
applyDocUpdates: Scalars['String']['output'];
calendarAccountCalendars: Array<CalendarSubscriptionObjectType>;
calendarAccounts: Array<CalendarAccountObjectType>;
calendarEvents: Array<CalendarEventObjectType>;
calendarProviders: Array<CalendarProviderType>;
/** @deprecated use `user.quotaUsage` instead */
collectAllBlobSizes: WorkspaceBlobSizes;
/** Get current user */
@@ -2354,6 +2441,7 @@ export interface Query {
usersCount: Scalars['Int']['output'];
/** Get workspace by id */
workspace: WorkspaceType;
workspaceCalendars: Array<WorkspaceCalendarObjectType>;
/**
* Get workspace role permissions
* @deprecated use WorkspaceType[permissions] instead
@@ -2382,6 +2470,16 @@ export interface QueryApplyDocUpdatesArgs {
workspaceId: Scalars['String']['input'];
}
export interface QueryCalendarAccountCalendarsArgs {
accountId: Scalars['String']['input'];
}
export interface QueryCalendarEventsArgs {
from: Scalars['DateTime']['input'];
to: Scalars['DateTime']['input'];
workspaceCalendarId: Scalars['String']['input'];
}
export interface QueryErrorArgs {
name: ErrorNames;
}
@@ -2430,6 +2528,10 @@ export interface QueryWorkspaceArgs {
id: Scalars['String']['input'];
}
export interface QueryWorkspaceCalendarsArgs {
workspaceId: Scalars['String']['input'];
}
export interface QueryWorkspaceRolePermissionsArgs {
id: Scalars['String']['input'];
}
@@ -2883,6 +2985,11 @@ export interface UpdateUserSettingsInput {
receiveMentionEmail?: InputMaybe<Scalars['Boolean']['input']>;
}
export interface UpdateWorkspaceCalendarsInput {
items: Array<WorkspaceCalendarItemInput>;
workspaceId: Scalars['String']['input'];
}
export interface UpdateWorkspaceInput {
/** Enable AI */
enableAi?: InputMaybe<Scalars['Boolean']['input']>;
@@ -3014,6 +3121,32 @@ export interface WorkspaceBlobSizes {
size: Scalars['SafeInt']['output'];
}
export interface WorkspaceCalendarItemInput {
colorOverride?: InputMaybe<Scalars['String']['input']>;
sortOrder?: InputMaybe<Scalars['Int']['input']>;
subscriptionId: Scalars['String']['input'];
}
export interface WorkspaceCalendarItemObjectType {
__typename?: 'WorkspaceCalendarItemObjectType';
colorOverride: Maybe<Scalars['String']['output']>;
enabled: Scalars['Boolean']['output'];
id: Scalars['String']['output'];
sortOrder: Maybe<Scalars['Int']['output']>;
subscriptionId: Scalars['String']['output'];
}
export interface WorkspaceCalendarObjectType {
__typename?: 'WorkspaceCalendarObjectType';
colorOverride: Maybe<Scalars['String']['output']>;
createdByUserId: Scalars['String']['output'];
displayNameOverride: Maybe<Scalars['String']['output']>;
enabled: Scalars['Boolean']['output'];
id: Scalars['String']['output'];
items: Array<WorkspaceCalendarItemObjectType>;
workspaceId: Scalars['String']['output'];
}
export interface WorkspaceDocMeta {
__typename?: 'WorkspaceDocMeta';
createdAt: Scalars['DateTime']['output'];
@@ -3788,6 +3921,169 @@ export type GetBlobUploadPartUrlMutation = {
};
};
export type CalendarAccountCalendarsQueryVariables = Exact<{
accountId: Scalars['String']['input'];
}>;
export type CalendarAccountCalendarsQuery = {
__typename?: 'Query';
calendarAccountCalendars: Array<{
__typename?: 'CalendarSubscriptionObjectType';
id: string;
accountId: string;
provider: CalendarProviderType;
externalCalendarId: string;
displayName: string | null;
timezone: string | null;
color: string | null;
enabled: boolean;
lastSyncAt: string | null;
}>;
};
export type CalendarAccountsQueryVariables = Exact<{ [key: string]: never }>;
export type CalendarAccountsQuery = {
__typename?: 'Query';
calendarAccounts: Array<{
__typename?: 'CalendarAccountObjectType';
id: string;
provider: CalendarProviderType;
providerAccountId: string;
displayName: string | null;
email: string | null;
status: string;
lastError: string | null;
refreshIntervalMinutes: number;
calendarsCount: number;
createdAt: string;
updatedAt: string;
}>;
};
export type CalendarEventsQueryVariables = Exact<{
workspaceCalendarId: Scalars['String']['input'];
from: Scalars['DateTime']['input'];
to: Scalars['DateTime']['input'];
}>;
export type CalendarEventsQuery = {
__typename?: 'Query';
calendarEvents: Array<{
__typename?: 'CalendarEventObjectType';
id: string;
subscriptionId: string;
externalEventId: string;
recurrenceId: string | null;
status: string | null;
title: string | null;
description: string | null;
location: string | null;
startAtUtc: string;
endAtUtc: string;
originalTimezone: string | null;
allDay: boolean;
}>;
};
export type CalendarProvidersQueryVariables = Exact<{ [key: string]: never }>;
export type CalendarProvidersQuery = {
__typename?: 'Query';
calendarProviders: Array<CalendarProviderType>;
};
export type LinkCalendarAccountMutationVariables = Exact<{
input: LinkCalendarAccountInput;
}>;
export type LinkCalendarAccountMutation = {
__typename?: 'Mutation';
linkCalendarAccount: string;
};
export type UnlinkCalendarAccountMutationVariables = Exact<{
accountId: Scalars['String']['input'];
}>;
export type UnlinkCalendarAccountMutation = {
__typename?: 'Mutation';
unlinkCalendarAccount: boolean;
};
export type UpdateCalendarAccountMutationVariables = Exact<{
accountId: Scalars['String']['input'];
refreshIntervalMinutes: Scalars['Int']['input'];
}>;
export type UpdateCalendarAccountMutation = {
__typename?: 'Mutation';
updateCalendarAccount: {
__typename?: 'CalendarAccountObjectType';
id: string;
provider: CalendarProviderType;
providerAccountId: string;
displayName: string | null;
email: string | null;
status: string;
lastError: string | null;
refreshIntervalMinutes: number;
calendarsCount: number;
createdAt: string;
updatedAt: string;
} | null;
};
export type UpdateWorkspaceCalendarsMutationVariables = Exact<{
input: UpdateWorkspaceCalendarsInput;
}>;
export type UpdateWorkspaceCalendarsMutation = {
__typename?: 'Mutation';
updateWorkspaceCalendars: {
__typename?: 'WorkspaceCalendarObjectType';
id: string;
workspaceId: string;
createdByUserId: string;
displayNameOverride: string | null;
colorOverride: string | null;
enabled: boolean;
items: Array<{
__typename?: 'WorkspaceCalendarItemObjectType';
id: string;
subscriptionId: string;
sortOrder: number | null;
colorOverride: string | null;
enabled: boolean;
}>;
};
};
export type WorkspaceCalendarsQueryVariables = Exact<{
workspaceId: Scalars['String']['input'];
}>;
export type WorkspaceCalendarsQuery = {
__typename?: 'Query';
workspaceCalendars: Array<{
__typename?: 'WorkspaceCalendarObjectType';
id: string;
workspaceId: string;
createdByUserId: string;
displayNameOverride: string | null;
colorOverride: string | null;
enabled: boolean;
items: Array<{
__typename?: 'WorkspaceCalendarItemObjectType';
id: string;
subscriptionId: string;
sortOrder: number | null;
colorOverride: string | null;
enabled: boolean;
}>;
}>;
};
export type CancelSubscriptionMutationVariables = Exact<{
plan?: InputMaybe<SubscriptionPlan>;
workspaceId?: InputMaybe<Scalars['String']['input']>;
@@ -6810,6 +7106,31 @@ export type Queries =
variables: ListBlobsQueryVariables;
response: ListBlobsQuery;
}
| {
name: 'calendarAccountCalendarsQuery';
variables: CalendarAccountCalendarsQueryVariables;
response: CalendarAccountCalendarsQuery;
}
| {
name: 'calendarAccountsQuery';
variables: CalendarAccountsQueryVariables;
response: CalendarAccountsQuery;
}
| {
name: 'calendarEventsQuery';
variables: CalendarEventsQueryVariables;
response: CalendarEventsQuery;
}
| {
name: 'calendarProvidersQuery';
variables: CalendarProvidersQueryVariables;
response: CalendarProvidersQuery;
}
| {
name: 'workspaceCalendarsQuery';
variables: WorkspaceCalendarsQueryVariables;
response: WorkspaceCalendarsQuery;
}
| {
name: 'listCommentChangesQuery';
variables: ListCommentChangesQueryVariables;
@@ -7252,6 +7573,26 @@ export type Mutations =
variables: GetBlobUploadPartUrlMutationVariables;
response: GetBlobUploadPartUrlMutation;
}
| {
name: 'linkCalendarAccountMutation';
variables: LinkCalendarAccountMutationVariables;
response: LinkCalendarAccountMutation;
}
| {
name: 'unlinkCalendarAccountMutation';
variables: UnlinkCalendarAccountMutationVariables;
response: UnlinkCalendarAccountMutation;
}
| {
name: 'updateCalendarAccountMutation';
variables: UpdateCalendarAccountMutationVariables;
response: UpdateCalendarAccountMutation;
}
| {
name: 'updateWorkspaceCalendarsMutation';
variables: UpdateWorkspaceCalendarsMutationVariables;
response: UpdateWorkspaceCalendarsMutation;
}
| {
name: 'cancelSubscriptionMutation';
variables: CancelSubscriptionMutationVariables;

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."
}
},
"calendar": {
"google": {
"type": "Object",
"desc": "Google Calendar integration config",
"link": "https://developers.google.com/calendar/api/guides/push"
}
},
"captcha": {
"enabled": {
"type": "Boolean",

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.`
*/
["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.`
*/

View File

@@ -2196,6 +2196,7 @@
"error.WORKSPACE_ID_REQUIRED_FOR_TEAM_SUBSCRIPTION": "A workspace is required to checkout for team subscription.",
"error.WORKSPACE_ID_REQUIRED_TO_UPDATE_TEAM_SUBSCRIPTION": "Workspace id is required to update team subscription.",
"error.MANAGED_BY_APP_STORE_OR_PLAY": "This subscription is managed by App Store or Google Play. Please manage it in the corresponding store.",
"error.CALENDAR_PROVIDER_REQUEST_ERROR": "Calendar provider request error, status: {{status}}, message: {{message}}",
"error.COPILOT_SESSION_NOT_FOUND": "Copilot session not found.",
"error.COPILOT_SESSION_INVALID_INPUT": "Copilot session input is invalid.",
"error.COPILOT_SESSION_DELETED": "Copilot session has been deleted.",