From 6a1d4c86c902f63603dba908daf37dcef48b2f14 Mon Sep 17 00:00:00 2001 From: Jim Hodapp Date: Sat, 20 Dec 2025 16:07:57 -0600 Subject: [PATCH 01/17] Feat: Add settings page with integration and relationship configuration (Phase 2) Add settings infrastructure for coach integration configuration and per-relationship AI privacy settings: Settings Page: - Add /settings route with layout matching existing app structure - SettingsContainer with Integrations and Relationships tabs - Coach-only access (coachees see informational message) Integration Settings (coach-only): - Google Account connection status and OAuth flow trigger - Recall.ai API key input with save and verify functionality - AssemblyAI API key input with save and verify functionality - Status badges showing connection/verification state Relationship Settings (coach-only): - Per-coachee Google Meet URL configuration - Per-relationship AI privacy level selection: - Full: All AI features (recording, transcript, suggestions) - Transcribe Only: Text transcription without video/audio storage - None: No AI features for privacy-conscious clients - Visual privacy level selector with icons and descriptions Type Definitions: - Add AiPrivacyLevel enum to coaching-relationship types - Add meeting_url and ai_privacy_level to CoachingRelationship - Add UserIntegration types for API credentials status - Add MeetingRecording, Transcription, TranscriptSegment types - Add AiSuggestedItem types for AI-suggested actions/agreements API Updates: - Add user-integrations API module with hooks - Implement coaching relationship update for meeting_url and ai_privacy_level - Link Settings in user-nav dropdown Relates to: refactor-group/refactor-platform-fe#146 --- src/app/settings/layout.tsx | 32 ++ src/app/settings/page.tsx | 12 + .../ui/settings/integration-settings.tsx | 337 ++++++++++++++++++ .../ui/settings/relationship-settings.tsx | 219 ++++++++++++ .../ui/settings/settings-container.tsx | 90 +++++ src/components/ui/user-nav.tsx | 8 +- src/lib/api/coaching-relationships.ts | 16 +- src/lib/api/user-integrations.ts | 124 +++++++ src/types/coaching-relationship.ts | 26 +- src/types/meeting-recording.ts | 239 +++++++++++++ src/types/user-integration.ts | 86 +++++ 11 files changed, 1181 insertions(+), 8 deletions(-) create mode 100644 src/app/settings/layout.tsx create mode 100644 src/app/settings/page.tsx create mode 100644 src/components/ui/settings/integration-settings.tsx create mode 100644 src/components/ui/settings/relationship-settings.tsx create mode 100644 src/components/ui/settings/settings-container.tsx create mode 100644 src/lib/api/user-integrations.ts create mode 100644 src/types/meeting-recording.ts create mode 100644 src/types/user-integration.ts diff --git a/src/app/settings/layout.tsx b/src/app/settings/layout.tsx new file mode 100644 index 00000000..fe21652c --- /dev/null +++ b/src/app/settings/layout.tsx @@ -0,0 +1,32 @@ +import type { Metadata } from "next"; +import "@/styles/globals.css"; +import { siteConfig } from "@/site.config.ts"; + +import { SiteHeader } from "@/components/ui/site-header"; +import { AppSidebar } from "@/components/ui/app-sidebar"; +import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"; +import { Toaster } from "@/components/ui/sonner"; + +export const metadata: Metadata = { + title: `Settings | ${siteConfig.name}`, + description: "Manage your account and integration settings", +}; + +export default function SettingsLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + +
+ + + +
{children}
+ +
+
+
+ ); +} diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx new file mode 100644 index 00000000..5cf8c83e --- /dev/null +++ b/src/app/settings/page.tsx @@ -0,0 +1,12 @@ +"use client"; + +import { PageContainer } from "@/components/ui/page-container"; +import { SettingsContainer } from "@/components/ui/settings/settings-container"; + +export default function SettingsPage() { + return ( + + + + ); +} diff --git a/src/components/ui/settings/integration-settings.tsx b/src/components/ui/settings/integration-settings.tsx new file mode 100644 index 00000000..7321c91f --- /dev/null +++ b/src/components/ui/settings/integration-settings.tsx @@ -0,0 +1,337 @@ +"use client"; + +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Badge } from "@/components/ui/badge"; +import { toast } from "sonner"; +import { Id } from "@/types/general"; +import { UserIntegration } from "@/types/user-integration"; +import { useUserIntegrationMutation } from "@/lib/api/user-integrations"; +import { siteConfig } from "@/site.config"; +import { + CheckCircle2, + XCircle, + ExternalLink, + Eye, + EyeOff, + RefreshCw, +} from "lucide-react"; + +interface IntegrationSettingsProps { + userId: Id; + integration: UserIntegration; + onRefresh: () => void; +} + +export function IntegrationSettings({ + userId, + integration, + onRefresh, +}: IntegrationSettingsProps) { + const [recallApiKey, setRecallApiKey] = useState(""); + const [assemblyApiKey, setAssemblyApiKey] = useState(""); + const [showRecallKey, setShowRecallKey] = useState(false); + const [showAssemblyKey, setShowAssemblyKey] = useState(false); + const [isVerifying, setIsVerifying] = useState(null); + const [isSaving, setIsSaving] = useState(null); + + const { + updateRecallAi, + updateAssemblyAi, + verifyRecallAi, + verifyAssemblyAi, + disconnectGoogle, + } = useUserIntegrationMutation(userId); + + const handleSaveRecallAi = async () => { + if (!recallApiKey.trim()) { + toast.error("Please enter an API key"); + return; + } + + setIsSaving("recall"); + try { + await updateRecallAi({ api_key: recallApiKey }); + toast.success("Recall.ai API key saved successfully"); + setRecallApiKey(""); + onRefresh(); + } catch (error) { + toast.error("Failed to save Recall.ai API key"); + console.error("Error saving Recall.ai key:", error); + } finally { + setIsSaving(null); + } + }; + + const handleSaveAssemblyAi = async () => { + if (!assemblyApiKey.trim()) { + toast.error("Please enter an API key"); + return; + } + + setIsSaving("assembly"); + try { + await updateAssemblyAi({ api_key: assemblyApiKey }); + toast.success("AssemblyAI API key saved successfully"); + setAssemblyApiKey(""); + onRefresh(); + } catch (error) { + toast.error("Failed to save AssemblyAI API key"); + console.error("Error saving AssemblyAI key:", error); + } finally { + setIsSaving(null); + } + }; + + const handleVerifyRecallAi = async () => { + setIsVerifying("recall"); + try { + const result = await verifyRecallAi(); + if (result.success) { + toast.success("Recall.ai API key verified successfully"); + onRefresh(); + } else { + toast.error(result.message || "Verification failed"); + } + } catch (error) { + toast.error("Failed to verify Recall.ai API key"); + console.error("Error verifying Recall.ai key:", error); + } finally { + setIsVerifying(null); + } + }; + + const handleVerifyAssemblyAi = async () => { + setIsVerifying("assembly"); + try { + const result = await verifyAssemblyAi(); + if (result.success) { + toast.success("AssemblyAI API key verified successfully"); + onRefresh(); + } else { + toast.error(result.message || "Verification failed"); + } + } catch (error) { + toast.error("Failed to verify AssemblyAI API key"); + console.error("Error verifying AssemblyAI key:", error); + } finally { + setIsVerifying(null); + } + }; + + const handleConnectGoogle = () => { + // Redirect to Google OAuth flow + window.location.href = `${siteConfig.env.backendServiceURL}/oauth/google/authorize`; + }; + + const handleDisconnectGoogle = async () => { + try { + await disconnectGoogle(); + toast.success("Google account disconnected"); + onRefresh(); + } catch (error) { + toast.error("Failed to disconnect Google account"); + console.error("Error disconnecting Google:", error); + } + }; + + return ( +
+ {/* Google Integration */} +
+
+
+

Google Account

+

+ Connect your Google account to create Google Meet links +

+
+ +
+ + {integration.google_connected ? ( +
+
+

{integration.google_email}

+

Connected

+
+ +
+ ) : ( + + )} +
+ + {/* Recall.ai Integration */} +
+
+
+

Recall.ai

+

+ Meeting recording bot service for Google Meet +

+
+ +
+ +
+
+ +
+
+ setRecallApiKey(e.target.value)} + /> + +
+ +
+
+ + {integration.recall_ai_configured && ( + + )} +
+
+ + {/* AssemblyAI Integration */} +
+
+
+

AssemblyAI

+

+ AI transcription service for meeting recordings +

+
+ +
+ +
+
+ +
+
+ setAssemblyApiKey(e.target.value)} + /> + +
+ +
+
+ + {integration.assembly_ai_configured && ( + + )} +
+
+
+ ); +} + +function StatusBadge({ + connected, + verifiedAt, +}: { + connected: boolean; + verifiedAt?: string | null; +}) { + if (connected) { + return ( + + + Connected + + ); + } + + return ( + + + Not Connected + + ); +} diff --git a/src/components/ui/settings/relationship-settings.tsx b/src/components/ui/settings/relationship-settings.tsx new file mode 100644 index 00000000..d77d98e4 --- /dev/null +++ b/src/components/ui/settings/relationship-settings.tsx @@ -0,0 +1,219 @@ +"use client"; + +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { toast } from "sonner"; +import { Id } from "@/types/general"; +import { + CoachingRelationshipWithUserNames, + AiPrivacyLevel, +} from "@/types/coaching-relationship"; +import { CoachingRelationshipApi } from "@/lib/api/coaching-relationships"; +import { User, Video, FileText, Ban, Save, Check } from "lucide-react"; +import { cn } from "@/components/lib/utils"; + +interface RelationshipSettingsProps { + userId: Id; + relationships: CoachingRelationshipWithUserNames[]; +} + +export function RelationshipSettings({ + userId, + relationships, +}: RelationshipSettingsProps) { + if (relationships.length === 0) { + return ( +
+

+ You don't have any coaching relationships yet. +

+
+ ); + } + + return ( +
+
+

Coaching Relationships

+

+ Configure meeting settings and AI privacy levels for each of your coachees +

+
+ +
+ {relationships.map((relationship) => ( + + ))} +
+
+ ); +} + +function RelationshipCard({ + relationship, +}: { + relationship: CoachingRelationshipWithUserNames; +}) { + const [meetingUrl, setMeetingUrl] = useState(relationship.meeting_url || ""); + const [privacyLevel, setPrivacyLevel] = useState( + relationship.ai_privacy_level + ); + const [isSaving, setIsSaving] = useState(false); + const [hasChanges, setHasChanges] = useState(false); + + const handleMeetingUrlChange = (value: string) => { + setMeetingUrl(value); + setHasChanges( + value !== (relationship.meeting_url || "") || + privacyLevel !== relationship.ai_privacy_level + ); + }; + + const handlePrivacyLevelChange = (value: AiPrivacyLevel) => { + setPrivacyLevel(value); + setHasChanges( + meetingUrl !== (relationship.meeting_url || "") || + value !== relationship.ai_privacy_level + ); + }; + + const handleSave = async () => { + setIsSaving(true); + try { + await CoachingRelationshipApi.update(relationship.id, { + meeting_url: meetingUrl || null, + ai_privacy_level: privacyLevel, + }); + toast.success("Settings saved successfully"); + setHasChanges(false); + } catch (error) { + toast.error("Failed to save settings"); + console.error("Error saving relationship settings:", error); + } finally { + setIsSaving(false); + } + }; + + const coacheeName = `${relationship.coachee_first_name} ${relationship.coachee_last_name}`; + + return ( +
+
+
+ +
+
+

{coacheeName}

+

Coachee

+
+
+ +
+ + handleMeetingUrlChange(e.target.value)} + /> +

+ The Google Meet link for your coaching sessions with this coachee +

+
+ +
+ +
+ handlePrivacyLevelChange(AiPrivacyLevel.Full)} + icon={
+
+ + {hasChanges && ( +
+ +
+ )} +
+ ); +} + +interface PrivacyOptionProps { + value: AiPrivacyLevel; + selected: boolean; + onClick: () => void; + icon: React.ReactNode; + label: string; + sublabel?: string; + description: string; +} + +function PrivacyOption({ + selected, + onClick, + icon, + label, + sublabel, + description, +}: PrivacyOptionProps) { + return ( + + ); +} diff --git a/src/components/ui/settings/settings-container.tsx b/src/components/ui/settings/settings-container.tsx new file mode 100644 index 00000000..aa0d8dfa --- /dev/null +++ b/src/components/ui/settings/settings-container.tsx @@ -0,0 +1,90 @@ +"use client"; + +import { useState } from "react"; +import { useAuthStore } from "@/lib/providers/auth-store-provider"; +import { useUserIntegration } from "@/lib/api/user-integrations"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { IntegrationSettings } from "./integration-settings"; +import { RelationshipSettings } from "./relationship-settings"; +import { useCoachingRelationshipList } from "@/lib/api/coaching-relationships"; +import { useCurrentOrganization } from "@/lib/hooks/use-current-organization"; +import { isUserCoach } from "@/types/coaching-relationship"; + +export function SettingsContainer() { + const { userId } = useAuthStore((state) => ({ + userId: state.userId, + })); + const { currentOrganizationId } = useCurrentOrganization(); + const { integration, isLoading: integrationLoading, refresh: refreshIntegration } = useUserIntegration(userId); + const { relationships, isLoading: relationshipsLoading } = useCoachingRelationshipList(currentOrganizationId || ""); + const [activeTab, setActiveTab] = useState("integrations"); + + const isCoach = isUserCoach(userId, relationships); + const isLoading = integrationLoading || relationshipsLoading; + + if (isLoading) { + return ( +
+
+
+

Loading settings...

+
+
+ ); + } + + // If user is not a coach, show a message + if (!isCoach) { + return ( + + + Settings + Account settings and preferences + + +
+

+ Integration settings are only available for coaches. +

+

+ Contact your coach if you have questions about meeting recordings or transcriptions. +

+
+
+
+ ); + } + + return ( + + + Settings + + Manage your integrations and coaching relationship settings + + + + + + Integrations + Relationships + + + + + + r.coach_id === userId)} + /> + + + + + ); +} diff --git a/src/components/ui/user-nav.tsx b/src/components/ui/user-nav.tsx index 50a3db6d..d292a260 100644 --- a/src/components/ui/user-nav.tsx +++ b/src/components/ui/user-nav.tsx @@ -54,9 +54,11 @@ export function UserNav() { ⇧⌘P - - Settings - ⌘S + + + Settings + ⌘S + diff --git a/src/lib/api/coaching-relationships.ts b/src/lib/api/coaching-relationships.ts index f00e6849..be02f5a2 100644 --- a/src/lib/api/coaching-relationships.ts +++ b/src/lib/api/coaching-relationships.ts @@ -80,10 +80,20 @@ export const CoachingRelationshipApi = { }, /** - * Unimplemented + * Updates a coaching relationship. + * + * @param id The ID of the coaching relationship to update + * @param entity The updated coaching relationship data + * @returns Promise resolving to the updated CoachingRelationshipWithUserNames object */ - update: async (_id: Id, entity: NewCoachingRelationship) => { - throw new Error("Update operation not implemented"); + update: async ( + id: Id, + entity: Partial + ): Promise => { + return EntityApi.updateFn< + Partial, + CoachingRelationshipWithUserNames + >(`${siteConfig.env.backendServiceURL}/coaching_relationships/${id}`, entity); }, /** diff --git a/src/lib/api/user-integrations.ts b/src/lib/api/user-integrations.ts new file mode 100644 index 00000000..72706c86 --- /dev/null +++ b/src/lib/api/user-integrations.ts @@ -0,0 +1,124 @@ +// Interacts with the user integrations endpoints + +import { siteConfig } from "@/site.config"; +import { Id } from "@/types/general"; +import { EntityApi } from "./entity-api"; +import { + UserIntegration, + RecallAiIntegrationUpdate, + AssemblyAiIntegrationUpdate, + IntegrationVerifyResponse, + defaultUserIntegration, +} from "@/types/user-integration"; + +export const USER_INTEGRATIONS_BASEURL: string = `${siteConfig.env.backendServiceURL}/users`; + +/** + * API client for user integration operations. + */ +export const UserIntegrationApi = { + /** + * Fetches integration settings for a user. + */ + get: async (userId: Id): Promise => + EntityApi.getFn(`${USER_INTEGRATIONS_BASEURL}/${userId}/integrations`), + + /** + * Updates Recall.ai integration settings. + */ + updateRecallAi: async ( + userId: Id, + data: RecallAiIntegrationUpdate + ): Promise => + EntityApi.updateFn( + `${USER_INTEGRATIONS_BASEURL}/${userId}/integrations/recall-ai`, + data + ), + + /** + * Updates AssemblyAI integration settings. + */ + updateAssemblyAi: async ( + userId: Id, + data: AssemblyAiIntegrationUpdate + ): Promise => + EntityApi.updateFn( + `${USER_INTEGRATIONS_BASEURL}/${userId}/integrations/assembly-ai`, + data + ), + + /** + * Verifies a provider's API key. + */ + verifyProvider: async ( + userId: Id, + provider: "recall-ai" | "assembly-ai" + ): Promise => + EntityApi.createFn( + `${USER_INTEGRATIONS_BASEURL}/${userId}/integrations/verify/${provider}`, + null + ), + + /** + * Disconnects Google OAuth. + */ + disconnectGoogle: async (userId: Id): Promise => + EntityApi.deleteFn( + `${USER_INTEGRATIONS_BASEURL}/${userId}/integrations/google` + ), +}; + +/** + * Hook for fetching user integration settings. + */ +export const useUserIntegration = (userId: Id) => { + const url = userId ? `${USER_INTEGRATIONS_BASEURL}/${userId}/integrations` : null; + const fetcher = () => UserIntegrationApi.get(userId); + + const { entity, isLoading, isError, refresh } = EntityApi.useEntity( + url, + fetcher, + defaultUserIntegration() + ); + + return { + integration: entity, + isLoading, + isError, + refresh, + }; +}; + +/** + * Hook for user integration mutations. + * Provides methods to update integration settings. + */ +export const useUserIntegrationMutation = (userId: Id) => { + const updateRecallAi = async (data: RecallAiIntegrationUpdate) => { + return UserIntegrationApi.updateRecallAi(userId, data); + }; + + const updateAssemblyAi = async (data: AssemblyAiIntegrationUpdate) => { + return UserIntegrationApi.updateAssemblyAi(userId, data); + }; + + const verifyRecallAi = async () => { + return UserIntegrationApi.verifyProvider(userId, "recall-ai"); + }; + + const verifyAssemblyAi = async () => { + return UserIntegrationApi.verifyProvider(userId, "assembly-ai"); + }; + + const disconnectGoogle = async () => { + return UserIntegrationApi.disconnectGoogle(userId); + }; + + return { + updateRecallAi, + updateAssemblyAi, + verifyRecallAi, + verifyAssemblyAi, + disconnectGoogle, + }; +}; diff --git a/src/types/coaching-relationship.ts b/src/types/coaching-relationship.ts index 143d5e34..1be1b7dc 100644 --- a/src/types/coaching-relationship.ts +++ b/src/types/coaching-relationship.ts @@ -2,11 +2,28 @@ import { DateTime } from "ts-luxon"; import { Id } from "@/types/general"; import { User } from "@/types/user"; +/** + * AI privacy level for coaching relationships. + * Controls what AI features are enabled for the relationship. + */ +export enum AiPrivacyLevel { + /** No AI recording or transcribing integration */ + None = "none", + /** Text transcription only, no video/audio storage */ + TranscribeOnly = "transcribe_only", + /** All AI recording and transcribing features enabled */ + Full = "full", +} + export interface CoachingRelationship { id: Id; coach_id: Id; coachee_id: Id; organization_id: Id; + /** Google Meet URL for this coaching relationship */ + meeting_url: string | null; + /** AI privacy level for this coaching relationship */ + ai_privacy_level: AiPrivacyLevel; created_at: DateTime; updated_at: DateTime; } @@ -57,7 +74,10 @@ export function isCoachingRelationshipWithUserNames( typeof object.coachee_first_name === "string" && typeof object.coachee_last_name === "string" && typeof object.created_at === "string" && - typeof object.updated_at === "string" + typeof object.updated_at === "string" && + // New fields: meeting_url can be null or string, ai_privacy_level is required + (object.meeting_url === null || typeof object.meeting_url === "string") && + typeof object.ai_privacy_level === "string" ); } @@ -82,7 +102,7 @@ export function getCoachingRelationshipById( } export function defaultCoachingRelationshipWithUserNames(): CoachingRelationshipWithUserNames { - var now = DateTime.now(); + const now = DateTime.now(); return { id: "", coach_id: "", @@ -92,6 +112,8 @@ export function defaultCoachingRelationshipWithUserNames(): CoachingRelationship organization_id: "", coachee_first_name: "", coachee_last_name: "", + meeting_url: null, + ai_privacy_level: AiPrivacyLevel.Full, created_at: now, updated_at: now, }; diff --git a/src/types/meeting-recording.ts b/src/types/meeting-recording.ts new file mode 100644 index 00000000..25af4171 --- /dev/null +++ b/src/types/meeting-recording.ts @@ -0,0 +1,239 @@ +import { DateTime } from "ts-luxon"; +import { Id } from "@/types/general"; + +/** + * Status of a meeting recording in the Recall.ai pipeline. + */ +export enum RecordingStatus { + Pending = "pending", + Joining = "joining", + Recording = "recording", + Processing = "processing", + Completed = "completed", + Failed = "failed", +} + +/** + * Status of a transcription in the AssemblyAI pipeline. + */ +export enum TranscriptionStatus { + Pending = "pending", + Processing = "processing", + Completed = "completed", + Failed = "failed", +} + +/** + * Sentiment analysis result for a transcript segment. + */ +export enum Sentiment { + Positive = "positive", + Neutral = "neutral", + Negative = "negative", +} + +/** + * Type of AI-suggested item. + */ +export enum AiSuggestionType { + Action = "action", + Agreement = "agreement", +} + +/** + * Status of an AI-suggested item. + */ +export enum AiSuggestionStatus { + Pending = "pending", + Accepted = "accepted", + Dismissed = "dismissed", +} + +/** + * Meeting recording entity. + * Tracks Recall.ai bot recording sessions. + */ +export interface MeetingRecording { + id: Id; + coaching_session_id: Id; + recall_bot_id: string | null; + status: RecordingStatus; + recording_url: string | null; + duration_seconds: number | null; + started_at: string | null; + ended_at: string | null; + error_message: string | null; + created_at: DateTime; + updated_at: DateTime; +} + +/** + * Transcription entity. + * Stores AssemblyAI transcript data. + */ +export interface Transcription { + id: Id; + meeting_recording_id: Id; + assemblyai_transcript_id: string | null; + status: TranscriptionStatus; + full_text: string | null; + summary: string | null; + confidence_score: number | null; + word_count: number | null; + language_code: string; + error_message: string | null; + created_at: DateTime; + updated_at: DateTime; +} + +/** + * Transcript segment (utterance with speaker diarization). + */ +export interface TranscriptSegment { + id: Id; + transcription_id: Id; + speaker_label: string; + speaker_user_id: Id | null; + text: string; + start_time_ms: number; + end_time_ms: number; + confidence: number | null; + sentiment: Sentiment | null; + created_at: DateTime; +} + +/** + * AI-suggested action item or agreement. + * Pending user approval before becoming an official entity. + */ +export interface AiSuggestedItem { + id: Id; + transcription_id: Id; + item_type: AiSuggestionType; + content: string; + source_text: string | null; + confidence: number | null; + status: AiSuggestionStatus; + accepted_entity_id: Id | null; + created_at: DateTime; + updated_at: DateTime; +} + +/** + * Type guard for MeetingRecording. + */ +export function isMeetingRecording(value: unknown): value is MeetingRecording { + if (!value || typeof value !== "object") { + return false; + } + const object = value as Record; + + return ( + typeof object.id === "string" && + typeof object.coaching_session_id === "string" && + typeof object.status === "string" + ); +} + +/** + * Type guard for Transcription. + */ +export function isTranscription(value: unknown): value is Transcription { + if (!value || typeof value !== "object") { + return false; + } + const object = value as Record; + + return ( + typeof object.id === "string" && + typeof object.meeting_recording_id === "string" && + typeof object.status === "string" + ); +} + +/** + * Type guard for AiSuggestedItem. + */ +export function isAiSuggestedItem(value: unknown): value is AiSuggestedItem { + if (!value || typeof value !== "object") { + return false; + } + const object = value as Record; + + return ( + typeof object.id === "string" && + typeof object.transcription_id === "string" && + typeof object.item_type === "string" && + typeof object.content === "string" && + typeof object.status === "string" + ); +} + +/** + * Returns a default empty MeetingRecording. + */ +export function defaultMeetingRecording(): MeetingRecording { + const now = DateTime.now(); + return { + id: "", + coaching_session_id: "", + recall_bot_id: null, + status: RecordingStatus.Pending, + recording_url: null, + duration_seconds: null, + started_at: null, + ended_at: null, + error_message: null, + created_at: now, + updated_at: now, + }; +} + +/** + * Returns a default empty Transcription. + */ +export function defaultTranscription(): Transcription { + const now = DateTime.now(); + return { + id: "", + meeting_recording_id: "", + assemblyai_transcript_id: null, + status: TranscriptionStatus.Pending, + full_text: null, + summary: null, + confidence_score: null, + word_count: null, + language_code: "en", + error_message: null, + created_at: now, + updated_at: now, + }; +} + +/** + * Formats duration in seconds to a human-readable string (MM:SS or HH:MM:SS). + */ +export function formatDuration(seconds: number | null): string { + if (seconds === null || seconds < 0) { + return "00:00"; + } + + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const secs = seconds % 60; + + if (hours > 0) { + return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`; + } + return `${minutes.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`; +} + +/** + * Formats milliseconds to a timestamp string (MM:SS). + */ +export function formatTimestamp(ms: number): string { + const totalSeconds = Math.floor(ms / 1000); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + return `${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`; +} diff --git a/src/types/user-integration.ts b/src/types/user-integration.ts new file mode 100644 index 00000000..20c8ca14 --- /dev/null +++ b/src/types/user-integration.ts @@ -0,0 +1,86 @@ +import { DateTime } from "ts-luxon"; +import { Id } from "@/types/general"; + +/** + * User integration settings for external services. + * Contains connection status for Google, Recall.ai, and AssemblyAI. + */ +export interface UserIntegration { + id: Id; + user_id: Id; + /** Whether Google OAuth is connected */ + google_connected: boolean; + /** Connected Google email address */ + google_email: string | null; + /** Whether Recall.ai API key is configured */ + recall_ai_configured: boolean; + /** When Recall.ai was last verified */ + recall_ai_verified_at: string | null; + /** Whether AssemblyAI API key is configured */ + assembly_ai_configured: boolean; + /** When AssemblyAI was last verified */ + assembly_ai_verified_at: string | null; + created_at: DateTime; + updated_at: DateTime; +} + +/** + * Payload for updating Recall.ai integration. + */ +export interface RecallAiIntegrationUpdate { + api_key: string; + region?: string; +} + +/** + * Payload for updating AssemblyAI integration. + */ +export interface AssemblyAiIntegrationUpdate { + api_key: string; +} + +/** + * Response from API key verification endpoint. + */ +export interface IntegrationVerifyResponse { + success: boolean; + message: string; + verified_at?: string; +} + +/** + * Type guard for UserIntegration. + */ +export function isUserIntegration(value: unknown): value is UserIntegration { + if (!value || typeof value !== "object") { + return false; + } + const object = value as Record; + + return ( + typeof object.id === "string" && + typeof object.user_id === "string" && + typeof object.google_connected === "boolean" && + typeof object.recall_ai_configured === "boolean" && + typeof object.assembly_ai_configured === "boolean" + ); +} + +/** + * Returns a default empty UserIntegration. + */ +export function defaultUserIntegration(): UserIntegration { + const now = DateTime.now(); + return { + id: "", + user_id: "", + google_connected: false, + google_email: null, + recall_ai_configured: false, + recall_ai_verified_at: null, + assembly_ai_configured: false, + assembly_ai_verified_at: null, + created_at: now, + updated_at: now, + }; +} From 1746499d235ebcf8930ab24acbc5f840013eff76 Mon Sep 17 00:00:00 2001 From: Jim Hodapp Date: Sun, 21 Dec 2025 10:26:54 -0600 Subject: [PATCH 02/17] feat: Add AI meeting integration frontend components (Phase 6) Add frontend components for the AI meeting recording and transcription feature: API Modules: - meeting-recordings.ts: Recording start/stop, transcript fetching with polling - ai-suggestions.ts: Accept/dismiss AI-detected actions and agreements Components: - meeting-controls.tsx: Join Meet button + Start/Stop recording controls - session-transcript.tsx: Displays transcript with segments and AI suggestions - transcript-segment.tsx: Single utterance with speaker label and sentiment - session-summary.tsx: AI-generated session summary with empty state - ai-suggestions-panel.tsx: Groups AI-detected actions/agreements - ai-suggestion-card.tsx: Accept/dismiss card for individual suggestions Integrations: - Added Summary tab to coaching-tabs-container (4th tab after Actions) - Added Transcript tab to overarching-goal-container with green dot indicator - Added MeetingControls to coaching session page header Relates to #146 --- src/app/coaching-sessions/[id]/page.tsx | 4 +- .../coaching-sessions/ai-suggestion-card.tsx | 139 +++++++++ .../ai-suggestions-panel.tsx | 79 ++++++ .../coaching-tabs-container.tsx | 8 +- .../ui/coaching-sessions/meeting-controls.tsx | 264 ++++++++++++++++++ .../overarching-goal-container.tsx | 22 +- .../ui/coaching-sessions/session-summary.tsx | 66 +++++ .../coaching-sessions/session-transcript.tsx | 129 +++++++++ .../coaching-sessions/transcript-segment.tsx | 62 ++++ src/lib/api/ai-suggestions.ts | 96 +++++++ src/lib/api/meeting-recordings.ts | 191 +++++++++++++ 11 files changed, 1055 insertions(+), 5 deletions(-) create mode 100644 src/components/ui/coaching-sessions/ai-suggestion-card.tsx create mode 100644 src/components/ui/coaching-sessions/ai-suggestions-panel.tsx create mode 100644 src/components/ui/coaching-sessions/meeting-controls.tsx create mode 100644 src/components/ui/coaching-sessions/session-summary.tsx create mode 100644 src/components/ui/coaching-sessions/session-transcript.tsx create mode 100644 src/components/ui/coaching-sessions/transcript-segment.tsx create mode 100644 src/lib/api/ai-suggestions.ts create mode 100644 src/lib/api/meeting-recordings.ts diff --git a/src/app/coaching-sessions/[id]/page.tsx b/src/app/coaching-sessions/[id]/page.tsx index bc77b5d8..6a774e8b 100644 --- a/src/app/coaching-sessions/[id]/page.tsx +++ b/src/app/coaching-sessions/[id]/page.tsx @@ -16,6 +16,7 @@ import { useRouter, useParams, useSearchParams } from "next/navigation"; import { useCurrentCoachingRelationship } from "@/lib/hooks/use-current-coaching-relationship"; import { useCurrentCoachingSession } from "@/lib/hooks/use-current-coaching-session"; import ShareSessionLink from "@/components/ui/share-session-link"; +import { MeetingControls } from "@/components/ui/coaching-sessions/meeting-controls"; import { toast } from "sonner"; import { ForbiddenError } from "@/components/ui/errors/forbidden-error"; import { EntityApiError } from "@/types/general"; @@ -122,7 +123,8 @@ export default function CoachingSessionsPage() { locale={siteConfig.locale} style={siteConfig.titleStyle} /> -
+
+ void; + className?: string; +} + +/** + * Renders a single AI suggestion with accept/dismiss actions. + * When accepted, creates the corresponding Action or Agreement. + */ +export function AiSuggestionCard({ + suggestion, + onAction, + className, +}: AiSuggestionCardProps) { + const [isAccepting, setIsAccepting] = useState(false); + const [isDismissing, setIsDismissing] = useState(false); + const { accept, dismiss } = useAiSuggestionMutation(); + + const isAction = suggestion.item_type === AiSuggestionType.Action; + const Icon = isAction ? Target : Handshake; + const typeLabel = isAction ? "Action" : "Agreement"; + + const handleAccept = async () => { + setIsAccepting(true); + try { + const result = await accept(suggestion.id); + toast.success(`${typeLabel} added successfully`, { + description: `Created new ${result.entity_type} from AI suggestion.`, + }); + onAction?.(); + } catch (error) { + toast.error(`Failed to add ${typeLabel.toLowerCase()}`, { + description: error instanceof Error ? error.message : "Please try again.", + }); + } finally { + setIsAccepting(false); + } + }; + + const handleDismiss = async () => { + setIsDismissing(true); + try { + await dismiss(suggestion.id); + toast.info("Suggestion dismissed"); + onAction?.(); + } catch (error) { + toast.error("Failed to dismiss suggestion", { + description: error instanceof Error ? error.message : "Please try again.", + }); + } finally { + setIsDismissing(false); + } + }; + + const isLoading = isAccepting || isDismissing; + + return ( + + +
+ {/* Icon */} +
+ +
+ + {/* Content */} +
+
+ + {typeLabel} + + {suggestion.confidence && ( + + {Math.round(suggestion.confidence * 100)}% confident + + )} +
+

{suggestion.content}

+ {suggestion.source_text && ( +

+ “{suggestion.source_text}” +

+ )} +
+ + {/* Actions */} +
+ + +
+
+
+
+ ); +} diff --git a/src/components/ui/coaching-sessions/ai-suggestions-panel.tsx b/src/components/ui/coaching-sessions/ai-suggestions-panel.tsx new file mode 100644 index 00000000..7c366640 --- /dev/null +++ b/src/components/ui/coaching-sessions/ai-suggestions-panel.tsx @@ -0,0 +1,79 @@ +"use client"; + +import { Bot } from "lucide-react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { AiSuggestedItem, AiSuggestionType } from "@/types/meeting-recording"; +import { AiSuggestionCard } from "./ai-suggestion-card"; + +interface AiSuggestionsPanelProps { + suggestions: AiSuggestedItem[]; + onSuggestionAction?: () => void; +} + +/** + * Panel displaying AI-detected actions and agreements. + * Groups suggestions by type and provides accept/dismiss actions. + */ +export function AiSuggestionsPanel({ + suggestions, + onSuggestionAction, +}: AiSuggestionsPanelProps) { + // Group suggestions by type + const actions = suggestions.filter((s) => s.item_type === AiSuggestionType.Action); + const agreements = suggestions.filter((s) => s.item_type === AiSuggestionType.Agreement); + + if (suggestions.length === 0) { + return null; + } + + return ( + + + + + AI-Detected Items + + ({suggestions.length} suggestion{suggestions.length !== 1 ? "s" : ""}) + + + + + {/* Action Items */} + {actions.length > 0 && ( +
+

+ Action Items ({actions.length}) +

+
+ {actions.map((suggestion) => ( + + ))} +
+
+ )} + + {/* Agreements */} + {agreements.length > 0 && ( +
+

+ Agreements ({agreements.length}) +

+
+ {agreements.map((suggestion) => ( + + ))} +
+
+ )} +
+
+ ); +} diff --git a/src/components/ui/coaching-sessions/coaching-tabs-container.tsx b/src/components/ui/coaching-sessions/coaching-tabs-container.tsx index 2795e8cd..65df6dd0 100644 --- a/src/components/ui/coaching-sessions/coaching-tabs-container.tsx +++ b/src/components/ui/coaching-sessions/coaching-tabs-container.tsx @@ -5,6 +5,7 @@ import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { CoachingNotes } from "@/components/ui/coaching-sessions/coaching-notes"; import { AgreementsList } from "@/components/ui/coaching-sessions/agreements-list"; import { ActionsList } from "@/components/ui/coaching-sessions/actions-list"; +import { SessionSummary } from "@/components/ui/coaching-sessions/session-summary"; import { useAgreementMutation } from "@/lib/api/agreements"; import { useActionMutation } from "@/lib/api/actions"; import { ItemStatus, Id } from "@/types/general"; @@ -110,10 +111,11 @@ const CoachingTabsContainer: React.FC<{
- + Notes Agreements Actions + Summary @@ -147,6 +149,10 @@ const CoachingTabsContainer: React.FC<{ onActionDeleted={handleActionDeleted} />
+ +
+ +
diff --git a/src/components/ui/coaching-sessions/meeting-controls.tsx b/src/components/ui/coaching-sessions/meeting-controls.tsx new file mode 100644 index 00000000..96ed5045 --- /dev/null +++ b/src/components/ui/coaching-sessions/meeting-controls.tsx @@ -0,0 +1,264 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { + Video, + VideoOff, + Circle, + Square, + ExternalLink, + Lock, + Settings, + Loader2, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { cn } from "@/components/lib/utils"; +import { Id } from "@/types/general"; +import { AiPrivacyLevel } from "@/types/coaching-relationship"; +import { RecordingStatus, formatDuration } from "@/types/meeting-recording"; +import { useMeetingRecording, useMeetingRecordingMutation } from "@/lib/api/meeting-recordings"; +import { useCurrentCoachingRelationship } from "@/lib/hooks/use-current-coaching-relationship"; +import { useCurrentRelationshipRole } from "@/lib/hooks/use-current-relationship-role"; +import { toast } from "sonner"; +import Link from "next/link"; + +interface MeetingControlsProps { + sessionId: Id; + className?: string; +} + +/** + * Meeting controls for joining Google Meet and managing recording. + * Shows different states based on recording status and user role. + */ +export function MeetingControls({ sessionId, className }: MeetingControlsProps) { + const [isStarting, setIsStarting] = useState(false); + const [isStopping, setIsStopping] = useState(false); + const [elapsedSeconds, setElapsedSeconds] = useState(0); + + const { recording, isLoading: recordingLoading } = useMeetingRecording(sessionId); + const { startRecording, stopRecording } = useMeetingRecordingMutation(sessionId); + const { currentCoachingRelationship } = useCurrentCoachingRelationship(); + const { isCoachInCurrentRelationship } = useCurrentRelationshipRole(); + + const meetingUrl = currentCoachingRelationship?.meeting_url; + const privacyLevel = currentCoachingRelationship?.ai_privacy_level ?? AiPrivacyLevel.Full; + + // Timer for recording duration + useEffect(() => { + let interval: NodeJS.Timeout; + + if (recording?.status === RecordingStatus.Recording && recording.started_at) { + const startTime = new Date(recording.started_at).getTime(); + + const updateElapsed = () => { + const now = Date.now(); + setElapsedSeconds(Math.floor((now - startTime) / 1000)); + }; + + updateElapsed(); + interval = setInterval(updateElapsed, 1000); + } else { + setElapsedSeconds(0); + } + + return () => { + if (interval) clearInterval(interval); + }; + }, [recording?.status, recording?.started_at]); + + const handleStartRecording = async () => { + setIsStarting(true); + try { + await startRecording(); + toast.success("Recording started", { + description: "The meeting bot is joining your call.", + }); + } catch (error) { + toast.error("Failed to start recording", { + description: error instanceof Error ? error.message : "Please try again.", + }); + } finally { + setIsStarting(false); + } + }; + + const handleStopRecording = async () => { + setIsStopping(true); + try { + await stopRecording(); + toast.success("Recording stopped", { + description: "Your transcript will be available shortly.", + }); + } catch (error) { + toast.error("Failed to stop recording", { + description: error instanceof Error ? error.message : "Please try again.", + }); + } finally { + setIsStopping(false); + } + }; + + const isRecordingActive = recording?.status === RecordingStatus.Recording; + const isJoining = recording?.status === RecordingStatus.Joining; + const isProcessing = recording?.status === RecordingStatus.Processing; + const isCompleted = recording?.status === RecordingStatus.Completed; + const isFailed = recording?.status === RecordingStatus.Failed; + const aiDisabled = privacyLevel === AiPrivacyLevel.None; + + // State A: No meeting URL configured + if (!meetingUrl) { + return ( + +
+ + +
+ + No meeting link +
+
+ +

Configure a Google Meet URL in Settings

+
+
+ {isCoachInCurrentRelationship && ( + + )} +
+
+ ); + } + + // State B: AI features disabled + if (aiDisabled) { + return ( + +
+ +
+ + AI disabled +
+
+
+ ); + } + + return ( + +
+ {/* Join Meet button */} + + + {/* Recording status and controls */} + {recordingLoading ? ( + + ) : isJoining ? ( +
+ + + Bot joining... + +
+ ) : isRecordingActive ? ( +
+ + + Recording {formatDuration(elapsedSeconds)} + + {isCoachInCurrentRelationship && ( + + )} +
+ ) : isProcessing ? ( + + + Processing... + + ) : isCompleted ? ( + + ✓ Recorded ({formatDuration(recording?.duration_seconds ?? 0)}) + + ) : isFailed ? ( + + + Recording failed + + +

{recording?.error_message || "An error occurred"}

+
+
+ ) : isCoachInCurrentRelationship ? ( + + ) : null} + + {/* Privacy level indicator for coach */} + {isCoachInCurrentRelationship && !isRecordingActive && !isJoining && !isProcessing && !isCompleted && ( + + + + {privacyLevel === AiPrivacyLevel.TranscribeOnly ? "Transcript only" : "Full recording"} + + + +

+ {privacyLevel === AiPrivacyLevel.TranscribeOnly + ? "Only transcript will be generated, no video storage" + : "Full video recording with transcript"} +

+
+
+ )} +
+
+ ); +} diff --git a/src/components/ui/coaching-sessions/overarching-goal-container.tsx b/src/components/ui/coaching-sessions/overarching-goal-container.tsx index 3ae65494..a4cedc9d 100644 --- a/src/components/ui/coaching-sessions/overarching-goal-container.tsx +++ b/src/components/ui/coaching-sessions/overarching-goal-container.tsx @@ -15,17 +15,24 @@ import { overarchingGoalToString, } from "@/types/overarching-goal"; import { useCurrentCoachingSession } from "@/lib/hooks/use-current-coaching-session"; +import { SessionTranscript } from "./session-transcript"; +import { useTranscript } from "@/lib/api/meeting-recordings"; +import { TranscriptionStatus } from "@/types/meeting-recording"; const OverarchingGoalContainer: React.FC<{ userId: Id; }> = ({ userId }) => { const [isOpen, setIsOpen] = useState(false); - + // Get coaching session ID from URL const { currentCoachingSessionId } = useCurrentCoachingSession(); - + const { overarchingGoal, isLoading, isError, refresh } = useOverarchingGoalBySession(currentCoachingSessionId || ""); + + // Get transcript to check if one exists + const { transcript } = useTranscript(currentCoachingSessionId || ""); + const hasTranscript = transcript && transcript.status === TranscriptionStatus.Completed; const { create: createOverarchingGoal, update: updateOverarchingGoal } = useOverarchingGoalMutation(); @@ -80,8 +87,14 @@ const OverarchingGoalContainer: React.FC<{
- + Sub Goals + + Transcript + {hasTranscript && ( + + )} +
@@ -92,6 +105,9 @@ const OverarchingGoalContainer: React.FC<{
+ + + diff --git a/src/components/ui/coaching-sessions/session-summary.tsx b/src/components/ui/coaching-sessions/session-summary.tsx new file mode 100644 index 00000000..0d38b238 --- /dev/null +++ b/src/components/ui/coaching-sessions/session-summary.tsx @@ -0,0 +1,66 @@ +"use client"; + +import { FileText, Loader2 } from "lucide-react"; +import { useTranscript } from "@/lib/api/meeting-recordings"; +import { Id } from "@/types/general"; +import { TranscriptionStatus } from "@/types/meeting-recording"; +import { ScrollArea } from "@/components/ui/scroll-area"; + +interface SessionSummaryProps { + coachingSessionId: Id; +} + +/** + * Displays the AI-generated summary for a coaching session. + * Shows empty state when no summary is available. + */ +export function SessionSummary({ coachingSessionId }: SessionSummaryProps) { + const { transcript, isLoading } = useTranscript(coachingSessionId); + + // Loading state + if (isLoading) { + return ( +
+ +
+ ); + } + + // No transcript or no summary + if (!transcript || !transcript.summary) { + return ( +
+
+ +

+ No summary available yet. +
+ Record a session to generate an AI summary. +

+
+
+ ); + } + + // Transcript is still processing + if (transcript.status === TranscriptionStatus.Processing) { + return ( +
+
+ +

