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
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,18 @@ class="space-y-6"
<span class="font-medium text-info-600 dark:text-info-400" x-text="updates.toLocaleString()"></span>
<span class="text-gray-500 dark:text-gray-400">will be updated</span>
</div>
<div x-show="isProcessing" x-cloak class="flex items-center gap-1.5 ml-auto text-gray-500 dark:text-gray-400">
<span class="text-xs" x-text="`${processed.toLocaleString()}/${totalRows.toLocaleString()} rows`"></span>
</div>
<div x-show="isReady" x-cloak class="flex items-center gap-1.5 ml-auto">
<x-filament::icon icon="heroicon-m-check-circle" class="h-5 w-5 text-success-500" />
<span class="text-sm text-success-600 dark:text-success-400">Ready to import</span>
</div>
<template x-if="showCompanyMatch && newCompanies > 0">
<div class="flex items-center gap-1.5">
<span x-show="isProcessing" x-cloak>
<x-filament::loading-indicator class="h-5 w-5 text-warning-500" />
</span>
<span x-show="!isProcessing" x-cloak>
<x-filament::icon icon="heroicon-m-building-office" class="h-5 w-5 text-warning-500" />
</span>
<span class="font-medium text-warning-600 dark:text-warning-400" x-text="newCompanies.toLocaleString()"></span>
<span class="text-gray-500 dark:text-gray-400">new companies</span>
</div>
</template>
</div>
</div>

Expand All @@ -45,7 +50,7 @@ class="space-y-6"
Showing <span x-text="currentRowCount.toLocaleString()"></span> of <span x-text="totalRows.toLocaleString()"></span> rows
</span>
</div>
<div x-ref="scrollContainer" class="overflow-x-auto max-h-96 overflow-y-auto">
<div x-ref="scrollContainer" class="overflow-x-auto max-h-96 overflow-y-auto relative">
<table class="min-w-full text-sm">
<thead class="bg-gray-50 dark:bg-gray-800/50 sticky top-0 z-10">
<tr>
Expand All @@ -71,17 +76,20 @@ class="space-y-6"
<td class="px-3 py-2">
<div class="flex items-center gap-2">
<span class="truncate max-w-[100px] text-gray-950 dark:text-white" x-text="row._company_name || row.company_name || '-'"></span>
<span x-html="getMatchBadge(row._company_match_type || 'none')"></span>
</div>
</td>
</template>
</tr>
</template>
</tbody>
</table>
</div>
<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">
<x-filament::loading-indicator class="h-5 w-5 mx-auto" />
<div
x-show="loadingMore"
x-cloak
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"
>
<x-filament::loading-indicator class="h-5 w-5 mx-auto" />
</div>
</div>
</div>
</template>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,14 @@
<div>
<div
x-data="{ heartbeatInterval: null }"
x-init="
heartbeatInterval = setInterval(() => {
if ($wire.sessionId) {
$wire.touchHeartbeat();
}
}, 15000);
"
@beforeunload.window="clearInterval(heartbeatInterval)"
>
{{-- Step Progress (Minimal) --}}
<nav class="mb-8" aria-label="Progress">
<ol role="list" class="flex items-center gap-2">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ class="space-y-6"
@php
$mappedField = array_search($header, $columnMap);
$selectedValue = $mappedField !== false ? $mappedField : '';
$isInferred = isset($this->inferredMappings[$header]);
$inferenceInfo = $isInferred ? $this->inferredMappings[$header] : null;
@endphp
<div
wire:key="map-{{ md5($header) }}"
Expand All @@ -27,7 +29,17 @@ class="flex items-center py-2 px-2 -mx-2 rounded-lg transition-colors"
@mouseenter="hoveredColumn = '{{ addslashes($header) }}'"
>
{{-- CSV Column Name --}}
<div class="flex-1 text-sm text-gray-950 dark:text-white">{{ $header }}</div>
<div class="flex-1 min-w-0">
<div class="text-sm text-gray-950 dark:text-white truncate">{{ $header }}</div>
@if ($isInferred)
<div class="flex items-center gap-1 mt-0.5">
<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">
<x-filament::icon icon="heroicon-m-sparkles" class="h-3 w-3" />
Suggested ({{ number_format($inferenceInfo['confidence'] * 100) }}%)
</span>
</div>
@endif
</div>

