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,
]);
}
}