Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/web/next-env.d.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />

// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
2 changes: 1 addition & 1 deletion bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

64 changes: 60 additions & 4 deletions packages/api/src/providers/calendars/google-calendar.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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 {
Expand All @@ -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,
});
Expand Down Expand Up @@ -365,13 +377,57 @@ export class GoogleCalendarProvider implements CalendarProvider {
operation: string,
fn: () => Promise<T> | T,
context?: Record<string, unknown>,
retryAttempt: number = 0,
): Promise<T> {
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,
});
}
}
}
76 changes: 74 additions & 2 deletions packages/api/src/providers/calendars/microsoft-calendar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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,
Expand Down Expand Up @@ -302,13 +310,77 @@ export class MicrosoftCalendarProvider implements CalendarProvider {
operation: string,
fn: () => Promise<T> | T,
context?: Record<string, unknown>,
retryAttempt: number = 0,
): Promise<T> {
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;
}
}
8 changes: 7 additions & 1 deletion packages/api/src/providers/conferencing/google-meet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type { ConferencingProvider } from "./interfaces";

interface GoogleMeetProviderOptions {
accessToken: string;
refreshToken: string;
accountId: string;
}

Expand All @@ -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,
});
Expand Down
4 changes: 3 additions & 1 deletion packages/api/src/providers/conferencing/zoom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,17 @@ import type { ConferencingProvider } from "./interfaces";

interface ZoomProviderOptions {
accessToken: string;
refreshToken: string;
accountId?: string; // Unused but allows shared construction signature
}

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
}

/**
Expand Down
2 changes: 2 additions & 0 deletions packages/api/src/providers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ function accountToProvider<

return new Provider({
accessToken: activeAccount.accessToken,
refreshToken: activeAccount.refreshToken,
accountId: activeAccount.accountId,
});
}
Expand Down Expand Up @@ -117,6 +118,7 @@ export function accountToConferencingProvider(

return new Provider({
accessToken: activeAccount.accessToken,
refreshToken: activeAccount.refreshToken,
accountId: activeAccount.accountId,
});
}
Expand Down
1 change: 1 addition & 0 deletions packages/api/src/providers/interfaces.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export interface ProviderConfig {
accessToken: string;
refreshToken: string;
accountId: string;
}

Expand Down
8 changes: 7 additions & 1 deletion packages/api/src/providers/tasks/google-tasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type { TaskProvider } from "./interfaces";

interface GoogleTasksProviderOptions {
accessToken: string;
refreshToken: string;
accountId: string;
}

Expand All @@ -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,
});
Expand Down
63 changes: 63 additions & 0 deletions packages/api/src/utils/token-refresh.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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<string> {
return refreshAccessToken({ accountId, providerId });
}

export async function refreshMicrosoftAccessToken({
accountId,
providerId = "microsoft",
}: RefreshTokenOptionsWithDefault): Promise<string> {
return refreshAccessToken({ accountId, providerId });
}