{{-- Arrow --}}
<div class="w-6 flex justify-center">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,14 @@
'sessionId' => $sessionId,
'entityType' => $entityType,
'columnMap' => $columnMap,
'fieldLabels' => $this->fieldLabels,
'previewRows' => $previewRows,
'totalRows' => $totalRows,
], key('preview-table-' . $sessionId))
</div>

{{-- Navigation buttons outside nested component --}}
<div class="flex justify-between pt-4 border-t border-gray-200 dark:border-gray-700">
<x-filament::button wire:click="previousStep" color="gray">Back</x-filament::button>
{{ $this->startImportAction }}
{{-- Navigation buttons outside nested component --}}
<div class="flex justify-between pt-4 border-t border-gray-200 dark:border-gray-700">
<x-filament::button wire:click="previousStep" color="gray">Back</x-filament::button>
{{ $this->startImportAction }}
</div>
</div>
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
</span>
@endif
</div>
<div class="text-xs text-gray-500 dark:text-gray-400">{{ $analysis->mappedToField }}</div>
<div class="text-xs text-gray-500 dark:text-gray-400">{{ $this->getFieldLabel($analysis->mappedToField) }}</div>
</button>
@endforeach
</div>
Expand Down
134 changes: 111 additions & 23 deletions app-modules/ImportWizard/src/Console/CleanupOrphanedImportsCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,52 +6,140 @@

use Carbon\Carbon;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Storage;
use Relaticle\ImportWizard\Data\ImportSessionData;

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

protected $description = 'Clean up orphaned import session files from temp-imports/';

/**
* @var string
* Heartbeat stale threshold: 5 minutes.
*
* Why 5 minutes (not shorter)?
* - Allows for brief network interruptions
* - Allows for user thinking/reading time
* - Browser tabs may pause JS when inactive
*
* Why 5 minutes (not longer)?
* - Quick detection of truly abandoned sessions
* - Combined with file age check provides safety
*/
protected $description = 'Clean up orphaned import session files';
private const int HEARTBEAT_STALE_SECONDS = 300;

public function handle(): int
{
$hours = (int) $this->option('hours');
$cutoff = now()->subHours($hours);
$deleted = 0;
$minAgeHours = (int) $this->option('hours');
$isDryRun = (bool) $this->option('dry-run');
$ageCutoff = now()->subHours($minAgeHours);

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

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

if (! Storage::disk('local')->exists($originalFile)) {
continue;
}
return Command::SUCCESS;
}

$this->info('Scanning '.count($directories).' import sessions...');

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

if (Carbon::createFromTimestamp($lastModified)->lt($cutoff)) {
$sessionId = basename($dir);
$deleted = 0;
$skipped = 0;

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

if ($result) {
$deleted++;
} else {
$skipped++;
}
}

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

return Command::SUCCESS;
}

private function processSession(string $directory, Carbon $ageCutoff, bool $isDryRun): bool
{
$sessionId = basename($directory);
$csvFile = "{$directory}/original.csv";

if (! Storage::disk('local')->exists($csvFile)) {
$this->lineVerbose(" [{$sessionId}] No CSV file, skipping");

return false;
}

$fileAge = Carbon::createFromTimestamp(
Storage::disk('local')->lastModified($csvFile)
);
$isOldEnough = $fileAge->lt($ageCutoff);

$sessionData = ImportSessionData::find($sessionId);
$isAbandoned = ! $sessionData instanceof ImportSessionData
|| $sessionData->isHeartbeatStale(self::HEARTBEAT_STALE_SECONDS);

if (! $isOldEnough) {
$this->lineVerbose(" [{$sessionId}] Too recent ({$fileAge->diffForHumans()})");

return false;
}

if (! $isAbandoned) {
$this->lineVerbose(" [{$sessionId}] Heartbeat active, user present");

return false;
}

if ($isDryRun) {
$this->line(" Would delete: {$sessionId} (age: {$fileAge->diffForHumans()})");

return true;
}

Storage::disk('local')->deleteDirectory($directory);
ImportSessionData::forget($sessionId);

$this->line(" Deleted: {$sessionId} (age: {$fileAge->diffForHumans()})");

return true;
}

