@@ -84,7 +84,7 @@ function IntegrationDetailPage() {
return (
@@ -93,7 +93,7 @@ function IntegrationDetailPage() {
Você ainda não conectou {mcp.name}.
@@ -154,7 +154,7 @@ function IntegrationDetailPage() {
return (
@@ -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: {} });
}
});
}
diff --git a/src/routes/painel.integracoes.index.tsx b/src/routes/painel.integracoes.index.tsx
index 6ff9dce..0855b81 100644
--- a/src/routes/painel.integracoes.index.tsx
+++ b/src/routes/painel.integracoes.index.tsx
@@ -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
): 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,
});
diff --git a/src/routes/signup.tsx b/src/routes/signup.tsx
index 6536f40..c4f2583 100644
--- a/src/routes/signup.tsx
+++ b/src/routes/signup.tsx
@@ -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;
}
diff --git a/supabase/functions/resume-agent/index.ts b/supabase/functions/resume-agent/index.ts
new file mode 100644
index 0000000..525a208
--- /dev/null
+++ b/supabase/functions/resume-agent/index.ts
@@ -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" },
+ });
+}
diff --git a/supabase/functions/suspend-agent/index.ts b/supabase/functions/suspend-agent/index.ts
new file mode 100644
index 0000000..f81837b
--- /dev/null
+++ b/supabase/functions/suspend-agent/index.ts
@@ -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" },
+ });
+}
diff --git a/supabase/migrations/20260422172628_59cd91b6-2e03-4e6a-9b92-1bcab3625f23.sql b/supabase/migrations/20260422172628_59cd91b6-2e03-4e6a-9b92-1bcab3625f23.sql
new file mode 100644
index 0000000..4585be3
--- /dev/null
+++ b/supabase/migrations/20260422172628_59cd91b6-2e03-4e6a-9b92-1bcab3625f23.sql
@@ -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();
\ No newline at end of file