diff --git a/src/integrations/supabase/types.ts b/src/integrations/supabase/types.ts index 21418a2..a4199c1 100644 --- a/src/integrations/supabase/types.ts +++ b/src/integrations/supabase/types.ts @@ -59,6 +59,13 @@ export type Database = { referencedRelation: "profiles" referencedColumns: ["id"] }, + { + foreignKeyName: "agent_instances_user_id_fkey" + columns: ["user_id"] + isOneToOne: true + referencedRelation: "user_skill_limits" + referencedColumns: ["user_id"] + }, ] } enterprise_leads: { @@ -214,6 +221,183 @@ export type Database = { } Relationships: [] } + skill_test_runs: { + Row: { + created_at: string + duration_ms: number | null + error_message: string | null + id: string + skill_version_id: string + status: string + test_input: string + test_output: string | null + test_type: string + user_id: string + } + Insert: { + created_at?: string + duration_ms?: number | null + error_message?: string | null + id?: string + skill_version_id: string + status: string + test_input: string + test_output?: string | null + test_type?: string + user_id: string + } + Update: { + created_at?: string + duration_ms?: number | null + error_message?: string | null + id?: string + skill_version_id?: string + status?: string + test_input?: string + test_output?: string | null + test_type?: string + user_id?: string + } + Relationships: [ + { + foreignKeyName: "skill_test_runs_skill_version_id_fkey" + columns: ["skill_version_id"] + isOneToOne: false + referencedRelation: "skill_versions" + referencedColumns: ["id"] + }, + { + foreignKeyName: "skill_test_runs_user_id_fkey" + columns: ["user_id"] + isOneToOne: false + referencedRelation: "profiles" + referencedColumns: ["id"] + }, + { + foreignKeyName: "skill_test_runs_user_id_fkey" + columns: ["user_id"] + isOneToOne: false + referencedRelation: "user_skill_limits" + referencedColumns: ["user_id"] + }, + ] + } + skill_versions: { + Row: { + created_at: string + created_by: string + form_inputs: Json + id: string + is_live: boolean + markdown_content: string + skill_id: string + version_number: number + } + Insert: { + created_at?: string + created_by: string + form_inputs: Json + id?: string + is_live?: boolean + markdown_content: string + skill_id: string + version_number: number + } + Update: { + created_at?: string + created_by?: string + form_inputs?: Json + id?: string + is_live?: boolean + markdown_content?: string + skill_id?: string + version_number?: number + } + Relationships: [ + { + foreignKeyName: "skill_versions_created_by_fkey" + columns: ["created_by"] + isOneToOne: false + referencedRelation: "profiles" + referencedColumns: ["id"] + }, + { + foreignKeyName: "skill_versions_created_by_fkey" + columns: ["created_by"] + isOneToOne: false + referencedRelation: "user_skill_limits" + referencedColumns: ["user_id"] + }, + { + foreignKeyName: "skill_versions_skill_id_fkey" + columns: ["skill_id"] + isOneToOne: false + referencedRelation: "skills" + referencedColumns: ["id"] + }, + ] + } + skills: { + Row: { + agent_instance_id: string + created_at: string + current_version_id: string | null + description: string + id: string + name: string + status: string + trigger_keywords: string + updated_at: string + user_id: string + } + Insert: { + agent_instance_id: string + created_at?: string + current_version_id?: string | null + description: string + id?: string + name: string + status?: string + trigger_keywords: string + updated_at?: string + user_id: string + } + Update: { + agent_instance_id?: string + created_at?: string + current_version_id?: string | null + description?: string + id?: string + name?: string + status?: string + trigger_keywords?: string + updated_at?: string + user_id?: string + } + Relationships: [ + { + foreignKeyName: "skills_agent_instance_id_fkey" + columns: ["agent_instance_id"] + isOneToOne: false + referencedRelation: "agent_instances" + referencedColumns: ["id"] + }, + { + foreignKeyName: "skills_user_id_fkey" + columns: ["user_id"] + isOneToOne: false + referencedRelation: "profiles" + referencedColumns: ["id"] + }, + { + foreignKeyName: "skills_user_id_fkey" + columns: ["user_id"] + isOneToOne: false + referencedRelation: "user_skill_limits" + referencedColumns: ["user_id"] + }, + ] + } stripe_webhook_events: { Row: { event_type: string @@ -308,11 +492,26 @@ export type Database = { referencedRelation: "profiles" referencedColumns: ["id"] }, + { + foreignKeyName: "subscriptions_user_id_fkey" + columns: ["user_id"] + isOneToOne: false + referencedRelation: "user_skill_limits" + referencedColumns: ["user_id"] + }, ] } } Views: { - [_ in never]: never + user_skill_limits: { + Row: { + current_skills_count: number | null + max_skills: number | null + plan_slug: string | null + user_id: string | null + } + Relationships: [] + } } Functions: { has_active_subscription: { diff --git a/supabase/config.toml b/supabase/config.toml index cee1866..a059644 100644 --- a/supabase/config.toml +++ b/supabase/config.toml @@ -5,3 +5,12 @@ verify_jwt = false [functions.get-paddle-price] verify_jwt = false + +[functions.generate-skill-markdown] +verify_jwt = true + +[functions.test-skill-dry-run] +verify_jwt = true + +[functions.publish-skill-version] +verify_jwt = true diff --git a/supabase/functions/_shared/cors.ts b/supabase/functions/_shared/cors.ts new file mode 100644 index 0000000..65bb162 --- /dev/null +++ b/supabase/functions/_shared/cors.ts @@ -0,0 +1,6 @@ +export const corsHeaders = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Headers": + "authorization, x-client-info, apikey, content-type", + "Access-Control-Allow-Methods": "POST, GET, OPTIONS", +}; diff --git a/supabase/functions/generate-skill-markdown/index.ts b/supabase/functions/generate-skill-markdown/index.ts new file mode 100644 index 0000000..9d000ed --- /dev/null +++ b/supabase/functions/generate-skill-markdown/index.ts @@ -0,0 +1,114 @@ +// Gera o markdown de uma skill no padrão agentskills.io via Lovable AI Gateway. +import { corsHeaders } from "../_shared/cors.ts"; + +const LOVABLE_API_KEY = Deno.env.get("LOVABLE_API_KEY")!; +const MODEL = "google/gemini-2.5-flash"; +const MAX_LEN = 50000; + +const SYSTEM_PROMPT = `Você é um especialista em criar skills para o Hermes Agent no padrão agentskills.io. Gere um arquivo markdown completo e bem estruturado para a skill descrita pelo usuário. O arquivo deve seguir esta estrutura exata: cabeçalho YAML com name, description, trigger_keywords; seção ## Quando usar; seção ## Inputs esperados (se houver); seção ## Passo a passo numerada; seção ## Ferramentas necessárias; seção ## Critério de sucesso; seção opcional ## Exemplo. Use linguagem clara, imperativa e em português. Não adicione comentários fora do markdown. Retorne APENAS o conteúdo do arquivo .md, sem code fences.`; + +interface FormInputs { + name: string; + description: string; + trigger_keywords: string; + expected_inputs?: string | null; + steps: string; + required_tools: string[]; + success_criteria: string; + example_use_case?: string | null; +} + +function formatUserMessage(f: FormInputs): string { + return [ + `Nome: ${f.name}`, + `Descrição: ${f.description}`, + `Palavras-chave de gatilho: ${f.trigger_keywords}`, + f.expected_inputs ? `Inputs esperados: ${f.expected_inputs}` : null, + `Passo a passo:\n${f.steps}`, + `Ferramentas necessárias: ${f.required_tools.join(", ")}`, + `Critério de sucesso: ${f.success_criteria}`, + f.example_use_case ? `Exemplo de uso: ${f.example_use_case}` : null, + ] + .filter(Boolean) + .join("\n\n"); +} + +Deno.serve(async (req) => { + if (req.method === "OPTIONS") { + return new Response(null, { headers: corsHeaders }); + } + + try { + const body = await req.json(); + const form_inputs = body?.form_inputs as FormInputs | undefined; + + if (!form_inputs?.name || !form_inputs?.description || !form_inputs?.steps) { + return new Response( + JSON.stringify({ error: "form_inputs incompleto" }), + { status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }, + ); + } + + const aiRes = await fetch("https://ai.gateway.lovable.dev/v1/chat/completions", { + method: "POST", + headers: { + Authorization: `Bearer ${LOVABLE_API_KEY}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model: MODEL, + messages: [ + { role: "system", content: SYSTEM_PROMPT }, + { role: "user", content: formatUserMessage(form_inputs) }, + ], + }), + }); + + if (aiRes.status === 429) { + return new Response( + JSON.stringify({ error: "Muitas requisições. Aguarde 1 minuto e tente novamente." }), + { status: 429, headers: { ...corsHeaders, "Content-Type": "application/json" } }, + ); + } + if (aiRes.status === 402) { + return new Response( + JSON.stringify({ error: "Crédito de IA esgotado. Entre em contato com o suporte." }), + { status: 402, headers: { ...corsHeaders, "Content-Type": "application/json" } }, + ); + } + if (!aiRes.ok) { + const txt = await aiRes.text(); + console.error("AI gateway error:", aiRes.status, txt); + return new Response( + JSON.stringify({ error: "Falha ao gerar skill. Tente novamente." }), + { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }, + ); + } + + const data = await aiRes.json(); + let markdown_content: string = data?.choices?.[0]?.message?.content ?? ""; + markdown_content = markdown_content.trim(); + + if (!markdown_content) { + return new Response( + JSON.stringify({ error: "A IA retornou conteúdo vazio. Tente novamente." }), + { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }, + ); + } + + if (markdown_content.length > MAX_LEN) { + markdown_content = markdown_content.slice(0, MAX_LEN); + } + + return new Response( + JSON.stringify({ markdown_content }), + { status: 200, headers: { ...corsHeaders, "Content-Type": "application/json" } }, + ); + } catch (e) { + console.error("generate-skill-markdown error:", e); + return new Response( + JSON.stringify({ error: "Erro inesperado ao gerar skill." }), + { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }, + ); + } +}); diff --git a/supabase/functions/payments-webhook/index.ts b/supabase/functions/payments-webhook/index.ts index 5f604c8..9db9800 100644 --- a/supabase/functions/payments-webhook/index.ts +++ b/supabase/functions/payments-webhook/index.ts @@ -123,6 +123,18 @@ async function upsertSubscription(data: any, env: PaddleEnv) { // Atualiza paddle_customer_id no profile await supabase.from('profiles').update({ paddle_customer_id: customerId }).eq('id', userId); + + // Provisiona agent_instance se ainda não existir (idempotente via unique index em user_id). + // TODO Fase 5: dispatch provisioning job (criar container Docker na VPS via Coolify API) + if (status === 'active' || status === 'trialing') { + const { error: agentErr } = await supabase + .from('agent_instances') + .insert({ user_id: userId, status: 'provisioning' }); + // 23505 = já existe (esperado para usuários retornando), ignora + if (agentErr && agentErr.code !== '23505') { + console.error('Failed to provision agent_instance:', agentErr); + } + } } async function markCanceled(data: any, env: PaddleEnv) { diff --git a/supabase/functions/publish-skill-version/index.ts b/supabase/functions/publish-skill-version/index.ts new file mode 100644 index 0000000..b7064a9 --- /dev/null +++ b/supabase/functions/publish-skill-version/index.ts @@ -0,0 +1,148 @@ +// Promove uma skill_version a "live" de forma atômica e idempotente. +// Garantia adicional: unique index parcial skill_versions_one_live_per_skill no banco. +// TODO Fase 5: dispatch SSH deploy to container after publish +import { createClient } from "npm:@supabase/supabase-js@2"; +import { corsHeaders } from "../_shared/cors.ts"; + +const SUPABASE_URL = Deno.env.get("SUPABASE_URL")!; +const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!; + +const admin = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY); + +Deno.serve(async (req) => { + if (req.method === "OPTIONS") { + return new Response(null, { headers: corsHeaders }); + } + + // Auth + const authHeader = req.headers.get("Authorization"); + if (!authHeader) { + return new Response(JSON.stringify({ error: "Não autenticado" }), { + status: 401, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } + const token = authHeader.replace("Bearer ", ""); + const { data: userData, error: userErr } = await admin.auth.getUser(token); + if (userErr || !userData?.user) { + return new Response(JSON.stringify({ error: "Não autenticado" }), { + status: 401, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } + const userId = userData.user.id; + + let body: { skill_version_id?: string }; + try { + body = await req.json(); + } catch { + return new Response(JSON.stringify({ error: "JSON inválido" }), { + status: 400, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } + + const { skill_version_id } = body; + if (!skill_version_id) { + return new Response(JSON.stringify({ error: "skill_version_id é obrigatório" }), { + status: 400, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } + + // Carrega versão + skill (verifica ownership e estado atual) + const { data: versionRow, error: vErr } = await admin + .from("skill_versions") + .select("id, skill_id, version_number, is_live, skills!inner(id, user_id)") + .eq("id", skill_version_id) + .maybeSingle(); + + if (vErr || !versionRow) { + return new Response(JSON.stringify({ error: "Versão não encontrada" }), { + status: 404, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } + // @ts-expect-error nested + const skillUserId: string = versionRow.skills.user_id; + if (skillUserId !== userId) { + return new Response(JSON.stringify({ error: "Acesso negado" }), { + status: 403, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } + + // Idempotência: já é live, no-op + if (versionRow.is_live === true) { + return new Response( + JSON.stringify({ success: true, no_op: true, version_number: versionRow.version_number }), + { status: 200, headers: { ...corsHeaders, "Content-Type": "application/json" } }, + ); + } + + const skillId: string = versionRow.skill_id; + + // Postgres não permite transação multi-statement via supabase-js. + // Estratégia: 1) zera todos is_live da skill, 2) marca a alvo como live, 3) atualiza skills. + // O unique index parcial skill_versions_one_live_per_skill protege contra race. + // Se duas execuções rodarem em paralelo, uma delas falhará no passo 2 com 23505. + + // Passo 1: desmarcar todas as outras versões como live + const { error: clearErr } = await admin + .from("skill_versions") + .update({ is_live: false }) + .eq("skill_id", skillId) + .eq("is_live", true); + + if (clearErr) { + console.error("Clear live error:", clearErr); + return new Response(JSON.stringify({ error: "Falha ao publicar (clear)." }), { + status: 500, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } + + // Passo 2: marcar a versão alvo como live + const { error: setErr } = await admin + .from("skill_versions") + .update({ is_live: true }) + .eq("id", skill_version_id); + + if (setErr) { + // Race condition na invariante de banco + if (setErr.code === "23505") { + return new Response( + JSON.stringify({ error: "Conflito de concorrência. Tente novamente." }), + { status: 409, headers: { ...corsHeaders, "Content-Type": "application/json" } }, + ); + } + console.error("Set live error:", setErr); + return new Response(JSON.stringify({ error: "Falha ao publicar (set)." }), { + status: 500, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } + + // Passo 3: atualizar skills.current_version_id e status + const { error: updSkillErr } = await admin + .from("skills") + .update({ + current_version_id: skill_version_id, + status: "active", + updated_at: new Date().toISOString(), + }) + .eq("id", skillId); + + if (updSkillErr) { + console.error("Update skill error:", updSkillErr); + return new Response(JSON.stringify({ error: "Falha ao publicar (skill)." }), { + status: 500, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } + + return new Response( + JSON.stringify({ success: true, version_number: versionRow.version_number }), + { status: 200, headers: { ...corsHeaders, "Content-Type": "application/json" } }, + ); +}); diff --git a/supabase/functions/test-skill-dry-run/index.ts b/supabase/functions/test-skill-dry-run/index.ts new file mode 100644 index 0000000..648b2ff --- /dev/null +++ b/supabase/functions/test-skill-dry-run/index.ts @@ -0,0 +1,177 @@ +// Executa um dry-run de uma skill: simula a execução via LLM, sem acionar ferramentas reais. +import { createClient } from "npm:@supabase/supabase-js@2"; +import { corsHeaders } from "../_shared/cors.ts"; + +const LOVABLE_API_KEY = Deno.env.get("LOVABLE_API_KEY")!; +const SUPABASE_URL = Deno.env.get("SUPABASE_URL")!; +const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!; +const MODEL = "google/gemini-2.5-flash"; + +const SYSTEM_PROMPT = `Você é o agente Mika executando uma skill em modo de teste (dry-run). Você NÃO deve executar ferramentas reais — apenas simular. Receba a definição da skill e o input do usuário. Descreva passo a passo o que você faria, qual ferramenta acionaria em cada momento, e qual seria o output final. Seja claro e didático. Se a skill estiver mal definida ou ambígua para o input dado, explique o problema.`; + +const admin = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY); + +Deno.serve(async (req) => { + if (req.method === "OPTIONS") { + return new Response(null, { headers: corsHeaders }); + } + + // Auth: extrai user do JWT + const authHeader = req.headers.get("Authorization"); + if (!authHeader) { + return new Response(JSON.stringify({ error: "Não autenticado" }), { + status: 401, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } + const token = authHeader.replace("Bearer ", ""); + const { data: userData, error: userErr } = await admin.auth.getUser(token); + if (userErr || !userData?.user) { + return new Response(JSON.stringify({ error: "Não autenticado" }), { + status: 401, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } + const userId = userData.user.id; + + let body: { skill_version_id?: string; test_input?: string }; + try { + body = await req.json(); + } catch { + return new Response(JSON.stringify({ error: "JSON inválido" }), { + status: 400, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } + + const { skill_version_id, test_input } = body; + if (!skill_version_id || !test_input || test_input.trim().length === 0) { + return new Response( + JSON.stringify({ error: "skill_version_id e test_input são obrigatórios" }), + { status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }, + ); + } + + // Verifica ownership: skill_version -> skill -> user_id + const { data: versionRow, error: vErr } = await admin + .from("skill_versions") + .select("id, markdown_content, skills!inner(user_id)") + .eq("id", skill_version_id) + .maybeSingle(); + + if (vErr || !versionRow) { + return new Response(JSON.stringify({ error: "Versão não encontrada" }), { + status: 404, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } + // @ts-expect-error supabase nested type + if (versionRow.skills.user_id !== userId) { + return new Response(JSON.stringify({ error: "Acesso negado" }), { + status: 403, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } + + // Cria registro running + const { data: runRow, error: runErr } = await admin + .from("skill_test_runs") + .insert({ + skill_version_id, + user_id: userId, + test_input, + status: "running", + test_type: "dry_run", + }) + .select("id") + .single(); + + if (runErr || !runRow) { + console.error("Failed to create test run:", runErr); + return new Response(JSON.stringify({ error: "Falha ao registrar teste" }), { + status: 500, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } + + const runId = runRow.id; + const startedAt = Date.now(); + + try { + const aiRes = await fetch("https://ai.gateway.lovable.dev/v1/chat/completions", { + method: "POST", + headers: { + Authorization: `Bearer ${LOVABLE_API_KEY}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model: MODEL, + messages: [ + { role: "system", content: SYSTEM_PROMPT }, + { + role: "user", + content: `Definição da skill:\n\`\`\`\n${versionRow.markdown_content}\n\`\`\`\n\nInput do usuário: ${test_input}`, + }, + ], + }), + }); + + if (aiRes.status === 429) { + const duration = Date.now() - startedAt; + await admin + .from("skill_test_runs") + .update({ + status: "error", + error_message: "Muitas requisições. Aguarde 1 minuto.", + duration_ms: duration, + }) + .eq("id", runId); + return new Response( + JSON.stringify({ error: "Muitas requisições. Aguarde 1 minuto e tente novamente." }), + { status: 429, headers: { ...corsHeaders, "Content-Type": "application/json" } }, + ); + } + + if (!aiRes.ok) { + const txt = await aiRes.text(); + const duration = Date.now() - startedAt; + await admin + .from("skill_test_runs") + .update({ + status: "error", + error_message: `AI error ${aiRes.status}: ${txt.slice(0, 200)}`, + duration_ms: duration, + }) + .eq("id", runId); + return new Response(JSON.stringify({ error: "Falha ao executar teste." }), { + status: 500, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } + + const data = await aiRes.json(); + const test_output: string = (data?.choices?.[0]?.message?.content ?? "").trim(); + const duration = Date.now() - startedAt; + + await admin + .from("skill_test_runs") + .update({ status: "success", test_output, duration_ms: duration }) + .eq("id", runId); + + return new Response( + JSON.stringify({ test_output, duration_ms: duration, status: "success" }), + { status: 200, headers: { ...corsHeaders, "Content-Type": "application/json" } }, + ); + } catch (e) { + const duration = Date.now() - startedAt; + const msg = e instanceof Error ? e.message : "Erro desconhecido"; + await admin + .from("skill_test_runs") + .update({ status: "error", error_message: msg, duration_ms: duration }) + .eq("id", runId); + return new Response(JSON.stringify({ error: "Erro ao executar teste." }), { + status: 500, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } +}); diff --git a/supabase/migrations/20260417184648_f6e36dca-85ff-4288-b8e7-7af4790f8bc3.sql b/supabase/migrations/20260417184648_f6e36dca-85ff-4288-b8e7-7af4790f8bc3.sql new file mode 100644 index 0000000..95c92e1 --- /dev/null +++ b/supabase/migrations/20260417184648_f6e36dca-85ff-4288-b8e7-7af4790f8bc3.sql @@ -0,0 +1,252 @@ +-- ============================================================ +-- FASE 2: Skill Studio — Schema completo +-- ============================================================ + +-- ------------------------------------------------------------ +-- 1) agent_instances: garantir unicidade por user_id e popular retroativamente +-- ------------------------------------------------------------ + +-- Garante que cada usuário tem no máximo 1 agente (necessário para ON CONFLICT no webhook) +CREATE UNIQUE INDEX IF NOT EXISTS agent_instances_user_id_key + ON public.agent_instances(user_id); + +-- Migração retroativa: cria agent_instance 'active' para quem já tem subscription ativa/trial +INSERT INTO public.agent_instances (user_id, status, created_at, updated_at) +SELECT + s.user_id, + 'active' AS status, + now(), + now() +FROM public.subscriptions s +WHERE s.status IN ('active', 'trialing') + AND NOT EXISTS ( + SELECT 1 FROM public.agent_instances ai WHERE ai.user_id = s.user_id + ); + +-- ------------------------------------------------------------ +-- 2) Tabela: skills +-- ------------------------------------------------------------ +CREATE TABLE public.skills ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE, + agent_instance_id uuid NOT NULL REFERENCES public.agent_instances(id) ON DELETE CASCADE, + name text NOT NULL, + description text NOT NULL, + trigger_keywords text NOT NULL, + status text NOT NULL DEFAULT 'draft' + CHECK (status IN ('draft', 'testing', 'active', 'disabled', 'archived')), + current_version_id uuid, -- SEM FK por design (ver comment) + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +COMMENT ON COLUMN public.skills.current_version_id IS + 'Aponta para skill_versions(id). Sem FK constraint para evitar dependência circular com skill_versions (que referencia skills.id). A integridade é garantida pela aplicação e pela edge function publish-skill-version.'; + +CREATE INDEX idx_skills_user_id ON public.skills(user_id); +CREATE INDEX idx_skills_agent_instance_id ON public.skills(agent_instance_id); +CREATE INDEX idx_skills_status ON public.skills(status); + +-- Invariante: nome único por usuário entre não-arquivadas +CREATE UNIQUE INDEX skills_unique_name_per_user + ON public.skills(user_id, name) + WHERE status != 'archived'; + +ALTER TABLE public.skills ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "Usuários veem suas próprias skills" + ON public.skills FOR SELECT + USING (auth.uid() = user_id); + +CREATE POLICY "Usuários criam suas próprias skills" + ON public.skills FOR INSERT + WITH CHECK (auth.uid() = user_id); + +CREATE POLICY "Usuários atualizam suas próprias skills" + ON public.skills FOR UPDATE + USING (auth.uid() = user_id); + +CREATE POLICY "Usuários deletam suas próprias skills" + ON public.skills FOR DELETE + USING (auth.uid() = user_id); + +-- Trigger updated_at +CREATE TRIGGER trg_skills_updated_at + BEFORE UPDATE ON public.skills + FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column(); + +-- ------------------------------------------------------------ +-- 3) Tabela: skill_versions +-- ------------------------------------------------------------ +CREATE TABLE public.skill_versions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + skill_id uuid NOT NULL REFERENCES public.skills(id) ON DELETE CASCADE, + version_number int NOT NULL, + markdown_content text NOT NULL CHECK (length(markdown_content) <= 50000), + form_inputs jsonb NOT NULL, + is_live boolean NOT NULL DEFAULT false, + created_at timestamptz NOT NULL DEFAULT now(), + created_by uuid NOT NULL REFERENCES public.profiles(id), + UNIQUE (skill_id, version_number) +); + +COMMENT ON COLUMN public.skill_versions.form_inputs IS + 'Snapshot dos campos do formulário usados para gerar esta versão. Schema esperado: { name: string, description: string, trigger_keywords: string, expected_inputs: string|null, steps: string, required_tools: string[], success_criteria: string, example_use_case: string|null }'; + +CREATE INDEX idx_skill_versions_skill_id ON public.skill_versions(skill_id); +CREATE INDEX idx_skill_versions_is_live ON public.skill_versions(skill_id) WHERE is_live = true; + +-- INVARIANTE CRÍTICA: apenas 1 versão live por skill (garantido no banco) +CREATE UNIQUE INDEX skill_versions_one_live_per_skill + ON public.skill_versions(skill_id) + WHERE is_live = true; + +ALTER TABLE public.skill_versions ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "Usuários veem versões das próprias skills" + ON public.skill_versions FOR SELECT + USING (EXISTS ( + SELECT 1 FROM public.skills s WHERE s.id = skill_versions.skill_id AND s.user_id = auth.uid() + )); + +CREATE POLICY "Usuários criam versões das próprias skills" + ON public.skill_versions FOR INSERT + WITH CHECK (EXISTS ( + SELECT 1 FROM public.skills s WHERE s.id = skill_versions.skill_id AND s.user_id = auth.uid() + )); + +CREATE POLICY "Usuários atualizam versões das próprias skills" + ON public.skill_versions FOR UPDATE + USING (EXISTS ( + SELECT 1 FROM public.skills s WHERE s.id = skill_versions.skill_id AND s.user_id = auth.uid() + )); + +CREATE POLICY "Usuários deletam versões das próprias skills" + ON public.skill_versions FOR DELETE + USING (EXISTS ( + SELECT 1 FROM public.skills s WHERE s.id = skill_versions.skill_id AND s.user_id = auth.uid() + )); + +-- ------------------------------------------------------------ +-- 4) Tabela: skill_test_runs +-- ------------------------------------------------------------ +CREATE TABLE public.skill_test_runs ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + skill_version_id uuid NOT NULL REFERENCES public.skill_versions(id) ON DELETE CASCADE, + user_id uuid NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE, + test_input text NOT NULL, + test_output text, + status text NOT NULL CHECK (status IN ('running', 'success', 'error')), + error_message text, + duration_ms int, + test_type text NOT NULL DEFAULT 'dry_run' CHECK (test_type IN ('dry_run', 'real')), + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX idx_skill_test_runs_skill_version_id ON public.skill_test_runs(skill_version_id); +CREATE INDEX idx_skill_test_runs_user_id ON public.skill_test_runs(user_id); + +ALTER TABLE public.skill_test_runs ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "Usuários veem seus próprios test runs" + ON public.skill_test_runs FOR SELECT + USING (auth.uid() = user_id); + +CREATE POLICY "Usuários criam seus próprios test runs" + ON public.skill_test_runs FOR INSERT + WITH CHECK (auth.uid() = user_id); + +-- ------------------------------------------------------------ +-- 5) View: user_skill_limits +-- ------------------------------------------------------------ +CREATE VIEW public.user_skill_limits +WITH (security_invoker = true) +AS +SELECT + p.id AS user_id, + pl.slug AS plan_slug, + CASE + WHEN pl.slug IS NULL THEN NULL + WHEN pl.slug = 'basic' THEN 5 + WHEN pl.slug = 'starter' THEN 15 + WHEN pl.slug = 'professional' THEN 50 + WHEN pl.slug = 'enterprise' THEN 999999 + ELSE NULL + END AS max_skills, + ( + SELECT count(*)::int + FROM public.skills sk + WHERE sk.user_id = p.id AND sk.status != 'archived' + ) AS current_skills_count +FROM public.profiles p +LEFT JOIN public.subscriptions s + ON s.user_id = p.id + AND s.status IN ('active', 'trialing') +LEFT JOIN public.plans pl + ON pl.id = s.plan_id; + +GRANT SELECT ON public.user_skill_limits TO authenticated; + +-- ------------------------------------------------------------ +-- 6) Função + trigger: enforce_skill_limit (BEFORE INSERT) +-- ------------------------------------------------------------ +CREATE OR REPLACE FUNCTION public.enforce_skill_limit() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_max int; + v_count int; +BEGIN + SELECT max_skills, current_skills_count + INTO v_max, v_count + FROM public.user_skill_limits + WHERE user_id = NEW.user_id; + + IF v_max IS NULL THEN + RAISE EXCEPTION 'Sem assinatura ativa para criar skills' + USING ERRCODE = 'P0001'; + END IF; + + IF v_count >= v_max THEN + RAISE EXCEPTION 'Limite de skills atingido para o plano' + USING ERRCODE = 'P0002'; + END IF; + + RETURN NEW; +END; +$$; + +CREATE TRIGGER trigger_enforce_skill_limit + BEFORE INSERT ON public.skills + FOR EACH ROW EXECUTE FUNCTION public.enforce_skill_limit(); + +-- ------------------------------------------------------------ +-- 7) Função + trigger: cleanup_old_skill_versions (AFTER INSERT) +-- ------------------------------------------------------------ +CREATE OR REPLACE FUNCTION public.cleanup_old_skill_versions() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +BEGIN + DELETE FROM public.skill_versions + WHERE skill_id = NEW.skill_id + AND is_live = false + AND id NOT IN ( + SELECT id FROM public.skill_versions + WHERE skill_id = NEW.skill_id + AND is_live = false + ORDER BY created_at DESC + LIMIT 12 + ); + RETURN NULL; +END; +$$; + +CREATE TRIGGER trigger_cleanup_skill_versions + AFTER INSERT ON public.skill_versions + FOR EACH ROW EXECUTE FUNCTION public.cleanup_old_skill_versions(); \ No newline at end of file