Files
AFFiNE-Mirror/packages/backend/server/src/models/calendar-account.ts
T
DarkSky 41b3b0e82e feat: handle calendar sync failed (#14510)
#### PR Dependency Tree


* **PR #14510** 👈

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

* **Bug Fixes**
* Improved calendar sync reliability with exponential backoff for
repeated failures.
* Better handling of token refresh failures with automatic account
invalidation and cleanup when needed.
* Subscriptions are now automatically disabled and related events
removed when the calendar provider reports missing resources.

* **Tests**
* Added comprehensive tests covering sync failures, backoff behavior,
token refresh handling, skipping retries during backoff, and recovery.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-02-24 22:45:47 +08:00

204 lines
5.9 KiB
TypeScript

import { Injectable } from '@nestjs/common';
import { Transactional } from '@nestjs-cls/transactional';
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;
providerPresetId?: string | null;
serverUrl?: string | null;
principalUrl?: string | null;
calendarHomeUrl?: string | null;
username?: string | null;
authType?: 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,
providerPresetId: input.providerPresetId ?? null,
serverUrl: input.serverUrl ?? null,
principalUrl: input.principalUrl ?? null,
calendarHomeUrl: input.calendarHomeUrl ?? null,
username: input.username ?? null,
authType: input.authType ?? 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,
providerPresetId: data.providerPresetId,
serverUrl: data.serverUrl,
principalUrl: data.principalUrl,
calendarHomeUrl: data.calendarHomeUrl,
username: data.username,
authType: data.authType,
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 },
});
}
@Transactional()
async invalidateAndPurge(id: string, lastError?: string | null) {
await this.updateStatus(id, 'invalid', lastError ?? null);
const subscriptions =
await this.models.calendarSubscription.listByAccount(id);
const subscriptionIds = subscriptions.map(subscription => subscription.id);
if (subscriptionIds.length > 0) {
await this.models.calendarEvent.deleteBySubscriptionIds(subscriptionIds);
}
await this.models.calendarSubscription.clearSyncTokensByAccount(id);
}
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),
};
}
}