Update EspoCRM Best Practices to version 2.4 with new features and implementation patterns for Custom API Endpoints; remove outdated TESTERGEBNISSE_JUNCTION_TABLE.md file.

This commit is contained in:
2026-03-12 22:52:42 +01:00
parent bf0f596ad4
commit faffe3d874
3 changed files with 821 additions and 1097 deletions

View File

@@ -1,11 +1,31 @@
# EspoCRM Best Practices & Entwicklungsrichtlinien
**Version:** 2.3
**Datum:** 11. März 2026
**Version:** 2.4
**Datum:** 12. März 2026
**Zielgruppe:** AI Code Agents & Entwickler
---
## 🔄 Letzte Änderungen (v2.4 - 12. März 2026)
**Neue Features:**
-**Custom API Endpoints mit routes.json**: Vollständiges Pattern für moderne Custom API (EspoCRM 7+)
-**Junction Table Custom API**: Best Practice für direkte SQL-basierte Junction-Zugriffe
-**Real-World Beispiel**: CAIKnowledge Junction API (GET/PUT/POST) mit vollständigem Code
-**Performance-Optimierung**: JOIN-Queries in einem Call statt mehrere API-Requests
-**ACL-Workaround**: Umgehung von ACL-Problemen bei Junction Entities
**Dokumentierte Patterns:**
- routes.json Setup und Configuration
- Action Classes mit Constructor Property Promotion
- Direkte PDO-Queries für Junction Tables
- Dynamische UPDATE-Queries mit variablen Feldern
- Error Handling (BadRequest, NotFound, Forbidden)
- Snake_case vs CamelCase in DB-Queries
- Typische Fehler und Debugging-Strategien
---
## 🔄 Letzte Änderungen (v2.3 - 11. März 2026)
**Neue Features:**
@@ -951,6 +971,368 @@ curl -X GET "https://crm.example.com/api/v1/CMyEntity" \
---
### Custom API Endpoints mit routes.json (Modern, EspoCRM 7+)
**Status:** ✅ Empfohlene Methode seit EspoCRM 7.4+ (Controller-Methode deprecated)
#### Wann Custom API Endpoints verwenden?
**✅ Verwende Custom API wenn:**
- Junction Table additionalColumns lesen/schreiben
- JOINs über mehrere Tabellen erforderlich
- ACL-Probleme mit Standard Junction-Entity API
- Performance-kritische Operationen (viele Datensätze)
- Spezielle Business Logic ohne Entity-Hooks
- Hooks sollen NICHT ausgelöst werden
**❌ Verwende Standard API wenn:**
- Einfache CRUD-Operationen ohne Junction-Spalten
- ACL-System explizit gewünscht
- Entity-Hooks sollen ausgelöst werden
#### Implementierungs-Pattern
**1. Routes definieren**
**Datei:** `custom/Espo/Custom/Resources/routes.json`
```json
[
{
"route": "/JunctionData/CAIKnowledge/:knowledgeId/dokumentes",
"method": "get",
"actionClassName": "Espo\\Custom\\Api\\JunctionData\\GetDokumentes"
},
{
"route": "/JunctionData/CAIKnowledge/:knowledgeId/dokumentes/:documentId",
"method": "put",
"actionClassName": "Espo\\Custom\\Api\\JunctionData\\UpdateJunction"
},
{
"route": "/JunctionData/CAIKnowledge/:knowledgeId/dokumentes/:documentId",
"method": "post",
"actionClassName": "Espo\\Custom\\Api\\JunctionData\\LinkDokument"
}
]
```
**Wichtig:**
- **Dateiname:** `routes.json` (NICHT `api.json`!)
- **Location:** `custom/Espo/Custom/Resources/routes.json`
- **Namespace:** Doppelte Backslashes in `actionClassName`!
- **Cache:** Nach Änderungen **IMMER** Clear Cache + Rebuild!
**2. Action Class implementieren**
**Datei:** `custom/Espo/Custom/Api/JunctionData/GetDokumentes.php`
```php
<?php
namespace Espo\Custom\Api\JunctionData;
use Espo\Core\Api\Action;
use Espo\Core\Api\Request;
use Espo\Core\Api\Response;
use Espo\Core\Api\ResponseComposer;
use Espo\Core\Exceptions\BadRequest;
use Espo\Core\Exceptions\NotFound;
use Espo\ORM\EntityManager;
/**
* GET /api/v1/JunctionData/CAIKnowledge/:knowledgeId/dokumentes
*
* Retrieves all documents linked to a knowledge entry with junction data.
* Uses direct SQL JOIN for optimal performance.
*/
class GetDokumentes implements Action
{
public function __construct(
private EntityManager $entityManager
) {}
public function process(Request $request): Response
{
$knowledgeId = $request->getRouteParam('knowledgeId');
if (!$knowledgeId) {
throw new BadRequest('Knowledge ID is required');
}
// Validate entity exists
$knowledge = $this->entityManager->getEntityById('CAIKnowledge', $knowledgeId);
if (!$knowledge) {
throw new NotFound('Knowledge entry not found');
}
$pdo = $this->entityManager->getPDO();
// Direct SQL with JOIN - much faster than multiple API calls!
$sql = "
SELECT
j.id as junctionId,
j.c_a_i_knowledge_id as cAIKnowledgeId,
j.c_dokumente_id as cDokumenteId,
j.ai_document_id as aiDocumentId,
j.syncstatus,
j.last_sync as lastSync,
d.id as documentId,
d.name as documentName,
d.blake3hash as blake3hash,
d.created_at as documentCreatedAt,
d.modified_at as documentModifiedAt
FROM c_a_i_knowledge_dokumente j
INNER JOIN c_dokumente d ON j.c_dokumente_id = d.id
WHERE j.c_a_i_knowledge_id = :knowledgeId
AND j.deleted = 0
AND d.deleted = 0
ORDER BY j.id DESC
";
$sth = $pdo->prepare($sql);
$sth->execute(['knowledgeId' => $knowledgeId]);
$results = $sth->fetchAll(\PDO::FETCH_ASSOC);
return ResponseComposer::json([
'total' => count($results),
'list' => $results
]);
}
}
```
**3. Action Class für UPDATE**
**Datei:** `custom/Espo/Custom/Api/JunctionData/UpdateJunction.php`
```php
<?php
namespace Espo\Custom\Api\JunctionData;
use Espo\Core\Api\Action;
use Espo\Core\Api\Request;
use Espo\Core\Api\Response;
use Espo\Core\Api\ResponseComposer;
use Espo\Core\Exceptions\BadRequest;
use Espo\Core\Exceptions\NotFound;
use Espo\ORM\EntityManager;
/**
* PUT /api/v1/JunctionData/CAIKnowledge/:knowledgeId/dokumentes/:documentId
*
* Updates junction table columns without triggering entity hooks.
*/
class UpdateJunction implements Action
{
public function __construct(
private EntityManager $entityManager
) {}
public function process(Request $request): Response
{
$knowledgeId = $request->getRouteParam('knowledgeId');
$documentId = $request->getRouteParam('documentId');
$data = $request->getParsedBody();
if (!$knowledgeId || !$documentId) {
throw new BadRequest('Knowledge ID and Document ID required');
}
$pdo = $this->entityManager->getPDO();
// Dynamic UPDATE - only fields provided in request body
$setClauses = [];
$params = [
'knowledgeId' => $knowledgeId,
'documentId' => $documentId
];
if (isset($data->aiDocumentId)) {
$setClauses[] = "ai_document_id = :aiDocumentId";
$params['aiDocumentId'] = $data->aiDocumentId;
}
if (isset($data->syncstatus)) {
$allowedStatuses = ['new', 'unclean', 'synced', 'failed', 'unsupported'];
if (!in_array($data->syncstatus, $allowedStatuses)) {
throw new BadRequest('Invalid syncstatus. Allowed: ' . implode(', ', $allowedStatuses));
}
$setClauses[] = "syncstatus = :syncstatus";
$params['syncstatus'] = $data->syncstatus;
}
if (isset($data->lastSync)) {
$setClauses[] = "last_sync = :lastSync";
$params['lastSync'] = $data->lastSync;
} elseif (isset($data->updateLastSync) && $data->updateLastSync === true) {
$setClauses[] = "last_sync = NOW()";
}
if (empty($setClauses)) {
throw new BadRequest('No fields to update');
}
$sql = "
UPDATE c_a_i_knowledge_dokumente
SET " . implode(', ', $setClauses) . "
WHERE c_a_i_knowledge_id = :knowledgeId
AND c_dokumente_id = :documentId
AND deleted = 0
";
$sth = $pdo->prepare($sql);
$sth->execute($params);
if ($sth->rowCount() === 0) {
throw new NotFound('Junction entry not found or no changes made');
}
// Return updated entry
return ResponseComposer::json($this->getJunctionEntry($knowledgeId, $documentId));
}
private function getJunctionEntry(string $knowledgeId, string $documentId): array
{
$pdo = $this->entityManager->getPDO();
$sql = "
SELECT
id as junctionId,
c_a_i_knowledge_id as cAIKnowledgeId,
c_dokumente_id as cDokumenteId,
ai_document_id as aiDocumentId,
syncstatus,
last_sync as lastSync
FROM c_a_i_knowledge_dokumente
WHERE c_a_i_knowledge_id = :knowledgeId
AND c_dokumente_id = :documentId
AND deleted = 0
";
$sth = $pdo->prepare($sql);
$sth->execute([
'knowledgeId' => $knowledgeId,
'documentId' => $documentId
]);
$result = $sth->fetch(\PDO::FETCH_ASSOC);
if (!$result) {
throw new NotFound('Junction entry not found');
}
return $result;
}
}
```
#### Verwendung
**GET: Alle Dokumente mit Junction-Daten**
```bash
curl -X GET "http://localhost:8080/api/v1/JunctionData/CAIKnowledge/69b1b03582bb6e2da/dokumentes" \
-H "X-Api-Key: e53def10eea27b92a6cd00f40a3e09a4"
```
**PUT: Junction-Spalten aktualisieren**
```bash
curl -X PUT "http://localhost:8080/api/v1/JunctionData/CAIKnowledge/69b1b03582bb6e2da/dokumentes/69a68b556a39771bf" \
-H "X-Api-Key: e53def10eea27b92a6cd00f40a3e09a4" \
-H "Content-Type: application/json" \
-d '{
"aiDocumentId": "EXTERNAL-AI-123",
"syncstatus": "synced",
"updateLastSync": true
}'
```
#### Best Practices
**DO:**
- Constructor Property Promotion (`private EntityManager $entityManager`)
- Prepared Statements IMMER (`$pdo->prepare()` + `execute()`)
- Validierung BEVOR DB-Operations
- Spezifische Exceptions (`BadRequest`, `NotFound`, `Forbidden`)
- PHPDoc mit Endpoint-Beschreibung
- `snake_case` für DB-Spaltennamen beachten!
- Cache IMMER löschen nach routes.json Änderungen
**DON'T:**
- Raw SQL ohne Prepared Statements (SQL Injection!)
- Vergessen `deleted = 0` zu prüfen
- Camel Case für DB-Spalten annonehmen (DB verwendet snake_case!)
- Entity-Methods auf routes.json Änderungen verzichten
- ResponseComposer::json() vergessen
- Controller-Methode für neue Projekte (deprecated!)
#### Vorteile gegenüber Standard API
| Feature | Standard Junction API | Custom routes.json API |
|---------|----------------------|------------------------|
| JOINs | ❌ Mehrere Calls nötig | ✅ Ein Call mit JOIN |
| Performance | ⚠️ Langsam bei vielen Records | ✅ Optimiert mit direktem SQL |
| ACL-Probleme | ❌ Oft 403 Forbidden | ✅ Keine ACL-Issues |
| Hooks | ✅ Werden ausgelöst | ❌ Werden umgangen |
| Flexibilität | ⚠️ Eingeschränkt | ✅ Volle SQL-Kontrolle |
| Wartbarkeit | ✅ Standard-Konformität | ⚠️ Custom Code |
#### Typische Fehler
**1. routes.json nicht gefunden**
```
❌ custom/Espo/Custom/Resources/metadata/app/api.json
✅ custom/Espo/Custom/Resources/routes.json
```
**2. Cache nicht gelöscht**
```bash
# PFLICHT nach routes.json Änderungen!
docker exec espocrm php clear_cache.php
docker exec espocrm php rebuild.php
```
**3. Falsche Spalten-Namen**
```php
j.lastSync // CamelCase (falsch!)
j.last_sync // snake_case (richtig!)
```
**4. File Permissions falsch**
```bash
# Alle Custom-Dateien müssen www-data:www-data gehören
chown -R www-data:www-data custom/Espo/Custom/Api/
```
**5. Namespace-Fehler**
```json
"actionClassName": "Espo\Custom\Api\MyAction"
"actionClassName": "Espo\\Custom\\Api\\MyAction"
```
#### Debugging
**Check routes cache:**
```bash
docker exec espocrm cat data/cache/application/routes.php | grep -i "YourRoute"
```
**Check logs:**
```bash
docker exec espocrm tail -100 data/logs/espo.log | grep -i "error\|exception"
```
**Test mit curl verbose:**
```bash
curl -v "http://localhost:8080/api/v1/YourEndpoint" \
-H "X-Api-Key: your-key" 2>&1 | head -30
```
**Siehe:** `custom/docs/API_ENDPOINTS.md` für vollständige Beispiele
---
## Hook-Entwicklung
### Überblick