HEX
Server: LiteSpeed
System: Linux s1049.use1.mysecurecloudhost.com 4.18.0-477.27.2.lve.el8.x86_64 #1 SMP Wed Oct 11 12:32:56 UTC 2023 x86_64
User: xedaptot (3356)
PHP: 8.3.31
Disabled: NONE
Upload Files
File: /home/xedaptot/hi.naniguide.com/app/Jobs/ImportSubscribersJob.php
<?php

namespace App\Jobs;

use App\Models\Customer;
use App\Models\EmailList;
use App\Models\ListSubscriber;
use App\Models\SubscriberImport;
use App\Notifications\SubscriberImportStatusNotification;
use App\Services\ListSubscriberService;
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\Str;

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

    public function __construct(
        public EmailList $emailList,
        public string $filePath,
        public array $columnMapping,
        public bool $skipDuplicates = true,
        public bool $updateExisting = false,
        public string $source = 'import',
        public ?string $ipAddress = null,
        public ?int $subscriberImportId = null
    ) {}

    /**
     * Execute the job.
     */
    public function handle(): void
    {
        $listSubscriberService = app(ListSubscriberService::class);

        $subscriberImport = null;
        if ($this->subscriberImportId) {
            $subscriberImport = SubscriberImport::query()->find($this->subscriberImportId);
        }

        // Verify file exists
        if (!file_exists($this->filePath)) {
            Log::error("Import file not found: {$this->filePath}");
            throw new \RuntimeException("Import file not found: {$this->filePath}");
        }

        // Verify file is readable
        if (!is_readable($this->filePath)) {
            Log::error("Import file is not readable: {$this->filePath}");
            throw new \RuntimeException("Import file is not readable: {$this->filePath}");
        }

        $file = fopen($this->filePath, 'r');
        
        if ($file === false) {
            Log::error("Failed to open import file: {$this->filePath}");
            throw new \RuntimeException("Failed to open import file: {$this->filePath}");
        }

        $headers = fgetcsv($file);
        
        if ($headers === false || empty($headers)) {
            fclose($file);
            Log::error("Invalid CSV file: No headers found in {$this->filePath}");
            throw new \RuntimeException("Invalid CSV file: No headers found");
        }

        $headers = array_map(fn ($header) => $this->cleanHeader($header), $headers);

        $totalRows = 0;
        while (($row = fgetcsv($file)) !== false) {
            if (empty(array_filter($row))) {
                continue;
            }
            $totalRows++;
        }

        rewind($file);
        $headers = fgetcsv($file);

        $headers = $headers === false ? [] : array_map(fn ($header) => $this->cleanHeader($header), $headers);

        $headerByNormalized = [];
        foreach ($headers as $header) {
            $headerByNormalized[$this->normalizeHeader($header)] = $header;
        }

        $emailColumn = $this->resolveMappedColumn($this->columnMapping['email'] ?? null, $headerByNormalized);
        $firstNameColumn = $this->resolveMappedColumn($this->columnMapping['first_name'] ?? null, $headerByNormalized);
        $lastNameColumn = $this->resolveMappedColumn($this->columnMapping['last_name'] ?? null, $headerByNormalized);
        $nameColumn = null;
        if (!$firstNameColumn && !$lastNameColumn) {
            $nameColumn = $this->firstExistingColumnByNormalized($headerByNormalized, ['name', 'full name', 'fullname']);
        }

        $nameColumnFromMapping = null;
        if ($firstNameColumn && !$lastNameColumn) {
            $firstNameNormalized = $this->normalizeHeader($firstNameColumn);
            if (in_array($firstNameNormalized, ['name', 'full name', 'fullname'], true)) {
                $nameColumnFromMapping = $firstNameColumn;
            }
        }

        $resolvedNameColumn = $nameColumnFromMapping ?? $nameColumn;

        $this->maybeAddListCustomFields(
            $this->emailList,
            is_array($this->columnMapping) ? $this->columnMapping : [],
            $headers,
            $headerByNormalized,
            $emailColumn,
            $firstNameColumn,
            $lastNameColumn,
            $resolvedNameColumn
        );

        if ($subscriberImport) {
            $subscriberImport->update([
                'status' => 'running',
                'total_rows' => (int) $totalRows,
                'processed_count' => 0,
                'imported_count' => 0,
                'updated_count' => 0,
                'skipped_count' => 0,
                'error_count' => 0,
                'failure_reason' => null,
                'started_at' => now(),
                'finished_at' => null,
            ]);
        }

        $processed = 0;
        $imported = 0;
        $skipped = 0;
        $updated = 0;
        $errors = 0;

        while (($row = fgetcsv($file)) !== false) {
            // Skip empty rows
            if (empty(array_filter($row))) {
                continue;
            }

            $processed++;

            // Ensure row has same number of columns as headers
            if (count($row) !== count($headers)) {
                $skipped++;
                $this->maybePersistProgress($subscriberImport, $processed, $imported, $updated, $skipped, $errors);
                continue;
            }

            $data = array_combine($headers, $row);
            
            if (!$emailColumn || !isset($data[$emailColumn])) {
                $skipped++;
                $this->maybePersistProgress($subscriberImport, $processed, $imported, $updated, $skipped, $errors);
                continue;
            }

            $email = trim($data[$emailColumn]);
            if (empty($email) || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
                $skipped++;
                $this->maybePersistProgress($subscriberImport, $processed, $imported, $updated, $skipped, $errors);
                continue;
            }

            $subscriberData = [
                'list_id' => $this->emailList->id,
                'email' => strtolower(trim($email)),
                'first_name' => $firstNameColumn && isset($data[$firstNameColumn]) ? trim((string) $data[$firstNameColumn]) : null,
                'last_name' => $lastNameColumn && isset($data[$lastNameColumn]) ? trim((string) $data[$lastNameColumn]) : null,
                'source' => $this->source,
                'ip_address' => $this->ipAddress,
                'subscribed_at' => now(),
                'custom_fields' => $this->extractCustomFields(
                    $data,
                    $headerByNormalized,
                    $emailColumn,
                    $firstNameColumn,
                    $lastNameColumn,
                    $resolvedNameColumn
                ),
            ];

            $shouldSplitFullName = false;
            if ($resolvedNameColumn && isset($data[$resolvedNameColumn])) {
                if ($resolvedNameColumn === $firstNameColumn && !$lastNameColumn) {
                    $shouldSplitFullName = true;
                }

                if (($subscriberData['first_name'] === null || $subscriberData['first_name'] === '') && ($subscriberData['last_name'] === null || $subscriberData['last_name'] === '')) {
                    $shouldSplitFullName = true;
                }
            }

            if ($shouldSplitFullName) {
                [$first, $last] = $this->splitFullName((string) $data[$resolvedNameColumn]);
                $subscriberData['first_name'] = $first !== '' ? $first : null;
                $subscriberData['last_name'] = $last !== '' ? $last : null;
            }

            // Check if subscriber exists
            $existing = ListSubscriber::where('list_id', $this->emailList->id)
                ->where('email', $subscriberData['email'])
                ->first();

            if ($existing) {
                if ($this->skipDuplicates && !$this->updateExisting) {
                    $skipped++;
                    $this->maybePersistProgress($subscriberImport, $processed, $imported, $updated, $skipped, $errors);
                    continue;
                }

                if ($this->updateExisting) {
                    $existingCustomFields = is_array($existing->custom_fields) ? $existing->custom_fields : [];
                    $importCustomFields = is_array($subscriberData['custom_fields'] ?? null) ? ($subscriberData['custom_fields'] ?? []) : [];
                    $subscriberData['custom_fields'] = array_merge($existingCustomFields, $importCustomFields);
                    $existing->update($subscriberData);
                    $updated++;
                }
            } else {
                try {
                    // Create through service so confirmations and autoresponder triggers run.
                    // ListSubscriberService will handle double opt-in status.
                    $listSubscriberService->create($this->emailList, $subscriberData);
                    $imported++;
                } catch (\Throwable $e) {
                    $errors++;
                    Log::warning('Failed to import subscriber row', [
                        'list_id' => $this->emailList->id,
                        'email' => $subscriberData['email'] ?? null,
                        'message' => $e->getMessage(),
                    ]);
                }
            }

            $this->maybePersistProgress($subscriberImport, $processed, $imported, $updated, $skipped, $errors);
        }

        fclose($file);

        // Update list subscriber counts
        try {
            app(\App\Services\EmailListService::class)->updateSubscriberCounts($this->emailList);
        } catch (\Exception $e) {
            Log::warning("Failed to update subscriber counts: " . $e->getMessage());
        }

        // Delete the temporary file
        if (file_exists($this->filePath)) {
            try {
                unlink($this->filePath);
            } catch (\Exception $e) {
                Log::warning("Failed to delete import file: " . $e->getMessage());
            }
        }

        if ($subscriberImport) {
            $subscriberImport->update([
                'status' => 'completed',
                'processed_count' => (int) $processed,
                'imported_count' => (int) $imported,
                'updated_count' => (int) $updated,
                'skipped_count' => (int) $skipped,
                'error_count' => (int) $errors,
                'finished_at' => now(),
            ]);

            $this->notifyCustomer($subscriberImport);
        }

        Log::info("Import completed for list {$this->emailList->id}: {$imported} imported, {$updated} updated, {$skipped} skipped, {$errors} errors");
    }

    private function maybePersistProgress(
        ?SubscriberImport $subscriberImport,
        int $processed,
        int $imported,
        int $updated,
        int $skipped,
        int $errors
    ): void {
        if (!$subscriberImport) {
            return;
        }

        if ($processed % 25 !== 0) {
            return;
        }

        $subscriberImport->update([
            'processed_count' => (int) $processed,
            'imported_count' => (int) $imported,
            'updated_count' => (int) $updated,
            'skipped_count' => (int) $skipped,
            'error_count' => (int) $errors,
        ]);
    }

    private function notifyCustomer(SubscriberImport $subscriberImport): void
    {
        $customer = Customer::query()->find($subscriberImport->customer_id);
        if (!$customer) {
            return;
        }

        $customer->notify(new SubscriberImportStatusNotification($subscriberImport));
    }

    private function cleanHeader(mixed $header): string
    {
        $value = trim((string) $header);

        $withoutBom = preg_replace('/^\xEF\xBB\xBF/', '', $value);
        if (is_string($withoutBom)) {
            $value = $withoutBom;
        }

        return trim($value);
    }

    private function normalizeHeader(mixed $header): string
    {
        $value = $this->cleanHeader($header);
        $value = preg_replace('/\s+/', ' ', $value);
        if (!is_string($value)) {
            $value = (string) $value;
        }

        return Str::lower(trim($value));
    }

    private function resolveMappedColumn(?string $mapped, array $headerByNormalized): ?string
    {
        $mapped = $mapped !== null ? trim($mapped) : null;
        if ($mapped === null || $mapped === '') {
            return null;
        }

        $normalized = $this->normalizeHeader($mapped);
        if (isset($headerByNormalized[$normalized])) {
            return $headerByNormalized[$normalized];
        }

        return $this->cleanHeader($mapped);
    }

    private function firstExistingColumnByNormalized(array $headerByNormalized, array $candidates): ?string
    {
        foreach ($candidates as $candidate) {
            $normalized = $this->normalizeHeader($candidate);
            if (isset($headerByNormalized[$normalized])) {
                return $headerByNormalized[$normalized];
            }
        }

        return null;
    }

    private function splitFullName(string $fullName): array
    {
        $fullName = trim(preg_replace('/\s+/', ' ', $fullName) ?? '');
        if ($fullName === '') {
            return ['', ''];
        }

        $parts = explode(' ', $fullName);
        if (count($parts) === 1) {
            return [$parts[0], ''];
        }

        $first = array_shift($parts);
        $last = implode(' ', $parts);

        return [trim($first), trim($last)];
    }

    private function extractCustomFields(
        array $rowData,
        array $headerByNormalized,
        ?string $emailColumn,
        ?string $firstNameColumn,
        ?string $lastNameColumn,
        ?string $resolvedNameColumn
    ): array {
        $result = [];

        $mapping = is_array($this->columnMapping) ? $this->columnMapping : [];
        $mappedCustomFields = isset($mapping['custom_fields']) && is_array($mapping['custom_fields']) ? $mapping['custom_fields'] : [];

        foreach ($mappedCustomFields as $key => $mappedColumn) {
            $key = trim((string) $key);
            if ($key === '') {
                continue;
            }

            if (!preg_match('/^[a-zA-Z][a-zA-Z0-9_]*$/', $key)) {
                $key = $this->normalizeCustomFieldKey($key);
            }

            $resolved = $this->resolveMappedColumn(is_string($mappedColumn) ? $mappedColumn : null, $headerByNormalized);
            if (!$resolved || !array_key_exists($resolved, $rowData)) {
                continue;
            }

            $value = trim((string) ($rowData[$resolved] ?? ''));
            if ($value === '') {
                continue;
            }

            $result[$key] = $value;
        }

        $captureUnmapped = isset($mapping['capture_unmapped']) ? (bool) $mapping['capture_unmapped'] : true;
        if (!$captureUnmapped) {
            return $result;
        }

        $usedColumns = array_filter([
            $emailColumn,
            $firstNameColumn,
            $lastNameColumn,
            $resolvedNameColumn,
        ], fn ($v) => is_string($v) && $v !== '');

        foreach ($mappedCustomFields as $mappedColumn) {
            if (!is_string($mappedColumn)) {
                continue;
            }
            $resolved = $this->resolveMappedColumn($mappedColumn, $headerByNormalized);
            if (is_string($resolved) && $resolved !== '') {
                $usedColumns[] = $resolved;
            }
        }

        $usedColumns = array_values(array_unique($usedColumns));

        foreach ($rowData as $header => $value) {
            if (!is_string($header) || $header === '') {
                continue;
            }

            if (in_array($header, $usedColumns, true)) {
                continue;
            }

            $v = trim((string) ($value ?? ''));
            if ($v === '') {
                continue;
            }

            $key = $this->normalizeCustomFieldKey($header);
            if ($key === '') {
                continue;
            }

            if (array_key_exists($key, $result)) {
                continue;
            }

            $result[$key] = $v;
        }

        return $result;
    }

    private function normalizeCustomFieldKey(string $value): string
    {
        $value = trim($value);
        if ($value === '') {
            return '';
        }

        $value = Str::lower($value);
        $value = preg_replace('/[^a-z0-9_]+/i', '_', $value) ?? '';
        $value = preg_replace('/_+/', '_', $value) ?? '';
        $value = trim($value, '_');

        if ($value === '') {
            return '';
        }

        if (!preg_match('/^[a-z]/', $value)) {
            $value = 'f_' . $value;
        }

        $value = preg_replace('/[^a-z0-9_]+/i', '', $value) ?? '';

        return $value;
    }

    private function maybeAddListCustomFields(
        EmailList $emailList,
        array $mapping,
        array $headers,
        array $headerByNormalized,
        ?string $emailColumn,
        ?string $firstNameColumn,
        ?string $lastNameColumn,
        ?string $resolvedNameColumn
    ): void {
        $shouldAdd = isset($mapping['add_list_custom_fields']) ? (bool) $mapping['add_list_custom_fields'] : false;
        if (!$shouldAdd) {
            return;
        }

        $existing = is_array($emailList->custom_fields) ? $emailList->custom_fields : [];
        $existingByKey = [];
        foreach ($existing as $def) {
            if (!is_array($def)) {
                continue;
            }
            $k = trim((string) ($def['key'] ?? ''));
            if ($k === '') {
                continue;
            }
            $existingByKey[strtolower($k)] = true;
        }

        $reserved = ['email', 'first_name', 'last_name', 'name', 'full_name'];
        foreach ($reserved as $r) {
            $existingByKey[strtolower($r)] = true;
        }

        $toAdd = [];
        $mappedCustomFields = isset($mapping['custom_fields']) && is_array($mapping['custom_fields']) ? $mapping['custom_fields'] : [];

        foreach ($mappedCustomFields as $key => $mappedColumn) {
            $key = trim((string) $key);
            if ($key === '') {
                continue;
            }

            if (!preg_match('/^[a-zA-Z][a-zA-Z0-9_]*$/', $key)) {
                $key = $this->normalizeCustomFieldKey($key);
            }
            if ($key === '') {
                continue;
            }

            $keyLower = strtolower($key);
            if (isset($existingByKey[$keyLower])) {
                continue;
            }

            $label = $key;
            $resolved = $this->resolveMappedColumn(is_string($mappedColumn) ? $mappedColumn : null, $headerByNormalized);
            if (is_string($resolved) && $resolved !== '') {
                $label = trim($resolved) !== '' ? trim($resolved) : $label;
            }

            $toAdd[] = [
                'key' => $key,
                'label' => $label,
                'type' => 'text',
                'required' => false,
            ];
            $existingByKey[$keyLower] = true;
        }

        $captureUnmapped = isset($mapping['capture_unmapped']) ? (bool) $mapping['capture_unmapped'] : true;
        if ($captureUnmapped) {
            $usedColumns = array_filter([
                $emailColumn,
                $firstNameColumn,
                $lastNameColumn,
                $resolvedNameColumn,
            ], fn ($v) => is_string($v) && $v !== '');

            foreach ($mappedCustomFields as $mappedColumn) {
                if (!is_string($mappedColumn)) {
                    continue;
                }
                $resolved = $this->resolveMappedColumn($mappedColumn, $headerByNormalized);
                if (is_string($resolved) && $resolved !== '') {
                    $usedColumns[] = $resolved;
                }
            }

            $usedColumns = array_values(array_unique($usedColumns));

            foreach ($headers as $header) {
                $header = is_string($header) ? trim($header) : '';
                if ($header === '' || in_array($header, $usedColumns, true)) {
                    continue;
                }

                $key = $this->normalizeCustomFieldKey($header);
                if ($key === '') {
                    continue;
                }

                $keyLower = strtolower($key);
                if (isset($existingByKey[$keyLower])) {
                    continue;
                }

                $toAdd[] = [
                    'key' => $key,
                    'label' => $header,
                    'type' => 'text',
                    'required' => false,
                ];
                $existingByKey[$keyLower] = true;
            }
        }

        if (empty($toAdd)) {
            return;
        }

        $emailList->update([
            'custom_fields' => array_values(array_merge($existing, $toAdd)),
        ]);
    }

    /**
     * Handle a job failure.
     */
    public function failed(\Throwable $exception): void
    {
        Log::error("ImportSubscribersJob failed for list {$this->emailList->id}: " . $exception->getMessage());

        if ($this->subscriberImportId) {
            $subscriberImport = SubscriberImport::query()->find($this->subscriberImportId);
            if ($subscriberImport) {
                $subscriberImport->update([
                    'status' => 'failed',
                    'failure_reason' => $exception->getMessage(),
                    'finished_at' => now(),
                ]);

                $this->notifyCustomer($subscriberImport);
            }
        }
        
        // Try to delete the file even on failure
        if (file_exists($this->filePath)) {
            try {
                unlink($this->filePath);
            } catch (\Exception $e) {
                // Ignore cleanup errors
            }
        }
    }
}