Skip to content

Commit 327f08a

Browse files
Merge pull request #72 from Relaticle/improvements
Improvements
2 parents 88d2a45 + 3eafe29 commit 327f08a

39 files changed

+1378
-695
lines changed

app-modules/ImportWizard/resources/views/livewire/import-preview-table.blade.php

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,18 @@ class="space-y-6"
2626
<span class="font-medium text-info-600 dark:text-info-400" x-text="updates.toLocaleString()"></span>
2727
<span class="text-gray-500 dark:text-gray-400">will be updated</span>
2828
</div>
29-
<div x-show="isProcessing" x-cloak class="flex items-center gap-1.5 ml-auto text-gray-500 dark:text-gray-400">
30-
<span class="text-xs" x-text="`${processed.toLocaleString()}/${totalRows.toLocaleString()} rows`"></span>
31-
</div>
32-
<div x-show="isReady" x-cloak class="flex items-center gap-1.5 ml-auto">
33-
<x-filament::icon icon="heroicon-m-check-circle" class="h-5 w-5 text-success-500" />
34-
<span class="text-sm text-success-600 dark:text-success-400">Ready to import</span>
35-
</div>
29+
<template x-if="showCompanyMatch && newCompanies > 0">
30+
<div class="flex items-center gap-1.5">
31+
<span x-show="isProcessing" x-cloak>
32+
<x-filament::loading-indicator class="h-5 w-5 text-warning-500" />
33+
</span>
34+
<span x-show="!isProcessing" x-cloak>
35+
<x-filament::icon icon="heroicon-m-building-office" class="h-5 w-5 text-warning-500" />
36+
</span>
37+
<span class="font-medium text-warning-600 dark:text-warning-400" x-text="newCompanies.toLocaleString()"></span>
38+
<span class="text-gray-500 dark:text-gray-400">new companies</span>
39+
</div>
40+
</template>
3641
</div>
3742
</div>
3843

@@ -45,7 +50,7 @@ class="space-y-6"
4550
Showing <span x-text="currentRowCount.toLocaleString()"></span> of <span x-text="totalRows.toLocaleString()"></span> rows
4651
</span>
4752
</div>
48-
<div x-ref="scrollContainer" class="overflow-x-auto max-h-96 overflow-y-auto">
53+
<div x-ref="scrollContainer" class="overflow-x-auto max-h-96 overflow-y-auto relative">
4954
<table class="min-w-full text-sm">
5055
<thead class="bg-gray-50 dark:bg-gray-800/50 sticky top-0 z-10">
5156
<tr>
@@ -71,17 +76,20 @@ class="space-y-6"
7176
<td class="px-3 py-2">
7277
<div class="flex items-center gap-2">
7378
<span class="truncate max-w-[100px] text-gray-950 dark:text-white" x-text="row._company_name || row.company_name || '-'"></span>
74-
<span x-html="getMatchBadge(row._company_match_type || 'none')"></span>
7579
</div>
7680
</td>
7781
</template>
7882
</tr>
7983
</template>
8084
</tbody>
8185
</table>
82-
</div>
83-
<div x-show="loadingMore" x-cloak class="px-3 py-4 text-center text-gray-500 border-t border-gray-200 dark:border-gray-700">
84-
<x-filament::loading-indicator class="h-5 w-5 mx-auto" />
86+
<div
87+
x-show="loadingMore"
88+
x-cloak
89+
class="sticky bottom-0 left-0 right-0 px-3 py-3 text-center bg-white/95 dark:bg-gray-900/95 backdrop-blur-sm border-t border-gray-200 dark:border-gray-700"
90+
>
91+
<x-filament::loading-indicator class="h-5 w-5 mx-auto" />
92+
</div>
8593
</div>
8694
</div>
8795
</template>

app-modules/ImportWizard/resources/views/livewire/import-wizard.blade.php

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,14 @@
1-
<div>
1+
<div
2+
x-data="{ heartbeatInterval: null }"
3+
x-init="
4+
heartbeatInterval = setInterval(() => {
5+
if ($wire.sessionId) {
6+
$wire.touchHeartbeat();
7+
}
8+
}, 15000);
9+
"
10+
@beforeunload.window="clearInterval(heartbeatInterval)"
11+
>
212
{{-- Step Progress (Minimal) --}}
313
<nav class="mb-8" aria-label="Progress">
414
<ol role="list" class="flex items-center gap-2">

