diff --git a/ProcessMaker/CaseRetention/CaseRetentionLogCsvWriter.php b/ProcessMaker/CaseRetention/CaseRetentionLogCsvWriter.php new file mode 100644 index 0000000000..c3f3f79151 --- /dev/null +++ b/ProcessMaker/CaseRetention/CaseRetentionLogCsvWriter.php @@ -0,0 +1,49 @@ +clone()->chunkById(500, function ($rows) use ($stream) { + foreach ($rows as $row) { + $caseIds = $row->case_ids; + if (is_array($caseIds)) { + $caseIds = json_encode($caseIds); + } + + fputcsv($stream, [ + $row->id, + $row->process_id, + $caseIds, + $row->deleted_count, + $row->total_time_taken, + self::csvDateColumn($row->deleted_at), + self::csvDateColumn($row->created_at), + ]); + } + }); + } + + public static function csvDateColumn(mixed $value): string + { + if ($value === null || $value === '') { + return ''; + } + if ($value instanceof \DateTimeInterface) { + return $value->format('Y-m-d H:i:s'); + } + + return (string) $value; + } +} diff --git a/ProcessMaker/CaseRetention/CaseRetentionLogQueryFilter.php b/ProcessMaker/CaseRetention/CaseRetentionLogQueryFilter.php new file mode 100644 index 0000000000..e6f9d6627f --- /dev/null +++ b/ProcessMaker/CaseRetention/CaseRetentionLogQueryFilter.php @@ -0,0 +1,39 @@ +getConnection()->getDriverName(); + + $query->where(function ($q) use ($like, $driver) { + $q->where('id', 'like', $like) + ->orWhere('process_id', 'like', $like) + ->orWhere('deleted_count', 'like', $like) + ->orWhere('total_time_taken', 'like', $like); + + if ($driver === 'pgsql') { + $q->orWhereRaw('case_ids::text ILIKE ?', [$like]); + } else { + $q->orWhereRaw('CAST(case_ids AS CHAR) LIKE ?', [$like]); + } + }); + } +} diff --git a/ProcessMaker/Http/Controllers/Api/CasesRetentionController.php b/ProcessMaker/Http/Controllers/Api/CasesRetentionController.php index 218b7e0439..4a3ac93795 100644 --- a/ProcessMaker/Http/Controllers/Api/CasesRetentionController.php +++ b/ProcessMaker/Http/Controllers/Api/CasesRetentionController.php @@ -2,11 +2,17 @@ namespace ProcessMaker\Http\Controllers\Api; +use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Storage; +use Illuminate\Support\Str; +use ProcessMaker\CaseRetention\CaseRetentionLogQueryFilter; use ProcessMaker\Http\Controllers\Controller; use ProcessMaker\Http\Resources\ApiCollection; +use ProcessMaker\Jobs\DownloadCaseRetentionLogExport; use ProcessMaker\Models\CaseRetentionPolicyLog; +use Symfony\Component\HttpFoundation\BinaryFileResponse; class CasesRetentionController extends Controller { @@ -20,40 +26,11 @@ class CasesRetentionController extends Controller 'created_at', ]; - /** - * Search log id, process_id, numeric columns, and JSON case_ids — not date columns. - */ - private function applyLogsFilter($query, string $term): void - { - $term = trim($term); - if ($term === '') { - return; - } - - $like = '%' . $term . '%'; - $driver = $query->getConnection()->getDriverName(); - - $query->where(function ($q) use ($like, $driver) { - $q->where('id', 'like', $like) - ->orWhere('process_id', 'like', $like) - ->orWhere('deleted_count', 'like', $like) - ->orWhere('total_time_taken', 'like', $like); - - if ($driver === 'pgsql') { - $q->orWhereRaw('case_ids::text ILIKE ?', [$like]); - } else { - $q->orWhereRaw('CAST(case_ids AS CHAR) LIKE ?', [$like]); - } - }); - } - public function logs(Request $request): ApiCollection { $query = CaseRetentionPolicyLog::query(); - if ($request->filled('filter')) { - $this->applyLogsFilter($query, (string) $request->input('filter')); - } + CaseRetentionLogQueryFilter::applyIfFilled($query, $request->input('filter')); $orderBy = $request->input('order_by'); if ($orderBy && in_array($orderBy, self::LOG_SORT_COLUMNS, true)) { @@ -73,4 +50,43 @@ public function logs(Request $request): ApiCollection return new ApiCollection($response); } + + /** + * Queue a CSV export to disk; user receives a database + broadcast notification with a signed download URL when ready. + */ + public function queueExportCsv(Request $request): JsonResponse + { + $request->validate([ + 'filter' => ['sometimes', 'nullable', 'string'], + ]); + + $exportToken = (string) Str::uuid(); + DownloadCaseRetentionLogExport::dispatch($request->user(), $request->input('filter'), $exportToken); + + return response()->json([ + 'success' => true, + 'message' => __('The file is processing. You may continue working while the log file compiles.'), + ]); + } + + /** + * Signed URL only (no API token). Link is included in the export-ready notification when the job finishes. + */ + public function downloadExportFile(Request $request, string $token): BinaryFileResponse + { + if (!Str::isUuid($token)) { + abort(404); + } + + $relativePath = 'exports/case-retention/' . $token . '.csv'; + if (!Storage::disk('local')->exists($relativePath)) { + abort(404); + } + + return response()->download( + Storage::disk('local')->path($relativePath), + 'case_retention_policy_logs.csv', + ['Content-Type' => 'text/csv; charset=UTF-8'], + )->deleteFileAfterSend(true); + } } diff --git a/ProcessMaker/Jobs/DownloadCaseRetentionLogExport.php b/ProcessMaker/Jobs/DownloadCaseRetentionLogExport.php new file mode 100644 index 0000000000..bb82b05f24 --- /dev/null +++ b/ProcessMaker/Jobs/DownloadCaseRetentionLogExport.php @@ -0,0 +1,89 @@ +filter; + } + + public function handle(): void + { + if (!Str::isUuid($this->exportToken)) { + $this->user->notifyNow( + new CaseRetentionLogExportNotification(false, __('Invalid export token.'), null), + ); + + return; + } + + $relativePath = 'exports/case-retention/' . $this->exportToken . '.csv'; + + try { + Storage::disk('local')->makeDirectory('exports/case-retention'); + + $fullPath = Storage::disk('local')->path($relativePath); + $handle = fopen($fullPath, 'w'); + if ($handle === false) { + throw new \RuntimeException('Could not open export file for writing.'); + } + + try { + $query = CaseRetentionPolicyLog::query(); + CaseRetentionLogQueryFilter::applyIfFilled($query, $this->filter); + CaseRetentionLogCsvWriter::writeQueryToStream($query, $handle); + } finally { + fclose($handle); + } + + $expires = now()->addHours(self::LINK_TTL_HOURS); + $url = URL::temporarySignedRoute( + 'api.cases-retention.logs.export.download', + $expires, + ['token' => $this->exportToken], + ); + + $message = __('Click on the link to download the log file. This link will be available until ' . $expires->toString()); + + $this->user->notifyNow( + new CaseRetentionLogExportNotification(true, $message, $url), + ); + } catch (Throwable $e) { + Storage::disk('local')->delete($relativePath); + $this->user->notifyNow( + new CaseRetentionLogExportNotification(false, $e->getMessage(), null), + ); + } + } +} diff --git a/ProcessMaker/Notifications/CaseRetentionLogExportNotification.php b/ProcessMaker/Notifications/CaseRetentionLogExportNotification.php new file mode 100644 index 0000000000..0a3f9f34ff --- /dev/null +++ b/ProcessMaker/Notifications/CaseRetentionLogExportNotification.php @@ -0,0 +1,83 @@ + + */ + public function via($notifiable) + { + return ['broadcast', NotificationChannel::class]; + } + + /** + * @param mixed $notifiable + */ + public function toDatabase($notifiable) + { + return $this->payloadData(); + } + + /** + * @param mixed $notifiable + */ + public function toBroadcast($notifiable) + { + return new BroadcastMessage($this->payloadData()); + } + + /** + * Full payload shape for Echo + bell (matches API notification resource). + * + * @return array + */ + public function broadcastWith() + { + $now = now()->toIso8601String(); + + return [ + 'id' => (string) $this->id, + 'type' => self::class, + 'data' => $this->payloadData(), + 'read_at' => null, + 'url' => $this->downloadUrl, + 'created_at' => $now, + 'updated_at' => $now, + ]; + } + + /** + * @return array + */ + private function payloadData(): array + { + return [ + 'type' => $this->success ? 'CASE_RETENTION_LOG_EXPORT_READY' : 'CASE_RETENTION_LOG_EXPORT_FAILED', + 'message' => $this->message, + 'name' => $this->success ? __('Case retention logs') : $this->message, + 'url' => $this->downloadUrl, + 'dateTime' => now()->toIso8601String(), + ]; + } + + public function broadcastType() + { + return str_replace('\\', '.', self::class); + } +} diff --git a/config/notifications.php b/config/notifications.php index aeac8e7f60..fdaab22365 100644 --- a/config/notifications.php +++ b/config/notifications.php @@ -12,5 +12,7 @@ 'ERROR_EXECUTION' => '{{- subject }} caused an error', 'COMMENT' => '{{- user}} commented on {{- subject}}', 'ProcessMaker\\Notifications\\ImportReady' => 'Imported {{- subject }}', + 'CASE_RETENTION_LOG_EXPORT_READY' => 'Case retention log export is ready. Click to download.', + 'CASE_RETENTION_LOG_EXPORT_FAILED' => '{{- subject }}', ], ]; diff --git a/resources/js/admin/cases-retention/index.js b/resources/js/admin/cases-retention/index.js index e03a86808f..0491b8ce8c 100644 --- a/resources/js/admin/cases-retention/index.js +++ b/resources/js/admin/cases-retention/index.js @@ -12,7 +12,28 @@ const casesRetentionApp = new window.Vue({ }, methods: { downloadRetentionLogs() { - console.log("downloadRetentionLogs"); + const params = new URLSearchParams(); + if (this.filter) { + params.set("filter", this.filter); + } + const qs = params.toString(); + const path = qs ? `cases-retention/logs/export?${qs}` : "cases-retention/logs/export"; + + ProcessMaker.apiClient + .get(path) + .then((response) => { + if (response.data.success) { + ProcessMaker.alert(response.data.message, "success"); + } else { + ProcessMaker.alert( + response.data.message || "Unable to start export.", + "danger", + ); + } + }) + .catch(() => { + ProcessMaker.alert("Unable to download logs.", "danger"); + }); }, reload() { this.$refs.casesRetentionLogs.reload(); diff --git a/resources/js/bootstrap.js b/resources/js/bootstrap.js index 0ea799903c..8df87873b1 100644 --- a/resources/js/bootstrap.js +++ b/resources/js/bootstrap.js @@ -142,9 +142,10 @@ window.ProcessMaker = { * @returns {void} */ pushNotification(notification) { - if (window.ProcessMaker.notifications.filter((x) => x.id === notification).length === 0) { - window.ProcessMaker.notifications.push(notification); + if (!notification || window.ProcessMaker.notifications.some((x) => x.id === notification.id)) { + return; } + window.ProcessMaker.notifications.push(notification); }, /** diff --git a/resources/js/common/sessionSync.js b/resources/js/common/sessionSync.js index 19ea8f41d0..a03e476e36 100644 --- a/resources/js/common/sessionSync.js +++ b/resources/js/common/sessionSync.js @@ -519,6 +519,19 @@ export const initSessionSync = ({ return localDeviceId && localDeviceId === remoteDeviceId; }; + // initSessionSync runs from bootstrap.js before app-layout.js replaces + // ProcessMaker.alert (2-arg stub → navbar alert with msgLink). Resolve at event time + // so export/download toasts get alertLink and show the Download anchor. + const showNavbarAlert = (...args) => { + if (typeof window.ProcessMaker?.alert === "function") { + window.ProcessMaker.alert(...args); + return; + } + if (typeof alert === "function") { + alert(...args); + } + }; + if (Echo) { Echo.private(`ProcessMaker.Models.User.${userId}`) .notification((token) => { @@ -558,15 +571,12 @@ export const initSessionSync = ({ } }) .listen(".SecurityLogDownloadJobCompleted", (e) => { - if (typeof alert !== "function") { - return; - } if (e.success) { const { link } = e; const { message } = e; - alert(message, "success", 0, false, false, link); + showNavbarAlert(message, "success", 0, false, false, link); } else { - alert(e.message, "warning"); + showNavbarAlert(e.message, "warning"); } }); } diff --git a/resources/js/next/config/notifications.js b/resources/js/next/config/notifications.js index 50627949db..84ac10ee90 100644 --- a/resources/js/next/config/notifications.js +++ b/resources/js/next/config/notifications.js @@ -6,9 +6,10 @@ export default () => { const notifications = []; const pushNotification = (notification) => { - if (notifications.filter((x) => x.id === notification).length === 0) { - notifications.push(notification); + if (!notification || notifications.some((x) => x.id === notification.id)) { + return; } + notifications.push(notification); }; const removeNotifications = (messageIds = [], urls = []) => apiClient.put("/read_notifications", { message_ids: messageIds, routes: urls }).then(() => { diff --git a/resources/js/notifications/components/notifications.vue b/resources/js/notifications/components/notifications.vue index f63b63b097..88cf46d065 100644 --- a/resources/js/notifications/components/notifications.vue +++ b/resources/js/notifications/components/notifications.vue @@ -264,10 +264,13 @@ export default { return "link"; }, hasMessages() { - return this.totalMessages > 0; + const localUnread = this.messages.filter((m) => !m.read_at).length; + return this.totalMessages > 0 || localUnread > 0; }, displayTotalCount() { - return this.totalMessages > 10 ? "10+" : this.totalMessages; + const localUnread = this.messages.filter((m) => !m.read_at).length; + const count = Math.max(this.totalMessages, localUnread); + return count > 10 ? "10+" : count; }, }, watch: { diff --git a/routes/api.php b/routes/api.php index 8617b3de7e..011ca9d41d 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1,5 +1,6 @@ name('connector-slack.validate-token'); // Cases Retention + Route::get('cases-retention/logs/export', [CasesRetentionController::class, 'queueExportCsv'])->name('cases-retention.logs.export'); Route::get('cases-retention/logs', [CasesRetentionController::class, 'logs'])->name('cases-retention.logs'); }); + +Route::middleware([ValidateSignature::class, 'setlocale'])->prefix('api/1.0')->name('api.')->group(function () { + Route::get('cases-retention/logs/export/download/{token}', [CasesRetentionController::class, 'downloadExportFile']) + ->name('cases-retention.logs.export.download'); +}); + Route::post('devlink/bundle-updated/{bundle}/{token}', [DevLinkController::class, 'bundleUpdated'])->name('devlink.bundle-updated'); diff --git a/tests/Feature/Api/CasesRetentionLogsExportTest.php b/tests/Feature/Api/CasesRetentionLogsExportTest.php new file mode 100644 index 0000000000..53868e876b --- /dev/null +++ b/tests/Feature/Api/CasesRetentionLogsExportTest.php @@ -0,0 +1,89 @@ +create([ + 'is_administrator' => true, + ]); + + $response = $this->actingAs($user, 'api') + ->get('/api/1.0/cases-retention/logs/export'); + + $response->assertOk(); + $response->assertJson(['success' => true]); + Bus::assertDispatched(DownloadCaseRetentionLogExport::class); + } + + public function testExportPassesFilterToJob(): void + { + Bus::fake(); + + $user = User::factory()->create([ + 'is_administrator' => true, + ]); + + $this->actingAs($user, 'api') + ->get('/api/1.0/cases-retention/logs/export?filter=99'); + + Bus::assertDispatched(DownloadCaseRetentionLogExport::class, function (DownloadCaseRetentionLogExport $job) { + return $job->getFilter() === '99'; + }); + } + + public function testSignedDownloadStreamsCsvFile(): void + { + Storage::fake('local'); + $token = (string) Str::uuid(); + $path = 'exports/case-retention/' . $token . '.csv'; + Storage::disk('local')->makeDirectory('exports/case-retention'); + Storage::disk('local')->put($path, "\xEF\xBB\xBF1,2,3"); + + $url = URL::temporarySignedRoute( + 'api.cases-retention.logs.export.download', + now()->addMinutes(10), + ['token' => $token], + ); + + $response = $this->get($url); + + $response->assertOk(); + $this->assertStringContainsString('text/csv', (string) $response->headers->get('content-type')); + } + + public function testExportJobSendsNotificationWithDownloadUrl(): void + { + Notification::fake(); + Storage::fake('local'); + + $user = User::factory()->create([ + 'is_administrator' => true, + ]); + + $token = (string) Str::uuid(); + $job = new DownloadCaseRetentionLogExport($user, null, $token); + $job->handle(); + + Notification::assertSentTo($user, CaseRetentionLogExportNotification::class, function (CaseRetentionLogExportNotification $n) { + return $n->broadcastWith()['url'] !== null; + }); + } +}