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