Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 37a8b67b95 | |||
| 39d9093bf6 |
File diff suppressed because one or more lines are too long
@@ -13,6 +13,8 @@ use Espo\Modules\SmartAssistant\Services\ActionExecutor;
|
|||||||
use Espo\Modules\SmartAssistant\Services\CaseMemoryService;
|
use Espo\Modules\SmartAssistant\Services\CaseMemoryService;
|
||||||
use Espo\Modules\SmartAssistant\Services\DocumentAnalyzer;
|
use Espo\Modules\SmartAssistant\Services\DocumentAnalyzer;
|
||||||
use Espo\Modules\SmartAssistant\Services\GenericTemplateGenerator;
|
use Espo\Modules\SmartAssistant\Services\GenericTemplateGenerator;
|
||||||
|
use Espo\Modules\SmartAssistant\Services\FreeDocumentGenerator;
|
||||||
|
use Espo\Modules\SmartAssistant\Services\CaseFolderManager;
|
||||||
use Espo\Entities\User;
|
use Espo\Entities\User;
|
||||||
|
|
||||||
class SmartAssistant
|
class SmartAssistant
|
||||||
@@ -384,4 +386,117 @@ class SmartAssistant
|
|||||||
|
|
||||||
return $generator->generate($templateId, $caseId, $documentSubject, $customPlaceholders);
|
return $generator->generate($templateId, $caseId, $documentSubject, $customPlaceholders);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function postActionCreateDocument(Request $request, Response $response): array
|
||||||
|
{
|
||||||
|
$this->checkAccess();
|
||||||
|
$data = $request->getParsedBody();
|
||||||
|
|
||||||
|
$caseId = $data->caseId ?? null;
|
||||||
|
$title = $data->title ?? null;
|
||||||
|
$body = $data->body ?? null;
|
||||||
|
|
||||||
|
if (empty($caseId)) {
|
||||||
|
throw new BadRequest('caseId is required.');
|
||||||
|
}
|
||||||
|
if (empty($title)) {
|
||||||
|
throw new BadRequest('title is required.');
|
||||||
|
}
|
||||||
|
if (empty($body)) {
|
||||||
|
throw new BadRequest('body is required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$documentType = $data->documentType ?? 'letter';
|
||||||
|
$recipient = $data->recipient ?? null;
|
||||||
|
|
||||||
|
$generator = $this->injectableFactory->create(FreeDocumentGenerator::class);
|
||||||
|
|
||||||
|
return $generator->generate($caseId, $title, $body, $documentType, $recipient);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function postActionWriteToFolder(Request $request, Response $response): array
|
||||||
|
{
|
||||||
|
$this->checkAccess();
|
||||||
|
$data = $request->getParsedBody();
|
||||||
|
|
||||||
|
$caseId = $data->caseId ?? null;
|
||||||
|
$relPath = $data->relPath ?? null;
|
||||||
|
$contentBase64 = $data->contentBase64 ?? null;
|
||||||
|
|
||||||
|
if (empty($caseId)) throw new BadRequest('caseId is required.');
|
||||||
|
if (empty($relPath)) throw new BadRequest('relPath is required.');
|
||||||
|
if (!isset($contentBase64)) throw new BadRequest('contentBase64 is required.');
|
||||||
|
|
||||||
|
$content = base64_decode((string) $contentBase64, true);
|
||||||
|
if ($content === false) {
|
||||||
|
throw new BadRequest('contentBase64 is not valid base64.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$manager = $this->injectableFactory->create(CaseFolderManager::class);
|
||||||
|
return $manager->writeFile($caseId, $relPath, $content);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function postActionCreateFolder(Request $request, Response $response): array
|
||||||
|
{
|
||||||
|
$this->checkAccess();
|
||||||
|
$data = $request->getParsedBody();
|
||||||
|
|
||||||
|
$caseId = $data->caseId ?? null;
|
||||||
|
$relPath = $data->relPath ?? null;
|
||||||
|
|
||||||
|
if (empty($caseId)) throw new BadRequest('caseId is required.');
|
||||||
|
if (empty($relPath)) throw new BadRequest('relPath is required.');
|
||||||
|
|
||||||
|
$manager = $this->injectableFactory->create(CaseFolderManager::class);
|
||||||
|
return $manager->createFolder($caseId, $relPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function postActionRenameItem(Request $request, Response $response): array
|
||||||
|
{
|
||||||
|
$this->checkAccess();
|
||||||
|
$data = $request->getParsedBody();
|
||||||
|
|
||||||
|
$caseId = $data->caseId ?? null;
|
||||||
|
$currentRelPath = $data->currentRelPath ?? null;
|
||||||
|
$newName = $data->newName ?? null;
|
||||||
|
|
||||||
|
if (empty($caseId)) throw new BadRequest('caseId is required.');
|
||||||
|
if (empty($currentRelPath)) throw new BadRequest('currentRelPath is required.');
|
||||||
|
if (empty($newName)) throw new BadRequest('newName is required.');
|
||||||
|
|
||||||
|
$manager = $this->injectableFactory->create(CaseFolderManager::class);
|
||||||
|
return $manager->renameItem($caseId, $currentRelPath, $newName);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function postActionMoveItem(Request $request, Response $response): array
|
||||||
|
{
|
||||||
|
$this->checkAccess();
|
||||||
|
$data = $request->getParsedBody();
|
||||||
|
|
||||||
|
$caseId = $data->caseId ?? null;
|
||||||
|
$sourceRelPath = $data->sourceRelPath ?? null;
|
||||||
|
$targetRelPath = $data->targetRelPath ?? null;
|
||||||
|
|
||||||
|
if (empty($caseId)) throw new BadRequest('caseId is required.');
|
||||||
|
if (empty($sourceRelPath)) throw new BadRequest('sourceRelPath is required.');
|
||||||
|
if (empty($targetRelPath)) throw new BadRequest('targetRelPath is required.');
|
||||||
|
|
||||||
|
$manager = $this->injectableFactory->create(CaseFolderManager::class);
|
||||||
|
return $manager->moveItem($caseId, $sourceRelPath, $targetRelPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function postActionMarkForDeletion(Request $request, Response $response): array
|
||||||
|
{
|
||||||
|
$this->checkAccess();
|
||||||
|
$data = $request->getParsedBody();
|
||||||
|
|
||||||
|
$caseId = $data->caseId ?? null;
|
||||||
|
$fileRelPath = $data->fileRelPath ?? null;
|
||||||
|
|
||||||
|
if (empty($caseId)) throw new BadRequest('caseId is required.');
|
||||||
|
if (empty($fileRelPath)) throw new BadRequest('fileRelPath is required.');
|
||||||
|
|
||||||
|
$manager = $this->injectableFactory->create(CaseFolderManager::class);
|
||||||
|
return $manager->markForDeletion($caseId, $fileRelPath);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"fields": {
|
||||||
|
"markedForDeletionAt": "סומן למחיקה בתאריך",
|
||||||
|
"markedForDeletionBy": "סומן למחיקה על ידי"
|
||||||
|
},
|
||||||
|
"tooltips": {
|
||||||
|
"markedForDeletionAt": "מסמך זה סומן למחיקה על ידי שירה ומחכה לאישור ידני של עו\"ד למחיקה הפיזית."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"fields": {
|
||||||
|
"markedForDeletionAt": {
|
||||||
|
"type": "datetime",
|
||||||
|
"readOnly": true,
|
||||||
|
"tooltip": true
|
||||||
|
},
|
||||||
|
"markedForDeletionBy": {
|
||||||
|
"type": "link",
|
||||||
|
"readOnly": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"links": {
|
||||||
|
"markedForDeletionBy": {
|
||||||
|
"type": "belongsTo",
|
||||||
|
"entity": "User"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -69,11 +69,12 @@ class AlertCalculator
|
|||||||
{
|
{
|
||||||
$alerts = [];
|
$alerts = [];
|
||||||
$now = new \DateTime();
|
$now = new \DateTime();
|
||||||
|
$today = $now->format('Y-m-d');
|
||||||
$warningThreshold = (new \DateTime())->modify("-{$warningDays} days")->format('Y-m-d H:i:s');
|
$warningThreshold = (new \DateTime())->modify("-{$warningDays} days")->format('Y-m-d H:i:s');
|
||||||
$criticalThreshold = (new \DateTime())->modify("-{$criticalDays} days")->format('Y-m-d H:i:s');
|
$criticalThreshold = (new \DateTime())->modify("-{$criticalDays} days")->format('Y-m-d H:i:s');
|
||||||
|
|
||||||
$cases = $this->entityManager->getRDBRepository('Case')
|
$cases = $this->entityManager->getRDBRepository('Case')
|
||||||
->select(['id', 'name', 'status', 'assignedUserName', 'cLastActivityAt', 'createdAt'])
|
->select(['id', 'name', 'status', 'assignedUserName', 'cLastActivityAt', 'createdAt', 'cNextHearing'])
|
||||||
->where([
|
->where([
|
||||||
'status!=' => self::CLOSED_STATUSES, 'deleted' => false,
|
'status!=' => self::CLOSED_STATUSES, 'deleted' => false,
|
||||||
'OR' => [
|
'OR' => [
|
||||||
@@ -83,6 +84,17 @@ class AlertCalculator
|
|||||||
])->order('cLastActivityAt', 'ASC')->limit(0, 50)->find();
|
])->order('cLastActivityAt', 'ASC')->limit(0, 50)->find();
|
||||||
|
|
||||||
foreach ($cases as $case) {
|
foreach ($cases as $case) {
|
||||||
|
$status = $case->get('status');
|
||||||
|
|
||||||
|
// Suppress inactivity alerts for cases legitimately awaiting court action.
|
||||||
|
// PendingDecision: nothing the lawyer can do until the court rules.
|
||||||
|
// PendingHearing: nothing to do if a future hearing is already scheduled.
|
||||||
|
if ($status === 'PendingDecision') continue;
|
||||||
|
if ($status === 'PendingHearing') {
|
||||||
|
$nextHearing = $case->get('cNextHearing');
|
||||||
|
if ($nextHearing && $nextHearing >= $today) continue;
|
||||||
|
}
|
||||||
|
|
||||||
$lastActivity = $case->get('cLastActivityAt') ?? $case->get('createdAt');
|
$lastActivity = $case->get('cLastActivityAt') ?? $case->get('createdAt');
|
||||||
if (!$lastActivity) continue;
|
if (!$lastActivity) continue;
|
||||||
$daysSince = $now->diff(new \DateTime($lastActivity))->days;
|
$daysSince = $now->diff(new \DateTime($lastActivity))->days;
|
||||||
|
|||||||
@@ -188,10 +188,20 @@ class CaseContextBuilder
|
|||||||
{
|
{
|
||||||
$templates = [];
|
$templates = [];
|
||||||
$collection = $this->entityManager->getRDBRepository('DocumentTemplate')
|
$collection = $this->entityManager->getRDBRepository('DocumentTemplate')
|
||||||
->where(['entityType' => 'Case'])->order('name', 'ASC')->find();
|
->where([
|
||||||
|
'targetEntityType' => 'Case',
|
||||||
|
'isActive' => true,
|
||||||
|
])
|
||||||
|
->order('name', 'ASC')
|
||||||
|
->find();
|
||||||
|
|
||||||
foreach ($collection as $t) {
|
foreach ($collection as $t) {
|
||||||
$templates[] = ['id' => $t->get('id'), 'name' => $t->get('name')];
|
$templates[] = [
|
||||||
|
'id' => $t->get('id'),
|
||||||
|
'name' => $t->get('name'),
|
||||||
|
'category' => $t->get('category'),
|
||||||
|
'description' => $t->get('description'),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
return $templates;
|
return $templates;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,334 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Espo\Modules\SmartAssistant\Services;
|
||||||
|
|
||||||
|
use Espo\Core\Exceptions\BadRequest;
|
||||||
|
use Espo\Core\Exceptions\Error;
|
||||||
|
use Espo\Core\Exceptions\Forbidden;
|
||||||
|
use Espo\Core\Exceptions\NotFound;
|
||||||
|
use Espo\Core\InjectableFactory;
|
||||||
|
use Espo\Core\Utils\Log;
|
||||||
|
use Espo\ORM\EntityManager;
|
||||||
|
use Espo\Entities\User;
|
||||||
|
use Espo\Modules\NetworkStorageIntegration\Services\NetworkDocumentService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps NetworkDocumentService and constrains every operation to the case's
|
||||||
|
* own folder. Never exposes a physical delete — only soft-delete via
|
||||||
|
* markForDeletion().
|
||||||
|
*/
|
||||||
|
class CaseFolderManager
|
||||||
|
{
|
||||||
|
private const DELETION_FOLDER_NAME = 'מסמכים למחיקה';
|
||||||
|
private const DELETION_PREFIX = 'למחיקה - ';
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private EntityManager $entityManager,
|
||||||
|
private InjectableFactory $injectableFactory,
|
||||||
|
private Log $log,
|
||||||
|
private User $user
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{success: bool, path: string, size: int}
|
||||||
|
*/
|
||||||
|
public function writeFile(string $caseId, string $relPath, string $content): array
|
||||||
|
{
|
||||||
|
$absInsideStorage = $this->resolveSafePath($caseId, $relPath, requireExists: false);
|
||||||
|
$client = $this->getNds()->getClient();
|
||||||
|
|
||||||
|
$parent = $this->dirnameRel($absInsideStorage);
|
||||||
|
if ($parent !== '') {
|
||||||
|
$client->createFolderRecursive($parent);
|
||||||
|
}
|
||||||
|
|
||||||
|
$ok = $client->uploadFile($absInsideStorage, $content);
|
||||||
|
if (!$ok) {
|
||||||
|
throw new Error("Failed to write file: {$absInsideStorage}");
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->log->info("CaseFolderManager: wrote '{$absInsideStorage}' (case {$caseId})");
|
||||||
|
|
||||||
|
return [
|
||||||
|
'success' => true,
|
||||||
|
'path' => $absInsideStorage,
|
||||||
|
'size' => strlen($content),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{success: bool, path: string, alreadyExisted: bool}
|
||||||
|
*/
|
||||||
|
public function createFolder(string $caseId, string $relPath): array
|
||||||
|
{
|
||||||
|
$abs = $this->resolveSafePath($caseId, $relPath, requireExists: false);
|
||||||
|
$client = $this->getNds()->getClient();
|
||||||
|
|
||||||
|
$alreadyExisted = $client->exists($abs);
|
||||||
|
if (!$alreadyExisted) {
|
||||||
|
$ok = $client->createFolderRecursive($abs);
|
||||||
|
if (!$ok) {
|
||||||
|
throw new Error("Failed to create folder: {$abs}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->log->info("CaseFolderManager: created folder '{$abs}' (case {$caseId})");
|
||||||
|
|
||||||
|
return [
|
||||||
|
'success' => true,
|
||||||
|
'path' => $abs,
|
||||||
|
'alreadyExisted' => $alreadyExisted,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{success: bool, oldPath: string, newPath: string}
|
||||||
|
*/
|
||||||
|
public function renameItem(string $caseId, string $currentRelPath, string $newName): array
|
||||||
|
{
|
||||||
|
$newName = trim($newName);
|
||||||
|
if ($newName === '' || str_contains($newName, '/') || str_contains($newName, '\\')) {
|
||||||
|
throw new BadRequest('newName must be a plain file/folder name without slashes.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$absCurrent = $this->resolveSafePath($caseId, $currentRelPath, requireExists: true);
|
||||||
|
|
||||||
|
$parent = $this->dirnameRel($absCurrent);
|
||||||
|
$absNew = $parent === '' ? $newName : ($parent . '/' . $newName);
|
||||||
|
|
||||||
|
// Confirm the new path is still inside the case folder.
|
||||||
|
$this->assertWithinCaseRoot($caseId, $absNew);
|
||||||
|
|
||||||
|
$client = $this->getNds()->getClient();
|
||||||
|
if ($client->exists($absNew)) {
|
||||||
|
throw new Error("Target already exists: {$absNew}");
|
||||||
|
}
|
||||||
|
|
||||||
|
$ok = $client->move($absCurrent, $absNew);
|
||||||
|
if (!$ok) {
|
||||||
|
throw new Error("Failed to rename: {$absCurrent} -> {$absNew}");
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->log->info("CaseFolderManager: renamed '{$absCurrent}' -> '{$absNew}' (case {$caseId})");
|
||||||
|
|
||||||
|
return [
|
||||||
|
'success' => true,
|
||||||
|
'oldPath' => $absCurrent,
|
||||||
|
'newPath' => $absNew,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{success: bool, oldPath: string, newPath: string}
|
||||||
|
*/
|
||||||
|
public function moveItem(string $caseId, string $sourceRelPath, string $targetRelPath): array
|
||||||
|
{
|
||||||
|
$absSource = $this->resolveSafePath($caseId, $sourceRelPath, requireExists: true);
|
||||||
|
$absTarget = $this->resolveSafePath($caseId, $targetRelPath, requireExists: false);
|
||||||
|
|
||||||
|
$client = $this->getNds()->getClient();
|
||||||
|
|
||||||
|
$parent = $this->dirnameRel($absTarget);
|
||||||
|
if ($parent !== '' && !$client->exists($parent)) {
|
||||||
|
$client->createFolderRecursive($parent);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($client->exists($absTarget)) {
|
||||||
|
throw new Error("Target already exists: {$absTarget}");
|
||||||
|
}
|
||||||
|
|
||||||
|
$ok = $client->move($absSource, $absTarget);
|
||||||
|
if (!$ok) {
|
||||||
|
throw new Error("Failed to move: {$absSource} -> {$absTarget}");
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->log->info("CaseFolderManager: moved '{$absSource}' -> '{$absTarget}' (case {$caseId})");
|
||||||
|
|
||||||
|
return [
|
||||||
|
'success' => true,
|
||||||
|
'oldPath' => $absSource,
|
||||||
|
'newPath' => $absTarget,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Soft-delete: move file into the case's "מסמכים למחיקה" folder with a
|
||||||
|
* "למחיקה - " prefix. Never physically deletes. Also updates the linked
|
||||||
|
* Document entity (if any) so admins can audit pending deletions.
|
||||||
|
*
|
||||||
|
* @return array{success: bool, originalPath: string, newPath: string, deletionFolder: string}
|
||||||
|
*/
|
||||||
|
public function markForDeletion(string $caseId, string $fileRelPath): array
|
||||||
|
{
|
||||||
|
$absSource = $this->resolveSafePath($caseId, $fileRelPath, requireExists: true);
|
||||||
|
$caseRoot = $this->getCaseRoot($caseId);
|
||||||
|
|
||||||
|
$deletionFolder = $caseRoot . '/' . self::DELETION_FOLDER_NAME;
|
||||||
|
$client = $this->getNds()->getClient();
|
||||||
|
|
||||||
|
if (!$client->exists($deletionFolder)) {
|
||||||
|
$client->createFolderRecursive($deletionFolder);
|
||||||
|
}
|
||||||
|
|
||||||
|
$baseName = basename($absSource);
|
||||||
|
$targetName = self::DELETION_PREFIX . $baseName;
|
||||||
|
$absTarget = $deletionFolder . '/' . $targetName;
|
||||||
|
|
||||||
|
// Avoid name collisions with already-marked files.
|
||||||
|
if ($client->exists($absTarget)) {
|
||||||
|
$stamp = date('Y-m-d_H-i-s');
|
||||||
|
$targetName = self::DELETION_PREFIX . $stamp . ' - ' . $baseName;
|
||||||
|
$absTarget = $deletionFolder . '/' . $targetName;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ok = $client->move($absSource, $absTarget);
|
||||||
|
if (!$ok) {
|
||||||
|
throw new Error("Failed to mark for deletion: {$absSource}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Best-effort: update Document entity if we can find one pointing to this file.
|
||||||
|
$this->touchDocumentForDeletion($absSource, $absTarget);
|
||||||
|
|
||||||
|
$this->log->info(
|
||||||
|
"CaseFolderManager: marked for deletion '{$absSource}' -> '{$absTarget}' " .
|
||||||
|
"(case {$caseId}, by user {$this->user->getId()})"
|
||||||
|
);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'success' => true,
|
||||||
|
'originalPath' => $absSource,
|
||||||
|
'newPath' => $absTarget,
|
||||||
|
'deletionFolder' => $deletionFolder,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- internals ---------------------------------------------------------
|
||||||
|
|
||||||
|
private function getNds(): NetworkDocumentService
|
||||||
|
{
|
||||||
|
return $this->injectableFactory->create(NetworkDocumentService::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the storage-relative root folder of the case.
|
||||||
|
*/
|
||||||
|
private function getCaseRoot(string $caseId): string
|
||||||
|
{
|
||||||
|
$root = $this->getNds()->getEntityFolderPath('Case', $caseId);
|
||||||
|
if (!$root) {
|
||||||
|
throw new NotFound("No folder is configured for Case {$caseId}.");
|
||||||
|
}
|
||||||
|
$root = $this->normalize($root);
|
||||||
|
if ($root === '') {
|
||||||
|
throw new Error("Case {$caseId} resolved to an empty folder path; refusing.");
|
||||||
|
}
|
||||||
|
return $root;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize + reject traversal, return storage-relative path inside the case folder.
|
||||||
|
* If $requireExists, ensures the resolved path exists in storage.
|
||||||
|
*/
|
||||||
|
private function resolveSafePath(string $caseId, string $relPath, bool $requireExists): string
|
||||||
|
{
|
||||||
|
$relPath = trim((string) $relPath);
|
||||||
|
if ($relPath === '') {
|
||||||
|
throw new BadRequest('Path is required.');
|
||||||
|
}
|
||||||
|
if (str_starts_with($relPath, '/') || str_starts_with($relPath, '\\')) {
|
||||||
|
throw new BadRequest('Absolute paths are not allowed.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$caseRoot = $this->getCaseRoot($caseId);
|
||||||
|
|
||||||
|
// Allow the caller to either pass a path relative to the case root, OR a
|
||||||
|
// full storage-relative path that already includes the case root.
|
||||||
|
$normalizedRel = $this->normalize($relPath);
|
||||||
|
$candidate = str_starts_with($normalizedRel, $caseRoot . '/') || $normalizedRel === $caseRoot
|
||||||
|
? $normalizedRel
|
||||||
|
: $this->normalize($caseRoot . '/' . $normalizedRel);
|
||||||
|
|
||||||
|
$this->assertWithinCaseRoot($caseId, $candidate);
|
||||||
|
|
||||||
|
if ($requireExists) {
|
||||||
|
$client = $this->getNds()->getClient();
|
||||||
|
if (!$client->exists($candidate)) {
|
||||||
|
throw new NotFound("Path not found: {$candidate}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function assertWithinCaseRoot(string $caseId, string $absInStorage): void
|
||||||
|
{
|
||||||
|
$caseRoot = $this->getCaseRoot($caseId);
|
||||||
|
if ($absInStorage !== $caseRoot && !str_starts_with($absInStorage, $caseRoot . '/')) {
|
||||||
|
throw new Forbidden("Path '{$absInStorage}' is outside the case folder '{$caseRoot}'.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Canonicalize a storage-relative path:
|
||||||
|
* - convert '\' to '/'
|
||||||
|
* - collapse repeated '/'
|
||||||
|
* - resolve '.' segments
|
||||||
|
* - reject '..' segments (refuses traversal)
|
||||||
|
* - strip trailing '/'
|
||||||
|
*/
|
||||||
|
private function normalize(string $path): string
|
||||||
|
{
|
||||||
|
$path = str_replace('\\', '/', $path);
|
||||||
|
$segments = explode('/', $path);
|
||||||
|
$out = [];
|
||||||
|
foreach ($segments as $seg) {
|
||||||
|
if ($seg === '' || $seg === '.') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($seg === '..') {
|
||||||
|
throw new Forbidden('Parent-directory traversal is not allowed.');
|
||||||
|
}
|
||||||
|
// Block null bytes and other suspicious characters.
|
||||||
|
if (preg_match('/[\x00-\x1F]/', $seg)) {
|
||||||
|
throw new BadRequest('Path contains invalid control characters.');
|
||||||
|
}
|
||||||
|
$out[] = $seg;
|
||||||
|
}
|
||||||
|
return implode('/', $out);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function dirnameRel(string $path): string
|
||||||
|
{
|
||||||
|
$pos = strrpos($path, '/');
|
||||||
|
if ($pos === false) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return substr($path, 0, $pos);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the Document entity (if any) that pointed to this file, so the
|
||||||
|
* CRM reflects the soft-delete state.
|
||||||
|
*/
|
||||||
|
private function touchDocumentForDeletion(string $oldPath, string $newPath): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$doc = $this->entityManager->getRDBRepository('Document')
|
||||||
|
->where(['networkStoragePath' => $oldPath])
|
||||||
|
->findOne();
|
||||||
|
if (!$doc) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$doc->set([
|
||||||
|
'networkStoragePath' => $newPath,
|
||||||
|
'markedForDeletionAt' => date('Y-m-d H:i:s'),
|
||||||
|
'markedForDeletionById' => $this->user->getId(),
|
||||||
|
]);
|
||||||
|
$this->entityManager->saveEntity($doc);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->log->warning(
|
||||||
|
"CaseFolderManager: could not update Document entity for '{$oldPath}': " . $e->getMessage()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Espo\Modules\SmartAssistant\Services;
|
||||||
|
|
||||||
|
use Espo\Core\Exceptions\BadRequest;
|
||||||
|
use Espo\Core\Exceptions\NotFound;
|
||||||
|
use Espo\Core\InjectableFactory;
|
||||||
|
use Espo\Core\Utils\Log;
|
||||||
|
use Espo\ORM\EntityManager;
|
||||||
|
use Espo\Entities\User;
|
||||||
|
use Espo\Modules\NetworkStorageIntegration\Services\NetworkDocumentService;
|
||||||
|
use PhpOffice\PhpWord\PhpWord;
|
||||||
|
use PhpOffice\PhpWord\IOFactory;
|
||||||
|
|
||||||
|
class FreeDocumentGenerator
|
||||||
|
{
|
||||||
|
private const TEMP_DIR = 'data/tmp/';
|
||||||
|
private const FONT_NAME = 'David';
|
||||||
|
private const FONT_SIZE = 12;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private EntityManager $entityManager,
|
||||||
|
private InjectableFactory $injectableFactory,
|
||||||
|
private Log $log,
|
||||||
|
private User $user
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{success: bool, documentId: string, documentName: string, fileName: string, networkPath: ?string, message: string}
|
||||||
|
*/
|
||||||
|
public function generate(
|
||||||
|
string $caseId,
|
||||||
|
string $title,
|
||||||
|
string $body,
|
||||||
|
string $documentType = 'letter',
|
||||||
|
?string $recipient = null
|
||||||
|
): array {
|
||||||
|
$title = trim($title);
|
||||||
|
if ($title === '') {
|
||||||
|
throw new BadRequest('title is required.');
|
||||||
|
}
|
||||||
|
if (trim($body) === '') {
|
||||||
|
throw new BadRequest('body is required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$case = $this->entityManager->getEntityById('Case', $caseId);
|
||||||
|
if (!$case) {
|
||||||
|
throw new NotFound("Case {$caseId} not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
$docxBinary = $this->buildDocx($title, $body, $recipient);
|
||||||
|
|
||||||
|
$sanitizedTitle = $this->sanitizeFileName($title);
|
||||||
|
$today = date('Y-m-d');
|
||||||
|
$fileName = "{$today} - {$sanitizedTitle}.docx";
|
||||||
|
|
||||||
|
$attachment = $this->entityManager->getNewEntity('Attachment');
|
||||||
|
$attachment->set([
|
||||||
|
'name' => $fileName,
|
||||||
|
'type' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||||
|
'role' => 'Attachment',
|
||||||
|
'size' => strlen($docxBinary),
|
||||||
|
'relatedType' => 'Document',
|
||||||
|
]);
|
||||||
|
$this->entityManager->saveEntity($attachment);
|
||||||
|
file_put_contents('data/upload/' . $attachment->getId(), $docxBinary);
|
||||||
|
|
||||||
|
$document = $this->entityManager->getNewEntity('Document');
|
||||||
|
$document->set([
|
||||||
|
'name' => $title,
|
||||||
|
'fileId' => $attachment->getId(),
|
||||||
|
'fileName' => $fileName,
|
||||||
|
'assignedUserId' => $this->user->getId(),
|
||||||
|
]);
|
||||||
|
$this->entityManager->saveEntity($document);
|
||||||
|
|
||||||
|
$this->entityManager->getRDBRepository('Case')
|
||||||
|
->getRelation($case, 'documents')
|
||||||
|
->relate($document);
|
||||||
|
|
||||||
|
$networkPath = null;
|
||||||
|
try {
|
||||||
|
$nds = $this->injectableFactory->create(NetworkDocumentService::class);
|
||||||
|
if ($nds->isEnabled()) {
|
||||||
|
$result = $nds->uploadDocument($document, $attachment, 'Case', $caseId);
|
||||||
|
$networkPath = $result['path'] ?? null;
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// Document is safely persisted in Espo; the network-storage copy is best-effort.
|
||||||
|
$this->log->warning(
|
||||||
|
"FreeDocumentGenerator: failed to copy to network storage for Case {$caseId}: " .
|
||||||
|
$e->getMessage()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->log->info(
|
||||||
|
"FreeDocumentGenerator: Created '{$title}' (type={$documentType}) for Case {$caseId}"
|
||||||
|
);
|
||||||
|
|
||||||
|
$message = "✅ המסמך \"{$title}\" נוצר בהצלחה וצורף לתיק";
|
||||||
|
if ($networkPath) {
|
||||||
|
$message .= " ולתיקיית הרשת";
|
||||||
|
}
|
||||||
|
$message .= ".";
|
||||||
|
|
||||||
|
return [
|
||||||
|
'success' => true,
|
||||||
|
'documentId' => $document->getId(),
|
||||||
|
'documentName' => $title,
|
||||||
|
'fileName' => $fileName,
|
||||||
|
'networkPath' => $networkPath,
|
||||||
|
'message' => $message,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildDocx(string $title, string $body, ?string $recipient): string
|
||||||
|
{
|
||||||
|
$phpWord = new PhpWord();
|
||||||
|
$phpWord->setDefaultFontName(self::FONT_NAME);
|
||||||
|
$phpWord->setDefaultFontSize(self::FONT_SIZE);
|
||||||
|
|
||||||
|
$section = $phpWord->addSection([
|
||||||
|
'marginLeft' => 1440,
|
||||||
|
'marginRight' => 1440,
|
||||||
|
'marginTop' => 1440,
|
||||||
|
'marginBottom' => 1440,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$rtlPara = ['bidi' => true, 'alignment' => 'right'];
|
||||||
|
$centerPara = ['bidi' => true, 'alignment' => 'center'];
|
||||||
|
$titleFont = ['name' => self::FONT_NAME, 'size' => 16, 'bold' => true, 'rtl' => true];
|
||||||
|
$boldFont = ['name' => self::FONT_NAME, 'size' => self::FONT_SIZE, 'bold' => true, 'rtl' => true];
|
||||||
|
$normalFont = ['name' => self::FONT_NAME, 'size' => self::FONT_SIZE, 'rtl' => true];
|
||||||
|
|
||||||
|
$section->addText($this->formatHebrewDate(new \DateTime()), $normalFont, $rtlPara);
|
||||||
|
$section->addTextBreak(1);
|
||||||
|
|
||||||
|
if ($recipient !== null && trim($recipient) !== '') {
|
||||||
|
$section->addText('אל: ' . trim($recipient), $boldFont, $rtlPara);
|
||||||
|
$section->addTextBreak(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
$section->addText($title, $titleFont, $centerPara);
|
||||||
|
$section->addTextBreak(1);
|
||||||
|
|
||||||
|
$this->renderMarkdown($section, $body);
|
||||||
|
|
||||||
|
if (!is_dir(self::TEMP_DIR)) {
|
||||||
|
mkdir(self::TEMP_DIR, 0775, true);
|
||||||
|
}
|
||||||
|
$tmpPath = self::TEMP_DIR . 'free_' . uniqid() . '.docx';
|
||||||
|
$writer = IOFactory::createWriter($phpWord, 'Word2007');
|
||||||
|
$writer->save($tmpPath);
|
||||||
|
$content = file_get_contents($tmpPath);
|
||||||
|
@unlink($tmpPath);
|
||||||
|
|
||||||
|
return $content;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function renderMarkdown($section, string $body): void
|
||||||
|
{
|
||||||
|
$rtlPara = ['bidi' => true, 'alignment' => 'right'];
|
||||||
|
$bulletStyle = ['listType' => \PhpOffice\PhpWord\Style\ListItem::TYPE_BULLET_FILLED];
|
||||||
|
$bulletFont = ['name' => self::FONT_NAME, 'size' => self::FONT_SIZE, 'rtl' => true];
|
||||||
|
$h1Size = 16; $h2Size = 14; $h3Size = 13;
|
||||||
|
|
||||||
|
$body = str_replace(["\r\n", "\r"], "\n", $body);
|
||||||
|
$lines = explode("\n", $body);
|
||||||
|
|
||||||
|
$i = 0;
|
||||||
|
$n = count($lines);
|
||||||
|
while ($i < $n) {
|
||||||
|
$line = $lines[$i];
|
||||||
|
$trimmed = ltrim($line);
|
||||||
|
|
||||||
|
if (trim($line) === '') {
|
||||||
|
$section->addTextBreak(1);
|
||||||
|
$i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('/^(#{1,3})\s+(.+)$/u', $trimmed, $m)) {
|
||||||
|
$level = strlen($m[1]);
|
||||||
|
$size = match ($level) { 1 => $h1Size, 2 => $h2Size, default => $h3Size };
|
||||||
|
$section->addText(
|
||||||
|
$m[2],
|
||||||
|
['name' => self::FONT_NAME, 'size' => $size, 'bold' => true, 'rtl' => true],
|
||||||
|
$rtlPara
|
||||||
|
);
|
||||||
|
$i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('/^[\-\*]\s+(.+)$/u', $trimmed)) {
|
||||||
|
while ($i < $n && preg_match('/^[\-\*]\s+(.+)$/u', ltrim($lines[$i]), $m2)) {
|
||||||
|
$plain = preg_replace('/(\*\*|\*)/u', '', $m2[1]);
|
||||||
|
$section->addListItem($plain, 0, $bulletFont, $bulletStyle, $rtlPara);
|
||||||
|
$i++;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$textRun = $section->addTextRun($rtlPara);
|
||||||
|
$this->addInlineRuns($textRun, $trimmed);
|
||||||
|
$i++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse **bold** and *italic* into multiple PhpWord runs.
|
||||||
|
*/
|
||||||
|
private function addInlineRuns($textRun, string $text): void
|
||||||
|
{
|
||||||
|
$base = ['name' => self::FONT_NAME, 'size' => self::FONT_SIZE, 'rtl' => true];
|
||||||
|
$i = 0;
|
||||||
|
$len = mb_strlen($text);
|
||||||
|
$buffer = '';
|
||||||
|
|
||||||
|
$flush = function () use (&$buffer, $textRun, $base) {
|
||||||
|
if ($buffer !== '') {
|
||||||
|
$textRun->addText($buffer, $base);
|
||||||
|
$buffer = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
while ($i < $len) {
|
||||||
|
$two = mb_substr($text, $i, 2);
|
||||||
|
$one = mb_substr($text, $i, 1);
|
||||||
|
|
||||||
|
if ($two === '**') {
|
||||||
|
$end = mb_strpos($text, '**', $i + 2);
|
||||||
|
if ($end !== false) {
|
||||||
|
$flush();
|
||||||
|
$inner = mb_substr($text, $i + 2, $end - $i - 2);
|
||||||
|
$textRun->addText($inner, array_merge($base, ['bold' => true]));
|
||||||
|
$i = $end + 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
} elseif ($one === '*') {
|
||||||
|
$end = mb_strpos($text, '*', $i + 1);
|
||||||
|
if ($end !== false) {
|
||||||
|
$flush();
|
||||||
|
$inner = mb_substr($text, $i + 1, $end - $i - 1);
|
||||||
|
$textRun->addText($inner, array_merge($base, ['italic' => true]));
|
||||||
|
$i = $end + 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$buffer .= $one;
|
||||||
|
$i++;
|
||||||
|
}
|
||||||
|
$flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function formatHebrewDate(\DateTime $dt): string
|
||||||
|
{
|
||||||
|
$months = [
|
||||||
|
1 => 'ינואר', 2 => 'פברואר', 3 => 'מרץ', 4 => 'אפריל',
|
||||||
|
5 => 'מאי', 6 => 'יוני', 7 => 'יולי', 8 => 'אוגוסט',
|
||||||
|
9 => 'ספטמבר', 10 => 'אוקטובר', 11 => 'נובמבר', 12 => 'דצמבר',
|
||||||
|
];
|
||||||
|
$day = (int) $dt->format('j');
|
||||||
|
$month = $months[(int) $dt->format('n')];
|
||||||
|
$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));
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-2
@@ -3,11 +3,11 @@
|
|||||||
"module": "SmartAssistant",
|
"module": "SmartAssistant",
|
||||||
"description": "Unified AI Assistant for Legal CRM — floating chat with case memory, office alerts, and AI Gateway integration",
|
"description": "Unified AI Assistant for Legal CRM — floating chat with case memory, office alerts, and AI Gateway integration",
|
||||||
"author": "klear",
|
"author": "klear",
|
||||||
"version": "2.8.2",
|
"version": "2.9.0",
|
||||||
"acceptableVersions": [
|
"acceptableVersions": [
|
||||||
">=8.0.0"
|
">=8.0.0"
|
||||||
],
|
],
|
||||||
"releaseDate": "2026-05-13",
|
"releaseDate": "2026-05-14",
|
||||||
"php": [
|
"php": [
|
||||||
">=8.1"
|
">=8.1"
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user