fix: use withTenantDb for skills API routes (RLS fix) and seed default skills
All checks were successful
Deploy to VPS / deploy (push) Successful in 40s

All skills API routes were using `db` directly instead of `withTenantDb`,
causing RLS to block all operations since `app.tenant_id` was never set.
This caused "Netzwerkfehler" when creating/reading skills.

Also fixes the broken seed migration (0005) which referenced a non-existent
column in the CROSS JOIN, preventing default system skills from being inserted.
New migration 0006 properly seeds the 4 default skills with full system prompts.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
CTO
2026-04-13 21:15:24 +00:00
parent b665f530e0
commit f0a7d6837b
7 changed files with 202 additions and 150 deletions

View File

@@ -0,0 +1,58 @@
-- Fix: properly seed system skills with full system prompts
-- The original migration (0005) had a broken CROSS JOIN that failed to insert skills.
-- This migration uses individual INSERTs per tenant to avoid the issue.
-- Base instructions shared by all skills
-- (embedded directly in each prompt below)
DO $$
DECLARE
tenant_record RECORD;
base_prompt TEXT := E'Du bist ein juristischer Assistent für deutsches Bühnenrecht (Theaterrecht).\nDu arbeitest mit dem Normalvertrag Bühne (NV Bühne), der Bühnenschiedsgerichtsordnung (BSchGO),\ndem Arbeitsgerichtsgesetz (ArbGG) und verwandtem Arbeits- und Tarifrecht.\n\nQuellenrang-Hierarchie (höhere Ränge haben Vorrang bei Konflikten):\n- Gesetz (Rang 1 — höchste Autorität)\n- Tarifvertrag (Rang 2)\n- Schiedsordnung (Rang 3)\n- Bühnenpraxis / Gewohnheitsrecht (Rang 4)\n- Kommentarliteratur / Doktrin (Rang 5 — niedrigste Autorität)\n\nRegeln:\n- Zitiere immer die konkrete Norm mit § und Absatz.\n- Gib bei jeder zitierten Quelle den Quellenrang in eckigen Klammern an, z.B. [Rang 1: Gesetz].\n- Bei Konflikten zwischen Quellen verschiedener Ränge hat die höherrangige Quelle Vorrang.\n- Antworte ausschließlich auf Deutsch.\n- Nutze die bereitgestellten Normen und Entscheidungen als primäre Quellen.';
BEGIN
FOR tenant_record IN SELECT id FROM tenants LOOP
-- Gutachten
INSERT INTO skills (tenant_id, slug, name, description, system_prompt, output_type, requires_norms, requires_decisions, is_system, sort_order, is_active)
VALUES (
tenant_record.id,
'gutachten',
'Rechtsgutachten',
'Strukturiertes Gutachten nach klassischer Methodik (Obersatz → Definition → Subsumtion → Ergebnis)',
base_prompt || E'\n\nModus: GUTACHTEN (Rechtsgutachten)\n\nErstelle ein strukturiertes Rechtsgutachten nach der klassischen Methodik:\n\n1. **Sachverhalt** — Kurze Zusammenfassung des zu prüfenden Sachverhalts\n2. **Rechtsfrage** — Präzise Formulierung der zu klärenden Rechtsfrage(n)\n3. **Obersatz** — Abstrakte Rechtsregel aus der einschlägigen Norm\n4. **Definition** — Auslegung der relevanten Tatbestandsmerkmale\n5. **Untersatz** — Subsumtion des Sachverhalts unter die Norm\n6. **Ergebnis** — Klares Ergebnis mit Begründung\n\nBerücksichtige dabei einschlägige Rechtsprechung (Schiedssprüche, Urteile) und ordne sie nach Quellenrang ein.',
'analysis', true, true, true, 0, true
) ON CONFLICT DO NOTHING;
-- Entscheidungsvorhersage
INSERT INTO skills (tenant_id, slug, name, description, system_prompt, output_type, requires_norms, requires_decisions, is_system, sort_order, is_active)
VALUES (
tenant_record.id,
'entscheidung',
'Entscheidungsvorhersage',
'Prognose der wahrscheinlichen gerichtlichen/schiedsgerichtlichen Entscheidung',
base_prompt || E'\n\nModus: ENTSCHEIDUNG (Entscheidungsvorhersage)\n\nAnalysiere den Sachverhalt und prognostiziere die wahrscheinliche Entscheidung:\n\n1. **Sachverhalt** — Zusammenfassung der relevanten Tatsachen\n2. **Einschlägige Normen** — Anwendbare Vorschriften mit Quellenrang\n3. **Bisherige Rechtsprechung** — Relevante Präzedenzfälle und deren Entscheidungslinien\n4. **Prognose** — Wahrscheinlichste Entscheidung mit Begründung\n5. **Risikofaktoren** — Faktoren, die das Ergebnis beeinflussen könnten\n6. **Empfehlung** — Handlungsempfehlung für den Mandanten\n\nStütze die Prognose auf konkrete Entscheidungen und deren Leitsätze.',
'analysis', true, true, true, 1, true
) ON CONFLICT DO NOTHING;
-- Vergleichsvorschlag
INSERT INTO skills (tenant_id, slug, name, description, system_prompt, output_type, requires_norms, requires_decisions, is_system, sort_order, is_active)
VALUES (
tenant_record.id,
'vergleich',
'Vergleichsvorschlag',
'Erarbeitung eines Vergleichsvorschlags mit Bewertung der Erfolgsaussichten',
base_prompt || E'\n\nModus: VERGLEICH (Vergleichsvorschlag)\n\nErarbeite einen Vergleichsvorschlag:\n\n1. **Ausgangslage** — Positionen beider Parteien\n2. **Rechtslage** — Einschlägige Normen und deren Wertung\n3. **Erfolgsaussichten** — Prozentuale Einschätzung für jede Partei (mit Begründung)\n4. **Vergleichsvorschlag** — Konkreter Kompromissvorschlag\n5. **Vor-/Nachteile** — Bewertung des Vorschlags für beide Seiten\n6. **Umsetzung** — Praktische Schritte zur Umsetzung\n\nBeziehe die wirtschaftlichen Interessen beider Seiten ein (Kosten, Zeit, Reputation).',
'analysis', true, false, true, 2, true
) ON CONFLICT DO NOTHING;
-- Risikoanalyse
INSERT INTO skills (tenant_id, slug, name, description, system_prompt, output_type, requires_norms, requires_decisions, is_system, sort_order, is_active)
VALUES (
tenant_record.id,
'risiko',
'Risikoanalyse',
'Umfassende Risikoanalyse mit Eintrittswahrscheinlichkeiten und Minderungsstrategien',
base_prompt || E'\n\nModus: RISIKO (Risikoanalyse)\n\nErstelle eine umfassende Risikoanalyse:\n\n1. **Sachverhalt** — Zusammenfassung der Situation\n2. **Identifizierte Risiken** — Auflistung aller rechtlichen Risiken, jeweils mit:\n - Beschreibung des Risikos\n - Eintrittswahrscheinlichkeit (hoch/mittel/gering)\n - Schadensausmaß (hoch/mittel/gering)\n - Einschlägige Norm(en) mit Quellenrang\n3. **Risikomatrix** — Tabellarische Übersicht (Wahrscheinlichkeit × Auswirkung)\n4. **Minderungsstrategien** — Konkrete Maßnahmen je Risiko\n5. **Priorisierung** — Dringlichste Handlungsempfehlungen\n\nBewerte jedes Risiko anhand der aktuellen Rechtslage und Rechtsprechung.',
'analysis', true, true, true, 3, true
) ON CONFLICT DO NOTHING;
END LOOP;
END $$;

