Implement CAIKnowledge and CDokumente junction functionality; add API routes, metadata, and localization files

This commit is contained in:
2026-03-12 22:39:33 +01:00
parent d0397e475e
commit 3ecc6275bc
13 changed files with 480 additions and 103 deletions

View File

@@ -0,0 +1,71 @@
<?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
*
* Returns all documents linked to a knowledge entry with junction table data
*/
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');
}
// Verify knowledge exists
$knowledge = $this->entityManager->getEntityById('CAIKnowledge', $knowledgeId);
if (!$knowledge) {
throw new NotFound('Knowledge entry not found');
}
$pdo = $this->entityManager->getPDO();
$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
]);
}
}

View File

@@ -0,0 +1,178 @@
<?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\Core\Exceptions\Conflict;
use Espo\ORM\EntityManager;
/**
* POST /api/v1/JunctionData/CAIKnowledge/:knowledgeId/dokumentes/:documentId
*
* Creates or updates relationship with junction table data
* This endpoint links the entities AND sets junction columns in one call
*/
class LinkDokument 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 are required');
}
// Verify entities exist
$knowledge = $this->entityManager->getEntityById('CAIKnowledge', $knowledgeId);
if (!$knowledge) {
throw new NotFound('Knowledge entry not found');
}
$document = $this->entityManager->getEntityById('CDokumente', $documentId);
if (!$document) {
throw new NotFound('Document not found');
}
$pdo = $this->entityManager->getPDO();
// Check if link already exists
$existing = $this->checkIfLinked($knowledgeId, $documentId);
if ($existing) {
// Link exists - update junction columns
return $this->updateExisting($knowledgeId, $documentId, $data);
}
// Create new link via ORM (triggers hooks like DokumenteSyncStatus)
$this->entityManager->getRDBRepository('CAIKnowledge')
->getRelation($knowledge, 'dokumentes')
->relate($document);
// Now set junction columns if provided
if (!empty((array)$data)) {
return $this->updateExisting($knowledgeId, $documentId, $data);
}
// Return created entry
$result = $this->getJunctionEntry($knowledgeId, $documentId);
return ResponseComposer::json($result);
}
private function checkIfLinked(string $knowledgeId, string $documentId): bool
{
$pdo = $this->entityManager->getPDO();
$sql = "
SELECT COUNT(*) as count
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);
return $result['count'] > 0;
}
private function updateExisting(string $knowledgeId, string $documentId, \stdClass $data): Response
{
$pdo = $this->entityManager->getPDO();
// Build dynamic UPDATE SET clause
$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 value. 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)) {
$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);
}
// Return updated data
$result = $this->getJunctionEntry($knowledgeId, $documentId);
return ResponseComposer::json($result);
}
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;
}
}

View File

@@ -0,0 +1,123 @@
<?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 for an existing relationship
*/
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 are required');
}
$pdo = $this->entityManager->getPDO();
// Build dynamic UPDATE SET clause
$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 value. 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 at least one of: aiDocumentId, syncstatus, 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);
$affectedRows = $sth->rowCount();
if ($affectedRows === 0) {
throw new NotFound('Junction entry not found or no changes made');
}
// Return updated data
$result = $this->getJunctionEntry($knowledgeId, $documentId);
return ResponseComposer::json($result);
}
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;
}
}

View File

