diff --git a/docs/API_GUIDE.md b/docs/API_GUIDE.md new file mode 100644 index 0000000..0686ecf --- /dev/null +++ b/docs/API_GUIDE.md @@ -0,0 +1,633 @@ +# StageAI API Usage Guide + +This guide covers all API endpoints with practical examples for text and office document sources. + +## Base URL + +``` +http://localhost:3000/api +``` + +--- + +## 1. Authentication + +### Register a New Tenant + +Creates a new tenant (law firm) and its first admin user. + +```bash +curl -X POST http://localhost:3000/api/auth/register \ + -H "Content-Type: application/json" \ + -d '{ + "name": "Dr. Müller", + "email": "mueller@kanzlei.de", + "password": "securepassword123", + "tenantName": "Kanzlei Müller" + }' +``` + +**Response (201):** +```json +{ + "user": { + "id": "uuid", + "email": "mueller@kanzlei.de", + "name": "Dr. Müller", + "role": "admin" + }, + "tenant": { + "id": "uuid", + "name": "Kanzlei Müller", + "slug": "kanzlei-mueller" + } +} +``` + +### Sign In (Get Session Cookie) + +Authentication uses NextAuth.js with the Credentials provider. Sign in to get a session cookie: + +```bash +curl -X POST http://localhost:3000/api/auth/callback/credentials \ + -H "Content-Type: application/json" \ + -d '{"email": "mueller@kanzlei.de", "password": "securepassword123"}' \ + -c cookies.txt +``` + +Use `-b cookies.txt` on subsequent requests to send the session cookie. + +### Session Info + +```bash +curl http://localhost:3000/api/auth/session -b cookies.txt +``` + +Returns the current user session including `tenantId`, `userId`, `role`, `email`, and `name`. + +> **Note:** Sessions expire after 8 hours. All protected endpoints require a valid session cookie. Tenant isolation is enforced at the database level via Row-Level Security. + +--- + +## 2. Cases (Fälle) + +### Create a Case + +```bash +curl -X POST http://localhost:3000/api/cases \ + -H "Content-Type: application/json" \ + -b cookies.txt \ + -d '{ + "caseNumber": "2024-BSchG-001", + "title": "Nichtverlängerung Solist", + "clientName": "Max Mustermann", + "opposingParty": "Staatstheater Berlin", + "venue": "Bühnenschiedsgericht Berlin", + "status": "active", + "domains": ["nv_buehne", "arbeitsrecht"] + }' +``` + +**Response (201):** +```json +{ + "id": "uuid", + "caseNumber": "2024-BSchG-001", + "title": "Nichtverlängerung Solist", + "clientName": "Max Mustermann", + "status": "active", + "createdAt": "2024-01-15T10:00:00Z" +} +``` + +### List Cases + +```bash +# All cases +curl http://localhost:3000/api/cases -b cookies.txt + +# Search + filter +curl "http://localhost:3000/api/cases?q=Mustermann&status=active&limit=10&offset=0" \ + -b cookies.txt +``` + +### Get Case Details + +```bash +curl http://localhost:3000/api/cases/{caseId} -b cookies.txt +``` + +Returns the case along with related analyses and proceedings. + +### Update / Delete a Case + +```bash +# Update +curl -X PATCH http://localhost:3000/api/cases/{caseId} \ + -H "Content-Type: application/json" \ + -b cookies.txt \ + -d '{"status": "closed"}' + +# Delete +curl -X DELETE http://localhost:3000/api/cases/{caseId} -b cookies.txt +``` + +--- + +## 3. Document Upload (Text & Office Sources) + +StageAI supports **PDF** and **DOCX** files (max 10 MB). Text is extracted automatically after upload. + +### Upload a Generic Document + +```bash +curl -X POST http://localhost:3000/api/documents \ + -b cookies.txt \ + -F "file=@/path/to/urteil.pdf" \ + -F "category=entscheidung" \ + -F "sourceScope=global" +``` + +**Parameters:** + +| Field | Required | Values | +|-------|----------|--------| +| `file` | Yes | PDF or DOCX file | +| `category` | Yes | `entscheidung`, `norm`, `falldokument`, `sonstiges` | +| `sourceScope` | No | `case` (private to a case) or `global` | +| `caseId` | No | Link to a specific case | +| `decisionId` | No | Link to a specific decision | +| `normInstrumentId` | No | Link to a norm instrument | + +**Response (201):** +```json +{ + "id": "uuid", + "filename": "urteil.pdf", + "mimeType": "application/pdf", + "category": "entscheidung", + "status": "uploaded", + "createdAt": "2024-01-15T10:30:00Z" +} +``` + +After upload, text extraction runs asynchronously. Status progresses: `uploaded` -> `extracting` -> `extracted` (or `failed`). + +### List Documents + +```bash +# All documents +curl "http://localhost:3000/api/documents" -b cookies.txt + +# Filter by category and scope +curl "http://localhost:3000/api/documents?category=entscheidung&sourceScope=global&limit=20&offset=0" \ + -b cookies.txt +``` + +--- + +## 4. Contract Analysis (Vertragsanalyse) + +Upload employment contracts (NV Bühne) for AI-powered clause analysis. + +### Upload a Contract + +```bash +curl -X POST http://localhost:3000/api/contracts \ + -b cookies.txt \ + -F "file=@/path/to/arbeitsvertrag.pdf" \ + -F "caseId=uuid-of-case" +``` + +**Response (201):** +```json +{ + "id": "uuid", + "filename": "arbeitsvertrag.pdf", + "mimeType": "application/pdf", + "status": "uploaded", + "caseId": "uuid-of-case" +} +``` + +### Trigger Clause Analysis + +After uploading, trigger the AI analysis: + +```bash +curl -X POST http://localhost:3000/api/contracts/{contractId}/analyze \ + -b cookies.txt +``` + +This extracts text from the document, identifies contract clauses, and compares them against NV Bühne standard clauses. The status progresses: `uploaded` -> `extracting` -> `extracted` -> `analyzing` -> `completed`. + +### Get Contract with Analysis Results + +```bash +curl http://localhost:3000/api/contracts/{contractId} \ + -H "x-tenant-id: your-tenant-id" \ + -b cookies.txt +``` + +**Response (200):** +```json +{ + "document": { + "id": "uuid", + "filename": "arbeitsvertrag.pdf", + "status": "completed" + }, + "clauses": [ + { + "id": "uuid", + "category": "Vergütung", + "extractedText": "Die monatliche Gage beträgt...", + "rating": "standard", + "analysis": "Entspricht der Gagenklasse III NV Bühne.", + "riskScore": 5, + "deviations": [] + }, + { + "id": "uuid", + "category": "Nichtverlängerung", + "extractedText": "Der Vertrag kann mit einer Frist von...", + "rating": "kritisch", + "analysis": "Die Frist weicht von § 69 NV Bühne ab.", + "riskScore": 85, + "deviations": ["Frist kürzer als tariflich vorgesehen"] + } + ] +} +``` + +**Clause Categories:** Vertragsparteien, Vertragsdauer, Nichtverlängerung, Vergütung, Arbeitszeit, Proben, Gastspiele, Urlaub, Krankheit, Kündigung, Nebentätigkeit, Geheimhaltung, Sonstiges + +**Ratings:** `standard` (conforms to NV Bühne), `abweichend` (deviates), `kritisch` (critical deviation), `unbekannt` (unclassifiable) + +### List Contracts + +```bash +curl "http://localhost:3000/api/contracts?limit=20&offset=0" -b cookies.txt +``` + +--- + +## 5. Legal Norms (Normen) + +### Create a Norm Instrument + +```bash +curl -X POST http://localhost:3000/api/norms \ + -H "Content-Type: application/json" \ + -b cookies.txt \ + -d '{ + "type": "tarifvertrag", + "sourceRank": "tarifvertrag", + "abbreviation": "NV Bühne", + "fullTitle": "Normalvertrag Bühne", + "enactedAt": "2023-01-01", + "issuingBody": "GDBA / VdO / DBV" + }' +``` + +### Import Norm Provisions (Bulk) + +```bash +curl -X POST http://localhost:3000/api/norms/import \ + -H "Content-Type: application/json" \ + -b cookies.txt \ + -d '{ + "instrumentId": "uuid-of-instrument", + "provisions": [ + { + "paragraph": "§ 1", + "title": "Geltungsbereich", + "body": "Dieser Normalvertrag gilt für alle Bühnenmitglieder...", + "validFrom": "2023-01-01", + "domains": ["nv_buehne"] + }, + { + "paragraph": "§ 69", + "title": "Nichtverlängerungsmitteilung", + "body": "Die Nichtverlängerungsmitteilung muss bis zum 31. Oktober...", + "validFrom": "2023-01-01", + "domains": ["nv_buehne", "nichtverlängerung"] + } + ] + }' +``` + +### Query Norms (with Temporal Versioning) + +Retrieve all paragraphs of an instrument valid on a specific date: + +```bash +# Norms valid today (default) +curl http://localhost:3000/api/norms/{instrumentId} -b cookies.txt + +# Norms valid on a specific date (Stichtag) +curl "http://localhost:3000/api/norms/{instrumentId}?date=2024-06-15" -b cookies.txt +``` + +**Response (200):** +```json +{ + "instrument": { + "id": "uuid", + "abbreviation": "NV Bühne", + "fullTitle": "Normalvertrag Bühne", + "type": "tarifvertrag" + }, + "provisions": [ + { + "id": "uuid", + "paragraph": "§ 1", + "title": "Geltungsbereich", + "body": "Dieser Normalvertrag gilt für alle Bühnenmitglieder...", + "validFrom": "2023-01-01", + "validTo": null + } + ] +} +``` + +--- + +## 6. Decisions (Entscheidungen) + +### Create a Decision + +```bash +curl -X POST http://localhost:3000/api/decisions \ + -H "Content-Type: application/json" \ + -b cookies.txt \ + -d '{ + "type": "schiedsspruch", + "caseReference": "BSchG Berlin 3/2024", + "decisionDate": "2024-03-15", + "court": "Bühnenschiedsgericht Berlin", + "headnote": "Zur Wirksamkeit einer Nichtverlängerungsmitteilung...", + "tenor": "Der Schiedsspruch wird aufgehoben...", + "facts": "Der Kläger ist seit 2018 als Solist...", + "reasoning": "Die Nichtverlängerungsmitteilung ist unwirksam, weil...", + "domains": ["nv_buehne", "nichtverlängerung"], + "keywords": ["Nichtverlängerung", "Solist", "Fristversäumnis"] + }' +``` + +### Full-Text Search for Decisions + +Uses PostgreSQL full-text search with German language support: + +```bash +# Search by keyword +curl "http://localhost:3000/api/decisions?q=Vergütung" -b cookies.txt + +# Combined filters +curl "http://localhost:3000/api/decisions?q=Nichtverlängerung&court=Bühnenschiedsgericht&type=schiedsspruch&dateFrom=2020-01-01&dateTo=2024-12-31" \ + -b cookies.txt +``` + +### Link Norms to a Decision + +```bash +curl -X POST http://localhost:3000/api/decisions/{decisionId}/norms \ + -H "Content-Type: application/json" \ + -b cookies.txt \ + -d '{ + "normId": "uuid-of-norm-paragraph", + "applicationType": "angewendet", + "passage": "Rn. 15-18" + }' +``` + +**Application types:** `angewendet` (applied), `zitiert` (cited), `ausgelegt` (interpreted), `verworfen` (rejected) + +--- + +## 7. AI-Powered Legal Analysis (Analysen) + +### Create a Streaming Analysis + +```bash +curl -X POST http://localhost:3000/api/analyses \ + -H "Content-Type: application/json" \ + -b cookies.txt \ + -d '{ + "mode": "gutachten", + "title": "Wirksamkeit der Nichtverlängerung", + "query": "Ist die Nichtverlängerungsmitteilung vom 15.11.2024 wirksam, wenn der Solist seit 15 Jahren am Haus beschäftigt ist?", + "normIds": ["uuid-of-§69-nv-buehne"], + "decisionIds": ["uuid-of-relevant-decision"], + "documentIds": ["uuid-of-uploaded-document"], + "stichtag": "2024-11-15", + "caseId": "uuid-of-case" + }' +``` + +The response streams text (the AI-generated analysis). The `X-Analysis-Id` response header contains the analysis ID for later retrieval. + +**Analysis Modes:** + +| Mode | Purpose | Requires | +|------|---------|----------| +| `gutachten` | Expert legal opinion | Norms | +| `entscheidung` | Decision proposal based on precedent | Decisions | +| `vergleich` | Comparative analysis | Norms or decisions | +| `risiko` | Risk assessment with probability ratings | Any sources | + +### Create a Structured Analysis (JSON Output) + +```bash +curl -X POST http://localhost:3000/api/analyses/structured \ + -H "Content-Type: application/json" \ + -b cookies.txt \ + -d '{ + "mode": "risiko", + "query": "Risikobewertung für die Kündigung eines Chormitglieds nach § 626 BGB", + "normIds": ["uuid1"], + "decisionIds": ["uuid2"] + }' +``` + +Returns typed JSON with structured fields depending on the mode. + +### List & Retrieve Analyses + +```bash +# List (metadata only, DSGVO-compliant) +curl "http://localhost:3000/api/analyses?limit=20&offset=0" -b cookies.txt + +# Get single analysis with full result and sources +curl http://localhost:3000/api/analyses/{analysisId} \ + -H "x-tenant-id: your-tenant-id" \ + -b cookies.txt +``` + +--- + +## 8. Proceedings (Verfahren) + +### Create a Proceeding + +```bash +curl -X POST http://localhost:3000/api/proceedings \ + -H "Content-Type: application/json" \ + -d '{ + "tenantId": "your-tenant-id", + "type": "bschgo_bezirk", + "caseId": "uuid-of-case", + "applicant": "Max Mustermann", + "respondent": "Staatstheater Berlin", + "subject": "Nichtverlängerung Spielzeit 2024/25" + }' +``` + +**Proceeding types:** `bschgo_bezirk`, `bschgo_bund`, `arbgg_erste_instanz`, `arbgg_berufung`, `arbgg_revision` + +Workflow steps and initial deadlines are automatically created from templates. + +### Check Deadlines + +```bash +# All deadlines +curl http://localhost:3000/api/proceedings/{proceedingId}/deadlines + +# Only overdue deadlines +curl "http://localhost:3000/api/proceedings/{proceedingId}/deadlines?overdue=true" + +# Upcoming in next 14 days +curl "http://localhost:3000/api/proceedings/{proceedingId}/deadlines?upcoming=14" +``` + +### Advance Proceeding + +```bash +curl -X POST http://localhost:3000/api/proceedings/{proceedingId}/advance \ + -b cookies.txt +``` + +--- + +## 9. NV Bühne Utilities + +These are public endpoints (no auth required) for quick calculations. + +### Calculate Compensation (Gage) + +```bash +# GET (simple query) +curl "http://localhost:3000/api/nv-buehne/compensation?gagenklasse=III&yearsOfService=5&spielzeit=2024/25&fachgruppe=Solo" + +# POST (with tenant-specific rules) +curl -X POST http://localhost:3000/api/nv-buehne/compensation \ + -H "Content-Type: application/json" \ + -d '{ + "gagenklasse": "III", + "yearsOfService": 5, + "spielzeit": "2024/25", + "fachgruppe": "Solo", + "tenantId": "uuid", + "fachgruppeId": "uuid" + }' +``` + +### Check Non-Renewal Deadline (Nichtverlängerungsfrist) + +```bash +curl "http://localhost:3000/api/nv-buehne/deadline-check?yearsOfService=15&isOver55=false&spielzeit=2024/25&fachgruppe=Solo&referenceDate=2024-10-31" +``` + +### Get Current Spielzeit (Season) + +```bash +curl "http://localhost:3000/api/nv-buehne/spielzeit" +# or for a specific date: +curl "http://localhost:3000/api/nv-buehne/spielzeit?date=2024-09-01" +``` + +--- + +## 10. Settings + +### Manage AI Provider API Keys + +```bash +# List keys (returns hints only, never full keys) +curl http://localhost:3000/api/settings/api-keys -b cookies.txt + +# Add an API key +curl -X POST http://localhost:3000/api/settings/api-keys \ + -H "Content-Type: application/json" \ + -b cookies.txt \ + -d '{ + "provider": "anthropic", + "apiKey": "sk-ant-...", + "label": "Production Key" + }' +``` + +Keys are encrypted at rest with AES-256-GCM. + +--- + +## Pagination + +All list endpoints support pagination: + +``` +?limit=20&offset=0 +``` + +**Response format:** +```json +{ + "data": [...], + "pagination": { + "total": 100, + "limit": 20, + "offset": 0, + "hasMore": true + } +} +``` + +## Error Handling + +All errors return JSON: + +```json +{ + "error": "Human-readable error message" +} +``` + +| Status | Meaning | +|--------|---------| +| 400 | Validation error (missing/invalid fields) | +| 401 | Not authenticated (missing or expired session) | +| 403 | Permission denied (role lacks required permission) | +| 404 | Resource not found | +| 409 | Conflict (duplicate entry) | +| 413 | File too large (max 10 MB) | + +## User Roles & Permissions + +| Role | Permissions | +|------|------------| +| `admin` | Full access, manage settings & users | +| `attorney` | Create/edit cases, analyses, norms, decisions | +| `paralegal` | Read access + limited write | +| `viewer` | Read-only | + +## Typical Workflow + +1. **Register** a tenant and admin user +2. **Configure** AI provider API key in Settings +3. **Create norms** (import NV Bühne provisions) +4. **Upload decisions** (create + link applicable norms) +5. **Create a case** for a client +6. **Upload documents** (court rulings, contracts as PDF/DOCX) +7. **Upload & analyze a contract** to identify clause deviations +8. **Run AI analyses** referencing norms, decisions, and documents +9. **Create proceedings** to track deadlines and workflow steps diff --git a/src/app/api/analyses/structured/route.ts b/src/app/api/analyses/structured/route.ts index c656579..26a08b0 100644 --- a/src/app/api/analyses/structured/route.ts +++ b/src/app/api/analyses/structured/route.ts @@ -3,11 +3,9 @@ import { type NextRequest } from 'next/server'; import { runStructuredAnalysis } from '@/lib/ai/structured-analysis'; -import { AnalyseMode } from '@/types'; +import { resolveAnalysisMode } from '@/lib/ai/modes'; import { requirePermission } from '@/lib/auth/rbac'; -const VALID_MODES = new Set(Object.values(AnalyseMode)); - export async function POST(request: NextRequest) { const auth = await requirePermission('analyses:create'); if ('response' in auth) return auth.response; @@ -25,9 +23,17 @@ export async function POST(request: NextRequest) { additionalContext, } = body; - if (!mode || !VALID_MODES.has(mode)) { + if (!mode || typeof mode !== 'string') { return Response.json( - { error: `Invalid mode. Must be one of: ${[...VALID_MODES].join(', ')}` }, + { error: 'mode is required' }, + { status: 400 }, + ); + } + + const modeConfig = await resolveAnalysisMode(mode, ctx.tenantId); + if (!modeConfig) { + return Response.json( + { error: `Unbekannter Analysemodus: ${mode}` }, { status: 400 }, ); } diff --git a/src/lib/ai/modes/index.ts b/src/lib/ai/modes/index.ts index 5a0fcfa..1a6f973 100644 --- a/src/lib/ai/modes/index.ts +++ b/src/lib/ai/modes/index.ts @@ -2,9 +2,12 @@ // Each mode defines a specific legal analysis workflow import { AnalyseMode } from '@/types'; +import { withTenantDb } from '@/lib/db'; +import { skills } from '@/lib/db/schema'; +import { eq, and } from 'drizzle-orm'; export interface AnalysisModeConfig { - mode: AnalyseMode; + mode: string; systemPromptKey: string; requiresNorms: boolean; requiresDecisions: boolean; @@ -12,6 +15,10 @@ export interface AnalysisModeConfig { label: string; /** Short description */ description: string; + /** Custom system prompt (for skill-based modes) */ + customSystemPrompt?: string; + /** Skill ID if resolved from skills table */ + skillId?: string; } export const ANALYSIS_MODES: Record = { @@ -48,3 +55,43 @@ export const ANALYSIS_MODES: Record = { description: 'Umfassende Risikoanalyse mit Eintrittswahrscheinlichkeiten und Minderungsstrategien', }, }; + +const STANDARD_MODES = new Set(Object.values(AnalyseMode)); + +/** + * Resolve an analysis mode config — checks hardcoded standard modes first, + * then falls back to the skills table for custom tenant-defined modes. + * Returns null if the mode/slug does not exist. + */ +export async function resolveAnalysisMode( + mode: string, + tenantId: string, +): Promise { + // Standard hardcoded mode + if (STANDARD_MODES.has(mode)) { + return ANALYSIS_MODES[mode as AnalyseMode]; + } + + // Look up custom skill by slug + const skill = await withTenantDb(tenantId, async (tdb) => { + const [row] = await tdb + .select() + .from(skills) + .where(and(eq(skills.slug, mode), eq(skills.isActive, true))) + .limit(1); + return row ?? null; + }); + + if (!skill) return null; + + return { + mode: skill.slug, + systemPromptKey: skill.slug, + requiresNorms: skill.requiresNorms ?? false, + requiresDecisions: skill.requiresDecisions ?? false, + label: skill.name, + description: skill.description ?? '', + customSystemPrompt: skill.systemPrompt, + skillId: skill.id, + }; +} diff --git a/src/lib/ai/structured-analysis.ts b/src/lib/ai/structured-analysis.ts index 64f0cdf..5a36cfa 100644 --- a/src/lib/ai/structured-analysis.ts +++ b/src/lib/ai/structured-analysis.ts @@ -4,9 +4,8 @@ import { generateText } from 'ai'; import { getModelForTenant } from './providers'; import { SYSTEM_PROMPTS, buildContextBlock, type AnalysisModeKey } from './prompts'; -import { ANALYSIS_MODES } from './modes'; +import { ANALYSIS_MODES, resolveAnalysisMode } from './modes'; import { STRUCTURED_OUTPUT_INSTRUCTION, type StructuredAnalysisOutput } from './structured-output'; -import { AnalyseMode } from '@/types'; import { db } from '@/lib/db'; import { norms, normInstruments, decisions, analyses } from '@/lib/db/schema'; import { eq, and, lte, or, isNull, gte, inArray } from 'drizzle-orm'; @@ -15,7 +14,7 @@ interface StructuredAnalysisInput { tenantId: string; userId: string; caseId?: string; - mode: AnalyseMode; + mode: string; title: string; query: string; normIds?: string[]; @@ -108,8 +107,10 @@ export async function runStructuredAnalysis( result: StructuredAnalysisOutput; sources: { normIds: string[]; decisionIds: string[] }; }> { - const modeConfig = ANALYSIS_MODES[input.mode]; - const systemPromptKey = modeConfig.systemPromptKey as AnalysisModeKey; + const modeConfig = await resolveAnalysisMode(input.mode, input.tenantId); + if (!modeConfig) { + throw new Error(`Unbekannter Analysemodus: ${input.mode}`); + } const [normContext, decisionContext] = await Promise.all([ modeConfig.requiresNorms @@ -132,8 +133,10 @@ export async function runStructuredAnalysis( const userMessage = messageParts.join('\n\n---\n\n'); - // Add structured output instruction to system prompt - const systemPrompt = SYSTEM_PROMPTS[systemPromptKey] + STRUCTURED_OUTPUT_INSTRUCTION; + // Use custom system prompt for skill-based modes, standard prompts for built-in modes + const basePrompt = modeConfig.customSystemPrompt + ?? SYSTEM_PROMPTS[modeConfig.systemPromptKey as AnalysisModeKey]; + const systemPrompt = basePrompt + STRUCTURED_OUTPUT_INSTRUCTION; const { model, provider, modelId } = await getModelForTenant(input.tenantId); @@ -145,6 +148,7 @@ export async function runStructuredAnalysis( userId: input.userId, caseId: input.caseId ?? null, mode: input.mode, + skillId: modeConfig.skillId ?? null, status: 'in_progress', title: input.title, query: input.query, @@ -172,7 +176,7 @@ export async function runStructuredAnalysis( const jsonMatch = result.text.match(/\{[\s\S]*\}/); if (!jsonMatch) throw new Error('No JSON object found'); structured = JSON.parse(jsonMatch[0]); - structured.mode = input.mode; + (structured as any).mode = input.mode; } catch { // Fallback: store raw text, mark as non-structured await db diff --git a/src/lib/auth/rbac.ts b/src/lib/auth/rbac.ts index d6cbc02..6b32dd9 100644 --- a/src/lib/auth/rbac.ts +++ b/src/lib/auth/rbac.ts @@ -31,6 +31,12 @@ const PERMISSIONS: Record = { 'decisions:write': ['admin', 'attorney'], 'decisions:read': ['admin', 'attorney', 'paralegal', 'viewer'], + // Skills + 'skills:read': ['admin', 'attorney', 'paralegal', 'viewer'], + 'skills:create': ['admin'], + 'skills:edit': ['admin'], + 'skills:delete': ['admin'], + // Settings 'settings:manage': ['admin'],