Generating summary...

+
+
+ ); + } + + // Display summary + return ( + +
+

Session Summary

+
{transcript.summary}
+
+
+ ); +} diff --git a/src/components/ui/coaching-sessions/session-transcript.tsx b/src/components/ui/coaching-sessions/session-transcript.tsx new file mode 100644 index 00000000..cdb8f1b1 --- /dev/null +++ b/src/components/ui/coaching-sessions/session-transcript.tsx @@ -0,0 +1,129 @@ +"use client"; + +import { FileText, Loader2 } from "lucide-react"; +import { useTranscript, useTranscriptSegments } from "@/lib/api/meeting-recordings"; +import { useAiSuggestions } from "@/lib/api/ai-suggestions"; +import { Id } from "@/types/general"; +import { TranscriptionStatus } from "@/types/meeting-recording"; +import { TranscriptSegment } from "./transcript-segment"; +import { AiSuggestionsPanel } from "./ai-suggestions-panel"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Badge } from "@/components/ui/badge"; + +interface SessionTranscriptProps { + sessionId: Id; +} + +/** + * Displays the transcript for a coaching session. + * Shows transcript segments with speaker diarization and AI suggestions panel. + */ +export function SessionTranscript({ sessionId }: SessionTranscriptProps) { + const { transcript, isLoading: transcriptLoading } = useTranscript(sessionId); + const { segments, isLoading: segmentsLoading } = useTranscriptSegments(sessionId); + const { suggestions, refresh: refreshSuggestions } = useAiSuggestions(sessionId); + + const isLoading = transcriptLoading || segmentsLoading; + + // No transcript yet + if (!isLoading && !transcript) { + return ( +
+
+ +

+ No transcript available yet. +
+ Record a session to generate a transcript. +

+
+
+ ); + } + + // Loading state + if (isLoading) { + return ( +
+ +
+ ); + } + + // Transcript is processing + if (transcript?.status === TranscriptionStatus.Processing) { + return ( +
+
+ +