@@ -1,31 +1,30 @@
{ {
"labels": {
"Create CAIKnowledgeCDokumente": "AI Knowledge-Dokument Verknüpfung erstellen",
"CAIKnowledgeCDokumente": "AI Knowledge-Dokument Verknüpfung"
},
"fields": { "fields": {
"cAIKnowledge": "AI Knowledge", "cAIKnowledge": "AI Knowledge",
"cAIKnowledgeId": "AI Knowledge ID",
"cDokumente": "Dokument", "cDokumente": "Dokument",
"cDokumenteId": "Dokument ID",
"aiDocumentId": "AI Dokument-ID", "aiDocumentId": "AI Dokument-ID",
"syncstatus": "Sync-Status", "syncstatus": "Sync-Status",
"lastSync": "Letzte Synchronisation", "lastSync": "Letzte Synchronisation",
"syncedHash": "Sync-Hash" "syncedHash": "Sync-Hash"
}, },
"links": {
"cAIKnowledge": "AI Knowledge",
"cDokumente": "Dokument"
},
"labels": {
"Create CAIKnowledgeCDokumente": "Verknüpfung erstellen"
},
"options": { "options": {
"syncstatus": { "syncstatus": {
"new": "Neu", "new": "Neu",
"unclean": "Unklar", "unclean": "Geändert",
"synced": "Synchronisiert", "synced": "Synchronisiert",
"failed": "Fehlgeschlagen", "failed": "Fehler",
"unsupported": "Nicht unterstützt" "unsupported": "Nicht unterstützt"
} }
}, },
"tooltips": { "tooltips": {
"aiDocumentId": "Externe AI-Dokument-Referenz-ID", "aiDocumentId": "Externe AI-Dokument-Referenz-ID",
"syncstatus": "Status der Synchronisation mit externem AI-System", "syncedHash": "Hash-Wert des zuletzt synchronisierten Dokument-Zustands"
"lastSync": "Zeitpunkt der letzten erfolgreichen Synchronisation",
"syncedHash": "Hash-Wert des zuletzt synchronisierten Dokument-Zustands (zur Änderungserkennung)"
} }
} }

View File

@@ -1,31 +0,0 @@
{
"labels": {
"Create CAIKnowledgeCDokumente": "Create AI Knowledge-Document Link",
"CAIKnowledgeCDokumente": "AI Knowledge-Document Link"
},
"fields": {
"cAIKnowledge": "AI Knowledge",
"cAIKnowledgeId": "AI Knowledge ID",
"cDokumente": "Document",
"cDokumenteId": "Document ID",
"aiDocumentId": "AI Document ID",
"syncstatus": "Sync Status",
"lastSync": "Last Synchronization",
"syncedHash": "Sync Hash"
},
"options": {
"syncstatus": {
"new": "New",
"unclean": "Unclean",
"synced": "Synced",
"failed": "Failed",
"unsupported": "Unsupported"
}
},
"tooltips": {
"aiDocumentId": "External AI document reference ID",
"syncstatus": "Synchronization status with external AI system",
"lastSync": "Timestamp of the last successful synchronization",
"syncedHash": "Hash value of the last synchronized document state (for change detection)"
}
}

View File

@@ -0,0 +1,30 @@
{
"fields": {
"cAIKnowledge": "AI Knowledge",
"cDokumente": "Document",
"aiDocumentId": "AI Document ID",
"syncstatus": "Sync Status",
"lastSync": "Last Sync",
"syncedHash": "Synced Hash"
},
"links": {
"cAIKnowledge": "AI Knowledge",
"cDokumente": "Document"
},
"labels": {
"Create CAIKnowledgeCDokumente": "Create Link"
},
"options": {
"syncstatus": {
"new": "New",
"unclean": "Changed",
"synced": "Synced",
"failed": "Failed",
"unsupported": "Unsupported"
}
},
"tooltips": {
"aiDocumentId": "External AI document reference ID",
"syncedHash": "Hash value of last synced document state"
}
}

View File

@@ -9,6 +9,21 @@
"route": "/CPuls/:id/abschliessen-fuer-team", "route": "/CPuls/:id/abschliessen-fuer-team",
"method": "post", "method": "post",
"actionClassName": "Espo\\Custom\\Api\\CPuls\\AbschliessenFuerTeam" "actionClassName": "Espo\\Custom\\Api\\CPuls\\AbschliessenFuerTeam"
},
{
"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"
} }
] ]
} }

View File

