Compare commits

..

1 Commits

Author SHA1 Message Date
chaim 0f9d06739e fix(2.10.3): expose AssistantRule + CaseMemory in Admin Layout Manager
Two metadata gaps spotted when the user couldn't see Shira entities in
Admin → Layout Manager and got console 404s on /#CaseMemory:

* AssistantRule.json (scopes/) was missing `customizable: true` and
  `importable: false`. Without `customizable: true` EspoCRM hides the
  entity from Admin → Entity Manager → Layouts. Other Shira entities
  already had it; this one was the outlier.

* CaseMemory.json (clientDefs/) didn't exist at all. The Metadata
  endpoint returned an empty object, so the frontend fell through to
  default convention `client/custom/modules/smart-assistant/src/controllers/case-memory.js`
  which doesn't exist → 404 in browser console. Adding clientDefs with
  `controller: controllers/record` + icon + filters lets the standard
  record controller handle the entity without any custom JS file.

Verified on prod after hot-patch:
- Metadata?key=scopes.AssistantRule.customizable → "true"
- Metadata?key=clientDefs.CaseMemory → full object

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 09:52:13 +00:00
6 changed files with 64 additions and 208 deletions
File diff suppressed because one or more lines are too long
@@ -1,10 +0,0 @@
{
"controller": "controllers/record",
"color": "#5e8c61",
"iconClass": "fas fa-list-check",
"boolFilterList": ["onlyMy"],
"filterList": [
{"name": "active"},
{"name": "inactive"}
]
}
@@ -11,7 +11,6 @@ use Espo\Core\Utils\Log;
use Espo\ORM\EntityManager;
use Espo\Entities\User;
use Espo\Modules\NetworkStorageIntegration\Services\NetworkDocumentService;
use Espo\Modules\NetworkStorageIntegration\Classes\LocalFilesystemClient;
/**
* Wraps NetworkDocumentService and constrains every operation to the case's
@@ -35,32 +34,14 @@ class CaseFolderManager
*/
public function writeFile(string $caseId, string $relPath, string $content): array
{
$absInsideStorage = $this->resolveSafePath($caseId, $relPath, requireExists: false);
$client = $this->getNds()->getClient();
// Split the (LLM-provided) relPath into folder + filename. Subfolders
// are allowed, but the final filename is sanitized and de-duplicated by
// the canonical namer (rule N1) — never trust the model to name files.
$normalized = $this->normalize($relPath);
if ($normalized === '') {
throw new BadRequest('Path is required.');
$parent = $this->dirnameRel($absInsideStorage);
if ($parent !== '') {
$client->createFolderRecursive($parent);
}
$slash = strrpos($normalized, '/');
$dirRel = $slash === false ? '' : substr($normalized, 0, $slash);
$baseName = $slash === false ? $normalized : substr($normalized, $slash + 1);
$ext = pathinfo($baseName, PATHINFO_EXTENSION) ?: 'bin';
$subject = pathinfo($baseName, PATHINFO_FILENAME);
$parentAbs = $dirRel === ''
? $this->getCaseRoot($caseId)
: $this->resolveSafePath($caseId, $this->sanitizeRelSegments($dirRel), requireExists: false);
$client->createFolderRecursive($parentAbs);
$storedName = LocalFilesystemClient::buildStoredFileName($client, $parentAbs, $subject, $ext);
$absInsideStorage = $parentAbs . '/' . $storedName;
$ok = $client->uploadFile($absInsideStorage, $content);
if (!$ok) {
throw new Error("Failed to write file: {$absInsideStorage}");
@@ -80,12 +61,7 @@ class CaseFolderManager
*/
public function createFolder(string $caseId, string $relPath): array
{
$cleanRel = $this->sanitizeRelSegments($this->normalize($relPath));
if ($cleanRel === '') {
throw new BadRequest('A valid folder name is required.');
}
$abs = $this->resolveSafePath($caseId, $cleanRel, requireExists: false);
$abs = $this->resolveSafePath($caseId, $relPath, requireExists: false);
$client = $this->getNds()->getClient();
$alreadyExisted = $client->exists($abs);
@@ -115,12 +91,6 @@ class CaseFolderManager
throw new BadRequest('newName must be a plain file/folder name without slashes.');
}
// Sanitize the model-chosen name (rule N1).
$newName = LocalFilesystemClient::sanitizeFileName($newName);
if ($newName === '') {
throw new BadRequest('newName is empty after sanitization.');
}
$absCurrent = $this->resolveSafePath($caseId, $currentRelPath, requireExists: true);
$parent = $this->dirnameRel($absCurrent);
@@ -154,11 +124,7 @@ class CaseFolderManager
public function moveItem(string $caseId, string $sourceRelPath, string $targetRelPath): array
{
$absSource = $this->resolveSafePath($caseId, $sourceRelPath, requireExists: true);
$absTarget = $this->resolveSafePath(
$caseId,
$this->sanitizeRelSegments($this->normalize($targetRelPath)),
requireExists: false
);
$absTarget = $this->resolveSafePath($caseId, $targetRelPath, requireExists: false);
$client = $this->getNds()->getClient();
@@ -331,26 +297,6 @@ class CaseFolderManager
return implode('/', $out);
}
/**
* Sanitize every segment of an already-normalized storage-relative path
* with the canonical file-name rules (rule N1). Empty segments are dropped.
*/
private function sanitizeRelSegments(string $normalizedPath): string
{
if ($normalizedPath === '') {
return '';
}
$segments = array_map(
static fn (string $seg): string => LocalFilesystemClient::sanitizeFileName($seg),
explode('/', $normalizedPath)
);
$segments = array_filter($segments, static fn (string $seg): bool => $seg !== '');
return implode('/', $segments);
}
private function dirnameRel(string $path): string
{
$pos = strrpos($path, '/');
@@ -9,7 +9,6 @@ use Espo\Core\Utils\Log;
use Espo\ORM\EntityManager;
use Espo\Entities\User;
use Espo\Modules\NetworkStorageIntegration\Services\NetworkDocumentService;
use Espo\Modules\NetworkStorageIntegration\Classes\LocalFilesystemClient;
use PhpOffice\PhpWord\PhpWord;
use PhpOffice\PhpWord\IOFactory;
@@ -51,16 +50,9 @@ class FreeDocumentGenerator
$docxBinary = $this->buildDocx($title, $body, $recipient);
// Subject-only naming (rule N1): no date prefix. The canonical stored
// name (+ "-{NNN}" on collision) is produced by NetworkDocumentService
// on upload; we then align the Espo Document/Attachment to it so the
// CRM, the attachment and the file on disk never disagree.
$baseName = LocalFilesystemClient::sanitizeFileName($title);
if ($baseName === '') {
$baseName = 'document';
}
$fileName = $baseName . '.docx';
$documentName = $title;
$sanitizedTitle = $this->sanitizeFileName($title);
$today = date('Y-m-d');
$fileName = "{$today} - {$sanitizedTitle}.docx";
$attachment = $this->entityManager->getNewEntity('Attachment');
$attachment->set([
@@ -75,15 +67,12 @@ class FreeDocumentGenerator
$document = $this->entityManager->getNewEntity('Document');
$document->set([
'name' => $documentName,
'name' => $title,
'fileId' => $attachment->getId(),
'fileName' => $fileName,
'assignedUserId' => $this->user->getId(),
]);
// Suppress the NSI upload hook: it fires on this save, before the Case
// link below exists, so it would file the document in the fallback
// folder. We upload explicitly (with the Case) right after.
$this->entityManager->saveEntity($document, ['skipNetworkUpload' => true]);
$this->entityManager->saveEntity($document);
$this->entityManager->getRDBRepository('Case')
->getRelation($case, 'documents')
@@ -94,39 +83,7 @@ class FreeDocumentGenerator
$nds = $this->injectableFactory->create(NetworkDocumentService::class);
if ($nds->isEnabled()) {
$result = $nds->uploadDocument($document, $attachment, 'Case', $caseId);
if (!empty($result['success'])) {
$networkPath = $result['path'] ?? null;
$storedName = $result['fileName'] ?? $fileName;
$storedBase = pathinfo($storedName, PATHINFO_FILENAME) ?: $documentName;
// Align DB names to the canonical stored name.
$document->set([
'name' => $storedBase,
'fileName' => $storedName,
'storageType' => 'network',
'networkStoragePath' => $networkPath,
'networkStorageFileName' => $storedName,
'networkStorageSize' => $result['size'] ?? strlen($docxBinary),
'networkStorageModifiedAt' => date('Y-m-d H:i:s'),
]);
$this->entityManager->saveEntity($document, [
'skipNetworkUpload' => true,
'silent' => true,
]);
$attachment->set('name', $storedName);
$this->entityManager->saveEntity($attachment, ['silent' => true]);
// The file now lives in network storage; drop the local copy.
$sourcePath = 'data/upload/' . $attachment->getSourceId();
if (file_exists($sourcePath)) {
@unlink($sourcePath);
}
$fileName = $storedName;
$documentName = $storedBase;
}
$networkPath = $result['path'] ?? null;
}
} catch (\Throwable $e) {
// Document is safely persisted in Espo; the network-storage copy is best-effort.
@@ -137,10 +94,10 @@ class FreeDocumentGenerator
}
$this->log->info(
"FreeDocumentGenerator: Created '{$documentName}' (type={$documentType}) for Case {$caseId}"
"FreeDocumentGenerator: Created '{$title}' (type={$documentType}) for Case {$caseId}"
);
$message = "✅ המסמך \"{$documentName}\" נוצר בהצלחה וצורף לתיק";
$message = "✅ המסמך \"{$title}\" נוצר בהצלחה וצורף לתיק";
if ($networkPath) {
$message .= " ולתיקיית הרשת";
}
@@ -149,7 +106,7 @@ class FreeDocumentGenerator
return [
'success' => true,
'documentId' => $document->getId(),
'documentName' => $documentName,
'documentName' => $title,
'fileName' => $fileName,
'networkPath' => $networkPath,
'message' => $message,
@@ -308,4 +265,11 @@ class FreeDocumentGenerator
$year = $dt->format('Y');
return "{$day} ב{$month} {$year}";
}
private function sanitizeFileName(string $name): string
{
$name = preg_replace('/[\\/:*?"<>|]/', '', $name);
$name = preg_replace('/\s+/', ' ', $name);
return trim(mb_substr($name, 0, 100));
}
}
@@ -10,7 +10,6 @@ use Espo\ORM\EntityManager;
use Espo\Entities\User;
use Espo\Modules\NetworkStorageIntegration\Services\DocumentTemplateService;
use Espo\Modules\NetworkStorageIntegration\Services\NetworkDocumentService;
use Espo\Modules\NetworkStorageIntegration\Classes\LocalFilesystemClient;
use PhpOffice\PhpWord\TemplateProcessor;
class GenericTemplateGenerator
@@ -73,23 +72,14 @@ class GenericTemplateGenerator
@unlink($outputPath);
@unlink($localTemplatePath);
// Step 6: Build subject-only document name (rule N1) — no contact,
// no em-dash. Defaults to the template name when no subject given.
$subject = trim((string) ($documentSubject ?? ''));
if ($subject === '') {
$subject = (string) $templateName;
}
$docName = $subject;
$baseName = LocalFilesystemClient::sanitizeFileName($subject);
if ($baseName === '') {
$baseName = 'document';
}
$fileName = $baseName . '.docx';
// Step 6: Build document name
$contactName = $this->getContactName($case);
$docName = $documentSubject ?? "{$templateName}{$contactName}";
// Step 7: Create Attachment + Document
$attachment = $this->entityManager->getNewEntity('Attachment');
$attachment->set([
'name' => $fileName,
'name' => $docName . '.docx',
'type' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'role' => 'Attachment',
'size' => strlen($content),
@@ -102,61 +92,16 @@ class GenericTemplateGenerator
$document->set([
'name' => $docName,
'fileId' => $attachment->getId(),
'fileName' => $fileName,
'fileName' => $docName . '.docx',
'assignedUserId' => $this->user->getId(),
]);
// Suppress the NSI upload hook (fires before the Case link below);
// we upload explicitly with the Case so it lands in the case folder.
$this->entityManager->saveEntity($document, ['skipNetworkUpload' => true]);
$this->entityManager->saveEntity($document);
// Link to Case
$this->entityManager->getRDBRepository('Case')
->getRelation($case, 'documents')
->relate($document);
// Upload to the case folder; align DB names to the canonical stored name.
try {
$nds = $this->injectableFactory->create(NetworkDocumentService::class);
if ($nds->isEnabled()) {
$result = $nds->uploadDocument($document, $attachment, 'Case', $caseId);
if (!empty($result['success'])) {
$storedName = $result['fileName'] ?? $fileName;
$storedBase = pathinfo($storedName, PATHINFO_FILENAME) ?: $docName;
$document->set([
'name' => $storedBase,
'fileName' => $storedName,
'storageType' => 'network',
'networkStoragePath' => $result['path'] ?? null,
'networkStorageFileName' => $storedName,
'networkStorageSize' => $result['size'] ?? strlen($content),
'networkStorageModifiedAt' => date('Y-m-d H:i:s'),
]);
$this->entityManager->saveEntity($document, [
'skipNetworkUpload' => true,
'silent' => true,
]);
$attachment->set('name', $storedName);
$this->entityManager->saveEntity($attachment, ['silent' => true]);
$sourcePath = 'data/upload/' . $attachment->getSourceId();
if (file_exists($sourcePath)) {
@unlink($sourcePath);
}
$docName = $storedBase;
}
}
} catch (\Throwable $e) {
// Document is safely persisted in Espo; the network copy is best-effort.
$this->log->warning(
"GenericTemplateGenerator: failed to copy to network storage for Case {$caseId}: " .
$e->getMessage()
);
}
$this->log->info("GenericTemplateGenerator: Created '{$docName}' from template '{$templateName}' for Case {$caseId}");
return [
@@ -245,4 +190,24 @@ class GenericTemplateGenerator
$processor->saveAs($outputPath);
}
private function getContactName($case): string
{
$contactId = $case->get('contactId');
if (!$contactId) {
$ids = $case->getLinkMultipleIdList('contacts');
if (!empty($ids)) {
$contactId = $ids[0];
}
}
if ($contactId) {
$contact = $this->entityManager->getEntityById('Contact', $contactId);
if ($contact) {
return trim($contact->get('firstName') . ' ' . $contact->get('lastName'));
}
}
return $case->get('name') ?? '';
}
}
+2 -2
View File
@@ -3,11 +3,11 @@
"module": "SmartAssistant",
"description": "Unified AI Assistant for Legal CRM — floating chat with case memory, office alerts, and AI Gateway integration",
"author": "klear",
"version": "2.10.5",
"version": "2.10.3",
"acceptableVersions": [
">=8.0.0"
],
"releaseDate": "2026-06-03",
"releaseDate": "2026-05-27",
"php": [
">=8.1"
]