mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-08-18 10:31:50 +08:00
feat: init cloud calendar support (#14247)
#### PR Dependency Tree * **PR #14247** 👈 * **PR #14248** This tree was auto-generated by [Charcoal](https://github.com/danerwilliams/charcoal) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Google Calendar integration (disabled by default): link/unlink accounts, OAuth flow, webhooks, real-time push, background sync, workspace calendars with customizable items and date-range event viewing. * **GraphQL / Client** * New queries & mutations for accounts, subscriptions, events, providers, and workspace calendar management. * **Localization** * Added localized error message for calendar provider request failures. * **Tests** * Backend tests covering sync, webhook renewal, and error/error-recovery scenarios. <sub>✏️ Tip: You can customize this high-level summary in your review settings.</sub> <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -0,0 +1,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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user