mirror of
https://github.com/domfelipe/mika-agent-assist.git
synced 2026-08-07 09:16:46 +00:00
fix: harden skill defaults and dry-run preview
This commit is contained in:
parent
354ca092c9
commit
fdef327c5e
11 changed files with 495 additions and 254 deletions
|
|
@ -33,7 +33,9 @@ export function SkillsDashboardWidget() {
|
|||
<h3 className="font-semibold">Suas skills</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{activeCount} ativa{activeCount === 1 ? "" : "s"}
|
||||
{limits?.max_skills ? ` de ${limits.max_skills} disponíveis` : ""}
|
||||
{limits?.max_skills
|
||||
? ` · ${limits.current_skills_count} de ${limits.max_skills} personalizadas`
|
||||
: ""}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -67,7 +69,11 @@ export function SkillsDashboardWidget() {
|
|||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium truncate">{skill.name}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Atualizada {formatDistanceToNow(new Date(skill.updated_at), { addSuffix: true, locale: ptBR })}
|
||||
Atualizada{" "}
|
||||
{formatDistanceToNow(new Date(skill.updated_at), {
|
||||
addSuffix: true,
|
||||
locale: ptBR,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<SkillStatusBadge status={skill.status} className="shrink-0" />
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { toast } from "sonner";
|
|||
import { supabase } from "@/integrations/supabase/client";
|
||||
import { syncAgentSkills } from "@/lib/sync-agent-skills";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
|
|
@ -128,9 +129,7 @@ export function SkillCard({ skill }: { skill: Skill }) {
|
|||
>
|
||||
{skill.name}
|
||||
</button>
|
||||
<p className="mt-1 text-sm text-muted-foreground line-clamp-2">
|
||||
{skill.description}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground line-clamp-2">{skill.description}</p>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
|
|
@ -191,7 +190,14 @@ export function SkillCard({ skill }: { skill: Skill }) {
|
|||
</div>
|
||||
|
||||
<div className="mt-4 flex items-center justify-between gap-2">
|
||||
<SkillStatusBadge status={skill.status} />
|
||||
<div className="flex items-center gap-1.5">
|
||||
<SkillStatusBadge status={skill.status} />
|
||||
{skill.is_default && (
|
||||
<Badge variant="muted" className="rounded-md font-medium">
|
||||
Padrão
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatDistanceToNow(new Date(skill.updated_at), { addSuffix: true, locale: ptBR })}
|
||||
</span>
|
||||
|
|
@ -203,8 +209,8 @@ export function SkillCard({ skill }: { skill: Skill }) {
|
|||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Arquivar esta skill?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
A skill <strong>{skill.name}</strong> ficará invisível para o agente. Você
|
||||
poderá restaurá-la depois sem perder o histórico.
|
||||
A skill <strong>{skill.name}</strong> ficará invisível para o agente. Você poderá
|
||||
restaurá-la depois sem perder o histórico.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
|
|
@ -219,8 +225,8 @@ export function SkillCard({ skill }: { skill: Skill }) {
|
|||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Deletar definitivamente?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Esta ação remove <strong>{skill.name}</strong> e todo o histórico de versões.
|
||||
Não é possível desfazer.
|
||||
Esta ação remove <strong>{skill.name}</strong> e todo o histórico de versões. Não é
|
||||
possível desfazer.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
|
|
|
|||
|
|
@ -6,20 +6,11 @@ import { Loader2, Play, X } from "lucide-react";
|
|||
import { formatDistanceToNow } from "date-fns";
|
||||
import { ptBR } from "date-fns/locale";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
|
|
@ -80,7 +71,7 @@ export function SkillTestPanel({
|
|||
// Modo preview sem persistência: chama AI direto via edge function
|
||||
const start = Date.now();
|
||||
const { data, error } = await supabase.functions.invoke("test-skill-dry-run", {
|
||||
body: { skill_version_id: skillVersionId, test_input: input },
|
||||
body: { markdown_content: stateless.markdown_content, test_input: input },
|
||||
});
|
||||
if (error) {
|
||||
return {
|
||||
|
|
@ -122,9 +113,7 @@ export function SkillTestPanel({
|
|||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium block mb-2">
|
||||
Digite um exemplo de input
|
||||
</label>
|
||||
<label className="text-sm font-medium block mb-2">Digite um exemplo de input</label>
|
||||
<Textarea
|
||||
ref={inputRef}
|
||||
value={input}
|
||||
|
|
@ -166,12 +155,11 @@ export function SkillTestPanel({
|
|||
<h3 className="font-semibold text-sm">
|
||||
{result.status === "success" ? "Resultado do teste" : "Erro no teste"}
|
||||
</h3>
|
||||
{result.status === "success" && (
|
||||
<Badge variant="info">
|
||||
Dry-run
|
||||
</Badge>
|
||||
)}
|
||||
<button onClick={() => setResult(null)} className="ml-auto text-muted-foreground hover:text-foreground">
|
||||
{result.status === "success" && <Badge variant="info">Dry-run</Badge>}
|
||||
<button
|
||||
onClick={() => setResult(null)}
|
||||
className="ml-auto text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -193,7 +181,10 @@ export function SkillTestPanel({
|
|||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="mt-3 space-y-2">
|
||||
{history.data.map((run) => (
|
||||
<div key={run.id} className="rounded-lg border border-border bg-muted/30 p-3 text-xs">
|
||||
<div
|
||||
key={run.id}
|
||||
className="rounded-lg border border-border bg-muted/30 p-3 text-xs"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<Badge
|
||||
variant="outline"
|
||||
|
|
@ -206,7 +197,10 @@ export function SkillTestPanel({
|
|||
{run.status === "success" ? "Sucesso" : "Erro"}
|
||||
</Badge>
|
||||
<span className="text-muted-foreground">
|
||||
{formatDistanceToNow(new Date(run.created_at), { addSuffix: true, locale: ptBR })}
|
||||
{formatDistanceToNow(new Date(run.created_at), {
|
||||
addSuffix: true,
|
||||
locale: ptBR,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<p className="font-medium text-foreground/80 truncate">{run.test_input}</p>
|
||||
|
|
@ -217,8 +211,8 @@ export function SkillTestPanel({
|
|||
)}
|
||||
|
||||
<p className="text-xs text-muted-foreground border-t border-border pt-3">
|
||||
O teste em modo simulação não executa ferramentas reais. Em breve você poderá
|
||||
testar a skill diretamente no seu agente.
|
||||
O teste em modo simulação não executa ferramentas reais. Em breve você poderá testar a
|
||||
skill diretamente no seu agente.
|
||||
</p>
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
|
|
|||
|
|
@ -694,6 +694,7 @@ export type Database = {
|
|||
current_version_id: string | null
|
||||
description: string
|
||||
id: string
|
||||
is_default: boolean
|
||||
name: string
|
||||
status: string
|
||||
trigger_keywords: string
|
||||
|
|
@ -706,6 +707,7 @@ export type Database = {
|
|||
current_version_id?: string | null
|
||||
description: string
|
||||
id?: string
|
||||
is_default?: boolean
|
||||
name: string
|
||||
status?: string
|
||||
trigger_keywords: string
|
||||
|
|
@ -718,6 +720,7 @@ export type Database = {
|
|||
current_version_id?: string | null
|
||||
description?: string
|
||||
id?: string
|
||||
is_default?: boolean
|
||||
name?: string
|
||||
status?: string
|
||||
trigger_keywords?: string
|
||||
|
|
|
|||
|
|
@ -19,7 +19,10 @@ export const Route = createFileRoute("/painel/agente")({
|
|||
component: AgentePage,
|
||||
});
|
||||
|
||||
const STATUS_MAP: Record<string, { label: string; variant: "warning" | "success" | "destructive"; pulse?: boolean }> = {
|
||||
const STATUS_MAP: Record<
|
||||
string,
|
||||
{ label: string; variant: "warning" | "success" | "destructive"; pulse?: boolean }
|
||||
> = {
|
||||
provisioning: { label: "Provisionando", variant: "warning", pulse: true },
|
||||
active: { label: "Online", variant: "success" },
|
||||
suspended: { label: "Suspenso", variant: "destructive" },
|
||||
|
|
@ -68,20 +71,22 @@ function AgentePage() {
|
|||
<div className="space-y-6">
|
||||
<header>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Meu Agente</h1>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
Gerencie seu agente pessoal de IA
|
||||
</p>
|
||||
<p className="mt-1 text-muted-foreground">Gerencie seu agente pessoal de IA</p>
|
||||
</header>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{/* Card 1: Seu agente — full width */}
|
||||
<div className="lg:col-span-2 rounded-xl border border-border bg-card p-6 shadow-soft">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className={cn(
|
||||
"h-14 w-14 rounded-full flex items-center justify-center shrink-0",
|
||||
status === "active" ? "bg-success/10" : "bg-primary/10",
|
||||
)}>
|
||||
<Bot className={cn("h-7 w-7", status === "active" ? "text-success" : "text-primary")} />
|
||||
<div
|
||||
className={cn(
|
||||
"h-14 w-14 rounded-full flex items-center justify-center shrink-0",
|
||||
status === "active" ? "bg-success/10" : "bg-primary/10",
|
||||
)}
|
||||
>
|
||||
<Bot
|
||||
className={cn("h-7 w-7", status === "active" ? "text-success" : "text-primary")}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
|
|
@ -128,14 +133,17 @@ function AgentePage() {
|
|||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||
<StatItem label="Interações hoje" value="0" />
|
||||
<StatItem
|
||||
label="Skills ativas"
|
||||
label="Skills personalizadas"
|
||||
value={limits.isLoading ? "..." : String(limits.data?.current_skills_count ?? 0)}
|
||||
/>
|
||||
<StatItem
|
||||
label="Último teste"
|
||||
value={
|
||||
lastTestRun.data
|
||||
? formatDistanceToNow(new Date(lastTestRun.data), { addSuffix: true, locale: ptBR })
|
||||
? formatDistanceToNow(new Date(lastTestRun.data), {
|
||||
addSuffix: true,
|
||||
locale: ptBR,
|
||||
})
|
||||
: "—"
|
||||
}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,18 @@
|
|||
import { createFileRoute, useNavigate, Link } from "@tanstack/react-router";
|
||||
import { useState, useCallback, lazy, Suspense } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { ArrowLeft, Check, Loader2, Play, Rocket, Save, MoreVertical, Copy, Archive, Trash2 } from "lucide-react";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Check,
|
||||
Loader2,
|
||||
Play,
|
||||
Rocket,
|
||||
Save,
|
||||
MoreVertical,
|
||||
Copy,
|
||||
Archive,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { ptBR } from "date-fns/locale";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
|
|
@ -81,23 +92,20 @@ function SkillDetailPage() {
|
|||
const [confirmArchive, setConfirmArchive] = useState(false);
|
||||
|
||||
// Sync markdown when versions load or selection changes
|
||||
const currentVersion = versions.data?.find((v) =>
|
||||
selectedVersionId ? v.id === selectedVersionId : v.is_live,
|
||||
) ?? versions.data?.[0];
|
||||
const currentVersion =
|
||||
versions.data?.find((v) => (selectedVersionId ? v.id === selectedVersionId : v.is_live)) ??
|
||||
versions.data?.[0];
|
||||
|
||||
if (currentVersion && markdown === "" && !editing) {
|
||||
// initial load
|
||||
setTimeout(() => setMarkdown(currentVersion.markdown_content), 0);
|
||||
}
|
||||
|
||||
const selectVersion = useCallback(
|
||||
(v: SkillVersion) => {
|
||||
setSelectedVersionId(v.id);
|
||||
setMarkdown(v.markdown_content);
|
||||
setEditing(false);
|
||||
},
|
||||
[],
|
||||
);
|
||||
const selectVersion = useCallback((v: SkillVersion) => {
|
||||
setSelectedVersionId(v.id);
|
||||
setMarkdown(v.markdown_content);
|
||||
setEditing(false);
|
||||
}, []);
|
||||
|
||||
// Save new version
|
||||
const saveVersion = useMutation({
|
||||
|
|
@ -106,15 +114,17 @@ function SkillDetailPage() {
|
|||
const maxVer = Math.max(...versions.data.map((v) => v.version_number), 0);
|
||||
const { data, error } = await supabase
|
||||
.from("skill_versions")
|
||||
.insert([{
|
||||
skill_id: id,
|
||||
version_number: maxVer + 1,
|
||||
markdown_content: markdown,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
form_inputs: (currentVersion?.form_inputs ?? {}) as any,
|
||||
is_live: false,
|
||||
created_by: user.id,
|
||||
}])
|
||||
.insert([
|
||||
{
|
||||
skill_id: id,
|
||||
version_number: maxVer + 1,
|
||||
markdown_content: markdown,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
form_inputs: (currentVersion?.form_inputs ?? {}) as any,
|
||||
is_live: false,
|
||||
created_by: user.id,
|
||||
},
|
||||
])
|
||||
.select("id, version_number")
|
||||
.single();
|
||||
if (error) throw error;
|
||||
|
|
@ -234,6 +244,11 @@ function SkillDetailPage() {
|
|||
<h1 className="text-xl font-bold truncate">{skill.data.name}</h1>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<SkillStatusBadge status={skill.data.status} />
|
||||
{skill.data.is_default && (
|
||||
<Badge variant="muted" className="rounded-md font-medium">
|
||||
Padrão
|
||||
</Badge>
|
||||
)}
|
||||
{currentVersion && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
v{currentVersion.version_number}
|
||||
|
|
@ -323,7 +338,10 @@ function SkillDetailPage() {
|
|||
<Suspense fallback={<Skeleton className="h-60" />}>
|
||||
<CodeMirrorEditor
|
||||
value={markdown}
|
||||
onChange={(v) => { setMarkdown(v); if (!editing) setEditing(true); }}
|
||||
onChange={(v) => {
|
||||
setMarkdown(v);
|
||||
if (!editing) setEditing(true);
|
||||
}}
|
||||
readOnly={!editing}
|
||||
/>
|
||||
</Suspense>
|
||||
|
|
@ -336,15 +354,22 @@ function SkillDetailPage() {
|
|||
<div className="lg:hidden">
|
||||
<Tabs defaultValue="preview">
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger value="editor" className="flex-1">Editor</TabsTrigger>
|
||||
<TabsTrigger value="preview" className="flex-1">Preview</TabsTrigger>
|
||||
<TabsTrigger value="editor" className="flex-1">
|
||||
Editor
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="preview" className="flex-1">
|
||||
Preview
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="editor" className="mt-4">
|
||||
<div className="rounded-xl border border-border bg-card overflow-hidden">
|
||||
<Suspense fallback={<Skeleton className="h-60" />}>
|
||||
<CodeMirrorEditor
|
||||
value={markdown}
|
||||
onChange={(v) => { setMarkdown(v); if (!editing) setEditing(true); }}
|
||||
onChange={(v) => {
|
||||
setMarkdown(v);
|
||||
if (!editing) setEditing(true);
|
||||
}}
|
||||
readOnly={!editing}
|
||||
/>
|
||||
</Suspense>
|
||||
|
|
|
|||
|
|
@ -14,11 +14,7 @@ import { Button } from "@/components/ui/button";
|
|||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
|
||||
export const Route = createFileRoute("/painel/skills/")({
|
||||
component: SkillsPage,
|
||||
|
|
@ -31,10 +27,8 @@ function SkillsPage() {
|
|||
const skills = useSkills(showArchived);
|
||||
|
||||
const loading = limits.isLoading || agent.isLoading || skills.isLoading;
|
||||
const noSub = !limits.isLoading && (limits.data?.max_skills == null);
|
||||
const agentNotReady =
|
||||
!agent.isLoading &&
|
||||
(!agent.data || agent.data.status === "provisioning");
|
||||
const noSub = !limits.isLoading && limits.data?.max_skills == null;
|
||||
const agentNotReady = !agent.isLoading && (!agent.data || agent.data.status === "provisioning");
|
||||
const atLimit =
|
||||
limits.data != null &&
|
||||
limits.data.max_skills != null &&
|
||||
|
|
@ -67,7 +61,7 @@ function SkillsPage() {
|
|||
<div className="flex items-center gap-3 flex-wrap">
|
||||
{limits.data && limits.data.max_skills != null && (
|
||||
<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} personalizadas
|
||||
</span>
|
||||
)}
|
||||
|
||||
|
|
@ -102,7 +96,7 @@ function SkillsPage() {
|
|||
<TooltipContent>
|
||||
{agentNotReady
|
||||
? "Aguarde o provisionamento do agente terminar"
|
||||
: `Limite de ${limits.data?.max_skills} skills do plano ${limits.data?.plan_slug ?? ""}. Faça upgrade ou arquive uma skill.`}
|
||||
: `Limite de ${limits.data?.max_skills} skills personalizadas do plano ${limits.data?.plan_slug ?? ""}. Faça upgrade ou arquive uma skill.`}
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
|
|
|
|||
|
|
@ -45,109 +45,117 @@ function SkillPreviewPage() {
|
|||
const hasContent = markdown.trim().length > 0;
|
||||
|
||||
// Helper: parse Supabase error codes
|
||||
const handleSupabaseError = useCallback((error: { code?: string; message?: string }) => {
|
||||
if (error.code === "P0001") {
|
||||
toast.error("Você precisa de uma assinatura ativa para criar skills.", {
|
||||
action: { label: "Ver planos", onClick: () => navigate({ to: "/", hash: "planos" }) },
|
||||
});
|
||||
return true;
|
||||
}
|
||||
if (error.code === "P0002") {
|
||||
toast.error("Você atingiu o limite de skills do seu plano. Faça upgrade ou arquive uma skill existente.");
|
||||
return true;
|
||||
}
|
||||
if (error.code === "23505") {
|
||||
toast.error("Você já tem uma skill com esse nome. Escolha outro.");
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}, [navigate]);
|
||||
|
||||
const createSkill = useCallback(async (publish: boolean) => {
|
||||
if (!user || !agentId) return;
|
||||
if (markdown.length > 50000) {
|
||||
toast.error("O conteúdo excede 50.000 caracteres. Reduza antes de salvar.");
|
||||
return;
|
||||
}
|
||||
|
||||
const setter = publish ? setPublishing : setSaving;
|
||||
setter(true);
|
||||
|
||||
try {
|
||||
const fi = formInputs as Record<string, unknown>;
|
||||
// 1. Create skill
|
||||
const { data: skill, error: skillErr } = await supabase
|
||||
.from("skills")
|
||||
.insert({
|
||||
user_id: user.id,
|
||||
agent_instance_id: agentId,
|
||||
name: (fi.name as string) || "Skill sem nome",
|
||||
description: (fi.description as string) || "",
|
||||
trigger_keywords: (fi.trigger_keywords as string) || "",
|
||||
status: "draft",
|
||||
})
|
||||
.select("id")
|
||||
.single();
|
||||
|
||||
if (skillErr) {
|
||||
if (!handleSupabaseError(skillErr as { code?: string })) {
|
||||
toast.error(skillErr.message || "Erro ao criar skill");
|
||||
}
|
||||
return;
|
||||
const handleSupabaseError = useCallback(
|
||||
(error: { code?: string; message?: string }) => {
|
||||
if (error.code === "P0001") {
|
||||
toast.error("Você precisa de uma assinatura ativa para criar skills.", {
|
||||
action: { label: "Ver planos", onClick: () => navigate({ to: "/", hash: "planos" }) },
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// 2. Create version
|
||||
const { data: ver, error: verErr } = await supabase
|
||||
.from("skill_versions")
|
||||
.insert([{
|
||||
skill_id: skill.id,
|
||||
version_number: 1,
|
||||
markdown_content: markdown,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
form_inputs: formInputs as any,
|
||||
is_live: false,
|
||||
created_by: user.id,
|
||||
}])
|
||||
.select("id")
|
||||
.single();
|
||||
|
||||
if (verErr) {
|
||||
toast.error(verErr.message || "Erro ao salvar versão");
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Optionally publish
|
||||
if (publish) {
|
||||
const { data: pubData, error: pubErr } = await supabase.functions.invoke<{
|
||||
success?: boolean;
|
||||
synced?: boolean;
|
||||
sync_error?: string;
|
||||
}>(
|
||||
"publish-skill-version",
|
||||
{ body: { skill_version_id: ver.id } },
|
||||
if (error.code === "P0002") {
|
||||
toast.error(
|
||||
"Você atingiu o limite de skills do seu plano. Faça upgrade ou arquive uma skill existente.",
|
||||
);
|
||||
if (pubErr) {
|
||||
toast.error("Skill salva, mas falha ao publicar: " + pubErr.message);
|
||||
} else if (pubData?.synced === false) {
|
||||
toast.warning("Skill publicada, mas o sync com o container falhou.", {
|
||||
description: pubData.sync_error || "Tente novamente após o próximo deploy.",
|
||||
});
|
||||
} else {
|
||||
toast.success("Skill publicada com sucesso!");
|
||||
}
|
||||
} else {
|
||||
toast.success("Rascunho salvo!");
|
||||
return true;
|
||||
}
|
||||
if (error.code === "23505") {
|
||||
toast.error("Você já tem uma skill com esse nome. Escolha outro.");
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
[navigate],
|
||||
);
|
||||
|
||||
const createSkill = useCallback(
|
||||
async (publish: boolean) => {
|
||||
if (!user || !agentId) return;
|
||||
if (markdown.length > 50000) {
|
||||
toast.error("O conteúdo excede 50.000 caracteres. Reduza antes de salvar.");
|
||||
return;
|
||||
}
|
||||
|
||||
qc.invalidateQueries({ queryKey: ["skills"] });
|
||||
qc.invalidateQueries({ queryKey: ["user-limits"] });
|
||||
navigate({ to: "/painel/skills/$id", params: { id: skill.id } });
|
||||
} catch {
|
||||
toast.error("Erro inesperado");
|
||||
} finally {
|
||||
setter(false);
|
||||
}
|
||||
}, [user, agentId, markdown, formInputs, handleSupabaseError, navigate, qc]);
|
||||
const setter = publish ? setPublishing : setSaving;
|
||||
setter(true);
|
||||
|
||||
try {
|
||||
const fi = formInputs as Record<string, unknown>;
|
||||
// 1. Create skill
|
||||
const { data: skill, error: skillErr } = await supabase
|
||||
.from("skills")
|
||||
.insert({
|
||||
user_id: user.id,
|
||||
agent_instance_id: agentId,
|
||||
name: (fi.name as string) || "Skill sem nome",
|
||||
description: (fi.description as string) || "",
|
||||
trigger_keywords: (fi.trigger_keywords as string) || "",
|
||||
status: "draft",
|
||||
})
|
||||
.select("id")
|
||||
.single();
|
||||
|
||||
if (skillErr) {
|
||||
if (!handleSupabaseError(skillErr as { code?: string })) {
|
||||
toast.error(skillErr.message || "Erro ao criar skill");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Create version
|
||||
const { data: ver, error: verErr } = await supabase
|
||||
.from("skill_versions")
|
||||
.insert([
|
||||
{
|
||||
skill_id: skill.id,
|
||||
version_number: 1,
|
||||
markdown_content: markdown,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
form_inputs: formInputs as any,
|
||||
is_live: false,
|
||||
created_by: user.id,
|
||||
},
|
||||
])
|
||||
.select("id")
|
||||
.single();
|
||||
|
||||
if (verErr) {
|
||||
await supabase.from("skills").delete().eq("id", skill.id);
|
||||
toast.error(verErr.message || "Erro ao salvar versão");
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Optionally publish
|
||||
if (publish) {
|
||||
const { data: pubData, error: pubErr } = await supabase.functions.invoke<{
|
||||
success?: boolean;
|
||||
synced?: boolean;
|
||||
sync_error?: string;
|
||||
}>("publish-skill-version", { body: { skill_version_id: ver.id } });
|
||||
if (pubErr) {
|
||||
toast.error("Skill salva, mas falha ao publicar: " + pubErr.message);
|
||||
} else if (pubData?.synced === false) {
|
||||
toast.warning("Skill publicada, mas o sync com o container falhou.", {
|
||||
description: pubData.sync_error || "Tente novamente após o próximo deploy.",
|
||||
});
|
||||
} else {
|
||||
toast.success("Skill publicada com sucesso!");
|
||||
}
|
||||
} else {
|
||||
toast.success("Rascunho salvo!");
|
||||
}
|
||||
|
||||
qc.invalidateQueries({ queryKey: ["skills"] });
|
||||
qc.invalidateQueries({ queryKey: ["user-limits"] });
|
||||
navigate({ to: "/painel/skills/$id", params: { id: skill.id } });
|
||||
} catch {
|
||||
toast.error("Erro inesperado");
|
||||
} finally {
|
||||
setter(false);
|
||||
}
|
||||
},
|
||||
[user, agentId, markdown, formInputs, handleSupabaseError, navigate, qc],
|
||||
);
|
||||
|
||||
if (!routerState.markdown_content) {
|
||||
return (
|
||||
|
|
@ -175,8 +183,16 @@ function SkillPreviewPage() {
|
|||
<Button variant="outline" onClick={() => setTestOpen(true)} disabled={!hasContent}>
|
||||
<Play className="h-4 w-4 mr-1" /> Testar antes
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => createSkill(false)} disabled={saving || publishing || !hasContent}>
|
||||
{saving ? <Loader2 className="h-4 w-4 mr-1 animate-spin" /> : <Save className="h-4 w-4 mr-1" />}
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => createSkill(false)}
|
||||
disabled={saving || publishing || !hasContent}
|
||||
>
|
||||
{saving ? (
|
||||
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
|
||||
) : (
|
||||
<Save className="h-4 w-4 mr-1" />
|
||||
)}
|
||||
Salvar rascunho
|
||||
</Button>
|
||||
<Button
|
||||
|
|
@ -184,7 +200,11 @@ function SkillPreviewPage() {
|
|||
disabled={saving || publishing || !hasContent}
|
||||
className="bg-primary hover:bg-primary-dark text-primary-foreground"
|
||||
>
|
||||
{publishing ? <Loader2 className="h-4 w-4 mr-1 animate-spin" /> : <FileText className="h-4 w-4 mr-1" />}
|
||||
{publishing ? (
|
||||
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
|
||||
) : (
|
||||
<FileText className="h-4 w-4 mr-1" />
|
||||
)}
|
||||
Publicar agora
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -220,11 +220,16 @@ export async function ensureDefaultSkillsForAgent(
|
|||
for (const template of DEFAULT_HERMES_SKILLS) {
|
||||
const { data: existing, error: existingErr } = await supabase
|
||||
.from("skills")
|
||||
.select("id")
|
||||
.select("id, current_version_id, is_default")
|
||||
.eq("user_id", agent.user_id)
|
||||
.eq("name", template.name)
|
||||
.neq("status", "archived")
|
||||
.maybeSingle();
|
||||
const existingSkill = existing as {
|
||||
id: string;
|
||||
current_version_id: string | null;
|
||||
is_default?: boolean | null;
|
||||
} | null;
|
||||
|
||||
if (existingErr) {
|
||||
result.errors.push(
|
||||
|
|
@ -233,11 +238,20 @@ export async function ensureDefaultSkillsForAgent(
|
|||
continue;
|
||||
}
|
||||
|
||||
if (existing) {
|
||||
if (existingSkill?.current_version_id) {
|
||||
result.skipped_count += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (existingSkill) {
|
||||
if (existingSkill.is_default) {
|
||||
await supabase.from("skills").delete().eq("id", existingSkill.id);
|
||||
} else {
|
||||
result.skipped_count += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const { data: skillData, error: skillErr } = await supabase
|
||||
.from("skills")
|
||||
.insert({
|
||||
|
|
@ -246,6 +260,7 @@ export async function ensureDefaultSkillsForAgent(
|
|||
name: template.name,
|
||||
description: template.description,
|
||||
trigger_keywords: template.trigger_keywords,
|
||||
is_default: true,
|
||||
status: "draft",
|
||||
})
|
||||
.select("id")
|
||||
|
|
@ -274,6 +289,7 @@ export async function ensureDefaultSkillsForAgent(
|
|||
const version = versionData as { id: string } | null;
|
||||
|
||||
if (versionErr || !version) {
|
||||
await supabase.from("skills").delete().eq("id", skill.id);
|
||||
result.errors.push(
|
||||
`${template.name}: failed to create skill version (${versionErr?.message ?? "unknown"})`,
|
||||
);
|
||||
|
|
@ -290,6 +306,7 @@ export async function ensureDefaultSkillsForAgent(
|
|||
.eq("id", skill.id);
|
||||
|
||||
if (updateErr) {
|
||||
await supabase.from("skills").delete().eq("id", skill.id);
|
||||
result.errors.push(`${template.name}: failed to publish skill (${updateErr.message})`);
|
||||
continue;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,57 @@ const SYSTEM_PROMPT = `Você é o agente Mika executando uma skill em modo de te
|
|||
|
||||
const admin = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY);
|
||||
|
||||
class DryRunError extends Error {
|
||||
status: number;
|
||||
clientMessage: string;
|
||||
|
||||
constructor(status: number, clientMessage: string, logMessage = clientMessage) {
|
||||
super(logMessage);
|
||||
this.status = status;
|
||||
this.clientMessage = clientMessage;
|
||||
}
|
||||
}
|
||||
|
||||
async function generateDryRunOutput(markdownContent: string, testInput: string): Promise<string> {
|
||||
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${markdownContent}\n\`\`\`\n\nInput do usuário: ${testInput}`,
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
if (aiRes.status === 429) {
|
||||
throw new DryRunError(
|
||||
429,
|
||||
"Muitas requisições. Aguarde 1 minuto e tente novamente.",
|
||||
"Muitas requisições. Aguarde 1 minuto.",
|
||||
);
|
||||
}
|
||||
|
||||
if (!aiRes.ok) {
|
||||
const txt = await aiRes.text();
|
||||
throw new DryRunError(
|
||||
500,
|
||||
"Falha ao executar teste.",
|
||||
`AI error ${aiRes.status}: ${txt.slice(0, 200)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const data = await aiRes.json();
|
||||
return (data?.choices?.[0]?.message?.content ?? "").trim();
|
||||
}
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
if (req.method === "OPTIONS") {
|
||||
return new Response(null, { headers: corsHeaders });
|
||||
|
|
@ -34,7 +85,7 @@ Deno.serve(async (req) => {
|
|||
}
|
||||
const userId = userData.user.id;
|
||||
|
||||
let body: { skill_version_id?: string; test_input?: string };
|
||||
let body: { skill_version_id?: string; markdown_content?: string; test_input?: string };
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
|
|
@ -44,19 +95,57 @@ Deno.serve(async (req) => {
|
|||
});
|
||||
}
|
||||
|
||||
const { skill_version_id, test_input } = body;
|
||||
if (!skill_version_id || !test_input || test_input.trim().length === 0) {
|
||||
const skillVersionId =
|
||||
typeof body.skill_version_id === "string" ? body.skill_version_id.trim() : "";
|
||||
const markdownContent =
|
||||
typeof body.markdown_content === "string" ? body.markdown_content.trim() : "";
|
||||
const testInput = typeof body.test_input === "string" ? body.test_input.trim() : "";
|
||||
|
||||
if (testInput.length === 0) {
|
||||
return new Response(JSON.stringify({ error: "test_input é obrigatório" }), {
|
||||
status: 400,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
if (!skillVersionId && !markdownContent) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "skill_version_id e test_input são obrigatórios" }),
|
||||
JSON.stringify({ error: "skill_version_id ou markdown_content é obrigatório" }),
|
||||
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
|
||||
if (markdownContent.length > 50000) {
|
||||
return new Response(JSON.stringify({ error: "markdown_content excede 50000 caracteres" }), {
|
||||
status: 400,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
if (markdownContent) {
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
const test_output = await generateDryRunOutput(markdownContent, testInput);
|
||||
return new Response(
|
||||
JSON.stringify({ test_output, duration_ms: Date.now() - startedAt, status: "success" }),
|
||||
{ status: 200, headers: { ...corsHeaders, "Content-Type": "application/json" } },
|
||||
);
|
||||
} catch (e) {
|
||||
const msg = e instanceof DryRunError ? e.clientMessage : "Erro ao executar teste.";
|
||||
const status = e instanceof DryRunError ? e.status : 500;
|
||||
console.error("Stateless skill dry-run failed:", e instanceof Error ? e.message : String(e));
|
||||
return new Response(JSON.stringify({ error: msg }), {
|
||||
status,
|
||||
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)
|
||||
.eq("id", skillVersionId)
|
||||
.maybeSingle();
|
||||
|
||||
if (vErr || !versionRow) {
|
||||
|
|
@ -77,9 +166,9 @@ Deno.serve(async (req) => {
|
|||
const { data: runRow, error: runErr } = await admin
|
||||
.from("skill_test_runs")
|
||||
.insert({
|
||||
skill_version_id,
|
||||
skill_version_id: skillVersionId,
|
||||
user_id: userId,
|
||||
test_input,
|
||||
test_input: testInput,
|
||||
status: "running",
|
||||
test_type: "dry_run",
|
||||
})
|
||||
|
|
@ -98,59 +187,7 @@ Deno.serve(async (req) => {
|
|||
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 test_output = await generateDryRunOutput(versionRow.markdown_content, testInput);
|
||||
const duration = Date.now() - startedAt;
|
||||
|
||||
await admin
|
||||
|
|
@ -158,10 +195,10 @@ Deno.serve(async (req) => {
|
|||
.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" } },
|
||||
);
|
||||
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";
|
||||
|
|
@ -169,9 +206,14 @@ Deno.serve(async (req) => {
|
|||
.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" },
|
||||
});
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: e instanceof DryRunError ? e.clientMessage : "Erro ao executar teste.",
|
||||
}),
|
||||
{
|
||||
status: e instanceof DryRunError ? e.status : 500,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
126
supabase/migrations/20260528203000_default_skills_quota.sql
Normal file
126
supabase/migrations/20260528203000_default_skills_quota.sql
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
-- Mark platform-seeded Hermes skills separately from user-created skills.
|
||||
-- Default skills stay active and synced to the runtime, but do not consume the
|
||||
-- customer's custom skill quota.
|
||||
|
||||
ALTER TABLE public.skills
|
||||
ADD COLUMN IF NOT EXISTS is_default boolean NOT NULL DEFAULT false;
|
||||
|
||||
COMMENT ON COLUMN public.skills.is_default IS
|
||||
'True for Mika/Hermes skills seeded by the platform. These do not count against the custom skill quota.';
|
||||
|
||||
UPDATE public.skills
|
||||
SET is_default = true
|
||||
WHERE is_default = false
|
||||
AND (
|
||||
(
|
||||
name = 'Resumo diario'
|
||||
AND description = 'Gera um resumo curto do dia com compromissos, tarefas e proximas prioridades.'
|
||||
)
|
||||
OR (
|
||||
name = 'Planejamento semanal'
|
||||
AND description = 'Ajuda o usuario a transformar objetivos da semana em prioridades e proximas acoes.'
|
||||
)
|
||||
OR (
|
||||
name = 'Preparar reuniao'
|
||||
AND description = 'Monta um briefing rapido antes de reunioes com contexto, pauta e perguntas uteis.'
|
||||
)
|
||||
);
|
||||
|
||||
CREATE OR REPLACE 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'
|
||||
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 IN ('active', 'trialing')
|
||||
LEFT JOIN public.plans pl
|
||||
ON pl.id = s.plan_id;
|
||||
|
||||
GRANT SELECT ON public.user_skill_limits TO authenticated;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.protect_skill_default_flag()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_request_role text := COALESCE(current_setting('request.jwt.claim.role', true), '');
|
||||
BEGIN
|
||||
IF v_request_role = 'service_role' THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
IF TG_OP = 'UPDATE' THEN
|
||||
NEW.is_default := OLD.is_default;
|
||||
ELSE
|
||||
NEW.is_default := false;
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP TRIGGER IF EXISTS trigger_protect_skill_default_flag ON public.skills;
|
||||
CREATE TRIGGER trigger_protect_skill_default_flag
|
||||
BEFORE INSERT OR UPDATE ON public.skills
|
||||
FOR EACH ROW EXECUTE FUNCTION public.protect_skill_default_flag();
|
||||
|
||||
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;
|
||||
v_request_role text := COALESCE(current_setting('request.jwt.claim.role', true), '');
|
||||
BEGIN
|
||||
IF COALESCE(NEW.is_default, false) THEN
|
||||
IF v_request_role = 'service_role' THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
NEW.is_default := false;
|
||||
END IF;
|
||||
|
||||
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;
|
||||
$$;
|
||||
|
||||
REVOKE EXECUTE ON FUNCTION public.protect_skill_default_flag() FROM anon, authenticated, public;
|
||||
REVOKE EXECUTE ON FUNCTION public.enforce_skill_limit() FROM anon, authenticated, public;
|
||||
Loading…
Add table
Add a link
Reference in a new issue