Skip to content
Merged

Dev #3613

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
318 changes: 104 additions & 214 deletions app/Http/Controllers/BulkEventUploadController.php

Large diffs are not rendered by default.

23 changes: 18 additions & 5 deletions app/Imports/GenericEventsImport.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ class GenericEventsImport extends BaseEventsImport implements ToModel, WithCusto
/** Current Excel row number (2 = first data row after header). */
protected int $currentRow = 2;

/** @var array<string, int|null> */
private array $creatorIdByEmail = [];

public function __construct(?string $defaultCreatorEmail = null, ?BulkEventImportResult $result = null, bool $previewMode = false)
{
$this->defaultCreatorEmail = $defaultCreatorEmail ? trim($defaultCreatorEmail) : null;
Expand Down Expand Up @@ -136,21 +139,28 @@ protected function resolveCreatorId(array $row): ?int
if (! $contactEmail || ! filter_var($contactEmail, FILTER_VALIDATE_EMAIL)) {
return null;
}

if (array_key_exists($contactEmail, $this->creatorIdByEmail)) {
return $this->creatorIdByEmail[$contactEmail];
}

$user = User::where('email', $contactEmail)->first();
if ($user) {
return $user->id;
return $this->creatorIdByEmail[$contactEmail] = $user->id;
}
[$local] = explode('@', $contactEmail, 2);
$user = User::where('email', 'like', "{$local}@%")->first();
if ($user) {
return $user->id;
return $this->creatorIdByEmail[$contactEmail] = $user->id;
}
try {
$user = UserHelper::createUser($contactEmail);
return $user->id;

return $this->creatorIdByEmail[$contactEmail] = $user->id;
} catch (\Exception $e) {
Log::warning('User creation failed for contact_email: '.$contactEmail, ['exception' => $e->getMessage()]);
return null;

return $this->creatorIdByEmail[$contactEmail] = null;
}
}

