diff --git a/apps/web/next-env.d.ts b/apps/web/next-env.d.ts
index 1b3be084..830fb594 100644
--- a/apps/web/next-env.d.ts
+++ b/apps/web/next-env.d.ts
@@ -1,5 +1,6 @@
///
///
+///
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
diff --git a/bun.lock b/bun.lock
index 39dfe2ec..8d028fc3 100644
--- a/bun.lock
+++ b/bun.lock
@@ -225,7 +225,7 @@
"devDependencies": {
"@repo/typescript-config": "workspace:*",
"@types/node": "^22.9.0",
- "typescript": "^5.8.3",
+ "typescript": "^5.9.2",
},
},
"packages/google-tasks": {
diff --git a/packages/api/src/providers/calendars/google-calendar.ts b/packages/api/src/providers/calendars/google-calendar.ts
index 38ca56c0..6af81298 100644
--- a/packages/api/src/providers/calendars/google-calendar.ts
+++ b/packages/api/src/providers/calendars/google-calendar.ts
@@ -1,6 +1,10 @@
import { Temporal } from "temporal-polyfill";
-import { ConflictError, GoogleCalendar } from "@repo/google-calendar";
+import {
+ AuthenticationError,
+ ConflictError,
+ GoogleCalendar,
+} from "@repo/google-calendar";
import { CALENDAR_DEFAULTS } from "../../constants/calendar";
import type {
@@ -9,6 +13,7 @@ import type {
CalendarFreeBusy,
} from "../../interfaces";
import type { CreateEventInput, UpdateEventInput } from "../../schemas/events";
+import { refreshGoogleAccessToken } from "../../utils/token-refresh";
import { ProviderError } from "../lib/provider-error";
import { parseGoogleCalendarCalendarListEntry } from "./google-calendar/calendars";
import {
@@ -21,16 +26,23 @@ import type { CalendarProvider, ResponseToEventInput } from "./interfaces";
interface GoogleCalendarProviderOptions {
accessToken: string;
+ refreshToken: string;
accountId: string;
}
export class GoogleCalendarProvider implements CalendarProvider {
public readonly providerId = "google" as const;
public readonly accountId: string;
+ private refreshToken: string;
private client: GoogleCalendar;
- constructor({ accessToken, accountId }: GoogleCalendarProviderOptions) {
+ constructor({
+ accessToken,
+ refreshToken,
+ accountId,
+ }: GoogleCalendarProviderOptions) {
this.accountId = accountId;
+ this.refreshToken = refreshToken;
this.client = new GoogleCalendar({
accessToken,
});
@@ -365,13 +377,57 @@ export class GoogleCalendarProvider implements CalendarProvider {
operation: string,
fn: () => Promise | T,
context?: Record,
+ retryAttempt: number = 0,
): Promise {
try {
return await Promise.resolve(fn());
} catch (error: unknown) {
- console.error(`Failed to ${operation}:`, error);
+ if (error instanceof AuthenticationError && retryAttempt === 0) {
+ console.warn(
+ `Authentication error in ${operation}, attempting token refresh for account ${this.accountId}`,
+ );
+
+ try {
+ const newAccessToken = await refreshGoogleAccessToken({
+ accountId: this.accountId,
+ });
+
+ this.client = new GoogleCalendar({
+ accessToken: newAccessToken,
+ });
+
+ console.info(
+ `Successfully refreshed token for account ${this.accountId}, retrying ${operation}`,
+ );
+ return await this.withErrorHandler(
+ operation,
+ fn,
+ context,
+ retryAttempt + 1,
+ );
+ } catch (refreshError) {
+ console.error(
+ `Failed to refresh token for account ${this.accountId}:`,
+ refreshError,
+ );
+ throw new ProviderError(
+ refreshError as Error,
+ `${operation}_token_refresh`,
+ {
+ ...context,
+ originalError: error,
+ accountId: this.accountId,
+ },
+ );
+ }
+ }
- throw new ProviderError(error as Error, operation, context);
+ console.error(`Failed to ${operation}:`, error);
+ throw new ProviderError(error as Error, operation, {
+ ...context,
+ accountId: this.accountId,
+ retryAttempt,
+ });
}
}
}
diff --git a/packages/api/src/providers/calendars/microsoft-calendar.ts b/packages/api/src/providers/calendars/microsoft-calendar.ts
index 5aeec4d5..5adf51be 100644
--- a/packages/api/src/providers/calendars/microsoft-calendar.ts
+++ b/packages/api/src/providers/calendars/microsoft-calendar.ts
@@ -17,6 +17,7 @@ import type {
UpdateCalendarInput,
} from "../../schemas/calendars";
import type { CreateEventInput, UpdateEventInput } from "../../schemas/events";
+import { refreshMicrosoftAccessToken } from "../../utils/token-refresh";
import { ProviderError } from "../lib/provider-error";
import type { CalendarProvider, ResponseToEventInput } from "./interfaces";
import {
@@ -33,16 +34,23 @@ import { parseScheduleItem } from "./microsoft-calendar/freebusy";
interface MicrosoftCalendarProviderOptions {
accessToken: string;
+ refreshToken: string;
accountId: string;
}
export class MicrosoftCalendarProvider implements CalendarProvider {
public readonly providerId = "microsoft" as const;
public readonly accountId: string;
+ private refreshToken: string;
private graphClient: Client;
- constructor({ accessToken, accountId }: MicrosoftCalendarProviderOptions) {
+ constructor({
+ accessToken,
+ refreshToken,
+ accountId,
+ }: MicrosoftCalendarProviderOptions) {
this.accountId = accountId;
+ this.refreshToken = refreshToken;
this.graphClient = Client.initWithMiddleware({
authProvider: {
getAccessToken: async () => accessToken,
@@ -302,13 +310,77 @@ export class MicrosoftCalendarProvider implements CalendarProvider {
operation: string,
fn: () => Promise | T,
context?: Record,
+ retryAttempt: number = 0,
): Promise {
try {
return await Promise.resolve(fn());
} catch (error: unknown) {
+ const isAuthError = this.isAuthenticationError(error);
+
+ if (isAuthError && retryAttempt === 0) {
+ console.warn(
+ `Authentication error in ${operation}, attempting token refresh for account ${this.accountId}`,
+ );
+
+ try {
+ const newAccessToken = await refreshMicrosoftAccessToken({
+ accountId: this.accountId,
+ });
+
+ this.graphClient = Client.initWithMiddleware({
+ authProvider: {
+ getAccessToken: async () => newAccessToken,
+ },
+ });
+
+ console.info(
+ `Successfully refreshed token for account ${this.accountId}, retrying ${operation}`,
+ );
+ return await this.withErrorHandler(
+ operation,
+ fn,
+ context,
+ retryAttempt + 1,
+ );
+ } catch (refreshError) {
+ console.error(
+ `Failed to refresh token for account ${this.accountId}:`,
+ refreshError,
+ );
+ throw new ProviderError(
+ refreshError as Error,
+ `${operation}_token_refresh`,
+ {
+ ...context,
+ originalError: error,
+ accountId: this.accountId,
+ },
+ );
+ }
+ }
+
console.error(`Failed to ${operation}:`, error);
+ throw new ProviderError(error as Error, operation, {
+ ...context,
+ accountId: this.accountId,
+ retryAttempt,
+ });
+ }
+ }
- throw new ProviderError(error as Error, operation, context);
+ private isAuthenticationError(error: unknown): boolean {
+ if (error && typeof error === "object") {
+ const err = error as any;
+ // Microsoft Graph errors typically have a status or code property
+ return (
+ err.status === 401 ||
+ err.code === 401 ||
+ err.statusCode === 401 ||
+ (err.message && err.message.includes("401")) ||
+ (err.message && err.message.toLowerCase().includes("unauthorized")) ||
+ (err.message && err.message.toLowerCase().includes("authentication"))
+ );
}
+ return false;
}
}
diff --git a/packages/api/src/providers/conferencing/google-meet.ts b/packages/api/src/providers/conferencing/google-meet.ts
index ad2d503d..98ad6ecc 100644
--- a/packages/api/src/providers/conferencing/google-meet.ts
+++ b/packages/api/src/providers/conferencing/google-meet.ts
@@ -7,6 +7,7 @@ import type { ConferencingProvider } from "./interfaces";
interface GoogleMeetProviderOptions {
accessToken: string;
+ refreshToken: string;
accountId: string;
}
@@ -15,8 +16,13 @@ export class GoogleMeetProvider implements ConferencingProvider {
public readonly accountId: string;
private client: GoogleCalendar;
- constructor({ accessToken, accountId }: GoogleMeetProviderOptions) {
+ constructor({
+ accessToken,
+ refreshToken,
+ accountId,
+ }: GoogleMeetProviderOptions) {
this.accountId = accountId;
+ // Note: conferencing provider can use same refresh logic as calendar provider when needed
this.client = new GoogleCalendar({
accessToken,
});
diff --git a/packages/api/src/providers/conferencing/zoom.ts b/packages/api/src/providers/conferencing/zoom.ts
index e621ac94..22ddcfd4 100644
--- a/packages/api/src/providers/conferencing/zoom.ts
+++ b/packages/api/src/providers/conferencing/zoom.ts
@@ -6,6 +6,7 @@ import type { ConferencingProvider } from "./interfaces";
interface ZoomProviderOptions {
accessToken: string;
+ refreshToken: string;
accountId?: string; // Unused but allows shared construction signature
}
@@ -13,8 +14,9 @@ export class ZoomProvider implements ConferencingProvider {
public readonly providerId = "zoom" as const;
private accessToken: string;
- constructor({ accessToken }: ZoomProviderOptions) {
+ constructor({ accessToken, refreshToken }: ZoomProviderOptions) {
this.accessToken = accessToken;
+ // Note: refreshToken is accepted but not used in this implementation
}
/**
diff --git a/packages/api/src/providers/index.ts b/packages/api/src/providers/index.ts
index f2ecf136..24f6b2a2 100644
--- a/packages/api/src/providers/index.ts
+++ b/packages/api/src/providers/index.ts
@@ -66,6 +66,7 @@ function accountToProvider<
return new Provider({
accessToken: activeAccount.accessToken,
+ refreshToken: activeAccount.refreshToken,
accountId: activeAccount.accountId,
});
}
@@ -117,6 +118,7 @@ export function accountToConferencingProvider(
return new Provider({
accessToken: activeAccount.accessToken,
+ refreshToken: activeAccount.refreshToken,
accountId: activeAccount.accountId,
});
}
diff --git a/packages/api/src/providers/interfaces.ts b/packages/api/src/providers/interfaces.ts
index a81be318..6f93f48e 100644
--- a/packages/api/src/providers/interfaces.ts
+++ b/packages/api/src/providers/interfaces.ts
@@ -1,5 +1,6 @@
export interface ProviderConfig {
accessToken: string;
+ refreshToken: string;
accountId: string;
}
diff --git a/packages/api/src/providers/tasks/google-tasks.ts b/packages/api/src/providers/tasks/google-tasks.ts
index 924c30a2..2dac6ff4 100644
--- a/packages/api/src/providers/tasks/google-tasks.ts
+++ b/packages/api/src/providers/tasks/google-tasks.ts
@@ -12,6 +12,7 @@ import type { TaskProvider } from "./interfaces";
interface GoogleTasksProviderOptions {
accessToken: string;
+ refreshToken: string;
accountId: string;
}
@@ -20,8 +21,13 @@ export class GoogleTasksProvider implements TaskProvider {
public readonly accountId: string;
private client: GoogleTasks;
- constructor({ accessToken, accountId }: GoogleTasksProviderOptions) {
+ constructor({
+ accessToken,
+ refreshToken,
+ accountId,
+ }: GoogleTasksProviderOptions) {
this.accountId = accountId;
+ // Note: refreshToken accepted for interface compatibility, refresh logic can be added when needed
this.client = new GoogleTasks({
accessToken,
});
diff --git a/packages/api/src/utils/token-refresh.ts b/packages/api/src/utils/token-refresh.ts
new file mode 100644
index 00000000..aa37284b
--- /dev/null
+++ b/packages/api/src/utils/token-refresh.ts
@@ -0,0 +1,63 @@
+import { TRPCError } from "@trpc/server";
+
+import { auth } from "@repo/auth/server";
+
+interface RefreshTokenOptions {
+ accountId: string;
+ providerId: string;
+}
+
+interface RefreshTokenOptionsWithDefault {
+ accountId: string;
+ providerId?: string;
+}
+
+export async function refreshAccessToken({
+ accountId,
+ providerId,
+}: RefreshTokenOptions): Promise {
+ try {
+ const { accessToken } = await auth.api.getAccessToken({
+ body: {
+ providerId,
+ accountId,
+ },
+ });
+
+ if (!accessToken) {
+ throw new TRPCError({
+ code: "UNAUTHORIZED",
+ message: "Failed to refresh access token. Please re-authenticate.",
+ });
+ }
+
+ console.info(
+ `Successfully refreshed ${providerId} access token for account ${accountId}`,
+ );
+ return accessToken;
+ } catch (error) {
+ if (error instanceof TRPCError) {
+ throw error;
+ }
+
+ console.error(`Error refreshing ${providerId} access token:`, error);
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: "Failed to refresh access token",
+ });
+ }
+}
+
+export async function refreshGoogleAccessToken({
+ accountId,
+ providerId = "google",
+}: RefreshTokenOptionsWithDefault): Promise {
+ return refreshAccessToken({ accountId, providerId });
+}
+
+export async function refreshMicrosoftAccessToken({
+ accountId,
+ providerId = "microsoft",
+}: RefreshTokenOptionsWithDefault): Promise {
+ return refreshAccessToken({ accountId, providerId });
+}