From c0c98b5c6d960c10e693513d681e24e46ed4d885 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Sat, 18 Apr 2026 02:04:57 +0000 Subject: [PATCH] Changes Co-authored-by: domfelipe <53182096+domfelipe@users.noreply.github.com> --- src/hooks/use-integrations.ts | 174 ++++++++++++++++++++++++++++++++++ src/routes/painel.tsx | 4 +- 2 files changed, 176 insertions(+), 2 deletions(-) create mode 100644 src/hooks/use-integrations.ts diff --git a/src/hooks/use-integrations.ts b/src/hooks/use-integrations.ts new file mode 100644 index 0000000..fdd051c --- /dev/null +++ b/src/hooks/use-integrations.ts @@ -0,0 +1,174 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { supabase } from "@/integrations/supabase/client"; +import { useAuth } from "@/hooks/use-auth"; +import { useSubscription } from "@/hooks/use-profile"; + +export interface AvailableMcp { + id: string; + slug: string; + name: string; + provider: string; + description: string; + icon_url: string; + oauth_authorize_url: string; + oauth_token_url: string; + oauth_revoke_url: string | null; + required_scopes: string[]; + available_in_plans: string[]; + supports_refresh_token: boolean; + display_order: number; + is_active: boolean; +} + +export interface UserIntegration { + id: string; + user_id: string; + mcp_id: string; + status: "active" | "expired" | "revoked" | "error"; + connected_account_email: string | null; + connected_account_name: string | null; + granted_scopes: string[]; + token_expires_at: string | null; + last_refreshed_at: string | null; + error_message: string | null; + created_at: string; + updated_at: string; +} + +export type IntegrationCardState = + | { kind: "available"; mcp: AvailableMcp } + | { kind: "locked"; mcp: AvailableMcp; userPlan: string | null } + | { kind: "connected"; mcp: AvailableMcp; integration: UserIntegration } + | { kind: "error"; mcp: AvailableMcp; integration: UserIntegration }; + +export function useAvailableMcps() { + return useQuery({ + queryKey: ["available-mcps"], + queryFn: async (): Promise => { + const { data, error } = await supabase + .from("available_mcps") + .select("*") + .eq("is_active", true) + .order("display_order", { ascending: true }); + if (error) throw error; + return (data ?? []).map((row) => ({ + ...row, + required_scopes: Array.isArray(row.required_scopes) + ? (row.required_scopes as string[]) + : [], + available_in_plans: Array.isArray(row.available_in_plans) + ? (row.available_in_plans as string[]) + : [], + })) as AvailableMcp[]; + }, + }); +} + +export function useUserIntegrations() { + const { user } = useAuth(); + return useQuery({ + queryKey: ["user-integrations", user?.id], + enabled: !!user, + queryFn: async (): Promise => { + const { data, error } = await supabase + .from("user_integrations") + .select( + "id, user_id, mcp_id, status, connected_account_email, connected_account_name, granted_scopes, token_expires_at, last_refreshed_at, error_message, created_at, updated_at", + ) + .eq("user_id", user!.id); + if (error) throw error; + return (data ?? []).map((row) => ({ + ...row, + granted_scopes: Array.isArray(row.granted_scopes) + ? (row.granted_scopes as string[]) + : [], + })) as UserIntegration[]; + }, + }); +} + +export function useUserIntegrationLimits() { + const { user } = useAuth(); + return useQuery({ + queryKey: ["user-integration-limits", user?.id], + enabled: !!user, + queryFn: async () => { + const { data, error } = await supabase + .from("user_integration_limits") + .select("*") + .eq("user_id", user!.id) + .maybeSingle(); + if (error) throw error; + return data as { + user_id: string; + plan_slug: string | null; + current_integrations_count: number | null; + max_integrations: number | null; + } | null; + }, + }); +} + +/** + * Combina available_mcps + user_integrations + plano em estados de cartão. + */ +export function useIntegrationCards() { + const mcpsQ = useAvailableMcps(); + const integsQ = useUserIntegrations(); + const subQ = useSubscription(); + + const userPlan = subQ.data?.plan_id ?? null; + + // Para checagem de plano usamos a view de limites (que tem plan_slug derivado) + const limitsQ = useUserIntegrationLimits(); + const planSlug = limitsQ.data?.plan_slug ?? null; + + const isLoading = mcpsQ.isLoading || integsQ.isLoading || limitsQ.isLoading; + const error = mcpsQ.error ?? integsQ.error ?? limitsQ.error; + + const cards: IntegrationCardState[] = (mcpsQ.data ?? []).map((mcp) => { + const integ = (integsQ.data ?? []).find((i) => i.mcp_id === mcp.id); + if (integ) { + if (integ.status === "active") { + return { kind: "connected", mcp, integration: integ }; + } + return { kind: "error", mcp, integration: integ }; + } + const planAllows = !planSlug || mcp.available_in_plans.length === 0 + ? true + : mcp.available_in_plans.includes(planSlug); + if (!planAllows) { + return { kind: "locked", mcp, userPlan: planSlug }; + } + return { kind: "available", mcp }; + }); + + return { + cards, + isLoading, + error, + planSlug, + userPlan, + limit: limitsQ.data, + }; +} + +export function useDependentJobsForMcp(mcpSlug: string | undefined) { + const { user } = useAuth(); + return useQuery({ + queryKey: ["dependent-jobs", user?.id, mcpSlug], + enabled: !!user && !!mcpSlug, + queryFn: async (): Promise<{ id: string; name: string; status: string }[]> => { + const { data, error } = await supabase + .from("scheduled_jobs") + .select("id, name, status, required_mcp_slugs") + .eq("user_id", user!.id); + if (error) throw error; + return (data ?? []) + .filter((j) => Array.isArray(j.required_mcp_slugs) && (j.required_mcp_slugs as string[]).includes(mcpSlug!)) + .map((j) => ({ id: j.id, name: j.name, status: j.status })); + }, + }); +} diff --git a/src/routes/painel.tsx b/src/routes/painel.tsx index 60b3f75..f2b6520 100644 --- a/src/routes/painel.tsx +++ b/src/routes/painel.tsx @@ -59,7 +59,7 @@ export const Route = createFileRoute("/painel")({ }); interface NavItem { - to: "/painel" | "/painel/agente" | "/painel/skills" | "/painel/faturamento" | "/painel/configuracoes"; + to: "/painel" | "/painel/agente" | "/painel/skills" | "/painel/integracoes" | "/painel/faturamento" | "/painel/configuracoes"; label: string; icon: React.ComponentType<{ className?: string }>; disabled?: boolean; @@ -76,7 +76,7 @@ const NAV: (NavItem | DisabledNavItem)[] = [ { to: "/painel", label: "Dashboard", icon: Home }, { to: "/painel/agente", label: "Meu Agente", icon: Bot }, { to: "/painel/skills", label: "Skills", icon: Sparkles }, - { to: null, label: "Integrações", icon: Plug, disabled: true }, + { to: "/painel/integracoes", label: "Integrações", icon: Plug }, { to: "/painel/faturamento", label: "Faturamento", icon: CreditCard }, { to: "/painel/configuracoes", label: "Configurações", icon: Settings }, ];