diff --git a/src/components/mika/cronjobs/CronjobCard.tsx b/src/components/mika/cronjobs/CronjobCard.tsx index 955e723..3c5a384 100644 --- a/src/components/mika/cronjobs/CronjobCard.tsx +++ b/src/components/mika/cronjobs/CronjobCard.tsx @@ -68,14 +68,13 @@ export function CronjobCard({ job }: Props) { async function remove() { try { - await deleteMut.mutateAsync(job.id); - toast.success("Automação excluída."); - setConfirmDelete(false); - - const { error: syncError } = await syncAgentRuntime(job.agent_instance_id, "cronjobs"); - if (syncError) { - toast.warning("Automação removida, mas o runtime do agente não sincronizou."); + const result = await deleteMut.mutateAsync(job.id); + if (result.runtime_sync_warning) { + toast.warning("Automação removida do painel; limpeza final do runtime pendente."); + } else { + toast.success("Automação excluída."); } + setConfirmDelete(false); } catch (e) { toast.error(e instanceof Error ? e.message : "Erro ao excluir."); } diff --git a/src/hooks/use-cronjobs.ts b/src/hooks/use-cronjobs.ts index 2a87444..87652a0 100644 --- a/src/hooks/use-cronjobs.ts +++ b/src/hooks/use-cronjobs.ts @@ -3,6 +3,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { supabase } from "@/integrations/supabase/client"; import { useAuth } from "@/hooks/use-auth"; +import { invokeFunction } from "@/lib/invoke-function"; export interface ScheduledJob { id: string; @@ -49,6 +50,7 @@ export function useCronjobs() { .from("scheduled_jobs") .select("*") .eq("user_id", user!.id) + .neq("status", "archived") .order("created_at", { ascending: false }); if (error) throw error; return (data ?? []).map(normalizeJob); @@ -67,6 +69,7 @@ export function useCronjob(id: string | undefined) { .select("*") .eq("id", id!) .eq("user_id", user!.id) + .neq("status", "archived") .maybeSingle(); if (error) throw error; return data ? normalizeJob(data) : null; @@ -135,7 +138,7 @@ export function useCreateCronjob() { if (error) throw error; return normalizeJob(data); }, - onSuccess: () => { + onSettled: () => { qc.invalidateQueries({ queryKey: ["cronjobs"] }); qc.invalidateQueries({ queryKey: ["user-jobs-limits"] }); }, @@ -178,8 +181,17 @@ export function useDeleteCronjob() { const qc = useQueryClient(); return useMutation({ mutationFn: async (id: string) => { - const { error } = await supabase.from("scheduled_jobs").delete().eq("id", id); - if (error) throw error; + const { data, error } = await invokeFunction<{ + success: boolean; + job_id: string; + agent_instance_id: string; + archived: boolean; + deleted: boolean; + runtime_sync_warning: string | null; + }>("delete-cronjob", { job_id: id }); + if (error) throw new Error(error.message); + if (!data?.success) throw new Error("Erro ao excluir automação."); + return data; }, onSuccess: () => { qc.invalidateQueries({ queryKey: ["cronjobs"] }); diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 0bb5aca..0fe84b5 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -670,12 +670,3 @@ const rootRouteChildren: RootRouteChildren = { export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) ._addFileTypes() - -import type { getRouter } from './router.tsx' -import type { createStart } from '@tanstack/react-start' -declare module '@tanstack/react-start' { - interface Register { - ssr: true - router: Awaited> - } -} diff --git a/src/routes/painel.cronjobs.$id.tsx b/src/routes/painel.cronjobs.$id.tsx index a7809d9..fc10478 100644 --- a/src/routes/painel.cronjobs.$id.tsx +++ b/src/routes/painel.cronjobs.$id.tsx @@ -88,12 +88,11 @@ function CronjobDetailPage() { async function remove() { try { - await deleteMut.mutateAsync(job!.id); - toast.success("Excluída."); - - const { error: syncError } = await syncAgentRuntime(job!.agent_instance_id, "cronjobs"); - if (syncError) { - toast.warning("Automação removida, mas o runtime do agente não sincronizou."); + const result = await deleteMut.mutateAsync(job!.id); + if (result.runtime_sync_warning) { + toast.warning("Excluída do painel; limpeza final do runtime pendente."); + } else { + toast.success("Excluída."); } navigate({ to: "/painel/cronjobs" }); diff --git a/supabase/functions/delete-cronjob/index.ts b/supabase/functions/delete-cronjob/index.ts new file mode 100644 index 0000000..04075a4 --- /dev/null +++ b/supabase/functions/delete-cronjob/index.ts @@ -0,0 +1,230 @@ +// delete-cronjob (authenticated) +// +// Server-side deletion for Mika managed cronjobs. The flow intentionally avoids +// deleting the DB row before the runtime has received a safe state: +// 1) pause the job and sync, so the runtime stops executing it; +// 2) archive the job and sync again, so the runtime removes it; +// 3) hard-delete the archived row from Supabase. + +import { createClient } from "https://esm.sh/@supabase/supabase-js@2.57.4"; +import { corsHeaders } from "../_shared/cors.ts"; +import { syncAgentCronjobsSnapshot } 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") ?? ""; + +interface DeleteCronjobBody { + job_id?: string; +} + +interface ScheduledJobForDelete { + id: string; + user_id: string; + agent_instance_id: string; + name: string; + status: string; +} + +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 truncateDetail(detail: string): string { + return detail.slice(0, 2000); +} + +async function markRuntimeSyncError( + // deno-lint-ignore no-explicit-any + admin: any, + jobId: string, + detail: string, +): Promise { + const { error } = await admin + .from("scheduled_jobs") + .update({ + runtime_state: "error", + runtime_last_status: "error", + runtime_last_error: truncateDetail(detail), + }) + .eq("id", jobId); + + if (error) { + console.error("delete-cronjob failed to persist runtime sync error:", error.message); + } +} + +async function syncCronjobs( + // deno-lint-ignore no-explicit-any + admin: any, + agentInstanceId: string, +) { + return await syncAgentCronjobsSnapshot({ + 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: DeleteCronjobBody; + try { + body = await req.json() as DeleteCronjobBody; + } catch { + return jsonResponse(400, { error: "invalid json body" }); + } + + if (!body.job_id) { + return jsonResponse(400, { error: "job_id required" }); + } + + 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: jobData, error: jobErr } = await admin + .from("scheduled_jobs") + .select("id, user_id, agent_instance_id, name, status") + .eq("id", body.job_id) + .maybeSingle(); + + if (jobErr) { + return jsonResponse(500, { error: "failed to load cronjob", detail: jobErr.message }); + } + if (!jobData) { + return jsonResponse(404, { error: "cronjob not found" }); + } + + const job = jobData as ScheduledJobForDelete; + 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 (job.user_id !== userData.user.id && !isAdmin) { + return jsonResponse(403, { error: "forbidden" }); + } + + if (job.status !== "archived") { + const { error: pauseErr } = await admin + .from("scheduled_jobs") + .update({ + status: "paused", + auto_paused_reason: "Exclusão em andamento pelo usuário.", + runtime_state: "paused", + runtime_last_status: null, + runtime_last_error: null, + runtime_last_delivery_error: null, + }) + .eq("id", job.id); + + if (pauseErr) { + return jsonResponse(500, { + error: "failed to pause cronjob before deletion", + detail: pauseErr.message, + }); + } + + try { + await syncCronjobs(admin, job.agent_instance_id); + } catch (err) { + const detail = errorMessage(err); + await markRuntimeSyncError(admin, job.id, detail); + console.error("delete-cronjob pause sync failed:", detail); + return jsonResponse(502, { + error: "runtime pause sync failed", + detail, + }); + } + } + + const { error: archiveErr } = await admin + .from("scheduled_jobs") + .update({ + status: "archived", + auto_paused_reason: "Excluída pelo usuário.", + runtime_state: "paused", + runtime_last_status: null, + runtime_last_error: null, + runtime_last_delivery_error: null, + }) + .eq("id", job.id); + + if (archiveErr) { + return jsonResponse(500, { + error: "failed to archive cronjob", + detail: archiveErr.message, + }); + } + + let runtimeSyncWarning: string | null = null; + try { + await syncCronjobs(admin, job.agent_instance_id); + } catch (err) { + runtimeSyncWarning = errorMessage(err); + await markRuntimeSyncError(admin, job.id, runtimeSyncWarning); + console.error("delete-cronjob archive sync warning:", runtimeSyncWarning); + } + + let deleted = false; + if (!runtimeSyncWarning) { + const { error: deleteErr } = await admin + .from("scheduled_jobs") + .delete() + .eq("id", job.id); + + if (deleteErr) { + runtimeSyncWarning = `DB delete failed after archive sync: ${deleteErr.message}`; + await markRuntimeSyncError(admin, job.id, runtimeSyncWarning); + console.error("delete-cronjob hard delete warning:", runtimeSyncWarning); + } else { + deleted = true; + } + } + + return jsonResponse(200, { + success: true, + job_id: job.id, + agent_instance_id: job.agent_instance_id, + archived: true, + deleted, + runtime_sync_warning: runtimeSyncWarning, + }); +});