Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
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
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,70 @@
import { LoadingContent } from "@/components/LoadingContent";
import { useCalendars } from "@/hooks/useCalendars";
import { CalendarConnectionCard } from "./CalendarConnectionCard";
import { EnableFeatureCard } from "@/components/EnableFeatureCard";
import { useAccount } from "@/providers/EmailAccountProvider";
import { ConnectCalendarButton } from "./ConnectCalendarButton";
import { useState } from "react";
import { toastError } from "@/components/Toast";
import type { GetCalendarAuthUrlResponse } from "@/app/api/google/calendar/auth-url/route";
import { fetchWithAccount } from "@/utils/fetch";

export function CalendarConnections() {
const { data, isLoading, error } = useCalendars();
const { emailAccountId } = useAccount();
const connections = data?.connections || [];
const [isConnecting, setIsConnecting] = useState(false);

const handleConnectCalendar = async () => {
setIsConnecting(true);
try {
const response = await fetchWithAccount({
url: "/api/google/calendar/auth-url",
emailAccountId,
init: { headers: { "Content-Type": "application/json" } },
});

if (!response.ok) {
throw new Error("Failed to initiate calendar connection");
}

const data: GetCalendarAuthUrlResponse = await response.json();
window.location.href = data.url;
} catch (error) {
toastError({
title: "Error initiating calendar connection",
description: "Please try again or contact support",
});
setIsConnecting(false);
}
};

return (
<LoadingContent loading={isLoading} error={error}>
<div className="space-y-6">
{connections.length === 0 ? (
<div className="text-center text-muted-foreground py-10">
<p>No calendar connections found.</p>
<p>Connect your Google Calendar to get started.</p>
{connections.length === 0 ? (
<EnableFeatureCard
title="Connect Your Calendar"
description="Connect your Google Calendar to enable meeting transcript generation and AI-powered calendar management."
imageSrc="/images/illustrations/communication.svg"
imageAlt="Calendar integration"
buttonText={
isConnecting ? "Connecting..." : "Connect Google Calendar"
}
onEnable={handleConnectCalendar}
hideBorder
/>
) : (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold">Calendar Connections</h3>
<p className="text-sm text-muted-foreground">
Manage your connected calendars and their settings
</p>
</div>
<ConnectCalendarButton />
</div>
) : (

<div className="grid gap-4">
{connections.map((connection) => (
<CalendarConnectionCard
Expand All @@ -25,8 +75,8 @@ export function CalendarConnections() {
/>
))}
</div>
)}
</div>
</div>
)}
</LoadingContent>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
"use client";

import { useCallback } from "react";
import { Toggle } from "@/components/Toggle";
import { LoadingContent } from "@/components/LoadingContent";
import { SettingCard } from "@/components/SettingCard";
import { EnableFeatureCard } from "@/components/EnableFeatureCard";
import { toastSuccess, toastError } from "@/components/Toast";
import { useAccount } from "@/providers/EmailAccountProvider";
import { useCalendars } from "@/hooks/useCalendars";
import { updateTranscriptsForAllCalendarsAction } from "@/utils/actions/transcript-settings";
import {
createRecallCalendarAction,
deleteRecallCalendarAction,
} from "@/utils/actions/recall-calendar";

export function TranscriptSettingsManager() {
const { emailAccountId } = useAccount();
const { data: calendarsData, isLoading, error, mutate } = useCalendars();

// Import the actions directly instead of using useAction
const executeUpdate = updateTranscriptsForAllCalendarsAction.bind(
null,
emailAccountId,
);
const executeCreateRecall = createRecallCalendarAction.bind(
null,
emailAccountId,
);
const executeDeleteRecall = deleteRecallCalendarAction.bind(
null,
emailAccountId,
);

const connectedCalendars =
calendarsData?.connections.filter((conn) => conn.isConnected) || [];
const recallConnection = connectedCalendars.find(
(conn) => conn.recallCalendarId,
);

const handleToggleTranscripts = useCallback(
async (enabled: boolean) => {
if (!calendarsData) return;

const optimisticData = {
...calendarsData,
connections: calendarsData.connections.map((conn) => {
if (conn.isConnected) {
return {
...conn,
calendars: conn.calendars.map((cal) => ({
...cal,
transcriptEnabled: enabled,
})),
};
}
return conn;
}),
};
mutate(optimisticData, false);

try {
if (enabled) {
if (!recallConnection) {
await executeCreateRecall();
}
await executeUpdate({ transcriptEnabled: true });
toastSuccess({
description: "Transcripts enabled successfully",
});
} else {
if (recallConnection) {
await executeDeleteRecall();
}
await executeUpdate({ transcriptEnabled: false });
toastSuccess({
description: "Transcripts disabled successfully",
});
}
mutate();
} catch (error) {
mutate();
toastError({
title: "Error updating transcript settings",
description: error instanceof Error ? error.message : "Unknown error",
});
}
},
[
executeUpdate,
executeCreateRecall,
executeDeleteRecall,
recallConnection,
calendarsData,
mutate,
],
);

if (connectedCalendars.length === 0) {
return (
<EnableFeatureCard
title="Meeting Transcripts"
description="Connect your calendar first to enable automatic transcript generation for your meetings"
imageSrc="/images/illustrations/communication.svg"
imageAlt="Meeting transcripts"
buttonText="Connect Calendar"
href={`/${emailAccountId}/calendars`}
hideBorder
/>
);
}

const enabledCalendars =
connectedCalendars[0]?.calendars.filter((cal) => cal.isEnabled) || [];
const transcriptEnabled = enabledCalendars[0]?.transcriptEnabled ?? false;

return (
<LoadingContent loading={isLoading} error={error}>
<div className="max-w-2xl">
<div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900">
Transcript Settings
</h1>
<p className="text-gray-600 mt-1">
Configure automatic transcript generation for your meetings
</p>
</div>

<SettingCard
title="Meeting Transcripts"
description="Enable automatic transcript generation for your meetings to improve AI reply drafting"
right={
<Toggle
name="transcript-enabled"
enabled={transcriptEnabled}
onChange={handleToggleTranscripts}
/>
}
/>
</div>
</LoadingContent>
);
}
10 changes: 10 additions & 0 deletions apps/web/app/(app)/[emailAccountId]/transcript-settings/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { PageWrapper } from "@/components/PageWrapper";
import { TranscriptSettingsManager } from "./TranscriptSettingsManager";

export default function TranscriptSettingsPage() {
return (
<PageWrapper>
<TranscriptSettingsManager />
</PageWrapper>
);
}
31 changes: 28 additions & 3 deletions apps/web/app/api/google/calendar/callback/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,39 @@ import { CALENDAR_STATE_COOKIE_NAME } from "@/utils/calendar/constants";
import { parseOAuthState } from "@/utils/oauth/state";
import { auth } from "@/utils/auth";
import { prefixPath } from "@/utils/path";
import { createRecallCalendar } from "@/utils/recall/calendar";

const logger = createScopedLogger("google/calendar/callback");

async function createAndLinkRecallCalendar(
connectionId: string,
refreshToken: string,
emailAccountId: string,
): Promise<void> {
try {
const recallCalendar = await createRecallCalendar({
oauth_refresh_token: refreshToken,
});

await prisma.calendarConnection.update({
where: { id: connectionId },
data: { recallCalendarId: recallCalendar.id },
});
} catch (error) {
logger.error("Failed to create Recall calendar for connection", {
error: error instanceof Error ? error.message : error,
connectionId,
emailAccountId,
});
}
}

export const GET = withError(async (request) => {
const searchParams = request.nextUrl.searchParams;
const code = searchParams.get("code");
const receivedState = searchParams.get("state");
const storedState = request.cookies.get(CALENDAR_STATE_COOKIE_NAME)?.value;

// We'll set the proper redirect URL after we decode the state and get emailAccountId
let redirectUrl = new URL("/calendars", request.nextUrl.origin);
const response = NextResponse.redirect(redirectUrl);

Expand Down Expand Up @@ -61,13 +84,11 @@ export const GET = withError(async (request) => {

const { emailAccountId } = decodedState;

// Update redirect URL to include emailAccountId
redirectUrl = new URL(
prefixPath(emailAccountId, "/calendars"),
request.nextUrl.origin,
);

// Verify user owns this email account
const session = await auth();
if (!session?.user?.id) {
logger.warn("Unauthorized calendar callback - no session");
Expand Down Expand Up @@ -134,7 +155,11 @@ export const GET = withError(async (request) => {
logger.info("Calendar connection already exists", {
emailAccountId,
googleEmail,
connectionId: existingConnection.id,
hasRecallCalendarId: !!existingConnection.recallCalendarId,
recallCalendarId: existingConnection.recallCalendarId,
});

redirectUrl.searchParams.set("message", "calendar_already_connected");
return NextResponse.redirect(redirectUrl, { headers: response.headers });
}
Expand Down
Loading
Loading