@@ -1,4 +1,5 @@
{ {
"table": "c_a_i_knowledge_dokumente",
"fields": { "fields": {
"id": { "id": {
"type": "id", "type": "id",
@@ -6,58 +7,58 @@
"autoincrement": true "autoincrement": true
}, },
"cAIKnowledge": { "cAIKnowledge": {
"type": "link" "type": "link",
"entity": "CAIKnowledge"
}, },
"cAIKnowledgeId": { "cAIKnowledgeId": {
"type": "varchar", "type": "varchar",
"len": 17, "len": 17,
"index": true "index": true
}, },
"cAIKnowledgeName": {
"type": "varchar",
"notStorable": true,
"relation": "cAIKnowledge",
"foreign": "name"
},
"cDokumente": { "cDokumente": {
"type": "link" "type": "link",
"entity": "CDokumente"
}, },
"cDokumenteId": { "cDokumenteId": {
"type": "varchar", "type": "varchar",
"len": 17, "len": 17,
"index": true "index": true
}, },
"cDokumenteName": {
"type": "varchar",
"notStorable": true,
"relation": "cDokumente",
"foreign": "name"
},
"aiDocumentId": { "aiDocumentId": {
"type": "varchar", "type": "varchar",
"len": 255, "len": 255,
"isCustom": true,
"tooltip": true "tooltip": true
}, },
"syncstatus": { "syncstatus": {
"type": "enum", "type": "enum",
"required": false, "options": ["new", "unclean", "synced", "failed", "unsupported"],
"options": [ "default": "new",
"new",
"unclean",
"synced",
"failed",
"unsupported"
],
"style": { "style": {
"new": "info", "new": "primary",
"unclean": "warning", "unclean": "warning",
"synced": "success", "synced": "success",
"failed": "danger", "failed": "danger",
"unsupported": "default" "unsupported": "default"
}, }
"default": "new",
"isCustom": true,
"tooltip": true
}, },
"lastSync": { "lastSync": {
"type": "datetime", "type": "datetime"
"required": false,
"isCustom": true,
"tooltip": true
}, },
"syncedHash": { "syncedHash": {
"type": "varchar", "type": "varchar",
"len": 64, "len": 64,
"isCustom": true,
"tooltip": true "tooltip": true
}, },
"deleted": { "deleted": {
@@ -68,30 +69,17 @@
"links": { "links": {
"cAIKnowledge": { "cAIKnowledge": {
"type": "belongsTo", "type": "belongsTo",
"entity": "CAIKnowledge" "entity": "CAIKnowledge",
"foreign": "dokumentes"
}, },
"cDokumente": { "cDokumente": {
"type": "belongsTo", "type": "belongsTo",
"entity": "CDokumente" "entity": "CDokumente",
"foreign": "aIKnowledges"
} }
}, },
"collection": { "collection": {
"orderBy": "id", "orderBy": "id",
"order": "desc" "order": "asc"
},
"indexes": {
"cAIKnowledgeId": {
"columns": ["cAIKnowledgeId"]
},
"cDokumenteId": {
"columns": ["cDokumenteId"]
},
"syncstatus": {
"columns": ["syncstatus"]
},
"uniqueRelation": {
"type": "unique",
"columns": ["cAIKnowledgeId", "cDokumenteId", "deleted"]
}
} }
} }

View File

@@ -1,10 +1,11 @@
{ {
"entity": true, "entity": true,
"type": "Base", "object": false,
"module": "Custom", "layouts": false,
"object": true,
"isCustom": true,
"tab": false, "tab": false,
"acl": true, "acl": true,
"disabled": false "customizable": false,
"type": "Base",
"module": "Custom",
"isCustom": true
} }

View File

@@ -0,0 +1,17 @@
[
{
"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"
}
]

View File

@@ -1,14 +0,0 @@
<?php
namespace Espo\Custom\Services;
use Espo\Services\Record;
/**
* Junction Service: CAIKnowledge ↔ CDokumente
*
* Handles business logic for the junction table.
*/
class CAIKnowledgeCDokumente extends Record
{
// Standard CRUD logic inherited from Record service
}

View File

@@ -360,7 +360,7 @@ return [
0 => 'youtube.com', 0 => 'youtube.com',
1 => 'google.com' 1 => 'google.com'
], ],
'microtime' => 1773348571.271434, 'microtime' => 1773351315.93688,
'siteUrl' => 'https://crm.bitbylaw.com', 'siteUrl' => 'https://crm.bitbylaw.com',
'fullTextSearchMinLength' => 4, 'fullTextSearchMinLength' => 4,
'webSocketUrl' => 'ws://api.bitbylaw.com:5000/espocrm/ws', 'webSocketUrl' => 'ws://api.bitbylaw.com:5000/espocrm/ws',

View File

@@ -1,7 +1,7 @@
<?php <?php
return [ return [
'cacheTimestamp' => 1773348571, 'cacheTimestamp' => 1773351316,
'microtimeState' => 1773348571.391315, 'microtimeState' => 1773351316.064287,
'currencyRates' => [ 'currencyRates' => [
'EUR' => 1.0 'EUR' => 1.0
], ],