46a89537eb
Серверный 3-й слой защиты (SupplierProjectName::strip) в DealController, V1/DealsController, DealExportController — паритет с фронтом stripChannelPrefix. На входе префикс и так срезается (RouteSupplierLeadJob/CsvLeadsParser), это belt-and-suspenders на случай будущих правок парсера. Тесты: API+CSV не палят B<N>_. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
97 lines
3.3 KiB
PHP
97 lines
3.3 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Http\Controllers\Api\V1;
|
|
|
|
use App\Http\Controllers\Controller;
|
|
use App\Models\Deal;
|
|
use App\Support\SupplierProjectName;
|
|
use Carbon\Carbon;
|
|
use Illuminate\Http\JsonResponse;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\DB;
|
|
|
|
/**
|
|
* Публичный read-API сделок тенанта (G6). Аутентификация — middleware ApiKeyAuth
|
|
* (tenant_id в request->attributes['api_tenant_id']). Только сделки (deals), не
|
|
* supplier_leads.
|
|
*/
|
|
class DealsController extends Controller
|
|
{
|
|
public function index(Request $request): JsonResponse
|
|
{
|
|
$tenantId = (int) $request->attributes->get('api_tenant_id');
|
|
|
|
$limit = max(1, min(500, (int) $request->query('limit', '100')));
|
|
|
|
$since = trim((string) $request->query('since', ''));
|
|
$sinceDt = null;
|
|
if ($since !== '') {
|
|
try {
|
|
$sinceDt = Carbon::parse($since);
|
|
} catch (\Throwable) {
|
|
return response()->json(['message' => 'Невалидный since.'], 422);
|
|
}
|
|
}
|
|
|
|
$cursorRaw = (string) $request->query('cursor', '');
|
|
$cursor = null;
|
|
if ($cursorRaw !== '') {
|
|
$decoded = base64_decode($cursorRaw, true);
|
|
$parsed = $decoded === false ? null : json_decode($decoded, true);
|
|
if (! is_array($parsed) || ! isset($parsed['r'], $parsed['i'])) {
|
|
return response()->json(['message' => 'Невалидный cursor.'], 422);
|
|
}
|
|
$cursor = ['r' => (string) $parsed['r'], 'i' => (int) $parsed['i']];
|
|
}
|
|
|
|
[$rows, $next] = DB::transaction(function () use ($tenantId, $limit, $sinceDt, $cursor) {
|
|
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
|
|
|
|
$query = Deal::query()
|
|
->where('tenant_id', $tenantId)
|
|
->with('project:id,name');
|
|
|
|
if ($sinceDt !== null) {
|
|
$query->where('received_at', '>=', $sinceDt);
|
|
}
|
|
if ($cursor !== null) {
|
|
$query->whereRaw('(received_at, id) < (?, ?)', [$cursor['r'], $cursor['i']]);
|
|
}
|
|
|
|
$rows = $query->orderByDesc('received_at')->orderByDesc('id')
|
|
->limit($limit + 1)->get();
|
|
|
|
$hasNext = $rows->count() > $limit;
|
|
if ($hasNext) {
|
|
$rows = $rows->slice(0, $limit)->values();
|
|
}
|
|
|
|
$next = null;
|
|
if ($hasNext && $rows->isNotEmpty()) {
|
|
$last = $rows->last();
|
|
$next = base64_encode((string) json_encode([
|
|
'r' => $last->received_at->toIso8601String(),
|
|
'i' => $last->id,
|
|
]));
|
|
}
|
|
|
|
return [$rows, $next];
|
|
});
|
|
|
|
return response()->json([
|
|
'data' => $rows->map(fn (Deal $d) => [
|
|
'id' => $d->id,
|
|
'received_at' => $d->received_at->toIso8601String(),
|
|
'phone' => $d->phone,
|
|
'contact_name' => $d->contact_name,
|
|
'city' => $d->city,
|
|
'status' => $d->status,
|
|
'project' => SupplierProjectName::strip($d->project?->name),
|
|
])->all(),
|
|
'next_cursor' => $next,
|
|
]);
|
|
}
|
|
}
|