File: /home/xedaptot/ai.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);
}
}