Expand All @@ -175,6 +185,10 @@ public function model(array $row): ?Model
{
$rowIndex = $this->currentRow++;

if ($this->result) {
$this->result->rowsProcessed++;
}

try {
return $this->processRow($rowIndex, $row);
} catch (\Throwable $e) {
Expand All @@ -193,7 +207,6 @@ public function model(array $row): ?Model
protected function processRow(int $rowIndex, array $row): ?Model
{
$row = $this->normalizeRow($row);
Log::info('Importing row:', $row);

// 1) coordinate validation
$coordError = $this->validateCoordinates($row);
Expand Down
94 changes: 94 additions & 0 deletions app/Jobs/ProcessBulkEventImportJob.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
<?php

namespace App\Jobs;

use App\Imports\GenericEventsImport;
use App\Services\BulkEventImportResult;
use App\Services\BulkEventUploadCache;
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\Cache;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Maatwebsite\Excel\Facades\Excel;

class ProcessBulkEventImportJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

public int $timeout = 3600;

public int $tries = 1;

public function __construct(
public readonly string $token,
) {
}

public function handle(): void
{
$payload = BulkEventUploadCache::get($this->token);
if ($payload === null) {
return;
}

$path = (string) ($payload['path'] ?? '');
$disk = (string) ($payload['disk'] ?? 'local');
$defaultCreatorEmail = $payload['default_creator_email'] ?? null;

if ($path === '' || ! Storage::disk($disk)->exists($path)) {
BulkEventUploadCache::merge($this->token, [
'phase' => BulkEventUploadCache::PHASE_FAILED,
'error' => 'Uploaded file is no longer available. Please upload again.',
]);

return;
}

BulkEventUploadCache::merge($this->token, [
'phase' => BulkEventUploadCache::PHASE_IMPORTING,
'error' => null,
]);

try {
$result = new BulkEventImportResult;
$preview = $payload['preview'] ?? [];
$totalRows = (int) ($preview['total_count'] ?? 0);
$result->trackCreatedDetails = $totalRows <= BulkEventUploadCache::PREVIEW_FAILURES_ONLY_THRESHOLD;

$import = new GenericEventsImport($defaultCreatorEmail, $result);
Excel::import($import, $path, $disk);

Storage::disk($disk)->delete($path);
Cache::flush();

BulkEventUploadCache::merge($this->token, [
'phase' => BulkEventUploadCache::PHASE_COMPLETED,
'path' => null,
'report' => [
'created' => $result->created,
'created_count' => $result->createdCount,
'created_details_truncated' => ! $result->trackCreatedDetails,
'failures' => $result->failures,
],
'error' => null,
]);
} catch (\Throwable $e) {
Log::error('Bulk event upload import failed: '.$e->getMessage(), [
'token' => $this->token,
]);

if (Storage::disk($disk)->exists($path)) {
Storage::disk($disk)->delete($path);
}

BulkEventUploadCache::merge($this->token, [
'phase' => BulkEventUploadCache::PHASE_FAILED,
'error' => 'Import failed: '.$e->getMessage(),
]);
}
}
}
73 changes: 73 additions & 0 deletions app/Jobs/ValidateBulkEventUploadJob.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
<?php

namespace App\Jobs;

use App\Imports\GenericEventsImport;
use App\Services\BulkEventImportResult;
use App\Services\BulkEventUploadCache;
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\Log;
use Illuminate\Support\Facades\Storage;
use Maatwebsite\Excel\Facades\Excel;

class ValidateBulkEventUploadJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

public int $timeout = 1800;

public int $tries = 1;

public function __construct(
public readonly string $token,
) {
}

public function handle(): void
{
$payload = BulkEventUploadCache::get($this->token);
if ($payload === null) {
return;
}

$path = (string) ($payload['path'] ?? '');
$disk = (string) ($payload['disk'] ?? 'local');
$defaultCreatorEmail = $payload['default_creator_email'] ?? null;

if ($path === '' || ! Storage::disk($disk)->exists($path)) {
BulkEventUploadCache::merge($this->token, [
'phase' => BulkEventUploadCache::PHASE_FAILED,
'error' => 'Uploaded file is no longer available. Please upload again.',
]);

return;
}

try {
$result = new BulkEventImportResult;
$import = new GenericEventsImport($defaultCreatorEmail, $result, true);
Excel::import($import, $path, $disk);

$preview = BulkEventUploadCache::previewFromResult($result);

BulkEventUploadCache::merge($this->token, [
'phase' => BulkEventUploadCache::PHASE_VALIDATED,
'error' => null,
'preview' => $preview,
]);
} catch (\Throwable $e) {
Log::error('Bulk event upload validation failed: '.$e->getMessage(), [
'token' => $this->token,
]);

BulkEventUploadCache::merge($this->token, [
'phase' => BulkEventUploadCache::PHASE_FAILED,
'error' => 'Validation failed: '.$e->getMessage(),
]);
}
}
}
12 changes: 12 additions & 0 deletions app/Services/BulkEventImportResult.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@ class BulkEventImportResult
/** @var array<int, true> Row index (1-based) => valid (for preview) */
public array $valid = [];

public int $rowsProcessed = 0;

public int $createdCount = 0;

public bool $trackCreatedDetails = true;

public function addFailure(int $rowIndex, string $reason): void
{
$this->failures[$rowIndex] = $reason;
Expand All @@ -27,6 +33,12 @@ public function addValid(int $rowIndex): void

public function addCreated(Event $event): void
{
$this->createdCount++;

if (! $this->trackCreatedDetails) {
return;
}

$this->created[] = [
'id' => $event->id,
'title' => $event->title,
Expand Down
100 changes: 100 additions & 0 deletions app/Services/BulkEventUploadCache.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
<?php

namespace App\Services;

use Illuminate\Support\Facades\Cache;

final class BulkEventUploadCache
{
public const PHASE_VALIDATING = 'validating';

public const PHASE_VALIDATED = 'validated';

public const PHASE_IMPORTING = 'importing';

public const PHASE_COMPLETED = 'completed';

public const PHASE_FAILED = 'failed';

/** Above this row count, preview shows failures only (not every green row). */
public const PREVIEW_FAILURES_ONLY_THRESHOLD = 500;

public static function key(string $token): string
{
return 'bulk_upload_'.$token;
}

/**
* @return array<string, mixed>|null
*/
public static function get(string $token): ?array
{
$payload = Cache::get(self::key($token));

return is_array($payload) ? $payload : null;
}

/**
* @param array<string, mixed> $data
*/
public static function put(string $token, array $data): void
{
Cache::put(self::key($token), $data, now()->addHours(2));
}

/**
* @param array<string, mixed> $data
*/
public static function merge(string $token, array $data): void
{
self::put($token, array_merge(self::get($token) ?? [], $data));
}

/**
* @return array{
* valid_count: int,
* total_count: int,
* failures_only: bool,
* row_statuses: list<array{row: int, valid: bool, message?: string}>
* }
*/
public static function previewFromResult(BulkEventImportResult $result): array
{
$validCount = count($result->valid);
$failureCount = count($result->failures);
$totalCount = max($result->rowsProcessed, $validCount + $failureCount);
$failuresOnly = $totalCount > self::PREVIEW_FAILURES_ONLY_THRESHOLD;

$rowStatuses = [];

if ($failuresOnly) {
foreach ($result->failures as $row => $message) {
$rowStatuses[] = [
'row' => (int) $row,
'valid' => false,
'message' => $message,
];
}
} else {
foreach ($result->valid as $row => $_) {
$rowStatuses[] = ['row' => (int) $row, 'valid' => true];
}
foreach ($result->failures as $row => $message) {
$rowStatuses[] = [
'row' => (int) $row,
'valid' => false,
'message' => $message,
];
}

usort($rowStatuses, fn (array $a, array $b) => $a['row'] <=> $b['row']);
}

return [
'valid_count' => $validCount,
'total_count' => $totalCount,
'failures_only' => $failuresOnly,
'row_statuses' => $rowStatuses,
];
}
}
Loading
Loading