diff --git a/src/components/mika/skills/SkillCard.tsx b/src/components/mika/skills/SkillCard.tsx index ad44797..1c31def 100644 --- a/src/components/mika/skills/SkillCard.tsx +++ b/src/components/mika/skills/SkillCard.tsx @@ -9,6 +9,7 @@ import { ptBR } from "date-fns/locale"; import { toast } from "sonner"; import { supabase } from "@/integrations/supabase/client"; import { syncAgentSkills } from "@/lib/sync-agent-skills"; +import { deleteSkill } from "@/lib/delete-skill"; import { Button } from "@/components/ui/button"; import { DropdownMenu, @@ -76,16 +77,37 @@ export function SkillCard({ skill }: { skill: Skill }) { }; const handleArchive = () => { - updateStatus.mutate("archived", { - onSuccess: async () => { - toast.success("Skill arquivada"); - setConfirmArchive(false); - await syncRuntimeAfterMutation("Skill arquivada"); - }, - onError: (e: unknown) => toast.error(e instanceof Error ? e.message : "Erro ao arquivar"), - }); + handleArchiveMutation.mutate("archive"); }; + const handleArchiveMutation = useMutation({ + mutationFn: async (action: "archive" | "delete") => { + const { data, error } = await deleteSkill(skill.id, action); + if (error) throw new Error(error.message); + if (!data?.success) throw new Error("Erro ao atualizar skill."); + return data; + }, + onSuccess: (data, action) => { + if (data.runtime_sync_warning) { + toast.warning( + action === "delete" + ? "Skill arquivada; exclusão final do runtime pendente." + : "Skill arquivada no painel; sync com o runtime pendente.", + ); + } else { + toast.success(action === "delete" ? "Skill deletada" : "Skill arquivada"); + } + if (action === "archive") { + setConfirmArchive(false); + } else { + setConfirmDelete(false); + } + qc.invalidateQueries({ queryKey: ["skills"] }); + qc.invalidateQueries({ queryKey: ["user-limits"] }); + }, + onError: (e: unknown) => toast.error(e instanceof Error ? e.message : "Erro ao atualizar"), + }); + const handleRestore = () => { updateStatus.mutate("draft", { onSuccess: async () => { @@ -104,15 +126,20 @@ export function SkillCard({ skill }: { skill: Skill }) { const handleDelete = useMutation({ mutationFn: async () => { - const { error } = await supabase.from("skills").delete().eq("id", skill.id); - if (error) throw error; + const { data, error } = await deleteSkill(skill.id, "delete"); + if (error) throw new Error(error.message); + if (!data?.success) throw new Error("Erro ao deletar skill."); + return data; }, - onSuccess: async () => { - toast.success("Skill deletada"); + onSuccess: (data) => { + if (data.runtime_sync_warning) { + toast.warning("Skill arquivada; exclusão final do runtime pendente."); + } else { + toast.success("Skill deletada"); + } qc.invalidateQueries({ queryKey: ["skills"] }); qc.invalidateQueries({ queryKey: ["user-limits"] }); setConfirmDelete(false); - await syncRuntimeAfterMutation("Skill deletada"); }, onError: (e: unknown) => toast.error(e instanceof Error ? e.message : "Erro ao deletar"), }); diff --git a/src/lib/delete-skill.ts b/src/lib/delete-skill.ts new file mode 100644 index 0000000..6b59081 --- /dev/null +++ b/src/lib/delete-skill.ts @@ -0,0 +1,22 @@ +"use client"; + +import { invokeFunction } from "@/lib/invoke-function"; + +export interface DeleteSkillResponse { + success: boolean; + skill_id: string; + agent_instance_id: string; + archived: boolean; + deleted: boolean; + runtime_sync_warning: string | null; +} + +export async function deleteSkill( + skillId: string, + action: "archive" | "delete" = "archive", +) { + return await invokeFunction("delete-skill", { + skill_id: skillId, + action, + }); +} diff --git a/src/routes/painel.skills.$id.tsx b/src/routes/painel.skills.$id.tsx index 3792ea8..aba9258 100644 --- a/src/routes/painel.skills.$id.tsx +++ b/src/routes/painel.skills.$id.tsx @@ -12,7 +12,7 @@ import { toast } from "sonner"; import { supabase } from "@/integrations/supabase/client"; import { useAuth } from "@/hooks/use-auth"; import { useSkill } from "@/hooks/use-skills"; -import { syncAgentSkills } from "@/lib/sync-agent-skills"; +import { deleteSkill } from "@/lib/delete-skill"; import { SkillStatusBadge } from "@/components/mika/skills/SkillStatusBadge"; import { SkillTestPanel } from "@/components/mika/skills/SkillTestPanel"; import { Button } from "@/components/ui/button"; @@ -171,25 +171,19 @@ function SkillDetailPage() { // Archive const archiveSkill = useMutation({ mutationFn: async () => { - const { error } = await supabase - .from("skills") - .update({ status: "archived", updated_at: new Date().toISOString() }) - .eq("id", id); - if (error) throw error; + const { data, error } = await deleteSkill(id, "archive"); + if (error) throw new Error(error.message); + if (!data?.success) throw new Error("Erro ao arquivar skill."); + return data; }, - onSuccess: () => { - toast.success("Skill arquivada"); + onSuccess: (data) => { + if (data.runtime_sync_warning) { + toast.warning("Skill arquivada no painel; sync com o runtime pendente."); + } else { + toast.success("Skill arquivada"); + } qc.invalidateQueries({ queryKey: ["skills"] }); qc.invalidateQueries({ queryKey: ["user-limits"] }); - if (skill.data?.agent_instance_id) { - void syncAgentSkills(skill.data.agent_instance_id).then(({ error }) => { - if (error) { - toast.warning("Skill arquivada, mas o sync com o container falhou.", { - description: error.message, - }); - } - }); - } navigate({ to: "/painel/skills" }); }, onError: (e: unknown) => toast.error(e instanceof Error ? e.message : "Erro"), diff --git a/supabase/functions/delete-skill/index.ts b/supabase/functions/delete-skill/index.ts new file mode 100644 index 0000000..6e12fee --- /dev/null +++ b/supabase/functions/delete-skill/index.ts @@ -0,0 +1,180 @@ +// delete-skill (authenticated) +// +// Archives or deletes a Mika-managed skill through the server so the runtime is +// synchronized before the platform removes the source row. + +import { createClient } from "https://esm.sh/@supabase/supabase-js@2.57.4"; +import { corsHeaders } from "../_shared/cors.ts"; +import { syncAgentSkillsSnapshot } from "../_shared/runtime-sync.ts"; + +const SUPABASE_URL = Deno.env.get("SUPABASE_URL")!; +const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!; +const SUPABASE_ANON_KEY = Deno.env.get("SUPABASE_ANON_KEY")!; +const RAILWAY_API_TOKEN = Deno.env.get("RAILWAY_API_TOKEN") ?? ""; +const HERMES_API_SERVER_KEY = Deno.env.get("HERMES_API_SERVER_KEY") ?? ""; + +type DeleteSkillAction = "archive" | "delete"; + +interface DeleteSkillBody { + skill_id?: string; + action?: DeleteSkillAction; +} + +interface SkillForDelete { + id: string; + user_id: string; + agent_instance_id: string; + name: string; + status: string; + is_default: boolean | null; +} + +function jsonResponse(status: number, body: unknown) { + return new Response(JSON.stringify(body), { + status, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function isValidAction(action: unknown): action is DeleteSkillAction { + return action === "archive" || action === "delete"; +} + +async function syncSkills( + // deno-lint-ignore no-explicit-any + admin: any, + agentInstanceId: string, +) { + return await syncAgentSkillsSnapshot({ + supabase: admin, + agentInstanceId, + railwayToken: RAILWAY_API_TOKEN, + apiKey: HERMES_API_SERVER_KEY, + }); +} + +Deno.serve(async (req) => { + if (req.method === "OPTIONS") { + return new Response(null, { headers: corsHeaders }); + } + + if (req.method !== "POST") { + return jsonResponse(405, { error: "method not allowed" }); + } + + const authHeader = req.headers.get("Authorization") ?? ""; + const jwt = authHeader.replace(/^Bearer\s+/i, ""); + if (!jwt) { + return jsonResponse(401, { error: "missing authorization" }); + } + + let body: DeleteSkillBody; + try { + body = await req.json() as DeleteSkillBody; + } catch { + return jsonResponse(400, { error: "invalid json body" }); + } + + if (!body.skill_id) { + return jsonResponse(400, { error: "skill_id required" }); + } + + const action = body.action ?? "archive"; + if (!isValidAction(action)) { + return jsonResponse(400, { error: "invalid action" }); + } + + const userClient = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, { + global: { headers: { Authorization: `Bearer ${jwt}` } }, + auth: { persistSession: false, autoRefreshToken: false }, + }); + + const { data: userData, error: userErr } = await userClient.auth.getUser(); + if (userErr || !userData?.user) { + return jsonResponse(401, { error: "invalid token" }); + } + + const admin = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, { + auth: { persistSession: false, autoRefreshToken: false }, + }); + + const { data: skillData, error: skillErr } = await admin + .from("skills") + .select("id, user_id, agent_instance_id, name, status, is_default") + .eq("id", body.skill_id) + .maybeSingle(); + + if (skillErr) { + return jsonResponse(500, { error: "failed to load skill", detail: skillErr.message }); + } + if (!skillData) { + return jsonResponse(404, { error: "skill not found" }); + } + + const skill = skillData as SkillForDelete; + const { data: isAdmin, error: roleErr } = await admin.rpc("has_role", { + _user_id: userData.user.id, + _role: "admin", + }); + if (roleErr) { + return jsonResponse(500, { error: "failed to resolve role" }); + } + + if (skill.user_id !== userData.user.id && !isAdmin) { + return jsonResponse(403, { error: "forbidden" }); + } + + if (skill.is_default) { + return jsonResponse(403, { error: "default skills cannot be archived or deleted" }); + } + + if (skill.status !== "archived") { + const { error: archiveErr } = await admin + .from("skills") + .update({ + status: "archived", + updated_at: new Date().toISOString(), + }) + .eq("id", skill.id); + + if (archiveErr) { + return jsonResponse(500, { error: "failed to archive skill", detail: archiveErr.message }); + } + } + + let runtimeSyncWarning: string | null = null; + try { + await syncSkills(admin, skill.agent_instance_id); + } catch (err) { + runtimeSyncWarning = errorMessage(err); + console.error("delete-skill runtime sync warning:", runtimeSyncWarning); + } + + let deleted = false; + if (action === "delete" && !runtimeSyncWarning) { + const { error: deleteErr } = await admin + .from("skills") + .delete() + .eq("id", skill.id); + + if (deleteErr) { + runtimeSyncWarning = `DB delete failed after archive sync: ${deleteErr.message}`; + console.error("delete-skill hard delete warning:", runtimeSyncWarning); + } else { + deleted = true; + } + } + + return jsonResponse(200, { + success: true, + skill_id: skill.id, + agent_instance_id: skill.agent_instance_id, + archived: true, + deleted, + runtime_sync_warning: runtimeSyncWarning, + }); +});