private function lineVerbose(string $message): void
{
if ($this->output->isVerbose()) {
$this->line($message);
}
}
}
96 changes: 96 additions & 0 deletions app-modules/ImportWizard/src/Data/ImportSessionData.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
<?php

declare(strict_types=1);

namespace Relaticle\ImportWizard\Data;

use Illuminate\Support\Facades\Cache;
use Relaticle\ImportWizard\Enums\PreviewStatus;
use Spatie\LaravelData\Attributes\MapName;
use Spatie\LaravelData\Data;
use Spatie\LaravelData\Mappers\SnakeCaseMapper;

#[MapName(SnakeCaseMapper::class)]
final class ImportSessionData extends Data
{
private const int DEFAULT_TTL_HOURS = 24;

public function __construct(
public string $teamId,
public string $inputHash,
public int $total,
public int $processed = 0,
public int $creates = 0,
public int $updates = 0,
public int $newCompanies = 0,
public ?int $heartbeat = null,
public ?string $error = null,
) {}

public static function find(string $sessionId): ?self
{
$data = Cache::get(self::cacheKey($sessionId));

return $data !== null ? self::from($data) : null;
}

public function status(): PreviewStatus
{
return match (true) {
$this->error !== null => PreviewStatus::Failed,
$this->processed >= $this->total => PreviewStatus::Ready,
default => PreviewStatus::Processing,
};
}

public function isHeartbeatStale(int $thresholdSeconds = 30): bool
{
return $this->heartbeat !== null
&& (int) now()->timestamp - $this->heartbeat > $thresholdSeconds;
}

public function refresh(string $sessionId): void
{
Cache::put(
self::cacheKey($sessionId),
[...$this->toArray(), 'heartbeat' => (int) now()->timestamp],
Comment on lines +54 to +56
Copy link

Copilot AI Jan 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The refresh() method updates the cache with a spread of $this->toArray() plus the new heartbeat, but it doesn't update the current object's $this->heartbeat property. This could lead to inconsistency if the same ImportSessionData instance is used after calling refresh(). Consider updating the object's heartbeat property as well, or making it clear through naming/documentation that this method only updates the cache.

Suggested change
Cache::put(
self::cacheKey($sessionId),
[...$this->toArray(), 'heartbeat' => (int) now()->timestamp],
$heartbeat = (int) now()->timestamp;
$this->heartbeat = $heartbeat;
Cache::put(
self::cacheKey($sessionId),
[...$this->toArray(), 'heartbeat' => $heartbeat],

Copilot uses AI. Check for mistakes.
now()->addHours(self::ttlHours())
);
}

/** @param array<string, mixed> $changes */
public static function update(string $sessionId, array $changes): void
{
$data = self::find($sessionId);

if (! $data instanceof self) {
return;
}

Cache::put(
self::cacheKey($sessionId),
[...$data->toArray(), ...$changes],
now()->addHours(self::ttlHours())
);
}
Comment on lines +62 to +75
Copy link

Copilot AI Jan 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The update() method has a race condition. Between calling find() (line 64) and Cache::put() (line 70-73), another process could update the cache, and those changes would be overwritten. For example, if two job chunks update progress simultaneously, one update could be lost. Consider using atomic cache operations like Cache::lock() or designing the update to merge changes more safely.

Copilot uses AI. Check for mistakes.

public function save(string $sessionId): void
{
Cache::put(self::cacheKey($sessionId), $this->toArray(), now()->addHours(self::ttlHours()));
}

public static function forget(string $sessionId): void
{
Cache::forget(self::cacheKey($sessionId));
}

public static function cacheKey(string $sessionId): string
{
return "import:{$sessionId}";
}

public static function ttlHours(): int
{
return (int) config('import-wizard.session_ttl_hours', self::DEFAULT_TTL_HOURS);
}
}
Loading
Loading