Transcription in progress...

+ Processing audio +
+
+ ); + } + + // Transcript failed + if (transcript?.status === TranscriptionStatus.Failed) { + return ( +
+
+ +

Transcription failed

+ {transcript.error_message && ( +

{transcript.error_message}

+ )} +
+
+ ); + } + + // Filter pending suggestions + const pendingSuggestions = suggestions.filter((s) => s.status === "pending"); + + return ( +
+ {/* Metadata bar */} +
+ {transcript?.word_count && ( + {transcript.word_count.toLocaleString()} words + )} + {transcript?.confidence_score && ( + {Math.round(transcript.confidence_score * 100)}% confidence + )} + {segments.length > 0 && ( + {segments.length} segments + )} +
+ + {/* AI Suggestions Panel (if any pending) */} + {pendingSuggestions.length > 0 && ( + + )} + + {/* Transcript content */} + {segments.length > 0 ? ( + +
+ {segments.map((segment) => ( + + ))} +
+
+ ) : transcript?.full_text ? ( + +
+

{transcript.full_text}

+
+
+ ) : ( +
+ No transcript content available. +
+ )} +
+ ); +} diff --git a/src/components/ui/coaching-sessions/transcript-segment.tsx b/src/components/ui/coaching-sessions/transcript-segment.tsx new file mode 100644 index 00000000..dbd01deb --- /dev/null +++ b/src/components/ui/coaching-sessions/transcript-segment.tsx @@ -0,0 +1,62 @@ +"use client"; + +import { cn } from "@/components/lib/utils"; +import { TranscriptSegment as TranscriptSegmentType, Sentiment, formatTimestamp } from "@/types/meeting-recording"; +import { Badge } from "@/components/ui/badge"; + +interface TranscriptSegmentProps { + segment: TranscriptSegmentType; + className?: string; +} + +/** + * Renders a single transcript segment with speaker label, timestamp, and text. + * Optionally shows sentiment indicator. + */ +export function TranscriptSegment({ segment, className }: TranscriptSegmentProps) { + const getSentimentColor = (sentiment: Sentiment | null) => { + switch (sentiment) { + case Sentiment.Positive: + return "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200"; + case Sentiment.Negative: + return "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200"; + case Sentiment.Neutral: + default: + return "bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-200"; + } + }; + + return ( +
+ {/* Timestamp column */} +
+ {formatTimestamp(segment.start_time_ms)} +
+ + {/* Content column */} +
+ {/* Speaker label and sentiment */} +
+ + {segment.speaker_label} + + {segment.sentiment && ( + + {segment.sentiment} + + )} + {segment.confidence !== null && segment.confidence < 0.8 && ( + + ({Math.round(segment.confidence * 100)}% confidence) + + )} +
+ + {/* Text content */} +

+ {segment.text} +

+
+
+ ); +} diff --git a/src/lib/api/ai-suggestions.ts b/src/lib/api/ai-suggestions.ts new file mode 100644 index 00000000..d044b877 --- /dev/null +++ b/src/lib/api/ai-suggestions.ts @@ -0,0 +1,96 @@ +// Interacts with the AI suggestions endpoints + +import { siteConfig } from "@/site.config"; +import { Id } from "@/types/general"; +import { EntityApi } from "./entity-api"; +import { AiSuggestedItem } from "@/types/meeting-recording"; + +export const COACHING_SESSIONS_BASEURL: string = `${siteConfig.env.backendServiceURL}/coaching_sessions`; +export const AI_SUGGESTIONS_BASEURL: string = `${siteConfig.env.backendServiceURL}/ai-suggestions`; + +/** + * Response from accepting a suggestion. + */ +export interface AcceptSuggestionResponse { + suggestion: AiSuggestedItem; + entity_id: Id; + entity_type: "action" | "agreement"; +} + +/** + * API client for AI suggestion operations. + */ +export const AiSuggestionApi = { + /** + * Fetches pending AI suggestions for a coaching session. + */ + getBySession: async (sessionId: Id): Promise => { + try { + return await EntityApi.getFn( + `${COACHING_SESSIONS_BASEURL}/${sessionId}/ai-suggestions` + ); + } catch { + // Return empty array if no suggestions exist + return []; + } + }, + + /** + * Accepts an AI suggestion and creates the corresponding entity. + */ + accept: async (suggestionId: Id): Promise => + EntityApi.createFn( + `${AI_SUGGESTIONS_BASEURL}/${suggestionId}/accept`, + null + ), + + /** + * Dismisses an AI suggestion. + */ + dismiss: async (suggestionId: Id): Promise => + EntityApi.createFn( + `${AI_SUGGESTIONS_BASEURL}/${suggestionId}/dismiss`, + null + ), +}; + +/** + * Hook for fetching AI suggestions for a session. + */ +export const useAiSuggestions = (sessionId: Id) => { + const url = sessionId + ? `${COACHING_SESSIONS_BASEURL}/${sessionId}/ai-suggestions` + : null; + const fetcher = () => AiSuggestionApi.getBySession(sessionId); + + const { entity, isLoading, isError, refresh } = EntityApi.useEntity< + AiSuggestedItem[] + >(url, fetcher, []); + + return { + suggestions: entity, + isLoading, + isError, + refresh, + }; +}; + +/** + * Hook for AI suggestion mutations (accept/dismiss). + */ +export const useAiSuggestionMutation = () => { + const accept = async ( + suggestionId: Id + ): Promise => { + return AiSuggestionApi.accept(suggestionId); + }; + + const dismiss = async (suggestionId: Id): Promise => { + return AiSuggestionApi.dismiss(suggestionId); + }; + + return { + accept, + dismiss, + }; +}; diff --git a/src/lib/api/meeting-recordings.ts b/src/lib/api/meeting-recordings.ts new file mode 100644 index 00000000..16368cc9 --- /dev/null +++ b/src/lib/api/meeting-recordings.ts @@ -0,0 +1,191 @@ +// Interacts with the meeting recordings and transcription endpoints + +import { siteConfig } from "@/site.config"; +import { Id } from "@/types/general"; +import { EntityApi } from "./entity-api"; +import { + MeetingRecording, + Transcription, + TranscriptSegment, + defaultMeetingRecording, + defaultTranscription, +} from "@/types/meeting-recording"; + +export const COACHING_SESSIONS_BASEURL: string = `${siteConfig.env.backendServiceURL}/coaching_sessions`; + +/** + * Response from starting a recording. + */ +export interface StartRecordingResponse { + recording: MeetingRecording; + message: string; +} + +/** + * Response from stopping a recording. + */ +export interface StopRecordingResponse { + recording: MeetingRecording; + message: string; +} + +/** + * API client for meeting recording operations. + */ +export const MeetingRecordingApi = { + /** + * Fetches the current recording for a coaching session. + */ + get: async (sessionId: Id): Promise => { + try { + return await EntityApi.getFn( + `${COACHING_SESSIONS_BASEURL}/${sessionId}/recording` + ); + } catch { + // Return null if no recording exists (404) + return null; + } + }, + + /** + * Starts a new recording for a coaching session. + */ + start: async (sessionId: Id): Promise => + EntityApi.createFn( + `${COACHING_SESSIONS_BASEURL}/${sessionId}/recording/start`, + null + ), + + /** + * Stops the current recording for a coaching session. + */ + stop: async (sessionId: Id): Promise => + EntityApi.createFn( + `${COACHING_SESSIONS_BASEURL}/${sessionId}/recording/stop`, + null + ), + + /** + * Fetches the transcript for a coaching session. + */ + getTranscript: async (sessionId: Id): Promise => { + try { + return await EntityApi.getFn( + `${COACHING_SESSIONS_BASEURL}/${sessionId}/transcript` + ); + } catch { + // Return null if no transcript exists (404) + return null; + } + }, + + /** + * Fetches the transcript segments for a coaching session. + */ + getTranscriptSegments: async (sessionId: Id): Promise => { + try { + return await EntityApi.getFn( + `${COACHING_SESSIONS_BASEURL}/${sessionId}/transcript/segments` + ); + } catch { + // Return empty array if no segments exist + return []; + } + }, + + /** + * Fetches the session summary. + */ + getSummary: async (sessionId: Id): Promise => { + try { + const transcript = await EntityApi.getFn( + `${COACHING_SESSIONS_BASEURL}/${sessionId}/transcript` + ); + return transcript?.summary ?? null; + } catch { + return null; + } + }, +}; + +/** + * Hook for fetching the current recording for a session. + */ +export const useMeetingRecording = (sessionId: Id) => { + const url = sessionId + ? `${COACHING_SESSIONS_BASEURL}/${sessionId}/recording` + : null; + const fetcher = () => MeetingRecordingApi.get(sessionId); + + const { entity, isLoading, isError, refresh } = + EntityApi.useEntity(url, fetcher, null, { + refreshInterval: 5000, // Poll every 5 seconds for status updates + revalidateOnFocus: true, + }); + + return { + recording: entity, + isLoading, + isError, + refresh, + }; +}; + +/** + * Hook for fetching the transcript for a session. + */ +export const useTranscript = (sessionId: Id) => { + const url = sessionId + ? `${COACHING_SESSIONS_BASEURL}/${sessionId}/transcript` + : null; + const fetcher = () => MeetingRecordingApi.getTranscript(sessionId); + + const { entity, isLoading, isError, refresh } = + EntityApi.useEntity(url, fetcher, null); + + return { + transcript: entity, + isLoading, + isError, + refresh, + }; +}; + +/** + * Hook for fetching transcript segments for a session. + */ +export const useTranscriptSegments = (sessionId: Id) => { + const url = sessionId + ? `${COACHING_SESSIONS_BASEURL}/${sessionId}/transcript/segments` + : null; + const fetcher = () => MeetingRecordingApi.getTranscriptSegments(sessionId); + + const { entity, isLoading, isError, refresh } = EntityApi.useEntity< + TranscriptSegment[] + >(url, fetcher, []); + + return { + segments: entity, + isLoading, + isError, + refresh, + }; +}; + +/** + * Hook for meeting recording mutations. + */ +export const useMeetingRecordingMutation = (sessionId: Id) => { + const startRecording = async (): Promise => { + return MeetingRecordingApi.start(sessionId); + }; + + const stopRecording = async (): Promise => { + return MeetingRecordingApi.stop(sessionId); + }; + + return { + startRecording, + stopRecording, + }; +}; From 0ee30ca4dff86859b8ad0da883e390d4c5f1637f Mon Sep 17 00:00:00 2001 From: Jim Hodapp Date: Sun, 21 Dec 2025 22:17:15 -0600 Subject: [PATCH 03/17] Fix: Improve AI meeting integration reliability and UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add polling to transcript hooks for automatic updates (5s interval) - Fix empty POST body type (null → {} for TypeScript compatibility) - Unify user integrations API to single endpoint - Simplify meeting controls header layout --- src/app/coaching-sessions/[id]/page.tsx | 2 +- .../ui/coaching-sessions/meeting-controls.tsx | 337 +++++++++++------- src/lib/api/ai-suggestions.ts | 8 +- src/lib/api/meeting-recordings.ts | 20 +- src/lib/api/user-integrations.ts | 47 ++- 5 files changed, 259 insertions(+), 155 deletions(-) diff --git a/src/app/coaching-sessions/[id]/page.tsx b/src/app/coaching-sessions/[id]/page.tsx index 6a774e8b..e9ef59d4 100644 --- a/src/app/coaching-sessions/[id]/page.tsx +++ b/src/app/coaching-sessions/[id]/page.tsx @@ -123,7 +123,7 @@ export default function CoachingSessionsPage() { locale={siteConfig.locale} style={siteConfig.titleStyle} /> -
+
{ setIsStarting(true); + setIsOpen(false); try { await startRecording(); toast.success("Recording started", { @@ -92,6 +101,7 @@ export function MeetingControls({ sessionId, className }: MeetingControlsProps) const handleStopRecording = async () => { setIsStopping(true); + setIsOpen(false); try { await stopRecording(); toast.success("Recording stopped", { @@ -113,152 +123,207 @@ export function MeetingControls({ sessionId, className }: MeetingControlsProps) const isFailed = recording?.status === RecordingStatus.Failed; const aiDisabled = privacyLevel === AiPrivacyLevel.None; - // State A: No meeting URL configured - if (!meetingUrl) { - return ( - -
- - -
- - No meeting link -
-
- -

Configure a Google Meet URL in Settings

-
-
- {isCoachInCurrentRelationship && ( - - )} -
-
- ); - } + // Determine the button appearance based on state + const getButtonContent = () => { + if (recordingLoading || isStarting || isStopping) { + return ( + <> + + + + ); + } + + if (isRecordingActive) { + return ( + <> + + {formatDuration(elapsedSeconds)} + + + ); + } + + if (isJoining) { + return ( + <> + + Joining... + + + ); + } + + if (isProcessing) { + return ( + <> + + Processing + + + ); + } + + if (!meetingUrl) { + return ( + <> + + + + ); + } + + if (aiDisabled) { + return ( + <> +
diff --git a/src/components/ui/header-session-selector.tsx b/src/components/ui/header-session-selector.tsx new file mode 100644 index 00000000..b95b190f --- /dev/null +++ b/src/components/ui/header-session-selector.tsx @@ -0,0 +1,30 @@ +"use client"; + +import { usePathname } from "next/navigation"; +import { useCurrentCoachingRelationship } from "@/lib/hooks/use-current-coaching-relationship"; +import CoachingSessionSelector from "@/components/ui/coaching-session-selector"; + +/** + * Session selector for the site header. + * Only renders when on a coaching session page and we have a valid relationship. + */ +export function HeaderSessionSelector() { + const pathname = usePathname(); + const { currentCoachingRelationshipId } = useCurrentCoachingRelationship(); + + // Only show on coaching session pages (e.g., /coaching-sessions/[id]) + const isCoachingSessionPage = pathname?.startsWith("/coaching-sessions/"); + + if (!isCoachingSessionPage) { + return null; + } + + return ( +
+ +
+ ); +} diff --git a/src/components/ui/site-header.tsx b/src/components/ui/site-header.tsx index 8bda971c..f06864d5 100644 --- a/src/components/ui/site-header.tsx +++ b/src/components/ui/site-header.tsx @@ -4,6 +4,7 @@ import { CommandMenu } from "@/components/ui/command-menu"; import { MainNav } from "@/components/ui/main-nav"; import { ModeToggle } from "@/components/ui/mode-toggle"; import { UserNav } from "@/components/ui/user-nav"; +import { HeaderSessionSelector } from "@/components/ui/header-session-selector"; export function SiteHeader() { return ( @@ -14,7 +15,8 @@ export function SiteHeader() { {/*
*/} -