WebhookTriggerPanel.tsx5.3 KBView on GitHub
'use client';

import { useEffect, useRef, useState } from 'react';
import { useMutation, useQuery } from '@tanstack/react-query';
import { RefreshCw } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Switch } from '@/components/ui/switch';
import { cn } from '@/lib/utils';
import { useTRPC } from '@/providers/query-provider';

function curlExample(url: string): string {
  return `curl -X POST '${url}' \\\n  -H 'content-type: application/json' \\\n  -d '{"hello":"world"}'`;
}

/**
 * Presentational webhook panel — copyable POST URL, curl example, enable toggle,
 * and rotate. Pure props so it can be unit-tested without tRPC/TipTap.
 */
export function WebhookTriggerPanelView({
  url,
  enabled,
  lastTriggeredAt,
  loading = false,
  rotating = false,
  onRotate,
  onToggleEnabled,
}: {
  url?: string;
  enabled: boolean;
  lastTriggeredAt?: string | Date | null;
  loading?: boolean;
  rotating?: boolean;
  onRotate: () => void;
  onToggleEnabled: (value: boolean) => void;
}) {
  const [copied, setCopied] = useState(false);

  if (!url) {
    return (
      <p className="text-sm text-muted-foreground">
        {loading ? 'Generating webhook URL…' : 'No webhook URL yet.'}
      </p>
    );
  }

  return (
    <div className="space-y-3">
      <p className="text-sm text-muted-foreground">
        Any <code>POST</code> to this URL triggers this agent. The request body is the only event
        context (no conversation).
      </p>

      <div className="flex items-center gap-2">
        <Input
          readOnly
          value={url}
          data-testid="webhook-url"
          className="h-8 font-mono text-sm"
          onFocus={(e) => e.currentTarget.select()}
        />
        <Button
          type="button"
          size="sm"
          variant="outline"
          onClick={() => {
            void navigator.clipboard?.writeText(url);
            setCopied(true);
            setTimeout(() => setCopied(false), 1500);
          }}
        >
          {copied ? 'Copied' : 'Copy'}
        </Button>
      </div>

      <pre className="overflow-x-auto rounded-md bg-muted/50 p-2 text-xs">{curlExample(url)}</pre>

      <div className="flex items-center justify-between">
        <label className="flex items-center gap-2 text-sm">
          <Switch checked={enabled} onCheckedChange={onToggleEnabled} aria-label="Enabled" />
          {enabled ? 'Enabled' : 'Disabled'}
        </label>
        <Button type="button" size="sm" variant="ghost" onClick={onRotate} disabled={rotating}>
          <RefreshCw className={cn('mr-1 h-3 w-3', rotating && 'animate-spin')} />
          Rotate URL
        </Button>
      </div>

      {lastTriggeredAt ? (
        <p className="text-xs text-muted-foreground">
          Last triggered {new Date(lastTriggeredAt).toLocaleString()}
        </p>
      ) : null}
    </div>
  );
}

/**
 * tRPC-backed container. Mints a webhook row on first render (when the block has
 * no `webhookId` yet), stores the id back on the block via {@link onMinted}, and
 * wires rotate/enable to the aop router.
 */
export function WebhookTriggerPanel({
  aopId,
  ownerUserId,
  webhookId,
  onMinted,
}: {
  aopId: string | null;
  /**
   * The playbook's owner. Threaded from the node rather than read from the
   * member picker: this panel renders inside playbook documents, which the
   * `/brain` editor opens for any member without mounting a picker.
   */
  ownerUserId?: string | null;
  webhookId?: string;
  onMinted: (webhookId: string) => void;
}) {
  const trpc = useTRPC();
  const targetUserId = ownerUserId ?? undefined;
  const createMutation = useMutation(trpc.aop.createPlaybookWebhook.mutationOptions());
  const rotateMutation = useMutation(trpc.aop.rotatePlaybookWebhookToken.mutationOptions());
  const enableMutation = useMutation(trpc.aop.setPlaybookWebhookEnabled.mutationOptions());

  // Mint exactly once when the block has no webhookId yet.
  const mintedRef = useRef(false);
  useEffect(() => {
    if (webhookId || mintedRef.current || !aopId) return;
    mintedRef.current = true;
    createMutation
      .mutateAsync({ aopId, scope: 'user', targetUserId })
      .then((result) => onMinted(result.webhookId))
      .catch(() => {
        mintedRef.current = false;
      });
    // createMutation/onMinted are stable enough; mint is keyed on (aopId, webhookId).
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [aopId, webhookId]);

  const webhookQuery = useQuery({
    ...trpc.aop.getPlaybookWebhook.queryOptions({ webhookId: webhookId ?? '', targetUserId }),
    enabled: Boolean(webhookId),
  });

  const data = webhookQuery.data;

  return (
    <WebhookTriggerPanelView
      url={data?.url}
      enabled={data?.enabled ?? true}
      lastTriggeredAt={data?.lastTriggeredAt ?? null}
      loading={createMutation.isPending || webhookQuery.isLoading}
      rotating={rotateMutation.isPending}
      onRotate={() => {
        if (!webhookId) return;
        rotateMutation.mutateAsync({ webhookId, targetUserId }).then(() => webhookQuery.refetch());
      }}
      onToggleEnabled={(value) => {
        if (!webhookId) return;
        enableMutation
          .mutateAsync({ webhookId, enabled: value, targetUserId })
          .then(() => webhookQuery.refetch());
      }}
    />
  );
}