Skip to content

Commit 6d51ee5

Browse files
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.
1 parent 5a6c16f commit 6d51ee5

8 files changed

Lines changed: 351 additions & 31 deletions

File tree

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
<?php
2+
3+
namespace ProcessMaker\Events;
4+
5+
use Illuminate\Broadcasting\InteractsWithSockets;
6+
use Illuminate\Broadcasting\PrivateChannel;
7+
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
8+
use Illuminate\Foundation\Events\Dispatchable;
9+
use Illuminate\Queue\SerializesModels;
10+
use ProcessMaker\Models\User;
11+
12+
class CaseRetentionLogExportFailed implements ShouldBroadcastNow
13+
{
14+
use Dispatchable;
15+
use InteractsWithSockets;
16+
use SerializesModels;
17+
18+
public $user;
19+
20+
private bool $success;
21+
22+
private ?string $link;
23+
24+
private ?string $message;
25+
26+
public function __construct(User $user, bool $success, string $message, ?string $link = null)
27+
{
28+
$this->user = $user;
29+
$this->success = $success;
30+
$this->message = $message;
31+
$this->link = $link;
32+
}
33+
34+
public function broadcastOn()
35+
{
36+
return new PrivateChannel("ProcessMaker.Models.User.{$this->user->id}");
37+
}
38+
39+
public function broadcastAs()
40+
{
41+
return 'CaseRetentionLogExportFailed';
42+
}
43+
44+
public function broadcastWith()
45+
{
46+
return [
47+
'success' => $this->success,
48+
'message' => $this->message,
49+
'link' => $this->link,
50+
];
51+
}
52+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
<?php
2+
3+
namespace ProcessMaker\Events;
4+
5+
use Illuminate\Broadcasting\InteractsWithSockets;
6+
use Illuminate\Broadcasting\PrivateChannel;
7+
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
8+
use Illuminate\Foundation\Events\Dispatchable;
9+
use Illuminate\Queue\SerializesModels;
10+
use ProcessMaker\Models\User;
11+
12+
class CaseRetentionLogExportReady implements ShouldBroadcastNow
13+
{
14+
use Dispatchable;
15+
use InteractsWithSockets;
16+
use SerializesModels;
17+
18+
public $user;
19+
20+
private bool $success;
21+
22+
private ?string $link;
23+
24+
private ?string $message;
25+
26+
public function __construct(User $user, bool $success, string $message, ?string $link = null)
27+
{
28+
$this->user = $user;
29+
$this->success = $success;
30+
$this->message = $message;
31+
$this->link = $link;
32+
}
33+
34+
public function broadcastOn()
35+
{
36+
return new PrivateChannel("ProcessMaker.Models.User.{$this->user->id}");
37+
}
38+
39+
public function broadcastAs()
40+
{
41+
return 'CaseRetentionLogExportReady';
42+
}
43+
44+
public function broadcastWith()
45+
{
46+
return [
47+
'success' => $this->success,
48+
'message' => $this->message,
49+
'link' => $this->link,
50+
];
51+
}
52+
}

ProcessMaker/Http/Controllers/Api/CasesRetentionController.php

Lines changed: 46 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,17 @@
22

33
namespace ProcessMaker\Http\Controllers\Api;
44

5+
use Illuminate\Http\JsonResponse;
56
use Illuminate\Http\Request;
67
use Illuminate\Support\Facades\DB;
8+
use Illuminate\Support\Facades\Storage;
9+
use Illuminate\Support\Str;
10+
use ProcessMaker\CaseRetention\CaseRetentionLogQueryFilter;
711
use ProcessMaker\Http\Controllers\Controller;
812
use ProcessMaker\Http\Resources\ApiCollection;
13+
use ProcessMaker\Jobs\DownloadCaseRetentionLogExport;
914
use ProcessMaker\Models\CaseRetentionPolicyLog;
15+
use Symfony\Component\HttpFoundation\BinaryFileResponse;
1016

1117
class CasesRetentionController extends Controller
1218
{
@@ -20,40 +26,11 @@ class CasesRetentionController extends Controller
2026
'created_at',
2127
];
2228

23-
/**
24-
* Search log id, process_id, numeric columns, and JSON case_ids — not date columns.
25-
*/
26-
private function applyLogsFilter($query, string $term): void
27-
{
28-
$term = trim($term);
29-
if ($term === '') {
30-
return;
31-
}
32-
33-
$like = '%' . $term . '%';
34-
$driver = $query->getConnection()->getDriverName();
35-
36-
$query->where(function ($q) use ($like, $driver) {
37-
$q->where('id', 'like', $like)
38-
->orWhere('process_id', 'like', $like)
39-
->orWhere('deleted_count', 'like', $like)
40-
->orWhere('total_time_taken', 'like', $like);
41-
42-
if ($driver === 'pgsql') {
43-
$q->orWhereRaw('case_ids::text ILIKE ?', [$like]);
44-
} else {
45-
$q->orWhereRaw('CAST(case_ids AS CHAR) LIKE ?', [$like]);
46-
}
47-
});
48-
}
49-
5029
public function logs(Request $request): ApiCollection
5130
{
5231
$query = CaseRetentionPolicyLog::query();
5332

54-
if ($request->filled('filter')) {
55-
$this->applyLogsFilter($query, (string) $request->input('filter'));
56-
}
33+
CaseRetentionLogQueryFilter::applyIfFilled($query, $request->input('filter'));
5734

5835
$orderBy = $request->input('order_by');
5936
if ($orderBy && in_array($orderBy, self::LOG_SORT_COLUMNS, true)) {
@@ -73,4 +50,43 @@ public function logs(Request $request): ApiCollection
7350

7451
return new ApiCollection($response);
7552
}
53+
54+
/**
55+
* Queue a CSV export to disk; user receives a signed download link over the websocket when ready.
56+
*/
57+
public function queueExportCsv(Request $request): JsonResponse
58+
{
59+
$request->validate([
60+
'filter' => ['sometimes', 'nullable', 'string'],
61+
]);
62+
63+
$exportToken = (string) Str::uuid();
64+
DownloadCaseRetentionLogExport::dispatch($request->user(), $request->input('filter'), $exportToken);
65+
66+
return response()->json([
67+
'success' => true,
68+
'message' => __('The file is processing. You may continue working while the log file compiles.'),
69+
]);
70+
}
71+
72+
/**
73+
* Signed URL only (no API token). Link is broadcast to the requesting user when the job finishes.
74+
*/
75+
public function downloadExportFile(Request $request, string $token): BinaryFileResponse
76+
{
77+
if (!Str::isUuid($token)) {
78+
abort(404);
79+
}
80+
81+
$relativePath = 'exports/case-retention/' . $token . '.csv';
82+
if (!Storage::disk('local')->exists($relativePath)) {
83+
abort(404);
84+
}
85+
86+
return response()->download(
87+
Storage::disk('local')->path($relativePath),
88+
'case_retention_policy_logs.csv',
89+
['Content-Type' => 'text/csv; charset=UTF-8'],
90+
)->deleteFileAfterSend(true);
91+
}
7692
}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
<?php
2+
3+
namespace ProcessMaker\Jobs;
4+
5+
use Illuminate\Bus\Queueable;
6+
use Illuminate\Contracts\Queue\ShouldQueue;
7+
use Illuminate\Foundation\Bus\Dispatchable;
8+
use Illuminate\Queue\InteractsWithQueue;
9+
use Illuminate\Queue\SerializesModels;
10+
use Illuminate\Support\Facades\Storage;
11+
use Illuminate\Support\Facades\URL;
12+
use Illuminate\Support\Str;
13+
use ProcessMaker\CaseRetention\CaseRetentionLogCsvWriter;
14+
use ProcessMaker\CaseRetention\CaseRetentionLogQueryFilter;
15+
use ProcessMaker\Events\CaseRetentionLogExportFailed;
16+
use ProcessMaker\Events\CaseRetentionLogExportReady;
17+
use ProcessMaker\Models\CaseRetentionPolicyLog;
18+
use ProcessMaker\Models\User;
19+
use Throwable;
20+
21+
class DownloadCaseRetentionLogExport implements ShouldQueue
22+
{
23+
use Dispatchable;
24+
use InteractsWithQueue;
25+
use Queueable;
26+
use SerializesModels;
27+
28+
public const LINK_TTL_HOURS = 24;
29+
30+
public function __construct(
31+
private User $user,
32+
private ?string $filter,
33+
private string $exportToken,
34+
) {
35+
}
36+
37+
public function getFilter(): ?string
38+
{
39+
return $this->filter;
40+
}
41+
42+
public function handle(): void
43+
{
44+
if (!Str::isUuid($this->exportToken)) {
45+
event(new CaseRetentionLogExportFailed($this->user, false, 'Invalid export token.'));
46+
47+
return;
48+
}
49+
50+
$relativePath = 'exports/case-retention/' . $this->exportToken . '.csv';
51+
52+
try {
53+
Storage::disk('local')->makeDirectory('exports/case-retention');
54+
55+
$fullPath = Storage::disk('local')->path($relativePath);
56+
$handle = fopen($fullPath, 'w');
57+
if ($handle === false) {
58+
throw new \RuntimeException('Could not open export file for writing.');
59+
}
60+
61+
try {
62+
$query = CaseRetentionPolicyLog::query();
63+
CaseRetentionLogQueryFilter::applyIfFilled($query, $this->filter);
64+
CaseRetentionLogCsvWriter::writeQueryToStream($query, $handle);
65+
} finally {
66+
fclose($handle);
67+
}
68+
69+
$expires = now()->addHours(self::LINK_TTL_HOURS);
70+
$url = URL::temporarySignedRoute(
71+
'api.cases-retention.logs.export.download',
72+
$expires,
73+
['token' => $this->exportToken],
74+
);
75+
76+
$message = __('Click on the link to download the log file. This link will be available until ' . $expires->toString());
77+
78+
event(new CaseRetentionLogExportReady($this->user, true, $message, $url));
79+
} catch (Throwable $e) {
80+
Storage::disk('local')->delete($relativePath);
81+
event(new CaseRetentionLogExportFailed($this->user, false, $e->getMessage()));
82+
}
83+
}
84+
}

resources/js/admin/cases-retention/index.js

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,28 @@ const casesRetentionApp = new window.Vue({
1212
},
1313
methods: {
1414
downloadRetentionLogs() {
15-
console.log("downloadRetentionLogs");
15+
const params = new URLSearchParams();
16+
if (this.filter) {
17+
params.set("filter", this.filter);
18+
}
19+
const qs = params.toString();
20+
const path = qs ? `cases-retention/logs/export?${qs}` : "cases-retention/logs/export";
21+
22+
ProcessMaker.apiClient
23+
.get(path)
24+
.then((response) => {
25+
if (response.data.success) {
26+
ProcessMaker.alert(response.data.message, "success");
27+
} else {
28+
ProcessMaker.alert(
29+
response.data.message || "Unable to start export.",
30+
"danger",
31+
);
32+
}
33+
})
34+
.catch(() => {
35+
ProcessMaker.alert("Unable to download logs.", "danger");
36+
});
1637
},
1738
reload() {
1839
this.$refs.casesRetentionLogs.reload();

resources/js/common/sessionSync.js

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -568,6 +568,24 @@ export const initSessionSync = ({
568568
} else {
569569
alert(e.message, "warning");
570570
}
571+
})
572+
.listen(".CaseRetentionLogExportReady", (e) => {
573+
if (typeof alert !== "function") {
574+
return;
575+
}
576+
if (e.success) {
577+
const { link } = e;
578+
const { message } = e;
579+
alert(message, "success", 0, false, false, link);
580+
} else {
581+
alert(e.message, "warning");
582+
}
583+
})
584+
.listen(".CaseRetentionLogExportFailed", (e) => {
585+
if (typeof alert !== "function") {
586+
return;
587+
}
588+
alert(e.message, "warning");
571589
});
572590
}
573591

routes/api.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
<?php
22

3+
use Illuminate\Routing\Middleware\ValidateSignature;
34
use Illuminate\Support\Facades\Route;
45
use ProcessMaker\Http\Controllers\Admin\TenantQueueController;
56
use ProcessMaker\Http\Controllers\Api\BookmarkController;
@@ -453,6 +454,13 @@
453454
Route::post('connector-slack/validate-token', [ProcessMaker\Packages\Connectors\Slack\Controllers\SlackController::class, 'validateToken'])->name('connector-slack.validate-token');
454455

455456
// Cases Retention
457+
Route::get('cases-retention/logs/export', [CasesRetentionController::class, 'queueExportCsv'])->name('cases-retention.logs.export');
456458
Route::get('cases-retention/logs', [CasesRetentionController::class, 'logs'])->name('cases-retention.logs');
457459
});
460+
461+
Route::middleware([ValidateSignature::class, 'setlocale'])->prefix('api/1.0')->name('api.')->group(function () {
462+
Route::get('cases-retention/logs/export/download/{token}', [CasesRetentionController::class, 'downloadExportFile'])
463+
->name('cases-retention.logs.export.download');
464+
});
465+
458466
Route::post('devlink/bundle-updated/{bundle}/{token}', [DevLinkController::class, 'bundleUpdated'])->name('devlink.bundle-updated');

0 commit comments

Comments
 (0)