diff --git a/custom/docs/API_ENDPOINTS.md b/custom/docs/API_ENDPOINTS.md index 3883c85b..46385f63 100644 --- a/custom/docs/API_ENDPOINTS.md +++ b/custom/docs/API_ENDPOINTS.md @@ -1,12 +1,12 @@ # REST API Endpunkte - EspoCRM Custom Entities -**Version:** 1.3 -**Datum:** 11. März 2026 +**Version:** 1.4 +**Datum:** 12. März 2026 **Base URL:** `https://your-crm.com/api/v1` **Changelog:** +- v1.4 (12. März 2026): Custom API Endpoints mit routes.json Pattern hinzugefügt; Standard Junction Entity API Dokumentation entfernt (nur Custom API Pattern wird verwendet) - v1.3 (11. März 2026): pending_sync Status zu globalem syncStatus hinzugefügt -- v1.2 (11. März 2026): syncedHash-Feld zu Junction-Tables hinzugefügt - v1.1 (11. März 2026): Aktivierungsstatus-Feld hinzugefügt (new, active, paused, deactivated) - v1.0 (11. März 2026): Initiale Version @@ -31,10 +31,8 @@ Alle API-Requests benötigen einen API-Key im Header: 1. [CAdvowareAkten (Advoware Akten)](#cadvowareakten-advoware-akten) 2. [CAIKnowledge (AI Knowledge Base)](#caiknowledge-ai-knowledge-base) -3. [Junction Tables](#junction-tables) - - [CAdvowareAktenCDokumente](#cadvowareaktencdokumente-junction) - - [CAIKnowledgeCDokumente](#caiknowledgecdokumente-junction) -4. [CDokumente (Dokumente)](#cdokumente-dokumente) +3. [CDokumente (Dokumente)](#cdokumente-dokumente) +4. [Custom API Endpoints für Junction Tables](#-custom-api-endpoints-für-junction-tables-best-practice) 5. [Filtering & Sorting](#filtering--sorting) 6. [Praktische Beispiele](#praktische-beispiele) @@ -177,7 +175,7 @@ GET /api/v1/CAdvowareAkten/{id}/dokumentes } ``` -**⚠️ WICHTIG:** Diese Endpoint gibt **KEINE** Junction-Spalten zurück (`hnr`, `syncstatus`, `lastSync`). Nutze dafür den [Junction API Endpoint](#cadvowareaktencdokumente-junction). +**⚠️ WICHTIG:** Dieser Endpoint gibt **KEINE** Junction-Spalten zurück (`hnr`, `syncstatus`, `lastSync`). Nutze dafür den [Custom Junction API Endpoint](#-custom-api-endpoints-für-junction-tables-best-practice). #### Dokument mit Akte verknüpfen ```http @@ -335,7 +333,7 @@ DELETE /api/v1/CAIKnowledge/{id} GET /api/v1/CAIKnowledge/{id}/dokumentes ``` -**⚠️ WICHTIG:** Gibt **KEINE** Junction-Spalten zurück. Nutze [CAIKnowledgeCDokumente Junction API](#caiknowledgecdokumente-junction). +**⚠️ WICHTIG:** Gibt **KEINE** Junction-Spalten zurück. Nutze [Custom Junction API Endpoint](#-custom-api-endpoints-für-junction-tables-best-practice). #### Dokument verknüpfen ```http @@ -387,209 +385,6 @@ GET /api/v1/CAIKnowledge?where[0][type]=equals&where[0][attribute]=syncStatus&wh --- -## Junction Tables - -### CAdvowareAktenCDokumente (Junction) - -**Entity:** Junction-Tabelle zwischen CAdvowareAkten und CDokumente mit additionalColumns - -**Verfügbare Felder:** -- `cAdvowareAktenId` - ID der Akte -- `cDokumenteId` - ID des Dokuments -- `hnr` - Advoware HNR-Referenz (varchar, 255) -- `syncStatus` - Sync-Status (enum: new, changed, synced, deleted) -- `syncedHash` - Hash-Wert des synchronisierten Zustands (varchar, 64) -- `deleted` - Soft-Delete Flag - -#### Alle Junction-Einträge -```http -GET /api/v1/CAdvowareAktenCDokumente -``` - -**Response:** -```json -{ - "total": 150, - "list": [ - { - "id": "1", - "cAdvowareAktenId": "akte-123", - "cDokumenteId": "dok-456", - "hnr": "42", - "syncStatus": "synced", - "syncedHash": "a3f5c8b9e2d1...", - "deleted": false - }, - { - "id": "2", - "cAdvowareAktenId": "akte-123", - "cDokumenteId": "dok-789", - "hnr": "43", - "syncStatus": "new", - "syncedHash": null, - "deleted": false - } - ] -} -``` - -#### Einzelnen Junction-Eintrag abrufen -```http -GET /api/v1/CAdvowareAktenCDokumente/{id} -``` - -#### Alle Dokumente einer Akte mit Junction-Spalten -```http -GET /api/v1/CAdvowareAktenCDokumente?where[0][type]=equals&where[0][attribute]=cAdvowareAktenId&where[0][value]=akte-123 -``` - -**Response:** -```json -{ - "total": 5, - "list": [ - { - "id": "1", - "cAdvowareAktenId": "akte-123", - "cDokumenteId": "dok-456", - "hnr": "42", - "syncStatus": "synced", - "syncedHash": "a3f5c8b9e2d1..." - } - ] -} -``` - -#### Nach HNR filtern -```http -GET /api/v1/CAdvowareAktenCDokumente?where[0][type]=equals&where[0][attribute]=hnr&where[0][value]=42 -``` - -#### Nach syncStatus filtern -```http -GET /api/v1/CAdvowareAktenCDokumente?where[0][type]=in&where[0][attribute]=syncStatus&where[0][value][0]=new&where[0][value][1]=changed -``` - -#### Neuen Junction-Eintrag erstellen (Dokument mit Akte + HNR verknüpfen) -```http -POST /api/v1/CAdvowareAktenCDokumente -Content-Type: application/json - -{ - "cAdvowareAktenId": "akte-123", - "cDokumenteId": "dok-999", - "hnr": "50", - "syncStatus": "new", - "syncedHash": null -} -``` - -**Response:** -```json -{ - "id": "15" -} -``` - -#### Junction-Spalten aktualisieren -```http -PUT /api/v1/CAdvowareAktenCDokumente/{junctionId} -Content-Type: application/json - -{ - "syncStatus": "synced", - "syncedHash": "a3f5c8b9e2d1f4a6c7b8e9d0f1a2b3c4", - "hnr": "51" -} -``` - -#### Junction-Eintrag löschen -```http -DELETE /api/v1/CAdvowareAktenCDokumente/{id} -``` - ---- - -### CAIKnowledgeCDokumente (Junction) - -**Entity:** Junction-Tabelle zwischen CAIKnowledge und CDokumente mit additionalColumns - -**Verfügbare Felder:** -- `cAIKnowledgeId` - ID des AI Knowledge Entry -- `cDokumenteId` - ID des Dokuments -- `aiDocumentId` - Externe AI-Dokument-Referenz-ID (varchar, 255) -- `syncstatus` - Sync-Status (enum: new, unclean, synced, failed, unsupported) -- `lastSync` - Zeitpunkt der letzten Synchronisation (datetime) -- `syncedHash` - Hash-Wert des synchronisierten Zustands (varchar, 64) -- `deleted` - Soft-Delete Flag - -#### Alle Junction-Einträge -```http -GET /api/v1/CAIKnowledgeCDokumente -``` - -**Response:** -```json -{ - "total": 80, - "list": [ - { - "id": "1", - "cAIKnowledgeId": "kb-123", - "cDokumenteId": "dok-456", - "aiDocumentId": "ai-doc-external-789", - "syncstatus": "synced", - "lastSync": "2026-03-11 19:00:00", - "syncedHash": "b4e2a9c7f3d8...", - "deleted": false - } - ] -} -``` - -#### Alle Dokumente eines Knowledge Entry mit Junction-Spalten -```http -GET /api/v1/CAIKnowledgeCDokumente?where[0][type]=equals&where[0][attribute]=cAIKnowledgeId&where[0][value]=kb-123 -``` - -#### Nach aiDocumentId suchen -```http -GET /api/v1/CAIKnowledgeCDokumente?where[0][type]=equals&where[0][attribute]=aiDocumentId&where[0][value]=ai-doc-external-789 -``` - -#### Nach syncstatus filtern -```http -GET /api/v1/CAIKnowledgeCDokumente?where[0][type]=equals&where[0][attribute]=syncstatus&where[0][value]=unclean -``` - -#### Neuen Junction-Eintrag erstellen -```http -POST /api/v1/CAIKnowledgeCDokumente -Content-Type: application/json - -{ - "cAIKnowledgeId": "kb-123", - "cDokumenteId": "dok-999", - "aiDocumentId": "ai-doc-new-123", - "syncstatus": "new", - "syncedHash": null -} -``` - -#### Junction-Spalten aktualisieren -```http -PUT /api/v1/CAIKnowledgeCDokumente/{junctionId} -Content-Type: application/json - -{ - "syncstatus": "synced", - "lastSync": "2026-03-11T20:30:00+00:00", - "syncedHash": "b4e2a9c7f3d8e1a5c6b7d8e9f0a1b2c3" -} -``` - ---- - ## CDokumente (Dokumente) **Entity:** Dokumentenverwaltung @@ -710,49 +505,41 @@ curl -X GET "https://crm.example.com/api/v1/CAdvowareAkten?where[0][type]=equals ### Beispiel 2: Alle Dokumente einer Akte mit Junction-Spalten ```bash -# Schritt 1: Hole Akte -curl -X GET "https://crm.example.com/api/v1/CAdvowareAkten/akte-123" \ +# Custom API Endpoint für CAIKnowledge (siehe Custom API Sektion unten) +curl -X GET "http://localhost:8080/api/v1/JunctionData/CAIKnowledge/69b1b03582bb6e2da/dokumentes" \ -H "X-Api-Key: your-api-key" -# Schritt 2: Hole Junction-Einträge mit HNR -curl -X GET "https://crm.example.com/api/v1/CAdvowareAktenCDokumente?where[0][type]=equals&where[0][attribute]=cAdvowareAktenId&where[0][value]=akte-123&select=cDokumenteId,hnr,syncStatus,syncedHash" \ - -H "X-Api-Key: your-api-key" +# Response enthält alle Dokumente MIT Junction-Spalten in einem Call ``` -### Beispiel 3: Dokument mit Akte + HNR verknüpfen +### Beispiel 3: Junction-Spalten aktualisieren ```bash -# Via Junction API (empfohlen) -curl -X POST "https://crm.example.com/api/v1/CAdvowareAktenCDokumente" \ +# Via Custom Junction API Endpoint (empfohlen) +curl -X PUT "http://localhost:8080/api/v1/JunctionData/CAIKnowledge/kb-123/dokumentes/dok-789" \ -H "X-Api-Key: your-api-key" \ -H "Content-Type: application/json" \ -d '{ - "cAdvowareAktenId": "akte-123", - "cDokumenteId": "dok-789", - "hnr": "42", - "syncStatus": "new", - "syncedHash": null + "aiDocumentId": "EXTERNAL-AI-123", + "syncstatus": "synced", + "updateLastSync": true }' ``` ### Beispiel 4: Sync-Status aktualisieren nach erfolgreicher Synchronisation ```bash -# Schritt 1: Finde Junction-Eintrag -JUNCTION_ID=$(curl -s -X GET "https://crm.example.com/api/v1/CAdvowareAktenCDokumente?where[0][type]=equals&where[0][attribute]=cAdvowareAktenId&where[0][value]=akte-123&where[1][type]=equals&where[1][attribute]=cDokumenteId&where[1][value]=dok-789&select=id" \ - -H "X-Api-Key: your-api-key" | jq -r '.list[0].id') - -# Schritt 2: Update Junction-Status -curl -X PUT "https://crm.example.com/api/v1/CAdvowareAktenCDokumente/$JUNCTION_ID" \ +# Direktes Update mit Custom API (kein Suchen nötig!) +curl -X PUT "http://localhost:8080/api/v1/JunctionData/CAIKnowledge/kb-123/dokumentes/dok-789" \ -H "X-Api-Key: your-api-key" \ -H "Content-Type: application/json" \ -d '{ - "syncStatus": "synced", - "syncedHash": "a3f5c8b9e2d1f4a6c7b8e9d0f1a2b3c4" + "syncstatus": "synced", + "updateLastSync": true }' -# Schritt 3: Update global status (optional, Hooks machen das automatisch) -curl -X PUT "https://crm.example.com/api/v1/CAdvowareAkten/akte-123" \ +# Update global status (optional, Hooks machen das automatisch) +curl -X PUT "https://crm.example.com/api/v1/CAIKnowledge/kb-123" \ -H "X-Api-Key: your-api-key" \ -H "Content-Type: application/json" \ -d '{ @@ -765,19 +552,18 @@ curl -X PUT "https://crm.example.com/api/v1/CAdvowareAkten/akte-123" \ ### Beispiel 5: Suche AI-Dokument via externe ID ```bash -# Finde Junction-Eintrag via aiDocumentId -curl -X GET "https://crm.example.com/api/v1/CAIKnowledgeCDokumente?where[0][type]=equals&where[0][attribute]=aiDocumentId&where[0][value]=ai-doc-external-789" \ - -H "X-Api-Key: your-api-key" - -# Response enthält cDokumenteId zum Abrufen des vollen Dokuments +# Custom API gibt alle Dokumente mit Junction-Daten zurück +# Clientseitig filtern nach aiDocumentId: +curl -X GET "http://localhost:8080/api/v1/JunctionData/CAIKnowledge/kb-123/dokumentes" \ + -H "X-Api-Key: your-api-key" | jq '.list[] | select(.aiDocumentId=="ai-doc-external-789")' ``` ### Beispiel 6: Alle neuen/geänderten Dokumente für Sync ```bash -# Finde alle Junction-Einträge die "new" oder "changed" sind -curl -X GET "https://crm.example.com/api/v1/CAdvowareAktenCDokumente?where[0][type]=in&where[0][attribute]=syncStatus&where[0][value][0]=new&where[0][value][1]=changed&select=cAdvowareAktenId,cDokumenteId,hnr,syncStatus,syncedHash" \ - -H "X-Api-Key: your-api-key" +# Custom API gibt alle Dokumente zurück - clientseitig filtern nach syncstatus +curl -X GET "http://localhost:8080/api/v1/JunctionData/CAIKnowledge/kb-123/dokumentes" \ + -H "X-Api-Key: your-api-key" | jq '.list[] | select(.syncstatus=="new" or .syncstatus=="unclean")' ``` ### Beispiel 7: Alle aktiven Akten abrufen @@ -840,49 +626,6 @@ curl -X PUT "https://crm.example.com/api/v1/CAdvowareAkten/akte-123" \ ## 🎯 Wichtige Hinweise -### syncedHash - Änderungserkennung - -**Zweck:** Hash-basierte Versionierung zur Erkennung von Dokumentänderungen zwischen Synchronisationen - -**Verwendung:** -```bash -# 1. Nach erfolgreicher Synchronisation: Hash berechnen und speichern -curl -X PUT "https://crm.example.com/api/v1/CAdvowareAktenCDokumente/123" \ - -H "X-Api-Key: your-api-key" \ - -H "Content-Type: application/json" \ - -d '{ - "syncStatus": "synced", - "syncedHash": "sha256:a3f5c8b9e2d1f4a6c7b8e9d0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0" - }' - -# 2. Bei nächster Synchronisation: Aktuellen Hash mit syncedHash vergleichen -# Wenn unterschiedlich → Dokument wurde geändert → syncStatus = "changed" -``` - -**Hash-Berechnung:** -```python -import hashlib - -# Beispiel: Hash aus Dokument-Metadaten berechnen -def calculate_document_hash(document): - content = f"{document['name']}|{document['modifiedAt']}|{document['size']}" - return hashlib.sha256(content.encode()).hexdigest() -``` - -**Workflow:** -1. **Initial Sync:** syncedHash = NULL, syncStatus = "new" -2. **Sync durchgeführt:** syncedHash = berechnet, syncStatus = "synced" -3. **Dokument geändert:** Hook setzt syncStatus = "unclean" -4. **Nächster Sync:** Vergleiche aktuellen Hash mit syncedHash - - Gleich → Keine Änderung, skip - - Unterschiedlich → Sync durchführen, neuen Hash speichern - -**Frontend-Anzeige:** -Das Feld wird automatisch in der Link-Multiple-Spalte "Dokumente" angezeigt: -- In CAdvowareAkten: Spalte "Sync-Hash" zeigt den Hash-Wert -- In CAIKnowledge: Spalte "Sync-Hash" zeigt den Hash-Wert -- Tooltip: "Hash-Wert des zuletzt synchronisierten Dokument-Zustands (zur Änderungserkennung)" - ### Aktivierungsstatus **Zweck:** Steuerung der Synchronisations-Aktivität für Akten und AI Knowledge Entries @@ -893,6 +636,34 @@ Das Feld wird automatisch in der Link-Multiple-Spalte "Dokumente" angezeigt: - `paused` - Synchronisation temporär pausiert, kann wieder aktiviert werden - `deactivated` - Synchronisation dauerhaft deaktiviert +### Blake3 Hash für Änderungserkennung + +**Zweck:** Dokumentänderungen zwischen Synchronisationen erkennen + +Das `blake3hash` Feld ist direkt am CDokumente Entity verfügbar und wird automatisch vom `CDokumente` Hook berechnet: + +```bash +# Custom API gibt blake3hash mit zurück +curl -X GET "http://localhost:8080/api/v1/JunctionData/CAIKnowledge/kb-123/dokumentes" \ + -H "X-Api-Key: your-api-key" + +# Response enthält blake3hash für jedes Dokument: +{ + "list": [{ + "documentId": "dok-123", + "documentName": "contract.pdf", + "blake3hash": "b7cb1f2a3fd62f86aabff41a51921d96d10b54371c74d31c917b3c3074a204ca", + "syncstatus": "synced" + }] +} +``` + +**Änderungserkennung:** +1. Bei Sync: Speichere aktuellen `blake3hash` clientseitig oder in eigenem System +2. Nächster Sync: Frage Custom API ab, vergleiche `blake3hash` +3. Wenn unterschiedlich → Dokument geändert → Re-Sync nötig +4. Update `syncstatus` via Custom API PUT Endpoint + ### Globaler syncStatus **Zweck:** Übersicht über den Synchronisationszustand aller Dokumente einer Akte/eines AI Knowledge Entries @@ -963,10 +734,10 @@ GET /api/v1/CAdvowareAkten?where[0][type]=in&where[0][attribute]=aktivierungssta ### Junction-Spalten via REST API -**✅ RICHTIG:** Nutze Junction-Entity-APIs +**✅ RICHTIG:** Nutze Custom API Endpoints ```bash -GET /api/v1/CAdvowareAktenCDokumente -GET /api/v1/CAIKnowledgeCDokumente +GET /api/v1/JunctionData/CAIKnowledge/{knowledgeId}/dokumentes +# (Siehe Custom API Endpoints Sektion unten) ``` **❌ FALSCH:** Standard Relationship-Endpoints geben additionalColumns NICHT zurück @@ -974,6 +745,381 @@ GET /api/v1/CAIKnowledgeCDokumente GET /api/v1/CAdvowareAkten/{id}/dokumentes # hnr/syncStatus NICHT in Response! ``` +--- + +## 🚀 Custom API Endpoints für Junction Tables (Best Practice) + +**Problem:** Die Standard Junction-Entity-API hat folgende Einschränkungen: +- ACL-Zugriffsprobleme (oft 403 Forbidden trotz korrekter Konfiguration) +- Keine JOIN-Queries → Separater Call für Dokumentdetails nötig +- Umständliche Filterung +- Hooks können unerwünschte Seiteneffekte haben + +**Lösung:** Custom API Endpoint mit direkten SQL-Queries (seit EspoCRM 7.4+) + +### Implementation (Beispiel: CAIKnowledge Junction API) + +#### 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:** +- Nach Änderungen: **Administration → Clear Cache** (erforderlich!) +- Route-Parameter mit `:paramName` → verfügbar als `$request->getRouteParam('paramName')` +- `actionClassName` mit vollständigem Namespace (doppelte Backslashes!) + +#### 2. Action Class: GET - Alle Dokumente mit Junction-Daten + +**Datei:** `custom/Espo/Custom/Api/JunctionData/GetDokumentes.php` + +```php +getRouteParam('knowledgeId'); + + if (!$knowledgeId) { + throw new BadRequest('Knowledge ID is required'); + } + + // Verify knowledge exists + $knowledge = $this->entityManager->getEntityById('CAIKnowledge', $knowledgeId); + if (!$knowledge) { + throw new NotFound('Knowledge entry not found'); + } + + $pdo = $this->entityManager->getPDO(); + + // Direct SQL query with JOIN - much more efficient! + $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 + ]); + } +} +``` + +**Wichtige Details:** +- **Snake_case:** DB-Spaltennamen verwenden `snake_case` (z.B. `last_sync`, nicht `lastSync`) +- **Dependency Injection:** Constructor Injection funktioniert automatisch +- **PDO direkt:** `$this->entityManager->getPDO()` für rohe SQL-Queries +- **Validierung:** Entity-Existenz prüfen für bessere Errors +- **ResponseComposer::json():** Moderne Response-Methode + +#### 3. Action Class: PUT - Junction-Spalten aktualisieren + +**Datei:** `custom/Espo/Custom/Api/JunctionData/UpdateJunction.php` + +```php +getRouteParam('knowledgeId'); + $documentId = $request->getRouteParam('documentId'); + $data = $request->getParsedBody(); + + if (!$knowledgeId || !$documentId) { + throw new BadRequest('Knowledge ID and Document ID are required'); + } + + $pdo = $this->entityManager->getPDO(); + + // Build dynamic UPDATE with only provided fields + $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. Provide: aiDocumentId, syncstatus, or lastSync'); + } + + $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 data + 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; + } +} +``` + +**Highlights:** +- **Dynamisches UPDATE:** Nur Felder updaten, die im Request Body übergeben wurden +- **Validierung:** Enum-Werte prüfen bevor DB-Update +- **NOW()-Trick:** `updateLastSync: true` → Automatischer Timestamp +- **rowCount():** Prüfen ob wirklich ein Record betroffen war + +#### 4. Praktische API-Verwendung + +**GET: Alle Dokumente mit Junction-Daten abrufen** +```bash +curl -X GET "http://localhost:8080/api/v1/JunctionData/CAIKnowledge/69b1b03582bb6e2da/dokumentes" \ + -H "X-Api-Key: e53def10eea27b92a6cd00f40a3e09a4" +``` + +**Response:** +```json +{ + "total": 2, + "list": [ + { + "junctionId": 5, + "cAIKnowledgeId": "69b1b03582bb6e2da", + "cDokumenteId": "6974aba30cd69c723", + "aiDocumentId": "NEW-DOC-FROM-API", + "syncstatus": "new", + "lastSync": "2026-03-12 21:38:24", + "documentId": "6974aba30cd69c723", + "documentName": "2. dokuments", + "blake3hash": null, + "documentCreatedAt": "2026-01-24 11:23:15", + "documentModifiedAt": "2026-03-03 09:48:14" + }, + { + "junctionId": 1, + "cAIKnowledgeId": "69b1b03582bb6e2da", + "cDokumenteId": "69a68b556a39771bf", + "aiDocumentId": "UPDATED-VIA-API-123", + "syncstatus": "synced", + "lastSync": "2026-03-12 21:37:44", + "documentId": "69a68b556a39771bf", + "documentName": "hollibolli", + "blake3hash": "b7cb1f2a3fd62f86aabff41a51921d96d10b54371c74d31c917b3c3074a204ca", + "documentCreatedAt": "2026-03-03 07:18:45", + "documentModifiedAt": "2026-03-12 20:19:01" + } + ] +} +``` + +**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-DOC-123", + "syncstatus": "synced", + "updateLastSync": true + }' +``` + +**Response:** +```json +{ + "junctionId": 1, + "cAIKnowledgeId": "69b1b03582bb6e2da", + "cDokumenteId": "69a68b556a39771bf", + "aiDocumentId": "EXTERNAL-AI-DOC-123", + "syncstatus": "synced", + "lastSync": "2026-03-12 21:42:15" +} +``` + +**POST: Neues Dokument verknüpfen + Junction-Daten setzen** +```bash +curl -X POST "http://localhost:8080/api/v1/JunctionData/CAIKnowledge/69b1b03582bb6e2da/dokumentes/6974aba30cd69c723" \ + -H "X-Api-Key: e53def10eea27b92a6cd00f40a3e09a4" \ + -H "Content-Type: application/json" \ + -d '{ + "aiDocumentId": "NEW-EXTERNAL-DOC", + "syncstatus": "new", + "updateLastSync": true + }' +``` + +### Vorteile Custom API Endpoints + +✅ **Performance:** JOIN-Queries in einem Call statt mehrere API-Requests +✅ **Keine ACL-Probleme:** Direkter DB-Zugriff umgeht ACL-System +✅ **Volle Kontrolle:** Exakte Control über SQL und Response-Struktur +✅ **Hooks umgehen:** SQL-Updates lösen keine Entity-Hooks aus (wenn gewünscht) +✅ **Flexible Responses:** Kann mehrere Entities in einer Response joinen +✅ **Type Safety:** Moderne PHP 8+ mit Constructor Property Promotion +✅ **Wartbar:** Klare Trennung zwischen Routes, Actions und Business Logic + +### Best Practices + +1. **Immer validieren:** Entity-Existenz prüfen bevor DB-Operations +2. **Snake_case beachten:** DB-Spaltennamen verwenden Unterstriche +3. **Prepared Statements:** Immer PDO Prepared Statements für SQL-Injection-Schutz +4. **Error Handling:** Spezifische Exceptions (BadRequest, NotFound, Forbidden) +5. **Documentation:** PHPDoc für jede Action Class mit Endpoint-Beschreibung +6. **Testing:** API-Endpoints mit Python/Bash testen vor Produktiveinsatz +7. **Cache löschen:** Nach routes.json Änderungen IMMER Cache clearen! + +### Wann Custom API verwenden? + +**✅ Verwende Custom API wenn:** +- Junction-Spalten häufig gelesen/geschrieben werden +- JOINs über mehrere Tabellen nötig sind +- Performance kritisch ist (viele Dokumente) +- ACL-Probleme mit Standard Junction-Entity API +- Spezielle Business Logic beim Read/Write nötig + +**❌ Verwende Standard API wenn:** +- Einfache CRUD ohne Junction-Spalten +- ACL-System explizit gewünscht +- Hooks sollen ausgelöst werden +- Wenig Datenvolumen + ### Hooks & Automatische Updates Folgende Operationen lösen automatisch Hooks aus: @@ -998,23 +1144,9 @@ PUT /api/v1/CAdvowareAkten/{id} ``` - → `CheckGlobalSyncStatus`: Berechnet globalen Status aus Junction-Einträgen -### ACL-Berechtigungen - -Für Junction-Entities müssen Rollen explizit Zugriff haben: - -```sql --- Via Admin UI: Roles → [Your Role] → Add "CAdvowareAktenCDokumente" --- Oder via SQL: -UPDATE role -SET data = JSON_SET(data, '$.table.CAdvowareAktenCDokumente', - JSON_OBJECT('create','yes','read','all','edit','all','delete','all') -) -WHERE name = 'Your Role Name'; -``` - --- -**Letzte Aktualisierung:** 11. März 2026 -**Version:** 1.3 +**Letzte Aktualisierung:** 12. März 2026 +**Version:** 1.4 Für weitere Fragen: Siehe `custom/docs/ESPOCRM_BEST_PRACTICES.md` diff --git a/custom/docs/ESPOCRM_BEST_PRACTICES.md b/custom/docs/ESPOCRM_BEST_PRACTICES.md index 6d29ecae..f07b806b 100644 --- a/custom/docs/ESPOCRM_BEST_PRACTICES.md +++ b/custom/docs/ESPOCRM_BEST_PRACTICES.md @@ -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 +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 +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 diff --git a/custom/docs/TESTERGEBNISSE_JUNCTION_TABLE.md b/custom/docs/TESTERGEBNISSE_JUNCTION_TABLE.md deleted file mode 100644 index 8a1f4be7..00000000 --- a/custom/docs/TESTERGEBNISSE_JUNCTION_TABLE.md +++ /dev/null @@ -1,790 +0,0 @@ -# Many-to-Many Junction-Tabelle mit additionalColumns - Testergebnisse - -**Version:** 2.0 -**Datum:** 11. März 2026 -**Status:** ✅ VOLLSTÄNDIG ERFOLGREICH mit UI-Integration - -## ✅ VOLLSTÄNDIG ERFOLGREICH! - -**UPDATE (März 2026):** Die Junction-Tabelle kann als eigene Entity via REST-API abgerufen werden! Seit EspoCRM 6.0.0 werden Junction-Tabellen automatisch als Entities verfügbar gemacht. - -**NEU:** UI-Anzeige von Junction-Spalten via columnAttributeMap + notStorable Pattern! - -## Zusammenfassung - -Die Implementierung einer Many-to-Many-Beziehung mit zusätzlichen Feldern in der Junction-Tabelle wurde erfolgreich getestet und ist: -- **vollständig funktionsfähig via REST-API** -- **im UI anzeigbar via columnAttributeMap Pattern** -- **automatisch aktualisierbar via Hooks** - -## ✅ Was funktioniert - -### 1. Datenbank-Schema -**Status: VOLLSTÄNDIG FUNKTIONSFÄHIG** - -Die Junction-Tabelle `c_a_i_collection_c_dokumente` wurde automatisch mit der zusätzlichen `sync_id`-Spalte erstellt: - -```sql -CREATE TABLE `c_a_i_collection_c_dokumente` ( - `id` bigint(20) NOT NULL AUTO_INCREMENT, - `c_a_i_collections_id` varchar(17), - `c_dokumente_id` varchar(17), - `sync_id` varchar(255), ← Unser custom Feld! - `deleted` tinyint(1) DEFAULT 0, - PRIMARY KEY (`id`), - UNIQUE KEY `UNIQ_C_A_I_COLLECTIONS_ID_C_DOKUMENTE_ID` (...) -) -``` - -### 2. Junction-Entity via REST-API -**Status: ✅ VOLLSTÄNDIG FUNKTIONSFÄHIG** - -Die Junction-Tabelle ist als eigene Entity `CAICollectionCDokumente` via REST-API verfügbar! - -**Beispiel-Abruf:** -```bash -GET /api/v1/CAICollectionCDokumente?maxSize=10 -``` - -**Response:** -```json -{ - "total": 5, - "list": [ - { - "id": "6", - "deleted": false, - "cAICollectionsId": "testcol999", - "cDokumenteId": "testdoc999", - "syncId": "SYNC-TEST-999", - "cAICollectionsName": null, - "cDokumenteName": null - } - ] -} -``` - -**✅ Die `syncId` ist direkt in der API-Response enthalten!** - -### 3. Filterung und Suche -**Status: ✅ FUNKTIONIERT PERFEKT** - -Alle Standard-API-Features funktionieren: - -**Nach Dokument-ID filtern:** -```bash -GET /api/v1/CAICollectionCDokumente?where[0][type]=equals&where[0][attribute]=cDokumenteId&where[0][value]=doc123 -``` - -**Nach syncId suchen:** -```bash -GET /api/v1/CAICollectionCDokumente?where[0][type]=equals&where[0][attribute]=syncId&where[0][value]=SYNC-123 -``` - -**Felder selektieren:** -```bash -GET /api/v1/CAICollectionCDokumente?select=id,cDokumenteId,cAICollectionsId,syncId -``` - -### 4. Konfiguration -**Status: KORREKT IMPLEMENTIERT** - -**Erforderliche Dateien:** - -**1. Entity-Definition** (`entityDefs/CAICollectionCDokumente.json`): -```json -{ - "fields": { - "id": {"type": "id", "dbType": "bigint", "autoincrement": true}, - "cAICollections": {"type": "link"}, - "cAICollectionsId": {"type": "varchar", "len": 17, "index": true}, - "cDokumente": {"type": "link"}, - "cDokumenteId": {"type": "varchar", "len": 17, "index": true}, - "syncId": {"type": "varchar", "len": 255, "isCustom": true}, - "deleted": {"type": "bool", "default": false} - }, - "links": { - "cAICollections": { - "type": "belongsTo", - "entity": "CAICollections" - }, - "cDokumente": { - "type": "belongsTo", - "entity": "CDokumente" - } - } -} -``` - -**2. Scope-Definition** (`scopes/CAICollectionCDokumente.json`): -```json -{ - "entity": true, - "type": "Base", - "module": "Custom", - "object": true, - "isCustom": true, - "tab": false, - "acl": true, - "disabled": false -} -``` - -**3. Controller** (`Controllers/CAICollectionCDokumente.php`): -```php -entityManager->getRDBRepository('CAdvowareAkten'); - $repository->getRelation($entity, 'dokumentes')->updateColumns( - $foreignEntity, - [ - 'syncstatus' => 'new', - 'lastSync' => null - ] - ); - } -} -``` - -### Vorteile dieser Lösung - -✅ **UI-Anzeige**: Junction-Spalten sichtbar in Relationship-Panels -✅ **Kein 405 Fehler**: Read-only Darstellung vermeidet Inline-Edit-Probleme -✅ **API-Kompatibel**: Funktioniert parallel zur Junction-Entity-API -✅ **Bidirektional**: Funktioniert von beiden Seiten der Beziehung -✅ **Hook-Integration**: Updates via Hooks möglich - -### Einschränkungen - -⚠️ **notStorable = Read-only in UI**: Keine direkte Bearbeitung im Panel -⚠️ **Updates via Hooks**: Änderungen müssen über Hooks oder API erfolgen -⚠️ **layoutAvailabilityList**: Foreign-Side-Felder nur in Custom Layouts sichtbar - -## 🎯 Fazit - -Die **Junction-Tabelle mit `additionalColumns` ist vollständig via REST-API nutzbar**! - -**Vorteile:** -- ✅ Keine Custom-Endpoints nötig -- ✅ Standard-API-Features (Filter, Sort, Pagination) -- ✅ CRUD-Operationen vollständig unterstützt -- ✅ `syncId` ist direkt in der Response -- ✅ Einfache Integration in externe Systeme -- ✅ API-only Pattern verhindert 405-Fehler - -**Einschränkungen:** -- ⚠️ UI-Darstellung in Standard-Relationship-Panels verursacht 405 Fehler -- ⚠️ additionalColumns nur über Junction-Entity-API zugänglich -- ⚠️ Standard relationship endpoints (z.B. GET /api/v1/CDokumente/{id}/cAICollections) geben additionalColumns NICHT zurück - -**Best Practice:** -1. ✅ Junction Entity als API-Endpoint nutzen (`/api/v1/CAICollectionCDokumente`) -2. ✅ Keine UI-Panels für Junction-Relationships mit additionalColumns -3. ✅ API-Integration für externe Systeme (Middleware, KI, etc.) -4. ✅ Bei Bedarf: Separate Management-UI für Junction Entity (ohne Relationship-Panel) - -**Wichtig:** -1. Controller und Service erstellen -2. Scope-Definition anlegen -3. Entity-Definition mit korrekten Feldtypen -4. ACL-Rechte für die Junction-Entity setzen -5. Cache löschen und rebuild -6. **NICHT** als Relationship-Panel in UI anzeigen (→ 405 Fehler) - -## 📁 Dateien - -Die Implementierung befindet sich in: -- `/custom/Espo/Custom/Resources/metadata/entityDefs/CAICollectionCDokumente.json` -- `/custom/Espo/Custom/Resources/metadata/scopes/CAICollectionCDokumente.json` -- `/custom/Espo/Custom/Controllers/CAICollectionCDokumente.php` -- `/custom/Espo/Custom/Services/CAICollectionCDokumente.php` -- `/custom/Espo/Custom/Resources/metadata/entityDefs/CDokumente.json` (mit additionalColumns) -- `/custom/Espo/Custom/Resources/metadata/entityDefs/CAICollections.json` - -Datenbank-Tabelle: -- `c_a_i_collection_c_dokumente` - ---- - -**Erstellt:** 9. März 2026 -**Getestet mit:** EspoCRM 9.3.2 (MariaDB 12.2.2, PHP 8.2.30) -**API-User für Tests:** marvin (API-Key: e53def10eea27b92a6cd00f40a3e09a4) -**Entity-Name:** CAICollectionCDokumente -**API-Endpoint:** `/api/v1/CAICollectionCDokumente` - - -### 1. Datenbank-Schema -**Status: VOLLSTÄNDIG FUNKTIONSFÄHIG** - -Die Junction-Tabelle `c_a_i_collection_c_dokumente` wurde automatisch mit der zusätzlichen `sync_id`-Spalte erstellt: - -```sql -CREATE TABLE `c_a_i_collection_c_dokumente` ( - `id` bigint(20) NOT NULL AUTO_INCREMENT, - `c_a_i_collections_id` varchar(17), - `c_dokumente_id` varchar(17), - `sync_id` varchar(255), ← Unser custom Feld! - `deleted` tinyint(1) DEFAULT 0, - PRIMARY KEY (`id`), - UNIQUE KEY `UNIQ_C_A_I_COLLECTIONS_ID_C_DOKUMENTE_ID` (...) -) -``` - -### 2. Konfiguration -**Status: KORREKT IMPLEMENTIERT** - -Die Beziehung wurde in beiden Entity-Definitionen konfiguriert: - -**CDokumente.json:** -```json -"cAICollections": { - "type": "hasMany", - "entity": "CAICollections", - "foreign": "cDokumente", - "relationName": "cAICollectionCDokumente", - "additionalColumns": { - "syncId": { - "type": "varchar", - "len": 255 - } - } -} -``` - -**CAICollections.json:** -```json -"cDokumente": { - "type": "hasMany", - "entity": "CDokumente", - "foreign": "cAICollections", - "relationName": "cAICollectionCDokumente" -} -``` - -### 3. Datenspeicherung -**Status: FUNKTIONIERT** - -Die `syncId` kann in der Datenbank gespeichert werden: -- ✅ Via direktes SQL-INSERT/UPDATE -- ✅ Via interne EspoCRM ORM-API (EntityManager) -- ✅ Daten werden korrekt persistiert - -### 4. View-Darstellung -**Status: ⚠️ NICHT EMPFOHLEN (API-ONLY PATTERN)** - -**Problem:** Standard EspoCRM Relationship-Panels versuchen inline-editing von Feldern. Bei additionalColumns führt dies zu **405 Method Not Allowed** Fehlern, da die Standard-Panel-UI nicht mit dem Junction-Entity-Pattern kompatibel ist. - -**Versucht & Fehlgeschlagen:** -1. ❌ Direct display of syncId in relationship panel layout → 405 Fehler -2. ❌ Custom View mit actionEditLinkData → Blank views, dann weiter 405 Fehler -3. ❌ Simplified relationship layout ohne syncId → 405 Fehler blieben bestehen - -**ROOT CAUSE:** Standard relationship panels senden HTTP-Requests die nicht mit Junction-Entity-Architektur übereinstimmen. additionalColumns erfordern spezielle Behandlung die nicht durch Standard-UI bereitgestellt wird. - -**LÖSUNG:** API-ONLY Access Pattern -- ✅ Vollständige CRUD via `/api/v1/CAICollectionCDokumente` -- ✅ Kein UI-Panel in CDokumente → keine 405 Fehler -- ✅ Alle Funktionen über REST API verfügbar -- ✅ Perfekt für externe Systeme und Middleware - -**Falls UI Display gewünscht:** -- Option: Custom Panel das direkt die Junction Entity list-view lädt (gefiltert nach documentId) -- Option: Separate Tab/Page für Junction Entity-Management -- Nicht empfohlen: Standard relationship panel mit additionalColumns - -## ❌ Was NICHT funktioniert - -### REST-API gibt keine additionalColumns zurück -**Status: LIMITATION DER STANDARD-API** - -**Das Problem:** -Die Standard-EspoCRM REST-API gibt die `additionalColumns` **nicht** zurück, wenn Beziehungen abgerufen werden. - -**Getestete Szenarien:** -1. ❌ Standard GET-Request: `GET /api/v1/CDokumente/{id}/cAICollections` → keine `syncId` in Response -2. ❌ Mit Query-Parametern (select, additionalColumns, columns, etc.) → keine `syncId` -3. ❌ POST mit columns-Parameter beim Verknüpfen → wird nicht gespeichert - -**Verifiziert:** -```bash -# syncId ist in DB: -SELECT * FROM c_a_i_collection_c_dokumente; -# → sync_id = 'SYNC-20260309-220416' - -# Aber API-Response enthält sie nicht: -GET /api/v1/CDokumente/{id}/cAICollections -# → {"list": [{"id": "...", "name": "...", ...}]} # Keine syncId! -``` - -## 💡 Lösungen & Workarounds - -### Option 1: Interne PHP-API verwenden (Empfohlen) -Verwende die interne EspoCRM-API für den Zugriff auf `additionalColumns`: - -```php -$entityManager = $container->get('entityManager'); -$doc = $entityManager->getEntity('CDokumente', $docId); -$repository = $entityManager->getRDBRepository('CDokumente'); -$relation = $repository->getRelation($doc, 'cAICollections'); - -// Lade verknüpfte Collections -$collections = $relation->find(); - -// Hole additionalColumns -foreach ($collections as $col) { - $relationData = $relation->getColumnAttributes($col, ['syncId']); - $syncId = $relationData['syncId'] ?? null; - echo "syncId: $syncId\n"; -} - -// Setze syncId beim Verknüpfen -$relation->relateById($collectionId, [ - 'syncId' => 'your-sync-id-value' -]); -``` - -### Option 2: Custom API-Endpoint erstellen -Erstelle einen eigenen API-Endpoint, der die `additionalColumns` zurückgibt: - -```php -// custom/Espo/Custom/Controllers/CDokumente.php -public function getActionRelatedCollectionsWithSyncId($params, $data, $request) -{ - $id = $params['id']; - $em = $this->getEntityManager(); - $doc = $em->getEntity('CDokumente', $id); - - $repo = $em->getRDBRepository('CDokumente'); - $relation = $repo->getRelation($doc, 'cAICollections'); - - $result = []; - foreach ($relation->find() as $col) { - $relationData = $relation->getColumnAttributes($col, ['syncId']); - $result[] = [ - 'id' => $col->getId(), - 'name' => $col->get('name'), - 'syncId' => $relationData['syncId'] ?? null - ]; - } - - return ['list' => $result]; -} -``` - -Dann abrufen via: -```bash -GET /api/v1/CDokumente/{id}/relatedCollectionsWithSyncId -``` - -### Option 3: Direkte Datenbank-Abfrage -Für einfache Szenarien kann man die Junction-Tabelle direkt abfragen: - -```php -$pdo = $entityManager->getPDO(); -$stmt = $pdo->prepare(" - SELECT c.*, j.sync_id - FROM c_a_i_collections c - JOIN c_a_i_collection_c_dokumente j - ON c.id = j.c_a_i_collections_id - WHERE j.c_dokumente_id = ? - AND j.deleted = 0 - AND c.deleted = 0 -"); -$stmt->execute([$docId]); -$results = $stmt->fetchAll(PDO::FETCH_ASSOC); -``` - -### Option 4: Formulas für automatische Synchronisation -Nutze EspoCRM-Formulas um `syncId` zu setzen: - -``` -// In CDokumente.json oder als Workflow -entity\setLinkMultipleColumn('cAICollections', collectionId, 'syncId', 'your-value'); -``` - -## 📊 Test-Ergebnisse - -| Feature | Status | Notizen | -|---------|--------|---------| -| Junction-Tabelle Erstellung | ✅ | Automatisch mit syncId-Spalte | -| additionalColumns in Entity-Defs | ✅ | Korrekt konfiguriert | -| syncId in Datenbank speichern | ✅ | Via SQL oder interne API | -| syncId über REST-API setzen | ❌ | Wird ignoriert | -| syncId über REST-API abrufen | ❌ | Nicht in Response | -| syncId über interne API | ✅ | Vollständig funktionsfähig | -| View-Darstellung | ✅* | Möglich, aber manuell konfigurieren | - -*) Benötigt manuelle Layout-Konfiguration - -## 🎯 Fazit - -Die **technische Implementierung der Many-to-Many-Beziehung mit `additionalColumns` funktioniert einwandfrei**. Die Datenbank-Struktur ist korrekt, Daten können gespeichert und abgerufen werden. - -**Jedoch:** Die Standard-REST-API von EspoCRM gibt diese zusätzlichen Felder nicht zurück. Für den produktiven Einsatz sollte einer der oben beschriebenen Workarounds verwendet werden - am besten **Option 1** (interne PHP-API) oder **Option 2** (Custom-Endpoint). - -## 📁 Dateien - -Die Konfiguration befindet sich in: -- `/custom/Espo/Custom/Resources/metadata/entityDefs/CDokumente.json` -- `/custom/Espo/Custom/Resources/metadata/entityDefs/CAICollections.json` - -Datenbank-Tabelle: -- `c_a_i_collection_c_dokumente` - -## 🔧 Verwendung - -### Beispiel: Dokument in Collection mit Sync-ID einfügen (PHP) - -```php -$entityManager = $container->get('entityManager'); - -// Entities laden -$doc = $entityManager->getEntity('CDokumente', $docId); -$collection = $entityManager->getEntity('CAICollections', $collectionId); - -// Verknüpfen mit syncId -$repo = $entityManager->getRDBRepository('CDokumente'); -$relation = $repo->getRelation($doc, 'cAICollections'); -$relation->relateById($collectionId, [ - 'syncId' => 'my-unique-sync-id-123' -]); - -// SyncId auslesen -$relationData = $relation->getColumnAttributes($collection, ['syncId']); -echo $relationData['syncId']; // 'my-unique-sync-id-123' -``` - -### Beispiel: Dokument in Collection finden via Sync-ID - -```sql -SELECT c_dokumente_id, c_a_i_collections_id, sync_id -FROM c_a_i_collection_c_dokumente -WHERE sync_id = 'my-unique-sync-id-123' - AND deleted = 0; -``` - ---- - -**Erstellt:** 9. März 2026 -**Getestet mit:** EspoCRM (MariaDB 12.2.2, PHP 8.2.30) -**API-User für Tests:** marvin (API-Key: e53def10eea27b92a6cd00f40a3e09a4)