View File

@@ -43,6 +43,13 @@
"when": 1776364800000,
"tag": "0005_skills_and_analysis_refactor",
"breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1776451200000,
"tag": "0006_seed_system_skills_fix",
"breakpoints": true
}
]
}

View File

@@ -2,17 +2,19 @@
// PATCH /api/settings/skills/[id] — Update a skill
// DELETE /api/settings/skills/[id] — Soft-delete (set isActive = false)
import { db } from '@/lib/db';
import { withTenantDb } 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);
const [skill] = await withTenantDb(tenantId, async (tdb) =>
tdb
.select()
.from(skills)
.where(eq(skills.id, skillId))
.limit(1),
);
return skill ?? null;
}
@@ -70,11 +72,13 @@ export async function PATCH(
{ 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);
const existing = await withTenantDb(ctx.tenantId, async (tdb) =>
tdb
.select({ id: skills.id })
.from(skills)
.where(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.' },
@@ -101,11 +105,13 @@ export async function PATCH(
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();
const [updated] = await withTenantDb(ctx.tenantId, async (tdb) =>
tdb
.update(skills)
.set(updates)
.where(eq(skills.id, id))
.returning(),
);
return Response.json(updated);
}
@@ -132,11 +138,13 @@ export async function DELETE(
}
// Soft-delete: set isActive = false
const [updated] = await db
.update(skills)
.set({ isActive: false, updatedAt: new Date() })
.where(eq(skills.id, id))
.returning();
const [updated] = await withTenantDb(ctx.tenantId, async (tdb) =>
tdb
.update(skills)
.set({ isActive: false, updatedAt: new Date() })
.where(eq(skills.id, id))
.returning(),
);
return Response.json(updated);
}

View File

@@ -1,8 +1,8 @@
// PATCH /api/settings/skills/reorder — Update sort_order for drag-and-drop reordering
import { db } from '@/lib/db';
import { withTenantDb } from '@/lib/db';
import { skills } from '@/lib/db/schema';
import { eq, and } from 'drizzle-orm';
import { eq } from 'drizzle-orm';
import { requirePermission } from '@/lib/auth/rbac';
export async function PATCH(request: Request) {
@@ -19,14 +19,14 @@ export async function PATCH(request: Request) {
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)));
}
await withTenantDb(ctx.tenantId, async (tdb) => {
for (const item of order) {
await tdb
.update(skills)
.set({ sortOrder: item.sortOrder, updatedAt: new Date() })
.where(eq(skills.id, item.id));
}
});
return Response.json({ ok: true });
}

