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:
Claude Dev
2026-02-10 06:30:57 -05:00
parent bf4f5f97c0
commit 45d25d61ba
101 changed files with 13225 additions and 1888 deletions

View File

@@ -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;
}
}