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/ai.naniguide.com/app/Jobs/BulkUpsertSubscribersJsonJob.php
<?php

namespace App\Jobs;

use App\Events\MailListImported;
use App\Library\Bulk\JsonlRowStream;
use App\Library\Bulk\SubscriberRowNormalizer;
use App\Library\DTOs\ImportProgress;
use App\Library\Traits\Trackable;
use App\Model\Blacklist;
use App\Model\MailList;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Log;

/**
 * Async job for the JSON-payload bulk-import API path.
 *
 * Flow:
 *   1. Client POSTs JSON array to /api/v1/lists/{uid}/subscribers/bulk/async
 *   2. Controller serializes payload to a .jsonl temp file + dispatches this job
 *   3. Job reads .jsonl in batches via JsonlRowStream
 *   4. Each batch: per-row normalize via SubscriberRowNormalizer →
 *      MailList::bulkUpsert($validRows, $options)
 *   5. After each batch, updateJsonData() on JobMonitor so UI poll sees progress
 *   6. Deletes the .jsonl file on success
 *
 * Mirror of ImportSubscribersJob (the CSV path) — both end up calling
 * bulkUpsert() under the hood. UI's `importJobs()` query must include this
 * class to surface API-triggered jobs in the Recent imports panel.
 */
class BulkUpsertSubscribersJsonJob extends Base
{
    use Trackable;

    public $timeout = 259200; // 3 days — matches ImportSubscribersJob

    protected MailList $list;
    protected string $file;
    protected array $options;
    protected $customer;

    public function __construct(MailList $list, string $file, array $options = [])
    {
        $this->list     = $list;
        $this->file     = $file;
        $this->options  = $options;
        $this->customer = $this->list->customer;
    }

    public function handle(): void
    {
        $this->customer->setUserDbConnection();
        // Customer may not have a language row in dev/test seeds — fall back
        // to the framework default rather than throwing during job init.
        $locale = $this->customer->language?->code ?? config('app.locale', 'en');
        \App::setLocale($locale);
        \Carbon\Carbon::setLocale($locale);

        $stream  = new JsonlRowStream($this->file);
        $total   = $stream->total();
        $batchId = uniqid();

        // Seed the monitor so the UI panel + status endpoint can show progress
        // from the first poll. The "source" key is what differentiates API jobs
        // from wizard-dispatched ones in the Recent imports badge.
        $this->monitor->updateJsonData((new ImportProgress(
            percentage: 0,
            total:      $total,
            processed:  0,
            failed:     0,
            message:    null,
            status:     null,
            error:      null,
            logfile:    null,
        ))->toArray() + ['source' => 'api']);

        $processed = 0;
        $failed    = 0;
        $inserted  = 0;
        $updated   = 0;

        foreach ($stream->batches(config('custom.import_batch_size', 1000)) as $batch) {
            $valid = [];
            foreach ($batch as $rawRow) {
                [$canonical, $rowErrors] = SubscriberRowNormalizer::normalize($rawRow, $this->list);
                if ($canonical === null) {
                    $failed += 1;
                    continue;
                }
                $valid[] = $canonical;
            }

            // Dedupe by email within batch (last-wins).
            $emailColumn = $this->list->getEmailField()->custom_field_name;
            $seen        = [];
            foreach ($valid as $row) {
                $seen[$row[$emailColumn]] = $row;
            }
            $valid = array_values($seen);

            if (!empty($valid)) {
                $counts    = $this->list->bulkUpsert($valid, $this->options, $batchId);
                $inserted += $counts['inserted'];
                $updated  += $counts['updated'];
            }

            $processed += count($batch);

            $this->monitor->updateJsonData((new ImportProgress(
                percentage: $total > 0 ? (int) ($processed * 100 / $total) : 0,
                total:      $total,
                processed:  $processed,
                failed:     $failed,
                message:    'Importing...',
                status:     null,
                error:      null,
                logfile:    null,
            ))->toArray() + [
                'source'   => 'api',
                'inserted' => $inserted,
                'updated'  => $updated,
                'batch_id' => $batchId,
            ]);
        }

        // Blacklist sweep + cache refresh — match the CSV adapter's tail behavior.
        try {
            Blacklist::doBlacklist($this->customer);
        } catch (\Exception $ex) {
            Log::warning('Blacklist sweep failed after bulk JSON import', [
                'list_uid' => $this->list->uid,
                'message'  => $ex->getMessage(),
            ]);
        }

        $this->list->updateCachedInfo();

        // Fire MailListImported so downstream listeners (cache, audit, ads
        // platforms) run the same as the CSV wizard path.
        MailListImported::dispatch($this->list, $batchId);

        // Final progress write (status=done will be set by middleware).
        $this->monitor->updateJsonData([
            'percentage' => 100,
            'message'    => "Processed: {$processed}/{$total} records, skipped: {$failed} records.",
            'inserted'   => $inserted,
            'updated'    => $updated,
            'failed'     => $failed,
            'source'     => 'api',
        ]);

        // Best-effort cleanup of the temp .jsonl file.
        try {
            File::delete($this->file);
        } catch (\Throwable $e) {
            // Non-fatal — temp file storage gets swept by housekeeping.
        }
    }

    public function failed(\Throwable $exception): void
    {
        Log::error('[JOB_FAILED] ' . static::class, [
            'exception' => $exception->getMessage(),
            'file'      => $exception->getFile(),
            'line'      => $exception->getLine(),
            'list_uid'  => $this->list->uid ?? null,
            'input'     => $this->file,
        ]);
    }
}