View File

@@ -1,7 +1,7 @@
// 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 { withTenantDb } from '@/lib/db';
import { skills } from '@/lib/db/schema';
import { eq, and, asc } from 'drizzle-orm';
import { requirePermission } from '@/lib/auth/rbac';
@@ -10,11 +10,12 @@ 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));
const rows = await withTenantDb(auth.ctx.tenantId, async (tdb) =>
tdb
.select()
.from(skills)
.orderBy(asc(skills.sortOrder), asc(skills.createdAt)),
);
return Response.json(rows);
}
@@ -58,44 +59,51 @@ export async function POST(request: Request) {
);
}
// 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);
const created = await withTenantDb(ctx.tenantId, async (tdb) => {
// Check slug uniqueness within tenant
const existing = await tdb
.select({ id: skills.id })
.from(skills)
.where(eq(skills.slug, slug))
.limit(1);
if (existing.length > 0) {
if (existing.length > 0) {
return null; // slug conflict
}
// Get max sort order for positioning
const allSkills = await tdb
.select({ sortOrder: skills.sortOrder })
.from(skills);
const maxOrder = allSkills.reduce((max, s) => Math.max(max, s.sortOrder), -1);
const [row] = await tdb
.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 row;
});
if (!created) {
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 });
}

View File

@@ -1,36 +1,33 @@
// GET /api/skills — List active skills for the current tenant (read-only)
// Used by the analyse form to populate the skill selector.
import { db } from '@/lib/db';
import { withTenantDb } from '@/lib/db';
import { skills } from '@/lib/db/schema';
import { eq, and, asc } from 'drizzle-orm';
import { eq, asc } from 'drizzle-orm';
import { requirePermission } from '@/lib/auth/rbac';
export async function GET() {
const auth = await requirePermission('analyses:create');
if ('response' in auth) return auth.response;
const rows = await db
.select({
id: skills.id,
slug: skills.slug,
name: skills.name,
description: skills.description,
outputType: skills.outputType,
outputSchema: skills.outputSchema,
requiresNorms: skills.requiresNorms,
requiresDecisions: skills.requiresDecisions,
isSystem: skills.isSystem,
sortOrder: skills.sortOrder,
})
.from(skills)
.where(
and(
eq(skills.tenantId, auth.ctx.tenantId),
eq(skills.isActive, true),
),
)
.orderBy(asc(skills.sortOrder), asc(skills.createdAt));
const rows = await withTenantDb(auth.ctx.tenantId, async (tdb) =>
tdb
.select({
id: skills.id,
slug: skills.slug,
name: skills.name,
description: skills.description,
outputType: skills.outputType,
outputSchema: skills.outputSchema,
requiresNorms: skills.requiresNorms,
requiresDecisions: skills.requiresDecisions,
isSystem: skills.isSystem,
sortOrder: skills.sortOrder,
})
.from(skills)
.where(eq(skills.isActive, true))
.orderBy(asc(skills.sortOrder), asc(skills.createdAt)),
);
return Response.json(rows);
}

