Idempotent provisioning, service soft-delete, Plans page redesign, doc updates
Part A: Fix duplicate Service creation on provisioning retry - All 4 provisioning services use Service::firstOrCreate() keyed on subscription_id+service_type to prevent duplicates on queue retries - HandleSubscriptionCreated sends notification before provisioning, no longer re-throws on failure - RetryProvisioningCommand simplified to reuse existing Service records Part B: Plans/Pricing page complete redesign - Service type tabs (VPS, Dedicated, Web Hosting, MySQL) - Billing cycle segmented toggle (monthly/quarterly/semi-annual/annual) - Feature icons per service type, Popular/Best Value badges - Stock indicators, effective monthly price calculations Part C: Admin service soft-delete/archive - Service model uses SoftDeletes trait - Admin can archive and restore services - Show archived toggle on services list - Migration adds deleted_at column Docs: Updated TASKS.md, CLAUDE.md, PROJECT_DEVELOPMENT.md, MEMORY.md - Phase 3 marked complete, test counts updated (252 passing) - SupportPal references replaced with standalone ticket system - Frontend design skill background rule added - Closed GitHub issues #3, #6, #7, #8, #9 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -6,13 +6,183 @@ namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\AuditLog;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class AuditLogController extends Controller
|
||||
{
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$query = $this->applyFilters($request);
|
||||
|
||||
$auditLogs = $query->paginate(25)->withQueryString();
|
||||
|
||||
// Get distinct actions for the filter dropdown
|
||||
$actions = AuditLog::query()
|
||||
->distinct()
|
||||
->orderBy('action')
|
||||
->pluck('action');
|
||||
|
||||
return Inertia::render('Admin/AuditLogs/Index', [
|
||||
'auditLogs' => $auditLogs,
|
||||
'actions' => $actions,
|
||||
'filters' => [
|
||||
'search' => $request->input('search', ''),
|
||||
'action' => $request->input('action', ''),
|
||||
'date_from' => $request->input('date_from', ''),
|
||||
'date_to' => $request->input('date_to', ''),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function export(Request $request): StreamedResponse
|
||||
{
|
||||
$request->validate([
|
||||
'format' => ['required', 'in:csv,json'],
|
||||
'search' => ['nullable', 'string', 'max:255'],
|
||||
'action' => ['nullable', 'string', 'max:255'],
|
||||
'date_from' => ['nullable', 'date'],
|
||||
'date_to' => ['nullable', 'date'],
|
||||
]);
|
||||
|
||||
$format = $request->input('format', 'csv');
|
||||
$query = $this->applyFilters($request);
|
||||
|
||||
if ($format === 'json') {
|
||||
return $this->exportJson($query);
|
||||
}
|
||||
|
||||
return $this->exportCsv($query);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<AuditLog> $query
|
||||
*/
|
||||
private function exportCsv(Builder $query): StreamedResponse
|
||||
{
|
||||
$filename = 'audit-logs-'.now()->format('Y-m-d-His').'.csv';
|
||||
|
||||
return response()->streamDownload(function () use ($query): void {
|
||||
$handle = fopen('php://output', 'w');
|
||||
|
||||
if ($handle === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
fputcsv($handle, [
|
||||
'ID',
|
||||
'Date',
|
||||
'User',
|
||||
'User Email',
|
||||
'Action',
|
||||
'Resource Type',
|
||||
'Resource ID',
|
||||
'IP Address',
|
||||
'User Agent',
|
||||
'Changes Summary',
|
||||
]);
|
||||
|
||||
$query->chunk(500, function ($logs) use ($handle): void {
|
||||
foreach ($logs as $log) {
|
||||
fputcsv($handle, [
|
||||
$log->id,
|
||||
$log->created_at->format('Y-m-d H:i:s'),
|
||||
$log->user?->name ?? 'System',
|
||||
$log->user?->email ?? '-',
|
||||
$log->action,
|
||||
$log->resource_type ?? '-',
|
||||
$log->resource_id ?? '-',
|
||||
$log->ip_address ?? '-',
|
||||
$log->user_agent ?? '-',
|
||||
$this->summarizeChanges($log->changes),
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
fclose($handle);
|
||||
}, $filename, [
|
||||
'Content-Type' => 'text/csv',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<AuditLog> $query
|
||||
*/
|
||||
private function exportJson(Builder $query): StreamedResponse
|
||||
{
|
||||
$filename = 'audit-logs-'.now()->format('Y-m-d-His').'.json';
|
||||
|
||||
return response()->streamDownload(function () use ($query): void {
|
||||
echo '[';
|
||||
|
||||
$first = true;
|
||||
|
||||
$query->chunk(500, function ($logs) use (&$first): void {
|
||||
foreach ($logs as $log) {
|
||||
if (! $first) {
|
||||
echo ',';
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
'id' => $log->id,
|
||||
'date' => $log->created_at->format('Y-m-d H:i:s'),
|
||||
'user' => $log->user ? [
|
||||
'id' => $log->user->id,
|
||||
'name' => $log->user->name,
|
||||
'email' => $log->user->email,
|
||||
] : null,
|
||||
'action' => $log->action,
|
||||
'resource_type' => $log->resource_type,
|
||||
'resource_id' => $log->resource_id,
|
||||
'ip_address' => $log->ip_address,
|
||||
'user_agent' => $log->user_agent,
|
||||
'changes' => $log->changes,
|
||||
'created_at' => $log->created_at->toIso8601String(),
|
||||
], JSON_PRETTY_PRINT);
|
||||
|
||||
$first = false;
|
||||
}
|
||||
});
|
||||
|
||||
echo ']';
|
||||
}, $filename, [
|
||||
'Content-Type' => 'application/json',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|null $changes
|
||||
*/
|
||||
private function summarizeChanges(?array $changes): string
|
||||
{
|
||||
if (! $changes || count($changes) === 0) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
// If it has before/after structure
|
||||
if (isset($changes['before']) || isset($changes['after'])) {
|
||||
$fields = [];
|
||||
|
||||
if (isset($changes['after']) && is_array($changes['after'])) {
|
||||
$fields = array_keys($changes['after']);
|
||||
} elseif (isset($changes['before']) && is_array($changes['before'])) {
|
||||
$fields = array_keys($changes['before']);
|
||||
}
|
||||
|
||||
return 'Changed: '.implode(', ', $fields);
|
||||
}
|
||||
|
||||
// Otherwise list the top-level keys
|
||||
return 'Fields: '.implode(', ', array_keys($changes));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Builder<AuditLog>
|
||||
*/
|
||||
private function applyFilters(Request $request): Builder
|
||||
{
|
||||
$query = AuditLog::query()
|
||||
->with('user:id,name,email')
|
||||
@@ -45,23 +215,6 @@ class AuditLogController extends Controller
|
||||
$query->whereDate('created_at', '<=', $dateTo);
|
||||
}
|
||||
|
||||
$auditLogs = $query->paginate(25)->withQueryString();
|
||||
|
||||
// Get distinct actions for the filter dropdown
|
||||
$actions = AuditLog::query()
|
||||
->distinct()
|
||||
->orderBy('action')
|
||||
->pluck('action');
|
||||
|
||||
return Inertia::render('Admin/AuditLogs/Index', [
|
||||
'auditLogs' => $auditLogs,
|
||||
'actions' => $actions,
|
||||
'filters' => [
|
||||
'search' => $request->input('search', ''),
|
||||
'action' => $request->input('action', ''),
|
||||
'date_from' => $request->input('date_from', ''),
|
||||
'date_to' => $request->input('date_to', ''),
|
||||
],
|
||||
]);
|
||||
return $query;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user