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/Http/Controllers/Api/SubscribersBulkAsyncController.php
<?php

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Http\Resources\JobMonitorResource;
use App\Model\JobMonitor;
use App\Model\MailList;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\File;

/**
 * Async bulk-import API surface.
 *
 *   POST   /api/v1/lists/{list_uid}/subscribers/bulk/async
 *            ├─ Content-Type: application/json   → JSON payload (any size, serialized to .jsonl)
 *            └─ Content-Type: multipart/form-data → CSV file + mapping (same path the wizard uses)
 *
 *   GET    /api/v1/import-jobs/{job_uid}        → poll status + progress
 *   DELETE /api/v1/import-jobs/{job_uid}        → cancel a running/queued job
 *
 * The endpoint delegates to existing dispatch infrastructure on MailList:
 *   - JSON variant   → MailList::dispatchJsonBulkUpsertJob()
 *   - CSV variant    → MailList::dispatchImportJob() (the wizard already uses this)
 *
 * Both create JobMonitor records the UI Recent imports panel + GET endpoint
 * surface uniformly.
 */
class SubscribersBulkAsyncController extends Controller
{
    public function store(Request $request, string $list_uid): JsonResponse
    {
        $list = MailList::findByUid($list_uid);
        if (!$list) {
            return response()->json(['message' => 'List not found'], 404);
        }

        $user = \Auth::guard('api')->user();
        if (!$user || !$user->can('import', $list)) {
            return response()->json(['message' => 'Unauthorized'], 403);
        }

        // Validate duplicate-handling options up-front (same as sync endpoint).
        $mode = $request->input('mode', 'overwrite');
        if (!in_array($mode, ['overwrite', 'skip'], true)) {
            return response()->json(['message' => "invalid mode: '{$mode}'"], 422);
        }
        $options = [
            'mode'            => $mode,
            'overwriteStatus' => $request->boolean('overwrite_status'),
            'overwriteTags'   => $request->boolean('overwrite_tags'),
        ];

        // Branch on input format: file upload vs JSON body.
        if ($request->hasFile('file')) {
            $monitor = $this->dispatchFile($list, $request, $options);
        } else {
            $monitor = $this->dispatchJson($list, $request, $options);
        }

        if ($monitor instanceof JsonResponse) {
            return $monitor; // dispatch helper returned an error response
        }

        return response()->json([
            'job_uid'    => $monitor->uid,
            'status'     => $monitor->status,
            'status_url' => url("/api/v1/import-jobs/{$monitor->uid}"),
            'cancel_url' => url("/api/v1/import-jobs/{$monitor->uid}"),
        ], 202);
    }

    public function show(string $job_uid): JsonResponse
    {
        $monitor = JobMonitor::findByUid($job_uid);
        if (!$monitor) {
            return response()->json(['message' => 'Job not found'], 404);
        }

        if (!$this->canAccessMonitor($monitor)) {
            return response()->json(['message' => 'Unauthorized'], 403);
        }

        return response()->json((new JobMonitorResource($monitor))->toArray(request()));
    }

    public function destroy(string $job_uid): JsonResponse
    {
        $monitor = JobMonitor::findByUid($job_uid);
        if (!$monitor) {
            return response()->json(['message' => 'Job not found'], 404);
        }
        if (!$this->canAccessMonitor($monitor)) {
            return response()->json(['message' => 'Unauthorized'], 403);
        }

        // Soft cancel — kills the queue job/batch but keeps the monitor row
        // so callers can still GET this uid and see status='cancelled' in
        // their audit trail. (UI wizard's cancel button uses hard cancel().)
        $monitor->cancelAndRetain();
        return response()->json([
            'message' => 'cancelled',
            'job_uid' => $monitor->uid,
            'status'  => $monitor->status,
        ]);
    }

