Applied default skills hardening

X-Lovable-Edit-ID: edt-6d6c0346-b8bc-44ea-a630-43a989a99ad8
Co-authored-by: domfelipe <53182096+domfelipe@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot] 2026-05-28 21:59:43 +00:00
commit 2782e0d182
8 changed files with 167 additions and 84 deletions

View file

@ -191,11 +191,19 @@ export function SkillCard({ skill }: { skill: Skill }) {
</div> </div>
<div className="mt-4 flex items-center justify-between gap-2"> <div className="mt-4 flex items-center justify-between gap-2">
<SkillStatusBadge status={skill.status} /> <div className="flex items-center gap-2">
<SkillStatusBadge status={skill.status} />
{(skill as Skill & { is_default?: boolean }).is_default && (
<span className="inline-flex items-center rounded-md border border-border bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground">
Padrão
</span>
)}
</div>
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{formatDistanceToNow(new Date(skill.updated_at), { addSuffix: true, locale: ptBR })} {formatDistanceToNow(new Date(skill.updated_at), { addSuffix: true, locale: ptBR })}
</span> </span>
</div> </div>
</div> </div>
<AlertDialog open={confirmArchive} onOpenChange={setConfirmArchive}> <AlertDialog open={confirmArchive} onOpenChange={setConfirmArchive}>

View file

@ -76,25 +76,23 @@ export function SkillTestPanel({
const runTest = useMutation({ const runTest = useMutation({
mutationFn: async (): Promise<TestResult> => { mutationFn: async (): Promise<TestResult> => {
const start = Date.now();
const requestBody: Record<string, unknown> = { test_input: input };
if (stateless) { if (stateless) {
// Modo preview sem persistência: chama AI direto via edge function requestBody.markdown_content = stateless.markdown_content;
const start = Date.now(); } else {
const { data, error } = await supabase.functions.invoke("test-skill-dry-run", { requestBody.skill_version_id = skillVersionId;
body: { skill_version_id: skillVersionId, test_input: input },
});
if (error) {
return {
status: "error",
duration_ms: Date.now() - start,
error_message: error.message,
};
}
return data as TestResult;
} }
const { data, error } = await supabase.functions.invoke("test-skill-dry-run", { const { data, error } = await supabase.functions.invoke("test-skill-dry-run", {
body: { skill_version_id: skillVersionId, test_input: input }, body: requestBody,
}); });
if (error) throw new Error(error.message); if (error) {
return {
status: "error",
duration_ms: Date.now() - start,
error_message: error.message,
};
}
return data as TestResult; return data as TestResult;
}, },
onSuccess: (r) => { onSuccess: (r) => {
@ -110,6 +108,7 @@ export function SkillTestPanel({
}, },
}); });
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl w-full max-h-[90vh] overflow-y-auto"> <DialogContent className="max-w-2xl w-full max-h-[90vh] overflow-y-auto">

View file

@ -694,6 +694,7 @@ export type Database = {
current_version_id: string | null current_version_id: string | null
description: string description: string
id: string id: string
is_default: boolean
name: string name: string
status: string status: string
trigger_keywords: string trigger_keywords: string
@ -706,6 +707,7 @@ export type Database = {
current_version_id?: string | null current_version_id?: string | null
description: string description: string
id?: string id?: string
is_default?: boolean
name: string name: string
status?: string status?: string
trigger_keywords: string trigger_keywords: string
@ -718,6 +720,7 @@ export type Database = {
current_version_id?: string | null current_version_id?: string | null
description?: string description?: string
id?: string id?: string
is_default?: boolean
name?: string name?: string
status?: string status?: string
trigger_keywords?: string trigger_keywords?: string

View file

@ -67,10 +67,11 @@ function SkillsPage() {
<div className="flex items-center gap-3 flex-wrap"> <div className="flex items-center gap-3 flex-wrap">
{limits.data && limits.data.max_skills != null && ( {limits.data && limits.data.max_skills != null && (
<span className="text-sm text-muted-foreground px-3 py-1.5 rounded-lg bg-muted"> <span className="text-sm text-muted-foreground px-3 py-1.5 rounded-lg bg-muted">
{limits.data.current_skills_count} de {limits.data.max_skills} skills {limits.data.current_skills_count} de {limits.data.max_skills} skills personalizadas
</span> </span>
)} )}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Switch id="archived" checked={showArchived} onCheckedChange={setShowArchived} /> <Switch id="archived" checked={showArchived} onCheckedChange={setShowArchived} />
<Label htmlFor="archived" className="text-sm text-muted-foreground cursor-pointer"> <Label htmlFor="archived" className="text-sm text-muted-foreground cursor-pointer">

View file

@ -228,7 +228,7 @@ function SkillPreviewPage() {
open={testOpen} open={testOpen}
onOpenChange={setTestOpen} onOpenChange={setTestOpen}
skillName={(formInputs as Record<string, string>).name || "Skill"} skillName={(formInputs as Record<string, string>).name || "Skill"}
skillVersionId="preview" skillVersionId=""
triggerKeywords={(formInputs as Record<string, string>).trigger_keywords} triggerKeywords={(formInputs as Record<string, string>).trigger_keywords}
stateless={{ markdown_content: markdown }} stateless={{ markdown_content: markdown }}
/> />

View file

@ -247,9 +247,11 @@ export async function ensureDefaultSkillsForAgent(
description: template.description, description: template.description,
trigger_keywords: template.trigger_keywords, trigger_keywords: template.trigger_keywords,
status: "draft", status: "draft",
is_default: true,
}) })
.select("id") .select("id")
.single(); .single();
const skill = skillData as { id: string } | null; const skill = skillData as { id: string } | null;
if (skillErr || !skill) { if (skillErr || !skill) {

View file

@ -34,7 +34,7 @@ Deno.serve(async (req) => {
} }
const userId = userData.user.id; const userId = userData.user.id;
let body: { skill_version_id?: string; test_input?: string }; let body: { skill_version_id?: string; test_input?: string; markdown_content?: string };
try { try {
body = await req.json(); body = await req.json();
} catch { } catch {
@ -44,59 +44,94 @@ Deno.serve(async (req) => {
}); });
} }
const { skill_version_id, test_input } = body; const { skill_version_id, test_input, markdown_content } = body;
if (!skill_version_id || !test_input || test_input.trim().length === 0) { if (!test_input || test_input.trim().length === 0) {
return new Response( return new Response(
JSON.stringify({ error: "skill_version_id e test_input são obrigatórios" }), JSON.stringify({ error: "test_input é obrigatório" }),
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }, { status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } },
); );
} }
// Verifica ownership: skill_version -> skill -> user_id // Stateless mode: markdown_content direto (preview de nova skill, sem persistência)
const { data: versionRow, error: vErr } = await admin const isStateless =
.from("skill_versions") !!markdown_content &&
.select("id, markdown_content, skills!inner(user_id)") markdown_content.trim().length > 0 &&
.eq("id", skill_version_id) (!skill_version_id || skill_version_id === "preview");
.maybeSingle();
if (vErr || !versionRow) { let resolvedMarkdown: string | null = null;
return new Response(JSON.stringify({ error: "Versão não encontrada" }), { let persistedVersionId: string | null = null;
status: 404,
headers: { ...corsHeaders, "Content-Type": "application/json" }, if (isStateless) {
}); if (markdown_content!.length > 50000) {
} return new Response(JSON.stringify({ error: "markdown_content excede 50.000 caracteres" }), {
// @ts-expect-error supabase nested type status: 400,
if (versionRow.skills.user_id !== userId) { headers: { ...corsHeaders, "Content-Type": "application/json" },
return new Response(JSON.stringify({ error: "Acesso negado" }), { });
status: 403, }
headers: { ...corsHeaders, "Content-Type": "application/json" }, resolvedMarkdown = markdown_content!;
}); } else {
if (!skill_version_id) {
return new Response(
JSON.stringify({ error: "skill_version_id ou markdown_content é obrigatório" }),
{ 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" },
});
}
resolvedMarkdown = versionRow.markdown_content as string;
persistedVersionId = skill_version_id;
} }
// Cria registro running // Cria registro running apenas no modo persistido
const { data: runRow, error: runErr } = await admin let runId: string | null = null;
.from("skill_test_runs") if (persistedVersionId) {
.insert({ const { data: runRow, error: runErr } = await admin
skill_version_id, .from("skill_test_runs")
user_id: userId, .insert({
test_input, skill_version_id: persistedVersionId,
status: "running", user_id: userId,
test_type: "dry_run", test_input,
}) status: "running",
.select("id") test_type: "dry_run",
.single(); })
.select("id")
.single();
if (runErr || !runRow) { if (runErr || !runRow) {
console.error("Failed to create test run:", runErr); console.error("Failed to create test run:", runErr);
return new Response(JSON.stringify({ error: "Falha ao registrar teste" }), { return new Response(JSON.stringify({ error: "Falha ao registrar teste" }), {
status: 500, status: 500,
headers: { ...corsHeaders, "Content-Type": "application/json" }, headers: { ...corsHeaders, "Content-Type": "application/json" },
}); });
}
runId = runRow.id;
} }
const runId = runRow.id;
const startedAt = Date.now(); const startedAt = Date.now();
const updateRun = async (patch: Record<string, unknown>) => {
if (!runId) return;
await admin.from("skill_test_runs").update(patch).eq("id", runId);
};
try { try {
const aiRes = await fetch("https://ai.gateway.lovable.dev/v1/chat/completions", { const aiRes = await fetch("https://ai.gateway.lovable.dev/v1/chat/completions", {
method: "POST", method: "POST",
@ -110,7 +145,7 @@ Deno.serve(async (req) => {
{ role: "system", content: SYSTEM_PROMPT }, { role: "system", content: SYSTEM_PROMPT },
{ {
role: "user", role: "user",
content: `Definição da skill:\n\`\`\`\n${versionRow.markdown_content}\n\`\`\`\n\nInput do usuário: ${test_input}`, content: `Definição da skill:\n\`\`\`\n${resolvedMarkdown}\n\`\`\`\n\nInput do usuário: ${test_input}`,
}, },
], ],
}), }),
@ -118,14 +153,11 @@ Deno.serve(async (req) => {
if (aiRes.status === 429) { if (aiRes.status === 429) {
const duration = Date.now() - startedAt; const duration = Date.now() - startedAt;
await admin await updateRun({
.from("skill_test_runs") status: "error",
.update({ error_message: "Muitas requisições. Aguarde 1 minuto.",
status: "error", duration_ms: duration,
error_message: "Muitas requisições. Aguarde 1 minuto.", });
duration_ms: duration,
})
.eq("id", runId);
return new Response( return new Response(
JSON.stringify({ error: "Muitas requisições. Aguarde 1 minuto e tente novamente." }), JSON.stringify({ error: "Muitas requisições. Aguarde 1 minuto e tente novamente." }),
{ status: 429, headers: { ...corsHeaders, "Content-Type": "application/json" } }, { status: 429, headers: { ...corsHeaders, "Content-Type": "application/json" } },
@ -135,14 +167,11 @@ Deno.serve(async (req) => {
if (!aiRes.ok) { if (!aiRes.ok) {
const txt = await aiRes.text(); const txt = await aiRes.text();
const duration = Date.now() - startedAt; const duration = Date.now() - startedAt;
await admin await updateRun({
.from("skill_test_runs") status: "error",
.update({ error_message: `AI error ${aiRes.status}: ${txt.slice(0, 200)}`,
status: "error", duration_ms: duration,
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." }), { return new Response(JSON.stringify({ error: "Falha ao executar teste." }), {
status: 500, status: 500,
headers: { ...corsHeaders, "Content-Type": "application/json" }, headers: { ...corsHeaders, "Content-Type": "application/json" },
@ -153,10 +182,7 @@ Deno.serve(async (req) => {
const test_output: string = (data?.choices?.[0]?.message?.content ?? "").trim(); const test_output: string = (data?.choices?.[0]?.message?.content ?? "").trim();
const duration = Date.now() - startedAt; const duration = Date.now() - startedAt;
await admin await updateRun({ status: "success", test_output, duration_ms: duration });
.from("skill_test_runs")
.update({ status: "success", test_output, duration_ms: duration })
.eq("id", runId);
return new Response( return new Response(
JSON.stringify({ test_output, duration_ms: duration, status: "success" }), JSON.stringify({ test_output, duration_ms: duration, status: "success" }),
@ -165,13 +191,11 @@ Deno.serve(async (req) => {
} catch (e) { } catch (e) {
const duration = Date.now() - startedAt; const duration = Date.now() - startedAt;
const msg = e instanceof Error ? e.message : "Erro desconhecido"; const msg = e instanceof Error ? e.message : "Erro desconhecido";
await admin await updateRun({ status: "error", error_message: msg, duration_ms: duration });
.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." }), { return new Response(JSON.stringify({ error: "Erro ao executar teste." }), {
status: 500, status: 500,
headers: { ...corsHeaders, "Content-Type": "application/json" }, headers: { ...corsHeaders, "Content-Type": "application/json" },
}); });
} }
}); });

View file

@ -0,0 +1,46 @@
-- Add is_default column to skills and update user_skill_limits view
-- to exclude default skills from custom skill quota
ALTER TABLE public.skills
ADD COLUMN IF NOT EXISTS is_default boolean NOT NULL DEFAULT false;
-- Mark known default skills by name (where description matches the template too)
UPDATE public.skills
SET is_default = true
WHERE is_default = false
AND name IN ('Resumo diario', 'Planejamento semanal', 'Preparar reuniao')
AND description IN (
'Gera um resumo curto do dia com compromissos, tarefas e proximas prioridades.',
'Ajuda o usuario a transformar objetivos da semana em prioridades e proximas acoes.',
'Monta um briefing rapido antes de reunioes com contexto, pauta e perguntas uteis.'
);
-- Recreate user_skill_limits to exclude default skills from quota count
DROP VIEW IF EXISTS public.user_skill_limits;
CREATE VIEW public.user_skill_limits AS
SELECT
p.id AS user_id,
pl.slug AS plan_slug,
CASE
WHEN pl.slug IS NULL THEN NULL::integer
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::integer
END AS max_skills,
(
SELECT count(*)::integer
FROM public.skills sk
WHERE sk.user_id = p.id
AND sk.status <> 'archived'
AND COALESCE(sk.is_default, false) = false
) AS current_skills_count
FROM public.profiles p
LEFT JOIN public.subscriptions s
ON s.user_id = p.id
AND s.status = ANY (ARRAY['active'::text, 'trialing'::text])
LEFT JOIN public.plans pl ON pl.id = s.plan_id;
GRANT SELECT ON public.user_skill_limits TO authenticated;
GRANT SELECT ON public.user_skill_limits TO service_role;