From 6d51ee56c5bee8a4a8d1c338419f5487919e303b Mon Sep 17 00:00:00 2001 From: sanja <52755494+sanjacornelius@users.noreply.github.com> Date: Wed, 25 Mar 2026 12:26:46 -0700 Subject: [PATCH 1/4] Add CSV export job and signed download URL Add async CSV export for case retention logs: introduces DownloadCaseRetentionLogExport job to stream query results to disk, two broadcast events (CaseRetentionLogExportReady / CaseRetentionLogExportFailed) to notify users, and controller endpoints to queue the export and serve a signed download URL. The controller uses CaseRetentionLogQueryFilter to apply the current filter when queuing and downloading; temporary signed URLs are generated with a 24-hour TTL. Frontend changes wire a button to hit the queue endpoint and session sync listeners show success/failure alerts with the download link. A feature test was added to verify job dispatch, filter propagation, and signed download streaming. --- .../Events/CaseRetentionLogExportFailed.php | 52 ++++++++++++ .../Events/CaseRetentionLogExportReady.php | 52 ++++++++++++ .../Api/CasesRetentionController.php | 76 ++++++++++------- .../Jobs/DownloadCaseRetentionLogExport.php | 84 +++++++++++++++++++ resources/js/admin/cases-retention/index.js | 23 ++++- resources/js/common/sessionSync.js | 18 ++++ routes/api.php | 8 ++ .../Api/CasesRetentionLogsExportTest.php | 69 +++++++++++++++ 8 files changed, 351 insertions(+), 31 deletions(-) create mode 100644 ProcessMaker/Events/CaseRetentionLogExportFailed.php create mode 100644 ProcessMaker/Events/CaseRetentionLogExportReady.php create mode 100644 ProcessMaker/Jobs/DownloadCaseRetentionLogExport.php create mode 100644 tests/Feature/Api/CasesRetentionLogsExportTest.php diff --git a/ProcessMaker/Events/CaseRetentionLogExportFailed.php b/ProcessMaker/Events/CaseRetentionLogExportFailed.php new file mode 100644 index 0000000000..fac0bd6813 --- /dev/null +++ b/ProcessMaker/Events/CaseRetentionLogExportFailed.php @@ -0,0 +1,52 @@ +user = $user; + $this->success = $success; + $this->message = $message; + $this->link = $link; + } + + public function broadcastOn() + { + return new PrivateChannel("ProcessMaker.Models.User.{$this->user->id}"); + } + + public function broadcastAs() + { + return 'CaseRetentionLogExportFailed'; + } + + public function broadcastWith() + { + return [ + 'success' => $this->success, + 'message' => $this->message, + 'link' => $this->link, + ]; + } +} diff --git a/ProcessMaker/Events/CaseRetentionLogExportReady.php b/ProcessMaker/Events/CaseRetentionLogExportReady.php new file mode 100644 index 0000000000..201555f3ff --- /dev/null +++ b/ProcessMaker/Events/CaseRetentionLogExportReady.php @@ -0,0 +1,52 @@ +user = $user; + $this->success = $success; + $this->message = $message; + $this->link = $link; + } + + public function broadcastOn() + { + return new PrivateChannel("ProcessMaker.Models.User.{$this->user->id}"); + } + + public function broadcastAs() + { + return 'CaseRetentionLogExportReady'; + } + + public function broadcastWith() + { + return [ + 'success' => $this->success, + 'message' => $this->message, + 'link' => $this->link, + ]; + } +} diff --git a/ProcessMaker/Http/Controllers/Api/CasesRetentionController.php b/ProcessMaker/Http/Controllers/Api/CasesRetentionController.php index 218b7e0439..d3383fb1cd 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 signed download link over the websocket 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 broadcast to the requesting user 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..930ad82ebe --- /dev/null +++ b/ProcessMaker/Jobs/DownloadCaseRetentionLogExport.php @@ -0,0 +1,84 @@ +filter; + } + + public function handle(): void + { + if (!Str::isUuid($this->exportToken)) { + event(new CaseRetentionLogExportFailed($this->user, false, 'Invalid export token.')); + + 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()); + + event(new CaseRetentionLogExportReady($this->user, true, $message, $url)); + } catch (Throwable $e) { + Storage::disk('local')->delete($relativePath); + event(new CaseRetentionLogExportFailed($this->user, false, $e->getMessage())); + } + } +} 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/common/sessionSync.js b/resources/js/common/sessionSync.js index 19ea8f41d0..a45c5d380f 100644 --- a/resources/js/common/sessionSync.js +++ b/resources/js/common/sessionSync.js @@ -568,6 +568,24 @@ export const initSessionSync = ({ } else { alert(e.message, "warning"); } + }) + .listen(".CaseRetentionLogExportReady", (e) => { + if (typeof alert !== "function") { + return; + } + if (e.success) { + const { link } = e; + const { message } = e; + alert(message, "success", 0, false, false, link); + } else { + alert(e.message, "warning"); + } + }) + .listen(".CaseRetentionLogExportFailed", (e) => { + if (typeof alert !== "function") { + return; + } + alert(e.message, "warning"); }); } 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..50dbf4ed70 --- /dev/null +++ b/tests/Feature/Api/CasesRetentionLogsExportTest.php @@ -0,0 +1,69 @@ +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')); + } +} From accef990eb72115e306735d6d14ab645056d332f Mon Sep 17 00:00:00 2001 From: sanja <52755494+sanjacornelius@users.noreply.github.com> Date: Wed, 25 Mar 2026 12:28:13 -0700 Subject: [PATCH 2/4] Add CaseRetention log CSV writer and filter Introduce two new utilities for CaseRetention logs: - CaseRetentionLogCsvWriter: streams a query to a writable stream as CSV (no header), prepends a UTF-8 BOM, chunks results (500 rows), JSON-encodes array case_ids when present, and normalizes date columns using a csvDateColumn helper (returns empty string for nulls, formats DateTimeInterface as 'Y-m-d H:i:s'). - CaseRetentionLogQueryFilter: provides applyIfFilled (no-op for empty input) and apply methods to filter a Builder by term across id, process_id, numeric columns, and JSON case_ids; uses driver-specific SQL (ILIKE for pg, CAST to CHAR otherwise). --- .../CaseRetentionLogCsvWriter.php | 49 +++++++++++++++++++ .../CaseRetentionLogQueryFilter.php | 39 +++++++++++++++ 2 files changed, 88 insertions(+) create mode 100644 ProcessMaker/CaseRetention/CaseRetentionLogCsvWriter.php create mode 100644 ProcessMaker/CaseRetention/CaseRetentionLogQueryFilter.php 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]); + } + }); + } +} From cb600605c06d4ad1228b4e71ce6e5482cd096ead Mon Sep 17 00:00:00 2001 From: sanja <52755494+sanjacornelius@users.noreply.github.com> Date: Thu, 26 Mar 2026 09:59:37 -0700 Subject: [PATCH 3/4] Remove case retention broadcast events Delete two event classes: CaseRetentionLogExportReady and CaseRetentionLogExportFailed. Both implemented ShouldBroadcastNow and broadcasted export status to a PrivateChannel for a user with payload {success, message, link}. These removals clean up legacy broadcasting events related to case retention log exports. --- .../Events/CaseRetentionLogExportFailed.php | 52 ------------------- .../Events/CaseRetentionLogExportReady.php | 52 ------------------- 2 files changed, 104 deletions(-) delete mode 100644 ProcessMaker/Events/CaseRetentionLogExportFailed.php delete mode 100644 ProcessMaker/Events/CaseRetentionLogExportReady.php diff --git a/ProcessMaker/Events/CaseRetentionLogExportFailed.php b/ProcessMaker/Events/CaseRetentionLogExportFailed.php deleted file mode 100644 index fac0bd6813..0000000000 --- a/ProcessMaker/Events/CaseRetentionLogExportFailed.php +++ /dev/null @@ -1,52 +0,0 @@ -user = $user; - $this->success = $success; - $this->message = $message; - $this->link = $link; - } - - public function broadcastOn() - { - return new PrivateChannel("ProcessMaker.Models.User.{$this->user->id}"); - } - - public function broadcastAs() - { - return 'CaseRetentionLogExportFailed'; - } - - public function broadcastWith() - { - return [ - 'success' => $this->success, - 'message' => $this->message, - 'link' => $this->link, - ]; - } -} diff --git a/ProcessMaker/Events/CaseRetentionLogExportReady.php b/ProcessMaker/Events/CaseRetentionLogExportReady.php deleted file mode 100644 index 201555f3ff..0000000000 --- a/ProcessMaker/Events/CaseRetentionLogExportReady.php +++ /dev/null @@ -1,52 +0,0 @@ -user = $user; - $this->success = $success; - $this->message = $message; - $this->link = $link; - } - - public function broadcastOn() - { - return new PrivateChannel("ProcessMaker.Models.User.{$this->user->id}"); - } - - public function broadcastAs() - { - return 'CaseRetentionLogExportReady'; - } - - public function broadcastWith() - { - return [ - 'success' => $this->success, - 'message' => $this->message, - 'link' => $this->link, - ]; - } -} From 20b8eaf1a8325f24e5eeff2f261da38d4e867b76 Mon Sep 17 00:00:00 2001 From: sanja <52755494+sanjacornelius@users.noreply.github.com> Date: Thu, 26 Mar 2026 10:00:59 -0700 Subject: [PATCH 4/4] Add notification for case retention export Introduce CaseRetentionLogExportNotification and switch the export job to send database + broadcast notifications (notifyNow) with the signed download URL instead of firing events. Update notification strings in config. Adjust frontend notification handling: de-duplicate pushes, add showNavbarAlert resolver so export/download toasts include the download link, and update the Vue notification component to consider local unread counts for badge/display. Add a test to assert the export job sends a notification containing a download URL. Minor docblock tweaks in the controller. --- .../Api/CasesRetentionController.php | 4 +- .../Jobs/DownloadCaseRetentionLogExport.php | 15 ++-- .../CaseRetentionLogExportNotification.php | 83 +++++++++++++++++++ config/notifications.php | 2 + resources/js/bootstrap.js | 5 +- resources/js/common/sessionSync.js | 38 ++++----- resources/js/next/config/notifications.js | 5 +- .../components/notifications.vue | 7 +- .../Api/CasesRetentionLogsExportTest.php | 20 +++++ 9 files changed, 143 insertions(+), 36 deletions(-) create mode 100644 ProcessMaker/Notifications/CaseRetentionLogExportNotification.php diff --git a/ProcessMaker/Http/Controllers/Api/CasesRetentionController.php b/ProcessMaker/Http/Controllers/Api/CasesRetentionController.php index d3383fb1cd..4a3ac93795 100644 --- a/ProcessMaker/Http/Controllers/Api/CasesRetentionController.php +++ b/ProcessMaker/Http/Controllers/Api/CasesRetentionController.php @@ -52,7 +52,7 @@ public function logs(Request $request): ApiCollection } /** - * Queue a CSV export to disk; user receives a signed download link over the websocket when ready. + * 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 { @@ -70,7 +70,7 @@ public function queueExportCsv(Request $request): JsonResponse } /** - * Signed URL only (no API token). Link is broadcast to the requesting user when the job finishes. + * 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 { diff --git a/ProcessMaker/Jobs/DownloadCaseRetentionLogExport.php b/ProcessMaker/Jobs/DownloadCaseRetentionLogExport.php index 930ad82ebe..bb82b05f24 100644 --- a/ProcessMaker/Jobs/DownloadCaseRetentionLogExport.php +++ b/ProcessMaker/Jobs/DownloadCaseRetentionLogExport.php @@ -12,10 +12,9 @@ use Illuminate\Support\Str; use ProcessMaker\CaseRetention\CaseRetentionLogCsvWriter; use ProcessMaker\CaseRetention\CaseRetentionLogQueryFilter; -use ProcessMaker\Events\CaseRetentionLogExportFailed; -use ProcessMaker\Events\CaseRetentionLogExportReady; use ProcessMaker\Models\CaseRetentionPolicyLog; use ProcessMaker\Models\User; +use ProcessMaker\Notifications\CaseRetentionLogExportNotification; use Throwable; class DownloadCaseRetentionLogExport implements ShouldQueue @@ -42,7 +41,9 @@ public function getFilter(): ?string public function handle(): void { if (!Str::isUuid($this->exportToken)) { - event(new CaseRetentionLogExportFailed($this->user, false, 'Invalid export token.')); + $this->user->notifyNow( + new CaseRetentionLogExportNotification(false, __('Invalid export token.'), null), + ); return; } @@ -75,10 +76,14 @@ public function handle(): void $message = __('Click on the link to download the log file. This link will be available until ' . $expires->toString()); - event(new CaseRetentionLogExportReady($this->user, true, $message, $url)); + $this->user->notifyNow( + new CaseRetentionLogExportNotification(true, $message, $url), + ); } catch (Throwable $e) { Storage::disk('local')->delete($relativePath); - event(new CaseRetentionLogExportFailed($this->user, false, $e->getMessage())); + $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/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 a45c5d380f..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,34 +571,13 @@ 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); - } else { - alert(e.message, "warning"); - } - }) - .listen(".CaseRetentionLogExportReady", (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"); - } - }) - .listen(".CaseRetentionLogExportFailed", (e) => { - if (typeof alert !== "function") { - return; + showNavbarAlert(e.message, "warning"); } - alert(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/tests/Feature/Api/CasesRetentionLogsExportTest.php b/tests/Feature/Api/CasesRetentionLogsExportTest.php index 50dbf4ed70..53868e876b 100644 --- a/tests/Feature/Api/CasesRetentionLogsExportTest.php +++ b/tests/Feature/Api/CasesRetentionLogsExportTest.php @@ -4,11 +4,13 @@ use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Bus; +use Illuminate\Support\Facades\Notification; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\URL; use Illuminate\Support\Str; use ProcessMaker\Jobs\DownloadCaseRetentionLogExport; use ProcessMaker\Models\User; +use ProcessMaker\Notifications\CaseRetentionLogExportNotification; use Tests\TestCase; class CasesRetentionLogsExportTest extends TestCase @@ -66,4 +68,22 @@ public function testSignedDownloadStreamsCsvFile(): void $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; + }); + } }