diff --git a/custom/README.md b/custom/README.md new file mode 100644 index 00000000..9fdbd303 --- /dev/null +++ b/custom/README.md @@ -0,0 +1,974 @@ +# 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('