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&apos;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';