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
@@ -0,0 +1,172 @@
import { Injectable } from '@nestjs/common';
import type { CalendarAccount, Prisma } from '@prisma/client';
import { CryptoHelper } from '../base';
import { BaseModel } from './base';
export interface CalendarAccountTokens {
accessToken?: string | null;
refreshToken?: string | null;
expiresAt?: Date | null;
scope?: string | null;
}
export interface UpsertCalendarAccountInput extends CalendarAccountTokens {
userId: string;
provider: string;
providerAccountId: string;
displayName?: string | null;
email?: string | null;
status?: string | null;
lastError?: string | null;
refreshIntervalMinutes?: number | null;
}
export interface UpdateCalendarAccountTokensInput extends CalendarAccountTokens {
status?: string | null;
lastError?: string | null;
}
@Injectable()
export class CalendarAccountModel extends BaseModel {
constructor(private readonly crypto: CryptoHelper) {
super();
}
private encryptToken(token?: string | null) {
return token ? this.crypto.encrypt(token) : null;
}
private decryptToken(token?: string | null) {
return token ? this.crypto.decrypt(token) : null;
}
async listByUser(userId: string) {
return await this.db.calendarAccount.findMany({
where: { userId },
orderBy: { createdAt: 'desc' },
});
}
async get(id: string) {
return await this.db.calendarAccount.findUnique({
where: { id },
});
}
async getByProviderAccount(
userId: string,
provider: string,
providerAccountId: string
) {
return await this.db.calendarAccount.findFirst({
where: { userId, provider, providerAccountId },
});
}
async upsert(input: UpsertCalendarAccountInput) {
const accessToken = this.encryptToken(input.accessToken);
const refreshToken = this.encryptToken(input.refreshToken);
const data: Prisma.CalendarAccountUncheckedCreateInput = {
userId: input.userId,
provider: input.provider,
providerAccountId: input.providerAccountId,
displayName: input.displayName ?? null,
email: input.email ?? null,
accessToken: accessToken ?? null,
refreshToken: refreshToken ?? null,
expiresAt: input.expiresAt ?? null,
scope: input.scope ?? null,
status: input.status ?? 'active',
lastError: input.lastError ?? null,
refreshIntervalMinutes: input.refreshIntervalMinutes ?? 60,
};
const updateData: Prisma.CalendarAccountUncheckedUpdateInput = {
displayName: data.displayName,
email: data.email,
expiresAt: data.expiresAt,
scope: data.scope,
status: data.status,
lastError: data.lastError,
refreshIntervalMinutes: data.refreshIntervalMinutes,
};
if (!!accessToken) {
updateData.accessToken = accessToken;
}
if (!!refreshToken) {
updateData.refreshToken = refreshToken;
}
return await this.db.calendarAccount.upsert({
where: {
userId_provider_providerAccountId: {
userId: input.userId,
provider: input.provider,
providerAccountId: input.providerAccountId,
},
},
create: data,
update: updateData,
});
}
async updateTokens(id: string, input: UpdateCalendarAccountTokensInput) {
const data: Prisma.CalendarAccountUncheckedUpdateInput = {};
if (input.accessToken !== undefined) {
data.accessToken = this.encryptToken(input.accessToken);
}
if (input.refreshToken !== undefined) {
data.refreshToken = this.encryptToken(input.refreshToken);
}
if (input.expiresAt !== undefined) {
data.expiresAt = input.expiresAt ?? null;
}
if (input.scope !== undefined) {
data.scope = input.scope ?? null;
}
if (input.status !== undefined) {
data.status = input.status ?? undefined;
}
if (input.lastError !== undefined) {
data.lastError = input.lastError ?? null;
}
return await this.db.calendarAccount.update({
where: { id },
data,
});
}
async updateStatus(id: string, status: string, lastError?: string | null) {
return await this.db.calendarAccount.update({
where: { id },
data: {
status,
lastError: lastError ?? null,
},
});
}
async updateRefreshInterval(id: string, refreshIntervalMinutes: number) {
return await this.db.calendarAccount.update({
where: { id },
data: { refreshIntervalMinutes },
});
}
async delete(id: string) {
return await this.db.calendarAccount.delete({
where: { id },
});
}
decryptTokens(account: CalendarAccount) {
return {
...account,
accessToken: this.decryptToken(account.accessToken),
refreshToken: this.decryptToken(account.refreshToken),
};
}
}
@@ -0,0 +1,16 @@
import { Injectable } from '@nestjs/common';
import { BaseModel } from './base';
@Injectable()
export class CalendarEventInstanceModel extends BaseModel {
async deleteByEventIds(eventIds: string[]) {
if (eventIds.length === 0) {
return;
}
await this.db.calendarEventInstance.deleteMany({
where: { calendarEventId: { in: eventIds } },
});
}
}
@@ -0,0 +1,119 @@
import { Injectable } from '@nestjs/common';
import type { Prisma } from '@prisma/client';
import { BaseModel } from './base';
export interface UpsertCalendarEventInput {
subscriptionId: string;
externalEventId: string;
recurrenceId?: string | null;
etag?: string | null;
status?: string | null;
title?: string | null;
description?: string | null;
location?: string | null;
startAtUtc: Date;
endAtUtc: Date;
originalTimezone?: string | null;
allDay: boolean;
providerUpdatedAt?: Date | null;
raw: Prisma.InputJsonValue;
}
@Injectable()
export class CalendarEventModel extends BaseModel {
async upsert(input: UpsertCalendarEventInput) {
const recurrenceId = input.recurrenceId ?? input.externalEventId;
return await this.db.calendarEvent.upsert({
where: {
subscriptionId_externalEventId_recurrenceId: {
subscriptionId: input.subscriptionId,
externalEventId: input.externalEventId,
recurrenceId,
},
},
create: {
subscriptionId: input.subscriptionId,
externalEventId: input.externalEventId,
recurrenceId,
etag: input.etag ?? null,
status: input.status ?? null,
title: input.title ?? null,
description: input.description ?? null,
location: input.location ?? null,
startAtUtc: input.startAtUtc,
endAtUtc: input.endAtUtc,
originalTimezone: input.originalTimezone ?? null,
allDay: input.allDay,
providerUpdatedAt: input.providerUpdatedAt ?? null,
raw: input.raw,
},
update: {
etag: input.etag ?? null,
status: input.status ?? null,
title: input.title ?? null,
description: input.description ?? null,
location: input.location ?? null,
startAtUtc: input.startAtUtc,
endAtUtc: input.endAtUtc,
originalTimezone: input.originalTimezone ?? null,
allDay: input.allDay,
providerUpdatedAt: input.providerUpdatedAt ?? null,
raw: input.raw,
},
});
}
async deleteBySubscription(subscriptionId: string) {
return await this.db.calendarEvent.deleteMany({
where: { subscriptionId },
});
}
async deleteBySubscriptionIds(subscriptionIds: string[]) {
return await this.db.calendarEvent.deleteMany({
where: { subscriptionId: { in: subscriptionIds } },
});
}
async deleteByIds(ids: string[]) {
return await this.db.calendarEvent.deleteMany({
where: { id: { in: ids } },
});
}
async deleteByExternalIds(
subscriptionId: string,
externalEventIds: string[]
) {
if (externalEventIds.length === 0) {
return;
}
await this.db.calendarEvent.deleteMany({
where: {
subscriptionId,
externalEventId: { in: externalEventIds },
},
});
}
async listBySubscriptionsInRange(
subscriptionIds: string[],
from: Date,
to: Date
) {
if (subscriptionIds.length === 0) {
return [];
}
return await this.db.calendarEvent.findMany({
where: {
subscriptionId: { in: subscriptionIds },
startAtUtc: { lt: to },
endAtUtc: { gt: from },
},
orderBy: [{ startAtUtc: 'asc' }, { endAtUtc: 'asc' }],
});
}
}
@@ -0,0 +1,194 @@
import { Injectable } from '@nestjs/common';
import type { CalendarSubscription, Prisma } from '@prisma/client';
import { BaseModel } from './base';
export interface UpsertCalendarSubscriptionInput {
accountId: string;
provider: string;
externalCalendarId: string;
displayName?: string | null;
timezone?: string | null;
color?: string | null;
enabled?: boolean;
}
export interface UpdateCalendarSubscriptionSyncInput {
syncToken?: string | null;
lastSyncAt?: Date | null;
}
export interface UpdateCalendarSubscriptionChannelInput {
customChannelId?: string | null;
customResourceId?: string | null;
channelExpiration?: Date | null;
}
@Injectable()
export class CalendarSubscriptionModel extends BaseModel {
async listByAccount(accountId: string) {
return await this.db.calendarSubscription.findMany({
where: { accountId },
orderBy: { createdAt: 'asc' },
});
}
async listByAccountIds(accountIds: string[]) {
return await this.db.calendarSubscription.findMany({
where: { accountId: { in: accountIds } },
});
}
async get(id: string) {
return await this.db.calendarSubscription.findUnique({
where: { id },
});
}
async getByChannelId(customChannelId: string) {
return await this.db.calendarSubscription.findFirst({
where: { customChannelId },
});
}
async upsert(input: UpsertCalendarSubscriptionInput) {
const data: Prisma.CalendarSubscriptionUncheckedCreateInput = {
accountId: input.accountId,
provider: input.provider,
externalCalendarId: input.externalCalendarId,
displayName: input.displayName ?? null,
timezone: input.timezone ?? null,
color: input.color ?? null,
enabled: input.enabled ?? true,
};
return await this.db.calendarSubscription.upsert({
where: {
accountId_externalCalendarId: {
accountId: input.accountId,
externalCalendarId: input.externalCalendarId,
},
},
create: data,
update: {
displayName: data.displayName,
timezone: data.timezone,
color: data.color,
enabled: data.enabled,
},
});
}
async updateSync(id: string, input: UpdateCalendarSubscriptionSyncInput) {
return await this.db.calendarSubscription.update({
where: { id },
data: {
syncToken: input.syncToken ?? null,
lastSyncAt: input.lastSyncAt ?? null,
},
});
}
async updateChannel(
id: string,
input: UpdateCalendarSubscriptionChannelInput
) {
return await this.db.calendarSubscription.update({
where: { id },
data: {
customChannelId: input.customChannelId ?? null,
customResourceId: input.customResourceId ?? null,
channelExpiration: input.channelExpiration ?? null,
},
});
}
async updateEnabled(id: string, enabled: boolean) {
return await this.db.calendarSubscription.update({
where: { id },
data: { enabled },
});
}
async deleteByAccount(accountId: string) {
return await this.db.calendarSubscription.deleteMany({
where: { accountId },
});
}
async deleteByIds(ids: string[]) {
return await this.db.calendarSubscription.deleteMany({
where: { id: { in: ids } },
});
}
async listActiveByAccount(accountId: string) {
return await this.db.calendarSubscription.findMany({
where: { accountId, enabled: true },
});
}
async listWithAccount(id: string) {
return await this.db.calendarSubscription.findUnique({
where: { id },
include: { account: true },
});
}
async listWithAccounts(ids: string[]) {
return await this.db.calendarSubscription.findMany({
where: { id: { in: ids } },
include: { account: true },
});
}
async listAccountSubscriptions(
accountId: string,
subscriptionIds?: string[]
) {
return await this.db.calendarSubscription.findMany({
where: {
accountId,
...(subscriptionIds ? { id: { in: subscriptionIds } } : undefined),
},
});
}
async listAllWithAccountForSync() {
return await this.db.calendarSubscription.findMany({
where: { enabled: true },
include: { account: true },
});
}
async listByAccountForSync(accountId: string) {
return await this.db.calendarSubscription.findMany({
where: { accountId, enabled: true },
include: { account: true },
});
}
async updateLastSyncAt(id: string, lastSyncAt: Date) {
return await this.db.calendarSubscription.update({
where: { id },
data: { lastSyncAt },
});
}
async clearSyncTokensByAccount(accountId: string) {
return await this.db.calendarSubscription.updateMany({
where: { accountId },
data: { syncToken: null },
});
}
async updateManyStatus(
ids: string[],
data: Partial<Pick<CalendarSubscription, 'enabled'>>
) {
return await this.db.calendarSubscription.updateMany({
where: { id: { in: ids } },
data,
});
}
}
@@ -9,6 +9,10 @@ import { ModuleRef } from '@nestjs/core';
import { ApplyType } from '../base';
import { 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';
@@ -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 },
});
}
}