mirror of
https://github.com/domfelipe/mika-agent-assist.git
synced 2026-08-07 06:16:42 +00:00
Corrigiu search params obrigatório
X-Lovable-Edit-ID: edt-454c8f6f-1bdf-4e9e-9a14-027e8ebe3a47 Co-authored-by: domfelipe <53182096+domfelipe@users.noreply.github.com>
This commit is contained in:
commit
16b1210c5a
18 changed files with 570 additions and 210 deletions
|
|
@ -57,7 +57,7 @@ export function LandingHeader() {
|
|||
<ThemeToggle />
|
||||
{user ? (
|
||||
<Button asChild className="rounded-lg bg-primary hover:bg-primary-dark text-primary-foreground transition-all duration-150 active:scale-[0.98]">
|
||||
<Link to="/painel">Ir para o painel</Link>
|
||||
<Link to="/painel" search={{}}>Ir para o painel</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
|
|
@ -98,7 +98,7 @@ export function LandingHeader() {
|
|||
<div className="mt-6 flex flex-col gap-3">
|
||||
{user ? (
|
||||
<Button asChild className="rounded-lg w-full bg-primary hover:bg-primary-dark text-primary-foreground">
|
||||
<Link to="/painel" onClick={() => setOpen(false)}>Ir para o painel</Link>
|
||||
<Link to="/painel" search={{}} onClick={() => setOpen(false)}>Ir para o painel</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -324,7 +324,7 @@ export function CronjobWizard({ onCreated, onCancel }: Props) {
|
|||
Conecte as integrações faltantes antes de criar.
|
||||
</p>
|
||||
<Button asChild size="sm" variant="outline" className="mt-2">
|
||||
<Link to="/painel/integracoes">Ir para Integrações</Link>
|
||||
<Link to="/painel/integracoes" search={{}}>Ir para Integrações</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ export function IntegrationsDashboardWidget() {
|
|||
</div>
|
||||
</div>
|
||||
<Button asChild variant="ghost" size="sm" className="text-primary hover:text-primary">
|
||||
<Link to="/painel/integracoes">
|
||||
<Link to="/painel/integracoes" search={{}}>
|
||||
Gerenciar <ArrowRight className="ml-1 h-3.5 w-3.5" />
|
||||
</Link>
|
||||
</Button>
|
||||
|
|
@ -52,7 +52,7 @@ export function IntegrationsDashboardWidget() {
|
|||
Conecte serviços como Gmail, Notion e Cal.com para ampliar seu Mika.
|
||||
</p>
|
||||
<Button asChild size="sm" variant="outline" className="rounded-lg">
|
||||
<Link to="/painel/integracoes">Explorar integrações</Link>
|
||||
<Link to="/painel/integracoes" search={{}}>Explorar integrações</Link>
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -1,67 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Loader2, AlertCircle } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { invokeFunction } from "@/lib/invoke-function";
|
||||
|
||||
interface Props {
|
||||
onConfigured: () => void;
|
||||
onSkip: () => void;
|
||||
}
|
||||
|
||||
export function StepConfiguring({ onConfigured, onSkip }: Props) {
|
||||
const [state, setState] = useState<"loading" | "error">("loading");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setState("loading");
|
||||
setError(null);
|
||||
|
||||
(async () => {
|
||||
const { error: err } = await invokeFunction("configure-telegram-webhook");
|
||||
if (cancelled) return;
|
||||
if (err) {
|
||||
setError(err.message);
|
||||
setState("error");
|
||||
return;
|
||||
}
|
||||
window.setTimeout(() => {
|
||||
if (!cancelled) onConfigured();
|
||||
}, 800);
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [attempt, onConfigured]);
|
||||
|
||||
return (
|
||||
<div className="px-6 py-12 flex flex-col items-center justify-center min-h-[320px]">
|
||||
{state === "loading" && (
|
||||
<>
|
||||
<Loader2 className="h-10 w-10 text-primary animate-spin" />
|
||||
<p className="mt-4 text-sm text-muted-foreground">
|
||||
Configurando recebimento de mensagens...
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{state === "error" && (
|
||||
<div className="w-full max-w-md rounded-lg border border-destructive bg-destructive/5 p-6 text-center">
|
||||
<AlertCircle className="mx-auto h-8 w-8 text-destructive" />
|
||||
<h3 className="mt-3 font-semibold">Falha ao configurar webhook</h3>
|
||||
{error && <p className="mt-2 text-sm text-muted-foreground">{error}</p>}
|
||||
<div className="mt-5 flex flex-col sm:flex-row gap-2 justify-center">
|
||||
<Button onClick={() => setAttempt((a) => a + 1)}>Tentar novamente</Button>
|
||||
<Button variant="ghost" onClick={onSkip}>
|
||||
Pular (configurar depois)
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,71 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Copy, Check } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface Props {
|
||||
suggestedName: string;
|
||||
suggestedUsername: string;
|
||||
onNext: () => void;
|
||||
}
|
||||
|
||||
export function StepNaming({ suggestedName, suggestedUsername, onNext }: Props) {
|
||||
return (
|
||||
<div className="px-6 py-8 max-w-2xl mx-auto">
|
||||
<h2 className="text-2xl font-bold tracking-tight">Escolha os nomes</h2>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
O BotFather vai pedir dois nomes. Use nossas sugestões se quiser.
|
||||
</p>
|
||||
|
||||
<div className="mt-6 space-y-4">
|
||||
<CopyField
|
||||
label="Nome do bot"
|
||||
value={suggestedName}
|
||||
help="Esse é o nome que aparece no topo da conversa."
|
||||
/>
|
||||
<CopyField
|
||||
label="Username (termina em bot)"
|
||||
value={suggestedUsername}
|
||||
help="Se o username já estiver em uso, tente adicionar um número no final."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex justify-end">
|
||||
<Button onClick={onNext}>Próximo: colar o token</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CopyField({ label, value, help }: { label: string; value: string; help?: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
async function copy() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
setCopied(true);
|
||||
toast.success("Copiado para a área de transferência");
|
||||
window.setTimeout(() => setCopied(false), 1500);
|
||||
} catch {
|
||||
toast.error("Não foi possível copiar");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-card p-4">
|
||||
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
{label}
|
||||
</label>
|
||||
<div className="mt-2 flex gap-2">
|
||||
<Input value={value} readOnly className="font-mono text-sm" />
|
||||
<Button type="button" variant="outline" size="icon" onClick={copy} aria-label="Copiar">
|
||||
{copied ? <Check className="h-4 w-4 text-success" /> : <Copy className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
{help && <p className="mt-2 text-xs text-muted-foreground">{help}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,37 +1,32 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { X } from "lucide-react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { useProfile } from "@/hooks/use-profile";
|
||||
import { useAgentInstance } from "@/hooks/use-agent-instance";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { suggestBotName, suggestBotUsername } from "@/lib/telegram-username";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { StepWelcome } from "./StepWelcome";
|
||||
import { StepCreateBot } from "./StepCreateBot";
|
||||
import { StepNaming } from "./StepNaming";
|
||||
import { StepToken, type ValidatedBot } from "./StepToken";
|
||||
import { StepConfiguring } from "./StepConfiguring";
|
||||
import { StepWaiting } from "./StepWaiting";
|
||||
|
||||
const STORAGE_KEY = "mika-onboarding-last-step";
|
||||
const TOTAL_STEPS = 6;
|
||||
const TOTAL_STEPS = 4;
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** Step inicial (1-6). Se omitido, lê do localStorage ou começa em 1. */
|
||||
/** Step inicial (1-4). Se omitido, lê do localStorage ou começa em 1. */
|
||||
initialStep?: number;
|
||||
}
|
||||
|
||||
export function TelegramOnboardingWizard({ open, onOpenChange, initialStep }: Props) {
|
||||
const { data: profile } = useProfile();
|
||||
const { data: agent } = useAgentInstance();
|
||||
const { user } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
|
|
@ -39,12 +34,6 @@ export function TelegramOnboardingWizard({ open, onOpenChange, initialStep }: Pr
|
|||
const [step, setStep] = useState(1);
|
||||
const [validated, setValidated] = useState<ValidatedBot | null>(null);
|
||||
|
||||
const suggestedName = useMemo(() => suggestBotName(profile?.full_name), [profile?.full_name]);
|
||||
const suggestedUsername = useMemo(
|
||||
() => suggestBotUsername(profile?.full_name),
|
||||
[profile?.full_name],
|
||||
);
|
||||
|
||||
// Inicializa step ao abrir
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
|
@ -101,13 +90,6 @@ export function TelegramOnboardingWizard({ open, onOpenChange, initialStep }: Pr
|
|||
}
|
||||
}
|
||||
|
||||
function handleConfigured() {
|
||||
if (user) {
|
||||
queryClient.invalidateQueries({ queryKey: ["agent-instance", user.id] });
|
||||
}
|
||||
setStep(6);
|
||||
}
|
||||
|
||||
const botUsername = validated?.bot_username ?? agent?.telegram_bot_username ?? "";
|
||||
const connectedAt = agent?.telegram_connected_at ?? null;
|
||||
|
||||
|
|
@ -118,9 +100,7 @@ export function TelegramOnboardingWizard({ open, onOpenChange, initialStep }: Pr
|
|||
<DialogPrimitive.Content
|
||||
className={cn(
|
||||
"fixed z-50 bg-background shadow-2xl",
|
||||
// Mobile: full-screen
|
||||
"inset-0 sm:inset-auto",
|
||||
// Desktop: dialog grande centralizado
|
||||
"sm:left-[50%] sm:top-[50%] sm:translate-x-[-50%] sm:translate-y-[-50%]",
|
||||
"sm:w-full sm:max-w-2xl sm:rounded-2xl sm:max-h-[90vh] sm:overflow-y-auto",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out",
|
||||
|
|
@ -130,10 +110,9 @@ export function TelegramOnboardingWizard({ open, onOpenChange, initialStep }: Pr
|
|||
Conectar Telegram ao Mika
|
||||
</DialogPrimitive.Title>
|
||||
<DialogPrimitive.Description className="sr-only">
|
||||
Wizard guiado de 6 passos para conectar seu agente Mika ao Telegram.
|
||||
Wizard guiado para conectar seu agente Mika ao Telegram em 4 passos.
|
||||
</DialogPrimitive.Description>
|
||||
|
||||
{/* Header com progress + close */}
|
||||
<div className="sticky top-0 z-10 bg-background/95 backdrop-blur-sm border-b border-border">
|
||||
<div className="flex items-center justify-between px-4 sm:px-6 py-3">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
|
|
@ -157,7 +136,6 @@ export function TelegramOnboardingWizard({ open, onOpenChange, initialStep }: Pr
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Conteúdo dos steps */}
|
||||
<div className="relative overflow-hidden">
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
<motion.div
|
||||
|
|
@ -170,26 +148,13 @@ export function TelegramOnboardingWizard({ open, onOpenChange, initialStep }: Pr
|
|||
{step === 1 && <StepWelcome onNext={() => setStep(2)} />}
|
||||
{step === 2 && <StepCreateBot onNext={() => setStep(3)} />}
|
||||
{step === 3 && (
|
||||
<StepNaming
|
||||
suggestedName={suggestedName}
|
||||
suggestedUsername={suggestedUsername}
|
||||
onNext={() => setStep(4)}
|
||||
/>
|
||||
)}
|
||||
{step === 4 && (
|
||||
<StepToken
|
||||
validated={validated}
|
||||
onValidated={handleValidated}
|
||||
onNext={() => setStep(5)}
|
||||
onNext={() => setStep(4)}
|
||||
/>
|
||||
)}
|
||||
{step === 5 && (
|
||||
<StepConfiguring
|
||||
onConfigured={handleConfigured}
|
||||
onSkip={() => setStep(6)}
|
||||
/>
|
||||
)}
|
||||
{step === 6 && agent && botUsername && (
|
||||
{step === 4 && agent && botUsername && (
|
||||
<StepWaiting
|
||||
agentInstanceId={agent.id}
|
||||
botUsername={botUsername}
|
||||
|
|
@ -197,7 +162,7 @@ export function TelegramOnboardingWizard({ open, onOpenChange, initialStep }: Pr
|
|||
onFinish={handleFinish}
|
||||
/>
|
||||
)}
|
||||
{step === 6 && (!agent || !botUsername) && (
|
||||
{step === 4 && (!agent || !botUsername) && (
|
||||
<div className="px-6 py-12 text-center text-sm text-muted-foreground">
|
||||
Conecte o bot primeiro para receber a primeira mensagem.
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { Route as RedefinirSenhaRouteImport } from './routes/redefinir-senha'
|
|||
import { Route as RecuperarSenhaRouteImport } from './routes/recuperar-senha'
|
||||
import { Route as PainelRouteImport } from './routes/painel'
|
||||
import { Route as LoginRouteImport } from './routes/login'
|
||||
import { Route as AdminRouteImport } from './routes/admin'
|
||||
import { Route as IndexRouteImport } from './routes/index'
|
||||
import { Route as PainelIndexRouteImport } from './routes/painel.index'
|
||||
import { Route as PainelSkillsRouteImport } from './routes/painel.skills'
|
||||
|
|
@ -56,6 +57,11 @@ const LoginRoute = LoginRouteImport.update({
|
|||
path: '/login',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const AdminRoute = AdminRouteImport.update({
|
||||
id: '/admin',
|
||||
path: '/admin',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const IndexRoute = IndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
|
|
@ -139,6 +145,7 @@ const PainelCronjobsIdRoute = PainelCronjobsIdRouteImport.update({
|
|||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'/admin': typeof AdminRoute
|
||||
'/login': typeof LoginRoute
|
||||
'/painel': typeof PainelRouteWithChildren
|
||||
'/recuperar-senha': typeof RecuperarSenhaRoute
|
||||
|
|
@ -162,6 +169,7 @@ export interface FileRoutesByFullPath {
|
|||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
'/admin': typeof AdminRoute
|
||||
'/login': typeof LoginRoute
|
||||
'/recuperar-senha': typeof RecuperarSenhaRoute
|
||||
'/redefinir-senha': typeof RedefinirSenhaRoute
|
||||
|
|
@ -184,6 +192,7 @@ export interface FileRoutesByTo {
|
|||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
'/': typeof IndexRoute
|
||||
'/admin': typeof AdminRoute
|
||||
'/login': typeof LoginRoute
|
||||
'/painel': typeof PainelRouteWithChildren
|
||||
'/recuperar-senha': typeof RecuperarSenhaRoute
|
||||
|
|
@ -209,6 +218,7 @@ export interface FileRouteTypes {
|
|||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
fullPaths:
|
||||
| '/'
|
||||
| '/admin'
|
||||
| '/login'
|
||||
| '/painel'
|
||||
| '/recuperar-senha'
|
||||
|
|
@ -232,6 +242,7 @@ export interface FileRouteTypes {
|
|||
fileRoutesByTo: FileRoutesByTo
|
||||
to:
|
||||
| '/'
|
||||
| '/admin'
|
||||
| '/login'
|
||||
| '/recuperar-senha'
|
||||
| '/redefinir-senha'
|
||||
|
|
@ -253,6 +264,7 @@ export interface FileRouteTypes {
|
|||
id:
|
||||
| '__root__'
|
||||
| '/'
|
||||
| '/admin'
|
||||
| '/login'
|
||||
| '/painel'
|
||||
| '/recuperar-senha'
|
||||
|
|
@ -277,6 +289,7 @@ export interface FileRouteTypes {
|
|||
}
|
||||
export interface RootRouteChildren {
|
||||
IndexRoute: typeof IndexRoute
|
||||
AdminRoute: typeof AdminRoute
|
||||
LoginRoute: typeof LoginRoute
|
||||
PainelRoute: typeof PainelRouteWithChildren
|
||||
RecuperarSenhaRoute: typeof RecuperarSenhaRoute
|
||||
|
|
@ -322,6 +335,13 @@ declare module '@tanstack/react-router' {
|
|||
preLoaderRoute: typeof LoginRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/admin': {
|
||||
id: '/admin'
|
||||
path: '/admin'
|
||||
fullPath: '/admin'
|
||||
preLoaderRoute: typeof AdminRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/': {
|
||||
id: '/'
|
||||
path: '/'
|
||||
|
|
@ -486,6 +506,7 @@ const PainelRouteWithChildren =
|
|||
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
IndexRoute: IndexRoute,
|
||||
AdminRoute: AdminRoute,
|
||||
LoginRoute: LoginRoute,
|
||||
PainelRoute: PainelRouteWithChildren,
|
||||
RecuperarSenhaRoute: RecuperarSenhaRoute,
|
||||
|
|
|
|||
255
src/routes/admin.tsx
Normal file
255
src/routes/admin.tsx
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
"use client";
|
||||
|
||||
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Loader2,
|
||||
PlayCircle,
|
||||
PauseCircle,
|
||||
RotateCw,
|
||||
Server,
|
||||
ShieldAlert,
|
||||
} from "lucide-react";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { invokeFunction } from "@/lib/invoke-function";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
|
||||
export const Route = createFileRoute("/admin")({
|
||||
component: AdminPage,
|
||||
});
|
||||
|
||||
interface AgentRow {
|
||||
id: string;
|
||||
user_id: string;
|
||||
status: string;
|
||||
uuid_tenant: string;
|
||||
telegram_bot_username: string | null;
|
||||
railway_service_id: string | null;
|
||||
vps_pool_id: string | null;
|
||||
created_at: string;
|
||||
provisioned_at: string | null;
|
||||
}
|
||||
|
||||
function AdminPage() {
|
||||
const { user, loading: authLoading } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
|
||||
const { data: isAdmin, isLoading: roleLoading } = useQuery({
|
||||
queryKey: ["is-admin", user?.id],
|
||||
enabled: !!user,
|
||||
queryFn: async () => {
|
||||
const { data, error } = await supabase.rpc("has_role", {
|
||||
_user_id: user!.id,
|
||||
_role: "admin",
|
||||
});
|
||||
if (error) throw error;
|
||||
return data === true;
|
||||
},
|
||||
});
|
||||
|
||||
const { data: agents, isLoading: agentsLoading } = useQuery({
|
||||
queryKey: ["admin-agents"],
|
||||
enabled: !!isAdmin,
|
||||
queryFn: async () => {
|
||||
const { data, error } = await supabase
|
||||
.from("agent_instances")
|
||||
.select(
|
||||
"id, user_id, status, uuid_tenant, telegram_bot_username, railway_service_id, vps_pool_id, created_at, provisioned_at",
|
||||
)
|
||||
.order("created_at", { ascending: false })
|
||||
.limit(100);
|
||||
if (error) throw error;
|
||||
return data as AgentRow[];
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading && !user) {
|
||||
navigate({ to: "/login", search: { redirect: "/admin" } });
|
||||
}
|
||||
}, [authLoading, user, navigate]);
|
||||
|
||||
if (authLoading || roleLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAdmin) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background px-4">
|
||||
<div className="max-w-md text-center space-y-4">
|
||||
<div className="mx-auto h-16 w-16 rounded-full bg-destructive/10 flex items-center justify-center">
|
||||
<ShieldAlert className="h-8 w-8 text-destructive" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold">Acesso negado</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Esta área é restrita a administradores do Mika.
|
||||
</p>
|
||||
<Button asChild variant="outline">
|
||||
<Link to="/painel">
|
||||
<ArrowLeft className="h-4 w-4 mr-2" /> Voltar ao painel
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function action(
|
||||
fn: "provision-agent" | "suspend-agent" | "resume-agent",
|
||||
agentId: string,
|
||||
) {
|
||||
setBusy(agentId + fn);
|
||||
const { data, error } = await invokeFunction<{ ok?: boolean; error?: string }>(fn, {
|
||||
agent_instance_id: agentId,
|
||||
});
|
||||
setBusy(null);
|
||||
if (error) {
|
||||
toast.error(`${fn} falhou: ${error.message}`);
|
||||
} else if (data?.error) {
|
||||
toast.error(`${fn}: ${data.error}`);
|
||||
} else {
|
||||
toast.success(`${fn} executado com sucesso`);
|
||||
queryClient.invalidateQueries({ queryKey: ["admin-agents"] });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background px-4 sm:px-8 py-8">
|
||||
<div className="mx-auto max-w-6xl space-y-6">
|
||||
<header className="flex items-start justify-between gap-4 flex-wrap">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-2">
|
||||
<Server className="h-7 w-7 text-primary" /> Admin · Mika
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Gerencie agentes provisionados, suspenda e reative containers Railway.
|
||||
</p>
|
||||
</div>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link to="/painel">
|
||||
<ArrowLeft className="h-4 w-4 mr-2" /> Voltar ao painel
|
||||
</Link>
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
<div className="rounded-xl border border-border bg-card p-4 sm:p-6 shadow-soft">
|
||||
<h2 className="font-semibold mb-4">Agentes ({agents?.length ?? 0})</h2>
|
||||
{agentsLoading ? (
|
||||
<Skeleton className="h-64 w-full" />
|
||||
) : !agents?.length ? (
|
||||
<p className="text-sm text-muted-foreground py-8 text-center">
|
||||
Nenhum agente cadastrado ainda.
|
||||
</p>
|
||||
) : (
|
||||
<div className="overflow-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Tenant</TableHead>
|
||||
<TableHead>Bot</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Railway</TableHead>
|
||||
<TableHead className="text-right">Ações</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{agents.map((a) => (
|
||||
<TableRow key={a.id}>
|
||||
<TableCell className="font-mono text-xs">
|
||||
{a.uuid_tenant.slice(0, 8)}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
{a.telegram_bot_username ? `@${a.telegram_bot_username}` : "—"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadge status={a.status} />
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">
|
||||
{a.railway_service_id?.slice(0, 8) ?? "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right space-x-1 whitespace-nowrap">
|
||||
{a.status === "provisioning" && !a.railway_service_id && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={busy === a.id + "provision-agent"}
|
||||
onClick={() => action("provision-agent", a.id)}
|
||||
>
|
||||
{busy === a.id + "provision-agent" ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<RotateCw className="h-3.5 w-3.5" />
|
||||
)}
|
||||
<span className="ml-1.5">Provisionar</span>
|
||||
</Button>
|
||||
)}
|
||||
{a.status === "active" && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={busy === a.id + "suspend-agent"}
|
||||
onClick={() => action("suspend-agent", a.id)}
|
||||
>
|
||||
{busy === a.id + "suspend-agent" ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<PauseCircle className="h-3.5 w-3.5" />
|
||||
)}
|
||||
<span className="ml-1.5">Suspender</span>
|
||||
</Button>
|
||||
)}
|
||||
{a.status === "suspended" && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={busy === a.id + "resume-agent"}
|
||||
onClick={() => action("resume-agent", a.id)}
|
||||
>
|
||||
{busy === a.id + "resume-agent" ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<PlayCircle className="h-3.5 w-3.5" />
|
||||
)}
|
||||
<span className="ml-1.5">Reativar</span>
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
if (status === "active") return <Badge variant="success">Ativo</Badge>;
|
||||
if (status === "provisioning") return <Badge variant="secondary">Provisionando</Badge>;
|
||||
if (status === "suspended") return <Badge variant="outline">Suspenso</Badge>;
|
||||
if (status === "error") return <Badge variant="destructive">Erro</Badge>;
|
||||
return <Badge variant="secondary">{status}</Badge>;
|
||||
}
|
||||
|
|
@ -18,7 +18,7 @@ function CheckoutSuccessPage() {
|
|||
// Invalida queries de assinatura para refletir o novo estado quando o webhook chegar
|
||||
queryClient.invalidateQueries({ queryKey: ["subscription"] });
|
||||
const t = setTimeout(() => {
|
||||
navigate({ to: "/painel" });
|
||||
navigate({ to: "/painel", search: {} });
|
||||
}, 6000);
|
||||
return () => clearTimeout(t);
|
||||
}, [navigate, queryClient]);
|
||||
|
|
@ -37,7 +37,7 @@ function CheckoutSuccessPage() {
|
|||
</div>
|
||||
<div className="flex flex-col sm:flex-row gap-3 justify-center">
|
||||
<Button asChild className="rounded-lg bg-primary hover:bg-primary-dark text-primary-foreground">
|
||||
<Link to="/painel">Ir para o painel</Link>
|
||||
<Link to="/painel" search={{}}>Ir para o painel</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline" className="rounded-lg">
|
||||
<Link to="/painel/faturamento">Ver faturamento</Link>
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ function LoginPage() {
|
|||
return;
|
||||
}
|
||||
toast.success("Bem-vindo de volta!");
|
||||
navigate({ to: (redirect as "/painel") || "/painel" });
|
||||
navigate({ to: (redirect as "/painel") || "/painel", search: {} });
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -149,7 +149,7 @@ function CronjobDetailPage() {
|
|||
))}
|
||||
</ul>
|
||||
<Button asChild size="sm" variant="outline" className="mt-3">
|
||||
<Link to="/painel/integracoes">Conectar agora</Link>
|
||||
<Link to="/painel/integracoes" search={{}}>Conectar agora</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { zodValidator, fallback } from "@tanstack/zod-adapter";
|
||||
import { z } from "zod";
|
||||
import { useEffect, useState } from "react";
|
||||
import { ArrowRight, CheckCircle2, Loader2, Sparkles } from "lucide-react";
|
||||
import { useSubscription } from "@/hooks/use-profile";
|
||||
|
|
@ -19,12 +17,12 @@ import { TelegramOnboardingWizard } from "@/components/mika/telegram/TelegramOnb
|
|||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const dashboardSearchSchema = z.object({
|
||||
status: fallback(z.string().optional(), undefined),
|
||||
});
|
||||
type DashboardSearch = { status?: string };
|
||||
|
||||
export const Route = createFileRoute("/painel/")({
|
||||
validateSearch: zodValidator(dashboardSearchSchema),
|
||||
validateSearch: (search: Record<string, unknown>): DashboardSearch => ({
|
||||
status: typeof search.status === "string" ? search.status : undefined,
|
||||
}),
|
||||
component: DashboardPage,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ function IntegrationDetailPage() {
|
|||
return (
|
||||
<div className="space-y-4">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link to="/painel/integracoes">
|
||||
<Link to="/painel/integracoes" search={{}}>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" /> Voltar
|
||||
</Link>
|
||||
</Button>
|
||||
|
|
@ -84,7 +84,7 @@ function IntegrationDetailPage() {
|
|||
return (
|
||||
<div className="space-y-4">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link to="/painel/integracoes">
|
||||
<Link to="/painel/integracoes" search={{}}>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" /> Voltar
|
||||
</Link>
|
||||
</Button>
|
||||
|
|
@ -93,7 +93,7 @@ function IntegrationDetailPage() {
|
|||
Você ainda não conectou {mcp.name}.
|
||||
</p>
|
||||
<Button asChild className="mt-4">
|
||||
<Link to="/painel/integracoes">Conectar</Link>
|
||||
<Link to="/painel/integracoes" search={{}}>Conectar</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -154,7 +154,7 @@ function IntegrationDetailPage() {
|
|||
return (
|
||||
<div className="space-y-6">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link to="/painel/integracoes">
|
||||
<Link to="/painel/integracoes" search={{}}>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" /> Voltar
|
||||
</Link>
|
||||
</Button>
|
||||
|
|
@ -298,7 +298,7 @@ function IntegrationDetailPage() {
|
|||
.then(() => {
|
||||
const stillConnected = integs.some((i) => i.id === integration.id);
|
||||
if (!stillConnected) {
|
||||
navigate({ to: "/painel/integracoes" });
|
||||
navigate({ to: "/painel/integracoes", search: {} });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { zodValidator, fallback } from "@tanstack/zod-adapter";
|
||||
import { z } from "zod";
|
||||
import { Plug } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
|
@ -11,14 +9,14 @@ import { useIntegrationCards } from "@/hooks/use-integrations";
|
|||
import { useAgentInstance } from "@/hooks/use-agent-instance";
|
||||
import { IntegrationCard } from "@/components/mika/integrations/IntegrationCard";
|
||||
|
||||
const integracoesSearchSchema = z.object({
|
||||
status: fallback(z.string().optional(), undefined),
|
||||
error: fallback(z.string().optional(), undefined),
|
||||
mcp: fallback(z.string().optional(), undefined),
|
||||
});
|
||||
type IntegracoesSearch = { status?: string; error?: string; mcp?: string };
|
||||
|
||||
export const Route = createFileRoute("/painel/integracoes/")({
|
||||
validateSearch: zodValidator(integracoesSearchSchema),
|
||||
validateSearch: (search: Record<string, unknown>): IntegracoesSearch => ({
|
||||
status: typeof search.status === "string" ? search.status : undefined,
|
||||
error: typeof search.error === "string" ? search.error : undefined,
|
||||
mcp: typeof search.mcp === "string" ? search.mcp : undefined,
|
||||
}),
|
||||
component: IntegracoesPage,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -86,9 +86,9 @@ function SignupPage() {
|
|||
toast.success("Conta criada!");
|
||||
if (plan && plan !== "enterprise") {
|
||||
// TODO Etapa 3: chamar create-checkout-session com plan/cycle
|
||||
navigate({ to: "/painel" });
|
||||
navigate({ to: "/painel", search: {} });
|
||||
} else {
|
||||
navigate({ to: "/painel" });
|
||||
navigate({ to: "/painel", search: {} });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
94
supabase/functions/resume-agent/index.ts
Normal file
94
supabase/functions/resume-agent/index.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
// resume-agent
|
||||
// Retoma o serviço Railway (numReplicas=1) e marca agent_instance.status='active'.
|
||||
// Disparado automaticamente quando subscription volta para active/trialing,
|
||||
// ou manualmente pelo painel admin.
|
||||
|
||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.45.4";
|
||||
import { corsHeaders } from "../_shared/cors.ts";
|
||||
import { setRailwayReplicas } from "../_shared/railway.ts";
|
||||
|
||||
const SUPABASE_URL = Deno.env.get("SUPABASE_URL")!;
|
||||
const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
|
||||
const RAILWAY_API_TOKEN = Deno.env.get("RAILWAY_API_TOKEN");
|
||||
|
||||
interface RequestBody {
|
||||
agent_instance_id: string;
|
||||
}
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
if (req.method === "OPTIONS") return new Response(null, { headers: corsHeaders });
|
||||
|
||||
if (!RAILWAY_API_TOKEN) {
|
||||
return jsonResponse(500, { error: "RAILWAY_API_TOKEN not configured" });
|
||||
}
|
||||
|
||||
let body: RequestBody;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return jsonResponse(400, { error: "invalid json body" });
|
||||
}
|
||||
|
||||
if (!body.agent_instance_id) {
|
||||
return jsonResponse(400, { error: "agent_instance_id required" });
|
||||
}
|
||||
|
||||
const supabase = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, {
|
||||
auth: { persistSession: false, autoRefreshToken: false },
|
||||
});
|
||||
|
||||
const { data: agent } = await supabase
|
||||
.from("agent_instances")
|
||||
.select("id, status, railway_service_id, vps_pool_id")
|
||||
.eq("id", body.agent_instance_id)
|
||||
.maybeSingle();
|
||||
|
||||
if (!agent) return jsonResponse(404, { error: "agent_instance not found" });
|
||||
|
||||
if (!agent.railway_service_id || !agent.vps_pool_id) {
|
||||
return jsonResponse(409, {
|
||||
error: "agent has no container — needs full provisioning instead",
|
||||
});
|
||||
}
|
||||
|
||||
if (agent.status === "active") {
|
||||
return jsonResponse(200, { ok: true, already_active: true });
|
||||
}
|
||||
|
||||
const { data: pool } = await supabase
|
||||
.from("vps_pool")
|
||||
.select("railway_environment_id")
|
||||
.eq("id", agent.vps_pool_id)
|
||||
.maybeSingle();
|
||||
|
||||
if (!pool?.railway_environment_id) {
|
||||
return jsonResponse(500, { error: "pool environment not configured" });
|
||||
}
|
||||
|
||||
try {
|
||||
await setRailwayReplicas({
|
||||
token: RAILWAY_API_TOKEN,
|
||||
serviceId: agent.railway_service_id,
|
||||
environmentId: pool.railway_environment_id,
|
||||
replicas: 1,
|
||||
});
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
console.error("resume-agent: setRailwayReplicas failed:", msg);
|
||||
return jsonResponse(500, { error: "railway scale-up failed", detail: msg });
|
||||
}
|
||||
|
||||
await supabase
|
||||
.from("agent_instances")
|
||||
.update({ status: "active", last_health_check_at: new Date().toISOString() })
|
||||
.eq("id", agent.id);
|
||||
|
||||
return jsonResponse(200, { ok: true, agent_id: agent.id, new_status: "active" });
|
||||
});
|
||||
|
||||
function jsonResponse(status: number, body: unknown) {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
95
supabase/functions/suspend-agent/index.ts
Normal file
95
supabase/functions/suspend-agent/index.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
// suspend-agent
|
||||
// Pausa o serviço Railway (numReplicas=0) e marca agent_instance.status='suspended'.
|
||||
// Disparado automaticamente quando subscription muda para canceled/past_due/unpaid/paused,
|
||||
// ou manualmente pelo painel admin.
|
||||
|
||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.45.4";
|
||||
import { corsHeaders } from "../_shared/cors.ts";
|
||||
import { setRailwayReplicas } from "../_shared/railway.ts";
|
||||
|
||||
const SUPABASE_URL = Deno.env.get("SUPABASE_URL")!;
|
||||
const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
|
||||
const RAILWAY_API_TOKEN = Deno.env.get("RAILWAY_API_TOKEN");
|
||||
|
||||
interface RequestBody {
|
||||
agent_instance_id: string;
|
||||
}
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
if (req.method === "OPTIONS") return new Response(null, { headers: corsHeaders });
|
||||
|
||||
if (!RAILWAY_API_TOKEN) {
|
||||
return jsonResponse(500, { error: "RAILWAY_API_TOKEN not configured" });
|
||||
}
|
||||
|
||||
let body: RequestBody;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return jsonResponse(400, { error: "invalid json body" });
|
||||
}
|
||||
|
||||
if (!body.agent_instance_id) {
|
||||
return jsonResponse(400, { error: "agent_instance_id required" });
|
||||
}
|
||||
|
||||
const supabase = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, {
|
||||
auth: { persistSession: false, autoRefreshToken: false },
|
||||
});
|
||||
|
||||
const { data: agent } = await supabase
|
||||
.from("agent_instances")
|
||||
.select("id, status, railway_service_id, vps_pool_id")
|
||||
.eq("id", body.agent_instance_id)
|
||||
.maybeSingle();
|
||||
|
||||
if (!agent) return jsonResponse(404, { error: "agent_instance not found" });
|
||||
if (agent.status === "suspended") {
|
||||
return jsonResponse(200, { ok: true, already_suspended: true });
|
||||
}
|
||||
if (!agent.railway_service_id || !agent.vps_pool_id) {
|
||||
// Sem container provisionado ainda — só marca o status
|
||||
await supabase
|
||||
.from("agent_instances")
|
||||
.update({ status: "suspended" })
|
||||
.eq("id", agent.id);
|
||||
return jsonResponse(200, { ok: true, no_container: true });
|
||||
}
|
||||
|
||||
const { data: pool } = await supabase
|
||||
.from("vps_pool")
|
||||
.select("railway_environment_id")
|
||||
.eq("id", agent.vps_pool_id)
|
||||
.maybeSingle();
|
||||
|
||||
if (!pool?.railway_environment_id) {
|
||||
return jsonResponse(500, { error: "pool environment not configured" });
|
||||
}
|
||||
|
||||
try {
|
||||
await setRailwayReplicas({
|
||||
token: RAILWAY_API_TOKEN,
|
||||
serviceId: agent.railway_service_id,
|
||||
environmentId: pool.railway_environment_id,
|
||||
replicas: 0,
|
||||
});
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
console.error("suspend-agent: setRailwayReplicas failed:", msg);
|
||||
return jsonResponse(500, { error: "railway scale-down failed", detail: msg });
|
||||
}
|
||||
|
||||
await supabase
|
||||
.from("agent_instances")
|
||||
.update({ status: "suspended" })
|
||||
.eq("id", agent.id);
|
||||
|
||||
return jsonResponse(200, { ok: true, agent_id: agent.id, new_status: "suspended" });
|
||||
});
|
||||
|
||||
function jsonResponse(status: number, body: unknown) {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
-- Trigger para chamar suspend-agent quando subscription fica inactive (canceled/past_due)
|
||||
-- e resume-agent quando volta para active.
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.trigger_suspend_or_resume_agent()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = 'public', 'extensions'
|
||||
AS $$
|
||||
DECLARE
|
||||
v_supabase_url text;
|
||||
v_anon_key text;
|
||||
v_function text;
|
||||
v_agent_id uuid;
|
||||
BEGIN
|
||||
-- Só age em UPDATE, não em INSERT inicial
|
||||
IF TG_OP <> 'UPDATE' THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
IF NEW.status = OLD.status THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
-- Determina ação: active/trialing => resume; canceled/past_due/unpaid => suspend
|
||||
IF NEW.status IN ('active', 'trialing') AND OLD.status NOT IN ('active', 'trialing') THEN
|
||||
v_function := 'resume-agent';
|
||||
ELSIF NEW.status IN ('canceled', 'past_due', 'unpaid', 'paused') AND OLD.status IN ('active', 'trialing') THEN
|
||||
v_function := 'suspend-agent';
|
||||
ELSE
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
-- Busca o agent_instance correspondente
|
||||
SELECT id INTO v_agent_id FROM public.agent_instances WHERE user_id = NEW.user_id LIMIT 1;
|
||||
IF v_agent_id IS NULL THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
-- Lê config
|
||||
BEGIN
|
||||
SELECT decrypted_secret INTO v_supabase_url
|
||||
FROM vault.decrypted_secrets WHERE name = 'project_url' LIMIT 1;
|
||||
SELECT decrypted_secret INTO v_anon_key
|
||||
FROM vault.decrypted_secrets WHERE name = 'anon_key' LIMIT 1;
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
v_supabase_url := NULL;
|
||||
END;
|
||||
|
||||
IF v_supabase_url IS NULL OR v_anon_key IS NULL THEN
|
||||
RAISE LOG 'trigger_suspend_or_resume_agent: vault não configurado, pulando';
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
PERFORM net.http_post(
|
||||
url := v_supabase_url || '/functions/v1/' || v_function,
|
||||
headers := jsonb_build_object(
|
||||
'Content-Type', 'application/json',
|
||||
'Authorization', 'Bearer ' || v_anon_key
|
||||
),
|
||||
body := jsonb_build_object('agent_instance_id', v_agent_id)
|
||||
);
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP TRIGGER IF EXISTS subscription_status_change_trigger ON public.subscriptions;
|
||||
CREATE TRIGGER subscription_status_change_trigger
|
||||
AFTER UPDATE ON public.subscriptions
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION public.trigger_suspend_or_resume_agent();
|
||||
Loading…
Add table
Add a link
Reference in a new issue