diff --git a/apps/web/src/granola-plugin-availability.test.ts b/apps/web/src/granola-plugin-availability.test.ts new file mode 100644 index 000000000..fcb9ff397 --- /dev/null +++ b/apps/web/src/granola-plugin-availability.test.ts @@ -0,0 +1,66 @@ +import { afterEach, describe, expect, test } from "bun:test"; + +import { fetchGranolaPluginConnected } from "./granola-plugin-availability"; + +const realFetch = globalThis.fetch; + +afterEach(() => { + globalThis.fetch = realFetch; +}); + +describe("fetchGranolaPluginConnected", () => { + test("false on 404 — Granola not connected", async () => { + globalThis.fetch = (async () => + new Response(null, { status: 404 })) as unknown as typeof fetch; + expect(await fetchGranolaPluginConnected("tnt_1")).toBe(false); + }); + + test("true when the resolved credential is active", async () => { + globalThis.fetch = (async () => + new Response( + JSON.stringify({ + id: "cred_1", + tenantId: "tnt_1", + name: "Granola", + status: "active", + }), + { status: 200, headers: { "content-type": "application/json" } }, + )) as unknown as typeof fetch; + expect(await fetchGranolaPluginConnected("tnt_1")).toBe(true); + }); + + test("true when the credential needs attention (expired/error)", async () => { + globalThis.fetch = (async () => + new Response( + JSON.stringify({ + id: "cred_1", + tenantId: "tnt_1", + name: "Granola", + status: "expired", + }), + { status: 200, headers: { "content-type": "application/json" } }, + )) as unknown as typeof fetch; + expect(await fetchGranolaPluginConnected("tnt_1")).toBe(true); + }); + + test("false when the credential is revoked", async () => { + globalThis.fetch = (async () => + new Response( + JSON.stringify({ + id: "cred_1", + tenantId: "tnt_1", + name: "Granola", + status: "revoked", + }), + { status: 200, headers: { "content-type": "application/json" } }, + )) as unknown as typeof fetch; + expect(await fetchGranolaPluginConnected("tnt_1")).toBe(false); + }); + + test("false on network failure — never claim connected", async () => { + globalThis.fetch = (async () => { + throw new Error("offline"); + }) as unknown as typeof fetch; + expect(await fetchGranolaPluginConnected("tnt_1")).toBe(false); + }); +}); diff --git a/apps/web/src/granola-plugin-availability.ts b/apps/web/src/granola-plugin-availability.ts new file mode 100644 index 000000000..644cad2b1 --- /dev/null +++ b/apps/web/src/granola-plugin-availability.ts @@ -0,0 +1,51 @@ +// Whether this tenant has Granola connected — the routine trigger popover +// (see `shell/routine-panel.tsx`) reads this so an unconnected bench never +// offers "Granola call notes" as if it were a working trigger (CL-6759). +// Same honesty rule as Slack via `deployment-capabilities-api.ts`, but +// tenant-scoped: Granola's credential resolves through the ancestor chain +// (`GET /credentials/resolve/Granola`), not a deployment env gate. +import { type } from "arktype"; +import { useQuery } from "@tanstack/react-query"; + +import { tenantKeys } from "./query-client"; + +const ResolvedCredential = type({ + status: "'active' | 'expired' | 'revoked' | 'error'", +}); + +/** True when Granola is connected (or needs attention) for `tenantId`. A + * missing, revoked, or unreadable credential is not offerable. */ +export async function fetchGranolaPluginConnected( + tenantId: string, +): Promise { + const response = await fetch( + `/api/tenants/${tenantId}/credentials/resolve/${encodeURIComponent("Granola")}`, + { headers: { accept: "application/json" } }, + ).catch(() => null); + if (response === null || response.status === 404) return false; + if (!response.ok) return false; + const body: unknown = await response.json().catch(() => undefined); + const parsed = ResolvedCredential(body); + if (parsed instanceof type.errors) return false; + // Revoked reads as not connected — same rule as + // `@workbench/connections/plugins`' resolveOne. + return ( + parsed.status === "active" || + parsed.status === "expired" || + parsed.status === "error" + ); +} + +/** Absent (still fetching) never claims Granola is connected — the trigger + * popover's own "hide when not available" default. */ +export function useGranolaPluginConnected(tenantId: string | null): boolean { + const { data } = useQuery({ + queryKey: + tenantId === null + ? (["granola-plugin-connected", "none"] as const) + : ([...tenantKeys.all(tenantId), "granola-plugin-connected"] as const), + queryFn: () => fetchGranolaPluginConnected(tenantId as string), + enabled: tenantId !== null, + }); + return data ?? false; +} diff --git a/apps/web/src/shell/routine-panel.tsx b/apps/web/src/shell/routine-panel.tsx index be62407c1..4a1fb10f2 100644 --- a/apps/web/src/shell/routine-panel.tsx +++ b/apps/web/src/shell/routine-panel.tsx @@ -69,6 +69,7 @@ import { DEFAULT_WEBHOOK_INPUT_TEMPLATE, } from "../webhook-triggers-api"; import { useDeploymentCapabilities } from "../deployment-capabilities-api"; +import { useGranolaPluginConnected } from "../granola-plugin-availability"; import { tenantKeys } from "../query-client"; import { useCanvasColumnRoutine, useCloseCanvas } from "./canvas-availability"; import type { RoutinePanelSubject } from "./canvas-availability"; @@ -93,20 +94,22 @@ function triggerRowSummary( /** * `+ Add trigger` popover contents: schedule is always offered; Granola - * call notes is a plain inbound webhook binding (no external credential — - * Granola pushes to us), so it's always offered too; Slack is offered only - * when this deployment's Slack tag ingress is actually mounted (see - * `deployment-capabilities-api.ts`) — an unconfigured deployment must never - * offer a trigger that can't honestly fire. + * call notes is offered only when this tenant has Granola connected (CL-6759) + * — an unconnected plugin must never look like a working trigger; Slack is + * offered only when this deployment's Slack tag ingress is actually mounted + * (see `deployment-capabilities-api.ts`) — an unconfigured deployment must + * never offer a trigger that can't honestly fire. */ function AddTriggerMenu({ slackAvailable, + granolaAvailable, onSchedule, onGranola, onSlack, disabled, }: { readonly slackAvailable: boolean; + readonly granolaAvailable: boolean; readonly onSchedule: () => void; readonly onGranola: () => void; readonly onSlack: () => void; @@ -121,7 +124,9 @@ function AddTriggerMenu({ On a schedule › - Granola call notes + {granolaAvailable ? ( + Granola call notes + ) : null} {slackAvailable ? Slack : null} @@ -166,6 +171,7 @@ function RoutineEditorPanel({ const navigate = useNavigate(); const { selectedTenantId: tenantId } = useBench(); const { slackConfigured } = useDeploymentCapabilities(); + const granolaConnected = useGranolaPluginConnected(tenantId); const queryClient = useQueryClient(); const invalidateRoutines = () => { @@ -562,6 +568,7 @@ function RoutineEditorPanel({ {trigger === null && !addingSchedule ? ( setAddingSchedule(true)} onGranola={() => addWebhookTrigger("Granola call notes")} diff --git a/apps/web/test/routine-panel.test.tsx b/apps/web/test/routine-panel.test.tsx index 60a558b81..c01f468b3 100644 --- a/apps/web/test/routine-panel.test.tsx +++ b/apps/web/test/routine-panel.test.tsx @@ -51,6 +51,7 @@ let createRoutineCalls: Record[] = []; let createWorkbenchCalls: Record[] = []; let runNowCalls = 0; let slackConfigured = false; +let granolaConnected = false; let networkDelayMs = 0; let workbenchAgentsByWorkbench: Record< string, @@ -105,6 +106,20 @@ async function routeFetch( if (url.includes("/api/deployment-capabilities")) { return jsonResponse({ slackConfigured }); } + if (url.includes("/credentials/resolve/Granola")) { + if (!granolaConnected) { + return new Response(null, { status: 404 }); + } + return jsonResponse({ + id: "cred_granola", + tenantId: "tnt_1", + name: "Granola", + status: "active", + }); + } + if (url.includes("/credentials/resolve/")) { + return new Response(null, { status: 404 }); + } if (url.includes("/workflows/definitions")) { return jsonResponse({ data: [{ id: "wfd_myra", name: "assistant", status: "deployed" }], @@ -256,6 +271,7 @@ describe("RoutinePanel", () => { createWorkbenchCalls = []; runNowCalls = 0; slackConfigured = false; + granolaConnected = false; networkDelayMs = 0; chatWorkbenches = []; runsByRoutineId = {}; @@ -536,6 +552,7 @@ describe("RoutinePanel", () => { test("the trigger popover lists only honestly-working triggers — Slack hidden when not configured, shown when it is", async () => { slackConfigured = false; + granolaConnected = true; await renderPanel({ routineId: null, workbenchId: "ch_1" }); act(() => openMenu(buttonWithText("+ Add trigger"))); await settle(); @@ -561,6 +578,33 @@ describe("RoutinePanel", () => { expect(items).toContain("Slack"); }); + test("the trigger popover hides Granola call notes when Granola is not connected (CL-6759)", async () => { + granolaConnected = false; + await renderPanel({ routineId: null, workbenchId: "ch_1" }); + act(() => openMenu(buttonWithText("+ Add trigger"))); + await settle(); + let items = [...document.querySelectorAll('[role="menuitem"]')].map( + (el) => el.textContent?.trim(), + ); + expect(items).toContain("On a schedule ›"); + expect(items).not.toContain("Granola call notes"); + expect(items.every((label) => !label?.includes("Granola"))).toBe(true); + + act(() => root.unmount()); + container.remove(); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + granolaConnected = true; + await renderPanel({ routineId: null, workbenchId: "ch_1" }); + act(() => openMenu(buttonWithText("+ Add trigger"))); + await settle(); + items = [...document.querySelectorAll('[role="menuitem"]')].map((el) => + el.textContent?.trim(), + ); + expect(items).toContain("Granola call notes"); + }); + test("picking a schedule preset commits the trigger in one click — no sub-menu chain", async () => { await renderPanel({ routineId: null, workbenchId: "ch_1" }); act(() => openMenu(buttonWithText("+ Add trigger")));