app-modules/ImportWizard/resources/views/livewire/partials/step-map.blade.php

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ class="space-y-6"
1919
@php
2020
$mappedField = array_search($header, $columnMap);
2121
$selectedValue = $mappedField !== false ? $mappedField : '';
22+
$isInferred = isset($this->inferredMappings[$header]);
23+
$inferenceInfo = $isInferred ? $this->inferredMappings[$header] : null;
2224
@endphp
2325
<div
2426
wire:key="map-{{ md5($header) }}"
@@ -27,7 +29,17 @@ class="flex items-center py-2 px-2 -mx-2 rounded-lg transition-colors"
2729
@mouseenter="hoveredColumn = '{{ addslashes($header) }}'"
2830
>
2931
{{-- CSV Column Name --}}
30-
<div class="flex-1 text-sm text-gray-950 dark:text-white">{{ $header }}</div>
32+
<div class="flex-1 min-w-0">
33+
<div class="text-sm text-gray-950 dark:text-white truncate">{{ $header }}</div>
34+
@if ($isInferred)
35+
<div class="flex items-center gap-1 mt-0.5">
36+
<span class="inline-flex items-center gap-1 px-1.5 py-0.5 text-xs rounded-md bg-info-100 text-info-700 dark:bg-info-900 dark:text-info-300">
37+
<x-filament::icon icon="heroicon-m-sparkles" class="h-3 w-3" />
38+
Suggested ({{ number_format($inferenceInfo['confidence'] * 100) }}%)
39+
</span>
40+
</div>
41+
@endif
42+
</div>
3143

3244
{{-- Arrow --}}
3345
<div class="w-6 flex justify-center">

app-modules/ImportWizard/resources/views/livewire/partials/step-preview.blade.php

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,14 @@
88
'sessionId' => $sessionId,
99
'entityType' => $entityType,
1010
'columnMap' => $columnMap,
11+
'fieldLabels' => $this->fieldLabels,
1112
'previewRows' => $previewRows,
1213
'totalRows' => $totalRows,
1314
], key('preview-table-' . $sessionId))
14-
</div>
1515

16-
{{-- Navigation buttons outside nested component --}}
17-
<div class="flex justify-between pt-4 border-t border-gray-200 dark:border-gray-700">
18-
<x-filament::button wire:click="previousStep" color="gray">Back</x-filament::button>
19-
{{ $this->startImportAction }}
16+
{{-- Navigation buttons outside nested component --}}
17+
<div class="flex justify-between pt-4 border-t border-gray-200 dark:border-gray-700">
18+
<x-filament::button wire:click="previousStep" color="gray">Back</x-filament::button>
19+
{{ $this->startImportAction }}
20+
</div>
2021
</div>

app-modules/ImportWizard/resources/views/livewire/partials/step-review.blade.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
</span>
2828
@endif
2929
</div>
30-
<div class="text-xs text-gray-500 dark:text-gray-400">{{ $analysis->mappedToField }}</div>
30+
<div class="text-xs text-gray-500 dark:text-gray-400">{{ $this->getFieldLabel($analysis->mappedToField) }}</div>
3131
</button>
3232
@endforeach
3333
</div>

app-modules/ImportWizard/src/Console/CleanupOrphanedImportsCommand.php

Lines changed: 111 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -6,52 +6,140 @@
66

77
use Carbon\Carbon;
88
use Illuminate\Console\Command;
9-
use Illuminate\Support\Facades\Cache;
109
use Illuminate\Support\Facades\Storage;
10+
use Relaticle\ImportWizard\Data\ImportSessionData;
1111

