Update cache timestamps in config; add README for custom actions implementation
This commit is contained in:
974
custom/README.md
Normal file
974
custom/README.md
Normal file
@@ -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
|
||||||
|
<?php
|
||||||
|
namespace Espo\Custom\Controllers;
|
||||||
|
|
||||||
|
use Espo\Core\Exceptions\BadRequest;
|
||||||
|
use Espo\Core\Api\Request;
|
||||||
|
|
||||||
|
class CYourEntity extends \Espo\Core\Templates\Controllers\Base
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* POST Action: Beschreibung der Aktion
|
||||||
|
*/
|
||||||
|
public function postActionYourAction(Request $request): array
|
||||||
|
{
|
||||||
|
$data = $request->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
|
||||||
|
<?php
|
||||||
|
namespace Espo\Custom\Services;
|
||||||
|
|
||||||
|
use Espo\Core\Exceptions\Forbidden;
|
||||||
|
use Espo\Core\Exceptions\NotFound;
|
||||||
|
|
||||||
|
class CYourEntity extends \Espo\Services\Record
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Beschreibung der Business-Logik
|
||||||
|
*
|
||||||
|
* @param string $sourceId
|
||||||
|
* @return array
|
||||||
|
* @throws NotFound
|
||||||
|
* @throws Forbidden
|
||||||
|
*/
|
||||||
|
public function yourAction(string $sourceId): array
|
||||||
|
{
|
||||||
|
// 1. LADEN: Source Entity laden
|
||||||
|
$source = $this->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": "<span class=\"fas fa-bolt\"></span>",
|
||||||
|
"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('<div>' + userInput + '</div>');
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. CSRF Protection
|
||||||
|
|
||||||
|
**Automatisch geschützt** durch:
|
||||||
|
- ✅ EspoCRM API Token System
|
||||||
|
- ✅ Espo.Ajax verwendet automatisch CSRF-Token
|
||||||
|
- ✅ Session-basierte Validierung
|
||||||
|
|
||||||
|
**Kein manueller Code nötig!**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing & Debugging
|
||||||
|
|
||||||
|
### 1. Entwickler-Workflow
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Code ändern
|
||||||
|
vim custom/Espo/Custom/Services/YourEntity.php
|
||||||
|
|
||||||
|
# 2. Validieren & Rebuild
|
||||||
|
python3 custom/scripts/validate_and_rebuild.py
|
||||||
|
|
||||||
|
# 3. Browser Hard-Refresh
|
||||||
|
# Ctrl + Shift + R (Chrome/Firefox)
|
||||||
|
# Cmd + Shift + R (Mac)
|
||||||
|
|
||||||
|
# 4. Test durchführen
|
||||||
|
|
||||||
|
# 5. Logs prüfen bei Fehlern
|
||||||
|
tail -f data/logs/espo-$(date +%Y-%m-%d).log
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Häufige Fehler & Lösungen
|
||||||
|
|
||||||
|
| Fehler | Ursache | Lösung |
|
||||||
|
|--------|---------|--------|
|
||||||
|
| `Error 500: Call to undefined method getAcl()` | Falsche ACL-Methode | Nutze `$this->acl` statt `$this->getAcl()` |
|
||||||
|
| `Error 500: Entity does not have a relation 'xyz'` | Relation existiert nicht | Prüfe entityDefs, korrekter Relationname |
|
||||||
|
| Button erscheint nicht | Falsche clientDefs-Struktur | Nutze `menu.detail.buttons` nicht `detailActionList` |
|
||||||
|
| JavaScript-Fehler | Handler nicht gefunden | Prüfe Pfad und Class-Name, Cache leeren |
|
||||||
|
| Transaction-Fehler | Verschachtelte Transactions | Nur eine Transaction auf Service-Level |
|
||||||
|
|
||||||
|
### 3. Debugging-Kommandos
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Logs live verfolgen
|
||||||
|
tail -f data/logs/espo-$(date +%Y-%m-%d).log
|
||||||
|
|
||||||
|
# Letzten Fehler finden
|
||||||
|
tail -n 200 data/logs/espo-$(date +%Y-%m-%d).log | grep -i "error"
|
||||||
|
|
||||||
|
# PHP-Syntax prüfen
|
||||||
|
find custom/Espo/Custom -name "*.php" -exec php -l {} \;
|
||||||
|
|
||||||
|
# JSON-Syntax prüfen
|
||||||
|
find custom/Espo/Custom/Resources/metadata -name "*.json" -exec python3 -m json.tool {} \; > /dev/null
|
||||||
|
|
||||||
|
# Cache manuell löschen
|
||||||
|
rm -rf data/cache/*
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Browser DevTools
|
||||||
|
|
||||||
|
**Console (F12):**
|
||||||
|
```javascript
|
||||||
|
// Current Model inspizieren
|
||||||
|
console.log(this.model.attributes);
|
||||||
|
|
||||||
|
// AJAX Request debuggen
|
||||||
|
Espo.Ajax.postRequest('Entity/action/test', {id: 'xyz'})
|
||||||
|
.then(r => console.log('Success:', r))
|
||||||
|
.catch(e => console.error('Error:', e));
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Beispiel-Implementierung
|
||||||
|
|
||||||
|
### Use Case: "Räumungsklage einleiten"
|
||||||
|
|
||||||
|
**Requirement:**
|
||||||
|
- Button in Mietobjekt-Detailansicht
|
||||||
|
- Erstellt CVmhRumungsklage
|
||||||
|
- Verknüpft Mietverhältnisse, Vermieter (Kläger), Mieter (Beklagte)
|
||||||
|
- Kopiert assignedUser und Teams
|
||||||
|
|
||||||
|
### Dateien-Struktur
|
||||||
|
|
||||||
|
```
|
||||||
|
custom/Espo/Custom/
|
||||||
|
├── Controllers/
|
||||||
|
│ └── CMietobjekt.php # POST Action Endpoint
|
||||||
|
├── Services/
|
||||||
|
│ └── CMietobjekt.php # Business Logic
|
||||||
|
└── Resources/
|
||||||
|
├── metadata/clientDefs/
|
||||||
|
│ └── CMietobjekt.json # Button Definition
|
||||||
|
└── i18n/
|
||||||
|
├── de_DE/
|
||||||
|
│ └── CMietobjekt.json # Deutsche Übersetzungen
|
||||||
|
└── en_US/
|
||||||
|
└── CMietobjekt.json # Englische Übersetzungen
|
||||||
|
|
||||||
|
client/custom/src/handlers/
|
||||||
|
└── mietobjekt/
|
||||||
|
└── eviction-action.js # Frontend Handler
|
||||||
|
```
|
||||||
|
|
||||||
|
### Vollständiger Code
|
||||||
|
|
||||||
|
**1. Controller** ([CMietobjekt.php](custom/Espo/Custom/Controllers/CMietobjekt.php)):
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
namespace Espo\Custom\Controllers;
|
||||||
|
|
||||||
|
use Espo\Core\Exceptions\BadRequest;
|
||||||
|
use Espo\Core\Api\Request;
|
||||||
|
|
||||||
|
class CMietobjekt extends \Espo\Core\Templates\Controllers\Base
|
||||||
|
{
|
||||||
|
public function postActionInitiateEviction(Request $request): array
|
||||||
|
{
|
||||||
|
$data = $request->getParsedBody();
|
||||||
|
$id = $data->id ?? null;
|
||||||
|
|
||||||
|
if (!$id) {
|
||||||
|
throw new BadRequest('No Mietobjekt ID provided');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->getRecordService()->initiateEviction($id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. Service** ([CMietobjekt.php](custom/Espo/Custom/Services/CMietobjekt.php)):
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
namespace Espo\Custom\Services;
|
||||||
|
|
||||||
|
use Espo\Core\Exceptions\Forbidden;
|
||||||
|
use Espo\Core\Exceptions\NotFound;
|
||||||
|
|
||||||
|
class CMietobjekt extends \Espo\Services\Record
|
||||||
|
{
|
||||||
|
public function initiateEviction(string $mietobjektId): array
|
||||||
|
{
|
||||||
|
// 1. Load & Validate
|
||||||
|
$mietobjekt = $this->entityManager->getEntity('CMietobjekt', $mietobjektId);
|
||||||
|
if (!$mietobjekt) {
|
||||||
|
throw new NotFound('Mietobjekt not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. ACL Checks
|
||||||
|
if (!$this->acl->check($mietobjekt, 'read')) {
|
||||||
|
throw new Forbidden('No read access to Mietobjekt');
|
||||||
|
}
|
||||||
|
if (!$this->acl->checkScope('CVmhRumungsklage', 'create')) {
|
||||||
|
throw new Forbidden('No create access to Räumungsklage');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Start Transaction
|
||||||
|
$this->entityManager->getTransactionManager()->start();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 4. Create Räumungsklage
|
||||||
|
$data = new \stdClass();
|
||||||
|
$data->name = 'Räumungsklage - ' . $mietobjekt->get('name');
|
||||||
|
|
||||||
|
if ($mietobjekt->get('assignedUserId')) {
|
||||||
|
$data->assignedUserId = $mietobjekt->get('assignedUserId');
|
||||||
|
}
|
||||||
|
|
||||||
|
$teamsIds = $mietobjekt->getLinkMultipleIdList('teams');
|
||||||
|
if (!empty($teamsIds)) {
|
||||||
|
$data->teamsIds = $teamsIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
$raeumungsklage = $this->entityManager->createEntity(
|
||||||
|
'CVmhRumungsklage',
|
||||||
|
(array)$data
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!$raeumungsklage) {
|
||||||
|
throw new \RuntimeException('Failed to create Räumungsklage');
|
||||||
|
}
|
||||||
|
|
||||||
|
$repo = $this->entityManager->getRepository('CVmhRumungsklage');
|
||||||
|
|
||||||
|
// 5. Link Relations
|
||||||
|
$repo->getRelation($raeumungsklage, 'mietobjekte')
|
||||||
|
->relate($mietobjekt);
|
||||||
|
|
||||||
|
$mietverhaeltnisse = $this->entityManager
|
||||||
|
->getRepository('CMietobjekt')
|
||||||
|
->getRelation($mietobjekt, 'vmhMietverhltnises')
|
||||||
|
->find();
|
||||||
|
|
||||||
|
foreach ($mietverhaeltnisse as $mv) {
|
||||||
|
$repo->getRelation($raeumungsklage, 'vmhMietverhltnises')
|
||||||
|
->relate($mv);
|
||||||
|
|
||||||
|
// Link Vermieter as Kläger
|
||||||
|
$vermieter = $this->entityManager
|
||||||
|
->getRepository('CVmhMietverhltnis')
|
||||||
|
->getRelation($mv, 'vmhbeteiligtevermieter')
|
||||||
|
->find();
|
||||||
|
foreach ($vermieter as $v) {
|
||||||
|
$repo->getRelation($raeumungsklage, 'klaeger')->relate($v);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Link Mieter as Beklagte
|
||||||
|
$mieter = $this->entityManager
|
||||||
|
->getRepository('CVmhMietverhltnis')
|
||||||
|
->getRelation($mv, 'vmhbeteiligtemieter')
|
||||||
|
->find();
|
||||||
|
foreach ($mieter as $m) {
|
||||||
|
$repo->getRelation($raeumungsklage, 'beklagte')->relate($m);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Commit
|
||||||
|
$this->entityManager->getTransactionManager()->commit();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $raeumungsklage->getId(),
|
||||||
|
'name' => $raeumungsklage->get('name')
|
||||||
|
];
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$this->entityManager->getTransactionManager()->rollback();
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**3. Button Config** ([clientDefs/CMietobjekt.json](custom/Espo/Custom/Resources/metadata/clientDefs/CMietobjekt.json)):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"menu": {
|
||||||
|
"detail": {
|
||||||
|
"buttons": [
|
||||||
|
{
|
||||||
|
"name": "initiateEviction",
|
||||||
|
"label": "Initiate Eviction",
|
||||||
|
"handler": "custom:handlers/mietobjekt/eviction-action",
|
||||||
|
"iconHtml": "<span class=\"fas fa-gavel\"></span>",
|
||||||
|
"style": "danger",
|
||||||
|
"acl": "edit"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**4. JavaScript Handler** ([eviction-action.js](client/custom/src/handlers/mietobjekt/eviction-action.js)):
|
||||||
|
```javascript
|
||||||
|
define('custom:handlers/mietobjekt/eviction-action', [], function () {
|
||||||
|
|
||||||
|
class EvictionActionHandler {
|
||||||
|
|
||||||
|
constructor(view) {
|
||||||
|
this.view = view;
|
||||||
|
}
|
||||||
|
|
||||||
|
initInitiateEviction() {
|
||||||
|
// Optional: Button-Logik nach Render
|
||||||
|
}
|
||||||
|
|
||||||
|
actionInitiateEviction() {
|
||||||
|
this.view.confirm(
|
||||||
|
this.view.translate('confirmEviction', 'messages', 'CMietobjekt'),
|
||||||
|
() => this.initiateEviction(this.view.model.id)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
initiateEviction(id) {
|
||||||
|
Espo.Ui.notify(this.view.translate('pleaseWait', 'messages'));
|
||||||
|
|
||||||
|
Espo.Ajax.postRequest('CMietobjekt/action/initiateEviction', { id })
|
||||||
|
.then(response => {
|
||||||
|
Espo.Ui.success(
|
||||||
|
this.view.translate('evictionCreated', 'messages', 'CMietobjekt')
|
||||||
|
);
|
||||||
|
this.view.getRouter().navigate(
|
||||||
|
'#CVmhRumungsklage/view/' + response.id,
|
||||||
|
{ trigger: true }
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch(xhr => {
|
||||||
|
let msg = this.view.translate('evictionError', 'messages', 'CMietobjekt');
|
||||||
|
if (xhr.status === 403) {
|
||||||
|
msg = this.view.translate('Access denied', 'messages');
|
||||||
|
}
|
||||||
|
Espo.Ui.error(msg);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return EvictionActionHandler;
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**5. Translations** ([i18n/de_DE/CMietobjekt.json](custom/Espo/Custom/Resources/i18n/de_DE/CMietobjekt.json)):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"labels": {
|
||||||
|
"Initiate Eviction": "Räumungsklage einleiten"
|
||||||
|
},
|
||||||
|
"messages": {
|
||||||
|
"confirmEviction": "Möchten Sie wirklich eine Räumungsklage einleiten?",
|
||||||
|
"evictionCreated": "Räumungsklage wurde erfolgreich erstellt",
|
||||||
|
"evictionError": "Fehler beim Erstellen der Räumungsklage"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Checkliste für neue Implementierungen
|
||||||
|
|
||||||
|
### Vor dem Start
|
||||||
|
- [ ] Requirements klar definiert (Welche Entity? Welche Relationen?)
|
||||||
|
- [ ] Entity-Relationen in entityDefs geprüft
|
||||||
|
- [ ] ACL-Requirements geklärt
|
||||||
|
- [ ] Datenfluss dokumentiert
|
||||||
|
|
||||||
|
### Implementierung
|
||||||
|
- [ ] Controller erstellt mit `postAction[Name]` Methode
|
||||||
|
- [ ] Service erstellt mit Business-Logik
|
||||||
|
- [ ] ACL-Checks: `check()` für Source + `checkScope()` für Target
|
||||||
|
- [ ] Transaction: `start()` → `try/catch` → `commit()/rollback()`
|
||||||
|
- [ ] Null-Check nach `createEntity()`
|
||||||
|
- [ ] Repository-Caching bei Loops
|
||||||
|
- [ ] Button in clientDefs konfiguriert (`menu.detail.buttons`)
|
||||||
|
- [ ] JavaScript Handler erstellt
|
||||||
|
- [ ] Init-Methode: `init[ButtonName]()`
|
||||||
|
- [ ] Action-Methode: `action[ButtonName]()`
|
||||||
|
- [ ] Confirmation Dialog implementiert
|
||||||
|
- [ ] Error Handling mit HTTP-Status-Codes
|
||||||
|
- [ ] Translations: Deutsch + Englisch
|
||||||
|
- [ ] Labels + Messages vollständig
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
- [ ] Validierung erfolgreich: `validate_and_rebuild.py`
|
||||||
|
- [ ] Browser Hard-Refresh durchgeführt
|
||||||
|
- [ ] Button erscheint in Detailansicht
|
||||||
|
- [ ] Confirmation Dialog funktioniert
|
||||||
|
- [ ] Entity wird korrekt erstellt
|
||||||
|
- [ ] Relationen sind korrekt verknüpft
|
||||||
|
- [ ] Navigation zur neuen Entity funktioniert
|
||||||
|
- [ ] ACL wird respektiert (Test mit eingeschränktem User)
|
||||||
|
- [ ] Error Cases getestet (keine Permission, Entity nicht gefunden)
|
||||||
|
- [ ] Logs geprüft: Keine Errors
|
||||||
|
|
||||||
|
### Dokumentation
|
||||||
|
- [ ] Code-Kommentare hinzugefügt
|
||||||
|
- [ ] PHPDoc vollständig
|
||||||
|
- [ ] README aktualisiert (falls generische Änderungen)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Support & Weiterentwicklung
|
||||||
|
|
||||||
|
### Erweiterungen des Templates
|
||||||
|
|
||||||
|
**Optional implementierbar:**
|
||||||
|
- Conditional Button Visibility (nur unter bestimmten Bedingungen)
|
||||||
|
- Batch Operations (mehrere Entities gleichzeitig)
|
||||||
|
- Background Jobs (lange Operationen asynchron)
|
||||||
|
- Email-Benachrichtigungen nach Erstellung
|
||||||
|
- PDF-Generierung
|
||||||
|
- Webhook-Trigger
|
||||||
|
- Audit Logging
|
||||||
|
|
||||||
|
### Performance-Optimierungen
|
||||||
|
|
||||||
|
Bei großen Datenmengen (> 100 Relations):
|
||||||
|
- Batch-Insert mit `saveEntity()` statt `createEntity()`
|
||||||
|
- Eager Loading mit `with()`
|
||||||
|
- Query Optimization mit `select()` und `where()`
|
||||||
|
- Background Jobs für lange Operationen
|
||||||
|
|
||||||
|
### Nützliche Links
|
||||||
|
|
||||||
|
- [EspoCRM Dokumentation](https://docs.espocrm.com)
|
||||||
|
- [GitHub Repository](https://github.com/espocrm/espocrm)
|
||||||
|
- [Community Forum](https://forum.espocrm.com)
|
||||||
|
- [API Dokumentation](https://docs.espocrm.com/development/api/)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Versionierung
|
||||||
|
|
||||||
|
| Version | Datum | Änderungen |
|
||||||
|
|---------|-------|------------|
|
||||||
|
| 1.0 | 24.01.2026 | Initiale Version basierend auf Räumungsklage-Implementierung |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Erstellt von:** EspoCRM Development Team
|
||||||
|
**Letzte Aktualisierung:** 24. Januar 2026
|
||||||
|
**Status:** Production Ready ✅
|
||||||
@@ -359,8 +359,8 @@ return [
|
|||||||
0 => 'youtube.com',
|
0 => 'youtube.com',
|
||||||
1 => 'google.com'
|
1 => 'google.com'
|
||||||
],
|
],
|
||||||
'cacheTimestamp' => 1769249126,
|
'cacheTimestamp' => 1769252191,
|
||||||
'microtime' => 1769249126.108164,
|
'microtime' => 1769252191.810945,
|
||||||
'siteUrl' => 'https://crm.bitbylaw.com',
|
'siteUrl' => 'https://crm.bitbylaw.com',
|
||||||
'fullTextSearchMinLength' => 4,
|
'fullTextSearchMinLength' => 4,
|
||||||
'appTimestamp' => 1768843902,
|
'appTimestamp' => 1768843902,
|
||||||
|
|||||||
Reference in New Issue
Block a user