2026-07-23 08:42:26 +02:00
# LeoCRM Plugin Development Guide
2026-07-23 19:01:18 +02:00
> **Version:** 2.0
> **Date:** 2026-07-23
> **Applies to:** All plugin developers
2026-07-23 08:42:26 +02:00
---
2026-07-23 19:01:18 +02:00
## 1. Overview
2026-07-23 08:42:26 +02:00
2026-07-23 19:01:18 +02:00
Plugins are self-contained modules that extend LeoCRM's functionality. Each plugin lives in its own directory under `app/plugins/builtins/` and declares its capabilities via a `PluginManifest` . The plugin system supports:
- **API Routes** — Register FastAPI route handlers
- **Event Subscriptions** — React to domain events (contact.created, etc.)
- **Database Migrations** — Versioned SQL migrations run on install
- **RBAC Permissions** — Declare and enforce permissions
- **Frontend UI Contributions** — Menu items, page routes, detail tabs, settings pages, dashboard widgets
- **AI Agent Capabilities** — Register tools for the AI assistant
- **Lifecycle Hooks** — on_install, on_activate, on_deactivate, on_uninstall
---
## 2. Plugin Structure
Every plugin lives under `app/plugins/builtins/<plugin_name>/` :
2026-07-23 08:42:26 +02:00
```
app/plugins/builtins/my_plugin/
2026-07-23 19:01:18 +02:00
├── __init__.py # Package init (can be empty)
├── plugin.py # Plugin class with manifest (required)
├── routes.py # FastAPI route definitions
├── models.py # SQLAlchemy models (optional)
├── schemas.py # Pydantic schemas (optional)
├── services.py # Business logic (optional)
├── migrations/ # SQL migration files
2026-07-23 08:42:26 +02:00
│ └── 0001_initial.sql
2026-07-23 19:01:18 +02:00
└── tests/ # Plugin tests (optional)
├── __init__.py
└── test_plugin.py
```
---
## 3. Manifest Format
The `PluginManifest` is a Pydantic v2 model that declares all metadata and capabilities of a plugin. It is defined in `app/plugins/manifest.py` .
### 3.1 Core Fields
| Field | Type | Required | Description |
|---|---|---|---|
| `name` | `str` | Yes | Unique plugin identifier (snake_case, max 80 chars, alphanumeric with underscores only) |
| `version` | `str` | Yes | Semantic version string (e.g. "1.0.0") |
| `display_name` | `str` | Yes | Human-readable plugin name (max 120 chars) |
| `description` | `str` | No | Plugin description (max 500 chars) |
| `dependencies` | `list[str]` | No | Other plugin names this plugin depends on |
| `is_core` | `bool` | No | Whether this is a core plugin that cannot be deactivated (default: `false` ) |
### 3.2 Route Definitions
``` python
from app . plugins . manifest import PluginRouteDef
routes = [
PluginRouteDef (
path = " /api/v1/my-plugin " , # URL path prefix
module = " app.plugins.builtins.my_plugin.routes " , # Dotted module path
router_attr = " router " , # Attribute name of the APIRouter in the module
) ,
]
```
### 3.3 Event Subscriptions
``` python
events = [
" contact.created " ,
" contact.updated " ,
" contact.deleted " ,
]
```
Event naming convention: `<entity>.<action>` (e.g. `company.created` , `task.assigned` ).
### 3.4 Migrations
``` python
migrations = [
" 0001_initial.sql " ,
" 0002_add_indexes.sql " ,
]
```
Migration files are stored in the plugin's `migrations/` directory and run in order on install.
### 3.5 Permissions
``` python
permissions = [
" my_plugin:read " ,
" my_plugin:write " ,
" my_plugin:delete " ,
" my_plugin:admin " ,
]
```
Permission naming convention: `<plugin_name>:<action>` .
### 3.6 Field Definitions (Field-Level Permissions)
``` python
from app . plugins . manifest import FieldDefinition
field_definitions = [
FieldDefinition (
module = " companies " , # Module name (e.g. 'companies', 'contacts')
field = " annual_revenue " , # Field name
label = " Annual Revenue " , # Human-readable label
sensitivity = " sensitive " , # normal|sensitive|critical
) ,
]
```
### 3.7 Agent Capabilities
``` python
agent_capabilities = [
" contact_search " , # Contact search capability
" email_draft " , # Email draft generation
" calendar_scheduling " , # Calendar scheduling
]
```
### 3.8 Frontend UI Fields (Phase 3)
#### FrontendMenuItem — Sidebar Navigation
``` python
from app . plugins . manifest import FrontendMenuItem
menu_items = [
FrontendMenuItem (
label_key = " nav.myPlugin " , # i18n key for the menu label
label = " My Plugin " , # Fallback label if i18n key is missing
path = " /my-plugin " , # Frontend route path
icon = " Sparkles " , # lucide-react icon name
group = " " , # Optional group label_key for tree-style nesting
order = 100 , # Sort order within the sidebar
badge_key = " " , # Optional store key for badge count
) ,
]
```
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
| `label_key` | `str` | Yes | — | i18n key for the menu label |
| `label` | `str` | No | `""` | Fallback label if i18n key is missing |
| `path` | `str` | Yes | — | Frontend route path, e.g. `/mail` |
| `icon` | `str` | No | `"FileText"` | lucide-react icon name |
| `group` | `str` | No | `""` | Optional group label_key for tree-style nesting |
| `order` | `int` | No | `100` | Sort order within the sidebar |
| `badge_key` | `str` | No | `""` | Optional store key for badge count |
#### FrontendPageRoute — Page Routes
``` python
from app . plugins . manifest import FrontendPageRoute
page_routes = [
FrontendPageRoute (
path = " /my-plugin " , # Frontend route path
component = " @/pages/MyPlugin " , # Dotted path to the React component
parent = " " , # Parent route path for nested routes
protected = True , # Whether the route requires authentication
order = 100 , # Sort order
) ,
]
```
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
| `path` | `str` | Yes | — | Frontend route path, e.g. `/mail` or `/mail/settings` |
| `component` | `str` | Yes | — | Dotted path to the React component, e.g. `@/pages/Mail` |
| `parent` | `str` | No | `""` | Parent route path for nested routes (e.g. `/settings` for a settings sub-page) |
| `protected` | `bool` | No | `True` | Whether the route requires authentication |
| `order` | `int` | No | `100` | Sort order |
#### FrontendDetailTab — Entity Detail Tabs
``` python
from app . plugins . manifest import FrontendDetailTab
detail_tabs = [
FrontendDetailTab (
entity_type = " contact " , # Entity type this tab applies to
label_key = " tabs.myPlugin " , # i18n key for the tab label
label = " My Tab " , # Fallback label
component = " @/components/MyTab " , # Dotted path to the React component
icon = " FileText " , # lucide-react icon name
order = 50 , # Sort order within the detail view
permission = " my_plugin:read " , # Optional permission required to see this tab
) ,
]
```
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
| `entity_type` | `str` | Yes | — | Entity type this tab applies to, e.g. `'contact'` |
| `label_key` | `str` | Yes | — | i18n key for the tab label |
| `label` | `str` | No | `""` | Fallback label |
| `component` | `str` | Yes | — | Dotted path to the React component |
| `icon` | `str` | No | `"FileText"` | lucide-react icon name |
| `order` | `int` | No | `100` | Sort order within the detail view |
| `permission` | `str` | No | `""` | Optional permission required to see this tab |
#### FrontendSettingsPage — Settings Sub-Pages
``` python
from app . plugins . manifest import FrontendSettingsPage
settings_pages = [
FrontendSettingsPage (
path = " my-plugin " , # Settings sub-route path
label_key = " settings.myPlugin " , # i18n key for the settings nav label
label = " My Plugin " , # Fallback label
component = " @/pages/MyPluginSettings " , # Dotted path to the React component
icon = " Sparkles " , # lucide-react icon name
order = 100 , # Sort order within settings nav
permission = " my_plugin:admin " , # Optional permission required
) ,
]
2026-07-23 08:42:26 +02:00
```
2026-07-23 19:01:18 +02:00
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
| `path` | `str` | Yes | — | Settings sub-route path, e.g. `mail` or `notifications` |
| `label_key` | `str` | Yes | — | i18n key for the settings nav label |
| `label` | `str` | No | `""` | Fallback label |
| `component` | `str` | Yes | — | Dotted path to the React component |
| `icon` | `str` | No | `"Settings"` | lucide-react icon name |
| `order` | `int` | No | `100` | Sort order within settings nav |
| `permission` | `str` | No | `""` | Optional permission required |
2026-07-23 08:42:26 +02:00
2026-07-23 19:01:18 +02:00
#### FrontendDashboardWidget — Dashboard Widgets
``` python
from app . plugins . manifest import FrontendDashboardWidget
dashboard_widgets = [
FrontendDashboardWidget (
id = " my_plugin_stats " , # Unique widget identifier
label_key = " widgets.myPlugin " , # i18n key for the widget title
label = " My Plugin Stats " , # Fallback label
component = " @/components/MyWidget " , # Dotted path to the React component
icon = " LayoutDashboard " , # lucide-react icon name
order = 100 , # Sort order on the dashboard
col_span = 1 , # Grid column span (1-4)
row_span = 1 , # Grid row span
permission = " my_plugin:read " , # Optional permission required
) ,
]
```
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
| `id` | `str` | Yes | — | Unique widget identifier |
| `label_key` | `str` | Yes | — | i18n key for the widget title |
| `label` | `str` | No | `""` | Fallback label |
| `component` | `str` | Yes | — | Dotted path to the React component |
| `icon` | `str` | No | `"LayoutDashboard"` | lucide-react icon name |
| `order` | `int` | No | `100` | Sort order on the dashboard |
| `col_span` | `int` | No | `1` | Grid column span (1-4) |
| `row_span` | `int` | No | `1` | Grid row span |
| `permission` | `str` | No | `""` | Optional permission required |
### 3.9 Complete Manifest Example
2026-07-23 08:42:26 +02:00
``` python
from app . plugins . base import BasePlugin
2026-07-23 19:01:18 +02:00
from app . plugins . manifest import (
PluginManifest , PluginRouteDef , FieldDefinition ,
FrontendMenuItem , FrontendPageRoute , FrontendDetailTab ,
FrontendSettingsPage , FrontendDashboardWidget ,
)
2026-07-23 08:42:26 +02:00
class MyPlugin ( BasePlugin ) :
manifest = PluginManifest (
name = " my_plugin " ,
version = " 1.0.0 " ,
display_name = " My Plugin " ,
2026-07-23 19:01:18 +02:00
description = " A comprehensive example plugin. " ,
dependencies = [ " permissions " ] ,
is_core = False ,
2026-07-23 08:42:26 +02:00
routes = [
PluginRouteDef (
path = " /api/v1/my-plugin " ,
module = " app.plugins.builtins.my_plugin.routes " ,
router_attr = " router " ,
) ,
] ,
events = [ " contact.created " , " contact.updated " ] ,
migrations = [ " 0001_initial.sql " ] ,
2026-07-23 19:01:18 +02:00
permissions = [ " my_plugin:read " , " my_plugin:write " , " my_plugin:admin " ] ,
field_definitions = [
FieldDefinition (
module = " contacts " ,
field = " custom_field " ,
label = " Custom Field " ,
sensitivity = " normal " ,
) ,
] ,
agent_capabilities = [ " my_plugin:search " ] ,
menu_items = [
FrontendMenuItem (
label_key = " nav.myPlugin " ,
label = " My Plugin " ,
path = " /my-plugin " ,
icon = " Sparkles " ,
order = 100 ,
) ,
] ,
page_routes = [
FrontendPageRoute (
path = " /my-plugin " ,
component = " @/pages/MyPlugin " ,
protected = True ,
) ,
] ,
detail_tabs = [
FrontendDetailTab (
entity_type = " contact " ,
label_key = " tabs.myPlugin " ,
label = " My Tab " ,
component = " @/components/MyTab " ,
icon = " FileText " ,
order = 50 ,
permission = " my_plugin:read " ,
) ,
] ,
settings_pages = [
FrontendSettingsPage (
path = " my-plugin " ,
label_key = " settings.myPlugin " ,
label = " My Plugin " ,
component = " @/pages/MyPluginSettings " ,
icon = " Sparkles " ,
order = 100 ,
permission = " my_plugin:admin " ,
) ,
2026-07-23 08:42:26 +02:00
] ,
2026-07-23 19:01:18 +02:00
dashboard_widgets = [
FrontendDashboardWidget (
id = " my_plugin_stats " ,
label_key = " widgets.myPlugin " ,
label = " My Plugin Stats " ,
component = " @/components/MyWidget " ,
icon = " LayoutDashboard " ,
order = 100 ,
col_span = 2 ,
row_span = 1 ,
permission = " my_plugin:read " ,
) ,
2026-07-23 08:42:26 +02:00
] ,
)
```
2026-07-23 19:01:18 +02:00
---
## 4. Plugin Lifecycle
2026-07-23 08:42:26 +02:00
2026-07-23 19:01:18 +02:00
### 4.1 Installation (`on_install`)
Called when the plugin is installed, after migrations are run. Override to perform seed data or initial setup.
``` python
async def on_install ( self , db : AsyncSession , service_container : ServiceContainer ) - > None :
""" Perform initial setup after migrations. """
# Create default settings
settings_service = service_container . settings
await settings_service . create_defaults ( db , plugin_name = self . name )
```
### 4.2 Activation (`on_activate`)
Called when the plugin is activated. Override to register event listeners and prepare runtime state. The default implementation subscribes to events listed in the manifest.
``` python
async def on_activate (
self , db : AsyncSession , service_container : ServiceContainer , event_bus : EventBus
) - > None :
""" Register event listeners and prepare runtime state. """
# Default: subscribes to manifest events
for event_name in self . manifest . events :
handler = self . _make_event_handler ( event_name )
self . _event_handlers [ event_name ] = handler
event_bus . subscribe ( event_name , handler )
self . _container = service_container
```
### 4.3 Deactivation (`on_deactivate`)
Called when the plugin is deactivated. Override to clean up runtime state. The default implementation unsubscribes all event listeners.
``` python
async def on_deactivate (
self , db : AsyncSession , service_container : ServiceContainer , event_bus : EventBus
) - > None :
""" Clean up runtime state. """
for event_name , handler in self . _event_handlers . items ( ) :
event_bus . unsubscribe ( event_name , handler )
self . _event_handlers . clear ( )
```
### 4.4 Uninstallation (`on_uninstall`)
Called when the plugin is uninstalled (before data tables are dropped). Override to clean up external resources.
``` python
async def on_uninstall ( self , db : AsyncSession , service_container : ServiceContainer ) - > None :
""" Clean up external resources before tables are dropped. """
# Remove external API webhooks, etc.
pass
```
---
## 5. UI Registration
Plugins contribute frontend UI elements through their manifest. The frontend `PluginRegistry` component fetches active manifests and populates the `pluginStore` (Zustand), which is then consumed by:
### 5.1 Sidebar Menu Items
Menu items from all active plugins are merged and sorted by `order` via `getAllMenuItems()` . The sidebar renders them alongside built-in navigation items.
### 5.2 Page Routes
Page routes are registered via `PluginRouteRenderer` , a catch-all route handler that checks the current URL against all plugin `page_routes` . When a match is found, it renders the plugin's page component using `PluginPage` (React.lazy + Suspense + ErrorBoundary).
### 5.3 Detail Tabs
Detail tabs are filtered by `entity_type` via `getDetailTabsForEntity(entityType)` . Entity detail views (contacts, companies, etc.) render these tabs alongside built-in tabs.
### 5.4 Settings Pages
Settings pages are merged and sorted via `getAllSettingsPages()` . The settings navigation renders them alongside built-in settings pages.
### 5.5 Dashboard Widgets
Dashboard widgets are merged and sorted via `getAllDashboardWidgets()` . The dashboard grid renders them with their specified `col_span` and `row_span` .
### 5.6 Frontend Component Resolution
Component paths use the `@/` alias (resolved to `src/` by Vite). The `PluginPage` component converts `@/pages/MyPlugin` to `../pages/MyPlugin` for dynamic import:
``` typescript
// PluginLoader.tsx
const importPath = componentPath . replace ( /^@\// , '../' ) ;
const LazyComp = lazy ( ( ) = >
import ( /* @vite-ignore */ importPath ) . then ( ( m ) = > ( {
default : m . default || m [ Object . keys ( m ) [ 0 ] ] ,
} ) )
) ;
```
---
## 6. Event Bus
### 6.1 Subscribing to Events
Events are declared in the manifest and handled by methods named `on_<event_name>` with dots replaced by underscores:
2026-07-23 08:42:26 +02:00
2026-07-23 19:01:18 +02:00
``` python
# Manifest
events = [ " contact.created " , " contact.updated " ]
# Handler methods
async def on_contact_created ( self , event_data : dict ) :
contact_id = event_data . get ( " contact_id " )
# React to new contact
async def on_contact_updated ( self , event_data : dict ) :
contact_id = event_data . get ( " contact_id " )
# React to contact update
```
2026-07-23 08:42:26 +02:00
2026-07-23 19:01:18 +02:00
### 6.2 Publishing Events
2026-07-23 08:42:26 +02:00
2026-07-23 19:01:18 +02:00
Events are published via the EventBus service:
``` python
from app . core . event_bus import get_event_bus
event_bus = get_event_bus ( )
await event_bus . publish ( " contact.created " , { " contact_id " : str ( contact . id ) } )
```
### 6.3 Event Naming Conventions
- Format: `<entity>.<action>`
- Standard actions: `created` , `updated` , `deleted` , `assigned` , `completed`
- Examples: `company.created` , `task.assigned` , `email.sent`
- Use past tense for actions
---
2026-08-13 17:35:43 +02:00
## 23. Migration Runner
2026-07-23 19:01:18 +02:00
### 7.1 Writing Migrations
SQL migration files are stored in the plugin's `migrations/` directory and referenced in the manifest:
``` sql
-- migrations/0001_initial.sql
CREATE TABLE my_plugin_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid ( ) ,
tenant_id UUID NOT NULL REFERENCES tenants ( id ) ,
name VARCHAR ( 200 ) NOT NULL ,
config JSONB DEFAULT ' {} ' ,
is_active BOOLEAN DEFAULT true ,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW ( ) ,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW ( )
) ;
CREATE INDEX idx_my_plugin_items_tenant ON my_plugin_items ( tenant_id ) ;
```
### 7.2 Migration Naming
- Files are named with a zero-padded sequence number and a descriptive slug: `0001_initial.sql` , `0002_add_indexes.sql`
- Migrations run in alphanumeric order
- Each migration runs exactly once per plugin installation
### 7.3 Migration Guidelines
- Always include `tenant_id` for multi-tenant tables
- Use `UUID` primary keys with `gen_random_uuid()`
- Include `created_at` and `updated_at` timestamps
- Add appropriate indexes for foreign keys and frequently queried columns
- Use `IF NOT EXISTS` / `IF EXISTS` for idempotent operations
---
2026-08-13 17:35:43 +02:00
## 24. Service Container
2026-07-23 19:01:18 +02:00
After activation, plugins can access shared services via `self.services` :
``` python
class MyPlugin ( BasePlugin ) :
async def on_activate ( self , db , service_container , event_bus ) :
self . _container = service_container
async def do_something ( self ) :
# Access services after activation
settings = self . services . settings
audit = self . services . audit
cache = self . services . cache
```
Available services (defined in `app/core/service_container.py` ):
| Service | Accessor | Description |
|---|---|---|
| Settings | `self.services.settings` | Global and per-tenant settings |
| Audit | `self.services.audit` | Audit logging |
| Cache | `self.services.cache` | Redis/In-memory cache |
| EventBus | `self.services.event_bus` | Event publishing/subscribing |
| Notifications | `self.services.notifications` | User notifications |
---
2026-08-13 17:35:43 +02:00
## 25. RBAC / Permissions
2026-07-23 19:01:18 +02:00
### 9.1 Declaring Permissions
Permissions are declared in the manifest:
2026-07-23 08:42:26 +02:00
``` python
permissions = [
" my_plugin:read " ,
" my_plugin:write " ,
" my_plugin:delete " ,
" my_plugin:admin " ,
2026-07-23 19:01:18 +02:00
]
2026-07-23 08:42:26 +02:00
```
2026-07-23 19:01:18 +02:00
### 9.2 Securing Routes
2026-07-23 08:42:26 +02:00
2026-07-23 19:01:18 +02:00
Use the `require_permission` dependency:
2026-07-23 08:42:26 +02:00
``` python
from app . deps import get_current_user , require_permission
from fastapi import Depends
@router.get ( " " , dependencies = [ Depends ( require_permission ( " my_plugin:read " ) ) ] )
async def list_items ( current_user : dict = Depends ( get_current_user ) ) :
. . .
@router.post ( " " , status_code = 201 , dependencies = [ Depends ( require_permission ( " my_plugin:write " ) ) ] )
async def create_item ( data : ItemCreate , current_user : dict = Depends ( get_current_user ) ) :
. . .
```
2026-07-23 19:01:18 +02:00
### 9.3 Permission Naming Convention
2026-07-23 08:42:26 +02:00
- Format: `<plugin_name>:<action>`
2026-07-23 19:01:18 +02:00
- Standard actions: `read` , `write` , `delete` , `share` , `admin`
- Examples: `calendar:read` , `dms:write` , `tags:delete`
2026-07-23 08:42:26 +02:00
2026-07-23 19:01:18 +02:00
### 9.4 Field-Level Permissions
2026-07-23 08:42:26 +02:00
2026-07-23 19:01:18 +02:00
Field definitions in the manifest enable field-level access control:
2026-07-23 08:42:26 +02:00
2026-07-23 19:01:18 +02:00
``` python
field_definitions = [
FieldDefinition (
module = " companies " ,
field = " annual_revenue " ,
label = " Annual Revenue " ,
sensitivity = " sensitive " , # normal|sensitive|critical
) ,
]
2026-07-23 08:42:26 +02:00
```
2026-07-23 19:01:18 +02:00
---
2026-07-23 08:42:26 +02:00
2026-08-13 17:35:43 +02:00
## 26. AI Agent Integration
2026-07-23 08:42:26 +02:00
2026-07-23 19:01:18 +02:00
### 10.1 Agent Capabilities
2026-07-23 08:42:26 +02:00
2026-07-23 19:01:18 +02:00
Declare AI capabilities in the manifest:
2026-07-23 08:42:26 +02:00
2026-07-23 19:01:18 +02:00
``` python
agent_capabilities = [
" contact_search " ,
" email_draft " ,
" calendar_scheduling " ,
]
```
2026-07-23 08:42:26 +02:00
2026-07-23 19:01:18 +02:00
### 10.2 Tool Registry
2026-07-23 08:42:26 +02:00
2026-07-23 19:01:18 +02:00
Plugins can register tools for the AI assistant:
2026-07-23 08:42:26 +02:00
``` python
from app . plugins . builtins . ai_assistant . tool_registry import get_tool_registry
registry = get_tool_registry ( )
registry . register (
name = " search_contacts " ,
description = " Search contacts by name, email, or phone number " ,
parameters = {
" type " : " object " ,
" properties " : {
" query " : { " type " : " string " , " description " : " Search query " } ,
" limit " : { " type " : " integer " , " description " : " Max results " , " default " : 10 } ,
} ,
" required " : [ " query " ] ,
} ,
handler = my_search_handler ,
plugin_name = " my_plugin " ,
required_permission = " contacts:read " ,
category = " search " ,
)
```
2026-07-23 19:01:18 +02:00
### 10.3 Tool Handler
2026-07-23 08:42:26 +02:00
``` python
async def my_search_handler ( arguments : dict , context : dict ) - > str :
query = arguments . get ( " query " , " " )
limit = arguments . get ( " limit " , 10 )
2026-07-23 19:01:18 +02:00
# Perform search...
2026-07-23 08:42:26 +02:00
return json . dumps ( { " results " : results } )
```
2026-07-23 19:01:18 +02:00
### 10.4 Cleanup on Deactivation
2026-07-23 08:42:26 +02:00
``` python
2026-07-23 19:01:18 +02:00
async def on_deactivate ( self , db , service_container , event_bus ) :
2026-07-23 08:42:26 +02:00
registry = get_tool_registry ( )
registry . unregister_plugin ( " my_plugin " )
```
2026-07-23 19:01:18 +02:00
---
2026-07-23 08:42:26 +02:00
2026-08-13 17:35:43 +02:00
## 27. Testing Guide
2026-07-23 08:42:26 +02:00
2026-07-23 19:01:18 +02:00
### 11.1 Backend Tests
2026-07-23 08:42:26 +02:00
2026-07-23 19:01:18 +02:00
Tests live in the plugin's `tests/` directory or in the central `tests/` folder:
2026-07-23 08:42:26 +02:00
``` python
# tests/test_my_plugin.py
import pytest
from httpx import AsyncClient
@pytest.mark.asyncio
async def test_list_items_requires_permission ( client : AsyncClient , auth_headers ) :
response = await client . get ( " /api/v1/my-plugin " , headers = auth_headers )
assert response . status_code == 200
@pytest.mark.asyncio
async def test_list_items_without_permission_returns_403 ( client : AsyncClient , no_perm_headers ) :
response = await client . get ( " /api/v1/my-plugin " , headers = no_perm_headers )
assert response . status_code == 403
```
2026-07-23 19:01:18 +02:00
### 11.2 Frontend Tests
Frontend tests use vitest, @testing -library/react, and jsdom:
``` typescript
// frontend/src/components/plugins/__tests__/PluginRegistry.test.tsx
import React from 'react' ;
import { describe , it , expect , vi , beforeEach } from 'vitest' ;
import { render } from '@testing-library/react' ;
import { PluginRegistry } from '../PluginRegistry' ;
import { usePluginStore } from '@/store/pluginStore' ;
// Mock the API hook
vi . mock ( '@/api/pluginManifests' , ( ) = > ( {
useActivePluginManifests : ( ) = > ( {
data : { plugins : [ /* ... */ ] , total : 1 } ,
isLoading : false ,
error : null ,
} ) ,
} ) ) ;
describe ( 'PluginRegistry' , ( ) = > {
beforeEach ( ( ) = > {
usePluginStore . getState ( ) . reset ( ) ;
} ) ;
it ( 'populates store with manifests on mount' , ( ) = > {
render ( < PluginRegistry / > ) ;
const manifests = usePluginStore . getState ( ) . manifests ;
expect ( manifests ) . toHaveLength ( 1 ) ;
} ) ;
} ) ;
```
---
2026-08-13 17:35:43 +02:00
## 28. Do's and Don'ts
2026-07-23 19:01:18 +02:00
### Do's
- **Do** use snake_case for plugin names
- **Do** declare all permissions in the manifest
- **Do** secure every API route with `require_permission`
- **Do** include `tenant_id` in all multi-tenant tables
- **Do** use UUID primary keys
- **Do** prefix i18n keys with the plugin name
- **Do** clean up resources in `on_deactivate` and `on_uninstall`
- **Do** write tests for both backend and frontend
- **Do** follow the event naming convention `<entity>.<action>`
- **Do** use semantic versioning for plugin versions
### Don'ts
- **Don't** hardcode tenant IDs or user IDs
- **Don't** use synchronous database operations
- **Don't** store secrets in the database without encryption
- **Don't** modify other plugins' data directly (use events instead)
- **Don't** create circular dependencies between plugins
- **Don't** use `is_core=True` unless the plugin is essential for system operation
- **Don't** skip error handling in event handlers (they run asynchronously)
- **Don't** register the same route path in multiple plugins
---
2026-08-13 17:35:43 +02:00
## 29. Examples
2026-07-23 08:42:26 +02:00
2026-07-23 19:01:18 +02:00
### 13.1 Minimal Plugin
2026-07-23 08:42:26 +02:00
``` python
2026-07-23 19:01:18 +02:00
# app/plugins/builtins/minimal_example/plugin.py
2026-07-23 08:42:26 +02:00
from app . plugins . base import BasePlugin
from app . plugins . manifest import PluginManifest , PluginRouteDef
2026-07-23 19:01:18 +02:00
class MinimalExamplePlugin ( BasePlugin ) :
2026-07-23 08:42:26 +02:00
manifest = PluginManifest (
2026-07-23 19:01:18 +02:00
name = " minimal_example " ,
2026-07-23 08:42:26 +02:00
version = " 1.0.0 " ,
2026-07-23 19:01:18 +02:00
display_name = " Minimal Example " ,
2026-07-23 08:42:26 +02:00
description = " A minimal example plugin. " ,
dependencies = [ ] ,
routes = [
PluginRouteDef (
2026-07-23 19:01:18 +02:00
path = " /api/v1/minimal-example " ,
module = " app.plugins.builtins.minimal_example.routes " ,
2026-07-23 08:42:26 +02:00
router_attr = " router " ,
) ,
] ,
events = [ ] ,
migrations = [ ] ,
2026-07-23 19:01:18 +02:00
permissions = [ " minimal_example:read " ] ,
2026-07-23 08:42:26 +02:00
)
```
``` python
2026-07-23 19:01:18 +02:00
# app/plugins/builtins/minimal_example/routes.py
2026-07-23 08:42:26 +02:00
from fastapi import APIRouter , Depends
from app . deps import get_current_user , require_permission
router = APIRouter ( )
2026-07-23 19:01:18 +02:00
@router.get ( " " , dependencies = [ Depends ( require_permission ( " minimal_example:read " ) ) ] )
2026-07-23 08:42:26 +02:00
async def list_items ( current_user : dict = Depends ( get_current_user ) ) :
return { " items " : [ ] }
```
2026-07-23 19:01:18 +02:00
### 13.2 Plugin with UI
See the complete manifest example in Section 3.9 for a plugin with full UI contributions (menu items, page routes, detail tabs, settings pages, dashboard widgets).
### 13.3 Plugin with Events
``` python
# app/plugins/builtins/event_example/plugin.py
from app . plugins . base import BasePlugin
from app . plugins . manifest import PluginManifest
class EventExamplePlugin ( BasePlugin ) :
manifest = PluginManifest (
name = " event_example " ,
version = " 1.0.0 " ,
display_name = " Event Example " ,
description = " Demonstrates event handling. " ,
dependencies = [ ] ,
events = [ " contact.created " , " contact.updated " , " contact.deleted " ] ,
migrations = [ ] ,
permissions = [ ] ,
)
async def on_contact_created ( self , event_data : dict ) :
contact_id = event_data . get ( " contact_id " )
print ( f " Contact created: { contact_id } " )
async def on_contact_updated ( self , event_data : dict ) :
contact_id = event_data . get ( " contact_id " )
print ( f " Contact updated: { contact_id } " )
async def on_contact_deleted ( self , event_data : dict ) :
contact_id = event_data . get ( " contact_id " )
print ( f " Contact deleted: { contact_id } " )
```
2026-07-23 08:42:26 +02:00
---
2026-08-13 16:22:05 +02:00
## 7. LLM Integration
LeoCRM stellt einen zentralen LLM-Client bereit über den alle LLM-Calls (Completion und Embedding) laufen. **Keine direkten `litellm.acompletion()` oder `litellm.aembedding()` Aufrufe in Plugin-Code. **
### 7.1 Completion
``` python
from app . ai . llm_client import llm_complete
result = await llm_complete (
model = " openai/gpt-4o " , # oder None für Default-Model
messages = [
{ " role " : " system " , " content " : " You are a helpful assistant. " } ,
{ " role " : " user " , " content " : " Summarize this email. " } ,
] ,
temperature = 0.3 ,
max_tokens = 1000 ,
# Optional: API-Key/Base aus DB holen
db = db ,
tenant_id = tenant_id ,
# Optional: JSON-Response erzwingen
response_format = { " type " : " json_object " } ,
# Optional: Tools für Function-Calling
tools = [ { " type " : " function " , " function " : { . . . } } ] ,
# Optional: Retry-Konfiguration
timeout = 30 ,
max_retries = 2 ,
)
content = result [ " content " ] # str — LLM-Response-Text
usage = result [ " usage " ] # dict — {prompt_tokens, completion_tokens, total_tokens}
cost_usd = result [ " cost_usd " ] # float — geschätzte Kosten
model = result [ " model " ] # str — verwendetes Modell
raw_response = result [ " raw_response " ] # litellm-Response-Objekt für erweiterte Nutzung
```
### 7.2 Embedding
``` python
from app . ai . llm_client import llm_embed
# Einzelne Embedding
embeddings = await llm_embed (
texts = " Text to embed " ,
model = " openai/text-embedding-3-small " , # oder None für Default
db = db ,
tenant_id = tenant_id ,
dimensions = 768 , # Optional, für text-embedding-3 Modelle
)
# → [[0.01, 0.02, ...]]
# Batch-Embedding
embeddings = await llm_embed (
texts = [ " Text 1 " , " Text 2 " , " Text 3 " ] ,
db = db ,
tenant_id = tenant_id ,
)
# → [[...], [...], [...]]
```
### 7.3 Provider-Auswahl und API-Key-Auflösung
Der zentrale Client löst API-Keys automatisch aus der Datenbank (`AIProvider` -Tabelle) oder Environment-Variablen. Priorität:
1. Explizit übergebener `api_key` Parameter
2. DB-Lookup über `get_api_credentials(db, tenant_id)`
3. Environment-Variablen (`AI_API_KEY` , `AI_API_BASE` , `AI_PROVIDER` )
4. Mock-Mode (kein API-Key → Keyword-basierte Fallback-Antworten)
``` python
from app . ai . llm_client import get_api_credentials , build_model
# API-Credentials aus DB holen
api_key , api_base , provider_type = await get_api_credentials ( db , tenant_id )
# Model-String bauen (provider/model)
model = build_model ( " gpt-4o " , provider_type ) # → "openai/gpt-4o"
```
### 7.4 Error-Handling
Der zentrale Client klassifiziert Errors automatisch:
- **Transient** (Timeout, Rate-Limit 429, Service-Unavailable 503) → Retry mit Exponential-Backoff
- **Permanent** (Auth 401/403, Validation, Model-Not-Found) → Sofortiger Fehler, kein Retry
``` python
try :
result = await llm_complete ( model = " openai/gpt-4o " , messages = [ . . . ] )
except Exception as e :
# Transient errors wurden bereits retried
# Permanent errors kommen hier an
logger . error ( f " LLM call failed permanently: { e } " )
```
### 7.5 Cost-Tracking
`llm_complete()` gibt `cost_usd` zurück — automatisch berechnet aus Token-Usage. Plugins sollen diesen Wert in ihren Cost-Tracking-Mechanismus übernehmen.
``` python
result = await llm_complete ( . . . )
total_cost + = result [ " cost_usd " ]
```
### 7.6 Was NICHT zu tun ist
- ❌ `import litellm` und direkte `litellm.acompletion()` / `litellm.aembedding()` Aufrufe
- ❌ Eigene API-Key-Verwaltung — immer über `get_api_credentials()` oder `llm_complete(db=db, tenant_id=tenant_id)`
- ❌ Eigene Retry-Logik — `llm_complete()` hat bereits Retry mit Backoff
- ❌ Eigene Cost-Tracking-Logik — `llm_complete()` gibt `cost_usd` zurück
- ❌ Eigene Provider-Auswahl — `build_model()` und `get_api_credentials()` zentralisieren das
---
2026-08-13 17:30:31 +02:00
## 8. Event-System Rollen
LeoCRM hat **4 Event-Systeme ** mit klar getrennten Rollen. **Nicht dieselbe Funktion über Hook UND EventBus triggern. **
### Übersicht
| System | Rolle | Persistenz | Use Case |
|--------|-------|-----------|----------|
| **HookRegistry ** | Lifecycle-Erweiterungspunkte | In-Memory | `contact.before_create` , `mail.after_send` , `dms.after_delete` — Plugins können Daten anpassen oder reagieren |
| **EventBus ** | Flüchtige interne Events | In-Memory | `notification.created` , `ui.contact_selected` — asynchrone Notifikationen, UI-Events, Proactive Suggestions |
| **Outbox ** | Dauerhafte Domain Events | DB (transactional) | `contact.created` , `mail.received` , `task.completed` — reliable Delivery, Retry, DLQ, Worker-Polling |
| **WebhookDispatcher ** | Externe HTTP-Zustellung | DB + HTTP | Externe Webhooks an registrierte URLs — Retry, Auth, Payload-Signatur |
### 8.1 HookRegistry (`app/core/hooks.py`)
**Wann verwenden: ** Wenn ein Plugin bei einem Lifecycle-Punkt Daten anpassen oder reagieren will.
``` python
from app . core . hooks import get_hook_registry
reg = get_hook_registry ( )
# Action — kein Return, nur Seiteneffekte
reg . register_action ( " contact.before_create " , self . _on_contact_create , priority = 10 )
# Filter — Return modifizierten Wert
def _format_name ( self , name : str ) - > str :
return name . title ( )
reg . register_filter ( " contact.format_display_name " , self . _format_name , priority = 10 )
```
**Aufruf im Core/Plugin-Service: **
``` python
from app . core . hooks import do_action , apply_filters
await do_action ( " contact.before_create " , contact_data , db = db )
display_name = await apply_filters ( " contact.format_display_name " , contact . name )
```
2026-08-13 20:54:15 +02:00
#### Verfügbare Hooks
**Actions ** (fire-and-forget, kein Return-Wert):
| Hook Name | Modul | Parameter |
|-----------|-------|----------|
| `contact.before_create` | `app/services/contact_service.py` | `data, db, tenant_id, user_id` |
| `contact.after_create` | `app/services/contact_service.py` | `serialized, db, tenant_id, user_id` |
| `contact.before_update` | `app/services/contact_service.py` | `data, db, tenant_id, user_id, contact_id` |
| `contact.after_update` | `app/services/contact_service.py` | `snapshot, db, tenant_id, user_id, contact_id` |
| `contact.before_delete` | `app/services/contact_service.py` | `db, tenant_id, contact_id, user_id` |
| `contact.after_delete` | `app/services/contact_service.py` | `db, tenant_id, contact_id, user_id` |
| `company.before_create` | `app/routes/companies.py` | `body, db, tenant_id, user_id` |
| `company.after_create` | `app/routes/companies.py` | `serialized, db, tenant_id, user_id` |
| `company.before_update` | `app/routes/companies.py` | `body, db, tenant_id, user_id, company_id` |
| `company.after_update` | `app/routes/companies.py` | `serialized, db, tenant_id, user_id, company_id` |
| `company.before_delete` | `app/routes/companies.py` | `db, tenant_id, user_id, company_id` |
| `company.after_delete` | `app/routes/companies.py` | `snapshot, db, tenant_id, user_id, company_id` |
| `mail.before_send` | `app/plugins/builtins/mail/services.py` | `mail_data` (Filter) |
| `mail.after_send` | `app/plugins/builtins/mail/services.py` | `mail_data, db, account, msg_id` |
| `mail.after_receive` | `app/plugins/builtins/mail/services.py` | `payload, db, tenant_id` |
| `mail.before_delete` | `app/plugins/builtins/mail/services.py` | `mail_id, tenant_id, permanent, db` |
| `mail.after_delete` | `app/plugins/builtins/mail/services.py` | `mail_id, tenant_id, permanent, db` |
| `mail.before_move` | `app/plugins/builtins/mail/services.py` | `mail_id, tenant_id, source_folder_id, target_folder_id, db` |
| `mail.after_move` | `app/plugins/builtins/mail/services.py` | `mail_id, tenant_id, source_folder_id, target_folder_id, db` |
| `dms.before_upload` | `app/plugins/builtins/dms/routes.py` | `upload_data` (Filter) |
| `dms.after_upload` | `app/plugins/builtins/dms/routes.py` | `payload, db, tenant_id, user_id` |
| `dms.before_update` | `app/plugins/builtins/dms/routes.py` | `data, db, tenant_id, user_id, file_id` |
| `dms.after_update` | `app/plugins/builtins/dms/routes.py` | `payload, db, tenant_id, user_id, file_id` |
| `dms.before_delete` | `app/plugins/builtins/dms/routes.py` | `db, tenant_id, user_id, file_id` |
| `dms.after_delete` | `app/plugins/builtins/dms/routes.py` | `db, tenant_id, user_id, file_id` |
| `dms.before_restore` | `app/plugins/builtins/dms/routes.py` | `db, tenant_id, user_id, file_id` |
| `dms.after_restore` | `app/plugins/builtins/dms/routes.py` | `payload, db, tenant_id, user_id, file_id` |
| `dms.folder.before_create` | `app/plugins/builtins/dms/routes.py` | `body, db, tenant_id, user_id` |
| `dms.folder.after_create` | `app/plugins/builtins/dms/routes.py` | `payload, db, tenant_id, user_id` |
| `dms.folder.before_delete` | `app/plugins/builtins/dms/routes.py` | `db, tenant_id, user_id, folder_id` |
| `dms.folder.after_delete` | `app/plugins/builtins/dms/routes.py` | `db, tenant_id, user_id, folder_id` |
| `calendar.before_appointment` | `app/plugins/builtins/calendar/routes.py` | `body, tenant_id, user_id, cal_id` |
| `calendar.after_appointment` | `app/plugins/builtins/calendar/routes.py` | `entry_id, tenant_id, user_id` |
| `calendar.before_update` | `app/plugins/builtins/calendar/routes.py` | `body, tenant_id, user_id, entry_id` |
| `calendar.after_update` | `app/plugins/builtins/calendar/routes.py` | `entry_id, tenant_id, user_id` |
| `calendar.before_delete` | `app/plugins/builtins/calendar/routes.py` | `tenant_id, user_id, entry_id` |
| `calendar.after_delete` | `app/plugins/builtins/calendar/routes.py` | `tenant_id, user_id, entry_id` |
| `task.before_create` | `app/plugins/builtins/tasks/services.py` | `data, db, tenant_id, user_id` |
| `task.after_create` | `app/plugins/builtins/tasks/services.py` | `serialized, db, tenant_id, user_id` |
| `task.before_update` | `app/plugins/builtins/tasks/services.py` | `data, db, tenant_id, task_id` |
| `task.after_update` | `app/plugins/builtins/tasks/services.py` | `serialized, db, tenant_id, task_id` |
| `task.before_delete` | `app/plugins/builtins/tasks/services.py` | `db, tenant_id, task_id` |
| `task.after_delete` | `app/plugins/builtins/tasks/services.py` | `db, tenant_id, task_id` |
| `comm.conversation.before_create` | `app/plugins/builtins/kommunikation/services.py` | `tenant_id, user_id` |
| `comm.conversation.after_create` | `app/plugins/builtins/kommunikation/services.py` | `conversation_id, tenant_id, user_id` |
| `comm.before_message` | `app/plugins/builtins/kommunikation/services.py` | `conversation_id, tenant_id, sender_id` |
| `comm.after_message` | `app/plugins/builtins/kommunikation/services.py` | `message_id, conversation_id, tenant_id, sender_id` |
| `comm.before_edit` | `app/plugins/builtins/kommunikation/services.py` | `message_id, tenant_id, user_id` |
| `comm.after_edit` | `app/plugins/builtins/kommunikation/services.py` | `message_id, tenant_id, user_id` |
| `comm.before_delete` | `app/plugins/builtins/kommunikation/services.py` | `message_id` |
| `comm.after_delete` | `app/plugins/builtins/kommunikation/services.py` | `message_id` |
| `agent.before_run` | `app/plugins/builtins/automation/agent_runner.py` | `agent_id, tenant_id, trigger_type` |
| `agent.after_run` | `app/plugins/builtins/automation/agent_runner.py` | `agent_id, tenant_id, status, result` |
| `workflow.before_start` | `app/services/workflow_service.py` | `instance_id, workflow_id, tenant_id, user_id` |
| `workflow.after_start` | `app/services/workflow_service.py` | `instance_id, workflow_id, tenant_id, user_id` |
| `workflow.after_complete` | `app/services/workflow_service.py` | `instance_id, workflow_id, tenant_id, user_id` |
| `workflow.after_cancel` | `app/services/workflow_service.py` | `instance_id, workflow_id, tenant_id, user_id` |
| `tag.before_create` | `app/plugins/builtins/tags/routes.py` | `body, db, tenant_id, user_id` |
| `tag.after_create` | `app/plugins/builtins/tags/routes.py` | `payload, db, tenant_id, user_id` |
| `tag.before_assign` | `app/plugins/builtins/tags/routes.py` | `body, db, tenant_id, user_id` |
| `tag.after_assign` | `app/plugins/builtins/tags/routes.py` | `payload, db, tenant_id, user_id` |
| `tag.before_unassign` | `app/plugins/builtins/tags/routes.py` | `body, db, tenant_id, user_id` |
| `tag.after_unassign` | `app/plugins/builtins/tags/routes.py` | `payload, db, tenant_id, user_id` |
| `tag.before_delete` | `app/plugins/builtins/tags/routes.py` | `db, tenant_id, user_id, tag_id` |
| `tag.after_delete` | `app/plugins/builtins/tags/routes.py` | `db, tenant_id, user_id, tag_id` |
**Filters ** (modifizieren Wert, Return erforderlich):
| Hook Name | Modul | Parameter |
|-----------|-------|----------|
| `contact.format_display_name` | `app/services/contact_service.py` | `name, data, db, tenant_id, user_id` |
| `dms.before_upload` | `app/plugins/builtins/dms/routes.py` | `upload_data` (filename, mime_type) |
| `mail.before_send` | `app/plugins/builtins/mail/services.py` | `mail_data` |
| `search.before_search` | `app/plugins/builtins/unified_search/search_engine.py` | `query_analysis` |
| `search.after_search` | `app/plugins/builtins/unified_search/search_engine.py` | `all_results` |
2026-08-13 17:30:31 +02:00
### 8.2 EventBus (`app/core/event_bus.py`)
**Wann verwenden: ** Für flüchtige interne Notifikationen, UI-Events, Proactive Suggestions. **Nicht ** für Events die reliable Delivery brauchen.
``` python
from app . core . event_bus import get_event_bus
event_bus = get_event_bus ( )
# Subscribe
event_bus . subscribe ( " notification.created " , self . _on_notification )
# Publish (ephemeral — geht verloren bei Crash)
await event_bus . publish ( " notification.created " , { " user_id " : " ... " , " message " : " ... " } )
```
### 8.3 Outbox (`app/core/outbox.py`)
**Wann verwenden: ** Für dauerhafte Domain Events die reliable Delivery, Retry und Worker-Verarbeitung brauchen.
``` python
from app . core . outbox import enqueue_outbox_event
# In derselben Transaktion wie die Business-Operation
await enqueue_outbox_event ( db , tenant_id , " contact.created " , {
" contact_id " : str ( contact . id ) ,
" tenant_id " : str ( tenant_id ) ,
} )
# Transaction commit → Event ist durable → Worker pollt und published an EventBus
```
**Features: ** DLQ (`error_message` , `failed_at` ), Replay (`replay_failed_event` ), Consumer Registry, Stats.
2026-08-13 20:54:15 +02:00
#### Verfügbare Outbox Events
| Event Name | Modul | Payload | Aggregate |
|------------|-------|---------|-----------|
| `contact.created` | `app/services/contact_service.py` | `contact_id, tenant_id, displayname, type` | `contact` |
| `contact.updated` | `app/services/contact_service.py` | `contact_id, tenant_id, changes` | `contact` |
| `lead.created` | `app/services/contact_service.py` | `contact_id, tenant_id` | `contact` |
| `mail.received` | `app/plugins/builtins/mail/services.py` | `mail_id, tenant_id, account_id, folder_id, subject, from_address` | `mail` |
| `mail.send` | `app/plugins/builtins/mail/services.py` | `mail_id, tenant_id, account_id` | `mail` |
| `file.created` | `app/plugins/builtins/dms/routes.py` | `file_id, tenant_id, name, mime_type, size_bytes` | `dms_file` |
| `file.deleted` | `app/plugins/builtins/dms/routes.py` | `file_id, tenant_id` | `dms_file` |
| `file.restored` | `app/plugins/builtins/dms/routes.py` | `file_id, tenant_id` | `dms_file` |
| `dms.file.uploaded` | `app/plugins/builtins/dms/routes.py` | (legacy alias for `file.created` ) | `dms_file` |
| `task.completed` | `app/plugins/builtins/tasks/services.py` | `task_id, tenant_id, title, assigned_to` | `task` |
| `workflow.started` | `app/services/workflow_service.py` | `instance_id, workflow_id, tenant_id` | `workflow_instance` |
| `workflow.completed` | `app/services/workflow_service.py` | `instance_id, workflow_id, tenant_id, status` | `workflow_instance` |
| `workflow.cancelled` | `app/services/workflow_service.py` | `instance_id, workflow_id, tenant_id` | `workflow_instance` |
| `agent.run_started` | `app/plugins/builtins/automation/agent_runner.py` | `agent_id, tenant_id, trigger_type` | `agent` |
| `agent.run_completed` | `app/plugins/builtins/automation/agent_runner.py` | `agent_id, tenant_id, status, cost_usd` | `agent` |
2026-08-13 17:30:31 +02:00
### 8.4 WebhookDispatcher (`app/core/webhook_dispatcher.py`)
**Wann verwenden: ** Für externe HTTP-Zustellung an registrierte Webhook-URLs.
``` python
from app . core . webhook_dispatcher import register_webhook_event_handlers
# Wird automatisch im Worker registriert — Plugins müssen nur Webhook-Configs erstellen
# und Events über die Outbox publishen
```
### 8.5 Entscheidungsregel
``` text
Braucht das Event reliable Delivery + Retry?
→ JA → Outbox
→ NEIN → Braucht es Daten-Anpassung (Filter)?
→ JA → HookRegistry (register_filter)
→ NEIN → Braucht es nur Reaktion (Action)?
→ JA → HookRegistry (register_action)
→ NEIN → Ist es eine flüchtige Notifikation / UI-Event?
→ JA → EventBus
→ NEIN → Geht es an externe Systeme?
→ JA → WebhookDispatcher (über Outbox)
→ NEIN → Braucht kein Event
```
**Verboten: ** Dieselbe Funktion über Hook UND EventBus triggern — das führt zu Doppel-Ausführung und Race-Conditions.
---
## 9. Schema Authority
LeoCRM hat eine klare Schema-Authority-Hierarchie. **Kein neuer Schema-Mechanismus. **
### Authority-Regeln
| Schema-Typ | Authority | Wie |
|-----------|----------|-----|
| **Core-Tabellen ** | Alembic-Migrationen | `alembic revision --autogenerate -m "..."` → `alembic upgrade head` |
| **Plugin-Tabellen ** | Plugin-Migrationsweg | `plugin/migrations/` → `sync_plugin_schema.py` bei Aktivierung |
| **Runtime Auto-Sync ** | **Nicht authoritative ** | `Base.metadata.create_all` in Tests/dev — nie in Produktion |
### Verbindliche Regeln
1. **Core-Schema-Änderungen ** immer über Alembic-Migrationen — nie manuelle SQL-Statements in Produktion
2. **Plugin-Schema-Änderungen ** über Plugin-Migrationen — nie Core-Migrationen für Plugin-Tabellen
3. **Runtime Auto-Sync ** (`create_all` , `sync_plugin_schema.py` ) ist Convenience für Dev/Tests — **nicht ** für Produktion authoritative
4. **Migration-Staffelung ** beachten: neu → migrieren → umstellen → testen → release → alt entfernen
5. **Keine Schema-Drift ** — wenn Core und Plugin dasselbe Modell nutzen, ist Core authoritative
### Plugin-Migrationen
``` python
# plugin/migrations/001_initial.py
from alembic import op
def upgrade ( ) :
op . create_table ( " my_plugin_table " , . . . )
def downgrade ( ) :
op . drop_table ( " my_plugin_table " )
```
Plugin-Migrationen werden bei Plugin-Aktivierung automatisch ausgeführt (`sync_plugin_schema.py` ). Bei Deaktivierung bleiben die Tabellen erhalten (Soft-Deactivate). Bei Uninstall werden sie gedroppt.
---
2026-08-13 17:35:43 +02:00
## 10. Trigger
Plugins können durch vier Trigger-Typen aktiviert werden: **Domain-Events ** , **UI-Events ** , **Cron-Jobs ** und **manuelle Trigger ** . Webhook-Trigger folgt in Phase G.
### 10.1 Domain-Event-Trigger (durable)
Domain-Events werden über die **Outbox ** gepublished und sind reliable. Siehe Kapitel 8 für die Event-System-Rollen.
``` python
# Plugin deklariert Events im Manifest
manifest = PluginManifest (
name = " my_plugin " ,
events = [ " contact.created " , " contact.updated " ] ,
. . .
)
# Handler wird automatisch via on_activate registriert:
async def on_contact_created ( self , payload : dict [ str , Any ] ) - > None :
""" Reagiert auf contact.created Outbox-Event. """
contact_id = payload . get ( " contact_id " )
# Business logic here
```
### 10.2 UI-Event-Trigger (ephemeral)
Flüchtige UI-Events (z.B. `ui.contact_selected` ) laufen über den **EventBus ** — nicht über die Outbox. Siehe Kapitel 18.
``` python
event_bus = get_event_bus ( )
event_bus . subscribe ( " ui.contact_selected " , self . _on_contact_selected )
```
### 10.3 Cron-Trigger
Cron-Jobs werden im Manifest deklariert und vom Worker ausgeführt:
``` python
from app . plugins . manifest import CronJobContribution
cron_jobs = [
CronJobContribution (
name = " my_plugin_daily_cleanup " ,
cron_expression = " 0 3 * * * " ,
job_type = " custom " ,
plugin_name = " my_plugin " ,
) ,
] ,
```
### 10.4 Manuelle Trigger
Manuelle Trigger laufen über API-Routes oder Automation-Templates:
``` python
@router.post ( " /api/v1/my-plugin/run-sync " )
async def run_manual_sync ( request : Request , db : AsyncSession = Depends ( get_db ) ) :
""" Manueller Trigger — Benutzer startet Sync von der UI. """
# Business logic
return { " status " : " started " }
```
**Wichtig: ** Durable Domain Events (Outbox) und ephemere UI-Events (EventBus) strikt trennen. Siehe Kapitel 8.5 Entscheidungsregel.
---
## 11. Message-System
Plugins können System-Nachrichten in Chat-Räume posten, eigene Chat-Räume erstellen und Mini-Apps registrieren. Das Communication-Plugin (`kommunikation` ) stellt die Infrastruktur bereit.
### 11.1 System-Nachrichten posten
``` python
from app . plugins . builtins . kommunikation . contracts import create_plugin_room , send_message
# Plugin-Room für einen Benutzer erstellen
await create_plugin_room (
db , tenant_id , user_id ,
plugin_name = " my_plugin " ,
title = " My Plugin " ,
participant_type = " system " ,
user_role = " reader " ,
)
# Nachricht in den Room senden
await send_message (
db , tenant_id , conversation_id ,
sender_id = None ,
sender_type = " system " ,
content = " **Sync abgeschlossen** \n 120 Kontakte aktualisiert " ,
content_format = " markdown " ,
blocks = None ,
metadata = { " event_type " : " sync.completed " } ,
)
```
### 11.2 Rich Content Blocks
Nachrichten können strukturierte Blocks enthalten (`app/plugins/builtins/kommunikation/content_types.py` ):
``` python
blocks = [
{
" block_type " : " action_card " ,
" block_data " : {
" title " : " Backup fehlgeschlagen " ,
" body " : " Letztes Backup um 03:00 Uhr ist gescheitert. " ,
" actions " : [
{ " label " : " Öffnen " , " action " : " /settings/backup " , " type " : " primary " } ,
{ " label " : " Archivieren " , " action " : " dismiss " , " type " : " secondary " } ,
] ,
} ,
} ,
{
" block_type " : " contact_card " ,
" block_data " : { " contact_id " : str ( contact_id ) , " name " : " Max Mustermann " } ,
} ,
{
" block_type " : " miniapp " ,
" block_data " : { " app_id " : " my_miniapp " , " config " : { " contact_id " : str ( contact_id ) } } ,
} ,
]
await send_message ( db , tenant_id , conv_id , sender_id = None , sender_type = " system " ,
content = " Neuer Kontakt " , content_format = " markdown " , blocks = blocks )
```
Unterstützte Block-Typen: `text` , `markdown` , `html` , `image` , `audio` , `video` , `file` , `action_card` , `contact_card` , `miniapp` .
### 11.3 Mini-Apps registrieren
Mini-Apps werden im Manifest deklariert:
``` python
from app . plugins . manifest import MiniAppContribution
miniapps = [
MiniAppContribution (
app_id = " my_miniapp " ,
name = " My Mini App " ,
icon = " AppWindow " ,
description = " Interactive mini-app embedded in chat " ,
render_schema = { " type " : " object " , " properties " : { " contact_id " : { " type " : " string " } } } ,
) ,
] ,
```
Siehe `system_notif` Plugin als Referenz-Implementierung.
---
## 12. Search
Plugins können Search-Provider registrieren, um ihre Entitäten in der Unified Search bereitzustellen.
### 12.1 SearchProvider implementieren
Implementiere das `SearchProvider` -Protokoll oder erbe von `BaseSearchProvider` für automatische Visibility-Filterung:
``` python
from app . plugins . builtins . unified_search . base_provider import BaseSearchProvider
class MyEntitySearchProvider ( BaseSearchProvider ) :
entity_type = " my_entity "
async def _search_fts_filtered ( self , db , tsquery , tenant_id , limit , visible_ids ) :
# FTS-Query mit visible_ids Filter
. . .
async def _search_vector_filtered ( self , db , embedding , tenant_id , limit , visible_ids ) :
# Vector-Search mit pgvector + visible_ids Filter
. . .
async def get_embedding_text ( self , db , entity_id , tenant_id ) - > str :
# Text-Repräsentation für Embedding-Generierung
. . .
def to_search_result ( self , entity ) - > dict :
return { " id " : str ( entity . id ) , " type " : " my_entity " , " title " : entity . name }
```
### 12.2 Provider registrieren
``` python
from app . plugins . builtins . unified_search . provider_registry import get_search_registry
async def on_activate ( self , db , service_container , event_bus ) :
await super ( ) . on_activate ( db , service_container , event_bus )
registry = get_search_registry ( )
registry . register ( MyEntitySearchProvider ( ) )
async def on_deactivate ( self , db , service_container , event_bus ) :
get_search_registry ( ) . unregister ( " my_entity " )
await super ( ) . on_deactivate ( db , service_container , event_bus )
```
### 12.3 Unterstützte Modi
| Modus | Beschreibung |
|-------|-------------|
| **FTS ** | PostgreSQL Full-Text Search (`tsquery` ) |
| **Vector ** | Semantische Suche via pgvector (`embedding` ) |
| **RAG ** | Retrieval-Augmented Generation (Vector + LLM) |
| **Graph ** | Beziehungs-Suche (zukünftig) |
### 12.4 Auto-Indexierung
Wenn eine Entität erstellt/aktualisiert wird, wird ein Outbox-Event gepublished. Der Worker generiert das Embedding und aktualisiert den Such-Index automatisch. Plugins müssen nur `get_embedding_text()` korrekt implementieren.
---
## 13. File Storage
Plugins speichern Files über das zentrale Storage-Backend (`app/core/storage.py` ). **Keine eigenen Storage-Backends implementieren. **
### 13.1 save_with_metadata()
``` python
from app . core . storage import save_with_metadata , get_storage_backend
# Speichert mit MIME-Prüfung, Size-Limit und Hash-Berechnung
metadata = await save_with_metadata (
path = f " my_plugin/ { tenant_id } / { file_name } " ,
data = file_bytes ,
allowed_mimes = [ " application/pdf " , " image/png " , " image/jpeg " ] , # None = Default-Allowlist
max_size_mb = 10 , # None = aus Config
)
# Returns: {path, mime_type, size, hash, storage_path}
```
### 13.2 MIME-Prüfung und Path-Traversal-Schutz
``` python
from app . core . storage import validate_mime , validate_size , compute_hash
# MIME wird content-based erkannt (python-magic) mit Extension-Fallback
mime = validate_mime ( path , data , allowed_mimes = [ " application/pdf " ] )
# ValueError bei nicht erlaubtem MIME-Typ
# Path-Traversal wird im LocalStorage automatisch blockiert:
# _full_path() normt den Pfad und prüft, ob er innerhalb base_path bleibt
```
### 13.3 Storage-Backend lesen
``` python
backend = get_storage_backend ( )
data = await backend . read ( path )
url = await backend . get_url ( path , expires = 3600 )
exists = await backend . exists ( path )
await backend . delete ( path )
```
Für File-Embedding siehe Kapitel 7.2 (LLM Integration — Embedding).
---
## 14. Redis
Plugins nutzen den globalen Redis-Singleton. **Keine eigenen Connections, kein `aioredis.from_url()`. **
### 14.1 Redis-Client
``` python
from app . core . auth import get_redis
redis = get_redis ( ) # Globaler Singleton
await redis . setex ( f " my_plugin:lock: { resource_id } " , 30 , " locked " )
value = await redis . get ( f " my_plugin:lock: { resource_id } " )
await redis . delete ( f " my_plugin:lock: { resource_id } " )
```
### 14.2 Cache-Wrapper
``` python
from app . core . cache import cache_get , cache_set , cache_delete , cache_flush_pattern
# Set mit TTL (Default: 300s)
await cache_set ( f " my_plugin:summary: { tenant_id } " , { " count " : 42 } , ttl = 60 )
# Get
data = await cache_get ( f " my_plugin:summary: { tenant_id } " )
# Delete
await cache_delete ( f " my_plugin:summary: { tenant_id } " )
# Pattern-Flush (alle Keys mit Prefix)
await cache_flush_pattern ( " my_plugin:* " )
```
**Verboten: ** `aioredis.from_url()` in Plugin-Code — immer `get_redis()` oder `get_cache()` verwenden.
---
## 15. Permissions
Plugins deklarieren Permissions im Manifest und nutzen das bestehende RBAC/ABAC-System.
### 15.1 Permissions deklarieren
``` python
manifest = PluginManifest (
name = " my_plugin " ,
permissions = [
" my_plugin:read " ,
" my_plugin:write " ,
" my_plugin:admin " ,
] ,
. . .
)
```
### 15.2 Permissions in Routes erzwingen
``` python
from app . deps import require_permission
@router.get ( " /api/v1/my-plugin/items " ,
dependencies = [ Depends ( require_permission ( " my_plugin:read " ) ) ] )
async def list_items ( . . . ) :
. . .
@router.post ( " /api/v1/my-plugin/items " ,
dependencies = [ Depends ( require_permission ( " my_plugin:write " ) ) ] )
async def create_item ( . . . ) :
. . .
```
### 15.3 Wildcard-Support
Das Permission-System unterstützt Wildcards: `my_plugin:*` , `*:read` , `*:*` (Superadmin). Bare `*` ist verboten.
### 15.4 Field-Level Permissions
``` python
from app . plugins . manifest import FieldDefinition
field_definitions = [
FieldDefinition ( module = " my_plugin " , field = " secret_code " ,
label = " Secret Code " , sensitivity = " sensitive " ) ,
] ,
```
**Wichtig: ** Tools, Skills und MCP dürfen keine Rechte verleihen. Siehe `docs/permissions.md` und `docs/permissions_plugin_dev.md` .
---
## 16. AI Tools
Plugins können Tools in der globalen `ToolRegistry` registrieren, die von AI-Agenten aufgerufen werden können.
### 16.1 Tool registrieren
``` python
from app . plugins . builtins . ai_assistant . contracts import get_tool_registry
async def on_activate ( self , db , service_container , event_bus ) :
await super ( ) . on_activate ( db , service_container , event_bus )
registry = get_tool_registry ( )
registry . register (
name = " my_plugin_lookup_contact " ,
description = " Look up a contact by name in My Plugin " ,
parameters = {
" type " : " object " ,
" properties " : {
" name " : { " type " : " string " , " description " : " Contact name " } ,
} ,
" required " : [ " name " ] ,
} ,
handler = self . _lookup_contact_handler ,
plugin_name = " my_plugin " ,
required_permission = " my_plugin:read " ,
category = " contacts " ,
)
async def _lookup_contact_handler ( self , arguments : dict , context : dict ) - > str :
name = arguments . get ( " name " , " " )
# Business logic — return JSON string
return f ' {{ " found " : true, " name " : " { name } " }} '
```
### 16.2 Tool bei Deaktivierung entfernen
``` python
async def on_deactivate ( self , db , service_container , event_bus ) :
get_tool_registry ( ) . unregister_plugin ( " my_plugin " )
await super ( ) . on_deactivate ( db , service_container , event_bus )
```
### 16.3 Permission-Prüfung
Jedes Tool hat ein `required_permission` -Feld. Der AI-Service prüft dies vor der Ausführung:
``` python
# Wird automatisch im Service geprüft:
if tool . required_permission :
if not check_permission ( user_context , tool . required_permission ) :
return f " Error: Permission ' { tool . required_permission } ' required "
```
### 16.4 Tool-Schema (OpenAI-kompatibel)
Das `AITool` -Dataclass konvertiert automatisch ins OpenAI Function-Calling-Format via `to_openai_schema()` .
---
## 17. MCP
MCP (Model Context Protocol) dient als **dünne Exposure-Schicht ** auf bestehende Tools/Services. MCP erhält **keine eigenen Rechte ** .
### 17.1 Architektur
```
External MCP Server → MCP Client Plugin → ToolRegistry → Existing Tools/Services
```
MCP-Tools werden mit dem Naming-Schema `mcp__{server}__{tool}` in der ToolRegistry registriert.
### 17.2 Tool-Registrierung
``` python
# MCP Client Plugin registriert externe Tools automatisch:
registry . register (
name = f " mcp__ { server_name } __ { tool_name } " ,
description = f " [MCP: { server_name } ] { tool . description } " ,
parameters = tool . parameters ,
handler = _make_handler ( server_cfg , tool . name ) ,
plugin_name = " mcp_client " ,
required_permission = " mcp-client:read " , # Bestehende Permission
category = " mcp-external " ,
)
```
### 17.3 Auth- und Run-as-Kontext
Der vorhandene Auth-/Run-as-Kontext und normale Permission-Prüfungen bleiben maßgeblich. MCP-Tools erben die Permissions des aufrufenden Benutzers — MCP kann keine Rechte verleihen, die der Benutzer nicht hat.
**Verboten: ** Eigene Auth-Bypass-Logik in MCP-Handlern. Immer den Standard-Permission-Check verwenden.
---
## 18. UI-Events
Plugins können auf UI-Events reagieren und eigene UI-Events publishen. UI-Events sind **ephemeral ** — über EventBus, nicht über Outbox.
### 18.1 UI-Events abonnieren
``` python
from app . core . event_bus import get_event_bus
async def on_activate ( self , db , service_container , event_bus ) :
await super ( ) . on_activate ( db , service_container , event_bus )
event_bus . subscribe ( " ui.contact_selected " , self . _on_contact_selected )
event_bus . subscribe ( " ui.page_navigated " , self . _on_page_navigated )
event_bus . subscribe ( " ui.mail_opened " , self . _on_mail_opened )
async def _on_contact_selected ( self , payload : dict ) - > None :
contact_id = payload . get ( " contact_id " )
# React to contact selection — e.g. preload data
```
### 18.2 UI-Events publishen
``` python
event_bus = get_event_bus ( )
await event_bus . publish ( " ui.my_plugin_widget_ready " , {
" widget_id " : " summary " ,
" tenant_id " : str ( tenant_id ) ,
" user_id " : str ( user_id ) ,
} )
```
### 18.3 Bekannte UI-Events
| Event | Payload | Beschreibung |
|-------|---------|-------------|
| `ui.contact_selected` | `{contact_id, user_id}` | Benutzer hat Kontakt ausgewählt |
| `ui.page_navigated` | `{path, user_id}` | Benutzer hat Seite navigiert |
| `ui.mail_opened` | `{mail_id, user_id}` | Benutzer hat E-Mail geöffnet |
**Wichtig: ** UI-Events gehen bei Crash verloren. Für reliable Delivery Outbox verwenden (Kapitel 8).
---
## 19. AI UI Control
Das `ai_ui_control` Plugin ermöglicht AI-Agenten, die Frontend-UI zu steuern: Navigation, Filter, Kontakte öffnen, Modals, Tabs und Settings.
### 19.1 Command-Typen
``` python
from app . plugins . builtins . ai_ui_control . schemas import UICommandType
# Unterstützte Actions:
# navigate → {action: 'navigate', path: '/contacts/123'}
# filter → {action: 'filter', entity: 'contacts', filter: {type: 'company'}}
# open_contact → {action: 'open_contact', contact_id: '...'}
# modal → {action: 'modal', modal: 'edit', contact_id: '...'}
# tab → {action: 'tab', tab: 'emails', contact_id: '...'}
# settings → {action: 'settings', section: 'ai', key: 'model', value: 'gpt-4'}
```
### 19.2 Command senden (REST)
``` python
# AI-Agent sendet Command via REST:
POST / api / v1 / ai - ui - control / command
{
" action " : " navigate " ,
" path " : " /contacts/abc-123 " ,
" description " : " Opening contact detail page "
}
# Response: {command_id, status: "pending", action: "navigate"}
```
### 19.3 Command empfangen (WebSocket)
Das Frontend verbindet sich via WebSocket `/ws/ai-ui-control` und empfängt Commands in Echtzeit. Nach Ausführung sendet das Frontend Feedback zurück:
``` python
UICommandFeedback (
command_id = " ... " ,
status = UICommandStatus . success ,
action = UICommandType . navigate ,
current_path = " /contacts/abc-123 " ,
)
```
### 19.4 Permissions
AI UI Control benötigt `ai_ui_control:write` für Commands und `ai_ui_control:read` für Status-Abfragen.
**Wichtig: ** Persistente Mutationen (Daten ändern, erstellen, löschen) dürfen **nicht ** über AI UI Control laufen. Diese müssen über reguläre Tools/Services mit Permission-Prüfungen gehen. AI UI Control ist nur für UI-Navigation und Anzeige.
---
## 20. Sensitive Data
Plugins müssen sensible Daten explizit deklarieren und sicherstellen, dass diese nicht in Snapshots, Such-Index, Embeddings, Exporten oder Logs landen.
### 20.1 SENSITIVE_FIELDS deklarieren
Im Manifest über `field_definitions` mit `sensitivity="sensitive"` oder `sensitivity="critical"` :
``` python
from app . plugins . manifest import FieldDefinition
field_definitions = [
FieldDefinition ( module = " my_plugin " , field = " api_key " ,
label = " API Key " , sensitivity = " critical " ) ,
FieldDefinition ( module = " my_plugin " , field = " internal_notes " ,
label = " Internal Notes " , sensitivity = " sensitive " ) ,
] ,
```
### 20.2 Was NICHT in Snapshots/Index/Embeddings/Export/Logs darf
- **Passwords, Tokens, API Keys, Session-IDs** — niemals loggen, indexieren oder embedden
- **Personenbezogene Daten** (DSGVO-relevant) — nicht in Such-Embeddings ohne explizite Freigabe
- **Interne Notizen** mit `sensitivity="sensitive"` — nicht in Exporten ohne Berechtigung
### 20.3 Error-Logging Sanitization
Das Error-Logging-Endpoint (`/api/v1/errors` ) sanitized automatisch sensible Keys:
``` python
# app/routes/errors.py — _SENSITIVE_PATTERNS
# Erkennt: token, password, secret, authorization, cookie, session,
# api_key, access_token, refresh_token, csrf, bearer, private_key
# Diese Felder werden durch "[redacted]" ersetzt.
```
### 20.4 AI/Data Exposure Policy
- AI-Tools dürfen keine sensiblen Felder an LLM-Provider senden, ohne dass der Benutzer die entsprechende Permission hat
- Embeddings dürfen nur aus nicht-sensiblen Texten generiert werden
- Export-Service respektiert Field-Level Permissions (`hidden` , `readonly` , `read` )
---
## 21. Migration-Staffelung
Schema-Änderungen in Plugins müssen gestaffelt durchgeführt werden, um Downtime und Datenverlust zu vermeiden.
### 21.1 Sechs-Schritt-Prozess
``` text
1. Neue Struktur erstellen (neue Tabelle/Spalte/Index)
→ Migration 0002_add_new_column.sql
2. Daten migrieren (Backfill)
→ Migration 0003_backfill_data.sql (oder Worker-Job)
3. Reads/Writes umstellen
→ Code schreibt in neue UND alte Struktur (Dual-Write)
→ Code liest aus neuer Struktur (mit Fallback auf alte)
4. Tests
→ Unit-Tests mit neuer Struktur
→ Integration-Tests mit Dual-Write
→ Migration-Tests (upgrade + downgrade)
5. Stabiler Release
→ Deploy mit neuer Struktur + Dual-Write
→ Verify: alle Daten korrekt migriert
6. Alte Struktur entfernen
→ Migration 0004_drop_old_column.sql
→ Code: Dual-Write entfernen, nur neue Struktur
```
### 21.2 Plugin-Migration-Beispiel
``` sql
-- migrations/0002_add_status_v2.sql
ALTER TABLE my_plugin_items ADD COLUMN status_v2 VARCHAR ( 20 ) DEFAULT ' active ' ;
-- migrations/0003_backfill_status.sql
UPDATE my_plugin_items SET status_v2 = CASE
WHEN status = ' pending ' THEN ' pending '
WHEN status = ' done ' THEN ' completed '
ELSE ' active '
END ;
```
``` python
# Schritt 3: Dual-Write in Service
item . status = old_status # alte Spalte
item . status_v2 = map_to_new_status ( old_status ) # neue Spalte
```
**Wichtig: ** Jeder Schritt ist ein separater Release. Nie Struktur ändern und Daten migrieren in einer Migration.
---
## 22. Error-Handling
Plugins werfen strukturierte Errors über `ApiError` mit `code` , `detail` , `field` und `status` . Tracebacks werden niemals an den User gesendet.
### 22.1 ApiError werfen
``` python
from app . core . error_codes import ApiError
@router.post ( " /api/v1/my-plugin/items " )
async def create_item ( body : ItemCreate , db : AsyncSession = Depends ( get_db ) ) :
if not body . name :
raise ApiError ( code = " validation_error " , detail = " Name is required " , field = " name " )
existing = await check_duplicate ( db , body . name )
if existing :
raise ApiError ( code = " not_found " , detail = " Item already exists " , status = 409 )
# Service unavailable
raise ApiError ( code = " service_unavailable " , detail = " External API timeout " )
```
### 22.2 Standardisierte Error-Codes
| Code | HTTP Status | Beschreibung |
|------|------------|-------------|
| `not_found` | 404 | Resource nicht gefunden |
| `permission_denied` | 403 | Keine Berechtigung |
| `validation_error` | 422 | Validierung fehlgeschlagen |
| `rate_limited` | 429 | Zu viele Requests |
| `internal_error` | 500 | Interner Fehler |
| `service_unavailable` | 503 | Service temporär nicht verfügbar |
### 22.3 Error-Propagation-Kette
``` text
Plugin → ApiError(code, detail)
→ Core Exception Handler → JSON Response {error: {code, detail, field}}
→ Frontend API Client → TanStack Query onError
→ ErrorBoundary / Toast Notification → User
```
### 22.4 trace_id-Korrelation
Jeder Error bekommt eine `trace_id` für End-to-End-Tracing:
``` python
# WebSocket-Errors via ws_helpers:
from app . core . ws_helpers import send_ws_error
await send_ws_error ( websocket , code = " validation_error " ,
detail = " Invalid input " , trace_id = trace_id )
```
### 22.5 Frontend ErrorBoundary
Plugin-Seiten und MiniApps **müssen ** eine React ErrorBoundary haben. Bei unhandled Errors wird eine freundliche Fehlermeldung angezeigt — kein Stacktrace.
### 22.6 Partial-Failure bei Batch-Operationen
Bei Batch-Operationen (z.B. Bulk-Import) wird **nicht ** die gesamte Operation abgebrochen. Erfolgreiche Items werden committed, fehlgeschlagene Items werden mit Fehlergrund gesammelt zurückgegeben:
``` python
results = {
" success " : [ item_id_1 , item_id_2 ] ,
" failed " : [ { " item " : item_3 , " error " : " validation_error: Name required " } ] ,
}
```
**Verboten: ** Unbehandelte Tracebacks an den User senden. Alle Plugin-Errors müssen als `ApiError` geworfen werden.
---
2026-07-23 19:01:18 +02:00
* This document is authoritative for all plugin development at LeoCRM. *