12+
/**
13+
* Cleans up orphaned import session files from temp-imports/ directory.
14+
*
15+
* CLEANUP SAFETY: Uses dual-check system to prevent accidental deletion:
16+
*
17+
* 1. FILE AGE CHECK (--hours, default 2 hours)
18+
* Protects against: network interruptions, user taking a break
19+
* Even if heartbeat stops, we wait before deleting
20+
*
21+
* 2. HEARTBEAT CHECK (5 minutes stale)
22+
* Protects against: deleting while user is actively working
23+
* If heartbeat is recent, user is present - don't delete
24+
*
25+
* Only deletes when BOTH conditions are true:
26+
* - File is older than threshold (user had plenty of time)
27+
* - Heartbeat is stale (user definitely left)
28+
*
29+
* Note: Successfully completed imports clean up automatically via cleanupTempFile().
30+
* This command only handles abandoned/orphaned sessions.
31+
*/
1232
final class CleanupOrphanedImportsCommand extends Command
1333
{
14-
/**
15-
* @var string
16-
*/
17-
protected $signature = 'import:cleanup {--hours=24 : Delete sessions older than this many hours}';
34+
protected $signature = 'import:cleanup
35+
{--hours=2 : Minimum file age in hours before considering for deletion}
36+
{--dry-run : Show what would be deleted without actually deleting}';
37+
38+
protected $description = 'Clean up orphaned import session files from temp-imports/';
1839

1940
/**
20-
* @var string
41+
* Heartbeat stale threshold: 5 minutes.
42+
*
43+
* Why 5 minutes (not shorter)?
44+
* - Allows for brief network interruptions
45+
* - Allows for user thinking/reading time
46+
* - Browser tabs may pause JS when inactive
47+
*
48+
* Why 5 minutes (not longer)?
49+
* - Quick detection of truly abandoned sessions
50+
* - Combined with file age check provides safety
2151
*/
22-
protected $description = 'Clean up orphaned import session files';
52+
private const int HEARTBEAT_STALE_SECONDS = 300;
2353

2454
public function handle(): int
2555
{
26-
$hours = (int) $this->option('hours');
27-
$cutoff = now()->subHours($hours);
28-
$deleted = 0;
56+
$minAgeHours = (int) $this->option('hours');
57+
$isDryRun = (bool) $this->option('dry-run');
58+
$ageCutoff = now()->subHours($minAgeHours);
2959

3060
$directories = Storage::disk('local')->directories('temp-imports');
3161

32-
foreach ($directories as $dir) {
33-
$originalFile = "{$dir}/original.csv";
62+
if ($directories === []) {
63+
$this->info('No import sessions found.');
3464

35-
if (! Storage::disk('local')->exists($originalFile)) {
36-
continue;
37-
}
65+
return Command::SUCCESS;
66+
}
67+
68+
$this->info('Scanning '.count($directories).' import sessions...');
3869

39-
$lastModified = Storage::disk('local')->lastModified($originalFile);
70+
if ($isDryRun) {
71+
$this->warn('DRY RUN: No files will be deleted.');
72+
}
4073

41-
if (Carbon::createFromTimestamp($lastModified)->lt($cutoff)) {
42-
$sessionId = basename($dir);
74+
$deleted = 0;
75+
$skipped = 0;
4376

44-
Storage::disk('local')->deleteDirectory($dir);
45-
Cache::forget("import:{$sessionId}:status");
46-
Cache::forget("import:{$sessionId}:progress");
47-
Cache::forget("import:{$sessionId}:team");
77+
foreach ($directories as $directory) {
78+
$result = $this->processSession($directory, $ageCutoff, $isDryRun);
4879

80+
if ($result) {
4981
$deleted++;
82+
} else {
83+
$skipped++;
5084
}
5185
}
5286

53-
$this->info("Deleted {$deleted} orphaned import sessions.");
87+
$this->newLine();
88+
$this->info("Deleted: {$deleted} | Skipped: {$skipped}");
5489

5590
return Command::SUCCESS;
5691
}
92+
93+
private function processSession(string $directory, Carbon $ageCutoff, bool $isDryRun): bool
94+
{
95+
$sessionId = basename($directory);
96+
$csvFile = "{$directory}/original.csv";
97+
98+
if (! Storage::disk('local')->exists($csvFile)) {
99+
$this->lineVerbose(" [{$sessionId}] No CSV file, skipping");
100+
101+
return false;
102+
}
103+
104+
$fileAge = Carbon::createFromTimestamp(
105+
Storage::disk('local')->lastModified($csvFile)
106+
);
107+
$isOldEnough = $fileAge->lt($ageCutoff);
108+
109+
$sessionData = ImportSessionData::find($sessionId);
110+
$isAbandoned = ! $sessionData instanceof ImportSessionData
111+
|| $sessionData->isHeartbeatStale(self::HEARTBEAT_STALE_SECONDS);
112+
113+
if (! $isOldEnough) {
114+
$this->lineVerbose(" [{$sessionId}] Too recent ({$fileAge->diffForHumans()})");
115+
116+
return false;
117+
}
118+
119+
if (! $isAbandoned) {
120+
$this->lineVerbose(" [{$sessionId}] Heartbeat active, user present");
121+
122+
return false;
123+
}
124+
125+
if ($isDryRun) {
126+
$this->line(" Would delete: {$sessionId} (age: {$fileAge->diffForHumans()})");
127+
128+
return true;
129+
}
130+
131+
Storage::disk('local')->deleteDirectory($directory);
132+
ImportSessionData::forget($sessionId);
133+
134+
$this->line(" Deleted: {$sessionId} (age: {$fileAge->diffForHumans()})");
135+
136+
return true;
137+
}
138+
139+
private function lineVerbose(string $message): void
140+
{
141+
if ($this->output->isVerbose()) {
142+
$this->line($message);
143+
}
144+
}
57145
}
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Relaticle\ImportWizard\Data;
6+
7+
use Illuminate\Support\Facades\Cache;
8+
use Relaticle\ImportWizard\Enums\PreviewStatus;
9+
use Spatie\LaravelData\Attributes\MapName;
10+
use Spatie\LaravelData\Data;
11+
use Spatie\LaravelData\Mappers\SnakeCaseMapper;
12+
13+
#[MapName(SnakeCaseMapper::class)]
14+
final class ImportSessionData extends Data
15+
{
16+
private const int DEFAULT_TTL_HOURS = 24;
17+
18+
public function __construct(
19+
public string $teamId,
20+
public string $inputHash,
21+
public int $total,
22+
public int $processed = 0,
23+
public int $creates = 0,
24+
public int $updates = 0,
25+
public int $newCompanies = 0,
26+
public ?int $heartbeat = null,
27+
public ?string $error = null,
28+
) {}
29+
30+
public static function find(string $sessionId): ?self
31+
{
32+
$data = Cache::get(self::cacheKey($sessionId));
33+
34+
return $data !== null ? self::from($data) : null;
35+
}
36+
37+
public function status(): PreviewStatus
38+
{
39+
return match (true) {
40+
$this->error !== null => PreviewStatus::Failed,
41+
$this->processed >= $this->total => PreviewStatus::Ready,
42+
default => PreviewStatus::Processing,
43+
};
44+
}
45+
46+
public function isHeartbeatStale(int $thresholdSeconds = 30): bool
47+
{
48+
return $this->heartbeat !== null
49+
&& (int) now()->timestamp - $this->heartbeat > $thresholdSeconds;
50+
}
51+
52+
public function refresh(string $sessionId): void
53+
{
54+
Cache::put(
55+
self::cacheKey($sessionId),
56+
[...$this->toArray(), 'heartbeat' => (int) now()->timestamp],
57+
now()->addHours(self::ttlHours())
58+
);
59+
}
60+
61+
/** @param array<string, mixed> $changes */
62+
public static function update(string $sessionId, array $changes): void
63+
{
64+
$data = self::find($sessionId);
65+
66+
if (! $data instanceof self) {
67+
return;
68+
}
69+
70+
Cache::put(
71+
self::cacheKey($sessionId),
72+
[...$data->toArray(), ...$changes],
73+
now()->addHours(self::ttlHours())
74+
);
75+
}
76+
77+
public function save(string $sessionId): void
78+
{
79+
Cache::put(self::cacheKey($sessionId), $this->toArray(), now()->addHours(self::ttlHours()));
80+
}
81+
82+
public static function forget(string $sessionId): void
83+
{
84+
Cache::forget(self::cacheKey($sessionId));
85+
}
86+
87+
public static function cacheKey(string $sessionId): string
88+
{
89+
return "import:{$sessionId}";
90+
}
91+
92+
public static function ttlHours(): int
93+
{
94+
return (int) config('import-wizard.session_ttl_hours', self::DEFAULT_TTL_HOURS);
95+
}
96+
}

0 commit comments

Comments
 (0)