diff --git a/.taskmaster/tasks/tasks.json b/.taskmaster/tasks/tasks.json index 23a2015..79f3f61 100644 --- a/.taskmaster/tasks/tasks.json +++ b/.taskmaster/tasks/tasks.json @@ -31,18 +31,33 @@ "description": "Production incident where multi-tool AI flows (delegation, memory writes, web search) occasionally exceeded the default 180s client timeout, causing \"Communication error\" to display even though the backend continued processing and eventually succeeded. Users saw false errors after ~3min wait.", "details": "## Root Cause\nThe client-side `Espo.Ajax.postRequest('SmartAssistant/action/chat', ...)` call in `floating-chat.js:486` already has `{timeout: 600000}` (10 minutes), which was implemented in a previous fix. However, the production incident revealed that:\n\n1. Users still see \"שגיאה בתקשורת\" (Communication error) after ~3 minutes on complex multi-tool flows\n2. The \"חושבת...\" (Thinking...) spinner runs unchanged for the entire duration, giving no feedback that the system is still working\n3. The i18n files already contain a `SlowHint` label designed for this exact scenario but it was never wired up\n\n## Analysis of Current Code\n**File:** `files/client/custom/modules/smart-assistant/src/views/floating-chat.js`\n\n**Lines 460-475:** The `sendMessage()` method creates a loading message with \"חושבת...\" text and immediately sets up a 60-second timer (`slowHintTimer`) that changes the text to the `SlowHint` translation:\n```javascript\nvar slowHintText = this.translate('SlowHint', 'labels', 'SmartAssistant') ||\n 'חושבת... זה לוקח קצת יותר זמן כי אני מעבדת כמה מקורות במקביל';\nvar slowHintTimer = setTimeout(function () {\n $loading.find('.sa-thinking-text').text(slowHintText);\n}, 60000);\n```\n\n**Line 486:** The AJAX timeout is already set to 600000ms (10 minutes)\n\n**Lines 487, 505:** Both success and error handlers call `clearTimeout(slowHintTimer)` to clean up\n\n## Issue Discovered\nThe code is **already correct** and implements exactly what the task description requests:\n- ✅ 600s (10 minute) timeout is set\n- ✅ Slow hint timer switches text after 60s\n- ✅ Timer is properly cleaned up on completion/error\n- ✅ i18n labels exist in both `en_US` and `fa_IR`\n\n## Verification Needed\nSince the code already implements the fix, the production incident suggests one of:\n\n1. **Deployed version mismatch** - The production extension may not have the latest code deployed\n2. **Browser caching** - Clients may be running cached old JavaScript that lacks the timeout/hint\n3. **Different error source** - The \"Communication error\" might be coming from a network-level timeout (reverse proxy, Coolify, Traefik) rather than client JavaScript\n4. **EspoCRM core override** - EspoCRM's base `Espo.Ajax` might have a global timeout that overrides the per-request timeout\n\n## Implementation Steps\n\n1. **Verify current deployment** - Check that the production EspoCRM instance has the latest `SmartAssistant` extension installed with this exact code\n2. **Check manifest version** - Confirm `manifest.json` version number matches what's deployed\n3. **Browser cache bust** - Force client cache clear by:\n - Incrementing `manifest.json` version\n - EspoCRM admin may need to rebuild/clear cache\n4. **Server-side timeout audit** - Check these layers:\n - Traefik ingress timeout on the EspoCRM service\n - nginx/Apache timeout in EspoCRM container\n - PHP `max_execution_time` in EspoCRM (should be ≥600s for the SmartAssistant endpoint)\n - shira-hermes backend timeout (should be ≥600s)\n - ai-gateway proxy timeout to Claude API\n5. **Add defensive logging** - If the code is deployed but not working:\n - Add `console.log` when slow hint fires\n - Add `console.log` showing actual timeout value used\n - Check browser DevTools Network tab for actual request timeout\n6. **Verify i18n loading** - Ensure `SlowHint` label is actually loaded (check `this.translate('SlowHint', ...)` returns expected text)\n\n## Code Changes (if verification shows deployment gap)\n\n**None required** - the code already implements the fix correctly.\n\nIf verification reveals the code is NOT deployed, simply:\n1. Rebuild the extension package\n2. Upload to EspoCRM via Administration → Extensions\n3. Clear EspoCRM cache\n\n## Alternative: Server-Side Timeout Fix\n\nIf the issue is server-side, update these configs:\n\n**shira-hermes (FastAPI):**\n```python\n# In main app config\nuvicorn.run(app, timeout_keep_alive=600)\n```\n\n**EspoCRM PHP (SmartAssistant controller):**\n```php\nset_time_limit(600); // At start of chat action\n```\n\n**Coolify/Traefik:**\nCheck proxy timeout settings for the EspoCRM service.", "testStrategy": "## Test Strategy\n\n### Phase 1: Verify Current Deployment\n1. **Check installed extension version**\n - Log into production EspoCRM (https://crm.prod.marcus-law.co.il)\n - Go to Administration → Extensions\n - Find SmartAssistant extension, note version number\n - Compare with `manifest.json` in this repo\n\n2. **Check deployed JavaScript**\n - In browser, open DevTools → Sources\n - Find `client/custom/modules/smart-assistant/src/views/floating-chat.js`\n - Search for \"timeout: 600000\" - should exist on line ~486\n - Search for \"slowHintTimer\" - should exist on line ~473\n - Search for \"SlowHint\" - should exist on line ~471\n\n### Phase 2: Browser Cache Verification\n1. **Hard refresh test**\n - Open production EspoCRM in incognito/private window\n - Navigate to any Case detail view\n - Open Shira chat panel\n - Check browser console for any JavaScript errors\n - Send a message that triggers slow processing (e.g., \"search the web for...\")\n - Verify \"חושבת...\" changes to \"חושבת... זה לוקח קצת יותר זמן...\" after 60 seconds\n\n2. **Cache headers check**\n - DevTools → Network → filter for `floating-chat.js`\n - Check `Cache-Control` and `ETag` headers\n - Note timestamp of file\n\n### Phase 3: Network Timeout Audit\n1. **Client-side timing**\n - Open DevTools → Network\n - Send message that takes >3 minutes\n - Watch the `SmartAssistant/action/chat` request\n - Note exact time when it fails (should be 600s, not 180s)\n - Check response status (timeout = no status, server error = 500/502/504)\n\n2. **Server logs correlation**\n - While test message is processing, tail these logs:\n ```bash\n # shira-hermes logs\n docker logs -f --since 1m\n \n # EspoCRM logs\n tail -f data/logs/espo-$(date +%Y-%m-%d).log\n \n # ai-gateway logs \n docker logs -f --since 1m\n ```\n - If logs show success but client shows error → client timeout\n - If logs show timeout/error → server timeout\n\n### Phase 4: Reproduction Test\n1. **Create slow scenario** (production or staging)\n - Open a complex case with many documents\n - Ask Shira: \"תעשי לי סיכום מלא של התיק כולל חיפוש באינטרנט על התקדים הרלוונטיים\"\n - This should trigger: delegation + memory search + web search + document analysis\n - Expected: Takes 2-4 minutes\n \n2. **Observe behavior**\n - ✅ At 60s: Text changes to \"חושבת... זה לוקח קצת יותר זמן...\"\n - ✅ At 180s: Still shows spinner (no error)\n - ✅ At 300s: Still shows spinner (no error)\n - ✅ At completion: Shows response, spinner removed\n - ❌ If error before 600s: Fix not deployed or server timeout\n\n### Phase 5: Post-Fix Validation\nAfter confirming fix is deployed:\n\n1. **Quick response test** (<10s)\n - \"מה שם התובע בתיק?\"\n - Should show \"חושבת...\" only, no slow hint\n\n2. **Medium response test** (30-90s)\n - \"הכן טיוטת תזכיר\"\n - Should show \"חושבת...\" then slow hint after 60s\n\n3. **Long response test** (>180s)\n - Complex multi-tool query\n - Should show slow hint, complete successfully\n\n4. **Error handling test**\n - Disconnect network after sending message\n - Should show \"שגיאה בתקשורת\" (real network error)\n - Reconnect and retry - should work\n\n### Success Criteria\n- ✅ Slow hint appears after 60 seconds on all requests\n- ✅ No \"Communication error\" before 600 seconds on working requests\n- ✅ Requests that take 3-5 minutes complete successfully\n- ✅ Timer cleanup confirmed (no memory leaks on rapid message sending)\n- ✅ i18n works in both Hebrew and English interface", - "status": "in-progress", + "status": "done", "dependencies": [], "priority": "medium", "subtasks": [], - "updatedAt": "2026-05-13T12:22:54.379Z" + "updatedAt": "2026-05-13T12:48:21.525Z" + }, + { + "id": "4", + "title": "Implement FreeDocumentGenerator, CaseFolderManager, AlertCalculator pending-status filter, CaseContextBuilder template query fix, and Document.markedForDeletion metadata", + "description": "Add comprehensive document management system with 6 new controller actions (createDocument, writeToFolder, createFolder, renameItem, moveItem, markForDeletion), integrate FreeDocumentGenerator for markdown-to-DOCX conversion, CaseFolderManager for secure network storage operations, fix AlertCalculator to filter pending-status cases, fix CaseContextBuilder template query bug, and add Document entity metadata for soft-delete tracking.", + "details": "## Implementation Overview\n\nThis task bundles several interconnected features that work together to provide Shira (the AI assistant) with comprehensive document management capabilities. The implementation consists of:\n\n1. **FreeDocumentGenerator** (`Services/FreeDocumentGenerator.php`, lines 1-276)\n - Creates DOCX documents from markdown text with Hebrew RTL support\n - Uses PhpOffice/PhpWord library for document generation\n - Handles markdown parsing: headers (# ## ###), bold (**text**), italic (*text*), bullet lists (- item)\n - Formats Hebrew dates in the header (e.g., \"14 במאי 2026\")\n - Automatically uploads to network storage via NetworkDocumentService\n - Returns: `{success, documentId, documentName, fileName, networkPath, message}`\n - Font: David, size 12, with proper RTL/bidi settings\n - File naming: `{YYYY-MM-DD} - {sanitized_title}.docx`\n\n2. **CaseFolderManager** (`Services/CaseFolderManager.php`, lines 1-335)\n - Security-focused wrapper around NetworkDocumentService\n - All operations constrained to case folder via path normalization\n - Rejects traversal attempts (../, null bytes, control characters)\n - Methods:\n - `writeFile(caseId, relPath, content)` - Write binary content to network storage\n - `createFolder(caseId, relPath)` - Create folders recursively\n - `renameItem(caseId, currentRelPath, newName)` - Rename files/folders (no slashes allowed)\n - `moveItem(caseId, sourceRelPath, targetRelPath)` - Move items between folders\n - `markForDeletion(caseId, fileRelPath)` - Soft-delete: move to \"מסמכים למחיקה\" folder with \"למחיקה - \" prefix\n - Path handling: accepts both case-relative paths and full storage-relative paths\n - Soft-delete updates linked Document entity with `markedForDeletionAt` and `markedForDeletionById`\n\n3. **AlertCalculator pending-status filter** (`Services/AlertCalculator.php`, lines 88-96)\n - Bug fix: suppress inactivity alerts for cases in legitimately inactive states\n - Skip `PendingDecision` status entirely (nothing lawyer can do until court rules)\n - Skip `PendingHearing` if future hearing is scheduled (`cNextHearing >= today`)\n - Prevents false-positive \"inactive case\" alerts during normal court waiting periods\n - Also broadened task queries to include `parentType=['Case', 'Contact']` (Task #1 fix)\n\n4. **CaseContextBuilder template query fix** (`Services/CaseContextBuilder.php`, lines 84-98)\n - Bug fix: broadened task queries to include Contact-linked tasks\n - Query now uses `OR` conditions: `['parentType' => 'Case', 'parentId' => $caseId]` OR `['parentType' => 'Contact', 'parentId' => $contactIds]`\n - Prevents false \"no preparation task\" alerts in Shira's daily standup\n - Production incident: preparation tasks had `parentType='Contact'` because Shira created them linked to contact entity\n\n5. **Document entity metadata** (`Resources/metadata/entityDefs/Document.json`)\n - New fields:\n - `markedForDeletionAt` (datetime, readOnly, tooltip)\n - `markedForDeletionBy` (link to User, readOnly)\n - New link: `markedForDeletionBy` (belongsTo User)\n - Enables audit trail for soft-deleted documents\n\n6. **Controller actions** (`Controllers/SmartAssistant.php`, lines 390-501)\n - `postActionCreateDocument` (lines 390-415): Generate free-form DOCX from markdown\n - Params: `caseId`, `title`, `body`, `documentType='letter'`, `recipient=null`\n - Delegates to FreeDocumentGenerator\n - `postActionWriteToFolder` (lines 417-437): Write binary file to case folder\n - Params: `caseId`, `relPath`, `contentBase64`\n - Decodes base64 content, delegates to CaseFolderManager.writeFile\n - `postActionCreateFolder` (lines 439-452): Create folder in case storage\n - Params: `caseId`, `relPath`\n - `postActionRenameItem` (lines 454-469): Rename file/folder\n - Params: `caseId`, `currentRelPath`, `newName`\n - `postActionMoveItem` (lines 471-486): Move file/folder\n - Params: `caseId`, `sourceRelPath`, `targetRelPath`\n - `postActionMarkForDeletion` (lines 488-501): Soft-delete file\n - Params: `caseId`, `fileRelPath`\n\n## Route Registration\n\nThe controller actions are **NOT** registered in `routes.json` yet (current file only has 11 routes, ends at line 98 with `executeTool`). The routes need to be added manually or through EspoCRM's automatic route discovery (controller methods prefixed with `postAction*` are auto-discovered).\n\n**Note:** EspoCRM automatically discovers controller actions following the naming convention `{verb}Action{ActionName}`, so these 6 actions should be accessible without explicit route registration at:\n- `POST /api/v1/SmartAssistant/action/createDocument`\n- `POST /api/v1/SmartAssistant/action/writeToFolder`\n- `POST /api/v1/SmartAssistant/action/createFolder`\n- `POST /api/v1/SmartAssistant/action/renameItem`\n- `POST /api/v1/SmartAssistant/action/moveItem`\n- `POST /api/v1/SmartAssistant/action/markForDeletion`\n\n## Shira-Hermes Integration\n\nThis extension pairs with shira-hermes (the AI backend, gitea.dev.marcus-law.co.il/espocrm-extensions/shira-hermes) which provides the user-facing tools that call these endpoints:\n- `create_document` tool → `createDocument` action\n- `write_to_case_folder` tool → `writeToFolder` action\n- `create_case_folder` tool → `createFolder` action\n- `rename_case_item` tool → `renameItem` action\n- `move_case_item` tool → `moveItem` action\n- `mark_for_deletion` tool → `markForDeletion` action\n\n## Files Modified\n\n1. `files/custom/Espo/Modules/SmartAssistant/Services/FreeDocumentGenerator.php` (new file, 276 lines)\n2. `files/custom/Espo/Modules/SmartAssistant/Services/CaseFolderManager.php` (new file, 335 lines)\n3. `files/custom/Espo/Modules/SmartAssistant/Services/AlertCalculator.php` (modified, +8 lines)\n4. `files/custom/Espo/Modules/SmartAssistant/Services/CaseContextBuilder.php` (modified, +14 lines)\n5. `files/custom/Espo/Modules/SmartAssistant/Controllers/SmartAssistant.php` (modified, +115 lines, 6 new actions)\n6. `files/custom/Espo/Modules/SmartAssistant/Resources/metadata/entityDefs/Document.json` (new file, 20 lines)\n\n## Dependencies\n\n- PhpOffice/PhpWord (already in composer.json)\n- NetworkStorageIntegration module (already exists)\n- NetworkDocumentService (used by FreeDocumentGenerator and CaseFolderManager)\n\n## Security Considerations\n\n1. **Path traversal prevention**: CaseFolderManager.normalize() rejects `..`, control characters, null bytes\n2. **Case folder isolation**: All operations verified to be within case root via assertWithinCaseRoot()\n3. **ACL checks**: All controller actions call checkAccess() to verify Case.read permission\n4. **Soft-delete only**: markForDeletion never physically deletes, preserves audit trail\n5. **Base64 validation**: writeToFolder validates base64 encoding before writing\n\n## Error Handling\n\n- Missing parameters → BadRequest exception with clear message\n- Path traversal attempts → Forbidden exception\n- File/folder not found → NotFound exception\n- Network storage failures → Error exception with context\n- Best-effort Document entity updates (logs warning on failure, doesn't block operation)", + "testStrategy": "## Testing Strategy\n\n### Phase 1: Unit Testing (Developer Console / API Client)\n\n#### 1.1 Test FreeDocumentGenerator via createDocument action\n```javascript\n// In EspoCRM developer console or via curl\nPOST /api/v1/SmartAssistant/action/createDocument\n{\n \"caseId\": \"{existing_case_id}\",\n \"title\": \"מכתב בדיקה\",\n \"body\": \"# כותרת ראשית\\n\\n**טקסט מודגש** וטקסט רגיל.\\n\\n- פריט ראשון\\n- פריט שני\\n\\n## כותרת משנית\\n\\nסיכום התיק.\",\n \"documentType\": \"letter\",\n \"recipient\": \"כבוד השופט\"\n}\n\n// Expected response:\n{\n \"success\": true,\n \"documentId\": \"...\",\n \"documentName\": \"מכתב בדיקה\",\n \"fileName\": \"2026-05-14 - מכתב בדיקה.docx\",\n \"networkPath\": \"תיקים/...\",\n \"message\": \"✅ המסמך \\\"מכתב בדיקה\\\" נוצר בהצלחה וצורף לתיק ולתיקיית הרשת.\"\n}\n\n// Verify:\n// 1. Document entity exists in EspoCRM with correct name and attachment\n// 2. DOCX file is in data/upload/{attachmentId}\n// 3. File is in network storage at networkPath\n// 4. Open DOCX and verify:\n// - Hebrew RTL rendering\n// - Date in Hebrew format at top\n// - Recipient line if provided\n// - Title is bold, centered\n// - Markdown formatting (bold, bullets, headers) is correct\n```\n\n#### 1.2 Test CaseFolderManager via writeToFolder action\n```javascript\nPOST /api/v1/SmartAssistant/action/writeToFolder\n{\n \"caseId\": \"{existing_case_id}\",\n \"relPath\": \"בדיקה/test.txt\",\n \"contentBase64\": \"VGVzdCBmaWxl\" // base64 for \"Test file\"\n}\n\n// Expected response:\n{\n \"success\": true,\n \"path\": \"{case_folder}/בדיקה/test.txt\",\n \"size\": 9\n}\n\n// Verify: File exists in network storage at returned path\n```\n\n#### 1.3 Test createFolder action\n```javascript\nPOST /api/v1/SmartAssistant/action/createFolder\n{\n \"caseId\": \"{existing_case_id}\",\n \"relPath\": \"תיקיית בדיקה/תת-תיקיה\"\n}\n\n// Expected response:\n{\n \"success\": true,\n \"path\": \"{case_folder}/תיקיית בדיקה/תת-תיקיה\",\n \"alreadyExisted\": false\n}\n\n// Verify: Folder exists in network storage\n```\n\n#### 1.4 Test renameItem action\n```javascript\nPOST /api/v1/SmartAssistant/action/renameItem\n{\n \"caseId\": \"{existing_case_id}\",\n \"currentRelPath\": \"test.txt\",\n \"newName\": \"renamed.txt\"\n}\n\n// Expected response:\n{\n \"success\": true,\n \"oldPath\": \"{case_folder}/test.txt\",\n \"newPath\": \"{case_folder}/renamed.txt\"\n}\n\n// Verify: File renamed in network storage\n```\n\n#### 1.5 Test moveItem action\n```javascript\nPOST /api/v1/SmartAssistant/action/moveItem\n{\n \"caseId\": \"{existing_case_id}\",\n \"sourceRelPath\": \"renamed.txt\",\n \"targetRelPath\": \"תיקיית בדיקה/renamed.txt\"\n}\n\n// Expected response:\n{\n \"success\": true,\n \"oldPath\": \"{case_folder}/renamed.txt\",\n \"newPath\": \"{case_folder}/תיקיית בדיקה/renamed.txt\"\n}\n\n// Verify: File moved in network storage\n```\n\n#### 1.6 Test markForDeletion action\n```javascript\nPOST /api/v1/SmartAssistant/action/markForDeletion\n{\n \"caseId\": \"{existing_case_id}\",\n \"fileRelPath\": \"תיקיית בדיקה/renamed.txt\"\n}\n\n// Expected response:\n{\n \"success\": true,\n \"originalPath\": \"{case_folder}/תיקיית בדיקה/renamed.txt\",\n \"newPath\": \"{case_folder}/מסמכים למחיקה/למחיקה - renamed.txt\",\n \"deletionFolder\": \"{case_folder}/מסמכים למחיקה\"\n}\n\n// Verify:\n// 1. File moved to deletion folder with prefix\n// 2. If Document entity existed, markedForDeletionAt and markedForDeletionById are set\n```\n\n### Phase 2: Security Testing\n\n#### 2.1 Test path traversal rejection\n```javascript\n// Should fail with Forbidden\nPOST /api/v1/SmartAssistant/action/writeToFolder\n{\n \"caseId\": \"{existing_case_id}\",\n \"relPath\": \"../../../etc/passwd\",\n \"contentBase64\": \"YXR0YWNr\"\n}\n\n// Expected: HTTP 403 Forbidden with message about traversal\n```\n\n#### 2.2 Test null byte injection\n```javascript\n// Should fail with BadRequest\nPOST /api/v1/SmartAssistant/action/createFolder\n{\n \"caseId\": \"{existing_case_id}\",\n \"relPath\": \"test\\u0000.txt\"\n}\n\n// Expected: HTTP 400 BadRequest with message about invalid characters\n```\n\n#### 2.3 Test cross-case access\n```javascript\n// Try to write to another case's folder using full path\nPOST /api/v1/SmartAssistant/action/writeToFolder\n{\n \"caseId\": \"{case_A_id}\",\n \"relPath\": \"{case_B_folder}/attack.txt\",\n \"contentBase64\": \"YXR0YWNr\"\n}\n\n// Expected: HTTP 403 Forbidden with message about being outside case folder\n```\n\n### Phase 3: Integration Testing (via Shira)\n\n#### 3.1 Test document creation flow\n1. Open Shira chat for a case\n2. Say: \"צור מכתב בדחיפות לבית המשפט עם בקשה לדחיית מועד\"\n3. Verify Shira calls `create_document` tool\n4. Verify document appears in case's Documents panel\n5. Verify document is in network storage\n6. Download and verify DOCX formatting\n\n#### 3.2 Test folder management\n1. Say: \"צור תיקיית עדויות בתיקייה הראשית\"\n2. Verify folder created via `create_case_folder` tool\n3. Say: \"העבר את הקובץ X לתיקיית עדויות\"\n4. Verify file moved via `move_case_item` tool\n\n#### 3.3 Test soft-delete\n1. Say: \"סמן את הקובץ Y למחיקה\"\n2. Verify file moved to \"מסמכים למחיקה\" folder\n3. Check Document entity has markedForDeletionAt timestamp\n\n### Phase 4: AlertCalculator Fix Verification\n\n#### 4.1 Test pending-status suppression\n1. Create a case with status `PendingDecision`\n2. Set cLastActivityAt to 35 days ago (past critical threshold)\n3. Call `/api/v1/SmartAssistant/action/alerts`\n4. Verify this case is **NOT** in the alerts list\n\n#### 4.2 Test pending-hearing with scheduled hearing\n1. Create a case with status `PendingHearing`\n2. Set cNextHearing to tomorrow\n3. Set cLastActivityAt to 35 days ago\n4. Call `/api/v1/SmartAssistant/action/alerts`\n5. Verify this case is **NOT** in the alerts list\n\n#### 4.3 Test pending-hearing WITHOUT scheduled hearing\n1. Create a case with status `PendingHearing`\n2. Set cNextHearing to null or past date\n3. Set cLastActivityAt to 35 days ago\n4. Call `/api/v1/SmartAssistant/action/alerts`\n5. Verify this case **IS** in the alerts list (critical severity)\n\n### Phase 5: CaseContextBuilder Fix Verification\n\n#### 5.1 Test Contact-linked task retrieval\n1. Create a case with one contact\n2. Create a task with parentType='Contact', parentId={contact_id}\n3. Call buildContext() for the case\n4. Verify the task appears in openTasks array\n\n#### 5.2 Test Shira daily standup\n1. Create a case with upcoming hearing (7 days from now)\n2. Create preparation task linked to case contact (not case itself)\n3. Trigger Shira's daily standup for the assigned lawyer\n4. Verify **NO** \"no preparation task\" false alert\n\n### Phase 6: Regression Testing\n\n1. Test existing controller actions still work:\n - `/SmartAssistant/action/chat`\n - `/SmartAssistant/action/readDocument`\n - `/SmartAssistant/action/listDocuments`\n - `/SmartAssistant/action/generateFromTemplate`\n\n2. Test ACL enforcement:\n - User without Case.read permission should get HTTP 403 on all new actions\n - User without Case.edit permission should still be able to read\n\n3. Test error handling:\n - Missing required params → clear BadRequest errors\n - Invalid caseId → NotFound error\n - Network storage disabled → graceful degradation (FreeDocumentGenerator succeeds, logs warning)\n\n### Phase 7: Production Deployment Checklist\n\n1. **Pre-deployment:**\n - Verify NetworkStorageIntegration extension is installed\n - Verify PhpOffice/PhpWord composer package is available\n - Run `php command.php rebuild` to clear cache\n\n2. **Post-deployment:**\n - Check `data/logs/espo-YYYY-MM-DD.log` for any errors\n - Test one document creation in production case\n - Verify network storage path is correct\n - Monitor Mattermost #פיתוח-שגיאות channel for 24 hours\n\n3. **Shira backend update:**\n - Deploy paired shira-hermes release with new tools\n - Both must be deployed in sync (EspoCRM extension first, then shira-hermes)\n - Test end-to-end flow: Shira → shira-hermes → EspoCRM → network storage\n\n### Success Criteria\n\n- [ ] All 6 controller actions respond correctly with valid inputs\n- [ ] Path traversal attempts are blocked with Forbidden error\n- [ ] Documents created via createDocument are properly formatted DOCX with Hebrew RTL\n- [ ] Soft-delete moves files to correct folder and updates Document entity\n- [ ] AlertCalculator no longer shows false alerts for PendingDecision/PendingHearing cases\n- [ ] CaseContextBuilder includes Contact-linked tasks in openTasks\n- [ ] No errors in EspoCRM logs after 24 hours of production use\n- [ ] Shira can successfully create, manage, and organize case documents", + "status": "in-progress", + "dependencies": [ + "1", + "2" + ], + "priority": "medium", + "subtasks": [], + "updatedAt": "2026-05-14T12:39:54.992Z" } ], "metadata": { "version": "1.0.0", - "lastModified": "2026-05-13T12:22:54.379Z", - "taskCount": 3, - "completedCount": 2, + "lastModified": "2026-05-14T12:39:55.000Z", + "taskCount": 4, + "completedCount": 3, "tags": [ "master" ] diff --git a/files/custom/Espo/Modules/SmartAssistant/Controllers/SmartAssistant.php b/files/custom/Espo/Modules/SmartAssistant/Controllers/SmartAssistant.php index cdd874c..f57c685 100644 --- a/files/custom/Espo/Modules/SmartAssistant/Controllers/SmartAssistant.php +++ b/files/custom/Espo/Modules/SmartAssistant/Controllers/SmartAssistant.php @@ -13,6 +13,8 @@ use Espo\Modules\SmartAssistant\Services\ActionExecutor; use Espo\Modules\SmartAssistant\Services\CaseMemoryService; use Espo\Modules\SmartAssistant\Services\DocumentAnalyzer; use Espo\Modules\SmartAssistant\Services\GenericTemplateGenerator; +use Espo\Modules\SmartAssistant\Services\FreeDocumentGenerator; +use Espo\Modules\SmartAssistant\Services\CaseFolderManager; use Espo\Entities\User; class SmartAssistant @@ -384,4 +386,117 @@ class SmartAssistant 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); + } } diff --git a/files/custom/Espo/Modules/SmartAssistant/Resources/i18n/fa_IR/Document.json b/files/custom/Espo/Modules/SmartAssistant/Resources/i18n/fa_IR/Document.json new file mode 100644 index 0000000..3fbe64f --- /dev/null +++ b/files/custom/Espo/Modules/SmartAssistant/Resources/i18n/fa_IR/Document.json @@ -0,0 +1,9 @@ +{ + "fields": { + "markedForDeletionAt": "סומן למחיקה בתאריך", + "markedForDeletionBy": "סומן למחיקה על ידי" + }, + "tooltips": { + "markedForDeletionAt": "מסמך זה סומן למחיקה על ידי שירה ומחכה לאישור ידני של עו\"ד למחיקה הפיזית." + } +} diff --git a/files/custom/Espo/Modules/SmartAssistant/Resources/metadata/entityDefs/Document.json b/files/custom/Espo/Modules/SmartAssistant/Resources/metadata/entityDefs/Document.json new file mode 100644 index 0000000..01872d7 --- /dev/null +++ b/files/custom/Espo/Modules/SmartAssistant/Resources/metadata/entityDefs/Document.json @@ -0,0 +1,19 @@ +{ + "fields": { + "markedForDeletionAt": { + "type": "datetime", + "readOnly": true, + "tooltip": true + }, + "markedForDeletionBy": { + "type": "link", + "readOnly": true + } + }, + "links": { + "markedForDeletionBy": { + "type": "belongsTo", + "entity": "User" + } + } +} diff --git a/files/custom/Espo/Modules/SmartAssistant/Services/AlertCalculator.php b/files/custom/Espo/Modules/SmartAssistant/Services/AlertCalculator.php index 66a29b4..49acb22 100644 --- a/files/custom/Espo/Modules/SmartAssistant/Services/AlertCalculator.php +++ b/files/custom/Espo/Modules/SmartAssistant/Services/AlertCalculator.php @@ -69,11 +69,12 @@ class AlertCalculator { $alerts = []; $now = new \DateTime(); + $today = $now->format('Y-m-d'); $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'); $cases = $this->entityManager->getRDBRepository('Case') - ->select(['id', 'name', 'status', 'assignedUserName', 'cLastActivityAt', 'createdAt']) + ->select(['id', 'name', 'status', 'assignedUserName', 'cLastActivityAt', 'createdAt', 'cNextHearing']) ->where([ 'status!=' => self::CLOSED_STATUSES, 'deleted' => false, 'OR' => [ @@ -83,6 +84,17 @@ class AlertCalculator ])->order('cLastActivityAt', 'ASC')->limit(0, 50)->find(); 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'); if (!$lastActivity) continue; $daysSince = $now->diff(new \DateTime($lastActivity))->days; diff --git a/files/custom/Espo/Modules/SmartAssistant/Services/CaseContextBuilder.php b/files/custom/Espo/Modules/SmartAssistant/Services/CaseContextBuilder.php index 7c9685d..bc8a41d 100644 --- a/files/custom/Espo/Modules/SmartAssistant/Services/CaseContextBuilder.php +++ b/files/custom/Espo/Modules/SmartAssistant/Services/CaseContextBuilder.php @@ -188,10 +188,20 @@ class CaseContextBuilder { $templates = []; $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) { - $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; } diff --git a/files/custom/Espo/Modules/SmartAssistant/Services/CaseFolderManager.php b/files/custom/Espo/Modules/SmartAssistant/Services/CaseFolderManager.php new file mode 100644 index 0000000..672b4cf --- /dev/null +++ b/files/custom/Espo/Modules/SmartAssistant/Services/CaseFolderManager.php @@ -0,0 +1,334 @@ +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() + ); + } + } +} diff --git a/files/custom/Espo/Modules/SmartAssistant/Services/FreeDocumentGenerator.php b/files/custom/Espo/Modules/SmartAssistant/Services/FreeDocumentGenerator.php new file mode 100644 index 0000000..95d56ee --- /dev/null +++ b/files/custom/Espo/Modules/SmartAssistant/Services/FreeDocumentGenerator.php @@ -0,0 +1,275 @@ +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)); + } +}