Files
portal/app/routes/web.php
T
Дмитрий 77f61fb1d1
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
merge: сведение ветки «Реклама Телеграм» с боевым main
Слияние feat/client-telegram-ads с main 8bdd58e8. Десять швов разобраны вручную:
денежный файл AdWalletService взят из main целиком — проверено поимённо, что все три
починки на месте: свой контекст клиента, оживление брони, таяние заморозки. В расписании
объединены оба набора заданий: телеграмные два и рекламные четыре. В боковом меню и в
мобильном «Ещё» сохранён пункт «Рекламный кошелёк», подписи поправлены — на реальные
экраны ведут ОБА канала. Словарь, пример настроек и журнал схемы объединены.

Сверх самого слияния:

- Журнал схемы: телеграмные записи v8.86-v8.95 перенумерованы в v9.18-v9.27, блок
  переставлен наверх, пометки «номер предварительный» сняты и заменены одной врезкой
  о перенумерации. Задвоенных номеров не осталось. Врезка шапки теперь называет и
  телеграмные таблицы: их DDL, как и рекламный, живёт только в дельта-миграциях.
- Новый сторож денег tests/Feature/ClientTg/TgMoneyUnderRealRoleTest.php: списание и
  возврат под боевой ролью crm_app_user. С контекстом клиента деньги двигаются, без
  контекста возврат падает громко. Обычные тесты ходят суперюзером и этот класс дыр
  увидеть не могут.
- Помощник rejectedCampaign переименован в tgRejectedCampaign: одноимённый помощник
  есть у рекламного модуля, помощники Pest глобальные, полный прогон падал фаталом.
  Каждая ветка по отдельности этого увидеть не могла.
- Два теста уведомлений считали ВСЮ таблицу целиком вместо строк своего пользователя:
  в одиночку зелёные, в полном прогоне красные. Счёт сужен до конкретного пользователя.
- Убраны две проверки отменённой сущности «своё имя отправителя» — сама сущность
  дропнута в v9.27 как СМС-фантазия, её адрес отдаёт 405.

Прогоны: телеграм 193/193, реклама 336/336 при 1029 проверках, вместе 532/532,
экраны 1704/1708, сборка фронта чисто, полный Unit+Feature 3923/3960. Шестнадцать
падений полного прогона совпадают построчно с прогоном ветки без телеграма — слияние
не добавило ни одного. Статанализ в свежем каталоге запустить не удалось: он требует
сгенерированного файла-подсказки, которого нет в репозитории, и без него молча падает
на обеих ветках.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 16:41:38 +03:00

690 lines
55 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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 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');
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_auto_rule): клиент управляет авто-режимом из кабинета.
Route::get('/auto-rule', 'App\Http\Controllers\Api\ClientTg\AutoRuleController@show');
Route::put('/auto-rule', 'App\Http\Controllers\Api\ClientTg\AutoRuleController@update');
});
// 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');
});
// 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'));