Files
portal/app/routes/web.php
T
Дмитрий 00cc072e2f feat,телеграм-реклама: «Моя база номеров» заработала — пункт больше не ведёт в никуда
Это снимает запрет на выкат телеграм-рекламы.

Пункт «Моя база номеров» стоял в выборе аудитории с 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>
2026-08-02 23:13:31 +03:00

713 lines
58 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 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'));