meeting-manager.tsx27.7 KBView on GitHub 'use client';
import {
Loader2,
RefreshCw,
Calendar,
Webhook,
CheckCircle2,
ExternalLink,
ChevronDown,
ChevronUp,
Copy,
} from 'lucide-react';
import { useState, useImperativeHandle, forwardRef, useEffect, useRef, useCallback } from 'react';
import { useQuery, useMutation } from '@tanstack/react-query';
import { ScrollArea } from '@/components/ui/scroll-area';
import { useTRPC, useTRPCClient } from '@/providers/query-provider';
import { Checkbox } from '@/components/ui/checkbox';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { AlertCircle } from 'lucide-react';
import { toast } from 'sonner';
/**
* Cedar meeting data type - matches server-side CedarMeetingData
* Used by all meeting provider integrations
*/
export interface CedarMeetingData {
// Meeting identification
externalId?: string | null;
meetingUrl?: string | null;
calendarEventId?: string | null;
// Meeting details
title: string;
description?: string | null;
location?: string | null;
// Timing
meetingTime: Date | string;
durationMinutes?: number | null;
// Participants
participants: Array<{ name: string; email: string }>;
organizerName?: string | null;
organizerEmail: string;
// Notes
notes?: string | null;
aiNotes?: string | null;
// Transcription data
hasTranscription?: boolean | null;
transcriptionKey?: string | null;
transcriptionWordCount?: number | null;
// Recording
hasRecording?: boolean | null;
recordingKey?: string | null;
recordingDurationSeconds?: number | null;
// Metadata
meetingProvider?: string | null;
status?: string | null;
// Full transcript (for R2 storage, not a DB field)
fullTranscript?: string;
}
export interface MeetingManagerRef {
getSelectedMeetingIds: () => Set<string>;
}
interface MeetingManagerProps {
providerId: string;
userId?: string;
selectionMode?: boolean;
selectedMeetingIds?: Set<string>;
onSelectionChange?: (selectedIds: Set<string>) => void;
daysBack?: number;
showWebhookSection?: boolean;
showMeetingsList?: boolean; // When false, only shows webhook section (for user-facing mode)
}
export const MeetingManager = forwardRef<MeetingManagerRef, MeetingManagerProps>(
(
{
providerId,
userId,
selectionMode = false,
selectedMeetingIds: controlledSelectedIds,
onSelectionChange,
daysBack = selectionMode ? 30 : 3,
showWebhookSection = true,
showMeetingsList = true,
},
ref,
) => {
const trpc = useTRPC();
const [shouldFetchMeetings, setShouldFetchMeetings] = useState(false);
const [expandedMeetingIds, setExpandedMeetingIds] = useState<Set<string>>(new Set());
const [internalSelectedIds, setInternalSelectedIds] = useState<Set<string>>(new Set());
const [meetingRangeCount, setMeetingRangeCount] = useState<string>('');
const [meetingRangeOffset, setMeetingRangeOffset] = useState<string>('');
const hasAutoSelectedRef = useRef(false);
const trpcClient = useTRPCClient();
// Use controlled selection if provided, otherwise use internal state
const selectedMeetingIds = controlledSelectedIds ?? internalSelectedIds;
// Accepts the same value-or-updater signature as a useState setter. In controlled mode
// the updater has to be resolved here, because `onSelectionChange` only takes a Set.
const setSelectedMeetingIds = (
update: Set<string> | ((prev: Set<string>) => Set<string>),
) => {
if (onSelectionChange) {
onSelectionChange(typeof update === 'function' ? update(selectedMeetingIds) : update);
} else {
setInternalSelectedIds(update);
}
};
// Fetch integration info to get webhook setup instructions
const { data: integrationsData } = useQuery(
trpc.integrations.list.queryOptions(userId ? { userId } : undefined),
);
const integration = integrationsData?.integrations.find((i) => i.id === providerId);
const webhookSetupInstructions = (
integration?.capabilities as { webhookSetupInstructions?: string } | undefined
)?.webhookSetupInstructions;
// Batched meeting fetching state
const BATCH_DAYS = 14;
const [meetings, setMeetings] = useState<CedarMeetingData[]>([]);
const [isLoadingMeetings, setIsLoadingMeetings] = useState(false);
const [batchProgress, setBatchProgress] = useState<{ current: number; total: number } | null>(
null,
);
const [error, setError] = useState<Error | null>(null);
const loadMeetings = useCallback(
async (days: number) => {
if (!providerId || !userId) return;
setIsLoadingMeetings(true);
setError(null);
setMeetings([]);
setBatchProgress(null);
try {
if (days <= BATCH_DAYS) {
const result = await trpcClient.integrations.meetings.listMeetings.query({
providerId,
userId,
daysBack: days,
});
setMeetings((result.meetings as unknown as CedarMeetingData[]) ?? []);
} else {
// Split into BATCH_DAYS-sized windows from most recent backwards
const totalBatches = Math.ceil(days / BATCH_DAYS);
const now = new Date();
const accumulated: CedarMeetingData[] = [];
const seenIds = new Set<string>();
for (let i = 0; i < totalBatches; i++) {
setBatchProgress({ current: i + 1, total: totalBatches });
const endDate = new Date(now);
endDate.setDate(endDate.getDate() - i * BATCH_DAYS);
const startDate = new Date(now);
startDate.setDate(startDate.getDate() - Math.min((i + 1) * BATCH_DAYS, days));
const result = await trpcClient.integrations.meetings.listMeetings.query({
providerId,
userId,
startDate: startDate.toISOString(),
endDate: endDate.toISOString(),
});
const batch = (result.meetings as unknown as CedarMeetingData[]) ?? [];
for (const m of batch) {
const id =
m.externalId || m.calendarEventId || `${m.meetingTime}-${m.title}`;
if (!seenIds.has(id)) {
seenIds.add(id);
accumulated.push(m);
}
}
// Show partial results as each batch arrives
setMeetings([...accumulated]);
}
setBatchProgress(null);
}
} catch (err) {
setError(err instanceof Error ? err : new Error(String(err)));
} finally {
setIsLoadingMeetings(false);
setBatchProgress(null);
}
},
[providerId, userId, trpcClient],
);
// Expose selected meeting IDs via ref
useImperativeHandle(ref, () => ({
getSelectedMeetingIds: () => selectedMeetingIds,
}));
// Auto-select all meetings when they first load in selection mode
useEffect(() => {
if (
selectionMode &&
meetings.length > 0 &&
selectedMeetingIds.size === 0 &&
!hasAutoSelectedRef.current
) {
const allIds = meetings.map(
(m, index) => m.externalId || m.calendarEventId || `meeting-${index}`,
);
setSelectedMeetingIds(new Set(allIds));
hasAutoSelectedRef.current = true;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [meetings.length, selectionMode, selectedMeetingIds.size]);
// Fetch webhook status
const {
data: webhookStatus,
isLoading: isLoadingWebhook,
refetch: refetchWebhook,
} = useQuery({
...trpc.integrations.meetings.getWebhookStatus.queryOptions({
providerId,
userId,
}),
retry: false,
});
// Create webhook mutation
const createWebhookMutation = useMutation({
...trpc.integrations.meetings.createWebhook.mutationOptions(),
onSuccess: (data) => {
if (data.success) {
refetchWebhook();
} else {
const errorMsg = data.error || 'Failed to create webhook';
console.error('[MeetingManager] Webhook creation failed:', errorMsg);
toast.error(errorMsg, {
duration: 5000,
});
}
},
onError: (error) => {
console.error('[MeetingManager] Webhook creation error:', error);
toast.error(error.message || 'Failed to create webhook', {
duration: 5000,
});
},
});
const hasError = error !== null;
const errorMessage = error instanceof Error ? error.message : error ? String(error) : null;
const isNotAvailable =
hasError &&
errorMessage?.includes('not available') &&
!errorMessage?.includes('not authenticated');
const toggleMeetingJson = (meetingId: string) => {
setExpandedMeetingIds((prev) => {
const newSet = new Set(prev);
if (newSet.has(meetingId)) {
newSet.delete(meetingId);
} else {
newSet.add(meetingId);
}
return newSet;
});
};
const toggleMeeting = (meetingId: string, checked: boolean) => {
setSelectedMeetingIds((prev) => {
const newSet = new Set(prev);
if (checked) {
newSet.add(meetingId);
} else {
newSet.delete(meetingId);
}
return newSet;
});
};
const handleSelectAll = () => {
const allIds = meetings.map(
(m, index) => m.externalId || m.calendarEventId || `meeting-${index}`,
);
setSelectedMeetingIds(new Set(allIds));
};
const handleDeselectAll = () => {
setSelectedMeetingIds(new Set());
};
const handleSelectRange = () => {
const count = parseInt(meetingRangeCount, 10);
const offset = parseInt(meetingRangeOffset, 10);
if (isNaN(count) || isNaN(offset) || count <= 0 || offset < 0) {
return;
}
const endIndex = Math.min(offset + count, meetings.length);
const rangeMeetings = meetings.slice(offset, endIndex);
const rangeIds = rangeMeetings.map(
(m) => m.externalId || m.calendarEventId || `meeting-${meetings.indexOf(m)}`,
);
setSelectedMeetingIds((prev) => {
const newSet = new Set(prev);
rangeIds.forEach((id) => newSet.add(id));
return newSet;
});
};
return (
<div className="flex min-w-0 max-w-full flex-col gap-6 overflow-hidden p-4">
{/* Webhook Status Section */}
{showWebhookSection && (
<div className="border-t pt-4">
<div className="mb-4 flex items-center justify-between">
<h4 className="text-muted-foreground text-sm font-medium">Webhook Configuration</h4>
{!isLoadingWebhook && (
<Button variant="ghost" size="sm" onClick={() => refetchWebhook()}>
<RefreshCw className="h-4 w-4" />
</Button>
)}
</div>
{isLoadingWebhook ? (
<div className="flex items-center justify-center py-4">
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin" />
</div>
) : webhookStatus?.hasWebhook ? (
<div className="bg-card flex items-center justify-between rounded-md border p-3">
<div className="flex items-center gap-2">
<CheckCircle2 className="h-4 w-4 text-green-500" />
<div className="flex flex-col">
<span className="text-sm font-medium">Webhook Active</span>
<span className="text-muted-foreground text-xs">
Created{' '}
{webhookStatus.webhookCreatedAt
? new Date(webhookStatus.webhookCreatedAt).toLocaleDateString()
: ''}
</span>
</div>
</div>
{webhookStatus.webhookId && (
<Badge variant="outline" className="text-xs">
ID: {webhookStatus.webhookId.slice(0, 8)}...
</Badge>
)}
</div>
) : webhookSetupInstructions ? (
<div className="bg-card flex flex-col gap-3 rounded-md border p-3">
<div className="flex items-center gap-2">
<AlertCircle className="text-muted-foreground h-4 w-4" />
<span className="text-sm">Manual webhook setup required</span>
</div>
<div className="text-muted-foreground text-xs">
<div className="mb-2 whitespace-pre-line">{webhookSetupInstructions}</div>
<div className="bg-muted flex items-center justify-between rounded p-2">
<code className="text-xs">
{webhookStatus?.webhookUrl ||
`https://api.mail.cedarcopilot.com/webhooks/meeting-notes/${providerId}`}
</code>
<Button
variant="ghost"
size="sm"
onClick={() => {
const url =
webhookStatus?.webhookUrl ||
`https://api.mail.cedarcopilot.com/webhooks/meeting-notes/${providerId}`;
navigator.clipboard.writeText(url);
}}
>
<ExternalLink className="h-3 w-3" />
</Button>
</div>
</div>
</div>
) : (
<div className="bg-card flex flex-col gap-3 rounded-md border p-3">
<div className="flex items-center gap-2">
<Webhook className="text-muted-foreground h-4 w-4" />
<span className="text-sm">No webhook configured</span>
</div>
<div className="text-muted-foreground text-xs">
<p className="mb-2">
We'll automatically create a webhook to capture all your future meetings.
</p>
</div>
<Button
size="sm"
onClick={() =>
createWebhookMutation.mutate({
providerId,
userId,
})
}
disabled={createWebhookMutation.isPending}
className="w-full"
>
{createWebhookMutation.isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Creating...
</>
) : (
<>
<Webhook className="mr-2 h-4 w-4" />
Create Webhook
</>
)}
</Button>
</div>
)}
</div>
)}
{showMeetingsList && (
<div className="border-t pt-4">
<div className="mb-4 flex items-center justify-between">
<div className="flex-1">
<h4 className="text-muted-foreground text-sm font-medium">
{selectionMode ? 'Select Meetings to Sync' : 'Recent Meetings'}
</h4>
{selectionMode && meetings.length > 0 && (
<p className="text-muted-foreground text-xs">
{selectedMeetingIds.size} of {meetings.length} selected
</p>
)}
</div>
{!isNotAvailable && (
<div className="flex items-center gap-2">
{selectionMode && meetings.length > 0 && (
<div className="flex flex-col gap-2">
<div className="flex gap-2">
<Button variant="ghost" size="sm" onClick={handleSelectAll}>
Select All
</Button>
<Button variant="ghost" size="sm" onClick={handleDeselectAll}>
Deselect All
</Button>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1">
<Label htmlFor="meeting-range-count" className="whitespace-nowrap text-xs">
#
</Label>
<Input
id="meeting-range-count"
type="number"
placeholder="Count"
value={meetingRangeCount}
onChange={(e) => setMeetingRangeCount(e.target.value)}
className="h-8 w-16 text-xs"
min="1"
/>
</div>
<div className="flex items-center gap-1">
<Label htmlFor="meeting-range-offset" className="whitespace-nowrap text-xs">
Offset
</Label>
<Input
id="meeting-range-offset"
type="number"
placeholder="Offset"
value={meetingRangeOffset}
onChange={(e) => setMeetingRangeOffset(e.target.value)}
className="h-8 w-16 text-xs"
min="0"
/>
</div>
<Button
variant="ghost"
size="sm"
onClick={handleSelectRange}
disabled={
!meetingRangeCount || !meetingRangeOffset || meetings.length === 0
}
className="h-8 text-xs"
>
Select Range
</Button>
</div>
</div>
)}
<Button
variant={selectionMode && !shouldFetchMeetings ? 'default' : 'ghost'}
size="sm"
onClick={() => {
setShouldFetchMeetings(true);
loadMeetings(daysBack);
}}
disabled={isLoadingMeetings || !providerId || !userId}
>
{isLoadingMeetings ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{batchProgress
? `Batch ${batchProgress.current}/${batchProgress.total}…`
: 'Loading…'}
</>
) : selectionMode && !shouldFetchMeetings ? (
<>
<RefreshCw className="mr-2 h-4 w-4" />
Load Meetings
</>
) : (
<RefreshCw className="h-4 w-4" />
)}
</Button>
</div>
)}
</div>
<ScrollArea className="h-[400px] w-full rounded-md border p-4">
<div className="min-w-0 max-w-full overflow-hidden">
{!shouldFetchMeetings ? (
<div className="flex h-full flex-col items-center justify-center gap-2 py-8">
<RefreshCw className="text-muted-foreground h-8 w-8" />
<p className="text-muted-foreground text-center text-sm">
{selectionMode
? 'Click "Load Meetings" to fetch meetings'
: 'Click refresh to load meetings'}
</p>
</div>
) : isNotAvailable ? (
<div className="flex h-full flex-col items-center justify-center gap-2 py-8">
<AlertCircle className="text-muted-foreground h-8 w-8" />
<p className="text-muted-foreground text-center text-sm">
Meeting listing is not available for {providerId}.
</p>
</div>
) : hasError && meetings.length === 0 ? (
<div className="flex h-full flex-col items-center justify-center gap-2 py-8">
<AlertCircle className="h-8 w-8 text-red-400" />
<p className="text-muted-foreground text-center text-sm">
{errorMessage ?? 'Failed to load meetings.'}
</p>
</div>
) : isLoadingMeetings && meetings.length === 0 ? (
<div className="flex h-full flex-col items-center justify-center gap-2">
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin" />
{batchProgress && (
<p className="text-muted-foreground text-xs">
Loading batch {batchProgress.current} of {batchProgress.total}…
</p>
)}
</div>
) : meetings.length > 0 ? (
<div className="min-w-0 max-w-full space-y-3">
{isLoadingMeetings && batchProgress && (
<div className="text-muted-foreground flex items-center gap-2 pb-1 text-xs">
<Loader2 className="h-3 w-3 animate-spin" />
Loading batch {batchProgress.current} of {batchProgress.total}…
</div>
)}
{hasError && meetings.length > 0 && (
<div className="flex items-center gap-2 pb-2 text-xs text-red-400">
<AlertCircle className="h-3 w-3 flex-shrink-0" />
<span>Some batches failed — results may be incomplete. {errorMessage}</span>
</div>
)}
{meetings.map((meeting, index) => {
const meetingId =
meeting.externalId || meeting.calendarEventId || `meeting-${index}`;
const title = meeting.title || 'Untitled Meeting';
const meetingTime = meeting.meetingTime ? new Date(meeting.meetingTime) : null;
const durationMinutes = meeting.durationMinutes;
const participants = meeting.participants || [];
return (
<div
key=[redacted]
className="bg-card flex min-w-0 max-w-full flex-col gap-2 overflow-hidden rounded-md border p-3 text-sm transition-all"
>
<div className="flex min-w-0 items-start justify-between gap-2">
<div className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden">
{selectionMode && (
<Checkbox
id={`meeting-${meetingId}`}
checked={selectedMeetingIds.has(meetingId)}
onCheckedChange={(checked) =>
toggleMeeting(meetingId, checked === true)
}
/>
)}
<Calendar className="text-muted-foreground h-4 w-4 flex-shrink-0" />
<div className="min-w-0 flex-1 overflow-hidden break-words font-medium">
{title}
</div>
</div>
</div>
<div className="ml-6 flex flex-col gap-2">
<div className="text-muted-foreground flex flex-wrap items-center gap-2 text-xs">
{meetingTime && (
<span className="whitespace-nowrap">
{meetingTime.toLocaleDateString()}{' '}
{meetingTime.toLocaleTimeString()}
</span>
)}
{durationMinutes && (
<Badge variant="outline" className="text-[10px] font-normal">
{Math.round(durationMinutes)} min
</Badge>
)}
{participants.length > 0 && (
<Badge
variant="secondary"
className="h-5 px-1.5 text-[10px] font-normal"
>
{participants.length} participant
{participants.length !== 1 ? 's' : ''}
</Badge>
)}
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
className="h-6 flex-1 justify-start text-xs"
onClick={() => toggleMeetingJson(meetingId)}
>
{expandedMeetingIds.has(meetingId) ? (
<>
<ChevronUp className="mr-1 h-3 w-3" />
Hide JSON
</>
) : (
<>
<ChevronDown className="mr-1 h-3 w-3" />
Show JSON
</>
)}
</Button>
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
onClick={() => {
const jsonStr = JSON.stringify(
meeting,
(key, value) => (value === undefined ? null : value),
2,
);
navigator.clipboard.writeText(jsonStr);
}}
>
<Copy className="h-3 w-3" />
</Button>
</div>
{expandedMeetingIds.has(meetingId) && (
<pre className="bg-muted max-h-96 max-w-full overflow-x-auto overflow-y-auto rounded-md p-2 text-xs">
<code className="block max-w-full whitespace-pre-wrap break-words">
{JSON.stringify(
meeting,
(key, value) => {
// Include undefined values as null for visibility
if (value === undefined) {
return null;
}
return value;
},
2,
)}
</code>
</pre>
)}
</div>
</div>
);
})}
</div>
) : (
<div className="text-muted-foreground py-8 text-center text-sm">
No meetings found.
</div>
)}
</div>
</ScrollArea>
</div>
)}
</div>
);
},
);
MeetingManager.displayName = 'MeetingManager';