00cc072e2f
Это снимает запрет на выкат телеграм-рекламы. Пункт «Моя база номеров» стоял в выборе аудитории с 27.07, а писать в таблицу client_tg_contacts не умела НИ ОДНА строка кода: ни экрана загрузки, ни серверной ручки, ни переноса из сделок. У любого клиента пункт всегда показывал ноль. Клиент выбрал бы «свою базу», увидел пустоту и решил, что портал потерял его клиентов. Песочница выключена, деньги живые — катить в таком виде было нельзя. Читающая половина при этом была готова с самого начала: TelegramAudienceService умеет и нормализацию, и схлопывание дублей, и вычитание стоп-листа. Не хватало только записи, поэтому работа вышла куда меньше, чем казалось. Сервер: - TelegramBazaService — разбор файла, замена базы, очистка. Номер берётся по одному в строке либо первым столбцом таблицы, разделители запятая, точка с запятой, табуляция. Мусорные строки не роняют разбор, а считаются отдельно. - три ручки: GET, POST и DELETE /api/telegram/contacts. - потолок 200 000 номеров и 10 МБ на файл; обрыв по потолку не молчит, а возвращается признаком. Экран, в шаге «Кому показываем»: - сколько номеров в базе, заливка файла, очистка; - итог заливки целиком: принято, повторов, не похоже на номер. «Принято 1200» без остального читалось бы как «файл зашёл полностью»; - после заливки счётчик охвата пересчитывается сам. Решения, которые стоит знать: - заливка ЗАМЕНЯЕТ базу, а не добавляет. Так предсказуемее: клиент держит базу у себя и заливает заново. Подмешивание копило бы номера, от которых он не смог бы избавиться — построчного удаления в интерфейсе нет. На экране это написано до нажатия, молчаливой потери нет. - файл без единого годного номера отклоняется, старая база остаётся цела. Иначе клиент залил бы файл не того формата и потерял всё. - замена сделана удалением и вставкой, а не upsert: право UPDATE на таблице не выдано, upsert упёрся бы в это на бою и молча правил бы ноль строк. - наружу отдаём только счётчики. Номера — персональные данные, экрану они не нужны и в ответах не появляются. Приёмка: 12 тестов сервера и 9 тестов экрана, все до кода и все красные по верной причине — сервер отвечал 405, блока на экране не было. Замеры: телеграм-модуль 325 тестов 0 падений, экраны 244 файла 1855 тестов 0 падений, статанализ 0, форматтер чисто. Проверка типов 6 ошибок, все чужие, столько же было до работы. Для выката, проверить на бою: право USAGE на счётчике client_tg_contacts_id_seq. Замерил в тестовой базе — счётчик без права, но ровно так же выглядит и счётчик client_tg_campaigns_id_seq, в который портал на бою пишет. То есть новых прав эта работа не требует, но проверка дешёвая, а пропущенный grant на бою даёт отказ, которого на dev не видно. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
713 lines
58 KiB
PHP
713 lines
58 KiB
PHP
<?php
|
||
|
||
use App\Http\Controllers\Api\CreativeRobotController;
|
||
use App\Http\Controllers\Api\Sales\SalesAdAudienceController;
|
||
use App\Http\Controllers\Api\Sales\SalesAttachmentController;
|
||
use App\Http\Controllers\Api\Sales\SalesAuthController;
|
||
use App\Http\Controllers\Api\Sales\SalesClientsController;
|
||
use App\Http\Controllers\Api\Sales\SalesDashboardController;
|
||
use App\Http\Controllers\Api\Sales\SalesIncomeController;
|
||
use App\Http\Controllers\Api\Sales\SalesIntegrationController;
|
||
use App\Http\Controllers\Api\Sales\SalesInvoiceController;
|
||
use App\Http\Controllers\Api\Sales\SalesManagersController;
|
||
use App\Http\Controllers\Api\Sales\SalesPayoutController;
|
||
use App\Http\Controllers\Api\Sales\SalesProspectController;
|
||
use App\Http\Controllers\Api\Sales\SalesSmsController;
|
||
use App\Http\Controllers\Api\Sales\SalesTariffController;
|
||
use App\Http\Controllers\Api\TgRobotController;
|
||
use Illuminate\Support\Facades\Route;
|
||
|
||
// Laravel 13 string-based lazy-loading контроллеров (Sprint 2 Phase A, O-stack-03).
|
||
//
|
||
// Контроллеры передаются как строки 'FQN@method' вместо
|
||
// [Class::class, 'method']. При таком синтаксисе Laravel НЕ автозагружает
|
||
// класс при boot'е route'ов — резолвит из строки только при матче маршрута.
|
||
// Экономит ~50-100 ms на cold-boot для middleware-пайплайна с 20+ контроллерами.
|
||
//
|
||
// Почему именно строки, а не FQN-class:
|
||
// - Pint default preset (Laravel) применяет fixer fully_qualified_strict_types,
|
||
// который сворачивает \App\Http\...\X::class обратно в use-импорт.
|
||
// На строки этот fixer не действует — синтаксис стабилен под форматтером.
|
||
//
|
||
// SPA-API маршруты auth-flow. Размещены в web.php (а не api.php), потому
|
||
// что Sanctum SPA mode использует session-cookie auth, а session middleware
|
||
// добавляется только к web-группе. См. laravel.com/docs/sanctum#spa-authentication.
|
||
Route::prefix('/api/auth')->group(function () {
|
||
// Route-throttle (P1 go-live): per-IP лимит поверх per-credential rate-limit
|
||
// в контроллерах. Именованные лимитеры — в AppServiceProvider::boot.
|
||
Route::post('/login', 'App\Http\Controllers\Api\AuthController@login')
|
||
->middleware('throttle:auth-login');
|
||
Route::post('/register', 'App\Http\Controllers\Api\RegistrationController@register')
|
||
->middleware('throttle:auth-register');
|
||
Route::post('/confirm-email', 'App\Http\Controllers\Api\RegistrationController@confirmEmail')
|
||
->middleware('throttle:auth-register');
|
||
Route::post('/resend-code', 'App\Http\Controllers\Api\RegistrationController@resendCode')
|
||
->middleware('throttle:auth-register');
|
||
// /2fa/verify публичный — у user'а ещё нет полноценной session-auth, только
|
||
// pending_user_id в session. Verify завершает login после проверки TOTP.
|
||
//
|
||
// Sprint 3 Phase B (audit O-refactor-02): 2FA verify/recovery-use вынесены
|
||
// из AuthController в TwoFactorController; forgot/reset — в PasswordResetController.
|
||
// URL без изменений.
|
||
Route::post('/2fa/verify', 'App\Http\Controllers\Api\TwoFactorController@verifyTwoFactor')
|
||
->middleware('throttle:auth-2fa');
|
||
// /2fa/recovery-use — публичный (нет полноценной session-auth до verify).
|
||
Route::post('/2fa/recovery-use', 'App\Http\Controllers\Api\TwoFactorController@useRecoveryCode')
|
||
->middleware('throttle:auth-2fa');
|
||
// /forgot — публичный (anti-enumeration unified-ответ + rate-limit).
|
||
Route::post('/forgot', 'App\Http\Controllers\Api\PasswordResetController@forgotPassword')
|
||
->middleware('throttle:auth-password');
|
||
// /reset-password — публичный (deep-link из email с token+email+password).
|
||
Route::post('/reset-password', 'App\Http\Controllers\Api\PasswordResetController@resetPassword')
|
||
->middleware('throttle:auth-password');
|
||
Route::middleware('auth:sanctum')->group(function () {
|
||
Route::get('/me', 'App\Http\Controllers\Api\AuthController@me');
|
||
Route::patch('/me', 'App\Http\Controllers\Api\AuthController@updateProfile');
|
||
Route::post('/logout', 'App\Http\Controllers\Api\AuthController@logout');
|
||
Route::patch('/me/notification-preferences', 'App\Http\Controllers\Api\AuthController@updateNotificationPreferences');
|
||
});
|
||
});
|
||
|
||
// Аккаунт — вкладка «Безопасность» (UI-аудит 21.06.2026): смена пароля + недавние входы.
|
||
Route::middleware('auth:sanctum')->prefix('/api/account')->group(function () {
|
||
Route::post('/change-password', 'App\Http\Controllers\Api\AccountController@changePassword')
|
||
->middleware('throttle:auth-password');
|
||
Route::get('/security', 'App\Http\Controllers\Api\AccountController@security');
|
||
Route::delete('/sessions/{id}', 'App\Http\Controllers\Api\AccountController@revokeSession')
|
||
->whereNumber('id');
|
||
});
|
||
|
||
// In-app уведомления (P0 этап 2b). Все endpoint'ы под Sanctum SPA auth —
|
||
// уведомления USER-personal, читать/писать может только сам user.
|
||
Route::middleware(['auth:sanctum,impersonation', 'tenant'])->prefix('/api/notifications')->group(function () {
|
||
Route::get('/', 'App\Http\Controllers\Api\InAppNotificationController@index');
|
||
Route::patch('/{id}/read', 'App\Http\Controllers\Api\InAppNotificationController@markRead')->where('id', '[0-9]+');
|
||
Route::post('/mark-all-read', 'App\Http\Controllers\Api\InAppNotificationController@markAllRead');
|
||
Route::delete('/{id}', 'App\Http\Controllers\Api\InAppNotificationController@destroy')->where('id', '[0-9]+');
|
||
});
|
||
|
||
// Реквизиты тенанта (G1/SP2). Лёгкий гейт первого проекта + дозаполнение в ЛК.
|
||
Route::middleware(['auth:sanctum', 'tenant'])->prefix('/api/tenant/requisites')->group(function () {
|
||
Route::get('/', 'App\Http\Controllers\Api\TenantRequisitesController@show');
|
||
Route::put('/', 'App\Http\Controllers\Api\TenantRequisitesController@update');
|
||
Route::post('/lookup-inn', 'App\Http\Controllers\Api\TenantRequisitesController@lookupInn')
|
||
->middleware('throttle:30,1');
|
||
});
|
||
|
||
// Клиентская Telegram-реклама «по своей базе» (client_tg_*). RLS требует tenant
|
||
// middleware. Создание (store) и запуск (launch) разделены: store — черновик,
|
||
// launch — очередь + робот. См. Api\ClientTg\CampaignController.
|
||
Route::middleware(['auth:sanctum', 'tenant'])->prefix('/api/telegram')->group(function () {
|
||
Route::get('/campaigns', 'App\Http\Controllers\Api\ClientTg\CampaignController@index');
|
||
Route::post('/campaigns', 'App\Http\Controllers\Api\ClientTg\CampaignController@store');
|
||
// Оценка охвата и сметы БЕЗ создания кампании — под живой счётчик на экране.
|
||
// Стоит выше «/campaigns/{id}», хотя тот и ограничен числами: так порядок читается сам.
|
||
Route::post('/campaigns/estimate', 'App\Http\Controllers\Api\ClientTg\CampaignController@estimate');
|
||
Route::get('/campaigns/{id}', 'App\Http\Controllers\Api\ClientTg\CampaignController@show')->whereNumber('id');
|
||
Route::post('/campaigns/{id}/launch', 'App\Http\Controllers\Api\ClientTg\CampaignController@launch')->whereNumber('id');
|
||
Route::post('/campaigns/{id}/cancel', 'App\Http\Controllers\Api\ClientTg\CampaignController@cancel')->whereNumber('id');
|
||
Route::post('/campaigns/{id}/resubmit', 'App\Http\Controllers\Api\ClientTg\CampaignController@resubmit')->whereNumber('id');
|
||
Route::post('/campaigns/{id}/media', 'App\Http\Controllers\Api\ClientTg\CampaignController@attachMedia')->whereNumber('id');
|
||
|
||
// «Моя база номеров» (client_tg_contacts): показать размер, залить файл, очистить.
|
||
// Пункт «Моя база» жил на экране с 27.07.2026, а писать в таблицу не умела ни одна
|
||
// строка кода — у любого клиента он всегда показывал ноль.
|
||
Route::get('/contacts', 'App\Http\Controllers\Api\ClientTg\ContactController@index');
|
||
Route::post('/contacts', 'App\Http\Controllers\Api\ClientTg\ContactController@store');
|
||
Route::delete('/contacts', 'App\Http\Controllers\Api\ClientTg\ContactController@destroy');
|
||
|
||
// Правило авто-рекламы (client_tg_auto_rule): клиент управляет авто-режимом из кабинета.
|
||
Route::get('/auto-rule', 'App\Http\Controllers\Api\ClientTg\AutoRuleController@show');
|
||
Route::put('/auto-rule', 'App\Http\Controllers\Api\ClientTg\AutoRuleController@update');
|
||
Route::post('/auto-rule/media', 'App\Http\Controllers\Api\ClientTg\AutoRuleController@media');
|
||
});
|
||
|
||
// Reports backend. Schema §13.5 report_jobs. Auth обязательный.
|
||
// Этапы 1+2 (CRUD + provider/formatter) + этап 3 (retry/cancel/delete +
|
||
// retention cron `reports:cleanup-expired`).
|
||
Route::middleware(['auth:sanctum,impersonation', 'tenant'])->prefix('/api/reports/jobs')->group(function () {
|
||
Route::get('/', 'App\Http\Controllers\Api\ReportJobController@index');
|
||
Route::post('/', 'App\Http\Controllers\Api\ReportJobController@store');
|
||
Route::get('/{id}', 'App\Http\Controllers\Api\ReportJobController@show')->where('id', '[0-9]+');
|
||
Route::post('/{id}/retry', 'App\Http\Controllers\Api\ReportJobController@retry')->where('id', '[0-9]+');
|
||
Route::post('/{id}/cancel', 'App\Http\Controllers\Api\ReportJobController@cancel')->where('id', '[0-9]+');
|
||
Route::delete('/{id}', 'App\Http\Controllers\Api\ReportJobController@destroy')->where('id', '[0-9]+');
|
||
});
|
||
|
||
// F2 (audit): скачивание готового файла отчёта по signed URL (24 ч, OPEN-И-20).
|
||
// НЕ под auth:sanctum — подпись URL = capability-token (генерируется только
|
||
// в ReportJobController::toResource() для отчётов своего тенанта).
|
||
Route::get('/api/reports/jobs/{id}/file', 'App\Http\Controllers\Api\ReportJobController@download')
|
||
->where('id', '[0-9]+')
|
||
->name('reports.download')
|
||
->middleware('signed');
|
||
|
||
// SaaS-admin зона (/api/admin/*). Гейт `saas-admin` = EnsureSaasAdmin: на проде
|
||
// fail-closed по nginx HTTP Basic Auth (^~ /api/admin, .htpasswd-admin) + M-1
|
||
// app-слой (REMOTE_USER ∈ ADMIN_ALLOWED_USERS, закрывает обходы фронт-контроллера)
|
||
// + запрет входа во время impersonation. Реальный Yandex 360 SSO — TODO под Б-1+DO-4.
|
||
// admin_user_id для audit — трейт ResolvesAdminUserId (отдельная зона).
|
||
// admin-db (UseAdminConnection) — ПОСЛЕ saas-admin: на время admin-запроса
|
||
// default-подключение = pgsql_admin (роль crm_admin_user, srv_bypass), чтобы
|
||
// AdminTenants/AdminBillingController видели все тенанты после переезда на
|
||
// Managed PG (Путь А). Контроллеры на pgsql_supplier не затрагиваются.
|
||
Route::middleware(['saas-admin', 'admin-db'])->group(function () {
|
||
// Командный центр (дашборд) — read-only агрегаты L1 + L2.
|
||
Route::get('/api/admin/dashboard', 'App\Http\Controllers\Api\AdminDashboardController@summary');
|
||
Route::get('/api/admin/dashboard/finance', 'App\Http\Controllers\Api\AdminDashboardController@finance');
|
||
Route::get('/api/admin/dashboard/health', 'App\Http\Controllers\Api\AdminDashboardController@health');
|
||
Route::get('/api/admin/dashboard/leads', 'App\Http\Controllers\Api\AdminDashboardController@leads');
|
||
Route::get('/api/admin/dashboard/supply', 'App\Http\Controllers\Api\AdminDashboardController@supply');
|
||
Route::get('/api/admin/dashboard/balances', 'App\Http\Controllers\Api\AdminDashboardController@balances');
|
||
Route::post('/api/admin/dashboard/balances/refresh', 'App\Http\Controllers\Api\AdminDashboardController@refreshBalances');
|
||
Route::get('/api/admin/dashboard/clients', 'App\Http\Controllers\Api\AdminDashboardController@clients');
|
||
Route::get('/api/admin/leads', 'App\Http\Controllers\Api\AdminLeadsController@index');
|
||
Route::get('/api/admin/leads/{id}', 'App\Http\Controllers\Api\AdminLeadsController@show')->whereNumber('id');
|
||
|
||
// SaaS-admin impersonation flow (Ю-1). Авторизация — через гейт группы (EnsureSaasAdmin).
|
||
Route::prefix('/api/admin/impersonation')->group(function () {
|
||
Route::get('/active', 'App\Http\Controllers\Api\ImpersonationController@active');
|
||
Route::get('/recent', 'App\Http\Controllers\Api\ImpersonationController@recent');
|
||
Route::post('/init', 'App\Http\Controllers\Api\ImpersonationController@init');
|
||
Route::post('/verify', 'App\Http\Controllers\Api\ImpersonationController@verify');
|
||
Route::post('/end', 'App\Http\Controllers\Api\ImpersonationController@end');
|
||
});
|
||
|
||
// SaaS-admin → Тенанты: lookup + детали для AdminTenantsView/AdminTenantDetailView.
|
||
Route::get('/api/admin/tenants', 'App\Http\Controllers\Api\AdminTenantsController@index');
|
||
Route::get('/api/admin/tenants/{subdomain}', 'App\Http\Controllers\Api\AdminTenantsController@show')
|
||
->where('subdomain', '[a-z0-9_-]+');
|
||
Route::patch('/api/admin/tenants/{id}/balance', 'App\Http\Controllers\Api\AdminTenantsController@updateBalance')
|
||
->where('id', '[0-9]+');
|
||
|
||
// SaaS-admin → Биллинг: aggregates пополнений/списаний за текущий месяц.
|
||
Route::get('/api/admin/billing', 'App\Http\Controllers\Api\AdminBillingController@index');
|
||
|
||
// T4a: SaaS-admin → Реклама: расход у Яндекса и наша маржа по тенантам (ad_wallet_transactions).
|
||
Route::get('/api/admin/advertising/spend', 'App\Http\Controllers\Api\AdminAdvertisingController@spend');
|
||
Route::get('/api/admin/advertising/settings', 'App\Http\Controllers\Api\AdminAdvertisingController@settings');
|
||
Route::put('/api/admin/advertising/settings', 'App\Http\Controllers\Api\AdminAdvertisingController@updateSettings');
|
||
// 8e: кампании, ждущие номер креатива Яндекса, перед запуском.
|
||
Route::get('/api/admin/advertising/campaigns', 'App\Http\Controllers\Api\AdminAdvertisingController@campaignsAwaiting');
|
||
Route::patch('/api/admin/advertising/campaigns/{id}/creative', 'App\Http\Controllers\Api\AdminAdvertisingController@setCampaignCreative')
|
||
->whereNumber('id');
|
||
// Задача 15: «ждёт разбора» — кампании, где робот сходил в кабинет и не понял, что видит.
|
||
Route::get('/api/admin/advertising/robot-stuck', 'App\Http\Controllers\Api\AdminAdvertisingController@robotStuck');
|
||
|
||
// SaaS-admin → Счета: список выставленных счетов + ручная отметка оплаты (Этап 1).
|
||
Route::get('/api/admin/invoices', 'App\Http\Controllers\Api\AdminInvoiceController@index');
|
||
Route::post('/api/admin/invoices/{id}/mark-paid', 'App\Http\Controllers\Api\AdminInvoiceController@markPaid')
|
||
->whereNumber('id');
|
||
|
||
// Sprint 3D (G4): SaaS-admin billing row-actions — приостановка/возврат/смена тарифа.
|
||
Route::get('/api/admin/billing/tariff-plans', 'App\Http\Controllers\Api\AdminBillingController@tariffPlans');
|
||
Route::patch('/api/admin/billing/tenants/{id}/status', 'App\Http\Controllers\Api\AdminBillingController@updateStatus')
|
||
->where('id', '[0-9]+');
|
||
Route::post('/api/admin/billing/tenants/{id}/refund', 'App\Http\Controllers\Api\AdminBillingController@refund')
|
||
->where('id', '[0-9]+');
|
||
Route::patch('/api/admin/billing/tenants/{id}/tariff', 'App\Http\Controllers\Api\AdminBillingController@changeTariff')
|
||
->where('id', '[0-9]+');
|
||
|
||
// SaaS-admin → Инциденты: чтение incidents_log для AdminIncidentsView.
|
||
Route::get('/api/admin/incidents', 'App\Http\Controllers\Api\AdminIncidentsController@index');
|
||
|
||
// Sprint 3D (G5): SaaS-admin incident detail-view drill-down.
|
||
Route::get('/api/admin/incidents/{id}', 'App\Http\Controllers\Api\AdminIncidentsController@show')
|
||
->where('id', '[0-9]+');
|
||
|
||
// Sprint 3D (G6): РКН-notify endpoint (152-ФЗ).
|
||
Route::post('/api/admin/incidents/{id}/rkn-notify', 'App\Http\Controllers\Api\AdminIncidentsController@notifyRkn')
|
||
->where('id', '[0-9]+');
|
||
|
||
// Раздел «Посетители» (spec 2026-07-13) — свой счётчик, не Метрика.
|
||
Route::get('/api/admin/visitors/funnel', 'App\Http\Controllers\Api\AdminVisitorsController@funnel');
|
||
Route::get('/api/admin/visitors/channels', 'App\Http\Controllers\Api\AdminVisitorsController@channels');
|
||
Route::get('/api/admin/visitors/visits', 'App\Http\Controllers\Api\AdminVisitorsController@visits');
|
||
Route::get('/api/admin/visitors/portal', 'App\Http\Controllers\Api\AdminVisitorsController@portalActivity');
|
||
|
||
// SaaS-admin → Система: edit-flow для system_settings + audit-log (4-eyes-pattern).
|
||
// Авторизация — через гейт группы saas-admin (EnsureSaasAdmin: nginx-basic + M-1).
|
||
Route::prefix('/api/admin/system-settings')->group(function () {
|
||
Route::get('/', 'App\Http\Controllers\Api\AdminSystemSettingsController@index');
|
||
Route::put('/{key}', 'App\Http\Controllers\Api\AdminSystemSettingsController@update')->where('key', '[a-z0-9_\.]+');
|
||
});
|
||
|
||
// SaaS-admin → Система: ручной баланс Яндекс 360 (почта).
|
||
Route::get('/api/admin/settings/yandex360-balance', 'App\Http\Controllers\Api\AdminYandex360BalanceController@show');
|
||
Route::put('/api/admin/settings/yandex360-balance', 'App\Http\Controllers\Api\AdminYandex360BalanceController@update');
|
||
|
||
// SaaS-admin → Биллинг: ввод секретных ключей платёжного шлюза (config зашифрован).
|
||
Route::put('/api/admin/payment-gateways/{code}', 'App\Http\Controllers\Api\AdminPaymentGatewayController@update')
|
||
->where('code', '[a-z0-9_]+');
|
||
|
||
// Plan 4: SaaS-admin pricing-tiers editor.
|
||
// CRUD для 7-ступенчатого тарифа. effective_from auto-computed = 1-е число
|
||
// следующего месяца (МСК). Audit-trail в saas_admin_audit_log.
|
||
Route::prefix('/api/admin/pricing-tiers')->group(function () {
|
||
Route::get('/', 'App\Http\Controllers\Api\AdminPricingTiersController@index');
|
||
Route::post('/', 'App\Http\Controllers\Api\AdminPricingTiersController@store');
|
||
Route::delete('/scheduled/{effective_from}',
|
||
'App\Http\Controllers\Api\AdminPricingTiersController@deleteScheduled')
|
||
->where('effective_from', '\d{4}-\d{2}-\d{2}');
|
||
});
|
||
|
||
// Plan 4 Task 10: SaaS-admin supplier prices editor.
|
||
// CRUD для B1/B2/B3 закупочных цен. Audit-trail в saas_admin_audit_log.
|
||
Route::get('/api/admin/suppliers', 'App\Http\Controllers\Api\AdminSuppliersController@index');
|
||
Route::patch('/api/admin/suppliers/{id}', 'App\Http\Controllers\Api\AdminSuppliersController@update')
|
||
->where('id', '[0-9]+');
|
||
|
||
// Резервный CSV-канал (Путь 2): здоровье канала + ручной запуск сверки.
|
||
Route::get('/api/admin/supplier-integration', 'App\Http\Controllers\Api\AdminSupplierIntegrationController@index');
|
||
Route::post('/api/admin/supplier-integration/reconcile', 'App\Http\Controllers\Api\AdminSupplierIntegrationController@reconcile');
|
||
|
||
// Эпик 5: история вечерних заливок проектов поставщику (supplier_sync_runs).
|
||
Route::get('/api/admin/supplier-integration/sync-runs', 'App\Http\Controllers\Api\AdminSupplierIntegrationController@syncRuns');
|
||
|
||
// Резерв канала миграции проектов (ярус 3): ручная очередь оператора.
|
||
Route::get('/api/admin/supplier-integration/manual-queue', 'App\Http\Controllers\Api\AdminSupplierIntegrationController@manualQueueIndex');
|
||
Route::post('/api/admin/supplier-integration/manual-queue/{id}/resolve', 'App\Http\Controllers\Api\AdminSupplierIntegrationController@manualQueueResolve')
|
||
->where('id', '[0-9]+');
|
||
|
||
// Plan 4 Task 1: глобальный тумблер режима экспорта проектов (online|batch).
|
||
Route::get('/api/admin/supplier-integration/export-mode', 'App\Http\Controllers\Api\AdminSupplierIntegrationController@getExportMode');
|
||
Route::post('/api/admin/supplier-integration/export-mode', 'App\Http\Controllers\Api\AdminSupplierIntegrationController@setExportMode');
|
||
|
||
// Тумблер «Разблокировка смены источника» (флаг routing_match_by_snapshot).
|
||
Route::get('/api/admin/supplier-integration/source-edit-flag', 'App\Http\Controllers\Api\AdminSupplierIntegrationController@getSourceEditFlag');
|
||
Route::post('/api/admin/supplier-integration/source-edit-flag', 'App\Http\Controllers\Api\AdminSupplierIntegrationController@setSourceEditFlag');
|
||
|
||
// Plan 4 Task 2: экран «Проекты у поставщика» — список + bulk-delete.
|
||
Route::get('/api/admin/supplier-integration/projects', 'App\Http\Controllers\Api\AdminSupplierIntegrationController@projectsIndex');
|
||
Route::post('/api/admin/supplier-integration/projects/delete', 'App\Http\Controllers\Api\AdminSupplierIntegrationController@projectsDestroy');
|
||
|
||
// 152-ФЗ: обращения субъектов ПДн + анонимизация (дыра #4).
|
||
Route::prefix('/api/admin/pd-subject-requests')->group(function () {
|
||
Route::get('/', 'App\Http\Controllers\Api\AdminPdSubjectRequestsController@index');
|
||
Route::post('/', 'App\Http\Controllers\Api\AdminPdSubjectRequestsController@store');
|
||
Route::get('/{id}', 'App\Http\Controllers\Api\AdminPdSubjectRequestsController@show')
|
||
->where('id', '[0-9]+');
|
||
Route::post('/{id}/erase', 'App\Http\Controllers\Api\AdminPdSubjectRequestsController@executeErasure')
|
||
->where('id', '[0-9]+');
|
||
});
|
||
|
||
// Telegram-модуль, задача 4.4: тарифная сетка клиентской Telegram-рекламы
|
||
// (client_tg_tariffs, глобальная).
|
||
Route::get('/api/admin/telegram/tariffs', 'App\Http\Controllers\Api\Admin\TgTariffController@index');
|
||
Route::put('/api/admin/telegram/tariffs', 'App\Http\Controllers\Api\Admin\TgTariffController@updateTariffs');
|
||
});
|
||
|
||
// Портал отдела продаж (/api/sales/*). Вход — guard 'sales' (Sanctum, Bearer).
|
||
// Всё через admin-db (crm_admin_user): и логин, и проверка токена, и cross-tenant
|
||
// чтение; каждый запрос данных фильтруется по владению (ScopesSalesOwnership).
|
||
// admin-db СТОИТ ПЕРЕД auth:sales (Sanctum читает токены/sales_users под crm_admin_user).
|
||
Route::middleware('admin-db')->prefix('api/sales/auth')->group(function () {
|
||
Route::post('/login', [SalesAuthController::class, 'login']);
|
||
Route::middleware('auth:sales')->group(function () {
|
||
Route::get('/me', [SalesAuthController::class, 'me']);
|
||
Route::post('/logout', [SalesAuthController::class, 'logout']);
|
||
});
|
||
});
|
||
// Зона данных портала (наполняется в Фазах 1–7).
|
||
Route::middleware(['admin-db', 'auth:sales', 'sales-portal'])->prefix('api/sales')->group(function () {
|
||
Route::get('/clients', [SalesClientsController::class, 'index']);
|
||
Route::get('/clients/{tenantId}', [SalesClientsController::class, 'show'])->whereNumber('tenantId');
|
||
Route::get('/overview', [SalesClientsController::class, 'overview']); // Task 1.5
|
||
// Task 2.3: заявки на привязку клиентов
|
||
Route::post('/attachments', [SalesAttachmentController::class, 'store']);
|
||
Route::get('/attachments', [SalesAttachmentController::class, 'index']);
|
||
Route::post('/attachments/{id}/decide', [SalesAttachmentController::class, 'decide'])->whereNumber('id');
|
||
// Task 3.2a: каталог тарифов + назначение текущего тарифа менеджеру (только head)
|
||
Route::get('/tariffs', [SalesTariffController::class, 'index']);
|
||
Route::post('/tariffs', [SalesTariffController::class, 'store']);
|
||
Route::match(['put', 'patch'], '/tariffs/{id}', [SalesTariffController::class, 'update'])->whereNumber('id');
|
||
Route::post('/tariffs/assign', [SalesTariffController::class, 'assign']);
|
||
// Этап 1 (воронка «Потенциальные клиенты»): доски менеджера/начальника.
|
||
Route::get('/prospects', [SalesProspectController::class, 'index']);
|
||
Route::post('/prospects', [SalesProspectController::class, 'store']);
|
||
// §16.4: подсказка юрлица/города по ИНН (ДаData). Ничего не сохраняет.
|
||
Route::post('/prospects/lookup-inn', [SalesProspectController::class, 'lookupInn']);
|
||
Route::patch('/prospects/{id}', [SalesProspectController::class, 'update'])->whereNumber('id');
|
||
// §18: менеджер фиксирует контактных лиц после обзвона предполагаемых номеров.
|
||
Route::patch('/prospects/{id}/contacts', [SalesProspectController::class, 'updateContacts'])->whereNumber('id');
|
||
// §19: журнал разговоров — история не теряется между звонками.
|
||
Route::get('/prospects/{id}/notes', [SalesProspectController::class, 'notes'])->whereNumber('id');
|
||
Route::post('/prospects/{id}/notes', [SalesProspectController::class, 'storeNote'])->whereNumber('id');
|
||
// Task 4.1: выплаты менеджерам (append-only журнал). remaining/store — только head.
|
||
Route::get('/payouts/remaining', [SalesPayoutController::class, 'remaining']);
|
||
Route::get('/payouts', [SalesPayoutController::class, 'index']);
|
||
Route::post('/payouts', [SalesPayoutController::class, 'store']);
|
||
// Task 4.2a: сводка «Мой доход» авторизованного менеджера.
|
||
Route::get('/income', [SalesIncomeController::class, 'show']);
|
||
// Task 5.1a: счета клиентов + отметка оплаты (только head; зачисление — InvoicePaymentService).
|
||
Route::get('/invoices', [SalesInvoiceController::class, 'index']);
|
||
Route::post('/invoices/{id}/mark-paid', [SalesInvoiceController::class, 'markPaid'])->whereNumber('id');
|
||
// Task 6.1a: сводка отдела «Дашборд начальника» (только head).
|
||
Route::get('/dashboard/overview', [SalesDashboardController::class, 'overview']);
|
||
// Task 6.2a: результативность менеджеров (только head).
|
||
Route::get('/managers/performance', [SalesManagersController::class, 'performance']);
|
||
// Task 7.1a: заведение менеджеров начальником + список аккаунтов (только head).
|
||
Route::get('/managers', [SalesManagersController::class, 'index']);
|
||
Route::post('/managers', [SalesManagersController::class, 'store']);
|
||
// Task 4+5 (план трёх площадок): «Реклама на кандидатов» по площадкам yandex|vk|mts
|
||
// (только head). Неизвестную площадку контроллер отвергает сам (platformOr404 → 404
|
||
// JSON) — без whereIn на маршруте, иначе непойманный сегмент проваливается
|
||
// в Route::fallback (SPA welcome-view) вместо чистого 404-ответа API.
|
||
Route::get('/warming/{platform}', [SalesAdAudienceController::class, 'show']);
|
||
Route::patch('/warming/{platform}', [SalesAdAudienceController::class, 'update']);
|
||
Route::get('/warming/{platform}/firms', [SalesAdAudienceController::class, 'firms']);
|
||
Route::post('/warming/{platform}/firms/{firm}/toggle', [SalesAdAudienceController::class, 'toggle'])->whereNumber('firm');
|
||
Route::post('/warming/{platform}/firms/{firm}/assign', [SalesAdAudienceController::class, 'assign'])->whereNumber('firm');
|
||
// Task C2 (Фаза 2 Этап C): массовый «Греть» — выбор режима funnel/flat, тело — firm_ids.
|
||
// Literal-сегмент 'warm' не конфликтует с 'firms' (toggle/assign) и с /warming/mts/file ниже.
|
||
Route::post('/warming/{platform}/warm', [SalesAdAudienceController::class, 'warm']);
|
||
// Task C3 (Фаза 2 Этап C): массовый «Убрать» — снять фирмы с площадки, тело — firm_ids.
|
||
Route::post('/warming/{platform}/unassign', [SalesAdAudienceController::class, 'unassign']);
|
||
// Task 3 (план трёх площадок): выгрузка списка номеров файлом для МТС (только head).
|
||
// Регистрируем ДО /warming/{platform}/firms — иначе .../firms не перепутается,
|
||
// но mts/file имеет другое число сегментов, поэтому порядок не критичен.
|
||
Route::get('/warming/mts/file', [SalesAdAudienceController::class, 'mtsFile']);
|
||
// Регрессия: старая /ad-audience/firms была снесена разъездом на площадки, но модуль
|
||
// «Прогрев СМС» (SalesSmsView) зовёт именно её для фирмо-пикера — восстановлена как
|
||
// платформо-независимый список ВСЕХ фирм прогрева (allFirms(), НЕ путать с firms()).
|
||
Route::get('/ad-audience/firms', [SalesAdAudienceController::class, 'allFirms']);
|
||
// Task D1 (Фаза 2 Этап D): только фирмы 4-го канала «СМС» (firm_channels(channel='sms')),
|
||
// с полем sms_sent_count. Literal-сегмент 'sms-firms' не конфликтует с 'firms' выше.
|
||
Route::get('/ad-audience/sms-firms', [SalesAdAudienceController::class, 'smsFirms']);
|
||
// Task 2 (кусок «Прогрев СМС»): назначение менеджера платформо-независимым маршрутом —
|
||
// у СМС нет понятия «площадка», карточка воронки в assignAny() та же, что у /warming/{platform}/....
|
||
Route::post('/ad-audience/firms/{firm}/assign', [SalesAdAudienceController::class, 'assignAny'])->whereNumber('firm');
|
||
// Task 9 (план «Прогрев СМС»): список рассылок, предпросмотр цены, запуск (только head).
|
||
Route::get('/sms/campaigns', [SalesSmsController::class, 'index']);
|
||
Route::post('/sms/campaigns', [SalesSmsController::class, 'store']);
|
||
Route::get('/sms/campaigns/{id}', [SalesSmsController::class, 'show'])->whereNumber('id');
|
||
Route::post('/sms/preview', [SalesSmsController::class, 'preview']);
|
||
// Task 10.5 (план «Прогрев СМС»): номера отмеченных галочками фирм (только head).
|
||
Route::post('/sms/recipients', [SalesSmsController::class, 'recipients']);
|
||
// managers, dashboard
|
||
});
|
||
|
||
// Сервис-канал «Поиск → Портал» (Этап 2). Токен вместо пользователя (sales-integration),
|
||
// admin-db — чтобы писать в sales_prospects под crm_admin_user. Дизайн §7.
|
||
Route::middleware(['admin-db', 'sales-integration'])->prefix('api/sales/integration')->group(function () {
|
||
Route::get('/managers', [SalesIntegrationController::class, 'managers']);
|
||
Route::post('/prospects', [SalesIntegrationController::class, 'ingest']);
|
||
// Рекламная аудитория: мобильные номера директоров отмеченных фирм.
|
||
Route::post('/ad-audience', [SalesIntegrationController::class, 'ingestAdAudience']);
|
||
// Витрина прогрева (кусок B, B6): состояние прогрева по списку ИНН для finder.
|
||
Route::post('/warming-state', [SalesIntegrationController::class, 'warmingState']);
|
||
});
|
||
|
||
// Служебный канал «Робот-грузчик креативов → Портал». Токен вместо пользователя.
|
||
// admin-db — робот ходит без tenant-контекста, работает под ролью crm_admin_user.
|
||
// Токен ПЕРВЫМ, служебное соединение вторым: сначала пропуск, потом ключи от служебного
|
||
// входа. Тот же порядок, что у админского канала (см. комментарий в UseAdminConnection).
|
||
Route::middleware(['creative-robot', 'admin-db'])->prefix('api/creative-robot')->group(function () {
|
||
Route::get('/next', [CreativeRobotController::class, 'next']);
|
||
// Номер задания — часть адреса файла: робот получает файлы только того задания,
|
||
// которое ему выдали, а не «какого-нибудь, что сейчас в работе».
|
||
Route::get('/jobs/{jobId}/banners/{bannerId}/file', [CreativeRobotController::class, 'file'])
|
||
->whereNumber(['jobId', 'bannerId']);
|
||
Route::post('/jobs/{jobId}/done', [CreativeRobotController::class, 'done'])->whereNumber('jobId');
|
||
// Доклад разведки: что робот прочитал на экране кабинета про отклонённое объявление.
|
||
// Отдельно от «готово» — у разведки другой смысл готовности: без текста её не бывает.
|
||
Route::post('/jobs/{jobId}/inspection', [CreativeRobotController::class, 'inspection'])->whereNumber('jobId');
|
||
});
|
||
|
||
// Служебный канал «Телеграм-робот кабинета МТС → Портал». Токен вместо пользователя.
|
||
// admin-db — робот ходит без tenant-контекста, работает под ролью crm_admin_user.
|
||
// Токен ПЕРВЫМ, служебное соединение вторым: сначала пропуск, потом ключи от служебного входа.
|
||
Route::middleware(['tg-robot', 'admin-db'])->prefix('api/tg-robot')->group(function () {
|
||
Route::get('/next', [TgRobotController::class, 'next']);
|
||
// Номер задания — часть адреса: робот получает номера только того задания, которое
|
||
// ему выдали, а не «какого-нибудь, что сейчас в работе».
|
||
Route::get('/jobs/{jobId}/phones', [TgRobotController::class, 'phones'])->whereNumber('jobId');
|
||
Route::post('/jobs/{jobId}/done', [TgRobotController::class, 'done'])->whereNumber('jobId');
|
||
});
|
||
|
||
// Plan 4 Task 11: tenant charges ledger (read-only + CSV export).
|
||
// RLS изоляция через SetTenantContext (auth:sanctum + tenant) — текущий tenant
|
||
// видит только свои lead_charges. Pagination 20/page, фильтры period/source.
|
||
Route::middleware(['auth:sanctum,impersonation', 'tenant'])->prefix('/api/billing/charges')->group(function () {
|
||
Route::get('/', 'App\Http\Controllers\Api\TenantChargesController@index');
|
||
Route::post('/export', 'App\Http\Controllers\Api\TenantChargesController@export');
|
||
});
|
||
|
||
// Биллинг тенанта: пополнение/кошелёк/транзакции/счета (audit E1/E3).
|
||
// RLS на balance_transactions / saas_invoices требует tenant middleware.
|
||
Route::middleware(['auth:sanctum', 'tenant'])->prefix('/api/billing')->group(function () {
|
||
Route::post('/topup', 'App\Http\Controllers\Api\BillingController@topup');
|
||
Route::get('/wallet', 'App\Http\Controllers\Api\BillingController@wallet');
|
||
// Судьба последней онлайн-оплаты — кабинет показывает «оплата не завершена» вместо
|
||
// вранья «платёж обрабатывается» (инцидент 14.07.2026).
|
||
Route::get('/last-payment', 'App\Http\Controllers\Api\BillingController@lastPayment');
|
||
Route::get('/balance-status', 'App\Http\Controllers\Api\BillingController@balanceStatus');
|
||
Route::get('/transactions', 'App\Http\Controllers\Api\BillingController@transactions');
|
||
Route::get('/invoices', 'App\Http\Controllers\Api\BillingController@invoices');
|
||
Route::post('/invoices', 'App\Http\Controllers\Api\InvoiceController@store');
|
||
Route::get('/invoices/{id}/pdf', 'App\Http\Controllers\Api\InvoiceController@pdf')->whereNumber('id');
|
||
Route::get('/invoices/{id}/act', 'App\Http\Controllers\Api\InvoiceController@act')->whereNumber('id');
|
||
});
|
||
|
||
// Реклама тенанта: статус рекламного кошелька для баннера «пополните
|
||
// рекламный кошелёк» (Task 9, часть B). RLS на ad_wallets требует tenant middleware.
|
||
Route::middleware(['auth:sanctum', 'tenant'])->prefix('/api/advertising')->group(function () {
|
||
Route::get('/wallet', 'App\Http\Controllers\Api\AdvertisingWalletController@show');
|
||
|
||
// Кампании Директа для клиентского портала (Часть A, Task 12) — CRUD-черновик +
|
||
// счётчик аудитории + запуск + креатив. Фронт (Часть B2) — отдельная задача.
|
||
Route::get('/campaigns', 'App\Http\Controllers\Api\AdvertisingCampaignController@index');
|
||
Route::post('/campaigns', 'App\Http\Controllers\Api\AdvertisingCampaignController@store');
|
||
Route::get('/campaigns/{id}', 'App\Http\Controllers\Api\AdvertisingCampaignController@show')->whereNumber('id');
|
||
Route::patch('/campaigns/{id}', 'App\Http\Controllers\Api\AdvertisingCampaignController@update')->whereNumber('id');
|
||
Route::get('/campaigns/{id}/audience-size', 'App\Http\Controllers\Api\AdvertisingCampaignController@audienceSize')->whereNumber('id');
|
||
Route::get('/campaigns/{id}/banners', 'App\Http\Controllers\Api\AdvertisingCampaignController@listBanners')->whereNumber('id');
|
||
Route::post('/campaigns/{id}/banners', 'App\Http\Controllers\Api\AdvertisingCampaignController@uploadBanner')->whereNumber('id');
|
||
Route::patch('/campaigns/{id}/banners/{bannerId}', 'App\Http\Controllers\Api\AdvertisingCampaignController@toggleBannerIncluded')->whereNumber(['id', 'bannerId']);
|
||
Route::delete('/campaigns/{id}/banners/{bannerId}', 'App\Http\Controllers\Api\AdvertisingCampaignController@deleteBanner')->whereNumber(['id', 'bannerId']);
|
||
Route::get('/campaigns/{id}/banners/{bannerId}/preview', 'App\Http\Controllers\Api\AdvertisingCampaignController@previewBanner')->whereNumber('id')->whereNumber('bannerId');
|
||
Route::post('/campaigns/{id}/banners/approve', 'App\Http\Controllers\Api\AdvertisingCampaignController@approveBanners')->whereNumber('id');
|
||
Route::post('/campaigns/{id}/launch', 'App\Http\Controllers\Api\AdvertisingCampaignController@launch')->whereNumber('id');
|
||
Route::post('/campaigns/{id}/submit', 'App\Http\Controllers\Api\AdvertisingCampaignController@submit')->whereNumber('id');
|
||
// «Исправить» — вернуть отклонённую кампанию в черновик. Отдельного пути запуска
|
||
// за этим не появляется: дальше клиент жмёт обычное «Запустить».
|
||
Route::post('/campaigns/{id}/revive', 'App\Http\Controllers\Api\AdvertisingCampaignController@revive')->whereNumber('id');
|
||
Route::post('/campaigns/{id}/pause', 'App\Http\Controllers\Api\AdvertisingCampaignController@pause')->whereNumber('id');
|
||
Route::post('/campaigns/{id}/resume', 'App\Http\Controllers\Api\AdvertisingCampaignController@resume')->whereNumber('id');
|
||
Route::post('/campaigns/{id}/ads', 'App\Http\Controllers\Api\AdvertisingCampaignController@storeAd')->whereNumber('id');
|
||
Route::post('/campaigns/{id}/ads/{adId}/image', 'App\Http\Controllers\Api\AdvertisingCampaignController@uploadAdImage')->whereNumber(['id', 'adId']);
|
||
Route::delete('/campaigns/{id}', 'App\Http\Controllers\Api\AdvertisingCampaignController@destroy')->whereNumber('id');
|
||
Route::post('/campaigns/{id}/phones', 'App\Http\Controllers\Api\AdvertisingCampaignController@storePhones')->whereNumber('id');
|
||
|
||
// Лента сообщений по кампании — окно передачи между Яндексом и клиентом:
|
||
// пояснения модератора, ответы клиента, служебные отметки портала.
|
||
Route::get('/campaigns/{id}/messages', 'App\Http\Controllers\Api\AdvertisingCampaignMessageController@index')->whereNumber('id');
|
||
Route::post('/campaigns/{id}/messages', 'App\Http\Controllers\Api\AdvertisingCampaignMessageController@store')->whereNumber('id');
|
||
Route::get('/campaigns/{id}/messages/{messageId}/file', 'App\Http\Controllers\Api\AdvertisingCampaignMessageController@file')->whereNumber(['id', 'messageId']);
|
||
});
|
||
|
||
// API-ключи тенанта (audit D2/D3/J5). RLS на api_keys требует tenant middleware.
|
||
Route::middleware(['auth:sanctum', 'tenant'])->prefix('/api/api-keys')->group(function () {
|
||
Route::get('/', 'App\Http\Controllers\Api\ApiKeyController@index');
|
||
Route::post('/regenerate', 'App\Http\Controllers\Api\ApiKeyController@regenerate');
|
||
});
|
||
|
||
// G6: публичный read-API сделок по API-ключу (middleware apikey — без sanctum/tenant).
|
||
// apiv1-rate (приёмка 21.06): throttle:api-v1 (120/мин/источник) ПЕРЕД apikey —
|
||
// прикрывает bcrypt/DB-работу аутентификации от brute/DoS (лимитер — AppServiceProvider).
|
||
Route::middleware(['throttle:api-v1', 'apikey'])->prefix('/api/v1')->group(function () {
|
||
Route::get('/deals', 'App\Http\Controllers\Api\V1\DealsController@index')->name('api.v1.deals.index');
|
||
});
|
||
|
||
// Настройки исходящего webhook'а тенанта (audit D4/D5/J5).
|
||
Route::middleware(['auth:sanctum', 'tenant'])->group(function () {
|
||
Route::get('/api/tenants/me/webhook-settings', 'App\Http\Controllers\Api\WebhookSettingsController@show');
|
||
Route::put('/api/tenants/me/webhook-settings', 'App\Http\Controllers\Api\WebhookSettingsController@update');
|
||
Route::post('/api/webhooks/test', 'App\Http\Controllers\Api\WebhookSettingsController@test');
|
||
});
|
||
|
||
// Дашборд — агрегат KPI/баланса/активности/воронки (audit J3). Go-live: auth:sanctum
|
||
// + tenant; tenant_id из auth()->user()->tenant_id (SetTenantContext), НЕ из параметра
|
||
// запроса — закрывает кросс-tenant утечку KPI (как DealController J1).
|
||
Route::middleware(['auth:sanctum,impersonation', 'tenant'])->group(function () {
|
||
Route::get('/api/dashboard/summary', 'App\Http\Controllers\Api\DashboardController@summary');
|
||
});
|
||
|
||
// G7-A: клиентские заявки в техподдержку.
|
||
Route::middleware(['auth:sanctum,impersonation', 'tenant'])->post('/api/support-requests', 'App\Http\Controllers\Api\SupportRequestController@store');
|
||
|
||
// G7-B: выход из режима поддержки из самого кабинета клиента.
|
||
Route::middleware('auth:sanctum')->post('/api/impersonation/leave', 'App\Http\Controllers\Api\ImpersonationController@leave');
|
||
|
||
// Сделки — single-resource CRUD + bulk + export. J1 (Sprint 3F, audit):
|
||
// auth:sanctum + tenant. tenant_id берётся из auth()->user()->tenant_id
|
||
// (SetTenantContext), НЕ из параметра запроса — закрывает кросс-tenant утечку.
|
||
//
|
||
// Sprint 3 Phase A (audit O-refactor-01): single-resource CRUD в
|
||
// DealController, bulk (transition/destroy/restore) — в
|
||
// DealBulkActionController, export — в DealExportController.
|
||
Route::middleware(['auth:sanctum,impersonation', 'tenant'])->group(function () {
|
||
Route::get('/api/deals', 'App\Http\Controllers\Api\DealController@index');
|
||
Route::get('/api/deals/{id}', 'App\Http\Controllers\Api\DealController@show')->where('id', '[0-9]+');
|
||
Route::post('/api/deals', 'App\Http\Controllers\Api\DealController@store');
|
||
Route::post('/api/deals/export', 'App\Http\Controllers\Api\DealExportController@export');
|
||
Route::post('/api/deals/transition', 'App\Http\Controllers\Api\DealBulkActionController@transition');
|
||
Route::patch('/api/deals/{id}', 'App\Http\Controllers\Api\DealController@update')->where('id', '[0-9]+');
|
||
Route::delete('/api/deals', 'App\Http\Controllers\Api\DealBulkActionController@destroy');
|
||
Route::post('/api/deals/restore', 'App\Http\Controllers\Api\DealBulkActionController@restore');
|
||
});
|
||
|
||
// Sprint 4 — CSV-импорт исторических лидов (ТЗ §6).
|
||
// ВАЖНО: /unknown-statuses и /unknown-statuses/resolve объявлены ДО
|
||
// /{importLog}, иначе литеральный сегмент перехватывается параметром.
|
||
Route::middleware(['auth:sanctum,impersonation', 'tenant'])->group(function () {
|
||
Route::get('/api/imports/unknown-statuses', 'App\Http\Controllers\Api\ImportController@unknownStatuses');
|
||
Route::post('/api/imports/unknown-statuses/resolve', 'App\Http\Controllers\Api\ImportController@resolveUnknownStatuses');
|
||
Route::get('/api/imports', 'App\Http\Controllers\Api\ImportController@index');
|
||
Route::post('/api/imports', 'App\Http\Controllers\Api\ImportController@store');
|
||
Route::get('/api/imports/{importLog}', 'App\Http\Controllers\Api\ImportController@show');
|
||
});
|
||
|
||
// Lookup endpoints — заполняют v-select'ы (NewDealDialog, smart-filters).
|
||
// Go-live: auth:sanctum. /api/managers — tenant-scoped (tenant_id из authed-user, НЕ из
|
||
// параметра — закрывает кросс-tenant утечку списка пользователей); /api/lead-statuses —
|
||
// глобальная таблица (без tenant_id), нужен только auth:sanctum.
|
||
Route::middleware(['auth:sanctum,impersonation', 'tenant'])->group(function () {
|
||
Route::get('/api/managers', 'App\Http\Controllers\Api\ManagerController@index');
|
||
});
|
||
Route::middleware('auth:sanctum,impersonation')->group(function () {
|
||
Route::get('/api/lead-statuses', 'App\Http\Controllers\Api\LeadStatusController@index');
|
||
});
|
||
|
||
// Plan 5 Task 2: Projects CRUD — расширенный API с auth:sanctum + RLS.
|
||
// Заменяет старый GET /api/projects?tenant_id={id} (без auth, MVP-версия).
|
||
// ⚠️ NewDealDialog использовал старый endpoint (tenant_id param, без auth) —
|
||
// после этой замены получит 401. Defer fix до Task 7 (frontend phase).
|
||
Route::middleware(['auth:sanctum,impersonation', 'tenant'])->prefix('/api/projects')->group(function () {
|
||
Route::get('/', 'App\Http\Controllers\Api\ProjectController@index')->name('projects.index');
|
||
Route::post('/', 'App\Http\Controllers\Api\ProjectController@store')->name('projects.store');
|
||
// /bulk MUST be declared before /{id} parameterized routes so the literal
|
||
// segment matches before the regex placeholder is even considered.
|
||
Route::post('/bulk', 'App\Http\Controllers\Api\ProjectController@bulk')->name('projects.bulk');
|
||
Route::get('/{id}', 'App\Http\Controllers\Api\ProjectController@show')->name('projects.show')->where('id', '[0-9]+');
|
||
Route::patch('/{id}', 'App\Http\Controllers\Api\ProjectController@update')->name('projects.update')->where('id', '[0-9]+');
|
||
Route::delete('/{id}', 'App\Http\Controllers\Api\ProjectController@destroy')->name('projects.destroy')->where('id', '[0-9]+');
|
||
Route::post('/{id}/sync', 'App\Http\Controllers\Api\ProjectController@sync')->name('projects.sync')->where('id', '[0-9]+');
|
||
Route::patch('/{id}/toggle-active', 'App\Http\Controllers\Api\ProjectController@toggleActive')->name('projects.toggle')->where('id', '[0-9]+');
|
||
});
|
||
|
||
// Автоподбор конкурентов — клиентский API (Task 17a).
|
||
Route::middleware(['auth:sanctum,impersonation', 'tenant'])->prefix('/api/autopodbor')->group(function () {
|
||
Route::get('/state', 'App\Http\Controllers\Api\AutopodborController@state');
|
||
Route::get('/field', 'App\Http\Controllers\Api\AutopodborController@field');
|
||
Route::get('/field/duplicate-groups', 'App\Http\Controllers\Api\AutopodborController@duplicateGroups');
|
||
Route::post('/field/merge', 'App\Http\Controllers\Api\AutopodborController@mergeCompetitors');
|
||
Route::get('/field/merge-events', 'App\Http\Controllers\Api\AutopodborController@mergeEvents');
|
||
Route::post('/field/merge-events/{event}/restore', 'App\Http\Controllers\Api\AutopodborController@restoreMergeEvent')->where('event', '[0-9]+');
|
||
Route::get('/proposals', 'App\Http\Controllers\Api\AutopodborController@proposals');
|
||
Route::post('/proposals/dismiss-archived', 'App\Http\Controllers\Api\AutopodborController@dismissArchivedGroup');
|
||
Route::get('/runs/{run}', 'App\Http\Controllers\Api\AutopodborController@run')->where('run', '[0-9]+');
|
||
Route::get('/runs/{run}/competitors', 'App\Http\Controllers\Api\AutopodborController@runCompetitors')->where('run', '[0-9]+');
|
||
Route::get('/competitors/{competitor}', 'App\Http\Controllers\Api\AutopodborController@competitor')->where('competitor', '[0-9]+');
|
||
Route::post('/search', 'App\Http\Controllers\Api\AutopodborController@search');
|
||
Route::post('/study', 'App\Http\Controllers\Api\AutopodborController@study');
|
||
Route::post('/study/batch', 'App\Http\Controllers\Api\AutopodborController@studyBatch');
|
||
Route::post('/resolve', 'App\Http\Controllers\Api\AutopodborController@resolve');
|
||
Route::post('/projects', 'App\Http\Controllers\Api\AutopodborController@createProjects');
|
||
Route::post('/manual-study', 'App\Http\Controllers\Api\AutopodborController@manualStudy');
|
||
Route::post('/sources/manual', 'App\Http\Controllers\Api\AutopodborController@addManualSource');
|
||
Route::post('/competitors/manual', 'App\Http\Controllers\Api\AutopodborController@manualCompetitor');
|
||
Route::post('/competitors/{competitor}/actualize', 'App\Http\Controllers\Api\AutopodborController@actualize')->where('competitor', '[0-9]+');
|
||
Route::post('/competitors/{competitor}/dismiss-actualize', 'App\Http\Controllers\Api\AutopodborController@dismissActualize')->where('competitor', '[0-9]+');
|
||
Route::patch('/competitors/{competitor}/box', 'App\Http\Controllers\Api\AutopodborController@competitorBox')->where('competitor', '[0-9]+');
|
||
Route::patch('/competitors/{competitor}', 'App\Http\Controllers\Api\AutopodborController@updateCompetitor')->where('competitor', '[0-9]+');
|
||
Route::delete('/competitors/{competitor}', 'App\Http\Controllers\Api\AutopodborController@destroyCompetitor')->where('competitor', '[0-9]+');
|
||
Route::patch('/sources/{source}/box', 'App\Http\Controllers\Api\AutopodborController@sourceBox')->where('source', '[0-9]+');
|
||
Route::patch('/sources/{source}', 'App\Http\Controllers\Api\AutopodborController@updateSource')->where('source', '[0-9]+');
|
||
Route::delete('/sources/{source}', 'App\Http\Controllers\Api\AutopodborController@destroySource')->where('source', '[0-9]+');
|
||
});
|
||
|
||
// Supplier-integration webhook (Plan 2/5, spec §5.1).
|
||
// Platform-wide endpoint: единый {secret} в URL для всех лидов от crm.bp-gr.ru.
|
||
// Auth: secret (system_settings.supplier_webhook_secret) + IP allowlist
|
||
// (system_settings.supplier_ip_allowlist). Не пересекается с legacy /api/webhook/{token}.
|
||
// Secretless-вариант: аутентификация по HMAC-подписи (X-Webhook-Signature),
|
||
// чтобы секрет не светился в URL/access-логах (P2/E4). receive() c $secret=''.
|
||
Route::post('/api/webhook/supplier', 'App\Http\Controllers\Api\SupplierWebhookController@receive');
|
||
Route::post('/api/webhook/supplier/{secret}', 'App\Http\Controllers\Api\SupplierWebhookController@receive')
|
||
->where('secret', '[A-Za-z0-9_\-]+');
|
||
|
||
// Свой чат: окошко на портале и лендинге (спека 2026-07-13-own-chat-widget-design §5).
|
||
// Публичный адрес — лендинг открыт всему интернету; защита от накрутки — ChatRateLimiter.
|
||
Route::post('/api/chat/message', 'App\Http\Controllers\Api\ChatController@send');
|
||
Route::get('/api/chat/{chat}/messages', 'App\Http\Controllers\Api\ChatController@messages')
|
||
->where('chat', '[a-f0-9]{32}');
|
||
|
||
// Платёжный webhook (ЮKassa). Публичный, под маской api/webhook/* → CSRF-exempt.
|
||
// Подлинность — server-to-server сверкой статуса (не доверяем телу). Plan billing-yookassa Task 7.
|
||
Route::post('/api/webhook/payment', 'App\Http\Controllers\Api\PaymentWebhookController@receive');
|
||
|
||
// Публичная (без auth) тарифная сетка — для страницы цен и модерации ЮKassa.
|
||
Route::get('/api/public/pricing', 'App\Http\Controllers\Api\PublicPricingController@index');
|
||
|
||
// Маячок учёта посетителей (spec 2026-07-13). Публичный, без auth: его шлёт JS
|
||
// лендинга и портала. Робот JS не исполняет → в базу не попадает.
|
||
// throttle:track — 60/мин с адреса, защита от накрутки.
|
||
Route::post('/api/track', 'App\Http\Controllers\Api\TrackController@store')
|
||
->middleware('throttle:track');
|
||
|
||
// 2FA setup wizard — все эндпоинты под auth:sanctum (только для уже залогиненных).
|
||
Route::prefix('/api/2fa')->middleware('auth:sanctum')->group(function () {
|
||
Route::post('/init', 'App\Http\Controllers\Api\TwoFactorSetupController@init');
|
||
Route::post('/confirm', 'App\Http\Controllers\Api\TwoFactorSetupController@confirm');
|
||
Route::post('/disable', 'App\Http\Controllers\Api\TwoFactorSetupController@disable');
|
||
Route::post('/regenerate-recovery-codes', 'App\Http\Controllers\Api\TwoFactorSetupController@regenerateRecoveryCodes');
|
||
});
|
||
|
||
// SPA-страницы: каждый путь отдаёт Vue-shell (один Blade-template `welcome`).
|
||
// Vue Router (createWebHistory) разруливает /login, /register и т.п. на фронте.
|
||
//
|
||
// Регистрируем явно, а не catch-all `/{any?}` — иначе тесты, регистрирующие
|
||
// runtime routes через `Route::get(...)` в beforeEach (например _test/...),
|
||
// будут перехвачены catch-all'ом и вернут welcome view вместо expected response.
|
||
// Короткие ссылки для рассылок (spec 2026-07-13 §7). В смс дорог каждый символ,
|
||
// поэтому вместо длинного хвоста с метками даём liderra.ru/s — сервер сам подставит
|
||
// канал и дату рассылки, и учёт увидит смс отдельно от «прямых заходов».
|
||
// Дата в кампании (дд-мм) отличает рассылки друг от друга без ручной правки ссылки.
|
||
Route::get('/s', fn () => redirect()->away(
|
||
'https://liderra.ru/?utm_source=sms&utm_medium=sms&utm_campaign='.now()->format('d-m')
|
||
));
|
||
Route::get('/hh', fn () => redirect()->away(
|
||
'https://liderra.ru/?utm_source=hh&utm_medium=vacancy'
|
||
));
|
||
Route::get('/tg', fn () => redirect()->away(
|
||
'https://liderra.ru/?utm_source=tg&utm_medium=social'
|
||
));
|
||
|
||
Route::view('/', 'welcome');
|
||
Route::view('/login', 'welcome');
|
||
Route::view('/register', 'welcome');
|
||
Route::view('/forgot', 'welcome');
|
||
Route::view('/reset', 'welcome'); // SPA-router рендерит ResetPasswordView для /reset/{token}
|
||
Route::view('/2fa', 'welcome');
|
||
Route::view('/recovery-use', 'welcome');
|
||
Route::view('/legal/offer', 'welcome');
|
||
Route::view('/legal/privacy', 'welcome');
|
||
Route::view('/dashboard', 'welcome');
|
||
Route::view('/deals', 'welcome');
|
||
Route::view('/kanban', 'welcome');
|
||
Route::view('/projects', 'welcome');
|
||
Route::view('/billing', 'welcome');
|
||
Route::view('/settings', 'welcome');
|
||
Route::view('/reports', 'welcome');
|
||
Route::view('/import', 'welcome'); // Sprint 4 — CSV-импорт исторических лидов §6
|
||
Route::view('/admin', 'welcome');
|
||
Route::view('/admin/tenants', 'welcome');
|
||
Route::view('/admin/billing', 'welcome');
|
||
Route::view('/admin/invoices', 'welcome');
|
||
Route::view('/admin/incidents', 'welcome');
|
||
Route::view('/admin/system', 'welcome');
|
||
Route::view('/admin/pricing-tiers', 'welcome');
|
||
Route::view('/admin/supplier-prices', 'welcome');
|
||
Route::view('/admin/supplier-integration', 'welcome');
|
||
Route::view('/admin/impersonation', 'welcome');
|
||
Route::view('/403', 'welcome');
|
||
Route::view('/500', 'welcome');
|
||
|
||
// Страница разборов «Как это работает» — её бот присылает ГОСТЮ с лендинга (ссылка вида
|
||
// https://liderra.ru/kak-eto-rabotaet#sozdat-proekt). На боевом nginx отдаёт лендинг только
|
||
// по адресу «/», все остальные пути уходят в портал — поэтому страницу отдаём отсюда, а не
|
||
// правкой nginx. Сама страница и кадры — статика в public/ (kak-eto-rabotaet.html + guides/).
|
||
Route::get('/kak-eto-rabotaet', function () {
|
||
$page = public_path('kak-eto-rabotaet.html');
|
||
|
||
abort_unless(file_exists($page), 404);
|
||
|
||
return response()->file($page, ['Content-Type' => 'text/html; charset=utf-8']);
|
||
});
|
||
|
||
// Fallback для всех неизвестных путей — Vue Router catch-all отрисует 404.
|
||
// Срабатывает ПОСЛЕ всех явных route'ов выше и runtime-route'ов от Pest
|
||
// beforeEach (они регистрируются в момент теста, до запроса).
|
||
Route::fallback(fn () => view('welcome'));
|