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/be.naniguide.com/app/Http/Controllers/Api/SubscribersBulkController.php
<?php

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Library\Bulk\SubscriberRowNormalizer;
use App\Model\MailList;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

/**
 * Bulk subscriber API — sync entry point.
 *
 *   POST /api/v1/lists/{list_uid}/subscribers/bulk
 *
 * Accepts a JSON array of subscribers and applies them through
 * MailList::bulkUpsert() inline. Returns per-row error detail for invalid
 * rows; valid rows still land. No size cap — caller is responsible for
 * picking sync vs async (`/bulk/async`) based on payload size and their
 * own PHP-FPM / proxy timeout configuration.
 *
 * The matching async endpoint and the JobMonitor polling routes live in a
 * sibling controller — this one stays sync-only on purpose.
 */
class SubscribersBulkController 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);
        }

        $subscribers = $request->input('subscribers');
        if (!is_array($subscribers) || empty($subscribers)) {
            return response()->json([
                'message' => '`subscribers` array is required and must be non-empty',
            ], 400);
        }

        // Validate duplicate-handling options. Fall-through on bogus mode
        // would silently take the wrong SQL branch downstream.
        $mode = $request->input('mode', 'overwrite');
        if (!in_array($mode, ['overwrite', 'skip'], true)) {
            return response()->json([
                'message' => "invalid mode: '{$mode}' — expected 'overwrite' or 'skip'",
            ], 422);
        }
        $options = [
            'mode'            => $mode,
            'overwriteStatus' => $request->boolean('overwrite_status'),
            'overwriteTags'   => $request->boolean('overwrite_tags'),
        ];

        // Per-row normalize. Partial success: invalid rows go into errors[],
        // valid rows still get upserted.
        $validRows = [];
        $errors    = [];
        $seenEmails = [];
        foreach ($subscribers as $index => $row) {
            if (!is_array($row)) {
                $errors[] = ['index' => $index, 'email' => null, 'errors' => ['_root' => 'row must be an object']];
                continue;
            }

            [$canonical, $rowErrors] = SubscriberRowNormalizer::normalize($row, $list);

            // "unknown field" warnings don't kill the row; surface them but
            // still upsert. Only normalizer returning null → drop the row.
            if ($canonical === null) {
                $errors[] = [
                    'index'  => $index,
                    'email'  => isset($row['EMAIL']) ? (string) $row['EMAIL'] : null,
                    'errors' => $rowErrors,
                ];
                continue;
            }

            // Within-batch email dedupe (last-write-wins, same as CSV path).
            $emailColumn = $list->getEmailField()->custom_field_name;
            $email = $canonical[$emailColumn];
            $seenEmails[$email] = $index;

            $validRows[$index] = $canonical;

            if (!empty($rowErrors)) {
                // Soft errors that didn't disqualify the row (unknown fields, etc.)
                $errors[] = [
                    'index'  => $index,
                    'email'  => $email,
                    'errors' => $rowErrors,
                    'warning' => true,
                ];
            }
        }

        // Apply dedupe — for each duplicated email, keep the last occurrence only.
        $deduped = [];
        foreach ($seenEmails as $email => $lastIndex) {
            $deduped[] = $validRows[$lastIndex];
        }

        try {
            $counts = $list->bulkUpsert($deduped, $options);
        } catch (\LogicException $e) {
            return response()->json(['message' => $e->getMessage()], 422);
        } catch (\Exception $e) {
            // Quota gate failure or other infrastructure issue.
            return response()->json(['message' => $e->getMessage()], 422);
        }

        // Refresh the list + customer subscriber-count caches. bulkUpsert()
        // only invalidates a per-batch counter; updateCachedInfo() rebuilds
        // the keys the UI badge ("Subscribers 1,043") and customer dashboard
        // read from. Without this the UI shows the pre-import count until
        // some other event flushes the cache.
        $list->updateCachedInfo();

        // Fire same event the CSV wizard fires so downstream listeners
        // (blacklist sweep, cache invalidation, audit trail) run on every
        // import regardless of entry point.
        \App\Events\MailListImported::dispatch($list, $counts['batch_id']);

        return response()->json([
            'list_uid' => $list->uid,
            'received' => count($subscribers),
            'inserted' => $counts['inserted'],
            'updated'  => $counts['updated'],
            'failed'   => count($errors) - count(array_filter($errors, fn ($e) => !empty($e['warning']))),
            'batch_id' => $counts['batch_id'],
            'errors'   => $errors,
        ], 200);
    }
}