    /**
     * List recent jobs for a list. Caller passes list_uid in URL; we filter
     * to jobs whose subject is that list. Feeds the UI Recent imports panel
     * + lets external callers paginate their integration history.
     */
    public function indexForList(Request $request, string $list_uid): JsonResponse
    {
        $list = MailList::findByUid($list_uid);
        if (!$list) {
            return response()->json(['message' => 'List not found'], 404);
        }
        $user = \Auth::guard('api')->user();
        if (!$user || !$user->can('import', $list)) {
            return response()->json(['message' => 'Unauthorized'], 403);
        }

        $jobs = $list->importJobs()->limit((int) $request->input('limit', 20))->get();

        return response()->json([
            'data' => $jobs->map(fn ($j) => (new JobMonitorResource($j))->toArray($request))->all(),
        ]);
    }

    /* ════════════════════════════════════════════════════════════════════
     *  Internals
     * ════════════════════════════════════════════════════════════════════ */

    private function dispatchJson(MailList $list, Request $request, array $options): JsonResponse|JobMonitor
    {
        $subscribers = $request->input('subscribers');
        if (!is_array($subscribers) || empty($subscribers)) {
            return response()->json([
                'message' => '`subscribers` array required (or upload a CSV file)',
            ], 400);
        }

        // Serialize to a .jsonl temp file so the queue payload stays small.
        // The async job reads + parses this file in batches.
        $dir = storage_path('app/tmp/bulk-import');
        File::ensureDirectoryExists($dir);
        $path = $dir . '/bulk-' . uniqid() . '.jsonl';
        $fh = fopen($path, 'w');
        foreach ($subscribers as $row) {
            if (is_array($row)) {
                fwrite($fh, json_encode($row) . "\n");
            }
        }
        fclose($fh);

        return $list->dispatchJsonBulkUpsertJob($path, $options);
    }

    private function dispatchFile(MailList $list, Request $request, array $options): JsonResponse|JobMonitor
    {
        $mapping = $request->input('mapping');
        if (is_string($mapping)) {
            $mapping = json_decode($mapping, true);
        }
        if (!is_array($mapping) || empty($mapping)) {
            return response()->json([
                'message' => '`mapping` JSON object required for file uploads',
            ], 400);
        }

        // Resolve mapping values: caller may send field tags OR numeric ids.
        // Wizard sends ids; API users typically send tags. Accept both.
        $resolvedMapping = $this->resolveMapping($mapping, $list);
        if ($resolvedMapping instanceof JsonResponse) {
            return $resolvedMapping;
        }

        // Persist uploaded CSV under the same conventions as the wizard.
        $filepath = $list->uploadCsv($request->file('file'));

        return $list->dispatchImportJob($filepath, $resolvedMapping, $options);
    }

    /**
     * Translate `[csvHeader => tagOrId]` into `[csvHeader => fieldId]` that
     * MailListFieldMapping::parse expects.
     */
    private function resolveMapping(array $mapping, MailList $list): array|JsonResponse
    {
        $tagToId = $list->fields->pluck('id', 'tag')->mapWithKeys(
            fn ($id, $tag) => [strtoupper($tag) => $id]
        )->toArray();

        $resolved = [];
        foreach ($mapping as $header => $value) {
            if (is_numeric($value)) {
                $resolved[$header] = (int) $value;
                continue;
            }
            $upper = strtoupper((string) $value);
            if (!isset($tagToId[$upper])) {
                return response()->json([
                    'message' => "unknown field tag '{$value}' in mapping for column '{$header}'",
                ], 422);
            }
            $resolved[$header] = $tagToId[$upper];
        }
        return $resolved;
    }

    /**
     * Ownership check — caller's customer must own the list this job targets.
     */
    private function canAccessMonitor(JobMonitor $monitor): bool
    {
        $user = \Auth::guard('api')->user();
        if (!$user) {
            return false;
        }
        if ($monitor->subject_name !== MailList::class) {
            return false;
        }
        $list = MailList::find($monitor->subject_id);
        return $list && $user->can('import', $list);
    }
}