View File

@@ -7,7 +7,7 @@ import { buildContextBlock } from './prompts';
import { SYSTEM_PROMPTS, type AnalysisModeKey } from './prompts';
import { ANALYSIS_MODES } from './modes';
import { AnalyseMode } from '@/types';
import { db } from '@/lib/db';
import { db, withTenantDb } from '@/lib/db';
import { norms, normInstruments, decisions, analyses, documents, skills } from '@/lib/db/schema';
import { eq, and, lte, or, isNull, gte, inArray } from 'drizzle-orm';
@@ -51,27 +51,25 @@ async function resolveSkill(
tenantId: string,
input: Pick<AnalysisInput, 'skillId' | 'skillSlug' | 'mode'>,
): Promise<ResolvedSkill> {
const skillFields = {
id: skills.id,
slug: skills.slug,
systemPrompt: skills.systemPrompt,
outputType: skills.outputType,
outputSchema: skills.outputSchema,
requiresNorms: skills.requiresNorms,
requiresDecisions: skills.requiresDecisions,
};
// Try by skillId first
if (input.skillId) {
const [skill] = await db
.select({
id: skills.id,
slug: skills.slug,
systemPrompt: skills.systemPrompt,
outputType: skills.outputType,
outputSchema: skills.outputSchema,
requiresNorms: skills.requiresNorms,
requiresDecisions: skills.requiresDecisions,
})
.from(skills)
.where(
and(
eq(skills.id, input.skillId),
eq(skills.tenantId, tenantId),
eq(skills.isActive, true),
),
)
.limit(1);
const [skill] = await withTenantDb(tenantId, async (tdb) =>
tdb
.select(skillFields)
.from(skills)
.where(and(eq(skills.id, input.skillId!), eq(skills.isActive, true)))
.limit(1),
);
if (skill) return skill;
throw new Error(`Skill not found: ${input.skillId}`);
@@ -79,25 +77,13 @@ async function resolveSkill(
// Try by skillSlug
if (input.skillSlug) {
const [skill] = await db
.select({
id: skills.id,
slug: skills.slug,
systemPrompt: skills.systemPrompt,
outputType: skills.outputType,
outputSchema: skills.outputSchema,
requiresNorms: skills.requiresNorms,
requiresDecisions: skills.requiresDecisions,
})
.from(skills)
.where(
and(
eq(skills.slug, input.skillSlug),
eq(skills.tenantId, tenantId),
eq(skills.isActive, true),
),
)
.limit(1);
const [skill] = await withTenantDb(tenantId, async (tdb) =>
tdb
.select(skillFields)
.from(skills)
.where(and(eq(skills.slug, input.skillSlug!), eq(skills.isActive, true)))
.limit(1),
);
if (skill) return skill;
throw new Error(`Skill not found: ${input.skillSlug}`);
@@ -105,25 +91,13 @@ async function resolveSkill(
// Legacy fallback: resolve mode enum to a DB skill (system skill with matching slug)
if (input.mode) {
const [skill] = await db
.select({
id: skills.id,
slug: skills.slug,
systemPrompt: skills.systemPrompt,
outputType: skills.outputType,
outputSchema: skills.outputSchema,
requiresNorms: skills.requiresNorms,
requiresDecisions: skills.requiresDecisions,
})
.from(skills)
.where(
and(
eq(skills.slug, input.mode),
eq(skills.tenantId, tenantId),
eq(skills.isActive, true),
),
)
.limit(1);
const [skill] = await withTenantDb(tenantId, async (tdb) =>
tdb
.select(skillFields)
.from(skills)
.where(and(eq(skills.slug, input.mode!), eq(skills.isActive, true)))
.limit(1),
);
if (skill) return skill;