Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions ProcessMaker/CaseRetention/CaseRetentionLogCsvWriter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<?php

namespace ProcessMaker\CaseRetention;

use Illuminate\Database\Eloquent\Builder;

final class CaseRetentionLogCsvWriter
{
/**
* Stream CSV rows to a writable stream (no column header row). UTF-8 BOM prepended.
*
* @param resource $stream
*/
public static function writeQueryToStream(Builder $query, $stream): void
{
fwrite($stream, "\xEF\xBB\xBF");

$query->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;
}
}
39 changes: 39 additions & 0 deletions ProcessMaker/CaseRetention/CaseRetentionLogQueryFilter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?php

namespace ProcessMaker\CaseRetention;

use Illuminate\Database\Eloquent\Builder;

final class CaseRetentionLogQueryFilter
{
public static function applyIfFilled(Builder $query, ?string $filter): void
{
if ($filter === null || trim($filter) === '') {
return;
}

self::apply($query, trim($filter));
}

/**
* Search log id, process_id, numeric columns, and JSON case_ids — not date columns.
*/
public static function apply(Builder $query, string $term): void
{
$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]);
}
});
}
}
76 changes: 46 additions & 30 deletions ProcessMaker/Http/Controllers/Api/CasesRetentionController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -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)) {
Expand All @@ -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);
}
}
89 changes: 89 additions & 0 deletions ProcessMaker/Jobs/DownloadCaseRetentionLogExport.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
<?php

namespace ProcessMaker\Jobs;

use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\Str;
use ProcessMaker\CaseRetention\CaseRetentionLogCsvWriter;
use ProcessMaker\CaseRetention\CaseRetentionLogQueryFilter;
use ProcessMaker\Models\CaseRetentionPolicyLog;
use ProcessMaker\Models\User;
use ProcessMaker\Notifications\CaseRetentionLogExportNotification;
use Throwable;

class DownloadCaseRetentionLogExport implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;

public const LINK_TTL_HOURS = 24;

public function __construct(
private User $user,
private ?string $filter,
private string $exportToken,
) {
}

public function getFilter(): ?string
{
return $this->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),
);
}
}
}
83 changes: 83 additions & 0 deletions ProcessMaker/Notifications/CaseRetentionLogExportNotification.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
<?php

namespace ProcessMaker\Notifications;

use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Messages\BroadcastMessage;
use Illuminate\Notifications\Notification;

class CaseRetentionLogExportNotification extends Notification
{
use Queueable;

public function __construct(
private bool $success,
private string $message,
private ?string $downloadUrl = null,
) {
}

/**
* @param mixed $notifiable
* @return array<int, string>
*/
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<string, mixed>
*/
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<string, mixed>
*/
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);
}
}
2 changes: 2 additions & 0 deletions config/notifications.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}',
],
];
Loading
Loading