feat: add Skills management settings UI and API routes (AIIA-97)
- Skill types (src/types/skill.ts) with form data, slugify helper - Skills settings component with list view (drag-and-drop reorder), editor form (name, slug, prompt, output type, JSON schema, context requirements, active toggle), system skill protection - API routes: GET/POST /api/settings/skills, GET/PATCH/DELETE /api/settings/skills/[id], PATCH /api/settings/skills/reorder - Integrated into /einstellungen page (admin only) - API routes depend on `skills` table from AIIA-94 schema migration Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -6,6 +6,7 @@ import { tenants, users } from '@/lib/db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import AISettingsForm from './ai-settings';
|
||||
import ApiKeySettings from './api-key-settings';
|
||||
import SkillsSettings from './skills-settings';
|
||||
|
||||
const ROLE_LABELS: Record<string, string> = {
|
||||
admin: 'Administrator',
|
||||
@@ -76,6 +77,8 @@ export default async function EinstellungenPage() {
|
||||
|
||||
{isAdmin && <ApiKeySettings />}
|
||||
|
||||
{isAdmin && <SkillsSettings />}
|
||||
|
||||
{isAdmin && tenantUsers.length > 0 && (
|
||||
<div className="bg-card-bg border border-card-border rounded-xl p-6">
|
||||
<h3 className="text-sm font-semibold text-foreground mb-4">Benutzer</h3>
|
||||
|
||||
496
src/app/(dashboard)/einstellungen/skills-settings.tsx
Normal file
496
src/app/(dashboard)/einstellungen/skills-settings.tsx
Normal file
@@ -0,0 +1,496 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import type { Skill, SkillFormData } from '@/types/skill';
|
||||
import { emptySkillForm, slugify } from '@/types/skill';
|
||||
|
||||
const OUTPUT_TYPE_LABELS: Record<string, string> = {
|
||||
analysis: 'Analyse',
|
||||
structured_data: 'Strukturierte Daten',
|
||||
};
|
||||
|
||||
export default function SkillsSettings() {
|
||||
const [skills, setSkills] = useState<Skill[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||
|
||||
// Editor state
|
||||
const [editing, setEditing] = useState<Skill | null>(null); // null = creating new
|
||||
const [showEditor, setShowEditor] = useState(false);
|
||||
const [form, setForm] = useState<SkillFormData>(emptySkillForm());
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [slugManual, setSlugManual] = useState(false);
|
||||
|
||||
// Drag state
|
||||
const [dragIdx, setDragIdx] = useState<number | null>(null);
|
||||
|
||||
const loadSkills = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/settings/skills');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setSkills(data);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadSkills();
|
||||
}, [loadSkills]);
|
||||
|
||||
function openCreate() {
|
||||
setEditing(null);
|
||||
setForm(emptySkillForm());
|
||||
setSlugManual(false);
|
||||
setShowEditor(true);
|
||||
setMessage(null);
|
||||
}
|
||||
|
||||
function openEdit(skill: Skill) {
|
||||
setEditing(skill);
|
||||
setForm({
|
||||
name: skill.name,
|
||||
slug: skill.slug,
|
||||
description: skill.description ?? '',
|
||||
systemPrompt: skill.systemPrompt,
|
||||
outputType: skill.outputType,
|
||||
outputSchema: skill.outputSchema ? JSON.stringify(skill.outputSchema, null, 2) : '',
|
||||
requiresNorms: skill.requiresNorms,
|
||||
requiresDecisions: skill.requiresDecisions,
|
||||
isActive: skill.isActive,
|
||||
});
|
||||
setSlugManual(true);
|
||||
setShowEditor(true);
|
||||
setMessage(null);
|
||||
}
|
||||
|
||||
function closeEditor() {
|
||||
setShowEditor(false);
|
||||
setEditing(null);
|
||||
setForm(emptySkillForm());
|
||||
setMessage(null);
|
||||
}
|
||||
|
||||
function handleNameChange(name: string) {
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
name,
|
||||
...(!slugManual && { slug: slugify(name) }),
|
||||
}));
|
||||
}
|
||||
|
||||
function handleSlugChange(slug: string) {
|
||||
setSlugManual(true);
|
||||
setForm((f) => ({ ...f, slug }));
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!form.name.trim() || !form.slug.trim() || !form.systemPrompt.trim()) {
|
||||
setMessage({ type: 'error', text: 'Name, Slug und System-Prompt sind Pflichtfelder.' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate JSON schema if structured data
|
||||
let outputSchema: Record<string, unknown> | null = null;
|
||||
if (form.outputType === 'structured_data') {
|
||||
if (!form.outputSchema.trim()) {
|
||||
setMessage({ type: 'error', text: 'JSON Schema ist bei strukturierten Daten erforderlich.' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
outputSchema = JSON.parse(form.outputSchema);
|
||||
} catch {
|
||||
setMessage({ type: 'error', text: 'Ungültiges JSON im Schema-Feld.' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
setMessage(null);
|
||||
|
||||
const payload = {
|
||||
name: form.name.trim(),
|
||||
slug: form.slug.trim(),
|
||||
description: form.description.trim() || null,
|
||||
systemPrompt: form.systemPrompt,
|
||||
outputType: form.outputType,
|
||||
outputSchema,
|
||||
requiresNorms: form.requiresNorms,
|
||||
requiresDecisions: form.requiresDecisions,
|
||||
isActive: form.isActive,
|
||||
};
|
||||
|
||||
try {
|
||||
const url = editing
|
||||
? `/api/settings/skills/${editing.id}`
|
||||
: '/api/settings/skills';
|
||||
const method = editing ? 'PATCH' : 'POST';
|
||||
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setMessage({ type: 'success', text: editing ? 'Skill aktualisiert.' : 'Skill erstellt.' });
|
||||
closeEditor();
|
||||
await loadSkills();
|
||||
} else {
|
||||
const data = await res.json();
|
||||
setMessage({ type: 'error', text: data.error ?? 'Fehler beim Speichern.' });
|
||||
}
|
||||
} catch {
|
||||
setMessage({ type: 'error', text: 'Netzwerkfehler.' });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(skill: Skill) {
|
||||
if (skill.isSystem) return;
|
||||
if (!confirm(`Skill "${skill.name}" wirklich löschen?`)) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/settings/skills/${skill.id}`, { method: 'DELETE' });
|
||||
if (res.ok) {
|
||||
setMessage({ type: 'success', text: 'Skill deaktiviert.' });
|
||||
await loadSkills();
|
||||
} else {
|
||||
const data = await res.json();
|
||||
setMessage({ type: 'error', text: data.error ?? 'Fehler beim Löschen.' });
|
||||
}
|
||||
} catch {
|
||||
setMessage({ type: 'error', text: 'Netzwerkfehler.' });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleActive(skill: Skill) {
|
||||
try {
|
||||
const res = await fetch(`/api/settings/skills/${skill.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ isActive: !skill.isActive }),
|
||||
});
|
||||
if (res.ok) await loadSkills();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
function handleResetToDefault(skill: Skill) {
|
||||
if (!skill.isSystem) return;
|
||||
// Prefill with current values — the backend handles "reset" by restoring original system prompt
|
||||
// For now, we just re-fetch. A future enhancement could add a dedicated reset endpoint.
|
||||
setMessage({ type: 'error', text: 'Zurücksetzen auf Standard wird noch nicht unterstützt.' });
|
||||
}
|
||||
|
||||
// Drag-and-drop reorder
|
||||
function handleDragStart(idx: number) {
|
||||
setDragIdx(idx);
|
||||
}
|
||||
|
||||
function handleDragOver(e: React.DragEvent, idx: number) {
|
||||
e.preventDefault();
|
||||
if (dragIdx === null || dragIdx === idx) return;
|
||||
|
||||
const reordered = [...skills];
|
||||
const [moved] = reordered.splice(dragIdx, 1);
|
||||
reordered.splice(idx, 0, moved);
|
||||
setSkills(reordered);
|
||||
setDragIdx(idx);
|
||||
}
|
||||
|
||||
async function handleDragEnd() {
|
||||
if (dragIdx === null) return;
|
||||
setDragIdx(null);
|
||||
|
||||
const order = skills.map((s, i) => ({ id: s.id, sortOrder: i }));
|
||||
try {
|
||||
await fetch('/api/settings/skills/reorder', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ order }),
|
||||
});
|
||||
} catch {
|
||||
await loadSkills(); // revert on error
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="bg-card-bg border border-card-border rounded-xl p-6">
|
||||
<p className="text-sm text-muted">Lade Skills...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-card-bg border border-card-border rounded-xl p-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-foreground">Skills (Analysemodi)</h3>
|
||||
{!showEditor && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={openCreate}
|
||||
className="rounded-lg bg-primary px-3 py-1.5 text-sm font-medium text-white hover:bg-primary/90"
|
||||
>
|
||||
+ Neuer Skill
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Message */}
|
||||
{message && !showEditor && (
|
||||
<p className={`text-sm ${message.type === 'success' ? 'text-green-500' : 'text-red-500'}`}>
|
||||
{message.text}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Editor */}
|
||||
{showEditor && (
|
||||
<form onSubmit={handleSubmit} className="space-y-4 border-t border-card-border pt-4">
|
||||
<p className="text-xs font-medium text-muted uppercase tracking-wide">
|
||||
{editing ? `Skill bearbeiten: ${editing.name}` : 'Neuen Skill erstellen'}
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm text-muted mb-1">Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.name}
|
||||
onChange={(e) => handleNameChange(e.target.value)}
|
||||
placeholder="z.B. Rechtsgutachten"
|
||||
className="w-full rounded-lg border border-card-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted/50"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-muted mb-1">Slug *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.slug}
|
||||
onChange={(e) => handleSlugChange(e.target.value)}
|
||||
placeholder="z.B. rechtsgutachten"
|
||||
className="w-full rounded-lg border border-card-border bg-background px-3 py-2 text-sm text-foreground font-mono placeholder:text-muted/50"
|
||||
/>
|
||||
<p className="text-xs text-muted mt-1">Eindeutiger Bezeichner (Kleinbuchstaben, Bindestriche)</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-muted mb-1">Beschreibung</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.description}
|
||||
onChange={(e) => setForm({ ...form, description: e.target.value })}
|
||||
placeholder="Kurze Beschreibung des Skills"
|
||||
className="w-full rounded-lg border border-card-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted/50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-muted mb-1">System-Prompt *</label>
|
||||
<textarea
|
||||
value={form.systemPrompt}
|
||||
onChange={(e) => setForm({ ...form, systemPrompt: e.target.value })}
|
||||
rows={20}
|
||||
placeholder="Der System-Prompt, der an das AI-Modell gesendet wird..."
|
||||
className="w-full rounded-lg border border-card-border bg-background px-3 py-2 text-sm text-foreground font-mono placeholder:text-muted/50 resize-y"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-muted mb-1">Ausgabetyp</label>
|
||||
<div className="flex gap-4">
|
||||
<label className="flex items-center gap-2 text-sm text-foreground cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="outputType"
|
||||
value="analysis"
|
||||
checked={form.outputType === 'analysis'}
|
||||
onChange={() => setForm({ ...form, outputType: 'analysis' })}
|
||||
className="accent-primary"
|
||||
/>
|
||||
Analyse (Freitext)
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm text-foreground cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="outputType"
|
||||
value="structured_data"
|
||||
checked={form.outputType === 'structured_data'}
|
||||
onChange={() => setForm({ ...form, outputType: 'structured_data' })}
|
||||
className="accent-primary"
|
||||
/>
|
||||
Strukturierte Daten
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{form.outputType === 'structured_data' && (
|
||||
<div>
|
||||
<label className="block text-sm text-muted mb-1">JSON Schema *</label>
|
||||
<textarea
|
||||
value={form.outputSchema}
|
||||
onChange={(e) => setForm({ ...form, outputSchema: e.target.value })}
|
||||
rows={10}
|
||||
placeholder='{"type": "object", "properties": {...}}'
|
||||
className="w-full rounded-lg border border-card-border bg-background px-3 py-2 text-sm text-foreground font-mono placeholder:text-muted/50 resize-y"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-6">
|
||||
<label className="flex items-center gap-2 text-sm text-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.requiresNorms}
|
||||
onChange={(e) => setForm({ ...form, requiresNorms: e.target.checked })}
|
||||
className="accent-primary"
|
||||
/>
|
||||
Normen benötigt
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm text-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.requiresDecisions}
|
||||
onChange={(e) => setForm({ ...form, requiresDecisions: e.target.checked })}
|
||||
className="accent-primary"
|
||||
/>
|
||||
Entscheidungen benötigt
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="flex items-center gap-2 text-sm text-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.isActive}
|
||||
onChange={(e) => setForm({ ...form, isActive: e.target.checked })}
|
||||
className="accent-primary"
|
||||
/>
|
||||
Aktiv
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{message && showEditor && (
|
||||
<p className={`text-sm ${message.type === 'success' ? 'text-green-500' : 'text-red-500'}`}>
|
||||
{message.text}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{saving ? 'Speichern...' : 'Speichern'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeEditor}
|
||||
className="rounded-lg border border-card-border px-4 py-2 text-sm font-medium text-muted hover:text-foreground"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
{editing?.isSystem && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleResetToDefault(editing)}
|
||||
className="rounded-lg border border-amber-300 px-4 py-2 text-sm font-medium text-amber-600 hover:bg-amber-50"
|
||||
>
|
||||
Standard wiederherstellen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* Skills list */}
|
||||
{!showEditor && skills.length === 0 && (
|
||||
<p className="text-sm text-muted">
|
||||
Keine Skills vorhanden. Erstellen Sie einen neuen Skill oder warten Sie auf die System-Skills.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!showEditor && skills.length > 0 && (
|
||||
<div className="divide-y divide-card-border">
|
||||
{skills.map((skill, idx) => (
|
||||
<div
|
||||
key={skill.id}
|
||||
draggable
|
||||
onDragStart={() => handleDragStart(idx)}
|
||||
onDragOver={(e) => handleDragOver(e, idx)}
|
||||
onDragEnd={handleDragEnd}
|
||||
className={`py-3 flex items-center justify-between gap-3 ${
|
||||
dragIdx === idx ? 'opacity-50' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<span
|
||||
className="cursor-grab text-muted hover:text-foreground shrink-0"
|
||||
title="Ziehen zum Sortieren"
|
||||
>
|
||||
⠿
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-foreground">{skill.name}</span>
|
||||
<span className="text-xs text-muted font-mono">({skill.slug})</span>
|
||||
<span
|
||||
className={`inline-block w-2 h-2 rounded-full shrink-0 ${
|
||||
skill.isActive ? 'bg-green-500' : 'bg-gray-400'
|
||||
}`}
|
||||
title={skill.isActive ? 'Aktiv' : 'Inaktiv'}
|
||||
/>
|
||||
{skill.isSystem && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-primary/10 text-primary font-medium">
|
||||
System
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted mt-0.5 truncate">
|
||||
{skill.description ?? '—'} · {OUTPUT_TYPE_LABELS[skill.outputType] ?? skill.outputType}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleToggleActive(skill)}
|
||||
className="text-xs px-2 py-1 rounded border border-card-border text-muted hover:text-foreground"
|
||||
>
|
||||
{skill.isActive ? 'Deaktivieren' : 'Aktivieren'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openEdit(skill)}
|
||||
className="text-xs px-2 py-1 rounded border border-card-border text-muted hover:text-foreground"
|
||||
>
|
||||
Bearbeiten
|
||||
</button>
|
||||
{!skill.isSystem && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDelete(skill)}
|
||||
className="text-xs px-2 py-1 rounded border border-red-300 text-red-500 hover:bg-red-50"
|
||||
>
|
||||
Löschen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
142
src/app/api/settings/skills/[id]/route.ts
Normal file
142
src/app/api/settings/skills/[id]/route.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
// GET /api/settings/skills/[id] — Get skill detail
|
||||
// PATCH /api/settings/skills/[id] — Update a skill
|
||||
// DELETE /api/settings/skills/[id] — Soft-delete (set isActive = false)
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { skills } from '@/lib/db/schema';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { requirePermission } from '@/lib/auth/rbac';
|
||||
|
||||
async function findSkillForTenant(skillId: string, tenantId: string) {
|
||||
const [skill] = await db
|
||||
.select()
|
||||
.from(skills)
|
||||
.where(and(eq(skills.id, skillId), eq(skills.tenantId, tenantId)))
|
||||
.limit(1);
|
||||
return skill ?? null;
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const auth = await requirePermission('settings:manage');
|
||||
if ('response' in auth) return auth.response;
|
||||
const { id } = await params;
|
||||
|
||||
const skill = await findSkillForTenant(id, auth.ctx.tenantId);
|
||||
if (!skill) {
|
||||
return Response.json({ error: 'Skill nicht gefunden.' }, { status: 404 });
|
||||
}
|
||||
|
||||
return Response.json(skill);
|
||||
}
|
||||
|
||||
export async function PATCH(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const auth = await requirePermission('settings:manage');
|
||||
if ('response' in auth) return auth.response;
|
||||
const { ctx } = auth;
|
||||
const { id } = await params;
|
||||
|
||||
const skill = await findSkillForTenant(id, ctx.tenantId);
|
||||
if (!skill) {
|
||||
return Response.json({ error: 'Skill nicht gefunden.' }, { status: 404 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const {
|
||||
name, slug, description, systemPrompt, outputType,
|
||||
outputSchema, requiresNorms, requiresDecisions, isActive,
|
||||
} = body as {
|
||||
name?: string;
|
||||
slug?: string;
|
||||
description?: string | null;
|
||||
systemPrompt?: string;
|
||||
outputType?: string;
|
||||
outputSchema?: Record<string, unknown> | null;
|
||||
requiresNorms?: boolean;
|
||||
requiresDecisions?: boolean;
|
||||
isActive?: boolean;
|
||||
};
|
||||
|
||||
// Validate slug if changed
|
||||
if (slug !== undefined && slug !== skill.slug) {
|
||||
if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/.test(slug)) {
|
||||
return Response.json(
|
||||
{ error: 'Slug muss Kleinbuchstaben, Ziffern und Bindestriche enthalten.' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
const existing = await db
|
||||
.select({ id: skills.id })
|
||||
.from(skills)
|
||||
.where(and(eq(skills.tenantId, ctx.tenantId), eq(skills.slug, slug)))
|
||||
.limit(1);
|
||||
if (existing.length > 0 && existing[0].id !== id) {
|
||||
return Response.json(
|
||||
{ error: 'Ein Skill mit diesem Slug existiert bereits.' },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (outputType === 'structured_data' && outputSchema === null) {
|
||||
return Response.json(
|
||||
{ error: 'JSON Schema ist bei strukturierten Daten erforderlich.' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const updates: Record<string, unknown> = { updatedAt: new Date() };
|
||||
if (name !== undefined) updates.name = name;
|
||||
if (slug !== undefined) updates.slug = slug;
|
||||
if (description !== undefined) updates.description = description;
|
||||
if (systemPrompt !== undefined) updates.systemPrompt = systemPrompt;
|
||||
if (outputType !== undefined) updates.outputType = outputType;
|
||||
if (outputSchema !== undefined) updates.outputSchema = outputSchema;
|
||||
if (requiresNorms !== undefined) updates.requiresNorms = requiresNorms;
|
||||
if (requiresDecisions !== undefined) updates.requiresDecisions = requiresDecisions;
|
||||
if (isActive !== undefined) updates.isActive = isActive;
|
||||
|
||||
const [updated] = await db
|
||||
.update(skills)
|
||||
.set(updates)
|
||||
.where(eq(skills.id, id))
|
||||
.returning();
|
||||
|
||||
return Response.json(updated);
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const auth = await requirePermission('settings:manage');
|
||||
if ('response' in auth) return auth.response;
|
||||
const { ctx } = auth;
|
||||
const { id } = await params;
|
||||
|
||||
const skill = await findSkillForTenant(id, ctx.tenantId);
|
||||
if (!skill) {
|
||||
return Response.json({ error: 'Skill nicht gefunden.' }, { status: 404 });
|
||||
}
|
||||
|
||||
if (skill.isSystem) {
|
||||
return Response.json(
|
||||
{ error: 'System-Skills können nicht gelöscht werden. Deaktivieren Sie den Skill stattdessen.' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Soft-delete: set isActive = false
|
||||
const [updated] = await db
|
||||
.update(skills)
|
||||
.set({ isActive: false, updatedAt: new Date() })
|
||||
.where(eq(skills.id, id))
|
||||
.returning();
|
||||
|
||||
return Response.json(updated);
|
||||
}
|
||||
32
src/app/api/settings/skills/reorder/route.ts
Normal file
32
src/app/api/settings/skills/reorder/route.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
// PATCH /api/settings/skills/reorder — Update sort_order for drag-and-drop reordering
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { skills } from '@/lib/db/schema';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { requirePermission } from '@/lib/auth/rbac';
|
||||
|
||||
export async function PATCH(request: Request) {
|
||||
const auth = await requirePermission('settings:manage');
|
||||
if ('response' in auth) return auth.response;
|
||||
const { ctx } = auth;
|
||||
|
||||
const body = await request.json();
|
||||
const { order } = body as {
|
||||
order?: Array<{ id: string; sortOrder: number }>;
|
||||
};
|
||||
|
||||
if (!order || !Array.isArray(order)) {
|
||||
return Response.json({ error: 'order Array ist erforderlich.' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Update each skill's sortOrder within a transaction-like loop
|
||||
// Verify tenant ownership for each skill
|
||||
for (const item of order) {
|
||||
await db
|
||||
.update(skills)
|
||||
.set({ sortOrder: item.sortOrder, updatedAt: new Date() })
|
||||
.where(and(eq(skills.id, item.id), eq(skills.tenantId, ctx.tenantId)));
|
||||
}
|
||||
|
||||
return Response.json({ ok: true });
|
||||
}
|
||||
101
src/app/api/settings/skills/route.ts
Normal file
101
src/app/api/settings/skills/route.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
// GET /api/settings/skills — List all skills for tenant (sorted by sortOrder)
|
||||
// POST /api/settings/skills — Create a new skill
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { skills } from '@/lib/db/schema';
|
||||
import { eq, and, asc } from 'drizzle-orm';
|
||||
import { requirePermission } from '@/lib/auth/rbac';
|
||||
|
||||
export async function GET() {
|
||||
const auth = await requirePermission('settings:manage');
|
||||
if ('response' in auth) return auth.response;
|
||||
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(skills)
|
||||
.where(eq(skills.tenantId, auth.ctx.tenantId))
|
||||
.orderBy(asc(skills.sortOrder), asc(skills.createdAt));
|
||||
|
||||
return Response.json(rows);
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const auth = await requirePermission('settings:manage');
|
||||
if ('response' in auth) return auth.response;
|
||||
const { ctx } = auth;
|
||||
|
||||
const body = await request.json();
|
||||
const { name, slug, description, systemPrompt, outputType, outputSchema, requiresNorms, requiresDecisions, isActive } = body as {
|
||||
name?: string;
|
||||
slug?: string;
|
||||
description?: string | null;
|
||||
systemPrompt?: string;
|
||||
outputType?: string;
|
||||
outputSchema?: Record<string, unknown> | null;
|
||||
requiresNorms?: boolean;
|
||||
requiresDecisions?: boolean;
|
||||
isActive?: boolean;
|
||||
};
|
||||
|
||||
if (!name || !slug || !systemPrompt) {
|
||||
return Response.json(
|
||||
{ error: 'Name, Slug und System-Prompt sind erforderlich.' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/.test(slug)) {
|
||||
return Response.json(
|
||||
{ error: 'Slug muss Kleinbuchstaben, Ziffern und Bindestriche enthalten.' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (outputType === 'structured_data' && !outputSchema) {
|
||||
return Response.json(
|
||||
{ error: 'JSON Schema ist bei strukturierten Daten erforderlich.' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Check slug uniqueness within tenant
|
||||
const existing = await db
|
||||
.select({ id: skills.id })
|
||||
.from(skills)
|
||||
.where(and(eq(skills.tenantId, ctx.tenantId), eq(skills.slug, slug)))
|
||||
.limit(1);
|
||||
|
||||
if (existing.length > 0) {
|
||||
return Response.json(
|
||||
{ error: 'Ein Skill mit diesem Slug existiert bereits.' },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
|
||||
// Get max sort order for positioning
|
||||
const allSkills = await db
|
||||
.select({ sortOrder: skills.sortOrder })
|
||||
.from(skills)
|
||||
.where(eq(skills.tenantId, ctx.tenantId));
|
||||
const maxOrder = allSkills.reduce((max, s) => Math.max(max, s.sortOrder), -1);
|
||||
|
||||
const [created] = await db
|
||||
.insert(skills)
|
||||
.values({
|
||||
tenantId: ctx.tenantId,
|
||||
name,
|
||||
slug,
|
||||
description: description || null,
|
||||
systemPrompt,
|
||||
outputType: (outputType as 'analysis' | 'structured_data') || 'analysis',
|
||||
outputSchema: outputSchema || null,
|
||||
requiresNorms: requiresNorms ?? false,
|
||||
requiresDecisions: requiresDecisions ?? false,
|
||||
isSystem: false,
|
||||
sortOrder: maxOrder + 1,
|
||||
isActive: isActive ?? true,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return Response.json(created, { status: 201 });
|
||||
}
|
||||
57
src/types/skill.ts
Normal file
57
src/types/skill.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
// Skill types for the Skills management feature
|
||||
|
||||
export interface Skill {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
systemPrompt: string;
|
||||
outputType: 'analysis' | 'structured_data';
|
||||
outputSchema: Record<string, unknown> | null;
|
||||
requiresNorms: boolean;
|
||||
requiresDecisions: boolean;
|
||||
isSystem: boolean;
|
||||
sortOrder: number;
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface SkillFormData {
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string;
|
||||
systemPrompt: string;
|
||||
outputType: 'analysis' | 'structured_data';
|
||||
outputSchema: string; // JSON string for the textarea
|
||||
requiresNorms: boolean;
|
||||
requiresDecisions: boolean;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export function emptySkillForm(): SkillFormData {
|
||||
return {
|
||||
name: '',
|
||||
slug: '',
|
||||
description: '',
|
||||
systemPrompt: '',
|
||||
outputType: 'analysis',
|
||||
outputSchema: '',
|
||||
requiresNorms: false,
|
||||
requiresDecisions: false,
|
||||
isActive: true,
|
||||
};
|
||||
}
|
||||
|
||||
/** Generate a URL-safe slug from a name */
|
||||
export function slugify(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[äÄ]/g, 'ae')
|
||||
.replace(/[öÖ]/g, 'oe')
|
||||
.replace(/[üÜ]/g, 'ue')
|
||||
.replace(/ß/g, 'ss')
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
}
|
||||
Reference in New Issue
Block a user