Files
portal/app/routes/web.php
T
Дмитрий ac186593f2 phase2(bulk-transition+reload): POST /api/deals/transition + reload-btn
Закрывает gap «UI меняет статус, но изменения не сохраняются на backend»
из v1.51. Reload-btn заменяет polling/SSE до прихода long-poll'а на prod.

Backend (DealController::transition):
- POST /api/deals/transition {tenant_id, ids: [1..1000 ints], status}.
- Валидация status — exists в lead_statuses (глобальная таблица).
- RLS-обёртка SET LOCAL + defense-in-depth where(tenant_id) для
  partial-update: чужие id остаются в исходном статусе.
- ActivityLog event=deal.status_changed с context={from, to, source: 'bulk'}
  для каждой ИЗМЕНЁННОЙ сделки. NO-OP (старый==новый) не пишется в audit.
- Ответ: {updated, requested, status}.

Pest +7 (DealTransitionTest):
- 422 missing fields / 404 unknown tenant / 422 неизвестный slug + не апдейт /
  batch update 3 сделок + 3 ActivityLog с правильным context /
  NO-OP не пишет ActivityLog / defense-in-depth (2 tenant'а — обновляется
  только свой) / 422 пустой массив ids.

Frontend:
- dealsApi.transitionDeals — типизированный helper с ensureCsrfCookie.
- applyBulkStatus в DealsView переписан async: optimistic local-update +
  backend-вызов если auth.user.tenant_id. На success — toast «Обновлено
  N из M.», на fail — warning toast + локальный update НЕ откатывается.
  Без auth.user — только optimistic (legacy local-mode сохранён).
- reload-btn в DealsView и KanbanView — outlined «Обновить» mdi-refresh,
  привязан к loadDeals. В DealsView :loading="loading" во время fetch'а.

Vitest +5:
- reload-btn (Deals + Kanban) — listDeals вызывается дважды.
- applyBulkStatus с tenant_id — transitionDeals + optimistic + toast.
- applyBulkStatus без tenant_id — НЕ вызывается transitionDeals.
- applyBulkStatus reject — toast warning + локальный update остаётся.

PHPStan baseline регенерирован. cspell-glossary +апдейт*.

Регресс:
- Lint+type-check+format passed.
- Vitest 266/266 за 18.16 сек (+5 от 261).
- Vite build 1.06 сек.
- Pint + PHPStan passed.
- Pest 193/193 за 23.27 сек (+7 от 186, 767 assertions).

Реестр v1.60→v1.61 / CLAUDE.md v1.51→v1.52.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 07:46:19 +03:00

103 lines
6.0 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\AdminSystemSettingsController;
use App\Http\Controllers\Api\AuthController;
use App\Http\Controllers\Api\DealController;
use App\Http\Controllers\Api\ImpersonationController;
use App\Http\Controllers\Api\ManagerController;
use App\Http\Controllers\Api\ProjectController;
use App\Http\Controllers\Api\TwoFactorSetupController;
use App\Http\Controllers\Api\WebhookReceiveController;
use Illuminate\Support\Facades\Route;
// 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::post('/login', [AuthController::class, 'login']);
Route::post('/register', [AuthController::class, 'register']);
// /2fa/verify публичный — у user'а ещё нет полноценной session-auth, только
// pending_user_id в session. Verify завершает login после проверки TOTP.
Route::post('/2fa/verify', [AuthController::class, 'verifyTwoFactor']);
// /2fa/recovery-use — публичный (нет полноценной session-auth до verify).
Route::post('/2fa/recovery-use', [AuthController::class, 'useRecoveryCode']);
// /forgot — публичный (anti-enumeration unified-ответ + rate-limit).
Route::post('/forgot', [AuthController::class, 'forgotPassword']);
// /reset-password — публичный (deep-link из email с token+email+password).
Route::post('/reset-password', [AuthController::class, 'resetPassword']);
Route::middleware('auth:sanctum')->group(function () {
Route::get('/me', [AuthController::class, 'me']);
Route::post('/logout', [AuthController::class, 'logout']);
});
});
// SaaS-admin impersonation flow (Ю-1). На MVP без middleware (saas-admin auth
// не реализован), production: middleware('auth:saas-admin') + role('compliance' if needed).
Route::prefix('/api/admin/impersonation')->group(function () {
Route::get('/active', [ImpersonationController::class, 'active']);
Route::get('/recent', [ImpersonationController::class, 'recent']);
Route::post('/init', [ImpersonationController::class, 'init']);
Route::post('/verify', [ImpersonationController::class, 'verify']);
Route::post('/end', [ImpersonationController::class, 'end']);
});
// SaaS-admin → Система: edit-flow для system_settings + audit-log (4-eyes-pattern).
// На MVP без auth-middleware (admin_user_id параметром); production: middleware('auth:saas-admin').
Route::prefix('/api/admin/system-settings')->group(function () {
Route::get('/', [AdminSystemSettingsController::class, 'index']);
Route::put('/{key}', [AdminSystemSettingsController::class, 'update'])->where('key', '[a-z0-9_\.]+');
});
// Сделки — manual create через UI (NewDealDialog). На prod: middleware
// 'auth:sanctum' + 'tenant', tenant_id берётся из user'а. На MVP — параметром.
Route::get('/api/deals', [DealController::class, 'index']);
Route::post('/api/deals', [DealController::class, 'store']);
Route::post('/api/deals/export', [DealController::class, 'export']);
Route::post('/api/deals/transition', [DealController::class, 'transition']);
// Lookup endpoints — заполняют v-select'ы (NewDealDialog, smart-filters).
Route::get('/api/managers', [ManagerController::class, 'index']);
Route::get('/api/projects', [ProjectController::class, 'index']);
// Receive endpoint для входящих webhook'ов (narrative §5.5).
// Auth — по `tenants.webhook_token` в URL (без middleware, проверка внутри controller).
// На prod: + HMAC-валидация X-Webhook-Signature + per-token rate-limit.
Route::post('/api/webhook/{token}', [WebhookReceiveController::class, 'receive'])
->where('token', '[A-Za-z0-9\-_]+');
// 2FA setup wizard — все эндпоинты под auth:sanctum (только для уже залогиненных).
Route::prefix('/api/2fa')->middleware('auth:sanctum')->group(function () {
Route::post('/init', [TwoFactorSetupController::class, 'init']);
Route::post('/confirm', [TwoFactorSetupController::class, 'confirm']);
Route::post('/disable', [TwoFactorSetupController::class, 'disable']);
Route::post('/regenerate-recovery-codes', [TwoFactorSetupController::class, '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.
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', 'welcome');
Route::view('/recovery-use', 'welcome');
Route::view('/dashboard', 'welcome');
Route::view('/deals', 'welcome');
Route::view('/kanban', 'welcome');
Route::view('/billing', 'welcome');
Route::view('/settings', 'welcome');
Route::view('/reports', 'welcome');
Route::view('/403', 'welcome');
Route::view('/500', 'welcome');
// Fallback для всех неизвестных путей — Vue Router catch-all отрисует 404.
// Срабатывает ПОСЛЕ всех явных route'ов выше и runtime-route'ов от Pest
// beforeEach (они регистрируются в момент теста, до запроса).
Route::fallback(fn () => view('welcome'));