# EspoCRM Custom Actions - Implementierungsprinzip **Version:** 1.0 **Datum:** 24. Januar 2026 **Blueprint für:** Custom Button Actions mit Entity-Erstellung und Relationen --- ## 📋 Inhaltsverzeichnis 1. [Überblick](#überblick) 2. [Architektur-Prinzipien](#architektur-prinzipien) 3. [Implementierungs-Schritte](#implementierungs-schritte) 4. [Code-Template](#code-template) 5. [Best Practices](#best-practices) 6. [Sicherheit](#sicherheit) 7. [Testing & Debugging](#testing--debugging) 8. [Beispiel-Implementierung](#beispiel-implementierung) --- ## Überblick Dieses Dokument beschreibt das standardisierte Vorgehen zur Implementierung von Custom Button Actions in EspoCRM, die: - Von einer Entity-Detailansicht ausgelöst werden - Eine neue verwandte Entity erstellen - Automatisch Daten und Relationen übertragen - ACL-konform und transaktionssicher arbeiten **Basis-Beispiel:** Button "Räumungsklage einleiten" in Mietobjekt → Erstellt CVmhRumungsklage mit allen Relationen --- ## Architektur-Prinzipien ### 1. Drei-Schichten-Architektur ``` ┌─────────────────────────────────────────────────────┐ │ FRONTEND (client/custom/) │ │ • JavaScript Handler (Action Trigger) │ │ • Button Definition (clientDefs) │ │ • i18n Translations │ └────────────────┬────────────────────────────────────┘ │ AJAX POST Request ┌────────────────▼────────────────────────────────────┐ │ CONTROLLER (custom/Espo/Custom/Controllers/) │ │ • Request Validation │ │ • Service Call │ │ • Response Formatting │ └────────────────┬────────────────────────────────────┘ │ Method Call ┌────────────────▼────────────────────────────────────┐ │ SERVICE (custom/Espo/Custom/Services/) │ │ • ACL Security Checks │ │ • Business Logic │ │ • Transaction Management │ │ • Entity Operations │ └─────────────────────────────────────────────────────┘ ``` ### 2. Separation of Concerns | Schicht | Verantwortlichkeit | Nicht erlaubt | |---------|-------------------|---------------| | **Frontend** | User Interaction, Confirmation, Navigation | Business Logic, Direct DB Access | | **Controller** | HTTP Request/Response Handling | Business Logic, Direct Entity Manipulation | | **Service** | Business Logic, ACL, Transactions, Entity Operations | HTTP Handling, View Logic | --- ## Implementierungs-Schritte ### Schritt 1: Backend Controller erstellen **Datei:** `custom/Espo/Custom/Controllers/[EntityName].php` ```php getParsedBody(); $id = $data->id ?? null; if (!$id) { throw new BadRequest('No Entity ID provided'); } $service = $this->getRecordService(); $result = $service->yourAction($id); return $result; } } ``` **Wichtig:** - Methode MUSS `postAction[Name]` heißen - Parameter ist `Request $request` - Return Type ist `array` - Extends `\Espo\Core\Templates\Controllers\Base` --- ### Schritt 2: Backend Service erstellen **Datei:** `custom/Espo/Custom/Services/[EntityName].php` ```php entityManager->getEntity('CYourEntity', $sourceId); if (!$source) { throw new NotFound('Entity not found'); } // 2. SECURITY: ACL-Checks if (!$this->acl->check($source, 'read')) { throw new Forbidden('No read access to source entity'); } if (!$this->acl->checkScope('CTargetEntity', 'create')) { throw new Forbidden('No create access to target entity'); } // 3. TRANSACTION: Start $this->entityManager->getTransactionManager()->start(); try { // 4. ERSTELLEN: Target Entity $data = new \stdClass(); $data->name = 'Generated from ' . $source->get('name'); // Optional: User und Teams übernehmen if ($source->get('assignedUserId')) { $data->assignedUserId = $source->get('assignedUserId'); } $teamsIds = $source->getLinkMultipleIdList('teams'); if (!empty($teamsIds)) { $data->teamsIds = $teamsIds; } $target = $this->entityManager->createEntity('CTargetEntity', (array)$data); if (!$target) { throw new \RuntimeException('Failed to create target entity'); } // 5. RELATIONEN: Verknüpfen $targetRepo = $this->entityManager->getRepository('CTargetEntity'); // Source → Target $targetRepo ->getRelation($target, 'sources') ->relate($source); // Related Entities übertragen $relatedEntities = $this->entityManager ->getRepository('CYourEntity') ->getRelation($source, 'relatedThings') ->find(); foreach ($relatedEntities as $related) { $targetRepo ->getRelation($target, 'relatedThings') ->relate($related); } // 6. COMMIT: Transaktion abschließen $this->entityManager->getTransactionManager()->commit(); // 7. RETURN: ID und Name für Frontend return [ 'id' => $target->getId(), 'name' => $target->get('name') ]; } catch (\Exception $e) { // ROLLBACK: Bei jedem Fehler $this->entityManager->getTransactionManager()->rollback(); throw $e; } } } ``` **Wichtig:** - Extends `\Espo\Services\Record` - Immer Transactions verwenden - Alle Exceptions im catch-Block fangen - Rollback bei Fehlern --- ### Schritt 3: Button im Frontend konfigurieren **Datei:** `custom/Espo/Custom/Resources/metadata/clientDefs/[EntityName].json` ```json { "menu": { "detail": { "buttons": [ { "name": "initiateYourAction", "label": "Your Action Button", "handler": "custom:handlers/your-entity/your-action", "iconHtml": "", "style": "danger", "acl": "edit" } ] } } } ``` **Button-Optionen:** - `name`: Eindeutiger Identifier - `label`: Translation-Key (siehe i18n) - `handler`: Pfad zum JavaScript Handler - `iconHtml`: FontAwesome Icon oder HTML - `style`: `default`, `success`, `danger`, `warning`, `info` - `acl`: `read`, `edit`, `delete` (benötigte Permission) --- ### Schritt 4: JavaScript Handler implementieren **Datei:** `client/custom/src/handlers/your-entity/your-action.js` ```javascript define('custom:handlers/your-entity/your-action', [], function () { class YourActionHandler { constructor(view) { this.view = view; } /** * Setup-Methode wird automatisch aufgerufen */ initYourAction() { this.view.listenTo( this.view, 'after:render', () => this.controlButtonVisibility() ); } /** * Button-Sichtbarkeit steuern (optional) */ controlButtonVisibility() { const model = this.view.model; // Beispiel: Button nur bei bestimmten Bedingungen zeigen if (!model.get('someField')) { this.view.hideHeaderActionItem('initiateYourAction'); } else { this.view.showHeaderActionItem('initiateYourAction'); } } /** * Action-Handler (wird bei Button-Click aufgerufen) */ actionInitiateYourAction() { const model = this.view.model; // 1. CONFIRMATION: User bestätigen lassen this.view.confirm( this.view.translate('confirmationMessage', 'messages', 'CYourEntity'), () => { this.initiateAction(model.id); } ); } /** * AJAX Request zum Backend */ initiateAction(entityId) { Espo.Ui.notify(this.view.translate('pleaseWait', 'messages')); Espo.Ajax.postRequest('CYourEntity/action/yourAction', { id: entityId }) .then(response => { Espo.Ui.success( this.view.translate('successMessage', 'messages', 'CYourEntity') ); // Navigation zur erstellten Entity this.view.getRouter().navigate( '#CTargetEntity/view/' + response.id, {trigger: true} ); }) .catch(xhr => { console.error('Action failed:', xhr); let errorMessage = this.view.translate('errorMessage', 'messages', 'CYourEntity'); if (xhr.status === 403) { errorMessage = this.view.translate('Access denied', 'messages'); } else if (xhr.status === 404) { errorMessage = this.view.translate('Not found', 'messages'); } Espo.Ui.error(errorMessage); }); } } return YourActionHandler; }); ``` **Wichtig:** - Class-Name muss mit Filename übereinstimmen (CamelCase) - Methode `init[ButtonName]()` für Setup - Methode `action[ButtonName]()` für Click-Handler - Immer Confirmation Dialog für kritische Actions - Error Handling mit spezifischen HTTP-Status-Codes --- ### Schritt 5: Übersetzungen hinzufügen **Datei:** `custom/Espo/Custom/Resources/i18n/de_DE/[EntityName].json` ```json { "labels": { "Your Action Button": "Ihre Aktion" }, "messages": { "confirmationMessage": "Möchten Sie diese Aktion wirklich ausführen?", "successMessage": "Die Aktion wurde erfolgreich ausgeführt", "errorMessage": "Fehler beim Ausführen der Aktion" } } ``` **Datei:** `custom/Espo/Custom/Resources/i18n/en_US/[EntityName].json` ```json { "labels": { "Your Action Button": "Your Action" }, "messages": { "confirmationMessage": "Do you really want to execute this action?", "successMessage": "Action executed successfully", "errorMessage": "Error executing action" } } ``` --- ### Schritt 6: Validierung & Rebuild ```bash # Im Root-Verzeichnis von EspoCRM python3 custom/scripts/validate_and_rebuild.py ``` **Prüfungen:** - ✅ JSON-Syntax aller Dateien - ✅ PHP-Syntax (Service, Controller) - ✅ JavaScript-Syntax (Handler) - ✅ Relationship-Konsistenz - ✅ Automatischer Cache-Clear & Rebuild --- ## Best Practices ### 1. Naming Conventions | Element | Pattern | Beispiel | |---------|---------|----------| | Controller Action | `postAction[Name]` | `postActionInitiateEviction` | | Service Method | `camelCase` | `initiateEviction` | | Button Name | `camelCase` | `initiateEviction` | | JS Handler File | `kebab-case.js` | `eviction-action.js` | | JS Class Name | `PascalCase` | `EvictionActionHandler` | | JS Init Method | `init[ButtonName]` | `initInitiateEviction` | | JS Action Method | `action[ButtonName]` | `actionInitiateEviction` | ### 2. Transaction Management **Immer Transactions verwenden** bei: - Entity-Erstellung - Mehreren Relationierungen - Datenänderungen über mehrere Entities ```php // ✅ RICHTIG $this->entityManager->getTransactionManager()->start(); try { // ... operations $this->entityManager->getTransactionManager()->commit(); } catch (\Exception $e) { $this->entityManager->getTransactionManager()->rollback(); throw $e; } // ❌ FALSCH - Keine Transaction $entity = $this->entityManager->createEntity(...); // Bei Fehler in nächstem Schritt: Inkonsistente Daten! $this->entityManager->getRepository(...)->relate(...); ``` ### 3. Repository Caching ```php // ✅ RICHTIG - Repository nur 1× holen $targetRepo = $this->entityManager->getRepository('Target'); foreach ($items as $item) { $targetRepo->getRelation($target, 'items')->relate($item); } // ❌ FALSCH - Repository in Schleife holen foreach ($items as $item) { $this->entityManager->getRepository('Target') ->getRelation($target, 'items') ->relate($item); } ``` ### 4. Error Messages ```php // ✅ RICHTIG - Spezifische Exceptions if (!$source) { throw new NotFound('Mietobjekt not found'); } if (!$this->acl->check($source, 'read')) { throw new Forbidden('No read access to Mietobjekt'); } // ❌ FALSCH - Generische Fehler if (!$source) { throw new \Exception('Error'); } ``` ### 5. Null Checks ```php // ✅ RICHTIG $entity = $this->entityManager->createEntity(...); if (!$entity) { throw new \RuntimeException('Failed to create entity'); } // ❌ FALSCH - Keine Prüfung $entity = $this->entityManager->createEntity(...); $entity->getId(); // Kann zu Fehler führen! ``` --- ## Sicherheit ### 1. ACL-Checks (Access Control Lists) **Minimum:** Immer beide Checks durchführen: ```php // Check 1: Kann User Source-Entity lesen? if (!$this->acl->check($sourceEntity, 'read')) { throw new Forbidden('No read access'); } // Check 2: Kann User Target-Entity erstellen? if (!$this->acl->checkScope('TargetEntity', 'create')) { throw new Forbidden('No create access'); } ``` **Optional:** Zusätzliche Checks je nach Business-Logik: ```php // Check 3: Kann User Source-Entity bearbeiten? if (!$this->acl->check($sourceEntity, 'edit')) { throw new Forbidden('No edit access'); } // Check 4: Ownership-Check if ($sourceEntity->get('assignedUserId') !== $this->user->getId()) { throw new Forbidden('Not the owner'); } ``` ### 2. Input Validation ```php // ✅ RICHTIG - ID validieren $id = $data->id ?? null; if (!$id || !is_string($id)) { throw new BadRequest('Invalid ID provided'); } // Optional: Format prüfen if (!preg_match('/^[a-f0-9]{17}$/', $id)) { throw new BadRequest('Invalid ID format'); } ``` ### 3. SQL Injection Prevention **Automatisch geschützt** durch: - ✅ EntityManager ORM (immer verwenden!) - ✅ Repository Pattern - ✅ Prepared Statements **Niemals:** - ❌ Direct SQL Queries - ❌ String Concatenation in Queries - ❌ `$pdo->query()` direkt verwenden ### 4. XSS Prevention **Automatisch geschützt** durch: - ✅ EspoCRM Template Engine - ✅ Automatic HTML Escaping - ✅ Content Security Policy Headers **Im Frontend:** ```javascript // ✅ RICHTIG - translate() escaped automatisch this.view.translate('message', 'messages', 'Entity'); // ❌ FALSCH - Rohe HTML-Injection $('.container').html('